diff --git a/apiserver/Dockerfile.api b/apiserver/Dockerfile.api index c5113e059..34a50334a 100644 --- a/apiserver/Dockerfile.api +++ b/apiserver/Dockerfile.api @@ -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 diff --git a/apiserver/Dockerfile.dev b/apiserver/Dockerfile.dev index bd6684fd5..06f15231c 100644 --- a/apiserver/Dockerfile.dev +++ b/apiserver/Dockerfile.dev @@ -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 diff --git a/apiserver/plane/api/views/cycle.py b/apiserver/plane/api/views/cycle.py index bb2796bf6..637d713c3 100644 --- a/apiserver/plane/api/views/cycle.py +++ b/apiserver/plane/api/views/cycle.py @@ -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()), diff --git a/apiserver/plane/api/views/module.py b/apiserver/plane/api/views/module.py index 460722f99..643221dca 100644 --- a/apiserver/plane/api/views/module.py +++ b/apiserver/plane/api/views/module.py @@ -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()), diff --git a/apiserver/plane/app/views/cycle/base.py b/apiserver/plane/app/views/cycle/base.py index b70db4c11..6809efbe6 100644 --- a/apiserver/plane/app/views/cycle/base.py +++ b/apiserver/plane/app/views/cycle/base.py @@ -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) diff --git a/apiserver/plane/app/views/cycle/issue.py b/apiserver/plane/app/views/cycle/issue.py index d2a7795da..2a5505dd0 100644 --- a/apiserver/plane/app/views/cycle/issue.py +++ b/apiserver/plane/app/views/cycle/issue.py @@ -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())), ), diff --git a/apiserver/plane/app/views/dashboard/base.py b/apiserver/plane/app/views/dashboard/base.py index 508f81f21..33b3cf9d5 100644 --- a/apiserver/plane/app/views/dashboard/base.py +++ b/apiserver/plane/app/views/dashboard/base.py @@ -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())), ), diff --git a/apiserver/plane/app/views/inbox/base.py b/apiserver/plane/app/views/inbox/base.py index fb3b9227f..710aa10a2 100644 --- a/apiserver/plane/app/views/inbox/base.py +++ b/apiserver/plane/app/views/inbox/base.py @@ -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())), ), diff --git a/apiserver/plane/app/views/issue/archive.py b/apiserver/plane/app/views/issue/archive.py index 540715a24..d9274ae4f 100644 --- a/apiserver/plane/app/views/issue/archive.py +++ b/apiserver/plane/app/views/issue/archive.py @@ -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())), ), diff --git a/apiserver/plane/app/views/issue/base.py b/apiserver/plane/app/views/issue/base.py index f1b4c7627..a27f52c74 100644 --- a/apiserver/plane/app/views/issue/base.py +++ b/apiserver/plane/app/views/issue/base.py @@ -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())), ), diff --git a/apiserver/plane/app/views/issue/draft.py b/apiserver/plane/app/views/issue/draft.py index db6b5b9fb..e1c6962d8 100644 --- a/apiserver/plane/app/views/issue/draft.py +++ b/apiserver/plane/app/views/issue/draft.py @@ -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())), ), diff --git a/apiserver/plane/app/views/issue/sub_issue.py b/apiserver/plane/app/views/issue/sub_issue.py index 6ec4a2de1..da479e0e9 100644 --- a/apiserver/plane/app/views/issue/sub_issue.py +++ b/apiserver/plane/app/views/issue/sub_issue.py @@ -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())), ), diff --git a/apiserver/plane/app/views/module/base.py b/apiserver/plane/app/views/module/base.py index f6329c223..39dbcb751 100644 --- a/apiserver/plane/app/views/module/base.py +++ b/apiserver/plane/app/views/module/base.py @@ -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) diff --git a/apiserver/plane/app/views/module/issue.py b/apiserver/plane/app/views/module/issue.py index cfa8ee478..d26433340 100644 --- a/apiserver/plane/app/views/module/issue.py +++ b/apiserver/plane/app/views/module/issue.py @@ -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())), ), diff --git a/apiserver/plane/app/views/view/base.py b/apiserver/plane/app/views/view/base.py index e2fc29aac..45e7bd29c 100644 --- a/apiserver/plane/app/views/view/base.py +++ b/apiserver/plane/app/views/view/base.py @@ -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())), ), diff --git a/apiserver/plane/app/views/workspace/module.py b/apiserver/plane/app/views/workspace/module.py index fbd760271..8dd5e97f4 100644 --- a/apiserver/plane/app/views/workspace/module.py +++ b/apiserver/plane/app/views/workspace/module.py @@ -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")) diff --git a/apiserver/plane/app/views/workspace/user.py b/apiserver/plane/app/views/workspace/user.py index fe495de6c..94a22a1a7 100644 --- a/apiserver/plane/app/views/workspace/user.py +++ b/apiserver/plane/app/views/workspace/user.py @@ -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())), ), diff --git a/packages/types/src/cycle/cycle.d.ts b/packages/types/src/cycle/cycle.d.ts index c41ab279b..30724706b 100644 --- a/packages/types/src/cycle/cycle.d.ts +++ b/packages/types/src/cycle/cycle.d.ts @@ -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; diff --git a/packages/types/src/cycle/cycle_filters.d.ts b/packages/types/src/cycle/cycle_filters.d.ts index 470a20dd2..38f8a7549 100644 --- a/packages/types/src/cycle/cycle_filters.d.ts +++ b/packages/types/src/cycle/cycle_filters.d.ts @@ -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; diff --git a/packages/types/src/module/module_filters.d.ts b/packages/types/src/module/module_filters.d.ts index 10d56c328..297c8046c 100644 --- a/packages/types/src/module/module_filters.d.ts +++ b/packages/types/src/module/module_filters.d.ts @@ -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; diff --git a/packages/types/src/module/modules.d.ts b/packages/types/src/module/modules.d.ts index 0af293e50..7ba2c3b41 100644 --- a/packages/types/src/module/modules.d.ts +++ b/packages/types/src/module/modules.d.ts @@ -39,6 +39,7 @@ export interface IModule { unstarted_issues: number; updated_at: Date; updated_by: string; + archived_at: string | null; view_props: { filters: IIssueFilterOptions; }; diff --git a/web/components/archives/archive-tabs-list.tsx b/web/components/archives/archive-tabs-list.tsx new file mode 100644 index 000000000..57d1c36a1 --- /dev/null +++ b/web/components/archives/archive-tabs-list.tsx @@ -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) && ( + + + {tab.label} + + + ) + )} + + ); +}); diff --git a/web/components/archives/index.ts b/web/components/archives/index.ts new file mode 100644 index 000000000..4b519fca0 --- /dev/null +++ b/web/components/archives/index.ts @@ -0,0 +1 @@ +export * from "./archive-tabs-list"; diff --git a/web/components/core/sidebar/links-list.tsx b/web/components/core/sidebar/links-list.tsx index 9556eb1aa..83db67c34 100644 --- a/web/components/core/sidebar/links-list.tsx +++ b/web/components/core/sidebar/links-list.tsx @@ -16,12 +16,13 @@ type Props = { handleDeleteLink: (linkId: string) => void; handleEditLink: (link: ILinkDetails) => void; userAuth: UserAuth; + disabled?: boolean; }; -export const LinksList: React.FC = observer(({ links, handleDeleteLink, handleEditLink, userAuth }) => { +export const LinksList: React.FC = 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); diff --git a/web/components/core/sidebar/progress-chart.tsx b/web/components/core/sidebar/progress-chart.tsx index a1c5f3a13..68b1708fe 100644 --- a/web/components/core/sidebar/progress-chart.tsx +++ b/web/components/core/sidebar/progress-chart.tsx @@ -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 = ({ distribution, startDate, endDate, totalIssues }) => { +const ProgressChart: React.FC = ({ 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 = ({ distribution, startDate, endDate, tota }; return ( -
+
= 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 ( +
+ { + switch (i) { + case 0: + return setTab("Priority-Issues"); + case 1: + return setTab("Assignees"); + case 2: + return setTab("Labels"); + + default: + return setTab("Priority-Issues"); + } + }} + > + + + 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 + + + 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 + + + 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 + + + + + +
+ {cycleIssues ? ( + cycleIssues.length > 0 ? ( + cycleIssues.map((issue: TIssue) => ( + +
+ + + + + {currentProjectDetails?.identifier}-{issue.sequence_id} + + + + {issue.name} + +
+
+ {}} + projectId={projectId?.toString() ?? ""} + disabled + buttonVariant="background-with-text" + buttonContainerClassName="cursor-pointer max-w-24" + showTooltip + /> + {issue.target_date && ( + +
+ + + {renderFormattedDateWithoutYear(issue.target_date)} + +
+
+ )} +
+ + )) + ) : ( +
+ There are no high priority issues present in this cycle. +
+ ) + ) : ( + + + + + + )} +
+
+ + + {cycle.distribution?.assignees?.map((assignee, index) => { + if (assignee.assignee_id) + return ( + + + + {assignee.display_name} +
+ } + completed={assignee.completed_issues} + total={assignee.total_issues} + /> + ); + else + return ( + +
+ User +
+ No assignee +
+ } + completed={assignee.completed_issues} + total={assignee.total_issues} + /> + ); + })} + + + + {cycle.distribution?.labels?.map((label, index) => ( + + + {label.label_name ?? "No labels"} +
+ } + completed={label.completed_issues} + total={label.total_issues} + /> + ))} + + + + + ); +}); diff --git a/web/components/cycles/active-cycle/header.tsx b/web/components/cycles/active-cycle/header.tsx new file mode 100644 index 000000000..98ed91c1d --- /dev/null +++ b/web/components/cycles/active-cycle/header.tsx @@ -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 = (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 ( +
+
+ + +

{truncateText(cycle.name, 70)}

+
+ + + {`${daysLeft} ${daysLeft > 1 ? "days" : "day"} left`} + + +
+
+
+
+ + {cycleAssignee.length > 0 && ( + + + {cycleAssignee.map((member) => ( + + ))} + + + )} +
+
+ + View Cycle + +
+
+ ); +}; diff --git a/web/components/cycles/active-cycle/index.ts b/web/components/cycles/active-cycle/index.ts index 73d5d1e98..d88ccc3e8 100644 --- a/web/components/cycles/active-cycle/index.ts +++ b/web/components/cycles/active-cycle/index.ts @@ -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"; diff --git a/web/components/cycles/active-cycle/productivity.tsx b/web/components/cycles/active-cycle/productivity.tsx new file mode 100644 index 000000000..59c2ac3c9 --- /dev/null +++ b/web/components/cycles/active-cycle/productivity.tsx @@ -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 = (props) => { + const { cycle } = props; + + return ( +
+
+

Issue burndown

+
+ +
+
+
+
+ + Ideal +
+
+ + Current +
+
+ {`Pending issues - ${cycle.backlog_issues + cycle.unstarted_issues + cycle.started_issues}`} +
+
+ +
+
+
+ ); +}; diff --git a/web/components/cycles/active-cycle/progress.tsx b/web/components/cycles/active-cycle/progress.tsx new file mode 100644 index 000000000..dea3b496a --- /dev/null +++ b/web/components/cycles/active-cycle/progress.tsx @@ -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 = (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 ( +
+
+
+

Progress

+ + {`${cycle.completed_issues + cycle.cancelled_issues}/${cycle.total_issues - cycle.cancelled_issues} ${ + cycle.completed_issues + cycle.cancelled_issues > 1 ? "Issues" : "Issue" + } closed`} + +
+ +
+ +
+ {Object.keys(groupedIssues).map((group, index) => ( + <> + {groupedIssues[group] > 0 && ( +
+
+
+ + {group} +
+ {`${groupedIssues[group]} ${ + groupedIssues[group] > 1 ? "Issues" : "Issue" + }`} +
+
+ )} + + ))} + {cycle.cancelled_issues > 0 && ( + + + {`${cycle.cancelled_issues} cancelled ${ + cycle.cancelled_issues > 1 ? "issues are" : "issue is" + } excluded from this report.`}{" "} + + + )} +
+
+ ); +}; diff --git a/web/components/cycles/active-cycle/root.tsx b/web/components/cycles/active-cycle/root.tsx index 83acd1521..bd2c3b613 100644 --- a/web/components/cycles/active-cycle/root.tsx +++ b/web/components/cycles/active-cycle/root.tsx @@ -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 = 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 = observer((props) = Create new cycles to find them here or check
{"'"}All{"'"} cycles tab to see all cycles or{" "} -

- + ); } - 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) => { - 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) => { - 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 ( -
-
-
-
-
-
- - - - - -

{truncateText(activeCycle.name, 70)}

-
-
- - - {`${daysLeft} ${daysLeft > 1 ? "days" : "day"} left`} - - {activeCycle.is_favorite ? ( - - ) : ( - - )} - -
- -
-
- - {renderFormattedDate(startDate)} -
- -
- - {renderFormattedDate(endDate)} -
-
- -
-
- - {cycleOwnerDetails?.display_name} -
- - {activeCycle.assignee_ids.length > 0 && ( -
- - {activeCycle.assignee_ids.map((assignee_id) => { - const member = getUserDetails(assignee_id); - return ; - })} - -
- )} -
- -
-
- - {activeCycle.total_issues} issues -
-
- - {activeCycle.completed_issues} issues -
-
- - - View cycle - -
-
-
-
-
-
-
- Progress - -
-
- {Object.keys(groupedIssues).map((group, index) => ( - - - {group} -
- } - completed={groupedIssues[group]} - total={activeCycle.total_issues} - /> - ))} -
-
-
-
- -
+ <> +
+ +
+ + +
-
-
-
High priority issues
-
- {activeCycleIssues ? ( - activeCycleIssues.length > 0 ? ( - activeCycleIssues.map((issue) => ( - -
- - - - - {currentProjectDetails?.identifier}-{issue.sequence_id} - - - - {truncateText(issue.name, 30)} - -
-
- {}} - projectId={projectId} - disabled - buttonVariant="background-with-text" - /> - {issue.target_date && ( - -
- - {renderFormattedDateWithoutYear(issue.target_date)} -
-
- )} -
- - )) - ) : ( -
- There are no high priority issues present in this cycle. -
- ) - ) : ( - - - - - - )} -
-
-
-
-
-
- - Ideal -
-
- - Current -
-
-
- - - - - Pending issues-{" "} - {activeCycle.total_issues - (activeCycle.completed_issues + activeCycle.cancelled_issues)} - -
-
-
- -
-
-
-
+ {currentProjectUpcomingCycleIds && } + ); }); diff --git a/web/components/cycles/active-cycle/upcoming-cycles-list.tsx b/web/components/cycles/active-cycle/upcoming-cycles-list.tsx index c2d8b2388..f4156f341 100644 --- a/web/components/cycles/active-cycle/upcoming-cycles-list.tsx +++ b/web/components/cycles/active-cycle/upcoming-cycles-list.tsx @@ -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 = observer((props) => { + const { handleEmptyStateAction } = props; // store hooks const { currentProjectUpcomingCycleIds } = useCycle(); @@ -12,14 +18,30 @@ export const UpcomingCyclesList = observer(() => { return (
-
- Upcoming cycles -
-
- {currentProjectUpcomingCycleIds.map((cycleId) => ( - - ))} +
+ Next cycles
+ {currentProjectUpcomingCycleIds.length > 0 ? ( +
+ {currentProjectUpcomingCycleIds.map((cycleId) => ( + + ))} +
+ ) : ( +
+
+
No upcoming cycles
+

+ Create new cycles to find them here or check +
+ {"'"}All{"'"} cycles tab to see all cycles or{" "} + +

+
+
+ )}
); }); diff --git a/web/components/cycles/archived-cycles/header.tsx b/web/components/cycles/archived-cycles/header.tsx new file mode 100644 index 000000000..267c87388 --- /dev/null +++ b/web/components/cycles/archived-cycles/header.tsx @@ -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(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) => { + if (e.key === "Escape") { + if (archivedCyclesSearchQuery && archivedCyclesSearchQuery.trim() !== "") updateArchivedCyclesSearchQuery(""); + else { + setIsSearchOpen(false); + inputRef.current?.blur(); + } + } + }; + + return ( +
+
+ +
+ {/* filter options */} +
+ {!isSearchOpen && ( + + )} +
+ + updateArchivedCyclesSearchQuery(e.target.value)} + onKeyDown={handleInputKeyDown} + /> + {isSearchOpen && ( + + )} +
+ } title="Filters" placement="bottom-end"> + + +
+
+ ); +}); diff --git a/web/components/cycles/archived-cycles/index.ts b/web/components/cycles/archived-cycles/index.ts new file mode 100644 index 000000000..f59f0954e --- /dev/null +++ b/web/components/cycles/archived-cycles/index.ts @@ -0,0 +1,4 @@ +export * from "./root"; +export * from "./view"; +export * from "./header"; +export * from "./modal"; diff --git a/web/components/cycles/archived-cycles/modal.tsx b/web/components/cycles/archived-cycles/modal.tsx new file mode 100644 index 000000000..a9b421351 --- /dev/null +++ b/web/components/cycles/archived-cycles/modal.tsx @@ -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; +}; + +export const ArchiveCycleModal: React.FC = (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 ( + + + +
+ + +
+
+ + +
+

Archive cycle {cycleName}

+

+ Are you sure you want to archive the cycle? All your archives can be restored later. +

+
+ + +
+
+
+
+
+
+
+
+ ); +}; diff --git a/web/components/cycles/archived-cycles/root.tsx b/web/components/cycles/archived-cycles/root.tsx new file mode 100644 index 000000000..4d47c8f34 --- /dev/null +++ b/web/components/cycles/archived-cycles/root.tsx @@ -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 ; + } + + return ( + <> + {calculateTotalFilters(currentProjectArchivedFilters ?? {}) !== 0 && ( +
+ clearAllFilters(projectId.toString(), "archived")} + handleRemoveFilter={handleRemoveFilter} + /> +
+ )} + {totalArchivedCycles === 0 ? ( +
+ +
+ ) : ( +
+ +
+ )} + + ); +}); diff --git a/web/components/cycles/archived-cycles/view.tsx b/web/components/cycles/archived-cycles/view.tsx new file mode 100644 index 000000000..ed86a56b4 --- /dev/null +++ b/web/components/cycles/archived-cycles/view.tsx @@ -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 = 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 ; + + if (filteredArchivedCycleIds.length === 0) + return ( +
+
+ No matching cycles +
No matching cycles
+

+ {archivedCyclesSearchQuery.trim() === "" + ? "Remove the filters to see all cycles" + : "Remove the search criteria to see all cycles"} +

+
+
+ ); + + return ( + + ); +}); diff --git a/web/components/cycles/cycle-peek-overview.tsx b/web/components/cycles/cycle-peek-overview.tsx index 4b88d8d7b..8409c06fe 100644 --- a/web/components/cycles/cycle-peek-overview.tsx +++ b/web/components/cycles/cycle-peek-overview.tsx @@ -9,9 +9,10 @@ import { CycleDetailsSidebar } from "./sidebar"; type Props = { projectId: string; workspaceSlug: string; + isArchived?: boolean; }; -export const CyclePeekOverview: React.FC = observer(({ projectId, workspaceSlug }) => { +export const CyclePeekOverview: React.FC = observer(({ projectId, workspaceSlug, isArchived = false }) => { // router const router = useRouter(); const { peekCycle } = router.query; @@ -29,9 +30,9 @@ export const CyclePeekOverview: React.FC = 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 = 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)", }} > - +
)} diff --git a/web/components/cycles/cycles-view-header.tsx b/web/components/cycles/cycles-view-header.tsx index c687c965e..aad650dd6 100644 --- a/web/components/cycles/cycles-view-header.tsx +++ b/web/components/cycles/cycles-view-header.tsx @@ -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 = observer((props) => { const { projectId } = props; - // states - const [isSearchOpen, setIsSearchOpen] = useState(false); // refs const inputRef = useRef(null); // hooks @@ -38,6 +36,8 @@ export const CyclesViewHeader: React.FC = 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); diff --git a/web/components/cycles/dropdowns/filters/root.tsx b/web/components/cycles/dropdowns/filters/root.tsx index 768b8a5dc..57e9ec90c 100644 --- a/web/components/cycles/dropdowns/filters/root.tsx +++ b/web/components/cycles/dropdowns/filters/root.tsx @@ -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 = observer((props) => { - const { filters, handleFiltersUpdate } = props; + const { filters, handleFiltersUpdate, isArchived = false } = props; // states const [filtersSearchQuery, setFiltersSearchQuery] = useState(""); @@ -38,13 +39,15 @@ export const CycleFiltersSelection: React.FC = observer((props) => {
{/* cycle status */} -
- handleFiltersUpdate("status", val)} - searchQuery={filtersSearchQuery} - /> -
+ {!isArchived && ( +
+ handleFiltersUpdate("status", val)} + searchQuery={filtersSearchQuery} + /> +
+ )} {/* start date */}
diff --git a/web/components/cycles/form.tsx b/web/components/cycles/form.tsx index 27b972044..f8092f8d0 100644 --- a/web/components/cycles/form.tsx +++ b/web/components/cycles/form.tsx @@ -113,7 +113,7 @@ export const CycleForm: React.FC = (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} diff --git a/web/components/cycles/index.ts b/web/components/cycles/index.ts index e37d266b7..b1b718175 100644 --- a/web/components/cycles/index.ts +++ b/web/components/cycles/index.ts @@ -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"; diff --git a/web/components/cycles/list/cycles-list-item.tsx b/web/components/cycles/list/cycles-list-item.tsx index 6e81da3c7..a418f9b04 100644 --- a/web/components/cycles/list/cycles-list-item.tsx +++ b/web/components/cycles/list/cycles-list-item.tsx @@ -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 = 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 = observer((props) => { }); }; - const openCycleOverview = (e: MouseEvent) => { + const openCycleOverview = (e: MouseEvent) => { const { query } = router; e.preventDefault(); e.stopPropagation(); @@ -151,7 +146,14 @@ export const CyclesListItem: FC = observer((props) => { return ( <> - + { + if (isArchived) { + openCycleOverview(e); + } + }} + >
@@ -221,21 +223,23 @@ export const CyclesListItem: FC = observer((props) => {
- {isEditingAllowed && ( - <> - {cycleDetails.is_favorite ? ( - - ) : ( - - )} - - - - )} + {isEditingAllowed && + !isArchived && + (cycleDetails.is_favorite ? ( + + ) : ( + + ))} +
diff --git a/web/components/cycles/list/cycles-list-map.tsx b/web/components/cycles/list/cycles-list-map.tsx index 004c66fca..7a99f5ab7 100644 --- a/web/components/cycles/list/cycles-list-map.tsx +++ b/web/components/cycles/list/cycles-list-map.tsx @@ -5,15 +5,22 @@ type Props = { cycleIds: string[]; projectId: string; workspaceSlug: string; + isArchived?: boolean; }; export const CyclesListMap: React.FC = (props) => { - const { cycleIds, projectId, workspaceSlug } = props; + const { cycleIds, projectId, workspaceSlug, isArchived } = props; return ( <> {cycleIds.map((cycleId) => ( - + ))} ); diff --git a/web/components/cycles/list/root.tsx b/web/components/cycles/list/root.tsx index 904daa1d9..ef05228ee 100644 --- a/web/components/cycles/list/root.tsx +++ b/web/components/cycles/list/root.tsx @@ -12,16 +12,22 @@ export interface ICyclesList { cycleIds: string[]; workspaceSlug: string; projectId: string; + isArchived?: boolean; } export const CyclesList: FC = observer((props) => { - const { completedCycleIds, cycleIds, workspaceSlug, projectId } = props; + const { completedCycleIds, cycleIds, workspaceSlug, projectId, isArchived = false } = props; return (
- + {completedCycleIds.length !== 0 && ( @@ -37,12 +43,17 @@ export const CyclesList: FC = observer((props) => { )} - + )}
- +
); diff --git a/web/components/cycles/quick-actions.tsx b/web/components/cycles/quick-actions.tsx index eebd28a9f..215f07bef 100644 --- a/web/components/cycles/quick-actions.tsx +++ b/web/components/cycles/quick-actions.tsx @@ -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 = 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 = observer((props) => { setUpdateModal(true); }; + const handleArchiveCycle = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + setArchiveCycleModal(true); + }; + + const handleRestoreCycle = async (e: React.MouseEvent) => { + 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) => { e.preventDefault(); e.stopPropagation(); @@ -74,6 +107,13 @@ export const CycleQuickActions: React.FC = observer((props) => { workspaceSlug={workspaceSlug} projectId={projectId} /> + setArchiveCycleModal(false)} + /> = observer((props) => {
)} + {!isCompleted && isEditingAllowed && !isArchived && ( + + + + Edit cycle + + + )} + {isEditingAllowed && !isArchived && ( + + {isCompleted ? ( +
+ + Archive cycle +
+ ) : ( +
+ +
+

Archive cycle

+

+ Only completed cycle
can be archived. +

+
+
+ )} +
+ )} + {isEditingAllowed && isArchived && ( + + + + Restore cycle + + + )} + {!isArchived && ( + + + + Copy cycle link + + + )} {!isCompleted && isEditingAllowed && ( - <> - - - - Edit cycle - - +
Delete cycle - +
)} - - - - Copy cycle link - -
); diff --git a/web/components/cycles/sidebar.tsx b/web/components/cycles/sidebar.tsx index e142b95ec..e333564ee 100644 --- a/web/components/cycles/sidebar.tsx +++ b/web/components/cycles/sidebar.tsx @@ -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 = { @@ -42,8 +52,9 @@ const cycleService = new CycleService(); // TODO: refactor the whole component export const CycleDetailsSidebar: React.FC = 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 = 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 = 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({ @@ -219,8 +251,8 @@ export const CycleDetailsSidebar: React.FC = observer((props) => { ? "0 Issue" : `${cycleDetails.progress_snapshot.completed_issues}/${cycleDetails.progress_snapshot.total_issues}` : cycleDetails.total_issues === 0 - ? "0 Issue" - : `${cycleDetails.completed_issues}/${cycleDetails.total_issues}`; + ? "0 Issue" + : `${cycleDetails.completed_issues}/${cycleDetails.total_issues}`; const daysLeft = findHowManyDaysLeft(cycleDetails.end_date); @@ -229,13 +261,22 @@ export const CycleDetailsSidebar: React.FC = observer((props) => { return (
{cycleDetails && workspaceSlug && projectId && ( - setCycleDeleteModal(false)} - workspaceSlug={workspaceSlug.toString()} - projectId={projectId.toString()} - /> + <> + setArchiveCycleModal(false)} + /> + setCycleDeleteModal(false)} + workspaceSlug={workspaceSlug.toString()} + projectId={projectId.toString()} + /> + )} <> @@ -249,22 +290,54 @@ export const CycleDetailsSidebar: React.FC = observer((props) => {
- - {!isCompleted && isEditingAllowed && ( + {!isArchived && ( + + )} + {isEditingAllowed && ( - { - setTrackElement("CYCLE_PAGE_SIDEBAR"); - setCycleDeleteModal(true); - }} - > - - - Delete cycle - - + {!isArchived && ( + setArchiveCycleModal(true)} disabled={!isCompleted}> + {isCompleted ? ( +
+ + Archive cycle +
+ ) : ( +
+ +
+

Archive cycle

+

+ Only completed cycle
can be archived. +

+
+
+ )} +
+ )} + {isArchived && ( + + + + Restore cycle + + + )} + {!isCompleted && ( + { + setTrackElement("CYCLE_PAGE_SIDEBAR"); + setCycleDeleteModal(true); + }} + > + + + Delete cycle + + + )}
)}
@@ -290,9 +363,11 @@ export const CycleDetailsSidebar: React.FC = observer((props) => { {cycleDetails.description && ( - - {cycleDetails.description} - +