Merge branch 'develop' of github.com:makeplane/plane into dev/api_logging

This commit is contained in:
pablohashescobar 2023-10-31 11:49:54 +05:30
commit edb4280ec1
82 changed files with 2370 additions and 2909 deletions

View File

@ -579,7 +579,6 @@ class CycleIssueViewSet(BaseViewSet):
) )
) )
total_issues = issues.count()
issues_data = IssueStateSerializer(issues, many=True).data issues_data = IssueStateSerializer(issues, many=True).data
if sub_group_by and sub_group_by == group_by: if sub_group_by and sub_group_by == group_by:
@ -591,12 +590,12 @@ class CycleIssueViewSet(BaseViewSet):
if group_by: if group_by:
grouped_results = group_results(issues_data, group_by, sub_group_by) grouped_results = group_results(issues_data, group_by, sub_group_by)
return Response( return Response(
{"data": grouped_results, "total_issues": total_issues}, grouped_results,
status=status.HTTP_200_OK, status=status.HTTP_200_OK,
) )
return Response( return Response(
{"data": issues_data, "total_issues": total_issues}, status=status.HTTP_200_OK issues_data, status=status.HTTP_200_OK
) )
def create(self, request, slug, project_id, cycle_id): def create(self, request, slug, project_id, cycle_id):

View File

@ -217,7 +217,6 @@ class IssueViewSet(BaseViewSet):
else: else:
issue_queryset = issue_queryset.order_by(order_by_param) issue_queryset = issue_queryset.order_by(order_by_param)
total_issues = issue_queryset.count()
issues = IssueLiteSerializer(issue_queryset, many=True).data issues = IssueLiteSerializer(issue_queryset, many=True).data
## Grouping the results ## Grouping the results
@ -232,12 +231,12 @@ class IssueViewSet(BaseViewSet):
if group_by: if group_by:
grouped_results = group_results(issues, group_by, sub_group_by) grouped_results = group_results(issues, group_by, sub_group_by)
return Response( return Response(
{"data": grouped_results, "total_issues": total_issues}, grouped_results,
status=status.HTTP_200_OK, status=status.HTTP_200_OK,
) )
return Response( return Response(
{"data": issues, "total_issues": total_issues}, status=status.HTTP_200_OK issues, status=status.HTTP_200_OK
) )
@ -426,7 +425,6 @@ class UserWorkSpaceIssues(BaseAPIView):
else: else:
issue_queryset = issue_queryset.order_by(order_by_param) issue_queryset = issue_queryset.order_by(order_by_param)
total_issues = issue_queryset.count()
issues = IssueLiteSerializer(issue_queryset, many=True).data issues = IssueLiteSerializer(issue_queryset, many=True).data
## Grouping the results ## Grouping the results
@ -441,12 +439,12 @@ class UserWorkSpaceIssues(BaseAPIView):
if group_by: if group_by:
grouped_results = group_results(issues, group_by, sub_group_by) grouped_results = group_results(issues, group_by, sub_group_by)
return Response( return Response(
{"data": grouped_results, "total_issues": total_issues}, grouped_results,
status=status.HTTP_200_OK, status=status.HTTP_200_OK,
) )
return Response( return Response(
{"data": issues, "total_issues": total_issues}, status=status.HTTP_200_OK issues, status=status.HTTP_200_OK
) )
@ -2151,7 +2149,6 @@ class IssueDraftViewSet(BaseViewSet):
else: else:
issue_queryset = issue_queryset.order_by(order_by_param) issue_queryset = issue_queryset.order_by(order_by_param)
total_issues = issue_queryset.count()
issues = IssueLiteSerializer(issue_queryset, many=True).data issues = IssueLiteSerializer(issue_queryset, many=True).data
## Grouping the results ## Grouping the results
@ -2159,12 +2156,12 @@ class IssueDraftViewSet(BaseViewSet):
if group_by: if group_by:
grouped_results = group_results(issues, group_by) grouped_results = group_results(issues, group_by)
return Response( return Response(
{"data": grouped_results, "total_issues": total_issues}, grouped_results,
status=status.HTTP_200_OK, status=status.HTTP_200_OK,
) )
return Response( return Response(
{"data": issues, "total_issues": total_issues}, status=status.HTTP_200_OK issues, status=status.HTTP_200_OK
) )
def create(self, request, slug, project_id): def create(self, request, slug, project_id):

View File

@ -364,7 +364,6 @@ class ModuleIssueViewSet(BaseViewSet):
.values("count") .values("count")
) )
) )
total_issues = issues.count()
issues_data = IssueStateSerializer(issues, many=True).data issues_data = IssueStateSerializer(issues, many=True).data
if sub_group_by and sub_group_by == group_by: if sub_group_by and sub_group_by == group_by:
@ -376,12 +375,12 @@ class ModuleIssueViewSet(BaseViewSet):
if group_by: if group_by:
grouped_results = group_results(issues_data, group_by, sub_group_by) grouped_results = group_results(issues_data, group_by, sub_group_by)
return Response( return Response(
{"data": grouped_results, "total_issues": total_issues}, grouped_results,
status=status.HTTP_200_OK, status=status.HTTP_200_OK,
) )
return Response( return Response(
{"data": issues_data, "total_issues": total_issues}, status=status.HTTP_200_OK issues_data, status=status.HTTP_200_OK
) )
def create(self, request, slug, project_id, module_id): def create(self, request, slug, project_id, module_id):

View File

@ -93,7 +93,6 @@ class GlobalViewIssuesViewSet(BaseViewSet):
) )
) )
@method_decorator(gzip_page) @method_decorator(gzip_page)
def list(self, request, slug): def list(self, request, slug):
filters = issue_filters(request.query_params, "GET") filters = issue_filters(request.query_params, "GET")
@ -117,9 +116,7 @@ class GlobalViewIssuesViewSet(BaseViewSet):
.values("count") .values("count")
) )
.annotate( .annotate(
attachment_count=IssueAttachment.objects.filter( attachment_count=IssueAttachment.objects.filter(issue=OuterRef("id"))
issue=OuterRef("id")
)
.order_by() .order_by()
.annotate(count=Func(F("id"), function="Count")) .annotate(count=Func(F("id"), function="Count"))
.values("count") .values("count")
@ -129,9 +126,7 @@ class GlobalViewIssuesViewSet(BaseViewSet):
# Priority Ordering # Priority Ordering
if order_by_param == "priority" or order_by_param == "-priority": if order_by_param == "priority" or order_by_param == "-priority":
priority_order = ( priority_order = (
priority_order priority_order if order_by_param == "priority" else priority_order[::-1]
if order_by_param == "priority"
else priority_order[::-1]
) )
issue_queryset = issue_queryset.annotate( issue_queryset = issue_queryset.annotate(
priority_order=Case( priority_order=Case(
@ -183,8 +178,6 @@ class GlobalViewIssuesViewSet(BaseViewSet):
) )
else: else:
issue_queryset = issue_queryset.order_by(order_by_param) issue_queryset = issue_queryset.order_by(order_by_param)
total_issues = issue_queryset.count()
issues = IssueLiteSerializer(issue_queryset, many=True).data issues = IssueLiteSerializer(issue_queryset, many=True).data
## Grouping the results ## Grouping the results
@ -195,17 +188,15 @@ class GlobalViewIssuesViewSet(BaseViewSet):
{"error": "Group by and sub group by cannot be same"}, {"error": "Group by and sub group by cannot be same"},
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,
) )
if group_by: if group_by:
grouped_results = group_results(issues, group_by, sub_group_by) grouped_results = group_results(issues, group_by, sub_group_by)
return Response( return Response(
{"data": grouped_results, "total_issues": total_issues}, grouped_results,
status=status.HTTP_200_OK, status=status.HTTP_200_OK,
) )
return Response( return Response(issues, status=status.HTTP_200_OK)
{"data": issues, "total_issues": total_issues}, status=status.HTTP_200_OK
)
class IssueViewViewSet(BaseViewSet): class IssueViewViewSet(BaseViewSet):

View File

@ -1223,7 +1223,6 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView):
else: else:
issue_queryset = issue_queryset.order_by(order_by_param) issue_queryset = issue_queryset.order_by(order_by_param)
total_issues = issue_queryset.count()
issues = IssueLiteSerializer(issue_queryset, many=True).data issues = IssueLiteSerializer(issue_queryset, many=True).data
## Grouping the results ## Grouping the results
@ -1231,12 +1230,12 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView):
if group_by: if group_by:
grouped_results = group_results(issues, group_by) grouped_results = group_results(issues, group_by)
return Response( return Response(
{"data": grouped_results, "total_issues": total_issues}, grouped_results,
status=status.HTTP_200_OK, status=status.HTTP_200_OK,
) )
return Response( return Response(
{"data": issues, "total_issues": total_issues}, status=status.HTTP_200_OK issues, status=status.HTTP_200_OK
) )

View File

@ -0,0 +1,20 @@
import * as React from "react";
import { ISvgIcons } from "../type";
export const CircleDotFullIcon: React.FC<ISvgIcons> = ({
className = "text-current",
...rest
}) => (
<svg
viewBox="0 0 24 24"
className={`${className} stroke-2`}
stroke="currentColor"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...rest}
>
<circle cx="8.33333" cy="8.33333" r="5.33333" stroke-linecap="round" />
<circle cx="8.33333" cy="8.33333" r="4.33333" fill="currentColor" />
</svg>
);

View File

@ -1,6 +1,6 @@
import * as React from "react"; import * as React from "react";
import { ISvgIcons } from "./type"; import { ISvgIcons } from "../type";
export const ContrastIcon: React.FC<ISvgIcons> = ({ export const ContrastIcon: React.FC<ISvgIcons> = ({
className = "text-current", className = "text-current",

View File

@ -0,0 +1,33 @@
import * as React from "react";
import { ContrastIcon } from "./contrast-icon";
import { CircleDotFullIcon } from "./circle-dot-full-icon";
import { CircleDotDashed, Circle } from "lucide-react";
import { CYCLE_GROUP_COLORS, ICycleGroupIcon } from "./helper";
const iconComponents = {
current: ContrastIcon,
upcoming: CircleDotDashed,
completed: CircleDotFullIcon,
draft: Circle,
};
export const CycleGroupIcon: React.FC<ICycleGroupIcon> = ({
className = "",
color,
cycleGroup,
height = "12px",
width = "12px",
}) => {
const CycleIconComponent = iconComponents[cycleGroup] || ContrastIcon;
return (
<CycleIconComponent
height={height}
width={width}
color={color ?? CYCLE_GROUP_COLORS[cycleGroup]}
className={`flex-shrink-0 ${className}`}
/>
);
};

View File

@ -1,6 +1,6 @@
import * as React from "react"; import * as React from "react";
import { ISvgIcons } from "./type"; import { ISvgIcons } from "../type";
export const DoubleCircleIcon: React.FC<ISvgIcons> = ({ export const DoubleCircleIcon: React.FC<ISvgIcons> = ({
className = "text-current", className = "text-current",

View File

@ -0,0 +1,18 @@
export interface ICycleGroupIcon {
className?: string;
color?: string;
cycleGroup: TCycleGroups;
height?: string;
width?: string;
}
export type TCycleGroups = "current" | "upcoming" | "completed" | "draft";
export const CYCLE_GROUP_COLORS: {
[key in TCycleGroups]: string;
} = {
current: "#F59E0B",
upcoming: "#3F76FF",
completed: "#16A34A",
draft: "#525252",
};

View File

@ -0,0 +1,5 @@
export * from "./double-circle-icon";
export * from "./circle-dot-full-icon";
export * from "./contrast-icon";
export * from "./circle-dot-full-icon";
export * from "./cycle-group-icon";

View File

@ -1,5 +1,4 @@
export * from "./user-group-icon"; export * from "./user-group-icon";
export * from "./contrast-icon";
export * from "./dice-icon"; export * from "./dice-icon";
export * from "./layers-icon"; export * from "./layers-icon";
export * from "./photo-filter-icon"; export * from "./photo-filter-icon";
@ -7,7 +6,6 @@ export * from "./archive-icon";
export * from "./admin-profile-icon"; export * from "./admin-profile-icon";
export * from "./create-icon"; export * from "./create-icon";
export * from "./subscribe-icon"; export * from "./subscribe-icon";
export * from "./double-circle-icon";
export * from "./external-link-icon"; export * from "./external-link-icon";
export * from "./copy-icon"; export * from "./copy-icon";
export * from "./layer-stack"; export * from "./layer-stack";
@ -20,6 +18,7 @@ export * from "./blocked-icon";
export * from "./blocker-icon"; export * from "./blocker-icon";
export * from "./related-icon"; export * from "./related-icon";
export * from "./module"; export * from "./module";
export * from "./cycle";
export * from "./github-icon"; export * from "./github-icon";
export * from "./discord-icon"; export * from "./discord-icon";
export * from "./transfer-icon"; export * from "./transfer-icon";

View File

@ -36,7 +36,7 @@ type Props = {
module?: IModule; module?: IModule;
roundedTab?: boolean; roundedTab?: boolean;
noBackground?: boolean; noBackground?: boolean;
isPeekModuleDetails?: boolean; isPeekView?: boolean;
}; };
export const SidebarProgressStats: React.FC<Props> = ({ export const SidebarProgressStats: React.FC<Props> = ({
@ -46,7 +46,7 @@ export const SidebarProgressStats: React.FC<Props> = ({
module, module,
roundedTab, roundedTab,
noBackground, noBackground,
isPeekModuleDetails = false, isPeekView = false,
}) => { }) => {
const { filters, setFilters } = useIssuesView(); const { filters, setFilters } = useIssuesView();
@ -154,7 +154,7 @@ export const SidebarProgressStats: React.FC<Props> = ({
} }
completed={assignee.completed_issues} completed={assignee.completed_issues}
total={assignee.total_issues} total={assignee.total_issues}
{...(!isPeekModuleDetails && { {...(!isPeekView && {
onClick: () => { onClick: () => {
if (filters?.assignees?.includes(assignee.assignee_id ?? "")) if (filters?.assignees?.includes(assignee.assignee_id ?? ""))
setFilters({ setFilters({
@ -213,7 +213,7 @@ export const SidebarProgressStats: React.FC<Props> = ({
} }
completed={label.completed_issues} completed={label.completed_issues}
total={label.total_issues} total={label.total_issues}
{...(!isPeekModuleDetails && { {...(!isPeekView && {
onClick: () => { onClick: () => {
if (filters.labels?.includes(label.label_id ?? "")) if (filters.labels?.includes(label.label_id ?? ""))
setFilters({ setFilters({

View File

@ -0,0 +1,55 @@
import React, { useEffect } from "react";
import { useRouter } from "next/router";
// mobx
import { observer } from "mobx-react-lite";
import { useMobxStore } from "lib/mobx/store-provider";
// components
import { CycleDetailsSidebar } from "./sidebar";
type Props = {
projectId: string;
workspaceSlug: string;
};
export const CyclePeekOverview: React.FC<Props> = observer(({ projectId, workspaceSlug }) => {
const router = useRouter();
const { peekCycle } = router.query;
const ref = React.useRef(null);
const { cycle: cycleStore } = useMobxStore();
const { fetchCycleWithId } = cycleStore;
const handleClose = () => {
delete router.query.peekCycle;
router.push({
pathname: router.pathname,
query: { ...router.query },
});
};
useEffect(() => {
if (!peekCycle) return;
fetchCycleWithId(workspaceSlug, projectId, peekCycle.toString());
}, [fetchCycleWithId, peekCycle, projectId, workspaceSlug]);
return (
<>
{peekCycle && (
<div
ref={ref}
className="flex flex-col gap-3.5 h-full w-[24rem] z-10 overflow-y-auto border-l border-custom-border-100 bg-custom-sidebar-background-100 px-6 py-3.5 duration-300 flex-shrink-0"
style={{
boxShadow:
"0px 1px 4px 0px rgba(0, 0, 0, 0.06), 0px 2px 4px 0px rgba(16, 24, 40, 0.06), 0px 1px 8px -1px rgba(16, 24, 40, 0.06)",
}}
>
<CycleDetailsSidebar cycleId={peekCycle?.toString() ?? ""} handleClose={handleClose} />
</div>
)}
</>
);
});

View File

@ -1,64 +1,32 @@
import { FC, MouseEvent, useState } from "react"; import { FC, MouseEvent, useState } from "react";
import { useRouter } from "next/router";
// next imports // next imports
import Link from "next/link"; import Link from "next/link";
// headless ui
import { Disclosure, Transition } from "@headlessui/react";
// hooks // hooks
import useToast from "hooks/use-toast"; import useToast from "hooks/use-toast";
// components // components
import { SingleProgressStats } from "components/core";
import { CycleCreateUpdateModal, CycleDeleteModal } from "components/cycles"; import { CycleCreateUpdateModal, CycleDeleteModal } from "components/cycles";
// ui // ui
import { AssigneesList } from "components/ui/avatar"; import { AssigneesList } from "components/ui/avatar";
import { CustomMenu, Tooltip, LinearProgressIndicator, ContrastIcon, RunningIcon } from "@plane/ui"; import { CustomMenu, Tooltip, LayersIcon, CycleGroupIcon } from "@plane/ui";
// icons // icons
import { import { Info, LinkIcon, Pencil, Star, Trash2 } from "lucide-react";
AlarmClock,
AlertTriangle,
ArrowRight,
CalendarDays,
ChevronDown,
LinkIcon,
Pencil,
Star,
Target,
Trash2,
} from "lucide-react";
// helpers // helpers
import { getDateRangeStatus, renderShortDateWithYearFormat, findHowManyDaysLeft } from "helpers/date-time.helper"; import {
import { copyTextToClipboard, truncateText } from "helpers/string.helper"; getDateRangeStatus,
findHowManyDaysLeft,
renderShortDate,
renderShortMonthDate,
} from "helpers/date-time.helper";
import { copyTextToClipboard } from "helpers/string.helper";
// types // types
import { ICycle } from "types"; import { ICycle } from "types";
// store // store
import { useMobxStore } from "lib/mobx/store-provider"; import { useMobxStore } from "lib/mobx/store-provider";
// constants
const stateGroups = [ import { CYCLE_STATUS } from "constants/cycle";
{
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",
},
];
export interface ICyclesBoardCard { export interface ICyclesBoardCard {
workspaceSlug: string; workspaceSlug: string;
@ -81,7 +49,34 @@ export const CyclesBoardCard: FC<ICyclesBoardCard> = (props) => {
const endDate = new Date(cycle.end_date ?? ""); const endDate = new Date(cycle.end_date ?? "");
const startDate = new Date(cycle.start_date ?? ""); const startDate = new Date(cycle.start_date ?? "");
const handleCopyText = () => { const router = useRouter();
const currentCycle = CYCLE_STATUS.find((status) => status.value === cycleStatus);
const areYearsEqual = startDate.getFullYear() === endDate.getFullYear();
const cycleTotalIssues =
cycle.backlog_issues +
cycle.unstarted_issues +
cycle.started_issues +
cycle.completed_issues +
cycle.cancelled_issues;
const completionPercentage = (cycle.completed_issues / cycleTotalIssues) * 100;
const issueCount = cycle
? cycleTotalIssues === 0
? "0 Issue"
: cycleTotalIssues === cycle.completed_issues
? cycleTotalIssues > 1
? `${cycleTotalIssues} Issues`
: `${cycleTotalIssues} Issue`
: `${cycle.completed_issues}/${cycleTotalIssues} Issues`
: "0 Issue";
const handleCopyText = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
const originURL = typeof window !== "undefined" && window.location.origin ? window.location.origin : ""; const originURL = typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
copyTextToClipboard(`${originURL}/${workspaceSlug}/projects/${projectId}/cycles/${cycle.id}`).then(() => { copyTextToClipboard(`${originURL}/${workspaceSlug}/projects/${projectId}/cycles/${cycle.id}`).then(() => {
@ -93,21 +88,6 @@ export const CyclesBoardCard: FC<ICyclesBoardCard> = (props) => {
}); });
}; };
const progressIndicatorData = stateGroups.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 groupedIssues: any = {
backlog: cycle.backlog_issues,
unstarted: cycle.unstarted_issues,
started: cycle.started_issues,
completed: cycle.completed_issues,
cancelled: cycle.cancelled_issues,
};
const handleAddToFavorites = (e: MouseEvent<HTMLButtonElement>) => { const handleAddToFavorites = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault(); e.preventDefault();
if (!workspaceSlug || !projectId) return; if (!workspaceSlug || !projectId) return;
@ -134,6 +114,29 @@ export const CyclesBoardCard: FC<ICyclesBoardCard> = (props) => {
}); });
}; };
const handleEditCycle = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
setUpdateModal(true);
};
const handleDeleteCycle = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
setDeleteModal(true);
};
const openCycleOverview = (e: MouseEvent<HTMLButtonElement>) => {
const { query } = router;
e.preventDefault();
e.stopPropagation();
router.push({
pathname: router.pathname,
query: { ...query, peekCycle: cycle.id },
});
};
return ( return (
<div> <div>
<CycleCreateUpdateModal <CycleCreateUpdateModal
@ -152,267 +155,119 @@ export const CyclesBoardCard: FC<ICyclesBoardCard> = (props) => {
projectId={projectId} projectId={projectId}
/> />
<div className="flex flex-col rounded-[10px] bg-custom-background-100 border border-custom-border-200 text-xs shadow"> <Link href={`/${workspaceSlug}/projects/${projectId}/cycles/${cycle.id}`}>
<Link href={`/${workspaceSlug}/projects/${projectId}/cycles/${cycle.id}`}> <a className="flex flex-col justify-between p-4 h-44 w-full min-w-[250px] text-sm rounded bg-custom-background-100 border border-custom-border-100 hover:shadow-md">
<a className="w-full"> <div>
<div className="flex h-full flex-col gap-4 rounded-b-[10px] p-4"> <div className="flex items-center justify-between gap-2">
<div className="flex items-center justify-between gap-1"> <div className="flex items-center gap-3">
<span className="flex items-center gap-1"> <span className="flex-shrink-0">
<span className="h-5 w-5"> <CycleGroupIcon cycleGroup={cycleStatus} className="h-3.5 w-3.5" />
<ContrastIcon
className="h-5 w-5"
color={`${
cycleStatus === "current"
? "#09A953"
: cycleStatus === "upcoming"
? "#F7AE59"
: cycleStatus === "completed"
? "#3F76FF"
: cycleStatus === "draft"
? "rgb(var(--color-text-200))"
: ""
}`}
/>
</span>
<Tooltip tooltipContent={cycle.name} className="break-words" position="top-left">
<h3 className="break-words text-lg font-semibold">{truncateText(cycle.name, 15)}</h3>
</Tooltip>
</span> </span>
<span className="flex items-center gap-1 capitalize"> <Tooltip tooltipContent={cycle.name} position="top">
<span className="text-base font-medium truncate">{cycle.name}</span>
</Tooltip>
</div>
<div className="flex items-center gap-2">
{currentCycle && (
<span <span
className={`rounded-full px-1.5 py-0.5 className="flex items-center justify-center text-xs text-center h-6 w-20 rounded-sm"
${ style={{
cycleStatus === "current" color: currentCycle.color,
? "bg-green-600/5 text-green-600" backgroundColor: `${currentCycle.color}20`,
: cycleStatus === "upcoming" }}
? "bg-orange-300/5 text-orange-300"
: cycleStatus === "completed"
? "bg-blue-500/5 text-blue-500"
: cycleStatus === "draft"
? "bg-neutral-400/5 text-neutral-400"
: ""
}`}
> >
{cycleStatus === "current" ? ( {currentCycle.value === "current"
<span className="flex gap-1 whitespace-nowrap"> ? `${findHowManyDaysLeft(cycle.end_date ?? new Date())} ${currentCycle.label}`
<RunningIcon className="h-4 w-4" /> : `${currentCycle.label}`}
{findHowManyDaysLeft(cycle.end_date ?? new Date())} Days Left
</span>
) : cycleStatus === "upcoming" ? (
<span className="flex gap-1 whitespace-nowrap">
<AlarmClock className="h-4 w-4" />
{findHowManyDaysLeft(cycle.start_date ?? new Date())} Days Left
</span>
) : cycleStatus === "completed" ? (
<span className="flex gap-1 whitespace-nowrap">
{cycle.total_issues - cycle.completed_issues > 0 && (
<Tooltip
tooltipContent={`${cycle.total_issues - cycle.completed_issues} more pending ${
cycle.total_issues - cycle.completed_issues === 1 ? "issue" : "issues"
}`}
>
<span>
<AlertTriangle className="h-3.5 w-3.5" />
</span>
</Tooltip>
)}{" "}
Completed
</span>
) : (
cycleStatus
)}
</span> </span>
{cycle.is_favorite ? (
<button onClick={handleRemoveFromFavorites}>
<Star className="h-4 w-4 text-orange-400" fill="#f6ad55" />
</button>
) : (
<button onClick={handleAddToFavorites}>
<Star className="h-4 w-4 " color="rgb(var(--color-text-200))" />
</button>
)}
</span>
</div>
<div className="flex h-4 items-center justify-start gap-5 text-custom-text-200">
{cycleStatus !== "draft" && (
<>
<div className="flex items-start gap-1">
<CalendarDays className="h-4 w-4" />
<span>{renderShortDateWithYearFormat(startDate)}</span>
</div>
<ArrowRight className="h-4 w-4" />
<div className="flex items-start gap-1">
<Target className="h-4 w-4" />
<span>{renderShortDateWithYearFormat(endDate)}</span>
</div>
</>
)} )}
</div> <button onClick={openCycleOverview}>
<Info className="h-4 w-4 text-custom-text-400" />
<div className="flex justify-between items-end"> </button>
<div className="flex flex-col gap-2 text-xs text-custom-text-200">
<div className="flex items-center gap-2">
<div className="w-16">Creator:</div>
<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-orange-300 capitalize text-white">
{cycle.owned_by.display_name.charAt(0)}
</span>
)}
<span className="text-custom-text-200">{cycle.owned_by.display_name}</span>
</div>
</div>
<div className="flex h-5 items-center gap-2">
<div className="w-16">Members:</div>
{cycle.assignees.length > 0 ? (
<div className="flex items-center gap-1 text-custom-text-200">
<AssigneesList users={cycle.assignees} length={4} />
</div>
) : (
"No members"
)}
</div>
</div>
<div className="flex items-center">
{!isCompleted && (
<button
onClick={(e) => {
e.preventDefault();
setUpdateModal(true);
}}
className="cursor-pointer rounded p-1 text-custom-text-200 duration-300 hover:bg-custom-background-80"
>
<Pencil className="h-4 w-4" />
</button>
)}
<CustomMenu width="auto" verticalEllipsis>
{!isCompleted && (
<CustomMenu.MenuItem
onClick={(e) => {
e.preventDefault();
setDeleteModal(true);
}}
>
<span className="flex items-center justify-start gap-2">
<Trash2 className="h-4 w-4" />
<span>Delete cycle</span>
</span>
</CustomMenu.MenuItem>
)}
<CustomMenu.MenuItem
onClick={(e) => {
e.preventDefault();
handleCopyText();
}}
>
<span className="flex items-center justify-start gap-2">
<LinkIcon className="h-4 w-4" />
<span>Copy cycle link</span>
</span>
</CustomMenu.MenuItem>
</CustomMenu>
</div>
</div> </div>
</div> </div>
</a> </div>
</Link>
<div className="flex h-full flex-col rounded-b-[10px]"> <div className="flex flex-col gap-3">
<Disclosure> <div className="flex items-center justify-between">
{({ open }) => ( <div className="flex items-center gap-1.5 text-custom-text-200">
<div <LayersIcon className="h-4 w-4 text-custom-text-300" />
className={`flex h-full w-full flex-col rounded-b-[10px] border-t border-custom-border-200 bg-custom-background-80 text-custom-text-200 ${ <span className="text-xs text-custom-text-300">{issueCount}</span>
open ? "" : "flex-row"
}`}
>
<div className="flex w-full items-center gap-2 px-4 py-1">
<span>Progress</span>
<Tooltip
tooltipContent={
<div className="flex w-56 flex-col">
{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: stateGroups[index].color,
}}
/>
<span className="text-xs capitalize">{group}</span>
</div>
}
completed={groupedIssues[group]}
total={cycle.total_issues}
/>
))}
</div>
}
position="bottom"
>
<div className="flex w-full items-center">
<LinearProgressIndicator data={progressIndicatorData} noTooltip={true} />
</div>
</Tooltip>
<Disclosure.Button>
<span className="p-1">
<ChevronDown className={`h-3 w-3 ${open ? "rotate-180 transform" : ""}`} aria-hidden="true" />
</span>
</Disclosure.Button>
</div>
<Transition show={open}>
<Disclosure.Panel>
<div className="overflow-hidden rounded-b-md bg-custom-background-80 py-3 shadow">
<div className="col-span-2 space-y-3 px-4">
<div className="space-y-3 text-xs">
{stateGroups.map((group) => (
<div key={group.key} className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<span
className="block h-2 w-2 rounded-full"
style={{
backgroundColor: group.color,
}}
/>
<h6 className="text-xs">{group.title}</h6>
</div>
<div>
<span>
{cycle[group.key as keyof ICycle] as number}{" "}
<span className="text-custom-text-200">
-{" "}
{cycle.total_issues > 0
? `${Math.round(
((cycle[group.key as keyof ICycle] as number) / cycle.total_issues) * 100
)}%`
: "0%"}
</span>
</span>
</div>
</div>
))}
</div>
</div>
</div>
</Disclosure.Panel>
</Transition>
</div> </div>
)} {cycle.assignees.length > 0 && (
</Disclosure> <Tooltip tooltipContent={`${cycle.assignees.length} Members`}>
</div> <div className="flex items-center gap-1 cursor-default">
</div> <AssigneesList users={cycle.assignees} length={3} />
</div>
</Tooltip>
)}
</div>
<Tooltip
tooltipContent={isNaN(completionPercentage) ? "0" : `${completionPercentage.toFixed(0)}%`}
position="top-left"
>
<div className="flex items-center w-full">
<div
className="bar relative h-1.5 w-full rounded bg-custom-background-90"
style={{
boxShadow: "1px 1px 4px 0px rgba(161, 169, 191, 0.35) inset",
}}
>
<div
className="absolute top-0 left-0 h-1.5 rounded bg-blue-600 duration-300"
style={{
width: `${isNaN(completionPercentage) ? 0 : completionPercentage.toFixed(0)}%`,
}}
/>
</div>
</div>
</Tooltip>
<div className="flex items-center justify-between">
<span className="text-xs text-custom-text-300">
{areYearsEqual ? renderShortDate(startDate, "_ _") : renderShortMonthDate(startDate, "_ _")} -{" "}
{areYearsEqual ? renderShortDate(endDate, "_ _") : renderShortMonthDate(endDate, "_ _")}
</span>
<div className="flex items-center gap-1.5 z-10">
{cycle.is_favorite ? (
<button type="button" onClick={handleRemoveFromFavorites}>
<Star className="h-3.5 w-3.5 text-amber-500 fill-current" />
</button>
) : (
<button type="button" onClick={handleAddToFavorites}>
<Star className="h-3.5 w-3.5 text-custom-text-200" />
</button>
)}
<CustomMenu width="auto" ellipsis className="z-10">
{!isCompleted && (
<>
<CustomMenu.MenuItem onClick={handleEditCycle}>
<span className="flex items-center justify-start gap-2">
<Pencil className="h-3 w-3" />
<span>Edit cycle</span>
</span>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={handleDeleteCycle}>
<span className="flex items-center justify-start gap-2">
<Trash2 className="h-3 w-3" />
<span>Delete module</span>
</span>
</CustomMenu.MenuItem>
</>
)}
<CustomMenu.MenuItem onClick={handleCopyText}>
<span className="flex items-center justify-start gap-2">
<LinkIcon className="h-3 w-3" />
<span>Copy cycle link</span>
</span>
</CustomMenu.MenuItem>
</CustomMenu>
</div>
</div>
</div>
</a>
</Link>
</div> </div>
); );
}; };

View File

@ -2,26 +2,41 @@ import { FC } from "react";
// types // types
import { ICycle } from "types"; import { ICycle } from "types";
// components // components
import { CyclesBoardCard } from "components/cycles"; import { CyclePeekOverview, CyclesBoardCard } from "components/cycles";
export interface ICyclesBoard { export interface ICyclesBoard {
cycles: ICycle[]; cycles: ICycle[];
filter: string; filter: string;
workspaceSlug: string; workspaceSlug: string;
projectId: string; projectId: string;
peekCycle: string;
} }
export const CyclesBoard: FC<ICyclesBoard> = (props) => { export const CyclesBoard: FC<ICyclesBoard> = (props) => {
const { cycles, filter, workspaceSlug, projectId } = props; const { cycles, filter, workspaceSlug, projectId, peekCycle } = props;
return ( return (
<div className="grid grid-cols-1 gap-9 lg:grid-cols-2 xl:grid-cols-3"> <>
{cycles.length > 0 ? ( {cycles.length > 0 ? (
<> <div className="h-full w-full">
{cycles.map((cycle) => ( <div className="flex justify-between h-full w-full">
<CyclesBoardCard key={cycle.id} workspaceSlug={workspaceSlug} projectId={projectId} cycle={cycle} /> <div
))} className={`grid grid-cols-1 gap-6 p-8 h-full w-full overflow-y-auto ${
</> peekCycle
? "lg:grid-cols-1 xl:grid-cols-2 3xl:grid-cols-3"
: "lg:grid-cols-2 xl:grid-cols-3 3xl:grid-cols-4"
} auto-rows-max transition-all `}
>
{cycles.map((cycle) => (
<CyclesBoardCard key={cycle.id} workspaceSlug={workspaceSlug} projectId={projectId} cycle={cycle} />
))}
</div>
<CyclePeekOverview
projectId={projectId?.toString() ?? ""}
workspaceSlug={workspaceSlug?.toString() ?? ""}
/>
</div>
</div>
) : ( ) : (
<div className="h-full grid place-items-center text-center"> <div className="h-full grid place-items-center text-center">
<div className="space-y-2"> <div className="space-y-2">
@ -50,6 +65,6 @@ export const CyclesBoard: FC<ICyclesBoard> = (props) => {
</div> </div>
</div> </div>
)} )}
</div> </>
); );
}; };

View File

@ -1,29 +1,30 @@
import { FC, MouseEvent, useState } from "react"; import { FC, MouseEvent, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router";
// stores
import { useMobxStore } from "lib/mobx/store-provider";
// hooks // hooks
import useToast from "hooks/use-toast"; import useToast from "hooks/use-toast";
// components // components
import { CycleCreateUpdateModal, CycleDeleteModal } from "components/cycles"; import { CycleCreateUpdateModal, CycleDeleteModal } from "components/cycles";
import { AssigneesList } from "components/ui";
// ui // ui
import { CustomMenu, RadialProgressBar, Tooltip, LinearProgressIndicator, ContrastIcon, RunningIcon } from "@plane/ui"; import { CustomMenu, Tooltip, CircularProgressIndicator, CycleGroupIcon } from "@plane/ui";
// icons // icons
import { import { Check, Info, LinkIcon, Pencil, Star, Trash2, User2 } from "lucide-react";
AlarmClock,
AlertTriangle,
ArrowRight,
CalendarDays,
LinkIcon,
Pencil,
Star,
Target,
Trash2,
} from "lucide-react";
// helpers // helpers
import { getDateRangeStatus, renderShortDateWithYearFormat, findHowManyDaysLeft } from "helpers/date-time.helper"; import {
getDateRangeStatus,
findHowManyDaysLeft,
renderShortDate,
renderShortMonthDate,
} from "helpers/date-time.helper";
import { copyTextToClipboard } from "helpers/string.helper"; import { copyTextToClipboard } from "helpers/string.helper";
// types // types
import { ICycle } from "types"; import { ICycle } from "types";
import { useMobxStore } from "lib/mobx/store-provider"; // constants
import { CYCLE_STATUS } from "constants/cycle";
type TCyclesListItem = { type TCyclesListItem = {
cycle: ICycle; cycle: ICycle;
@ -35,34 +36,6 @@ type TCyclesListItem = {
projectId: string; projectId: string;
}; };
const stateGroups = [
{
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",
},
];
export const CyclesListItem: FC<TCyclesListItem> = (props) => { export const CyclesListItem: FC<TCyclesListItem> = (props) => {
const { cycle, workspaceSlug, projectId } = props; const { cycle, workspaceSlug, projectId } = props;
// store // store
@ -78,7 +51,28 @@ export const CyclesListItem: FC<TCyclesListItem> = (props) => {
const endDate = new Date(cycle.end_date ?? ""); const endDate = new Date(cycle.end_date ?? "");
const startDate = new Date(cycle.start_date ?? ""); const startDate = new Date(cycle.start_date ?? "");
const handleCopyText = () => { const router = useRouter();
const cycleTotalIssues =
cycle.backlog_issues +
cycle.unstarted_issues +
cycle.started_issues +
cycle.completed_issues +
cycle.cancelled_issues;
const renderDate = cycle.start_date || cycle.end_date;
const areYearsEqual = startDate.getFullYear() === endDate.getFullYear();
const completionPercentage = (cycle.completed_issues / cycleTotalIssues) * 100;
const progress = isNaN(completionPercentage) ? 0 : Math.floor(completionPercentage);
const currentCycle = CYCLE_STATUS.find((status) => status.value === cycleStatus);
const handleCopyText = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
const originURL = typeof window !== "undefined" && window.location.origin ? window.location.origin : ""; const originURL = typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
copyTextToClipboard(`${originURL}/${workspaceSlug}/projects/${projectId}/cycles/${cycle.id}`).then(() => { copyTextToClipboard(`${originURL}/${workspaceSlug}/projects/${projectId}/cycles/${cycle.id}`).then(() => {
@ -90,13 +84,6 @@ export const CyclesListItem: FC<TCyclesListItem> = (props) => {
}); });
}; };
const progressIndicatorData = stateGroups.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 handleAddToFavorites = (e: MouseEvent<HTMLButtonElement>) => { const handleAddToFavorites = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault(); e.preventDefault();
if (!workspaceSlug || !projectId) return; if (!workspaceSlug || !projectId) return;
@ -123,224 +110,31 @@ export const CyclesListItem: FC<TCyclesListItem> = (props) => {
}); });
}; };
const handleEditCycle = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
setUpdateModal(true);
};
const handleDeleteCycle = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
setDeleteModal(true);
};
const openCycleOverview = (e: MouseEvent<HTMLButtonElement>) => {
const { query } = router;
e.preventDefault();
e.stopPropagation();
router.push({
pathname: router.pathname,
query: { ...query, peekCycle: cycle.id },
});
};
return ( return (
<> <>
<div className="relative flex items-center gap-1 hover:bg-custom-background-80 transition-all rounded px-2 pl-3">
<div className="w-full text-xs py-3">
<Link href={`/${workspaceSlug}/projects/${projectId}/cycles/${cycle.id}`}>
<a className="w-full h-full relative overflow-hidden flex items-center gap-2">
{/* left content */}
<div className="relative flex items-center gap-2 overflow-hidden">
{/* cycle state */}
<div className="flex-shrink-0">
<ContrastIcon
className="h-5 w-5"
color={`${
cycleStatus === "current"
? "#09A953"
: cycleStatus === "upcoming"
? "#F7AE59"
: cycleStatus === "completed"
? "#3F76FF"
: cycleStatus === "draft"
? "rgb(var(--color-text-200))"
: ""
}`}
/>
</div>
{/* cycle title and description */}
<div className="max-w-xl">
<Tooltip tooltipContent={cycle.name} className="break-words" position="top-left">
<div className="text-base font-semibold line-clamp-1 pr-5 overflow-hidden break-words">
{cycle.name}
</div>
</Tooltip>
{cycle.description && (
<div className="mt-1 text-custom-text-200 break-words w-full line-clamp-2">{cycle.description}</div>
)}
</div>
</div>
{/* right content */}
<div className="ml-auto flex-shrink-0 relative flex items-center gap-3 p-2">
{/* cycle status */}
<div
className={`rounded-full px-2 py-1
${
cycleStatus === "current"
? "bg-green-600/10 text-green-600"
: cycleStatus === "upcoming"
? "bg-orange-300/10 text-orange-300"
: cycleStatus === "completed"
? "bg-blue-500/10 text-blue-500"
: cycleStatus === "draft"
? "bg-neutral-400/10 text-neutral-400"
: ""
}`}
>
{cycleStatus === "current" ? (
<span className="flex items-center gap-1 whitespace-nowrap">
<RunningIcon className="h-3.5 w-3.5" />
{findHowManyDaysLeft(cycle.end_date ?? new Date())} days left
</span>
) : cycleStatus === "upcoming" ? (
<span className="flex items-center gap-1">
<AlarmClock className="h-3.5 w-3.5" />
{findHowManyDaysLeft(cycle.start_date ?? new Date())} days left
</span>
) : cycleStatus === "completed" ? (
<span className="flex items-center gap-1">
{cycle.total_issues - cycle.completed_issues > 0 && (
<Tooltip
tooltipContent={`${cycle.total_issues - cycle.completed_issues} more pending ${
cycle.total_issues - cycle.completed_issues === 1 ? "issue" : "issues"
}`}
>
<span>
<AlertTriangle className="h-3.5 w-3.5" />
</span>
</Tooltip>
)}{" "}
Completed
</span>
) : (
cycleStatus
)}
</div>
{/* cycle start_date and target_date */}
{cycleStatus !== "draft" && (
<div className="flex items-center justify-start gap-2 text-custom-text-200">
<div className="flex items-start gap-1 whitespace-nowrap">
<CalendarDays className="h-4 w-4" />
<span>{renderShortDateWithYearFormat(startDate)}</span>
</div>
<ArrowRight className="h-4 w-4" />
<div className="flex items-start gap-1 whitespace-nowrap">
<Target className="h-4 w-4" />
<span>{renderShortDateWithYearFormat(endDate)}</span>
</div>
</div>
)}
{/* cycle created by */}
<div className="flex items-center 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-orange-300 capitalize text-white">
{cycle.owned_by.display_name.charAt(0)}
</span>
)}
</div>
{/* cycle progress */}
<Tooltip
position="top-right"
tooltipContent={
<div className="flex w-80 items-center gap-2 px-4 py-1">
<span>Progress</span>
<LinearProgressIndicator data={progressIndicatorData} />
</div>
}
>
<span
className={`rounded-md px-1.5 py-1
${
cycleStatus === "current"
? "border border-green-600 bg-green-600/5 text-green-600"
: cycleStatus === "upcoming"
? "border border-orange-300 bg-orange-300/5 text-orange-300"
: cycleStatus === "completed"
? "border border-blue-500 bg-blue-500/5 text-blue-500"
: cycleStatus === "draft"
? "border border-neutral-400 bg-neutral-400/5 text-neutral-400"
: ""
}`}
>
{cycleStatus === "current" ? (
<span className="flex gap-1 whitespace-nowrap">
{cycle.total_issues > 0 ? (
<>
<RadialProgressBar progress={(cycle.completed_issues / cycle.total_issues) * 100} />
<span>{Math.floor((cycle.completed_issues / cycle.total_issues) * 100)} %</span>
</>
) : (
<span className="normal-case">No issues present</span>
)}
</span>
) : cycleStatus === "upcoming" ? (
<span className="flex gap-1">
<RadialProgressBar progress={100} /> Yet to start
</span>
) : cycleStatus === "completed" ? (
<span className="flex gap-1">
<RadialProgressBar progress={100} />
<span>{100} %</span>
</span>
) : (
<span className="flex gap-1">
<RadialProgressBar progress={(cycle.total_issues / cycle.completed_issues) * 100} />
{cycleStatus}
</span>
)}
</span>
</Tooltip>
{/* cycle favorite */}
{cycle.is_favorite ? (
<button type="button" onClick={handleRemoveFromFavorites}>
<Star className="h-4 w-4 text-orange-400" fill="#f6ad55" />
</button>
) : (
<button type="button" onClick={handleAddToFavorites}>
<Star className="h-4 w-4 " color="rgb(var(--color-text-200))" />
</button>
)}
</div>
</a>
</Link>
</div>
<div className="flex-shrink-0">
<CustomMenu width="auto" verticalEllipsis>
{!isCompleted && (
<CustomMenu.MenuItem onClick={() => setUpdateModal(true)}>
<span className="flex items-center justify-start gap-2">
<Pencil className="h-4 w-4" />
<span>Edit Cycle</span>
</span>
</CustomMenu.MenuItem>
)}
{!isCompleted && (
<CustomMenu.MenuItem onClick={() => setDeleteModal(true)}>
<span className="flex items-center justify-start gap-2">
<Trash2 className="h-4 w-4" />
<span>Delete cycle</span>
</span>
</CustomMenu.MenuItem>
)}
<CustomMenu.MenuItem onClick={handleCopyText}>
<span className="flex items-center justify-start gap-2">
<LinkIcon className="h-4 w-4" />
<span>Copy cycle link</span>
</span>
</CustomMenu.MenuItem>
</CustomMenu>
</div>
</div>
<CycleCreateUpdateModal <CycleCreateUpdateModal
data={cycle} data={cycle}
isOpen={updateModal} isOpen={updateModal}
@ -348,7 +142,6 @@ export const CyclesListItem: FC<TCyclesListItem> = (props) => {
workspaceSlug={workspaceSlug} workspaceSlug={workspaceSlug}
projectId={projectId} projectId={projectId}
/> />
<CycleDeleteModal <CycleDeleteModal
cycle={cycle} cycle={cycle}
isOpen={deleteModal} isOpen={deleteModal}
@ -356,6 +149,110 @@ export const CyclesListItem: FC<TCyclesListItem> = (props) => {
workspaceSlug={workspaceSlug} workspaceSlug={workspaceSlug}
projectId={projectId} projectId={projectId}
/> />
<Link href={`/${workspaceSlug}/projects/${projectId}/cycles/${cycle.id}`}>
<a className="group flex items-center justify-between gap-5 px-10 py-6 h-16 w-full text-sm bg-custom-background-100 border-b border-custom-border-100 hover:bg-custom-background-90">
<div className="flex items-center gap-3 w-full truncate">
<div className="flex items-center gap-4 truncate">
<span className="flex-shrink-0">
<CircularProgressIndicator size={38} percentage={progress}>
{isCompleted ? (
<span className="text-sm text-custom-primary-100">{`!`}</span>
) : progress === 100 ? (
<Check className="h-3 w-3 text-custom-primary-100 stroke-[2]" />
) : (
<span className="text-xs text-custom-text-300">{`${progress}%`}</span>
)}
</CircularProgressIndicator>
</span>
<div className="flex items-center gap-2.5">
<span className="flex-shrink-0">
<CycleGroupIcon cycleGroup={cycleStatus} className="h-3.5 w-3.5" />
</span>
<Tooltip tooltipContent={cycle.name} position="top">
<span className="text-base font-medium truncate">{cycle.name}</span>
</Tooltip>
</div>
</div>
<button onClick={openCycleOverview} className="flex-shrink-0 hidden group-hover:flex z-10">
<Info className="h-4 w-4 text-custom-text-400" />
</button>
</div>
<div className="flex items-center gap-2.5 justify-end w-full md:w-auto md:flex-shrink-0 ">
<div className="flex items-center justify-center">
{currentCycle && (
<span
className="flex items-center justify-center text-xs text-center h-6 w-20 rounded-sm"
style={{
color: currentCycle.color,
backgroundColor: `${currentCycle.color}20`,
}}
>
{currentCycle.value === "current"
? `${findHowManyDaysLeft(cycle.end_date ?? new Date())} ${currentCycle.label}`
: `${currentCycle.label}`}
</span>
)}
</div>
{renderDate && (
<span className="flex items-center justify-center gap-2 w-28 text-xs text-custom-text-300">
{areYearsEqual ? renderShortDate(startDate, "_ _") : renderShortMonthDate(startDate, "_ _")}
{" - "}
{areYearsEqual ? renderShortDate(endDate, "_ _") : renderShortMonthDate(endDate, "_ _")}
</span>
)}
<Tooltip tooltipContent={`${cycle.assignees.length} Members`}>
<div className="flex items-center justify-center gap-1 cursor-default w-16">
{cycle.assignees.length > 0 ? (
<AssigneesList users={cycle.assignees} length={2} />
) : (
<span className="flex items-end justify-center h-5 w-5 bg-custom-background-80 rounded-full border border-dashed border-custom-text-400">
<User2 className="h-4 w-4 text-custom-text-400" />
</span>
)}
</div>
</Tooltip>
{cycle.is_favorite ? (
<button type="button" onClick={handleRemoveFromFavorites}>
<Star className="h-3.5 w-3.5 text-amber-500 fill-current" />
</button>
) : (
<button type="button" onClick={handleAddToFavorites}>
<Star className="h-3.5 w-3.5 text-custom-text-200" />
</button>
)}
<CustomMenu width="auto" ellipsis className="z-10">
{!isCompleted && (
<>
<CustomMenu.MenuItem onClick={handleEditCycle}>
<span className="flex items-center justify-start gap-2">
<Pencil className="h-3 w-3" />
<span>Edit cycle</span>
</span>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={handleDeleteCycle}>
<span className="flex items-center justify-start gap-2">
<Trash2 className="h-3 w-3" />
<span>Delete module</span>
</span>
</CustomMenu.MenuItem>
</>
)}
<CustomMenu.MenuItem onClick={handleCopyText}>
<span className="flex items-center justify-start gap-2">
<LinkIcon className="h-3 w-3" />
<span>Copy cycle link</span>
</span>
</CustomMenu.MenuItem>
</CustomMenu>
</div>
</a>
</Link>
</> </>
); );
}; };

View File

@ -1,6 +1,7 @@
import { FC } from "react"; import { FC } from "react";
// components // components
import { CyclesListItem } from "./cycles-list-item"; import { CyclePeekOverview, CyclesListItem } from "components/cycles";
// ui // ui
import { Loader } from "@plane/ui"; import { Loader } from "@plane/ui";
// types // types
@ -17,18 +18,22 @@ export const CyclesList: FC<ICyclesList> = (props) => {
const { cycles, filter, workspaceSlug, projectId } = props; const { cycles, filter, workspaceSlug, projectId } = props;
return ( return (
<div> <>
{cycles ? ( {cycles ? (
<> <>
{cycles.length > 0 ? ( {cycles.length > 0 ? (
<div className="divide-y divide-custom-border-200"> <div className="h-full overflow-y-auto">
{cycles.map((cycle) => ( <div className="flex justify-between h-full w-full">
<div className="hover:bg-custom-background-80" key={cycle.id}> <div className="flex flex-col h-full w-full overflow-y-auto">
<div className="flex flex-col border-custom-border-200"> {cycles.map((cycle) => (
<CyclesListItem cycle={cycle} workspaceSlug={workspaceSlug} projectId={projectId} /> <CyclesListItem cycle={cycle} workspaceSlug={workspaceSlug} projectId={projectId} />
</div> ))}
</div> </div>
))} <CyclePeekOverview
projectId={projectId?.toString() ?? ""}
workspaceSlug={workspaceSlug?.toString() ?? ""}
/>
</div>
</div> </div>
) : ( ) : (
<div className="h-full grid place-items-center text-center"> <div className="h-full grid place-items-center text-center">
@ -68,6 +73,6 @@ export const CyclesList: FC<ICyclesList> = (props) => {
<Loader.Item height="50px" /> <Loader.Item height="50px" />
</Loader> </Loader>
)} )}
</div> </>
); );
}; };

View File

@ -15,10 +15,11 @@ export interface ICyclesView {
layout: TCycleLayout; layout: TCycleLayout;
workspaceSlug: string; workspaceSlug: string;
projectId: string; projectId: string;
peekCycle: string;
} }
export const CyclesView: FC<ICyclesView> = observer((props) => { export const CyclesView: FC<ICyclesView> = observer((props) => {
const { filter, layout, workspaceSlug, projectId } = props; const { filter, layout, workspaceSlug, projectId, peekCycle } = props;
// store // store
const { cycle: cycleStore } = useMobxStore(); const { cycle: cycleStore } = useMobxStore();
@ -50,7 +51,13 @@ export const CyclesView: FC<ICyclesView> = observer((props) => {
{layout === "board" && ( {layout === "board" && (
<> <>
{!isLoading ? ( {!isLoading ? (
<CyclesBoard cycles={cyclesList} filter={filter} workspaceSlug={workspaceSlug} projectId={projectId} /> <CyclesBoard
cycles={cyclesList}
filter={filter}
workspaceSlug={workspaceSlug}
projectId={projectId}
peekCycle={peekCycle}
/>
) : ( ) : (
<Loader className="grid grid-cols-1 gap-9 md:grid-cols-2 lg:grid-cols-3"> <Loader className="grid grid-cols-1 gap-9 md:grid-cols-2 lg:grid-cols-3">
<Loader.Item height="200px" /> <Loader.Item height="200px" />

View File

@ -17,3 +17,5 @@ export * from "./cycles-board";
export * from "./cycles-board-card"; export * from "./cycles-board-card";
export * from "./cycles-gantt"; export * from "./cycles-gantt";
export * from "./delete-modal"; export * from "./delete-modal";
export * from "./cycle-peek-overview";
export * from "./cycles-list-item";

View File

@ -15,36 +15,29 @@ import { SidebarProgressStats } from "components/core";
import ProgressChart from "components/core/sidebar/progress-chart"; import ProgressChart from "components/core/sidebar/progress-chart";
import { CycleDeleteModal } from "components/cycles/delete-modal"; import { CycleDeleteModal } from "components/cycles/delete-modal";
// ui // ui
import { CustomRangeDatePicker } from "components/ui"; import { Avatar, CustomRangeDatePicker } from "components/ui";
import { CustomMenu, Loader, ProgressBar } from "@plane/ui"; import { CustomMenu, Loader, LayersIcon } from "@plane/ui";
// icons // icons
import { import { ChevronDown, LinkIcon, Trash2, UserCircle2, AlertCircle, ChevronRight, MoveRight } from "lucide-react";
CalendarDays,
ChevronDown,
File,
MoveRight,
LinkIcon,
PieChart,
Trash2,
UserCircle2,
AlertCircle,
} from "lucide-react";
// helpers // helpers
import { capitalizeFirstLetter, copyUrlToClipboard } from "helpers/string.helper"; import { copyUrlToClipboard } from "helpers/string.helper";
import { import {
findHowManyDaysLeft,
getDateRangeStatus, getDateRangeStatus,
isDateGreaterThanToday, isDateGreaterThanToday,
renderDateFormat, renderDateFormat,
renderShortDateWithYearFormat, renderShortDate,
renderShortMonthDate,
} from "helpers/date-time.helper"; } from "helpers/date-time.helper";
// types // types
import { ICycle } from "types"; import { ICycle } from "types";
// fetch-keys // fetch-keys
import { CYCLE_DETAILS } from "constants/fetch-keys"; import { CYCLE_DETAILS } from "constants/fetch-keys";
import { CYCLE_STATUS } from "constants/cycle";
type Props = { type Props = {
isOpen: boolean;
cycleId: string; cycleId: string;
handleClose: () => void;
}; };
// services // services
@ -52,12 +45,12 @@ const cycleService = new CycleService();
// TODO: refactor the whole component // TODO: refactor the whole component
export const CycleDetailsSidebar: React.FC<Props> = observer((props) => { export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
const { isOpen, cycleId } = props; const { cycleId, handleClose } = props;
const [cycleDeleteModal, setCycleDeleteModal] = useState(false); const [cycleDeleteModal, setCycleDeleteModal] = useState(false);
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId } = router.query; const { workspaceSlug, projectId, peekCycle } = router.query;
const { user: userStore, cycle: cycleDetailsStore } = useMobxStore(); const { user: userStore, cycle: cycleDetailsStore } = useMobxStore();
@ -280,6 +273,22 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
if (!cycleDetails) return null; if (!cycleDetails) return null;
const endDate = new Date(cycleDetails.end_date ?? "");
const startDate = new Date(cycleDetails.start_date ?? "");
const areYearsEqual = startDate.getFullYear() === endDate.getFullYear();
const currentCycle = CYCLE_STATUS.find((status) => status.value === cycleStatus);
const issueCount =
cycleDetails.total_issues === 0
? "0 Issue"
: cycleDetails.total_issues === cycleDetails.completed_issues
? cycleDetails.total_issues > 1
? `${cycleDetails.total_issues}`
: `${cycleDetails.total_issues}`
: `${cycleDetails.completed_issues}/${cycleDetails.total_issues}`;
return ( return (
<> <>
{cycleDetails && workspaceSlug && projectId && ( {cycleDetails && workspaceSlug && projectId && (
@ -291,327 +300,266 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
projectId={projectId.toString()} projectId={projectId.toString()}
/> />
)} )}
<div
className={`fixed top-[66px] ${
isOpen ? "right-0" : "-right-[24rem]"
} h-full w-[24rem] overflow-y-auto border-l border-custom-border-200 bg-custom-sidebar-background-100 pt-5 pb-10 duration-300`}
>
{cycleDetails ? (
<>
<div className="flex flex-col items-start justify-center">
<div className="flex gap-2.5 px-5 text-sm">
<div className="flex items-center">
<span className="flex items-center rounded border-[0.5px] border-custom-border-200 bg-custom-background-90 px-2 py-1 text-center text-xs capitalize">
{capitalizeFirstLetter(cycleStatus)}
</span>
</div>
<div className="relative flex h-full w-52 items-center gap-2">
<Popover className="flex h-full items-center justify-center rounded-lg">
{({}) => (
<>
<Popover.Button
disabled={isCompleted ?? false}
className={`group flex h-full items-center gap-2 whitespace-nowrap rounded border-[0.5px] border-custom-border-200 bg-custom-background-90 px-2 py-1 text-xs ${
cycleDetails.start_date ? "" : "text-custom-text-200"
}`}
>
<CalendarDays className="h-3 w-3" />
<span>
{renderShortDateWithYearFormat(
new Date(`${watch("start_date") ? watch("start_date") : cycleDetails?.start_date}`),
"Start date"
)}
</span>
</Popover.Button>
<Transition {cycleDetails ? (
as={React.Fragment} <>
enter="transition ease-out duration-200" <div className="flex items-center justify-between w-full">
enterFrom="opacity-0 translate-y-1" <div>
enterTo="opacity-100 translate-y-0" {peekCycle && (
leave="transition ease-in duration-150" <button
leaveFrom="opacity-100 translate-y-0" className="flex items-center justify-center h-5 w-5 rounded-full bg-custom-border-300"
leaveTo="opacity-0 translate-y-1" onClick={() => handleClose()}
> >
<Popover.Panel className="absolute top-10 -right-5 z-20 transform overflow-hidden"> <ChevronRight className="h-3 w-3 text-white stroke-2" />
<CustomRangeDatePicker </button>
value={watch("start_date") ? watch("start_date") : cycleDetails?.start_date} )}
onChange={(val) => { </div>
if (val) { <div className="flex items-center gap-3.5">
handleStartDateChange(val); <button onClick={handleCopyText}>
} <LinkIcon className="h-3 w-3 text-custom-text-300" />
}} </button>
startDate={watch("start_date") ? `${watch("start_date")}` : null} {!isCompleted && (
endDate={watch("end_date") ? `${watch("end_date")}` : null} <CustomMenu width="lg" ellipsis>
maxDate={new Date(`${watch("end_date")}`)} <CustomMenu.MenuItem onClick={() => setCycleDeleteModal(true)}>
selectsStart <span className="flex items-center justify-start gap-2">
/> <Trash2 className="h-4 w-4" />
</Popover.Panel> <span>Delete</span>
</Transition> </span>
</> </CustomMenu.MenuItem>
)} </CustomMenu>
</Popover> )}
<span> </div>
<MoveRight className="h-3 w-3 text-custom-text-200" /> </div>
</span>
<Popover className="flex h-full items-center justify-center rounded-lg">
{({}) => (
<>
<Popover.Button
disabled={isCompleted ?? false}
className={`group flex items-center gap-2 whitespace-nowrap rounded border-[0.5px] border-custom-border-200 bg-custom-background-90 px-2 py-1 text-xs ${
cycleDetails.end_date ? "" : "text-custom-text-200"
}`}
>
<CalendarDays className="h-3 w-3" />
<span> <div className="flex flex-col gap-3">
{renderShortDateWithYearFormat( <h4 className="text-xl font-semibold break-words w-full text-custom-text-100">{cycleDetails.name}</h4>
new Date(`${watch("end_date") ? watch("end_date") : cycleDetails?.end_date}`), <div className="flex items-center gap-5">
"End date" {currentCycle && (
)} <span
</span> className="flex items-center justify-center text-xs text-center h-6 w-20 rounded-sm"
</Popover.Button> style={{
color: currentCycle.color,
backgroundColor: `${currentCycle.color}20`,
}}
>
{currentCycle.value === "current"
? `${findHowManyDaysLeft(cycleDetails.end_date ?? new Date())} ${currentCycle.label}`
: `${currentCycle.label}`}
</span>
)}
<div className="relative flex h-full w-52 items-center gap-2.5">
<Popover className="flex h-full items-center justify-center rounded-lg">
{({}) => (
<>
<Popover.Button
disabled={isCompleted ?? false}
className="text-sm text-custom-text-300 font-medium cursor-default"
>
{areYearsEqual ? renderShortDate(startDate, "_ _") : renderShortMonthDate(startDate, "_ _")}
</Popover.Button>
<Transition <Transition
as={React.Fragment} as={React.Fragment}
enter="transition ease-out duration-200" enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1" enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0" enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150" leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0" leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1" leaveTo="opacity-0 translate-y-1"
> >
<Popover.Panel className="absolute top-10 -right-5 z-20 transform overflow-hidden"> <Popover.Panel className="absolute top-10 -right-5 z-20 transform overflow-hidden">
<CustomRangeDatePicker <CustomRangeDatePicker
value={watch("end_date") ? watch("end_date") : cycleDetails?.end_date} value={watch("start_date") ? watch("start_date") : cycleDetails?.start_date}
onChange={(val) => { onChange={(val) => {
if (val) { if (val) {
handleEndDateChange(val); handleStartDateChange(val);
} }
}} }}
startDate={watch("start_date") ? `${watch("start_date")}` : null} startDate={watch("start_date") ? `${watch("start_date")}` : null}
endDate={watch("end_date") ? `${watch("end_date")}` : null} endDate={watch("end_date") ? `${watch("end_date")}` : null}
minDate={new Date(`${watch("start_date")}`)} maxDate={new Date(`${watch("end_date")}`)}
selectsEnd selectsStart
/> />
</Popover.Panel> </Popover.Panel>
</Transition> </Transition>
</> </>
)} )}
</Popover> </Popover>
</div> <MoveRight className="h-4 w-4 text-custom-text-300" />
<Popover className="flex h-full items-center justify-center rounded-lg">
{({}) => (
<>
<Popover.Button
disabled={isCompleted ?? false}
className="text-sm text-custom-text-300 font-medium cursor-default"
>
{areYearsEqual ? renderShortDate(endDate, "_ _") : renderShortMonthDate(endDate, "_ _")}
</Popover.Button>
<Transition
as={React.Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<Popover.Panel className="absolute top-10 -right-5 z-20 transform overflow-hidden">
<CustomRangeDatePicker
value={watch("end_date") ? watch("end_date") : cycleDetails?.end_date}
onChange={(val) => {
if (val) {
handleEndDateChange(val);
}
}}
startDate={watch("start_date") ? `${watch("start_date")}` : null}
endDate={watch("end_date") ? `${watch("end_date")}` : null}
minDate={new Date(`${watch("start_date")}`)}
selectsEnd
/>
</Popover.Panel>
</Transition>
</>
)}
</Popover>
</div> </div>
</div>
</div>
<div className="flex w-full flex-col gap-6 px-6 py-6"> {cycleDetails.description && (
<div className="flex w-full flex-col items-start justify-start gap-2"> <span className="whitespace-normal text-sm leading-5 py-2.5 text-custom-text-200 break-words w-full">
<div className="flex w-full items-start justify-between gap-2"> {cycleDetails.description}
<div className="max-w-[300px]"> </span>
<h4 className="text-xl font-semibold text-custom-text-100 break-words w-full"> )}
{cycleDetails.name}
</h4>
</div>
<CustomMenu width="lg" ellipsis>
{!isCompleted && (
<CustomMenu.MenuItem onClick={() => setCycleDeleteModal(true)}>
<span className="flex items-center justify-start gap-2">
<Trash2 className="h-4 w-4" />
<span>Delete</span>
</span>
</CustomMenu.MenuItem>
)}
<CustomMenu.MenuItem onClick={handleCopyText}>
<span className="flex items-center justify-start gap-2">
<LinkIcon className="h-4 w-4" />
<span>Copy link</span>
</span>
</CustomMenu.MenuItem>
</CustomMenu>
</div>
<span className="whitespace-normal text-sm leading-5 text-custom-text-200 break-words w-full"> <div className="flex flex-col gap-5 pt-2.5 pb-6">
{cycleDetails.description} <div className="flex items-center justify-start gap-1">
</span> <div className="flex w-1/2 items-center justify-start gap-2 text-custom-text-300">
</div> <UserCircle2 className="h-4 w-4" />
<span className="text-base">Lead</span>
<div className="flex flex-col gap-4 text-sm"> </div>
<div className="flex items-center justify-start gap-1"> <div className="flex items-center w-1/2 rounded-sm">
<div className="flex w-40 items-center justify-start gap-2 text-custom-text-200"> <div className="flex items-center gap-2.5">
<UserCircle2 className="h-5 w-5" /> <Avatar user={cycleDetails.owned_by} />
<span>Lead</span> <span className="text-sm text-custom-text-200">{cycleDetails.owned_by.display_name}</span>
</div>
<div className="flex items-center gap-2.5">
{cycleDetails.owned_by.avatar && cycleDetails.owned_by.avatar !== "" ? (
<img
src={cycleDetails.owned_by.avatar}
height={12}
width={12}
className="rounded-full"
alt={cycleDetails.owned_by.display_name}
/>
) : (
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-gray-800 capitalize text-white">
{cycleDetails.owned_by.display_name.charAt(0)}
</span>
)}
<span className="text-custom-text-200">{cycleDetails.owned_by.display_name}</span>
</div>
</div>
<div className="flex items-center justify-start gap-1">
<div className="flex w-40 items-center justify-start gap-2 text-custom-text-200">
<PieChart className="h-5 w-5" />
<span>Progress</span>
</div>
<div className="flex items-center gap-2.5 text-custom-text-200">
<span className="h-4 w-4">
<ProgressBar value={cycleDetails.completed_issues} maxValue={cycleDetails.total_issues} />
</span>
{cycleDetails.completed_issues}/{cycleDetails.total_issues}
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
<div className="flex w-full flex-col items-center justify-start gap-2 border-t border-custom-border-200 p-6">
<Disclosure defaultOpen> <div className="flex items-center justify-start gap-1">
<div className="flex w-1/2 items-center justify-start gap-2 text-custom-text-300">
<LayersIcon className="h-4 w-4" />
<span className="text-base">Issues</span>
</div>
<div className="flex items-center w-1/2">
<span className="text-sm text-custom-text-300 px-1.5">{issueCount}</span>
</div>
</div>
</div>
<div className="flex flex-col">
<div className="flex w-full flex-col items-center justify-start gap-2 border-t border-custom-border-200 py-5 px-1.5">
<Disclosure>
{({ open }) => ( {({ open }) => (
<div className={`relative flex h-full w-full flex-col ${open ? "" : "flex-row"}`}> <div className={`relative flex h-full w-full flex-col ${open ? "" : "flex-row"}`}>
<div className="flex w-full items-center justify-between gap-2 "> <div className="flex w-full items-center justify-between gap-2">
<div className="flex items-center justify-start gap-2 text-sm"> <div className="flex items-center justify-start gap-2 text-sm">
<span className="font-medium text-custom-text-200">Progress</span> <span className="font-medium text-custom-text-200">Progress</span>
{!open && progressPercentage ? ( </div>
<span className="rounded bg-[#09A953]/10 px-1.5 py-0.5 text-xs text-[#09A953]">
<div className="flex items-center gap-2.5">
{progressPercentage ? (
<span className="flex items-center justify-center h-5 w-9 rounded text-xs font-medium text-amber-500 bg-amber-50">
{progressPercentage ? `${progressPercentage}%` : ""} {progressPercentage ? `${progressPercentage}%` : ""}
</span> </span>
) : ( ) : (
"" ""
)} )}
{isStartValid && isEndValid ? (
<Disclosure.Button className="p-1.5">
<ChevronDown
className={`h-3 w-3 ${open ? "rotate-180 transform" : ""}`}
aria-hidden="true"
/>
</Disclosure.Button>
) : (
<div className="flex items-center gap-1">
<AlertCircle height={14} width={14} className="text-custom-text-200" />
<span className="text-xs italic text-custom-text-200">
Invalid date. Please enter valid date.
</span>
</div>
)}
</div> </div>
{isStartValid && isEndValid ? (
<Disclosure.Button>
<ChevronDown className={`h-3 w-3 ${open ? "rotate-180 transform" : ""}`} aria-hidden="true" />
</Disclosure.Button>
) : (
<div className="flex items-center gap-1">
<AlertCircle className="h-3.5 w-3.5 text-custom-text-200" />
<span className="text-xs italic text-custom-text-200">
{cycleStatus === "upcoming"
? "Cycle is yet to start."
: "Invalid date. Please enter valid date."}
</span>
</div>
)}
</div> </div>
<Transition show={open}> <Transition show={open}>
<Disclosure.Panel> <Disclosure.Panel>
{isStartValid && isEndValid ? ( <div className="flex flex-col gap-3">
<div className=" h-full w-full py-4"> {isStartValid && isEndValid ? (
<div className="flex items-start justify-between gap-4 py-2 text-xs"> <div className=" h-full w-full pt-4">
<div className="flex items-center gap-1"> <div className="flex items-start gap-4 py-2 text-xs">
<span> <div className="flex items-center gap-3 text-custom-text-100">
<File className="h-3 w-3 text-custom-text-200" /> <div className="flex items-center justify-center gap-1">
</span> <span className="h-2.5 w-2.5 rounded-full bg-[#A9BBD0]" />
<span> <span>Ideal</span>
Pending Issues -{" "} </div>
{cycleDetails.total_issues - <div className="flex items-center justify-center gap-1">
(cycleDetails.completed_issues + cycleDetails.cancelled_issues)} <span className="h-2.5 w-2.5 rounded-full bg-[#4C8FFF]" />
</span> <span>Current</span>
</div>
</div>
</div> </div>
<div className="relative h-40 w-80">
<div className="flex items-center gap-3 text-custom-text-100"> <ProgressChart
<div className="flex items-center justify-center gap-1"> distribution={cycleDetails.distribution.completion_chart}
<span className="h-2.5 w-2.5 rounded-full bg-[#A9BBD0]" /> startDate={cycleDetails.start_date ?? ""}
<span>Ideal</span> endDate={cycleDetails.end_date ?? ""}
</div> totalIssues={cycleDetails.total_issues}
<div className="flex items-center justify-center gap-1"> />
<span className="h-2.5 w-2.5 rounded-full bg-[#4C8FFF]" />
<span>Current</span>
</div>
</div> </div>
</div> </div>
<div className="relative"> ) : (
<ProgressChart ""
distribution={cycleDetails.distribution.completion_chart} )}
startDate={cycleDetails.start_date ?? ""} {cycleDetails.total_issues > 0 && (
endDate={cycleDetails.end_date ?? ""} <div className="h-full w-full pt-5 border-t border-custom-border-200">
<SidebarProgressStats
distribution={cycleDetails.distribution}
groupedIssues={{
backlog: cycleDetails.backlog_issues,
unstarted: cycleDetails.unstarted_issues,
started: cycleDetails.started_issues,
completed: cycleDetails.completed_issues,
cancelled: cycleDetails.cancelled_issues,
}}
totalIssues={cycleDetails.total_issues} totalIssues={cycleDetails.total_issues}
isPeekView={Boolean(peekCycle)}
/> />
</div> </div>
</div> )}
) : (
""
)}
</Disclosure.Panel>
</Transition>
</div>
)}
</Disclosure>
</div>
<div className="flex w-full flex-col items-center justify-start gap-2 border-t border-custom-border-200 p-6">
<Disclosure defaultOpen>
{({ open }) => (
<div className={`relative flex h-full w-full flex-col ${open ? "" : "flex-row"}`}>
<div className="flex w-full items-center justify-between gap-2">
<div className="flex items-center justify-start gap-2 text-sm">
<span className="font-medium text-custom-text-200">Other Information</span>
</div>
{cycleDetails.total_issues > 0 ? (
<Disclosure.Button>
<ChevronDown className={`h-3 w-3 ${open ? "rotate-180 transform" : ""}`} aria-hidden="true" />
</Disclosure.Button>
) : (
<div className="flex items-center gap-1">
<AlertCircle className="h-3.5 w-3.5 text-custom-text-200" />
<span className="text-xs italic text-custom-text-200">
No issues found. Please add issue.
</span>
</div> </div>
)}
</div>
<Transition show={open}>
<Disclosure.Panel>
{cycleDetails.total_issues > 0 ? (
<div className="h-full w-full py-4">
<SidebarProgressStats
distribution={cycleDetails.distribution}
groupedIssues={{
backlog: cycleDetails.backlog_issues,
unstarted: cycleDetails.unstarted_issues,
started: cycleDetails.started_issues,
completed: cycleDetails.completed_issues,
cancelled: cycleDetails.cancelled_issues,
}}
totalIssues={cycleDetails.total_issues}
/>
</div>
) : (
""
)}
</Disclosure.Panel> </Disclosure.Panel>
</Transition> </Transition>
</div> </div>
)} )}
</Disclosure> </Disclosure>
</div> </div>
</> </div>
) : ( </>
<Loader className="px-5"> ) : (
<div className="space-y-2"> <Loader className="px-5">
<Loader.Item height="15px" width="50%" /> <div className="space-y-2">
<Loader.Item height="15px" width="30%" /> <Loader.Item height="15px" width="50%" />
</div> <Loader.Item height="15px" width="30%" />
<div className="mt-8 space-y-3"> </div>
<Loader.Item height="30px" /> <div className="mt-8 space-y-3">
<Loader.Item height="30px" /> <Loader.Item height="30px" />
<Loader.Item height="30px" /> <Loader.Item height="30px" />
</div> <Loader.Item height="30px" />
</Loader> </div>
)} </Loader>
</div> )}
</> </>
); );
}); });

View File

@ -31,7 +31,7 @@ export const JiraImportUsers: FC = () => {
const { data: members } = useSWR( const { data: members } = useSWR(
workspaceSlug ? WORKSPACE_MEMBERS_WITH_EMAIL(workspaceSlug?.toString() ?? "") : null, workspaceSlug ? WORKSPACE_MEMBERS_WITH_EMAIL(workspaceSlug?.toString() ?? "") : null,
workspaceSlug ? () => workspaceService.workspaceMembersWithEmail(workspaceSlug?.toString() ?? "") : null workspaceSlug ? () => workspaceService.workspaceMembers(workspaceSlug?.toString() ?? "") : null
); );
const options = members?.map((member) => ({ const options = members?.map((member) => ({

View File

@ -1,6 +1,5 @@
export * from "./attachment"; export * from "./attachment";
export * from "./comment"; export * from "./comment";
export * from "./my-issues";
export * from "./sidebar-select"; export * from "./sidebar-select";
export * from "./view-select"; export * from "./view-select";
export * from "./activity"; export * from "./activity";

View File

@ -0,0 +1,25 @@
import { PlusIcon } from "lucide-react";
// components
import { EmptyState } from "components/common";
// assets
import emptyIssue from "public/empty-state/issue.svg";
export const CycleEmptyState: React.FC = () => (
<div className="h-full w-full grid place-items-center">
<EmptyState
title="Cycle issues will appear here"
description="Issues help you track individual pieces of work. With Issues, keep track of what's going on, who is working on it, and what's done."
image={emptyIssue}
primaryButton={{
text: "New issue",
icon: <PlusIcon className="h-3 w-3" strokeWidth={2} />,
onClick: () => {
const e = new KeyboardEvent("keydown", {
key: "c",
});
document.dispatchEvent(e);
},
}}
/>
</div>
);

View File

@ -0,0 +1,25 @@
import { PlusIcon } from "lucide-react";
// components
import { EmptyState } from "components/common";
// assets
import emptyIssue from "public/empty-state/issue.svg";
export const GlobalViewEmptyState: React.FC = () => (
<div className="h-full w-full grid place-items-center">
<EmptyState
title="View issues will appear here"
description="Issues help you track individual pieces of work. With Issues, keep track of what's going on, who is working on it, and what's done."
image={emptyIssue}
primaryButton={{
text: "New issue",
icon: <PlusIcon className="h-3 w-3" strokeWidth={2} />,
onClick: () => {
const e = new KeyboardEvent("keydown", {
key: "c",
});
document.dispatchEvent(e);
},
}}
/>
</div>
);

View File

@ -0,0 +1,5 @@
export * from "./cycle";
export * from "./global-view";
export * from "./module";
export * from "./project-view";
export * from "./project";

View File

@ -0,0 +1,25 @@
import { PlusIcon } from "lucide-react";
// components
import { EmptyState } from "components/common";
// assets
import emptyIssue from "public/empty-state/issue.svg";
export const ModuleEmptyState: React.FC = () => (
<div className="h-full w-full grid place-items-center">
<EmptyState
title="Module issues will appear here"
description="Issues help you track individual pieces of work. With Issues, keep track of what's going on, who is working on it, and what's done."
image={emptyIssue}
primaryButton={{
text: "New issue",
icon: <PlusIcon className="h-3 w-3" strokeWidth={2} />,
onClick: () => {
const e = new KeyboardEvent("keydown", {
key: "c",
});
document.dispatchEvent(e);
},
}}
/>
</div>
);

View File

@ -0,0 +1,25 @@
import { PlusIcon } from "lucide-react";
// components
import { EmptyState } from "components/common";
// assets
import emptyIssue from "public/empty-state/issue.svg";
export const ProjectViewEmptyState: React.FC = () => (
<div className="h-full w-full grid place-items-center">
<EmptyState
title="View issues will appear here"
description="Issues help you track individual pieces of work. With Issues, keep track of what's going on, who is working on it, and what's done."
image={emptyIssue}
primaryButton={{
text: "New issue",
icon: <PlusIcon className="h-3 w-3" strokeWidth={2} />,
onClick: () => {
const e = new KeyboardEvent("keydown", {
key: "c",
});
document.dispatchEvent(e);
},
}}
/>
</div>
);

View File

@ -0,0 +1,25 @@
import { PlusIcon } from "lucide-react";
// components
import { EmptyState } from "components/common";
// assets
import emptyIssue from "public/empty-state/issue.svg";
export const ProjectEmptyState: React.FC = () => (
<div className="h-full w-full grid place-items-center">
<EmptyState
title="Project issues will appear here"
description="Issues help you track individual pieces of work. With Issues, keep track of what's going on, who is working on it, and what's done."
image={emptyIssue}
primaryButton={{
text: "New issue",
icon: <PlusIcon className="h-3 w-3" strokeWidth={2} />,
onClick: () => {
const e = new KeyboardEvent("keydown", {
key: "c",
});
document.dispatchEvent(e);
},
}}
/>
</div>
);

View File

@ -1,5 +1,6 @@
// filters // filters
export * from "./filters"; export * from "./filters";
export * from "./empty-states";
export * from "./quick-action-dropdowns"; export * from "./quick-action-dropdowns";
// layouts // layouts

View File

@ -1,4 +1,5 @@
export * from "./roots"; export * from "./roots";
export * from "./block"; export * from "./block";
export * from "./roots";
export * from "./blocks-list"; export * from "./blocks-list";
export * from "./inline-create-issue-form"; export * from "./inline-create-issue-form";

View File

@ -68,7 +68,7 @@ export const ModuleListLayout: React.FC = observer(() => {
: null; : null;
return ( return (
<div className={`relative w-full h-full bg-custom-background-90`}> <div className="relative w-full h-full bg-custom-background-90">
<List <List
issues={issues} issues={issues}
group_by={group_by} group_by={group_by}

View File

@ -8,6 +8,7 @@ import { useMobxStore } from "lib/mobx/store-provider";
import { import {
CycleAppliedFiltersRoot, CycleAppliedFiltersRoot,
CycleCalendarLayout, CycleCalendarLayout,
CycleEmptyState,
CycleGanttLayout, CycleGanttLayout,
CycleKanBanLayout, CycleKanBanLayout,
CycleListLayout, CycleListLayout,
@ -50,25 +51,31 @@ export const CycleLayoutRoot: React.FC = observer(() => {
? getDateRangeStatus(cycleDetails?.start_date, cycleDetails?.end_date) ? getDateRangeStatus(cycleDetails?.start_date, cycleDetails?.end_date)
: "draft"; : "draft";
const issueCount = cycleIssueStore.getIssuesCount;
return ( return (
<> <>
<TransferIssuesModal handleClose={() => setTransferIssuesModal(false)} isOpen={transferIssuesModal} /> <TransferIssuesModal handleClose={() => setTransferIssuesModal(false)} isOpen={transferIssuesModal} />
<div className="relative w-full h-full flex flex-col overflow-hidden"> <div className="relative w-full h-full flex flex-col overflow-hidden">
{cycleStatus === "completed" && <TransferIssues handleClick={() => setTransferIssuesModal(true)} />} {cycleStatus === "completed" && <TransferIssues handleClick={() => setTransferIssuesModal(true)} />}
<CycleAppliedFiltersRoot /> <CycleAppliedFiltersRoot />
<div className="w-full h-full overflow-auto"> {(activeLayout === "list" || activeLayout === "spreadsheet") && issueCount === 0 ? (
{activeLayout === "list" ? ( <CycleEmptyState />
<CycleListLayout /> ) : (
) : activeLayout === "kanban" ? ( <div className="w-full h-full overflow-auto">
<CycleKanBanLayout /> {activeLayout === "list" ? (
) : activeLayout === "calendar" ? ( <CycleListLayout />
<CycleCalendarLayout /> ) : activeLayout === "kanban" ? (
) : activeLayout === "gantt_chart" ? ( <CycleKanBanLayout />
<CycleGanttLayout /> ) : activeLayout === "calendar" ? (
) : activeLayout === "spreadsheet" ? ( <CycleCalendarLayout />
<CycleSpreadsheetLayout /> ) : activeLayout === "gantt_chart" ? (
) : null} <CycleGanttLayout />
</div> ) : activeLayout === "spreadsheet" ? (
<CycleSpreadsheetLayout />
) : null}
</div>
)}
</div> </div>
</> </>
); );

View File

@ -5,7 +5,7 @@ import useSWR from "swr";
// mobx store // mobx store
import { useMobxStore } from "lib/mobx/store-provider"; import { useMobxStore } from "lib/mobx/store-provider";
// components // components
import { GlobalViewsAppliedFiltersRoot, SpreadsheetView } from "components/issues"; import { GlobalViewEmptyState, GlobalViewsAppliedFiltersRoot, SpreadsheetView } from "components/issues";
// types // types
import { IIssue, IIssueDisplayFilterOptions, TStaticViewTypes } from "types"; import { IIssue, IIssueDisplayFilterOptions, TStaticViewTypes } from "types";
@ -81,19 +81,23 @@ export const GlobalViewLayoutRoot: React.FC<Props> = observer((props) => {
return ( return (
<div className="relative w-full h-full flex flex-col overflow-hidden"> <div className="relative w-full h-full flex flex-col overflow-hidden">
<GlobalViewsAppliedFiltersRoot /> <GlobalViewsAppliedFiltersRoot />
<div className="h-full w-full overflow-auto"> {issues?.length === 0 ? (
<SpreadsheetView <GlobalViewEmptyState />
displayProperties={workspaceFilterStore.workspaceDisplayProperties} ) : (
displayFilters={workspaceFilterStore.workspaceDisplayFilters} <div className="h-full w-full overflow-auto">
handleDisplayFilterUpdate={handleDisplayFiltersUpdate} <SpreadsheetView
issues={issues} displayProperties={workspaceFilterStore.workspaceDisplayProperties}
members={workspaceStore.workspaceMembers ? workspaceStore.workspaceMembers.map((m) => m.member) : undefined} displayFilters={workspaceFilterStore.workspaceDisplayFilters}
labels={workspaceStore.workspaceLabels ? workspaceStore.workspaceLabels : undefined} handleDisplayFilterUpdate={handleDisplayFiltersUpdate}
handleIssueAction={() => {}} issues={issues}
handleUpdateIssue={handleUpdateIssue} members={workspaceStore.workspaceMembers ? workspaceStore.workspaceMembers.map((m) => m.member) : undefined}
disableUserActions={false} labels={workspaceStore.workspaceLabels ? workspaceStore.workspaceLabels : undefined}
/> handleIssueAction={() => {}}
</div> handleUpdateIssue={handleUpdateIssue}
disableUserActions={false}
/>
</div>
)}
</div> </div>
); );
}); });

View File

@ -9,6 +9,7 @@ import { useMobxStore } from "lib/mobx/store-provider";
import { import {
ModuleAppliedFiltersRoot, ModuleAppliedFiltersRoot,
ModuleCalendarLayout, ModuleCalendarLayout,
ModuleEmptyState,
ModuleGanttLayout, ModuleGanttLayout,
ModuleKanBanLayout, ModuleKanBanLayout,
ModuleListLayout, ModuleListLayout,
@ -46,22 +47,28 @@ export const ModuleLayoutRoot: React.FC = observer(() => {
const activeLayout = issueFilterStore.userDisplayFilters.layout; const activeLayout = issueFilterStore.userDisplayFilters.layout;
const issueCount = moduleIssueStore.getIssuesCount;
return ( return (
<div className="relative w-full h-full flex flex-col overflow-hidden"> <div className="relative w-full h-full flex flex-col overflow-hidden">
<ModuleAppliedFiltersRoot /> <ModuleAppliedFiltersRoot />
<div className="h-full w-full overflow-auto"> {(activeLayout === "list" || activeLayout === "spreadsheet") && issueCount === 0 ? (
{activeLayout === "list" ? ( <ModuleEmptyState />
<ModuleListLayout /> ) : (
) : activeLayout === "kanban" ? ( <div className="h-full w-full overflow-auto">
<ModuleKanBanLayout /> {activeLayout === "list" ? (
) : activeLayout === "calendar" ? ( <ModuleListLayout />
<ModuleCalendarLayout /> ) : activeLayout === "kanban" ? (
) : activeLayout === "gantt_chart" ? ( <ModuleKanBanLayout />
<ModuleGanttLayout /> ) : activeLayout === "calendar" ? (
) : activeLayout === "spreadsheet" ? ( <ModuleCalendarLayout />
<ModuleSpreadsheetLayout /> ) : activeLayout === "gantt_chart" ? (
) : null} <ModuleGanttLayout />
</div> ) : activeLayout === "spreadsheet" ? (
<ModuleSpreadsheetLayout />
) : null}
</div>
)}
</div> </div>
); );
}); });

View File

@ -12,6 +12,7 @@ import {
KanBanLayout, KanBanLayout,
ProjectAppliedFiltersRoot, ProjectAppliedFiltersRoot,
ProjectSpreadsheetLayout, ProjectSpreadsheetLayout,
ProjectEmptyState,
} from "components/issues"; } from "components/issues";
export const ProjectLayoutRoot: React.FC = observer(() => { export const ProjectLayoutRoot: React.FC = observer(() => {
@ -30,22 +31,28 @@ export const ProjectLayoutRoot: React.FC = observer(() => {
const activeLayout = issueFilterStore.userDisplayFilters.layout; const activeLayout = issueFilterStore.userDisplayFilters.layout;
const issueCount = issueStore.getIssuesCount;
return ( return (
<div className="relative w-full h-full flex flex-col overflow-hidden"> <div className="relative w-full h-full flex flex-col overflow-hidden">
<ProjectAppliedFiltersRoot /> <ProjectAppliedFiltersRoot />
<div className="w-full h-full overflow-auto"> {(activeLayout === "list" || activeLayout === "spreadsheet") && issueCount === 0 ? (
{activeLayout === "list" ? ( <ProjectEmptyState />
<ListLayout /> ) : (
) : activeLayout === "kanban" ? ( <div className="w-full h-full overflow-auto">
<KanBanLayout /> {activeLayout === "list" ? (
) : activeLayout === "calendar" ? ( <ListLayout />
<CalendarLayout /> ) : activeLayout === "kanban" ? (
) : activeLayout === "gantt_chart" ? ( <KanBanLayout />
<GanttLayout /> ) : activeLayout === "calendar" ? (
) : activeLayout === "spreadsheet" ? ( <CalendarLayout />
<ProjectSpreadsheetLayout /> ) : activeLayout === "gantt_chart" ? (
) : null} <GanttLayout />
</div> ) : activeLayout === "spreadsheet" ? (
<ProjectSpreadsheetLayout />
) : null}
</div>
)}
</div> </div>
); );
}); });

View File

@ -11,6 +11,7 @@ import {
ModuleListLayout, ModuleListLayout,
ProjectViewAppliedFiltersRoot, ProjectViewAppliedFiltersRoot,
ProjectViewCalendarLayout, ProjectViewCalendarLayout,
ProjectViewEmptyState,
ProjectViewGanttLayout, ProjectViewGanttLayout,
ProjectViewSpreadsheetLayout, ProjectViewSpreadsheetLayout,
} from "components/issues"; } from "components/issues";
@ -48,22 +49,28 @@ export const ProjectViewLayoutRoot: React.FC = observer(() => {
const activeLayout = issueFilterStore.userDisplayFilters.layout; const activeLayout = issueFilterStore.userDisplayFilters.layout;
const issueCount = projectViewIssuesStore.getIssuesCount;
return ( return (
<div className="relative h-full w-full flex flex-col overflow-hidden"> <div className="relative h-full w-full flex flex-col overflow-hidden">
<ProjectViewAppliedFiltersRoot /> <ProjectViewAppliedFiltersRoot />
<div className="h-full w-full overflow-y-auto"> {(activeLayout === "list" || activeLayout === "spreadsheet") && issueCount === 0 ? (
{activeLayout === "list" ? ( <ProjectViewEmptyState />
<ModuleListLayout /> ) : (
) : activeLayout === "kanban" ? ( <div className="h-full w-full overflow-y-auto">
<ModuleKanBanLayout /> {activeLayout === "list" ? (
) : activeLayout === "calendar" ? ( <ModuleListLayout />
<ProjectViewCalendarLayout /> ) : activeLayout === "kanban" ? (
) : activeLayout === "gantt_chart" ? ( <ModuleKanBanLayout />
<ProjectViewGanttLayout /> ) : activeLayout === "calendar" ? (
) : activeLayout === "spreadsheet" ? ( <ProjectViewCalendarLayout />
<ProjectViewSpreadsheetLayout /> ) : activeLayout === "gantt_chart" ? (
) : null} <ProjectViewGanttLayout />
</div> ) : activeLayout === "spreadsheet" ? (
<ProjectViewSpreadsheetLayout />
) : null}
</div>
)}
</div> </div>
); );
}); });

View File

@ -1,3 +0,0 @@
export * from "./my-issues-select-filters";
export * from "./my-issues-view-options";
export * from "./my-issues-view";

View File

@ -1,213 +0,0 @@
import { useState } from "react";
import { useRouter } from "next/router";
import useSWR from "swr";
// services
import { IssueLabelService } from "services/issue";
// components
import { DateFilterModal } from "components/core";
// ui
import { MultiLevelDropdown } from "components/ui";
// icons
import { PriorityIcon, StateGroupIcon } from "@plane/ui";
// helpers
import { checkIfArraysHaveSameElements } from "helpers/array.helper";
// types
import { IIssueFilterOptions, TStateGroups } from "types";
// fetch-keys
import { WORKSPACE_LABELS } from "constants/fetch-keys";
// constants
import { GROUP_CHOICES, PRIORITIES } from "constants/project";
import { DATE_FILTER_OPTIONS } from "constants/filters";
type Props = {
filters: Partial<IIssueFilterOptions> | any;
onSelect: (option: any) => void;
direction?: "left" | "right";
height?: "sm" | "md" | "rg" | "lg";
};
const issueLabelService = new IssueLabelService();
export const MyIssuesSelectFilters: React.FC<Props> = ({ filters, onSelect, direction = "right", height = "md" }) => {
const [isDateFilterModalOpen, setIsDateFilterModalOpen] = useState(false);
const [dateFilterType, setDateFilterType] = useState<{
title: string;
type: "start_date" | "target_date";
}>({
title: "",
type: "start_date",
});
const [fetchLabels, setFetchLabels] = useState(false);
const router = useRouter();
const { workspaceSlug } = router.query;
const { data: labels } = useSWR(
workspaceSlug && fetchLabels ? WORKSPACE_LABELS(workspaceSlug.toString()) : null,
workspaceSlug && fetchLabels ? () => issueLabelService.getWorkspaceIssueLabels(workspaceSlug.toString()) : null
);
return (
<>
{/* {isDateFilterModalOpen && (
<DateFilterModal
title={dateFilterType.title}
field={dateFilterType.type}
filters={filters as IIssueFilterOptions}
handleClose={() => setIsDateFilterModalOpen(false)}
isOpen={isDateFilterModalOpen}
onSelect={onSelect}
/>
)} */}
<MultiLevelDropdown
label="Filters"
onSelect={onSelect}
direction={direction}
height={height}
options={[
{
id: "priority",
label: "Priority",
value: PRIORITIES,
hasChildren: true,
children: [
...PRIORITIES.map((priority) => ({
id: priority === null ? "null" : priority,
label: (
<div className="flex items-center gap-2 capitalize">
<PriorityIcon priority={priority} /> {priority ?? "None"}
</div>
),
value: {
key: "priority",
value: priority === null ? "null" : priority,
},
selected: filters?.priority?.includes(priority === null ? "null" : priority),
})),
],
},
{
id: "state_group",
label: "State groups",
value: GROUP_CHOICES,
hasChildren: true,
children: [
...Object.keys(GROUP_CHOICES).map((key) => ({
id: key,
label: (
<div className="flex items-center gap-2">
<StateGroupIcon stateGroup={key as TStateGroups} />
{GROUP_CHOICES[key as keyof typeof GROUP_CHOICES]}
</div>
),
value: {
key: "state_group",
value: key,
},
selected: filters?.state?.includes(key),
})),
],
},
{
id: "labels",
label: "Labels",
onClick: () => setFetchLabels(true),
value: labels,
hasChildren: true,
children: labels?.map((label) => ({
id: label.id,
label: (
<div className="flex items-center gap-2">
<div
className="h-2 w-2 rounded-full"
style={{
backgroundColor: label.color && label.color !== "" ? label.color : "#000000",
}}
/>
{label.name}
</div>
),
value: {
key: "labels",
value: label.id,
},
selected: filters?.labels?.includes(label.id),
})),
},
{
id: "start_date",
label: "Start date",
value: DATE_FILTER_OPTIONS,
hasChildren: true,
children: [
...(DATE_FILTER_OPTIONS?.map((option) => ({
id: option.name,
label: option.name,
value: {
key: "start_date",
value: option.value,
},
selected: checkIfArraysHaveSameElements(filters?.start_date ?? [], [option.value]),
})) ?? []),
{
id: "custom",
label: "Custom",
value: "custom",
element: (
<button
onClick={() => {
setIsDateFilterModalOpen(true);
setDateFilterType({
title: "Start date",
type: "start_date",
});
}}
className="w-full rounded px-1 py-1.5 text-left text-custom-text-200 hover:bg-custom-background-80"
>
Custom
</button>
),
},
],
},
{
id: "target_date",
label: "Due date",
value: DATE_FILTER_OPTIONS,
hasChildren: true,
children: [
...(DATE_FILTER_OPTIONS?.map((option) => ({
id: option.name,
label: option.name,
value: {
key: "target_date",
value: option.value,
},
selected: checkIfArraysHaveSameElements(filters?.target_date ?? [], [option.value]),
})) ?? []),
{
id: "custom",
label: "Custom",
value: "custom",
element: (
<button
onClick={() => {
setIsDateFilterModalOpen(true);
setDateFilterType({
title: "Due date",
type: "target_date",
});
}}
className="w-full rounded px-1 py-1.5 text-left text-custom-text-200 hover:bg-custom-background-80"
>
Custom
</button>
),
},
],
},
]}
/>
</>
);
};

View File

@ -1,105 +0,0 @@
import React from "react";
import { useRouter } from "next/router";
// hooks
import useMyIssuesFilters from "hooks/my-issues/use-my-issues-filter";
// components
import { MyIssuesSelectFilters } from "components/issues";
// ui
import { Tooltip } from "@plane/ui";
// icons
import { List, Sheet } from "lucide-react";
// helpers
import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper";
import { checkIfArraysHaveSameElements } from "helpers/array.helper";
// types
import { TIssueLayouts } from "types";
const issueViewOptions: { type: TIssueLayouts; Icon: any }[] = [
{
type: "list",
Icon: List,
},
{
type: "spreadsheet",
Icon: Sheet,
},
];
export const MyIssuesViewOptions: React.FC = () => {
const router = useRouter();
const { workspaceSlug, globalViewId } = router.query;
const { displayFilters, setDisplayFilters, filters, setFilters } = useMyIssuesFilters(workspaceSlug?.toString());
const workspaceViewPathName = ["workspace-views/all-issues"];
const isWorkspaceViewPath = workspaceViewPathName.some((pathname) => router.pathname.includes(pathname));
const showFilters = isWorkspaceViewPath || globalViewId;
return (
<div className="flex items-center gap-2">
<div className="flex items-center gap-x-1">
{issueViewOptions.map((option) => (
<Tooltip
key={option.type}
tooltipContent={<span className="capitalize">{replaceUnderscoreIfSnakeCase(option.type)} View</span>}
position="bottom"
>
<button
type="button"
className={`grid h-7 w-7 place-items-center rounded p-1 outline-none hover:bg-custom-sidebar-background-100 duration-300 ${
displayFilters?.layout === option.type
? "bg-custom-sidebar-background-100 shadow-sm"
: "text-custom-sidebar-text-200"
}`}
onClick={() => {
setDisplayFilters({ layout: option.type });
if (option.type === "spreadsheet") router.push(`/${workspaceSlug}/workspace-views/all-issues`);
else router.push(`/${workspaceSlug}/workspace-views`);
}}
>
<option.Icon
sx={{
fontSize: 16,
}}
className={option.type === "spreadsheet" ? "h-4 w-4" : ""}
/>
</button>
</Tooltip>
))}
</div>
{showFilters && (
<MyIssuesSelectFilters
filters={filters}
onSelect={(option) => {
const key = option.key as keyof typeof filters;
if (key === "start_date" || key === "target_date") {
const valueExists = checkIfArraysHaveSameElements(filters?.[key] ?? [], option.value);
setFilters({
[key]: valueExists ? null : option.value,
});
} else {
const valueExists = filters[key]?.includes(option.value);
if (valueExists)
setFilters({
[option.key]: ((filters[key] ?? []) as any[])?.filter((val) => val !== option.value),
});
else
setFilters({
[option.key]: [...((filters[key] ?? []) as any[]), option.value],
});
}
}}
direction="left"
height="rg"
/>
)}
</div>
);
};

View File

@ -1,296 +0,0 @@
import { useState } from "react";
import { useRouter } from "next/router";
import useSWR from "swr";
// services
import { IssueLabelService } from "services/issue";
// hooks
import useMyIssues from "hooks/my-issues/use-my-issues";
import useMyIssuesFilters from "hooks/my-issues/use-my-issues-filter";
// components
import { FiltersList } from "components/core";
import { CreateUpdateIssueModal, DeleteIssueModal } from "components/issues";
// types
import { IIssue, IIssueFilterOptions } from "types";
// fetch-keys
import { WORKSPACE_LABELS } from "constants/fetch-keys";
type Props = {
openIssuesListModal?: () => void;
disableUserActions?: false;
};
const issueLabelService = new IssueLabelService();
export const MyIssuesView: React.FC<Props> = () => {
// create issue modal
const [createIssueModal, setCreateIssueModal] = useState(false);
const [preloadedData] = useState<(Partial<IIssue> & { actionType: "createIssue" | "edit" | "delete" }) | undefined>(
undefined
);
// update issue modal
const [editIssueModal, setEditIssueModal] = useState(false);
const [issueToEdit] = useState<(IIssue & { actionType: "edit" | "delete" }) | undefined>(undefined);
// delete issue modal
const [deleteIssueModal, setDeleteIssueModal] = useState(false);
const [issueToDelete] = useState<IIssue | null>(null);
// trash box
// const [trashBox, setTrashBox] = useState(false);
const router = useRouter();
const { workspaceSlug } = router.query;
const { mutateMyIssues } = useMyIssues(workspaceSlug?.toString());
const { filters, setFilters } = useMyIssuesFilters(workspaceSlug?.toString());
const { data: labels } = useSWR(
workspaceSlug && (filters?.labels ?? []).length > 0 ? WORKSPACE_LABELS(workspaceSlug.toString()) : null,
workspaceSlug && (filters?.labels ?? []).length > 0
? () => issueLabelService.getWorkspaceIssueLabels(workspaceSlug.toString())
: null
);
// const handleDeleteIssue = useCallback(
// (issue: IIssue) => {
// setDeleteIssueModal(true);
// setIssueToDelete(issue);
// },
// [setDeleteIssueModal, setIssueToDelete]
// );
// const handleOnDragEnd = useCallback(
// async (result: DropResult) => {
// setTrashBox(false);
// if (!result.destination || !workspaceSlug || !groupedIssues || displayFilters?.group_by !== "priority") return;
// const { source, destination } = result;
// if (source.droppableId === destination.droppableId) return;
// const draggedItem = groupedIssues[source.droppableId][source.index];
// if (!draggedItem) return;
// if (destination.droppableId === "trashBox") handleDeleteIssue(draggedItem);
// else {
// const sourceGroup = source.droppableId;
// const destinationGroup = destination.droppableId;
// draggedItem[displayFilters.group_by] = destinationGroup as TIssuePriorities;
// mutate<{
// [key: string]: IIssue[];
// }>(
// USER_ISSUES(workspaceSlug.toString(), params),
// (prevData) => {
// if (!prevData) return prevData;
// const sourceGroupArray = [...groupedIssues[sourceGroup]];
// const destinationGroupArray = [...groupedIssues[destinationGroup]];
// sourceGroupArray.splice(source.index, 1);
// destinationGroupArray.splice(destination.index, 0, draggedItem);
// return {
// ...prevData,
// [sourceGroup]: orderArrayBy(sourceGroupArray, displayFilters.order_by ?? "-created_at"),
// [destinationGroup]: orderArrayBy(destinationGroupArray, displayFilters.order_by ?? "-created_at"),
// };
// },
// false
// );
// // patch request
// issuesService
// .patchIssue(
// workspaceSlug as string,
// draggedItem.project,
// draggedItem.id,
// {
// priority: draggedItem.priority,
// },
// user
// )
// .catch(() => mutate(USER_ISSUES(workspaceSlug.toString(), params)));
// }
// },
// [displayFilters, groupedIssues, handleDeleteIssue, params, user, workspaceSlug]
// );
// const addIssueToGroup = useCallback(
// (groupTitle: string) => {
// setCreateIssueModal(true);
// let preloadedValue: string | string[] = groupTitle;
// if (displayFilters?.group_by === "labels") {
// if (groupTitle === "None") preloadedValue = [];
// else preloadedValue = [groupTitle];
// }
// if (displayFilters?.group_by)
// setPreloadedData({
// [displayFilters?.group_by]: preloadedValue,
// actionType: "createIssue",
// });
// else setPreloadedData({ actionType: "createIssue" });
// },
// [setCreateIssueModal, setPreloadedData, displayFilters?.group_by]
// );
// const addIssueToDate = useCallback(
// (date: string) => {
// setCreateIssueModal(true);
// setPreloadedData({
// target_date: date,
// actionType: "createIssue",
// });
// },
// [setCreateIssueModal, setPreloadedData]
// );
// const makeIssueCopy = useCallback(
// (issue: IIssue) => {
// setCreateIssueModal(true);
// setPreloadedData({ ...issue, name: `${issue.name} (Copy)`, actionType: "createIssue" });
// },
// [setCreateIssueModal, setPreloadedData]
// );
// const handleEditIssue = useCallback(
// (issue: IIssue) => {
// setEditIssueModal(true);
// setIssueToEdit({
// ...issue,
// actionType: "edit",
// cycle: issue.issue_cycle ? issue.issue_cycle.cycle : null,
// module: issue.issue_module ? issue.issue_module.module : null,
// });
// },
// [setEditIssueModal, setIssueToEdit]
// );
// const handleIssueAction = useCallback(
// (issue: IIssue, action: "copy" | "edit" | "delete" | "updateDraft") => {
// if (action === "copy") makeIssueCopy(issue);
// else if (action === "edit") handleEditIssue(issue);
// else if (action === "delete") handleDeleteIssue(issue);
// },
// [makeIssueCopy, handleEditIssue, handleDeleteIssue]
// );
const filtersToDisplay = { ...filters, assignees: null, created_by: null, subscriber: null };
const nullFilters = Object.keys(filtersToDisplay).filter(
(key) => filtersToDisplay[key as keyof IIssueFilterOptions] === null
);
const areFiltersApplied =
Object.keys(filtersToDisplay).length > 0 && nullFilters.length !== Object.keys(filtersToDisplay).length;
// const isSubscribedIssuesRoute = router.pathname.includes("subscribed");
// const isMySubscribedIssues =
// (filters.subscriber && filters.subscriber.length > 0 && router.pathname.includes("my-issues")) ?? false;
// const disableAddIssueOption = isSubscribedIssuesRoute || isMySubscribedIssues;
return (
<>
<CreateUpdateIssueModal
isOpen={createIssueModal && preloadedData?.actionType === "createIssue"}
handleClose={() => setCreateIssueModal(false)}
prePopulateData={{
...preloadedData,
}}
onSubmit={async () => {
mutateMyIssues();
}}
/>
<CreateUpdateIssueModal
isOpen={editIssueModal && issueToEdit?.actionType !== "delete"}
handleClose={() => setEditIssueModal(false)}
data={issueToEdit}
onSubmit={async () => {
mutateMyIssues();
}}
/>
{issueToDelete && (
<DeleteIssueModal
handleClose={() => setDeleteIssueModal(false)}
isOpen={deleteIssueModal}
data={issueToDelete}
onSubmit={async () => {
mutateMyIssues();
}}
/>
)}
{areFiltersApplied && (
<>
<div className="flex items-center justify-between gap-2 px-5 pt-3 pb-0">
<FiltersList
filters={filtersToDisplay}
setFilters={setFilters}
labels={labels}
members={undefined}
states={undefined}
clearAllFilters={() =>
setFilters({
labels: null,
priority: null,
state_group: null,
start_date: null,
target_date: null,
})
}
/>
</div>
{<div className="mt-3 border-t border-custom-border-200" />}
</>
)}
{/* <AllViews
addIssueToDate={addIssueToDate}
addIssueToGroup={addIssueToGroup}
disableUserActions={disableUserActions}
dragDisabled={displayFilters?.group_by !== "priority"}
emptyState={{
title: filters.assignees
? "You don't have any issue assigned to you yet"
: filters.created_by
? "You have not created any issue yet."
: "You have not subscribed to any issue yet.",
description: "Keep track of your work in a single place.",
primaryButton: filters.subscriber
? undefined
: {
icon: <PlusIcon className="h-4 w-4" />,
text: "New Issue",
onClick: () => {
const e = new KeyboardEvent("keydown", {
key: "c",
});
document.dispatchEvent(e);
},
},
}}
handleOnDragEnd={handleOnDragEnd}
handleIssueAction={handleIssueAction}
openIssuesListModal={openIssuesListModal ? openIssuesListModal : null}
removeIssue={null}
disableAddIssueOption={disableAddIssueOption}
trashBox={trashBox}
setTrashBox={setTrashBox}
viewProps={{
displayFilters,
groupedIssues,
isEmpty,
mutateIssues: mutateMyIssues,
params,
properties,
}}
/> */}
</>
);
};

View File

@ -28,8 +28,8 @@ type Props = {
export const ModuleCardItem: React.FC<Props> = observer((props) => { export const ModuleCardItem: React.FC<Props> = observer((props) => {
const { module } = props; const { module } = props;
const [editModuleModal, setEditModuleModal] = useState(false); const [editModal, setEditModal] = useState(false);
const [moduleDeleteModal, setModuleDeleteModal] = useState(false); const [deleteModal, setDeleteModal] = useState(false);
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId } = router.query; const { workspaceSlug, projectId } = router.query;
@ -38,50 +38,7 @@ export const ModuleCardItem: React.FC<Props> = observer((props) => {
const { module: moduleStore } = useMobxStore(); const { module: moduleStore } = useMobxStore();
const completionPercentage = ((module.completed_issues + module.cancelled_issues) / module.total_issues) * 100; const completionPercentage = (module.completed_issues / module.total_issues) * 100;
const handleAddToFavorites = () => {
if (!workspaceSlug || !projectId) return;
moduleStore.addModuleToFavorites(workspaceSlug.toString(), projectId.toString(), module.id).catch(() => {
setToastAlert({
type: "error",
title: "Error!",
message: "Couldn't add the module to favorites. Please try again.",
});
});
};
const handleRemoveFromFavorites = () => {
if (!workspaceSlug || !projectId) return;
moduleStore.removeModuleFromFavorites(workspaceSlug.toString(), projectId.toString(), module.id).catch(() => {
setToastAlert({
type: "error",
title: "Error!",
message: "Couldn't remove the module from favorites. Please try again.",
});
});
};
const handleCopyText = () => {
copyUrlToClipboard(`${workspaceSlug}/projects/${projectId}/modules/${module.id}`).then(() => {
setToastAlert({
type: "success",
title: "Link Copied!",
message: "Module link copied to clipboard.",
});
});
};
const openModuleOverview = () => {
const { query } = router;
router.push({
pathname: router.pathname,
query: { ...query, peekModule: module.id },
});
};
const endDate = new Date(module.target_date ?? ""); const endDate = new Date(module.target_date ?? "");
const startDate = new Date(module.start_date ?? ""); const startDate = new Date(module.start_date ?? "");
@ -101,23 +58,86 @@ export const ModuleCardItem: React.FC<Props> = observer((props) => {
: `${module.completed_issues}/${module.total_issues} Issues` : `${module.completed_issues}/${module.total_issues} Issues`
: "0 Issue"; : "0 Issue";
const handleAddToFavorites = (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
e.preventDefault();
if (!workspaceSlug || !projectId) return;
moduleStore.addModuleToFavorites(workspaceSlug.toString(), projectId.toString(), module.id).catch(() => {
setToastAlert({
type: "error",
title: "Error!",
message: "Couldn't add the module to favorites. Please try again.",
});
});
};
const handleRemoveFromFavorites = (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
e.preventDefault();
if (!workspaceSlug || !projectId) return;
moduleStore.removeModuleFromFavorites(workspaceSlug.toString(), projectId.toString(), module.id).catch(() => {
setToastAlert({
type: "error",
title: "Error!",
message: "Couldn't remove the module from favorites. Please try again.",
});
});
};
const handleCopyText = (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
e.preventDefault();
copyUrlToClipboard(`${workspaceSlug}/projects/${projectId}/modules/${module.id}`).then(() => {
setToastAlert({
type: "success",
title: "Link Copied!",
message: "Module link copied to clipboard.",
});
});
};
const handleEditModule = (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
setEditModal(true);
};
const handleDeleteModule = (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
setDeleteModal(true);
};
const openModuleOverview = (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
e.preventDefault();
const { query } = router;
router.push({
pathname: router.pathname,
query: { ...query, peekModule: module.id },
});
};
return ( return (
<> <>
{workspaceSlug && projectId && ( {workspaceSlug && projectId && (
<CreateUpdateModuleModal <CreateUpdateModuleModal
isOpen={editModuleModal} isOpen={editModal}
onClose={() => setEditModuleModal(false)} onClose={() => setEditModal(false)}
data={module} data={module}
projectId={projectId.toString()} projectId={projectId.toString()}
workspaceSlug={workspaceSlug.toString()} workspaceSlug={workspaceSlug.toString()}
/> />
)} )}
<DeleteModuleModal data={module} isOpen={moduleDeleteModal} onClose={() => setModuleDeleteModal(false)} /> <DeleteModuleModal data={module} isOpen={deleteModal} onClose={() => setDeleteModal(false)} />
<Link href={`/${workspaceSlug}/projects/${module.project}/modules/${module.id}`}> <Link href={`/${workspaceSlug}/projects/${module.project}/modules/${module.id}`}>
<a className="flex flex-col justify-between p-4 h-44 w-full min-w-[250px] text-sm rounded bg-custom-background-100 border border-custom-border-100 hover:shadow-md"> <a className="flex flex-col justify-between p-4 h-44 w-full min-w-[250px] text-sm rounded bg-custom-background-100 border border-custom-border-100 hover:shadow-md">
<div> <div>
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<Tooltip tooltipContent={module.name} position="auto"> <Tooltip tooltipContent={module.name} position="top">
<span className="text-base font-medium truncate">{module.name}</span> <span className="text-base font-medium truncate">{module.name}</span>
</Tooltip> </Tooltip>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -128,13 +148,7 @@ export const ModuleCardItem: React.FC<Props> = observer((props) => {
{moduleStatus.label} {moduleStatus.label}
</span> </span>
)} )}
<button <button onClick={openModuleOverview}>
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
openModuleOverview();
}}
>
<Info className="h-4 w-4 text-custom-text-400" /> <Info className="h-4 w-4 text-custom-text-400" />
</button> </button>
</div> </div>
@ -184,60 +198,28 @@ export const ModuleCardItem: React.FC<Props> = observer((props) => {
</span> </span>
<div className="flex items-center gap-1.5 z-10"> <div className="flex items-center gap-1.5 z-10">
{module.is_favorite ? ( {module.is_favorite ? (
<button <button type="button" onClick={handleRemoveFromFavorites}>
type="button"
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
handleRemoveFromFavorites();
}}
>
<Star className="h-3.5 w-3.5 text-amber-500 fill-current" /> <Star className="h-3.5 w-3.5 text-amber-500 fill-current" />
</button> </button>
) : ( ) : (
<button <button type="button" onClick={handleAddToFavorites}>
type="button"
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
handleAddToFavorites();
}}
>
<Star className="h-3.5 w-3.5 text-custom-text-200" /> <Star className="h-3.5 w-3.5 text-custom-text-200" />
</button> </button>
)} )}
<CustomMenu width="auto" ellipsis className="z-10"> <CustomMenu width="auto" ellipsis className="z-10">
<CustomMenu.MenuItem <CustomMenu.MenuItem onClick={handleEditModule}>
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setEditModuleModal(true);
}}
>
<span className="flex items-center justify-start gap-2"> <span className="flex items-center justify-start gap-2">
<Pencil className="h-3 w-3" /> <Pencil className="h-3 w-3" />
<span>Edit module</span> <span>Edit module</span>
</span> </span>
</CustomMenu.MenuItem> </CustomMenu.MenuItem>
<CustomMenu.MenuItem <CustomMenu.MenuItem onClick={handleDeleteModule}>
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setModuleDeleteModal(true);
}}
>
<span className="flex items-center justify-start gap-2"> <span className="flex items-center justify-start gap-2">
<Trash2 className="h-3 w-3" /> <Trash2 className="h-3 w-3" />
<span>Delete module</span> <span>Delete module</span>
</span> </span>
</CustomMenu.MenuItem> </CustomMenu.MenuItem>
<CustomMenu.MenuItem <CustomMenu.MenuItem onClick={handleCopyText}>
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleCopyText();
}}
>
<span className="flex items-center justify-start gap-2"> <span className="flex items-center justify-start gap-2">
<LinkIcon className="h-3 w-3" /> <LinkIcon className="h-3 w-3" />
<span>Copy module link</span> <span>Copy module link</span>

View File

@ -28,8 +28,8 @@ type Props = {
export const ModuleListItem: React.FC<Props> = observer((props) => { export const ModuleListItem: React.FC<Props> = observer((props) => {
const { module } = props; const { module } = props;
const [editModuleModal, setEditModuleModal] = useState(false); const [editModal, setEditModal] = useState(false);
const [moduleDeleteModal, setModuleDeleteModal] = useState(false); const [deleteModal, setDeleteModal] = useState(false);
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId } = router.query; const { workspaceSlug, projectId } = router.query;
@ -40,40 +40,6 @@ export const ModuleListItem: React.FC<Props> = observer((props) => {
const completionPercentage = ((module.completed_issues + module.cancelled_issues) / module.total_issues) * 100; const completionPercentage = ((module.completed_issues + module.cancelled_issues) / module.total_issues) * 100;
const handleAddToFavorites = () => {
if (!workspaceSlug || !projectId) return;
moduleStore.addModuleToFavorites(workspaceSlug.toString(), projectId.toString(), module.id).catch(() => {
setToastAlert({
type: "error",
title: "Error!",
message: "Couldn't add the module to favorites. Please try again.",
});
});
};
const handleRemoveFromFavorites = () => {
if (!workspaceSlug || !projectId) return;
moduleStore.removeModuleFromFavorites(workspaceSlug.toString(), projectId.toString(), module.id).catch(() => {
setToastAlert({
type: "error",
title: "Error!",
message: "Couldn't remove the module from favorites. Please try again.",
});
});
};
const handleCopyText = () => {
copyUrlToClipboard(`${workspaceSlug}/projects/${projectId}/modules/${module.id}`).then(() => {
setToastAlert({
type: "success",
title: "Link Copied!",
message: "Module link copied to clipboard.",
});
});
};
const endDate = new Date(module.target_date ?? ""); const endDate = new Date(module.target_date ?? "");
const startDate = new Date(module.start_date ?? ""); const startDate = new Date(module.start_date ?? "");
@ -87,7 +53,61 @@ export const ModuleListItem: React.FC<Props> = observer((props) => {
const completedModuleCheck = module.status === "completed" && module.total_issues - module.completed_issues; const completedModuleCheck = module.status === "completed" && module.total_issues - module.completed_issues;
const openModuleOverview = () => { const handleAddToFavorites = (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
e.preventDefault();
if (!workspaceSlug || !projectId) return;
moduleStore.addModuleToFavorites(workspaceSlug.toString(), projectId.toString(), module.id).catch(() => {
setToastAlert({
type: "error",
title: "Error!",
message: "Couldn't add the module to favorites. Please try again.",
});
});
};
const handleRemoveFromFavorites = (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
e.preventDefault();
if (!workspaceSlug || !projectId) return;
moduleStore.removeModuleFromFavorites(workspaceSlug.toString(), projectId.toString(), module.id).catch(() => {
setToastAlert({
type: "error",
title: "Error!",
message: "Couldn't remove the module from favorites. Please try again.",
});
});
};
const handleCopyText = (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
e.preventDefault();
copyUrlToClipboard(`${workspaceSlug}/projects/${projectId}/modules/${module.id}`).then(() => {
setToastAlert({
type: "success",
title: "Link Copied!",
message: "Module link copied to clipboard.",
});
});
};
const handleEditModule = (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
setEditModal(true);
};
const handleDeleteModule = (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
setDeleteModal(true);
};
const openModuleOverview = (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
e.preventDefault();
const { query } = router; const { query } = router;
router.push({ router.push({
@ -100,14 +120,14 @@ export const ModuleListItem: React.FC<Props> = observer((props) => {
<> <>
{workspaceSlug && projectId && ( {workspaceSlug && projectId && (
<CreateUpdateModuleModal <CreateUpdateModuleModal
isOpen={editModuleModal} isOpen={editModal}
onClose={() => setEditModuleModal(false)} onClose={() => setEditModal(false)}
data={module} data={module}
projectId={projectId.toString()} projectId={projectId.toString()}
workspaceSlug={workspaceSlug.toString()} workspaceSlug={workspaceSlug.toString()}
/> />
)} )}
<DeleteModuleModal data={module} isOpen={moduleDeleteModal} onClose={() => setModuleDeleteModal(false)} /> <DeleteModuleModal data={module} isOpen={deleteModal} onClose={() => setDeleteModal(false)} />
<Link href={`/${workspaceSlug}/projects/${module.project}/modules/${module.id}`}> <Link href={`/${workspaceSlug}/projects/${module.project}/modules/${module.id}`}>
<a className="group flex items-center justify-between gap-5 px-10 py-6 h-16 w-full text-sm bg-custom-background-100 border-b border-custom-border-100 hover:bg-custom-background-90"> <a className="group flex items-center justify-between gap-5 px-10 py-6 h-16 w-full text-sm bg-custom-background-100 border-b border-custom-border-100 hover:bg-custom-background-90">
<div className="flex items-center gap-3 w-full truncate"> <div className="flex items-center gap-3 w-full truncate">
@ -123,18 +143,11 @@ export const ModuleListItem: React.FC<Props> = observer((props) => {
)} )}
</CircularProgressIndicator> </CircularProgressIndicator>
</span> </span>
<Tooltip tooltipContent={module.name} position="auto"> <Tooltip tooltipContent={module.name} position="top">
<span className="text-base font-medium truncate">{module.name}</span> <span className="text-base font-medium truncate">{module.name}</span>
</Tooltip> </Tooltip>
</div> </div>
<button <button onClick={openModuleOverview} className="flex-shrink-0 hidden group-hover:flex z-10">
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
openModuleOverview();
}}
className="flex-shrink-0 hidden group-hover:flex z-10"
>
<Info className="h-4 w-4 text-custom-text-400" /> <Info className="h-4 w-4 text-custom-text-400" />
</button> </button>
</div> </div>
@ -171,63 +184,29 @@ export const ModuleListItem: React.FC<Props> = observer((props) => {
</Tooltip> </Tooltip>
{module.is_favorite ? ( {module.is_favorite ? (
<button <button type="button" onClick={handleRemoveFromFavorites} className="z-[1]">
type="button"
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
handleRemoveFromFavorites();
}}
className="z-[1]"
>
<Star className="h-3.5 w-3.5 text-amber-500 fill-current" /> <Star className="h-3.5 w-3.5 text-amber-500 fill-current" />
</button> </button>
) : ( ) : (
<button <button type="button" onClick={handleAddToFavorites} className="z-[1]">
type="button"
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
handleAddToFavorites();
}}
className="z-[1]"
>
<Star className="h-3.5 w-3.5 text-custom-text-300" /> <Star className="h-3.5 w-3.5 text-custom-text-300" />
</button> </button>
)} )}
<CustomMenu width="auto" verticalEllipsis buttonClassName="z-[1]"> <CustomMenu width="auto" verticalEllipsis buttonClassName="z-[1]">
<CustomMenu.MenuItem <CustomMenu.MenuItem onClick={handleEditModule}>
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setEditModuleModal(true);
}}
>
<span className="flex items-center justify-start gap-2"> <span className="flex items-center justify-start gap-2">
<Pencil className="h-3 w-3" /> <Pencil className="h-3 w-3" />
<span>Edit module</span> <span>Edit module</span>
</span> </span>
</CustomMenu.MenuItem> </CustomMenu.MenuItem>
<CustomMenu.MenuItem <CustomMenu.MenuItem onClick={handleDeleteModule}>
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setModuleDeleteModal(true);
}}
>
<span className="flex items-center justify-start gap-2"> <span className="flex items-center justify-start gap-2">
<Trash2 className="h-3 w-3" /> <Trash2 className="h-3 w-3" />
<span>Delete module</span> <span>Delete module</span>
</span> </span>
</CustomMenu.MenuItem> </CustomMenu.MenuItem>
<CustomMenu.MenuItem <CustomMenu.MenuItem onClick={handleCopyText}>
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleCopyText();
}}
>
<span className="flex items-center justify-start gap-2"> <span className="flex items-center justify-start gap-2">
<LinkIcon className="h-3 w-3" /> <LinkIcon className="h-3 w-3" />
<span>Copy module link</span> <span>Copy module link</span>

View File

@ -400,7 +400,7 @@ export const ModuleDetailsSidebar: React.FC<Props> = observer((props) => {
}} }}
totalIssues={moduleDetails.total_issues} totalIssues={moduleDetails.total_issues}
module={moduleDetails} module={moduleDetails}
isPeekModuleDetails={Boolean(peekModule)} isPeekView={Boolean(peekModule)}
/> />
</div> </div>
)} )}

View File

@ -11,7 +11,7 @@ import { ArchiveIcon, CustomMenu, Tooltip } from "@plane/ui";
import { ArchiveRestore, Clock, MessageSquare, User2 } from "lucide-react"; import { ArchiveRestore, Clock, MessageSquare, User2 } from "lucide-react";
// helper // helper
import { stripHTML, replaceUnderscoreIfSnakeCase, truncateText } from "helpers/string.helper"; import { replaceUnderscoreIfSnakeCase, truncateText, stripAndTruncateHTML } from "helpers/string.helper";
import { import {
formatDateDistance, formatDateDistance,
render12HourFormatTime, render12HourFormatTime,
@ -115,10 +115,10 @@ export const NotificationCard: React.FC<NotificationCardProps> = (props) => {
renderShortDateWithYearFormat(notification.data.issue_activity.new_value) renderShortDateWithYearFormat(notification.data.issue_activity.new_value)
) : notification.data.issue_activity.field === "attachment" ? ( ) : notification.data.issue_activity.field === "attachment" ? (
"the issue" "the issue"
) : stripHTML(notification.data.issue_activity.new_value).length > 55 ? ( ) : notification.data.issue_activity.field === "description" ? (
stripHTML(notification.data.issue_activity.new_value).slice(0, 50) + "..." stripAndTruncateHTML(notification.data.issue_activity.new_value, 55)
) : ( ) : (
stripHTML(notification.data.issue_activity.new_value) notification.data.issue_activity.new_value
) )
) : ( ) : (
<span> <span>

View File

@ -48,7 +48,6 @@ export const Workspace: React.FC<Props> = ({ finishOnboarding, stepChange, updat
onSubmit={completeStep} onSubmit={completeStep}
defaultValues={defaultValues} defaultValues={defaultValues}
setDefaultValues={setDefaultValues} setDefaultValues={setDefaultValues}
user={user}
primaryButtonText={{ primaryButtonText={{
loading: "Creating...", loading: "Creating...",
default: "Continue", default: "Continue",

View File

@ -1,6 +1,5 @@
export * from "./overview"; export * from "./overview";
export * from "./navbar"; export * from "./navbar";
export * from "./profile-issues-view-options";
export * from "./profile-issues-view"; export * from "./profile-issues-view";
export * from "./sidebar"; export * from "./sidebar";

View File

@ -1,282 +0,0 @@
import React from "react";
import { useRouter } from "next/router";
// headless ui
import { Popover, Transition } from "@headlessui/react";
// hooks
import useProfileIssues from "hooks/use-profile-issues";
import useEstimateOption from "hooks/use-estimate-option";
// components
import { MyIssuesSelectFilters } from "components/issues";
// ui
import { CustomMenu, ToggleSwitch, Tooltip } from "@plane/ui";
// icons
import { ChevronDown, Kanban, List } from "lucide-react";
// helpers
import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper";
import { checkIfArraysHaveSameElements } from "helpers/array.helper";
// types
import { Properties, TIssueLayouts } from "types";
// constants
import { ISSUE_GROUP_BY_OPTIONS, ISSUE_ORDER_BY_OPTIONS, ISSUE_FILTER_OPTIONS } from "constants/issue";
const issueViewOptions: { type: TIssueLayouts; Icon: any }[] = [
{
type: "list",
Icon: List,
},
{
type: "kanban",
Icon: Kanban,
},
];
export const ProfileIssuesViewOptions: React.FC = () => {
const router = useRouter();
const { workspaceSlug, userId } = router.query;
const { displayFilters, setDisplayFilters, filters, displayProperties, setProperties, setFilters } = useProfileIssues(
workspaceSlug?.toString(),
userId?.toString()
);
const { isEstimateActive } = useEstimateOption();
if (
!router.pathname.includes("assigned") &&
!router.pathname.includes("created") &&
!router.pathname.includes("subscribed")
)
return null;
return (
<div className="flex items-center gap-2">
<div className="flex items-center gap-x-1">
{issueViewOptions.map((option) => (
<Tooltip
key={option.type}
tooltipContent={<span className="capitalize">{replaceUnderscoreIfSnakeCase(option.type)} Layout</span>}
position="bottom"
>
<button
type="button"
className={`grid h-7 w-7 place-items-center rounded p-1 outline-none hover:bg-custom-sidebar-background-80 duration-300 ${
displayFilters?.layout === option.type
? "bg-custom-sidebar-background-80"
: "text-custom-sidebar-text-200"
}`}
onClick={() => setDisplayFilters({ layout: option.type })}
>
<option.Icon className="h-3.5 w-3.5" />
</button>
</Tooltip>
))}
</div>
<MyIssuesSelectFilters
filters={filters}
onSelect={(option) => {
const key = option.key as keyof typeof filters;
if (key === "start_date" || key === "target_date") {
const valueExists = checkIfArraysHaveSameElements(filters?.[key] ?? [], option.value);
setFilters({
[key]: valueExists ? null : option.value,
});
} else {
const valueExists = filters[key]?.includes(option.value);
if (valueExists)
setFilters({
[option.key]: ((filters[key] ?? []) as any[])?.filter((val) => val !== option.value),
});
else
setFilters({
[option.key]: [...((filters[key] ?? []) as any[]), option.value],
});
}
}}
direction="left"
height="rg"
/>
<Popover className="relative">
{({ open }) => (
<>
<Popover.Button
className={`group flex items-center gap-2 rounded-md border border-custom-border-200 bg-transparent px-3 py-1.5 text-xs hover:bg-custom-sidebar-background-90 hover:text-custom-sidebar-text-100 focus:outline-none duration-300 ${
open ? "bg-custom-sidebar-background-90 text-custom-sidebar-text-100" : "text-custom-sidebar-text-200"
}`}
>
Display
<ChevronDown className="h-3 w-3" aria-hidden="true" />
</Popover.Button>
<Transition
as={React.Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<Popover.Panel className="absolute right-0 z-30 mt-1 w-screen max-w-xs transform rounded-lg border border-custom-border-200 bg-custom-background-90 p-3 shadow-lg">
<div className="relative divide-y-2 divide-custom-border-200">
<div className="space-y-4 pb-3 text-xs">
{displayFilters?.layout !== "calendar" && displayFilters?.layout !== "spreadsheet" && (
<>
<div className="flex items-center justify-between">
<h4 className="text-custom-text-200">Group by</h4>
<div className="w-28">
<CustomMenu
label={
displayFilters?.group_by === "project"
? "Project"
: ISSUE_GROUP_BY_OPTIONS.find((option) => option.key === displayFilters?.group_by)
?.title ?? "Select"
}
className="!w-full"
buttonClassName="w-full"
>
{ISSUE_GROUP_BY_OPTIONS.map((option) => {
if (displayFilters?.layout === "kanban" && option.key === null) return null;
if (option.key === "state" || option.key === "created_by" || option.key === "assignees")
return null;
return (
<CustomMenu.MenuItem
key={option.key}
onClick={() => setDisplayFilters({ group_by: option.key })}
>
{option.title}
</CustomMenu.MenuItem>
);
})}
</CustomMenu>
</div>
</div>
<div className="flex items-center justify-between">
<h4 className="text-custom-text-200">Order by</h4>
<div className="w-28">
<CustomMenu
label={
ISSUE_ORDER_BY_OPTIONS.find((option) => option.key === displayFilters?.order_by)
?.title ?? "Select"
}
className="!w-full"
buttonClassName="w-full"
>
{ISSUE_ORDER_BY_OPTIONS.map((option) => {
if (displayFilters?.group_by === "priority" && option.key === "priority") return null;
if (option.key === "sort_order") return null;
return (
<CustomMenu.MenuItem
key={option.key}
onClick={() => {
setDisplayFilters({ order_by: option.key });
}}
>
{option.title}
</CustomMenu.MenuItem>
);
})}
</CustomMenu>
</div>
</div>
</>
)}
<div className="flex items-center justify-between">
<h4 className="text-custom-text-200">Issue type</h4>
<div className="w-28">
<CustomMenu
label={
<span className="truncate">
{ISSUE_FILTER_OPTIONS.find((option) => option.key === displayFilters?.type)?.title ??
"Select"}
</span>
}
className="!w-full"
buttonClassName="w-full"
>
{ISSUE_FILTER_OPTIONS.map((option) => (
<CustomMenu.MenuItem
key={option.key}
onClick={() =>
setDisplayFilters({
type: option.key,
})
}
>
{option.title}
</CustomMenu.MenuItem>
))}
</CustomMenu>
</div>
</div>
{displayFilters?.layout !== "calendar" && displayFilters?.layout !== "spreadsheet" && (
<>
<div className="flex items-center justify-between">
<h4 className="text-custom-text-200">Show empty states</h4>
<div className="w-28">
<ToggleSwitch
value={displayFilters?.show_empty_groups ?? true}
onChange={() =>
setDisplayFilters({
show_empty_groups: !displayFilters?.show_empty_groups,
})
}
/>
</div>
</div>
</>
)}
</div>
<div className="space-y-2 py-3">
<h4 className="text-sm text-custom-text-200">Display Properties</h4>
<div className="flex flex-wrap items-center gap-2 text-custom-text-200">
{displayProperties &&
Object.keys(displayProperties).map((key) => {
if (key === "estimate" && !isEstimateActive) return null;
if (
displayFilters?.layout === "spreadsheet" &&
(key === "attachment_count" || key === "link" || key === "sub_issue_count")
)
return null;
if (
displayFilters?.layout !== "spreadsheet" &&
(key === "created_on" || key === "updated_on")
)
return null;
return (
<button
key={key}
type="button"
className={`rounded border px-2 py-1 text-xs capitalize ${
displayProperties[key as keyof Properties]
? "border-custom-primary bg-custom-primary text-white"
: "border-custom-border-200"
}`}
onClick={() => setProperties(key as keyof Properties)}
>
{key === "key" ? "ID" : replaceUnderscoreIfSnakeCase(key)}
</button>
);
})}
</div>
</div>
</div>
</Popover.Panel>
</Transition>
</>
)}
</Popover>
</div>
);
};

View File

@ -1,29 +1,37 @@
import React, { useState } from "react"; import React, { useState } from "react";
// headless ui import { observer } from "mobx-react-lite";
import { Dialog, Transition } from "@headlessui/react"; import { Dialog, Transition } from "@headlessui/react";
// icons
import { AlertTriangle } from "lucide-react"; import { AlertTriangle } from "lucide-react";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
// ui // ui
import { Button } from "@plane/ui"; import { Button } from "@plane/ui";
type Props = { type Props = {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
handleDelete: () => void; onSubmit: () => Promise<void>;
data?: any; data?: any;
}; };
const ConfirmWorkspaceMemberRemove: React.FC<Props> = ({ isOpen, onClose, data, handleDelete }) => { export const ConfirmWorkspaceMemberRemove: React.FC<Props> = observer((props) => {
const [isDeleteLoading, setIsDeleteLoading] = useState(false); const { isOpen, onClose, data, onSubmit } = props;
const [isRemoving, setIsRemoving] = useState(false);
const { user: userStore } = useMobxStore();
const user = userStore.currentUser;
const handleClose = () => { const handleClose = () => {
onClose(); onClose();
setIsDeleteLoading(false); setIsRemoving(false);
}; };
const handleDeletion = async () => { const handleDeletion = async () => {
setIsDeleteLoading(true); setIsRemoving(true);
handleDelete();
await onSubmit();
handleClose(); handleClose();
}; };
@ -61,14 +69,21 @@ const ConfirmWorkspaceMemberRemove: React.FC<Props> = ({ isOpen, onClose, data,
</div> </div>
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"> <div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-custom-text-100"> <Dialog.Title as="h3" className="text-lg font-medium leading-6 text-custom-text-100">
Remove {data?.display_name}? {user?.id === data?.memberId ? "Leave workspace?" : `Remove ${data?.display_name}?`}
</Dialog.Title> </Dialog.Title>
<div className="mt-2"> <div className="mt-2">
<p className="text-sm text-custom-text-200"> {user?.id === data?.memberId ? (
Are you sure you want to remove member-{" "} <p className="text-sm text-custom-text-200">
<span className="font-bold">{data?.display_name}</span>? They will no longer have access to Are you sure you want to leave the workspace? You will no longer have access to this
this workspace. This action cannot be undone. workspace. This action cannot be undone.
</p> </p>
) : (
<p className="text-sm text-custom-text-200">
Are you sure you want to remove member-{" "}
<span className="font-bold">{data?.display_name}</span>? They will no longer have access to
this workspace. This action cannot be undone.
</p>
)}
</div> </div>
</div> </div>
</div> </div>
@ -77,8 +92,8 @@ const ConfirmWorkspaceMemberRemove: React.FC<Props> = ({ isOpen, onClose, data,
<Button variant="neutral-primary" onClick={handleClose}> <Button variant="neutral-primary" onClick={handleClose}>
Cancel Cancel
</Button> </Button>
<Button variant="danger" onClick={handleDeletion} loading={isDeleteLoading}> <Button variant="danger" onClick={handleDeletion} loading={isRemoving}>
{isDeleteLoading ? "Removing..." : "Remove"} {isRemoving ? "Removing..." : "Remove"}
</Button> </Button>
</div> </div>
</Dialog.Panel> </Dialog.Panel>
@ -88,6 +103,4 @@ const ConfirmWorkspaceMemberRemove: React.FC<Props> = ({ isOpen, onClose, data,
</Dialog> </Dialog>
</Transition.Root> </Transition.Root>
); );
}; });
export default ConfirmWorkspaceMemberRemove;

View File

@ -1,7 +1,10 @@
import { Dispatch, SetStateAction, useEffect, useState, FC } from "react"; import { Dispatch, SetStateAction, useEffect, useState, FC } from "react";
import { mutate } from "swr";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
import { mutate } from "swr";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
// services // services
import { WorkspaceService } from "services/workspace.service"; import { WorkspaceService } from "services/workspace.service";
// hooks // hooks
@ -9,7 +12,7 @@ import useToast from "hooks/use-toast";
// ui // ui
import { Button, CustomSelect, Input } from "@plane/ui"; import { Button, CustomSelect, Input } from "@plane/ui";
// types // types
import { IUser, IWorkspace } from "types"; import { IWorkspace } from "types";
// fetch-keys // fetch-keys
import { USER_WORKSPACES } from "constants/fetch-keys"; import { USER_WORKSPACES } from "constants/fetch-keys";
// constants // constants
@ -23,7 +26,6 @@ type Props = {
organization_size: string; organization_size: string;
}; };
setDefaultValues: Dispatch<SetStateAction<any>>; setDefaultValues: Dispatch<SetStateAction<any>>;
user: IUser | undefined;
secondaryButton?: React.ReactNode; secondaryButton?: React.ReactNode;
primaryButtonText?: { primaryButtonText?: {
loading: string; loading: string;
@ -49,23 +51,27 @@ const restrictedUrls = [
const workspaceService = new WorkspaceService(); const workspaceService = new WorkspaceService();
export const CreateWorkspaceForm: FC<Props> = ({ export const CreateWorkspaceForm: FC<Props> = observer((props) => {
onSubmit, const {
defaultValues, onSubmit,
setDefaultValues, defaultValues,
user, setDefaultValues,
secondaryButton, secondaryButton,
primaryButtonText = { primaryButtonText = {
loading: "Creating...", loading: "Creating...",
default: "Create Workspace", default: "Create Workspace",
}, },
}) => { } = props;
const [slugError, setSlugError] = useState(false); const [slugError, setSlugError] = useState(false);
const [invalidSlug, setInvalidSlug] = useState(false); const [invalidSlug, setInvalidSlug] = useState(false);
const { setToastAlert } = useToast();
const router = useRouter(); const router = useRouter();
const { workspace: workspaceStore } = useMobxStore();
const { setToastAlert } = useToast();
const { const {
handleSubmit, handleSubmit,
control, control,
@ -81,8 +87,8 @@ export const CreateWorkspaceForm: FC<Props> = ({
if (res.status === true && !restrictedUrls.includes(formData.slug)) { if (res.status === true && !restrictedUrls.includes(formData.slug)) {
setSlugError(false); setSlugError(false);
await workspaceService await workspaceStore
.createWorkspace(formData, user) .createWorkspace(formData)
.then(async (res) => { .then(async (res) => {
setToastAlert({ setToastAlert({
type: "success", type: "success",
@ -157,7 +163,7 @@ export const CreateWorkspaceForm: FC<Props> = ({
</div> </div>
<div className="space-y-1 text-sm"> <div className="space-y-1 text-sm">
<label htmlFor="workspaceUrl">Workspace URL</label> <label htmlFor="workspaceUrl">Workspace URL</label>
<div className="flex w-full items-center rounded-md border border-custom-border-200 px-3"> <div className="flex w-full items-center rounded-md border-[0.5px] border-custom-border-200 px-3">
<span className="whitespace-nowrap text-sm text-custom-text-200">{window && window.location.host}/</span> <span className="whitespace-nowrap text-sm text-custom-text-200">{window && window.location.host}/</span>
<Controller <Controller
control={control} control={control}
@ -200,9 +206,10 @@ export const CreateWorkspaceForm: FC<Props> = ({
onChange={onChange} onChange={onChange}
label={ label={
ORGANIZATION_SIZE.find((c) => c === value) ?? ( ORGANIZATION_SIZE.find((c) => c === value) ?? (
<span className="text-custom-text-200">Select organization size</span> <span className="text-custom-text-400">Select organization size</span>
) )
} }
buttonClassName="!border-[0.5px] !border-custom-border-200 !shadow-none"
input input
width="w-full" width="w-full"
> >
@ -232,4 +239,4 @@ export const CreateWorkspaceForm: FC<Props> = ({
</div> </div>
</form> </form>
); );
}; });

View File

@ -1,31 +1,22 @@
import React from "react"; import React from "react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
import { mutate } from "swr";
// react-hook-form
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
// headless ui
import { Dialog, Transition } from "@headlessui/react"; import { Dialog, Transition } from "@headlessui/react";
// services import { AlertTriangle } from "lucide-react";
import { WorkspaceService } from "services/workspace.service"; // mobx store
import { useMobxStore } from "lib/mobx/store-provider";
// hooks // hooks
import useToast from "hooks/use-toast"; import useToast from "hooks/use-toast";
// icons
import { AlertTriangle } from "lucide-react";
// ui // ui
import { Button, Input } from "@plane/ui"; import { Button, Input } from "@plane/ui";
// types // types
import type { IUser, IWorkspace } from "types"; import type { IWorkspace } from "types";
// fetch-keys
import { USER_WORKSPACES } from "constants/fetch-keys";
type Props = { type Props = {
isOpen: boolean; isOpen: boolean;
data: IWorkspace | null; data: IWorkspace | null;
onClose: () => void; onClose: () => void;
user: IUser | undefined;
}; };
const defaultValues = { const defaultValues = {
@ -33,12 +24,13 @@ const defaultValues = {
confirmDelete: "", confirmDelete: "",
}; };
// services export const DeleteWorkspaceModal: React.FC<Props> = observer((props) => {
const workspaceService = new WorkspaceService(); const { isOpen, data, onClose } = props;
export const DeleteWorkspaceModal: React.FC<Props> = ({ isOpen, data, onClose, user }) => {
const router = useRouter(); const router = useRouter();
const { workspace: workspaceStore } = useMobxStore();
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();
const { const {
@ -63,15 +55,13 @@ export const DeleteWorkspaceModal: React.FC<Props> = ({ isOpen, data, onClose, u
const onSubmit = async () => { const onSubmit = async () => {
if (!data || !canDelete) return; if (!data || !canDelete) return;
await workspaceService await workspaceStore
.deleteWorkspace(data.slug, user) .deleteWorkspace(data.slug)
.then(() => { .then(() => {
handleClose(); handleClose();
router.push("/"); router.push("/");
mutate<IWorkspace[]>(USER_WORKSPACES, (prevData) => prevData?.filter((workspace) => workspace.id !== data.id));
setToastAlert({ setToastAlert({
type: "success", type: "success",
title: "Success!", title: "Success!",
@ -196,4 +186,4 @@ export const DeleteWorkspaceModal: React.FC<Props> = ({ isOpen, data, onClose, u
</Dialog> </Dialog>
</Transition.Root> </Transition.Root>
); );
}; });

View File

@ -1,13 +1,17 @@
export * from "./settings";
export * from "./views"; export * from "./views";
export * from "./activity-graph"; export * from "./activity-graph";
export * from "./completed-issues-graph"; export * from "./completed-issues-graph";
export * from "./confirm-workspace-member-remove";
export * from "./create-workspace-form"; export * from "./create-workspace-form";
export * from "./delete-workspace-modal"; export * from "./delete-workspace-modal";
export * from "./help-section"; export * from "./help-section";
export * from "./issues-list"; export * from "./issues-list";
export * from "./issues-pie-chart"; export * from "./issues-pie-chart";
export * from "./issues-stats"; export * from "./issues-stats";
export * from "./member-select";
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 "./member-select"; export * from "./single-invitation";

View File

@ -14,14 +14,15 @@ import { Plus, X } from "lucide-react";
import { IUser } from "types"; import { IUser } from "types";
// constants // constants
import { ROLE } from "constants/workspace"; import { ROLE } from "constants/workspace";
// fetch-keys
import { WORKSPACE_INVITATIONS } from "constants/fetch-keys"; import { WORKSPACE_INVITATIONS } from "constants/fetch-keys";
type Props = { type Props = {
isOpen: boolean; isOpen: boolean;
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>; onClose: () => void;
workspace_slug: string; workspaceSlug: string;
user: IUser | undefined; user: IUser | undefined;
onSuccess: () => void; onSuccess?: () => Promise<void>;
}; };
type EmailRole = { type EmailRole = {
@ -44,8 +45,9 @@ const defaultValues: FormValues = {
const workspaceService = new WorkspaceService(); const workspaceService = new WorkspaceService();
const SendWorkspaceInvitationModal: React.FC<Props> = (props) => { export const SendWorkspaceInvitationModal: React.FC<Props> = (props) => {
const { isOpen, setIsOpen, workspace_slug, user, onSuccess } = props; const { isOpen, onClose, workspaceSlug, user, onSuccess } = props;
const { const {
control, control,
reset, reset,
@ -61,42 +63,38 @@ const SendWorkspaceInvitationModal: React.FC<Props> = (props) => {
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();
const handleClose = () => { const handleClose = () => {
setIsOpen(false); onClose();
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
reset(defaultValues); reset(defaultValues);
clearTimeout(timeout); clearTimeout(timeout);
}, 500); }, 350);
}; };
const onSubmit = async (formData: FormValues) => { const onSubmit = async (formData: FormValues) => {
if (!workspace_slug) return; if (!workspaceSlug) return;
const payload = { ...formData };
await workspaceService await workspaceService
.inviteWorkspace(workspace_slug, payload, user) .inviteWorkspace(workspaceSlug, formData, user)
.then(async () => { .then(async () => {
setIsOpen(false); if (onSuccess) await onSuccess();
handleClose(); handleClose();
setToastAlert({ setToastAlert({
type: "success", type: "success",
title: "Success!", title: "Success!",
message: "Invitations sent successfully.", message: "Invitations sent successfully.",
}); });
onSuccess();
}) })
.catch((err) => { .catch((err) =>
setToastAlert({ setToastAlert({
type: "error", type: "error",
title: "Error!", title: "Error!",
message: `${err.error}`, message: `${err.error ?? "Something went wrong. Please try again."}`,
}); })
console.log(err); )
}) .finally(() => mutate(WORKSPACE_INVITATIONS));
.finally(() => {
reset(defaultValues);
mutate(WORKSPACE_INVITATIONS);
});
}; };
const appendField = () => { const appendField = () => {
@ -104,9 +102,7 @@ const SendWorkspaceInvitationModal: React.FC<Props> = (props) => {
}; };
useEffect(() => { useEffect(() => {
if (fields.length === 0) { if (fields.length === 0) append([{ email: "", role: 15 }]);
append([{ email: "", role: 15 }]);
}
}, [fields, append]); }, [fields, append]);
return ( return (
@ -249,5 +245,3 @@ const SendWorkspaceInvitationModal: React.FC<Props> = (props) => {
</Transition.Root> </Transition.Root>
); );
}; };
export default SendWorkspaceInvitationModal;

View File

@ -0,0 +1,3 @@
export * from "./members-list-item";
export * from "./members-list";
export * from "./workspace-details";

View File

@ -0,0 +1,202 @@
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/router";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
// services
import { WorkspaceService } from "services/workspace.service";
// hooks
import useToast from "hooks/use-toast";
// components
import { ConfirmWorkspaceMemberRemove } from "components/workspace";
// ui
import { CustomSelect, Tooltip } from "@plane/ui";
// icons
import { ChevronDown, XCircle } from "lucide-react";
// constants
import { ROLE } from "constants/workspace";
type Props = {
member: {
id: string;
memberId: string;
avatar: string;
first_name: string;
last_name: string;
email: string | undefined;
display_name: string;
role: 5 | 10 | 15 | 20;
status: boolean;
member: boolean;
accountCreated: boolean;
};
};
// services
const workspaceService = new WorkspaceService();
export const WorkspaceMembersListItem: React.FC<Props> = (props) => {
const { member } = props;
const [removeMemberModal, setRemoveMemberModal] = useState(false);
const router = useRouter();
const { workspaceSlug } = router.query;
const { setToastAlert } = useToast();
const { workspace: workspaceStore, user: userStore } = useMobxStore();
const user = userStore.workspaceMemberInfo;
const isAdmin = userStore.workspaceMemberInfo?.role === 20;
const handleRemoveMember = async () => {
if (!workspaceSlug) return;
if (member.member)
await workspaceStore.removeMember(workspaceSlug.toString(), member.id).catch((err) => {
const error = err?.error;
setToastAlert({
type: "error",
title: "Error",
message: error || "Something went wrong",
});
});
else
await workspaceService
.deleteWorkspaceInvitations(workspaceSlug.toString(), member.id)
.then(() => {
setToastAlert({
type: "success",
title: "Success",
message: "Member removed successfully",
});
})
.catch((err) => {
const error = err?.error;
setToastAlert({
type: "error",
title: "Error",
message: error || "Something went wrong",
});
});
};
if (!user) return null;
return (
<>
<ConfirmWorkspaceMemberRemove
isOpen={removeMemberModal}
onClose={() => setRemoveMemberModal(false)}
data={member}
onSubmit={handleRemoveMember}
/>
<div className="group flex items-center justify-between px-3 py-4 hover:bg-custom-background-90">
<div className="flex items-center gap-x-4 gap-y-2">
{member.avatar && member.avatar !== "" ? (
<Link href={`/${workspaceSlug}/profile/${member.memberId}`}>
<a className="relative flex h-10 w-10 items-center justify-center rounded p-4 capitalize text-white">
<img
src={member.avatar}
className="absolute top-0 left-0 h-full w-full object-cover rounded"
alt={member.display_name || member.email}
/>
</a>
</Link>
) : (
<Link href={`/${workspaceSlug}/profile/${member.memberId}`}>
<a className="relative flex h-10 w-10 items-center justify-center rounded p-4 capitalize bg-gray-700 text-white">
{(member.email ?? member.display_name ?? "?")[0]}
</a>
</Link>
)}
<div>
{member.member ? (
<Link href={`/${workspaceSlug}/profile/${member.memberId}`}>
<a className="text-sm font-medium">
{member.first_name} {member.last_name}
</a>
</Link>
) : (
<h4 className="text-sm cursor-default">{member.display_name || member.email}</h4>
)}
<p className="mt-0.5 text-xs text-custom-sidebar-text-300">{member.email ?? member.display_name}</p>
</div>
</div>
<div className="flex items-center gap-2 text-xs">
{!member?.status && (
<div className="flex items-center justify-center rounded bg-yellow-500/20 px-2.5 py-1 text-center text-xs text-yellow-500 font-medium">
<p>Pending</p>
</div>
)}
{member?.status && !member?.accountCreated && (
<div className="flex items-center justify-center rounded bg-blue-500/20 px-2.5 py-1 text-center text-xs text-blue-500 font-medium">
<p>Account not created</p>
</div>
)}
<CustomSelect
customButton={
<div className="flex item-center gap-1 px-2 py-0.5 rounded">
<span
className={`flex items-center text-xs font-medium rounded ${
member.memberId !== user.member ? "" : "text-custom-sidebar-text-400"
}`}
>
{ROLE[member.role as keyof typeof ROLE]}
</span>
{member.memberId !== user.member && (
<span className="grid place-items-center">
<ChevronDown className="h-3 w-3" />
</span>
)}
</div>
}
value={member.role}
onChange={(value: 5 | 10 | 15 | 20 | undefined) => {
if (!workspaceSlug) return;
workspaceStore
.updateMember(workspaceSlug.toString(), member.id, {
role: value,
})
.catch(() => {
setToastAlert({
type: "error",
title: "Error!",
message: "An error occurred while updating member role. Please try again.",
});
});
}}
disabled={
member.memberId === user.member || !member.status || (user.role !== 20 && user.role < member.role)
}
placement="bottom-end"
>
{Object.keys(ROLE).map((key) => {
if (user.role !== 20 && user.role < parseInt(key)) return null;
return (
<CustomSelect.Option key={key} value={parseInt(key, 10)}>
<>{ROLE[parseInt(key) as keyof typeof ROLE]}</>
</CustomSelect.Option>
);
})}
</CustomSelect>
{isAdmin && (
<Tooltip tooltipContent={member.memberId === user.member ? "Leave workspace" : "Remove member"}>
<button
type="button"
onClick={() => setRemoveMemberModal(true)}
className="opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto"
>
<XCircle className="h-3.5 w-3.5 text-custom-text-400" strokeWidth={2} />
</button>
</Tooltip>
)}
</div>
</div>
</>
);
};

View File

@ -0,0 +1,75 @@
import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
import useSWR from "swr";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
// services
import { WorkspaceService } from "services/workspace.service";
// components
import { WorkspaceMembersListItem } from "components/workspace";
// ui
import { Loader } from "@plane/ui";
const workspaceService = new WorkspaceService();
export const WorkspaceMembersList: React.FC = observer(() => {
const router = useRouter();
const { workspaceSlug } = router.query;
const { workspace: workspaceStore, user: userStore } = useMobxStore();
const workspaceMembers = workspaceStore.workspaceMembers;
const user = userStore.workspaceMemberInfo;
const { data: workspaceInvitations } = useSWR(
workspaceSlug ? `WORKSPACE_INVITATIONS_${workspaceSlug.toString()}` : null,
workspaceSlug ? () => workspaceService.workspaceInvitations(workspaceSlug.toString()) : null
);
const members = [
...(workspaceInvitations?.map((item) => ({
id: item.id,
memberId: item.id,
avatar: "",
first_name: item.email,
last_name: "",
email: item.email,
display_name: item.email,
role: item.role,
status: item.accepted,
member: false,
accountCreated: item.accepted,
})) || []),
...(workspaceMembers?.map((item) => ({
id: item.id,
memberId: item.member?.id,
avatar: item.member?.avatar,
first_name: item.member?.first_name,
last_name: item.member?.last_name,
email: item.member?.email,
display_name: item.member?.display_name,
role: item.role,
status: true,
member: true,
accountCreated: true,
})) || []),
];
if (!workspaceMembers || !workspaceInvitations || !user)
return (
<Loader className="space-y-5">
<Loader.Item height="40px" />
<Loader.Item height="40px" />
<Loader.Item height="40px" />
<Loader.Item height="40px" />
</Loader>
);
return (
<div className="divide-y-[0.5px] divide-custom-border-200">
{members.length > 0
? members.map((member) => <WorkspaceMembersListItem key={member.id} member={member} />)
: null}
</div>
);
});

View File

@ -0,0 +1,306 @@
import { useEffect, useState } from "react";
import { observer } from "mobx-react-lite";
import { Controller, useForm } from "react-hook-form";
import { Disclosure, Transition } from "@headlessui/react";
import { ChevronDown, ChevronUp, Pencil } from "lucide-react";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
// services
import { FileService } from "services/file.service";
// hooks
import useToast from "hooks/use-toast";
// components
import { DeleteWorkspaceModal } from "components/workspace";
import { ImageUploadModal } from "components/core";
// ui
import { Button, CustomSelect, Input, Spinner } from "@plane/ui";
// types
import { IWorkspace } from "types";
// constants
import { ORGANIZATION_SIZE } from "constants/workspace";
const defaultValues: Partial<IWorkspace> = {
name: "",
url: "",
organization_size: "2-10",
logo: null,
};
// services
const fileService = new FileService();
export const WorkspaceDetails: React.FC = observer(() => {
const [deleteWorkspaceModal, setDeleteWorkspaceModal] = useState(false);
const [isImageUploading, setIsImageUploading] = useState(false);
const [isImageRemoving, setIsImageRemoving] = useState(false);
const [isImageUploadModalOpen, setIsImageUploadModalOpen] = useState(false);
const { workspace: workspaceStore, user: userStore } = useMobxStore();
const activeWorkspace = workspaceStore.currentWorkspace;
const { setToastAlert } = useToast();
const {
handleSubmit,
control,
reset,
watch,
setValue,
formState: { errors, isSubmitting },
} = useForm<IWorkspace>({
defaultValues: { ...defaultValues, ...activeWorkspace },
});
const onSubmit = async (formData: IWorkspace) => {
if (!activeWorkspace) return;
const payload: Partial<IWorkspace> = {
logo: formData.logo,
name: formData.name,
organization_size: formData.organization_size,
};
await workspaceStore
.updateWorkspace(activeWorkspace.slug, payload)
.then(() =>
setToastAlert({
title: "Success",
type: "success",
message: "Workspace updated successfully",
})
)
.catch((err) => console.error(err));
};
const handleDelete = (url: string | null | undefined) => {
if (!activeWorkspace || !url) return;
setIsImageRemoving(true);
fileService.deleteFile(activeWorkspace.id, url).then(() => {
workspaceStore
.updateWorkspace(activeWorkspace.slug, { logo: "" })
.then(() => {
setToastAlert({
type: "success",
title: "Success!",
message: "Workspace picture removed successfully.",
});
setIsImageUploadModalOpen(false);
})
.catch(() => {
setToastAlert({
type: "error",
title: "Error!",
message: "There was some error in deleting your profile picture. Please try again.",
});
})
.finally(() => setIsImageRemoving(false));
});
};
useEffect(() => {
if (activeWorkspace) reset({ ...activeWorkspace });
}, [activeWorkspace, reset]);
const isAdmin = userStore.workspaceMemberInfo?.role === 20;
if (!activeWorkspace)
return (
<div className="grid place-items-center h-full w-full px-4 sm:px-0">
<Spinner />
</div>
);
return (
<>
<DeleteWorkspaceModal
isOpen={deleteWorkspaceModal}
onClose={() => setDeleteWorkspaceModal(false)}
data={activeWorkspace}
/>
<ImageUploadModal
isOpen={isImageUploadModalOpen}
onClose={() => setIsImageUploadModalOpen(false)}
isRemoving={isImageRemoving}
handleDelete={() => handleDelete(activeWorkspace?.logo)}
onSuccess={(imageUrl) => {
setIsImageUploading(true);
setValue("logo", imageUrl);
setIsImageUploadModalOpen(false);
handleSubmit(onSubmit)().then(() => setIsImageUploading(false));
}}
value={watch("logo")}
/>
<div className={`pr-9 py-8 w-full overflow-y-auto ${isAdmin ? "" : "opacity-60"}`}>
<div className="flex gap-5 items-center pb-7 border-b border-custom-border-200">
<div className="flex flex-col gap-1">
<button type="button" onClick={() => setIsImageUploadModalOpen(true)} disabled={!isAdmin}>
{watch("logo") && watch("logo") !== null && watch("logo") !== "" ? (
<div className="relative mx-auto flex h-14 w-14">
<img
src={watch("logo")!}
className="absolute top-0 left-0 h-full w-full object-cover rounded-md"
alt="Workspace Logo"
/>
</div>
) : (
<div className="relative flex h-14 w-14 items-center justify-center rounded bg-gray-700 p-4 uppercase text-white">
{activeWorkspace?.name?.charAt(0) ?? "N"}
</div>
)}
</button>
</div>
<div className="flex flex-col gap-1">
<h3 className="text-lg font-semibold leading-6">{watch("name")}</h3>
<span className="text-sm tracking-tight">{`${
typeof window !== "undefined" && window.location.origin.replace("http://", "").replace("https://", "")
}/${activeWorkspace.slug}`}</span>
<div className="flex item-center gap-2.5">
<button
className="flex items-center gap-1.5 text-xs text-left text-custom-primary-100 font-medium"
onClick={() => setIsImageUploadModalOpen(true)}
disabled={!isAdmin}
>
{watch("logo") && watch("logo") !== null && watch("logo") !== "" ? (
<>
<Pencil className="h-3 w-3" />
Edit logo
</>
) : (
"Upload logo"
)}
</button>
</div>
</div>
</div>
<div className="flex flex-col gap-8 my-10">
<div className="grid grid-col grid-cols-1 xl:grid-cols-2 2xl:grid-cols-3 items-center justify-between gap-10 w-full">
<div className="flex flex-col gap-1 ">
<h4 className="text-sm">Workspace Name</h4>
<Controller
control={control}
name="name"
rules={{
required: "Name is required",
maxLength: {
value: 80,
message: "Workspace name should not exceed 80 characters",
},
}}
render={({ field: { value, onChange, ref } }) => (
<Input
id="name"
name="name"
type="text"
value={value}
onChange={onChange}
ref={ref}
hasError={Boolean(errors.name)}
placeholder="Name"
className="rounded-md font-medium w-full"
disabled={!isAdmin}
/>
)}
/>
</div>
<div className="flex flex-col gap-1 ">
<h4 className="text-sm">Company Size</h4>
<Controller
name="organization_size"
control={control}
render={({ field: { value, onChange } }) => (
<CustomSelect
value={value}
onChange={onChange}
label={ORGANIZATION_SIZE.find((c) => c === value) ?? "Select organization size"}
width="w-full"
buttonClassName="!border-[0.5px] !border-custom-border-200 !shadow-none"
input
disabled={!isAdmin}
>
{ORGANIZATION_SIZE.map((item) => (
<CustomSelect.Option key={item} value={item}>
{item}
</CustomSelect.Option>
))}
</CustomSelect>
)}
/>
</div>
<div className="flex flex-col gap-1 ">
<h4 className="text-sm">Workspace URL</h4>
<Controller
control={control}
name="url"
render={({ field: { onChange, ref } }) => (
<Input
id="url"
name="url"
type="url"
value={`${
typeof window !== "undefined" &&
window.location.origin.replace("http://", "").replace("https://", "")
}/${activeWorkspace.slug}`}
onChange={onChange}
ref={ref}
hasError={Boolean(errors.url)}
className="w-full"
disabled
/>
)}
/>
</div>
</div>
<div className="flex items-center justify-between py-2">
<Button variant="primary" onClick={handleSubmit(onSubmit)} loading={isSubmitting} disabled={!isAdmin}>
{isSubmitting ? "Updating..." : "Update Workspace"}
</Button>
</div>
</div>
{isAdmin && (
<Disclosure as="div" className="border-t border-custom-border-200">
{({ open }) => (
<div className="w-full">
<Disclosure.Button as="button" type="button" className="flex items-center justify-between w-full py-4">
<span className="text-lg tracking-tight">Delete Workspace</span>
{/* <Icon iconName={open ? "expand_less" : "expand_more"} className="!text-2xl" /> */}
{open ? <ChevronUp className="h-5 w-5" /> : <ChevronDown className="h-5 w-5" />}
</Disclosure.Button>
<Transition
show={open}
enter="transition duration-100 ease-out"
enterFrom="transform opacity-0"
enterTo="transform opacity-100"
leave="transition duration-75 ease-out"
leaveFrom="transform opacity-100"
leaveTo="transform opacity-0"
>
<Disclosure.Panel>
<div className="flex flex-col gap-8">
<span className="text-sm tracking-tight">
The danger zone of the workspace delete page is a critical area that requires careful
consideration and attention. When deleting a workspace, all of the data and resources within
that workspace will be permanently removed and cannot be recovered.
</span>
<div>
<Button variant="danger" onClick={() => setDeleteWorkspaceModal(true)}>
Delete my workspace
</Button>
</div>
</div>
</Disclosure.Panel>
</Transition>
</div>
)}
</Disclosure>
)}
</div>
</>
);
});

View File

@ -37,3 +37,40 @@ export const CYCLE_VIEWS = [
icon: <GanttChart className="h-4 w-4" />, icon: <GanttChart className="h-4 w-4" />,
}, },
]; ];
export const CYCLE_STATUS: {
label: string;
value: "current" | "upcoming" | "completed" | "draft";
color: string;
textColor: string;
bgColor: string;
}[] = [
{
label: "day left",
value: "current",
color: "#F59E0B",
textColor: "text-amber-500",
bgColor: "bg-amber-50",
},
{
label: "Yet to start",
value: "upcoming",
color: "#3F76FF",
textColor: "text-blue-500",
bgColor: "bg-indigo-50",
},
{
label: "Completed",
value: "completed",
color: "#16A34A",
textColor: "text-green-600",
bgColor: "bg-green-50",
},
{
label: "Draft",
value: "draft",
color: "#525252",
textColor: "text-custom-text-300",
bgColor: "bg-custom-background-90",
},
];

View File

@ -26,6 +26,7 @@ type Props = {
// services // services
const workspaceService = new WorkspaceService(); const workspaceService = new WorkspaceService();
// TODO: remove this context
export const WorkspaceMemberProvider: React.FC<Props> = (props) => { export const WorkspaceMemberProvider: React.FC<Props> = (props) => {
const { children } = props; const { children } = props;
@ -40,7 +41,7 @@ export const WorkspaceMemberProvider: React.FC<Props> = (props) => {
const loading = !memberDetails && !error; const loading = !memberDetails && !error;
return ( return (
<WorkspaceMemberContext.Provider value={{ loading, memberDetails, error }}> <WorkspaceMemberContext.Provider value={{ loading, memberDetails: undefined, error }}>
{children} {children}
</WorkspaceMemberContext.Provider> </WorkspaceMemberContext.Provider>
); );

View File

@ -111,11 +111,20 @@ export const getFirstCharacters = (str: string) => {
*/ */
export const stripHTML = (html: string) => { export const stripHTML = (html: string) => {
const tmp = document.createElement("DIV"); const strippedText = html.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, ""); // Remove script tags
tmp.innerHTML = html; return strippedText.replace(/<[^>]*>/g, ""); // Remove all other HTML tags
return tmp.textContent || tmp.innerText || "";
}; };
/**
*
* @example:
* const html = "<p>Some text</p>";
* const text = stripAndTruncateHTML(html);
* console.log(text); // Some text
*/
export const stripAndTruncateHTML = (html: string, length: number = 55) => truncateText(stripHTML(html), length);
/** /**
* @description: This function return number count in string if number is more than 100 then it will return 99+ * @description: This function return number count in string if number is more than 100 then it will return 99+
* @param {number} number * @param {number} number

View File

@ -6,7 +6,7 @@ import { WorkspaceService } from "services/workspace.service";
import { import {
IIssueDisplayFilterOptions, IIssueDisplayFilterOptions,
IIssueFilterOptions, IIssueFilterOptions,
IWorkspaceMember, IWorkspaceMemberMe,
IWorkspaceViewProps, IWorkspaceViewProps,
Properties, Properties,
} from "types"; } from "types";
@ -66,7 +66,7 @@ const useMyIssuesFilters = (workspaceSlug: string | undefined) => {
const oldData = { ...myWorkspace }; const oldData = { ...myWorkspace };
mutate<IWorkspaceMember>( mutate<IWorkspaceMemberMe>(
WORKSPACE_MEMBERS_ME(workspaceSlug.toString()), WORKSPACE_MEMBERS_ME(workspaceSlug.toString()),
(prevData) => { (prevData) => {
if (!prevData) return; if (!prevData) return;

View File

@ -12,6 +12,8 @@ export interface IWorkspaceAuthWrapper {
children: ReactNode; children: ReactNode;
} }
const HIGHER_ROLES = [20, 15];
export const WorkspaceAuthWrapper: FC<IWorkspaceAuthWrapper> = observer((props) => { export const WorkspaceAuthWrapper: FC<IWorkspaceAuthWrapper> = observer((props) => {
const { children } = props; const { children } = props;
// store // store
@ -22,7 +24,7 @@ export const WorkspaceAuthWrapper: FC<IWorkspaceAuthWrapper> = observer((props)
// fetching all workspaces // fetching all workspaces
useSWR(`USER_WORKSPACES_LIST`, () => workspaceStore.fetchWorkspaces()); useSWR(`USER_WORKSPACES_LIST`, () => workspaceStore.fetchWorkspaces());
// fetching user workspace information // fetching user workspace information
useSWR( const { data: workspaceMemberInfo } = useSWR(
workspaceSlug ? `WORKSPACE_MEMBERS_ME_${workspaceSlug}` : null, workspaceSlug ? `WORKSPACE_MEMBERS_ME_${workspaceSlug}` : null,
workspaceSlug ? () => userStore.fetchUserWorkspaceInfo(workspaceSlug.toString()) : null workspaceSlug ? () => userStore.fetchUserWorkspaceInfo(workspaceSlug.toString()) : null
); );
@ -33,8 +35,12 @@ export const WorkspaceAuthWrapper: FC<IWorkspaceAuthWrapper> = observer((props)
); );
// fetch workspace members // fetch workspace members
useSWR( useSWR(
workspaceSlug ? `WORKSPACE_MEMBERS_${workspaceSlug}` : null, workspaceSlug && workspaceMemberInfo && HIGHER_ROLES.includes(workspaceMemberInfo.role)
workspaceSlug ? () => workspaceStore.fetchWorkspaceMembers(workspaceSlug.toString()) : null ? `WORKSPACE_MEMBERS_${workspaceSlug}`
: null,
workspaceSlug && workspaceMemberInfo && HIGHER_ROLES.includes(workspaceMemberInfo.role)
? () => workspaceStore.fetchWorkspaceMembers(workspaceSlug.toString())
: null
); );
// fetch workspace labels // fetch workspace labels
useSWR( useSWR(

View File

@ -25,7 +25,7 @@ const SingleCycle: React.FC = () => {
const { cycle: cycleStore } = useMobxStore(); const { cycle: cycleStore } = useMobxStore();
const { storedValue } = useLocalStorage("cycle_sidebar_collapsed", "false"); const { setValue, storedValue } = useLocalStorage("cycle_sidebar_collapsed", "false");
const isSidebarCollapsed = storedValue ? (storedValue === "true" ? true : false) : false; const isSidebarCollapsed = storedValue ? (storedValue === "true" ? true : false) : false;
const { error } = useSWR( const { error } = useSWR(
@ -35,6 +35,10 @@ const SingleCycle: React.FC = () => {
: null : null
); );
const toggleSidebar = () => {
setValue(`${!isSidebarCollapsed}`);
};
// TODO: add this function to bulk add issues to cycle // TODO: add this function to bulk add issues to cycle
// const handleAddIssuesToCycle = async (data: ISearchIssueResponse[]) => { // const handleAddIssuesToCycle = async (data: ISearchIssueResponse[]) => {
// if (!workspaceSlug || !projectId) return; // if (!workspaceSlug || !projectId) return;
@ -75,11 +79,21 @@ const SingleCycle: React.FC = () => {
/> />
) : ( ) : (
<> <>
<div className="relative w-full h-full flex overflow-auto"> <div className="flex h-full w-full">
<div className={`h-full w-full ${isSidebarCollapsed ? "" : "mr-[24rem]"} duration-300`}> <div className="h-full w-full">
<CycleLayoutRoot /> <CycleLayoutRoot />
</div> </div>
{cycleId && <CycleDetailsSidebar isOpen={!isSidebarCollapsed} cycleId={cycleId.toString()} />} {cycleId && !isSidebarCollapsed && (
<div
className="flex flex-col gap-3.5 h-full w-[24rem] z-10 overflow-y-auto border-l border-custom-border-100 bg-custom-sidebar-background-100 px-6 py-3.5 duration-300 flex-shrink-0"
style={{
boxShadow:
"0px 1px 4px 0px rgba(0, 0, 0, 0.06), 0px 2px 4px 0px rgba(16, 24, 40, 0.06), 0px 1px 8px -1px rgba(16, 24, 40, 0.06)",
}}
>
<CycleDetailsSidebar cycleId={cycleId.toString()} handleClose={toggleSidebar} />
</div>
)}
</div> </div>
</> </>
)} )}

View File

@ -29,7 +29,11 @@ const ProjectCyclesPage: NextPage = observer(() => {
const { project: projectStore, cycle: cycleStore } = useMobxStore(); const { project: projectStore, cycle: cycleStore } = useMobxStore();
// router // router
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId } = router.query as { workspaceSlug: string; projectId: string }; const { workspaceSlug, projectId, peekCycle } = router.query as {
workspaceSlug: string;
projectId: string;
peekCycle: string;
};
// fetching project details // fetching project details
useSWR( useSWR(
workspaceSlug && projectId ? `PROJECT_DETAILS_${projectId}` : null, workspaceSlug && projectId ? `PROJECT_DETAILS_${projectId}` : null,
@ -150,13 +154,14 @@ const ProjectCyclesPage: NextPage = observer(() => {
</div> </div>
<Tab.Panels as={Fragment}> <Tab.Panels as={Fragment}>
<Tab.Panel as="div" className="p-4 sm:p-5 h-full overflow-y-auto"> <Tab.Panel as="div" className="h-full overflow-y-auto">
{cycleView && cycleLayout && workspaceSlug && projectId && ( {cycleView && cycleLayout && workspaceSlug && projectId && (
<CyclesView <CyclesView
filter={"all"} filter={"all"}
layout={cycleLayout as TCycleLayout} layout={cycleLayout as TCycleLayout}
workspaceSlug={workspaceSlug} workspaceSlug={workspaceSlug}
projectId={projectId} projectId={projectId}
peekCycle={peekCycle}
/> />
)} )}
</Tab.Panel> </Tab.Panel>
@ -165,35 +170,38 @@ const ProjectCyclesPage: NextPage = observer(() => {
<ActiveCycleDetails workspaceSlug={workspaceSlug} projectId={projectId} /> <ActiveCycleDetails workspaceSlug={workspaceSlug} projectId={projectId} />
</Tab.Panel> </Tab.Panel>
<Tab.Panel as="div" className="p-4 sm:p-5 h-full overflow-y-auto"> <Tab.Panel as="div" className="h-full overflow-y-auto">
{cycleView && cycleLayout && workspaceSlug && projectId && ( {cycleView && cycleLayout && workspaceSlug && projectId && (
<CyclesView <CyclesView
filter={"upcoming"} filter={"upcoming"}
layout={cycleLayout as TCycleLayout} layout={cycleLayout as TCycleLayout}
workspaceSlug={workspaceSlug} workspaceSlug={workspaceSlug}
projectId={projectId} projectId={projectId}
peekCycle={peekCycle}
/> />
)} )}
</Tab.Panel> </Tab.Panel>
<Tab.Panel as="div" className="p-4 sm:p-5 h-full overflow-y-auto"> <Tab.Panel as="div" className="h-full overflow-y-auto">
{cycleView && cycleLayout && workspaceSlug && projectId && ( {cycleView && cycleLayout && workspaceSlug && projectId && (
<CyclesView <CyclesView
filter={"completed"} filter={"completed"}
layout={cycleLayout as TCycleLayout} layout={cycleLayout as TCycleLayout}
workspaceSlug={workspaceSlug} workspaceSlug={workspaceSlug}
projectId={projectId} projectId={projectId}
peekCycle={peekCycle}
/> />
)} )}
</Tab.Panel> </Tab.Panel>
<Tab.Panel as="div" className="p-4 sm:p-5 h-full overflow-y-auto"> <Tab.Panel as="div" className="h-full overflow-y-auto">
{cycleView && cycleLayout && workspaceSlug && projectId && ( {cycleView && cycleLayout && workspaceSlug && projectId && (
<CyclesView <CyclesView
filter={"draft"} filter={"draft"}
layout={cycleLayout as TCycleLayout} layout={cycleLayout as TCycleLayout}
workspaceSlug={workspaceSlug} workspaceSlug={workspaceSlug}
projectId={projectId} projectId={projectId}
peekCycle={peekCycle}
/> />
)} )}
</Tab.Panel> </Tab.Panel>

View File

@ -1,362 +1,18 @@
import React, { useEffect, useState } from "react";
import { useRouter } from "next/router";
import useSWR, { mutate } from "swr";
// react-hook-form
import { Controller, useForm } from "react-hook-form";
// services
import { WorkspaceService } from "services/workspace.service";
import { FileService } from "services/file.service";
// hooks
import useToast from "hooks/use-toast";
import useUserAuth from "hooks/use-user-auth";
// layouts // layouts
import { AppLayout } from "layouts/app-layout"; import { AppLayout } from "layouts/app-layout";
import { WorkspaceSettingLayout } from "layouts/setting-layout"; import { WorkspaceSettingLayout } from "layouts/setting-layout";
// components // components
import { ImageUploadModal } from "components/core";
import { DeleteWorkspaceModal } from "components/workspace";
import { WorkspaceSettingHeader } from "components/headers"; import { WorkspaceSettingHeader } from "components/headers";
// ui import { WorkspaceDetails } from "components/workspace";
import { Disclosure, Transition } from "@headlessui/react";
import { Button, CustomSelect, Input, Spinner } from "@plane/ui";
// icons
import { ChevronDown, ChevronUp, Pencil } from "lucide-react";
// types // types
import type { IWorkspace } from "types";
import type { NextPage } from "next"; import type { NextPage } from "next";
// fetch-keys
import { WORKSPACE_DETAILS, USER_WORKSPACES, WORKSPACE_MEMBERS_ME } from "constants/fetch-keys";
// constants
import { ORGANIZATION_SIZE } from "constants/workspace";
const defaultValues: Partial<IWorkspace> = { const WorkspaceSettings: NextPage = () => (
name: "", <AppLayout header={<WorkspaceSettingHeader title="General Settings" />}>
url: "", <WorkspaceSettingLayout>
organization_size: "2-10", <WorkspaceDetails />
logo: null, </WorkspaceSettingLayout>
}; </AppLayout>
);
// services
const workspaceService = new WorkspaceService();
const fileService = new FileService();
const WorkspaceSettings: NextPage = () => {
const [isOpen, setIsOpen] = useState(false);
const [isImageUploading, setIsImageUploading] = useState(false);
const [isImageRemoving, setIsImageRemoving] = useState(false);
const [isImageUploadModalOpen, setIsImageUploadModalOpen] = useState(false);
const router = useRouter();
const { workspaceSlug } = router.query;
const { user } = useUserAuth();
const { data: memberDetails } = useSWR(
workspaceSlug ? WORKSPACE_MEMBERS_ME(workspaceSlug.toString()) : null,
workspaceSlug ? () => workspaceService.workspaceMemberMe(workspaceSlug.toString()) : null
);
const { setToastAlert } = useToast();
const { data: activeWorkspace } = useSWR(workspaceSlug ? WORKSPACE_DETAILS(workspaceSlug as string) : null, () =>
workspaceSlug ? workspaceService.getWorkspace(workspaceSlug as string) : null
);
const {
handleSubmit,
control,
reset,
watch,
setValue,
formState: { errors, isSubmitting },
} = useForm<IWorkspace>({
defaultValues: { ...defaultValues, ...activeWorkspace },
});
useEffect(() => {
if (activeWorkspace) reset({ ...activeWorkspace });
}, [activeWorkspace, reset]);
const onSubmit = async (formData: IWorkspace) => {
if (!activeWorkspace) return;
const payload: Partial<IWorkspace> = {
logo: formData.logo,
name: formData.name,
organization_size: formData.organization_size,
};
await workspaceService
.updateWorkspace(activeWorkspace.slug, payload, user)
.then((res) => {
mutate<IWorkspace[]>(USER_WORKSPACES, (prevData) =>
prevData?.map((workspace) => (workspace.id === res.id ? res : workspace))
);
mutate<IWorkspace>(WORKSPACE_DETAILS(workspaceSlug as string), (prevData) => {
if (!prevData) return prevData;
return {
...prevData,
logo: formData.logo,
};
});
setToastAlert({
title: "Success",
type: "success",
message: "Workspace updated successfully",
});
})
.catch((err) => console.error(err));
};
const handleDelete = (url: string | null | undefined) => {
if (!activeWorkspace || !url) return;
setIsImageRemoving(true);
fileService.deleteFile(activeWorkspace.id, url).then(() => {
workspaceService
.updateWorkspace(activeWorkspace.slug, { logo: "" }, user)
.then((res) => {
setToastAlert({
type: "success",
title: "Success!",
message: "Workspace picture removed successfully.",
});
mutate<IWorkspace[]>(USER_WORKSPACES, (prevData) =>
prevData?.map((workspace) => (workspace.id === res.id ? res : workspace))
);
mutate<IWorkspace>(WORKSPACE_DETAILS(workspaceSlug as string), (prevData) => {
if (!prevData) return prevData;
return {
...prevData,
logo: "",
};
});
setIsImageUploadModalOpen(false);
})
.catch(() => {
setToastAlert({
type: "error",
title: "Error!",
message: "There was some error in deleting your profile picture. Please try again.",
});
})
.finally(() => setIsImageRemoving(false));
});
};
const isAdmin = memberDetails?.role === 20;
return (
<AppLayout header={<WorkspaceSettingHeader title="General Settings" />}>
<WorkspaceSettingLayout>
<ImageUploadModal
isOpen={isImageUploadModalOpen}
onClose={() => setIsImageUploadModalOpen(false)}
isRemoving={isImageRemoving}
handleDelete={() => handleDelete(activeWorkspace?.logo)}
onSuccess={(imageUrl) => {
setIsImageUploading(true);
setValue("logo", imageUrl);
setIsImageUploadModalOpen(false);
handleSubmit(onSubmit)().then(() => setIsImageUploading(false));
}}
value={watch("logo")}
/>
<DeleteWorkspaceModal
isOpen={isOpen}
onClose={() => {
setIsOpen(false);
}}
data={activeWorkspace ?? null}
user={user}
/>
{activeWorkspace ? (
<div className={`pr-9 py-8 w-full overflow-y-auto ${isAdmin ? "" : "opacity-60"}`}>
<div className="flex gap-5 items-center pb-7 border-b border-custom-border-200">
<div className="flex flex-col gap-1">
<button type="button" onClick={() => setIsImageUploadModalOpen(true)} disabled={!isAdmin}>
{watch("logo") && watch("logo") !== null && watch("logo") !== "" ? (
<div className="relative mx-auto flex h-14 w-14">
<img
src={watch("logo")!}
className="absolute top-0 left-0 h-full w-full object-cover rounded-md"
alt="Workspace Logo"
/>
</div>
) : (
<div className="relative flex h-14 w-14 items-center justify-center rounded bg-gray-700 p-4 uppercase text-white">
{activeWorkspace?.name?.charAt(0) ?? "N"}
</div>
)}
</button>
</div>
<div className="flex flex-col gap-1">
<h3 className="text-lg font-semibold leading-6">{watch("name")}</h3>
<span className="text-sm tracking-tight">{`${
typeof window !== "undefined" && window.location.origin.replace("http://", "").replace("https://", "")
}/${activeWorkspace.slug}`}</span>
<div className="flex item-center gap-2.5">
<button
className="flex items-center gap-1.5 text-xs text-left text-custom-primary-100 font-medium"
onClick={() => setIsImageUploadModalOpen(true)}
disabled={!isAdmin}
>
{watch("logo") && watch("logo") !== null && watch("logo") !== "" ? (
<>
<Pencil className="h-3 w-3" />
Edit logo
</>
) : (
"Upload logo"
)}
</button>
</div>
</div>
</div>
<div className="flex flex-col gap-8 my-10">
<div className="grid grid-col grid-cols-1 xl:grid-cols-2 2xl:grid-cols-3 items-center justify-between gap-10 w-full">
<div className="flex flex-col gap-1 ">
<h4 className="text-sm">Workspace Name</h4>
<Controller
control={control}
name="name"
rules={{
required: "Name is required",
maxLength: {
value: 80,
message: "Workspace name should not exceed 80 characters",
},
}}
render={({ field: { value, onChange, ref } }) => (
<Input
id="name"
name="name"
type="text"
value={value}
onChange={onChange}
ref={ref}
hasError={Boolean(errors.name)}
placeholder="Name"
className="rounded-md font-medium w-full"
disabled={!isAdmin}
/>
)}
/>
</div>
<div className="flex flex-col gap-1 ">
<h4 className="text-sm">Company Size</h4>
<Controller
name="organization_size"
control={control}
render={({ field: { value, onChange } }) => (
<CustomSelect
value={value}
onChange={onChange}
label={ORGANIZATION_SIZE.find((c) => c === value) ?? "Select organization size"}
width="w-full"
input
disabled={!isAdmin}
>
{ORGANIZATION_SIZE?.map((item) => (
<CustomSelect.Option key={item} value={item}>
{item}
</CustomSelect.Option>
))}
</CustomSelect>
)}
/>
</div>
<div className="flex flex-col gap-1 ">
<h4 className="text-sm">Workspace URL</h4>
<Controller
control={control}
name="url"
render={({ field: { onChange, ref } }) => (
<Input
id="url"
name="url"
type="url"
value={`${
typeof window !== "undefined" &&
window.location.origin.replace("http://", "").replace("https://", "")
}/${activeWorkspace.slug}`}
onChange={onChange}
ref={ref}
hasError={Boolean(errors.url)}
className="w-full"
disabled
/>
)}
/>
</div>
</div>
<div className="flex items-center justify-between py-2">
<Button variant="primary" onClick={handleSubmit(onSubmit)} loading={isSubmitting} disabled={!isAdmin}>
{isSubmitting ? "Updating..." : "Update Workspace"}
</Button>
</div>
</div>
{isAdmin && (
<Disclosure as="div" className="border-t border-custom-border-400">
{({ open }) => (
<div className="w-full">
<Disclosure.Button
as="button"
type="button"
className="flex items-center justify-between w-full py-4"
>
<span className="text-xl tracking-tight">Delete Workspace</span>
{/* <Icon iconName={open ? "expand_less" : "expand_more"} className="!text-2xl" /> */}
{open ? <ChevronUp className="h-5 w-5" /> : <ChevronDown className="h-5 w-5" />}
</Disclosure.Button>
<Transition
show={open}
enter="transition duration-100 ease-out"
enterFrom="transform opacity-0"
enterTo="transform opacity-100"
leave="transition duration-75 ease-out"
leaveFrom="transform opacity-100"
leaveTo="transform opacity-0"
>
<Disclosure.Panel>
<div className="flex flex-col gap-8">
<span className="text-sm tracking-tight">
The danger zone of the workspace delete page is a critical area that requires careful
consideration and attention. When deleting a workspace, all of the data and resources within
that workspace will be permanently removed and cannot be recovered.
</span>
<div>
<Button variant="danger" onClick={() => setIsOpen(true)}>
Delete my workspace
</Button>
</div>
</div>
</Disclosure.Panel>
</Transition>
</div>
)}
</Disclosure>
)}
</div>
) : (
<div className="flex items-center justify-center h-full w-full px-4 sm:px-0">
<Spinner />
</div>
)}
</WorkspaceSettingLayout>
</AppLayout>
);
};
export default WorkspaceSettings; export default WorkspaceSettings;

View File

@ -1,309 +1,45 @@
import { useState } from "react"; import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import useSWR from "swr";
// services
import { WorkspaceService } from "services/workspace.service";
// hooks // hooks
import useToast from "hooks/use-toast";
import useUser from "hooks/use-user"; import useUser from "hooks/use-user";
import useWorkspaceMembers from "hooks/use-workspace-members";
// layouts // layouts
import { AppLayout } from "layouts/app-layout"; import { AppLayout } from "layouts/app-layout";
import { WorkspaceSettingLayout } from "layouts/setting-layout"; import { WorkspaceSettingLayout } from "layouts/setting-layout";
// components // components
import ConfirmWorkspaceMemberRemove from "components/workspace/confirm-workspace-member-remove";
import SendWorkspaceInvitationModal from "components/workspace/send-workspace-invitation-modal";
import { WorkspaceSettingHeader } from "components/headers"; import { WorkspaceSettingHeader } from "components/headers";
import { SendWorkspaceInvitationModal, WorkspaceMembersList } from "components/workspace";
// ui // ui
import { Button, CustomMenu, CustomSelect, Loader } from "@plane/ui"; import { Button } from "@plane/ui";
// icons
import { ChevronDown, X } from "lucide-react";
// types // types
import type { NextPage } from "next"; import type { NextPage } from "next";
// fetch-keys
import { WORKSPACE_INVITATION_WITH_EMAIL, WORKSPACE_MEMBERS_WITH_EMAIL } from "constants/fetch-keys";
// constants
import { ROLE } from "constants/workspace";
// helper
// services
const workspaceService = new WorkspaceService();
const MembersSettings: NextPage = () => { const MembersSettings: NextPage = () => {
const [selectedRemoveMember, setSelectedRemoveMember] = useState<string | null>(null);
const [selectedInviteRemoveMember, setSelectedInviteRemoveMember] = useState<string | null>(null);
const [inviteModal, setInviteModal] = useState(false); const [inviteModal, setInviteModal] = useState(false);
const router = useRouter(); const router = useRouter();
const { workspaceSlug } = router.query; const { workspaceSlug } = router.query;
const { setToastAlert } = useToast();
const { user } = useUser(); const { user } = useUser();
const { isOwner } = useWorkspaceMembers(workspaceSlug?.toString(), Boolean(workspaceSlug));
const { data: workspaceMembers, mutate: mutateMembers } = useSWR(
workspaceSlug ? WORKSPACE_MEMBERS_WITH_EMAIL(workspaceSlug.toString()) : null,
workspaceSlug ? () => workspaceService.workspaceMembersWithEmail(workspaceSlug.toString()) : null
);
const { data: workspaceInvitations, mutate: mutateInvitations } = useSWR(
workspaceSlug ? WORKSPACE_INVITATION_WITH_EMAIL(workspaceSlug.toString()) : null,
workspaceSlug ? () => workspaceService.workspaceInvitationsWithEmail(workspaceSlug.toString()) : null
);
const members = [
...(workspaceInvitations?.map((item) => ({
id: item.id,
memberId: item.id,
avatar: "",
first_name: item.email,
last_name: "",
email: item.email,
display_name: item.email,
role: item.role,
status: item.accepted,
member: false,
accountCreated: item?.accepted ? false : true,
})) || []),
...(workspaceMembers?.map((item) => ({
id: item.id,
memberId: item.member?.id,
avatar: item.member?.avatar,
first_name: item.member?.first_name,
last_name: item.member?.last_name,
email: item.member?.email,
display_name: item.member?.display_name,
role: item.role,
status: true,
member: true,
accountCreated: true,
})) || []),
];
const currentUser = workspaceMembers?.find((item) => item.member?.id === user?.id);
const handleInviteModalSuccess = () => {
mutateInvitations();
};
return ( return (
<AppLayout header={<WorkspaceSettingHeader title="Members Settings" />}> <AppLayout header={<WorkspaceSettingHeader title="Members Settings" />}>
<WorkspaceSettingLayout> <WorkspaceSettingLayout>
<ConfirmWorkspaceMemberRemove {workspaceSlug && (
isOpen={Boolean(selectedRemoveMember) || Boolean(selectedInviteRemoveMember)} <SendWorkspaceInvitationModal
onClose={() => { isOpen={inviteModal}
setSelectedRemoveMember(null); onClose={() => setInviteModal(false)}
setSelectedInviteRemoveMember(null); workspaceSlug={workspaceSlug.toString()}
}} user={user}
data={ />
selectedRemoveMember )}
? members.find((item) => item.id === selectedRemoveMember)
: selectedInviteRemoveMember
? members.find((item) => item.id === selectedInviteRemoveMember)
: null
}
handleDelete={async () => {
if (!workspaceSlug) return;
if (selectedRemoveMember) {
workspaceService
.deleteWorkspaceMember(workspaceSlug as string, selectedRemoveMember)
.catch((err) => {
const error = err?.error;
setToastAlert({
type: "error",
title: "Error",
message: error || "Something went wrong",
});
})
.finally(() => {
mutateMembers((prevData: any) => prevData?.filter((item: any) => item.id !== selectedRemoveMember));
});
}
if (selectedInviteRemoveMember) {
mutateInvitations(
(prevData: any) => prevData?.filter((item: any) => item.id !== selectedInviteRemoveMember),
false
);
workspaceService
.deleteWorkspaceInvitations(workspaceSlug as string, selectedInviteRemoveMember)
.then(() => {
setToastAlert({
type: "success",
title: "Success",
message: "Member removed successfully",
});
})
.catch((err) => {
const error = err?.error;
setToastAlert({
type: "error",
title: "Error",
message: error || "Something went wrong",
});
})
.finally(() => {
mutateInvitations();
});
}
setSelectedRemoveMember(null);
setSelectedInviteRemoveMember(null);
}}
/>
<SendWorkspaceInvitationModal
isOpen={inviteModal}
setIsOpen={setInviteModal}
workspace_slug={workspaceSlug as string}
user={user}
onSuccess={handleInviteModalSuccess}
/>
<section className="pr-9 py-8 w-full overflow-y-auto"> <section className="pr-9 py-8 w-full overflow-y-auto">
<div className="flex items-center justify-between gap-4 py-3.5 border-b border-custom-border-200"> <div className="flex items-center justify-between gap-4 py-3.5 border-b-[0.5px] border-custom-border-200">
<h4 className="text-xl font-medium">Members</h4> <h4 className="text-xl font-medium">Members</h4>
<Button variant="primary" size="sm" onClick={() => setInviteModal(true)}> <Button variant="primary" size="sm" onClick={() => setInviteModal(true)}>
Add Member Add Member
</Button> </Button>
</div> </div>
{!workspaceMembers || !workspaceInvitations ? ( <WorkspaceMembersList />
<Loader className="space-y-5">
<Loader.Item height="40px" />
<Loader.Item height="40px" />
<Loader.Item height="40px" />
<Loader.Item height="40px" />
</Loader>
) : (
<div className="divide-y divide-custom-border-200">
{members.length > 0
? members.map((member) => (
<div key={member.id} className="group flex items-center justify-between px-3.5 py-[18px]">
<div className="flex items-center gap-x-8 gap-y-2">
{member.avatar && member.avatar !== "" ? (
<Link href={`/${workspaceSlug}/profile/${member.memberId}`}>
<a className="relative flex h-10 w-10 items-center justify-center rounded-lg p-4 capitalize text-white">
<img
src={member.avatar}
className="absolute top-0 left-0 h-full w-full object-cover rounded-lg"
alt={member.display_name || member.email}
/>
</a>
</Link>
) : member.display_name || member.email ? (
<Link href={`/${workspaceSlug}/profile/${member.memberId}`}>
<a className="relative flex h-10 w-10 items-center justify-center rounded-lg p-4 capitalize bg-gray-700 text-white">
{(member.display_name || member.email)?.charAt(0)}
</a>
</Link>
) : (
<div className="relative flex h-10 w-10 items-center justify-center rounded-lg p-4 capitalize bg-gray-700 text-white">
?
</div>
)}
<div>
{member.member ? (
<Link href={`/${workspaceSlug}/profile/${member.memberId}`}>
<a className="text-sm">
<span>
{member.first_name} {member.last_name}
</span>
<span className="text-custom-text-300 text-sm ml-2">({member.display_name})</span>
</a>
</Link>
) : (
<h4 className="text-sm cursor-default">{member.display_name || member.email}</h4>
)}
{isOwner && <p className="mt-0.5 text-xs text-custom-sidebar-text-300">{member.email}</p>}
</div>
</div>
<div className="flex items-center gap-3 text-xs">
{!member?.status && (
<div className="mr-2 flex items-center justify-center rounded-full bg-yellow-500/20 px-2 py-1 text-center text-xs text-yellow-500">
<p>Pending</p>
</div>
)}
{member?.status && !member?.accountCreated && (
<div className="mr-2 flex items-center justify-center rounded-full bg-blue-500/20 px-2 py-1 text-center text-xs text-blue-500">
<p>Account not created</p>
</div>
)}
<CustomSelect
customButton={
<div className="flex item-center gap-1">
<span
className={`flex items-center text-sm font-medium ${
member.memberId !== user?.id ? "" : "text-custom-sidebar-text-400"
}`}
>
{ROLE[member.role as keyof typeof ROLE]}
</span>
{member.memberId !== user?.id && <ChevronDown className="h-4 w-4" />}
</div>
}
value={member.role}
onChange={(value: 5 | 10 | 15 | 20 | undefined) => {
if (!workspaceSlug) return;
mutateMembers(
(prevData: any) =>
prevData?.map((m: any) => (m.id === member.id ? { ...m, role: value } : m)),
false
);
workspaceService
.updateWorkspaceMember(workspaceSlug?.toString(), member.id, {
role: value,
})
.catch(() => {
setToastAlert({
type: "error",
title: "Error!",
message: "An error occurred while updating member role. Please try again.",
});
});
}}
disabled={
member.memberId === currentUser?.member.id ||
!member.status ||
(currentUser && currentUser.role !== 20 && currentUser.role < member.role)
}
>
{Object.keys(ROLE).map((key) => {
if (currentUser && currentUser.role !== 20 && currentUser.role < parseInt(key)) return null;
return (
<CustomSelect.Option key={key} value={key}>
<>{ROLE[parseInt(key) as keyof typeof ROLE]}</>
</CustomSelect.Option>
);
})}
</CustomSelect>
<CustomMenu ellipsis>
<CustomMenu.MenuItem
onClick={() => {
if (member.member) {
setSelectedRemoveMember(member.id);
} else {
setSelectedInviteRemoveMember(member.id);
}
}}
>
<span className="flex items-center justify-start gap-2">
<X className="h-4 w-4" />
<span> {user?.id === member.memberId ? "Leave" : "Remove member"}</span>
</span>
</CustomMenu.MenuItem>
</CustomMenu>
</div>
</div>
))
: null}
</div>
)}
</section> </section>
</WorkspaceSettingLayout> </WorkspaceSettingLayout>
</AppLayout> </AppLayout>

View File

@ -68,7 +68,6 @@ const CreateWorkspace: NextPage = () => {
onSubmit={onSubmit} onSubmit={onSubmit}
defaultValues={defaultValues} defaultValues={defaultValues}
setDefaultValues={setDefaultValues} setDefaultValues={setDefaultValues}
user={user}
/> />
</div> </div>
</div> </div>

View File

@ -6,6 +6,7 @@ import { API_BASE_URL } from "helpers/common.helper";
// types // types
import { import {
IWorkspace, IWorkspace,
IWorkspaceMemberMe,
IWorkspaceMember, IWorkspaceMember,
IWorkspaceMemberInvitation, IWorkspaceMemberInvitation,
ILastActiveWorkspaceDetails, ILastActiveWorkspaceDetails,
@ -139,15 +140,7 @@ export class WorkspaceService extends APIService {
}); });
} }
async workspaceMembersWithEmail(workspaceSlug: string): Promise<IWorkspaceMember[]> { async workspaceMemberMe(workspaceSlug: string): Promise<IWorkspaceMemberMe> {
return this.get(`/api/workspaces/${workspaceSlug}/members/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async workspaceMemberMe(workspaceSlug: string): Promise<IWorkspaceMember> {
return this.get(`/api/workspaces/${workspaceSlug}/workspace-members/me/`) return this.get(`/api/workspaces/${workspaceSlug}/workspace-members/me/`)
.then((response) => response?.data) .then((response) => response?.data)
.catch((error) => { .catch((error) => {
@ -191,14 +184,6 @@ export class WorkspaceService extends APIService {
}); });
} }
async workspaceInvitationsWithEmail(workspaceSlug: string): Promise<IWorkspaceMemberInvitation[]> {
return this.get(`/api/workspaces/${workspaceSlug}/invitations/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async getWorkspaceInvitation(invitationId: string): Promise<IWorkspaceMemberInvitation> { async getWorkspaceInvitation(invitationId: string): Promise<IWorkspaceMemberInvitation> {
return this.get(`/api/users/me/invitations/${invitationId}/`, { headers: {} }) return this.get(`/api/users/me/invitations/${invitationId}/`, { headers: {} })
.then((response) => response?.data) .then((response) => response?.data)

View File

@ -6,15 +6,12 @@ import { IIssue } from "types";
// services // services
import { IssueService } from "services/issue"; import { IssueService } from "services/issue";
import { sortArrayByDate, sortArrayByPriority } from "constants/kanban-helpers"; import { sortArrayByDate, sortArrayByPriority } from "constants/kanban-helpers";
import {
export type IIssueType = "grouped" | "groupWithSubGroups" | "ungrouped"; IIssueGroupWithSubGroupsStructure,
export type IIssueGroupedStructure = { [group_id: string]: IIssue[] }; IIssueGroupedStructure,
export type IIssueGroupWithSubGroupsStructure = { IIssueType,
[group_id: string]: { IIssueUnGroupedStructure,
[sub_group_id: string]: IIssue[]; } from "store/issue";
};
};
export type IIssueUnGroupedStructure = IIssue[];
export interface IArchivedIssueStore { export interface IArchivedIssueStore {
loader: boolean; loader: boolean;

View File

@ -9,15 +9,12 @@ import { sortArrayByDate, sortArrayByPriority } from "constants/kanban-helpers";
// types // types
import { IIssue } from "types"; import { IIssue } from "types";
import { IBlockUpdateData } from "components/gantt-chart"; import { IBlockUpdateData } from "components/gantt-chart";
import {
export type IIssueType = "grouped" | "groupWithSubGroups" | "ungrouped"; IIssueGroupWithSubGroupsStructure,
export type IIssueGroupedStructure = { [group_id: string]: IIssue[] }; IIssueGroupedStructure,
export type IIssueGroupWithSubGroupsStructure = { IIssueType,
[group_id: string]: { IIssueUnGroupedStructure,
[sub_group_id: string]: IIssue[]; } from "store/issue";
};
};
export type IIssueUnGroupedStructure = IIssue[];
export interface ICycleIssueStore { export interface ICycleIssueStore {
loader: boolean; loader: boolean;
@ -33,6 +30,7 @@ export interface ICycleIssueStore {
// computed // computed
getIssueType: IIssueType | null; getIssueType: IIssueType | null;
getIssues: IIssueGroupedStructure | IIssueGroupWithSubGroupsStructure | IIssueUnGroupedStructure | null; getIssues: IIssueGroupedStructure | IIssueGroupWithSubGroupsStructure | IIssueUnGroupedStructure | null;
getIssuesCount: number;
// action // action
fetchIssues: (workspaceSlug: string, projectId: string, cycleId: string) => Promise<any>; fetchIssues: (workspaceSlug: string, projectId: string, cycleId: string) => Promise<any>;
updateIssueStructure: (group_id: string | null, sub_group_id: string | null, issue: IIssue) => void; updateIssueStructure: (group_id: string | null, sub_group_id: string | null, issue: IIssue) => void;
@ -73,6 +71,7 @@ export class CycleIssueStore implements ICycleIssueStore {
// computed // computed
getIssueType: computed, getIssueType: computed,
getIssues: computed, getIssues: computed,
getIssuesCount: computed,
// actions // actions
fetchIssues: action, fetchIssues: action,
updateIssueStructure: action, updateIssueStructure: action,
@ -130,6 +129,44 @@ export class CycleIssueStore implements ICycleIssueStore {
return this.issues?.[cycleId]?.[issueType] || null; return this.issues?.[cycleId]?.[issueType] || null;
} }
get getIssuesCount() {
const issueType = this.getIssueType;
let issuesCount = 0;
if (issueType === "grouped") {
const issues = this.getIssues as IIssueGroupedStructure;
if (!issues) return 0;
Object.keys(issues).map((group_id) => {
issuesCount += issues[group_id].length;
});
}
if (issueType === "groupWithSubGroups") {
const issues = this.getIssues as IIssueGroupWithSubGroupsStructure;
if (!issues) return 0;
Object.keys(issues).map((sub_group_id) => {
Object.keys(issues[sub_group_id]).map((group_id) => {
issuesCount += issues[sub_group_id][group_id].length;
});
});
}
if (issueType === "ungrouped") {
const issues = this.getIssues as IIssueUnGroupedStructure;
if (!issues) return 0;
issuesCount = issues.length;
}
return issuesCount;
}
updateIssueStructure = async (group_id: string | null, sub_group_id: string | null, issue: IIssue) => { updateIssueStructure = async (group_id: string | null, sub_group_id: string | null, issue: IIssue) => {
const cycleId: string | null = this.rootStore?.cycle?.cycleId || null; const cycleId: string | null = this.rootStore?.cycle?.cycleId || null;
const issueType = this.getIssueType; const issueType = this.getIssueType;

View File

@ -1,7 +1,7 @@
import { action, makeObservable, runInAction } from "mobx"; import { action, makeObservable, runInAction } from "mobx";
// types // types
import { RootStore } from "../root"; import { RootStore } from "../root";
import { IIssueType } from "./cycle_issue.store"; import { IIssueType } from "store/issue";
export interface ICycleIssueCalendarViewStore { export interface ICycleIssueCalendarViewStore {
// actions // actions

View File

@ -6,15 +6,12 @@ import { IIssue } from "types";
// services // services
import { IssueService } from "services/issue"; import { IssueService } from "services/issue";
import { sortArrayByDate, sortArrayByPriority } from "constants/kanban-helpers"; import { sortArrayByDate, sortArrayByPriority } from "constants/kanban-helpers";
import {
export type IIssueType = "grouped" | "groupWithSubGroups" | "ungrouped"; IIssueGroupWithSubGroupsStructure,
export type IIssueGroupedStructure = { [group_id: string]: IIssue[] }; IIssueGroupedStructure,
export type IIssueGroupWithSubGroupsStructure = { IIssueType,
[group_id: string]: { IIssueUnGroupedStructure,
[sub_group_id: string]: IIssue[]; } from "store/issue";
};
};
export type IIssueUnGroupedStructure = IIssue[];
export interface IDraftIssueStore { export interface IDraftIssueStore {
loader: boolean; loader: boolean;

View File

@ -31,6 +31,7 @@ export interface IIssueStore {
// computed // computed
getIssueType: IIssueType | null; getIssueType: IIssueType | null;
getIssues: IIssueGroupedStructure | IIssueGroupWithSubGroupsStructure | IIssueUnGroupedStructure | null; getIssues: IIssueGroupedStructure | IIssueGroupWithSubGroupsStructure | IIssueUnGroupedStructure | null;
getIssuesCount: number;
// action // action
fetchIssues: (workspaceSlug: string, projectId: string) => Promise<any>; fetchIssues: (workspaceSlug: string, projectId: string) => Promise<any>;
updateIssueStructure: (group_id: string | null, sub_group_id: string | null, issue: IIssue) => void; updateIssueStructure: (group_id: string | null, sub_group_id: string | null, issue: IIssue) => void;
@ -68,6 +69,7 @@ export class IssueStore implements IIssueStore {
// computed // computed
getIssueType: computed, getIssueType: computed,
getIssues: computed, getIssues: computed,
getIssuesCount: computed,
// actions // actions
fetchIssues: action, fetchIssues: action,
updateIssueStructure: action, updateIssueStructure: action,
@ -120,6 +122,44 @@ export class IssueStore implements IIssueStore {
return this.issues?.[projectId]?.[issueType] || null; return this.issues?.[projectId]?.[issueType] || null;
} }
get getIssuesCount() {
const issueType = this.getIssueType;
let issuesCount = 0;
if (issueType === "grouped") {
const issues = this.getIssues as IIssueGroupedStructure;
if (!issues) return 0;
Object.keys(issues).map((group_id) => {
issuesCount += issues[group_id].length;
});
}
if (issueType === "groupWithSubGroups") {
const issues = this.getIssues as IIssueGroupWithSubGroupsStructure;
if (!issues) return 0;
Object.keys(issues).map((sub_group_id) => {
Object.keys(issues[sub_group_id]).map((group_id) => {
issuesCount += issues[sub_group_id][group_id].length;
});
});
}
if (issueType === "ungrouped") {
const issues = this.getIssues as IIssueUnGroupedStructure;
if (!issues) return 0;
issuesCount = issues.length;
}
return issuesCount;
}
updateIssueStructure = async (group_id: string | null, sub_group_id: string | null, issue: IIssue) => { updateIssueStructure = async (group_id: string | null, sub_group_id: string | null, issue: IIssue) => {
const projectId: string | null = issue?.project; const projectId: string | null = issue?.project;
const issueType = this.getIssueType; const issueType = this.getIssueType;

View File

@ -8,15 +8,12 @@ import { sortArrayByDate, sortArrayByPriority } from "constants/kanban-helpers";
// types // types
import { IIssue } from "types"; import { IIssue } from "types";
import { IBlockUpdateData } from "components/gantt-chart"; import { IBlockUpdateData } from "components/gantt-chart";
import {
export type IIssueType = "grouped" | "groupWithSubGroups" | "ungrouped"; IIssueGroupWithSubGroupsStructure,
export type IIssueGroupedStructure = { [group_id: string]: IIssue[] }; IIssueGroupedStructure,
export type IIssueGroupWithSubGroupsStructure = { IIssueType,
[group_id: string]: { IIssueUnGroupedStructure,
[sub_group_id: string]: IIssue[]; } from "store/issue";
};
};
export type IIssueUnGroupedStructure = IIssue[];
export interface IModuleIssueStore { export interface IModuleIssueStore {
loader: boolean; loader: boolean;
@ -32,6 +29,7 @@ export interface IModuleIssueStore {
// computed // computed
getIssueType: IIssueType | null; getIssueType: IIssueType | null;
getIssues: IIssueGroupedStructure | IIssueGroupWithSubGroupsStructure | IIssueUnGroupedStructure | null; getIssues: IIssueGroupedStructure | IIssueGroupWithSubGroupsStructure | IIssueUnGroupedStructure | null;
getIssuesCount: number;
// action // action
fetchIssues: (workspaceSlug: string, projectId: string, moduleId: string) => Promise<any>; fetchIssues: (workspaceSlug: string, projectId: string, moduleId: string) => Promise<any>;
updateIssueStructure: (group_id: string | null, sub_group_id: string | null, issue: IIssue) => void; updateIssueStructure: (group_id: string | null, sub_group_id: string | null, issue: IIssue) => void;
@ -76,6 +74,7 @@ export class ModuleIssueStore implements IModuleIssueStore {
// computed // computed
getIssueType: computed, getIssueType: computed,
getIssues: computed, getIssues: computed,
getIssuesCount: computed,
// actions // actions
fetchIssues: action, fetchIssues: action,
updateIssueStructure: action, updateIssueStructure: action,
@ -132,6 +131,44 @@ export class ModuleIssueStore implements IModuleIssueStore {
return this.issues?.[moduleId]?.[issueType] || null; return this.issues?.[moduleId]?.[issueType] || null;
} }
get getIssuesCount() {
const issueType = this.getIssueType;
let issuesCount = 0;
if (issueType === "grouped") {
const issues = this.getIssues as IIssueGroupedStructure;
if (!issues) return 0;
Object.keys(issues).map((group_id) => {
issuesCount += issues[group_id].length;
});
}
if (issueType === "groupWithSubGroups") {
const issues = this.getIssues as IIssueGroupWithSubGroupsStructure;
if (!issues) return 0;
Object.keys(issues).map((sub_group_id) => {
Object.keys(issues[sub_group_id]).map((group_id) => {
issuesCount += issues[sub_group_id][group_id].length;
});
});
}
if (issueType === "ungrouped") {
const issues = this.getIssues as IIssueUnGroupedStructure;
if (!issues) return 0;
issuesCount = issues.length;
}
return issuesCount;
}
updateIssueStructure = async (group_id: string | null, sub_group_id: string | null, issue: IIssue) => { updateIssueStructure = async (group_id: string | null, sub_group_id: string | null, issue: IIssue) => {
const moduleId: string | null = this.rootStore?.module?.moduleId; const moduleId: string | null = this.rootStore?.module?.moduleId;
const issueType = this.getIssueType; const issueType = this.getIssueType;

View File

@ -1,7 +1,7 @@
import { action, makeObservable, runInAction } from "mobx"; import { action, makeObservable, runInAction } from "mobx";
// types // types
import { RootStore } from "../root"; import { RootStore } from "../root";
import { IIssueType } from "./module_issue.store"; import { IIssueType } from "store/issue";
export interface IModuleIssueCalendarViewStore { export interface IModuleIssueCalendarViewStore {
// actions // actions

View File

@ -45,6 +45,7 @@ export interface IProjectViewIssuesStore {
// computed // computed
getIssues: IIssueGroupedStructure | IIssueGroupWithSubGroupsStructure | IIssueUnGroupedStructure | null; getIssues: IIssueGroupedStructure | IIssueGroupWithSubGroupsStructure | IIssueUnGroupedStructure | null;
getIssuesCount: number;
getIssueType: IIssueType | null; getIssueType: IIssueType | null;
} }
@ -86,6 +87,7 @@ export class ProjectViewIssuesStore implements IProjectViewIssuesStore {
// computed // computed
getIssueType: computed, getIssueType: computed,
getIssues: computed, getIssues: computed,
getIssuesCount: computed,
}); });
this.rootStore = _rootStore; this.rootStore = _rootStore;
@ -147,6 +149,44 @@ export class ProjectViewIssuesStore implements IProjectViewIssuesStore {
return this.viewIssues?.[viewId]?.[issueType] || null; return this.viewIssues?.[viewId]?.[issueType] || null;
} }
get getIssuesCount() {
const issueType = this.rootStore.issue.getIssueType;
let issuesCount = 0;
if (issueType === "grouped") {
const issues = this.getIssues as IIssueGroupedStructure;
if (!issues) return 0;
Object.keys(issues).map((group_id) => {
issuesCount += issues[group_id].length;
});
}
if (issueType === "groupWithSubGroups") {
const issues = this.getIssues as IIssueGroupWithSubGroupsStructure;
if (!issues) return 0;
Object.keys(issues).map((sub_group_id) => {
Object.keys(issues[sub_group_id]).map((group_id) => {
issuesCount += issues[sub_group_id][group_id].length;
});
});
}
if (issueType === "ungrouped") {
const issues = this.getIssues as IIssueUnGroupedStructure;
if (!issues) return 0;
issuesCount = issues.length;
}
return issuesCount;
}
updateIssueStructure = async (group_id: string | null, sub_group_id: string | null, issue: IIssue) => { updateIssueStructure = async (group_id: string | null, sub_group_id: string | null, issue: IIssue) => {
const viewId: string | null = this.rootStore.projectViews.viewId; const viewId: string | null = this.rootStore.projectViews.viewId;
const issueType = this.rootStore.issue.getIssueType; const issueType = this.rootStore.issue.getIssueType;

View File

@ -6,7 +6,7 @@ import { UserService } from "services/user.service";
import { WorkspaceService } from "services/workspace.service"; import { WorkspaceService } from "services/workspace.service";
// interfaces // interfaces
import { IUser, IUserSettings } from "types/users"; import { IUser, IUserSettings } from "types/users";
import { IWorkspaceMember, IProjectMember } from "types"; import { IWorkspaceMemberMe, IProjectMember } from "types";
export interface IUserStore { export interface IUserStore {
loader: boolean; loader: boolean;
@ -17,7 +17,7 @@ export interface IUserStore {
dashboardInfo: any; dashboardInfo: any;
workspaceMemberInfo: any; workspaceMemberInfo: IWorkspaceMemberMe | null;
hasPermissionToWorkspace: boolean | null; hasPermissionToWorkspace: boolean | null;
projectMemberInfo: IProjectMember | null; projectMemberInfo: IProjectMember | null;
@ -27,7 +27,7 @@ export interface IUserStore {
fetchCurrentUser: () => Promise<IUser>; fetchCurrentUser: () => Promise<IUser>;
fetchCurrentUserSettings: () => Promise<IUserSettings>; fetchCurrentUserSettings: () => Promise<IUserSettings>;
fetchUserWorkspaceInfo: (workspaceSlug: string) => Promise<IWorkspaceMember>; fetchUserWorkspaceInfo: (workspaceSlug: string) => Promise<IWorkspaceMemberMe>;
fetchUserProjectInfo: (workspaceSlug: string, projectId: string) => Promise<IProjectMember>; fetchUserProjectInfo: (workspaceSlug: string, projectId: string) => Promise<IProjectMember>;
fetchUserDashboardInfo: (workspaceSlug: string, month: number) => Promise<any>; fetchUserDashboardInfo: (workspaceSlug: string, month: number) => Promise<any>;
@ -45,7 +45,7 @@ class UserStore implements IUserStore {
dashboardInfo: any = null; dashboardInfo: any = null;
workspaceMemberInfo: any = null; workspaceMemberInfo: IWorkspaceMemberMe | null = null;
hasPermissionToWorkspace: boolean | null = null; hasPermissionToWorkspace: boolean | null = null;
projectMemberInfo: IProjectMember | null = null; projectMemberInfo: IProjectMember | null = null;

View File

@ -26,6 +26,15 @@ export interface IWorkspaceStore {
fetchWorkspaceLabels: (workspaceSlug: string) => Promise<void>; fetchWorkspaceLabels: (workspaceSlug: string) => Promise<void>;
fetchWorkspaceMembers: (workspaceSlug: string) => Promise<void>; fetchWorkspaceMembers: (workspaceSlug: string) => Promise<void>;
// workspace write operations
createWorkspace: (data: Partial<IWorkspace>) => Promise<IWorkspace>;
updateWorkspace: (workspaceSlug: string, data: Partial<IWorkspace>) => Promise<IWorkspace>;
deleteWorkspace: (workspaceSlug: string) => Promise<void>;
// members write operations
updateMember: (workspaceSlug: string, memberId: string, data: Partial<IWorkspaceMember>) => Promise<void>;
removeMember: (workspaceSlug: string, memberId: string) => Promise<void>;
// computed // computed
currentWorkspace: IWorkspace | null; currentWorkspace: IWorkspace | null;
workspaceLabels: IIssueLabels[] | null; workspaceLabels: IIssueLabels[] | null;
@ -72,6 +81,15 @@ export class WorkspaceStore implements IWorkspaceStore {
fetchWorkspaceLabels: action, fetchWorkspaceLabels: action,
fetchWorkspaceMembers: action, fetchWorkspaceMembers: action,
// workspace write operations
createWorkspace: action,
updateWorkspace: action,
deleteWorkspace: action,
// members write operations
updateMember: action,
removeMember: action,
// computed // computed
currentWorkspace: computed, currentWorkspace: computed,
workspaceLabels: computed, workspaceLabels: computed,
@ -189,7 +207,6 @@ export class WorkspaceStore implements IWorkspaceStore {
* fetch workspace members using workspace slug * fetch workspace members using workspace slug
* @param workspaceSlug * @param workspaceSlug
*/ */
fetchWorkspaceMembers = async (workspaceSlug: string) => { fetchWorkspaceMembers = async (workspaceSlug: string) => {
try { try {
runInAction(() => { runInAction(() => {
@ -214,4 +231,174 @@ export class WorkspaceStore implements IWorkspaceStore {
}); });
} }
}; };
/**
* create workspace using the workspace data
* @param data
*/
createWorkspace = async (data: Partial<IWorkspace>) => {
try {
runInAction(() => {
this.loader = true;
this.error = null;
});
const user = this.rootStore.user.currentUser ?? undefined;
const response = await this.workspaceService.createWorkspace(data, user);
runInAction(() => {
this.loader = false;
this.error = null;
this.workspaces = [...this.workspaces, response];
});
return response;
} catch (error) {
runInAction(() => {
this.loader = false;
this.error = error;
});
throw error;
}
};
/**
* update workspace using the workspace slug and new workspace data
* @param workspaceSlug
* @param data
*/
updateWorkspace = async (workspaceSlug: string, data: Partial<IWorkspace>) => {
const newWorkspaces = this.workspaces?.map((w) => (w.slug === workspaceSlug ? { ...w, ...data } : w));
try {
runInAction(() => {
this.loader = true;
this.error = null;
});
const user = this.rootStore.user.currentUser ?? undefined;
const response = await this.workspaceService.updateWorkspace(workspaceSlug, data, user);
runInAction(() => {
this.loader = false;
this.error = null;
this.workspaces = newWorkspaces;
});
return response;
} catch (error) {
runInAction(() => {
this.loader = false;
this.error = error;
});
throw error;
}
};
/**
* delete workspace using the workspace slug
* @param workspaceSlug
*/
deleteWorkspace = async (workspaceSlug: string) => {
const newWorkspaces = this.workspaces?.filter((w) => w.slug !== workspaceSlug);
try {
runInAction(() => {
this.loader = true;
this.error = null;
});
const user = this.rootStore.user.currentUser ?? undefined;
await this.workspaceService.deleteWorkspace(workspaceSlug, user);
runInAction(() => {
this.loader = false;
this.error = null;
this.workspaces = newWorkspaces;
});
} catch (error) {
runInAction(() => {
this.loader = false;
this.error = error;
});
throw error;
}
};
/**
* update workspace member using workspace slug and member id and data
* @param workspaceSlug
* @param memberId
* @param data
*/
updateMember = async (workspaceSlug: string, memberId: string, data: Partial<IWorkspaceMember>) => {
const members = this.members?.[workspaceSlug];
members?.map((m) => (m.id === memberId ? { ...m, ...data } : m));
try {
runInAction(() => {
this.loader = true;
this.error = null;
});
await this.workspaceService.updateWorkspaceMember(workspaceSlug, memberId, data);
runInAction(() => {
this.loader = false;
this.error = null;
this.members = {
...this.members,
[workspaceSlug]: members,
};
});
} catch (error) {
runInAction(() => {
this.loader = false;
this.error = error;
});
throw error;
}
};
/**
* remove workspace member using workspace slug and member id
* @param workspaceSlug
* @param memberId
*/
removeMember = async (workspaceSlug: string, memberId: string) => {
const members = this.members?.[workspaceSlug];
members?.filter((m) => m.id !== memberId);
try {
runInAction(() => {
this.loader = true;
this.error = null;
});
await this.workspaceService.deleteWorkspaceMember(workspaceSlug, memberId);
runInAction(() => {
this.loader = false;
this.error = null;
this.members = {
...this.members,
[workspaceSlug]: members,
};
});
} catch (error) {
runInAction(() => {
this.loader = false;
this.error = error;
});
throw error;
}
};
} }

View File

@ -9,7 +9,7 @@ import {
IIssueDisplayFilterOptions, IIssueDisplayFilterOptions,
IIssueDisplayProperties, IIssueDisplayProperties,
IIssueFilterOptions, IIssueFilterOptions,
IWorkspaceMember, IWorkspaceMemberMe,
IWorkspaceViewProps, IWorkspaceViewProps,
TIssueParams, TIssueParams,
} from "types"; } from "types";
@ -25,7 +25,7 @@ export interface IWorkspaceFilterStore {
workspaceDisplayProperties: IIssueDisplayProperties; workspaceDisplayProperties: IIssueDisplayProperties;
// actions // actions
fetchUserWorkspaceFilters: (workspaceSlug: string) => Promise<IWorkspaceMember>; fetchUserWorkspaceFilters: (workspaceSlug: string) => Promise<IWorkspaceMemberMe>;
updateWorkspaceFilters: (workspaceSlug: string, filterToUpdate: Partial<IWorkspaceViewProps>) => Promise<void>; updateWorkspaceFilters: (workspaceSlug: string, filterToUpdate: Partial<IWorkspaceViewProps>) => Promise<void>;
// computed // computed

View File

@ -56,6 +56,7 @@ export interface IUserLite {
avatar: string; avatar: string;
created_at: Date; created_at: Date;
display_name: string; display_name: string;
email?: string;
first_name: string; first_name: string;
readonly id: string; readonly id: string;
is_bot: boolean; is_bot: boolean;

View File

@ -1,4 +1,4 @@
import type { IProjectMember, IUser, IUserMemberLite, IWorkspaceViewProps } from "types"; import type { IProjectMember, IUser, IUserLite, IUserMemberLite, IWorkspaceViewProps } from "types";
export interface IWorkspace { export interface IWorkspace {
readonly id: string; readonly id: string;
@ -56,16 +56,29 @@ export type Properties = {
}; };
export interface IWorkspaceMember { export interface IWorkspaceMember {
readonly id: string;
workspace: IWorkspace;
member: IUserMemberLite;
role: 5 | 10 | 15 | 20;
company_role: string | null; company_role: string | null;
view_props: IWorkspaceViewProps;
created_at: Date; created_at: Date;
updated_at: Date;
created_by: string; created_by: string;
id: string;
member: IUserLite;
role: 5 | 10 | 15 | 20;
updated_at: Date;
updated_by: string; updated_by: string;
workspace: IWorkspaceLite;
}
export interface IWorkspaceMemberMe {
company_role: string | null;
created_at: Date;
created_by: string;
default_props: IWorkspaceViewProps;
id: string;
member: string;
role: 5 | 10 | 15 | 20;
updated_at: Date;
updated_by: string;
view_props: IWorkspaceViewProps;
workspace: string;
} }
export interface ILastActiveWorkspaceDetails { export interface ILastActiveWorkspaceDetails {

View File

@ -2755,7 +2755,7 @@
dependencies: dependencies:
"@types/react" "*" "@types/react" "*"
"@types/react-color@^3.0.6": "@types/react-color@^3.0.6", "@types/react-color@^3.0.9":
version "3.0.9" version "3.0.9"
resolved "https://registry.yarnpkg.com/@types/react-color/-/react-color-3.0.9.tgz#8dbb0d798f2979c3d7e2e26dd46321e80da950b4" resolved "https://registry.yarnpkg.com/@types/react-color/-/react-color-3.0.9.tgz#8dbb0d798f2979c3d7e2e26dd46321e80da950b4"
integrity sha512-Ojyc6jySSKvM6UYQrZxaYe0JZXtgHHXwR2q9H4MhcNCswFdeZH1owYZCvPtdHtMOfh7t8h1fY0Gd0nvU1JGDkQ== integrity sha512-Ojyc6jySSKvM6UYQrZxaYe0JZXtgHHXwR2q9H4MhcNCswFdeZH1owYZCvPtdHtMOfh7t8h1fY0Gd0nvU1JGDkQ==
@ -7393,7 +7393,7 @@ react-markdown@^8.0.7:
unist-util-visit "^4.0.0" unist-util-visit "^4.0.0"
vfile "^5.0.0" vfile "^5.0.0"
react-moveable@^0.54.1: react-moveable@^0.54.1, react-moveable@^0.54.2:
version "0.54.2" version "0.54.2"
resolved "https://registry.yarnpkg.com/react-moveable/-/react-moveable-0.54.2.tgz#87ce9af3499dc1c8218bce7e174b10264c1bbecf" resolved "https://registry.yarnpkg.com/react-moveable/-/react-moveable-0.54.2.tgz#87ce9af3499dc1c8218bce7e174b10264c1bbecf"
integrity sha512-NGaVLbn0i9pb3+BWSKGWFqI/Mgm4+WMeWHxXXQ4Qi1tHxWCXrUrbGvpxEpt69G/hR7dez+/m68ex+fabjnvcUg== integrity sha512-NGaVLbn0i9pb3+BWSKGWFqI/Mgm4+WMeWHxXXQ4Qi1tHxWCXrUrbGvpxEpt69G/hR7dez+/m68ex+fabjnvcUg==