Compare commits

..
Author SHA1 Message Date
pablohashescobar 2c2e73e93c dev: create notification preference model 2023-10-23 12:09:31 +05:30
489 changed files with 22170 additions and 18497 deletions
-17
View File
@@ -1,17 +0,0 @@
version = 1
[[analyzers]]
name = "shell"
[[analyzers]]
name = "javascript"
[analyzers.meta]
plugins = ["react"]
environment = ["nodejs"]
[[analyzers]]
name = "python"
[analyzers.meta]
runtime_version = "3.x.x"
+2 -3
View File
@@ -16,8 +16,7 @@ node_modules
# Production
/build
dist/
out/
dist
# Misc
.DS_Store
@@ -75,7 +74,7 @@ pnpm-lock.yaml
pnpm-workspace.yaml
.npmrc
tmp/
## packages
dist
+6 -12
View File
@@ -75,13 +75,13 @@ class IssueCreateSerializer(BaseSerializer):
project_detail = ProjectLiteSerializer(read_only=True, source="project")
workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace")
assignees = serializers.ListField(
assignees_list = serializers.ListField(
child=serializers.PrimaryKeyRelatedField(queryset=User.objects.all()),
write_only=True,
required=False,
)
labels = serializers.ListField(
labels_list = serializers.ListField(
child=serializers.PrimaryKeyRelatedField(queryset=Label.objects.all()),
write_only=True,
required=False,
@@ -99,12 +99,6 @@ class IssueCreateSerializer(BaseSerializer):
"updated_at",
]
def to_representation(self, instance):
data = super().to_representation(instance)
data['assignees'] = [str(assignee.id) for assignee in instance.assignees.all()]
data['labels'] = [str(label.id) for label in instance.labels.all()]
return data
def validate(self, data):
if (
data.get("start_date", None) is not None
@@ -115,8 +109,8 @@ class IssueCreateSerializer(BaseSerializer):
return data
def create(self, validated_data):
assignees = validated_data.pop("assignees", None)
labels = validated_data.pop("labels", None)
assignees = validated_data.pop("assignees_list", None)
labels = validated_data.pop("labels_list", None)
project_id = self.context["project_id"]
workspace_id = self.context["workspace_id"]
@@ -174,8 +168,8 @@ class IssueCreateSerializer(BaseSerializer):
return issue
def update(self, instance, validated_data):
assignees = validated_data.pop("assignees", None)
labels = validated_data.pop("labels", None)
assignees = validated_data.pop("assignees_list", None)
labels = validated_data.pop("labels_list", None)
# Related models
project_id = instance.project_id
+3 -8
View File
@@ -19,7 +19,7 @@ from plane.db.models import (
class ModuleWriteSerializer(BaseSerializer):
members = serializers.ListField(
members_list = serializers.ListField(
child=serializers.PrimaryKeyRelatedField(queryset=User.objects.all()),
write_only=True,
required=False,
@@ -39,11 +39,6 @@ class ModuleWriteSerializer(BaseSerializer):
"created_at",
"updated_at",
]
def to_representation(self, instance):
data = super().to_representation(instance)
data['members'] = [str(member.id) for member in instance.members.all()]
return data
def validate(self, data):
if data.get("start_date", None) is not None and data.get("target_date", None) is not None and data.get("start_date", None) > data.get("target_date", None):
@@ -51,7 +46,7 @@ class ModuleWriteSerializer(BaseSerializer):
return data
def create(self, validated_data):
members = validated_data.pop("members", None)
members = validated_data.pop("members_list", None)
project = self.context["project"]
@@ -77,7 +72,7 @@ class ModuleWriteSerializer(BaseSerializer):
return module
def update(self, instance, validated_data):
members = validated_data.pop("members", None)
members = validated_data.pop("members_list", None)
if members is not None:
ModuleMember.objects.filter(module=instance).delete()
+3 -7
View File
@@ -33,7 +33,7 @@ class PageBlockLiteSerializer(BaseSerializer):
class PageSerializer(BaseSerializer):
is_favorite = serializers.BooleanField(read_only=True)
label_details = LabelLiteSerializer(read_only=True, source="labels", many=True)
labels = serializers.ListField(
labels_list = serializers.ListField(
child=serializers.PrimaryKeyRelatedField(queryset=Label.objects.all()),
write_only=True,
required=False,
@@ -50,13 +50,9 @@ class PageSerializer(BaseSerializer):
"project",
"owned_by",
]
def to_representation(self, instance):
data = super().to_representation(instance)
data['labels'] = [str(label.id) for label in instance.labels.all()]
return data
def create(self, validated_data):
labels = validated_data.pop("labels", None)
labels = validated_data.pop("labels_list", None)
project_id = self.context["project_id"]
owned_by_id = self.context["owned_by_id"]
page = Page.objects.create(
@@ -81,7 +77,7 @@ class PageSerializer(BaseSerializer):
return page
def update(self, instance, validated_data):
labels = validated_data.pop("labels", None)
labels = validated_data.pop("labels_list", None)
if labels is not None:
PageLabel.objects.filter(page=instance).delete()
PageLabel.objects.bulk_create(
+4 -4
View File
@@ -79,14 +79,14 @@ class UserMeSettingsSerializer(BaseSerializer):
email=obj.email
).count()
if obj.last_workspace_id is not None:
workspace = Workspace.objects.filter(
workspace = Workspace.objects.get(
pk=obj.last_workspace_id, workspace_member__member=obj.id
).first()
)
return {
"last_workspace_id": obj.last_workspace_id,
"last_workspace_slug": workspace.slug if workspace is not None else "",
"last_workspace_slug": workspace.slug,
"fallback_workspace_id": obj.last_workspace_id,
"fallback_workspace_slug": workspace.slug if workspace is not None else "",
"fallback_workspace_slug": workspace.slug,
"invites": workspace_invites,
}
else:
+22 -5
View File
@@ -17,7 +17,7 @@ from plane.api.views import (
IssueSubscriberViewSet,
IssueReactionViewSet,
CommentReactionViewSet,
IssueUserDisplayPropertyEndpoint,
IssuePropertyViewSet,
IssueArchiveViewSet,
IssueRelationViewSet,
IssueDraftViewSet,
@@ -235,11 +235,28 @@ urlpatterns = [
## End Comment Reactions
## IssueProperty
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issue-display-properties/",
IssueUserDisplayPropertyEndpoint.as_view(),
name="project-issue-display-properties",
"workspaces/<str:slug>/projects/<uuid:project_id>/issue-properties/",
IssuePropertyViewSet.as_view(
{
"get": "list",
"post": "create",
}
),
name="project-issue-roadmap",
),
## IssueProperty End
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issue-properties/<uuid:pk>/",
IssuePropertyViewSet.as_view(
{
"get": "retrieve",
"put": "update",
"patch": "partial_update",
"delete": "destroy",
}
),
name="project-issue-roadmap",
),
## IssueProperty Ebd
## Issue Archives
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/archived-issues/",
+14 -2
View File
@@ -4,15 +4,17 @@ from plane.api.views import (
ProjectViewSet,
InviteProjectEndpoint,
ProjectMemberViewSet,
ProjectMemberEndpoint,
ProjectMemberInvitationsViewset,
ProjectMemberUserEndpoint,
AddMemberToProjectEndpoint,
ProjectJoinEndpoint,
AddTeamToProjectEndpoint,
ProjectUserViewsEndpoint,
ProjectIdentifierEndpoint,
ProjectFavoritesViewSet,
LeaveProjectEndpoint,
ProjectPublicCoverImagesEndpoint,
ProjectPublicCoverImagesEndpoint
)
@@ -51,7 +53,7 @@ urlpatterns = [
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/members/",
ProjectMemberViewSet.as_view({"get": "list", "post": "create"}),
ProjectMemberViewSet.as_view({"get": "list"}),
name="project-member",
),
path(
@@ -65,6 +67,16 @@ urlpatterns = [
),
name="project-member",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/project-members/",
ProjectMemberEndpoint.as_view(),
name="project-member",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/members/add/",
AddMemberToProjectEndpoint.as_view(),
name="project",
),
path(
"workspaces/<str:slug>/projects/join/",
ProjectJoinEndpoint.as_view(),
+6
View File
@@ -5,6 +5,7 @@ from plane.api.views import (
WorkSpaceViewSet,
InviteWorkspaceEndpoint,
WorkSpaceMemberViewSet,
WorkspaceMembersEndpoint,
WorkspaceInvitationsViewset,
WorkspaceMemberUserEndpoint,
WorkspaceMemberUserViewsEndpoint,
@@ -85,6 +86,11 @@ urlpatterns = [
),
name="workspace-member",
),
path(
"workspaces/<str:slug>/workspace-members/",
WorkspaceMembersEndpoint.as_view(),
name="workspace-members",
),
path(
"workspaces/<str:slug>/teams/",
TeamMemberViewSet.as_view(
+21 -4
View File
@@ -82,7 +82,7 @@ from plane.api.views import (
BulkDeleteIssuesEndpoint,
BulkImportIssuesEndpoint,
ProjectUserViewsEndpoint,
IssueUserDisplayPropertyEndpoint,
IssuePropertyViewSet,
LabelViewSet,
SubIssuesEndpoint,
IssueLinkViewSet,
@@ -1008,9 +1008,26 @@ urlpatterns = [
## End Comment Reactions
## IssueProperty
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issue-display-properties/",
IssueUserDisplayPropertyEndpoint.as_view(),
name="project-issue-display-properties",
"workspaces/<str:slug>/projects/<uuid:project_id>/issue-properties/",
IssuePropertyViewSet.as_view(
{
"get": "list",
"post": "create",
}
),
name="project-issue-roadmap",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issue-properties/<uuid:pk>/",
IssuePropertyViewSet.as_view(
{
"get": "retrieve",
"put": "update",
"patch": "partial_update",
"delete": "destroy",
}
),
name="project-issue-roadmap",
),
## IssueProperty Ebd
## Issue Archives
+4 -1
View File
@@ -7,12 +7,14 @@ from .project import (
ProjectMemberInvitationsViewset,
ProjectMemberInviteDetailViewSet,
ProjectIdentifierEndpoint,
AddMemberToProjectEndpoint,
ProjectJoinEndpoint,
ProjectUserViewsEndpoint,
ProjectMemberUserEndpoint,
ProjectFavoritesViewSet,
ProjectDeployBoardViewSet,
ProjectDeployBoardPublicSettingsEndpoint,
ProjectMemberEndpoint,
WorkspaceProjectDeployBoardEndpoint,
LeaveProjectEndpoint,
ProjectPublicCoverImagesEndpoint,
@@ -51,6 +53,7 @@ from .workspace import (
WorkspaceUserProfileEndpoint,
WorkspaceUserProfileIssuesEndpoint,
WorkspaceLabelsEndpoint,
WorkspaceMembersEndpoint,
LeaveWorkspaceEndpoint,
)
from .state import StateViewSet
@@ -68,7 +71,7 @@ from .issue import (
WorkSpaceIssuesEndpoint,
IssueActivityEndpoint,
IssueCommentViewSet,
IssueUserDisplayPropertyEndpoint,
IssuePropertyViewSet,
LabelViewSet,
BulkDeleteIssuesEndpoint,
UserWorkSpaceIssues,
+3 -3
View File
@@ -249,11 +249,11 @@ class MagicSignInGenerateEndpoint(BaseAPIView):
## Generate a random token
token = (
"".join(random.choices(string.ascii_lowercase, k=4))
"".join(random.choices(string.ascii_lowercase + string.digits, k=4))
+ "-"
+ "".join(random.choices(string.ascii_lowercase, k=4))
+ "".join(random.choices(string.ascii_lowercase + string.digits, k=4))
+ "-"
+ "".join(random.choices(string.ascii_lowercase, k=4))
+ "".join(random.choices(string.ascii_lowercase + string.digits, k=4))
)
ri = redis_instance()
-2
View File
@@ -84,7 +84,6 @@ class BaseViewSet(TimezoneMixin, ModelViewSet, BasePaginator):
capture_exception(e)
return Response({"error": f"key {e} does not exist"}, status=status.HTTP_400_BAD_REQUEST)
print(e) if settings.DEBUG else print("Server Error")
capture_exception(e)
return Response({"error": "Something went wrong please try again later"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
@@ -162,7 +161,6 @@ class BaseAPIView(TimezoneMixin, APIView, BasePaginator):
if isinstance(e, KeyError):
return Response({"error": f"key {e} does not exist"}, status=status.HTTP_400_BAD_REQUEST)
print(e) if settings.DEBUG else print("Server Error")
capture_exception(e)
return Response({"error": "Something went wrong please try again later"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
+3 -3
View File
@@ -588,14 +588,14 @@ class CycleIssueViewSet(BaseViewSet):
)
if group_by:
grouped_results = group_results(issues_data, group_by, sub_group_by)
return Response(
grouped_results,
group_results(issues_data, group_by, sub_group_by),
status=status.HTTP_200_OK,
)
return Response(
issues_data, status=status.HTTP_200_OK
issues_data,
status=status.HTTP_200_OK,
)
def create(self, request, slug, project_id, cycle_id):
-4
View File
@@ -39,7 +39,6 @@ from plane.utils.integrations.github import get_github_repo_details
from plane.utils.importers.jira import jira_project_issue_summary
from plane.bgtasks.importer_task import service_importer
from plane.utils.html_processor import strip_tags
from plane.api.permissions import WorkSpaceAdminPermission
class ServiceIssueImportSummaryEndpoint(BaseAPIView):
@@ -120,9 +119,6 @@ class ServiceIssueImportSummaryEndpoint(BaseAPIView):
class ImportServiceEndpoint(BaseAPIView):
permission_classes = [
WorkSpaceAdminPermission,
]
def post(self, request, slug, service):
project_id = request.data.get("project_id", False)
+48 -34
View File
@@ -130,7 +130,7 @@ class IssueViewSet(BaseViewSet):
queryset=IssueReaction.objects.select_related("actor"),
)
)
).distinct()
)
@method_decorator(gzip_page)
def list(self, request, slug, project_id):
@@ -229,16 +229,12 @@ class IssueViewSet(BaseViewSet):
)
if group_by:
grouped_results = group_results(issues, group_by, sub_group_by)
return Response(
grouped_results,
group_results(issues, group_by, sub_group_by),
status=status.HTTP_200_OK,
)
return Response(
issues, status=status.HTTP_200_OK
)
return Response(issues, status=status.HTTP_200_OK)
def create(self, request, slug, project_id):
project = Project.objects.get(pk=project_id)
@@ -437,15 +433,12 @@ class UserWorkSpaceIssues(BaseAPIView):
)
if group_by:
grouped_results = group_results(issues, group_by, sub_group_by)
return Response(
grouped_results,
group_results(issues, group_by, sub_group_by),
status=status.HTTP_200_OK,
)
return Response(
issues, status=status.HTTP_200_OK
)
return Response(issues, status=status.HTTP_200_OK)
class WorkSpaceIssuesEndpoint(BaseAPIView):
@@ -604,12 +597,41 @@ class IssueCommentViewSet(BaseViewSet):
return Response(status=status.HTTP_204_NO_CONTENT)
class IssueUserDisplayPropertyEndpoint(BaseAPIView):
class IssuePropertyViewSet(BaseViewSet):
serializer_class = IssuePropertySerializer
model = IssueProperty
permission_classes = [
ProjectLitePermission,
ProjectEntityPermission,
]
def post(self, request, slug, project_id):
filterset_fields = []
def perform_create(self, serializer):
serializer.save(
project_id=self.kwargs.get("project_id"), user=self.request.user
)
def get_queryset(self):
return self.filter_queryset(
super()
.get_queryset()
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id"))
.filter(user=self.request.user)
.filter(project__project_projectmember__member=self.request.user)
.select_related("project")
.select_related("workspace")
)
def list(self, request, slug, project_id):
queryset = self.get_queryset()
serializer = IssuePropertySerializer(queryset, many=True)
return Response(
serializer.data[0] if len(serializer.data) > 0 else [],
status=status.HTTP_200_OK,
)
def create(self, request, slug, project_id):
issue_property, created = IssueProperty.objects.get_or_create(
user=request.user,
project_id=project_id,
@@ -618,20 +640,16 @@ class IssueUserDisplayPropertyEndpoint(BaseAPIView):
if not created:
issue_property.properties = request.data.get("properties", {})
issue_property.save()
serializer = IssuePropertySerializer(issue_property)
return Response(serializer.data, status=status.HTTP_200_OK)
issue_property.properties = request.data.get("properties", {})
issue_property.save()
serializer = IssuePropertySerializer(issue_property)
return Response(serializer.data, status=status.HTTP_201_CREATED)
def get(self, request, slug, project_id):
issue_property, _ = IssueProperty.objects.get_or_create(
user=request.user, project_id=project_id
)
serializer = IssuePropertySerializer(issue_property)
return Response(serializer.data, status=status.HTTP_200_OK)
class LabelViewSet(BaseViewSet):
serializer_class = LabelSerializer
model = Label
@@ -945,8 +963,8 @@ class IssueAttachmentEndpoint(BaseAPIView):
issue_attachments = IssueAttachment.objects.filter(
issue_id=issue_id, workspace__slug=slug, project_id=project_id
)
serializer = IssueAttachmentSerializer(issue_attachments, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
serilaizer = IssueAttachmentSerializer(issue_attachments, many=True)
return Response(serilaizer.data, status=status.HTTP_200_OK)
class IssueArchiveViewSet(BaseViewSet):
@@ -1147,7 +1165,9 @@ class IssueSubscriberViewSet(BaseViewSet):
def list(self, request, slug, project_id, issue_id):
members = (
ProjectMember.objects.filter(workspace__slug=slug, project_id=project_id)
ProjectMember.objects.filter(
workspace__slug=slug, project_id=project_id
)
.annotate(
is_subscribed=Exists(
IssueSubscriber.objects.filter(
@@ -2154,15 +2174,9 @@ class IssueDraftViewSet(BaseViewSet):
## Grouping the results
group_by = request.GET.get("group_by", False)
if group_by:
grouped_results = group_results(issues, group_by)
return Response(
grouped_results,
status=status.HTTP_200_OK,
)
return Response(group_results(issues, group_by), status=status.HTTP_200_OK)
return Response(
issues, status=status.HTTP_200_OK
)
return Response(issues, status=status.HTTP_200_OK)
def create(self, request, slug, project_id):
project = Project.objects.get(pk=project_id)
+4 -6
View File
@@ -149,9 +149,6 @@ class ModuleViewSet(BaseViewSet):
if serializer.is_valid():
serializer.save()
module = Module.objects.get(pk=serializer.data["id"])
serializer = ModuleSerializer(module)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@@ -364,6 +361,7 @@ class ModuleIssueViewSet(BaseViewSet):
.values("count")
)
)
issues_data = IssueStateSerializer(issues, many=True).data
if sub_group_by and sub_group_by == group_by:
@@ -373,14 +371,14 @@ class ModuleIssueViewSet(BaseViewSet):
)
if group_by:
grouped_results = group_results(issues_data, group_by, sub_group_by)
return Response(
grouped_results,
group_results(issues_data, group_by, sub_group_by),
status=status.HTTP_200_OK,
)
return Response(
issues_data, status=status.HTTP_200_OK
issues_data,
status=status.HTTP_200_OK,
)
def create(self, request, slug, project_id, module_id):
+74 -133
View File
@@ -69,7 +69,6 @@ from plane.db.models import (
ModuleMember,
Inbox,
ProjectDeployBoard,
IssueProperty,
)
from plane.bgtasks.project_invitation_task import project_invitation
@@ -202,11 +201,6 @@ class ProjectViewSet(BaseViewSet):
project_member = ProjectMember.objects.create(
project_id=serializer.data["id"], member=request.user, role=20
)
# Also create the issue property for the user
_ = IssueProperty.objects.create(
project_id=serializer.data["id"],
user=request.user,
)
if serializer.data["project_lead"] is not None and str(
serializer.data["project_lead"]
@@ -216,11 +210,6 @@ class ProjectViewSet(BaseViewSet):
member_id=serializer.data["project_lead"],
role=20,
)
# Also create the issue property for the user
IssueProperty.objects.create(
project_id=serializer.data["id"],
user_id=serializer.data["project_lead"],
)
# Default states
states = [
@@ -273,9 +262,12 @@ class ProjectViewSet(BaseViewSet):
]
)
project = self.get_queryset().filter(pk=serializer.data["id"]).first()
serializer = ProjectListSerializer(project)
return Response(serializer.data, status=status.HTTP_201_CREATED)
data = serializer.data
# Additional fields of the member
data["sort_order"] = project_member.sort_order
data["member_role"] = project_member.role
data["is_member"] = True
return Response(data, status=status.HTTP_201_CREATED)
return Response(
serializer.errors,
status=status.HTTP_400_BAD_REQUEST,
@@ -325,8 +317,6 @@ class ProjectViewSet(BaseViewSet):
color="#ff7700",
)
project = self.get_queryset().filter(pk=serializer.data["id"]).first()
serializer = ProjectListSerializer(project)
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@@ -403,8 +393,6 @@ class InviteProjectEndpoint(BaseAPIView):
member=user, project_id=project_id, role=role
)
_ = IssueProperty.objects.create(user=user, project_id=project_id)
return Response(
ProjectMemberSerializer(project_member).data, status=status.HTTP_200_OK
)
@@ -440,18 +428,6 @@ class UserProjectInvitationsViewset(BaseViewSet):
]
)
IssueProperty.objects.bulk_create(
[
ProjectMember(
project=invitation.project,
workspace=invitation.project.workspace,
user=request.user,
created_by=request.user,
)
for invitation in project_invitations
]
)
# Delete joined project invites
project_invitations.delete()
@@ -482,83 +458,6 @@ class ProjectMemberViewSet(BaseViewSet):
.select_related("workspace", "workspace__owner")
)
def create(self, request, slug, project_id):
members = request.data.get("members", [])
# get the project
project = Project.objects.get(pk=project_id, workspace__slug=slug)
if not len(members):
return Response(
{"error": "Atleast one member is required"},
status=status.HTTP_400_BAD_REQUEST,
)
bulk_project_members = []
bulk_issue_props = []
project_members = (
ProjectMember.objects.filter(
workspace__slug=slug,
member_id__in=[member.get("member_id") for member in members],
)
.values("member_id", "sort_order")
.order_by("sort_order")
)
for member in members:
sort_order = [
project_member.get("sort_order")
for project_member in project_members
if str(project_member.get("member_id")) == str(member.get("member_id"))
]
bulk_project_members.append(
ProjectMember(
member_id=member.get("member_id"),
role=member.get("role", 10),
project_id=project_id,
workspace_id=project.workspace_id,
sort_order=sort_order[0] - 10000 if len(sort_order) else 65535,
)
)
bulk_issue_props.append(
IssueProperty(
user_id=member.get("member_id"),
project_id=project_id,
workspace_id=project.workspace_id,
)
)
project_members = ProjectMember.objects.bulk_create(
bulk_project_members,
batch_size=10,
ignore_conflicts=True,
)
_ = IssueProperty.objects.bulk_create(
bulk_issue_props, batch_size=10, ignore_conflicts=True
)
serializer = ProjectMemberSerializer(project_members, many=True)
return Response(serializer.data, status=status.HTTP_201_CREATED)
def list(self, request, slug, project_id):
project_member = ProjectMember.objects.get(
member=request.user, workspace__slug=slug, project_id=project_id
)
project_members = ProjectMember.objects.filter(
project_id=project_id,
workspace__slug=slug,
member__is_bot=False,
).select_related("project", "member", "workspace")
if project_member.role > 10:
serializer = ProjectMemberAdminSerializer(project_members, many=True)
else:
serializer = ProjectMemberSerializer(project_members, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
def partial_update(self, request, slug, project_id, pk):
project_member = ProjectMember.objects.get(
pk=pk, workspace__slug=slug, project_id=project_id
@@ -644,6 +543,59 @@ class ProjectMemberViewSet(BaseViewSet):
return Response(status=status.HTTP_204_NO_CONTENT)
class AddMemberToProjectEndpoint(BaseAPIView):
permission_classes = [
ProjectBasePermission,
]
def post(self, request, slug, project_id):
members = request.data.get("members", [])
# get the project
project = Project.objects.get(pk=project_id, workspace__slug=slug)
if not len(members):
return Response(
{"error": "Atleast one member is required"},
status=status.HTTP_400_BAD_REQUEST,
)
bulk_project_members = []
project_members = (
ProjectMember.objects.filter(
workspace__slug=slug,
member_id__in=[member.get("member_id") for member in members],
)
.values("member_id", "sort_order")
.order_by("sort_order")
)
for member in members:
sort_order = [
project_member.get("sort_order")
for project_member in project_members
if str(project_member.get("member_id")) == str(member.get("member_id"))
]
bulk_project_members.append(
ProjectMember(
member_id=member.get("member_id"),
role=member.get("role", 10),
project_id=project_id,
workspace_id=project.workspace_id,
sort_order=sort_order[0] - 10000 if len(sort_order) else 65535,
)
)
project_members = ProjectMember.objects.bulk_create(
bulk_project_members,
batch_size=10,
ignore_conflicts=True,
)
serializer = ProjectMemberSerializer(project_members, many=True)
return Response(serializer.data, status=status.HTTP_201_CREATED)
class AddTeamToProjectEndpoint(BaseAPIView):
permission_classes = [
ProjectBasePermission,
@@ -662,7 +614,6 @@ class AddTeamToProjectEndpoint(BaseAPIView):
workspace = Workspace.objects.get(slug=slug)
project_members = []
issue_props = []
for member in team_members:
project_members.append(
ProjectMember(
@@ -672,23 +623,11 @@ class AddTeamToProjectEndpoint(BaseAPIView):
created_by=request.user,
)
)
issue_props.append(
IssueProperty(
project_id=project_id,
user_id=member,
workspace=workspace,
created_by=request.user,
)
)
ProjectMember.objects.bulk_create(
project_members, batch_size=10, ignore_conflicts=True
)
_ = IssueProperty.objects.bulk_create(
issue_props, batch_size=10, ignore_conflicts=True
)
serializer = ProjectMemberSerializer(project_members, many=True)
return Response(serializer.data, status=status.HTTP_201_CREATED)
@@ -804,19 +743,6 @@ class ProjectJoinEndpoint(BaseAPIView):
ignore_conflicts=True,
)
IssueProperty.objects.bulk_create(
[
IssueProperty(
project_id=project_id,
user=request.user,
workspace=workspace,
created_by=request.user,
)
for project_id in project_ids
],
ignore_conflicts=True,
)
return Response(
{"message": "Projects joined successfully"},
status=status.HTTP_201_CREATED,
@@ -943,6 +869,21 @@ class ProjectDeployBoardViewSet(BaseViewSet):
return Response(serializer.data, status=status.HTTP_200_OK)
class ProjectMemberEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
]
def get(self, request, slug, project_id):
project_members = ProjectMember.objects.filter(
project_id=project_id,
workspace__slug=slug,
member__is_bot=False,
).select_related("project", "member", "workspace")
serializer = ProjectMemberSerializer(project_members, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
class ProjectDeployBoardPublicSettingsEndpoint(BaseAPIView):
permission_classes = [
AllowAny,
+10 -6
View File
@@ -93,6 +93,7 @@ class GlobalViewIssuesViewSet(BaseViewSet):
)
)
@method_decorator(gzip_page)
def list(self, request, slug):
filters = issue_filters(request.query_params, "GET")
@@ -116,7 +117,9 @@ class GlobalViewIssuesViewSet(BaseViewSet):
.values("count")
)
.annotate(
attachment_count=IssueAttachment.objects.filter(issue=OuterRef("id"))
attachment_count=IssueAttachment.objects.filter(
issue=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
@@ -126,7 +129,9 @@ class GlobalViewIssuesViewSet(BaseViewSet):
# Priority Ordering
if order_by_param == "priority" or order_by_param == "-priority":
priority_order = (
priority_order if order_by_param == "priority" else priority_order[::-1]
priority_order
if order_by_param == "priority"
else priority_order[::-1]
)
issue_queryset = issue_queryset.annotate(
priority_order=Case(
@@ -178,6 +183,7 @@ class GlobalViewIssuesViewSet(BaseViewSet):
)
else:
issue_queryset = issue_queryset.order_by(order_by_param)
issues = IssueLiteSerializer(issue_queryset, many=True).data
## Grouping the results
@@ -188,12 +194,10 @@ class GlobalViewIssuesViewSet(BaseViewSet):
{"error": "Group by and sub group by cannot be same"},
status=status.HTTP_400_BAD_REQUEST,
)
if group_by:
grouped_results = group_results(issues, group_by, sub_group_by)
return Response(
grouped_results,
status=status.HTTP_200_OK,
group_results(issues, group_by, sub_group_by), status=status.HTTP_200_OK
)
return Response(issues, status=status.HTTP_200_OK)
+17 -28
View File
@@ -472,7 +472,7 @@ class WorkSpaceMemberViewSet(BaseViewSet):
model = WorkspaceMember
permission_classes = [
WorkspaceEntityPermission,
WorkSpaceAdminPermission,
]
search_fields = [
@@ -489,25 +489,6 @@ class WorkSpaceMemberViewSet(BaseViewSet):
.select_related("member")
)
def list(self, request, slug):
workspace_member = WorkspaceMember.objects.get(
member=request.user, workspace__slug=slug
)
workspace_members = WorkspaceMember.objects.filter(
workspace__slug=slug,
member__is_bot=False,
).select_related("workspace", "member")
if workspace_member.role > 10:
serializer = WorkspaceMemberAdminSerializer(workspace_members, many=True)
else:
serializer = WorkSpaceMemberSerializer(
workspace_members,
many=True,
)
return Response(serializer.data, status=status.HTTP_200_OK)
def partial_update(self, request, slug, pk):
workspace_member = WorkspaceMember.objects.get(pk=pk, workspace__slug=slug)
if request.user.id == workspace_member.member_id:
@@ -1247,15 +1228,9 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView):
## Grouping the results
group_by = request.GET.get("group_by", False)
if group_by:
grouped_results = group_results(issues, group_by)
return Response(
grouped_results,
status=status.HTTP_200_OK,
)
return Response(group_results(issues, group_by), status=status.HTTP_200_OK)
return Response(
issues, status=status.HTTP_200_OK
)
return Response(issues, status=status.HTTP_200_OK)
class WorkspaceLabelsEndpoint(BaseAPIView):
@@ -1271,6 +1246,20 @@ class WorkspaceLabelsEndpoint(BaseAPIView):
return Response(labels, status=status.HTTP_200_OK)
class WorkspaceMembersEndpoint(BaseAPIView):
permission_classes = [
WorkspaceEntityPermission,
]
def get(self, request, slug):
workspace_members = WorkspaceMember.objects.filter(
workspace__slug=slug,
member__is_bot=False,
).select_related("workspace", "member")
serialzier = WorkSpaceMemberSerializer(workspace_members, many=True)
return Response(serialzier.data, status=status.HTTP_200_OK)
class LeaveWorkspaceEndpoint(BaseAPIView):
permission_classes = [
WorkspaceEntityPermission,
-15
View File
@@ -25,7 +25,6 @@ from plane.db.models import (
WorkspaceIntegration,
Label,
User,
IssueProperty,
)
from .workspace_invitation_task import workspace_invitation
from plane.bgtasks.user_welcome_task import send_welcome_slack
@@ -104,20 +103,6 @@ def service_importer(service, importer_id):
ignore_conflicts=True,
)
IssueProperty.objects.bulk_create(
[
IssueProperty(
project_id=importer.project_id,
workspace_id=importer.workspace_id,
user=user,
created_by=importer.created_by,
)
for user in workspace_users
],
batch_size=100,
ignore_conflicts=True,
)
# Check if sync config is on for github importers
if service == "github" and importer.config.get("sync", False):
name = importer.metadata.get("name", False)
File diff suppressed because it is too large Load Diff
@@ -1,102 +0,0 @@
# Python imports
import json
# Django imports
from django.utils import timezone
# Module imports
from plane.db.models import IssueSubscriber, Project, IssueAssignee, Issue, Notification
# Third Party imports
from celery import shared_task
@shared_task
def notifications(type, issue_id, project_id, actor_id, subscriber, issue_activities_created):
issue_activities_created = (
json.loads(issue_activities_created) if issue_activities_created is not None else None
)
if type not in [
"cycle.activity.created",
"cycle.activity.deleted",
"module.activity.created",
"module.activity.deleted",
"issue_reaction.activity.created",
"issue_reaction.activity.deleted",
"comment_reaction.activity.created",
"comment_reaction.activity.deleted",
"issue_vote.activity.created",
"issue_vote.activity.deleted",
"issue_draft.activity.created",
"issue_draft.activity.updated",
"issue_draft.activity.deleted",
]:
# Create Notifications
bulk_notifications = []
issue_subscribers = list(
IssueSubscriber.objects.filter(project_id=project_id, issue_id=issue_id)
.exclude(subscriber_id=actor_id)
.values_list("subscriber", flat=True)
)
issue_assignees = list(
IssueAssignee.objects.filter(project_id=project_id, issue_id=issue_id)
.exclude(assignee_id=actor_id)
.values_list("assignee", flat=True)
)
issue_subscribers = issue_subscribers + issue_assignees
issue = Issue.objects.filter(pk=issue_id).first()
if subscriber:
# add the user to issue subscriber
try:
_ = IssueSubscriber.objects.get_or_create(
issue_id=issue_id, subscriber_id=actor_id
)
except Exception as e:
pass
project = Project.objects.get(pk=project_id)
for subscriber in list(set(issue_subscribers)):
for issue_activity in issue_activities_created:
bulk_notifications.append(
Notification(
workspace=project.workspace,
sender="in_app:issue_activities",
triggered_by_id=actor_id,
receiver_id=subscriber,
entity_identifier=issue_id,
entity_name="issue",
project=project,
title=issue_activity.get("comment"),
data={
"issue": {
"id": str(issue_id),
"name": str(issue.name),
"identifier": str(issue.project.identifier),
"sequence_id": issue.sequence_id,
"state_name": issue.state.name,
"state_group": issue.state.group,
},
"issue_activity": {
"id": str(issue_activity.get("id")),
"verb": str(issue_activity.get("verb")),
"field": str(issue_activity.get("field")),
"actor": str(issue_activity.get("actor_id")),
"new_value": str(issue_activity.get("new_value")),
"old_value": str(issue_activity.get("old_value")),
"issue_comment": str(
issue_activity.get("issue_comment").comment_stripped
if issue_activity.get("issue_comment") is not None
else ""
),
},
},
)
)
# Bulk create notifications
Notification.objects.bulk_create(bulk_notifications, batch_size=100)
@@ -1,21 +0,0 @@
# Generated by Django 4.2.5 on 2023-10-18 12:04
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import plane.db.models.issue
class Migration(migrations.Migration):
dependencies = [
('db', '0045_issueactivity_epoch_workspacemember_issue_props_and_more'),
]
operations = [
migrations.AlterField(
model_name='issueproperty',
name='properties',
field=models.JSONField(default=plane.db.models.issue.get_default_properties),
),
]
+6 -29
View File
@@ -16,24 +16,6 @@ from . import ProjectBaseModel
from plane.utils.html_processor import strip_tags
def get_default_properties():
return {
"assignee": True,
"start_date": True,
"due_date": True,
"labels": True,
"key": True,
"priority": True,
"state": True,
"sub_issue_count": True,
"link": True,
"attachment_count": True,
"estimate": True,
"created_on": True,
"updated_on": True,
}
# TODO: Handle identifiers for Bulk Inserts - nk
class IssueManager(models.Manager):
def get_queryset(self):
@@ -57,7 +39,7 @@ class Issue(ProjectBaseModel):
("high", "High"),
("medium", "Medium"),
("low", "Low"),
("none", "None"),
("none", "None")
)
parent = models.ForeignKey(
"self",
@@ -204,7 +186,7 @@ class IssueRelation(ProjectBaseModel):
("relates_to", "Relates To"),
("blocked_by", "Blocked By"),
)
issue = models.ForeignKey(
Issue, related_name="issue_relation", on_delete=models.CASCADE
)
@@ -226,7 +208,7 @@ class IssueRelation(ProjectBaseModel):
ordering = ("-created_at",)
def __str__(self):
return f"{self.issue.name} {self.related_issue.name}"
return f"{self.issue.name} {self.related_issue.name}"
class IssueAssignee(ProjectBaseModel):
@@ -345,9 +327,7 @@ class IssueComment(ProjectBaseModel):
comment_json = models.JSONField(blank=True, default=dict)
comment_html = models.TextField(blank=True, default="<p></p>")
attachments = ArrayField(models.URLField(), size=10, blank=True, default=list)
issue = models.ForeignKey(
Issue, on_delete=models.CASCADE, related_name="issue_comments"
)
issue = models.ForeignKey(Issue, on_delete=models.CASCADE, related_name="issue_comments")
# System can also create comment
actor = models.ForeignKey(
settings.AUTH_USER_MODEL,
@@ -387,7 +367,7 @@ class IssueProperty(ProjectBaseModel):
on_delete=models.CASCADE,
related_name="issue_property_user",
)
properties = models.JSONField(default=get_default_properties)
properties = models.JSONField(default=dict)
class Meta:
verbose_name = "Issue Property"
@@ -535,10 +515,7 @@ class IssueVote(ProjectBaseModel):
)
class Meta:
unique_together = [
"issue",
"actor",
]
unique_together = ["issue", "actor",]
verbose_name = "Issue Vote"
verbose_name_plural = "Issue Votes"
db_table = "issue_votes"
+51 -2
View File
@@ -1,8 +1,10 @@
# Django imports
from django.db import models
from django.conf import settings
# Third party imports
from .base import BaseModel
from plane.db.models import ProjectBaseModel
class Notification(BaseModel):
@@ -20,8 +22,15 @@ class Notification(BaseModel):
message_html = models.TextField(blank=True, default="<p></p>")
message_stripped = models.TextField(blank=True, null=True)
sender = models.CharField(max_length=255)
triggered_by = models.ForeignKey("db.User", related_name="triggered_notifications", on_delete=models.SET_NULL, null=True)
receiver = models.ForeignKey("db.User", related_name="received_notifications", on_delete=models.CASCADE)
triggered_by = models.ForeignKey(
"db.User",
related_name="triggered_notifications",
on_delete=models.SET_NULL,
null=True,
)
receiver = models.ForeignKey(
"db.User", related_name="received_notifications", on_delete=models.CASCADE
)
read_at = models.DateTimeField(null=True)
snoozed_till = models.DateTimeField(null=True)
archived_at = models.DateTimeField(null=True)
@@ -35,3 +44,43 @@ class Notification(BaseModel):
def __str__(self):
"""Return name of the notifications"""
return f"{self.receiver.email} <{self.workspace.name}>"
def get_default_preference():
return {
"property_change": {
"email": True,
},
"state": {
"email": True,
},
"comment": {
"email": True,
},
"mentions": {
"email": True,
},
}
class NotificationPreference(ProjectBaseModel):
created_by = models.JSONField(default=get_default_preference)
assigned = models.JSONField(default=get_default_preference)
subscribed = models.JSONField(default=get_default_preference)
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="notification_preferences",
)
class Meta:
unique_together = ["project", "user"]
verbose_name = "Notification Preference"
verbose_name_plural = "Notification Preferences"
db_table = "notification_preferences"
ordering = ("-created_at",)
def __str__(self):
"""Return name of the notifications"""
return f"{self.user.email} <{self.workspace.name}>"
+11 -12
View File
@@ -14,21 +14,19 @@ from .common import * # noqa
# Database
DEBUG = int(os.environ.get("DEBUG", 0)) == 1
if bool(os.environ.get("DATABASE_URL")):
# Parse database configuration from $DATABASE_URL
DATABASES["default"] = dj_database_url.config()
else:
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": os.environ.get("POSTGRES_DB"),
"USER": os.environ.get("POSTGRES_USER"),
"PASSWORD": os.environ.get("POSTGRES_PASSWORD"),
"HOST": os.environ.get("POSTGRES_HOST"),
}
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": "plane",
"USER": os.environ.get("PGUSER", ""),
"PASSWORD": os.environ.get("PGPASSWORD", ""),
"HOST": os.environ.get("PGHOST", ""),
}
}
# Parse database configuration from $DATABASE_URL
DATABASES["default"] = dj_database_url.config()
SITE_ID = 1
# Set the variable true if running in docker environment
@@ -280,3 +278,4 @@ SCOUT_NAME = "Plane"
# Unsplash Access key
UNSPLASH_ACCESS_KEY = os.environ.get("UNSPLASH_ACCESS_KEY")
+27 -50
View File
@@ -1,24 +1,10 @@
import re
import uuid
from datetime import timedelta
from django.utils import timezone
# The date from pattern
pattern = re.compile(r"\d+_(weeks|months)$")
# check the valid uuids
def filter_valid_uuids(uuid_list):
valid_uuids = []
for uuid_str in uuid_list:
try:
uuid_obj = uuid.UUID(uuid_str)
valid_uuids.append(uuid_obj)
except ValueError:
# ignore the invalid uuids
pass
return valid_uuids
# Get the 2_weeks, 3_months
def string_date_filter(filter, duration, subsequent, term, date_filter, offset):
@@ -75,41 +61,40 @@ def date_filter(filter, date_term, queries):
def filter_state(params, filter, method):
if method == "GET":
states = [item for item in params.get("state").split(",") if item != 'null']
states = filter_valid_uuids(states)
states = params.get("state").split(",")
if len(states) and "" not in states:
filter["state__in"] = states
else:
if params.get("state", None) and len(params.get("state")) and params.get("state") != 'null':
if params.get("state", None) and len(params.get("state")):
filter["state__in"] = params.get("state")
return filter
def filter_state_group(params, filter, method):
if method == "GET":
state_group = [item for item in params.get("state_group").split(",") if item != 'null']
state_group = params.get("state_group").split(",")
if len(state_group) and "" not in state_group:
filter["state__group__in"] = state_group
else:
if params.get("state_group", None) and len(params.get("state_group")) and params.get("state_group") != 'null':
if params.get("state_group", None) and len(params.get("state_group")):
filter["state__group__in"] = params.get("state_group")
return filter
def filter_estimate_point(params, filter, method):
if method == "GET":
estimate_points = [item for item in params.get("estimate_point").split(",") if item != 'null']
estimate_points = params.get("estimate_point").split(",")
if len(estimate_points) and "" not in estimate_points:
filter["estimate_point__in"] = estimate_points
else:
if params.get("estimate_point", None) and len(params.get("estimate_point")) and params.get("estimate_point") != 'null':
if params.get("estimate_point", None) and len(params.get("estimate_point")):
filter["estimate_point__in"] = params.get("estimate_point")
return filter
def filter_priority(params, filter, method):
if method == "GET":
priorities = [item for item in params.get("priority").split(",") if item != 'null']
priorities = params.get("priority").split(",")
if len(priorities) and "" not in priorities:
filter["priority__in"] = priorities
return filter
@@ -117,48 +102,44 @@ def filter_priority(params, filter, method):
def filter_parent(params, filter, method):
if method == "GET":
parents = [item for item in params.get("parent").split(",") if item != 'null']
parents = filter_valid_uuids(parents)
parents = params.get("parent").split(",")
if len(parents) and "" not in parents:
filter["parent__in"] = parents
else:
if params.get("parent", None) and len(params.get("parent")) and params.get("parent") != 'null':
if params.get("parent", None) and len(params.get("parent")):
filter["parent__in"] = params.get("parent")
return filter
def filter_labels(params, filter, method):
if method == "GET":
labels = [item for item in params.get("labels").split(",") if item != 'null']
labels = filter_valid_uuids(labels)
labels = params.get("labels").split(",")
if len(labels) and "" not in labels:
filter["labels__in"] = labels
else:
if params.get("labels", None) and len(params.get("labels")) and params.get("labels") != 'null':
if params.get("labels", None) and len(params.get("labels")):
filter["labels__in"] = params.get("labels")
return filter
def filter_assignees(params, filter, method):
if method == "GET":
assignees = [item for item in params.get("assignees").split(",") if item != 'null']
assignees = filter_valid_uuids(assignees)
assignees = params.get("assignees").split(",")
if len(assignees) and "" not in assignees:
filter["assignees__in"] = assignees
else:
if params.get("assignees", None) and len(params.get("assignees")) and params.get("assignees") != 'null':
if params.get("assignees", None) and len(params.get("assignees")):
filter["assignees__in"] = params.get("assignees")
return filter
def filter_created_by(params, filter, method):
if method == "GET":
created_bys = [item for item in params.get("created_by").split(",") if item != 'null']
created_bys = filter_valid_uuids(created_bys)
created_bys = params.get("created_by").split(",")
if len(created_bys) and "" not in created_bys:
filter["created_by__in"] = created_bys
else:
if params.get("created_by", None) and len(params.get("created_by")) and params.get("created_by") != 'null':
if params.get("created_by", None) and len(params.get("created_by")):
filter["created_by__in"] = params.get("created_by")
return filter
@@ -198,7 +179,7 @@ def filter_start_date(params, filter, method):
date_filter(filter=filter, date_term="start_date", queries=start_dates)
else:
if params.get("start_date", None) and len(params.get("start_date")):
filter["start_date"] = params.get("start_date")
date_filter(filter=filter, date_term="start_date", queries=params.get("start_date", []))
return filter
@@ -209,7 +190,7 @@ def filter_target_date(params, filter, method):
date_filter(filter=filter, date_term="target_date", queries=target_dates)
else:
if params.get("target_date", None) and len(params.get("target_date")):
filter["target_date"] = params.get("target_date")
date_filter(filter=filter, date_term="target_date", queries=params.get("target_date", []))
return filter
@@ -238,47 +219,44 @@ def filter_issue_state_type(params, filter, method):
def filter_project(params, filter, method):
if method == "GET":
projects = [item for item in params.get("project").split(",") if item != 'null']
projects = filter_valid_uuids(projects)
projects = params.get("project").split(",")
if len(projects) and "" not in projects:
filter["project__in"] = projects
else:
if params.get("project", None) and len(params.get("project")) and params.get("project") != 'null':
if params.get("project", None) and len(params.get("project")):
filter["project__in"] = params.get("project")
return filter
def filter_cycle(params, filter, method):
if method == "GET":
cycles = [item for item in params.get("cycle").split(",") if item != 'null']
cycles = filter_valid_uuids(cycles)
cycles = params.get("cycle").split(",")
if len(cycles) and "" not in cycles:
filter["issue_cycle__cycle_id__in"] = cycles
else:
if params.get("cycle", None) and len(params.get("cycle")) and params.get("cycle") != 'null':
if params.get("cycle", None) and len(params.get("cycle")):
filter["issue_cycle__cycle_id__in"] = params.get("cycle")
return filter
def filter_module(params, filter, method):
if method == "GET":
modules = [item for item in params.get("module").split(",") if item != 'null']
modules = filter_valid_uuids(modules)
modules = params.get("module").split(",")
if len(modules) and "" not in modules:
filter["issue_module__module_id__in"] = modules
else:
if params.get("module", None) and len(params.get("module")) and params.get("module") != 'null':
if params.get("module", None) and len(params.get("module")):
filter["issue_module__module_id__in"] = params.get("module")
return filter
def filter_inbox_status(params, filter, method):
if method == "GET":
status = [item for item in params.get("inbox_status").split(",") if item != 'null']
status = params.get("inbox_status").split(",")
if len(status) and "" not in status:
filter["issue_inbox__status__in"] = status
else:
if params.get("inbox_status", None) and len(params.get("inbox_status")) and params.get("inbox_status") != 'null':
if params.get("inbox_status", None) and len(params.get("inbox_status")):
filter["issue_inbox__status__in"] = params.get("inbox_status")
return filter
@@ -297,12 +275,11 @@ def filter_sub_issue_toggle(params, filter, method):
def filter_subscribed_issues(params, filter, method):
if method == "GET":
subscribers = [item for item in params.get("subscriber").split(",") if item != 'null']
subscribers = filter_valid_uuids(subscribers)
subscribers = params.get("subscriber").split(",")
if len(subscribers) and "" not in subscribers:
filter["issue_subscribers__subscriber_id__in"] = subscribers
else:
if params.get("subscriber", None) and len(params.get("subscriber")) and params.get("subscriber") != 'null':
if params.get("subscriber", None) and len(params.get("subscriber")):
filter["issue_subscribers__subscriber_id__in"] = params.get("subscriber")
return filter
+4
View File
@@ -0,0 +1,4 @@
# Deploy the Plane image
FROM makeplane/plane
LABEL maintainer="engineering@plane.so"
-168
View File
@@ -1,168 +0,0 @@
version: "3.8"
x-app-env : &app-env
environment:
- NGINX_PORT=${NGINX_PORT:-84}
- DEBUG=${DEBUG:-0}
- DJANGO_SETTINGS_MODULE=${DJANGO_SETTINGS_MODULE:-plane.settings.selfhosted}
- NEXT_PUBLIC_ENABLE_OAUTH=${NEXT_PUBLIC_ENABLE_OAUTH:-0}
- NEXT_PUBLIC_DEPLOY_URL=${NEXT_PUBLIC_DEPLOY_URL:-http://localhost/spaces}
- SENTRY_DSN=${SENTRY_DSN:-""}
- GITHUB_CLIENT_SECRET=${GITHUB_CLIENT_SECRET:-""}
- DOCKERIZED=${DOCKERIZED:-1}
#DB SETTINGS
- PGHOST=${PGHOST:-plane-db}
- PGDATABASE=${PGDATABASE:-plane}
- POSTGRES_USER=${POSTGRES_USER:-plane}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-plane}
- POSTGRES_DB=${POSTGRES_DB:-plane}
- PGDATA=${PGDATA:-/var/lib/postgresql/data}
- DATABASE_URL=${DATABASE_URL:-postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${PGHOST}/${PGDATABASE}}
# REDIS SETTINGS
- REDIS_HOST=${REDIS_HOST:-plane-redis}
- REDIS_PORT=${REDIS_PORT:-6379}
- REDIS_URL=${REDIS_URL:-redis://${REDIS_HOST}:6379/}
# EMAIL SETTINGS
- EMAIL_HOST=${EMAIL_HOST:-""}
- EMAIL_HOST_USER=${EMAIL_HOST_USER:-""}
- EMAIL_HOST_PASSWORD=${EMAIL_HOST_PASSWORD:-""}
- EMAIL_PORT=${EMAIL_PORT:-587}
- EMAIL_FROM=${EMAIL_FROM:-"Team Plane &lt;team@mailer.plane.so&gt;"}
- EMAIL_USE_TLS=${EMAIL_USE_TLS:-1}
- EMAIL_USE_SSL=${EMAIL_USE_SSL:-0}
- DEFAULT_EMAIL=${DEFAULT_EMAIL:-captain@plane.so}
- DEFAULT_PASSWORD=${DEFAULT_PASSWORD:-password123}
# OPENAI SETTINGS
- OPENAI_API_BASE=${OPENAI_API_BASE:-https://api.openai.com/v1}
- OPENAI_API_KEY=${OPENAI_API_KEY:-"sk-"}
- GPT_ENGINE=${GPT_ENGINE:-"gpt-3.5-turbo"}
# LOGIN/SIGNUP SETTINGS
- ENABLE_SIGNUP=${ENABLE_SIGNUP:-1}
- ENABLE_EMAIL_PASSWORD=${ENABLE_EMAIL_PASSWORD:-1}
- ENABLE_MAGIC_LINK_LOGIN=${ENABLE_MAGIC_LINK_LOGIN:-0}
- SECRET_KEY=${SECRET_KEY:-60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5}
# DATA STORE SETTINGS
- USE_MINIO=${USE_MINIO:-1}
- AWS_REGION=${AWS_REGION:-""}
- AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID:-"access-key"}
- AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY:-"secret-key"}
- AWS_S3_ENDPOINT_URL=${AWS_S3_ENDPOINT_URL:-http://plane-minio:9000}
- AWS_S3_BUCKET_NAME=${AWS_S3_BUCKET_NAME:-uploads}
- MINIO_ROOT_USER=${MINIO_ROOT_USER:-"access-key"}
- MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD:-"secret-key"}
- BUCKET_NAME=${BUCKET_NAME:-uploads}
- FILE_SIZE_LIMIT=${FILE_SIZE_LIMIT:-5242880}
services:
web:
<<: *app-env
platform: linux/amd64
image: makeplane/plane-frontend:${APP_RELEASE:-latest}
restart: unless-stopped
command: /usr/local/bin/start.sh web/server.js web
deploy:
replicas: ${WEB_REPLICAS:-1}
depends_on:
- api
- worker
space:
<<: *app-env
platform: linux/amd64
image: makeplane/plane-space:${APP_RELEASE:-latest}
restart: unless-stopped
command: /usr/local/bin/start.sh space/server.js space
deploy:
replicas: ${SPACE_REPLICAS:-1}
depends_on:
- api
- worker
- web
api:
<<: *app-env
platform: linux/amd64
image: makeplane/plane-backend:${APP_RELEASE:-latest}
restart: unless-stopped
command: ./bin/takeoff
deploy:
replicas: ${API_REPLICAS:-1}
depends_on:
- plane-db
- plane-redis
worker:
<<: *app-env
container_name: bgworker
platform: linux/amd64
image: makeplane/plane-backend:${APP_RELEASE:-latest}
restart: unless-stopped
command: ./bin/worker
depends_on:
- api
- plane-db
- plane-redis
beat-worker:
<<: *app-env
container_name: beatworker
platform: linux/amd64
image: makeplane/plane-backend:${APP_RELEASE:-latest}
restart: unless-stopped
command: ./bin/beat
depends_on:
- api
- plane-db
- plane-redis
plane-db:
<<: *app-env
container_name: plane-db
image: postgres:15.2-alpine
restart: unless-stopped
command: postgres -c 'max_connections=1000'
volumes:
- pgdata:/var/lib/postgresql/data
plane-redis:
<<: *app-env
container_name: plane-redis
image: redis:6.2.7-alpine
restart: unless-stopped
volumes:
- redisdata:/data
plane-minio:
<<: *app-env
container_name: plane-minio
image: minio/minio
restart: unless-stopped
command: server /export --console-address ":9090"
volumes:
- uploads:/export
createbuckets:
<<: *app-env
image: minio/mc
entrypoint: >
/bin/sh -c " /usr/bin/mc config host add plane-minio http://plane-minio:9000 \$AWS_ACCESS_KEY_ID \$AWS_SECRET_ACCESS_KEY; /usr/bin/mc mb plane-minio/\$AWS_S3_BUCKET_NAME; /usr/bin/mc anonymous set download plane-minio/\$AWS_S3_BUCKET_NAME; exit 0; "
depends_on:
- plane-minio
# Comment this if you already have a reverse proxy running
proxy:
<<: *app-env
container_name: proxy
platform: linux/amd64
image: makeplane/plane-proxy:${APP_RELEASE:-latest}
ports:
- ${NGINX_PORT}:80
depends_on:
- web
- api
- space
volumes:
pgdata:
redisdata:
uploads:
-111
View File
@@ -1,111 +0,0 @@
#!/bin/bash
BRANCH=${BRANCH:-master}
SCRIPT_DIR=$PWD
PLANE_INSTALL_DIR=$PWD/plane-app
mkdir -p $PLANE_INSTALL_DIR/archive
function install(){
echo
echo "Installing on $PLANE_INSTALL_DIR"
download
}
function download(){
cd $SCRIPT_DIR
TS=$(date +%s)
if [ -f "$PLANE_INSTALL_DIR/docker-compose.yaml" ]
then
mv $PLANE_INSTALL_DIR/docker-compose.yaml $PLANE_INSTALL_DIR/archive/$TS.docker-compose.yaml
fi
curl -H 'Cache-Control: no-cache, no-store' -s -o $PLANE_INSTALL_DIR/docker-compose.yaml https://raw.githubusercontent.com/makeplane/plane/$BRANCH/deploy/selfhost/docker-compose.yml?$(date +%s)
curl -H 'Cache-Control: no-cache, no-store' -s -o $PLANE_INSTALL_DIR/variables-upgrade.env https://raw.githubusercontent.com/makeplane/plane/$BRANCH/deploy/selfhost/variables.env?$(date +%s)
if [ -f "$PLANE_INSTALL_DIR/.env" ];
then
cp $PLANE_INSTALL_DIR/.env $PLANE_INSTALL_DIR/archive/$TS.env
else
mv $PLANE_INSTALL_DIR/variables-upgrade.env $PLANE_INSTALL_DIR/.env
fi
echo ""
echo "Latest version is now available for you to use"
echo ""
echo "In case of Upgrade, your new setting file is availabe as 'variables-upgrade.env'. Please compare and set the required values in '.env 'file."
echo ""
}
function startServices(){
cd $PLANE_INSTALL_DIR
docker compose up -d
cd $SCRIPT_DIR
}
function stopServices(){
cd $PLANE_INSTALL_DIR
docker compose down
cd $SCRIPT_DIR
}
function restartServices(){
cd $PLANE_INSTALL_DIR
docker compose restart
cd $SCRIPT_DIR
}
function upgrade(){
echo "***** STOPPING SERVICES ****"
stopServices
echo
echo "***** DOWNLOADING LATEST VERSION ****"
download
echo "***** PLEASE VALIDATE AND START SERVICES ****"
}
function askForAction(){
echo
echo "Select a Action you want to perform:"
echo " 1) Install"
echo " 2) Start"
echo " 3) Stop"
echo " 4) Restart"
echo " 5) Upgrade"
echo " 6) Exit"
echo
read -p "Action [2]: " ACTION
until [[ -z "$ACTION" || "$ACTION" =~ ^[1-6]$ ]]; do
echo "$ACTION: invalid selection."
read -p "Action [2]: " ACTION
done
echo
if [ "$ACTION" == "1" ]
then
install
askForAction
elif [ "$ACTION" == "2" ] || [ "$ACTION" == "" ]
then
startServices
askForAction
elif [ "$ACTION" == "3" ]
then
stopServices
askForAction
elif [ "$ACTION" == "4" ]
then
restartServices
askForAction
elif [ "$ACTION" == "5" ]
then
upgrade
askForAction
elif [ "$ACTION" == "6" ]
then
exit 0
else
echo "INVALID ACTION SUPPLIED"
fi
}
askForAction
-63
View File
@@ -1,63 +0,0 @@
APP_RELEASE=latest
WEB_REPLICAS=1
SPACE_REPLICAS=1
API_REPLICAS=1
NGINX_PORT=80
DEBUG=0
DJANGO_SETTINGS_MODULE=plane.settings.selfhosted
NEXT_PUBLIC_ENABLE_OAUTH=0
NEXT_PUBLIC_DEPLOY_URL=http://localhost/spaces
SENTRY_DSN=""
GITHUB_CLIENT_SECRET=""
DOCKERIZED=1
#DB SETTINGS
PGHOST=plane-db
PGDATABASE=plane
POSTGRES_USER=plane
POSTGRES_PASSWORD=plane
POSTGRES_DB=plane
PGDATA=/var/lib/postgresql/data
DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${PGHOST}/${PGDATABASE}
# REDIS SETTINGS
REDIS_HOST=plane-redis
REDIS_PORT=6379
REDIS_URL=redis://${REDIS_HOST}:6379/
# EMAIL SETTINGS
EMAIL_HOST=""
EMAIL_HOST_USER=""
EMAIL_HOST_PASSWORD=""
EMAIL_PORT=587
EMAIL_FROM="Team Plane &lt;team@mailer.plane.so&gt;"
EMAIL_USE_TLS=1
EMAIL_USE_SSL=0
DEFAULT_EMAIL=captain@plane.so
DEFAULT_PASSWORD=password123
# OPENAI SETTINGS
OPENAI_API_BASE=https://api.openai.com/v1
OPENAI_API_KEY="sk-"
GPT_ENGINE="gpt-3.5-turbo"
# LOGIN/SIGNUP SETTINGS
ENABLE_SIGNUP=1
ENABLE_EMAIL_PASSWORD=1
ENABLE_MAGIC_LINK_LOGIN=0
SECRET_KEY=60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5
# DATA STORE SETTINGS
USE_MINIO=1
AWS_REGION=""
AWS_ACCESS_KEY_ID="access-key"
AWS_SECRET_ACCESS_KEY="secret-key"
AWS_S3_ENDPOINT_URL=http://plane-minio:9000
AWS_S3_BUCKET_NAME=uploads
MINIO_ROOT_USER="access-key"
MINIO_ROOT_PASSWORD="secret-key"
BUCKET_NAME=uploads
FILE_SIZE_LIMIT=5242880
+234
View File
@@ -0,0 +1,234 @@
version: "3.8"
services:
web:
container_name: web
platform: linux/amd64
image: makeplane/plane-frontend:latest
restart: always
command: /usr/local/bin/start.sh web/server.js web
environment:
- NEXT_PUBLIC_ENABLE_OAUTH=${NEXT_PUBLIC_ENABLE_OAUTH:-0}
- NEXT_PUBLIC_DEPLOY_URL=${NEXT_PUBLIC_DEPLOY_URL:-http://localhost/spaces}
depends_on:
- api
- worker
space:
container_name: space
platform: linux/amd64
image: makeplane/plane-space:latest
restart: always
command: /usr/local/bin/start.sh space/server.js space
environment:
- NEXT_PUBLIC_ENABLE_OAUTH=${NEXT_PUBLIC_ENABLE_OAUTH:-0}
depends_on:
- api
- worker
- web
api:
container_name: api
platform: linux/amd64
image: makeplane/plane-backend:latest
restart: always
command: ./bin/takeoff
environment:
- DEBUG=${DEBUG:-0}
- DJANGO_SETTINGS_MODULE=${DJANGO_SETTINGS_MODULE:-plane.settings.selfhosted}
- SENTRY_DSN=${SENTRY_DSN:-""}
- PGUSER=${PGUSER:-plane}
- PGPASSWORD=${PGPASSWORD:-plane}
- PGHOST=${PGHOST:-plane-db}
- PGDATABASE=${PGDATABASE:-plane}
- DATABASE_URL=${DATABASE_URL:-postgresql://${PGUSER}:${PGPASSWORD}@${PGHOST}/${PGDATABASE}}
- REDIS_HOST=${REDIS_HOST:-plane-redis}
- REDIS_PORT=${REDIS_PORT:-6379}
- REDIS_URL=${REDIS_URL:-redis://${REDIS_HOST}:6379/}
- EMAIL_HOST=${EMAIL_HOST:-""}
- EMAIL_HOST_USER=${EMAIL_HOST_USER:-""}
- EMAIL_HOST_PASSWORD=${EMAIL_HOST_PASSWORD:-""}
- EMAIL_PORT=${EMAIL_PORT:-587}
- EMAIL_FROM=${EMAIL_FROM:-Team Plane <team@mailer.plane.so>}
- EMAIL_USE_TLS=${EMAIL_USE_TLS:-1}
- EMAIL_USE_SSL=${EMAIL_USE_SSL:-0}
- AWS_REGION=${AWS_REGION:-""}
- AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID:-access-key}
- AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY:-secret-key}
- AWS_S3_ENDPOINT_URL=${AWS_S3_ENDPOINT_URL:-http://plane-minio:9000}
- AWS_S3_BUCKET_NAME=${AWS_S3_BUCKET_NAME:-uploads}
- FILE_SIZE_LIMIT=${FILE_SIZE_LIMIT:-5242880}
- OPENAI_API_BASE=${OPENAI_API_BASE:-https://api.openai.com/v1}
- OPENAI_API_KEY=${OPENAI_API_KEY:-sk-}
- GPT_ENGINE=${GPT_ENGINE:-gpt-3.5-turbo}
- GITHUB_CLIENT_SECRET=${GITHUB_CLIENT_SECRET:-""}
- DOCKERIZED=${DOCKERIZED:-1}
- USE_MINIO=${USE_MINIO:-1}
- NGINX_PORT=${NGINX_PORT:-80}
- DEFAULT_EMAIL=${DEFAULT_EMAIL:-captain@plane.so}
- DEFAULT_PASSWORD=${DEFAULT_PASSWORD:-password123}
- ENABLE_SIGNUP=${ENABLE_SIGNUP:-1}
- ENABLE_EMAIL_PASSWORD=${ENABLE_EMAIL_PASSWORD:-1}
- ENABLE_MAGIC_LINK_LOGIN=${ENABLE_MAGIC_LINK_LOGIN:-0}
- SECRET_KEY=${SECRET_KEY:-60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5}
depends_on:
- plane-db
- plane-redis
worker:
container_name: bgworker
platform: linux/amd64
image: makeplane/plane-backend:latest
restart: always
command: ./bin/worker
environment:
- DEBUG=${DEBUG:-0}
- DJANGO_SETTINGS_MODULE=${DJANGO_SETTINGS_MODULE:-plane.settings.selfhosted}
- SENTRY_DSN=${SENTRY_DSN:-""}
- PGUSER=${PGUSER:-plane}
- PGPASSWORD=${PGPASSWORD:-plane}
- PGHOST=${PGHOST:-plane-db}
- PGDATABASE=${PGDATABASE:-plane}
- DATABASE_URL=${DATABASE_URL:-postgresql://${PGUSER}:${PGPASSWORD}@${PGHOST}/${PGDATABASE}}
- REDIS_HOST=${REDIS_HOST:-plane-redis}
- REDIS_PORT=${REDIS_PORT:-6379}
- REDIS_URL=${REDIS_URL:-redis://${REDIS_HOST}:6379/}
- EMAIL_HOST=${EMAIL_HOST:-""}
- EMAIL_HOST_USER=${EMAIL_HOST_USER:-""}
- EMAIL_HOST_PASSWORD=${EMAIL_HOST_PASSWORD:-""}
- EMAIL_PORT=${EMAIL_PORT:-587}
- EMAIL_FROM=${EMAIL_FROM:-Team Plane <team@mailer.plane.so>}
- EMAIL_USE_TLS=${EMAIL_USE_TLS:-1}
- EMAIL_USE_SSL=${EMAIL_USE_SSL:-0}
- AWS_REGION=${AWS_REGION:-""}
- AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID:-access-key}
- AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY:-secret-key}
- AWS_S3_ENDPOINT_URL=${AWS_S3_ENDPOINT_URL:-http://plane-minio:9000}
- AWS_S3_BUCKET_NAME=${AWS_S3_BUCKET_NAME:-uploads}
- FILE_SIZE_LIMIT=${FILE_SIZE_LIMIT:-5242880}
- OPENAI_API_BASE=${OPENAI_API_BASE:-https://api.openai.com/v1}
- OPENAI_API_KEY=${OPENAI_API_KEY:-sk-}
- GPT_ENGINE=${GPT_ENGINE:-gpt-3.5-turbo}
- GITHUB_CLIENT_SECRET=${GITHUB_CLIENT_SECRET:-""}
- DOCKERIZED=${DOCKERIZED:-1}
- USE_MINIO=${USE_MINIO:-1}
- NGINX_PORT=${NGINX_PORT:-80}
- DEFAULT_EMAIL=${DEFAULT_EMAIL:-captain@plane.so}
- DEFAULT_PASSWORD=${DEFAULT_PASSWORD:-password123}
- ENABLE_SIGNUP=${ENABLE_SIGNUP:-1}
- SECRET_KEY=${SECRET_KEY:-60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5}
depends_on:
- api
- plane-db
- plane-redis
beat-worker:
container_name: beatworker
platform: linux/amd64
image: makeplane/plane-backend:latest
restart: always
command: ./bin/beat
environment:
- DEBUG=${DEBUG:-0}
- DJANGO_SETTINGS_MODULE=${DJANGO_SETTINGS_MODULE:-plane.settings.selfhosted}
- SENTRY_DSN=${SENTRY_DSN:-""}
- PGUSER=${PGUSER:-plane}
- PGPASSWORD=${PGPASSWORD:-plane}
- PGHOST=${PGHOST:-plane-db}
- PGDATABASE=${PGDATABASE:-plane}
- DATABASE_URL=${DATABASE_URL:-postgresql://${PGUSER}:${PGPASSWORD}@${PGHOST}/${PGDATABASE}}
- REDIS_HOST=${REDIS_HOST:-plane-redis}
- REDIS_PORT=${REDIS_PORT:-6379}
- REDIS_URL=${REDIS_URL:-redis://${REDIS_HOST}:6379/}
- EMAIL_HOST=${EMAIL_HOST:-""}
- EMAIL_HOST_USER=${EMAIL_HOST_USER:-""}
- EMAIL_HOST_PASSWORD=${EMAIL_HOST_PASSWORD:-""}
- EMAIL_PORT=${EMAIL_PORT:-587}
- EMAIL_FROM=${EMAIL_FROM:-Team Plane <team@mailer.plane.so>}
- EMAIL_USE_TLS=${EMAIL_USE_TLS:-1}
- EMAIL_USE_SSL=${EMAIL_USE_SSL:-0}
- AWS_REGION=${AWS_REGION:-""}
- AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID:-access-key}
- AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY:-secret-key}
- AWS_S3_ENDPOINT_URL=${AWS_S3_ENDPOINT_URL:-http://plane-minio:9000}
- AWS_S3_BUCKET_NAME=${AWS_S3_BUCKET_NAME:-uploads}
- FILE_SIZE_LIMIT=${FILE_SIZE_LIMIT:-5242880}
- OPENAI_API_BASE=${OPENAI_API_BASE:-https://api.openai.com/v1}
- OPENAI_API_KEY=${OPENAI_API_KEY:-sk-}
- GPT_ENGINE=${GPT_ENGINE:-gpt-3.5-turbo}
- GITHUB_CLIENT_SECRET=${GITHUB_CLIENT_SECRET:-""}
- DOCKERIZED=${DOCKERIZED:-1}
- USE_MINIO=${USE_MINIO:-1}
- NGINX_PORT=${NGINX_PORT:-80}
- DEFAULT_EMAIL=${DEFAULT_EMAIL:-captain@plane.so}
- DEFAULT_PASSWORD=${DEFAULT_PASSWORD:-password123}
- ENABLE_SIGNUP=${ENABLE_SIGNUP:-1}
- SECRET_KEY=${SECRET_KEY:-60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5}
depends_on:
- api
- plane-db
- plane-redis
plane-db:
container_name: plane-db
image: postgres:15.2-alpine
restart: always
command: postgres -c 'max_connections=1000'
volumes:
- pgdata:/var/lib/postgresql/data
environment:
- POSTGRES_USER=${POSTGRES_USER:-plane}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-plane}
- POSTGRES_DB=${POSTGRES_DB:-plane}
- PGDATA=${PGDATA:-/var/lib/postgresql/data}
plane-redis:
container_name: plane-redis
image: redis:6.2.7-alpine
restart: always
volumes:
- redisdata:/data
plane-minio:
container_name: plane-minio
image: minio/minio
restart: always
command: server /export --console-address ":9090"
volumes:
- uploads:/export
environment:
- MINIO_ROOT_USER=${MINIO_ROOT_USER:-access-key}
- MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD:-secret-key}
createbuckets:
image: minio/mc
entrypoint: >
/bin/sh -c " /usr/bin/mc config host add plane-minio http://plane-minio:9000 \$AWS_ACCESS_KEY_ID \$AWS_SECRET_ACCESS_KEY; /usr/bin/mc mb plane-minio/\$AWS_S3_BUCKET_NAME; /usr/bin/mc anonymous set download plane-minio/\$AWS_S3_BUCKET_NAME; exit 0; "
environment:
- AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID:-access-key}
- AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY:-secret-key}
- AWS_S3_BUCKET_NAME=${AWS_S3_BUCKET_NAME:-uploads}
depends_on:
- plane-minio
# Comment this if you already have a reverse proxy running
proxy:
container_name: proxy
platform: linux/amd64
image: makeplane/plane-proxy:latest
ports:
- ${NGINX_PORT}:80
environment:
- NGINX_PORT=${NGINX_PORT:-80}
- FILE_SIZE_LIMIT=${FILE_SIZE_LIMIT:-5242880}
- BUCKET_NAME=${AWS_S3_BUCKET_NAME:-uploads}
depends_on:
- web
- api
- space
volumes:
pgdata:
redisdata:
uploads:
+1 -1
View File
@@ -27,7 +27,7 @@
"prettier": "latest",
"prettier-plugin-tailwindcss": "^0.5.4",
"tailwindcss": "^3.3.3",
"turbo": "^1.10.16"
"turbo": "^1.10.14"
},
"resolutions": {
"@types/react": "18.2.0"
-1
View File
@@ -27,7 +27,6 @@
"next-themes": "^0.2.1"
},
"dependencies": {
"react-moveable" : "^0.54.2",
"@blueprintjs/popover2": "^2.0.10",
"@tiptap/core": "^2.1.7",
"@tiptap/extension-color": "^2.1.11",
@@ -23,8 +23,8 @@ export const ImageResizer = ({ editor }: { editor: Editor }) => {
origin={false}
edge={false}
throttleDrag={0}
keepRatio
resizable
keepRatio={true}
resizable={true}
throttleResize={0}
onResize={({ target, width, height, delta }: any) => {
delta[0] && (target!.style.width = `${width}px`);
@@ -33,7 +33,7 @@ export const ImageResizer = ({ editor }: { editor: Editor }) => {
onResizeEnd={() => {
updateMediaSize();
}}
scalable
scalable={true}
renderDirections={["w", "e"]}
onScale={({ target, transform }: any) => {
target!.style.transform = transform;
+31 -42
View File
@@ -1,23 +1,18 @@
import { useEditor as useCustomEditor, Editor } from "@tiptap/react";
import {
useImperativeHandle,
useRef,
MutableRefObject,
useEffect,
} from "react";
import { DeleteImage } from "../../types/delete-image";
import { useImperativeHandle, useRef, MutableRefObject } from "react";
import { useDebouncedCallback } from "use-debounce";
import { DeleteImage } from '../../types/delete-image';
import { CoreEditorProps } from "../props";
import { CoreEditorExtensions } from "../extensions";
import { EditorProps } from "@tiptap/pm/view";
import { EditorProps } from '@tiptap/pm/view';
import { getTrimmedHTML } from "../../lib/utils";
import { UploadImage } from "../../types/upload-image";
import { useInitializedContent } from "./useInitializedContent";
const DEBOUNCE_DELAY = 1500;
interface CustomEditorProps {
uploadFile: UploadImage;
setIsSubmitting?: (
isSubmitting: "submitting" | "submitted" | "saved",
) => void;
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void;
setShouldShowAlert?: (showAlert: boolean) => void;
value: string;
deleteFile: DeleteImage;
@@ -28,37 +23,25 @@ interface CustomEditorProps {
forwardedRef?: any;
}
export const useEditor = ({
uploadFile,
deleteFile,
editorProps = {},
value,
extensions = [],
onChange,
setIsSubmitting,
forwardedRef,
setShouldShowAlert,
}: CustomEditorProps) => {
const editor = useCustomEditor(
{
editorProps: {
...CoreEditorProps(uploadFile, setIsSubmitting),
...editorProps,
},
extensions: [...CoreEditorExtensions(deleteFile), ...extensions],
content:
typeof value === "string" && value.trim() !== "" ? value : "<p></p>",
onUpdate: async ({ editor }) => {
// for instant feedback loop
setIsSubmitting?.("submitting");
setShouldShowAlert?.(true);
onChange?.(editor.getJSON(), getTrimmedHTML(editor.getHTML()));
},
export const useEditor = ({ uploadFile, deleteFile, editorProps = {}, value, extensions = [], onChange, setIsSubmitting, debouncedUpdatesEnabled, forwardedRef, setShouldShowAlert, }: CustomEditorProps) => {
const editor = useCustomEditor({
editorProps: {
...CoreEditorProps(uploadFile, setIsSubmitting),
...editorProps,
},
[],
);
useInitializedContent(editor, value);
extensions: [...CoreEditorExtensions(deleteFile), ...extensions],
content: (typeof value === "string" && value.trim() !== "") ? value : "<p></p>",
onUpdate: async ({ editor }) => {
// for instant feedback loop
setIsSubmitting?.("submitting");
setShouldShowAlert?.(true);
if (debouncedUpdatesEnabled) {
debouncedUpdates({ onChange: onChange, editor });
} else {
onChange?.(editor.getJSON(), getTrimmedHTML(editor.getHTML()));
}
},
});
const editorRef: MutableRefObject<Editor | null> = useRef(null);
editorRef.current = editor;
@@ -72,6 +55,12 @@ export const useEditor = ({
},
}));
const debouncedUpdates = useDebouncedCallback(async ({ onChange, editor }) => {
if (onChange) {
onChange(editor.getJSON(), getTrimmedHTML(editor.getHTML()));
}
}, DEBOUNCE_DELAY);
if (!editor) {
return null;
}
@@ -1,19 +0,0 @@
import { Editor } from "@tiptap/react";
import { useEffect, useRef } from "react";
export const useInitializedContent = (editor: Editor | null, value: string) => {
const hasInitializedContent = useRef(false);
useEffect(() => {
if (editor) {
const cleanedValue =
typeof value === "string" && value.trim() !== "" ? value : "<p></p>";
if (cleanedValue !== "<p></p>" && !hasInitializedContent.current) {
editor.commands.setContent(cleanedValue);
hasInitializedContent.current = true;
} else if (cleanedValue === "<p></p>" && hasInitializedContent.current) {
hasInitializedContent.current = false;
}
}
}, [value, editor]);
};
@@ -1,13 +1,8 @@
import { useEditor as useCustomEditor, Editor } from "@tiptap/react";
import {
useImperativeHandle,
useRef,
MutableRefObject,
useEffect,
} from "react";
import { useImperativeHandle, useRef, MutableRefObject } from "react";
import { CoreReadOnlyEditorExtensions } from "../../ui/read-only/extensions";
import { CoreReadOnlyEditorProps } from "../../ui/read-only/props";
import { EditorProps } from "@tiptap/pm/view";
import { EditorProps } from '@tiptap/pm/view';
interface CustomReadOnlyEditorProps {
value: string;
@@ -16,16 +11,10 @@ interface CustomReadOnlyEditorProps {
editorProps?: EditorProps;
}
export const useReadOnlyEditor = ({
value,
forwardedRef,
extensions = [],
editorProps = {},
}: CustomReadOnlyEditorProps) => {
export const useReadOnlyEditor = ({ value, forwardedRef, extensions = [], editorProps = {} }: CustomReadOnlyEditorProps) => {
const editor = useCustomEditor({
editable: false,
content:
typeof value === "string" && value.trim() !== "" ? value : "<p></p>",
content: (typeof value === "string" && value.trim() !== "") ? value : "<p></p>",
editorProps: {
...CoreReadOnlyEditorProps,
...editorProps,
@@ -33,14 +22,6 @@ export const useReadOnlyEditor = ({
extensions: [...CoreReadOnlyEditorExtensions, ...extensions],
});
const hasIntiliazedContent = useRef(false);
useEffect(() => {
if (editor && !value && !hasIntiliazedContent.current) {
editor.commands.setContent(value);
hasIntiliazedContent.current = true;
}
}, [value]);
const editorRef: MutableRefObject<Editor | null> = useRef(null);
editorRef.current = editor;
@@ -53,6 +34,7 @@ export const useReadOnlyEditor = ({
},
}));
if (!editor) {
return null;
}
@@ -16,7 +16,7 @@ const TrackImageDeletionPlugin = (deleteImage: DeleteImage): Plugin =>
new Plugin({
key: deleteKey,
appendTransaction: (transactions: readonly Transaction[], oldState: EditorState, newState: EditorState) => {
const newImageSources = new Set<string>();
const newImageSources = new Set();
newState.doc.descendants((node) => {
if (node.type.name === IMAGE_NODE_TYPE) {
newImageSources.add(node.attrs.src);
@@ -0,0 +1,9 @@
import ListItem from '@tiptap/extension-list-item'
export const CustomListItem = ListItem.extend({
addKeyboardShortcuts() {
return {
'Shift-Enter': () => this.editor.chain().focus().splitListItem('listItem').run(),
}
},
})
@@ -1,25 +1,16 @@
import { Extension } from "@tiptap/core";
import { Extension } from '@tiptap/core';
export const EnterKeyExtension = (onEnterKeyPress?: () => void) =>
Extension.create({
name: "enterKey",
export const EnterKeyExtension = (onEnterKeyPress?: () => void) => Extension.create({
name: 'enterKey',
addKeyboardShortcuts() {
return {
Enter: () => {
if (onEnterKeyPress) {
onEnterKeyPress();
}
return true;
},
"Shift-Enter": ({ editor }) =>
editor.commands.first(({ commands }) => [
() => commands.newlineInCode(),
() => commands.splitListItem("listItem"),
() => commands.createParagraphNear(),
() => commands.liftEmptyBlock(),
() => commands.splitBlock(),
]),
};
},
});
addKeyboardShortcuts() {
return {
'Enter': () => {
if (onEnterKeyPress) {
onEnterKeyPress();
}
return true;
},
}
},
});
@@ -1,5 +1,7 @@
import { CustomListItem } from "./custom-list-extension";
import { EnterKeyExtension } from "./enter-key-extension";
export const LiteTextEditorExtensions = (onEnterKeyPress?: () => void) => [
CustomListItem,
EnterKeyExtension(onEnterKeyPress),
];
+137 -140
View File
@@ -16,6 +16,143 @@ module.exports = {
],
},
theme: {
// scale down font sizes to 90% of default
fontSize: {
xs: "0.675rem",
sm: "0.7875rem",
base: "0.9rem",
lg: "1.0125rem",
xl: "1.125rem",
"2xl": "1.35rem",
"3xl": "1.6875rem",
"4xl": "2.25rem",
"5xl": "2.7rem",
"6xl": "3.375rem",
"7xl": "4.05rem",
"8xl": "5.4rem",
"9xl": "7.2rem",
},
// scale down spacing to 90% of default
padding: {
0: "0",
0.5: "0.1125rem",
1: "0.225rem",
1.5: "0.3375rem",
2: "0.45rem",
2.5: "0.5625rem",
3: "0.675rem",
3.5: "0.7875rem",
4: "0.9rem",
5: "1.125rem",
6: "1.35rem",
7: "1.575rem",
8: "1.8rem",
9: "2.025rem",
10: "2.25rem",
11: "2.475rem",
12: "2.7rem",
16: "3.6rem",
20: "4.5rem",
24: "5.4rem",
32: "7.2rem",
40: "9rem",
48: "10.8rem",
56: "12.6rem",
64: "14.4rem",
72: "16.2rem",
80: "18rem",
96: "21.6rem",
},
margin: {
0: "0",
0.5: "0.1125rem",
1: "0.225rem",
1.5: "0.3375rem",
2: "0.45rem",
2.5: "0.5625rem",
3: "0.675rem",
3.5: "0.7875rem",
4: "0.9rem",
5: "1.125rem",
6: "1.35rem",
7: "1.575rem",
8: "1.8rem",
9: "2.025rem",
10: "2.25rem",
11: "2.475rem",
12: "2.7rem",
16: "3.6rem",
20: "4.5rem",
24: "5.4rem",
32: "7.2rem",
40: "9rem",
48: "10.8rem",
56: "12.6rem",
64: "14.4rem",
72: "16.2rem",
80: "18rem",
96: "21.6rem",
},
space: {
0: "0",
0.5: "0.1125rem",
1: "0.225rem",
1.5: "0.3375rem",
2: "0.45rem",
2.5: "0.5625rem",
3: "0.675rem",
3.5: "0.7875rem",
4: "0.9rem",
5: "1.125rem",
6: "1.35rem",
7: "1.575rem",
8: "1.8rem",
9: "2.025rem",
10: "2.25rem",
11: "2.475rem",
12: "2.7rem",
16: "3.6rem",
20: "4.5rem",
24: "5.4rem",
32: "7.2rem",
40: "9rem",
48: "10.8rem",
56: "12.6rem",
64: "14.4rem",
72: "16.2rem",
80: "18rem",
96: "21.6rem",
},
gap: {
0: "0",
0.5: "0.1125rem",
1: "0.225rem",
1.5: "0.3375rem",
2: "0.45rem",
2.5: "0.5625rem",
3: "0.675rem",
3.5: "0.7875rem",
4: "0.9rem",
5: "1.125rem",
6: "1.35rem",
7: "1.575rem",
8: "1.8rem",
9: "2.025rem",
10: "2.25rem",
11: "2.475rem",
12: "2.7rem",
16: "3.6rem",
20: "4.5rem",
24: "5.4rem",
32: "7.2rem",
40: "9rem",
48: "10.8rem",
56: "12.6rem",
64: "14.4rem",
72: "16.2rem",
80: "18rem",
96: "21.6rem",
},
extend: {
boxShadow: {
"custom-shadow-2xs": "var(--color-shadow-2xs)",
@@ -213,146 +350,6 @@ module.exports = {
},
},
}),
screens: {
"3xl": "1792px",
},
// scale down font sizes to 90% of default
fontSize: {
xs: "0.675rem",
sm: "0.7875rem",
base: "0.9rem",
lg: "1.0125rem",
xl: "1.125rem",
"2xl": "1.35rem",
"3xl": "1.6875rem",
"4xl": "2.25rem",
"5xl": "2.7rem",
"6xl": "3.375rem",
"7xl": "4.05rem",
"8xl": "5.4rem",
"9xl": "7.2rem",
},
// scale down spacing to 90% of default
padding: {
0: "0",
0.5: "0.1125rem",
1: "0.225rem",
1.5: "0.3375rem",
2: "0.45rem",
2.5: "0.5625rem",
3: "0.675rem",
3.5: "0.7875rem",
4: "0.9rem",
5: "1.125rem",
6: "1.35rem",
7: "1.575rem",
8: "1.8rem",
9: "2.025rem",
10: "2.25rem",
11: "2.475rem",
12: "2.7rem",
16: "3.6rem",
20: "4.5rem",
24: "5.4rem",
32: "7.2rem",
40: "9rem",
48: "10.8rem",
56: "12.6rem",
64: "14.4rem",
72: "16.2rem",
80: "18rem",
96: "21.6rem",
},
margin: {
0: "0",
0.5: "0.1125rem",
1: "0.225rem",
1.5: "0.3375rem",
2: "0.45rem",
2.5: "0.5625rem",
3: "0.675rem",
3.5: "0.7875rem",
4: "0.9rem",
5: "1.125rem",
6: "1.35rem",
7: "1.575rem",
8: "1.8rem",
9: "2.025rem",
10: "2.25rem",
11: "2.475rem",
12: "2.7rem",
16: "3.6rem",
20: "4.5rem",
24: "5.4rem",
32: "7.2rem",
40: "9rem",
48: "10.8rem",
56: "12.6rem",
64: "14.4rem",
72: "16.2rem",
80: "18rem",
96: "21.6rem",
},
space: {
0: "0",
0.5: "0.1125rem",
1: "0.225rem",
1.5: "0.3375rem",
2: "0.45rem",
2.5: "0.5625rem",
3: "0.675rem",
3.5: "0.7875rem",
4: "0.9rem",
5: "1.125rem",
6: "1.35rem",
7: "1.575rem",
8: "1.8rem",
9: "2.025rem",
10: "2.25rem",
11: "2.475rem",
12: "2.7rem",
16: "3.6rem",
20: "4.5rem",
24: "5.4rem",
32: "7.2rem",
40: "9rem",
48: "10.8rem",
56: "12.6rem",
64: "14.4rem",
72: "16.2rem",
80: "18rem",
96: "21.6rem",
},
gap: {
0: "0",
0.5: "0.1125rem",
1: "0.225rem",
1.5: "0.3375rem",
2: "0.45rem",
2.5: "0.5625rem",
3: "0.675rem",
3.5: "0.7875rem",
4: "0.9rem",
5: "1.125rem",
6: "1.35rem",
7: "1.575rem",
8: "1.8rem",
9: "2.025rem",
10: "2.25rem",
11: "2.475rem",
12: "2.7rem",
16: "3.6rem",
20: "4.5rem",
24: "5.4rem",
32: "7.2rem",
40: "9rem",
48: "10.8rem",
56: "12.6rem",
64: "14.4rem",
72: "16.2rem",
80: "18rem",
96: "21.6rem",
},
},
fontFamily: {
custom: ["Inter", "sans-serif"],
+2 -3
View File
@@ -10,13 +10,12 @@
"dist/**"
],
"scripts": {
"build": "tsup src/index.ts --format esm,cjs --dts --external react",
"dev": "tsup src/index.ts --format esm,cjs --watch --dts --external react",
"build": "tsup src/index.tsx --format esm,cjs --dts --external react",
"dev": "tsup src/index.tsx --format esm,cjs --watch --dts --external react",
"lint": "eslint src/",
"clean": "rm -rf .turbo && rm -rf node_modules && rm -rf dist"
},
"devDependencies": {
"@types/react-color": "^3.0.9",
"@types/node": "^20.5.2",
"@types/react": "18.2.0",
"@types/react-dom": "18.2.0",
-85
View File
@@ -1,85 +0,0 @@
import React from "react";
// ui
import { Tooltip } from "../tooltip";
// types
import { TAvatarSize, getSizeInfo, isAValidNumber } from "./avatar";
type Props = {
/**
* The children of the avatar group.
* These should ideally should be `Avatar` components
*/
children: React.ReactNode;
/**
* The maximum number of avatars to display.
* If the number of children exceeds this value, the additional avatars will be replaced by a count of the remaining avatars.
* @default 2
*/
max?: number;
/**
* Whether to show the tooltip or not
* @default true
*/
showTooltip?: boolean;
/**
* The size of the avatars
* Possible values: "sm", "md", "base", "lg"
* @default "md"
*/
size?: TAvatarSize;
};
export const AvatarGroup: React.FC<Props> = (props) => {
const { children, max = 2, showTooltip = true, size = "md" } = props;
// calculate total length of avatars inside the group
const totalAvatars = React.Children.toArray(children).length;
// slice the children to the maximum number of avatars
const avatars = React.Children.toArray(children).slice(0, max);
// assign the necessary props from the AvatarGroup component to the Avatar components
const avatarsWithUpdatedProps = avatars.map((avatar) => {
const updatedProps: Partial<Props> = {
showTooltip,
size,
};
return React.cloneElement(avatar as React.ReactElement, updatedProps);
});
// get size details based on the size prop
const sizeInfo = getSizeInfo(size);
return (
<div className={`flex ${sizeInfo.spacing}`}>
{avatarsWithUpdatedProps.map((avatar, index) => (
<div key={index} className="ring-1 ring-custom-border-200 rounded-full">
{avatar}
</div>
))}
{max < totalAvatars && (
<Tooltip
tooltipContent={`${totalAvatars} total`}
disabled={!showTooltip}
>
<div
className={`${
!isAValidNumber(size) ? sizeInfo.avatarSize : ""
} ring-1 ring-custom-border-200 bg-custom-primary-500 text-white rounded-full grid place-items-center text-[9px]`}
style={
isAValidNumber(size)
? {
width: `${size}px`,
height: `${size}px`,
}
: {}
}
>
+{totalAvatars - max}
</div>
</Tooltip>
)}
</div>
);
};
-168
View File
@@ -1,168 +0,0 @@
import React from "react";
// ui
import { Tooltip } from "../tooltip";
export type TAvatarSize = "sm" | "md" | "base" | "lg" | number;
type Props = {
/**
* The name of the avatar which will be displayed on the tooltip
*/
name?: string;
/**
* The background color if the avatar image fails to load
*/
fallbackBackgroundColor?: string;
/**
* The text to display if the avatar image fails to load
*/
fallbackText?: string;
/**
* The text color if the avatar image fails to load
*/
fallbackTextColor?: string;
/**
* Whether to show the tooltip or not
* @default true
*/
showTooltip?: boolean;
/**
* The size of the avatars
* Possible values: "sm", "md", "base", "lg"
* @default "md"
*/
size?: TAvatarSize;
/**
* The shape of the avatar
* Possible values: "circle", "square"
* @default "circle"
*/
shape?: "circle" | "square";
/**
* The source of the avatar image
*/
src?: string;
};
/**
* Get the size details based on the size prop
* @param size The size of the avatar
* @returns The size details
*/
export const getSizeInfo = (size: TAvatarSize) => {
switch (size) {
case "sm":
return {
avatarSize: "h-4 w-4",
fontSize: "text-xs",
spacing: "-space-x-1",
};
case "md":
return {
avatarSize: "h-5 w-5",
fontSize: "text-xs",
spacing: "-space-x-1",
};
case "base":
return {
avatarSize: "h-6 w-6",
fontSize: "text-sm",
spacing: "-space-x-1.5",
};
case "lg":
return {
avatarSize: "h-7 w-7",
fontSize: "text-sm",
spacing: "-space-x-1.5",
};
default:
return {
avatarSize: "h-5 w-5",
fontSize: "text-xs",
spacing: "-space-x-1",
};
}
};
/**
* Get the border radius based on the shape prop
* @param shape The shape of the avatar
* @returns The border radius
*/
export const getBorderRadius = (shape: "circle" | "square") => {
switch (shape) {
case "circle":
return "rounded-full";
case "square":
return "rounded-md";
default:
return "rounded-full";
}
};
/**
* Check if the value is a valid number
* @param value The value to check
* @returns Whether the value is a valid number or not
*/
export const isAValidNumber = (value: any) => {
return typeof value === "number" && !isNaN(value);
};
export const Avatar: React.FC<Props> = (props) => {
const {
name,
fallbackBackgroundColor,
fallbackText,
fallbackTextColor,
showTooltip = true,
size = "md",
shape = "circle",
src,
} = props;
// get size details based on the size prop
const sizeInfo = getSizeInfo(size);
return (
<Tooltip
tooltipContent={fallbackText ?? name ?? "?"}
disabled={!showTooltip}
>
<div
className={`${
!isAValidNumber(size) ? sizeInfo.avatarSize : ""
} overflow-hidden grid place-items-center ${getBorderRadius(shape)}`}
style={
isAValidNumber(size)
? {
height: `${size}px`,
width: `${size}px`,
}
: {}
}
>
{src ? (
<img
src={src}
className={`h-full w-full ${getBorderRadius(shape)}`}
alt={name}
/>
) : (
<div
className={`${
sizeInfo.fontSize
} grid place-items-center h-full w-full ${getBorderRadius(shape)}`}
style={{
backgroundColor:
fallbackBackgroundColor ?? "rgba(var(--color-primary-500))",
color: fallbackTextColor ?? "#ffffff",
}}
>
{name ? name[0].toUpperCase() : fallbackText ?? "?"}
</div>
)}
</div>
</Tooltip>
);
};
-2
View File
@@ -1,2 +0,0 @@
export * from "./avatar-group";
export * from "./avatar";
-1
View File
@@ -1,4 +1,3 @@
// FIXME: fix this!!!
import { Placement } from "@blueprintjs/popover2";
export interface IDropdownProps {
@@ -11,14 +11,12 @@ export interface InputColorPickerProps {
value: string | undefined;
onChange: (value: string) => void;
name: string;
className?: string;
style?: React.CSSProperties;
className: string;
placeholder: string;
}
export const InputColorPicker: React.FC<InputColorPickerProps> = (props) => {
const { value, hasError, onChange, name, className, style, placeholder } =
props;
const { value, hasError, onChange, name, className, placeholder } = props;
const [referenceElement, setReferenceElement] =
React.useState<HTMLButtonElement | null>(null);
@@ -34,12 +32,12 @@ export const InputColorPicker: React.FC<InputColorPickerProps> = (props) => {
onChange(hex);
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
onChange(e.target.value);
const handleInputChange = (value: any) => {
onChange(value);
};
return (
<div className="relative">
<div className="flex items-center justify-between rounded border border-custom-border-200 px-1">
<Input
id={name}
name={name}
@@ -48,14 +46,10 @@ export const InputColorPicker: React.FC<InputColorPickerProps> = (props) => {
onChange={handleInputChange}
hasError={hasError}
placeholder={placeholder}
className={`border-[0.5px] border-custom-border-200 ${className}`}
style={style}
className={`border-none ${className}`}
/>
<Popover
as="div"
className="absolute top-1/2 -translate-y-1/2 right-1 z-10"
>
<Popover as="div">
{({ open }) => {
if (open) {
}
@@ -66,26 +60,26 @@ export const InputColorPicker: React.FC<InputColorPickerProps> = (props) => {
ref={setReferenceElement}
variant="neutral-primary"
size="sm"
className="border-none !bg-transparent"
className="border-none !p-1.5"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
className="lucide lucide-palette"
>
<circle cx="13.5" cy="6.5" r=".5" />
<circle cx="17.5" cy="10.5" r=".5" />
<circle cx="8.5" cy="7.5" r=".5" />
<circle cx="6.5" cy="12.5" r=".5" />
<path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.926 0 1.648-.746 1.648-1.688 0-.437-.18-.835-.437-1.125-.29-.289-.438-.652-.438-1.125a1.64 1.64 0 0 1 1.668-1.668h1.996c3.051 0 5.555-2.503 5.555-5.554C21.965 6.012 17.461 2 12 2z" />
</svg>
{value && value !== "" ? (
<span
className="h-3.5 w-3.5 rounded"
style={{
backgroundColor: `${value}`,
}}
/>
) : (
<svg
width={14}
height={14}
viewBox="0 0 14 14"
stroke="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M0.8125 13.7508C0.65 13.7508 0.515625 13.6977 0.409375 13.5914C0.303125 13.4852 0.25 13.3508 0.25 13.1883V10.8258C0.25 10.7508 0.2625 10.682 0.2875 10.6195C0.3125 10.557 0.35625 10.4945 0.41875 10.432L7.31875 3.53203L6.34375 2.55703C6.24375 2.45703 6.19688 2.32891 6.20312 2.17266C6.20938 2.01641 6.2625 1.88828 6.3625 1.78828C6.4625 1.68828 6.59063 1.63828 6.74688 1.63828C6.90313 1.63828 7.03125 1.68828 7.13125 1.78828L8.4625 3.13828L11.125 0.475781C11.2625 0.338281 11.4094 0.269531 11.5656 0.269531C11.7219 0.269531 11.8688 0.338281 12.0063 0.475781L13.525 1.99453C13.6625 2.13203 13.7313 2.27891 13.7313 2.43516C13.7313 2.59141 13.6625 2.73828 13.525 2.87578L10.8625 5.53828L12.2125 6.88828C12.3125 6.98828 12.3625 7.11328 12.3625 7.26328C12.3625 7.41328 12.3125 7.53828 12.2125 7.63828C12.1125 7.73828 11.9844 7.78828 11.8281 7.78828C11.6719 7.78828 11.5438 7.73828 11.4438 7.63828L10.4688 6.68203L3.56875 13.582C3.50625 13.6445 3.44375 13.6883 3.38125 13.7133C3.31875 13.7383 3.25 13.7508 3.175 13.7508H0.8125ZM1.375 12.6258H3.00625L9.6625 5.96953L8.03125 4.33828L1.375 10.9945V12.6258ZM10.0563 4.75078L12.3813 2.42578L11.575 1.61953L9.25 3.94453L10.0563 4.75078Z" />
</svg>
)}
</Button>
</Popover.Button>
<Transition
@@ -1,6 +1,6 @@
import * as React from "react";
import { ISvgIcons } from "../type";
import { ISvgIcons } from "./type";
export const ContrastIcon: React.FC<ISvgIcons> = ({
className = "text-current",
@@ -1,20 +0,0 @@
import * as React from "react";
import { ISvgIcons } from "../type";
export const CircleDotFullIcon: React.FC<ISvgIcons> = ({
className = "text-current",
...rest
}) => (
<svg
viewBox="0 0 24 24"
className={`${className} stroke-2`}
stroke="currentColor"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...rest}
>
<circle cx="8.33333" cy="8.33333" r="5.33333" stroke-linecap="round" />
<circle cx="8.33333" cy="8.33333" r="4.33333" fill="currentColor" />
</svg>
);
@@ -1,33 +0,0 @@
import * as React from "react";
import { ContrastIcon } from "./contrast-icon";
import { CircleDotFullIcon } from "./circle-dot-full-icon";
import { CircleDotDashed, Circle } from "lucide-react";
import { CYCLE_GROUP_COLORS, ICycleGroupIcon } from "./helper";
const iconComponents = {
current: ContrastIcon,
upcoming: CircleDotDashed,
completed: CircleDotFullIcon,
draft: Circle,
};
export const CycleGroupIcon: React.FC<ICycleGroupIcon> = ({
className = "",
color,
cycleGroup,
height = "12px",
width = "12px",
}) => {
const CycleIconComponent = iconComponents[cycleGroup] || ContrastIcon;
return (
<CycleIconComponent
height={height}
width={width}
color={color ?? CYCLE_GROUP_COLORS[cycleGroup]}
className={`flex-shrink-0 ${className}`}
/>
);
};
-18
View File
@@ -1,18 +0,0 @@
export interface ICycleGroupIcon {
className?: string;
color?: string;
cycleGroup: TCycleGroups;
height?: string;
width?: string;
}
export type TCycleGroups = "current" | "upcoming" | "completed" | "draft";
export const CYCLE_GROUP_COLORS: {
[key in TCycleGroups]: string;
} = {
current: "#F59E0B",
upcoming: "#3F76FF",
completed: "#16A34A",
draft: "#525252",
};
-5
View File
@@ -1,5 +0,0 @@
export * from "./double-circle-icon";
export * from "./circle-dot-full-icon";
export * from "./contrast-icon";
export * from "./circle-dot-full-icon";
export * from "./cycle-group-icon";
@@ -1,6 +1,6 @@
import * as React from "react";
import { ISvgIcons } from "../type";
import { ISvgIcons } from "./type";
export const DoubleCircleIcon: React.FC<ISvgIcons> = ({
className = "text-current",
+2 -1
View File
@@ -1,4 +1,5 @@
export * from "./user-group-icon";
export * from "./contrast-icon";
export * from "./dice-icon";
export * from "./layers-icon";
export * from "./photo-filter-icon";
@@ -6,6 +7,7 @@ export * from "./archive-icon";
export * from "./admin-profile-icon";
export * from "./create-icon";
export * from "./subscribe-icon";
export * from "./double-circle-icon";
export * from "./external-link-icon";
export * from "./copy-icon";
export * from "./layer-stack";
@@ -18,7 +20,6 @@ export * from "./blocked-icon";
export * from "./blocker-icon";
export * from "./related-icon";
export * from "./module";
export * from "./cycle";
export * from "./github-icon";
export * from "./discord-icon";
export * from "./transfer-icon";
@@ -1,10 +1,9 @@
export * from "./avatar";
export * from "./breadcrumbs";
export * from "./button";
export * from "./dropdowns";
export * from "./form-fields";
export * from "./icons";
export * from "./progress";
export * from "./spinners";
export * from "./tooltip";
export * from "./loader";
export * from "./tooltip";
export * from "./icons";
export * from "./breadcrumbs";
export * from "./dropdowns";
@@ -1,102 +0,0 @@
import React, { Children } from "react";
interface ICircularProgressIndicator {
size: number;
percentage: number;
strokeWidth?: number;
strokeColor?: string;
children?: React.ReactNode;
}
export const CircularProgressIndicator: React.FC<ICircularProgressIndicator> = (
props
) => {
const { size = 40, percentage = 25, strokeWidth = 6, children } = props;
const sqSize = size;
const radius = (size - strokeWidth) / 2;
const viewBox = `0 0 ${sqSize} ${sqSize}`;
const dashArray = radius * Math.PI * 2;
const dashOffset = dashArray - (dashArray * percentage) / 100;
return (
<div className="relative">
<svg width={size} height={size} viewBox={viewBox} fill="none">
<circle
className="fill-none stroke-custom-background-80"
cx={size / 2}
cy={size / 2}
r={radius}
strokeWidth={`${strokeWidth}px`}
style={{ filter: "url(#filter0_bi_377_19141)" }}
/>
<defs>
<filter
id="filter0_bi_377_19141"
x="-3.57544"
y="-3.57422"
width="45.2227"
height="45.2227"
filterUnits="userSpaceOnUse"
color-interpolation-filters="sRGB"
>
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feGaussianBlur in="BackgroundImageFix" stdDeviation="2" />
<feComposite
in2="SourceAlpha"
operator="in"
result="effect1_backgroundBlur_377_19141"
/>
<feBlend
mode="normal"
in="SourceGraphic"
in2="effect1_backgroundBlur_377_19141"
result="shape"
/>
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset dx="1" dy="1" />
<feGaussianBlur stdDeviation="2" />
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
<feColorMatrix
type="matrix"
values="0 0 0 0 0.63125 0 0 0 0 0.6625 0 0 0 0 0.75 0 0 0 0.35 0"
/>
<feBlend
mode="normal"
in2="shape"
result="effect2_innerShadow_377_19141"
/>
</filter>
</defs>
<circle
className="stroke-custom-primary-100 fill-none "
cx={size / 2}
cy={size / 2}
r={radius}
strokeWidth={`${strokeWidth}px`}
transform={`rotate(-90 ${size / 2} ${size / 2})`}
style={{
strokeDasharray: dashArray,
strokeDashoffset: dashOffset,
}}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
<div
className="absolute"
style={{
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
}}
>
{children}
</div>
</div>
);
};
-1
View File
@@ -1,4 +1,3 @@
export * from "./radial-progress";
export * from "./progress-bar";
export * from "./linear-progress-indicator";
export * from "./circular-progress-indicator";
@@ -1,11 +1,15 @@
import React from "react";
// mobx
import { observer } from "mobx-react-lite";
// headless ui
import { Listbox, Transition } from "@headlessui/react";
import { MoveRight } from "lucide-react";
// hooks
import useToast from "hooks/use-toast";
// ui
import { Icon } from "components/ui";
// icons
import { East } from "@mui/icons-material";
// helpers
import { copyTextToClipboard } from "helpers/string.helper";
// store
@@ -40,7 +44,7 @@ const peekModes: {
];
export const PeekOverviewHeader: React.FC<Props> = observer((props) => {
const { handleClose } = props;
const { handleClose, issueDetails } = props;
const { issueDetails: issueDetailStore }: RootStore = useMobxStore();
@@ -64,7 +68,11 @@ export const PeekOverviewHeader: React.FC<Props> = observer((props) => {
<div className="flex items-center gap-4">
{issueDetailStore.peekMode === "side" && (
<button type="button" onClick={handleClose}>
<MoveRight className="h-3.5 w-3.5" strokeWidth={2} />
<East
sx={{
fontSize: "14px",
}}
/>
</button>
)}
<Listbox
-3
View File
@@ -8,9 +8,6 @@ const nextConfig = {
experimental: {
outputFileTracingRoot: path.join(__dirname, "../"),
},
images: {
unoptimized: true,
},
output: "standalone",
};
+2 -4
View File
@@ -7,8 +7,7 @@
"develop": "next dev -p 4000",
"build": "next build",
"start": "next start -p 4000",
"lint": "next lint",
"export": "next export"
"lint": "next lint"
},
"dependencies": {
"@blueprintjs/core": "^4.16.3",
@@ -16,9 +15,8 @@
"@emotion/react": "^11.11.1",
"@emotion/styled": "^11.11.0",
"@headlessui/react": "^1.7.13",
"@mui/icons-material": "^5.14.1",
"@mui/material": "^5.14.1",
"@plane/ui": "*",
"@plane/lite-text-editor": "*",
"@plane/rich-text-editor": "*",
"axios": "^1.3.4",
"clsx": "^2.0.0",
@@ -144,7 +144,7 @@ export const CommandModal: React.FC<Props> = (props) => {
} else {
updatedAssignees.push(assignee);
}
updateIssue({ assignees: updatedAssignees });
updateIssue({ assignees_list: updatedAssignees });
};
const redirect = (path: string) => {
@@ -8,7 +8,7 @@ import useUser from "hooks/use-user";
// components
import { CommandModal, ShortcutsModal } from "components/command-palette";
import { BulkDeleteIssuesModal } from "components/core";
import { CycleCreateUpdateModal } from "components/cycles";
import { CreateUpdateCycleModal } from "components/cycles";
import { CreateUpdateIssueModal, DeleteIssueModal } from "components/issues";
import { CreateUpdateModuleModal } from "components/modules";
import { CreateProjectModal } from "components/project";
@@ -180,7 +180,7 @@ export const CommandPalette: FC = observer(() => {
)}
{workspaceSlug && projectId && (
<>
<CycleCreateUpdateModal
<CreateUpdateCycleModal
isOpen={isCreateCycleModalOpen}
handleClose={() => toggleCreateCycleModal(false)}
workspaceSlug={workspaceSlug.toString()}
@@ -9,7 +9,7 @@ import useProjectMembers from "hooks/use-project-members";
// constants
import { ISSUE_DETAILS, PROJECT_ISSUES_ACTIVITY } from "constants/fetch-keys";
// ui
import { Avatar } from "@plane/ui";
import { Avatar } from "components/ui";
// icons
import { Check } from "lucide-react";
// types
@@ -31,13 +31,13 @@ export const ChangeIssueAssignee: FC<Props> = ({ setIsPaletteOpen, issue, user }
const { members } = useProjectMembers(workspaceSlug as string, projectId as string);
const options =
members?.map(({ member }) => ({
members?.map(({ member }: any) => ({
value: member.id,
query: member.display_name,
content: (
<>
<div className="flex items-center gap-2">
<Avatar name={member.display_name} src={member.avatar} showTooltip={false} />
<Avatar user={member} />
{member.display_name}
</div>
{issue.assignees.includes(member.id) && (
@@ -79,7 +79,7 @@ export const ChangeIssueAssignee: FC<Props> = ({ setIsPaletteOpen, issue, user }
);
const handleIssueAssignees = (assignee: string) => {
const updatedAssignees = issue.assignees ?? [];
const updatedAssignees = issue.assignees_list ?? [];
if (updatedAssignees.includes(assignee)) {
updatedAssignees.splice(updatedAssignees.indexOf(assignee), 1);
@@ -87,7 +87,7 @@ export const ChangeIssueAssignee: FC<Props> = ({ setIsPaletteOpen, issue, user }
updatedAssignees.push(assignee);
}
updateIssue({ assignees: updatedAssignees });
updateIssue({ assignees_list: updatedAssignees });
setIsPaletteOpen(false);
};
+8 -4
View File
@@ -1,14 +1,18 @@
import React from "react";
import { X } from "lucide-react";
// icons
import { PriorityIcon, StateGroupIcon } from "@plane/ui";
// ui
import { Avatar, PriorityIcon, StateGroupIcon } from "@plane/ui";
import { Avatar } from "components/ui";
// helpers
import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper";
// helpers
import { renderShortDateWithYearFormat } from "helpers/date-time.helper";
// types
import { IIssueFilterOptions, IIssueLabels, IState, IUserLite, TStateGroups } from "types";
// constants
import { STATE_GROUP_COLORS } from "constants/state";
import { X } from "lucide-react";
type Props = {
filters: Partial<IIssueFilterOptions>;
@@ -145,7 +149,7 @@ export const FiltersList: React.FC<Props> = ({ filters, setFilters, clearAllFilt
key={memberId}
className="inline-flex items-center gap-x-1 rounded-full bg-custom-background-90 px-1"
>
<Avatar name={member?.display_name} src={member?.avatar} showTooltip={false} />
<Avatar user={member} />
<span>{member?.display_name}</span>
<span
className="cursor-pointer"
@@ -169,7 +173,7 @@ export const FiltersList: React.FC<Props> = ({ filters, setFilters, clearAllFilt
key={`${memberId}-${key}`}
className="inline-flex items-center gap-x-1 rounded-full bg-custom-background-90 px-1 capitalize"
>
<Avatar name={member?.display_name} src={member?.avatar} />
<Avatar user={member} />
<span>{member?.display_name}</span>
<span
className="cursor-pointer"
+1
View File
@@ -1,3 +1,4 @@
export * from "./date-filter-modal";
export * from "./date-filter-select";
export * from "./filters-list";
export * from "./workspace-filters-list";
@@ -0,0 +1,347 @@
import { FC } from "react";
// icons
import { X } from "lucide-react";
import { PriorityIcon, StateGroupIcon } from "@plane/ui";
// ui
import { Avatar } from "components/ui";
// helpers
import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper";
// helpers
import { renderShortDateWithYearFormat } from "helpers/date-time.helper";
// types
import { IIssueLabels, IProject, IUserLite, IWorkspaceIssueFilterOptions, TStateGroups } from "types";
// constants
import { STATE_GROUP_COLORS } from "constants/state";
type Props = {
filters: Partial<IWorkspaceIssueFilterOptions>;
setFilters: (updatedFilter: Partial<IWorkspaceIssueFilterOptions>) => void;
clearAllFilters: (...args: any) => void;
labels: IIssueLabels[] | undefined;
members: IUserLite[] | undefined;
stateGroup: string[] | undefined;
project?: IProject[] | undefined;
};
export const WorkspaceFiltersList: FC<Props> = (props) => {
const { filters, setFilters, clearAllFilters, labels, members, project } = props;
if (!filters) return <></>;
const nullFilters = Object.keys(filters).filter((key) => filters[key as keyof IWorkspaceIssueFilterOptions] === null);
return (
<div className="flex flex-1 flex-wrap items-center gap-2 text-xs">
{Object.keys(filters).map((filterKey) => {
const key = filterKey as keyof typeof filters;
if (filters[key] === null || (filters[key]?.length ?? 0) <= 0) return null;
return (
<div
key={key}
className="flex items-center gap-x-2 rounded-full border border-custom-border-200 bg-custom-background-80 px-2 py-1"
>
<span className="capitalize text-custom-text-200">
{key === "target_date" ? "Due Date" : replaceUnderscoreIfSnakeCase(key)}:
</span>
{filters[key] === null || (filters[key]?.length ?? 0) <= 0 ? (
<span className="inline-flex items-center px-2 py-0.5 font-medium">None</span>
) : Array.isArray(filters[key]) ? (
<div className="space-x-2">
<div className="flex flex-wrap items-center gap-1">
{key === "state_group"
? filters.state_group?.map((stateGroup) => {
const group = stateGroup as TStateGroups;
return (
<p
key={group}
className="inline-flex items-center gap-x-1 rounded-full px-2 py-0.5 capitalize"
style={{
color: STATE_GROUP_COLORS[group],
backgroundColor: `${STATE_GROUP_COLORS[group]}20`,
}}
>
<span>
<StateGroupIcon stateGroup={group} color={undefined} />
</span>
<span>{group}</span>
<span
className="cursor-pointer"
onClick={() =>
setFilters({
state_group: filters.state_group?.filter((g) => g !== group),
})
}
>
<X className="h-3 w-3" />
</span>
</p>
);
})
: key === "priority"
? filters.priority?.map((priority: any) => (
<p
key={priority}
className={`inline-flex items-center gap-x-1 rounded-full px-2 py-0.5 capitalize ${
priority === "urgent"
? "bg-red-500/20 text-red-500"
: priority === "high"
? "bg-orange-500/20 text-orange-500"
: priority === "medium"
? "bg-yellow-500/20 text-yellow-500"
: priority === "low"
? "bg-green-500/20 text-green-500"
: "bg-custom-background-90 text-custom-text-200"
}`}
>
<span>
<PriorityIcon priority={priority} />
</span>
<span>{priority === "null" ? "None" : priority}</span>
<span
className="cursor-pointer"
onClick={() =>
setFilters({
priority: filters.priority?.filter((p: any) => p !== priority),
})
}
>
<X className="h-3 w-3" />
</span>
</p>
))
: key === "assignees"
? filters.assignees?.map((memberId: string) => {
const member = members?.find((m) => m.id === memberId);
return (
<div
key={memberId}
className="inline-flex items-center gap-x-1 rounded-full bg-custom-background-90 px-1"
>
<Avatar user={member} />
<span>{member?.display_name}</span>
<span
className="cursor-pointer"
onClick={() =>
setFilters({
assignees: filters.assignees?.filter((p: any) => p !== memberId),
})
}
>
<X className="h-3 w-3" />
</span>
</div>
);
})
: key === "subscriber"
? filters.subscriber?.map((memberId: string) => {
const member = members?.find((m) => m.id === memberId);
return (
<div
key={memberId}
className="inline-flex items-center gap-x-1 rounded-full bg-custom-background-90 px-1"
>
<Avatar user={member} />
<span>{member?.display_name}</span>
<span
className="cursor-pointer"
onClick={() =>
setFilters({
assignees: filters.assignees?.filter((p: any) => p !== memberId),
})
}
>
<X className="h-3 w-3" />
</span>
</div>
);
})
: key === "created_by"
? filters.created_by?.map((memberId: string) => {
const member = members?.find((m) => m.id === memberId);
return (
<div
key={`${memberId}-${key}`}
className="inline-flex items-center gap-x-1 rounded-full bg-custom-background-90 px-1 capitalize"
>
<Avatar user={member} />
<span>{member?.display_name}</span>
<span
className="cursor-pointer"
onClick={() =>
setFilters({
created_by: filters.created_by?.filter((p: any) => p !== memberId),
})
}
>
<X className="h-3 w-3" />
</span>
</div>
);
})
: key === "labels"
? filters.labels?.map((labelId: string) => {
const label = labels?.find((l) => l.id === labelId);
if (!label) return null;
const color = label.color !== "" ? label.color : "#0f172a";
return (
<div
className="inline-flex items-center gap-x-1 rounded-full px-2 py-0.5"
style={{
color: color,
backgroundColor: `${color}20`, // add 20% opacity
}}
key={labelId}
>
<div
className="h-1.5 w-1.5 rounded-full"
style={{
backgroundColor: color,
}}
/>
<span>{label.name}</span>
<span
className="cursor-pointer"
onClick={() =>
setFilters({
labels: filters.labels?.filter((l: any) => l !== labelId),
})
}
>
<X
className="h-3 w-3"
style={{
color: color,
}}
/>
</span>
</div>
);
})
: key === "start_date"
? filters.start_date?.map((date: string) => {
if (filters.start_date && filters.start_date.length <= 0) return null;
const splitDate = date.split(";");
return (
<div
key={date}
className="inline-flex items-center gap-x-1 rounded-full border border-custom-border-200 bg-custom-background-100 px-1 py-0.5"
>
<div className="h-1.5 w-1.5 rounded-full" />
<span className="capitalize">
{splitDate[1]} {renderShortDateWithYearFormat(splitDate[0])}
</span>
<span
className="cursor-pointer"
onClick={() =>
setFilters({
start_date: filters.start_date?.filter((d: any) => d !== date),
})
}
>
<X className="h-3 w-3" />
</span>
</div>
);
})
: key === "target_date"
? filters.target_date?.map((date: string) => {
if (filters.target_date && filters.target_date.length <= 0) return null;
const splitDate = date.split(";");
return (
<div
key={date}
className="inline-flex items-center gap-x-1 rounded-full border border-custom-border-200 bg-custom-background-100 px-1 py-0.5"
>
<div className="h-1.5 w-1.5 rounded-full" />
<span className="capitalize">
{splitDate[1]} {renderShortDateWithYearFormat(splitDate[0])}
</span>
<span
className="cursor-pointer"
onClick={() =>
setFilters({
target_date: filters.target_date?.filter((d: any) => d !== date),
})
}
>
<X className="h-3 w-3" />
</span>
</div>
);
})
: key === "project"
? filters.project?.map((projectId) => {
const currentProject = project?.find((p) => p.id === projectId);
return (
<p
key={currentProject?.id}
className="inline-flex items-center gap-x-1 rounded-full px-2 py-0.5 capitalize"
>
<span>{currentProject?.name}</span>
<span
className="cursor-pointer"
onClick={() =>
setFilters({
project: filters.project?.filter((p) => p !== projectId),
})
}
>
<X className="h-3 w-3" />
</span>
</p>
);
})
: (filters[key] as any)?.join(", ")}
<button
type="button"
onClick={() =>
setFilters({
[key]: null,
})
}
>
<X className="h-3 w-3" />
</button>
</div>
</div>
) : (
<div className="flex items-center gap-x-1 capitalize">
{filters[key as keyof typeof filters]}
<button
type="button"
onClick={() =>
setFilters({
[key]: null,
})
}
>
<X className="h-3 w-3" />
</button>
</div>
)}
</div>
);
})}
{Object.keys(filters).length > 0 && nullFilters.length !== Object.keys(filters).length && (
<button
type="button"
onClick={clearAllFilters}
className="flex items-center gap-x-1 rounded-full border border-custom-border-200 bg-custom-background-80 px-3 py-1.5 text-xs"
>
<span>Clear all filters</span>
<X className="h-3 w-3" />
</button>
)}
</div>
);
};
+48 -53
View File
@@ -15,63 +15,58 @@ type Props = {
export const LinksList: React.FC<Props> = ({ links, handleDeleteLink, handleEditLink, userAuth }) => {
const isNotAllowed = userAuth.isGuest || userAuth.isViewer;
return (
<>
{links.map((link) => (
<div key={link.id} className="relative flex flex-col rounded-md bg-custom-background-90 p-2.5">
<div className="flex items-start justify-between gap-2 w-full">
<div className="flex items-start gap-2">
<span className="py-1">
<LinkIcon className="h-3 w-3 flex-shrink-0" />
</span>
<span className="text-xs break-all">{link.title && link.title !== "" ? link.title : link.url}</span>
<div key={link.id} className="relative">
{!isNotAllowed && (
<div className="absolute top-1.5 right-1.5 z-[1] flex items-center gap-1">
<button
type="button"
className="grid h-7 w-7 place-items-center rounded bg-custom-background-90 p-1 outline-none hover:bg-custom-background-80"
onClick={() => handleEditLink(link)}
>
<Pencil className="text-custom-text-200" />
</button>
<a
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="grid h-7 w-7 place-items-center rounded bg-custom-background-90 p-1 outline-none hover:bg-custom-background-80"
>
<ExternalLinkIcon className="h-4 w-4 text-custom-text-200" />
</a>
<button
type="button"
className="grid h-7 w-7 place-items-center rounded bg-custom-background-90 p-1 text-red-500 outline-none duration-300 hover:bg-red-500/20"
onClick={() => handleDeleteLink(link.id)}
>
<Trash2 className="h-4 w-4" />
</button>
</div>
{!isNotAllowed && (
<div className="flex items-center gap-2 flex-shrink-0 z-[1]">
<button
type="button"
className="flex items-center justify-center p-1 hover:bg-custom-background-80"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleEditLink(link);
}}
>
<Pencil className="h-3 w-3 text-custom-text-200 stroke-[1.5]" />
</button>
<a
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center p-1 hover:bg-custom-background-80"
>
<ExternalLinkIcon className="h-3 w-3 text-custom-text-200 stroke-[1.5]" />
</a>
<button
type="button"
className="flex items-center justify-center p-1 hover:bg-custom-background-80"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleDeleteLink(link.id);
}}
>
<Trash2 className="h-3 w-3" />
</button>
</div>
)}
</div>
<div className="px-5">
<p className="text-xs mt-0.5 text-custom-text-300 stroke-[1.5]">
Added {timeAgo(link.created_at)}
<br />
by{" "}
{link.created_by_detail.is_bot
? link.created_by_detail.first_name + " Bot"
: link.created_by_detail.display_name}
</p>
</div>
)}
<a
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="relative flex gap-2 rounded-md bg-custom-background-90 p-2"
>
<div className="mt-0.5">
<LinkIcon className="h-3.5 w-3.5" />
</div>
<div>
<h5 className="w-4/5 break-words">{link.title ?? link.url}</h5>
<p className="mt-0.5 text-custom-text-200">
Added {timeAgo(link.created_at)}
<br />
by{" "}
{link.created_by_detail.is_bot
? link.created_by_detail.first_name + " Bot"
: link.created_by_detail.display_name}
</p>
</div>
</a>
</div>
))}
</>
@@ -1,18 +1,14 @@
import React from "react";
import Image from "next/image";
// headless ui
import { Tab } from "@headlessui/react";
// hooks
import useLocalStorage from "hooks/use-local-storage";
import useIssuesView from "hooks/use-issues-view";
// images
import emptyLabel from "public/empty-state/empty_label.svg";
import emptyMembers from "public/empty-state/empty_members.svg";
// components
import { SingleProgressStats } from "components/core";
// ui
import { Avatar, StateGroupIcon } from "@plane/ui";
import { Avatar } from "components/ui";
// types
import {
IModule,
@@ -21,7 +17,9 @@ import {
TLabelsDistribution,
TStateGroups,
} from "types";
// constants
import { STATE_GROUP_COLORS } from "constants/state";
// types
type Props = {
distribution: {
assignees: TAssigneesDistribution[];
@@ -35,7 +33,6 @@ type Props = {
module?: IModule;
roundedTab?: boolean;
noBackground?: boolean;
isPeekView?: boolean;
};
export const SidebarProgressStats: React.FC<Props> = ({
@@ -45,7 +42,6 @@ export const SidebarProgressStats: React.FC<Props> = ({
module,
roundedTab,
noBackground,
isPeekView = false,
}) => {
const { filters, setFilters } = useIssuesView();
@@ -59,6 +55,7 @@ export const SidebarProgressStats: React.FC<Props> = ({
return 1;
case "States":
return 2;
default:
return 0;
}
@@ -75,6 +72,7 @@ export const SidebarProgressStats: React.FC<Props> = ({
return setTab("Labels");
case 2:
return setTab("States");
default:
return setTab("Assignees");
}
@@ -84,17 +82,15 @@ export const SidebarProgressStats: React.FC<Props> = ({
as="div"
className={`flex w-full items-center gap-2 justify-between rounded-md ${
noBackground ? "" : "bg-custom-background-90"
} p-0.5
${module ? "text-xs" : "text-sm"}`}
} px-1 py-1.5
${module ? "text-xs" : "text-sm"} `}
>
<Tab
className={({ selected }) =>
`w-full ${
roundedTab ? "rounded-3xl border border-custom-border-200" : "rounded"
} px-3 py-1 text-custom-text-100 ${
selected
? "bg-custom-background-100 text-custom-text-300 shadow-custom-shadow-2xs"
: "text-custom-text-400 hover:text-custom-text-300"
selected ? " bg-custom-primary text-white" : " hover:bg-custom-background-80"
}`
}
>
@@ -105,9 +101,7 @@ export const SidebarProgressStats: React.FC<Props> = ({
`w-full ${
roundedTab ? "rounded-3xl border border-custom-border-200" : "rounded"
} px-3 py-1 text-custom-text-100 ${
selected
? "bg-custom-background-100 text-custom-text-300 shadow-custom-shadow-2xs"
: "text-custom-text-400 hover:text-custom-text-300"
selected ? " bg-custom-primary text-white" : " hover:bg-custom-background-80"
}`
}
>
@@ -118,118 +112,113 @@ export const SidebarProgressStats: React.FC<Props> = ({
`w-full ${
roundedTab ? "rounded-3xl border border-custom-border-200" : "rounded"
} px-3 py-1 text-custom-text-100 ${
selected
? "bg-custom-background-100 text-custom-text-300 shadow-custom-shadow-2xs"
: "text-custom-text-400 hover:text-custom-text-300"
selected ? " bg-custom-primary text-white" : " hover:bg-custom-background-80"
}`
}
>
States
</Tab>
</Tab.List>
<Tab.Panels className="flex w-full items-center justify-between text-custom-text-200">
<Tab.Panel as="div" className="flex flex-col gap-1.5 pt-3.5 w-full h-44 overflow-y-auto">
{distribution.assignees.length > 0 ? (
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}
{...(!isPeekView && {
onClick: () => {
if (filters?.assignees?.includes(assignee.assignee_id ?? ""))
setFilters({
assignees: filters?.assignees?.filter((a) => a !== assignee.assignee_id),
});
else
setFilters({
assignees: [...(filters?.assignees ?? []), assignee.assignee_id ?? ""],
});
},
selected: filters?.assignees?.includes(assignee.assignee_id ?? ""),
})}
/>
);
else
return (
<SingleProgressStats
key={`unassigned-${index}`}
title={
<div className="flex items-center gap-2">
<div className="h-4 w-4 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}
/>
);
})
) : (
<div className="flex flex-col items-center justify-center gap-2 h-full">
<div className="flex items-center justify-center h-20 w-20 bg-custom-background-80 rounded-full">
<Image src={emptyMembers} className="h-12 w-12" alt="empty members" />
</div>
<h6 className="text-base text-custom-text-300">No assignees yet</h6>
</div>
)}
</Tab.Panel>
<Tab.Panel as="div" className="flex flex-col gap-1.5 pt-3.5 w-full h-44 overflow-y-auto">
{distribution.labels.length > 0 ? (
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 ?? "transparent",
}}
/>
<span className="text-xs">{label.label_name ?? "No labels"}</span>
</div>
}
completed={label.completed_issues}
total={label.total_issues}
{...(!isPeekView && {
onClick: () => {
if (filters.labels?.includes(label.label_id ?? ""))
<Tab.Panels className="flex w-full items-center justify-between pt-1 text-custom-text-200">
<Tab.Panel as="div" className="w-full space-y-1">
{distribution.assignees.map((assignee, index) => {
if (assignee.assignee_id)
return (
<SingleProgressStats
key={assignee.assignee_id}
title={
<div className="flex items-center gap-2">
<Avatar
user={{
id: assignee.assignee_id,
avatar: assignee.avatar ?? "",
first_name: assignee.first_name ?? "",
last_name: assignee.last_name ?? "",
display_name: assignee.display_name ?? "",
}}
/>
<span>{assignee.display_name}</span>
</div>
}
completed={assignee.completed_issues}
total={assignee.total_issues}
onClick={() => {
if (filters?.assignees?.includes(assignee.assignee_id ?? ""))
setFilters({
labels: filters?.labels?.filter((l) => l !== label.label_id),
assignees: filters?.assignees?.filter((a) => a !== assignee.assignee_id),
});
else setFilters({ labels: [...(filters?.labels ?? []), label.label_id ?? ""] });
},
selected: filters?.labels?.includes(label.label_id ?? ""),
})}
/>
))
) : (
<div className="flex flex-col items-center justify-center gap-2 h-full">
<div className="flex items-center justify-center h-20 w-20 bg-custom-background-80 rounded-full">
<Image src={emptyLabel} className="h-12 w-12" alt="empty label" />
</div>
<h6 className="text-base text-custom-text-300">No labels yet</h6>
</div>
)}
else
setFilters({
assignees: [...(filters?.assignees ?? []), assignee.assignee_id ?? ""],
});
}}
selected={filters?.assignees?.includes(assignee.assignee_id ?? "")}
/>
);
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 flex-col gap-1.5 pt-3.5 w-full h-44 overflow-y-auto">
<Tab.Panel as="div" className="w-full space-y-1">
{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 ?? "transparent",
}}
/>
<span className="text-xs">{label.label_name ?? "No labels"}</span>
</div>
}
completed={label.completed_issues}
total={label.total_issues}
onClick={() => {
if (filters.labels?.includes(label.label_id ?? ""))
setFilters({
labels: filters?.labels?.filter((l) => l !== label.label_id),
});
else setFilters({ labels: [...(filters?.labels ?? []), label.label_id ?? ""] });
}}
selected={filters?.labels?.includes(label.label_id ?? "")}
/>
))}
</Tab.Panel>
<Tab.Panel as="div" className="w-full space-y-1">
{Object.keys(groupedIssues).map((group, index) => (
<SingleProgressStats
key={index}
title={
<div className="flex items-center gap-2">
<StateGroupIcon stateGroup={group as TStateGroups} />
<span
className="block h-3 w-3 rounded-full "
style={{
backgroundColor: STATE_GROUP_COLORS[group as TStateGroups],
}}
/>
<span className="text-xs capitalize">{group}</span>
</div>
}
@@ -1,6 +1,6 @@
import React from "react";
import { CircularProgressIndicator } from "@plane/ui";
import { ProgressBar } from "@plane/ui";
type TSingleProgressStatsProps = {
title: any;
@@ -27,7 +27,7 @@ export const SingleProgressStats: React.FC<TSingleProgressStatsProps> = ({
<div className="flex w-1/2 items-center justify-end gap-1 px-2">
<div className="flex h-5 items-center justify-center gap-1">
<span className="h-4 w-4">
<CircularProgressIndicator percentage={(completed / total) * 100} size={14} strokeWidth={2} />
<ProgressBar value={completed} maxValue={total} />
</span>
<span className="w-8 text-right">
{isNaN(Math.floor((completed / total) * 100)) ? "0" : Math.floor((completed / total) * 100)}%
@@ -1,40 +1,27 @@
import { observer } from "mobx-react-lite";
import { Controller, useForm } from "react-hook-form";
import { FC } from "react";
import { useTheme } from "next-themes";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
import { Controller, useForm } from "react-hook-form";
// ui
import { Button, InputColorPicker } from "@plane/ui";
// types
import { IUserTheme } from "types";
// mobx react lite
import { observer } from "mobx-react-lite";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
const inputRules = {
required: "Background color is required",
minLength: {
value: 7,
message: "Enter a valid hex code of 6 characters",
},
maxLength: {
value: 7,
message: "Enter a valid hex code of 6 characters",
},
pattern: {
value: /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/,
message: "Enter a valid hex code of 6 characters",
},
};
type Props = {};
export const CustomThemeSelector: React.FC = observer(() => {
export const CustomThemeSelector: FC<Props> = observer(() => {
const { user: userStore } = useMobxStore();
const userTheme = userStore?.currentUser?.theme;
// hooks
const { setTheme } = useTheme();
const {
control,
formState: { errors, isSubmitting },
handleSubmit,
watch,
control,
} = useForm<IUserTheme>({
defaultValues: {
background: userTheme?.background !== "" ? userTheme?.background : "#0d101b",
@@ -64,151 +51,100 @@ export const CustomThemeSelector: React.FC = observer(() => {
return userStore.updateCurrentUser({ theme: payload });
};
const handleValueChange = (val: string | undefined, onChange: any) => {
let hex = val;
// prepend a hashtag if it doesn't exist
if (val && val[0] !== "#") hex = `#${val}`;
onChange(hex);
};
return (
<form onSubmit={handleSubmit(handleUpdateTheme)}>
<div className="space-y-5">
<h3 className="text-lg font-semibold text-custom-text-100">Customize your theme</h3>
<div className="space-y-4">
<div className="grid grid-cols-1 gap-x-8 gap-y-4 sm:grid-cols-2 md:grid-cols-3">
<div className="grid grid-cols-1 gap-x-6 gap-y-4 sm:grid-cols-2 md:grid-cols-3">
<div className="flex flex-col items-start gap-2">
<h3 className="text-left text-sm font-medium text-custom-text-200">Background color</h3>
<div className="w-full">
<Controller
control={control}
name="background"
rules={inputRules}
render={({ field: { value, onChange } }) => (
<InputColorPicker
name="background"
value={value}
onChange={(val) => handleValueChange(val, onChange)}
placeholder="#0d101b"
className="w-full"
style={{
backgroundColor: value,
color: watch("text"),
}}
hasError={Boolean(errors?.background)}
/>
)}
/>
{errors.background && <p className="text-xs text-red-500 mt-1">{errors.background.message}</p>}
</div>
<Controller
control={control}
name="background"
render={({ field: { value, onChange } }) => (
<InputColorPicker
name="background"
value={value}
onChange={onChange}
className=""
placeholder="#ffffff"
hasError={Boolean(errors?.background)}
/>
)}
/>
</div>
<div className="flex flex-col items-start gap-2">
<h3 className="text-left text-sm font-medium text-custom-text-200">Text color</h3>
<div className="w-full">
<Controller
control={control}
name="text"
rules={inputRules}
render={({ field: { value, onChange } }) => (
<InputColorPicker
name="text"
value={value}
onChange={(val) => handleValueChange(val, onChange)}
placeholder="#c5c5c5"
className="w-full"
style={{
backgroundColor: watch("background"),
color: value,
}}
hasError={Boolean(errors?.text)}
/>
)}
/>
{errors.text && <p className="text-xs text-red-500 mt-1">{errors.text.message}</p>}
</div>
<Controller
control={control}
name="text"
render={({ field: { value, onChange } }) => (
<InputColorPicker
name="text"
value={value}
onChange={onChange}
className=""
placeholder="#ffffff"
hasError={Boolean(errors?.text)}
/>
)}
/>
</div>
<div className="flex flex-col items-start gap-2">
<h3 className="text-left text-sm font-medium text-custom-text-200">Primary(Theme) color</h3>
<div className="w-full">
<Controller
control={control}
name="primary"
rules={inputRules}
render={({ field: { value, onChange } }) => (
<InputColorPicker
name="primary"
value={value}
onChange={(val) => handleValueChange(val, onChange)}
placeholder="#3f76ff"
className="w-full"
style={{
backgroundColor: value,
color: watch("text"),
}}
hasError={Boolean(errors?.primary)}
/>
)}
/>
{errors.primary && <p className="text-xs text-red-500 mt-1">{errors.primary.message}</p>}
</div>
<Controller
control={control}
name="primary"
render={({ field: { value, onChange } }) => (
<InputColorPicker
name="primary"
value={value}
onChange={onChange}
className=""
placeholder="#ffffff"
hasError={Boolean(errors?.primary)}
/>
)}
/>
</div>
<div className="flex flex-col items-start gap-2">
<h3 className="text-left text-sm font-medium text-custom-text-200">Sidebar background color</h3>
<div className="w-full">
<Controller
control={control}
name="sidebarBackground"
rules={inputRules}
render={({ field: { value, onChange } }) => (
<InputColorPicker
name="sidebarBackground"
value={value}
onChange={(val) => handleValueChange(val, onChange)}
placeholder="#0d101b"
className="w-full"
style={{
backgroundColor: value,
color: watch("sidebarText"),
}}
hasError={Boolean(errors?.sidebarBackground)}
/>
)}
/>
{errors.sidebarBackground && (
<p className="text-xs text-red-500 mt-1">{errors.sidebarBackground.message}</p>
<Controller
control={control}
name="sidebarBackground"
render={({ field: { value, onChange } }) => (
<InputColorPicker
name="sidebarBackground"
value={value}
onChange={onChange}
className=""
placeholder="#ffffff"
hasError={Boolean(errors?.sidebarBackground)}
/>
)}
</div>
/>
</div>
<div className="flex flex-col items-start gap-2">
<h3 className="text-left text-sm font-medium text-custom-text-200">Sidebar text color</h3>
<div className="w-full">
<Controller
control={control}
name="sidebarText"
rules={inputRules}
render={({ field: { value, onChange } }) => (
<InputColorPicker
name="sidebarText"
value={value}
onChange={(val) => handleValueChange(val, onChange)}
placeholder="#c5c5c5"
className="w-full"
style={{
backgroundColor: watch("sidebarBackground"),
color: value,
}}
hasError={Boolean(errors?.sidebarText)}
/>
)}
/>
{errors.sidebarText && <p className="text-xs text-red-500 mt-1">{errors.sidebarText.message}</p>}
</div>
<Controller
control={control}
name="sidebarText"
render={({ field: { value, onChange } }) => (
<InputColorPicker
name="sidebarText"
value={value}
onChange={onChange}
className=""
placeholder="#ffffff"
hasError={Boolean(errors?.sidebarText)}
/>
)}
/>
</div>
</div>
</div>
@@ -198,7 +198,8 @@ export const InlineCreateIssueFormWrapper: React.FC<Props> = (props) => {
if (onSuccess) await onSuccess(res);
if (formData.assignees?.some((assignee) => assignee === user?.id)) mutate(USER_ISSUE(workspaceSlug as string));
if (formData.assignees_list?.some((assignee) => assignee === user?.id))
mutate(USER_ISSUE(workspaceSlug as string));
if (formData.parent && formData.parent !== "") mutate(SUB_ISSUES(formData.parent));
})
+12 -14
View File
@@ -1,14 +1,16 @@
import { MouseEvent } from "react";
import Link from "next/link";
import { useRouter } from "next/router";
import useSWR from "swr";
import useSWR, { mutate } from "swr";
// services
import { CycleService } from "services/cycle.service";
// hooks
import useToast from "hooks/use-toast";
import { useMobxStore } from "lib/mobx/store-provider";
// ui
import { AssigneesList } from "components/ui/avatar";
import { SingleProgressStats } from "components/core";
import {
AvatarGroup,
Loader,
Tooltip,
LinearProgressIndicator,
@@ -17,7 +19,6 @@ import {
LayersIcon,
StateGroupIcon,
PriorityIcon,
Avatar,
} from "@plane/ui";
// components
import ProgressChart from "components/core/sidebar/progress-chart";
@@ -30,7 +31,9 @@ import { AlarmClock, AlertTriangle, ArrowRight, CalendarDays, Star, Target } fro
import { getDateRangeStatus, renderShortDateWithYearFormat, findHowManyDaysLeft } from "helpers/date-time.helper";
import { truncateText } from "helpers/string.helper";
// types
import { ICycle } from "types";
import { ICycle, IIssue } from "types";
// fetch-keys
import { CURRENT_CYCLE_LIST, CYCLES_LIST, CYCLE_ISSUES_WITH_PARAMS } from "constants/fetch-keys";
const stateGroups = [
{
@@ -66,6 +69,9 @@ interface IActiveCycleDetails {
}
export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = (props) => {
// services
const cycleService = new CycleService();
const router = useRouter();
const { workspaceSlug, projectId } = props;
@@ -300,11 +306,7 @@ export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = (props) => {
{cycle.assignees.length > 0 && (
<div className="flex items-center gap-1 text-custom-text-200">
<AvatarGroup>
{cycle.assignees.map((assignee) => (
<Avatar key={assignee.id} name={assignee.display_name} src={assignee.avatar} />
))}
</AvatarGroup>
<AssigneesList users={cycle.assignees} length={4} />
</div>
)}
</div>
@@ -404,11 +406,7 @@ export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = (props) => {
<div className={`flex items-center gap-2 text-custom-text-200`}>
{issue.assignees && issue.assignees.length > 0 && Array.isArray(issue.assignees) ? (
<div className="-my-0.5 flex items-center justify-center gap-2">
<AvatarGroup showTooltip={false}>
{issue.assignee_details.map((assignee: any) => (
<Avatar key={assignee.id} name={assignee.display_name} src={assignee.avatar} />
))}
</AvatarGroup>
<AssigneesList users={issue.assignee_details} length={3} showLength={false} />
</div>
) : (
""
+28 -7
View File
@@ -1,14 +1,16 @@
import React, { Fragment } from "react";
// headless ui
import { Tab } from "@headlessui/react";
// hooks
import useLocalStorage from "hooks/use-local-storage";
// components
import { SingleProgressStats } from "components/core";
// ui
import { Avatar } from "@plane/ui";
import { Avatar } from "components/ui";
// types
import { ICycle } from "types";
// types
type Props = {
cycle: ICycle;
};
@@ -69,7 +71,10 @@ export const ActiveCycleProgressStats: React.FC<Props> = ({ cycle }) => {
</Tab.List>
{cycle.total_issues > 0 ? (
<Tab.Panels as={Fragment}>
<Tab.Panel as="div" className="w-full gap-1 overflow-y-scroll items-center text-custom-text-200 p-4">
<Tab.Panel
as="div"
className="w-full gap-1 overflow-y-scroll items-center text-custom-text-200 p-4"
>
{cycle.distribution.assignees.map((assignee, index) => {
if (assignee.assignee_id)
return (
@@ -77,8 +82,15 @@ export const ActiveCycleProgressStats: React.FC<Props> = ({ cycle }) => {
key={assignee.assignee_id}
title={
<div className="flex items-center gap-2">
<Avatar name={assignee?.display_name ?? undefined} src={assignee?.avatar ?? undefined} />
<Avatar
user={{
id: assignee.assignee_id,
avatar: assignee.avatar ?? "",
first_name: assignee.first_name ?? "",
last_name: assignee.last_name ?? "",
display_name: assignee.display_name ?? "",
}}
/>
<span>{assignee.display_name}</span>
</div>
}
@@ -93,7 +105,13 @@ export const ActiveCycleProgressStats: React.FC<Props> = ({ cycle }) => {
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" />
<img
src="/user.png"
height="100%"
width="100%"
className="rounded-full"
alt="User"
/>
</div>
<span>No assignee</span>
</div>
@@ -104,7 +122,10 @@ export const ActiveCycleProgressStats: React.FC<Props> = ({ cycle }) => {
);
})}
</Tab.Panel>
<Tab.Panel as="div" className="w-full gap-1 overflow-y-scroll items-center text-custom-text-200 p-4">
<Tab.Panel
as="div"
className="w-full gap-1 overflow-y-scroll items-center text-custom-text-200 p-4"
>
{cycle.distribution.labels.map((label, index) => (
<SingleProgressStats
key={label.label_id ?? `no-label-${index}`}
@@ -0,0 +1,163 @@
import { Fragment, useEffect, useState } from "react";
import { Dialog, Transition } from "@headlessui/react";
import { observer } from "mobx-react-lite";
// components
import { CycleForm } from "./form";
// hooks
import useToast from "hooks/use-toast";
import { useMobxStore } from "lib/mobx/store-provider";
// types
import { CycleDateCheckData, ICycle } from "types";
interface ICycleCreateEdit {
cycle?: ICycle | null;
modal: boolean;
modalClose: () => void;
onSubmit?: () => void;
workspaceSlug: string;
projectId: string;
}
export const CycleCreateEditModal: React.FC<ICycleCreateEdit> = observer((props) => {
const { modal, modalClose, cycle = null, onSubmit, workspaceSlug, projectId } = props;
const [activeProject, setActiveProject] = useState<string | null>(null);
const { project: projectStore, cycle: cycleStore } = useMobxStore();
const projects = workspaceSlug ? projectStore.projects[workspaceSlug.toString()] : undefined;
const { setToastAlert } = useToast();
const validateCycleDate = async (payload: CycleDateCheckData) => {
let status = false;
await cycleStore.validateDate(workspaceSlug as string, projectId as string, payload).then((res) => {
status = res.status;
});
return status;
};
const formSubmit = async (data: Partial<ICycle>) => {
let isDateValid: boolean = true;
if (data?.start_date && data?.end_date) {
if (cycle?.id && cycle?.start_date && cycle?.end_date)
isDateValid = await validateCycleDate({
start_date: data.start_date,
end_date: data.end_date,
cycle_id: cycle.id,
});
else
isDateValid = await validateCycleDate({
start_date: data.start_date,
end_date: data.end_date,
});
}
if (isDateValid)
if (cycle) {
try {
await cycleStore.updateCycle(workspaceSlug, projectId, cycle.id, data);
if (modalClose) modalClose();
if (onSubmit) onSubmit();
setToastAlert({
type: "success",
title: "Success!",
message: "Cycle updated successfully.",
});
} catch (error) {
console.log("error", error);
setToastAlert({
type: "error",
title: "Warning!",
message: "Something went wrong please try again later.",
});
}
} else {
try {
await cycleStore.createCycle(workspaceSlug, projectId, data);
if (modalClose) modalClose();
if (onSubmit) onSubmit();
setToastAlert({
type: "success",
title: "Success!",
message: "Cycle created successfully.",
});
} catch (error) {
console.log("error", error);
setToastAlert({
type: "error",
title: "Warning!",
message: "Something went wrong please try again later.",
});
}
}
else
setToastAlert({
type: "error",
title: "Error!",
message: "You already have a cycle on the given dates, if you want to create a draft cycle, remove the dates.",
});
};
useEffect(() => {
// if modal is closed, reset active project to null
// and return to avoid activeProject being set to some other project
if (!modal) {
setActiveProject(null);
return;
}
// if data is present, set active project to the project of the
// issue. This has more priority than the project in the url.
if (cycle && cycle.project) {
setActiveProject(cycle.project);
return;
}
// if data is not present, set active project to the project
// in the url. This has the least priority.
if (projects && projects.length > 0 && !activeProject)
setActiveProject(projects?.find((p) => p.id === projectId)?.id ?? projects?.[0].id ?? null);
}, [activeProject, cycle, projectId, projects, modal]);
return (
<Transition.Root show={modal} as={Fragment}>
<Dialog as="div" className="relative z-20" onClose={modalClose}>
<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 bg-opacity-50 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 rounded-lg border border-custom-border-200 bg-custom-background-100 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-2xl p-5">
<CycleForm
handleFormSubmit={formSubmit}
handleClose={modalClose}
projectId={activeProject ?? ""}
setActiveProject={setActiveProject}
data={cycle}
/>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
});
@@ -13,23 +13,24 @@ import { useMobxStore } from "lib/mobx/store-provider";
interface ICycleDelete {
cycle: ICycle;
isOpen: boolean;
handleClose: () => void;
modal: boolean;
modalClose: () => void;
onSubmit?: () => void;
workspaceSlug: string;
projectId: string;
}
export const CycleDeleteModal: React.FC<ICycleDelete> = observer((props) => {
const { isOpen, handleClose, cycle, workspaceSlug, projectId } = props;
// store
const { cycle: cycleStore } = useMobxStore();
// toast
const { setToastAlert } = useToast();
// states
const [loader, setLoader] = useState(false);
const { modal, modalClose, cycle, onSubmit, workspaceSlug, projectId } = props;
const { cycle: cycleStore } = useMobxStore();
const { setToastAlert } = useToast();
const [loader, setLoader] = useState(false);
const formSubmit = async () => {
setLoader(true);
if (cycle?.id)
try {
await cycleStore.removeCycle(workspaceSlug, projectId, cycle?.id);
@@ -38,7 +39,8 @@ export const CycleDeleteModal: React.FC<ICycleDelete> = observer((props) => {
title: "Success!",
message: "Cycle deleted successfully.",
});
handleClose();
if (modalClose) modalClose();
if (onSubmit) onSubmit();
} catch (error) {
setToastAlert({
type: "error",
@@ -59,8 +61,8 @@ export const CycleDeleteModal: React.FC<ICycleDelete> = observer((props) => {
return (
<div>
<div>
<Transition.Root show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-20" onClose={handleClose}>
<Transition.Root show={modal} as={Fragment}>
<Dialog as="div" className="relative z-20" onClose={modalClose}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
@@ -101,7 +103,7 @@ export const CycleDeleteModal: React.FC<ICycleDelete> = observer((props) => {
</p>
</span>
<div className="flex justify-end gap-2">
<SecondaryButton onClick={handleClose}>Cancel</SecondaryButton>
<SecondaryButton onClick={modalClose}>Cancel</SecondaryButton>
<DangerButton onClick={formSubmit} loading={loader}>
{loader ? "Deleting..." : "Delete Cycle"}
</DangerButton>
@@ -1,55 +0,0 @@
import React, { useEffect } from "react";
import { useRouter } from "next/router";
// mobx
import { observer } from "mobx-react-lite";
import { useMobxStore } from "lib/mobx/store-provider";
// components
import { CycleDetailsSidebar } from "./sidebar";
type Props = {
projectId: string;
workspaceSlug: string;
};
export const CyclePeekOverview: React.FC<Props> = observer(({ projectId, workspaceSlug }) => {
const router = useRouter();
const { peekCycle } = router.query;
const ref = React.useRef(null);
const { cycle: cycleStore } = useMobxStore();
const { fetchCycleWithId } = cycleStore;
const handleClose = () => {
delete router.query.peekCycle;
router.push({
pathname: router.pathname,
query: { ...router.query },
});
};
useEffect(() => {
if (!peekCycle) return;
fetchCycleWithId(workspaceSlug, projectId, peekCycle.toString());
}, [fetchCycleWithId, peekCycle, projectId, workspaceSlug]);
return (
<>
{peekCycle && (
<div
ref={ref}
className="flex flex-col gap-3.5 h-full w-[24rem] z-10 overflow-y-auto border-l border-custom-border-100 bg-custom-sidebar-background-100 px-6 py-3.5 duration-300 flex-shrink-0"
style={{
boxShadow:
"0px 1px 4px 0px rgba(0, 0, 0, 0.06), 0px 2px 4px 0px rgba(16, 24, 40, 0.06), 0px 1px 8px -1px rgba(16, 24, 40, 0.06)",
}}
>
<CycleDetailsSidebar cycleId={peekCycle?.toString() ?? ""} handleClose={handleClose} />
</div>
)}
</>
);
});
+335 -182
View File
@@ -1,28 +1,65 @@
import { FC, MouseEvent, useState } from "react";
import { useRouter } from "next/router";
// next imports
import Link from "next/link";
// headless ui
import { Disclosure, Transition } from "@headlessui/react";
// hooks
import useToast from "hooks/use-toast";
// components
import { CycleCreateUpdateModal, CycleDeleteModal } from "components/cycles";
import { SingleProgressStats } from "components/core";
import { CycleCreateEditModal } from "./cycle-create-edit-modal";
import { CycleDeleteModal } from "./cycle-delete-modal";
// ui
import { Avatar, AvatarGroup, CustomMenu, Tooltip, LayersIcon, CycleGroupIcon } from "@plane/ui";
import { AssigneesList } from "components/ui/avatar";
import { CustomMenu, Tooltip, LinearProgressIndicator, ContrastIcon, RunningIcon } from "@plane/ui";
// icons
import { Info, LinkIcon, Pencil, Star, Trash2 } from "lucide-react";
// helpers
import {
getDateRangeStatus,
findHowManyDaysLeft,
renderShortDate,
renderShortMonthDate,
} from "helpers/date-time.helper";
import { copyTextToClipboard } from "helpers/string.helper";
AlarmClock,
AlertTriangle,
ArrowRight,
CalendarDays,
ChevronDown,
LinkIcon,
Pencil,
Star,
Target,
Trash2,
} from "lucide-react";
// helpers
import { getDateRangeStatus, renderShortDateWithYearFormat, findHowManyDaysLeft } from "helpers/date-time.helper";
import { copyTextToClipboard, truncateText } from "helpers/string.helper";
// types
import { ICycle } from "types";
// store
import { useMobxStore } from "lib/mobx/store-provider";
// constants
import { CYCLE_STATUS } from "constants/cycle";
const stateGroups = [
{
key: "backlog_issues",
title: "Backlog",
color: "#dee2e6",
},
{
key: "unstarted_issues",
title: "Unstarted",
color: "#26b5ce",
},
{
key: "started_issues",
title: "Started",
color: "#f7ae59",
},
{
key: "cancelled_issues",
title: "Cancelled",
color: "#d687ff",
},
{
key: "completed_issues",
title: "Completed",
color: "#09a953",
},
];
export interface ICyclesBoardCard {
workspaceSlug: string;
@@ -32,47 +69,25 @@ export interface ICyclesBoardCard {
export const CyclesBoardCard: FC<ICyclesBoardCard> = (props) => {
const { cycle, workspaceSlug, projectId } = props;
const [updateModal, setUpdateModal] = useState(false);
const updateModalCallback = () => {};
const [deleteModal, setDeleteModal] = useState(false);
const deleteModalCallback = () => {};
// store
const { cycle: cycleStore } = useMobxStore();
// toast
const { setToastAlert } = useToast();
// states
const [updateModal, setUpdateModal] = useState(false);
const [deleteModal, setDeleteModal] = useState(false);
// computed
const cycleStatus = getDateRangeStatus(cycle.start_date, cycle.end_date);
const isCompleted = cycleStatus === "completed";
const endDate = new Date(cycle.end_date ?? "");
const startDate = new Date(cycle.start_date ?? "");
const router = useRouter();
const currentCycle = CYCLE_STATUS.find((status) => status.value === cycleStatus);
const areYearsEqual = startDate.getFullYear() === endDate.getFullYear();
const cycleTotalIssues =
cycle.backlog_issues +
cycle.unstarted_issues +
cycle.started_issues +
cycle.completed_issues +
cycle.cancelled_issues;
const completionPercentage = (cycle.completed_issues / cycleTotalIssues) * 100;
const issueCount = cycle
? cycleTotalIssues === 0
? "0 Issue"
: cycleTotalIssues === cycle.completed_issues
? cycleTotalIssues > 1
? `${cycleTotalIssues} Issues`
: `${cycleTotalIssues} Issue`
: `${cycle.completed_issues}/${cycleTotalIssues} Issues`
: "0 Issue";
const handleCopyText = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
const handleCopyText = () => {
const originURL = typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
copyTextToClipboard(`${originURL}/${workspaceSlug}/projects/${projectId}/cycles/${cycle.id}`).then(() => {
@@ -84,6 +99,21 @@ export const CyclesBoardCard: FC<ICyclesBoardCard> = (props) => {
});
};
const progressIndicatorData = stateGroups.map((group, index) => ({
id: index,
name: group.title,
value: cycle.total_issues > 0 ? ((cycle[group.key as keyof ICycle] as number) / cycle.total_issues) * 100 : 0,
color: group.color,
}));
const groupedIssues: any = {
backlog: cycle.backlog_issues,
unstarted: cycle.unstarted_issues,
started: cycle.started_issues,
completed: cycle.completed_issues,
cancelled: cycle.cancelled_issues,
};
const handleAddToFavorites = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
if (!workspaceSlug || !projectId) return;
@@ -110,164 +140,287 @@ export const CyclesBoardCard: FC<ICyclesBoardCard> = (props) => {
});
};
const handleEditCycle = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
setUpdateModal(true);
};
const handleDeleteCycle = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
setDeleteModal(true);
};
const openCycleOverview = (e: MouseEvent<HTMLButtonElement>) => {
const { query } = router;
e.preventDefault();
e.stopPropagation();
router.push({
pathname: router.pathname,
query: { ...query, peekCycle: cycle.id },
});
};
return (
<div>
<CycleCreateUpdateModal
data={cycle}
isOpen={updateModal}
handleClose={() => setUpdateModal(false)}
<CycleCreateEditModal
cycle={cycle}
modal={updateModal}
modalClose={() => setUpdateModal(false)}
onSubmit={updateModalCallback}
workspaceSlug={workspaceSlug}
projectId={projectId}
/>
<CycleDeleteModal
cycle={cycle}
isOpen={deleteModal}
handleClose={() => setDeleteModal(false)}
modal={deleteModal}
modalClose={() => setDeleteModal(false)}
onSubmit={deleteModalCallback}
workspaceSlug={workspaceSlug}
projectId={projectId}
/>
<Link href={`/${workspaceSlug}/projects/${projectId}/cycles/${cycle.id}`}>
<a className="flex flex-col justify-between p-4 h-44 w-full min-w-[250px] text-sm rounded bg-custom-background-100 border border-custom-border-100 hover:shadow-md">
<div>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-3">
<span className="flex-shrink-0">
<CycleGroupIcon cycleGroup={cycleStatus} className="h-3.5 w-3.5" />
</span>
<Tooltip tooltipContent={cycle.name} position="top">
<span className="text-base font-medium truncate">{cycle.name}</span>
</Tooltip>
</div>
<div className="flex items-center gap-2">
{currentCycle && (
<span
className="flex items-center justify-center text-xs text-center h-6 w-20 rounded-sm"
style={{
color: currentCycle.color,
backgroundColor: `${currentCycle.color}20`,
}}
>
{currentCycle.value === "current"
? `${findHowManyDaysLeft(cycle.end_date ?? new Date())} ${currentCycle.label}`
: `${currentCycle.label}`}
<div className="flex flex-col rounded-[10px] bg-custom-background-100 border border-custom-border-200 text-xs shadow">
<Link href={`/${workspaceSlug}/projects/${projectId}/cycles/${cycle.id}`}>
<a className="w-full">
<div className="flex h-full flex-col gap-4 rounded-b-[10px] p-4">
<div className="flex items-center justify-between gap-1">
<span className="flex items-center gap-1">
<span className="h-5 w-5">
<ContrastIcon
className="h-5 w-5"
color={`${
cycleStatus === "current"
? "#09A953"
: cycleStatus === "upcoming"
? "#F7AE59"
: cycleStatus === "completed"
? "#3F76FF"
: cycleStatus === "draft"
? "rgb(var(--color-text-200))"
: ""
}`}
/>
</span>
<Tooltip tooltipContent={cycle.name} className="break-words" position="top-left">
<h3 className="break-words text-lg font-semibold">{truncateText(cycle.name, 15)}</h3>
</Tooltip>
</span>
<span className="flex items-center gap-1 capitalize">
<span
className={`rounded-full px-1.5 py-0.5
${
cycleStatus === "current"
? "bg-green-600/5 text-green-600"
: cycleStatus === "upcoming"
? "bg-orange-300/5 text-orange-300"
: cycleStatus === "completed"
? "bg-blue-500/5 text-blue-500"
: cycleStatus === "draft"
? "bg-neutral-400/5 text-neutral-400"
: ""
}`}
>
{cycleStatus === "current" ? (
<span className="flex gap-1 whitespace-nowrap">
<RunningIcon className="h-4 w-4" />
{findHowManyDaysLeft(cycle.end_date ?? new Date())} Days Left
</span>
) : cycleStatus === "upcoming" ? (
<span className="flex gap-1 whitespace-nowrap">
<AlarmClock className="h-4 w-4" />
{findHowManyDaysLeft(cycle.start_date ?? new Date())} Days Left
</span>
) : cycleStatus === "completed" ? (
<span className="flex gap-1 whitespace-nowrap">
{cycle.total_issues - cycle.completed_issues > 0 && (
<Tooltip
tooltipContent={`${cycle.total_issues - cycle.completed_issues} more pending ${
cycle.total_issues - cycle.completed_issues === 1 ? "issue" : "issues"
}`}
>
<span>
<AlertTriangle className="h-3.5 w-3.5" />
</span>
</Tooltip>
)}{" "}
Completed
</span>
) : (
cycleStatus
)}
</span>
{cycle.is_favorite ? (
<button onClick={handleRemoveFromFavorites}>
<Star className="h-4 w-4 text-orange-400" fill="#f6ad55" />
</button>
) : (
<button onClick={handleAddToFavorites}>
<Star className="h-4 w-4 " color="rgb(var(--color-text-200))" />
</button>
)}
</span>
</div>
<div className="flex h-4 items-center justify-start gap-5 text-custom-text-200">
{cycleStatus !== "draft" && (
<>
<div className="flex items-start gap-1">
<CalendarDays className="h-4 w-4" />
<span>{renderShortDateWithYearFormat(startDate)}</span>
</div>
<ArrowRight className="h-4 w-4" />
<div className="flex items-start gap-1">
<Target className="h-4 w-4" />
<span>{renderShortDateWithYearFormat(endDate)}</span>
</div>
</>
)}
<button onClick={openCycleOverview}>
<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}</span>
</div>
{cycle.assignees.length > 0 && (
<Tooltip tooltipContent={`${cycle.assignees.length} Members`}>
<div className="flex items-center gap-1 cursor-default">
<AvatarGroup showTooltip={false}>
{cycle.assignees.map((assignee) => (
<Avatar key={assignee.id} name={assignee.display_name} src={assignee.avatar} />
))}
</AvatarGroup>
<div className="flex justify-between items-end">
<div className="flex flex-col gap-2 text-xs text-custom-text-200">
<div className="flex items-center gap-2">
<div className="w-16">Creator:</div>
<div className="flex items-center gap-2.5 text-custom-text-200">
{cycle.owned_by.avatar && cycle.owned_by.avatar !== "" ? (
<img
src={cycle.owned_by.avatar}
height={16}
width={16}
className="rounded-full"
alt={cycle.owned_by.display_name}
/>
) : (
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-orange-300 capitalize text-white">
{cycle.owned_by.display_name.charAt(0)}
</span>
)}
<span className="text-custom-text-200">{cycle.owned_by.display_name}</span>
</div>
</div>
</Tooltip>
)}
</div>
<div className="flex h-5 items-center gap-2">
<div className="w-16">Members:</div>
{cycle.assignees.length > 0 ? (
<div className="flex items-center gap-1 text-custom-text-200">
<AssigneesList users={cycle.assignees} length={4} />
</div>
) : (
"No members"
)}
</div>
</div>
<Tooltip
tooltipContent={isNaN(completionPercentage) ? "0" : `${completionPercentage.toFixed(0)}%`}
position="top-left"
>
<div className="flex items-center w-full">
<div
className="bar relative h-1.5 w-full rounded bg-custom-background-90"
style={{
boxShadow: "1px 1px 4px 0px rgba(161, 169, 191, 0.35) inset",
}}
>
<div
className="absolute top-0 left-0 h-1.5 rounded bg-blue-600 duration-300"
style={{
width: `${isNaN(completionPercentage) ? 0 : completionPercentage.toFixed(0)}%`,
}}
/>
<div className="flex items-center">
{!isCompleted && (
<button
onClick={(e) => {
e.preventDefault();
setUpdateModal(true);
}}
className="cursor-pointer rounded p-1 text-custom-text-200 duration-300 hover:bg-custom-background-80"
>
<Pencil className="h-4 w-4" />
</button>
)}
<CustomMenu width="auto" verticalEllipsis>
{!isCompleted && (
<CustomMenu.MenuItem
onClick={(e) => {
e.preventDefault();
setDeleteModal(true);
}}
>
<span className="flex items-center justify-start gap-2">
<Trash2 className="h-4 w-4" />
<span>Delete cycle</span>
</span>
</CustomMenu.MenuItem>
)}
<CustomMenu.MenuItem
onClick={(e) => {
e.preventDefault();
handleCopyText();
}}
>
<span className="flex items-center justify-start gap-2">
<LinkIcon className="h-4 w-4" />
<span>Copy cycle link</span>
</span>
</CustomMenu.MenuItem>
</CustomMenu>
</div>
</div>
</Tooltip>
<div className="flex items-center justify-between">
<span className="text-xs text-custom-text-300">
{areYearsEqual ? renderShortDate(startDate, "_ _") : renderShortMonthDate(startDate, "_ _")} -{" "}
{areYearsEqual ? renderShortDate(endDate, "_ _") : renderShortMonthDate(endDate, "_ _")}
</span>
<div className="flex items-center gap-1.5 z-10">
{cycle.is_favorite ? (
<button type="button" onClick={handleRemoveFromFavorites}>
<Star className="h-3.5 w-3.5 text-amber-500 fill-current" />
</button>
) : (
<button type="button" onClick={handleAddToFavorites}>
<Star className="h-3.5 w-3.5 text-custom-text-200" />
</button>
)}
<CustomMenu width="auto" ellipsis className="z-10">
{!isCompleted && (
<>
<CustomMenu.MenuItem onClick={handleEditCycle}>
<span className="flex items-center justify-start gap-2">
<Pencil className="h-3 w-3" />
<span>Edit cycle</span>
</span>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={handleDeleteCycle}>
<span className="flex items-center justify-start gap-2">
<Trash2 className="h-3 w-3" />
<span>Delete module</span>
</span>
</CustomMenu.MenuItem>
</>
)}
<CustomMenu.MenuItem onClick={handleCopyText}>
<span className="flex items-center justify-start gap-2">
<LinkIcon className="h-3 w-3" />
<span>Copy cycle link</span>
</span>
</CustomMenu.MenuItem>
</CustomMenu>
</div>
</div>
</div>
</a>
</Link>
</a>
</Link>
<div className="flex h-full flex-col rounded-b-[10px]">
<Disclosure>
{({ open }) => (
<div
className={`flex h-full w-full flex-col rounded-b-[10px] border-t border-custom-border-200 bg-custom-background-80 text-custom-text-200 ${
open ? "" : "flex-row"
}`}
>
<div className="flex w-full items-center gap-2 px-4 py-1">
<span>Progress</span>
<Tooltip
tooltipContent={
<div className="flex w-56 flex-col">
{Object.keys(groupedIssues).map((group, index) => (
<SingleProgressStats
key={index}
title={
<div className="flex items-center gap-2">
<span
className="block h-3 w-3 rounded-full "
style={{
backgroundColor: stateGroups[index].color,
}}
/>
<span className="text-xs capitalize">{group}</span>
</div>
}
completed={groupedIssues[group]}
total={cycle.total_issues}
/>
))}
</div>
}
position="bottom"
>
<div className="flex w-full items-center">
<LinearProgressIndicator data={progressIndicatorData} noTooltip={true} />
</div>
</Tooltip>
<Disclosure.Button>
<span className="p-1">
<ChevronDown className={`h-3 w-3 ${open ? "rotate-180 transform" : ""}`} aria-hidden="true" />
</span>
</Disclosure.Button>
</div>
<Transition show={open}>
<Disclosure.Panel>
<div className="overflow-hidden rounded-b-md bg-custom-background-80 py-3 shadow">
<div className="col-span-2 space-y-3 px-4">
<div className="space-y-3 text-xs">
{stateGroups.map((group) => (
<div key={group.key} className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<span
className="block h-2 w-2 rounded-full"
style={{
backgroundColor: group.color,
}}
/>
<h6 className="text-xs">{group.title}</h6>
</div>
<div>
<span>
{cycle[group.key as keyof ICycle] as number}{" "}
<span className="text-custom-text-200">
-{" "}
{cycle.total_issues > 0
? `${Math.round(
((cycle[group.key as keyof ICycle] as number) / cycle.total_issues) * 100
)}%`
: "0%"}
</span>
</span>
</div>
</div>
))}
</div>
</div>
</div>
</Disclosure.Panel>
</Transition>
</div>
)}
</Disclosure>
</div>
</div>
</div>
);
};
+9 -24
View File
@@ -2,41 +2,26 @@ import { FC } from "react";
// types
import { ICycle } from "types";
// components
import { CyclePeekOverview, CyclesBoardCard } from "components/cycles";
import { CyclesBoardCard } from "components/cycles";
export interface ICyclesBoard {
cycles: ICycle[];
filter: string;
workspaceSlug: string;
projectId: string;
peekCycle: string;
}
export const CyclesBoard: FC<ICyclesBoard> = (props) => {
const { cycles, filter, workspaceSlug, projectId, peekCycle } = props;
const { cycles, filter, workspaceSlug, projectId } = props;
return (
<>
<div className="grid grid-cols-1 gap-9 lg:grid-cols-2 xl:grid-cols-3">
{cycles.length > 0 ? (
<div className="h-full w-full">
<div className="flex justify-between h-full w-full">
<div
className={`grid grid-cols-1 gap-6 p-8 h-full w-full overflow-y-auto ${
peekCycle
? "lg:grid-cols-1 xl:grid-cols-2 3xl:grid-cols-3"
: "lg:grid-cols-2 xl:grid-cols-3 3xl:grid-cols-4"
} auto-rows-max transition-all `}
>
{cycles.map((cycle) => (
<CyclesBoardCard key={cycle.id} workspaceSlug={workspaceSlug} projectId={projectId} cycle={cycle} />
))}
</div>
<CyclePeekOverview
projectId={projectId?.toString() ?? ""}
workspaceSlug={workspaceSlug?.toString() ?? ""}
/>
</div>
</div>
<>
{cycles.map((cycle) => (
<CyclesBoardCard key={cycle.id} workspaceSlug={workspaceSlug} projectId={projectId} cycle={cycle} />
))}
</>
) : (
<div className="h-full grid place-items-center text-center">
<div className="space-y-2">
@@ -65,6 +50,6 @@ export const CyclesBoard: FC<ICyclesBoard> = (props) => {
</div>
</div>
)}
</>
</div>
);
};
+281 -173
View File
@@ -1,29 +1,30 @@
import { FC, MouseEvent, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/router";
// stores
import { useMobxStore } from "lib/mobx/store-provider";
// hooks
import useToast from "hooks/use-toast";
// components
import { CycleCreateUpdateModal, CycleDeleteModal } from "components/cycles";
import { CycleCreateEditModal } from "./cycle-create-edit-modal";
import { CycleDeleteModal } from "./cycle-delete-modal";
// ui
import { CustomMenu, Tooltip, CircularProgressIndicator, CycleGroupIcon, AvatarGroup, Avatar } from "@plane/ui";
import { CustomMenu, RadialProgressBar, Tooltip, LinearProgressIndicator, ContrastIcon, RunningIcon } from "@plane/ui";
// icons
import { Check, Info, LinkIcon, Pencil, Star, Trash2, User2 } from "lucide-react";
// helpers
import {
getDateRangeStatus,
findHowManyDaysLeft,
renderShortDate,
renderShortMonthDate,
} from "helpers/date-time.helper";
AlarmClock,
AlertTriangle,
ArrowRight,
CalendarDays,
LinkIcon,
Pencil,
Star,
Target,
Trash2,
} from "lucide-react";
// helpers
import { getDateRangeStatus, renderShortDateWithYearFormat, findHowManyDaysLeft } from "helpers/date-time.helper";
import { copyTextToClipboard } from "helpers/string.helper";
// types
import { ICycle } from "types";
// constants
import { CYCLE_STATUS } from "constants/cycle";
import { useMobxStore } from "lib/mobx/store-provider";
type TCyclesListItem = {
cycle: ICycle;
@@ -35,43 +36,55 @@ type TCyclesListItem = {
projectId: string;
};
const stateGroups = [
{
key: "backlog_issues",
title: "Backlog",
color: "#dee2e6",
},
{
key: "unstarted_issues",
title: "Unstarted",
color: "#26b5ce",
},
{
key: "started_issues",
title: "Started",
color: "#f7ae59",
},
{
key: "cancelled_issues",
title: "Cancelled",
color: "#d687ff",
},
{
key: "completed_issues",
title: "Completed",
color: "#09a953",
},
];
export const CyclesListItem: FC<TCyclesListItem> = (props) => {
const { cycle, workspaceSlug, projectId } = props;
const [updateModal, setUpdateModal] = useState(false);
const updateModalCallback = () => {};
const [deleteModal, setDeleteModal] = useState(false);
const deleteModalCallback = () => {};
// store
const { cycle: cycleStore } = useMobxStore();
// toast
const { setToastAlert } = useToast();
// states
const [updateModal, setUpdateModal] = useState(false);
const [deleteModal, setDeleteModal] = useState(false);
// computed
const cycleStatus = getDateRangeStatus(cycle.start_date, cycle.end_date);
const isCompleted = cycleStatus === "completed";
const endDate = new Date(cycle.end_date ?? "");
const startDate = new Date(cycle.start_date ?? "");
const router = useRouter();
const cycleTotalIssues =
cycle.backlog_issues +
cycle.unstarted_issues +
cycle.started_issues +
cycle.completed_issues +
cycle.cancelled_issues;
const renderDate = cycle.start_date || cycle.end_date;
const areYearsEqual = startDate.getFullYear() === endDate.getFullYear();
const completionPercentage = (cycle.completed_issues / cycleTotalIssues) * 100;
const progress = isNaN(completionPercentage) ? 0 : Math.floor(completionPercentage);
const currentCycle = CYCLE_STATUS.find((status) => status.value === cycleStatus);
const handleCopyText = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
const handleCopyText = () => {
const originURL = typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
copyTextToClipboard(`${originURL}/${workspaceSlug}/projects/${projectId}/cycles/${cycle.id}`).then(() => {
@@ -83,6 +96,13 @@ export const CyclesListItem: FC<TCyclesListItem> = (props) => {
});
};
const progressIndicatorData = stateGroups.map((group, index) => ({
id: index,
name: group.title,
value: cycle.total_issues > 0 ? ((cycle[group.key as keyof ICycle] as number) / cycle.total_issues) * 100 : 0,
color: group.color,
}));
const handleAddToFavorites = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
if (!workspaceSlug || !projectId) return;
@@ -109,153 +129,241 @@ export const CyclesListItem: FC<TCyclesListItem> = (props) => {
});
};
const handleEditCycle = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
setUpdateModal(true);
};
const handleDeleteCycle = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
setDeleteModal(true);
};
const openCycleOverview = (e: MouseEvent<HTMLButtonElement>) => {
const { query } = router;
e.preventDefault();
e.stopPropagation();
router.push({
pathname: router.pathname,
query: { ...query, peekCycle: cycle.id },
});
};
return (
<>
<CycleCreateUpdateModal
data={cycle}
isOpen={updateModal}
handleClose={() => setUpdateModal(false)}
workspaceSlug={workspaceSlug}
projectId={projectId}
/>
<CycleDeleteModal
cycle={cycle}
isOpen={deleteModal}
handleClose={() => setDeleteModal(false)}
workspaceSlug={workspaceSlug}
projectId={projectId}
/>
<Link href={`/${workspaceSlug}/projects/${projectId}/cycles/${cycle.id}`}>
<a className="group flex items-center justify-between gap-5 px-10 py-6 h-16 w-full text-sm bg-custom-background-100 border-b border-custom-border-100 hover:bg-custom-background-90">
<div className="flex items-center gap-3 w-full truncate">
<div className="flex items-center gap-4 truncate">
<span className="flex-shrink-0">
<CircularProgressIndicator size={38} percentage={progress}>
{isCompleted ? (
<span className="text-sm text-custom-primary-100">{`!`}</span>
) : progress === 100 ? (
<Check className="h-3 w-3 text-custom-primary-100 stroke-[2]" />
) : (
<span className="text-xs text-custom-text-300">{`${progress}%`}</span>
<div className="relative flex items-center gap-1 hover:bg-custom-background-80 transition-all rounded px-2 pl-3">
<div className="w-full text-xs py-3">
<Link href={`/${workspaceSlug}/projects/${projectId}/cycles/${cycle.id}`}>
<a className="w-full h-full relative overflow-hidden flex items-center gap-2">
{/* left content */}
<div className="relative flex items-center gap-2 overflow-hidden">
{/* cycle state */}
<div className="flex-shrink-0">
<ContrastIcon
className="h-5 w-5"
color={`${
cycleStatus === "current"
? "#09A953"
: cycleStatus === "upcoming"
? "#F7AE59"
: cycleStatus === "completed"
? "#3F76FF"
: cycleStatus === "draft"
? "rgb(var(--color-text-200))"
: ""
}`}
/>
</div>
{/* cycle title and description */}
<div className="max-w-xl">
<Tooltip tooltipContent={cycle.name} className="break-words" position="top-left">
<div className="text-base font-semibold line-clamp-1 pr-5 overflow-hidden break-words">
{cycle.name}
</div>
</Tooltip>
{cycle.description && (
<div className="mt-1 text-custom-text-200 break-words w-full line-clamp-2">{cycle.description}</div>
)}
</CircularProgressIndicator>
</span>
<div className="flex items-center gap-2.5">
<span className="flex-shrink-0">
<CycleGroupIcon cycleGroup={cycleStatus} className="h-3.5 w-3.5" />
</span>
<Tooltip tooltipContent={cycle.name} position="top">
<span className="text-base font-medium truncate">{cycle.name}</span>
</Tooltip>
</div>
</div>
</div>
<button onClick={openCycleOverview} className="flex-shrink-0 hidden group-hover:flex z-10">
<Info className="h-4 w-4 text-custom-text-400" />
</button>
</div>
<div className="flex items-center gap-2.5 justify-end w-full md:w-auto md:flex-shrink-0 ">
<div className="flex items-center justify-center">
{currentCycle && (
<span
className="flex items-center justify-center text-xs text-center h-6 w-20 rounded-sm"
style={{
color: currentCycle.color,
backgroundColor: `${currentCycle.color}20`,
}}
{/* right content */}
<div className="ml-auto flex-shrink-0 relative flex items-center gap-3 p-2">
{/* cycle status */}
<div
className={`rounded-full px-2 py-1
${
cycleStatus === "current"
? "bg-green-600/10 text-green-600"
: cycleStatus === "upcoming"
? "bg-orange-300/10 text-orange-300"
: cycleStatus === "completed"
? "bg-blue-500/10 text-blue-500"
: cycleStatus === "draft"
? "bg-neutral-400/10 text-neutral-400"
: ""
}`}
>
{currentCycle.value === "current"
? `${findHowManyDaysLeft(cycle.end_date ?? new Date())} ${currentCycle.label}`
: `${currentCycle.label}`}
</span>
)}
</div>
{cycleStatus === "current" ? (
<span className="flex items-center gap-1 whitespace-nowrap">
<RunningIcon className="h-3.5 w-3.5" />
{findHowManyDaysLeft(cycle.end_date ?? new Date())} days left
</span>
) : cycleStatus === "upcoming" ? (
<span className="flex items-center gap-1">
<AlarmClock className="h-3.5 w-3.5" />
{findHowManyDaysLeft(cycle.start_date ?? new Date())} days left
</span>
) : cycleStatus === "completed" ? (
<span className="flex items-center gap-1">
{cycle.total_issues - cycle.completed_issues > 0 && (
<Tooltip
tooltipContent={`${cycle.total_issues - cycle.completed_issues} more pending ${
cycle.total_issues - cycle.completed_issues === 1 ? "issue" : "issues"
}`}
>
<span>
<AlertTriangle className="h-3.5 w-3.5" />
</span>
</Tooltip>
)}{" "}
Completed
</span>
) : (
cycleStatus
)}
</div>
{renderDate && (
<span className="flex items-center justify-center gap-2 w-28 text-xs text-custom-text-300">
{areYearsEqual ? renderShortDate(startDate, "_ _") : renderShortMonthDate(startDate, "_ _")}
{" - "}
{areYearsEqual ? renderShortDate(endDate, "_ _") : renderShortMonthDate(endDate, "_ _")}
</span>
)}
{/* cycle start_date and target_date */}
{cycleStatus !== "draft" && (
<div className="flex items-center justify-start gap-2 text-custom-text-200">
<div className="flex items-start gap-1 whitespace-nowrap">
<CalendarDays className="h-4 w-4" />
<span>{renderShortDateWithYearFormat(startDate)}</span>
</div>
<Tooltip tooltipContent={`${cycle.assignees.length} Members`}>
<div className="flex items-center justify-center gap-1 cursor-default w-16">
{cycle.assignees.length > 0 ? (
<AvatarGroup showTooltip={false}>
{cycle.assignees.map((assignee) => (
<Avatar key={assignee.id} name={assignee.display_name} src={assignee.avatar} />
))}
</AvatarGroup>
) : (
<span className="flex items-end justify-center h-5 w-5 bg-custom-background-80 rounded-full border border-dashed border-custom-text-400">
<User2 className="h-4 w-4 text-custom-text-400" />
<ArrowRight className="h-4 w-4" />
<div className="flex items-start gap-1 whitespace-nowrap">
<Target className="h-4 w-4" />
<span>{renderShortDateWithYearFormat(endDate)}</span>
</div>
</div>
)}
{/* cycle created by */}
<div className="flex items-center text-custom-text-200">
{cycle.owned_by.avatar && cycle.owned_by.avatar !== "" ? (
<img
src={cycle.owned_by.avatar}
height={16}
width={16}
className="rounded-full"
alt={cycle.owned_by.display_name}
/>
) : (
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-orange-300 capitalize text-white">
{cycle.owned_by.display_name.charAt(0)}
</span>
)}
</div>
{/* cycle progress */}
<Tooltip
position="top-right"
tooltipContent={
<div className="flex w-80 items-center gap-2 px-4 py-1">
<span>Progress</span>
<LinearProgressIndicator data={progressIndicatorData} />
</div>
}
>
<span
className={`rounded-md px-1.5 py-1
${
cycleStatus === "current"
? "border border-green-600 bg-green-600/5 text-green-600"
: cycleStatus === "upcoming"
? "border border-orange-300 bg-orange-300/5 text-orange-300"
: cycleStatus === "completed"
? "border border-blue-500 bg-blue-500/5 text-blue-500"
: cycleStatus === "draft"
? "border border-neutral-400 bg-neutral-400/5 text-neutral-400"
: ""
}`}
>
{cycleStatus === "current" ? (
<span className="flex gap-1 whitespace-nowrap">
{cycle.total_issues > 0 ? (
<>
<RadialProgressBar progress={(cycle.completed_issues / cycle.total_issues) * 100} />
<span>{Math.floor((cycle.completed_issues / cycle.total_issues) * 100)} %</span>
</>
) : (
<span className="normal-case">No issues present</span>
)}
</span>
) : cycleStatus === "upcoming" ? (
<span className="flex gap-1">
<RadialProgressBar progress={100} /> Yet to start
</span>
) : cycleStatus === "completed" ? (
<span className="flex gap-1">
<RadialProgressBar progress={100} />
<span>{100} %</span>
</span>
) : (
<span className="flex gap-1">
<RadialProgressBar progress={(cycle.total_issues / cycle.completed_issues) * 100} />
{cycleStatus}
</span>
)}
</span>
</Tooltip>
{/* cycle favorite */}
{cycle.is_favorite ? (
<button type="button" onClick={handleRemoveFromFavorites}>
<Star className="h-4 w-4 text-orange-400" fill="#f6ad55" />
</button>
) : (
<button type="button" onClick={handleAddToFavorites}>
<Star className="h-4 w-4 " color="rgb(var(--color-text-200))" />
</button>
)}
</div>
</Tooltip>
</a>
</Link>
</div>
{cycle.is_favorite ? (
<button type="button" onClick={handleRemoveFromFavorites}>
<Star className="h-3.5 w-3.5 text-amber-500 fill-current" />
</button>
) : (
<button type="button" onClick={handleAddToFavorites}>
<Star className="h-3.5 w-3.5 text-custom-text-200" />
</button>
)}
<CustomMenu width="auto" ellipsis className="z-10">
{!isCompleted && (
<>
<CustomMenu.MenuItem onClick={handleEditCycle}>
<span className="flex items-center justify-start gap-2">
<Pencil className="h-3 w-3" />
<span>Edit cycle</span>
</span>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={handleDeleteCycle}>
<span className="flex items-center justify-start gap-2">
<Trash2 className="h-3 w-3" />
<span>Delete module</span>
</span>
</CustomMenu.MenuItem>
</>
)}
<CustomMenu.MenuItem onClick={handleCopyText}>
<div className="flex-shrink-0">
<CustomMenu width="auto" verticalEllipsis>
{!isCompleted && (
<CustomMenu.MenuItem onClick={() => setUpdateModal(true)}>
<span className="flex items-center justify-start gap-2">
<LinkIcon className="h-3 w-3" />
<span>Copy cycle link</span>
<Pencil className="h-4 w-4" />
<span>Edit Cycle</span>
</span>
</CustomMenu.MenuItem>
</CustomMenu>
</div>
</a>
</Link>
)}
{!isCompleted && (
<CustomMenu.MenuItem onClick={() => setDeleteModal(true)}>
<span className="flex items-center justify-start gap-2">
<Trash2 className="h-4 w-4" />
<span>Delete cycle</span>
</span>
</CustomMenu.MenuItem>
)}
<CustomMenu.MenuItem onClick={handleCopyText}>
<span className="flex items-center justify-start gap-2">
<LinkIcon className="h-4 w-4" />
<span>Copy cycle link</span>
</span>
</CustomMenu.MenuItem>
</CustomMenu>
</div>
</div>
<CycleCreateEditModal
cycle={cycle}
modal={updateModal}
modalClose={() => setUpdateModal(false)}
onSubmit={updateModalCallback}
workspaceSlug={workspaceSlug}
projectId={projectId}
/>
<CycleDeleteModal
cycle={cycle}
modal={deleteModal}
modalClose={() => setDeleteModal(false)}
onSubmit={deleteModalCallback}
workspaceSlug={workspaceSlug}
projectId={projectId}
/>
</>
);
};
+9 -14
View File
@@ -1,7 +1,6 @@
import { FC } from "react";
// components
import { CyclePeekOverview, CyclesListItem } from "components/cycles";
import { CyclesListItem } from "./cycles-list-item";
// ui
import { Loader } from "@plane/ui";
// types
@@ -18,22 +17,18 @@ export const CyclesList: FC<ICyclesList> = (props) => {
const { cycles, filter, workspaceSlug, projectId } = props;
return (
<>
<div>
{cycles ? (
<>
{cycles.length > 0 ? (
<div className="h-full overflow-y-auto">
<div className="flex justify-between h-full w-full">
<div className="flex flex-col h-full w-full overflow-y-auto">
{cycles.map((cycle) => (
<div className="divide-y divide-custom-border-200">
{cycles.map((cycle) => (
<div className="hover:bg-custom-background-80" key={cycle.id}>
<div className="flex flex-col border-custom-border-200">
<CyclesListItem cycle={cycle} workspaceSlug={workspaceSlug} projectId={projectId} />
))}
</div>
</div>
<CyclePeekOverview
projectId={projectId?.toString() ?? ""}
workspaceSlug={workspaceSlug?.toString() ?? ""}
/>
</div>
))}
</div>
) : (
<div className="h-full grid place-items-center text-center">
@@ -73,6 +68,6 @@ export const CyclesList: FC<ICyclesList> = (props) => {
<Loader.Item height="50px" />
</Loader>
)}
</>
</div>
);
};
+8 -15
View File
@@ -15,17 +15,16 @@ export interface ICyclesView {
layout: TCycleLayout;
workspaceSlug: string;
projectId: string;
peekCycle: string;
}
export const CyclesView: FC<ICyclesView> = observer((props) => {
const { filter, layout, workspaceSlug, projectId, peekCycle } = props;
const { filter, layout, workspaceSlug, projectId } = props;
// store
const { cycle: cycleStore } = useMobxStore();
// api call to fetch cycles list
useSWR(
const { isLoading } = useSWR(
workspaceSlug && projectId && filter ? `CYCLES_LIST_${projectId}_${filter}` : null,
workspaceSlug && projectId && filter ? () => cycleStore.fetchCycles(workspaceSlug, projectId, filter) : null
);
@@ -36,10 +35,10 @@ export const CyclesView: FC<ICyclesView> = observer((props) => {
<>
{layout === "list" && (
<>
{cyclesList ? (
{!isLoading ? (
<CyclesList cycles={cyclesList} filter={filter} workspaceSlug={workspaceSlug} projectId={projectId} />
) : (
<Loader className="space-y-4 p-8">
<Loader className="space-y-4">
<Loader.Item height="50px" />
<Loader.Item height="50px" />
<Loader.Item height="50px" />
@@ -50,16 +49,10 @@ export const CyclesView: FC<ICyclesView> = observer((props) => {
{layout === "board" && (
<>
{cyclesList ? (
<CyclesBoard
cycles={cyclesList}
filter={filter}
workspaceSlug={workspaceSlug}
projectId={projectId}
peekCycle={peekCycle}
/>
{!isLoading ? (
<CyclesBoard cycles={cyclesList} filter={filter} workspaceSlug={workspaceSlug} projectId={projectId} />
) : (
<Loader className="grid grid-cols-1 gap-9 md:grid-cols-2 lg:grid-cols-3 p-8">
<Loader className="grid grid-cols-1 gap-9 md:grid-cols-2 lg:grid-cols-3">
<Loader.Item height="200px" />
<Loader.Item height="200px" />
<Loader.Item height="200px" />
@@ -70,7 +63,7 @@ export const CyclesView: FC<ICyclesView> = observer((props) => {
{layout === "gantt" && (
<>
{cyclesList ? (
{!isLoading ? (
<CyclesListGanttChartView cycles={cyclesList} workspaceSlug={workspaceSlug} />
) : (
<Loader className="space-y-4">
+1 -1
View File
@@ -10,7 +10,7 @@ type Props = {
handleFormSubmit: (values: Partial<ICycle>) => Promise<void>;
handleClose: () => void;
projectId: string;
setActiveProject: (projectId: string) => void;
setActiveProject: React.Dispatch<React.SetStateAction<string | null>>;
data?: ICycle | null;
};
+2 -3
View File
@@ -7,6 +7,8 @@ export * from "./form";
export * from "./modal";
export * from "./select";
export * from "./sidebar";
export * from "./single-cycle-card";
export * from "./single-cycle-list";
export * from "./transfer-issues-modal";
export * from "./transfer-issues";
export * from "./cycles-list";
@@ -14,6 +16,3 @@ export * from "./cycles-list-item";
export * from "./cycles-board";
export * from "./cycles-board-card";
export * from "./cycles-gantt";
export * from "./delete-modal";
export * from "./cycle-peek-overview";
export * from "./cycles-list-item";
+113 -17
View File
@@ -1,4 +1,5 @@
import React, { useState } from "react";
import React, { useEffect, useState } from "react";
import { mutate } from "swr";
import { Dialog, Transition } from "@headlessui/react";
// services
import { CycleService } from "services/cycle.service";
@@ -7,8 +8,20 @@ import useToast from "hooks/use-toast";
import { useMobxStore } from "lib/mobx/store-provider";
// components
import { CycleForm } from "components/cycles";
// helper
import { getDateRangeStatus } from "helpers/date-time.helper";
// types
import type { CycleDateCheckData, ICycle } from "types";
import type { CycleDateCheckData, ICycle, IProject, IUser } from "types";
// fetch keys
import {
COMPLETED_CYCLES_LIST,
CURRENT_CYCLE_LIST,
CYCLES_LIST,
DRAFT_CYCLES_LIST,
INCOMPLETE_CYCLES_LIST,
PROJECT_DETAILS,
UPCOMING_CYCLES_LIST,
} from "constants/fetch-keys";
type CycleModalProps = {
isOpen: boolean;
@@ -21,19 +34,49 @@ type CycleModalProps = {
// services
const cycleService = new CycleService();
export const CycleCreateUpdateModal: React.FC<CycleModalProps> = (props) => {
export const CreateUpdateCycleModal: React.FC<CycleModalProps> = (props) => {
const { isOpen, handleClose, data, workspaceSlug, projectId } = props;
// store
const { cycle: cycleStore } = useMobxStore();
// states
const [activeProject, setActiveProject] = useState<string>(projectId);
// toast
const [activeProject, setActiveProject] = useState<string | null>(null);
const { project: projectStore } = useMobxStore();
const projects = workspaceSlug ? projectStore.projects[workspaceSlug.toString()] : undefined;
const { setToastAlert } = useToast();
const createCycle = async (payload: Partial<ICycle>) =>
cycleStore
.createCycle(workspaceSlug, projectId, payload)
.then(() => {
const createCycle = async (payload: Partial<ICycle>) => {
await cycleService
.createCycle(workspaceSlug.toString(), projectId.toString(), payload, {} as IUser)
.then((res) => {
switch (getDateRangeStatus(res.start_date, res.end_date)) {
case "completed":
mutate(COMPLETED_CYCLES_LIST(projectId.toString()));
break;
case "current":
mutate(CURRENT_CYCLE_LIST(projectId.toString()));
break;
case "upcoming":
mutate(UPCOMING_CYCLES_LIST(projectId.toString()));
break;
default:
mutate(DRAFT_CYCLES_LIST(projectId.toString()));
}
mutate(INCOMPLETE_CYCLES_LIST(projectId.toString()));
mutate(CYCLES_LIST(projectId.toString()));
// update total cycles count in the project details
mutate<IProject>(
PROJECT_DETAILS(projectId.toString()),
(prevData) => {
if (!prevData) return prevData;
return {
...prevData,
total_cycles: prevData.total_cycles + 1,
};
},
false
);
setToastAlert({
type: "success",
title: "Success!",
@@ -47,11 +90,42 @@ export const CycleCreateUpdateModal: React.FC<CycleModalProps> = (props) => {
message: "Error in creating cycle. Please try again.",
});
});
};
const updateCycle = async (cycleId: string, payload: Partial<ICycle>) => {
await cycleService
.updateCycle(workspaceSlug.toString(), projectId.toString(), cycleId, payload, {} as IUser)
.then((res) => {
switch (getDateRangeStatus(data?.start_date, data?.end_date)) {
case "completed":
mutate(COMPLETED_CYCLES_LIST(projectId.toString()));
break;
case "current":
mutate(CURRENT_CYCLE_LIST(projectId.toString()));
break;
case "upcoming":
mutate(UPCOMING_CYCLES_LIST(projectId.toString()));
break;
default:
mutate(DRAFT_CYCLES_LIST(projectId.toString()));
}
mutate(CYCLES_LIST(projectId.toString()));
if (getDateRangeStatus(data?.start_date, data?.end_date) != getDateRangeStatus(res.start_date, res.end_date)) {
switch (getDateRangeStatus(res.start_date, res.end_date)) {
case "completed":
mutate(COMPLETED_CYCLES_LIST(projectId.toString()));
break;
case "current":
mutate(CURRENT_CYCLE_LIST(projectId.toString()));
break;
case "upcoming":
mutate(UPCOMING_CYCLES_LIST(projectId.toString()));
break;
default:
mutate(DRAFT_CYCLES_LIST(projectId.toString()));
}
}
const updateCycle = async (cycleId: string, payload: Partial<ICycle>) =>
cycleStore
.updateCycle(workspaceSlug, projectId, cycleId, payload)
.then(() => {
setToastAlert({
type: "success",
title: "Success!",
@@ -65,6 +139,7 @@ export const CycleCreateUpdateModal: React.FC<CycleModalProps> = (props) => {
message: "Error in updating cycle. Please try again.",
});
});
};
const dateChecker = async (payload: CycleDateCheckData) => {
let status = false;
@@ -111,6 +186,27 @@ export const CycleCreateUpdateModal: React.FC<CycleModalProps> = (props) => {
});
};
useEffect(() => {
// if modal is closed, reset active project to null
// and return to avoid activeProject being set to some other project
if (!isOpen) {
setActiveProject(null);
return;
}
// if data is present, set active project to the project of the
// issue. This has more priority than the project in the url.
if (data && data.project) {
setActiveProject(data.project);
return;
}
// if data is not present, set active project to the project
// in the url. This has the least priority.
if (projects && projects.length > 0 && !activeProject)
setActiveProject(projects?.find((p) => p.id === projectId)?.id ?? projects?.[0].id ?? null);
}, [activeProject, data, projectId, projects, isOpen]);
return (
<Transition.Root show={isOpen} as={React.Fragment}>
<Dialog as="div" className="relative z-20" onClose={handleClose}>
@@ -141,7 +237,7 @@ export const CycleCreateUpdateModal: React.FC<CycleModalProps> = (props) => {
<CycleForm
handleFormSubmit={handleFormSubmit}
handleClose={handleClose}
projectId={activeProject}
projectId={activeProject ?? ""}
setActiveProject={setActiveProject}
data={data}
/>
+5 -2
View File
@@ -1,6 +1,7 @@
import React, { useState } from "react";
import { useRouter } from "next/router";
import useSWR from "swr";
import useUserAuth from "hooks/use-user-auth";
import { Listbox, Transition } from "@headlessui/react";
// icons
import { ContrastIcon } from "@plane/ui";
@@ -8,7 +9,7 @@ import { Plus } from "lucide-react";
// services
import { CycleService } from "services/cycle.service";
// components
import { CycleCreateUpdateModal } from "components/cycles";
import { CreateUpdateCycleModal } from "components/cycles";
// fetch-keys
import { CYCLES_LIST } from "constants/fetch-keys";
@@ -28,6 +29,8 @@ export const CycleSelect: React.FC<IssueCycleSelectProps> = ({ projectId, value,
const router = useRouter();
const { workspaceSlug } = router.query;
const { user } = useUserAuth();
const { data: cycles } = useSWR(
workspaceSlug && projectId ? CYCLES_LIST(projectId) : null,
workspaceSlug && projectId
@@ -48,7 +51,7 @@ export const CycleSelect: React.FC<IssueCycleSelectProps> = ({ projectId, value,
return (
<>
{workspaceSlug && projectId && (
<CycleCreateUpdateModal
<CreateUpdateCycleModal
isOpen={isCycleModalActive}
handleClose={closeCycleModal}
workspaceSlug={workspaceSlug.toString()}
+311 -254
View File
@@ -13,31 +13,38 @@ import useToast from "hooks/use-toast";
// components
import { SidebarProgressStats } from "components/core";
import ProgressChart from "components/core/sidebar/progress-chart";
import { CycleDeleteModal } from "components/cycles/delete-modal";
import { CycleDeleteModal } from "components/cycles/cycle-delete-modal";
// ui
import { CustomRangeDatePicker } from "components/ui";
import { Avatar, CustomMenu, Loader, LayersIcon } from "@plane/ui";
import { CustomMenu, Loader, ProgressBar } from "@plane/ui";
// icons
import { ChevronDown, LinkIcon, Trash2, UserCircle2, AlertCircle, ChevronRight, MoveRight } from "lucide-react";
// helpers
import { copyUrlToClipboard } from "helpers/string.helper";
import {
findHowManyDaysLeft,
CalendarDays,
ChevronDown,
File,
MoveRight,
LinkIcon,
PieChart,
Trash2,
UserCircle2,
AlertCircle,
} from "lucide-react";
// helpers
import { capitalizeFirstLetter, copyUrlToClipboard } from "helpers/string.helper";
import {
getDateRangeStatus,
isDateGreaterThanToday,
renderDateFormat,
renderShortDate,
renderShortMonthDate,
renderShortDateWithYearFormat,
} from "helpers/date-time.helper";
// types
import { ICycle } from "types";
// fetch-keys
import { CYCLE_DETAILS } from "constants/fetch-keys";
import { CYCLE_STATUS } from "constants/cycle";
type Props = {
isOpen: boolean;
cycleId: string;
handleClose: () => void;
};
// services
@@ -45,12 +52,12 @@ const cycleService = new CycleService();
// TODO: refactor the whole component
export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
const { cycleId, handleClose } = props;
const { isOpen, cycleId } = props;
const [cycleDeleteModal, setCycleDeleteModal] = useState(false);
const router = useRouter();
const { workspaceSlug, projectId, peekCycle } = router.query;
const { workspaceSlug, projectId } = router.query;
const { user: userStore, cycle: cycleDetailsStore } = useMobxStore();
@@ -137,7 +144,7 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
});
if (isDateValidForExistingCycle) {
submitChanges({
await submitChanges({
start_date: renderDateFormat(`${watch("start_date")}`),
end_date: renderDateFormat(`${watch("end_date")}`),
});
@@ -211,7 +218,7 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
});
if (isDateValidForExistingCycle) {
submitChanges({
await submitChanges({
start_date: renderDateFormat(`${watch("start_date")}`),
end_date: renderDateFormat(`${watch("end_date")}`),
});
@@ -273,289 +280,339 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
if (!cycleDetails) return null;
const endDate = new Date(cycleDetails.end_date ?? "");
const startDate = new Date(cycleDetails.start_date ?? "");
const areYearsEqual = startDate.getFullYear() === endDate.getFullYear();
const currentCycle = CYCLE_STATUS.find((status) => status.value === cycleStatus);
const issueCount =
cycleDetails.total_issues === 0
? "0 Issue"
: cycleDetails.total_issues === cycleDetails.completed_issues
? cycleDetails.total_issues > 1
? `${cycleDetails.total_issues}`
: `${cycleDetails.total_issues}`
: `${cycleDetails.completed_issues}/${cycleDetails.total_issues}`;
return (
<>
{cycleDetails && workspaceSlug && projectId && (
<CycleDeleteModal
cycle={cycleDetails}
isOpen={cycleDeleteModal}
handleClose={() => setCycleDeleteModal(false)}
modal={cycleDeleteModal}
modalClose={() => setCycleDeleteModal(false)}
onSubmit={() => {}}
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
/>
)}
<div
className={`absolute top-0 z-20 ${
isOpen ? "right-0" : "-right-[24rem]"
} h-full w-[24rem] overflow-y-auto border-l border-custom-border-100 bg-custom-sidebar-background-100 pt-5 pb-10 duration-300`}
>
{cycleDetails ? (
<>
<div className="flex flex-col items-start justify-center">
<div className="flex gap-2.5 px-5 text-sm">
<div className="flex items-center">
<span className="flex items-center rounded border-[0.5px] border-custom-border-200 bg-custom-background-90 px-2 py-1 text-center text-xs capitalize">
{capitalizeFirstLetter(cycleStatus)}
</span>
</div>
<div className="relative flex h-full w-52 items-center gap-2">
<Popover className="flex h-full items-center justify-center rounded-lg">
{({}) => (
<>
<Popover.Button
disabled={isCompleted ?? false}
className={`group flex h-full items-center gap-2 whitespace-nowrap rounded border-[0.5px] border-custom-border-200 bg-custom-background-90 px-2 py-1 text-xs ${
cycleDetails.start_date ? "" : "text-custom-text-200"
}`}
>
<CalendarDays className="h-3 w-3" />
<span>
{renderShortDateWithYearFormat(
new Date(`${watch("start_date") ? watch("start_date") : cycleDetails?.start_date}`),
"Start date"
)}
</span>
</Popover.Button>
{cycleDetails ? (
<>
<div className="flex items-center justify-between w-full">
<div>
{peekCycle && (
<button
className="flex items-center justify-center h-5 w-5 rounded-full bg-custom-border-300"
onClick={() => handleClose()}
>
<ChevronRight className="h-3 w-3 text-white stroke-2" />
</button>
)}
</div>
<div className="flex items-center gap-3.5">
<button onClick={handleCopyText}>
<LinkIcon className="h-3 w-3 text-custom-text-300" />
</button>
{!isCompleted && (
<CustomMenu width="lg" ellipsis>
<CustomMenu.MenuItem onClick={() => setCycleDeleteModal(true)}>
<span className="flex items-center justify-start gap-2">
<Trash2 className="h-4 w-4" />
<span>Delete</span>
</span>
</CustomMenu.MenuItem>
</CustomMenu>
)}
</div>
</div>
<Transition
as={React.Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<Popover.Panel className="absolute top-10 -right-5 z-20 transform overflow-hidden">
<CustomRangeDatePicker
value={watch("start_date") ? watch("start_date") : cycleDetails?.start_date}
onChange={(val) => {
if (val) {
handleStartDateChange(val);
}
}}
startDate={watch("start_date") ? `${watch("start_date")}` : null}
endDate={watch("end_date") ? `${watch("end_date")}` : null}
maxDate={new Date(`${watch("end_date")}`)}
selectsStart
/>
</Popover.Panel>
</Transition>
</>
)}
</Popover>
<span>
<MoveRight className="h-3 w-3 text-custom-text-200" />
</span>
<Popover className="flex h-full items-center justify-center rounded-lg">
{({}) => (
<>
<Popover.Button
disabled={isCompleted ?? false}
className={`group flex items-center gap-2 whitespace-nowrap rounded border-[0.5px] border-custom-border-200 bg-custom-background-90 px-2 py-1 text-xs ${
cycleDetails.end_date ? "" : "text-custom-text-200"
}`}
>
<CalendarDays className="h-3 w-3" />
<div className="flex flex-col gap-3">
<h4 className="text-xl font-semibold break-words w-full text-custom-text-100">{cycleDetails.name}</h4>
<div className="flex items-center gap-5">
{currentCycle && (
<span
className="flex items-center justify-center text-xs text-center h-6 w-20 rounded-sm"
style={{
color: currentCycle.color,
backgroundColor: `${currentCycle.color}20`,
}}
>
{currentCycle.value === "current"
? `${findHowManyDaysLeft(cycleDetails.end_date ?? new Date())} ${currentCycle.label}`
: `${currentCycle.label}`}
</span>
)}
<div className="relative flex h-full w-52 items-center gap-2.5">
<Popover className="flex h-full items-center justify-center rounded-lg">
<Popover.Button
disabled={isCompleted ?? false}
className="text-sm text-custom-text-300 font-medium cursor-default"
>
{areYearsEqual ? renderShortDate(startDate, "_ _") : renderShortMonthDate(startDate, "_ _")}
</Popover.Button>
<span>
{renderShortDateWithYearFormat(
new Date(`${watch("end_date") ? watch("end_date") : cycleDetails?.end_date}`),
"End date"
)}
</span>
</Popover.Button>
<Transition
as={React.Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<Popover.Panel className="absolute top-10 -right-5 z-20 transform overflow-hidden">
<CustomRangeDatePicker
value={watch("start_date") ? watch("start_date") : cycleDetails?.start_date}
onChange={(val) => {
if (val) {
handleStartDateChange(val);
}
}}
startDate={watch("start_date") ? `${watch("start_date")}` : null}
endDate={watch("end_date") ? `${watch("end_date")}` : null}
maxDate={new Date(`${watch("end_date")}`)}
selectsStart
/>
</Popover.Panel>
</Transition>
</Popover>
<MoveRight className="h-4 w-4 text-custom-text-300" />
<Popover className="flex h-full items-center justify-center rounded-lg">
{({}) => (
<>
<Popover.Button
disabled={isCompleted ?? false}
className="text-sm text-custom-text-300 font-medium cursor-default"
>
{areYearsEqual ? renderShortDate(endDate, "_ _") : renderShortMonthDate(endDate, "_ _")}
</Popover.Button>
<Transition
as={React.Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<Popover.Panel className="absolute top-10 -right-5 z-20 transform overflow-hidden">
<CustomRangeDatePicker
value={watch("end_date") ? watch("end_date") : cycleDetails?.end_date}
onChange={(val) => {
if (val) {
handleEndDateChange(val);
}
}}
startDate={watch("start_date") ? `${watch("start_date")}` : null}
endDate={watch("end_date") ? `${watch("end_date")}` : null}
minDate={new Date(`${watch("start_date")}`)}
selectsEnd
/>
</Popover.Panel>
</Transition>
</>
)}
</Popover>
<Transition
as={React.Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<Popover.Panel className="absolute top-10 -right-5 z-20 transform overflow-hidden">
<CustomRangeDatePicker
value={watch("end_date") ? watch("end_date") : cycleDetails?.end_date}
onChange={(val) => {
if (val) {
handleEndDateChange(val);
}
}}
startDate={watch("start_date") ? `${watch("start_date")}` : null}
endDate={watch("end_date") ? `${watch("end_date")}` : null}
minDate={new Date(`${watch("start_date")}`)}
selectsEnd
/>
</Popover.Panel>
</Transition>
</>
)}
</Popover>
</div>
</div>
</div>
</div>
{cycleDetails.description && (
<span className="whitespace-normal text-sm leading-5 py-2.5 text-custom-text-200 break-words w-full">
{cycleDetails.description}
</span>
)}
<div className="flex w-full flex-col gap-6 px-6 py-6">
<div className="flex w-full flex-col items-start justify-start gap-2">
<div className="flex w-full items-start justify-between gap-2">
<div className="max-w-[300px]">
<h4 className="text-xl font-semibold text-custom-text-100 break-words w-full">
{cycleDetails.name}
</h4>
</div>
<CustomMenu width="lg" ellipsis>
{!isCompleted && (
<CustomMenu.MenuItem onClick={() => setCycleDeleteModal(true)}>
<span className="flex items-center justify-start gap-2">
<Trash2 className="h-4 w-4" />
<span>Delete</span>
</span>
</CustomMenu.MenuItem>
)}
<CustomMenu.MenuItem onClick={handleCopyText}>
<span className="flex items-center justify-start gap-2">
<LinkIcon className="h-4 w-4" />
<span>Copy link</span>
</span>
</CustomMenu.MenuItem>
</CustomMenu>
</div>
<div className="flex flex-col gap-5 pt-2.5 pb-6">
<div className="flex items-center justify-start gap-1">
<div className="flex w-1/2 items-center justify-start gap-2 text-custom-text-300">
<UserCircle2 className="h-4 w-4" />
<span className="text-base">Lead</span>
</div>
<div className="flex items-center w-1/2 rounded-sm">
<div className="flex items-center gap-2.5">
<Avatar name={cycleDetails.owned_by.display_name} src={cycleDetails.owned_by.avatar} />
<span className="text-sm text-custom-text-200">{cycleDetails.owned_by.display_name}</span>
<span className="whitespace-normal text-sm leading-5 text-custom-text-200 break-words w-full">
{cycleDetails.description}
</span>
</div>
<div className="flex flex-col gap-4 text-sm">
<div className="flex items-center justify-start gap-1">
<div className="flex w-40 items-center justify-start gap-2 text-custom-text-200">
<UserCircle2 className="h-5 w-5" />
<span>Lead</span>
</div>
<div className="flex items-center gap-2.5">
{cycleDetails.owned_by.avatar && cycleDetails.owned_by.avatar !== "" ? (
<img
src={cycleDetails.owned_by.avatar}
height={12}
width={12}
className="rounded-full"
alt={cycleDetails.owned_by.display_name}
/>
) : (
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-gray-800 capitalize text-white">
{cycleDetails.owned_by.display_name.charAt(0)}
</span>
)}
<span className="text-custom-text-200">{cycleDetails.owned_by.display_name}</span>
</div>
</div>
<div className="flex items-center justify-start gap-1">
<div className="flex w-40 items-center justify-start gap-2 text-custom-text-200">
<PieChart className="h-5 w-5" />
<span>Progress</span>
</div>
<div className="flex items-center gap-2.5 text-custom-text-200">
<span className="h-4 w-4">
<ProgressBar value={cycleDetails.completed_issues} maxValue={cycleDetails.total_issues} />
</span>
{cycleDetails.completed_issues}/{cycleDetails.total_issues}
</div>
</div>
</div>
</div>
</div>
<div className="flex items-center justify-start gap-1">
<div className="flex w-1/2 items-center justify-start gap-2 text-custom-text-300">
<LayersIcon className="h-4 w-4" />
<span className="text-base">Issues</span>
</div>
<div className="flex items-center w-1/2">
<span className="text-sm text-custom-text-300 px-1.5">{issueCount}</span>
</div>
</div>
</div>
<div className="flex flex-col">
<div className="flex w-full flex-col items-center justify-start gap-2 border-t border-custom-border-200 py-5 px-1.5">
<Disclosure>
<div className="flex w-full flex-col items-center justify-start gap-2 border-t border-custom-border-200 p-6">
<Disclosure defaultOpen>
{({ open }) => (
<div className={`relative flex h-full w-full flex-col ${open ? "" : "flex-row"}`}>
<div className="flex w-full items-center justify-between gap-2">
<div className="flex w-full items-center justify-between gap-2 ">
<div className="flex items-center justify-start gap-2 text-sm">
<span className="font-medium text-custom-text-200">Progress</span>
</div>
<div className="flex items-center gap-2.5">
{progressPercentage ? (
<span className="flex items-center justify-center h-5 w-9 rounded text-xs font-medium text-amber-500 bg-amber-50">
{!open && progressPercentage ? (
<span className="rounded bg-[#09A953]/10 px-1.5 py-0.5 text-xs text-[#09A953]">
{progressPercentage ? `${progressPercentage}%` : ""}
</span>
) : (
""
)}
{isStartValid && isEndValid ? (
<Disclosure.Button className="p-1.5">
<ChevronDown
className={`h-3 w-3 ${open ? "rotate-180 transform" : ""}`}
aria-hidden="true"
/>
</Disclosure.Button>
) : (
<div className="flex items-center gap-1">
<AlertCircle height={14} width={14} className="text-custom-text-200" />
<span className="text-xs italic text-custom-text-200">
Invalid date. Please enter valid date.
</span>
</div>
)}
</div>
{isStartValid && isEndValid ? (
<Disclosure.Button>
<ChevronDown className={`h-3 w-3 ${open ? "rotate-180 transform" : ""}`} aria-hidden="true" />
</Disclosure.Button>
) : (
<div className="flex items-center gap-1">
<AlertCircle className="h-3.5 w-3.5 text-custom-text-200" />
<span className="text-xs italic text-custom-text-200">
{cycleStatus === "upcoming"
? "Cycle is yet to start."
: "Invalid date. Please enter valid date."}
</span>
</div>
)}
</div>
<Transition show={open}>
<Disclosure.Panel>
<div className="flex flex-col gap-3">
{isStartValid && isEndValid ? (
<div className="h-full w-full pt-4">
<div className="flex items-start gap-4 py-2 text-xs">
<div className="flex items-center gap-3 text-custom-text-100">
<div className="flex items-center justify-center gap-1">
<span className="h-2.5 w-2.5 rounded-full bg-[#A9BBD0]" />
<span>Ideal</span>
</div>
<div className="flex items-center justify-center gap-1">
<span className="h-2.5 w-2.5 rounded-full bg-[#4C8FFF]" />
<span>Current</span>
</div>
{isStartValid && isEndValid ? (
<div className=" h-full w-full py-4">
<div className="flex items-start justify-between gap-4 py-2 text-xs">
<div className="flex items-center gap-1">
<span>
<File className="h-3 w-3 text-custom-text-200" />
</span>
<span>
Pending Issues -{" "}
{cycleDetails.total_issues -
(cycleDetails.completed_issues + cycleDetails.cancelled_issues)}
</span>
</div>
<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="relative h-40 w-80">
<ProgressChart
distribution={cycleDetails.distribution.completion_chart}
startDate={cycleDetails.start_date ?? ""}
endDate={cycleDetails.end_date ?? ""}
totalIssues={cycleDetails.total_issues}
/>
</div>
</div>
) : (
""
)}
{cycleDetails.total_issues > 0 && (
<div className="h-full w-full pt-5 border-t border-custom-border-200">
<SidebarProgressStats
distribution={cycleDetails.distribution}
groupedIssues={{
backlog: cycleDetails.backlog_issues,
unstarted: cycleDetails.unstarted_issues,
started: cycleDetails.started_issues,
completed: cycleDetails.completed_issues,
cancelled: cycleDetails.cancelled_issues,
}}
<div className="relative">
<ProgressChart
distribution={cycleDetails.distribution.completion_chart}
startDate={cycleDetails.start_date ?? ""}
endDate={cycleDetails.end_date ?? ""}
totalIssues={cycleDetails.total_issues}
isPeekView={Boolean(peekCycle)}
/>
</div>
)}
</div>
</div>
) : (
""
)}
</Disclosure.Panel>
</Transition>
</div>
)}
</Disclosure>
</div>
</div>
</>
) : (
<Loader className="px-5">
<div className="space-y-2">
<Loader.Item height="15px" width="50%" />
<Loader.Item height="15px" width="30%" />
</div>
<div className="mt-8 space-y-3">
<Loader.Item height="30px" />
<Loader.Item height="30px" />
<Loader.Item height="30px" />
</div>
</Loader>
)}
<div className="flex w-full flex-col items-center justify-start gap-2 border-t border-custom-border-200 p-6">
<Disclosure defaultOpen>
{({ open }) => (
<div className={`relative flex h-full w-full flex-col ${open ? "" : "flex-row"}`}>
<div className="flex w-full items-center justify-between gap-2">
<div className="flex items-center justify-start gap-2 text-sm">
<span className="font-medium text-custom-text-200">Other Information</span>
</div>
{cycleDetails.total_issues > 0 ? (
<Disclosure.Button>
<ChevronDown className={`h-3 w-3 ${open ? "rotate-180 transform" : ""}`} aria-hidden="true" />
</Disclosure.Button>
) : (
<div className="flex items-center gap-1">
<AlertCircle className="h-3.5 w-3.5 text-custom-text-200" />
<span className="text-xs italic text-custom-text-200">
No issues found. Please add issue.
</span>
</div>
)}
</div>
<Transition show={open}>
<Disclosure.Panel>
{cycleDetails.total_issues > 0 ? (
<div className="h-full w-full py-4">
<SidebarProgressStats
distribution={cycleDetails.distribution}
groupedIssues={{
backlog: cycleDetails.backlog_issues,
unstarted: cycleDetails.unstarted_issues,
started: cycleDetails.started_issues,
completed: cycleDetails.completed_issues,
cancelled: cycleDetails.cancelled_issues,
}}
totalIssues={cycleDetails.total_issues}
/>
</div>
) : (
""
)}
</Disclosure.Panel>
</Transition>
</div>
)}
</Disclosure>
</div>
</>
) : (
<Loader className="px-5">
<div className="space-y-2">
<Loader.Item height="15px" width="50%" />
<Loader.Item height="15px" width="30%" />
</div>
<div className="mt-8 space-y-3">
<Loader.Item height="30px" />
<Loader.Item height="30px" />
<Loader.Item height="30px" />
</div>
</Loader>
)}
</div>
</>
);
});
+389
View File
@@ -0,0 +1,389 @@
import React from "react";
import Link from "next/link";
import { useRouter } from "next/router";
// headless ui
import { Disclosure, Transition } from "@headlessui/react";
// hooks
import useToast from "hooks/use-toast";
// components
import { SingleProgressStats } from "components/core";
// ui
import { CustomMenu, Tooltip, LinearProgressIndicator, ContrastIcon, RunningIcon } from "@plane/ui";
import { AssigneesList } from "components/ui/avatar";
// icons
import {
AlarmClock,
AlertTriangle,
ArrowRight,
CalendarDays,
ChevronDown,
LinkIcon,
Pencil,
Star,
Target,
Trash2,
} from "lucide-react";
// helpers
import { getDateRangeStatus, renderShortDateWithYearFormat, findHowManyDaysLeft } from "helpers/date-time.helper";
import { copyTextToClipboard, truncateText } from "helpers/string.helper";
// types
import { ICycle } from "types";
type TSingleStatProps = {
cycle: ICycle;
handleEditCycle: () => void;
handleDeleteCycle: () => void;
handleAddToFavorites: () => void;
handleRemoveFromFavorites: () => void;
};
const stateGroups = [
{
key: "backlog_issues",
title: "Backlog",
color: "#dee2e6",
},
{
key: "unstarted_issues",
title: "Unstarted",
color: "#26b5ce",
},
{
key: "started_issues",
title: "Started",
color: "#f7ae59",
},
{
key: "cancelled_issues",
title: "Cancelled",
color: "#d687ff",
},
{
key: "completed_issues",
title: "Completed",
color: "#09a953",
},
];
export const SingleCycleCard: React.FC<TSingleStatProps> = ({
cycle,
handleEditCycle,
handleDeleteCycle,
handleAddToFavorites,
handleRemoveFromFavorites,
}) => {
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { setToastAlert } = useToast();
const cycleStatus = getDateRangeStatus(cycle.start_date, cycle.end_date);
const isCompleted = cycleStatus === "completed";
const endDate = new Date(cycle.end_date ?? "");
const startDate = new Date(cycle.start_date ?? "");
const handleCopyText = () => {
const originURL = typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
copyTextToClipboard(`${originURL}/${workspaceSlug}/projects/${projectId}/cycles/${cycle.id}`).then(() => {
setToastAlert({
type: "success",
title: "Link Copied!",
message: "Cycle link copied to clipboard.",
});
});
};
const progressIndicatorData = stateGroups.map((group, index) => ({
id: index,
name: group.title,
value: cycle.total_issues > 0 ? ((cycle[group.key as keyof ICycle] as number) / cycle.total_issues) * 100 : 0,
color: group.color,
}));
const groupedIssues: any = {
backlog: cycle.backlog_issues,
unstarted: cycle.unstarted_issues,
started: cycle.started_issues,
completed: cycle.completed_issues,
cancelled: cycle.cancelled_issues,
};
return (
<div>
<div className="flex flex-col rounded-[10px] bg-custom-background-100 border border-custom-border-200 text-xs shadow">
<Link href={`/${workspaceSlug}/projects/${projectId}/cycles/${cycle.id}`}>
<a className="w-full">
<div className="flex h-full flex-col gap-4 rounded-b-[10px] p-4">
<div className="flex items-center justify-between gap-1">
<span className="flex items-center gap-1">
<span className="h-5 w-5">
<ContrastIcon
className="h-5 w-5"
color={`${
cycleStatus === "current"
? "#09A953"
: cycleStatus === "upcoming"
? "#F7AE59"
: cycleStatus === "completed"
? "#3F76FF"
: cycleStatus === "draft"
? "rgb(var(--color-text-200))"
: ""
}`}
/>
</span>
<Tooltip tooltipContent={cycle.name} className="break-words" position="top-left">
<h3 className="break-words text-lg font-semibold">{truncateText(cycle.name, 15)}</h3>
</Tooltip>
</span>
<span className="flex items-center gap-1 capitalize">
<span
className={`rounded-full px-1.5 py-0.5
${
cycleStatus === "current"
? "bg-green-600/5 text-green-600"
: cycleStatus === "upcoming"
? "bg-orange-300/5 text-orange-300"
: cycleStatus === "completed"
? "bg-blue-500/5 text-blue-500"
: cycleStatus === "draft"
? "bg-neutral-400/5 text-neutral-400"
: ""
}`}
>
{cycleStatus === "current" ? (
<span className="flex gap-1 whitespace-nowrap">
<RunningIcon className="h-4 w-4" />
{findHowManyDaysLeft(cycle.end_date ?? new Date())} Days Left
</span>
) : cycleStatus === "upcoming" ? (
<span className="flex gap-1 whitespace-nowrap">
<AlarmClock className="h-4 w-4" />
{findHowManyDaysLeft(cycle.start_date ?? new Date())} Days Left
</span>
) : cycleStatus === "completed" ? (
<span className="flex gap-1 whitespace-nowrap">
{cycle.total_issues - cycle.completed_issues > 0 && (
<Tooltip
tooltipContent={`${cycle.total_issues - cycle.completed_issues} more pending ${
cycle.total_issues - cycle.completed_issues === 1 ? "issue" : "issues"
}`}
>
<span>
<AlertTriangle className="h-3.5 w-3.5" />
</span>
</Tooltip>
)}{" "}
Completed
</span>
) : (
cycleStatus
)}
</span>
{cycle.is_favorite ? (
<button
onClick={(e) => {
e.preventDefault();
handleRemoveFromFavorites();
}}
>
<Star className="h-4 w-4 text-orange-400" fill="#f6ad55" />
</button>
) : (
<button
onClick={(e) => {
e.preventDefault();
handleAddToFavorites();
}}
>
<Star className="h-4 w-4 " color="rgb(var(--color-text-200))" />
</button>
)}
</span>
</div>
<div className="flex h-4 items-center justify-start gap-5 text-custom-text-200">
{cycleStatus !== "draft" && (
<>
<div className="flex items-start gap-1">
<CalendarDays className="h-4 w-4" />
<span>{renderShortDateWithYearFormat(startDate)}</span>
</div>
<ArrowRight className="h-4 w-4" />
<div className="flex items-start gap-1">
<Target className="h-4 w-4" />
<span>{renderShortDateWithYearFormat(endDate)}</span>
</div>
</>
)}
</div>
<div className="flex justify-between items-end">
<div className="flex flex-col gap-2 text-xs text-custom-text-200">
<div className="flex items-center gap-2">
<div className="w-16">Creator:</div>
<div className="flex items-center gap-2.5 text-custom-text-200">
{cycle.owned_by.avatar && cycle.owned_by.avatar !== "" ? (
<img
src={cycle.owned_by.avatar}
height={16}
width={16}
className="rounded-full"
alt={cycle.owned_by.display_name}
/>
) : (
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-orange-300 capitalize text-white">
{cycle.owned_by.display_name.charAt(0)}
</span>
)}
<span className="text-custom-text-200">{cycle.owned_by.display_name}</span>
</div>
</div>
<div className="flex h-5 items-center gap-2">
<div className="w-16">Members:</div>
{cycle.assignees.length > 0 ? (
<div className="flex items-center gap-1 text-custom-text-200">
<AssigneesList users={cycle.assignees} length={4} />
</div>
) : (
"No members"
)}
</div>
</div>
<div className="flex items-center">
{!isCompleted && (
<button
onClick={(e) => {
e.preventDefault();
handleEditCycle();
}}
className="cursor-pointer rounded p-1 text-custom-text-200 duration-300 hover:bg-custom-background-80"
>
<Pencil className="h-4 w-4" />
</button>
)}
<CustomMenu width="auto" verticalEllipsis>
{!isCompleted && (
<CustomMenu.MenuItem
onClick={(e) => {
e.preventDefault();
handleDeleteCycle();
}}
>
<span className="flex items-center justify-start gap-2">
<Trash2 className="h-4 w-4" />
<span>Delete cycle</span>
</span>
</CustomMenu.MenuItem>
)}
<CustomMenu.MenuItem
onClick={(e) => {
e.preventDefault();
handleCopyText();
}}
>
<span className="flex items-center justify-start gap-2">
<LinkIcon className="h-4 w-4" />
<span>Copy cycle link</span>
</span>
</CustomMenu.MenuItem>
</CustomMenu>
</div>
</div>
</div>
</a>
</Link>
<div className="flex h-full flex-col rounded-b-[10px]">
<Disclosure>
{({ open }) => (
<div
className={`flex h-full w-full flex-col rounded-b-[10px] border-t border-custom-border-200 bg-custom-background-80 text-custom-text-200 ${
open ? "" : "flex-row"
}`}
>
<div className="flex w-full items-center gap-2 px-4 py-1">
<span>Progress</span>
<Tooltip
tooltipContent={
<div className="flex w-56 flex-col">
{Object.keys(groupedIssues).map((group, index) => (
<SingleProgressStats
key={index}
title={
<div className="flex items-center gap-2">
<span
className="block h-3 w-3 rounded-full "
style={{
backgroundColor: stateGroups[index].color,
}}
/>
<span className="text-xs capitalize">{group}</span>
</div>
}
completed={groupedIssues[group]}
total={cycle.total_issues}
/>
))}
</div>
}
position="bottom"
>
<div className="flex w-full items-center">
<LinearProgressIndicator data={progressIndicatorData} noTooltip={true} />
</div>
</Tooltip>
<Disclosure.Button>
<span className="p-1">
<ChevronDown className={`h-3 w-3 ${open ? "rotate-180 transform" : ""}`} aria-hidden="true" />
</span>
</Disclosure.Button>
</div>
<Transition show={open}>
<Disclosure.Panel>
<div className="overflow-hidden rounded-b-md bg-custom-background-80 py-3 shadow">
<div className="col-span-2 space-y-3 px-4">
<div className="space-y-3 text-xs">
{stateGroups.map((group) => (
<div key={group.key} className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<span
className="block h-2 w-2 rounded-full"
style={{
backgroundColor: group.color,
}}
/>
<h6 className="text-xs">{group.title}</h6>
</div>
<div>
<span>
{cycle[group.key as keyof ICycle] as number}{" "}
<span className="text-custom-text-200">
-{" "}
{cycle.total_issues > 0
? `${Math.round(
((cycle[group.key as keyof ICycle] as number) / cycle.total_issues) * 100
)}%`
: "0%"}
</span>
</span>
</div>
</div>
))}
</div>
</div>
</div>
</Disclosure.Panel>
</Transition>
</div>
)}
</Disclosure>
</div>
</div>
</div>
);
};
+369
View File
@@ -0,0 +1,369 @@
import React, { useEffect, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/router";
// hooks
import useToast from "hooks/use-toast";
// ui
import { CustomMenu, Tooltip, LinearProgressIndicator, ContrastIcon, RunningIcon } from "@plane/ui";
// icons
import {
AlarmClock,
AlertTriangle,
ArrowRight,
CalendarDays,
LinkIcon,
Pencil,
Star,
Target,
Trash2,
} from "lucide-react";
// helpers
import { getDateRangeStatus, renderShortDateWithYearFormat, findHowManyDaysLeft } from "helpers/date-time.helper";
import { copyTextToClipboard, truncateText } from "helpers/string.helper";
// types
import { ICycle } from "types";
type TSingleStatProps = {
cycle: ICycle;
handleEditCycle: () => void;
handleDeleteCycle: () => void;
handleAddToFavorites: () => void;
handleRemoveFromFavorites: () => void;
};
const stateGroups = [
{
key: "backlog_issues",
title: "Backlog",
color: "#dee2e6",
},
{
key: "unstarted_issues",
title: "Unstarted",
color: "#26b5ce",
},
{
key: "started_issues",
title: "Started",
color: "#f7ae59",
},
{
key: "cancelled_issues",
title: "Cancelled",
color: "#d687ff",
},
{
key: "completed_issues",
title: "Completed",
color: "#09a953",
},
];
type progress = {
progress: number;
};
function RadialProgressBar({ progress }: progress) {
const [circumference, setCircumference] = useState(0);
useEffect(() => {
const radius = 40;
const circumference = 2 * Math.PI * radius;
setCircumference(circumference);
}, []);
const progressOffset = ((100 - progress) / 100) * circumference;
return (
<div className="relative h-4 w-4">
<svg className="absolute top-0 left-0" viewBox="0 0 100 100">
<circle
className={"stroke-current opacity-10"}
cx="50"
cy="50"
r="40"
strokeWidth="12"
fill="none"
strokeDasharray={`${circumference} ${circumference}`}
/>
<circle
className={`stroke-current`}
cx="50"
cy="50"
r="40"
strokeWidth="12"
fill="none"
strokeDasharray={`${circumference} ${circumference}`}
strokeDashoffset={progressOffset}
transform="rotate(-90 50 50)"
/>
</svg>
</div>
);
}
export const SingleCycleList: React.FC<TSingleStatProps> = ({
cycle,
handleEditCycle,
handleDeleteCycle,
handleAddToFavorites,
handleRemoveFromFavorites,
}) => {
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { setToastAlert } = useToast();
const cycleStatus = getDateRangeStatus(cycle.start_date, cycle.end_date);
const isCompleted = cycleStatus === "completed";
const endDate = new Date(cycle.end_date ?? "");
const startDate = new Date(cycle.start_date ?? "");
const handleCopyText = () => {
const originURL = typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
copyTextToClipboard(`${originURL}/${workspaceSlug}/projects/${projectId}/cycles/${cycle.id}`).then(() => {
setToastAlert({
type: "success",
title: "Link Copied!",
message: "Cycle link copied to clipboard.",
});
});
};
const progressIndicatorData = stateGroups.map((group, index) => ({
id: index,
name: group.title,
value: cycle.total_issues > 0 ? ((cycle[group.key as keyof ICycle] as number) / cycle.total_issues) * 100 : 0,
color: group.color,
}));
const completedIssues = cycle.completed_issues + cycle.cancelled_issues;
const percentage = cycle.total_issues > 0 ? (completedIssues / cycle.total_issues) * 100 : 0;
return (
<div>
<div className="flex flex-col text-xs hover:bg-custom-background-80">
<Link href={`/${workspaceSlug}/projects/${projectId}/cycles/${cycle.id}`}>
<a className="w-full">
<div className="flex h-full flex-col gap-4 rounded-b-[10px] p-4">
<div className="flex items-center justify-between gap-1">
<div className="flex items-start gap-2">
<ContrastIcon
className="mt-1 h-5 w-5"
color={`${
cycleStatus === "current"
? "#09A953"
: cycleStatus === "upcoming"
? "#F7AE59"
: cycleStatus === "completed"
? "#3F76FF"
: cycleStatus === "draft"
? "rgb(var(--color-text-200))"
: ""
}`}
/>
<div className="max-w-2xl">
<Tooltip tooltipContent={cycle.name} className="break-words" position="top-left">
<h3 className="break-words w-full text-base font-semibold">{truncateText(cycle.name, 60)}</h3>
</Tooltip>
<p className="mt-2 text-custom-text-200 break-words w-full">{cycle.description}</p>
</div>
</div>
<div className="flex-shrink-0 flex items-center gap-4">
<span
className={`rounded-full px-1.5 py-0.5
${
cycleStatus === "current"
? "bg-green-600/5 text-green-600"
: cycleStatus === "upcoming"
? "bg-orange-300/5 text-orange-300"
: cycleStatus === "completed"
? "bg-blue-500/5 text-blue-500"
: cycleStatus === "draft"
? "bg-neutral-400/5 text-neutral-400"
: ""
}`}
>
{cycleStatus === "current" ? (
<span className="flex gap-1 whitespace-nowrap">
<RunningIcon className="h-4 w-4" />
{findHowManyDaysLeft(cycle.end_date ?? new Date())} days left
</span>
) : cycleStatus === "upcoming" ? (
<span className="flex gap-1">
<AlarmClock className="h-4 w-4" />
{findHowManyDaysLeft(cycle.start_date ?? new Date())} days left
</span>
) : cycleStatus === "completed" ? (
<span className="flex items-center gap-1">
{cycle.total_issues - cycle.completed_issues > 0 && (
<Tooltip
tooltipContent={`${cycle.total_issues - cycle.completed_issues} more pending ${
cycle.total_issues - cycle.completed_issues === 1 ? "issue" : "issues"
}`}
>
<span>
<AlertTriangle className="h-3.5 w-3.5" />
</span>
</Tooltip>
)}{" "}
Completed
</span>
) : (
cycleStatus
)}
</span>
{cycleStatus !== "draft" && (
<div className="flex items-center justify-start gap-2 text-custom-text-200">
<div className="flex items-start gap-1 whitespace-nowrap">
<CalendarDays className="h-4 w-4" />
<span>{renderShortDateWithYearFormat(startDate)}</span>
</div>
<ArrowRight className="h-4 w-4" />
<div className="flex items-start gap-1 whitespace-nowrap">
<Target className="h-4 w-4" />
<span>{renderShortDateWithYearFormat(endDate)}</span>
</div>
</div>
)}
<div className="flex items-center gap-2.5 text-custom-text-200">
{cycle.owned_by.avatar && cycle.owned_by.avatar !== "" ? (
<img
src={cycle.owned_by.avatar}
height={16}
width={16}
className="rounded-full"
alt={cycle.owned_by.display_name}
/>
) : (
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-orange-300 capitalize text-white">
{cycle.owned_by.display_name.charAt(0)}
</span>
)}
</div>
<Tooltip
position="top-right"
tooltipContent={
<div className="flex w-80 items-center gap-2 px-4 py-1">
<span>Progress</span>
<LinearProgressIndicator data={progressIndicatorData} />
</div>
}
>
<span
className={`rounded-md px-1.5 py-1
${
cycleStatus === "current"
? "border border-green-600 bg-green-600/5 text-green-600"
: cycleStatus === "upcoming"
? "border border-orange-300 bg-orange-300/5 text-orange-300"
: cycleStatus === "completed"
? "border border-blue-500 bg-blue-500/5 text-blue-500"
: cycleStatus === "draft"
? "border border-neutral-400 bg-neutral-400/5 text-neutral-400"
: ""
}`}
>
{cycleStatus === "current" ? (
<span className="flex gap-1 whitespace-nowrap">
{cycle.total_issues > 0 ? (
<>
<RadialProgressBar progress={(cycle.completed_issues / cycle.total_issues) * 100} />
<span>{Math.floor((cycle.completed_issues / cycle.total_issues) * 100)} %</span>
</>
) : (
<span className="normal-case">No issues present</span>
)}
</span>
) : cycleStatus === "upcoming" ? (
<span className="flex gap-1">
<RadialProgressBar progress={100} /> Yet to start
</span>
) : cycleStatus === "completed" ? (
<span className="flex gap-1">
<RadialProgressBar progress={100} />
<span>{Math.round(percentage)} %</span>
</span>
) : (
<span className="flex gap-1">
<RadialProgressBar progress={(cycle.total_issues / cycle.completed_issues) * 100} />
{cycleStatus}
</span>
)}
</span>
</Tooltip>
{cycle.is_favorite ? (
<button
onClick={(e) => {
e.preventDefault();
handleRemoveFromFavorites();
}}
>
<Star className="h-4 w-4 text-orange-400" fill="#f6ad55" />
</button>
) : (
<button
onClick={(e) => {
e.preventDefault();
handleAddToFavorites();
}}
>
<Star className="h-4 w-4 " color="rgb(var(--color-text-200))" />
</button>
)}
<div className="flex items-center">
<CustomMenu width="auto" verticalEllipsis>
{!isCompleted && (
<CustomMenu.MenuItem
onClick={(e) => {
e.preventDefault();
handleEditCycle();
}}
>
<span className="flex items-center justify-start gap-2">
<Pencil className="h-4 w-4" />
<span>Edit Cycle</span>
</span>
</CustomMenu.MenuItem>
)}
{!isCompleted && (
<CustomMenu.MenuItem
onClick={(e) => {
e.preventDefault();
handleDeleteCycle();
}}
>
<span className="flex items-center justify-start gap-2">
<Trash2 className="h-4 w-4" />
<span>Delete cycle</span>
</span>
</CustomMenu.MenuItem>
)}
<CustomMenu.MenuItem
onClick={(e) => {
e.preventDefault();
handleCopyText();
}}
>
<span className="flex items-center justify-start gap-2">
<LinkIcon className="h-4 w-4" />
<span>Copy cycle link</span>
</span>
</CustomMenu.MenuItem>
</CustomMenu>
</div>
</div>
</div>
</div>
</a>
</Link>
</div>
</div>
);
};
@@ -1,11 +1,15 @@
import React, { useEffect } from "react";
import { useRouter } from "next/router";
import { Controller, useForm } from "react-hook-form";
import { Dialog, Transition } from "@headlessui/react";
// store
import { observer } from "mobx-react-lite";
import { useMobxStore } from "lib/mobx/store-provider";
import { useRouter } from "next/router";
import { mutate } from "swr";
// react-hook-form
import { Controller, useForm } from "react-hook-form";
// headless ui
import { Dialog, Transition } from "@headlessui/react";
// services
import { ProjectEstimateService } from "services/project";
// hooks
import useToast from "hooks/use-toast";
// ui
@@ -13,15 +17,29 @@ import { Button, Input, TextArea } from "@plane/ui";
// helpers
import { checkDuplicates } from "helpers/array.helper";
// types
import { IEstimate, IEstimateFormData } from "types";
import { IUser, IEstimate, IEstimateFormData } from "types";
// fetch-keys
import { ESTIMATES_LIST, ESTIMATE_DETAILS } from "constants/fetch-keys";
type Props = {
isOpen: boolean;
handleClose: () => void;
data?: IEstimate;
user: IUser | undefined;
};
const defaultValues = {
type FormValues = {
name: string;
description: string;
value1: string;
value2: string;
value3: string;
value4: string;
value5: string;
value6: string;
};
const defaultValues: Partial<FormValues> = {
name: "",
description: "",
value1: "",
@@ -32,18 +50,10 @@ const defaultValues = {
value6: "",
};
type FormValues = typeof defaultValues;
export const CreateUpdateEstimateModal: React.FC<Props> = observer((props) => {
const { handleClose, data, isOpen } = props;
// router
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
// store
const { projectEstimates: projectEstimatesStore } = useMobxStore();
// services
const projectEstimateService = new ProjectEstimateService();
export const CreateUpdateEstimateModal: React.FC<Props> = ({ handleClose, data, isOpen, user }) => {
const {
formState: { errors, isSubmitting },
handleSubmit,
@@ -58,47 +68,71 @@ export const CreateUpdateEstimateModal: React.FC<Props> = observer((props) => {
reset();
};
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { setToastAlert } = useToast();
const createEstimate = async (payload: IEstimateFormData) => {
if (!workspaceSlug || !projectId) return;
await projectEstimatesStore
.createEstimate(workspaceSlug.toString(), projectId.toString(), payload)
await projectEstimateService
.createEstimate(workspaceSlug as string, projectId as string, payload, user)
.then(() => {
mutate(ESTIMATES_LIST(projectId as string));
onClose();
})
.catch((err) => {
const error = err?.error;
const errorString = Array.isArray(error) ? error[0] : error;
setToastAlert({
type: "error",
title: "Error!",
message:
errorString ?? err.status === 400
? "Estimate with that name already exists. Please try again with another name."
: "Estimate could not be created. Please try again.",
});
if (err.status === 400)
setToastAlert({
type: "error",
title: "Error!",
message: "Estimate with that name already exists. Please try again with another name.",
});
else
setToastAlert({
type: "error",
title: "Error!",
message: "Estimate could not be created. Please try again.",
});
});
};
const updateEstimate = async (payload: IEstimateFormData) => {
if (!workspaceSlug || !projectId || !data) return;
await projectEstimatesStore
.updateEstimate(workspaceSlug.toString(), projectId.toString(), data.id, payload)
mutate<IEstimate[]>(
ESTIMATES_LIST(projectId.toString()),
(prevData) =>
prevData?.map((p) => {
if (p.id === data.id)
return {
...p,
name: payload.estimate.name,
description: payload.estimate.description,
points: p.points.map((point, index) => ({
...point,
value: payload.estimate_points[index].value,
})),
};
return p;
}),
false
);
await projectEstimateService
.patchEstimate(workspaceSlug as string, projectId as string, data?.id as string, payload, user)
.then(() => {
mutate(ESTIMATES_LIST(projectId.toString()));
mutate(ESTIMATE_DETAILS(data.id));
handleClose();
})
.catch((err) => {
const error = err?.error;
const errorString = Array.isArray(error) ? error[0] : error;
.catch(() => {
setToastAlert({
type: "error",
title: "Error!",
message: errorString ?? "Estimate could not be updated. Please try again.",
message: "Estimate could not be updated. Please try again.",
});
});
@@ -257,38 +291,151 @@ export const CreateUpdateEstimateModal: React.FC<Props> = observer((props) => {
)}
/>
</div>
{/* list of all the points */}
{/* since they are all the same, we can use a loop to render them */}
<div className="grid grid-cols-3 gap-3">
{Array(6)
.fill(0)
.map((_, i) => (
<div className="flex items-center">
<span className="flex h-full items-center rounded-lg bg-custom-background-80">
<span className="rounded-lg px-2 text-sm text-custom-text-200">{i + 1}</span>
<span className="rounded-r-lg bg-custom-background-100">
<Controller
control={control}
name={`value${i + 1}` as keyof FormValues}
render={({ field: { value, onChange, ref } }) => (
<Input
ref={ref}
type="text"
value={value}
onChange={onChange}
id={`value${i + 1}`}
name={`value${i + 1}`}
placeholder={`Point ${i + 1}`}
className="rounded-l-none w-full"
hasError={Boolean(errors[`value${i + 1}` as keyof FormValues])}
/>
)}
<div className="flex items-center">
<span className="flex h-full items-center rounded-lg bg-custom-background-80">
<span className="rounded-lg px-2 text-sm text-custom-text-200">1</span>
<span className="rounded-r-lg bg-custom-background-100">
<Controller
control={control}
name="value1"
render={({ field: { value, onChange, ref } }) => (
<Input
id="value1"
name="value1"
type="text"
value={value}
onChange={onChange}
ref={ref}
hasError={Boolean(errors.value1)}
placeholder="Point 1"
className="rounded-l-none w-full"
/>
</span>
</span>
</div>
))}
)}
/>
</span>
</span>
</div>
<div className="flex items-center">
<span className="flex h-full items-center rounded-lg bg-custom-background-80">
<span className="rounded-lg px-2 text-sm text-custom-text-200">2</span>
<span className="rounded-r-lg bg-custom-background-100">
<Controller
control={control}
name="value2"
render={({ field: { value, onChange, ref } }) => (
<Input
id="value2"
name="value2"
type="text"
value={value}
onChange={onChange}
ref={ref}
hasError={Boolean(errors.value2)}
placeholder="Point 2"
className="rounded-l-none w-full"
/>
)}
/>
</span>
</span>
</div>
<div className="flex items-center">
<span className="flex h-full items-center rounded-lg bg-custom-background-80">
<span className="rounded-lg px-2 text-sm text-custom-text-200">3</span>
<span className="rounded-r-lg bg-custom-background-100">
<Controller
control={control}
name="value3"
render={({ field: { value, onChange, ref } }) => (
<Input
id="value3"
name="value3"
type="text"
value={value}
onChange={onChange}
ref={ref}
hasError={Boolean(errors.value3)}
placeholder="Point 3"
className="rounded-l-none w-full"
/>
)}
/>
</span>
</span>
</div>
<div className="flex items-center">
<span className="flex h-full items-center rounded-lg bg-custom-background-80">
<span className="rounded-lg px-2 text-sm text-custom-text-200">4</span>
<span className="rounded-r-lg bg-custom-background-100">
<Controller
control={control}
name="value4"
render={({ field: { value, onChange, ref } }) => (
<Input
id="value4"
name="value4"
type="text"
value={value}
onChange={onChange}
ref={ref}
hasError={Boolean(errors.value4)}
placeholder="Point 4"
className="rounded-l-none w-full"
/>
)}
/>
</span>
</span>
</div>
<div className="flex items-center">
<span className="flex h-full items-center rounded-lg bg-custom-background-80">
<span className="rounded-lg px-2 text-sm text-custom-text-200">5</span>
<span className="rounded-r-lg bg-custom-background-100">
<Controller
control={control}
name="value5"
render={({ field: { value, onChange, ref } }) => (
<Input
id="value5"
name="value5"
type="text"
value={value}
onChange={onChange}
ref={ref}
hasError={Boolean(errors.value5)}
placeholder="Point 5"
className="rounded-l-none w-full"
/>
)}
/>
</span>
</span>
</div>
<div className="flex items-center">
<span className="flex h-full items-center rounded-lg bg-custom-background-80">
<span className="rounded-lg px-2 text-sm text-custom-text-200">6</span>
<span className="rounded-r-lg bg-custom-background-100">
<Controller
control={control}
name="value6"
render={({ field: { value, onChange, ref } }) => (
<Input
id="value6"
name="value6"
type="text"
value={value}
onChange={onChange}
ref={ref}
hasError={Boolean(errors.value6)}
placeholder="Point 6"
className="rounded-l-none w-full"
/>
)}
/>
</span>
</span>
</div>
</div>
</div>
<div className="mt-5 flex justify-end gap-2">
@@ -314,4 +461,4 @@ export const CreateUpdateEstimateModal: React.FC<Props> = observer((props) => {
</Transition.Root>
</>
);
});
};
@@ -1,13 +1,10 @@
import React, { useEffect, useState } from "react";
import { useRouter } from "next/router";
// headless ui
import { Dialog, Transition } from "@headlessui/react";
// store
import { observer } from "mobx-react-lite";
import { useMobxStore } from "lib/mobx/store-provider";
// hooks
import useToast from "hooks/use-toast";
// types
import { IEstimate } from "types";
// icons
import { AlertTriangle } from "lucide-react";
// ui
@@ -15,43 +12,14 @@ import { Button } from "@plane/ui";
type Props = {
isOpen: boolean;
data: IEstimate | null;
handleClose: () => void;
data: IEstimate;
handleDelete: () => void;
};
export const DeleteEstimateModal: React.FC<Props> = observer((props) => {
const { isOpen, handleClose, data } = props;
// router
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
// store
const { projectEstimates: projectEstimatesStore } = useMobxStore();
// states
export const DeleteEstimateModal: React.FC<Props> = ({ isOpen, handleClose, data, handleDelete }) => {
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
// hooks
const { setToastAlert } = useToast();
const handleEstimateDelete = () => {
if (!workspaceSlug || !projectId) return;
const estimateId = data?.id!;
projectEstimatesStore.deleteEstimate(workspaceSlug.toString(), projectId.toString(), estimateId).catch((err) => {
const error = err?.error;
const errorString = Array.isArray(error) ? error[0] : error;
setToastAlert({
type: "error",
title: "Error!",
message: errorString ?? "Estimate could not be deleted. Please try again",
});
});
};
useEffect(() => {
setIsDeleteLoading(false);
}, [isOpen]);
@@ -100,7 +68,7 @@ export const DeleteEstimateModal: React.FC<Props> = observer((props) => {
<span>
<p className="break-words text-sm leading-7 text-custom-text-200">
Are you sure you want to delete estimate-{" "}
<span className="break-words font-medium text-custom-text-100">{data?.name}</span>
<span className="break-words font-medium text-custom-text-100">{data.name}</span>
{""}? All of the data related to the estiamte will be permanently removed. This action cannot be
undone.
</p>
@@ -113,7 +81,7 @@ export const DeleteEstimateModal: React.FC<Props> = observer((props) => {
variant="danger"
onClick={() => {
setIsDeleteLoading(true);
handleEstimateDelete();
handleDelete();
}}
loading={isDeleteLoading}
>
@@ -128,4 +96,4 @@ export const DeleteEstimateModal: React.FC<Props> = observer((props) => {
</Dialog>
</Transition.Root>
);
});
};
-139
View File
@@ -1,139 +0,0 @@
import React, { useState } from "react";
import { useRouter } from "next/router";
// store
import { observer } from "mobx-react-lite";
import { useMobxStore } from "lib/mobx/store-provider";
// components
import { CreateUpdateEstimateModal, DeleteEstimateModal, EstimateListItem } from "components/estimates";
//hooks
import useToast from "hooks/use-toast";
// ui
import { Button, Loader } from "@plane/ui";
import { EmptyState } from "components/common";
// icons
import { Plus } from "lucide-react";
// images
import emptyEstimate from "public/empty-state/estimate.svg";
// types
import { IEstimate } from "types";
export const EstimatesList: React.FC = observer(() => {
// router
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
// store
const { project: projectStore } = useMobxStore();
// states
const [estimateFormOpen, setEstimateFormOpen] = useState(false);
const [estimateToDelete, setEstimateToDelete] = useState<string | null>(null);
const [estimateToUpdate, setEstimateToUpdate] = useState<IEstimate | undefined>();
// hooks
const { setToastAlert } = useToast();
// derived values
const estimatesList = projectStore.projectEstimates;
const projectDetails = projectStore.project_details?.[projectId?.toString()!];
const editEstimate = (estimate: IEstimate) => {
setEstimateFormOpen(true);
setEstimateToUpdate(estimate);
};
const disableEstimates = () => {
if (!workspaceSlug || !projectId) return;
projectStore.updateProject(workspaceSlug.toString(), projectId.toString(), { estimate: null }).catch((err) => {
const error = err?.error;
const errorString = Array.isArray(error) ? error[0] : error;
setToastAlert({
type: "error",
title: "Error!",
message: errorString ?? "Estimate could not be disabled. Please try again",
});
});
};
return (
<>
<CreateUpdateEstimateModal
isOpen={estimateFormOpen}
data={estimateToUpdate}
handleClose={() => {
setEstimateFormOpen(false);
setEstimateToUpdate(undefined);
}}
/>
<DeleteEstimateModal
isOpen={!!estimateToDelete}
handleClose={() => setEstimateToDelete(null)}
data={projectStore.getProjectEstimateById(estimateToDelete!)}
/>
<section className="flex items-center justify-between py-3.5 border-b border-custom-border-200">
<h3 className="text-xl font-medium">Estimates</h3>
<div className="col-span-12 space-y-5 sm:col-span-7">
<div className="flex items-center gap-2">
<Button
variant="primary"
onClick={() => {
setEstimateFormOpen(true);
setEstimateToUpdate(undefined);
}}
>
Add Estimate
</Button>
{projectDetails?.estimate && (
<Button variant="neutral-primary" onClick={disableEstimates}>
Disable Estimates
</Button>
)}
</div>
</div>
</section>
{estimatesList ? (
estimatesList.length > 0 ? (
<section className="h-full bg-custom-background-100 overflow-y-auto">
{estimatesList.map((estimate) => (
<EstimateListItem
key={estimate.id}
estimate={estimate}
editEstimate={(estimate) => editEstimate(estimate)}
deleteEstimate={(estimateId) => setEstimateToDelete(estimateId)}
/>
))}
</section>
) : (
<div className="h-full w-full overflow-y-auto">
<EmptyState
title="No estimates yet"
description="Estimates help you communicate the complexity of an issue."
image={emptyEstimate}
primaryButton={{
icon: <Plus className="h-4 w-4" />,
text: "Add Estimate",
onClick: () => {
setEstimateFormOpen(true);
setEstimateToUpdate(undefined);
},
}}
/>
</div>
)
) : (
<Loader className="mt-5 space-y-5">
<Loader.Item height="40px" />
<Loader.Item height="40px" />
<Loader.Item height="40px" />
<Loader.Item height="40px" />
</Loader>
)}
</>
);
});
@@ -1,160 +0,0 @@
import React, { useState } from "react";
import { usePopper } from "react-popper";
import { Combobox } from "@headlessui/react";
import { Check, ChevronDown, Search, Triangle } from "lucide-react";
// types
import { Tooltip } from "components/ui";
import { Placement } from "@popperjs/core";
// constants
import { IEstimatePoint } from "types";
type Props = {
value: number | null;
onChange: (value: number | null) => void;
estimatePoints: IEstimatePoint[] | undefined;
className?: string;
buttonClassName?: string;
optionsClassName?: string;
placement?: Placement;
hideDropdownArrow?: boolean;
disabled?: boolean;
};
export const EstimateSelect: React.FC<Props> = (props) => {
const {
value,
onChange,
estimatePoints,
className = "",
buttonClassName = "",
optionsClassName = "",
placement,
hideDropdownArrow = false,
disabled = false,
} = props;
const [query, setQuery] = useState("");
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
const { styles, attributes } = usePopper(referenceElement, popperElement, {
placement: placement ?? "bottom-start",
modifiers: [
{
name: "preventOverflow",
options: {
padding: 12,
},
},
],
});
const options: { value: number | null; query: string; content: any }[] | undefined = estimatePoints?.map(
(estimate) => ({
value: estimate.key,
query: estimate.value,
content: (
<div className="flex items-center gap-2">
<Triangle className="h-3 w-3" strokeWidth={2} />
{estimate.value}
</div>
),
})
);
options?.unshift({
value: null,
query: "none",
content: (
<div className="flex items-center gap-2">
<Triangle className="h-3 w-3" strokeWidth={2} />
None
</div>
),
});
const filteredOptions =
query === "" ? options : options?.filter((option) => option.query.toLowerCase().includes(query.toLowerCase()));
const selectedEstimate = estimatePoints?.find((e) => e.key === value);
const label = (
<Tooltip tooltipHeading="Estimate" tooltipContent={selectedEstimate?.value ?? "None"} position="top">
<div className="flex items-center cursor-pointer w-full gap-2 text-custom-text-200">
<Triangle className="h-3 w-3" strokeWidth={2} />
<span className="truncate">{selectedEstimate?.value ?? "None"}</span>
</div>
</Tooltip>
);
return (
<Combobox
as="div"
className={`flex-shrink-0 text-left ${className}`}
value={value}
onChange={(val) => onChange(val as number | null)}
disabled={disabled}
>
<Combobox.Button as={React.Fragment}>
<button
ref={setReferenceElement}
type="button"
className={`flex items-center justify-between gap-1 w-full text-xs px-2.5 py-1 rounded border-[0.5px] border-custom-border-300 duration-300 focus:outline-none ${
disabled ? "cursor-not-allowed text-custom-text-200" : "cursor-pointer hover:bg-custom-background-80"
} ${buttonClassName}`}
>
{label}
{!hideDropdownArrow && !disabled && <ChevronDown className="h-3 w-3" aria-hidden="true" />}
</button>
</Combobox.Button>
<Combobox.Options className="fixed z-10">
<div
className={`border border-custom-border-300 px-2 py-2.5 rounded bg-custom-background-100 text-xs shadow-custom-shadow-rg focus:outline-none w-48 whitespace-nowrap my-1 ${optionsClassName}`}
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
<div className="flex w-full items-center justify-start rounded border border-custom-border-200 bg-custom-background-90 px-2">
<Search className="h-3.5 w-3.5 text-custom-text-300" />
<Combobox.Input
className="w-full bg-transparent py-1 px-2 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search"
displayValue={(assigned: any) => assigned?.name}
/>
</div>
<div className={`mt-2 space-y-1 max-h-48 overflow-y-scroll`}>
{filteredOptions ? (
filteredOptions.length > 0 ? (
filteredOptions.map((option) => (
<Combobox.Option
key={option.value}
value={option.value}
className={({ active, selected }) =>
`flex items-center justify-between gap-2 cursor-pointer select-none truncate rounded px-1 py-1.5 ${
active ? "bg-custom-background-80" : ""
} ${selected ? "text-custom-text-100" : "text-custom-text-200"}`
}
>
{({ selected }) => (
<>
{option.content}
{selected && <Check className="h-3.5 w-3.5" />}
</>
)}
</Combobox.Option>
))
) : (
<span className="flex items-center gap-2 p-1">
<p className="text-left text-custom-text-200 ">No matching results</p>
</span>
)
) : (
<p className="text-center text-custom-text-200">Loading...</p>
)}
</div>
</div>
</Combobox.Options>
</Combobox>
);
};
@@ -1,4 +1,3 @@
export * from "./create-update-estimate-modal";
export * from "./single-estimate";
export * from "./delete-estimate-modal";
export * from "./estimate-select";
export * from "./estimate-list-item";
@@ -1,12 +1,14 @@
import React from "react";
import React, { useState } from "react";
import { useRouter } from "next/router";
// store
import { observer } from "mobx-react-lite";
import { useMobxStore } from "lib/mobx/store-provider";
// services
import { ProjectService } from "services/project";
// hooks
import useToast from "hooks/use-toast";
import useProjectDetails from "hooks/use-project-details";
// components
import { DeleteEstimateModal } from "components/estimates";
// ui
import { Button, CustomMenu } from "@plane/ui";
//icons
@@ -14,46 +16,48 @@ import { Pencil, Trash2 } from "lucide-react";
// helpers
import { orderArrayBy } from "helpers/array.helper";
// types
import { IEstimate } from "types";
import { IUser, IEstimate } from "types";
type Props = {
user: IUser | undefined;
estimate: IEstimate;
editEstimate: (estimate: IEstimate) => void;
deleteEstimate: (estimateId: string) => void;
handleEstimateDelete: (estimateId: string) => void;
};
export const EstimateListItem: React.FC<Props> = observer((props) => {
const { estimate, editEstimate, deleteEstimate } = props;
// services
const projectService = new ProjectService();
export const SingleEstimate: React.FC<Props> = ({ user, estimate, editEstimate, handleEstimateDelete }) => {
const [isDeleteEstimateModalOpen, setIsDeleteEstimateModalOpen] = useState(false);
// router
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
// store
const { project: projectStore } = useMobxStore();
const { setToastAlert } = useToast();
// derived values
const projectDetails = projectStore.project_details?.[projectId?.toString()!];
const { projectDetails, mutateProjectDetails } = useProjectDetails();
const handleUseEstimate = async () => {
if (!workspaceSlug || !projectId) return;
await projectStore
.updateProject(workspaceSlug.toString(), projectId.toString(), {
estimate: estimate.id,
})
.catch((err) => {
const error = err?.error;
const errorString = Array.isArray(error) ? error[0] : error;
const payload = {
estimate: estimate.id,
};
setToastAlert({
type: "error",
title: "Error!",
message: errorString ?? "Estimate points could not be used. Please try again.",
});
mutateProjectDetails((prevData: any) => {
if (!prevData) return prevData;
return { ...prevData, estimate: estimate.id };
}, false);
await projectService.updateProject(workspaceSlug as string, projectId as string, payload, user).catch(() => {
setToastAlert({
type: "error",
title: "Error!",
message: "Estimate points could not be used. Please try again.",
});
});
};
return (
@@ -72,7 +76,7 @@ export const EstimateListItem: React.FC<Props> = observer((props) => {
</p>
</div>
<div className="flex items-center gap-2">
{projectDetails?.estimate !== estimate?.id && estimate?.points?.length > 0 && (
{projectDetails?.estimate !== estimate.id && estimate.points.length > 0 && (
<Button variant="neutral-primary" onClick={handleUseEstimate}>
Use
</Button>
@@ -91,7 +95,7 @@ export const EstimateListItem: React.FC<Props> = observer((props) => {
{projectDetails?.estimate !== estimate.id && (
<CustomMenu.MenuItem
onClick={() => {
deleteEstimate(estimate.id);
setIsDeleteEstimateModalOpen(true);
}}
>
<div className="flex items-center justify-start gap-2">
@@ -103,7 +107,7 @@ export const EstimateListItem: React.FC<Props> = observer((props) => {
</CustomMenu>
</div>
</div>
{estimate?.points?.length > 0 ? (
{estimate.points.length > 0 ? (
<div className="flex text-xs text-custom-text-200">
Estimate points (
<span className="flex gap-1">
@@ -122,6 +126,16 @@ export const EstimateListItem: React.FC<Props> = observer((props) => {
</div>
)}
</div>
<DeleteEstimateModal
isOpen={isDeleteEstimateModalOpen}
handleClose={() => setIsDeleteEstimateModalOpen(false)}
data={estimate}
handleDelete={() => {
handleEstimateDelete(estimate.id);
setIsDeleteEstimateModalOpen(false);
}}
/>
</>
);
});
};
+1 -1
View File
@@ -128,7 +128,7 @@ export const Exporter: React.FC<Props> = observer((props) => {
value={value ?? []}
onChange={(val: string[]) => onChange(val)}
options={options}
input
input={true}
label={
value && value.length > 0
? projects &&
+1 -1
View File
@@ -150,7 +150,7 @@ const IntegrationGuide = () => {
</>
{provider && (
<Exporter
isOpen
isOpen={true}
handleClose={() => handleCsvClose()}
data={null}
user={user}

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