Merge branch 'develop' of gurusainath:makeplane/plane into fix-auto-close-dropdowns-issue-modal

This commit is contained in:
gurusainath 2024-03-21 12:32:26 +05:30
commit 041134c08a
110 changed files with 3181 additions and 1117 deletions

View File

@ -48,7 +48,7 @@ USER root
RUN apk --no-cache add "bash~=5.2"
COPY ./bin ./bin/
RUN mkdir /code/plane/logs
RUN mkdir -p /code/plane/logs
RUN chmod +x ./bin/takeoff ./bin/worker ./bin/beat
RUN chmod -R 777 /code
RUN chown -R captain:plane /code

View File

@ -35,6 +35,7 @@ RUN addgroup -S plane && \
COPY . .
RUN mkdir -p /code/plane/logs
RUN chown -R captain.plane /code
RUN chmod -R +x /code/bin
RUN chmod -R 777 /code

View File

@ -481,7 +481,7 @@ class CycleArchiveUnarchiveAPIEndpoint(BaseAPIView):
.distinct()
)
def list(self, request, slug, project_id):
def get(self, request, slug, project_id):
return self.paginate(
request=request,
queryset=(self.get_queryset()),

View File

@ -67,6 +67,7 @@ class ModuleAPIEndpoint(WebhookMixin, BaseAPIView):
issue_module__issue__archived_at__isnull=True,
issue_module__issue__is_draft=False,
),
distinct=True,
),
)
.annotate(
@ -77,6 +78,7 @@ class ModuleAPIEndpoint(WebhookMixin, BaseAPIView):
issue_module__issue__archived_at__isnull=True,
issue_module__issue__is_draft=False,
),
distinct=True,
)
)
.annotate(
@ -87,6 +89,7 @@ class ModuleAPIEndpoint(WebhookMixin, BaseAPIView):
issue_module__issue__archived_at__isnull=True,
issue_module__issue__is_draft=False,
),
distinct=True,
)
)
.annotate(
@ -97,6 +100,7 @@ class ModuleAPIEndpoint(WebhookMixin, BaseAPIView):
issue_module__issue__archived_at__isnull=True,
issue_module__issue__is_draft=False,
),
distinct=True,
)
)
.annotate(
@ -107,6 +111,7 @@ class ModuleAPIEndpoint(WebhookMixin, BaseAPIView):
issue_module__issue__archived_at__isnull=True,
issue_module__issue__is_draft=False,
),
distinct=True,
)
)
.annotate(
@ -117,6 +122,7 @@ class ModuleAPIEndpoint(WebhookMixin, BaseAPIView):
issue_module__issue__archived_at__isnull=True,
issue_module__issue__is_draft=False,
),
distinct=True,
)
)
.order_by(self.kwargs.get("order_by", "-created_at"))
@ -486,6 +492,7 @@ class ModuleArchiveUnarchiveAPIEndpoint(BaseAPIView):
issue_module__issue__archived_at__isnull=True,
issue_module__issue__is_draft=False,
),
distinct=True,
),
)
.annotate(
@ -496,6 +503,7 @@ class ModuleArchiveUnarchiveAPIEndpoint(BaseAPIView):
issue_module__issue__archived_at__isnull=True,
issue_module__issue__is_draft=False,
),
distinct=True,
)
)
.annotate(
@ -506,6 +514,7 @@ class ModuleArchiveUnarchiveAPIEndpoint(BaseAPIView):
issue_module__issue__archived_at__isnull=True,
issue_module__issue__is_draft=False,
),
distinct=True,
)
)
.annotate(
@ -516,6 +525,7 @@ class ModuleArchiveUnarchiveAPIEndpoint(BaseAPIView):
issue_module__issue__archived_at__isnull=True,
issue_module__issue__is_draft=False,
),
distinct=True,
)
)
.annotate(
@ -526,6 +536,7 @@ class ModuleArchiveUnarchiveAPIEndpoint(BaseAPIView):
issue_module__issue__archived_at__isnull=True,
issue_module__issue__is_draft=False,
),
distinct=True,
)
)
.annotate(
@ -536,12 +547,13 @@ class ModuleArchiveUnarchiveAPIEndpoint(BaseAPIView):
issue_module__issue__archived_at__isnull=True,
issue_module__issue__is_draft=False,
),
distinct=True,
)
)
.order_by(self.kwargs.get("order_by", "-created_at"))
)
def list(self, request, slug, project_id):
def get(self, request, slug, project_id):
return self.paginate(
request=request,
queryset=(self.get_queryset()),

View File

@ -107,6 +107,7 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
distinct=True,
)
)
.annotate(
@ -117,6 +118,7 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
distinct=True,
)
)
.annotate(
@ -127,6 +129,7 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
distinct=True,
)
)
.annotate(
@ -137,6 +140,7 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
distinct=True,
)
)
.annotate(
@ -147,6 +151,7 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
),
distinct=True,
)
)
.annotate(
@ -175,6 +180,9 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
distinct=True,
filter=~Q(
issue_cycle__issue__assignees__id__isnull=True
)
& Q(
issue_cycle__issue__assignees__member_project__is_active=True
),
),
Value([], output_field=ArrayField(UUIDField())),
@ -706,10 +714,8 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView):
project_id=self.kwargs.get("project_id"),
workspace__slug=self.kwargs.get("slug"),
)
return self.filter_queryset(
super()
.get_queryset()
.filter(workspace__slug=self.kwargs.get("slug"))
return (
Cycle.objects.filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id"))
.filter(archived_at__isnull=False)
.filter(
@ -811,6 +817,9 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView):
distinct=True,
filter=~Q(
issue_cycle__issue__assignees__id__isnull=True
)
& Q(
issue_cycle__issue__assignees__member_project__is_active=True
),
),
Value([], output_field=ArrayField(UUIDField())),
@ -820,7 +829,7 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView):
.distinct()
)
def list(self, request, slug, project_id):
def get(self, request, slug, project_id):
queryset = (
self.get_queryset()
.annotate(
@ -858,6 +867,7 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView):
"backlog_issues",
"assignee_ids",
"status",
"archived_at",
)
).order_by("-is_favorite", "-created_at")
return Response(queryset, status=status.HTTP_200_OK)

View File

@ -143,7 +143,8 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet):
ArrayAgg(
"assignees__id",
distinct=True,
filter=~Q(assignees__id__isnull=True),
filter=~Q(assignees__id__isnull=True)
& Q(assignees__member_project__is_active=True),
),
Value([], output_field=ArrayField(UUIDField())),
),

View File

@ -149,7 +149,8 @@ def dashboard_assigned_issues(self, request, slug):
ArrayAgg(
"assignees__id",
distinct=True,
filter=~Q(assignees__id__isnull=True),
filter=~Q(assignees__id__isnull=True)
& Q(assignees__member_project__is_active=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
@ -303,7 +304,8 @@ def dashboard_created_issues(self, request, slug):
ArrayAgg(
"assignees__id",
distinct=True,
filter=~Q(assignees__id__isnull=True),
filter=~Q(assignees__id__isnull=True)
& Q(assignees__member_project__is_active=True),
),
Value([], output_field=ArrayField(UUIDField())),
),

View File

@ -146,7 +146,8 @@ class InboxIssueViewSet(BaseViewSet):
ArrayAgg(
"assignees__id",
distinct=True,
filter=~Q(assignees__id__isnull=True),
filter=~Q(assignees__id__isnull=True)
& Q(assignees__member_project__is_active=True),
),
Value([], output_field=ArrayField(UUIDField())),
),

View File

@ -105,7 +105,8 @@ class IssueArchiveViewSet(BaseViewSet):
ArrayAgg(
"assignees__id",
distinct=True,
filter=~Q(assignees__id__isnull=True),
filter=~Q(assignees__id__isnull=True)
& Q(assignees__member_project__is_active=True),
),
Value([], output_field=ArrayField(UUIDField())),
),

View File

@ -52,6 +52,7 @@ from plane.db.models import (
from plane.bgtasks.issue_activites_task import issue_activity
from plane.utils.issue_filters import issue_filters
class IssueListEndpoint(BaseAPIView):
permission_classes = [
@ -114,7 +115,8 @@ class IssueListEndpoint(BaseAPIView):
ArrayAgg(
"assignees__id",
distinct=True,
filter=~Q(assignees__id__isnull=True),
filter=~Q(assignees__id__isnull=True)
& Q(assignees__member_project__is_active=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
@ -308,7 +310,8 @@ class IssueViewSet(WebhookMixin, BaseViewSet):
ArrayAgg(
"assignees__id",
distinct=True,
filter=~Q(assignees__id__isnull=True),
filter=~Q(assignees__id__isnull=True)
& Q(assignees__member_project__is_active=True),
),
Value([], output_field=ArrayField(UUIDField())),
),

View File

@ -101,7 +101,8 @@ class IssueDraftViewSet(BaseViewSet):
ArrayAgg(
"assignees__id",
distinct=True,
filter=~Q(assignees__id__isnull=True),
filter=~Q(assignees__id__isnull=True)
& Q(assignees__member_project__is_active=True),
),
Value([], output_field=ArrayField(UUIDField())),
),

View File

@ -83,7 +83,8 @@ class SubIssuesEndpoint(BaseAPIView):
ArrayAgg(
"assignees__id",
distinct=True,
filter=~Q(assignees__id__isnull=True),
filter=~Q(assignees__id__isnull=True)
& Q(assignees__member_project__is_active=True),
),
Value([], output_field=ArrayField(UUIDField())),
),

View File

@ -86,6 +86,7 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
issue_module__issue__archived_at__isnull=True,
issue_module__issue__is_draft=False,
),
distinct=True,
),
)
.annotate(
@ -96,6 +97,7 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
issue_module__issue__archived_at__isnull=True,
issue_module__issue__is_draft=False,
),
distinct=True,
)
)
.annotate(
@ -106,6 +108,7 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
issue_module__issue__archived_at__isnull=True,
issue_module__issue__is_draft=False,
),
distinct=True,
)
)
.annotate(
@ -116,6 +119,7 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
issue_module__issue__archived_at__isnull=True,
issue_module__issue__is_draft=False,
),
distinct=True,
)
)
.annotate(
@ -126,6 +130,7 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
issue_module__issue__archived_at__isnull=True,
issue_module__issue__is_draft=False,
),
distinct=True,
)
)
.annotate(
@ -136,6 +141,7 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
issue_module__issue__archived_at__isnull=True,
issue_module__issue__is_draft=False,
),
distinct=True,
)
)
.annotate(
@ -492,10 +498,7 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView):
workspace__slug=self.kwargs.get("slug"),
)
return (
super()
.get_queryset()
.filter(project_id=self.kwargs.get("project_id"))
.filter(workspace__slug=self.kwargs.get("slug"))
Module.objects.filter(workspace__slug=self.kwargs.get("slug"))
.filter(archived_at__isnull=False)
.annotate(is_favorite=Exists(favorite_subquery))
.select_related("project")
@ -517,6 +520,7 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView):
issue_module__issue__archived_at__isnull=True,
issue_module__issue__is_draft=False,
),
distinct=True,
),
)
.annotate(
@ -527,6 +531,7 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView):
issue_module__issue__archived_at__isnull=True,
issue_module__issue__is_draft=False,
),
distinct=True,
)
)
.annotate(
@ -537,6 +542,7 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView):
issue_module__issue__archived_at__isnull=True,
issue_module__issue__is_draft=False,
),
distinct=True,
)
)
.annotate(
@ -547,6 +553,7 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView):
issue_module__issue__archived_at__isnull=True,
issue_module__issue__is_draft=False,
),
distinct=True,
)
)
.annotate(
@ -557,6 +564,7 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView):
issue_module__issue__archived_at__isnull=True,
issue_module__issue__is_draft=False,
),
distinct=True,
)
)
.annotate(
@ -567,6 +575,7 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView):
issue_module__issue__archived_at__isnull=True,
issue_module__issue__is_draft=False,
),
distinct=True,
)
)
.annotate(
@ -582,7 +591,7 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView):
.order_by("-is_favorite", "-created_at")
)
def list(self, request, slug, project_id):
def get(self, request, slug, project_id):
queryset = self.get_queryset()
modules = queryset.values( # Required fields
"id",
@ -612,6 +621,7 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView):
"backlog_issues",
"created_at",
"updated_at",
"archived_at"
)
return Response(modules, status=status.HTTP_200_OK)

View File

@ -93,7 +93,8 @@ class ModuleIssueViewSet(WebhookMixin, BaseViewSet):
ArrayAgg(
"assignees__id",
distinct=True,
filter=~Q(assignees__id__isnull=True),
filter=~Q(assignees__id__isnull=True)
& Q(assignees__member_project__is_active=True),
),
Value([], output_field=ArrayField(UUIDField())),
),

View File

@ -125,7 +125,8 @@ class GlobalViewIssuesViewSet(BaseViewSet):
ArrayAgg(
"assignees__id",
distinct=True,
filter=~Q(assignees__id__isnull=True),
filter=~Q(assignees__id__isnull=True)
& Q(assignees__member_project__is_active=True),
),
Value([], output_field=ArrayField(UUIDField())),
),

View File

@ -45,6 +45,7 @@ class WorkspaceModulesEndpoint(BaseAPIView):
issue_module__issue__archived_at__isnull=True,
issue_module__issue__is_draft=False,
),
distinct=True,
),
)
.annotate(
@ -55,6 +56,7 @@ class WorkspaceModulesEndpoint(BaseAPIView):
issue_module__issue__archived_at__isnull=True,
issue_module__issue__is_draft=False,
),
distinct=True,
)
)
.annotate(
@ -65,6 +67,7 @@ class WorkspaceModulesEndpoint(BaseAPIView):
issue_module__issue__archived_at__isnull=True,
issue_module__issue__is_draft=False,
),
distinct=True,
)
)
.annotate(
@ -75,6 +78,7 @@ class WorkspaceModulesEndpoint(BaseAPIView):
issue_module__issue__archived_at__isnull=True,
issue_module__issue__is_draft=False,
),
distinct=True,
)
)
.annotate(
@ -85,6 +89,7 @@ class WorkspaceModulesEndpoint(BaseAPIView):
issue_module__issue__archived_at__isnull=True,
issue_module__issue__is_draft=False,
),
distinct=True,
)
)
.annotate(
@ -95,6 +100,7 @@ class WorkspaceModulesEndpoint(BaseAPIView):
issue_module__issue__archived_at__isnull=True,
issue_module__issue__is_draft=False,
),
distinct=True,
)
)
.order_by(self.kwargs.get("order_by", "-created_at"))

View File

@ -165,7 +165,8 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView):
ArrayAgg(
"assignees__id",
distinct=True,
filter=~Q(assignees__id__isnull=True),
filter=~Q(assignees__id__isnull=True)
& Q(assignees__member_project__is_active=True),
),
Value([], output_field=ArrayField(UUIDField())),
),

View File

@ -31,6 +31,7 @@ export interface ICycle {
unstarted_issues: number;
updated_at: Date;
updated_by: string;
archived_at: string | null;
assignee_ids: string[];
view_props: {
filters: IIssueFilterOptions;

View File

@ -13,6 +13,11 @@ export type TCycleFilters = {
status?: string[] | null;
};
export type TCycleFiltersByState = {
default: TCycleFilters;
archived: TCycleFilters;
};
export type TCycleStoredFilters = {
display_filters?: TCycleDisplayFilters;
filters?: TCycleFilters;

View File

@ -26,6 +26,11 @@ export type TModuleFilters = {
target_date?: string[] | null;
};
export type TModuleFiltersByState = {
default: TModuleFilters;
archived: TModuleFilters;
};
export type TModuleStoredFilters = {
display_filters?: TModuleDisplayFilters;
filters?: TModuleFilters;

View File

@ -39,6 +39,7 @@ export interface IModule {
unstarted_issues: number;
updated_at: Date;
updated_by: string;
archived_at: string | null;
view_props: {
filters: IIssueFilterOptions;
};

View File

@ -0,0 +1,43 @@
import { FC } from "react";
import { observer } from "mobx-react";
import Link from "next/link";
import { useRouter } from "next/router";
// constants
import { ARCHIVES_TAB_LIST } from "@/constants/archives";
// hooks
import { useProject } from "@/hooks/store";
export const ArchiveTabsList: FC = observer(() => {
// router
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const activeTab = router.pathname.split("/").pop();
// store hooks
const { getProjectById } = useProject();
// derived values
if (!projectId) return null;
const projectDetails = getProjectById(projectId?.toString());
if (!projectDetails) return null;
return (
<>
{ARCHIVES_TAB_LIST.map(
(tab) =>
tab.shouldRender(projectDetails) && (
<Link key={tab.key} href={`/${workspaceSlug}/projects/${projectId}/archives/${tab.key}`}>
<span
className={`flex min-w-min flex-shrink-0 whitespace-nowrap border-b-2 py-3 px-4 text-sm font-medium outline-none ${
tab.key === activeTab
? "border-custom-primary-100 text-custom-primary-100"
: "border-transparent hover:border-custom-border-200 text-custom-text-300 hover:text-custom-text-400"
}`}
>
{tab.label}
</span>
</Link>
)
)}
</>
);
});

View File

@ -0,0 +1 @@
export * from "./archive-tabs-list";

View File

@ -16,12 +16,13 @@ type Props = {
handleDeleteLink: (linkId: string) => void;
handleEditLink: (link: ILinkDetails) => void;
userAuth: UserAuth;
disabled?: boolean;
};
export const LinksList: React.FC<Props> = observer(({ links, handleDeleteLink, handleEditLink, userAuth }) => {
export const LinksList: React.FC<Props> = observer(({ links, handleDeleteLink, handleEditLink, userAuth, disabled }) => {
const { getUserDetails } = useMember();
const { isMobile } = usePlatformOS();
const isNotAllowed = userAuth.isGuest || userAuth.isViewer;
const isNotAllowed = userAuth.isGuest || userAuth.isViewer || disabled;
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text);

View File

@ -12,6 +12,7 @@ type Props = {
startDate: string | Date;
endDate: string | Date;
totalIssues: number;
className?: string;
};
const styleById = {
@ -40,7 +41,7 @@ const DashedLine = ({ series, lineGenerator, xScale, yScale }: any) =>
/>
));
const ProgressChart: React.FC<Props> = ({ distribution, startDate, endDate, totalIssues }) => {
const ProgressChart: React.FC<Props> = ({ distribution, startDate, endDate, totalIssues, className = "" }) => {
const chartData = Object.keys(distribution ?? []).map((key) => ({
currentDate: renderFormattedDateWithoutYear(key),
pending: distribution[key],
@ -73,7 +74,7 @@ const ProgressChart: React.FC<Props> = ({ distribution, startDate, endDate, tota
};
return (
<div className="flex w-full items-center justify-center">
<div className={`flex w-full items-center justify-center ${className}`}>
<LineGraph
animate
curve="monotoneX"

View File

@ -0,0 +1,260 @@
import { FC, Fragment } from "react";
import { observer } from "mobx-react";
import Link from "next/link";
import useSWR from "swr";
import { CalendarCheck } from "lucide-react";
import { Tab } from "@headlessui/react";
// types
import { ICycle, TIssue } from "@plane/types";
// ui
import { Tooltip, Loader, PriorityIcon, Avatar } from "@plane/ui";
// components
import { SingleProgressStats } from "@/components/core";
import { StateDropdown } from "@/components/dropdowns";
// constants
import { CYCLE_ISSUES_WITH_PARAMS } from "@/constants/fetch-keys";
import { EIssuesStoreType } from "@/constants/issue";
// helper
import { cn } from "@/helpers/common.helper";
import { renderFormattedDate, renderFormattedDateWithoutYear } from "@/helpers/date-time.helper";
// hooks
import { useIssues, useProject } from "@/hooks/store";
import useLocalStorage from "@/hooks/use-local-storage";
export type ActiveCycleStatsProps = {
workspaceSlug: string;
projectId: string;
cycle: ICycle;
};
export const ActiveCycleStats: FC<ActiveCycleStatsProps> = observer((props) => {
const { workspaceSlug, projectId, cycle } = props;
const { storedValue: tab, setValue: setTab } = useLocalStorage("activeCycleTab", "Assignees");
const currentValue = (tab: string | null) => {
switch (tab) {
case "Priority-Issues":
return 0;
case "Assignees":
return 1;
case "Labels":
return 2;
default:
return 0;
}
};
const {
issues: { fetchActiveCycleIssues },
} = useIssues(EIssuesStoreType.CYCLE);
const { currentProjectDetails } = useProject();
const { data: activeCycleIssues } = useSWR(
workspaceSlug && projectId && cycle.id ? CYCLE_ISSUES_WITH_PARAMS(cycle.id, { priority: "urgent,high" }) : null,
workspaceSlug && projectId && cycle.id ? () => fetchActiveCycleIssues(workspaceSlug, projectId, cycle.id) : null
);
const cycleIssues = activeCycleIssues ?? [];
return (
<div className="flex flex-col gap-4 p-4 min-h-[17rem] overflow-hidden col-span-1 lg:col-span-2 xl:col-span-1 border border-custom-border-200 rounded-lg">
<Tab.Group
as={Fragment}
defaultIndex={currentValue(tab)}
onChange={(i) => {
switch (i) {
case 0:
return setTab("Priority-Issues");
case 1:
return setTab("Assignees");
case 2:
return setTab("Labels");
default:
return setTab("Priority-Issues");
}
}}
>
<Tab.List
as="div"
className="relative border-[0.5px] border-custom-border-200 rounded bg-custom-background-80 p-[1px] grid"
style={{
gridTemplateColumns: `repeat(3, 1fr)`,
}}
>
<Tab
className={({ selected }) =>
cn(
"relative z-[1] font-semibold text-xs rounded-[3px] py-1.5 text-custom-text-400 focus:outline-none transition duration-500",
{
"text-custom-text-300 bg-custom-background-100": selected,
"hover:text-custom-text-300": !selected,
}
)
}
>
Priority Issues
</Tab>
<Tab
className={({ selected }) =>
cn(
"relative z-[1] font-semibold text-xs rounded-[3px] py-1.5 text-custom-text-400 focus:outline-none transition duration-500",
{
"text-custom-text-300 bg-custom-background-100": selected,
"hover:text-custom-text-300": !selected,
}
)
}
>
Assignees
</Tab>
<Tab
className={({ selected }) =>
cn(
"relative z-[1] font-semibold text-xs rounded-[3px] py-1.5 text-custom-text-400 focus:outline-none transition duration-500",
{
"text-custom-text-300 bg-custom-background-100": selected,
"hover:text-custom-text-300": !selected,
}
)
}
>
Labels
</Tab>
</Tab.List>
<Tab.Panels as={Fragment}>
<Tab.Panel
as="div"
className="flex h-52 w-full flex-col gap-1 overflow-y-auto text-custom-text-200 vertical-scrollbar scrollbar-sm"
>
<div className="flex flex-col gap-1 h-full w-full overflow-y-auto vertical-scrollbar scrollbar-sm">
{cycleIssues ? (
cycleIssues.length > 0 ? (
cycleIssues.map((issue: TIssue) => (
<Link
key={issue.id}
href={`/${workspaceSlug}/projects/${projectId}/issues/${issue.id}`}
className="group flex cursor-pointer items-center justify-between gap-2 rounded-md hover:bg-custom-background-90 p-1"
>
<div className="flex items-center gap-1.5 flex-grow w-full min-w-24 truncate">
<PriorityIcon priority={issue.priority} withContainer size={12} />
<Tooltip
tooltipHeading="Issue ID"
tooltipContent={`${currentProjectDetails?.identifier}-${issue.sequence_id}`}
>
<span className="flex-shrink-0 text-xs text-custom-text-200">
{currentProjectDetails?.identifier}-{issue.sequence_id}
</span>
</Tooltip>
<Tooltip position="top-left" tooltipHeading="Title" tooltipContent={issue.name}>
<span className="text-[0.825rem] text-custom-text-100 truncate">{issue.name}</span>
</Tooltip>
</div>
<div className="flex items-center gap-1.5 flex-shrink-0">
<StateDropdown
value={issue.state_id ?? undefined}
onChange={() => {}}
projectId={projectId?.toString() ?? ""}
disabled
buttonVariant="background-with-text"
buttonContainerClassName="cursor-pointer max-w-24"
showTooltip
/>
{issue.target_date && (
<Tooltip tooltipHeading="Target Date" tooltipContent={renderFormattedDate(issue.target_date)}>
<div className="h-full flex truncate items-center gap-1.5 rounded text-xs px-2 py-0.5 bg-custom-background-80 group-hover:bg-custom-background-100 cursor-pointer">
<CalendarCheck className="h-3 w-3 flex-shrink-0" />
<span className="text-xs truncate">
{renderFormattedDateWithoutYear(issue.target_date)}
</span>
</div>
</Tooltip>
)}
</div>
</Link>
))
) : (
<div className="flex items-center justify-center text-center h-full text-sm text-custom-text-200">
<span>There are no high priority issues present in this cycle.</span>
</div>
)
) : (
<Loader className="space-y-3">
<Loader.Item height="50px" />
<Loader.Item height="50px" />
<Loader.Item height="50px" />
</Loader>
)}
</div>
</Tab.Panel>
<Tab.Panel
as="div"
className="flex h-52 w-full flex-col gap-1 overflow-y-auto text-custom-text-200 vertical-scrollbar scrollbar-sm"
>
{cycle.distribution?.assignees?.map((assignee, index) => {
if (assignee.assignee_id)
return (
<SingleProgressStats
key={assignee.assignee_id}
title={
<div className="flex items-center gap-2">
<Avatar name={assignee?.display_name ?? undefined} src={assignee?.avatar ?? undefined} />
<span>{assignee.display_name}</span>
</div>
}
completed={assignee.completed_issues}
total={assignee.total_issues}
/>
);
else
return (
<SingleProgressStats
key={`unassigned-${index}`}
title={
<div className="flex items-center gap-2">
<div className="h-5 w-5 rounded-full border-2 border-custom-border-200 bg-custom-background-80">
<img src="/user.png" height="100%" width="100%" className="rounded-full" alt="User" />
</div>
<span>No assignee</span>
</div>
}
completed={assignee.completed_issues}
total={assignee.total_issues}
/>
);
})}
</Tab.Panel>
<Tab.Panel
as="div"
className="flex h-52 w-full flex-col gap-1 overflow-y-auto text-custom-text-200 vertical-scrollbar scrollbar-sm"
>
{cycle.distribution?.labels?.map((label, index) => (
<SingleProgressStats
key={label.label_id ?? `no-label-${index}`}
title={
<div className="flex items-center gap-2">
<span
className="block h-3 w-3 rounded-full"
style={{
backgroundColor: label.color ?? "#000000",
}}
/>
<span className="text-xs">{label.label_name ?? "No labels"}</span>
</div>
}
completed={label.completed_issues}
total={label.total_issues}
/>
))}
</Tab.Panel>
</Tab.Panels>
</Tab.Group>
</div>
);
});

View File

@ -0,0 +1,77 @@
import { FC } from "react";
import Link from "next/link";
// types
import { ICycle, TCycleGroups } from "@plane/types";
// ui
import { Tooltip, CycleGroupIcon, getButtonStyling, Avatar, AvatarGroup } from "@plane/ui";
// helpers
import { renderFormattedDate, findHowManyDaysLeft } from "@/helpers/date-time.helper";
import { truncateText } from "@/helpers/string.helper";
// hooks
import { useMember } from "@/hooks/store";
export type ActiveCycleHeaderProps = {
cycle: ICycle;
workspaceSlug: string;
projectId: string;
};
export const ActiveCycleHeader: FC<ActiveCycleHeaderProps> = (props) => {
const { cycle, workspaceSlug, projectId } = props;
// store
const { getUserDetails } = useMember();
const cycleOwnerDetails = cycle && cycle.owned_by_id ? getUserDetails(cycle.owned_by_id) : undefined;
const daysLeft = findHowManyDaysLeft(cycle.end_date) ?? 0;
const currentCycleStatus = cycle.status.toLocaleLowerCase() as TCycleGroups;
const cycleAssignee = (cycle.distribution?.assignees ?? []).filter((assignee) => assignee.display_name);
return (
<div className="flex items-center justify-between px-3 py-1.5 rounded border-[0.5px] border-custom-border-100 bg-custom-background-90">
<div className="flex items-center gap-2 cursor-default">
<CycleGroupIcon cycleGroup={currentCycleStatus} className="h-4 w-4" />
<Tooltip tooltipContent={cycle.name} position="top-left">
<h3 className="break-words text-lg font-medium">{truncateText(cycle.name, 70)}</h3>
</Tooltip>
<Tooltip
tooltipContent={`Start date: ${renderFormattedDate(cycle.start_date ?? "")} Due Date: ${renderFormattedDate(
cycle.end_date ?? ""
)}`}
position="top-left"
>
<span className="flex gap-1 whitespace-nowrap rounded-sm text-custom-text-400 font-semibold text-sm leading-5">
{`${daysLeft} ${daysLeft > 1 ? "days" : "day"} left`}
</span>
</Tooltip>
</div>
<div className="flex items-center gap-4">
<div className="rounded-sm text-sm">
<div className="flex gap-2 divide-x spac divide-x-border-300 text-sm whitespace-nowrap text-custom-text-300 font-medium">
<Avatar name={cycleOwnerDetails?.display_name} src={cycleOwnerDetails?.avatar} />
{cycleAssignee.length > 0 && (
<span className="pl-2">
<AvatarGroup showTooltip>
{cycleAssignee.map((member) => (
<Avatar
key={member.assignee_id}
name={member?.display_name ?? ""}
src={member?.avatar ?? ""}
showTooltip={false}
/>
))}
</AvatarGroup>
</span>
)}
</div>
</div>
<Link
href={`/${workspaceSlug}/projects/${projectId}/cycles/${cycle.id}`}
className={`${getButtonStyling("outline-primary", "sm")} cursor-pointer`}
>
View Cycle
</Link>
</div>
</div>
);
};

View File

@ -1,4 +1,8 @@
export * from "./root";
export * from "./header";
export * from "./stats";
export * from "./upcoming-cycles-list-item";
export * from "./upcoming-cycles-list";
export * from "./cycle-stats";
export * from "./progress";
export * from "./productivity";

View File

@ -0,0 +1,46 @@
import { FC } from "react";
// types
import { ICycle } from "@plane/types";
// components
import ProgressChart from "@/components/core/sidebar/progress-chart";
export type ActiveCycleProductivityProps = {
cycle: ICycle;
};
export const ActiveCycleProductivity: FC<ActiveCycleProductivityProps> = (props) => {
const { cycle } = props;
return (
<div className="flex flex-col justify-center min-h-[17rem] gap-5 py-4 px-3.5 border border-custom-border-200 rounded-lg">
<div className="flex items-center justify-between gap-4">
<h3 className="text-base text-custom-text-300 font-semibold">Issue burndown</h3>
</div>
<div className="h-full w-full px-2">
<div className="flex items-center justify-between gap-4 py-1 text-xs text-custom-text-300">
<div className="flex items-center gap-3 text-custom-text-300">
<div className="flex items-center justify-center gap-1">
<span className="h-2 w-2 rounded-full bg-[#A9BBD0]" />
<span>Ideal</span>
</div>
<div className="flex items-center justify-center gap-1">
<span className="h-2 w-2 rounded-full bg-[#4C8FFF]" />
<span>Current</span>
</div>
</div>
<span>{`Pending issues - ${cycle.backlog_issues + cycle.unstarted_issues + cycle.started_issues}`}</span>
</div>
<div className="relative h-full">
<ProgressChart
className="h-full"
distribution={cycle.distribution?.completion_chart ?? {}}
startDate={cycle.start_date ?? ""}
endDate={cycle.end_date ?? ""}
totalIssues={cycle.total_issues}
/>
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,79 @@
import { FC } from "react";
// types
import { ICycle } from "@plane/types";
// ui
import { LinearProgressIndicator } from "@plane/ui";
// constants
import { CYCLE_STATE_GROUPS_DETAILS } from "@/constants/cycle";
export type ActiveCycleProgressProps = {
cycle: ICycle;
};
export const ActiveCycleProgress: FC<ActiveCycleProgressProps> = (props) => {
const { cycle } = props;
const progressIndicatorData = CYCLE_STATE_GROUPS_DETAILS.map((group, index) => ({
id: index,
name: group.title,
value: cycle.total_issues > 0 ? (cycle[group.key as keyof ICycle] as number) : 0,
color: group.color,
}));
const groupedIssues: any = {
completed: cycle.completed_issues,
started: cycle.started_issues,
unstarted: cycle.unstarted_issues,
backlog: cycle.backlog_issues,
};
return (
<div className="flex flex-col min-h-[17rem] gap-5 py-4 px-3.5 border border-custom-border-200 rounded-lg">
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between gap-4">
<h3 className="text-base text-custom-text-300 font-semibold">Progress</h3>
<span className="flex gap-1 text-sm text-custom-text-400 font-medium whitespace-nowrap rounded-sm px-3 py-1 ">
{`${cycle.completed_issues + cycle.cancelled_issues}/${cycle.total_issues - cycle.cancelled_issues} ${
cycle.completed_issues + cycle.cancelled_issues > 1 ? "Issues" : "Issue"
} closed`}
</span>
</div>
<LinearProgressIndicator size="lg" data={progressIndicatorData} />
</div>
<div className="flex flex-col gap-5">
{Object.keys(groupedIssues).map((group, index) => (
<>
{groupedIssues[group] > 0 && (
<div key={index}>
<div className="flex items-center justify-between gap-2 text-sm">
<div className="flex items-center gap-1.5">
<span
className="block h-3 w-3 rounded-full"
style={{
backgroundColor: CYCLE_STATE_GROUPS_DETAILS[index].color,
}}
/>
<span className="text-custom-text-300 capitalize font-medium w-16">{group}</span>
</div>
<span className="text-custom-text-300">{`${groupedIssues[group]} ${
groupedIssues[group] > 1 ? "Issues" : "Issue"
}`}</span>
</div>
</div>
)}
</>
))}
{cycle.cancelled_issues > 0 && (
<span className="flex items-center gap-2 text-sm text-custom-text-300">
<span>
{`${cycle.cancelled_issues} cancelled ${
cycle.cancelled_issues > 1 ? "issues are" : "issue is"
} excluded from this report.`}{" "}
</span>
</span>
)}
</div>
</div>
);
};

View File

@ -1,48 +1,20 @@
import { MouseEvent } from "react";
import { observer } from "mobx-react-lite";
import Link from "next/link";
import useSWR from "swr";
// hooks
import { ArrowRight, CalendarCheck, CalendarDays, Star, Target } from "lucide-react";
import { ICycle, TCycleGroups } from "@plane/types";
import {
AvatarGroup,
Loader,
Tooltip,
LinearProgressIndicator,
LayersIcon,
StateGroupIcon,
PriorityIcon,
Avatar,
CycleGroupIcon,
setPromiseToast,
getButtonStyling,
} from "@plane/ui";
import { SingleProgressStats } from "@/components/core";
// ui
import { Loader } from "@plane/ui";
// components
import ProgressChart from "@/components/core/sidebar/progress-chart";
import { ActiveCycleProgressStats, UpcomingCyclesList } from "@/components/cycles";
import { StateDropdown } from "@/components/dropdowns";
import { EmptyState } from "@/components/empty-state";
// icons
// helpers
// types
// constants
import { CYCLE_STATE_GROUPS_DETAILS } from "@/constants/cycle";
import { EmptyStateType } from "@/constants/empty-state";
import { CYCLE_ISSUES_WITH_PARAMS } from "@/constants/fetch-keys";
import { EIssuesStoreType } from "@/constants/issue";
import { cn } from "@/helpers/common.helper";
import {
renderFormattedDate,
findHowManyDaysLeft,
renderFormattedDateWithoutYear,
getDate,
} from "@/helpers/date-time.helper";
import { truncateText } from "@/helpers/string.helper";
import { useCycle, useCycleFilter, useIssues, useMember, useProject } from "@/hooks/store";
import { usePlatformOS } from "@/hooks/use-platform-os";
ActiveCycleHeader,
ActiveCycleProductivity,
ActiveCycleProgress,
ActiveCycleStats,
UpcomingCyclesList,
} from "@/components/cycles";
import { EmptyState } from "@/components/empty-state";
// constants
import { EmptyStateType } from "@/constants/empty-state";
// hooks
import { useCycle, useCycleFilter } from "@/hooks/store";
interface IActiveCycleDetails {
workspaceSlug: string;
@ -52,41 +24,24 @@ interface IActiveCycleDetails {
export const ActiveCycleRoot: React.FC<IActiveCycleDetails> = observer((props) => {
// props
const { workspaceSlug, projectId } = props;
// hooks
const { isMobile } = usePlatformOS();
// store hooks
const {
issues: { fetchActiveCycleIssues },
} = useIssues(EIssuesStoreType.CYCLE);
const {
currentProjectActiveCycleId,
currentProjectUpcomingCycleIds,
fetchActiveCycle,
getActiveCycleById,
addCycleToFavorites,
removeCycleFromFavorites,
} = useCycle();
const { currentProjectDetails } = useProject();
const { getUserDetails } = useMember();
const { fetchActiveCycle, currentProjectActiveCycleId, currentProjectUpcomingCycleIds, getActiveCycleById } =
useCycle();
// cycle filters hook
const { updateDisplayFilters } = useCycleFilter();
// derived values
const activeCycle = currentProjectActiveCycleId ? getActiveCycleById(currentProjectActiveCycleId) : null;
const cycleOwnerDetails = activeCycle ? getUserDetails(activeCycle.owned_by_id) : undefined;
// fetch active cycle details
const { isLoading } = useSWR(
workspaceSlug && projectId ? `PROJECT_ACTIVE_CYCLE_${projectId}` : null,
workspaceSlug && projectId ? () => fetchActiveCycle(workspaceSlug, projectId) : null
);
// fetch active cycle issues
const { data: activeCycleIssues } = useSWR(
workspaceSlug && projectId && currentProjectActiveCycleId
? CYCLE_ISSUES_WITH_PARAMS(currentProjectActiveCycleId, { priority: "urgent,high" })
: null,
workspaceSlug && projectId && currentProjectActiveCycleId
? () => fetchActiveCycleIssues(workspaceSlug, projectId, currentProjectActiveCycleId)
: null
);
const handleEmptyStateAction = () =>
updateDisplayFilters(projectId, {
active_tab: "all",
});
// show loader if active cycle is loading
if (!activeCycle && isLoading)
return (
@ -110,310 +65,28 @@ export const ActiveCycleRoot: React.FC<IActiveCycleDetails> = observer((props) =
Create new cycles to find them here or check
<br />
{"'"}All{"'"} cycles tab to see all cycles or{" "}
<button
type="button"
className="text-custom-primary-100 font-medium"
onClick={() =>
updateDisplayFilters(projectId, {
active_tab: "all",
})
}
>
<button type="button" className="text-custom-primary-100 font-medium" onClick={handleEmptyStateAction}>
click here
</button>
</p>
</div>
</div>
<UpcomingCyclesList />
<UpcomingCyclesList handleEmptyStateAction={handleEmptyStateAction} />
</>
);
}
const endDate = getDate(activeCycle.end_date);
const startDate = getDate(activeCycle.start_date);
const daysLeft = findHowManyDaysLeft(activeCycle.end_date) ?? 0;
const cycleStatus = activeCycle.status.toLowerCase() as TCycleGroups;
const groupedIssues: any = {
backlog: activeCycle.backlog_issues,
unstarted: activeCycle.unstarted_issues,
started: activeCycle.started_issues,
completed: activeCycle.completed_issues,
cancelled: activeCycle.cancelled_issues,
};
const handleAddToFavorites = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
if (!workspaceSlug || !projectId) return;
const addToFavoritePromise = addCycleToFavorites(workspaceSlug?.toString(), projectId.toString(), activeCycle.id);
setPromiseToast(addToFavoritePromise, {
loading: "Adding cycle to favorites...",
success: {
title: "Success!",
message: () => "Cycle added to favorites.",
},
error: {
title: "Error!",
message: () => "Couldn't add the cycle to favorites. Please try again.",
},
});
};
const handleRemoveFromFavorites = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
if (!workspaceSlug || !projectId) return;
const removeFromFavoritePromise = removeCycleFromFavorites(
workspaceSlug?.toString(),
projectId.toString(),
activeCycle.id
);
setPromiseToast(removeFromFavoritePromise, {
loading: "Removing cycle from favorites...",
success: {
title: "Success!",
message: () => "Cycle removed from favorites.",
},
error: {
title: "Error!",
message: () => "Couldn't remove the cycle from favorites. Please try again.",
},
});
};
const progressIndicatorData = CYCLE_STATE_GROUPS_DETAILS.map((group, index) => ({
id: index,
name: group.title,
value:
activeCycle.total_issues > 0
? ((activeCycle[group.key as keyof ICycle] as number) / activeCycle.total_issues) * 100
: 0,
color: group.color,
}));
return (
<div className="grid-row-2 grid divide-y rounded-[10px] border border-custom-border-200 bg-custom-background-100 shadow">
<div className="grid grid-cols-1 divide-y border-custom-border-200 lg:grid-cols-3 lg:divide-x lg:divide-y-0">
<div className="flex flex-col text-xs">
<div className="h-full w-full">
<div className="flex h-60 flex-col justify-between gap-5 rounded-b-[10px] p-4">
<div className="flex items-center justify-between gap-1">
<span className="flex items-center gap-1">
<span className="h-5 w-5">
<CycleGroupIcon cycleGroup={cycleStatus} className="h-4 w-4" />
</span>
<Tooltip tooltipContent={activeCycle.name} position="top-left" isMobile={isMobile}>
<h3 className="break-words text-lg font-semibold">{truncateText(activeCycle.name, 70)}</h3>
</Tooltip>
</span>
<span className="flex items-center gap-1">
<span className="flex gap-1 whitespace-nowrap rounded-sm bg-amber-500/10 px-3 py-0.5 text-sm text-amber-500">
{`${daysLeft} ${daysLeft > 1 ? "days" : "day"} left`}
</span>
{activeCycle.is_favorite ? (
<button
onClick={(e) => {
handleRemoveFromFavorites(e);
}}
>
<Star className="h-4 w-4 fill-orange-400 text-orange-400" />
</button>
) : (
<button
onClick={(e) => {
handleAddToFavorites(e);
}}
>
<Star className="h-4 w-4 " color="rgb(var(--color-text-200))" />
</button>
)}
</span>
</div>
<div className="flex items-center justify-start gap-5 text-custom-text-200">
<div className="flex items-start gap-1">
<CalendarDays className="h-4 w-4" />
<span>{renderFormattedDate(startDate)}</span>
</div>
<ArrowRight className="h-4 w-4 text-custom-text-200" />
<div className="flex items-start gap-1">
<Target className="h-4 w-4" />
<span>{renderFormattedDate(endDate)}</span>
</div>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2.5 text-custom-text-200">
<Avatar src={cycleOwnerDetails?.avatar} name={cycleOwnerDetails?.display_name} />
<span className="text-custom-text-200">{cycleOwnerDetails?.display_name}</span>
</div>
{activeCycle.assignee_ids.length > 0 && (
<div className="flex items-center gap-1 text-custom-text-200">
<AvatarGroup>
{activeCycle.assignee_ids.map((assignee_id) => {
const member = getUserDetails(assignee_id);
return <Avatar key={member?.id} name={member?.display_name} src={member?.avatar} />;
})}
</AvatarGroup>
</div>
)}
</div>
<div className="flex items-center gap-4 text-custom-text-200">
<div className="flex gap-2">
<LayersIcon className="h-3.5 w-3.5 flex-shrink-0" />
{activeCycle.total_issues} issues
</div>
<div className="flex items-center gap-2">
<StateGroupIcon stateGroup="completed" height="14px" width="14px" />
{activeCycle.completed_issues} issues
</div>
</div>
<Link
href={`/${workspaceSlug}/projects/${projectId}/cycles/${activeCycle.id}`}
className={cn(getButtonStyling("primary", "lg"), "w-min whitespace-nowrap")}
>
View cycle
</Link>
</div>
</div>
</div>
<div className="col-span-2 grid grid-cols-1 divide-y border-custom-border-200 md:grid-cols-2 md:divide-x md:divide-y-0">
<div className="flex h-60 flex-col border-custom-border-200">
<div className="flex h-full w-full flex-col p-4 text-custom-text-200">
<div className="flex w-full items-center gap-2 py-1">
<span>Progress</span>
<LinearProgressIndicator size="md" data={progressIndicatorData} inPercentage />
</div>
<div className="mt-2 flex flex-col items-center gap-1">
{Object.keys(groupedIssues).map((group, index) => (
<SingleProgressStats
key={index}
title={
<div className="flex items-center gap-2">
<span
className="block h-3 w-3 rounded-full "
style={{
backgroundColor: CYCLE_STATE_GROUPS_DETAILS[index].color,
}}
/>
<span className="text-xs capitalize">{group}</span>
</div>
}
completed={groupedIssues[group]}
total={activeCycle.total_issues}
/>
))}
</div>
</div>
</div>
<div className="h-60 overflow-y-scroll border-custom-border-200">
<ActiveCycleProgressStats cycle={activeCycle} />
</div>
</div>
</div>
<div className="grid grid-cols-1 divide-y border-custom-border-200 lg:grid-cols-2 lg:divide-x lg:divide-y-0">
<div className="flex max-h-60 flex-col gap-3 overflow-hidden p-4">
<div className="text-custom-primary">High priority issues</div>
<div className="flex h-full flex-col gap-2.5 overflow-y-scroll rounded-md">
{activeCycleIssues ? (
activeCycleIssues.length > 0 ? (
activeCycleIssues.map((issue) => (
<Link
key={issue.id}
href={`/${workspaceSlug}/projects/${projectId}/issues/${issue.id}`}
className="flex cursor-pointer flex-wrap items-center justify-between gap-2 rounded-md border border-custom-border-200 px-3 py-1.5"
>
<div className="flex items-center gap-1.5">
<PriorityIcon priority={issue.priority} withContainer size={12} />
<Tooltip
isMobile={isMobile}
tooltipHeading="Issue ID"
tooltipContent={`${currentProjectDetails?.identifier}-${issue.sequence_id}`}
>
<span className="flex-shrink-0 text-xs text-custom-text-200">
{currentProjectDetails?.identifier}-{issue.sequence_id}
</span>
</Tooltip>
<Tooltip position="top-left" tooltipContent={issue.name} isMobile={isMobile}>
<span className="text-[0.825rem] text-custom-text-100">{truncateText(issue.name, 30)}</span>
</Tooltip>
</div>
<div className="flex flex-shrink-0 items-center gap-1.5">
<StateDropdown
value={issue.state_id}
onChange={() => {}}
projectId={projectId}
disabled
buttonVariant="background-with-text"
/>
{issue.target_date && (
<Tooltip
tooltipHeading="Target Date"
tooltipContent={renderFormattedDate(issue.target_date)}
isMobile={isMobile}
>
<div className="flex h-full cursor-not-allowed items-center gap-1.5 rounded bg-custom-background-80 px-2 py-0.5 text-xs">
<CalendarCheck className="h-3 w-3 flex-shrink-0" />
<span className="text-xs">{renderFormattedDateWithoutYear(issue.target_date)}</span>
</div>
</Tooltip>
)}
</div>
</Link>
))
) : (
<div className="flex h-full items-center justify-center text-sm text-custom-text-200">
There are no high priority issues present in this cycle.
</div>
)
) : (
<Loader className="space-y-3">
<Loader.Item height="50px" />
<Loader.Item height="50px" />
<Loader.Item height="50px" />
</Loader>
)}
</div>
</div>
<div className="flex max-h-60 flex-col border-custom-border-200 p-4">
<div className="flex items-start justify-between gap-4 py-1.5 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 className="flex items-center gap-1">
<span>
<LayersIcon className="h-3.5 w-3.5 flex-shrink-0 text-custom-text-200" />
</span>
<span>
Pending issues-{" "}
{activeCycle.total_issues - (activeCycle.completed_issues + activeCycle.cancelled_issues)}
</span>
</div>
</div>
<div className="relative h-full">
<ProgressChart
distribution={activeCycle.distribution?.completion_chart ?? {}}
startDate={activeCycle.start_date ?? ""}
endDate={activeCycle.end_date ?? ""}
totalIssues={activeCycle.total_issues}
/>
</div>
</div>
<>
<div className="flex flex-col gap-4">
<ActiveCycleHeader cycle={activeCycle} workspaceSlug={workspaceSlug} projectId={projectId} />
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2 xl:grid-cols-3">
<ActiveCycleProgress cycle={activeCycle} />
<ActiveCycleProductivity cycle={activeCycle} />
<ActiveCycleStats cycle={activeCycle} workspaceSlug={workspaceSlug} projectId={projectId} />
</div>
</div>
{currentProjectUpcomingCycleIds && <UpcomingCyclesList handleEmptyStateAction={handleEmptyStateAction} />}
</>
);
});

View File

@ -1,10 +1,16 @@
import { FC } from "react";
import { observer } from "mobx-react";
// hooks
import { UpcomingCycleListItem } from "@/components/cycles";
import { useCycle } from "@/hooks/store";
// components
import { UpcomingCycleListItem } from "@/components/cycles";
// hooks
import { useCycle } from "@/hooks/store";
export const UpcomingCyclesList = observer(() => {
type Props = {
handleEmptyStateAction: () => void;
};
export const UpcomingCyclesList: FC<Props> = observer((props) => {
const { handleEmptyStateAction } = props;
// store hooks
const { currentProjectUpcomingCycleIds } = useCycle();
@ -12,14 +18,30 @@ export const UpcomingCyclesList = observer(() => {
return (
<div>
<div className="bg-custom-background-80 font-semibold text-sm py-1 px-2 rounded inline-block">
Upcoming cycles
<div className="bg-custom-background-80 font-semibold text-sm py-1 px-2 rounded inline-block text-custom-text-400">
Next cycles
</div>
{currentProjectUpcomingCycleIds.length > 0 ? (
<div className="mt-2 divide-y-[0.5px] divide-custom-border-200 border-b-[0.5px] border-custom-border-200">
{currentProjectUpcomingCycleIds.map((cycleId) => (
<UpcomingCycleListItem key={cycleId} cycleId={cycleId} />
))}
</div>
) : (
<div className="w-full grid place-items-center py-20">
<div className="text-center">
<h5 className="text-xl font-medium mb-1">No upcoming cycles</h5>
<p className="text-custom-text-400 text-base">
Create new cycles to find them here or check
<br />
{"'"}All{"'"} cycles tab to see all cycles or{" "}
<button type="button" className="text-custom-primary-100 font-medium" onClick={handleEmptyStateAction}>
click here
</button>
</p>
</div>
</div>
)}
</div>
);
});

View File

@ -0,0 +1,123 @@
import { FC, useCallback, useRef, useState } from "react";
import { observer } from "mobx-react-lite";
import { useRouter } from "next/router";
// icons
import { ListFilter, Search, X } from "lucide-react";
// types
import type { TCycleFilters } from "@plane/types";
// components
import { ArchiveTabsList } from "@/components/archives";
import { CycleFiltersSelection } from "@/components/cycles";
import { FiltersDropdown } from "@/components/issues";
// helpers
import { cn } from "@/helpers/common.helper";
// hooks
import { useCycleFilter } from "@/hooks/store";
import useOutsideClickDetector from "@/hooks/use-outside-click-detector";
export const ArchivedCyclesHeader: FC = observer(() => {
// router
const router = useRouter();
const { projectId } = router.query;
// refs
const inputRef = useRef<HTMLInputElement>(null);
// hooks
const { currentProjectArchivedFilters, archivedCyclesSearchQuery, updateFilters, updateArchivedCyclesSearchQuery } =
useCycleFilter();
// states
const [isSearchOpen, setIsSearchOpen] = useState(archivedCyclesSearchQuery !== "" ? true : false);
// outside click detector hook
useOutsideClickDetector(inputRef, () => {
if (isSearchOpen && archivedCyclesSearchQuery.trim() === "") setIsSearchOpen(false);
});
const handleFilters = useCallback(
(key: keyof TCycleFilters, value: string | string[]) => {
if (!projectId) return;
const newValues = currentProjectArchivedFilters?.[key] ?? [];
if (Array.isArray(value))
value.forEach((val) => {
if (!newValues.includes(val)) newValues.push(val);
});
else {
if (currentProjectArchivedFilters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1);
else newValues.push(value);
}
updateFilters(projectId.toString(), { [key]: newValues }, "archived");
},
[currentProjectArchivedFilters, projectId, updateFilters]
);
const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Escape") {
if (archivedCyclesSearchQuery && archivedCyclesSearchQuery.trim() !== "") updateArchivedCyclesSearchQuery("");
else {
setIsSearchOpen(false);
inputRef.current?.blur();
}
}
};
return (
<div className="group relative flex border-b border-custom-border-200">
<div className="flex w-full items-center overflow-x-auto px-4 gap-2 horizontal-scrollbar scrollbar-sm">
<ArchiveTabsList />
</div>
{/* filter options */}
<div className="h-full flex items-center gap-3 self-end px-8">
{!isSearchOpen && (
<button
type="button"
className="-mr-5 p-2 hover:bg-custom-background-80 rounded text-custom-text-400 grid place-items-center"
onClick={() => {
setIsSearchOpen(true);
inputRef.current?.focus();
}}
>
<Search className="h-3.5 w-3.5" />
</button>
)}
<div
className={cn(
"ml-auto flex items-center justify-start gap-1 rounded-md border border-transparent bg-custom-background-100 text-custom-text-400 w-0 transition-[width] ease-linear overflow-hidden opacity-0",
{
"w-64 px-2.5 py-1.5 border-custom-border-200 opacity-100": isSearchOpen,
}
)}
>
<Search className="h-3.5 w-3.5" />
<input
ref={inputRef}
className="w-full max-w-[234px] border-none bg-transparent text-sm text-custom-text-100 placeholder:text-custom-text-400 focus:outline-none"
placeholder="Search"
value={archivedCyclesSearchQuery}
onChange={(e) => updateArchivedCyclesSearchQuery(e.target.value)}
onKeyDown={handleInputKeyDown}
/>
{isSearchOpen && (
<button
type="button"
className="grid place-items-center"
onClick={() => {
updateArchivedCyclesSearchQuery("");
setIsSearchOpen(false);
}}
>
<X className="h-3 w-3" />
</button>
)}
</div>
<FiltersDropdown icon={<ListFilter className="h-3 w-3" />} title="Filters" placement="bottom-end">
<CycleFiltersSelection
filters={currentProjectArchivedFilters ?? {}}
handleFiltersUpdate={handleFilters}
isArchived
/>
</FiltersDropdown>
</div>
</div>
);
});

View File

@ -0,0 +1,4 @@
export * from "./root";
export * from "./view";
export * from "./header";
export * from "./modal";

View File

@ -0,0 +1,104 @@
import { useState, Fragment } from "react";
import { useRouter } from "next/router";
import { Dialog, Transition } from "@headlessui/react";
// ui
import { Button, TOAST_TYPE, setToast } from "@plane/ui";
// hooks
import { useCycle } from "@/hooks/store";
type Props = {
workspaceSlug: string;
projectId: string;
cycleId: string;
handleClose: () => void;
isOpen: boolean;
onSubmit?: () => Promise<void>;
};
export const ArchiveCycleModal: React.FC<Props> = (props) => {
const { workspaceSlug, projectId, cycleId, isOpen, handleClose } = props;
// router
const router = useRouter();
// states
const [isArchiving, setIsArchiving] = useState(false);
// store hooks
const { getCycleNameById, archiveCycle } = useCycle();
const cycleName = getCycleNameById(cycleId);
const onClose = () => {
setIsArchiving(false);
handleClose();
};
const handleArchiveIssue = async () => {
setIsArchiving(true);
await archiveCycle(workspaceSlug, projectId, cycleId)
.then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Archive success",
message: "Your archives can be found in project archives.",
});
onClose();
router.push(`/${workspaceSlug}/projects/${projectId}/archives/cycles?peekCycle=${cycleId}`);
})
.catch(() =>
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Cycle could not be archived. Please try again.",
})
)
.finally(() => setIsArchiving(false));
};
return (
<Transition.Root show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-20" onClose={onClose}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-custom-backdrop transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-10 overflow-y-auto">
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-custom-background-100 text-left shadow-custom-shadow-md transition-all sm:my-8 sm:w-full sm:max-w-lg">
<div className="px-5 py-4">
<h3 className="text-xl font-medium 2xl:text-2xl">Archive cycle {cycleName}</h3>
<p className="mt-3 text-sm text-custom-text-200">
Are you sure you want to archive the cycle? All your archives can be restored later.
</p>
<div className="mt-3 flex justify-end gap-2">
<Button variant="neutral-primary" size="sm" onClick={onClose}>
Cancel
</Button>
<Button size="sm" tabIndex={1} onClick={handleArchiveIssue} loading={isArchiving}>
{isArchiving ? "Archiving" : "Archive"}
</Button>
</div>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
};

View File

@ -0,0 +1,77 @@
import React from "react";
import { observer } from "mobx-react-lite";
import { useRouter } from "next/router";
import useSWR from "swr";
// types
import { TCycleFilters } from "@plane/types";
// components
import { ArchivedCyclesView, CycleAppliedFiltersList } from "@/components/cycles";
import { EmptyState } from "@/components/empty-state";
import { CycleModuleListLayout } from "@/components/ui";
// constants
import { EmptyStateType } from "@/constants/empty-state";
// helpers
import { calculateTotalFilters } from "@/helpers/filter.helper";
// hooks
import { useCycle, useCycleFilter } from "@/hooks/store";
export const ArchivedCycleLayoutRoot: React.FC = observer(() => {
// router
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
// hooks
const { fetchArchivedCycles, currentProjectArchivedCycleIds, loader } = useCycle();
// cycle filters hook
const { clearAllFilters, currentProjectArchivedFilters, updateFilters } = useCycleFilter();
// derived values
const totalArchivedCycles = currentProjectArchivedCycleIds?.length ?? 0;
useSWR(
workspaceSlug && projectId ? `ARCHIVED_CYCLES_${workspaceSlug.toString()}_${projectId.toString()}` : null,
async () => {
if (workspaceSlug && projectId) {
await fetchArchivedCycles(workspaceSlug.toString(), projectId.toString());
}
},
{ revalidateIfStale: false, revalidateOnFocus: false }
);
const handleRemoveFilter = (key: keyof TCycleFilters, value: string | null) => {
if (!projectId) return;
let newValues = currentProjectArchivedFilters?.[key] ?? [];
if (!value) newValues = [];
else newValues = newValues.filter((val) => val !== value);
updateFilters(projectId.toString(), { [key]: newValues }, "archived");
};
if (!workspaceSlug || !projectId) return <></>;
if (loader || !currentProjectArchivedCycleIds) {
return <CycleModuleListLayout />;
}
return (
<>
{calculateTotalFilters(currentProjectArchivedFilters ?? {}) !== 0 && (
<div className="border-b border-custom-border-200 px-5 py-3">
<CycleAppliedFiltersList
appliedFilters={currentProjectArchivedFilters ?? {}}
handleClearAllFilters={() => clearAllFilters(projectId.toString(), "archived")}
handleRemoveFilter={handleRemoveFilter}
/>
</div>
)}
{totalArchivedCycles === 0 ? (
<div className="h-full place-items-center">
<EmptyState type={EmptyStateType.PROJECT_ARCHIVED_NO_CYCLES} />
</div>
) : (
<div className="relative h-full w-full overflow-auto">
<ArchivedCyclesView workspaceSlug={workspaceSlug.toString()} projectId={projectId.toString()} />
</div>
)}
</>
);
});

View File

@ -0,0 +1,57 @@
import { FC } from "react";
import { observer } from "mobx-react-lite";
import Image from "next/image";
// components
import { CyclesList } from "@/components/cycles";
// ui
import { CycleModuleListLayout } from "@/components/ui";
// hooks
import { useCycle, useCycleFilter } from "@/hooks/store";
// assets
import AllFiltersImage from "@/public/empty-state/cycle/all-filters.svg";
import NameFilterImage from "@/public/empty-state/cycle/name-filter.svg";
export interface IArchivedCyclesView {
workspaceSlug: string;
projectId: string;
}
export const ArchivedCyclesView: FC<IArchivedCyclesView> = observer((props) => {
const { workspaceSlug, projectId } = props;
// store hooks
const { getFilteredArchivedCycleIds, loader } = useCycle();
const { archivedCyclesSearchQuery } = useCycleFilter();
// derived values
const filteredArchivedCycleIds = getFilteredArchivedCycleIds(projectId);
if (loader || !filteredArchivedCycleIds) return <CycleModuleListLayout />;
if (filteredArchivedCycleIds.length === 0)
return (
<div className="h-full w-full grid place-items-center">
<div className="text-center">
<Image
src={archivedCyclesSearchQuery.trim() === "" ? AllFiltersImage : NameFilterImage}
className="h-36 sm:h-48 w-36 sm:w-48 mx-auto"
alt="No matching cycles"
/>
<h5 className="text-xl font-medium mt-7 mb-1">No matching cycles</h5>
<p className="text-custom-text-400 text-base">
{archivedCyclesSearchQuery.trim() === ""
? "Remove the filters to see all cycles"
: "Remove the search criteria to see all cycles"}
</p>
</div>
</div>
);
return (
<CyclesList
completedCycleIds={[]}
cycleIds={filteredArchivedCycleIds}
workspaceSlug={workspaceSlug}
projectId={projectId}
isArchived
/>
);
});

View File

@ -9,9 +9,10 @@ import { CycleDetailsSidebar } from "./sidebar";
type Props = {
projectId: string;
workspaceSlug: string;
isArchived?: boolean;
};
export const CyclePeekOverview: React.FC<Props> = observer(({ projectId, workspaceSlug }) => {
export const CyclePeekOverview: React.FC<Props> = observer(({ projectId, workspaceSlug, isArchived = false }) => {
// router
const router = useRouter();
const { peekCycle } = router.query;
@ -29,9 +30,9 @@ export const CyclePeekOverview: React.FC<Props> = observer(({ projectId, workspa
};
useEffect(() => {
if (!peekCycle) return;
if (!peekCycle || isArchived) return;
fetchCycleDetails(workspaceSlug, projectId, peekCycle.toString());
}, [fetchCycleDetails, peekCycle, projectId, workspaceSlug]);
}, [fetchCycleDetails, isArchived, peekCycle, projectId, workspaceSlug]);
return (
<>
@ -44,7 +45,11 @@ export const CyclePeekOverview: React.FC<Props> = observer(({ projectId, workspa
"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} />
<CycleDetailsSidebar
cycleId={peekCycle?.toString() ?? ""}
handleClose={handleClose}
isArchived={isArchived}
/>
</div>
)}
</>

View File

@ -2,21 +2,21 @@ import { useCallback, useRef, useState } from "react";
import { observer } from "mobx-react";
import { ListFilter, Search, X } from "lucide-react";
import { Tab } from "@headlessui/react";
// types
import { TCycleFilters } from "@plane/types";
// hooks
// ui
import { Tooltip } from "@plane/ui";
// components
import { CycleFiltersSelection } from "@/components/cycles";
import { FiltersDropdown } from "@/components/issues";
// constants
import { CYCLE_TABS_LIST, CYCLE_VIEW_LAYOUTS } from "@/constants/cycle";
// helpers
import { cn } from "@/helpers/common.helper";
// hooks
import { useCycleFilter } from "@/hooks/store";
import useOutsideClickDetector from "@/hooks/use-outside-click-detector";
import { usePlatformOS } from "@/hooks/use-platform-os";
// components
// ui
// helpers
// types
// constants
type Props = {
projectId: string;
@ -24,8 +24,6 @@ type Props = {
export const CyclesViewHeader: React.FC<Props> = observer((props) => {
const { projectId } = props;
// states
const [isSearchOpen, setIsSearchOpen] = useState(false);
// refs
const inputRef = useRef<HTMLInputElement>(null);
// hooks
@ -38,6 +36,8 @@ export const CyclesViewHeader: React.FC<Props> = observer((props) => {
updateSearchQuery,
} = useCycleFilter();
const { isMobile } = usePlatformOS();
// states
const [isSearchOpen, setIsSearchOpen] = useState(searchQuery !== "" ? true : false);
// outside click detector hook
useOutsideClickDetector(inputRef, () => {
if (isSearchOpen && searchQuery.trim() === "") setIsSearchOpen(false);

View File

@ -9,10 +9,11 @@ import { FilterEndDate, FilterStartDate, FilterStatus } from "@/components/cycle
type Props = {
filters: TCycleFilters;
handleFiltersUpdate: (key: keyof TCycleFilters, value: string | string[]) => void;
isArchived?: boolean;
};
export const CycleFiltersSelection: React.FC<Props> = observer((props) => {
const { filters, handleFiltersUpdate } = props;
const { filters, handleFiltersUpdate, isArchived = false } = props;
// states
const [filtersSearchQuery, setFiltersSearchQuery] = useState("");
@ -38,6 +39,7 @@ export const CycleFiltersSelection: React.FC<Props> = observer((props) => {
</div>
<div className="h-full w-full divide-y divide-custom-border-200 overflow-y-auto px-2.5 vertical-scrollbar scrollbar-sm">
{/* cycle status */}
{!isArchived && (
<div className="py-2">
<FilterStatus
appliedFilters={(filters.status as TCycleGroups[]) ?? null}
@ -45,6 +47,7 @@ export const CycleFiltersSelection: React.FC<Props> = observer((props) => {
searchQuery={filtersSearchQuery}
/>
</div>
)}
{/* start date */}
<div className="py-2">

View File

@ -113,7 +113,7 @@ export const CycleForm: React.FC<Props> = (props) => {
id="cycle_description"
name="description"
placeholder="Description..."
className="!h-24 w-full resize-none text-sm"
className="w-full text-sm resize-none min-h-24"
hasError={Boolean(errors?.description)}
value={value}
onChange={onChange}

View File

@ -14,3 +14,6 @@ export * from "./quick-actions";
export * from "./sidebar";
export * from "./transfer-issues-modal";
export * from "./transfer-issues";
// archived cycles
export * from "./archived-cycles";

View File

@ -2,27 +2,21 @@ import { FC, MouseEvent } from "react";
import { observer } from "mobx-react";
import Link from "next/link";
import { useRouter } from "next/router";
// hooks
import { Check, Info, Star, User2 } from "lucide-react";
import type { TCycleGroups } from "@plane/types";
import { Tooltip, CircularProgressIndicator, CycleGroupIcon, AvatarGroup, Avatar, setPromiseToast } from "@plane/ui";
import { CycleQuickActions } from "@/components/cycles";
// components
// import { CycleCreateUpdateModal, CycleDeleteModal } from "@/components/cycles";
// ui
// icons
// helpers
import { Check, Info, Star, User2 } from "lucide-react";
// types
import type { TCycleGroups } from "@plane/types";
// ui
import { Tooltip, CircularProgressIndicator, CycleGroupIcon, AvatarGroup, Avatar, setPromiseToast } from "@plane/ui";
// components
import { CycleQuickActions } from "@/components/cycles";
// constants
import { CYCLE_STATUS } from "@/constants/cycle";
import { CYCLE_FAVORITED, CYCLE_UNFAVORITED } from "@/constants/event-tracker";
// components
// ui
// icons
// helpers
// constants
// types
import { EUserProjectRoles } from "@/constants/project";
// helpers
import { findHowManyDaysLeft, getDate, renderFormattedDate } from "@/helpers/date-time.helper";
// hooks
import { useEventTracker, useCycle, useUser, useMember } from "@/hooks/store";
import { usePlatformOS } from "@/hooks/use-platform-os";
@ -34,10 +28,11 @@ type TCyclesListItem = {
handleRemoveFromFavorites?: () => void;
workspaceSlug: string;
projectId: string;
isArchived?: boolean;
};
export const CyclesListItem: FC<TCyclesListItem> = observer((props) => {
const { cycleId, workspaceSlug, projectId } = props;
const { cycleId, workspaceSlug, projectId, isArchived } = props;
// router
const router = useRouter();
// hooks
@ -106,7 +101,7 @@ export const CyclesListItem: FC<TCyclesListItem> = observer((props) => {
});
};
const openCycleOverview = (e: MouseEvent<HTMLButtonElement>) => {
const openCycleOverview = (e: MouseEvent<HTMLButtonElement | HTMLAnchorElement>) => {
const { query } = router;
e.preventDefault();
e.stopPropagation();
@ -151,7 +146,14 @@ export const CyclesListItem: FC<TCyclesListItem> = observer((props) => {
return (
<>
<Link href={`/${workspaceSlug}/projects/${projectId}/cycles/${cycleDetails.id}`}>
<Link
href={`/${workspaceSlug}/projects/${projectId}/cycles/${cycleDetails.id}`}
onClick={(e) => {
if (isArchived) {
openCycleOverview(e);
}
}}
>
<div className="group flex w-full flex-col items-center justify-between gap-5 border-b border-custom-border-100 bg-custom-background-100 px-5 py-6 text-sm hover:bg-custom-background-90 md:flex-row">
<div className="relative flex w-full items-center justify-between gap-3 overflow-hidden">
<div className="relative flex w-full items-center gap-3 overflow-hidden">
@ -221,9 +223,9 @@ export const CyclesListItem: FC<TCyclesListItem> = observer((props) => {
</div>
</Tooltip>
{isEditingAllowed && (
<>
{cycleDetails.is_favorite ? (
{isEditingAllowed &&
!isArchived &&
(cycleDetails.is_favorite ? (
<button type="button" onClick={handleRemoveFromFavorites}>
<Star className="h-3.5 w-3.5 fill-current text-amber-500" />
</button>
@ -231,11 +233,13 @@ export const CyclesListItem: FC<TCyclesListItem> = observer((props) => {
<button type="button" onClick={handleAddToFavorites}>
<Star className="h-3.5 w-3.5 text-custom-text-200" />
</button>
)}
<CycleQuickActions cycleId={cycleId} projectId={projectId} workspaceSlug={workspaceSlug} />
</>
)}
))}
<CycleQuickActions
cycleId={cycleId}
projectId={projectId}
workspaceSlug={workspaceSlug}
isArchived={isArchived}
/>
</div>
</div>
</div>

View File

@ -5,15 +5,22 @@ type Props = {
cycleIds: string[];
projectId: string;
workspaceSlug: string;
isArchived?: boolean;
};
export const CyclesListMap: React.FC<Props> = (props) => {
const { cycleIds, projectId, workspaceSlug } = props;
const { cycleIds, projectId, workspaceSlug, isArchived } = props;
return (
<>
{cycleIds.map((cycleId) => (
<CyclesListItem key={cycleId} cycleId={cycleId} workspaceSlug={workspaceSlug} projectId={projectId} />
<CyclesListItem
key={cycleId}
cycleId={cycleId}
workspaceSlug={workspaceSlug}
projectId={projectId}
isArchived={isArchived}
/>
))}
</>
);

View File

@ -12,16 +12,22 @@ export interface ICyclesList {
cycleIds: string[];
workspaceSlug: string;
projectId: string;
isArchived?: boolean;
}
export const CyclesList: FC<ICyclesList> = observer((props) => {
const { completedCycleIds, cycleIds, workspaceSlug, projectId } = props;
const { completedCycleIds, cycleIds, workspaceSlug, projectId, isArchived = false } = props;
return (
<div className="h-full overflow-y-auto">
<div className="flex h-full w-full justify-between">
<div className="flex h-full w-full flex-col overflow-y-auto vertical-scrollbar scrollbar-lg">
<CyclesListMap cycleIds={cycleIds} projectId={projectId} workspaceSlug={workspaceSlug} />
<CyclesListMap
cycleIds={cycleIds}
projectId={projectId}
workspaceSlug={workspaceSlug}
isArchived={isArchived}
/>
{completedCycleIds.length !== 0 && (
<Disclosure as="div" className="mt-4 space-y-4">
<Disclosure.Button className="bg-custom-background-80 font-semibold text-sm py-1 px-2 rounded ml-5 flex items-center gap-1">
@ -37,12 +43,17 @@ export const CyclesList: FC<ICyclesList> = observer((props) => {
)}
</Disclosure.Button>
<Disclosure.Panel>
<CyclesListMap cycleIds={completedCycleIds} projectId={projectId} workspaceSlug={workspaceSlug} />
<CyclesListMap
cycleIds={completedCycleIds}
projectId={projectId}
workspaceSlug={workspaceSlug}
isArchived={isArchived}
/>
</Disclosure.Panel>
</Disclosure>
)}
</div>
<CyclePeekOverview projectId={projectId} workspaceSlug={workspaceSlug} />
<CyclePeekOverview projectId={projectId} workspaceSlug={workspaceSlug} isArchived={isArchived} />
</div>
</div>
);

View File

@ -1,34 +1,40 @@
import { useState } from "react";
import { observer } from "mobx-react";
import { LinkIcon, Pencil, Trash2 } from "lucide-react";
// hooks
// components
import { CustomMenu, TOAST_TYPE, setToast } from "@plane/ui";
import { CycleCreateUpdateModal, CycleDeleteModal } from "@/components/cycles";
import { useRouter } from "next/router";
// icons
import { ArchiveRestoreIcon, LinkIcon, Pencil, Trash2 } from "lucide-react";
// ui
// helpers
import { EUserProjectRoles } from "@/constants/project";
import { copyUrlToClipboard } from "@/helpers/string.helper";
import { ArchiveIcon, CustomMenu, TOAST_TYPE, setToast } from "@plane/ui";
// components
import { ArchiveCycleModal, CycleCreateUpdateModal, CycleDeleteModal } from "@/components/cycles";
// constants
import { EUserProjectRoles } from "@/constants/project";
// helpers
import { copyUrlToClipboard } from "@/helpers/string.helper";
// hooks
import { useCycle, useEventTracker, useUser } from "@/hooks/store";
type Props = {
cycleId: string;
projectId: string;
workspaceSlug: string;
isArchived?: boolean;
};
export const CycleQuickActions: React.FC<Props> = observer((props) => {
const { cycleId, projectId, workspaceSlug } = props;
const { cycleId, projectId, workspaceSlug, isArchived } = props;
// router
const router = useRouter();
// states
const [updateModal, setUpdateModal] = useState(false);
const [archiveCycleModal, setArchiveCycleModal] = useState(false);
const [deleteModal, setDeleteModal] = useState(false);
// store hooks
const { setTrackElement } = useEventTracker();
const {
membership: { currentWorkspaceAllProjectsRole },
} = useUser();
const { getCycleById } = useCycle();
const { getCycleById, restoreCycle } = useCycle();
// derived values
const cycleDetails = getCycleById(cycleId);
const isCompleted = cycleDetails?.status.toLowerCase() === "completed";
@ -56,6 +62,33 @@ export const CycleQuickActions: React.FC<Props> = observer((props) => {
setUpdateModal(true);
};
const handleArchiveCycle = (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
setArchiveCycleModal(true);
};
const handleRestoreCycle = async (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
await restoreCycle(workspaceSlug, projectId, cycleId)
.then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Restore success",
message: "Your cycle can be found in project cycles.",
});
router.push(`/${workspaceSlug}/projects/${projectId}/cycles/${cycleId}`);
})
.catch(() =>
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Cycle could not be restored. Please try again.",
})
);
};
const handleDeleteCycle = (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
@ -74,6 +107,13 @@ export const CycleQuickActions: React.FC<Props> = observer((props) => {
workspaceSlug={workspaceSlug}
projectId={projectId}
/>
<ArchiveCycleModal
workspaceSlug={workspaceSlug}
projectId={projectId}
cycleId={cycleId}
isOpen={archiveCycleModal}
handleClose={() => setArchiveCycleModal(false)}
/>
<CycleDeleteModal
cycle={cycleDetails}
isOpen={deleteModal}
@ -84,28 +124,60 @@ export const CycleQuickActions: React.FC<Props> = observer((props) => {
</div>
)}
<CustomMenu ellipsis placement="bottom-end">
{!isCompleted && isEditingAllowed && (
<>
{!isCompleted && isEditingAllowed && !isArchived && (
<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}>
)}
{isEditingAllowed && !isArchived && (
<CustomMenu.MenuItem onClick={handleArchiveCycle} disabled={!isCompleted}>
{isCompleted ? (
<div className="flex items-center gap-2">
<ArchiveIcon className="h-3 w-3" />
Archive cycle
</div>
) : (
<div className="flex items-start gap-2">
<ArchiveIcon className="h-3 w-3" />
<div className="-mt-1">
<p>Archive cycle</p>
<p className="text-xs text-custom-text-400">
Only completed cycle <br /> can be archived.
</p>
</div>
</div>
)}
</CustomMenu.MenuItem>
)}
{isEditingAllowed && isArchived && (
<CustomMenu.MenuItem onClick={handleRestoreCycle}>
<span className="flex items-center justify-start gap-2">
<Trash2 className="h-3 w-3" />
<span>Delete cycle</span>
<ArchiveRestoreIcon className="h-3 w-3" />
<span>Restore cycle</span>
</span>
</CustomMenu.MenuItem>
</>
)}
{!isArchived && (
<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>
)}
{!isCompleted && isEditingAllowed && (
<div className="border-t pt-1 mt-1">
<CustomMenu.MenuItem onClick={handleDeleteCycle}>
<span className="flex items-center justify-start gap-2">
<Trash2 className="h-3 w-3" />
<span>Delete cycle</span>
</span>
</CustomMenu.MenuItem>
</div>
)}
</CustomMenu>
</>
);

View File

@ -3,33 +3,43 @@ import isEmpty from "lodash/isEmpty";
import { observer } from "mobx-react-lite";
import { useRouter } from "next/router";
import { Controller, useForm } from "react-hook-form";
import { ChevronDown, LinkIcon, Trash2, UserCircle2, AlertCircle, ChevronRight, CalendarClock } from "lucide-react";
import { Disclosure, Transition } from "@headlessui/react";
// icons
import {
ArchiveRestoreIcon,
ChevronDown,
LinkIcon,
Trash2,
UserCircle2,
AlertCircle,
ChevronRight,
CalendarClock,
} from "lucide-react";
import { Disclosure, Transition } from "@headlessui/react";
// types
import { ICycle } from "@plane/types";
// ui
import { Avatar, CustomMenu, Loader, LayersIcon, TOAST_TYPE, setToast } from "@plane/ui";
import { Avatar, ArchiveIcon, CustomMenu, Loader, LayersIcon, TOAST_TYPE, setToast, TextArea } from "@plane/ui";
// components
import { SidebarProgressStats } from "@/components/core";
import ProgressChart from "@/components/core/sidebar/progress-chart";
import { CycleDeleteModal } from "@/components/cycles/delete-modal";
import { ArchiveCycleModal, CycleDeleteModal } from "@/components/cycles";
import { DateRangeDropdown } from "@/components/dropdowns";
// constants
import { CYCLE_STATUS } from "@/constants/cycle";
import { CYCLE_UPDATED } from "@/constants/event-tracker";
import { EUserWorkspaceRoles } from "@/constants/workspace";
// helpers
// hooks
import { findHowManyDaysLeft, getDate, renderFormattedPayloadDate } from "@/helpers/date-time.helper";
import { copyUrlToClipboard } from "@/helpers/string.helper";
// hooks
import { useEventTracker, useCycle, useUser, useMember } from "@/hooks/store";
// services
import { CycleService } from "@/services/cycle.service";
// types
type Props = {
cycleId: string;
handleClose: () => void;
isArchived?: boolean;
};
const defaultValues: Partial<ICycle> = {
@ -42,8 +52,9 @@ const cycleService = new CycleService();
// TODO: refactor the whole component
export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
const { cycleId, handleClose } = props;
const { cycleId, handleClose, isArchived } = props;
// states
const [archiveCycleModal, setArchiveCycleModal] = useState(false);
const [cycleDeleteModal, setCycleDeleteModal] = useState(false);
// router
const router = useRouter();
@ -53,7 +64,7 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
const {
membership: { currentProjectRole },
} = useUser();
const { getCycleById, updateCycleDetails } = useCycle();
const { getCycleById, updateCycleDetails, restoreCycle } = useCycle();
const { getUserDetails } = useMember();
// derived values
const cycleDetails = getCycleById(cycleId);
@ -108,6 +119,27 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
});
};
const handleRestoreCycle = async () => {
if (!workspaceSlug || !projectId) return;
await restoreCycle(workspaceSlug.toString(), projectId.toString(), cycleId)
.then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Restore success",
message: "Your cycle can be found in project cycles.",
});
router.push(`/${workspaceSlug.toString()}/projects/${projectId.toString()}/cycles/${cycleId}`);
})
.catch(() =>
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Cycle could not be restored. Please try again.",
})
);
};
useEffect(() => {
if (cycleDetails)
reset({
@ -229,6 +261,14 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
return (
<div className="relative">
{cycleDetails && workspaceSlug && projectId && (
<>
<ArchiveCycleModal
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
cycleId={cycleId}
isOpen={archiveCycleModal}
handleClose={() => setArchiveCycleModal(false)}
/>
<CycleDeleteModal
cycle={cycleDetails}
isOpen={cycleDeleteModal}
@ -236,6 +276,7 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
/>
</>
)}
<>
@ -249,11 +290,42 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
</button>
</div>
<div className="flex items-center gap-3.5">
{!isArchived && (
<button onClick={handleCopyText}>
<LinkIcon className="h-3 w-3 text-custom-text-300" />
</button>
{!isCompleted && isEditingAllowed && (
)}
{isEditingAllowed && (
<CustomMenu placement="bottom-end" ellipsis>
{!isArchived && (
<CustomMenu.MenuItem onClick={() => setArchiveCycleModal(true)} disabled={!isCompleted}>
{isCompleted ? (
<div className="flex items-center gap-2">
<ArchiveIcon className="h-3 w-3" />
Archive cycle
</div>
) : (
<div className="flex items-start gap-2">
<ArchiveIcon className="h-3 w-3" />
<div className="-mt-1">
<p>Archive cycle</p>
<p className="text-xs text-custom-text-400">
Only completed cycle <br /> can be archived.
</p>
</div>
</div>
)}
</CustomMenu.MenuItem>
)}
{isArchived && (
<CustomMenu.MenuItem onClick={handleRestoreCycle}>
<span className="flex items-center justify-start gap-2">
<ArchiveRestoreIcon className="h-3 w-3" />
<span>Restore cycle</span>
</span>
</CustomMenu.MenuItem>
)}
{!isCompleted && (
<CustomMenu.MenuItem
onClick={() => {
setTrackElement("CYCLE_PAGE_SIDEBAR");
@ -265,6 +337,7 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
<span>Delete cycle</span>
</span>
</CustomMenu.MenuItem>
)}
</CustomMenu>
)}
</div>
@ -290,9 +363,11 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
</div>
{cycleDetails.description && (
<span className="w-full whitespace-normal break-words py-2.5 text-sm leading-5 text-custom-text-200">
{cycleDetails.description}
</span>
<TextArea
className="outline-none ring-none w-full max-h-max bg-transparent !p-0 !m-0 !border-0 resize-none text-sm leading-5 text-custom-text-200"
value={cycleDetails.description}
disabled
/>
)}
<div className="flex flex-col gap-5 pb-6 pt-2.5">
@ -329,6 +404,7 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
to: "End date",
}}
required={cycleDetails.status !== "draft"}
disabled={isArchived}
/>
)}
/>

View File

@ -149,6 +149,7 @@ export const DateRangeDropdown: React.FC<Props> = (props) => {
if (!isOpen) handleKeyDown(e);
} else handleKeyDown(e);
}}
disabled={disabled}
>
<Combobox.Button as={React.Fragment}>
<button

View File

@ -17,7 +17,7 @@ export * from "./workspace-settings";
export * from "./pages";
export * from "./project-draft-issues";
export * from "./project-archived-issue-details";
export * from "./project-archived-issues";
export * from "./project-archives";
export * from "./project-issue-details";
export * from "./user-profile";
export * from "./workspace-active-cycles";

View File

@ -23,8 +23,6 @@ import { usePlatformOS } from "@/hooks/use-platform-os";
// types
export const ModulesListHeader: React.FC = observer(() => {
// states
const [isSearchOpen, setIsSearchOpen] = useState(false);
// refs
const inputRef = useRef<HTMLInputElement>(null);
// router
@ -49,6 +47,8 @@ export const ModulesListHeader: React.FC = observer(() => {
updateFilters,
updateSearchQuery,
} = useModuleFilter();
// states
const [isSearchOpen, setIsSearchOpen] = useState(searchQuery !== "" ? true : false);
// outside click detector hook
useOutsideClickDetector(inputRef, () => {
if (isSearchOpen && searchQuery.trim() === "") setIsSearchOpen(false);

View File

@ -3,7 +3,7 @@ import { observer } from "mobx-react-lite";
import { useRouter } from "next/router";
import useSWR from "swr";
// hooks
import { Breadcrumbs, LayersIcon } from "@plane/ui";
import { ArchiveIcon, Breadcrumbs, LayersIcon } from "@plane/ui";
import { BreadcrumbLink } from "@/components/common";
import { ProjectLogo } from "@/components/project";
import { ISSUE_DETAILS } from "@/constants/fetch-keys";
@ -39,7 +39,7 @@ export const ProjectArchivedIssueDetailsHeader: FC = observer(() => {
);
return (
<div className="relative z-10 flex h-[3.75rem] w-full flex-shrink-0 flex-row items-center justify-between gap-x-2 gap-y-4 bg-custom-sidebar-background-100 p-4">
<div className="relative z-10 flex h-14 w-full flex-shrink-0 flex-row items-center justify-between gap-x-2 gap-y-4 bg-custom-sidebar-background-100 p-4">
<div className="flex w-full flex-grow items-center gap-2 overflow-ellipsis whitespace-nowrap">
<div>
<Breadcrumbs>
@ -59,18 +59,26 @@ export const ProjectArchivedIssueDetailsHeader: FC = observer(() => {
/>
}
/>
<Breadcrumbs.BreadcrumbItem
type="text"
link={
<BreadcrumbLink
href={`/${workspaceSlug}/projects/${projectId}/archived-issues`}
label="Archived issues"
href={`/${workspaceSlug}/projects/${projectId}/archives/issues`}
label="Archives"
icon={<ArchiveIcon className="h-4 w-4 text-custom-text-300" />}
/>
}
/>
<Breadcrumbs.BreadcrumbItem
type="text"
link={
<BreadcrumbLink
href={`/${workspaceSlug}/projects/${projectId}/archives/issues`}
label="Issues"
icon={<LayersIcon className="h-4 w-4 text-custom-text-300" />}
/>
}
/>
<Breadcrumbs.BreadcrumbItem
type="text"
link={

View File

@ -0,0 +1,96 @@
import { FC } from "react";
import { observer } from "mobx-react-lite";
import { useRouter } from "next/router";
// ui
import { ArchiveIcon, Breadcrumbs, Tooltip } from "@plane/ui";
// components
import { BreadcrumbLink } from "@/components/common";
import { ProjectLogo } from "@/components/project";
// constants
import { PROJECT_ARCHIVES_BREADCRUMB_LIST } from "@/constants/archives";
import { EIssuesStoreType } from "@/constants/issue";
// hooks
import { useIssues, useProject } from "@/hooks/store";
import { usePlatformOS } from "@/hooks/use-platform-os";
export const ProjectArchivesHeader: FC = observer(() => {
// router
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const activeTab = router.pathname.split("/").pop();
// store hooks
const {
issuesFilter: { issueFilters },
} = useIssues(EIssuesStoreType.ARCHIVED);
const { currentProjectDetails } = useProject();
// hooks
const { isMobile } = usePlatformOS();
const issueCount = currentProjectDetails
? issueFilters?.displayFilters?.sub_issue
? currentProjectDetails.archived_issues + currentProjectDetails.archived_sub_issues
: currentProjectDetails.archived_issues
: undefined;
const activeTabBreadcrumbDetail =
PROJECT_ARCHIVES_BREADCRUMB_LIST[activeTab as keyof typeof PROJECT_ARCHIVES_BREADCRUMB_LIST];
return (
<div className="relative z-10 flex h-14 w-full flex-shrink-0 flex-row items-center justify-between gap-x-2 gap-y-4 bg-custom-sidebar-background-100 p-4">
<div className="flex w-full flex-grow items-center gap-2 overflow-ellipsis whitespace-nowrap">
<div className="flex items-center gap-2.5">
<Breadcrumbs onBack={router.back}>
<Breadcrumbs.BreadcrumbItem
type="text"
link={
<BreadcrumbLink
href={`/${workspaceSlug}/projects`}
label={currentProjectDetails?.name ?? "Project"}
icon={
currentProjectDetails && (
<span className="grid place-items-center flex-shrink-0 h-4 w-4">
<ProjectLogo logo={currentProjectDetails?.logo_props} className="text-sm" />
</span>
)
}
/>
}
/>
<Breadcrumbs.BreadcrumbItem
type="text"
link={
<BreadcrumbLink
href={`/${workspaceSlug}/projects/${projectId}/archives/issues`}
label="Archives"
icon={<ArchiveIcon className="h-4 w-4 text-custom-text-300" />}
/>
}
/>
{activeTabBreadcrumbDetail && (
<Breadcrumbs.BreadcrumbItem
type="text"
link={
<BreadcrumbLink
label={activeTabBreadcrumbDetail.label}
icon={<activeTabBreadcrumbDetail.icon className="h-4 w-4 text-custom-text-300" />}
/>
}
/>
)}
</Breadcrumbs>
{activeTab === "issues" && issueCount && issueCount > 0 ? (
<Tooltip
isMobile={isMobile}
tooltipContent={`There are ${issueCount} ${issueCount > 1 ? "issues" : "issue"} in project's archived`}
position="bottom"
>
<span className="cursor-default flex items-center text-center justify-center px-2.5 py-0.5 flex-shrink-0 bg-custom-primary-100/20 text-custom-primary-100 text-xs font-semibold rounded-xl">
{issueCount}
</span>
</Tooltip>
) : null}
</div>
</div>
</div>
);
});

View File

@ -1,22 +1,17 @@
import { FC } from "react";
import { observer } from "mobx-react-lite";
import { useRouter } from "next/router";
import type { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions } from "@plane/types";
// hooks
// constants
// ui
import { Breadcrumbs, LayersIcon, Tooltip } from "@plane/ui";
// components
import { BreadcrumbLink } from "@/components/common";
import { DisplayFiltersSelection, FilterSelection, FiltersDropdown } from "@/components/issues";
import { ProjectLogo } from "@/components/project";
import { EIssueFilterType, EIssuesStoreType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "@/constants/issue";
// helpers
import { useIssues, useLabel, useMember, useProject, useProjectState } from "@/hooks/store";
// types
import { usePlatformOS } from "@/hooks/use-platform-os";
import type { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions } from "@plane/types";
// components
import { ArchiveTabsList } from "@/components/archives";
import { DisplayFiltersSelection, FilterSelection, FiltersDropdown } from "@/components/issues";
// constants
import { EIssueFilterType, EIssuesStoreType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "@/constants/issue";
// hooks
import { useIssues, useLabel, useMember, useProjectState } from "@/hooks/store";
export const ProjectArchivedIssuesHeader: FC = observer(() => {
export const ArchivedIssuesHeader: FC = observer(() => {
// router
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
@ -24,7 +19,6 @@ export const ProjectArchivedIssuesHeader: FC = observer(() => {
const {
issuesFilter: { issueFilters, updateFilters },
} = useIssues(EIssuesStoreType.ARCHIVED);
const { currentProjectDetails } = useProject();
const { projectStates } = useProjectState();
const { projectLabels } = useLabel();
const {
@ -33,7 +27,6 @@ export const ProjectArchivedIssuesHeader: FC = observer(() => {
// for archived issues list layout is the only option
const activeLayout = "list";
// hooks
const { isMobile } = usePlatformOS();
const handleFiltersUpdate = (key: keyof IIssueFilterOptions, value: string | string[]) => {
if (!workspaceSlug || !projectId) return;
@ -68,60 +61,13 @@ export const ProjectArchivedIssuesHeader: FC = observer(() => {
updateFilters(workspaceSlug.toString(), projectId.toString(), EIssueFilterType.DISPLAY_PROPERTIES, property);
};
const issueCount = currentProjectDetails
? issueFilters?.displayFilters?.sub_issue
? currentProjectDetails.archived_issues + currentProjectDetails.archived_sub_issues
: currentProjectDetails.archived_issues
: undefined;
return (
<div className="relative z-10 flex h-14 w-full flex-shrink-0 flex-row items-center justify-between gap-x-2 gap-y-4 bg-custom-sidebar-background-100 p-4">
<div className="flex w-full flex-grow items-center gap-2 overflow-ellipsis whitespace-nowrap">
<div className="flex items-center gap-2.5">
<Breadcrumbs onBack={router.back}>
<Breadcrumbs.BreadcrumbItem
type="text"
link={
<BreadcrumbLink
href={`/${workspaceSlug}/projects`}
label={currentProjectDetails?.name ?? "Project"}
icon={
currentProjectDetails && (
<span className="grid place-items-center flex-shrink-0 h-4 w-4">
<ProjectLogo logo={currentProjectDetails?.logo_props} className="text-sm" />
</span>
)
}
/>
}
/>
<Breadcrumbs.BreadcrumbItem
type="text"
link={
<BreadcrumbLink
label="Archived issues"
icon={<LayersIcon className="h-4 w-4 text-custom-text-300" />}
/>
}
/>
</Breadcrumbs>
{issueCount && issueCount > 0 ? (
<Tooltip
isMobile={isMobile}
tooltipContent={`There are ${issueCount} ${issueCount > 1 ? "issues" : "issue"} in project's archived`}
position="bottom"
>
<span className="cursor-default flex items-center text-center justify-center px-2.5 py-0.5 flex-shrink-0 bg-custom-primary-100/20 text-custom-primary-100 text-xs font-semibold rounded-xl">
{issueCount}
</span>
</Tooltip>
) : null}
<div className="group relative flex border-b border-custom-border-200">
<div className="flex w-full items-center overflow-x-auto px-4 gap-2 horizontal-scrollbar scrollbar-sm">
<ArchiveTabsList />
</div>
</div>
{/* filter options */}
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 px-8">
<FiltersDropdown title="Filters" placement="bottom-end">
<FilterSelection
filters={issueFilters?.filters || {}}

View File

@ -16,3 +16,4 @@ export * from "./peek-overview";
// archived issue
export * from "./archive-issue-modal";
export * from "./archived-issues-header";

View File

@ -7,6 +7,8 @@ import { IIssueLabel } from "@plane/types";
// hooks
import { Input, TOAST_TYPE, setToast } from "@plane/ui";
import { useIssueDetail } from "@/hooks/store";
// helpers
import { cn } from "helpers/common.helper";
// ui
// types
import { TLabelOperations } from "./root";
@ -29,6 +31,7 @@ export const LabelCreate: FC<ILabelCreate> = (props) => {
// hooks
const {
issue: { getIssueById },
peekIssue,
} = useIssueDetail();
// state
const [isCreateToggle, setIsCreateToggle] = useState(false);
@ -82,13 +85,13 @@ export const LabelCreate: FC<ILabelCreate> = (props) => {
</div>
{isCreateToggle && (
<form className="flex items-center gap-x-2" onSubmit={handleSubmit(handleLabel)}>
<form className="relative flex items-center gap-x-2" onSubmit={handleSubmit(handleLabel)}>
<div>
<Controller
name="color"
control={control}
render={({ field: { value, onChange } }) => (
<Popover className="relative">
<Popover>
<>
<Popover.Button className="grid place-items-center outline-none">
{value && value?.trim() !== "" && (
@ -110,8 +113,14 @@ export const LabelCreate: FC<ILabelCreate> = (props) => {
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<Popover.Panel className="absolute z-10 mt-1.5 max-w-xs px-2 sm:px-0">
<TwitterPicker color={value} onChange={(value) => onChange(value.hex)} />
<Popover.Panel
className={cn("absolute z-10 mt-1.5 max-w-xs px-2 sm:px-0", !peekIssue ? "right-0" : "")}
>
<TwitterPicker
triangle={!peekIssue ? "hide" : "top-left"}
color={value}
onChange={(value) => onChange(value.hex)}
/>
</Popover.Panel>
</Transition>
</>

View File

@ -56,7 +56,7 @@ export const IssueLabelSelect: React.FC<IIssueLabelSelect> = observer((props) =>
query === "" ? options : options?.filter((option) => option.query.toLowerCase().includes(query.toLowerCase()));
const { styles, attributes } = usePopper(referenceElement, popperElement, {
placement: "bottom-start",
placement: "bottom-end",
modifiers: [
{
name: "preventOverflow",

View File

@ -110,7 +110,7 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
const handleArchiveIssue = async () => {
if (!issueOperations.archive) return;
await issueOperations.archive(workspaceSlug, projectId, issueId);
router.push(`/${workspaceSlug}/projects/${projectId}/archived-issues/${issue.id}`);
router.push(`/${workspaceSlug}/projects/${projectId}/archives/issues/${issue.id}`);
};
// derived values
const projectDetails = getProjectById(issue.project_id);

View File

@ -73,7 +73,7 @@ const KanbanIssueDetailsBlock: React.FC<IssueDetailsBlockProps> = observer((prop
</Tooltip>
) : (
<ControlLink
href={`/${workspaceSlug}/projects/${issue.project_id}/${issue.archived_at ? "archived-issues" : "issues"}/${
href={`/${workspaceSlug}/projects/${issue.project_id}/${issue.archived_at ? "archives/" : ""}issues/${
issue.id
}`}
target="_blank"

View File

@ -71,9 +71,9 @@ export const IssueBlock: React.FC<IssueBlockProps> = observer((props: IssueBlock
</Tooltip>
) : (
<ControlLink
href={`/${workspaceSlug}/projects/${issue.project_id}/${
issue.archived_at ? "archived-issues" : "issues"
}/${issue.id}`}
href={`/${workspaceSlug}/projects/${issue.project_id}/${issue.archived_at ? "archives/" : ""}issues/${
issue.id
}`}
target="_blank"
onClick={() => handleIssuePeekOverview(issue)}
className="w-full line-clamp-1 cursor-pointer text-sm text-custom-text-100"

View File

@ -235,7 +235,7 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
const redirectToIssueDetail = () => {
router.push({
pathname: `/${workspaceSlug}/projects/${issue.project_id}/${issue.archived_at ? "archived-issues" : "issues"}/${
pathname: `/${workspaceSlug}/projects/${issue.project_id}/${issue.archived_at ? "archives/" : ""}issues/${
issue.id
}`,
hash: "sub-issues",

View File

@ -1,6 +1,6 @@
import { useState } from "react";
import { useRouter } from "next/router";
import { ExternalLink, Link, RotateCcw, Trash2 } from "lucide-react";
import { ArchiveRestoreIcon, ExternalLink, Link, Trash2 } from "lucide-react";
// hooks
import { CustomMenu, TOAST_TYPE, setToast } from "@plane/ui";
@ -35,7 +35,7 @@ export const ArchivedIssueQuickActions: React.FC<IQuickActionProps> = (props) =>
const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER && !readOnly;
const isRestoringAllowed = handleRestore && isEditingAllowed;
const issueLink = `${workspaceSlug}/projects/${issue.project_id}/archived-issues/${issue.id}`;
const issueLink = `${workspaceSlug}/projects/${issue.project_id}/archives/issues/${issue.id}`;
const handleOpenInNewTab = () => window.open(`/${issueLink}`, "_blank");
const handleCopyIssueLink = () =>
@ -67,7 +67,7 @@ export const ArchivedIssueQuickActions: React.FC<IQuickActionProps> = (props) =>
{isRestoringAllowed && (
<CustomMenu.MenuItem onClick={handleRestore}>
<div className="flex items-center gap-2">
<RotateCcw className="h-3 w-3" />
<ArchiveRestoreIcon className="h-3 w-3" />
Restore
</div>
</CustomMenu.MenuItem>

View File

@ -43,9 +43,8 @@ export const ArchivedIssueLayoutRoot: React.FC = observer(() => {
if (!workspaceSlug || !projectId) return <></>;
return (
<div className="relative flex h-full w-full flex-col overflow-hidden">
<>
<ArchivedIssueAppliedFiltersRoot />
{issues?.groupedIssueIds?.length === 0 ? (
<div className="relative h-full w-full overflow-y-auto">
<ProjectArchivedEmptyState />
@ -58,6 +57,6 @@ export const ArchivedIssueLayoutRoot: React.FC = observer(() => {
<IssuePeekOverview is_archived />
</Fragment>
)}
</div>
</>
);
});

View File

@ -23,7 +23,7 @@ export const SpreadsheetSubIssueColumn: React.FC<Props> = observer((props: Props
const redirectToIssueDetail = () => {
router.push({
pathname: `/${workspaceSlug}/projects/${issue.project_id}/${issue.archived_at ? "archived-issues" : "issues"}/${
pathname: `/${workspaceSlug}/projects/${issue.project_id}/${issue.archived_at ? "archives/" : ""}issues/${
issue.id
}`,
hash: "sub-issues",

View File

@ -16,6 +16,7 @@ import { IssueDraftService } from "@/services/issue";
export interface DraftIssueProps {
changesMade: Partial<TIssue> | null;
data?: Partial<TIssue>;
issueTitleRef: React.MutableRefObject<HTMLInputElement | null>;
isCreateMoreToggleEnabled: boolean;
onCreateMoreToggleChange: (value: boolean) => void;
onChange: (formData: Partial<TIssue> | null) => void;
@ -31,6 +32,7 @@ export const DraftIssueLayout: React.FC<DraftIssueProps> = observer((props) => {
const {
changesMade,
data,
issueTitleRef,
onChange,
onClose,
onSubmit,
@ -107,6 +109,7 @@ export const DraftIssueLayout: React.FC<DraftIssueProps> = observer((props) => {
isCreateMoreToggleEnabled={isCreateMoreToggleEnabled}
onCreateMoreToggleChange={onCreateMoreToggleChange}
data={data}
issueTitleRef={issueTitleRef}
onChange={onChange}
onClose={handleClose}
onSubmit={onSubmit}

View File

@ -52,6 +52,7 @@ const defaultValues: Partial<TIssue> = {
export interface IssueFormProps {
data?: Partial<TIssue>;
issueTitleRef: React.MutableRefObject<HTMLInputElement | null>;
isCreateMoreToggleEnabled: boolean;
onCreateMoreToggleChange: (value: boolean) => void;
onChange?: (formData: Partial<TIssue> | null) => void;
@ -93,6 +94,7 @@ const getTabIndex = (key: string) => TAB_INDICES.findIndex((tabIndex) => tabInde
export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
const {
data,
issueTitleRef,
onChange,
onClose,
onSubmit,
@ -366,11 +368,12 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
onChange(e.target.value);
handleFormChange();
}}
ref={ref}
ref={issueTitleRef || ref}
hasError={Boolean(errors.name)}
placeholder="Issue Title"
className="w-full resize-none text-xl"
tabIndex={getTabIndex("name")}
autoFocus
/>
)}
/>

View File

@ -1,4 +1,4 @@
import React, { useEffect, useState } from "react";
import React, { useEffect, useRef, useState } from "react";
import { observer } from "mobx-react-lite";
import { useRouter } from "next/router";
import { Dialog, Transition } from "@headlessui/react";
@ -46,6 +46,8 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = observer((prop
storeType = EIssuesStoreType.PROJECT,
isDraft = false,
} = props;
// ref
const issueTitleRef = useRef<HTMLInputElement>(null);
// states
const [changesMade, setChangesMade] = useState<Partial<TIssue> | null>(null);
const [createMore, setCreateMore] = useState(false);
@ -169,6 +171,7 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = observer((prop
path: router.asPath,
});
!createMore && handleClose();
if (createMore) issueTitleRef && issueTitleRef?.current?.focus();
return response;
} catch (error) {
setToast({
@ -268,6 +271,7 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = observer((prop
cycle_id: data?.cycle_id ? data?.cycle_id : cycleId ? cycleId : null,
module_ids: data?.module_ids ? data?.module_ids : moduleId ? [moduleId] : null,
}}
issueTitleRef={issueTitleRef}
onChange={handleFormChange}
onClose={handleClose}
onSubmit={handleFormSubmit}
@ -278,6 +282,7 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = observer((prop
/>
) : (
<IssueFormRoot
issueTitleRef={issueTitleRef}
data={{
...data,
description_html: description,

View File

@ -1,7 +1,7 @@
import { FC } from "react";
import { observer } from "mobx-react";
import Link from "next/link";
import { MoveRight, MoveDiagonal, Link2, Trash2, RotateCcw } from "lucide-react";
import { MoveRight, MoveDiagonal, Link2, Trash2, ArchiveRestoreIcon } from "lucide-react";
// ui
import {
ArchiveIcon,
@ -86,7 +86,7 @@ export const IssuePeekOverviewHeader: FC<PeekOverviewHeaderProps> = observer((pr
const stateDetails = issueDetails ? getStateById(issueDetails?.state_id) : undefined;
const currentMode = PEEK_OPTIONS.find((m) => m.key === peekMode);
const issueLink = `${workspaceSlug}/projects/${projectId}/${isArchived ? "archived-issues" : "issues"}/${issueId}`;
const issueLink = `${workspaceSlug}/projects/${projectId}/${isArchived ? "archives/" : ""}issues/${issueId}`;
const handleCopyText = (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
@ -182,7 +182,7 @@ export const IssuePeekOverviewHeader: FC<PeekOverviewHeaderProps> = observer((pr
{isRestoringAllowed && (
<Tooltip tooltipContent="Restore" isMobile={isMobile}>
<button type="button" onClick={handleRestoreIssue}>
<RotateCcw className="h-4 w-4 text-custom-text-300 hover:text-custom-text-200" />
<ArchiveRestoreIcon className="h-4 w-4 text-custom-text-300 hover:text-custom-text-200" />
</button>
</Tooltip>
)}

View File

@ -45,6 +45,8 @@ export const IssueListItem: React.FC<ISubIssues> = observer((props) => {
setPeekIssue,
issue: { getIssueById },
subIssues: { subIssueHelpersByIssueId, setSubIssueHelpers },
toggleCreateIssueModal,
toggleDeleteIssueModal,
} = useIssueDetail();
const project = useProject();
const { getProjectStates } = useProjectState();
@ -139,7 +141,12 @@ export const IssueListItem: React.FC<ISubIssues> = observer((props) => {
<div className="flex-shrink-0 text-sm">
<CustomMenu placement="bottom-end" ellipsis>
{disabled && (
<CustomMenu.MenuItem onClick={() => handleIssueCrudState("update", parentIssueId, { ...issue })}>
<CustomMenu.MenuItem
onClick={() => {
handleIssueCrudState("update", parentIssueId, { ...issue });
toggleCreateIssueModal(true);
}}
>
<div className="flex items-center gap-2">
<Pencil className="h-3.5 w-3.5" strokeWidth={2} />
<span>Edit issue</span>
@ -148,7 +155,12 @@ export const IssueListItem: React.FC<ISubIssues> = observer((props) => {
)}
{disabled && (
<CustomMenu.MenuItem onClick={() => handleIssueCrudState("delete", parentIssueId, issue)}>
<CustomMenu.MenuItem
onClick={() => {
handleIssueCrudState("delete", parentIssueId, issue);
toggleDeleteIssueModal(true);
}}
>
<div className="flex items-center gap-2">
<Trash className="h-3.5 w-3.5" strokeWidth={2} />
<span>Delete issue</span>

View File

@ -72,8 +72,8 @@ export const IssueProperty: React.FC<IIssueProperty> = (props) => {
}
disabled={!disabled}
multiple
buttonVariant={issue.assignee_ids.length > 0 ? "transparent-without-text" : "border-without-text"}
buttonClassName={issue.assignee_ids.length > 0 ? "hover:bg-transparent px-0" : ""}
buttonVariant={(issue?.assignee_ids || []).length > 0 ? "transparent-without-text" : "border-without-text"}
buttonClassName={(issue?.assignee_ids || []).length > 0 ? "hover:bg-transparent px-0" : ""}
/>
</div>
</div>

View File

@ -57,6 +57,7 @@ export const SubIssuesRoot: FC<ISubIssuesRoot> = observer((props) => {
toggleCreateIssueModal,
isSubIssuesModalOpen,
toggleSubIssuesModal,
toggleDeleteIssueModal,
} = useIssueDetail();
const { setTrackElement, captureIssueEvent } = useEventTracker();
// state
@ -496,6 +497,7 @@ export const SubIssuesRoot: FC<ISubIssuesRoot> = observer((props) => {
isOpen={issueCrudState?.update?.toggle}
onClose={() => {
handleIssueCrudState("update", null, null);
toggleCreateIssueModal(false);
}}
data={issueCrudState?.update?.issue ?? undefined}
onSubmit={async (_issue: TIssue) => {
@ -521,6 +523,7 @@ export const SubIssuesRoot: FC<ISubIssuesRoot> = observer((props) => {
isOpen={issueCrudState?.delete?.toggle}
handleClose={() => {
handleIssueCrudState("delete", null, null);
toggleDeleteIssueModal(false);
}}
data={issueCrudState?.delete?.issue as TIssue}
onSubmit={async () =>

View File

@ -96,7 +96,7 @@ export const ProjectSettingsLabelList: React.FC = observer(() => {
Add label
</Button>
</div>
<div className="h-full w-full py-8">
<div className="w-full py-8">
{showLabelForm && (
<div className="my-2 w-full rounded border border-custom-border-200 px-3.5 py-2">
<CreateUpdateLabelInline

View File

@ -0,0 +1,147 @@
import { FC, useCallback, useRef, useState } from "react";
import { observer } from "mobx-react-lite";
import { useRouter } from "next/router";
// icons
import { ListFilter, Search, X } from "lucide-react";
// types
import type { TModuleFilters } from "@plane/types";
// components
import { ArchiveTabsList } from "@/components/archives";
import { FiltersDropdown } from "@/components/issues";
import { ModuleFiltersSelection, ModuleOrderByDropdown } from "@/components/modules";
// helpers
import { cn } from "@/helpers/common.helper";
// hooks
import { useMember, useModuleFilter } from "@/hooks/store";
import useOutsideClickDetector from "@/hooks/use-outside-click-detector";
export const ArchivedModulesHeader: FC = observer(() => {
// router
const router = useRouter();
const { projectId } = router.query;
// refs
const inputRef = useRef<HTMLInputElement>(null);
// hooks
const {
currentProjectArchivedFilters,
currentProjectDisplayFilters,
archivedModulesSearchQuery,
updateFilters,
updateDisplayFilters,
updateArchivedModulesSearchQuery,
} = useModuleFilter();
const {
workspace: { workspaceMemberIds },
} = useMember();
// states
const [isSearchOpen, setIsSearchOpen] = useState(archivedModulesSearchQuery !== "" ? true : false);
// outside click detector hook
useOutsideClickDetector(inputRef, () => {
if (isSearchOpen && archivedModulesSearchQuery.trim() === "") setIsSearchOpen(false);
});
const handleFilters = useCallback(
(key: keyof TModuleFilters, value: string | string[]) => {
if (!projectId) return;
const newValues = currentProjectArchivedFilters?.[key] ?? [];
if (Array.isArray(value))
value.forEach((val) => {
if (!newValues.includes(val)) newValues.push(val);
});
else {
if (currentProjectArchivedFilters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1);
else newValues.push(value);
}
updateFilters(projectId.toString(), { [key]: newValues }, "archived");
},
[currentProjectArchivedFilters, projectId, updateFilters]
);
const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Escape") {
if (archivedModulesSearchQuery && archivedModulesSearchQuery.trim() !== "") updateArchivedModulesSearchQuery("");
else {
setIsSearchOpen(false);
inputRef.current?.blur();
}
}
};
return (
<div className="group relative flex border-b border-custom-border-200">
<div className="flex w-full items-center overflow-x-auto px-4 gap-2 horizontal-scrollbar scrollbar-sm">
<ArchiveTabsList />
</div>
{/* filter options */}
<div className="h-full flex items-center gap-3 self-end px-8">
{!isSearchOpen && (
<button
type="button"
className="-mr-5 p-2 hover:bg-custom-background-80 rounded text-custom-text-400 grid place-items-center"
onClick={() => {
setIsSearchOpen(true);
inputRef.current?.focus();
}}
>
<Search className="h-3.5 w-3.5" />
</button>
)}
<div
className={cn(
"ml-auto flex items-center justify-start gap-1 rounded-md border border-transparent bg-custom-background-100 text-custom-text-400 w-0 transition-[width] ease-linear overflow-hidden opacity-0",
{
"w-64 px-2.5 py-1.5 border-custom-border-200 opacity-100": isSearchOpen,
}
)}
>
<Search className="h-3.5 w-3.5" />
<input
ref={inputRef}
className="w-full max-w-[234px] border-none bg-transparent text-sm text-custom-text-100 placeholder:text-custom-text-400 focus:outline-none"
placeholder="Search"
value={archivedModulesSearchQuery}
onChange={(e) => updateArchivedModulesSearchQuery(e.target.value)}
onKeyDown={handleInputKeyDown}
/>
{isSearchOpen && (
<button
type="button"
className="grid place-items-center"
onClick={() => {
updateArchivedModulesSearchQuery("");
setIsSearchOpen(false);
}}
>
<X className="h-3 w-3" />
</button>
)}
</div>
<ModuleOrderByDropdown
value={currentProjectDisplayFilters?.order_by}
onChange={(val) => {
if (!projectId || val === currentProjectDisplayFilters?.order_by) return;
updateDisplayFilters(projectId.toString(), {
order_by: val,
});
}}
/>
<FiltersDropdown icon={<ListFilter className="h-3 w-3" />} title="Filters" placement="bottom-end">
<ModuleFiltersSelection
displayFilters={currentProjectDisplayFilters ?? {}}
filters={currentProjectArchivedFilters ?? {}}
handleDisplayFiltersUpdate={(val) => {
if (!projectId) return;
updateDisplayFilters(projectId.toString(), val);
}}
handleFiltersUpdate={handleFilters}
memberIds={workspaceMemberIds ?? undefined}
isArchived
/>
</FiltersDropdown>
</div>
</div>
);
});

View File

@ -0,0 +1,4 @@
export * from "./root";
export * from "./view";
export * from "./header";
export * from "./modal";

View File

@ -0,0 +1,104 @@
import { useState, Fragment } from "react";
import { useRouter } from "next/router";
import { Dialog, Transition } from "@headlessui/react";
// ui
import { Button, TOAST_TYPE, setToast } from "@plane/ui";
// hooks
import { useModule } from "@/hooks/store";
type Props = {
workspaceSlug: string;
projectId: string;
moduleId: string;
handleClose: () => void;
isOpen: boolean;
onSubmit?: () => Promise<void>;
};
export const ArchiveModuleModal: React.FC<Props> = (props) => {
const { workspaceSlug, projectId, moduleId, isOpen, handleClose } = props;
// router
const router = useRouter();
// states
const [isArchiving, setIsArchiving] = useState(false);
// store hooks
const { getModuleNameById, archiveModule } = useModule();
const moduleName = getModuleNameById(moduleId);
const onClose = () => {
setIsArchiving(false);
handleClose();
};
const handleArchiveIssue = async () => {
setIsArchiving(true);
await archiveModule(workspaceSlug, projectId, moduleId)
.then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Archive success",
message: "Your archives can be found in project archives.",
});
onClose();
router.push(`/${workspaceSlug}/projects/${projectId}/archives/modules?peekModule=${moduleId}`);
})
.catch(() =>
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Module could not be archived. Please try again.",
})
)
.finally(() => setIsArchiving(false));
};
return (
<Transition.Root show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-20" onClose={onClose}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-custom-backdrop transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-10 overflow-y-auto">
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-custom-background-100 text-left shadow-custom-shadow-md transition-all sm:my-8 sm:w-full sm:max-w-lg">
<div className="px-5 py-4">
<h3 className="text-xl font-medium 2xl:text-2xl">Archive module {moduleName}</h3>
<p className="mt-3 text-sm text-custom-text-200">
Are you sure you want to archive the module? All your archives can be restored later.
</p>
<div className="mt-3 flex justify-end gap-2">
<Button variant="neutral-primary" size="sm" onClick={onClose}>
Cancel
</Button>
<Button size="sm" tabIndex={1} onClick={handleArchiveIssue} loading={isArchiving}>
{isArchiving ? "Archiving" : "Archive"}
</Button>
</div>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
};

View File

@ -0,0 +1,81 @@
import React, { useCallback } from "react";
import { observer } from "mobx-react-lite";
import { useRouter } from "next/router";
import useSWR from "swr";
// types
import { TModuleFilters } from "@plane/types";
// components
import { EmptyState } from "@/components/empty-state";
import { ArchivedModulesView, ModuleAppliedFiltersList } from "@/components/modules";
import { CycleModuleListLayout } from "@/components/ui";
// constants
import { EmptyStateType } from "@/constants/empty-state";
// helpers
import { calculateTotalFilters } from "@/helpers/filter.helper";
// hooks
import { useModule, useModuleFilter } from "@/hooks/store";
export const ArchivedModuleLayoutRoot: React.FC = observer(() => {
// router
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
// hooks
const { fetchArchivedModules, projectArchivedModuleIds, loader } = useModule();
// module filters hook
const { clearAllFilters, currentProjectArchivedFilters, updateFilters } = useModuleFilter();
// derived values
const totalArchivedModules = projectArchivedModuleIds?.length ?? 0;
useSWR(
workspaceSlug && projectId ? `ARCHIVED_MODULES_${workspaceSlug.toString()}_${projectId.toString()}` : null,
async () => {
if (workspaceSlug && projectId) {
await fetchArchivedModules(workspaceSlug.toString(), projectId.toString());
}
},
{ revalidateIfStale: false, revalidateOnFocus: false }
);
const handleRemoveFilter = useCallback(
(key: keyof TModuleFilters, value: string | null) => {
if (!projectId) return;
let newValues = currentProjectArchivedFilters?.[key] ?? [];
if (!value) newValues = [];
else newValues = newValues.filter((val) => val !== value);
updateFilters(projectId.toString(), { [key]: newValues }, "archived");
},
[currentProjectArchivedFilters, projectId, updateFilters]
);
if (!workspaceSlug || !projectId) return <></>;
if (loader || !projectArchivedModuleIds) {
return <CycleModuleListLayout />;
}
return (
<>
{calculateTotalFilters(currentProjectArchivedFilters ?? {}) !== 0 && (
<div className="border-b border-custom-border-200 px-5 py-3">
<ModuleAppliedFiltersList
appliedFilters={currentProjectArchivedFilters ?? {}}
handleClearAllFilters={() => clearAllFilters(projectId.toString(), "archived")}
handleRemoveFilter={handleRemoveFilter}
alwaysAllowEditing
/>
</div>
)}
{totalArchivedModules === 0 ? (
<div className="h-full place-items-center">
<EmptyState type={EmptyStateType.PROJECT_ARCHIVED_NO_MODULES} />
</div>
) : (
<div className="relative h-full w-full overflow-auto">
<ArchivedModulesView workspaceSlug={workspaceSlug.toString()} projectId={projectId.toString()} />
</div>
)}
</>
);
});

View File

@ -0,0 +1,64 @@
import { FC } from "react";
import { observer } from "mobx-react-lite";
import Image from "next/image";
// components
import { ModuleListItem, ModulePeekOverview } from "@/components/modules";
// ui
import { CycleModuleListLayout } from "@/components/ui";
// hooks
import { useModule, useModuleFilter } from "@/hooks/store";
// assets
import AllFiltersImage from "@/public/empty-state/module/all-filters.svg";
import NameFilterImage from "@/public/empty-state/module/name-filter.svg";
export interface IArchivedModulesView {
workspaceSlug: string;
projectId: string;
}
export const ArchivedModulesView: FC<IArchivedModulesView> = observer((props) => {
const { workspaceSlug, projectId } = props;
// store hooks
const { getFilteredArchivedModuleIds, loader } = useModule();
const { archivedModulesSearchQuery } = useModuleFilter();
// derived values
const filteredArchivedModuleIds = getFilteredArchivedModuleIds(projectId);
if (loader || !filteredArchivedModuleIds) return <CycleModuleListLayout />;
if (filteredArchivedModuleIds.length === 0)
return (
<div className="h-full w-full grid place-items-center">
<div className="text-center">
<Image
src={archivedModulesSearchQuery.trim() === "" ? AllFiltersImage : NameFilterImage}
className="h-36 sm:h-48 w-36 sm:w-48 mx-auto"
alt="No matching modules"
/>
<h5 className="text-xl font-medium mt-7 mb-1">No matching modules</h5>
<p className="text-custom-text-400 text-base">
{archivedModulesSearchQuery.trim() === ""
? "Remove the filters to see all modules"
: "Remove the search criteria to see all modules"}
</p>
</div>
</div>
);
return (
<div className="h-full overflow-y-auto">
<div className="flex h-full w-full justify-between">
<div className="flex h-full w-full flex-col overflow-y-auto vertical-scrollbar scrollbar-lg">
{filteredArchivedModuleIds.map((moduleId) => (
<ModuleListItem key={moduleId} moduleId={moduleId} isArchived />
))}
</div>
<ModulePeekOverview
projectId={projectId?.toString() ?? ""}
workspaceSlug={workspaceSlug?.toString() ?? ""}
isArchived
/>
</div>
</div>
);
});

View File

@ -14,10 +14,18 @@ type Props = {
handleDisplayFiltersUpdate: (updatedDisplayProperties: Partial<TModuleDisplayFilters>) => void;
handleFiltersUpdate: (key: keyof TModuleFilters, value: string | string[]) => void;
memberIds?: string[] | undefined;
isArchived?: boolean;
};
export const ModuleFiltersSelection: React.FC<Props> = observer((props) => {
const { displayFilters, filters, handleDisplayFiltersUpdate, handleFiltersUpdate, memberIds } = props;
const {
displayFilters,
filters,
handleDisplayFiltersUpdate,
handleFiltersUpdate,
memberIds,
isArchived = false,
} = props;
// states
const [filtersSearchQuery, setFiltersSearchQuery] = useState("");
@ -42,6 +50,7 @@ export const ModuleFiltersSelection: React.FC<Props> = observer((props) => {
</div>
</div>
<div className="h-full w-full divide-y divide-custom-border-200 overflow-y-auto px-2.5 vertical-scrollbar scrollbar-sm">
{!isArchived && (
<div className="py-2">
<FilterOption
isChecked={!!displayFilters.favorites}
@ -53,8 +62,10 @@ export const ModuleFiltersSelection: React.FC<Props> = observer((props) => {
title="Favorites"
/>
</div>
)}
{/* status */}
{!isArchived && (
<div className="py-2">
<FilterStatus
appliedFilters={(filters.status as TModuleStatus[]) ?? null}
@ -62,6 +73,7 @@ export const ModuleFiltersSelection: React.FC<Props> = observer((props) => {
searchQuery={filtersSearchQuery}
/>
</div>
)}
{/* lead */}
<div className="py-2">

View File

@ -128,7 +128,7 @@ export const ModuleForm: React.FC<Props> = (props) => {
value={value}
onChange={onChange}
placeholder="Description..."
className="h-24 w-full resize-none text-sm"
className="w-full text-sm resize-none min-h-24"
hasError={Boolean(errors?.description)}
tabIndex={2}
/>

View File

@ -11,3 +11,7 @@ export * from "./sidebar";
export * from "./module-card-item";
export * from "./module-list-item";
export * from "./module-peek-overview";
export * from "./quick-actions";
// archived modules
export * from "./archived-modules";

View File

@ -1,19 +1,19 @@
import React, { useState } from "react";
import React from "react";
import { observer } from "mobx-react-lite";
import Link from "next/link";
import { useRouter } from "next/router";
import { Info, LinkIcon, Pencil, Star, Trash2 } from "lucide-react";
// icons
import { Info, Star } from "lucide-react";
// ui
import { Avatar, AvatarGroup, CustomMenu, LayersIcon, Tooltip, TOAST_TYPE, setToast, setPromiseToast } from "@plane/ui";
import { Avatar, AvatarGroup, LayersIcon, Tooltip, setPromiseToast } from "@plane/ui";
// components
import { CreateUpdateModuleModal, DeleteModuleModal } from "@/components/modules";
import { ModuleQuickActions } from "@/components/modules";
// constants
import { MODULE_FAVORITED, MODULE_UNFAVORITED } from "@/constants/event-tracker";
import { MODULE_STATUS } from "@/constants/module";
import { EUserProjectRoles } from "@/constants/project";
// helpers
import { getDate, renderFormattedDate } from "@/helpers/date-time.helper";
import { copyUrlToClipboard } from "@/helpers/string.helper";
// hooks
import { useEventTracker, useMember, useModule, useUser } from "@/hooks/store";
import { usePlatformOS } from "@/hooks/use-platform-os";
@ -24,9 +24,6 @@ type Props = {
export const ModuleCardItem: React.FC<Props> = observer((props) => {
const { moduleId } = props;
// states
const [editModal, setEditModal] = useState(false);
const [deleteModal, setDeleteModal] = useState(false);
// router
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
@ -36,7 +33,7 @@ export const ModuleCardItem: React.FC<Props> = observer((props) => {
} = useUser();
const { getModuleById, addModuleToFavorites, removeModuleFromFavorites } = useModule();
const { getUserDetails } = useMember();
const { setTrackElement, captureEvent } = useEventTracker();
const { captureEvent } = useEventTracker();
// derived values
const moduleDetails = getModuleById(moduleId);
const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER;
@ -99,32 +96,6 @@ export const ModuleCardItem: React.FC<Props> = observer((props) => {
});
};
const handleCopyText = (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
e.preventDefault();
copyUrlToClipboard(`${workspaceSlug}/projects/${projectId}/modules/${moduleId}`).then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Link Copied!",
message: "Module link copied to clipboard.",
});
});
};
const handleEditModule = (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
setTrackElement("Modules page grid layout");
setEditModal(true);
};
const handleDeleteModule = (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
setTrackElement("Modules page grid layout");
setDeleteModal(true);
};
const openModuleOverview = (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
e.preventDefault();
@ -165,17 +136,6 @@ export const ModuleCardItem: React.FC<Props> = observer((props) => {
: "0 Issue";
return (
<>
{workspaceSlug && projectId && (
<CreateUpdateModuleModal
isOpen={editModal}
onClose={() => setEditModal(false)}
data={moduleDetails}
projectId={projectId.toString()}
workspaceSlug={workspaceSlug.toString()}
/>
)}
<DeleteModuleModal data={moduleDetails} isOpen={deleteModal} onClose={() => setDeleteModal(false)} />
<Link href={`/${workspaceSlug}/projects/${moduleDetails.project_id}/modules/${moduleDetails.id}`}>
<div className="flex h-44 w-full flex-col justify-between rounded border border-custom-border-100 bg-custom-background-100 p-4 text-sm hover:shadow-md">
<div>
@ -266,36 +226,17 @@ export const ModuleCardItem: React.FC<Props> = observer((props) => {
<Star className="h-3.5 w-3.5 text-custom-text-200" />
</button>
))}
<CustomMenu ellipsis className="z-10" placement="left-start">
{isEditingAllowed && (
<>
<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={handleDeleteModule}>
<span className="flex items-center justify-start gap-2">
<Trash2 className="h-3 w-3" />
<span>Delete module</span>
</span>
</CustomMenu.MenuItem>
</>
{workspaceSlug && projectId && (
<ModuleQuickActions
moduleId={moduleId}
projectId={projectId.toString()}
workspaceSlug={workspaceSlug.toString()}
/>
)}
<CustomMenu.MenuItem onClick={handleCopyText}>
<span className="flex items-center justify-start gap-2">
<LinkIcon className="h-3 w-3" />
<span>Copy module link</span>
</span>
</CustomMenu.MenuItem>
</CustomMenu>
</div>
</div>
</div>
</div>
</Link>
</>
);
});

View File

@ -1,44 +1,30 @@
import React, { useState } from "react";
import React from "react";
import { observer } from "mobx-react-lite";
import Link from "next/link";
import { useRouter } from "next/router";
import { Check, Info, LinkIcon, Pencil, Star, Trash2, User2 } from "lucide-react";
// icons
import { Check, Info, Star, User2 } from "lucide-react";
// ui
import {
Avatar,
AvatarGroup,
CircularProgressIndicator,
CustomMenu,
Tooltip,
TOAST_TYPE,
setToast,
setPromiseToast,
} from "@plane/ui";
import { CreateUpdateModuleModal, DeleteModuleModal } from "@/components/modules";
import { MODULE_FAVORITED, MODULE_UNFAVORITED } from "@/constants/event-tracker";
// helpers
import { Avatar, AvatarGroup, CircularProgressIndicator, Tooltip, setPromiseToast } from "@plane/ui";
// components
import { ModuleQuickActions } from "@/components/modules";
// constants
import { MODULE_FAVORITED, MODULE_UNFAVORITED } from "@/constants/event-tracker";
import { MODULE_STATUS } from "@/constants/module";
import { EUserProjectRoles } from "@/constants/project";
// helpers
import { getDate, renderFormattedDate } from "@/helpers/date-time.helper";
import { copyUrlToClipboard } from "@/helpers/string.helper";
// hooks
import { useModule, useUser, useEventTracker, useMember } from "@/hooks/store";
import { usePlatformOS } from "@/hooks/use-platform-os";
// components
// ui
// helpers
// constants
type Props = {
moduleId: string;
isArchived?: boolean;
};
export const ModuleListItem: React.FC<Props> = observer((props) => {
const { moduleId } = props;
// states
const [editModal, setEditModal] = useState(false);
const [deleteModal, setDeleteModal] = useState(false);
const { moduleId, isArchived = false } = props;
// router
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
@ -48,7 +34,7 @@ export const ModuleListItem: React.FC<Props> = observer((props) => {
} = useUser();
const { getModuleById, addModuleToFavorites, removeModuleFromFavorites } = useModule();
const { getUserDetails } = useMember();
const { setTrackElement, captureEvent } = useEventTracker();
const { captureEvent } = useEventTracker();
// derived values
const moduleDetails = getModuleById(moduleId);
const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER;
@ -111,33 +97,7 @@ export const ModuleListItem: React.FC<Props> = observer((props) => {
});
};
const handleCopyText = (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
e.preventDefault();
copyUrlToClipboard(`${workspaceSlug}/projects/${projectId}/modules/${moduleId}`).then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Link Copied!",
message: "Module link copied to clipboard.",
});
});
};
const handleEditModule = (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
setTrackElement("Modules page list layout");
setEditModal(true);
};
const handleDeleteModule = (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
setTrackElement("Modules page list layout");
setDeleteModal(true);
};
const openModuleOverview = (e: React.MouseEvent<HTMLButtonElement>) => {
const openModuleOverview = (e: React.MouseEvent<HTMLButtonElement | HTMLAnchorElement>) => {
e.stopPropagation();
e.preventDefault();
const { query } = router;
@ -167,18 +127,14 @@ export const ModuleListItem: React.FC<Props> = observer((props) => {
const completedModuleCheck = moduleDetails.status === "completed";
return (
<>
{workspaceSlug && projectId && (
<CreateUpdateModuleModal
isOpen={editModal}
onClose={() => setEditModal(false)}
data={moduleDetails}
projectId={projectId.toString()}
workspaceSlug={workspaceSlug.toString()}
/>
)}
<DeleteModuleModal data={moduleDetails} isOpen={deleteModal} onClose={() => setDeleteModal(false)} />
<Link href={`/${workspaceSlug}/projects/${moduleDetails.project_id}/modules/${moduleDetails.id}`}>
<Link
href={`/${workspaceSlug}/projects/${moduleDetails.project_id}/modules/${moduleDetails.id}`}
onClick={(e) => {
if (isArchived) {
openModuleOverview(e);
}
}}
>
<div className="group flex w-full flex-col items-center justify-between gap-5 border-b border-custom-border-100 bg-custom-background-100 px-5 py-6 text-sm hover:bg-custom-background-90 sm:flex-row">
<div className="relative flex w-full items-center justify-between gap-3 overflow-hidden">
<div className="relative flex w-full items-center gap-3 overflow-hidden">
@ -249,6 +205,7 @@ export const ModuleListItem: React.FC<Props> = observer((props) => {
</Tooltip>
{isEditingAllowed &&
!isArchived &&
(moduleDetails.is_favorite ? (
<button type="button" onClick={handleRemoveFromFavorites} className="z-[1]">
<Star className="h-3.5 w-3.5 fill-current text-amber-500" />
@ -258,35 +215,17 @@ export const ModuleListItem: React.FC<Props> = observer((props) => {
<Star className="h-3.5 w-3.5 text-custom-text-300" />
</button>
))}
<CustomMenu verticalEllipsis buttonClassName="z-[1]" placement="left-start">
{isEditingAllowed && (
<>
<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={handleDeleteModule}>
<span className="flex items-center justify-start gap-2">
<Trash2 className="h-3 w-3" />
<span>Delete module</span>
</span>
</CustomMenu.MenuItem>
</>
{workspaceSlug && projectId && (
<ModuleQuickActions
moduleId={moduleId}
projectId={projectId.toString()}
workspaceSlug={workspaceSlug.toString()}
isArchived={isArchived}
/>
)}
<CustomMenu.MenuItem onClick={handleCopyText}>
<span className="flex items-center justify-start gap-2">
<LinkIcon className="h-3 w-3" />
<span>Copy module link</span>
</span>
</CustomMenu.MenuItem>
</CustomMenu>
</div>
</div>
</div>
</Link>
</>
);
});

View File

@ -9,9 +9,10 @@ import { ModuleDetailsSidebar } from "./sidebar";
type Props = {
projectId: string;
workspaceSlug: string;
isArchived?: boolean;
};
export const ModulePeekOverview: React.FC<Props> = observer(({ projectId, workspaceSlug }) => {
export const ModulePeekOverview: React.FC<Props> = observer(({ projectId, workspaceSlug, isArchived = false }) => {
// router
const router = useRouter();
const { peekModule } = router.query;
@ -29,10 +30,10 @@ export const ModulePeekOverview: React.FC<Props> = observer(({ projectId, worksp
};
useEffect(() => {
if (!peekModule) return;
if (!peekModule || isArchived) return;
fetchModuleDetails(workspaceSlug, projectId, peekModule.toString());
}, [fetchModuleDetails, peekModule, projectId, workspaceSlug]);
}, [fetchModuleDetails, isArchived, peekModule, projectId, workspaceSlug]);
return (
<>
@ -45,7 +46,11 @@ export const ModulePeekOverview: React.FC<Props> = observer(({ projectId, worksp
"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)",
}}
>
<ModuleDetailsSidebar moduleId={peekModule?.toString() ?? ""} handleClose={handleClose} />
<ModuleDetailsSidebar
moduleId={peekModule?.toString() ?? ""}
handleClose={handleClose}
isArchived={isArchived}
/>
</div>
)}
</>

View File

@ -10,6 +10,7 @@ import { CycleModuleBoardLayout, CycleModuleListLayout, GanttLayoutLoader } from
// assets
// constants
import { EmptyStateType } from "@/constants/empty-state";
import { calculateTotalFilters } from "@/helpers/filter.helper";
import { useApplication, useEventTracker, useModule, useModuleFilter } from "@/hooks/store";
import AllFiltersImage from "public/empty-state/module/all-filters.svg";
import NameFilterImage from "public/empty-state/module/name-filter.svg";
@ -22,10 +23,12 @@ export const ModulesListView: React.FC = observer(() => {
const { commandPalette: commandPaletteStore } = useApplication();
const { setTrackElement } = useEventTracker();
const { getFilteredModuleIds, loader } = useModule();
const { currentProjectDisplayFilters: displayFilters, searchQuery } = useModuleFilter();
const { currentProjectDisplayFilters: displayFilters, searchQuery, currentProjectFilters } = useModuleFilter();
// derived values
const filteredModuleIds = projectId ? getFilteredModuleIds(projectId.toString()) : undefined;
const totalFilters = calculateTotalFilters(currentProjectFilters ?? {});
if (loader || !filteredModuleIds)
return (
<>
@ -35,7 +38,7 @@ export const ModulesListView: React.FC = observer(() => {
</>
);
if (filteredModuleIds.length === 0)
if (totalFilters > 0 || searchQuery.trim() !== "")
return (
<div className="h-full w-full grid place-items-center">
<div className="text-center">

View File

@ -0,0 +1,179 @@
import { useState } from "react";
import { observer } from "mobx-react";
import { useRouter } from "next/router";
// icons
import { ArchiveRestoreIcon, LinkIcon, Pencil, Trash2 } from "lucide-react";
// ui
import { ArchiveIcon, CustomMenu, TOAST_TYPE, setToast } from "@plane/ui";
// components
import { ArchiveModuleModal, CreateUpdateModuleModal, DeleteModuleModal } from "@/components/modules";
// constants
import { EUserProjectRoles } from "@/constants/project";
// helpers
import { copyUrlToClipboard } from "@/helpers/string.helper";
// hooks
import { useModule, useEventTracker, useUser } from "@/hooks/store";
type Props = {
moduleId: string;
projectId: string;
workspaceSlug: string;
isArchived?: boolean;
};
export const ModuleQuickActions: React.FC<Props> = observer((props) => {
const { moduleId, projectId, workspaceSlug, isArchived } = props;
// router
const router = useRouter();
// states
const [editModal, setEditModal] = useState(false);
const [archiveModuleModal, setArchiveModuleModal] = useState(false);
const [deleteModal, setDeleteModal] = useState(false);
// store hooks
const { setTrackElement } = useEventTracker();
const {
membership: { currentWorkspaceAllProjectsRole },
} = useUser();
const { getModuleById, restoreModule } = useModule();
// derived values
const moduleDetails = getModuleById(moduleId);
// auth
const isEditingAllowed =
!!currentWorkspaceAllProjectsRole && currentWorkspaceAllProjectsRole[projectId] >= EUserProjectRoles.MEMBER;
const moduleState = moduleDetails?.status.toLocaleLowerCase();
const isInArchivableGroup = !!moduleState && ["completed", "cancelled"].includes(moduleState);
const handleCopyText = (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
e.preventDefault();
copyUrlToClipboard(`${workspaceSlug}/projects/${projectId}/modules/${moduleId}`).then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Link Copied!",
message: "Module link copied to clipboard.",
});
});
};
const handleEditModule = (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
setTrackElement("Modules page list layout");
setEditModal(true);
};
const handleArchiveModule = (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
setArchiveModuleModal(true);
};
const handleRestoreModule = async (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
await restoreModule(workspaceSlug, projectId, moduleId)
.then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Restore success",
message: "Your module can be found in project modules.",
});
router.push(`/${workspaceSlug}/projects/${projectId}/modules/${moduleId}`);
})
.catch(() =>
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Module could not be restored. Please try again.",
})
);
};
const handleDeleteModule = (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
setTrackElement("Modules page list layout");
setDeleteModal(true);
};
return (
<>
{moduleDetails && (
<div className="fixed">
<CreateUpdateModuleModal
isOpen={editModal}
onClose={() => setEditModal(false)}
data={moduleDetails}
projectId={projectId}
workspaceSlug={workspaceSlug}
/>
<ArchiveModuleModal
workspaceSlug={workspaceSlug}
projectId={projectId}
moduleId={moduleId}
isOpen={archiveModuleModal}
handleClose={() => setArchiveModuleModal(false)}
/>
<DeleteModuleModal data={moduleDetails} isOpen={deleteModal} onClose={() => setDeleteModal(false)} />
</div>
)}
<CustomMenu ellipsis placement="left-start">
{isEditingAllowed && !isArchived && (
<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>
)}
{isEditingAllowed && !isArchived && (
<CustomMenu.MenuItem onClick={handleArchiveModule} disabled={!isInArchivableGroup}>
{isInArchivableGroup ? (
<div className="flex items-center gap-2">
<ArchiveIcon className="h-3 w-3" />
Archive module
</div>
) : (
<div className="flex items-start gap-2">
<ArchiveIcon className="h-3 w-3" />
<div className="-mt-1">
<p>Archive module</p>
<p className="text-xs text-custom-text-400">
Only completed or cancelled <br /> module can be archived.
</p>
</div>
</div>
)}
</CustomMenu.MenuItem>
)}
{isEditingAllowed && isArchived && (
<CustomMenu.MenuItem onClick={handleRestoreModule}>
<span className="flex items-center justify-start gap-2">
<ArchiveRestoreIcon className="h-3 w-3" />
<span>Restore module</span>
</span>
</CustomMenu.MenuItem>
)}
{!isArchived && (
<CustomMenu.MenuItem onClick={handleCopyText}>
<span className="flex items-center justify-start gap-2">
<LinkIcon className="h-3 w-3" />
<span>Copy module link</span>
</span>
</CustomMenu.MenuItem>
)}
{isEditingAllowed && (
<div className="border-t pt-1 mt-1">
<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>
</div>
)}
</CustomMenu>
</>
);
});

View File

@ -4,6 +4,7 @@ import { useRouter } from "next/router";
import { Controller, useForm } from "react-hook-form";
import {
AlertCircle,
ArchiveRestoreIcon,
CalendarClock,
ChevronDown,
ChevronRight,
@ -25,12 +26,14 @@ import {
UserGroupIcon,
TOAST_TYPE,
setToast,
ArchiveIcon,
TextArea,
} from "@plane/ui";
// components
import { LinkModal, LinksList, SidebarProgressStats } from "@/components/core";
import ProgressChart from "@/components/core/sidebar/progress-chart";
import { DateRangeDropdown, MemberDropdown } from "@/components/dropdowns";
import { DeleteModuleModal } from "@/components/modules";
import { ArchiveModuleModal, DeleteModuleModal } from "@/components/modules";
// constant
import {
MODULE_LINK_CREATED,
@ -58,13 +61,15 @@ const defaultValues: Partial<IModule> = {
type Props = {
moduleId: string;
handleClose: () => void;
isArchived?: boolean;
};
// TODO: refactor this component
export const ModuleDetailsSidebar: React.FC<Props> = observer((props) => {
const { moduleId, handleClose } = props;
const { moduleId, handleClose, isArchived } = props;
// states
const [moduleDeleteModal, setModuleDeleteModal] = useState(false);
const [archiveModuleModal, setArchiveModuleModal] = useState(false);
const [moduleLinkModal, setModuleLinkModal] = useState(false);
const [selectedLinkToUpdate, setSelectedLinkToUpdate] = useState<ILinkDetails | null>(null);
// router
@ -74,10 +79,14 @@ export const ModuleDetailsSidebar: React.FC<Props> = observer((props) => {
const {
membership: { currentProjectRole },
} = useUser();
const { getModuleById, updateModuleDetails, createModuleLink, updateModuleLink, deleteModuleLink } = useModule();
const { getModuleById, updateModuleDetails, createModuleLink, updateModuleLink, deleteModuleLink, restoreModule } =
useModule();
const { setTrackElement, captureModuleEvent, captureEvent } = useEventTracker();
const moduleDetails = getModuleById(moduleId);
const moduleState = moduleDetails?.status.toLocaleLowerCase();
const isInArchivableGroup = !!moduleState && ["completed", "cancelled"].includes(moduleState);
const { reset, control } = useForm({
defaultValues,
});
@ -205,6 +214,30 @@ export const ModuleDetailsSidebar: React.FC<Props> = observer((props) => {
});
};
const handleRestoreModule = async (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
if (!workspaceSlug || !projectId || !moduleId) return;
await restoreModule(workspaceSlug.toString(), projectId.toString(), moduleId)
.then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Restore success",
message: "Your module can be found in project modules.",
});
router.push(`/${workspaceSlug}/projects/${projectId}/modules/${moduleId}`);
})
.catch(() =>
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Module could not be restored. Please try again.",
})
);
};
useEffect(() => {
if (moduleDetails)
reset({
@ -261,8 +294,16 @@ export const ModuleDetailsSidebar: React.FC<Props> = observer((props) => {
createIssueLink={handleCreateLink}
updateIssueLink={handleUpdateLink}
/>
{workspaceSlug && projectId && (
<ArchiveModuleModal
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
moduleId={moduleId}
isOpen={archiveModuleModal}
handleClose={() => setArchiveModuleModal(false)}
/>
)}
<DeleteModuleModal isOpen={moduleDeleteModal} onClose={() => setModuleDeleteModal(false)} data={moduleDetails} />
<>
<div className="sticky z-10 top-0 flex items-center justify-between bg-custom-sidebar-background-100 py-5">
<div>
@ -274,11 +315,41 @@ export const ModuleDetailsSidebar: React.FC<Props> = observer((props) => {
</button>
</div>
<div className="flex items-center gap-3.5">
{!isArchived && (
<button onClick={handleCopyText}>
<LinkIcon className="h-3 w-3 text-custom-text-300" />
</button>
)}
{isEditingAllowed && (
<CustomMenu placement="bottom-end" ellipsis>
{!isArchived && (
<CustomMenu.MenuItem onClick={() => setArchiveModuleModal(true)} disabled={!isInArchivableGroup}>
{isInArchivableGroup ? (
<div className="flex items-center gap-2">
<ArchiveIcon className="h-3 w-3" />
Archive module
</div>
) : (
<div className="flex items-start gap-2">
<ArchiveIcon className="h-3 w-3" />
<div className="-mt-1">
<p>Archive module</p>
<p className="text-xs text-custom-text-400">
Only completed or cancelled <br /> module can be archived.
</p>
</div>
</div>
)}
</CustomMenu.MenuItem>
)}
{isArchived && (
<CustomMenu.MenuItem onClick={handleRestoreModule}>
<span className="flex items-center justify-start gap-2">
<ArchiveRestoreIcon className="h-3 w-3" />
<span>Restore module</span>
</span>
</CustomMenu.MenuItem>
)}
<CustomMenu.MenuItem
onClick={() => {
setTrackElement("Module peek-overview");
@ -305,7 +376,7 @@ export const ModuleDetailsSidebar: React.FC<Props> = observer((props) => {
customButton={
<span
className={`flex h-6 w-20 items-center justify-center rounded-sm text-center text-xs ${
isEditingAllowed ? "cursor-pointer" : "cursor-not-allowed"
isEditingAllowed && !isArchived ? "cursor-pointer" : "cursor-not-allowed"
}`}
style={{
color: moduleStatus ? moduleStatus.color : "#a3a3a2",
@ -319,7 +390,7 @@ export const ModuleDetailsSidebar: React.FC<Props> = observer((props) => {
onChange={(value: any) => {
submitChanges({ status: value });
}}
disabled={!isEditingAllowed}
disabled={!isEditingAllowed || isArchived}
>
{MODULE_STATUS.map((status) => (
<CustomSelect.Option key={status.value} value={status.value}>
@ -337,9 +408,11 @@ export const ModuleDetailsSidebar: React.FC<Props> = observer((props) => {
</div>
{moduleDetails.description && (
<span className="w-full whitespace-normal break-words py-2.5 text-sm leading-5 text-custom-text-200">
{moduleDetails.description}
</span>
<TextArea
className="outline-none ring-none w-full max-h-max bg-transparent !p-0 !m-0 !border-0 resize-none text-sm leading-5 text-custom-text-200"
value={moduleDetails.description}
disabled
/>
)}
<div className="flex items-center justify-start gap-1">
@ -376,6 +449,7 @@ export const ModuleDetailsSidebar: React.FC<Props> = observer((props) => {
from: "Start date",
to: "Target date",
}}
disabled={isArchived}
/>
);
}}
@ -405,6 +479,7 @@ export const ModuleDetailsSidebar: React.FC<Props> = observer((props) => {
multiple={false}
buttonVariant="background-with-text"
placeholder="Lead"
disabled={isArchived}
/>
</div>
)}
@ -429,7 +504,7 @@ export const ModuleDetailsSidebar: React.FC<Props> = observer((props) => {
projectId={projectId?.toString() ?? ""}
buttonVariant={value && value?.length > 0 ? "transparent-without-text" : "background-with-text"}
buttonClassName={value && value.length > 0 ? "hover:bg-transparent px-0" : ""}
disabled={!isEditingAllowed}
disabled={!isEditingAllowed || isArchived}
/>
</div>
)}
@ -553,7 +628,7 @@ export const ModuleDetailsSidebar: React.FC<Props> = observer((props) => {
<div className="mt-2 flex h-72 w-full flex-col space-y-3 overflow-y-auto">
{currentProjectRole && moduleDetails.link_module && moduleDetails.link_module.length > 0 ? (
<>
{isEditingAllowed && (
{isEditingAllowed && !isArchived && (
<div className="flex w-full items-center justify-end">
<button
className="flex items-center gap-1.5 text-sm font-medium text-custom-primary-100"
@ -575,6 +650,7 @@ export const ModuleDetailsSidebar: React.FC<Props> = observer((props) => {
isMember: currentProjectRole === EUserProjectRoles.MEMBER,
isOwner: currentProjectRole === EUserProjectRoles.ADMIN,
}}
disabled={isArchived}
/>
</>
) : (
@ -583,6 +659,7 @@ export const ModuleDetailsSidebar: React.FC<Props> = observer((props) => {
<Info className="h-3.5 w-3.5 stroke-[1.5] text-custom-text-300" />
<span className="p-0.5 text-xs text-custom-text-300">No links added yet</span>
</div>
{isEditingAllowed && !isArchived && (
<button
className="flex items-center gap-1.5 text-sm font-medium text-custom-primary-100"
onClick={() => setModuleLinkModal(true)}
@ -590,6 +667,7 @@ export const ModuleDetailsSidebar: React.FC<Props> = observer((props) => {
<Plus className="h-3 w-3" />
Add link
</button>
)}
</div>
)}
</div>

View File

@ -16,12 +16,12 @@ import {
NOTIFICATION_SNOOZED,
} from "@/constants/event-tracker";
import { snoozeOptions } from "@/constants/notification";
// helper
import { calculateTimeAgo, renderFormattedTime, renderFormattedDate, getDate } from "@/helpers/date-time.helper";
import { replaceUnderscoreIfSnakeCase, truncateText, stripAndTruncateHTML } from "@/helpers/string.helper";
// hooks
import { useEventTracker } from "@/hooks/store";
import { usePlatformOS } from "@/hooks/use-platform-os";
// helper
import { calculateTimeAgo, renderFormattedTime, renderFormattedDate, getDate } from "helpers/date-time.helper";
import { replaceUnderscoreIfSnakeCase, truncateText, stripAndTruncateHTML } from "helpers/string.helper";
type NotificationCardProps = {
@ -137,8 +137,8 @@ export const NotificationCard: React.FC<NotificationCardProps> = (props) => {
closePopover();
}}
href={`/${workspaceSlug}/projects/${notification.project}/${
notificationField === "archived_at" ? "archived-issues" : "issues"
}/${notification.data.issue.id}`}
notificationField === "archived_at" ? "archives/" : ""
}issues/${notification.data.issue.id}`}
className={`group relative flex w-full cursor-pointer items-center gap-4 p-3 pl-6 ${
notification.read_at === null ? "bg-custom-primary-70/5" : "hover:bg-custom-background-200"
}`}

View File

@ -19,8 +19,6 @@ export const ProjectCardList = observer(() => {
const { workspaceProjectIds, filteredProjectIds, getProjectById } = useProject();
const { searchQuery } = useProjectFilter();
if (!filteredProjectIds) return <ProjectsLoader />;
if (workspaceProjectIds?.length === 0)
return (
<EmptyState
@ -31,6 +29,9 @@ export const ProjectCardList = observer(() => {
}}
/>
);
if (!filteredProjectIds) return <ProjectsLoader />;
if (filteredProjectIds.length === 0)
return (
<div className="h-full w-full grid place-items-center">

View File

@ -282,13 +282,6 @@ export const ProjectSidebarListItem: React.FC<Props> = observer((props) => {
</span>
</CustomMenu.MenuItem>
)}
<CustomMenu.MenuItem onClick={handleCopyText}>
<span className="flex items-center justify-start gap-2">
<LinkIcon className="h-3.5 w-3.5 stroke-[1.5]" />
<span>Copy project link</span>
</span>
</CustomMenu.MenuItem>
{/* publish project settings */}
{isAdmin && (
<CustomMenu.MenuItem onClick={() => setPublishModal(true)}>
@ -300,16 +293,6 @@ export const ProjectSidebarListItem: React.FC<Props> = observer((props) => {
</div>
</CustomMenu.MenuItem>
)}
{!isViewerOrGuest && (
<CustomMenu.MenuItem>
<Link href={`/${workspaceSlug}/projects/${project?.id}/archived-issues/`}>
<div className="flex items-center justify-start gap-2">
<ArchiveIcon className="h-3.5 w-3.5 stroke-[1.5]" />
<span>Archived issues</span>
</div>
</Link>
</CustomMenu.MenuItem>
)}
<CustomMenu.MenuItem>
<Link href={`/${workspaceSlug}/projects/${project?.id}/draft-issues/`}>
<div className="flex items-center justify-start gap-2">
@ -318,6 +301,23 @@ export const ProjectSidebarListItem: React.FC<Props> = observer((props) => {
</div>
</Link>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={handleCopyText}>
<span className="flex items-center justify-start gap-2">
<LinkIcon className="h-3.5 w-3.5 stroke-[1.5]" />
<span>Copy link</span>
</span>
</CustomMenu.MenuItem>
{!isViewerOrGuest && (
<CustomMenu.MenuItem>
<Link href={`/${workspaceSlug}/projects/${project?.id}/archives/issues`}>
<div className="flex items-center justify-start gap-2">
<ArchiveIcon className="h-3.5 w-3.5 stroke-[1.5]" />
<span>Archives</span>
</div>
</Link>
</CustomMenu.MenuItem>
)}
<CustomMenu.MenuItem>
<Link href={`/${workspaceSlug}/projects/${project?.id}/settings`}>
<div className="flex items-center justify-start gap-2">

View File

@ -121,7 +121,10 @@ export const CreateWorkspaceForm: FC<Props> = observer((props) => {
<form className="space-y-6 sm:space-y-9" onSubmit={handleSubmit(handleCreateWorkspace)}>
<div className="space-y-6 sm:space-y-7">
<div className="space-y-1 text-sm">
<label htmlFor="workspaceName">Workspace Name</label>
<label htmlFor="workspaceName">
Workspace Name
<span className="ml-0.5 text-red-500">*</span>
</label>
<Controller
control={control}
name="name"
@ -153,7 +156,10 @@ export const CreateWorkspaceForm: FC<Props> = observer((props) => {
/>
</div>
<div className="space-y-1 text-sm">
<label htmlFor="workspaceUrl">Workspace URL</label>
<label htmlFor="workspaceUrl">
Workspace URL
<span className="ml-0.5 text-red-500">*</span>
</label>
<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
@ -185,7 +191,9 @@ export const CreateWorkspaceForm: FC<Props> = observer((props) => {
)}
</div>
<div className="space-y-1 text-sm">
<span>What size is your organization?</span>
<span>
What size is your organization?<span className="ml-0.5 text-red-500">*</span>
</span>
<div className="w-full">
<Controller
name="organization_size"

50
web/constants/archives.ts Normal file
View File

@ -0,0 +1,50 @@
// types
import { IProject } from "@plane/types";
// icons
import { ContrastIcon, DiceIcon, LayersIcon } from "@plane/ui";
export const ARCHIVES_TAB_LIST: {
key: string;
label: string;
shouldRender: (projectDetails: IProject) => boolean;
}[] = [
{
key: "issues",
label: "Issues",
shouldRender: () => true,
},
{
key: "cycles",
label: "Cycles",
shouldRender: (projectDetails) => projectDetails.cycle_view,
},
{
key: "modules",
label: "Modules",
shouldRender: (projectDetails) => projectDetails.module_view,
},
];
export const PROJECT_ARCHIVES_BREADCRUMB_LIST: {
[key: string]: {
label: string;
href: string;
icon: React.FC<React.SVGAttributes<SVGElement> & { className?: string }>;
};
} = {
issues: {
label: "Issues",
href: "/issues",
icon: LayersIcon,
},
cycles: {
label: "Cycles",
href: "/cycles",
icon: ContrastIcon,
},
modules: {
label: "Modules",
href: "/modules",
icon: DiceIcon,
},
};

View File

@ -51,6 +51,7 @@ export enum EmptyStateType {
PROJECT_CYCLE_ACTIVE = "project-cycle-active",
PROJECT_CYCLE_ALL = "project-cycle-all",
PROJECT_CYCLE_COMPLETED_NO_ISSUES = "project-cycle-completed-no-issues",
PROJECT_ARCHIVED_NO_CYCLES = "project-archived-no-cycles",
PROJECT_EMPTY_FILTER = "project-empty-filter",
PROJECT_ARCHIVED_EMPTY_FILTER = "project-archived-empty-filter",
PROJECT_DRAFT_EMPTY_FILTER = "project-draft-empty-filter",
@ -62,6 +63,7 @@ export enum EmptyStateType {
MEMBERS_EMPTY_SEARCH = "members-empty-search",
PROJECT_MODULE_ISSUES = "project-module-issues",
PROJECT_MODULE = "project-module",
PROJECT_ARCHIVED_NO_MODULES = "project-archived-no-modules",
PROJECT_VIEW = "project-view",
PROJECT_PAGE = "project-page",
PROJECT_PAGE_ALL = "project-page-all",
@ -308,6 +310,12 @@ const emptyStateDetails = {
"No issues in the cycle. Issues are either transferred or hidden. To see hidden issues if any, update your display properties accordingly.",
path: "/empty-state/cycle/completed-no-issues",
},
[EmptyStateType.PROJECT_ARCHIVED_NO_CYCLES]: {
key: EmptyStateType.PROJECT_ARCHIVED_NO_CYCLES,
title: "No archived cycles yet",
description: "To tidy up your project, archive completed cycles. Find them here once archived.",
path: "/empty-state/archived/empty-cycles",
},
[EmptyStateType.PROJECT_CYCLE_ALL]: {
key: EmptyStateType.PROJECT_CYCLE_ALL,
title: "No cycles",
@ -368,7 +376,7 @@ const emptyStateDetails = {
key: EmptyStateType.PROJECT_ARCHIVED_NO_ISSUES,
title: "No archived issues yet",
description:
"Archived issues help you remove issues you completed or cancelled from focus. You can set automation to auto archive issues and find them here.",
"Manually or through automation, you can archive issues that are completed or cancelled. Find them here once archived.",
path: "/empty-state/archived/empty-issues",
primaryButton: {
text: "Set automation",
@ -432,6 +440,12 @@ const emptyStateDetails = {
accessType: "project",
access: EUserProjectRoles.MEMBER,
},
[EmptyStateType.PROJECT_ARCHIVED_NO_MODULES]: {
key: EmptyStateType.PROJECT_ARCHIVED_NO_MODULES,
title: "No archived Modules yet",
description: "To tidy up your project, archive completed or cancelled modules. Find them here once archived.",
path: "/empty-state/archived/empty-modules",
},
// project views
[EmptyStateType.PROJECT_VIEW]: {
key: EmptyStateType.PROJECT_VIEW,

View File

@ -0,0 +1,44 @@
import { ReactElement } from "react";
import { observer } from "mobx-react";
import { useRouter } from "next/router";
// components
import { PageHead } from "@/components/core";
import { ArchivedCycleLayoutRoot, ArchivedCyclesHeader } from "@/components/cycles";
import { ProjectArchivesHeader } from "@/components/headers";
// hooks
import { useProject } from "@/hooks/store";
// layouts
import { AppLayout } from "@/layouts/app-layout";
// types
import { NextPageWithLayout } from "@/lib/types";
const ProjectArchivedCyclesPage: NextPageWithLayout = observer(() => {
// router
const router = useRouter();
const { projectId } = router.query;
// store hooks
const { getProjectById } = useProject();
// derived values
const project = projectId ? getProjectById(projectId.toString()) : undefined;
const pageTitle = project?.name && `${project?.name} - Archived cycles`;
return (
<>
<PageHead title={pageTitle} />
<div className="relative flex h-full w-full flex-col overflow-hidden">
<ArchivedCyclesHeader />
<ArchivedCycleLayoutRoot />
</div>
</>
);
});
ProjectArchivedCyclesPage.getLayout = function getLayout(page: ReactElement) {
return (
<AppLayout header={<ProjectArchivesHeader />} withProjectWrapper>
{page}
</AppLayout>
);
};
export default ProjectArchivedCyclesPage;

View File

@ -2,23 +2,23 @@ import { useState, ReactElement } from "react";
import { observer } from "mobx-react";
import { useRouter } from "next/router";
import useSWR from "swr";
// hooks
import { RotateCcw } from "lucide-react";
// icons
import { ArchiveRestoreIcon } from "lucide-react";
// ui
import { ArchiveIcon, Button, Loader, TOAST_TYPE, setToast } from "@plane/ui";
// components
import { PageHead } from "@/components/core";
import { ProjectArchivedIssueDetailsHeader } from "@/components/headers";
import { IssueDetailRoot } from "@/components/issues";
// constants
import { EIssuesStoreType } from "@/constants/issue";
import { EUserProjectRoles } from "@/constants/project";
// hooks
import { useIssueDetail, useIssues, useProject, useUser } from "@/hooks/store";
// layouts
import { AppLayout } from "@/layouts/app-layout";
// components
// ui
// icons
// types
import { NextPageWithLayout } from "@/lib/types";
// constants
const ArchivedIssueDetailsPage: NextPageWithLayout = observer(() => {
// router
@ -112,7 +112,7 @@ const ArchivedIssueDetailsPage: NextPageWithLayout = observer(() => {
{issue?.archived_at && canRestoreIssue && (
<div className="flex items-center justify-between gap-2 rounded-md border border-custom-border-200 bg-custom-background-90 px-2.5 py-2 text-sm text-custom-text-200">
<div className="flex items-center gap-2">
<ArchiveIcon className="h-3.5 w-3.5" />
<ArchiveIcon className="h-4 w-4" />
<p>This issue has been archived.</p>
</div>
<Button
@ -121,7 +121,7 @@ const ArchivedIssueDetailsPage: NextPageWithLayout = observer(() => {
disabled={isRestoring}
variant="neutral-primary"
>
<RotateCcw className="h-3 w-3" />
<ArchiveRestoreIcon className="h-3.5 w-3.5" />
<span>{isRestoring ? "Restoring" : "Restore"}</span>
</Button>
</div>

View File

@ -1,17 +1,16 @@
import { ReactElement } from "react";
import { observer } from "mobx-react";
import { useRouter } from "next/router";
// layouts
import { PageHead } from "@/components/core";
import { ProjectArchivedIssuesHeader } from "@/components/headers";
import { ArchivedIssueLayoutRoot } from "@/components/issues";
import { useProject } from "@/hooks/store";
import { AppLayout } from "@/layouts/app-layout";
// contexts
// components
import { PageHead } from "@/components/core";
import { ProjectArchivesHeader } from "@/components/headers";
import { ArchivedIssueLayoutRoot, ArchivedIssuesHeader } from "@/components/issues";
// hooks
import { useProject } from "@/hooks/store";
// layouts
import { AppLayout } from "@/layouts/app-layout";
// types
import { NextPageWithLayout } from "@/lib/types";
// hooks
const ProjectArchivedIssuesPage: NextPageWithLayout = observer(() => {
// router
@ -26,14 +25,17 @@ const ProjectArchivedIssuesPage: NextPageWithLayout = observer(() => {
return (
<>
<PageHead title={pageTitle} />
<div className="relative flex h-full w-full flex-col overflow-hidden">
<ArchivedIssuesHeader />
<ArchivedIssueLayoutRoot />
</div>
</>
);
});
ProjectArchivedIssuesPage.getLayout = function getLayout(page: ReactElement) {
return (
<AppLayout header={<ProjectArchivedIssuesHeader />} withProjectWrapper>
<AppLayout header={<ProjectArchivesHeader />} withProjectWrapper>
{page}
</AppLayout>
);

View File

@ -0,0 +1,44 @@
import { ReactElement } from "react";
import { observer } from "mobx-react";
import { useRouter } from "next/router";
// components
import { PageHead } from "@/components/core";
import { ProjectArchivesHeader } from "@/components/headers";
import { ArchivedModuleLayoutRoot, ArchivedModulesHeader } from "@/components/modules";
// hooks
import { useProject } from "@/hooks/store";
// layouts
import { AppLayout } from "@/layouts/app-layout";
// types
import { NextPageWithLayout } from "@/lib/types";
const ProjectArchivedModulesPage: NextPageWithLayout = observer(() => {
// router
const router = useRouter();
const { projectId } = router.query;
// store hooks
const { getProjectById } = useProject();
// derived values
const project = projectId ? getProjectById(projectId.toString()) : undefined;
const pageTitle = project?.name && `${project?.name} - Archived modules`;
return (
<>
<PageHead title={pageTitle} />
<div className="relative flex h-full w-full flex-col overflow-hidden">
<ArchivedModulesHeader />
<ArchivedModuleLayoutRoot />
</div>
</>
);
});
ProjectArchivedModulesPage.getLayout = function getLayout(page: ReactElement) {
return (
<AppLayout header={<ProjectArchivesHeader />} withProjectWrapper>
{page}
</AppLayout>
);
};
export default ProjectArchivedModulesPage;

View File

@ -19,7 +19,7 @@ const LabelsSettingsPage: NextPageWithLayout = observer(() => {
return (
<>
<PageHead title={pageTitle} />
<div className="w-full gap-10 overflow-y-auto py-8 pr-9">
<div className="h-full w-full gap-10 overflow-y-auto py-8 pr-9">
<ProjectSettingsLabelList />
</div>
</>

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Some files were not shown because too many files have changed in this diff Show More