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
if sub_group_by and sub_group_by == group_by:
@ -591,12 +590,12 @@ class CycleIssueViewSet(BaseViewSet):
if group_by:
grouped_results = group_results(issues_data, group_by, sub_group_by)
return Response(
{"data": grouped_results, "total_issues": total_issues},
grouped_results,
status=status.HTTP_200_OK,
)
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):

View File

@ -217,7 +217,6 @@ class IssueViewSet(BaseViewSet):
else:
issue_queryset = issue_queryset.order_by(order_by_param)
total_issues = issue_queryset.count()
issues = IssueLiteSerializer(issue_queryset, many=True).data
## Grouping the results
@ -232,12 +231,12 @@ class IssueViewSet(BaseViewSet):
if group_by:
grouped_results = group_results(issues, group_by, sub_group_by)
return Response(
{"data": grouped_results, "total_issues": total_issues},
grouped_results,
status=status.HTTP_200_OK,
)
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:
issue_queryset = issue_queryset.order_by(order_by_param)
total_issues = issue_queryset.count()
issues = IssueLiteSerializer(issue_queryset, many=True).data
## Grouping the results
@ -441,12 +439,12 @@ class UserWorkSpaceIssues(BaseAPIView):
if group_by:
grouped_results = group_results(issues, group_by, sub_group_by)
return Response(
{"data": grouped_results, "total_issues": total_issues},
grouped_results,
status=status.HTTP_200_OK,
)
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:
issue_queryset = issue_queryset.order_by(order_by_param)
total_issues = issue_queryset.count()
issues = IssueLiteSerializer(issue_queryset, many=True).data
## Grouping the results
@ -2159,12 +2156,12 @@ class IssueDraftViewSet(BaseViewSet):
if group_by:
grouped_results = group_results(issues, group_by)
return Response(
{"data": grouped_results, "total_issues": total_issues},
grouped_results,
status=status.HTTP_200_OK,
)
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):

View File

@ -364,7 +364,6 @@ class ModuleIssueViewSet(BaseViewSet):
.values("count")
)
)
total_issues = issues.count()
issues_data = IssueStateSerializer(issues, many=True).data
if sub_group_by and sub_group_by == group_by:
@ -376,12 +375,12 @@ class ModuleIssueViewSet(BaseViewSet):
if group_by:
grouped_results = group_results(issues_data, group_by, sub_group_by)
return Response(
{"data": grouped_results, "total_issues": total_issues},
grouped_results,
status=status.HTTP_200_OK,
)
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):

View File

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

View File

@ -1223,7 +1223,6 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView):
else:
issue_queryset = issue_queryset.order_by(order_by_param)
total_issues = issue_queryset.count()
issues = IssueLiteSerializer(issue_queryset, many=True).data
## Grouping the results
@ -1231,12 +1230,12 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView):
if group_by:
grouped_results = group_results(issues, group_by)
return Response(
{"data": grouped_results, "total_issues": total_issues},
grouped_results,
status=status.HTTP_200_OK,
)
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 { ISvgIcons } from "./type";
import { ISvgIcons } from "../type";
export const ContrastIcon: React.FC<ISvgIcons> = ({
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 { ISvgIcons } from "./type";
import { ISvgIcons } from "../type";
export const DoubleCircleIcon: React.FC<ISvgIcons> = ({
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 "./contrast-icon";
export * from "./dice-icon";
export * from "./layers-icon";
export * from "./photo-filter-icon";
@ -7,7 +6,6 @@ export * from "./archive-icon";
export * from "./admin-profile-icon";
export * from "./create-icon";
export * from "./subscribe-icon";
export * from "./double-circle-icon";
export * from "./external-link-icon";
export * from "./copy-icon";
export * from "./layer-stack";
@ -20,6 +18,7 @@ export * from "./blocked-icon";
export * from "./blocker-icon";
export * from "./related-icon";
export * from "./module";
export * from "./cycle";
export * from "./github-icon";
export * from "./discord-icon";
export * from "./transfer-icon";

View File

@ -36,7 +36,7 @@ type Props = {
module?: IModule;
roundedTab?: boolean;
noBackground?: boolean;
isPeekModuleDetails?: boolean;
isPeekView?: boolean;
};
export const SidebarProgressStats: React.FC<Props> = ({
@ -46,7 +46,7 @@ export const SidebarProgressStats: React.FC<Props> = ({
module,
roundedTab,
noBackground,
isPeekModuleDetails = false,
isPeekView = false,
}) => {
const { filters, setFilters } = useIssuesView();
@ -154,7 +154,7 @@ export const SidebarProgressStats: React.FC<Props> = ({
}
completed={assignee.completed_issues}
total={assignee.total_issues}
{...(!isPeekModuleDetails && {
{...(!isPeekView && {
onClick: () => {
if (filters?.assignees?.includes(assignee.assignee_id ?? ""))
setFilters({
@ -213,7 +213,7 @@ export const SidebarProgressStats: React.FC<Props> = ({
}
completed={label.completed_issues}
total={label.total_issues}
{...(!isPeekModuleDetails && {
{...(!isPeekView && {
onClick: () => {
if (filters.labels?.includes(label.label_id ?? ""))
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 { useRouter } from "next/router";
// next imports
import Link from "next/link";
// headless ui
import { Disclosure, Transition } from "@headlessui/react";
// hooks
import useToast from "hooks/use-toast";
// components
import { SingleProgressStats } from "components/core";
import { CycleCreateUpdateModal, CycleDeleteModal } from "components/cycles";
// ui
import { AssigneesList } from "components/ui/avatar";
import { CustomMenu, Tooltip, LinearProgressIndicator, ContrastIcon, RunningIcon } from "@plane/ui";
import { CustomMenu, Tooltip, LayersIcon, CycleGroupIcon } from "@plane/ui";
// icons
import {
AlarmClock,
AlertTriangle,
ArrowRight,
CalendarDays,
ChevronDown,
LinkIcon,
Pencil,
Star,
Target,
Trash2,
} from "lucide-react";
import { Info, LinkIcon, Pencil, Star, Trash2 } from "lucide-react";
// helpers
import { getDateRangeStatus, renderShortDateWithYearFormat, findHowManyDaysLeft } from "helpers/date-time.helper";
import { copyTextToClipboard, truncateText } from "helpers/string.helper";
import {
getDateRangeStatus,
findHowManyDaysLeft,
renderShortDate,
renderShortMonthDate,
} from "helpers/date-time.helper";
import { copyTextToClipboard } from "helpers/string.helper";
// types
import { ICycle } from "types";
// store
import { useMobxStore } from "lib/mobx/store-provider";
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",
},
];
// constants
import { CYCLE_STATUS } from "constants/cycle";
export interface ICyclesBoardCard {
workspaceSlug: string;
@ -81,7 +49,34 @@ export const CyclesBoardCard: FC<ICyclesBoardCard> = (props) => {
const endDate = new Date(cycle.end_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 : "";
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>) => {
e.preventDefault();
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 (
<div>
<CycleCreateUpdateModal
@ -152,267 +155,119 @@ export const CyclesBoardCard: FC<ICyclesBoardCard> = (props) => {
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}`}>
<a className="w-full">
<div className="flex h-full flex-col gap-4 rounded-b-[10px] p-4">
<div className="flex items-center justify-between gap-1">
<span className="flex items-center gap-1">
<span className="h-5 w-5">
<ContrastIcon
className="h-5 w-5"
color={`${
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>
<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">
<div>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-3">
<span className="flex-shrink-0">
<CycleGroupIcon cycleGroup={cycleStatus} className="h-3.5 w-3.5" />
</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
className={`rounded-full px-1.5 py-0.5
${
cycleStatus === "current"
? "bg-green-600/5 text-green-600"
: 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"
: ""
}`}
className="flex items-center justify-center text-xs text-center h-6 w-20 rounded-sm"
style={{
color: currentCycle.color,
backgroundColor: `${currentCycle.color}20`,
}}
>
{cycleStatus === "current" ? (
<span className="flex gap-1 whitespace-nowrap">
<RunningIcon className="h-4 w-4" />
{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
)}
{currentCycle.value === "current"
? `${findHowManyDaysLeft(cycle.end_date ?? new Date())} ${currentCycle.label}`
: `${currentCycle.label}`}
</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>
<div className="flex justify-between items-end">
<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>
<button onClick={openCycleOverview}>
<Info className="h-4 w-4 text-custom-text-400" />
</button>
</div>
</div>
</a>
</Link>
</div>
<div className="flex h-full flex-col rounded-b-[10px]">
<Disclosure>
{({ open }) => (
<div
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 ${
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 className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5 text-custom-text-200">
<LayersIcon className="h-4 w-4 text-custom-text-300" />
<span className="text-xs text-custom-text-300">{issueCount}</span>
</div>
)}
</Disclosure>
</div>
</div>
{cycle.assignees.length > 0 && (
<Tooltip tooltipContent={`${cycle.assignees.length} Members`}>
<div className="flex items-center gap-1 cursor-default">
<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>
);
};

View File

@ -2,26 +2,41 @@ import { FC } from "react";
// types
import { ICycle } from "types";
// components
import { CyclesBoardCard } from "components/cycles";
import { CyclePeekOverview, CyclesBoardCard } from "components/cycles";
export interface ICyclesBoard {
cycles: ICycle[];
filter: string;
workspaceSlug: string;
projectId: string;
peekCycle: string;
}
export const CyclesBoard: FC<ICyclesBoard> = (props) => {
const { cycles, filter, workspaceSlug, projectId } = props;
const { cycles, filter, workspaceSlug, projectId, peekCycle } = props;
return (
<div className="grid grid-cols-1 gap-9 lg:grid-cols-2 xl:grid-cols-3">
<>
{cycles.length > 0 ? (
<>
{cycles.map((cycle) => (
<CyclesBoardCard key={cycle.id} workspaceSlug={workspaceSlug} projectId={projectId} cycle={cycle} />
))}
</>
<div className="h-full w-full">
<div className="flex justify-between h-full w-full">
<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="space-y-2">
@ -50,6 +65,6 @@ export const CyclesBoard: FC<ICyclesBoard> = (props) => {
</div>
</div>
)}
</div>
</>
);
};

View File

@ -1,29 +1,30 @@
import { FC, MouseEvent, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/router";
// stores
import { useMobxStore } from "lib/mobx/store-provider";
// hooks
import useToast from "hooks/use-toast";
// components
import { CycleCreateUpdateModal, CycleDeleteModal } from "components/cycles";
import { AssigneesList } from "components/ui";
// ui
import { CustomMenu, RadialProgressBar, Tooltip, LinearProgressIndicator, ContrastIcon, RunningIcon } from "@plane/ui";
import { CustomMenu, Tooltip, CircularProgressIndicator, CycleGroupIcon } from "@plane/ui";
// icons
import {
AlarmClock,
AlertTriangle,
ArrowRight,
CalendarDays,
LinkIcon,
Pencil,
Star,
Target,
Trash2,
} from "lucide-react";
import { Check, Info, LinkIcon, Pencil, Star, Trash2, User2 } from "lucide-react";
// 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";
// types
import { ICycle } from "types";
import { useMobxStore } from "lib/mobx/store-provider";
// constants
import { CYCLE_STATUS } from "constants/cycle";
type TCyclesListItem = {
cycle: ICycle;
@ -35,34 +36,6 @@ type TCyclesListItem = {
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) => {
const { cycle, workspaceSlug, projectId } = props;
// store
@ -78,7 +51,28 @@ export const CyclesListItem: FC<TCyclesListItem> = (props) => {
const endDate = new Date(cycle.end_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 : "";
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>) => {
e.preventDefault();
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 (
<>
<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
data={cycle}
isOpen={updateModal}
@ -348,7 +142,6 @@ export const CyclesListItem: FC<TCyclesListItem> = (props) => {
workspaceSlug={workspaceSlug}
projectId={projectId}
/>
<CycleDeleteModal
cycle={cycle}
isOpen={deleteModal}
@ -356,6 +149,110 @@ export const CyclesListItem: FC<TCyclesListItem> = (props) => {
workspaceSlug={workspaceSlug}
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";
// components
import { CyclesListItem } from "./cycles-list-item";
import { CyclePeekOverview, CyclesListItem } from "components/cycles";
// ui
import { Loader } from "@plane/ui";
// types
@ -17,18 +18,22 @@ export const CyclesList: FC<ICyclesList> = (props) => {
const { cycles, filter, workspaceSlug, projectId } = props;
return (
<div>
<>
{cycles ? (
<>
{cycles.length > 0 ? (
<div className="divide-y divide-custom-border-200">
{cycles.map((cycle) => (
<div className="hover:bg-custom-background-80" key={cycle.id}>
<div className="flex flex-col border-custom-border-200">
<div className="h-full overflow-y-auto">
<div className="flex justify-between h-full w-full">
<div className="flex flex-col h-full w-full overflow-y-auto">
{cycles.map((cycle) => (
<CyclesListItem cycle={cycle} workspaceSlug={workspaceSlug} projectId={projectId} />
</div>
))}
</div>
))}
<CyclePeekOverview
projectId={projectId?.toString() ?? ""}
workspaceSlug={workspaceSlug?.toString() ?? ""}
/>
</div>
</div>
) : (
<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>
)}
</div>
</>
);
};

View File

@ -15,10 +15,11 @@ export interface ICyclesView {
layout: TCycleLayout;
workspaceSlug: string;
projectId: string;
peekCycle: string;
}
export const CyclesView: FC<ICyclesView> = observer((props) => {
const { filter, layout, workspaceSlug, projectId } = props;
const { filter, layout, workspaceSlug, projectId, peekCycle } = props;
// store
const { cycle: cycleStore } = useMobxStore();
@ -50,7 +51,13 @@ export const CyclesView: FC<ICyclesView> = observer((props) => {
{layout === "board" && (
<>
{!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.Item height="200px" />

View File

@ -17,3 +17,5 @@ export * from "./cycles-board";
export * from "./cycles-board-card";
export * from "./cycles-gantt";
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 { CycleDeleteModal } from "components/cycles/delete-modal";
// ui
import { CustomRangeDatePicker } from "components/ui";
import { CustomMenu, Loader, ProgressBar } from "@plane/ui";
import { Avatar, CustomRangeDatePicker } from "components/ui";
import { CustomMenu, Loader, LayersIcon } from "@plane/ui";
// icons
import {
CalendarDays,
ChevronDown,
File,
MoveRight,
LinkIcon,
PieChart,
Trash2,
UserCircle2,
AlertCircle,
} from "lucide-react";
import { ChevronDown, LinkIcon, Trash2, UserCircle2, AlertCircle, ChevronRight, MoveRight } from "lucide-react";
// helpers
import { capitalizeFirstLetter, copyUrlToClipboard } from "helpers/string.helper";
import { copyUrlToClipboard } from "helpers/string.helper";
import {
findHowManyDaysLeft,
getDateRangeStatus,
isDateGreaterThanToday,
renderDateFormat,
renderShortDateWithYearFormat,
renderShortDate,
renderShortMonthDate,
} from "helpers/date-time.helper";
// types
import { ICycle } from "types";
// fetch-keys
import { CYCLE_DETAILS } from "constants/fetch-keys";
import { CYCLE_STATUS } from "constants/cycle";
type Props = {
isOpen: boolean;
cycleId: string;
handleClose: () => void;
};
// services
@ -52,12 +45,12 @@ const cycleService = new CycleService();
// TODO: refactor the whole component
export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
const { isOpen, cycleId } = props;
const { cycleId, handleClose } = props;
const [cycleDeleteModal, setCycleDeleteModal] = useState(false);
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { workspaceSlug, projectId, peekCycle } = router.query;
const { user: userStore, cycle: cycleDetailsStore } = useMobxStore();
@ -280,6 +273,22 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
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 (
<>
{cycleDetails && workspaceSlug && projectId && (
@ -291,327 +300,266 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
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
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("start_date") ? watch("start_date") : cycleDetails?.start_date}
onChange={(val) => {
if (val) {
handleStartDateChange(val);
}
}}
startDate={watch("start_date") ? `${watch("start_date")}` : null}
endDate={watch("end_date") ? `${watch("end_date")}` : null}
maxDate={new Date(`${watch("end_date")}`)}
selectsStart
/>
</Popover.Panel>
</Transition>
</>
)}
</Popover>
<span>
<MoveRight className="h-3 w-3 text-custom-text-200" />
</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" />
{cycleDetails ? (
<>
<div className="flex items-center justify-between w-full">
<div>
{peekCycle && (
<button
className="flex items-center justify-center h-5 w-5 rounded-full bg-custom-border-300"
onClick={() => handleClose()}
>
<ChevronRight className="h-3 w-3 text-white stroke-2" />
</button>
)}
</div>
<div className="flex items-center gap-3.5">
<button onClick={handleCopyText}>
<LinkIcon className="h-3 w-3 text-custom-text-300" />
</button>
{!isCompleted && (
<CustomMenu width="lg" ellipsis>
<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>
)}
</div>
</div>
<span>
{renderShortDateWithYearFormat(
new Date(`${watch("end_date") ? watch("end_date") : cycleDetails?.end_date}`),
"End date"
)}
</span>
</Popover.Button>
<div className="flex flex-col gap-3">
<h4 className="text-xl font-semibold break-words w-full text-custom-text-100">{cycleDetails.name}</h4>
<div className="flex items-center gap-5">
{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(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
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>
<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("start_date") ? watch("start_date") : cycleDetails?.start_date}
onChange={(val) => {
if (val) {
handleStartDateChange(val);
}
}}
startDate={watch("start_date") ? `${watch("start_date")}` : null}
endDate={watch("end_date") ? `${watch("end_date")}` : null}
maxDate={new Date(`${watch("end_date")}`)}
selectsStart
/>
</Popover.Panel>
</Transition>
</>
)}
</Popover>
<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 className="flex w-full flex-col gap-6 px-6 py-6">
<div className="flex w-full flex-col items-start justify-start gap-2">
<div className="flex w-full items-start justify-between gap-2">
<div className="max-w-[300px]">
<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>
{cycleDetails.description && (
<span className="whitespace-normal text-sm leading-5 py-2.5 text-custom-text-200 break-words w-full">
{cycleDetails.description}
</span>
)}
<span className="whitespace-normal text-sm leading-5 text-custom-text-200 break-words w-full">
{cycleDetails.description}
</span>
</div>
<div className="flex flex-col gap-4 text-sm">
<div className="flex items-center justify-start gap-1">
<div className="flex w-40 items-center justify-start gap-2 text-custom-text-200">
<UserCircle2 className="h-5 w-5" />
<span>Lead</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 className="flex flex-col gap-5 pt-2.5 pb-6">
<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">
<UserCircle2 className="h-4 w-4" />
<span className="text-base">Lead</span>
</div>
<div className="flex items-center w-1/2 rounded-sm">
<div className="flex items-center gap-2.5">
<Avatar user={cycleDetails.owned_by} />
<span className="text-sm text-custom-text-200">{cycleDetails.owned_by.display_name}</span>
</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 }) => (
<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">
<span className="font-medium text-custom-text-200">Progress</span>
{!open && progressPercentage ? (
<span className="rounded bg-[#09A953]/10 px-1.5 py-0.5 text-xs text-[#09A953]">
</div>
<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}%` : ""}
</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>
{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>
<Transition show={open}>
<Disclosure.Panel>
{isStartValid && isEndValid ? (
<div className=" h-full w-full py-4">
<div className="flex items-start justify-between gap-4 py-2 text-xs">
<div className="flex items-center gap-1">
<span>
<File className="h-3 w-3 text-custom-text-200" />
</span>
<span>
Pending Issues -{" "}
{cycleDetails.total_issues -
(cycleDetails.completed_issues + cycleDetails.cancelled_issues)}
</span>
<div className="flex flex-col gap-3">
{isStartValid && isEndValid ? (
<div className=" h-full w-full pt-4">
<div className="flex items-start gap-4 py-2 text-xs">
<div className="flex items-center gap-3 text-custom-text-100">
<div className="flex items-center justify-center gap-1">
<span className="h-2.5 w-2.5 rounded-full bg-[#A9BBD0]" />
<span>Ideal</span>
</div>
<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 className="flex items-center gap-3 text-custom-text-100">
<div className="flex items-center justify-center gap-1">
<span className="h-2.5 w-2.5 rounded-full bg-[#A9BBD0]" />
<span>Ideal</span>
</div>
<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 className="relative h-40 w-80">
<ProgressChart
distribution={cycleDetails.distribution.completion_chart}
startDate={cycleDetails.start_date ?? ""}
endDate={cycleDetails.end_date ?? ""}
totalIssues={cycleDetails.total_issues}
/>
</div>
</div>
<div className="relative">
<ProgressChart
distribution={cycleDetails.distribution.completion_chart}
startDate={cycleDetails.start_date ?? ""}
endDate={cycleDetails.end_date ?? ""}
) : (
""
)}
{cycleDetails.total_issues > 0 && (
<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}
isPeekView={Boolean(peekCycle)}
/>
</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>
<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>
</Transition>
</div>
)}
</Disclosure>
</div>
</>
) : (
<Loader className="px-5">
<div className="space-y-2">
<Loader.Item height="15px" width="50%" />
<Loader.Item height="15px" width="30%" />
</div>
<div className="mt-8 space-y-3">
<Loader.Item height="30px" />
<Loader.Item height="30px" />
<Loader.Item height="30px" />
</div>
</Loader>
)}
</div>
</div>
</>
) : (
<Loader className="px-5">
<div className="space-y-2">
<Loader.Item height="15px" width="50%" />
<Loader.Item height="15px" width="30%" />
</div>
<div className="mt-8 space-y-3">
<Loader.Item height="30px" />
<Loader.Item height="30px" />
<Loader.Item height="30px" />
</div>
</Loader>
)}
</>
);
});

View File

@ -31,7 +31,7 @@ export const JiraImportUsers: FC = () => {
const { data: members } = useSWR(
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) => ({

View File

@ -1,6 +1,5 @@
export * from "./attachment";
export * from "./comment";
export * from "./my-issues";
export * from "./sidebar-select";
export * from "./view-select";
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
export * from "./filters";
export * from "./empty-states";
export * from "./quick-action-dropdowns";
// layouts

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -11,6 +11,7 @@ import {
ModuleListLayout,
ProjectViewAppliedFiltersRoot,
ProjectViewCalendarLayout,
ProjectViewEmptyState,
ProjectViewGanttLayout,
ProjectViewSpreadsheetLayout,
} from "components/issues";
@ -48,22 +49,28 @@ export const ProjectViewLayoutRoot: React.FC = observer(() => {
const activeLayout = issueFilterStore.userDisplayFilters.layout;
const issueCount = projectViewIssuesStore.getIssuesCount;
return (
<div className="relative h-full w-full flex flex-col overflow-hidden">
<ProjectViewAppliedFiltersRoot />
<div className="h-full w-full overflow-y-auto">
{activeLayout === "list" ? (
<ModuleListLayout />
) : activeLayout === "kanban" ? (
<ModuleKanBanLayout />
) : activeLayout === "calendar" ? (
<ProjectViewCalendarLayout />
) : activeLayout === "gantt_chart" ? (
<ProjectViewGanttLayout />
) : activeLayout === "spreadsheet" ? (
<ProjectViewSpreadsheetLayout />
) : null}
</div>
{(activeLayout === "list" || activeLayout === "spreadsheet") && issueCount === 0 ? (
<ProjectViewEmptyState />
) : (
<div className="h-full w-full overflow-y-auto">
{activeLayout === "list" ? (
<ModuleListLayout />
) : activeLayout === "kanban" ? (
<ModuleKanBanLayout />
) : activeLayout === "calendar" ? (
<ProjectViewCalendarLayout />
) : activeLayout === "gantt_chart" ? (
<ProjectViewGanttLayout />
) : activeLayout === "spreadsheet" ? (
<ProjectViewSpreadsheetLayout />
) : null}
</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) => {
const { module } = props;
const [editModuleModal, setEditModuleModal] = useState(false);
const [moduleDeleteModal, setModuleDeleteModal] = useState(false);
const [editModal, setEditModal] = useState(false);
const [deleteModal, setDeleteModal] = useState(false);
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
@ -38,50 +38,7 @@ export const ModuleCardItem: React.FC<Props> = observer((props) => {
const { module: moduleStore } = useMobxStore();
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 openModuleOverview = () => {
const { query } = router;
router.push({
pathname: router.pathname,
query: { ...query, peekModule: module.id },
});
};
const completionPercentage = (module.completed_issues / module.total_issues) * 100;
const endDate = new Date(module.target_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`
: "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 (
<>
{workspaceSlug && projectId && (
<CreateUpdateModuleModal
isOpen={editModuleModal}
onClose={() => setEditModuleModal(false)}
isOpen={editModal}
onClose={() => setEditModal(false)}
data={module}
projectId={projectId.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}`}>
<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 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>
</Tooltip>
<div className="flex items-center gap-2">
@ -128,13 +148,7 @@ export const ModuleCardItem: React.FC<Props> = observer((props) => {
{moduleStatus.label}
</span>
)}
<button
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
openModuleOverview();
}}
>
<button onClick={openModuleOverview}>
<Info className="h-4 w-4 text-custom-text-400" />
</button>
</div>
@ -184,60 +198,28 @@ export const ModuleCardItem: React.FC<Props> = observer((props) => {
</span>
<div className="flex items-center gap-1.5 z-10">
{module.is_favorite ? (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
handleRemoveFromFavorites();
}}
>
<button type="button" onClick={handleRemoveFromFavorites}>
<Star className="h-3.5 w-3.5 text-amber-500 fill-current" />
</button>
) : (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
handleAddToFavorites();
}}
>
<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">
<CustomMenu.MenuItem
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setEditModuleModal(true);
}}
>
<CustomMenu.MenuItem onClick={handleEditModule}>
<span className="flex items-center justify-start gap-2">
<Pencil className="h-3 w-3" />
<span>Edit module</span>
</span>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setModuleDeleteModal(true);
}}
>
<CustomMenu.MenuItem onClick={handleDeleteModule}>
<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={(e) => {
e.preventDefault();
e.stopPropagation();
handleCopyText();
}}
>
<CustomMenu.MenuItem onClick={handleCopyText}>
<span className="flex items-center justify-start gap-2">
<LinkIcon className="h-3 w-3" />
<span>Copy module link</span>

View File

@ -28,8 +28,8 @@ type Props = {
export const ModuleListItem: React.FC<Props> = observer((props) => {
const { module } = props;
const [editModuleModal, setEditModuleModal] = useState(false);
const [moduleDeleteModal, setModuleDeleteModal] = useState(false);
const [editModal, setEditModal] = useState(false);
const [deleteModal, setDeleteModal] = useState(false);
const router = useRouter();
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 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 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 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;
router.push({
@ -100,14 +120,14 @@ export const ModuleListItem: React.FC<Props> = observer((props) => {
<>
{workspaceSlug && projectId && (
<CreateUpdateModuleModal
isOpen={editModuleModal}
onClose={() => setEditModuleModal(false)}
isOpen={editModal}
onClose={() => setEditModal(false)}
data={module}
projectId={projectId.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}`}>
<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">
@ -123,18 +143,11 @@ export const ModuleListItem: React.FC<Props> = observer((props) => {
)}
</CircularProgressIndicator>
</span>
<Tooltip tooltipContent={module.name} position="auto">
<Tooltip tooltipContent={module.name} position="top">
<span className="text-base font-medium truncate">{module.name}</span>
</Tooltip>
</div>
<button
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
openModuleOverview();
}}
className="flex-shrink-0 hidden group-hover:flex z-10"
>
<button onClick={openModuleOverview} className="flex-shrink-0 hidden group-hover:flex z-10">
<Info className="h-4 w-4 text-custom-text-400" />
</button>
</div>
@ -171,63 +184,29 @@ export const ModuleListItem: React.FC<Props> = observer((props) => {
</Tooltip>
{module.is_favorite ? (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
handleRemoveFromFavorites();
}}
className="z-[1]"
>
<button type="button" onClick={handleRemoveFromFavorites} className="z-[1]">
<Star className="h-3.5 w-3.5 text-amber-500 fill-current" />
</button>
) : (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
handleAddToFavorites();
}}
className="z-[1]"
>
<button type="button" onClick={handleAddToFavorites} className="z-[1]">
<Star className="h-3.5 w-3.5 text-custom-text-300" />
</button>
)}
<CustomMenu width="auto" verticalEllipsis buttonClassName="z-[1]">
<CustomMenu.MenuItem
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setEditModuleModal(true);
}}
>
<CustomMenu.MenuItem onClick={handleEditModule}>
<span className="flex items-center justify-start gap-2">
<Pencil className="h-3 w-3" />
<span>Edit module</span>
</span>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setModuleDeleteModal(true);
}}
>
<CustomMenu.MenuItem onClick={handleDeleteModule}>
<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={(e) => {
e.preventDefault();
e.stopPropagation();
handleCopyText();
}}
>
<CustomMenu.MenuItem onClick={handleCopyText}>
<span className="flex items-center justify-start gap-2">
<LinkIcon className="h-3 w-3" />
<span>Copy module link</span>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,13 +1,17 @@
export * from "./settings";
export * from "./views";
export * from "./activity-graph";
export * from "./completed-issues-graph";
export * from "./confirm-workspace-member-remove";
export * from "./create-workspace-form";
export * from "./delete-workspace-modal";
export * from "./help-section";
export * from "./issues-list";
export * from "./issues-pie-chart";
export * from "./issues-stats";
export * from "./member-select";
export * from "./send-workspace-invitation-modal";
export * from "./sidebar-dropdown";
export * from "./sidebar-menu";
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";
// constants
import { ROLE } from "constants/workspace";
// fetch-keys
import { WORKSPACE_INVITATIONS } from "constants/fetch-keys";
type Props = {
isOpen: boolean;
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
workspace_slug: string;
onClose: () => void;
workspaceSlug: string;
user: IUser | undefined;
onSuccess: () => void;
onSuccess?: () => Promise<void>;
};
type EmailRole = {
@ -44,8 +45,9 @@ const defaultValues: FormValues = {
const workspaceService = new WorkspaceService();
const SendWorkspaceInvitationModal: React.FC<Props> = (props) => {
const { isOpen, setIsOpen, workspace_slug, user, onSuccess } = props;
export const SendWorkspaceInvitationModal: React.FC<Props> = (props) => {
const { isOpen, onClose, workspaceSlug, user, onSuccess } = props;
const {
control,
reset,
@ -61,42 +63,38 @@ const SendWorkspaceInvitationModal: React.FC<Props> = (props) => {
const { setToastAlert } = useToast();
const handleClose = () => {
setIsOpen(false);
onClose();
const timeout = setTimeout(() => {
reset(defaultValues);
clearTimeout(timeout);
}, 500);
}, 350);
};
const onSubmit = async (formData: FormValues) => {
if (!workspace_slug) return;
const payload = { ...formData };
if (!workspaceSlug) return;
await workspaceService
.inviteWorkspace(workspace_slug, payload, user)
.inviteWorkspace(workspaceSlug, formData, user)
.then(async () => {
setIsOpen(false);
if (onSuccess) await onSuccess();
handleClose();
setToastAlert({
type: "success",
title: "Success!",
message: "Invitations sent successfully.",
});
onSuccess();
})
.catch((err) => {
.catch((err) =>
setToastAlert({
type: "error",
title: "Error!",
message: `${err.error}`,
});
console.log(err);
})
.finally(() => {
reset(defaultValues);
mutate(WORKSPACE_INVITATIONS);
});
message: `${err.error ?? "Something went wrong. Please try again."}`,
})
)
.finally(() => mutate(WORKSPACE_INVITATIONS));
};
const appendField = () => {
@ -104,9 +102,7 @@ const SendWorkspaceInvitationModal: React.FC<Props> = (props) => {
};
useEffect(() => {
if (fields.length === 0) {
append([{ email: "", role: 15 }]);
}
if (fields.length === 0) append([{ email: "", role: 15 }]);
}, [fields, append]);
return (
@ -249,5 +245,3 @@ const SendWorkspaceInvitationModal: React.FC<Props> = (props) => {
</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" />,
},
];
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
const workspaceService = new WorkspaceService();
// TODO: remove this context
export const WorkspaceMemberProvider: React.FC<Props> = (props) => {
const { children } = props;
@ -40,7 +41,7 @@ export const WorkspaceMemberProvider: React.FC<Props> = (props) => {
const loading = !memberDetails && !error;
return (
<WorkspaceMemberContext.Provider value={{ loading, memberDetails, error }}>
<WorkspaceMemberContext.Provider value={{ loading, memberDetails: undefined, error }}>
{children}
</WorkspaceMemberContext.Provider>
);

View File

@ -111,11 +111,20 @@ export const getFirstCharacters = (str: string) => {
*/
export const stripHTML = (html: string) => {
const tmp = document.createElement("DIV");
tmp.innerHTML = html;
return tmp.textContent || tmp.innerText || "";
const strippedText = html.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, ""); // Remove script tags
return strippedText.replace(/<[^>]*>/g, ""); // Remove all other HTML tags
};
/**
*
* @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+
* @param {number} number

View File

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

View File

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

View File

@ -25,7 +25,7 @@ const SingleCycle: React.FC = () => {
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 { error } = useSWR(
@ -35,6 +35,10 @@ const SingleCycle: React.FC = () => {
: null
);
const toggleSidebar = () => {
setValue(`${!isSidebarCollapsed}`);
};
// TODO: add this function to bulk add issues to cycle
// const handleAddIssuesToCycle = async (data: ISearchIssueResponse[]) => {
// if (!workspaceSlug || !projectId) return;
@ -75,11 +79,21 @@ const SingleCycle: React.FC = () => {
/>
) : (
<>
<div className="relative w-full h-full flex overflow-auto">
<div className={`h-full w-full ${isSidebarCollapsed ? "" : "mr-[24rem]"} duration-300`}>
<div className="flex h-full w-full">
<div className="h-full w-full">
<CycleLayoutRoot />
</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>
</>
)}

View File

@ -29,7 +29,11 @@ const ProjectCyclesPage: NextPage = observer(() => {
const { project: projectStore, cycle: cycleStore } = useMobxStore();
// router
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
useSWR(
workspaceSlug && projectId ? `PROJECT_DETAILS_${projectId}` : null,
@ -150,13 +154,14 @@ const ProjectCyclesPage: NextPage = observer(() => {
</div>
<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 && (
<CyclesView
filter={"all"}
layout={cycleLayout as TCycleLayout}
workspaceSlug={workspaceSlug}
projectId={projectId}
peekCycle={peekCycle}
/>
)}
</Tab.Panel>
@ -165,35 +170,38 @@ const ProjectCyclesPage: NextPage = observer(() => {
<ActiveCycleDetails workspaceSlug={workspaceSlug} projectId={projectId} />
</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 && (
<CyclesView
filter={"upcoming"}
layout={cycleLayout as TCycleLayout}
workspaceSlug={workspaceSlug}
projectId={projectId}
peekCycle={peekCycle}
/>
)}
</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 && (
<CyclesView
filter={"completed"}
layout={cycleLayout as TCycleLayout}
workspaceSlug={workspaceSlug}
projectId={projectId}
peekCycle={peekCycle}
/>
)}
</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 && (
<CyclesView
filter={"draft"}
layout={cycleLayout as TCycleLayout}
workspaceSlug={workspaceSlug}
projectId={projectId}
peekCycle={peekCycle}
/>
)}
</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
import { AppLayout } from "layouts/app-layout";
import { WorkspaceSettingLayout } from "layouts/setting-layout";
// components
import { ImageUploadModal } from "components/core";
import { DeleteWorkspaceModal } from "components/workspace";
import { WorkspaceSettingHeader } from "components/headers";
// ui
import { Disclosure, Transition } from "@headlessui/react";
import { Button, CustomSelect, Input, Spinner } from "@plane/ui";
// icons
import { ChevronDown, ChevronUp, Pencil } from "lucide-react";
import { WorkspaceDetails } from "components/workspace";
// types
import type { IWorkspace } from "types";
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> = {
name: "",
url: "",
organization_size: "2-10",
logo: null,
};
// 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>
);
};
const WorkspaceSettings: NextPage = () => (
<AppLayout header={<WorkspaceSettingHeader title="General Settings" />}>
<WorkspaceSettingLayout>
<WorkspaceDetails />
</WorkspaceSettingLayout>
</AppLayout>
);
export default WorkspaceSettings;

View File

@ -1,309 +1,45 @@
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/router";
import useSWR from "swr";
// services
import { WorkspaceService } from "services/workspace.service";
// hooks
import useToast from "hooks/use-toast";
import useUser from "hooks/use-user";
import useWorkspaceMembers from "hooks/use-workspace-members";
// layouts
import { AppLayout } from "layouts/app-layout";
import { WorkspaceSettingLayout } from "layouts/setting-layout";
// 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 { SendWorkspaceInvitationModal, WorkspaceMembersList } from "components/workspace";
// ui
import { Button, CustomMenu, CustomSelect, Loader } from "@plane/ui";
// icons
import { ChevronDown, X } from "lucide-react";
import { Button } from "@plane/ui";
// types
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 [selectedRemoveMember, setSelectedRemoveMember] = useState<string | null>(null);
const [selectedInviteRemoveMember, setSelectedInviteRemoveMember] = useState<string | null>(null);
const [inviteModal, setInviteModal] = useState(false);
const router = useRouter();
const { workspaceSlug } = router.query;
const { setToastAlert } = useToast();
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 (
<AppLayout header={<WorkspaceSettingHeader title="Members Settings" />}>
<WorkspaceSettingLayout>
<ConfirmWorkspaceMemberRemove
isOpen={Boolean(selectedRemoveMember) || Boolean(selectedInviteRemoveMember)}
onClose={() => {
setSelectedRemoveMember(null);
setSelectedInviteRemoveMember(null);
}}
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}
/>
{workspaceSlug && (
<SendWorkspaceInvitationModal
isOpen={inviteModal}
onClose={() => setInviteModal(false)}
workspaceSlug={workspaceSlug.toString()}
user={user}
/>
)}
<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>
<Button variant="primary" size="sm" onClick={() => setInviteModal(true)}>
Add Member
</Button>
</div>
{!workspaceMembers || !workspaceInvitations ? (
<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>
)}
<WorkspaceMembersList />
</section>
</WorkspaceSettingLayout>
</AppLayout>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -31,6 +31,7 @@ export interface IIssueStore {
// computed
getIssueType: IIssueType | null;
getIssues: IIssueGroupedStructure | IIssueGroupWithSubGroupsStructure | IIssueUnGroupedStructure | null;
getIssuesCount: number;
// action
fetchIssues: (workspaceSlug: string, projectId: string) => Promise<any>;
updateIssueStructure: (group_id: string | null, sub_group_id: string | null, issue: IIssue) => void;
@ -68,6 +69,7 @@ export class IssueStore implements IIssueStore {
// computed
getIssueType: computed,
getIssues: computed,
getIssuesCount: computed,
// actions
fetchIssues: action,
updateIssueStructure: action,
@ -120,6 +122,44 @@ export class IssueStore implements IIssueStore {
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) => {
const projectId: string | null = issue?.project;
const issueType = this.getIssueType;

View File

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

View File

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

View File

@ -45,6 +45,7 @@ export interface IProjectViewIssuesStore {
// computed
getIssues: IIssueGroupedStructure | IIssueGroupWithSubGroupsStructure | IIssueUnGroupedStructure | null;
getIssuesCount: number;
getIssueType: IIssueType | null;
}
@ -86,6 +87,7 @@ export class ProjectViewIssuesStore implements IProjectViewIssuesStore {
// computed
getIssueType: computed,
getIssues: computed,
getIssuesCount: computed,
});
this.rootStore = _rootStore;
@ -147,6 +149,44 @@ export class ProjectViewIssuesStore implements IProjectViewIssuesStore {
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) => {
const viewId: string | null = this.rootStore.projectViews.viewId;
const issueType = this.rootStore.issue.getIssueType;

View File

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

View File

@ -26,6 +26,15 @@ export interface IWorkspaceStore {
fetchWorkspaceLabels: (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
currentWorkspace: IWorkspace | null;
workspaceLabels: IIssueLabels[] | null;
@ -72,6 +81,15 @@ export class WorkspaceStore implements IWorkspaceStore {
fetchWorkspaceLabels: action,
fetchWorkspaceMembers: action,
// workspace write operations
createWorkspace: action,
updateWorkspace: action,
deleteWorkspace: action,
// members write operations
updateMember: action,
removeMember: action,
// computed
currentWorkspace: computed,
workspaceLabels: computed,
@ -189,7 +207,6 @@ export class WorkspaceStore implements IWorkspaceStore {
* fetch workspace members using workspace slug
* @param workspaceSlug
*/
fetchWorkspaceMembers = async (workspaceSlug: string) => {
try {
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,
IIssueDisplayProperties,
IIssueFilterOptions,
IWorkspaceMember,
IWorkspaceMemberMe,
IWorkspaceViewProps,
TIssueParams,
} from "types";
@ -25,7 +25,7 @@ export interface IWorkspaceFilterStore {
workspaceDisplayProperties: IIssueDisplayProperties;
// actions
fetchUserWorkspaceFilters: (workspaceSlug: string) => Promise<IWorkspaceMember>;
fetchUserWorkspaceFilters: (workspaceSlug: string) => Promise<IWorkspaceMemberMe>;
updateWorkspaceFilters: (workspaceSlug: string, filterToUpdate: Partial<IWorkspaceViewProps>) => Promise<void>;
// computed

View File

@ -56,6 +56,7 @@ export interface IUserLite {
avatar: string;
created_at: Date;
display_name: string;
email?: string;
first_name: string;
readonly id: string;
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 {
readonly id: string;
@ -56,16 +56,29 @@ export type Properties = {
};
export interface IWorkspaceMember {
readonly id: string;
workspace: IWorkspace;
member: IUserMemberLite;
role: 5 | 10 | 15 | 20;
company_role: string | null;
view_props: IWorkspaceViewProps;
created_at: Date;
updated_at: Date;
created_by: string;
id: string;
member: IUserLite;
role: 5 | 10 | 15 | 20;
updated_at: Date;
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 {

View File

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