mirror of
https://github.com/makeplane/plane
synced 2024-06-14 14:31:34 +00:00
Merge branch 'develop' of gurusainath:makeplane/plane into fix-auto-close-dropdowns-issue-modal
This commit is contained in:
commit
041134c08a
@ -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
|
||||
|
@ -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
|
||||
|
@ -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()),
|
||||
|
@ -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()),
|
||||
|
@ -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)
|
||||
|
@ -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())),
|
||||
),
|
||||
|
@ -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())),
|
||||
),
|
||||
|
@ -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())),
|
||||
),
|
||||
|
@ -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())),
|
||||
),
|
||||
|
@ -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())),
|
||||
),
|
||||
|
@ -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())),
|
||||
),
|
||||
|
@ -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())),
|
||||
),
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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())),
|
||||
),
|
||||
|
@ -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())),
|
||||
),
|
||||
|
@ -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"))
|
||||
|
@ -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())),
|
||||
),
|
||||
|
1
packages/types/src/cycle/cycle.d.ts
vendored
1
packages/types/src/cycle/cycle.d.ts
vendored
@ -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;
|
||||
|
5
packages/types/src/cycle/cycle_filters.d.ts
vendored
5
packages/types/src/cycle/cycle_filters.d.ts
vendored
@ -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;
|
||||
|
@ -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;
|
||||
|
1
packages/types/src/module/modules.d.ts
vendored
1
packages/types/src/module/modules.d.ts
vendored
@ -39,6 +39,7 @@ export interface IModule {
|
||||
unstarted_issues: number;
|
||||
updated_at: Date;
|
||||
updated_by: string;
|
||||
archived_at: string | null;
|
||||
view_props: {
|
||||
filters: IIssueFilterOptions;
|
||||
};
|
||||
|
43
web/components/archives/archive-tabs-list.tsx
Normal file
43
web/components/archives/archive-tabs-list.tsx
Normal 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>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
1
web/components/archives/index.ts
Normal file
1
web/components/archives/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./archive-tabs-list";
|
@ -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);
|
||||
|
@ -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"
|
||||
|
260
web/components/cycles/active-cycle/cycle-stats.tsx
Normal file
260
web/components/cycles/active-cycle/cycle-stats.tsx
Normal 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>
|
||||
);
|
||||
});
|
77
web/components/cycles/active-cycle/header.tsx
Normal file
77
web/components/cycles/active-cycle/header.tsx
Normal 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>
|
||||
);
|
||||
};
|
@ -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";
|
||||
|
46
web/components/cycles/active-cycle/productivity.tsx
Normal file
46
web/components/cycles/active-cycle/productivity.tsx
Normal 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>
|
||||
);
|
||||
};
|
79
web/components/cycles/active-cycle/progress.tsx
Normal file
79
web/components/cycles/active-cycle/progress.tsx
Normal 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>
|
||||
);
|
||||
};
|
@ -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 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>
|
||||
<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>
|
||||
</div>
|
||||
{currentProjectUpcomingCycleIds && <UpcomingCyclesList handleEmptyStateAction={handleEmptyStateAction} />}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
@ -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>
|
||||
<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 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>
|
||||
);
|
||||
});
|
||||
|
123
web/components/cycles/archived-cycles/header.tsx
Normal file
123
web/components/cycles/archived-cycles/header.tsx
Normal 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>
|
||||
);
|
||||
});
|
4
web/components/cycles/archived-cycles/index.ts
Normal file
4
web/components/cycles/archived-cycles/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export * from "./root";
|
||||
export * from "./view";
|
||||
export * from "./header";
|
||||
export * from "./modal";
|
104
web/components/cycles/archived-cycles/modal.tsx
Normal file
104
web/components/cycles/archived-cycles/modal.tsx
Normal 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>
|
||||
);
|
||||
};
|
77
web/components/cycles/archived-cycles/root.tsx
Normal file
77
web/components/cycles/archived-cycles/root.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
57
web/components/cycles/archived-cycles/view.tsx
Normal file
57
web/components/cycles/archived-cycles/view.tsx
Normal 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
|
||||
/>
|
||||
);
|
||||
});
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
|
@ -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);
|
||||
|
@ -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,13 +39,15 @@ 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 */}
|
||||
<div className="py-2">
|
||||
<FilterStatus
|
||||
appliedFilters={(filters.status as TCycleGroups[]) ?? null}
|
||||
handleUpdate={(val) => handleFiltersUpdate("status", val)}
|
||||
searchQuery={filtersSearchQuery}
|
||||
/>
|
||||
</div>
|
||||
{!isArchived && (
|
||||
<div className="py-2">
|
||||
<FilterStatus
|
||||
appliedFilters={(filters.status as TCycleGroups[]) ?? null}
|
||||
handleUpdate={(val) => handleFiltersUpdate("status", val)}
|
||||
searchQuery={filtersSearchQuery}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* start date */}
|
||||
<div className="py-2">
|
||||
|
@ -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}
|
||||
|
@ -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";
|
||||
|
@ -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,21 +223,23 @@ export const CyclesListItem: FC<TCyclesListItem> = observer((props) => {
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
||||
{isEditingAllowed && (
|
||||
<>
|
||||
{cycleDetails.is_favorite ? (
|
||||
<button type="button" onClick={handleRemoveFromFavorites}>
|
||||
<Star className="h-3.5 w-3.5 fill-current text-amber-500" />
|
||||
</button>
|
||||
) : (
|
||||
<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} />
|
||||
</>
|
||||
)}
|
||||
{isEditingAllowed &&
|
||||
!isArchived &&
|
||||
(cycleDetails.is_favorite ? (
|
||||
<button type="button" onClick={handleRemoveFromFavorites}>
|
||||
<Star className="h-3.5 w-3.5 fill-current text-amber-500" />
|
||||
</button>
|
||||
) : (
|
||||
<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}
|
||||
isArchived={isArchived}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -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}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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 && !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>
|
||||
)}
|
||||
{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">
|
||||
<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 && (
|
||||
<>
|
||||
<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>
|
||||
<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.MenuItem onClick={handleCopyText}>
|
||||
<span className="flex items-center justify-start gap-2">
|
||||
<LinkIcon className="h-3 w-3" />
|
||||
<span>Copy cycle link</span>
|
||||
</span>
|
||||
</CustomMenu.MenuItem>
|
||||
</CustomMenu>
|
||||
</>
|
||||
);
|
||||
|
@ -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({
|
||||
@ -219,8 +251,8 @@ export const CycleDetailsSidebar: React.FC<Props> = 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<Props> = observer((props) => {
|
||||
return (
|
||||
<div className="relative">
|
||||
{cycleDetails && workspaceSlug && projectId && (
|
||||
<CycleDeleteModal
|
||||
cycle={cycleDetails}
|
||||
isOpen={cycleDeleteModal}
|
||||
handleClose={() => setCycleDeleteModal(false)}
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
projectId={projectId.toString()}
|
||||
/>
|
||||
<>
|
||||
<ArchiveCycleModal
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
projectId={projectId.toString()}
|
||||
cycleId={cycleId}
|
||||
isOpen={archiveCycleModal}
|
||||
handleClose={() => setArchiveCycleModal(false)}
|
||||
/>
|
||||
<CycleDeleteModal
|
||||
cycle={cycleDetails}
|
||||
isOpen={cycleDeleteModal}
|
||||
handleClose={() => setCycleDeleteModal(false)}
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
projectId={projectId.toString()}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<>
|
||||
@ -249,22 +290,54 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-3.5">
|
||||
<button onClick={handleCopyText}>
|
||||
<LinkIcon className="h-3 w-3 text-custom-text-300" />
|
||||
</button>
|
||||
{!isCompleted && isEditingAllowed && (
|
||||
{!isArchived && (
|
||||
<button onClick={handleCopyText}>
|
||||
<LinkIcon className="h-3 w-3 text-custom-text-300" />
|
||||
</button>
|
||||
)}
|
||||
{isEditingAllowed && (
|
||||
<CustomMenu placement="bottom-end" ellipsis>
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() => {
|
||||
setTrackElement("CYCLE_PAGE_SIDEBAR");
|
||||
setCycleDeleteModal(true);
|
||||
}}
|
||||
>
|
||||
<span className="flex items-center justify-start gap-2">
|
||||
<Trash2 className="h-3 w-3" />
|
||||
<span>Delete cycle</span>
|
||||
</span>
|
||||
</CustomMenu.MenuItem>
|
||||
{!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");
|
||||
setCycleDeleteModal(true);
|
||||
}}
|
||||
>
|
||||
<span className="flex items-center justify-start gap-2">
|
||||
<Trash2 className="h-3 w-3" />
|
||||
<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}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
@ -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
|
||||
|
@ -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";
|
||||
|
@ -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);
|
||||
|
@ -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={
|
||||
|
96
web/components/headers/project-archives.tsx
Normal file
96
web/components/headers/project-archives.tsx
Normal 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>
|
||||
);
|
||||
});
|
@ -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>
|
||||
<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="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 px-8">
|
||||
<FiltersDropdown title="Filters" placement="bottom-end">
|
||||
<FilterSelection
|
||||
filters={issueFilters?.filters || {}}
|
@ -16,3 +16,4 @@ export * from "./peek-overview";
|
||||
|
||||
// archived issue
|
||||
export * from "./archive-issue-modal";
|
||||
export * from "./archived-issues-header";
|
||||
|
@ -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>
|
||||
</>
|
||||
|
@ -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",
|
||||
|
@ -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);
|
||||
|
@ -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"
|
||||
|
@ -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"
|
||||
|
@ -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",
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
@ -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",
|
||||
|
@ -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}
|
||||
|
@ -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
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
@ -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,
|
||||
|
@ -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>
|
||||
)}
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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 () =>
|
||||
|
@ -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
|
||||
|
147
web/components/modules/archived-modules/header.tsx
Normal file
147
web/components/modules/archived-modules/header.tsx
Normal 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>
|
||||
);
|
||||
});
|
4
web/components/modules/archived-modules/index.ts
Normal file
4
web/components/modules/archived-modules/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export * from "./root";
|
||||
export * from "./view";
|
||||
export * from "./header";
|
||||
export * from "./modal";
|
104
web/components/modules/archived-modules/modal.tsx
Normal file
104
web/components/modules/archived-modules/modal.tsx
Normal 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>
|
||||
);
|
||||
};
|
81
web/components/modules/archived-modules/root.tsx
Normal file
81
web/components/modules/archived-modules/root.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
64
web/components/modules/archived-modules/view.tsx
Normal file
64
web/components/modules/archived-modules/view.tsx
Normal 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>
|
||||
);
|
||||
});
|
@ -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,26 +50,30 @@ 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">
|
||||
<div className="py-2">
|
||||
<FilterOption
|
||||
isChecked={!!displayFilters.favorites}
|
||||
onClick={() =>
|
||||
handleDisplayFiltersUpdate({
|
||||
favorites: !displayFilters.favorites,
|
||||
})
|
||||
}
|
||||
title="Favorites"
|
||||
/>
|
||||
</div>
|
||||
{!isArchived && (
|
||||
<div className="py-2">
|
||||
<FilterOption
|
||||
isChecked={!!displayFilters.favorites}
|
||||
onClick={() =>
|
||||
handleDisplayFiltersUpdate({
|
||||
favorites: !displayFilters.favorites,
|
||||
})
|
||||
}
|
||||
title="Favorites"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* status */}
|
||||
<div className="py-2">
|
||||
<FilterStatus
|
||||
appliedFilters={(filters.status as TModuleStatus[]) ?? null}
|
||||
handleUpdate={(val) => handleFiltersUpdate("status", val)}
|
||||
searchQuery={filtersSearchQuery}
|
||||
/>
|
||||
</div>
|
||||
{!isArchived && (
|
||||
<div className="py-2">
|
||||
<FilterStatus
|
||||
appliedFilters={(filters.status as TModuleStatus[]) ?? null}
|
||||
handleUpdate={(val) => handleFiltersUpdate("status", val)}
|
||||
searchQuery={filtersSearchQuery}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* lead */}
|
||||
<div className="py-2">
|
||||
|
@ -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}
|
||||
/>
|
||||
|
@ -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";
|
||||
|
@ -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();
|
||||
@ -160,142 +131,112 @@ export const ModuleCardItem: React.FC<Props> = observer((props) => {
|
||||
? !moduleTotalIssues || moduleTotalIssues === 0
|
||||
? "0 Issue"
|
||||
: moduleTotalIssues === moduleDetails.completed_issues
|
||||
? `${moduleTotalIssues} Issue${moduleTotalIssues > 1 ? "s" : ""}`
|
||||
: `${moduleDetails.completed_issues}/${moduleTotalIssues} Issues`
|
||||
? `${moduleTotalIssues} Issue${moduleTotalIssues > 1 ? "s" : ""}`
|
||||
: `${moduleDetails.completed_issues}/${moduleTotalIssues} Issues`
|
||||
: "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>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Tooltip tooltipContent={moduleDetails.name} position="top" isMobile={isMobile}>
|
||||
<span className="truncate text-base font-medium">{moduleDetails.name}</span>
|
||||
</Tooltip>
|
||||
<div className="flex items-center gap-2">
|
||||
{moduleStatus && (
|
||||
<span
|
||||
className="flex h-6 w-20 items-center justify-center rounded-sm text-center text-xs"
|
||||
style={{
|
||||
color: moduleStatus.color,
|
||||
backgroundColor: `${moduleStatus.color}20`,
|
||||
}}
|
||||
>
|
||||
{moduleStatus.label}
|
||||
</span>
|
||||
)}
|
||||
<button onClick={openModuleOverview}>
|
||||
<Info className="h-4 w-4 text-custom-text-400" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5 text-custom-text-200">
|
||||
<LayersIcon className="h-4 w-4 text-custom-text-300" />
|
||||
<span className="text-xs text-custom-text-300">{issueCount ?? "0 Issue"}</span>
|
||||
</div>
|
||||
{moduleDetails.member_ids?.length > 0 && (
|
||||
<Tooltip tooltipContent={`${moduleDetails.member_ids.length} Members`} isMobile={isMobile}>
|
||||
<div className="flex cursor-default items-center gap-1">
|
||||
<AvatarGroup showTooltip={false}>
|
||||
{moduleDetails.member_ids.map((member_id) => {
|
||||
const member = getUserDetails(member_id);
|
||||
return <Avatar key={member?.id} name={member?.display_name} src={member?.avatar} />;
|
||||
})}
|
||||
</AvatarGroup>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Tooltip
|
||||
isMobile={isMobile}
|
||||
tooltipContent={isNaN(completionPercentage) ? "0" : `${completionPercentage.toFixed(0)}%`}
|
||||
position="top-left"
|
||||
>
|
||||
<div className="flex w-full items-center">
|
||||
<div
|
||||
className="bar relative h-1.5 w-full rounded bg-custom-background-90"
|
||||
<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>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Tooltip tooltipContent={moduleDetails.name} position="top" isMobile={isMobile}>
|
||||
<span className="truncate text-base font-medium">{moduleDetails.name}</span>
|
||||
</Tooltip>
|
||||
<div className="flex items-center gap-2">
|
||||
{moduleStatus && (
|
||||
<span
|
||||
className="flex h-6 w-20 items-center justify-center rounded-sm text-center text-xs"
|
||||
style={{
|
||||
boxShadow: "1px 1px 4px 0px rgba(161, 169, 191, 0.35) inset",
|
||||
color: moduleStatus.color,
|
||||
backgroundColor: `${moduleStatus.color}20`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute left-0 top-0 h-1.5 rounded bg-blue-600 duration-300"
|
||||
style={{
|
||||
width: `${isNaN(completionPercentage) ? 0 : completionPercentage.toFixed(0)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
{isDateValid ? (
|
||||
<>
|
||||
<span className="text-xs text-custom-text-300">
|
||||
{renderFormattedDate(startDate) ?? "_ _"} - {renderFormattedDate(endDate) ?? "_ _"}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-xs text-custom-text-400">No due date</span>
|
||||
{moduleStatus.label}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="z-[5] flex items-center gap-1.5">
|
||||
{isEditingAllowed &&
|
||||
(moduleDetails.is_favorite ? (
|
||||
<button type="button" onClick={handleRemoveFromFavorites}>
|
||||
<Star className="h-3.5 w-3.5 fill-current text-amber-500" />
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" onClick={handleAddToFavorites}>
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
<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>
|
||||
<button onClick={openModuleOverview}>
|
||||
<Info className="h-4 w-4 text-custom-text-400" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5 text-custom-text-200">
|
||||
<LayersIcon className="h-4 w-4 text-custom-text-300" />
|
||||
<span className="text-xs text-custom-text-300">{issueCount ?? "0 Issue"}</span>
|
||||
</div>
|
||||
{moduleDetails.member_ids?.length > 0 && (
|
||||
<Tooltip tooltipContent={`${moduleDetails.member_ids.length} Members`} isMobile={isMobile}>
|
||||
<div className="flex cursor-default items-center gap-1">
|
||||
<AvatarGroup showTooltip={false}>
|
||||
{moduleDetails.member_ids.map((member_id) => {
|
||||
const member = getUserDetails(member_id);
|
||||
return <Avatar key={member?.id} name={member?.display_name} src={member?.avatar} />;
|
||||
})}
|
||||
</AvatarGroup>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Tooltip
|
||||
isMobile={isMobile}
|
||||
tooltipContent={isNaN(completionPercentage) ? "0" : `${completionPercentage.toFixed(0)}%`}
|
||||
position="top-left"
|
||||
>
|
||||
<div className="flex w-full items-center">
|
||||
<div
|
||||
className="bar relative h-1.5 w-full rounded bg-custom-background-90"
|
||||
style={{
|
||||
boxShadow: "1px 1px 4px 0px rgba(161, 169, 191, 0.35) inset",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute left-0 top-0 h-1.5 rounded bg-blue-600 duration-300"
|
||||
style={{
|
||||
width: `${isNaN(completionPercentage) ? 0 : completionPercentage.toFixed(0)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
{isDateValid ? (
|
||||
<>
|
||||
<span className="text-xs text-custom-text-300">
|
||||
{renderFormattedDate(startDate) ?? "_ _"} - {renderFormattedDate(endDate) ?? "_ _"}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-xs text-custom-text-400">No due date</span>
|
||||
)}
|
||||
|
||||
<div className="z-[5] flex items-center gap-1.5">
|
||||
{isEditingAllowed &&
|
||||
(moduleDetails.is_favorite ? (
|
||||
<button type="button" onClick={handleRemoveFromFavorites}>
|
||||
<Star className="h-3.5 w-3.5 fill-current text-amber-500" />
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" onClick={handleAddToFavorites}>
|
||||
<Star className="h-3.5 w-3.5 text-custom-text-200" />
|
||||
</button>
|
||||
))}
|
||||
{workspaceSlug && projectId && (
|
||||
<ModuleQuickActions
|
||||
moduleId={moduleId}
|
||||
projectId={projectId.toString()}
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
});
|
||||
|
@ -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,126 +127,105 @@ 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}`}>
|
||||
<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">
|
||||
<div className="flex items-center gap-4 truncate">
|
||||
<span className="flex-shrink-0">
|
||||
<CircularProgressIndicator size={38} percentage={progress}>
|
||||
{completedModuleCheck ? (
|
||||
progress === 100 ? (
|
||||
<Check className="h-3 w-3 stroke-[2] text-custom-primary-100" />
|
||||
) : (
|
||||
<span className="text-sm text-custom-primary-100">{`!`}</span>
|
||||
)
|
||||
) : progress === 100 ? (
|
||||
<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">
|
||||
<div className="flex items-center gap-4 truncate">
|
||||
<span className="flex-shrink-0">
|
||||
<CircularProgressIndicator size={38} percentage={progress}>
|
||||
{completedModuleCheck ? (
|
||||
progress === 100 ? (
|
||||
<Check className="h-3 w-3 stroke-[2] text-custom-primary-100" />
|
||||
) : (
|
||||
<span className="text-xs text-custom-text-300">{`${progress}%`}</span>
|
||||
)}
|
||||
</CircularProgressIndicator>
|
||||
</span>
|
||||
<Tooltip tooltipContent={moduleDetails.name} position="top" isMobile={isMobile}>
|
||||
<span className="truncate text-base font-medium">{moduleDetails.name}</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<button onClick={openModuleOverview} className="z-[5] hidden flex-shrink-0 group-hover:flex">
|
||||
<Info className="h-4 w-4 text-custom-text-400" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-shrink-0 items-center justify-center">
|
||||
{moduleStatus && (
|
||||
<span
|
||||
className="flex h-6 w-20 flex-shrink-0 items-center justify-center rounded-sm text-center text-xs"
|
||||
style={{
|
||||
color: moduleStatus.color,
|
||||
backgroundColor: `${moduleStatus.color}20`,
|
||||
}}
|
||||
>
|
||||
{moduleStatus.label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative flex w-full items-center justify-between gap-2.5 overflow-hidden sm:w-auto sm:flex-shrink-0 sm:justify-end ">
|
||||
<div className="text-xs text-custom-text-300">
|
||||
{renderDate && (
|
||||
<span className=" text-xs text-custom-text-300">
|
||||
{renderFormattedDate(startDate) ?? "_ _"} - {renderFormattedDate(endDate) ?? "_ _"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="relative flex flex-shrink-0 items-center gap-3">
|
||||
<Tooltip tooltipContent={`${moduleDetails?.member_ids?.length || 0} Members`} isMobile={isMobile}>
|
||||
<div className="flex w-10 cursor-default items-center justify-center gap-1">
|
||||
{moduleDetails.member_ids.length > 0 ? (
|
||||
<AvatarGroup showTooltip={false}>
|
||||
{moduleDetails.member_ids.map((member_id) => {
|
||||
const member = getUserDetails(member_id);
|
||||
return <Avatar key={member?.id} name={member?.display_name} src={member?.avatar} />;
|
||||
})}
|
||||
</AvatarGroup>
|
||||
<span className="text-sm text-custom-primary-100">{`!`}</span>
|
||||
)
|
||||
) : progress === 100 ? (
|
||||
<Check className="h-3 w-3 stroke-[2] text-custom-primary-100" />
|
||||
) : (
|
||||
<span className="flex h-5 w-5 items-end justify-center rounded-full border border-dashed border-custom-text-400 bg-custom-background-80">
|
||||
<User2 className="h-4 w-4 text-custom-text-400" />
|
||||
</span>
|
||||
<span className="text-xs text-custom-text-300">{`${progress}%`}</span>
|
||||
)}
|
||||
</div>
|
||||
</CircularProgressIndicator>
|
||||
</span>
|
||||
<Tooltip tooltipContent={moduleDetails.name} position="top" isMobile={isMobile}>
|
||||
<span className="truncate text-base font-medium">{moduleDetails.name}</span>
|
||||
</Tooltip>
|
||||
|
||||
{isEditingAllowed &&
|
||||
(moduleDetails.is_favorite ? (
|
||||
<button type="button" onClick={handleRemoveFromFavorites} className="z-[1]">
|
||||
<Star className="h-3.5 w-3.5 fill-current text-amber-500" />
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" onClick={handleAddToFavorites} className="z-[1]">
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
<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>
|
||||
<button onClick={openModuleOverview} className="z-[5] hidden flex-shrink-0 group-hover:flex">
|
||||
<Info className="h-4 w-4 text-custom-text-400" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-shrink-0 items-center justify-center">
|
||||
{moduleStatus && (
|
||||
<span
|
||||
className="flex h-6 w-20 flex-shrink-0 items-center justify-center rounded-sm text-center text-xs"
|
||||
style={{
|
||||
color: moduleStatus.color,
|
||||
backgroundColor: `${moduleStatus.color}20`,
|
||||
}}
|
||||
>
|
||||
{moduleStatus.label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</>
|
||||
|
||||
<div className="relative flex w-full items-center justify-between gap-2.5 overflow-hidden sm:w-auto sm:flex-shrink-0 sm:justify-end ">
|
||||
<div className="text-xs text-custom-text-300">
|
||||
{renderDate && (
|
||||
<span className=" text-xs text-custom-text-300">
|
||||
{renderFormattedDate(startDate) ?? "_ _"} - {renderFormattedDate(endDate) ?? "_ _"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="relative flex flex-shrink-0 items-center gap-3">
|
||||
<Tooltip tooltipContent={`${moduleDetails?.member_ids?.length || 0} Members`} isMobile={isMobile}>
|
||||
<div className="flex w-10 cursor-default items-center justify-center gap-1">
|
||||
{moduleDetails.member_ids.length > 0 ? (
|
||||
<AvatarGroup showTooltip={false}>
|
||||
{moduleDetails.member_ids.map((member_id) => {
|
||||
const member = getUserDetails(member_id);
|
||||
return <Avatar key={member?.id} name={member?.display_name} src={member?.avatar} />;
|
||||
})}
|
||||
</AvatarGroup>
|
||||
) : (
|
||||
<span className="flex h-5 w-5 items-end justify-center rounded-full border border-dashed border-custom-text-400 bg-custom-background-80">
|
||||
<User2 className="h-4 w-4 text-custom-text-400" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</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" />
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" onClick={handleAddToFavorites} className="z-[1]">
|
||||
<Star className="h-3.5 w-3.5 text-custom-text-300" />
|
||||
</button>
|
||||
))}
|
||||
{workspaceSlug && projectId && (
|
||||
<ModuleQuickActions
|
||||
moduleId={moduleId}
|
||||
projectId={projectId.toString()}
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
isArchived={isArchived}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
});
|
||||
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
|
@ -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">
|
||||
|
179
web/components/modules/quick-actions.tsx
Normal file
179
web/components/modules/quick-actions.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
});
|
@ -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">
|
||||
<button onClick={handleCopyText}>
|
||||
<LinkIcon className="h-3 w-3 text-custom-text-300" />
|
||||
</button>
|
||||
{!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,13 +659,15 @@ 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>
|
||||
<button
|
||||
className="flex items-center gap-1.5 text-sm font-medium text-custom-primary-100"
|
||||
onClick={() => setModuleLinkModal(true)}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
Add link
|
||||
</button>
|
||||
{isEditingAllowed && !isArchived && (
|
||||
<button
|
||||
className="flex items-center gap-1.5 text-sm font-medium text-custom-primary-100"
|
||||
onClick={() => setModuleLinkModal(true)}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
Add link
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
@ -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"
|
||||
}`}
|
||||
@ -184,12 +184,12 @@ export const NotificationCard: React.FC<NotificationCardProps> = (props) => {
|
||||
{notificationField === "comment"
|
||||
? "commented"
|
||||
: notificationField === "archived_at"
|
||||
? notification.data.issue_activity.new_value === "restore"
|
||||
? "restored the issue"
|
||||
: "archived the issue"
|
||||
: notificationField === "None"
|
||||
? null
|
||||
: replaceUnderscoreIfSnakeCase(notificationField)}{" "}
|
||||
? notification.data.issue_activity.new_value === "restore"
|
||||
? "restored the issue"
|
||||
: "archived the issue"
|
||||
: notificationField === "None"
|
||||
? null
|
||||
: replaceUnderscoreIfSnakeCase(notificationField)}{" "}
|
||||
{!["comment", "archived_at", "None"].includes(notificationField) ? "to" : ""}
|
||||
<span className="font-semibold">
|
||||
{" "}
|
||||
|
@ -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">
|
||||
|
@ -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">
|
||||
|
@ -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
50
web/constants/archives.ts
Normal 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,
|
||||
},
|
||||
};
|
@ -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,
|
||||
|
@ -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;
|
@ -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>
|
@ -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} />
|
||||
<ArchivedIssueLayoutRoot />
|
||||
<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>
|
||||
);
|
@ -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;
|
@ -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>
|
||||
</>
|
||||
|
BIN
web/public/empty-state/archived/empty-cycles-dark.webp
Normal file
BIN
web/public/empty-state/archived/empty-cycles-dark.webp
Normal file
Binary file not shown.
After Width: | Height: | Size: 47 KiB |
BIN
web/public/empty-state/archived/empty-cycles-light.webp
Normal file
BIN
web/public/empty-state/archived/empty-cycles-light.webp
Normal file
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
Loading…
Reference in New Issue
Block a user