mirror of
https://github.com/makeplane/plane
synced 2024-06-14 14:31:34 +00:00
Merge branch 'develop' of github.com:makeplane/plane into dev/api_logging
This commit is contained in:
commit
edb4280ec1
@ -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):
|
||||
|
@ -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):
|
||||
|
@ -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):
|
||||
|
@ -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):
|
||||
|
@ -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
|
||||
)
|
||||
|
||||
|
||||
|
20
packages/ui/src/icons/cycle/circle-dot-full-icon.tsx
Normal file
20
packages/ui/src/icons/cycle/circle-dot-full-icon.tsx
Normal 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>
|
||||
);
|
@ -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",
|
33
packages/ui/src/icons/cycle/cycle-group-icon.tsx
Normal file
33
packages/ui/src/icons/cycle/cycle-group-icon.tsx
Normal 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}`}
|
||||
/>
|
||||
);
|
||||
};
|
@ -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",
|
18
packages/ui/src/icons/cycle/helper.tsx
Normal file
18
packages/ui/src/icons/cycle/helper.tsx
Normal 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",
|
||||
};
|
5
packages/ui/src/icons/cycle/index.ts
Normal file
5
packages/ui/src/icons/cycle/index.ts
Normal 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";
|
@ -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";
|
||||
|
@ -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({
|
||||
|
55
web/components/cycles/cycle-peek-overview.tsx
Normal file
55
web/components/cycles/cycle-peek-overview.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -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" />
|
||||
|
@ -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";
|
||||
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
@ -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) => ({
|
||||
|
@ -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";
|
||||
|
25
web/components/issues/issue-layouts/empty-states/cycle.tsx
Normal file
25
web/components/issues/issue-layouts/empty-states/cycle.tsx
Normal 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>
|
||||
);
|
@ -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>
|
||||
);
|
@ -0,0 +1,5 @@
|
||||
export * from "./cycle";
|
||||
export * from "./global-view";
|
||||
export * from "./module";
|
||||
export * from "./project-view";
|
||||
export * from "./project";
|
25
web/components/issues/issue-layouts/empty-states/module.tsx
Normal file
25
web/components/issues/issue-layouts/empty-states/module.tsx
Normal 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>
|
||||
);
|
@ -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>
|
||||
);
|
25
web/components/issues/issue-layouts/empty-states/project.tsx
Normal file
25
web/components/issues/issue-layouts/empty-states/project.tsx
Normal 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>
|
||||
);
|
@ -1,5 +1,6 @@
|
||||
// filters
|
||||
export * from "./filters";
|
||||
export * from "./empty-states";
|
||||
export * from "./quick-action-dropdowns";
|
||||
|
||||
// layouts
|
||||
|
@ -1,4 +1,5 @@
|
||||
export * from "./roots";
|
||||
export * from "./block";
|
||||
export * from "./roots";
|
||||
export * from "./blocks-list";
|
||||
export * from "./inline-create-issue-form";
|
||||
|
@ -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}
|
||||
|
@ -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>
|
||||
</>
|
||||
);
|
||||
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
@ -1,3 +0,0 @@
|
||||
export * from "./my-issues-select-filters";
|
||||
export * from "./my-issues-view-options";
|
||||
export * from "./my-issues-view";
|
@ -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>
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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,
|
||||
}}
|
||||
/> */}
|
||||
</>
|
||||
);
|
||||
};
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
)}
|
||||
|
@ -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>
|
||||
|
@ -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",
|
||||
|
@ -1,6 +1,5 @@
|
||||
export * from "./overview";
|
||||
export * from "./navbar";
|
||||
export * from "./profile-issues-view-options";
|
||||
export * from "./profile-issues-view";
|
||||
export * from "./sidebar";
|
||||
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
@ -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;
|
||||
});
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
@ -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";
|
||||
|
@ -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;
|
||||
|
3
web/components/workspace/settings/index.ts
Normal file
3
web/components/workspace/settings/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export * from "./members-list-item";
|
||||
export * from "./members-list";
|
||||
export * from "./workspace-details";
|
202
web/components/workspace/settings/members-list-item.tsx
Normal file
202
web/components/workspace/settings/members-list-item.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
75
web/components/workspace/settings/members-list.tsx
Normal file
75
web/components/workspace/settings/members-list.tsx
Normal 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>
|
||||
);
|
||||
});
|
306
web/components/workspace/settings/workspace-details.tsx
Normal file
306
web/components/workspace/settings/workspace-details.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
});
|
@ -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",
|
||||
},
|
||||
];
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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
|
||||
|
@ -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;
|
||||
|
@ -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(
|
||||
|
@ -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>
|
||||
</>
|
||||
)}
|
||||
|
@ -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>
|
||||
|
@ -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;
|
||||
|
@ -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>
|
||||
|
@ -68,7 +68,6 @@ const CreateWorkspace: NextPage = () => {
|
||||
onSubmit={onSubmit}
|
||||
defaultValues={defaultValues}
|
||||
setDefaultValues={setDefaultValues}
|
||||
user={user}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -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)
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
@ -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
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
@ -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
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
@ -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
|
||||
|
1
web/types/users.d.ts
vendored
1
web/types/users.d.ts
vendored
@ -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;
|
||||
|
27
web/types/workspace.d.ts
vendored
27
web/types/workspace.d.ts
vendored
@ -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 {
|
||||
|
@ -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==
|
||||
|
Loading…
Reference in New Issue
Block a user