diff --git a/.gitignore b/.gitignore index ad72521ff..4933d309e 100644 --- a/.gitignore +++ b/.gitignore @@ -62,3 +62,6 @@ yarn-error.log *.sln package-lock.json .vscode + +# Sentry +.sentryclirc \ No newline at end of file diff --git a/README.md b/README.md index 0480ee4fd..6af8396ac 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@

- + Discord Discord @@ -48,4 +48,4 @@ Our [Code of Conduct](https://github.com/makeplane/plane/blob/master/CODE_OF_CON ## Security -If you believe you have found a security vulnerability in Plane, we encourage you to responsibly disclose this and not open a public issue. We will investigate all legitimate reports. Email security@plane.so to disclose any security vulnerabilities. \ No newline at end of file +If you believe you have found a security vulnerability in Plane, we encourage you to responsibly disclose this and not open a public issue. We will investigate all legitimate reports. Email security@plane.so to disclose any security vulnerabilities. diff --git a/apiserver/back_migration.py b/apiserver/back_migration.py index 57ded0ba4..9613412a3 100644 --- a/apiserver/back_migration.py +++ b/apiserver/back_migration.py @@ -1,11 +1,13 @@ # All the python scripts that are used for back migrations +import uuid from plane.db.models import ProjectIdentifier -from plane.db.models import Issue, IssueComment +from plane.db.models import Issue, IssueComment, User +from django.contrib.auth.hashers import make_password + # Update description and description html values for old descriptions def update_description(): try: - issues = Issue.objects.all() updated_issues = [] @@ -25,7 +27,6 @@ def update_description(): def update_comments(): try: - issue_comments = IssueComment.objects.all() updated_issue_comments = [] @@ -44,9 +45,11 @@ def update_comments(): def update_project_identifiers(): try: - project_identifiers = ProjectIdentifier.objects.filter(workspace_id=None).select_related("project", "project__workspace") + project_identifiers = ProjectIdentifier.objects.filter( + workspace_id=None + ).select_related("project", "project__workspace") updated_identifiers = [] - + for identifier in project_identifiers: identifier.workspace_id = identifier.project.workspace_id updated_identifiers.append(identifier) @@ -58,3 +61,21 @@ def update_project_identifiers(): except Exception as e: print(e) print("Failed") + + +def update_user_empty_password(): + try: + users = User.objects.filter(password="") + updated_users = [] + + for user in users: + user.password = make_password(uuid.uuid4().hex) + user.is_password_autoset = True + updated_users.append(user) + + User.objects.bulk_update(updated_users, ["password"], batch_size=50) + print("Success") + + except Exception as e: + print(e) + print("Failed") diff --git a/apiserver/plane/api/serializers/issue.py b/apiserver/plane/api/serializers/issue.py index a148cbfb5..3add8f965 100644 --- a/apiserver/plane/api/serializers/issue.py +++ b/apiserver/plane/api/serializers/issue.py @@ -40,12 +40,12 @@ class IssueFlatSerializer(BaseSerializer): "start_date", "target_date", "sequence_id", + "sort_order", ] # Issue Serializer with state details class IssueStateSerializer(BaseSerializer): - state_detail = StateSerializer(read_only=True, source="state") project_detail = ProjectSerializer(read_only=True, source="project") @@ -57,7 +57,6 @@ class IssueStateSerializer(BaseSerializer): ##TODO: Find a better way to write this serializer ## Find a better approach to save manytomany? class IssueCreateSerializer(BaseSerializer): - state_detail = StateSerializer(read_only=True, source="state") created_by_detail = UserLiteSerializer(read_only=True, source="created_by") project_detail = ProjectSerializer(read_only=True, source="project") @@ -176,7 +175,6 @@ class IssueCreateSerializer(BaseSerializer): return issue def update(self, instance, validated_data): - blockers = validated_data.pop("blockers_list", None) assignees = validated_data.pop("assignees_list", None) labels = validated_data.pop("labels_list", None) @@ -254,7 +252,6 @@ class IssueCreateSerializer(BaseSerializer): class IssueActivitySerializer(BaseSerializer): - actor_detail = UserLiteSerializer(read_only=True, source="actor") class Meta: @@ -263,7 +260,6 @@ class IssueActivitySerializer(BaseSerializer): class IssueCommentSerializer(BaseSerializer): - actor_detail = UserLiteSerializer(read_only=True, source="actor") issue_detail = IssueFlatSerializer(read_only=True, source="issue") project_detail = ProjectSerializer(read_only=True, source="project") @@ -319,7 +315,6 @@ class LabelSerializer(BaseSerializer): class IssueLabelSerializer(BaseSerializer): - # label_details = LabelSerializer(read_only=True, source="label") class Meta: @@ -332,7 +327,6 @@ class IssueLabelSerializer(BaseSerializer): class BlockedIssueSerializer(BaseSerializer): - blocked_issue_detail = IssueFlatSerializer(source="block", read_only=True) class Meta: @@ -341,7 +335,6 @@ class BlockedIssueSerializer(BaseSerializer): class BlockerIssueSerializer(BaseSerializer): - blocker_issue_detail = IssueFlatSerializer(source="blocked_by", read_only=True) class Meta: @@ -350,7 +343,6 @@ class BlockerIssueSerializer(BaseSerializer): class IssueAssigneeSerializer(BaseSerializer): - assignee_details = UserLiteSerializer(read_only=True, source="assignee") class Meta: @@ -373,7 +365,6 @@ class CycleBaseSerializer(BaseSerializer): class IssueCycleDetailSerializer(BaseSerializer): - cycle_detail = CycleBaseSerializer(read_only=True, source="cycle") class Meta: @@ -404,7 +395,6 @@ class ModuleBaseSerializer(BaseSerializer): class IssueModuleDetailSerializer(BaseSerializer): - module_detail = ModuleBaseSerializer(read_only=True, source="module") class Meta: diff --git a/apiserver/plane/api/views/api_token.py b/apiserver/plane/api/views/api_token.py index 4ed3d9de0..2508b06ac 100644 --- a/apiserver/plane/api/views/api_token.py +++ b/apiserver/plane/api/views/api_token.py @@ -15,12 +15,16 @@ from plane.api.serializers import APITokenSerializer class ApiTokenEndpoint(BaseAPIView): def post(self, request): try: - label = request.data.get("label", str(uuid4().hex)) + workspace = request.data.get("workspace", False) + + if not workspace: + return Response( + {"error": "Workspace is required"}, status=status.HTTP_200_OK + ) api_token = APIToken.objects.create( - label=label, - user=request.user, + label=label, user=request.user, workspace_id=workspace ) serializer = APITokenSerializer(api_token) diff --git a/apiserver/plane/api/views/authentication.py b/apiserver/plane/api/views/authentication.py index c77bdd160..ac218837d 100644 --- a/apiserver/plane/api/views/authentication.py +++ b/apiserver/plane/api/views/authentication.py @@ -9,6 +9,7 @@ from django.utils import timezone from django.core.exceptions import ValidationError from django.core.validators import validate_email from django.conf import settings +from django.contrib.auth.hashers import make_password # Third party imports from rest_framework.response import Response @@ -35,12 +36,10 @@ def get_tokens_for_user(user): class SignUpEndpoint(BaseAPIView): - permission_classes = (AllowAny,) def post(self, request): try: - email = request.data.get("email", False) password = request.data.get("password", False) @@ -216,14 +215,12 @@ class SignOutEndpoint(BaseAPIView): class MagicSignInGenerateEndpoint(BaseAPIView): - permission_classes = [ AllowAny, ] def post(self, request): try: - email = request.data.get("email", False) if not email: @@ -269,7 +266,6 @@ class MagicSignInGenerateEndpoint(BaseAPIView): ri.set(key, json.dumps(value), ex=expiry) else: - value = {"current_attempt": 0, "email": email, "token": token} expiry = 600 @@ -293,14 +289,12 @@ class MagicSignInGenerateEndpoint(BaseAPIView): class MagicSignInEndpoint(BaseAPIView): - permission_classes = [ AllowAny, ] def post(self, request): try: - user_token = request.data.get("token", "").strip().lower() key = request.data.get("key", False) @@ -313,19 +307,20 @@ class MagicSignInEndpoint(BaseAPIView): ri = redis_instance() if ri.exists(key): - data = json.loads(ri.get(key)) token = data["token"] email = data["email"] if str(token) == str(user_token): - if User.objects.filter(email=email).exists(): user = User.objects.get(email=email) else: user = User.objects.create( - email=email, username=uuid.uuid4().hex + email=email, + username=uuid.uuid4().hex, + password=make_password(uuid.uuid4().hex), + is_password_autoset=True, ) user.last_active = timezone.now() diff --git a/apiserver/plane/api/views/cycle.py b/apiserver/plane/api/views/cycle.py index d1b291d9a..2b18aab96 100644 --- a/apiserver/plane/api/views/cycle.py +++ b/apiserver/plane/api/views/cycle.py @@ -1,5 +1,9 @@ +# Python imports +import json + # Django imports from django.db.models import OuterRef, Func, F +from django.core import serializers # Third party imports from rest_framework.response import Response @@ -11,10 +15,10 @@ from . import BaseViewSet from plane.api.serializers import CycleSerializer, CycleIssueSerializer from plane.api.permissions import ProjectEntityPermission from plane.db.models import Cycle, CycleIssue, Issue +from plane.bgtasks.issue_activites_task import issue_activity class CycleViewSet(BaseViewSet): - serializer_class = CycleSerializer model = Cycle permission_classes = [ @@ -41,7 +45,6 @@ class CycleViewSet(BaseViewSet): class CycleIssueViewSet(BaseViewSet): - serializer_class = CycleIssueSerializer model = CycleIssue @@ -79,7 +82,6 @@ class CycleIssueViewSet(BaseViewSet): def create(self, request, slug, project_id, cycle_id): try: - issues = request.data.get("issues", []) if not len(issues): @@ -91,29 +93,77 @@ class CycleIssueViewSet(BaseViewSet): workspace__slug=slug, project_id=project_id, pk=cycle_id ) - issues = Issue.objects.filter( - pk__in=issues, workspace__slug=slug, project_id=project_id - ) + # Get all CycleIssues already created + cycle_issues = list(CycleIssue.objects.filter(issue_id__in=issues)) + records_to_update = [] + update_cycle_issue_activity = [] + record_to_create = [] - # Delete old records in order to maintain the database integrity - CycleIssue.objects.filter(issue_id__in=issues).delete() + for issue in issues: + cycle_issue = [ + cycle_issue + for cycle_issue in cycle_issues + if str(cycle_issue.issue_id) in issues + ] + # Update only when cycle changes + if len(cycle_issue): + if cycle_issue[0].cycle_id != cycle_id: + update_cycle_issue_activity.append( + { + "old_cycle_id": str(cycle_issue[0].cycle_id), + "new_cycle_id": str(cycle_id), + "issue_id": str(cycle_issue[0].issue_id), + } + ) + cycle_issue[0].cycle_id = cycle_id + records_to_update.append(cycle_issue[0]) + else: + record_to_create.append( + CycleIssue( + project_id=project_id, + workspace=cycle.workspace, + created_by=request.user, + updated_by=request.user, + cycle=cycle, + issue_id=issue, + ) + ) CycleIssue.objects.bulk_create( - [ - CycleIssue( - project_id=project_id, - workspace=cycle.workspace, - created_by=request.user, - updated_by=request.user, - cycle=cycle, - issue=issue, - ) - for issue in issues - ], + record_to_create, batch_size=10, ignore_conflicts=True, ) - return Response({"message": "Success"}, status=status.HTTP_200_OK) + CycleIssue.objects.bulk_update( + records_to_update, + ["cycle"], + batch_size=10, + ) + + # Capture Issue Activity + issue_activity.delay( + { + "type": "issue.activity", + "requested_data": json.dumps({"cycles_list": issues}), + "actor_id": str(self.request.user.id), + "issue_id": str(self.kwargs.get("pk", None)), + "project_id": str(self.kwargs.get("project_id", None)), + "current_instance": json.dumps( + { + "updated_cycle_issues": update_cycle_issue_activity, + "created_cycle_issues": serializers.serialize( + "json", record_to_create + ), + } + ), + }, + ) + + # Return all Cycle Issues + return Response( + CycleIssueSerializer(self.get_queryset(), many=True).data, + status=status.HTTP_200_OK, + ) except Cycle.DoesNotExist: return Response( diff --git a/apiserver/plane/api/views/module.py b/apiserver/plane/api/views/module.py index 9955ded76..a1cda9834 100644 --- a/apiserver/plane/api/views/module.py +++ b/apiserver/plane/api/views/module.py @@ -1,6 +1,10 @@ +# Python imports +import json + # Django Imports from django.db import IntegrityError from django.db.models import Prefetch, F, OuterRef, Func +from django.core import serializers # Third party imports from rest_framework.response import Response @@ -22,10 +26,10 @@ from plane.db.models import ( Issue, ModuleLink, ) +from plane.bgtasks.issue_activites_task import issue_activity class ModuleViewSet(BaseViewSet): - model = Module permission_classes = [ ProjectEntityPermission, @@ -95,7 +99,6 @@ class ModuleViewSet(BaseViewSet): class ModuleIssueViewSet(BaseViewSet): - serializer_class = ModuleIssueSerializer model = ModuleIssue @@ -148,29 +151,77 @@ class ModuleIssueViewSet(BaseViewSet): workspace__slug=slug, project_id=project_id, pk=module_id ) - issues = Issue.objects.filter( - pk__in=issues, workspace__slug=slug, project_id=project_id - ) + module_issues = list(ModuleIssue.objects.filter(issue_id__in=issues)) - # Delete old records in order to maintain the database integrity - ModuleIssue.objects.filter(issue_id__in=issues).delete() + update_module_issue_activity = [] + records_to_update = [] + record_to_create = [] + + for issue in issues: + module_issue = [ + module_issue + for module_issue in module_issues + if str(module_issue.issue_id) in issues + ] + + if len(module_issue): + if module_issue[0].module_id != module_id: + update_module_issue_activity.append( + { + "old_module_id": str(module_issue[0].module_id), + "new_module_id": str(module_id), + "issue_id": str(module_issue[0].issue_id), + } + ) + module_issue[0].module_id = module_id + records_to_update.append(module_issue[0]) + else: + record_to_create.append( + ModuleIssue( + module=module, + issue_id=issue, + project_id=project_id, + workspace=module.workspace, + created_by=request.user, + updated_by=request.user, + ) + ) ModuleIssue.objects.bulk_create( - [ - ModuleIssue( - module=module, - issue=issue, - project_id=project_id, - workspace=module.workspace, - created_by=request.user, - updated_by=request.user, - ) - for issue in issues - ], + record_to_create, batch_size=10, ignore_conflicts=True, ) - return Response({"message": "Success"}, status=status.HTTP_200_OK) + + ModuleIssue.objects.bulk_update( + records_to_update, + ["module"], + batch_size=10, + ) + + # Capture Issue Activity + issue_activity.delay( + { + "type": "issue.activity", + "requested_data": json.dumps({"modules_list": issues}), + "actor_id": str(self.request.user.id), + "issue_id": str(self.kwargs.get("pk", None)), + "project_id": str(self.kwargs.get("project_id", None)), + "current_instance": json.dumps( + { + "updated_module_issues": update_module_issue_activity, + "created_module_issues": serializers.serialize( + "json", record_to_create + ), + } + ), + }, + ) + + return Response( + ModuleIssueSerializer(self.get_queryset(), many=True).data, + status=status.HTTP_200_OK, + ) except Module.DoesNotExist: return Response( {"error": "Module Does not exists"}, status=status.HTTP_400_BAD_REQUEST diff --git a/apiserver/plane/bgtasks/issue_activites_task.py b/apiserver/plane/bgtasks/issue_activites_task.py index f6debc921..7e0e3f6ff 100644 --- a/apiserver/plane/bgtasks/issue_activites_task.py +++ b/apiserver/plane/bgtasks/issue_activites_task.py @@ -6,7 +6,16 @@ from django_rq import job from sentry_sdk import capture_exception # Module imports -from plane.db.models import User, Issue, Project, Label, IssueActivity, State +from plane.db.models import ( + User, + Issue, + Project, + Label, + IssueActivity, + State, + Cycle, + Module, +) # Track Chnages in name @@ -44,7 +53,6 @@ def track_parent( issue_activities, ): if current_instance.get("parent") != requested_data.get("parent"): - if requested_data.get("parent") == None: old_parent = Issue.objects.get(pk=current_instance.get("parent")) issue_activities.append( @@ -134,7 +142,6 @@ def track_state( issue_activities, ): if current_instance.get("state") != requested_data.get("state"): - new_state = State.objects.get(pk=requested_data.get("state", None)) old_state = State.objects.get(pk=current_instance.get("state", None)) @@ -167,7 +174,6 @@ def track_description( if current_instance.get("description_html") != requested_data.get( "description_html" ): - issue_activities.append( IssueActivity( issue_id=issue_id, @@ -274,7 +280,6 @@ def track_labels( ): # Label Addition if len(requested_data.get("labels_list")) > len(current_instance.get("labels")): - for label in requested_data.get("labels_list"): if label not in current_instance.get("labels"): label = Label.objects.get(pk=label) @@ -296,7 +301,6 @@ def track_labels( # Label Removal if len(requested_data.get("labels_list")) < len(current_instance.get("labels")): - for label in current_instance.get("labels"): if label not in requested_data.get("labels_list"): label = Label.objects.get(pk=label) @@ -326,12 +330,10 @@ def track_assignees( actor, issue_activities, ): - # Assignee Addition if len(requested_data.get("assignees_list")) > len( current_instance.get("assignees") ): - for assignee in requested_data.get("assignees_list"): if assignee not in current_instance.get("assignees"): assignee = User.objects.get(pk=assignee) @@ -354,7 +356,6 @@ def track_assignees( if len(requested_data.get("assignees_list")) < len( current_instance.get("assignees") ): - for assignee in current_instance.get("assignees"): if assignee not in requested_data.get("assignees_list"): assignee = User.objects.get(pk=assignee) @@ -386,7 +387,6 @@ def track_blocks( if len(requested_data.get("blocks_list")) > len( current_instance.get("blocked_issues") ): - for block in requested_data.get("blocks_list"): if ( len( @@ -418,7 +418,6 @@ def track_blocks( if len(requested_data.get("blocks_list")) < len( current_instance.get("blocked_issues") ): - for blocked in current_instance.get("blocked_issues"): if blocked.get("block") not in requested_data.get("blocks_list"): issue = Issue.objects.get(pk=blocked.get("block")) @@ -450,7 +449,6 @@ def track_blockings( if len(requested_data.get("blockers_list")) > len( current_instance.get("blocker_issues") ): - for block in requested_data.get("blockers_list"): if ( len( @@ -482,7 +480,6 @@ def track_blockings( if len(requested_data.get("blockers_list")) < len( current_instance.get("blocker_issues") ): - for blocked in current_instance.get("blocker_issues"): if blocked.get("blocked_by") not in requested_data.get("blockers_list"): issue = Issue.objects.get(pk=blocked.get("blocked_by")) @@ -502,6 +499,119 @@ def track_blockings( ) +def track_cycles( + requested_data, + current_instance, + issue_id, + project, + actor, + issue_activities, +): + # Updated Records: + updated_records = current_instance.get("updated_cycle_issues", []) + created_records = json.loads(current_instance.get("created_cycle_issues", [])) + + for updated_record in updated_records: + old_cycle = Cycle.objects.filter( + pk=updated_record.get("old_cycle_id", None) + ).first() + new_cycle = Cycle.objects.filter( + pk=updated_record.get("new_cycle_id", None) + ).first() + + issue_activities.append( + IssueActivity( + issue_id=updated_record.get("issue_id"), + actor=actor, + verb="updated", + old_value=old_cycle.name, + new_value=new_cycle.name, + field="cycles", + project=project, + workspace=project.workspace, + comment=f"{actor.email} updated cycle from {old_cycle.name} to {new_cycle.name}", + old_identifier=old_cycle.id, + new_identifier=new_cycle.id, + ) + ) + + for created_record in created_records: + cycle = Cycle.objects.filter( + pk=created_record.get("fields").get("cycle") + ).first() + + issue_activities.append( + IssueActivity( + issue_id=created_record.get("fields").get("issue"), + actor=actor, + verb="created", + old_value="", + new_value=cycle.name, + field="cycles", + project=project, + workspace=project.workspace, + comment=f"{actor.email} added cycle {cycle.name}", + new_identifier=cycle.id, + ) + ) + + +def track_modules( + requested_data, + current_instance, + issue_id, + project, + actor, + issue_activities, +): + # Updated Records: + updated_records = current_instance.get("updated_module_issues", []) + created_records = json.loads(current_instance.get("created_module_issues", [])) + + for updated_record in updated_records: + old_module = Module.objects.filter( + pk=updated_record.get("old_module_id", None) + ).first() + new_module = Module.objects.filter( + pk=updated_record.get("new_module_id", None) + ).first() + + issue_activities.append( + IssueActivity( + issue_id=updated_record.get("issue_id"), + actor=actor, + verb="updated", + old_value=old_module.name, + new_value=new_module.name, + field="modules", + project=project, + workspace=project.workspace, + comment=f"{actor.email} updated module from {old_module.name} to {new_module.name}", + old_identifier=old_module.id, + new_identifier=new_module.id, + ) + ) + + for created_record in created_records: + module = Module.objects.filter( + pk=created_record.get("fields").get("module") + ).first() + issue_activities.append( + IssueActivity( + issue_id=created_record.get("fields").get("issue"), + actor=actor, + verb="created", + old_value="", + new_value=module.name, + field="modules", + project=project, + workspace=project.workspace, + comment=f"{actor.email} added module {module.name}", + new_identifier=module.id, + ) + ) + + # Receive message from room group @job("default") def issue_activity(event): @@ -510,7 +620,7 @@ def issue_activity(event): requested_data = json.loads(event.get("requested_data")) current_instance = json.loads(event.get("current_instance")) - issue_id = event.get("issue_id") + issue_id = event.get("issue_id", None) actor_id = event.get("actor_id") project_id = event.get("project_id") @@ -530,6 +640,8 @@ def issue_activity(event): "assignees_list": track_assignees, "blocks_list": track_blocks, "blockers_list": track_blockings, + "cycles_list": track_cycles, + "modules_list": track_modules, } for key in requested_data: diff --git a/apiserver/plane/db/models/api_token.py b/apiserver/plane/db/models/api_token.py index 32ba013bc..b4009e6eb 100644 --- a/apiserver/plane/db/models/api_token.py +++ b/apiserver/plane/db/models/api_token.py @@ -17,7 +17,6 @@ def generate_token(): class APIToken(BaseModel): - token = models.CharField(max_length=255, unique=True, default=generate_token) label = models.CharField(max_length=255, default=generate_label_token) user = models.ForeignKey( @@ -28,6 +27,9 @@ class APIToken(BaseModel): user_type = models.PositiveSmallIntegerField( choices=((0, "Human"), (1, "Bot")), default=0 ) + workspace = models.ForeignKey( + "db.Workspace", related_name="api_tokens", on_delete=models.CASCADE, null=True + ) class Meta: verbose_name = "API Token" diff --git a/apiserver/plane/db/models/issue.py b/apiserver/plane/db/models/issue.py index c3984b3d2..3331b0832 100644 --- a/apiserver/plane/db/models/issue.py +++ b/apiserver/plane/db/models/issue.py @@ -9,6 +9,7 @@ from django.dispatch import receiver from . import ProjectBaseModel from plane.utils.html_processor import strip_tags + # TODO: Handle identifiers for Bulk Inserts - nk class Issue(ProjectBaseModel): PRIORITY_CHOICES = ( @@ -32,8 +33,8 @@ class Issue(ProjectBaseModel): related_name="state_issue", ) name = models.CharField(max_length=255, verbose_name="Issue Name") - description = models.JSONField(blank=True, null=True) - description_html = models.TextField(blank=True, null=True) + description = models.JSONField(blank=True, default=dict) + description_html = models.TextField(blank=True, default="

") description_stripped = models.TextField(blank=True, null=True) priority = models.CharField( max_length=30, @@ -56,6 +57,7 @@ class Issue(ProjectBaseModel): labels = models.ManyToManyField( "db.Label", blank=True, related_name="labels", through="IssueLabel" ) + sort_order = models.FloatField(default=65535) class Meta: verbose_name = "Issue" @@ -196,8 +198,8 @@ class TimelineIssue(ProjectBaseModel): class IssueComment(ProjectBaseModel): comment_stripped = models.TextField(verbose_name="Comment", blank=True) - comment_json = models.JSONField(blank=True, null=True) - comment_html = models.TextField(blank=True) + comment_json = models.JSONField(blank=True, default=dict) + comment_html = models.TextField(blank=True, default="

") attachments = ArrayField(models.URLField(), size=10, blank=True, default=list) issue = models.ForeignKey(Issue, on_delete=models.CASCADE) # System can also create comment @@ -246,7 +248,6 @@ class IssueProperty(ProjectBaseModel): class Label(ProjectBaseModel): - parent = models.ForeignKey( "self", on_delete=models.CASCADE, @@ -256,7 +257,7 @@ class Label(ProjectBaseModel): ) name = models.CharField(max_length=255) description = models.TextField(blank=True) - colour = models.CharField(max_length=255, blank=True) + color = models.CharField(max_length=255, blank=True) class Meta: verbose_name = "Label" @@ -269,7 +270,6 @@ class Label(ProjectBaseModel): class IssueLabel(ProjectBaseModel): - issue = models.ForeignKey( "db.Issue", on_delete=models.CASCADE, related_name="label_issue" ) @@ -288,7 +288,6 @@ class IssueLabel(ProjectBaseModel): class IssueSequence(ProjectBaseModel): - issue = models.ForeignKey( Issue, on_delete=models.SET_NULL, related_name="issue_sequence", null=True ) @@ -305,7 +304,6 @@ class IssueSequence(ProjectBaseModel): # TODO: Find a better method to save the model @receiver(post_save, sender=Issue) def create_issue_sequence(sender, instance, created, **kwargs): - if created: IssueSequence.objects.create( issue=instance, sequence=instance.sequence_id, project=instance.project diff --git a/apiserver/plane/db/models/project.py b/apiserver/plane/db/models/project.py index 545bcd8a6..4a180642b 100644 --- a/apiserver/plane/db/models/project.py +++ b/apiserver/plane/db/models/project.py @@ -29,7 +29,6 @@ def get_default_props(): class Project(BaseModel): - NETWORK_CHOICES = ((0, "Secret"), (2, "Public")) name = models.CharField(max_length=255, verbose_name="Project Name") description = models.TextField(verbose_name="Project Description", blank=True) @@ -63,6 +62,8 @@ class Project(BaseModel): blank=True, ) icon = models.CharField(max_length=255, null=True, blank=True) + module_view = models.BooleanField(default=True) + cycle_view = models.BooleanField(default=True) def __str__(self): """Return name of the project""" @@ -82,7 +83,6 @@ class Project(BaseModel): class ProjectBaseModel(BaseModel): - project = models.ForeignKey( Project, on_delete=models.CASCADE, related_name="project_%(class)s" ) @@ -117,7 +117,6 @@ class ProjectMemberInvite(ProjectBaseModel): class ProjectMember(ProjectBaseModel): - member = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.CASCADE, @@ -141,9 +140,9 @@ class ProjectMember(ProjectBaseModel): """Return members of the project""" return f"{self.member.email} <{self.project.name}>" + # TODO: Remove workspace relation later class ProjectIdentifier(AuditModel): - workspace = models.ForeignKey( "db.Workspace", models.CASCADE, related_name="project_identifiers", null=True ) diff --git a/apiserver/requirements/base.txt b/apiserver/requirements/base.txt index 578235003..e9ca677db 100644 --- a/apiserver/requirements/base.txt +++ b/apiserver/requirements/base.txt @@ -1,6 +1,6 @@ # base requirements -Django==3.2.16 +Django==3.2.17 django-braces==1.15.0 django-taggit==2.1.0 psycopg2==2.9.3 diff --git a/apps/app/components/account/email-code-form.tsx b/apps/app/components/account/email-code-form.tsx index 03f9ea822..93298b4e0 100644 --- a/apps/app/components/account/email-code-form.tsx +++ b/apps/app/components/account/email-code-form.tsx @@ -35,7 +35,6 @@ export const EmailCodeForm = ({ onSuccess }: any) => { }); const onSubmit = ({ email }: EmailCodeFormValues) => { - console.log(email); authenticationService .emailCode({ email }) .then((res) => { diff --git a/apps/app/components/command-palette/index.tsx b/apps/app/components/command-palette/index.tsx index 441fb31fa..e6138da94 100644 --- a/apps/app/components/command-palette/index.tsx +++ b/apps/app/components/command-palette/index.tsx @@ -1,37 +1,38 @@ // TODO: Refactor this component: into a different file, use this file to export the components import React, { useState, useCallback, useEffect } from "react"; -// next + import { useRouter } from "next/router"; -// swr + import useSWR from "swr"; -// hooks + +// headless ui import { Combobox, Dialog, Transition } from "@headlessui/react"; +// services +import userService from "services/user.service"; +// hooks +import useTheme from "hooks/use-theme"; +import useToast from "hooks/use-toast"; +import useUser from "hooks/use-user"; +// components +import ShortcutsModal from "components/command-palette/shortcuts"; +import { BulkDeleteIssuesModal } from "components/core"; +import { CreateProjectModal } from "components/project"; +import { CreateUpdateIssueModal } from "components/issues"; +import { CreateUpdateModuleModal } from "components/modules"; +import CreateUpdateCycleModal from "components/project/cycles/create-update-cycle-modal"; +// ui +import { Button } from "components/ui"; +// icons import { FolderIcon, RectangleStackIcon, ClipboardDocumentListIcon, MagnifyingGlassIcon, } from "@heroicons/react/24/outline"; -import useTheme from "hooks/use-theme"; -import useToast from "hooks/use-toast"; -import useUser from "hooks/use-user"; -// services -import userService from "services/user.service"; -// components -import ShortcutsModal from "components/command-palette/shortcuts"; -import { CreateProjectModal } from "components/project"; -import { CreateUpdateIssueModal } from "components/issues/modal"; -import CreateUpdateCycleModal from "components/project/cycles/create-update-cycle-modal"; -import CreateUpdateModuleModal from "components/project/modules/create-update-module-modal"; -import BulkDeleteIssuesModal from "components/common/bulk-delete-issues-modal"; -// headless ui // helpers import { copyTextToClipboard } from "helpers/string.helper"; // types import { IIssue } from "types"; -// ui -import { Button } from "components/ui"; -// icons // fetch-keys import { USER_ISSUE } from "constants/fetch-keys"; @@ -74,7 +75,7 @@ const CommandPalette: React.FC = () => { name: "Add new issue...", icon: RectangleStackIcon, hide: !projectId, - shortcut: "I", + shortcut: "C", onClick: () => { setIsIssueModalOpen(true); }, @@ -111,7 +112,6 @@ const CommandPalette: React.FC = () => { if (!router.query.issueId) return; const url = new URL(window.location.href); - console.log(url); copyTextToClipboard(url.href) .then(() => { setToastAlert({ @@ -179,7 +179,6 @@ const CommandPalette: React.FC = () => { )} @@ -330,7 +329,6 @@ const CommandPalette: React.FC = () => { /> {action.name} - {action.shortcut} diff --git a/apps/app/components/common/board-view/single-board.tsx b/apps/app/components/common/board-view/single-board.tsx deleted file mode 100644 index aedc969b5..000000000 --- a/apps/app/components/common/board-view/single-board.tsx +++ /dev/null @@ -1,3 +0,0 @@ -const SingleBoard = () => <>; - -export default SingleBoard; diff --git a/apps/app/components/common/board-view/single-issue.tsx b/apps/app/components/common/board-view/single-issue.tsx deleted file mode 100644 index cd84697e9..000000000 --- a/apps/app/components/common/board-view/single-issue.tsx +++ /dev/null @@ -1,464 +0,0 @@ -import React from "react"; - -import Link from "next/link"; -import Image from "next/image"; -import { useRouter } from "next/router"; - -import useSWR, { mutate } from "swr"; - -// react-beautiful-dnd -import { DraggableStateSnapshot } from "react-beautiful-dnd"; -// react-datepicker -import DatePicker from "react-datepicker"; -import "react-datepicker/dist/react-datepicker.css"; -// headless ui -import { Listbox, Transition } from "@headlessui/react"; -// constants -import { TrashIcon } from "@heroicons/react/24/outline"; -// services -import issuesService from "services/issues.service"; -import stateService from "services/state.service"; -import projectService from "services/project.service"; -// components -import { AssigneesList, CustomDatePicker } from "components/ui"; -// helpers -import { renderShortNumericDateFormat, findHowManyDaysLeft } from "helpers/date-time.helper"; -import { addSpaceIfCamelCase } from "helpers/string.helper"; -// types -import { - CycleIssueResponse, - IIssue, - IssueResponse, - IUserLite, - IWorkspaceMember, - ModuleIssueResponse, - Properties, - UserAuth, -} from "types"; -// common -import { PRIORITIES } from "constants/"; -import { - STATE_LIST, - PROJECT_DETAILS, - CYCLE_ISSUES, - MODULE_ISSUES, - PROJECT_ISSUES_LIST, -} from "constants/fetch-keys"; -import { getPriorityIcon } from "constants/global"; - -type Props = { - type?: string; - typeId?: string; - issue: IIssue; - properties: Properties; - snapshot?: DraggableStateSnapshot; - assignees: Partial[] | (Partial | undefined)[]; - people: IWorkspaceMember[] | undefined; - handleDeleteIssue?: React.Dispatch>; - userAuth: UserAuth; -}; - -const SingleBoardIssue: React.FC = ({ - type, - typeId, - issue, - properties, - snapshot, - assignees, - people, - handleDeleteIssue, - userAuth, -}) => { - const router = useRouter(); - const { workspaceSlug, projectId } = router.query; - - const { data: states } = useSWR( - workspaceSlug && projectId ? STATE_LIST(projectId as string) : null, - workspaceSlug && projectId - ? () => stateService.getStates(workspaceSlug as string, projectId as string) - : null - ); - - const { data: projectDetails } = useSWR( - workspaceSlug && projectId ? PROJECT_DETAILS(projectId as string) : null, - workspaceSlug && projectId - ? () => projectService.getProject(workspaceSlug as string, projectId as string) - : null - ); - - const partialUpdateIssue = (formData: Partial) => { - if (!workspaceSlug || !projectId) return; - - if (typeId) { - mutate( - CYCLE_ISSUES(typeId ?? ""), - (prevData) => { - const updatedIssues = (prevData ?? []).map((p) => { - if (p.issue_detail.id === issue.id) { - return { - ...p, - issue_detail: { - ...p.issue_detail, - ...formData, - }, - }; - } - return p; - }); - return [...updatedIssues]; - }, - false - ); - - mutate( - MODULE_ISSUES(typeId ?? ""), - (prevData) => { - const updatedIssues = (prevData ?? []).map((p) => { - if (p.issue_detail.id === issue.id) { - return { - ...p, - issue_detail: { - ...p.issue_detail, - ...formData, - }, - }; - } - return p; - }); - return [...updatedIssues]; - }, - false - ); - } - - mutate( - PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string), - (prevData) => ({ - ...(prevData as IssueResponse), - results: (prevData?.results ?? []).map((p) => { - if (p.id === issue.id) return { ...p, ...formData }; - return p; - }), - }), - false - ); - - issuesService - .patchIssue(workspaceSlug as string, projectId as string, issue.id, formData) - .then((res) => { - if (typeId) { - mutate(CYCLE_ISSUES(typeId ?? "")); - mutate(MODULE_ISSUES(typeId ?? "")); - } - - mutate(PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string)); - }) - .catch((error) => { - console.log(error); - }); - }; - - const isNotAllowed = userAuth.isGuest || userAuth.isViewer; - - return ( -
-
- {handleDeleteIssue && !isNotAllowed && ( -
- -
- )} - - - {properties.key && ( -
- {projectDetails?.identifier}-{issue.sequence_id} -
- )} -
- {issue.name} -
-
- -
- {properties.priority && ( - { - partialUpdateIssue({ priority: data }); - }} - className="group relative flex-shrink-0" - disabled={isNotAllowed} - > - {({ open }) => ( - <> -
- - {getPriorityIcon(issue?.priority ?? "None")} - - - - - {PRIORITIES?.map((priority) => ( - - `flex cursor-pointer select-none items-center gap-2 px-3 py-2 capitalize ${ - active ? "bg-indigo-50" : "bg-white" - }` - } - value={priority} - > - {getPriorityIcon(priority)} - {priority} - - ))} - - -
- - )} -
- )} - {properties.state && ( - { - partialUpdateIssue({ state: data }); - }} - className="group relative flex-shrink-0" - disabled={isNotAllowed} - > - {({ open }) => ( - <> -
- - - {addSpaceIfCamelCase(issue.state_detail.name)} - - - - - {states?.map((state) => ( - - `flex cursor-pointer select-none items-center gap-2 px-3 py-2 ${ - active ? "bg-indigo-50" : "bg-white" - }` - } - value={state.id} - > - - {addSpaceIfCamelCase(state.name)} - - ))} - - -
- - )} -
- )} - {/* {properties.cycle && !typeId && ( -
- {issue.issue_cycle ? issue.issue_cycle.cycle_detail.name : "None"} -
- )} */} - {properties.due_date && ( -
- - partialUpdateIssue({ - target_date: val, - }) - } - className={issue?.target_date ? "w-[6.5rem]" : "w-[3rem] text-center"} - /> - {/* { - partialUpdateIssue({ - target_date: val - ? `${val.getFullYear()}-${val.getMonth() + 1}-${val.getDate()}` - : null, - }); - }} - dateFormat="dd-MM-yyyy" - className={`cursor-pointer rounded-md border px-2 py-[3px] text-xs shadow-sm duration-300 hover:bg-gray-100 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 ${ - issue?.target_date ? "w-[4.5rem]" : "w-[3rem] text-center" - }`} - isClearable - /> */} -
- )} - {properties.sub_issue_count && ( -
- {issue.sub_issues_count} {issue.sub_issues_count === 1 ? "sub-issue" : "sub-issues"} -
- )} - {properties.assignee && ( - { - const newData = issue.assignees ?? []; - - if (newData.includes(data)) newData.splice(newData.indexOf(data), 1); - else newData.push(data); - - partialUpdateIssue({ assignees_list: newData }); - }} - className="group relative flex-shrink-0" - disabled={isNotAllowed} - > - {({ open }) => ( -
- -
- -
-
- - - - {people?.map((person) => ( - - `cursor-pointer select-none p-2 ${active ? "bg-indigo-50" : "bg-white"}` - } - value={person.member.id} - > -
- {person.member.avatar && person.member.avatar !== "" ? ( -
- avatar -
- ) : ( -
- {person.member.first_name && person.member.first_name !== "" - ? person.member.first_name.charAt(0) - : person.member.email.charAt(0)} -
- )} -

- {person.member.first_name && person.member.first_name !== "" - ? person.member.first_name - : person.member.email} -

-
-
- ))} -
-
-
- )} -
- )} -
-
-
- ); -}; - -export default SingleBoardIssue; diff --git a/apps/app/components/common/list-view/single-issue.tsx b/apps/app/components/common/list-view/single-issue.tsx deleted file mode 100644 index 262f332b5..000000000 --- a/apps/app/components/common/list-view/single-issue.tsx +++ /dev/null @@ -1,434 +0,0 @@ -import React, { useState } from "react"; - -import Link from "next/link"; -import { useRouter } from "next/router"; - -import useSWR, { mutate } from "swr"; - -// services -import issuesService from "services/issues.service"; -import workspaceService from "services/workspace.service"; -import stateService from "services/state.service"; -// headless ui -import { Listbox, Transition } from "@headlessui/react"; -// ui -import { CustomMenu, CustomSelect, AssigneesList, Avatar, CustomDatePicker } from "components/ui"; -// components -import ConfirmIssueDeletion from "components/project/issues/confirm-issue-deletion"; -// helpers -import { renderShortNumericDateFormat, findHowManyDaysLeft } from "helpers/date-time.helper"; -import { addSpaceIfCamelCase } from "helpers/string.helper"; -// types -import { - CycleIssueResponse, - IIssue, - IssueResponse, - IWorkspaceMember, - ModuleIssueResponse, - Properties, - UserAuth, -} from "types"; -// fetch-keys -import { - CYCLE_ISSUES, - MODULE_ISSUES, - PROJECT_ISSUES_LIST, - STATE_LIST, - WORKSPACE_MEMBERS, -} from "constants/fetch-keys"; -// constants -import { getPriorityIcon } from "constants/global"; -import { PRIORITIES } from "constants/"; - -type Props = { - type?: string; - typeId?: string; - issue: IIssue; - properties: Properties; - editIssue: () => void; - removeIssue?: () => void; - userAuth: UserAuth; -}; - -const SingleListIssue: React.FC = ({ - type, - typeId, - issue, - properties, - editIssue, - removeIssue, - userAuth, -}) => { - const [deleteIssue, setDeleteIssue] = useState(); - - const router = useRouter(); - const { workspaceSlug, projectId } = router.query; - - const { data: states } = useSWR( - workspaceSlug && projectId ? STATE_LIST(projectId as string) : null, - workspaceSlug && projectId - ? () => stateService.getStates(workspaceSlug as string, projectId as string) - : null - ); - - const { data: people } = useSWR( - workspaceSlug ? WORKSPACE_MEMBERS : null, - workspaceSlug ? () => workspaceService.workspaceMembers(workspaceSlug as string) : null - ); - - const partialUpdateIssue = (formData: Partial) => { - if (!workspaceSlug || !projectId) return; - - if (typeId) { - mutate( - CYCLE_ISSUES(typeId ?? ""), - (prevData) => { - const updatedIssues = (prevData ?? []).map((p) => { - if (p.issue_detail.id === issue.id) { - return { - ...p, - issue_detail: { - ...p.issue_detail, - ...formData, - }, - }; - } - return p; - }); - return [...updatedIssues]; - }, - false - ); - - mutate( - MODULE_ISSUES(typeId ?? ""), - (prevData) => { - const updatedIssues = (prevData ?? []).map((p) => { - if (p.issue_detail.id === issue.id) { - return { - ...p, - issue_detail: { - ...p.issue_detail, - ...formData, - }, - }; - } - return p; - }); - return [...updatedIssues]; - }, - false - ); - } - - mutate( - PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string), - (prevData) => ({ - ...(prevData as IssueResponse), - results: (prevData?.results ?? []).map((p) => { - if (p.id === issue.id) return { ...p, ...formData }; - return p; - }), - }), - false - ); - - issuesService - .patchIssue(workspaceSlug as string, projectId as string, issue.id, formData) - .then((res) => { - if (typeId) { - mutate(CYCLE_ISSUES(typeId ?? "")); - mutate(MODULE_ISSUES(typeId ?? "")); - } - - mutate(PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string)); - }) - .catch((error) => { - console.log(error); - }); - }; - - const isNotAllowed = userAuth.isGuest || userAuth.isViewer; - - return ( - <> - setDeleteIssue(undefined)} - isOpen={!!deleteIssue} - data={deleteIssue} - /> -
- -
- {properties.priority && ( - { - partialUpdateIssue({ priority: data }); - }} - className="group relative flex-shrink-0" - disabled={isNotAllowed} - > - {({ open }) => ( - <> -
- - {getPriorityIcon( - issue.priority && issue.priority !== "" ? issue.priority ?? "" : "None", - "text-sm" - )} - - - - - {PRIORITIES?.map((priority) => ( - - `flex cursor-pointer select-none items-center gap-x-2 px-3 py-2 capitalize ${ - active ? "bg-indigo-50" : "bg-white" - }` - } - value={priority} - > - {getPriorityIcon(priority, "text-sm")} - {priority ?? "None"} - - ))} - - -
-
-
Priority
-
- {issue.priority ?? "None"} -
-
- - )} -
- )} - {properties.state && ( - - - {addSpaceIfCamelCase(issue.state_detail.name)} - - } - value={issue.state} - onChange={(data: string) => { - partialUpdateIssue({ state: data }); - }} - maxHeight="md" - noChevron - disabled={isNotAllowed} - > - {states?.map((state) => ( - - <> - - {addSpaceIfCamelCase(state.name)} - - - ))} - - )} - {/* {properties.cycle && !typeId && ( -
- {issue.issue_cycle ? issue.issue_cycle.cycle_detail.name : "None"} -
- )} */} - {properties.due_date && ( -
- - partialUpdateIssue({ - target_date: val, - }) - } - className={issue?.target_date ? "w-[6.5rem]" : "w-[3rem] text-center"} - /> -
-
Due date
-
{renderShortNumericDateFormat(issue.target_date ?? "")}
-
- {issue.target_date - ? issue.target_date < new Date().toISOString() - ? `Due date has passed by ${findHowManyDaysLeft(issue.target_date)} days` - : findHowManyDaysLeft(issue.target_date) <= 3 - ? `Due date is in ${findHowManyDaysLeft(issue.target_date)} days` - : "Due date" - : "N/A"} -
-
-
- )} - {properties.sub_issue_count && ( -
- {issue.sub_issues_count} {issue.sub_issues_count === 1 ? "sub-issue" : "sub-issues"} -
- )} - {properties.assignee && ( - { - const newData = issue.assignees ?? []; - - if (newData.includes(data)) newData.splice(newData.indexOf(data), 1); - else newData.push(data); - - partialUpdateIssue({ assignees_list: newData }); - }} - className="group relative flex-shrink-0" - disabled={isNotAllowed} - > - {({ open }) => ( - <> -
- -
- -
-
- - - - {people?.map((person) => ( - - `flex items-center gap-x-1 cursor-pointer select-none p-2 ${ - active ? "bg-indigo-50" : "" - } ${ - selected || issue.assignees?.includes(person.member.id) - ? "bg-indigo-50 font-medium" - : "font-normal" - }` - } - value={person.member.id} - > - -

- {person.member.first_name && person.member.first_name !== "" - ? person.member.first_name - : person.member.email} -

-
- ))} -
-
-
-
-
Assigned to
-
- {issue.assignee_details?.length > 0 - ? issue.assignee_details.map((assignee) => assignee.first_name).join(", ") - : "No one"} -
-
- - )} -
- )} - {type && !isNotAllowed && ( - - Edit - {type !== "issue" && ( - - <>Remove from {type} - - )} - setDeleteIssue(issue)}> - Delete permanently - - - )} -
-
- - ); -}; - -export default SingleListIssue; diff --git a/apps/app/components/core/board-view/all-boards.tsx b/apps/app/components/core/board-view/all-boards.tsx new file mode 100644 index 000000000..77c36e548 --- /dev/null +++ b/apps/app/components/core/board-view/all-boards.tsx @@ -0,0 +1,82 @@ +// react-beautiful-dnd +import { DragDropContext, DropResult } from "react-beautiful-dnd"; +// hooks +import useIssueView from "hooks/use-issue-view"; +// components +import StrictModeDroppable from "components/dnd/StrictModeDroppable"; +import { SingleBoard } from "components/core/board-view/single-board"; +// types +import { IIssue, IProjectMember, IState, UserAuth } from "types"; + +type Props = { + type: "issue" | "cycle" | "module"; + issues: IIssue[]; + states: IState[] | undefined; + members: IProjectMember[] | undefined; + addIssueToState: (groupTitle: string, stateId: string | null) => void; + openIssuesListModal?: (() => void) | null; + handleDeleteIssue: (issue: IIssue) => void; + handleOnDragEnd: (result: DropResult) => void; + userAuth: UserAuth; +}; + +export const AllBoards: React.FC = ({ + type, + issues, + states, + members, + addIssueToState, + openIssuesListModal, + handleDeleteIssue, + handleOnDragEnd, + userAuth, +}) => { + const { groupedByIssues, groupByProperty: selectedGroup, orderBy } = useIssueView(issues); + + return ( + <> + {groupedByIssues ? ( +
+ +
+
+
+ {Object.keys(groupedByIssues).map((singleGroup, index) => { + const stateId = + selectedGroup === "state_detail.name" + ? states?.find((s) => s.name === singleGroup)?.id ?? null + : null; + + const bgColor = + selectedGroup === "state_detail.name" + ? states?.find((s) => s.name === singleGroup)?.color + : "#000000"; + + return ( + addIssueToState(singleGroup, stateId)} + handleDeleteIssue={handleDeleteIssue} + openIssuesListModal={openIssuesListModal ?? null} + orderBy={orderBy} + userAuth={userAuth} + /> + ); + })} +
+
+
+
+
+ ) : ( +
Loading...
+ )} + + ); +}; diff --git a/apps/app/components/common/board-view/board-header.tsx b/apps/app/components/core/board-view/board-header.tsx similarity index 76% rename from apps/app/components/common/board-view/board-header.tsx rename to apps/app/components/core/board-view/board-header.tsx index c04bc95d5..3a7753366 100644 --- a/apps/app/components/common/board-view/board-header.tsx +++ b/apps/app/components/core/board-view/board-header.tsx @@ -12,27 +12,23 @@ import { // helpers import { addSpaceIfCamelCase } from "helpers/string.helper"; // types -import { IIssue, NestedKeyOf } from "types"; +import { IIssue } from "types"; type Props = { isCollapsed: boolean; setIsCollapsed: React.Dispatch>; groupedByIssues: { [key: string]: IIssue[]; }; - selectedGroup: NestedKeyOf | null; groupTitle: string; createdBy: string | null; - bgColor: string; + bgColor?: string; addIssueToState: () => void; - provided?: DraggableProvided; }; -const BoardHeader: React.FC = ({ +export const BoardHeader: React.FC = ({ isCollapsed, setIsCollapsed, - provided, groupedByIssues, - selectedGroup, groupTitle, createdBy, bgColor, @@ -44,18 +40,6 @@ const BoardHeader: React.FC = ({ }`} >
- {provided && ( - - )}
= ({
); - -export default BoardHeader; diff --git a/apps/app/components/core/board-view/index.ts b/apps/app/components/core/board-view/index.ts new file mode 100644 index 000000000..6e5cdf8bf --- /dev/null +++ b/apps/app/components/core/board-view/index.ts @@ -0,0 +1,4 @@ +export * from "./all-boards"; +export * from "./board-header"; +export * from "./single-board"; +export * from "./single-issue"; diff --git a/apps/app/components/core/board-view/single-board.tsx b/apps/app/components/core/board-view/single-board.tsx new file mode 100644 index 000000000..82d789a07 --- /dev/null +++ b/apps/app/components/core/board-view/single-board.tsx @@ -0,0 +1,146 @@ +import { useState } from "react"; + +import { useRouter } from "next/router"; + +// react-beautiful-dnd +import StrictModeDroppable from "components/dnd/StrictModeDroppable"; +import { Draggable } from "react-beautiful-dnd"; +// hooks +import useIssuesProperties from "hooks/use-issue-properties"; +// components +import { BoardHeader, SingleBoardIssue } from "components/core"; +// ui +import { CustomMenu } from "components/ui"; +// icons +import { PlusIcon } from "@heroicons/react/24/outline"; +// types +import { IIssue, IProjectMember, NestedKeyOf, UserAuth } from "types"; + +type Props = { + type?: "issue" | "cycle" | "module"; + bgColor?: string; + groupTitle: string; + groupedByIssues: { + [key: string]: IIssue[]; + }; + selectedGroup: NestedKeyOf | null; + members: IProjectMember[] | undefined; + addIssueToState: () => void; + handleDeleteIssue: (issue: IIssue) => void; + openIssuesListModal?: (() => void) | null; + orderBy: NestedKeyOf | "manual" | null; + userAuth: UserAuth; +}; + +export const SingleBoard: React.FC = ({ + type, + bgColor, + groupTitle, + groupedByIssues, + selectedGroup, + members, + addIssueToState, + handleDeleteIssue, + openIssuesListModal, + orderBy, + userAuth, +}) => { + // collapse/expand + const [isCollapsed, setIsCollapsed] = useState(true); + + const router = useRouter(); + const { workspaceSlug, projectId } = router.query; + + const [properties] = useIssuesProperties(workspaceSlug as string, projectId as string); + + const createdBy = + selectedGroup === "created_by" + ? members?.find((m) => m.member.id === groupTitle)?.member.first_name ?? "loading..." + : null; + + if (selectedGroup === "priority") + groupTitle === "high" + ? (bgColor = "#dc2626") + : groupTitle === "medium" + ? (bgColor = "#f97316") + : groupTitle === "low" + ? (bgColor = "#22c55e") + : (bgColor = "#ff0000"); + + return ( +
+
+ + + {(provided, snapshot) => ( +
+ {groupedByIssues[groupTitle].map((issue, index: number) => ( + + ))} + + {provided.placeholder} + + {type === "issue" ? ( + + ) : ( + + + Add issue + + } + className="mt-1" + optionsPosition="left" + noBorder + > + Create new + {openIssuesListModal && ( + + Add an existing issue + + )} + + )} +
+ )} +
+
+
+ ); +}; diff --git a/apps/app/components/core/board-view/single-issue.tsx b/apps/app/components/core/board-view/single-issue.tsx new file mode 100644 index 000000000..59786ea7c --- /dev/null +++ b/apps/app/components/core/board-view/single-issue.tsx @@ -0,0 +1,240 @@ +import React, { useCallback } from "react"; + +import Link from "next/link"; +import { useRouter } from "next/router"; + +import useSWR, { mutate } from "swr"; + +// react-beautiful-dnd +import { + Draggable, + DraggableStateSnapshot, + DraggingStyle, + NotDraggingStyle, +} from "react-beautiful-dnd"; +// constants +import { TrashIcon } from "@heroicons/react/24/outline"; +// services +import issuesService from "services/issues.service"; +// components +import { + ViewAssigneeSelect, + ViewDueDateSelect, + ViewPrioritySelect, + ViewStateSelect, +} from "components/issues/view-select"; +// types +import { + CycleIssueResponse, + IIssue, + IssueResponse, + ModuleIssueResponse, + NestedKeyOf, + Properties, + UserAuth, +} from "types"; +// fetch-keys +import { CYCLE_ISSUES, MODULE_ISSUES, PROJECT_ISSUES_LIST } from "constants/fetch-keys"; + +type Props = { + index: number; + type?: string; + issue: IIssue; + selectedGroup: NestedKeyOf | null; + properties: Properties; + handleDeleteIssue: (issue: IIssue) => void; + orderBy: NestedKeyOf | "manual" | null; + userAuth: UserAuth; +}; + +export const SingleBoardIssue: React.FC = ({ + index, + type, + issue, + selectedGroup, + properties, + handleDeleteIssue, + orderBy, + userAuth, +}) => { + const router = useRouter(); + const { workspaceSlug, projectId, cycleId, moduleId } = router.query; + + const partialUpdateIssue = useCallback( + (formData: Partial) => { + if (!workspaceSlug || !projectId) return; + + if (cycleId) + mutate( + CYCLE_ISSUES(cycleId as string), + (prevData) => { + const updatedIssues = (prevData ?? []).map((p) => { + if (p.issue_detail.id === issue.id) { + return { + ...p, + issue_detail: { + ...p.issue_detail, + ...formData, + }, + }; + } + return p; + }); + return [...updatedIssues]; + }, + false + ); + + if (moduleId) + mutate( + MODULE_ISSUES(moduleId as string), + (prevData) => { + const updatedIssues = (prevData ?? []).map((p) => { + if (p.issue_detail.id === issue.id) { + return { + ...p, + issue_detail: { + ...p.issue_detail, + ...formData, + }, + }; + } + return p; + }); + return [...updatedIssues]; + }, + false + ); + + mutate( + PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string), + (prevData) => ({ + ...(prevData as IssueResponse), + results: (prevData?.results ?? []).map((p) => { + if (p.id === issue.id) return { ...p, ...formData }; + return p; + }), + }), + false + ); + + issuesService + .patchIssue(workspaceSlug as string, projectId as string, issue.id, formData) + .then((res) => { + if (cycleId) mutate(CYCLE_ISSUES(cycleId as string)); + if (moduleId) mutate(MODULE_ISSUES(moduleId as string)); + + mutate(PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string)); + }) + .catch((error) => { + console.log(error); + }); + }, + [workspaceSlug, projectId, cycleId, moduleId, issue] + ); + + function getStyle( + style: DraggingStyle | NotDraggingStyle | undefined, + snapshot: DraggableStateSnapshot + ) { + if (orderBy === "manual") return style; + if (!snapshot.isDragging) return {}; + if (!snapshot.isDropAnimating) { + return style; + } + + return { + ...style, + transitionDuration: `0.001s`, + }; + } + + const isNotAllowed = userAuth.isGuest || userAuth.isViewer; + + return ( + + {(provided, snapshot) => ( +
+
+ {!isNotAllowed && ( +
+ +
+ )} + + + {properties.key && ( +
+ {issue.project_detail.identifier}-{issue.sequence_id} +
+ )} +
+ {issue.name} +
+
+ +
+ {properties.priority && ( + + )} + {properties.state && ( + + )} + {properties.due_date && ( + + )} + {properties.sub_issue_count && ( +
+ {issue.sub_issues_count}{" "} + {issue.sub_issues_count === 1 ? "sub-issue" : "sub-issues"} +
+ )} + {properties.assignee && ( + + )} +
+
+
+ )} +
+ ); +}; diff --git a/apps/app/components/common/bulk-delete-issues-modal.tsx b/apps/app/components/core/bulk-delete-issues-modal.tsx similarity index 92% rename from apps/app/components/common/bulk-delete-issues-modal.tsx rename to apps/app/components/core/bulk-delete-issues-modal.tsx index 64a65c22a..6e879d885 100644 --- a/apps/app/components/common/bulk-delete-issues-modal.tsx +++ b/apps/app/components/core/bulk-delete-issues-modal.tsx @@ -1,27 +1,26 @@ -// react import React, { useState } from "react"; -// next + import { useRouter } from "next/router"; -// swr + import useSWR, { mutate } from "swr"; + // react hook form import { SubmitHandler, useForm } from "react-hook-form"; -// services +// headless ui import { Combobox, Dialog, Transition } from "@headlessui/react"; -import { FolderIcon, MagnifyingGlassIcon } from "@heroicons/react/24/outline"; +// services import issuesServices from "services/issues.service"; -import projectService from "services/project.service"; // hooks import useToast from "hooks/use-toast"; -// headless ui // ui import { Button } from "components/ui"; // icons +import { MagnifyingGlassIcon } from "@heroicons/react/24/outline"; import { LayerDiagonalIcon } from "components/icons"; // types import { IIssue, IssueResponse } from "types"; // fetch keys -import { PROJECT_ISSUES_LIST, PROJECT_DETAILS } from "constants/fetch-keys"; +import { PROJECT_ISSUES_LIST } from "constants/fetch-keys"; type FormInput = { delete_issue_ids: string[]; @@ -32,7 +31,7 @@ type Props = { setIsOpen: React.Dispatch>; }; -const BulkDeleteIssuesModal: React.FC = ({ isOpen, setIsOpen }) => { +export const BulkDeleteIssuesModal: React.FC = ({ isOpen, setIsOpen }) => { const [query, setQuery] = useState(""); const router = useRouter(); @@ -50,13 +49,6 @@ const BulkDeleteIssuesModal: React.FC = ({ isOpen, setIsOpen }) => { : null ); - const { data: projectDetails } = useSWR( - workspaceSlug && projectId ? PROJECT_DETAILS(projectId as string) : null, - workspaceSlug && projectId - ? () => projectService.getProject(workspaceSlug as string, projectId as string) - : null - ); - const { setToastAlert } = useToast(); const { @@ -213,7 +205,7 @@ const BulkDeleteIssuesModal: React.FC = ({ isOpen, setIsOpen }) => { }} /> - {projectDetails?.identifier}-{issue.sequence_id} + {issue.project_detail.identifier}-{issue.sequence_id} {issue.name} @@ -226,7 +218,7 @@ const BulkDeleteIssuesModal: React.FC = ({ isOpen, setIsOpen }) => {

No issues found. Create a new issue with{" "} -
C
. +
C
.

)} @@ -256,5 +248,3 @@ const BulkDeleteIssuesModal: React.FC = ({ isOpen, setIsOpen }) => { ); }; - -export default BulkDeleteIssuesModal; diff --git a/apps/app/components/common/existing-issues-list-modal.tsx b/apps/app/components/core/existing-issues-list-modal.tsx similarity index 91% rename from apps/app/components/common/existing-issues-list-modal.tsx rename to apps/app/components/core/existing-issues-list-modal.tsx index 5179facd4..15a313cb0 100644 --- a/apps/app/components/common/existing-issues-list-modal.tsx +++ b/apps/app/components/core/existing-issues-list-modal.tsx @@ -1,24 +1,17 @@ import React, { useState } from "react"; -import { useRouter } from "next/router"; - -import useSWR from "swr"; // react-hook-form import { Controller, SubmitHandler, useForm } from "react-hook-form"; // hooks import { Combobox, Dialog, Transition } from "@headlessui/react"; import { MagnifyingGlassIcon, RectangleStackIcon } from "@heroicons/react/24/outline"; import useToast from "hooks/use-toast"; -// services -import projectService from "services/project.service"; // headless ui // ui import { Button } from "components/ui"; import { LayerDiagonalIcon } from "components/icons"; // types import { IIssue } from "types"; -// fetch-keys -import { PROJECT_DETAILS } from "constants/fetch-keys"; type FormInput = { issues: string[]; @@ -32,7 +25,7 @@ type Props = { handleOnSubmit: any; }; -const ExistingIssuesListModal: React.FC = ({ +export const ExistingIssuesListModal: React.FC = ({ isOpen, handleClose: onClose, issues, @@ -41,16 +34,6 @@ const ExistingIssuesListModal: React.FC = ({ }) => { const [query, setQuery] = useState(""); - const router = useRouter(); - const { workspaceSlug, projectId } = router.query; - - const { data: projectDetails } = useSWR( - workspaceSlug && projectId ? PROJECT_DETAILS(projectId as string) : null, - workspaceSlug && projectId - ? () => projectService.getProject(workspaceSlug as string, projectId as string) - : null - ); - const { setToastAlert } = useToast(); const handleClose = () => { @@ -175,7 +158,7 @@ const ExistingIssuesListModal: React.FC = ({ }} /> - {projectDetails?.identifier}-{issue.sequence_id} + {issue.project_detail.identifier}-{issue.sequence_id} {issue.name} @@ -189,7 +172,7 @@ const ExistingIssuesListModal: React.FC = ({

No issues found. Create a new issue with{" "} -
C
. +
C
.

)} @@ -233,5 +216,3 @@ const ExistingIssuesListModal: React.FC = ({ ); }; - -export default ExistingIssuesListModal; diff --git a/apps/app/components/common/image-upload-modal.tsx b/apps/app/components/core/image-upload-modal.tsx similarity index 100% rename from apps/app/components/common/image-upload-modal.tsx rename to apps/app/components/core/image-upload-modal.tsx diff --git a/apps/app/components/core/index.ts b/apps/app/components/core/index.ts index 8266a5111..0865ea441 100644 --- a/apps/app/components/core/index.ts +++ b/apps/app/components/core/index.ts @@ -1 +1,8 @@ +export * from "./board-view"; +export * from "./list-view"; +export * from "./bulk-delete-issues-modal"; +export * from "./existing-issues-list-modal"; +export * from "./image-upload-modal"; +export * from "./issues-view-filter"; +export * from "./issues-view"; export * from "./not-authorized-view"; diff --git a/apps/app/components/core/view.tsx b/apps/app/components/core/issues-view-filter.tsx similarity index 87% rename from apps/app/components/core/view.tsx rename to apps/app/components/core/issues-view-filter.tsx index 1fe147f22..7225f5148 100644 --- a/apps/app/components/core/view.tsx +++ b/apps/app/components/core/issues-view-filter.tsx @@ -17,13 +17,13 @@ import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper"; // types import { IIssue, Properties } from "types"; // common -import { filterIssueOptions, groupByOptions, orderByOptions } from "constants/"; +import { GROUP_BY_OPTIONS, ORDER_BY_OPTIONS, FILTER_ISSUE_OPTIONS } from "constants/issue"; type Props = { issues?: IIssue[]; }; -const View: React.FC = ({ issues }) => { +export const IssuesFilterView: React.FC = ({ issues }) => { const router = useRouter(); const { workspaceSlug, projectId } = router.query; @@ -99,31 +99,33 @@ const View: React.FC = ({ issues }) => {

Group by

option.key === groupByProperty) + GROUP_BY_OPTIONS.find((option) => option.key === groupByProperty) ?.name ?? "Select" } width="lg" > - {groupByOptions.map((option) => ( - setGroupByProperty(option.key)} - > - {option.name} - - ))} + {GROUP_BY_OPTIONS.map((option) => + issueView === "kanban" && option.key === null ? null : ( + setGroupByProperty(option.key)} + > + {option.name} + + ) + )}

Order by

option.key === orderBy)?.name ?? + ORDER_BY_OPTIONS.find((option) => option.key === orderBy)?.name ?? "Select" } width="lg" > - {orderByOptions.map((option) => + {ORDER_BY_OPTIONS.map((option) => groupByProperty === "priority" && option.key === "priority" ? null : ( = ({ issues }) => {

Issue type

option.key === filterIssue) + FILTER_ISSUE_OPTIONS.find((option) => option.key === filterIssue) ?.name ?? "Select" } width="lg" > - {filterIssueOptions.map((option) => ( + {FILTER_ISSUE_OPTIONS.map((option) => ( setFilterIssue(option.key)} @@ -203,5 +205,3 @@ const View: React.FC = ({ issues }) => { ); }; - -export default View; diff --git a/apps/app/components/core/issues-view.tsx b/apps/app/components/core/issues-view.tsx new file mode 100644 index 000000000..5f1d2c289 --- /dev/null +++ b/apps/app/components/core/issues-view.tsx @@ -0,0 +1,405 @@ +import { useCallback, useState } from "react"; + +import { useRouter } from "next/router"; + +import useSWR, { mutate } from "swr"; + +// react-beautiful-dnd +import { DropResult } from "react-beautiful-dnd"; +// services +import issuesService from "services/issues.service"; +import stateService from "services/state.service"; +import projectService from "services/project.service"; +import modulesService from "services/modules.service"; +// hooks +import useIssueView from "hooks/use-issue-view"; +// components +import { AllLists, AllBoards, ExistingIssuesListModal } from "components/core"; +import { CreateUpdateIssueModal, DeleteIssueModal } from "components/issues"; +// types +import { + CycleIssueResponse, + IIssue, + IssueResponse, + IState, + ModuleIssueResponse, + UserAuth, +} from "types"; +// fetch-keys +import { + CYCLE_ISSUES, + MODULE_ISSUES, + PROJECT_ISSUES_LIST, + PROJECT_MEMBERS, + STATE_LIST, +} from "constants/fetch-keys"; + +type Props = { + type?: "issue" | "cycle" | "module"; + issues: IIssue[]; + openIssuesListModal?: () => void; + userAuth: UserAuth; +}; + +export const IssuesView: React.FC = ({ + type = "issue", + issues, + openIssuesListModal, + userAuth, +}) => { + // create issue modal + const [createIssueModal, setCreateIssueModal] = useState(false); + const [preloadedData, setPreloadedData] = useState< + (Partial & { actionType: "createIssue" | "edit" | "delete" }) | undefined + >(undefined); + + // updates issue modal + const [editIssueModal, setEditIssueModal] = useState(false); + const [issueToEdit, setIssueToEdit] = useState< + (IIssue & { actionType: "edit" | "delete" }) | undefined + >(undefined); + + // delete issue modal + const [deleteIssueModal, setDeleteIssueModal] = useState(false); + const [issueToDelete, setIssueToDelete] = useState(null); + + const router = useRouter(); + const { workspaceSlug, projectId, cycleId, moduleId } = router.query; + + const { issueView, groupedByIssues, groupByProperty: selectedGroup } = useIssueView(issues); + + const { data: states } = useSWR( + workspaceSlug && projectId ? STATE_LIST(projectId as string) : null, + workspaceSlug + ? () => stateService.getStates(workspaceSlug as string, projectId as string) + : null + ); + + const { data: members } = useSWR( + projectId ? PROJECT_MEMBERS(projectId as string) : null, + workspaceSlug && projectId + ? () => projectService.projectMembers(workspaceSlug as string, projectId as string) + : null + ); + + const handleOnDragEnd = useCallback( + (result: DropResult) => { + if (!result.destination || !workspaceSlug || !projectId) return; + + const { source, destination } = result; + + const draggedItem = groupedByIssues[source.droppableId][source.index]; + + if (source.droppableId !== destination.droppableId) { + const sourceGroup = source.droppableId; // source group id + const destinationGroup = destination.droppableId; // destination group id + + if (!sourceGroup || !destinationGroup) return; + + if (selectedGroup === "priority") { + // update the removed item for mutation + draggedItem.priority = destinationGroup; + + if (cycleId) + mutate( + CYCLE_ISSUES(cycleId as string), + (prevData) => { + if (!prevData) return prevData; + const updatedIssues = prevData.map((issue) => { + if (issue.issue_detail.id === draggedItem.id) { + return { + ...issue, + issue_detail: { + ...draggedItem, + priority: destinationGroup, + }, + }; + } + return issue; + }); + return [...updatedIssues]; + }, + false + ); + + if (moduleId) + mutate( + MODULE_ISSUES(moduleId as string), + (prevData) => { + if (!prevData) return prevData; + const updatedIssues = prevData.map((issue) => { + if (issue.issue_detail.id === draggedItem.id) { + return { + ...issue, + issue_detail: { + ...draggedItem, + priority: destinationGroup, + }, + }; + } + return issue; + }); + return [...updatedIssues]; + }, + false + ); + + mutate( + PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string), + (prevData) => { + if (!prevData) return prevData; + + const updatedIssues = prevData.results.map((issue) => { + if (issue.id === draggedItem.id) + return { + ...draggedItem, + priority: destinationGroup, + }; + + return issue; + }); + + return { + ...prevData, + results: updatedIssues, + }; + }, + false + ); + + // patch request + issuesService + .patchIssue(workspaceSlug as string, projectId as string, draggedItem.id, { + priority: destinationGroup, + }) + .then((res) => { + if (cycleId) mutate(CYCLE_ISSUES(cycleId as string)); + if (moduleId) mutate(MODULE_ISSUES(moduleId as string)); + + mutate(PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string)); + }); + } else if (selectedGroup === "state_detail.name") { + const destinationState = states?.find((s) => s.name === destinationGroup); + const destinationStateId = destinationState?.id; + + // update the removed item for mutation + if (!destinationStateId || !destinationState) return; + draggedItem.state = destinationStateId; + draggedItem.state_detail = destinationState; + + if (cycleId) + mutate( + CYCLE_ISSUES(cycleId as string), + (prevData) => { + if (!prevData) return prevData; + const updatedIssues = prevData.map((issue) => { + if (issue.issue_detail.id === draggedItem.id) { + return { + ...issue, + issue_detail: { + ...draggedItem, + state_detail: destinationState, + state: destinationStateId, + }, + }; + } + return issue; + }); + return [...updatedIssues]; + }, + false + ); + + if (moduleId) + mutate( + MODULE_ISSUES(moduleId as string), + (prevData) => { + if (!prevData) return prevData; + const updatedIssues = prevData.map((issue) => { + if (issue.issue_detail.id === draggedItem.id) { + return { + ...issue, + issue_detail: { + ...draggedItem, + state_detail: destinationState, + state: destinationStateId, + }, + }; + } + return issue; + }); + return [...updatedIssues]; + }, + false + ); + + mutate( + PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string), + (prevData) => { + if (!prevData) return prevData; + + const updatedIssues = prevData.results.map((issue) => { + if (issue.id === draggedItem.id) + return { + ...draggedItem, + state_detail: destinationState, + state: destinationStateId, + }; + + return issue; + }); + + return { + ...prevData, + results: updatedIssues, + }; + }, + false + ); + + // patch request + issuesService + .patchIssue(workspaceSlug as string, projectId as string, draggedItem.id, { + state: destinationStateId, + }) + .then((res) => { + if (cycleId) mutate(CYCLE_ISSUES(cycleId as string)); + if (moduleId) mutate(MODULE_ISSUES(moduleId as string)); + + mutate(PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string)); + }); + } + } + }, + [workspaceSlug, cycleId, moduleId, groupedByIssues, projectId, selectedGroup, states] + ); + + const addIssueToState = (groupTitle: string, stateId: string | null) => { + setCreateIssueModal(true); + if (selectedGroup) + setPreloadedData({ + state: stateId ?? undefined, + [selectedGroup]: groupTitle, + actionType: "createIssue", + }); + else setPreloadedData({ actionType: "createIssue" }); + }; + + const handleEditIssue = (issue: IIssue) => { + setEditIssueModal(true); + setIssueToEdit({ + ...issue, + actionType: "edit", + cycle: issue.issue_cycle ? issue.issue_cycle.cycle : null, + module: issue.issue_module ? issue.issue_module.module : null, + }); + }; + + const handleDeleteIssue = (issue: IIssue) => { + setDeleteIssueModal(true); + setIssueToDelete(issue); + }; + + const removeIssueFromCycle = (bridgeId: string) => { + if (!workspaceSlug || !projectId) return; + + mutate( + CYCLE_ISSUES(cycleId as string), + (prevData) => prevData?.filter((p) => p.id !== bridgeId), + false + ); + + issuesService + .removeIssueFromCycle( + workspaceSlug as string, + projectId as string, + cycleId as string, + bridgeId + ) + .then((res) => { + console.log(res); + }) + .catch((e) => { + console.log(e); + }); + }; + + const removeIssueFromModule = (bridgeId: string) => { + if (!workspaceSlug || !projectId) return; + + mutate( + MODULE_ISSUES(moduleId as string), + (prevData) => prevData?.filter((p) => p.id !== bridgeId), + false + ); + + modulesService + .removeIssueFromModule( + workspaceSlug as string, + projectId as string, + moduleId as string, + bridgeId + ) + .then((res) => { + console.log(res); + }) + .catch((e) => { + console.log(e); + }); + }; + + return ( + <> + setCreateIssueModal(false)} + prePopulateData={{ + ...preloadedData, + }} + /> + setEditIssueModal(false)} + data={issueToEdit} + /> + setDeleteIssueModal(false)} + isOpen={deleteIssueModal} + data={issueToDelete} + /> + {issueView === "list" ? ( + + ) : ( + + )} + + ); +}; diff --git a/apps/app/components/core/list-view/all-lists.tsx b/apps/app/components/core/list-view/all-lists.tsx new file mode 100644 index 000000000..c2b6c498a --- /dev/null +++ b/apps/app/components/core/list-view/all-lists.tsx @@ -0,0 +1,63 @@ +// hooks +import useIssueView from "hooks/use-issue-view"; +// components +import { SingleList } from "components/core/list-view/single-list"; +// types +import { IIssue, IProjectMember, IState, UserAuth } from "types"; + +// types +type Props = { + type: "issue" | "cycle" | "module"; + issues: IIssue[]; + states: IState[] | undefined; + members: IProjectMember[] | undefined; + addIssueToState: (groupTitle: string, stateId: string | null) => void; + handleEditIssue: (issue: IIssue) => void; + handleDeleteIssue: (issue: IIssue) => void; + openIssuesListModal?: (() => void) | null; + removeIssue: ((bridgeId: string) => void) | null; + userAuth: UserAuth; +}; + +export const AllLists: React.FC = ({ + type, + issues, + states, + members, + addIssueToState, + openIssuesListModal, + handleEditIssue, + handleDeleteIssue, + removeIssue, + userAuth, +}) => { + const { groupedByIssues, groupByProperty: selectedGroup } = useIssueView(issues); + + return ( +
+ {Object.keys(groupedByIssues).map((singleGroup) => { + const stateId = + selectedGroup === "state_detail.name" + ? states?.find((s) => s.name === singleGroup)?.id ?? null + : null; + + return ( + addIssueToState(singleGroup, stateId)} + handleEditIssue={handleEditIssue} + handleDeleteIssue={handleDeleteIssue} + openIssuesListModal={type !== "issue" ? openIssuesListModal : null} + removeIssue={removeIssue} + userAuth={userAuth} + /> + ); + })} +
+ ); +}; diff --git a/apps/app/components/core/list-view/index.ts b/apps/app/components/core/list-view/index.ts new file mode 100644 index 000000000..c515ed1c2 --- /dev/null +++ b/apps/app/components/core/list-view/index.ts @@ -0,0 +1,3 @@ +export * from "./all-lists"; +export * from "./single-issue"; +export * from "./single-list"; diff --git a/apps/app/components/core/list-view/single-issue.tsx b/apps/app/components/core/list-view/single-issue.tsx new file mode 100644 index 000000000..b779db594 --- /dev/null +++ b/apps/app/components/core/list-view/single-issue.tsx @@ -0,0 +1,198 @@ +import React, { useCallback } from "react"; + +import Link from "next/link"; +import { useRouter } from "next/router"; + +import { mutate } from "swr"; + +// services +import issuesService from "services/issues.service"; +// components +import { + ViewAssigneeSelect, + ViewDueDateSelect, + ViewPrioritySelect, + ViewStateSelect, +} from "components/issues/view-select"; +// ui +import { CustomMenu } from "components/ui"; +// types +import { + CycleIssueResponse, + IIssue, + IssueResponse, + ModuleIssueResponse, + Properties, + UserAuth, +} from "types"; +// fetch-keys +import { CYCLE_ISSUES, MODULE_ISSUES, PROJECT_ISSUES_LIST, STATE_LIST } from "constants/fetch-keys"; + +type Props = { + type?: string; + issue: IIssue; + properties: Properties; + editIssue: () => void; + removeIssue?: (() => void) | null; + handleDeleteIssue: (issue: IIssue) => void; + userAuth: UserAuth; +}; + +export const SingleListIssue: React.FC = ({ + type, + issue, + properties, + editIssue, + removeIssue, + handleDeleteIssue, + userAuth, +}) => { + const router = useRouter(); + const { workspaceSlug, projectId, cycleId, moduleId } = router.query; + + const partialUpdateIssue = useCallback( + (formData: Partial) => { + if (!workspaceSlug || !projectId) return; + + if (cycleId) + mutate( + CYCLE_ISSUES(cycleId as string), + (prevData) => { + const updatedIssues = (prevData ?? []).map((p) => { + if (p.issue_detail.id === issue.id) { + return { + ...p, + issue_detail: { + ...p.issue_detail, + ...formData, + }, + }; + } + return p; + }); + return [...updatedIssues]; + }, + false + ); + + if (moduleId) + mutate( + MODULE_ISSUES(moduleId as string), + (prevData) => { + const updatedIssues = (prevData ?? []).map((p) => { + if (p.issue_detail.id === issue.id) { + return { + ...p, + issue_detail: { + ...p.issue_detail, + ...formData, + }, + }; + } + return p; + }); + return [...updatedIssues]; + }, + false + ); + + mutate( + PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string), + (prevData) => ({ + ...(prevData as IssueResponse), + results: (prevData?.results ?? []).map((p) => { + if (p.id === issue.id) return { ...p, ...formData }; + return p; + }), + }), + false + ); + + issuesService + .patchIssue(workspaceSlug as string, projectId as string, issue.id, formData) + .then((res) => { + if (cycleId) mutate(CYCLE_ISSUES(cycleId as string)); + if (moduleId) mutate(MODULE_ISSUES(moduleId as string)); + + mutate(PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string)); + }) + .catch((error) => { + console.log(error); + }); + }, + [workspaceSlug, projectId, cycleId, moduleId, issue] + ); + + const isNotAllowed = userAuth.isGuest || userAuth.isViewer; + + return ( +
+ +
+ {properties.priority && ( + + )} + {properties.state && ( + + )} + {properties.due_date && ( + + )} + {properties.sub_issue_count && ( +
+ {issue.sub_issues_count} {issue.sub_issues_count === 1 ? "sub-issue" : "sub-issues"} +
+ )} + {properties.assignee && ( + + )} + {type && !isNotAllowed && ( + + Edit + {type !== "issue" && removeIssue && ( + + <>Remove from {type} + + )} + handleDeleteIssue(issue)}> + Delete permanently + + + )} +
+
+ ); +}; diff --git a/apps/app/components/core/list-view/single-list.tsx b/apps/app/components/core/list-view/single-list.tsx new file mode 100644 index 000000000..4309b2d33 --- /dev/null +++ b/apps/app/components/core/list-view/single-list.tsx @@ -0,0 +1,155 @@ +import { useRouter } from "next/router"; + +// headless ui +import { Disclosure, Transition } from "@headlessui/react"; +// hooks +import useIssuesProperties from "hooks/use-issue-properties"; +// components +import { SingleListIssue } from "components/core"; +// icons +import { ChevronDownIcon, PlusIcon } from "@heroicons/react/24/outline"; +// helpers +import { addSpaceIfCamelCase } from "helpers/string.helper"; +// types +import { IIssue, IProjectMember, NestedKeyOf, UserAuth } from "types"; +import { CustomMenu } from "components/ui"; + +type Props = { + type?: "issue" | "cycle" | "module"; + groupTitle: string; + groupedByIssues: { + [key: string]: IIssue[]; + }; + selectedGroup: NestedKeyOf | null; + members: IProjectMember[] | undefined; + addIssueToState: () => void; + handleEditIssue: (issue: IIssue) => void; + handleDeleteIssue: (issue: IIssue) => void; + openIssuesListModal?: (() => void) | null; + removeIssue: ((bridgeId: string) => void) | null; + userAuth: UserAuth; +}; + +export const SingleList: React.FC = ({ + type, + groupTitle, + groupedByIssues, + selectedGroup, + members, + addIssueToState, + handleEditIssue, + handleDeleteIssue, + openIssuesListModal, + removeIssue, + userAuth, +}) => { + const router = useRouter(); + const { workspaceSlug, projectId } = router.query; + + const [properties] = useIssuesProperties(workspaceSlug as string, projectId as string); + + const createdBy = + selectedGroup === "created_by" + ? members?.find((m) => m.member.id === groupTitle)?.member.first_name ?? "loading..." + : null; + + return ( + + {({ open }) => ( +
+
+ +
+ + + + {selectedGroup !== null ? ( +

+ {groupTitle === null || groupTitle === "null" + ? "None" + : createdBy + ? createdBy + : addSpaceIfCamelCase(groupTitle)} +

+ ) : ( +

All Issues

+ )} +

+ {groupedByIssues[groupTitle as keyof IIssue].length} +

+
+
+
+ + +
+ {groupedByIssues[groupTitle] ? ( + groupedByIssues[groupTitle].length > 0 ? ( + groupedByIssues[groupTitle].map((issue: IIssue) => ( + handleEditIssue(issue)} + handleDeleteIssue={handleDeleteIssue} + removeIssue={() => { + removeIssue && removeIssue(issue.bridge); + }} + userAuth={userAuth} + /> + )) + ) : ( +

No issues.

+ ) + ) : ( +
Loading...
+ )} +
+
+
+
+ {type === "issue" ? ( + + ) : ( + + + Add issue + + } + optionsPosition="left" + noBorder + > + Create new + {openIssuesListModal && ( + + Add an existing issue + + )} + + )} +
+
+ )} +
+ ); +}; diff --git a/apps/app/components/cycles/modal.tsx b/apps/app/components/cycles/modal.tsx index 9a53a2949..76e1c5ad1 100644 --- a/apps/app/components/cycles/modal.tsx +++ b/apps/app/components/cycles/modal.tsx @@ -61,9 +61,8 @@ export const CycleModal: React.FC = (props) => { if (workspaceSlug && projectId) { const payload = { ...formValues, - start_date: formValues.start_date ? renderDateFormat(formValues.start_date) : null, - end_date: formValues.end_date ? renderDateFormat(formValues.end_date) : null, }; + if (initialData) { updateCycle(initialData.id, payload); } else { diff --git a/apps/app/components/dnd/StrictModeDroppable.tsx b/apps/app/components/dnd/StrictModeDroppable.tsx index e63cab246..9ed01d3bf 100644 --- a/apps/app/components/dnd/StrictModeDroppable.tsx +++ b/apps/app/components/dnd/StrictModeDroppable.tsx @@ -1,4 +1,5 @@ import React, { useState, useEffect } from "react"; + // react beautiful dnd import { Droppable, DroppableProps } from "react-beautiful-dnd"; @@ -14,9 +15,7 @@ const StrictModeDroppable = ({ children, ...props }: DroppableProps) => { }; }, []); - if (!enabled) { - return null; - } + if (!enabled) return null; return {children}; }; diff --git a/apps/app/components/emoji-icon-picker/index.tsx b/apps/app/components/emoji-icon-picker/index.tsx index c2930e95b..a441cd4cb 100644 --- a/apps/app/components/emoji-icon-picker/index.tsx +++ b/apps/app/components/emoji-icon-picker/index.tsx @@ -7,7 +7,7 @@ import { Props } from "./types"; import emojis from "./emojis.json"; // helpers import { getRecentEmojis, saveRecentEmoji } from "./helpers"; -import { getRandomEmoji } from "helpers/functions.helper"; +import { getRandomEmoji } from "helpers/common.helper"; // hooks import useOutsideClickDetector from "hooks/use-outside-click-detector"; diff --git a/apps/app/constants/global.tsx b/apps/app/components/icons/priority-icon.tsx similarity index 100% rename from apps/app/constants/global.tsx rename to apps/app/components/icons/priority-icon.tsx diff --git a/apps/app/components/project/issues/issue-detail/activity/index.tsx b/apps/app/components/issues/activity.tsx similarity index 92% rename from apps/app/components/project/issues/issue-detail/activity/index.tsx rename to apps/app/components/issues/activity.tsx index 3594bf7a8..2bcc3853d 100644 --- a/apps/app/components/project/issues/issue-detail/activity/index.tsx +++ b/apps/app/components/issues/activity.tsx @@ -8,17 +8,18 @@ import { CalendarDaysIcon, ChartBarIcon, ChatBubbleBottomCenterTextIcon, + RectangleGroupIcon, Squares2X2Icon, UserIcon, } from "@heroicons/react/24/outline"; // services import issuesServices from "services/issues.service"; // components -import CommentCard from "components/project/issues/issue-detail/comment/issue-comment-card"; +import { CommentCard } from "components/issues/comment"; // ui import { Loader } from "components/ui"; // icons -import { BlockedIcon, BlockerIcon, TagIcon, UserGroupIcon } from "components/icons"; +import { BlockedIcon, BlockerIcon, CyclesIcon, TagIcon, UserGroupIcon } from "components/icons"; // helpers import { renderShortNumericDateFormat, timeAgo } from "helpers/date-time.helper"; import { addSpaceIfCamelCase } from "helpers/string.helper"; @@ -47,9 +48,17 @@ const activityDetails: { message: "marked this issue is blocking", icon: , }, + cycles: { + message: "set the cycle to", + icon: , + }, labels: { icon: , }, + modules: { + message: "set the module to", + icon: , + }, state: { message: "set the state to", icon: , @@ -76,10 +85,12 @@ const activityDetails: { }, }; -const IssueActivitySection: React.FC<{ +type Props = { issueActivities: IIssueActivity[]; mutate: KeyedMutator; -}> = ({ issueActivities, mutate }) => { +}; + +export const IssueActivitySection: React.FC = ({ issueActivities, mutate }) => { const router = useRouter(); const { workspaceSlug, projectId, issueId } = router.query; @@ -183,7 +194,9 @@ const IssueActivitySection: React.FC<{ ?.message}{" "} - {activity.verb === "created" ? ( + {activity.verb === "created" && + activity.field !== "cycles" && + activity.field !== "modules" ? ( created this issue. ) : activity.field === "description" ? null : activity.field === "state" ? ( activity.new_value ? ( @@ -216,7 +229,7 @@ const IssueActivitySection: React.FC<{
); - } else if ("comment_json" in activity) { + } else if ("comment_json" in activity) return ( ); - } })} ) : ( @@ -247,5 +259,3 @@ const IssueActivitySection: React.FC<{ ); }; - -export default IssueActivitySection; diff --git a/apps/app/components/project/issues/issue-detail/comment/issue-comment-section.tsx b/apps/app/components/issues/comment/add-comment.tsx similarity index 95% rename from apps/app/components/project/issues/issue-detail/comment/issue-comment-section.tsx rename to apps/app/components/issues/comment/add-comment.tsx index 5ac7e061a..9b31a2423 100644 --- a/apps/app/components/project/issues/issue-detail/comment/issue-comment-section.tsx +++ b/apps/app/components/issues/comment/add-comment.tsx @@ -10,7 +10,7 @@ import issuesServices from "services/issues.service"; // ui import { Loader } from "components/ui"; // helpers -import { debounce } from "helpers/functions.helper"; +import { debounce } from "helpers/common.helper"; // types import type { IIssueActivity, IIssueComment } from "types"; import type { KeyedMutator } from "swr"; @@ -28,7 +28,8 @@ const defaultValues: Partial = { comment_html: "", comment_json: "", }; -const AddIssueComment: React.FC<{ + +export const AddComment: React.FC<{ mutate: KeyedMutator; }> = ({ mutate }) => { const { @@ -111,5 +112,3 @@ const AddIssueComment: React.FC<{ ); }; - -export default AddIssueComment; diff --git a/apps/app/components/project/issues/issue-detail/comment/issue-comment-card.tsx b/apps/app/components/issues/comment/comment-card.tsx similarity index 97% rename from apps/app/components/project/issues/issue-detail/comment/issue-comment-card.tsx rename to apps/app/components/issues/comment/comment-card.tsx index ec270ff25..79582df3f 100644 --- a/apps/app/components/project/issues/issue-detail/comment/issue-comment-card.tsx +++ b/apps/app/components/issues/comment/comment-card.tsx @@ -24,7 +24,7 @@ type Props = { handleCommentDeletion: (comment: string) => void; }; -const CommentCard: React.FC = ({ comment, onSubmit, handleCommentDeletion }) => { +export const CommentCard: React.FC = ({ comment, onSubmit, handleCommentDeletion }) => { const { user } = useUser(); const [isEditing, setIsEditing] = useState(false); @@ -130,5 +130,3 @@ const CommentCard: React.FC = ({ comment, onSubmit, handleCommentDeletion ); }; - -export default CommentCard; diff --git a/apps/app/components/issues/comment/index.ts b/apps/app/components/issues/comment/index.ts new file mode 100644 index 000000000..cf13ca91e --- /dev/null +++ b/apps/app/components/issues/comment/index.ts @@ -0,0 +1,2 @@ +export * from "./add-comment"; +export * from "./comment-card"; diff --git a/apps/app/components/project/issues/confirm-issue-deletion.tsx b/apps/app/components/issues/delete-issue-modal.tsx similarity index 92% rename from apps/app/components/project/issues/confirm-issue-deletion.tsx rename to apps/app/components/issues/delete-issue-modal.tsx index 12b8c63e9..bbc6552ba 100644 --- a/apps/app/components/project/issues/confirm-issue-deletion.tsx +++ b/apps/app/components/issues/delete-issue-modal.tsx @@ -17,20 +17,20 @@ import { Button } from "components/ui"; // types import type { CycleIssueResponse, IIssue, IssueResponse, ModuleIssueResponse } from "types"; // fetch-keys -import { CYCLE_ISSUES, PROJECT_ISSUES_LIST, MODULE_ISSUES } from "constants/fetch-keys"; +import { CYCLE_ISSUES, PROJECT_ISSUES_LIST, MODULE_ISSUES, USER_ISSUE } from "constants/fetch-keys"; type Props = { isOpen: boolean; handleClose: () => void; - data?: IIssue; + data: IIssue | null; }; -const ConfirmIssueDeletion: React.FC = ({ isOpen, handleClose, data }) => { +export const DeleteIssueModal: React.FC = ({ isOpen, handleClose, data }) => { const cancelButtonRef = useRef(null); const [isDeleteLoading, setIsDeleteLoading] = useState(false); const router = useRouter(); - const { workspaceSlug } = router.query; + const { workspaceSlug, projectId: queryProjectId } = router.query; const { setToastAlert } = useToast(); @@ -43,15 +43,40 @@ const ConfirmIssueDeletion: React.FC = ({ isOpen, handleClose, data }) => handleClose(); }; - console.log(data); - const handleDeletion = async () => { setIsDeleteLoading(true); if (!data || !workspaceSlug) return; + const projectId = data.project; await issueServices .deleteIssue(workspaceSlug as string, projectId, data.id) .then(() => { + const cycleId = data?.cycle; + const moduleId = data?.module; + + if (cycleId) { + mutate( + CYCLE_ISSUES(cycleId), + (prevData) => prevData?.filter((i) => i.issue !== data.id), + false + ); + } + + if (moduleId) { + mutate( + MODULE_ISSUES(moduleId), + (prevData) => prevData?.filter((i) => i.issue !== data.id), + false + ); + } + + if (!queryProjectId) + mutate( + USER_ISSUE(workspaceSlug as string), + (prevData) => prevData?.filter((i) => i.id !== data.id), + false + ); + mutate( PROJECT_ISSUES_LIST(workspaceSlug as string, projectId), (prevData) => ({ @@ -62,24 +87,6 @@ const ConfirmIssueDeletion: React.FC = ({ isOpen, handleClose, data }) => false ); - const moduleId = data?.module; - const cycleId = data?.cycle; - - if (moduleId) { - mutate( - MODULE_ISSUES(moduleId), - (prevData) => prevData?.filter((i) => i.issue !== data.id), - false - ); - } - if (cycleId) { - mutate( - CYCLE_ISSUES(cycleId), - (prevData) => prevData?.filter((i) => i.issue !== data.id), - false - ); - } - handleClose(); setToastAlert({ title: "Success", @@ -173,5 +180,3 @@ const ConfirmIssueDeletion: React.FC = ({ isOpen, handleClose, data }) => ); }; - -export default ConfirmIssueDeletion; diff --git a/apps/app/components/issues/form.tsx b/apps/app/components/issues/form.tsx index a2233fc52..5e034822a 100644 --- a/apps/app/components/issues/form.tsx +++ b/apps/app/components/issues/form.tsx @@ -16,7 +16,7 @@ import { IssueStateSelect, } from "components/issues/select"; import { CycleSelect as IssueCycleSelect } from "components/cycles/select"; -import CreateUpdateStateModal from "components/project/issues/BoardView/state/create-update-state-modal"; +import { CreateUpdateStateModal } from "components/states"; import CreateUpdateCycleModal from "components/project/cycles/create-update-cycle-modal"; // ui import { Button, CustomDatePicker, CustomMenu, Input, Loader } from "components/ui"; diff --git a/apps/app/components/issues/index.ts b/apps/app/components/issues/index.ts index 5608866d4..ab62034b5 100644 --- a/apps/app/components/issues/index.ts +++ b/apps/app/components/issues/index.ts @@ -1,5 +1,12 @@ -export * from "./list-item"; +export * from "./comment"; +export * from "./sidebar-select"; +export * from "./activity"; +export * from "./delete-issue-modal"; export * from "./description-form"; -export * from "./sub-issue-list"; export * from "./form"; export * from "./modal"; +export * from "./my-issues-list-item"; +export * from "./parent-issues-list-modal"; +export * from "./sidebar"; +export * from "./sub-issues-list"; +export * from "./sub-issues-list-modal"; diff --git a/apps/app/components/issues/list-item.tsx b/apps/app/components/issues/list-item.tsx deleted file mode 100644 index 603d8d299..000000000 --- a/apps/app/components/issues/list-item.tsx +++ /dev/null @@ -1,144 +0,0 @@ -import React from "react"; - -import Link from "next/link"; -import { useRouter } from "next/router"; - -// components -import { AssigneesList } from "components/ui/avatar"; -// icons -import { CalendarDaysIcon } from "@heroicons/react/24/outline"; -// helpers -import { renderShortNumericDateFormat, findHowManyDaysLeft } from "helpers/date-time.helper"; -import { addSpaceIfCamelCase } from "helpers/string.helper"; -// types -import { IIssue, Properties } from "types"; -// constants -import { getPriorityIcon } from "constants/global"; - -type Props = { - type?: string; - issue: IIssue; - properties: Properties; - editIssue?: () => void; - handleDeleteIssue?: () => void; - removeIssue?: () => void; -}; - -export const IssueListItem: React.FC = (props) => { - // const { type, issue, properties, editIssue, handleDeleteIssue, removeIssue } = props; - const { issue, properties } = props; - // router - const router = useRouter(); - const { workspaceSlug } = router.query; - - return ( -
- -
- {properties.priority && ( -
- {getPriorityIcon(issue.priority)} -
-
Priority
-
- {issue.priority ?? "None"} -
-
-
- )} - {properties.state && ( -
- - {addSpaceIfCamelCase(issue?.state_detail.name)} -
-
State
-
{issue?.state_detail.name}
-
-
- )} - {properties.due_date && ( -
- - {issue.target_date ? renderShortNumericDateFormat(issue.target_date) : "N/A"} -
-
Due date
-
{renderShortNumericDateFormat(issue.target_date ?? "")}
-
- {issue.target_date && - (issue.target_date < new Date().toISOString() - ? `Due date has passed by ${findHowManyDaysLeft(issue.target_date)} days` - : findHowManyDaysLeft(issue.target_date) <= 3 - ? `Due date is in ${findHowManyDaysLeft(issue.target_date)} days` - : "Due date")} -
-
-
- )} - {properties.sub_issue_count && ( -
- {issue?.sub_issues_count} {issue?.sub_issues_count === 1 ? "sub-issue" : "sub-issues"} -
- )} - {properties.assignee && ( -
- -
- )} -
-
- ); -}; diff --git a/apps/app/components/issues/modal.tsx b/apps/app/components/issues/modal.tsx index 3f7555435..c7ff0c2d7 100644 --- a/apps/app/components/issues/modal.tsx +++ b/apps/app/components/issues/modal.tsx @@ -16,11 +16,7 @@ import issuesService from "services/issues.service"; import useUser from "hooks/use-user"; import useToast from "hooks/use-toast"; // components -import CreateUpdateStateModal from "components/project/issues/BoardView/state/create-update-state-modal"; -import CreateUpdateCycleModal from "components/project/cycles/create-update-cycle-modal"; import { IssueForm } from "components/issues"; -// common -import { renderDateFormat } from "helpers/date-time.helper"; // types import type { IIssue, IssueResponse } from "types"; // fetch keys @@ -54,7 +50,10 @@ export const CreateUpdateIssueModal: React.FC = ({ const [activeProject, setActiveProject] = useState(null); const router = useRouter(); - const { workspaceSlug, projectId } = router.query; + const { workspaceSlug, projectId, cycleId, moduleId } = router.query; + + if (cycleId) prePopulateData = { ...prePopulateData, cycle: cycleId as string }; + if (moduleId) prePopulateData = { ...prePopulateData, module: moduleId as string }; const { user } = useUser(); const { setToastAlert } = useToast(); @@ -176,7 +175,7 @@ export const CreateUpdateIssueModal: React.FC = ({ .then((res) => { if (isUpdatingSingleIssue) { mutate(PROJECT_ISSUES_DETAILS, (prevData) => ({ ...prevData, ...res }), false); - } else + } else { mutate( PROJECT_ISSUES_LIST(workspaceSlug as string, activeProject ?? ""), (prevData) => ({ @@ -187,8 +186,10 @@ export const CreateUpdateIssueModal: React.FC = ({ }), }) ); + } if (payload.cycle && payload.cycle !== "") addIssueToCycle(res.id, payload.cycle); + if (payload.module && payload.module !== "") addIssueToModule(res.id, payload.module); if (!createMore) handleClose(); @@ -206,15 +207,16 @@ export const CreateUpdateIssueModal: React.FC = ({ }; const handleFormSubmit = async (formData: Partial) => { - if (workspaceSlug && activeProject) { - const payload: Partial = { - ...formData, - target_date: formData.target_date ? renderDateFormat(formData.target_date ?? "") : null, - }; + if (!workspaceSlug || !activeProject) return; - if (!data) await createIssue(payload); - else await updateIssue(payload); - } + const payload: Partial = { + ...formData, + description: formData.description ? formData.description : "", + description_html: formData.description_html ? formData.description_html : "

", + }; + + if (!data) await createIssue(payload); + else await updateIssue(payload); }; return ( diff --git a/apps/app/components/issues/my-issues-list-item.tsx b/apps/app/components/issues/my-issues-list-item.tsx new file mode 100644 index 000000000..130c777af --- /dev/null +++ b/apps/app/components/issues/my-issues-list-item.tsx @@ -0,0 +1,127 @@ +import React, { useCallback } from "react"; + +import Link from "next/link"; +import { useRouter } from "next/router"; + +import { mutate } from "swr"; + +// services +import issuesService from "services/issues.service"; +// components +import { + ViewDueDateSelect, + ViewPrioritySelect, + ViewStateSelect, +} from "components/issues/view-select"; +// ui +import { AssigneesList } from "components/ui/avatar"; +import { CustomMenu } from "components/ui"; +// types +import { IIssue, Properties } from "types"; +// fetch-keys +import { USER_ISSUE } from "constants/fetch-keys"; + +type Props = { + issue: IIssue; + properties: Properties; + projectId: string; + handleDeleteIssue: () => void; +}; + +export const MyIssuesListItem: React.FC = ({ + issue, + properties, + projectId, + handleDeleteIssue, +}) => { + const router = useRouter(); + const { workspaceSlug } = router.query; + + const partialUpdateIssue = useCallback( + (formData: Partial) => { + if (!workspaceSlug) return; + + mutate( + USER_ISSUE(workspaceSlug as string), + (prevData) => + prevData?.map((p) => { + if (p.id === issue.id) return { ...p, ...formData }; + + return p; + }), + false + ); + + issuesService + .patchIssue(workspaceSlug as string, projectId as string, issue.id, formData) + .then((res) => { + mutate(USER_ISSUE(workspaceSlug as string)); + }) + .catch((error) => { + console.log(error); + }); + }, + [workspaceSlug, projectId, issue] + ); + + const isNotAllowed = false; + + return ( +
+ +
+ {properties.priority && ( + + )} + {properties.state && ( + + )} + {properties.due_date && ( + + )} + {properties.sub_issue_count && ( +
+ {issue?.sub_issues_count} {issue?.sub_issues_count === 1 ? "sub-issue" : "sub-issues"} +
+ )} + {properties.assignee && ( +
+ +
+ )} + + Delete permanently + +
+
+ ); +}; diff --git a/apps/app/components/project/issues/issues-list-modal.tsx b/apps/app/components/issues/parent-issues-list-modal.tsx similarity index 98% rename from apps/app/components/project/issues/issues-list-modal.tsx rename to apps/app/components/issues/parent-issues-list-modal.tsx index 9c56d6406..dc4de5329 100644 --- a/apps/app/components/project/issues/issues-list-modal.tsx +++ b/apps/app/components/issues/parent-issues-list-modal.tsx @@ -21,7 +21,7 @@ type Props = { customDisplay?: JSX.Element; }; -const IssuesListModal: React.FC = ({ +export const ParentIssuesListModal: React.FC = ({ isOpen, handleClose: onClose, value, @@ -212,7 +212,7 @@ const IssuesListModal: React.FC = ({

No issues found. Create a new issue with{" "} -
C
. +
C
.

)} @@ -227,5 +227,3 @@ const IssuesListModal: React.FC = ({ ); }; - -export default IssuesListModal; diff --git a/apps/app/components/issues/select/index.ts b/apps/app/components/issues/select/index.ts index de43d9b0e..4338b3162 100644 --- a/apps/app/components/issues/select/index.ts +++ b/apps/app/components/issues/select/index.ts @@ -1,6 +1,6 @@ export * from "./assignee"; export * from "./label"; -export * from "./parent-issue"; +export * from "./parent"; export * from "./priority"; export * from "./project"; export * from "./state"; diff --git a/apps/app/components/issues/select/label.tsx b/apps/app/components/issues/select/label.tsx index 42f441cba..b1b1c4338 100644 --- a/apps/app/components/issues/select/label.tsx +++ b/apps/app/components/issues/select/label.tsx @@ -72,7 +72,7 @@ export const IssueLabelSelect: React.FC = ({ value, onChange, projectId } const options = issueLabels?.map((label) => ({ value: label.id, display: label.name, - color: label.colour, + color: label.color, })); const filteredOptions = diff --git a/apps/app/components/issues/select/parent-issue.tsx b/apps/app/components/issues/select/parent.tsx similarity index 86% rename from apps/app/components/issues/select/parent-issue.tsx rename to apps/app/components/issues/select/parent.tsx index d08020d20..c04e89b92 100644 --- a/apps/app/components/issues/select/parent-issue.tsx +++ b/apps/app/components/issues/select/parent.tsx @@ -1,7 +1,7 @@ import React from "react"; import { Controller, Control } from "react-hook-form"; // components -import IssuesListModal from "components/project/issues/issues-list-modal"; +import { ParentIssuesListModal } from "components/issues"; // types import type { IIssue } from "types"; @@ -17,7 +17,7 @@ export const IssueParentSelect: React.FC = ({ control, isOpen, setIsOpen, control={control} name="parent" render={({ field: { onChange } }) => ( - setIsOpen(false)} onChange={onChange} diff --git a/apps/app/components/issues/select/priority.tsx b/apps/app/components/issues/select/priority.tsx index e85f2deac..1347e2765 100644 --- a/apps/app/components/issues/select/priority.tsx +++ b/apps/app/components/issues/select/priority.tsx @@ -2,9 +2,10 @@ import React from "react"; // headless ui import { Listbox, Transition } from "@headlessui/react"; +// icons +import { getPriorityIcon } from "components/icons/priority-icon"; // constants -import { getPriorityIcon } from "constants/global"; -import { PRIORITIES } from "constants/"; +import { PRIORITIES } from "constants/project"; type Props = { value: string | null; diff --git a/apps/app/components/project/issues/issue-detail/issue-detail-sidebar/select-assignee.tsx b/apps/app/components/issues/sidebar-select/assignee.tsx similarity index 98% rename from apps/app/components/project/issues/issue-detail/issue-detail-sidebar/select-assignee.tsx rename to apps/app/components/issues/sidebar-select/assignee.tsx index ceefa5e7a..369d03368 100644 --- a/apps/app/components/project/issues/issue-detail/issue-detail-sidebar/select-assignee.tsx +++ b/apps/app/components/issues/sidebar-select/assignee.tsx @@ -27,7 +27,7 @@ type Props = { userAuth: UserAuth; }; -const SelectAssignee: React.FC = ({ control, submitChanges, userAuth }) => { +export const SidebarAssigneeSelect: React.FC = ({ control, submitChanges, userAuth }) => { const router = useRouter(); const { workspaceSlug } = router.query; @@ -143,5 +143,3 @@ const SelectAssignee: React.FC = ({ control, submitChanges, userAuth }) = ); }; - -export default SelectAssignee; diff --git a/apps/app/components/project/issues/issue-detail/issue-detail-sidebar/select-blocked.tsx b/apps/app/components/issues/sidebar-select/blocked.tsx similarity index 97% rename from apps/app/components/project/issues/issue-detail/issue-detail-sidebar/select-blocked.tsx rename to apps/app/components/issues/sidebar-select/blocked.tsx index 0e8ec0881..38da455bd 100644 --- a/apps/app/components/project/issues/issue-detail/issue-detail-sidebar/select-blocked.tsx +++ b/apps/app/components/issues/sidebar-select/blocked.tsx @@ -16,7 +16,7 @@ import issuesService from "services/issues.service"; // ui import { Button } from "components/ui"; // icons -import { FolderIcon, MagnifyingGlassIcon, XMarkIcon } from "@heroicons/react/24/outline"; +import { MagnifyingGlassIcon, XMarkIcon } from "@heroicons/react/24/outline"; import { BlockedIcon, LayerDiagonalIcon } from "components/icons"; // types import { IIssue, UserAuth } from "types"; @@ -34,7 +34,12 @@ type Props = { userAuth: UserAuth; }; -const SelectBlocked: React.FC = ({ submitChanges, issuesList, watch, userAuth }) => { +export const SidebarBlockedSelect: React.FC = ({ + submitChanges, + issuesList, + watch, + userAuth, +}) => { const [query, setQuery] = useState(""); const [isBlockedModalOpen, setIsBlockedModalOpen] = useState(false); @@ -172,7 +177,6 @@ const SelectBlocked: React.FC = ({ submitChanges, issuesList, watch, user
{ - console.log("Triggered"); const selectedIssues = watchBlocked("blocked_issue_ids"); if (selectedIssues.includes(val)) setValue( @@ -262,7 +266,7 @@ const SelectBlocked: React.FC = ({ submitChanges, issuesList, watch, user

No issues found. Create a new issue with{" "} -
C
. +
C
.

)} @@ -301,5 +305,3 @@ const SelectBlocked: React.FC = ({ submitChanges, issuesList, watch, user ); }; - -export default SelectBlocked; diff --git a/apps/app/components/project/issues/issue-detail/issue-detail-sidebar/select-blocker.tsx b/apps/app/components/issues/sidebar-select/blocker.tsx similarity index 98% rename from apps/app/components/project/issues/issue-detail/issue-detail-sidebar/select-blocker.tsx rename to apps/app/components/issues/sidebar-select/blocker.tsx index 433f5c9fe..659728e73 100644 --- a/apps/app/components/project/issues/issue-detail/issue-detail-sidebar/select-blocker.tsx +++ b/apps/app/components/issues/sidebar-select/blocker.tsx @@ -16,7 +16,7 @@ import issuesServices from "services/issues.service"; // ui import { Button } from "components/ui"; // icons -import { FolderIcon, MagnifyingGlassIcon, XMarkIcon } from "@heroicons/react/24/outline"; +import { MagnifyingGlassIcon, XMarkIcon } from "@heroicons/react/24/outline"; import { BlockerIcon, LayerDiagonalIcon } from "components/icons"; // types import { IIssue, UserAuth } from "types"; @@ -34,7 +34,12 @@ type Props = { userAuth: UserAuth; }; -const SelectBlocker: React.FC = ({ submitChanges, issuesList, watch, userAuth }) => { +export const SidebarBlockerSelect: React.FC = ({ + submitChanges, + issuesList, + watch, + userAuth, +}) => { const [query, setQuery] = useState(""); const [isBlockerModalOpen, setIsBlockerModalOpen] = useState(false); @@ -261,7 +266,7 @@ const SelectBlocker: React.FC = ({ submitChanges, issuesList, watch, user

No issues found. Create a new issue with{" "} -
C
. +
C
.

)} @@ -299,5 +304,3 @@ const SelectBlocker: React.FC = ({ submitChanges, issuesList, watch, user ); }; - -export default SelectBlocker; diff --git a/apps/app/components/project/issues/issue-detail/issue-detail-sidebar/select-cycle.tsx b/apps/app/components/issues/sidebar-select/cycle.tsx similarity index 95% rename from apps/app/components/project/issues/issue-detail/issue-detail-sidebar/select-cycle.tsx rename to apps/app/components/issues/sidebar-select/cycle.tsx index 159f96c68..353bc5121 100644 --- a/apps/app/components/project/issues/issue-detail/issue-detail-sidebar/select-cycle.tsx +++ b/apps/app/components/issues/sidebar-select/cycle.tsx @@ -22,7 +22,11 @@ type Props = { userAuth: UserAuth; }; -const SelectCycle: React.FC = ({ issueDetail, handleCycleChange, userAuth }) => { +export const SidebarCycleSelect: React.FC = ({ + issueDetail, + handleCycleChange, + userAuth, +}) => { const router = useRouter(); const { workspaceSlug, projectId, issueId } = router.query; @@ -71,7 +75,7 @@ const SelectCycle: React.FC = ({ issueDetail, handleCycleChange, userAuth onChange={(value: any) => { value === null ? removeIssueFromCycle(issueCycle?.id ?? "", issueCycle?.cycle ?? "") - : handleCycleChange(cycles?.find((c) => c.id === value) as any); + : handleCycleChange(cycles?.find((c) => c.id === value) as ICycle); }} disabled={isNotAllowed} > @@ -98,5 +102,3 @@ const SelectCycle: React.FC = ({ issueDetail, handleCycleChange, userAuth ); }; - -export default SelectCycle; diff --git a/apps/app/components/issues/sidebar-select/index.ts b/apps/app/components/issues/sidebar-select/index.ts new file mode 100644 index 000000000..9070d2d2e --- /dev/null +++ b/apps/app/components/issues/sidebar-select/index.ts @@ -0,0 +1,8 @@ +export * from "./assignee"; +export * from "./blocked"; +export * from "./blocker"; +export * from "./cycle"; +export * from "./module"; +export * from "./parent"; +export * from "./priority"; +export * from "./state"; diff --git a/apps/app/components/issues/sidebar-select/module.tsx b/apps/app/components/issues/sidebar-select/module.tsx new file mode 100644 index 000000000..e57688887 --- /dev/null +++ b/apps/app/components/issues/sidebar-select/module.tsx @@ -0,0 +1,103 @@ +import React from "react"; + +import { useRouter } from "next/router"; + +import useSWR, { mutate } from "swr"; + +// services +import modulesService from "services/modules.service"; +// ui +import { Spinner, CustomSelect } from "components/ui"; +// icons +import { RectangleGroupIcon } from "@heroicons/react/24/outline"; +// types +import { IIssue, IModule, UserAuth } from "types"; +// fetch-keys +import { ISSUE_DETAILS, MODULE_ISSUES, MODULE_LIST } from "constants/fetch-keys"; + +type Props = { + issueDetail: IIssue | undefined; + handleModuleChange: (module: IModule) => void; + userAuth: UserAuth; +}; + +export const SidebarModuleSelect: React.FC = ({ + issueDetail, + handleModuleChange, + userAuth, +}) => { + const router = useRouter(); + const { workspaceSlug, projectId, issueId } = router.query; + + const { data: modules } = useSWR( + workspaceSlug && projectId ? MODULE_LIST(projectId as string) : null, + workspaceSlug && projectId + ? () => modulesService.getModules(workspaceSlug as string, projectId as string) + : null + ); + + const removeIssueFromModule = (bridgeId: string, moduleId: string) => { + if (!workspaceSlug || !projectId) return; + + modulesService + .removeIssueFromModule(workspaceSlug as string, projectId as string, moduleId, bridgeId) + .then((res) => { + mutate(ISSUE_DETAILS(issueId as string)); + + mutate(MODULE_ISSUES(moduleId)); + }) + .catch((e) => { + console.log(e); + }); + }; + + const issueModule = issueDetail?.issue_module; + + const isNotAllowed = userAuth.isGuest || userAuth.isViewer; + + return ( +
+
+ +

Module

+
+
+ + {modules?.find((m) => m.id === issueModule?.module)?.name ?? "None"} + + } + value={issueModule?.module_detail?.id} + onChange={(value: any) => { + value === null + ? removeIssueFromModule(issueModule?.id ?? "", issueModule?.module ?? "") + : handleModuleChange(modules?.find((m) => m.id === value) as IModule); + }} + disabled={isNotAllowed} + > + {modules ? ( + modules.length > 0 ? ( + <> + + None + + {modules.map((option) => ( + + {option.name} + + ))} + + ) : ( +
No modules found
+ ) + ) : ( + + )} +
+
+
+ ); +}; diff --git a/apps/app/components/project/issues/issue-detail/issue-detail-sidebar/select-parent.tsx b/apps/app/components/issues/sidebar-select/parent.tsx similarity index 94% rename from apps/app/components/project/issues/issue-detail/issue-detail-sidebar/select-parent.tsx rename to apps/app/components/issues/sidebar-select/parent.tsx index 7cb298324..1af86c359 100644 --- a/apps/app/components/project/issues/issue-detail/issue-detail-sidebar/select-parent.tsx +++ b/apps/app/components/issues/sidebar-select/parent.tsx @@ -10,7 +10,7 @@ import { UserIcon } from "@heroicons/react/24/outline"; // services import issuesServices from "services/issues.service"; // components -import IssuesListModal from "components/project/issues/issues-list-modal"; +import { ParentIssuesListModal } from "components/issues"; // icons // types import { IIssue, UserAuth } from "types"; @@ -26,7 +26,7 @@ type Props = { userAuth: UserAuth; }; -const SelectParent: React.FC = ({ +export const SidebarParentSelect: React.FC = ({ control, submitChanges, issuesList, @@ -61,7 +61,7 @@ const SelectParent: React.FC = ({ control={control} name="parent" render={({ field: { value, onChange } }) => ( - setIsParentModalOpen(false)} onChange={(val) => { @@ -93,5 +93,3 @@ const SelectParent: React.FC = ({ ); }; - -export default SelectParent; diff --git a/apps/app/components/project/issues/issue-detail/issue-detail-sidebar/select-priority.tsx b/apps/app/components/issues/sidebar-select/priority.tsx similarity index 86% rename from apps/app/components/project/issues/issue-detail/issue-detail-sidebar/select-priority.tsx rename to apps/app/components/issues/sidebar-select/priority.tsx index 8057b14ec..7583f16f4 100644 --- a/apps/app/components/project/issues/issue-detail/issue-detail-sidebar/select-priority.tsx +++ b/apps/app/components/issues/sidebar-select/priority.tsx @@ -1,17 +1,16 @@ -// react import React from "react"; + // react-hook-form -import { Control, Controller, UseFormWatch } from "react-hook-form"; +import { Control, Controller } from "react-hook-form"; // ui -import { ChartBarIcon } from "@heroicons/react/24/outline"; import { CustomSelect } from "components/ui"; // icons +import { ChartBarIcon } from "@heroicons/react/24/outline"; +import { getPriorityIcon } from "components/icons/priority-icon"; // types import { IIssue, UserAuth } from "types"; -// common // constants -import { getPriorityIcon } from "constants/global"; -import { PRIORITIES } from "constants/"; +import { PRIORITIES } from "constants/project"; type Props = { control: Control; @@ -19,7 +18,7 @@ type Props = { userAuth: UserAuth; }; -const SelectPriority: React.FC = ({ control, submitChanges, userAuth }) => { +export const SidebarPrioritySelect: React.FC = ({ control, submitChanges, userAuth }) => { const isNotAllowed = userAuth.isGuest || userAuth.isViewer; return ( @@ -65,5 +64,3 @@ const SelectPriority: React.FC = ({ control, submitChanges, userAuth }) = ); }; - -export default SelectPriority; diff --git a/apps/app/components/project/issues/issue-detail/issue-detail-sidebar/select-state.tsx b/apps/app/components/issues/sidebar-select/state.tsx similarity index 96% rename from apps/app/components/project/issues/issue-detail/issue-detail-sidebar/select-state.tsx rename to apps/app/components/issues/sidebar-select/state.tsx index 8906de605..bbe57cc7a 100644 --- a/apps/app/components/project/issues/issue-detail/issue-detail-sidebar/select-state.tsx +++ b/apps/app/components/issues/sidebar-select/state.tsx @@ -22,7 +22,7 @@ type Props = { userAuth: UserAuth; }; -const SelectState: React.FC = ({ control, submitChanges, userAuth }) => { +export const SidebarStateSelect: React.FC = ({ control, submitChanges, userAuth }) => { const router = useRouter(); const { workspaceSlug, projectId } = router.query; @@ -100,5 +100,3 @@ const SelectState: React.FC = ({ control, submitChanges, userAuth }) => { ); }; - -export default SelectState; diff --git a/apps/app/components/project/issues/issue-detail/issue-detail-sidebar/index.tsx b/apps/app/components/issues/sidebar.tsx similarity index 86% rename from apps/app/components/project/issues/issue-detail/issue-detail-sidebar/index.tsx rename to apps/app/components/issues/sidebar.tsx index fb18d4ebd..62a99eac1 100644 --- a/apps/app/components/project/issues/issue-detail/issue-detail-sidebar/index.tsx +++ b/apps/app/components/issues/sidebar.tsx @@ -1,4 +1,4 @@ -import React, { useState } from "react"; +import React, { useCallback, useState } from "react"; import { useRouter } from "next/router"; @@ -14,18 +14,21 @@ import { Popover, Listbox, Transition } from "@headlessui/react"; import useToast from "hooks/use-toast"; // services import issuesServices from "services/issues.service"; +import modulesService from "services/modules.service"; // components -import ConfirmIssueDeletion from "components/project/issues/confirm-issue-deletion"; -import SelectState from "components/project/issues/issue-detail/issue-detail-sidebar/select-state"; -import SelectPriority from "components/project/issues/issue-detail/issue-detail-sidebar/select-priority"; -import SelectParent from "components/project/issues/issue-detail/issue-detail-sidebar/select-parent"; -import SelectCycle from "components/project/issues/issue-detail/issue-detail-sidebar/select-cycle"; -import SelectAssignee from "components/project/issues/issue-detail/issue-detail-sidebar/select-assignee"; -import SelectBlocker from "components/project/issues/issue-detail/issue-detail-sidebar/select-blocker"; -import SelectBlocked from "components/project/issues/issue-detail/issue-detail-sidebar/select-blocked"; +import { + DeleteIssueModal, + SidebarAssigneeSelect, + SidebarBlockedSelect, + SidebarBlockerSelect, + SidebarCycleSelect, + SidebarModuleSelect, + SidebarParentSelect, + SidebarPrioritySelect, + SidebarStateSelect, +} from "components/issues"; // ui import { Input, Button, Spinner, CustomDatePicker } from "components/ui"; -import DatePicker from "react-datepicker"; // icons import { TagIcon, @@ -39,12 +42,10 @@ import { // helpers import { copyTextToClipboard } from "helpers/string.helper"; // types -import type { ICycle, IIssue, IIssueLabels, UserAuth } from "types"; +import type { ICycle, IIssue, IIssueLabels, IModule, UserAuth } from "types"; // fetch-keys import { PROJECT_ISSUE_LABELS, PROJECT_ISSUES_LIST, ISSUE_DETAILS } from "constants/fetch-keys"; -import "react-datepicker/dist/react-datepicker.css"; - type Props = { control: Control; submitChanges: (formData: Partial) => void; @@ -55,10 +56,10 @@ type Props = { const defaultValues: Partial = { name: "", - colour: "#ff0000", + color: "#ff0000", }; -const IssueDetailSidebar: React.FC = ({ +export const IssueDetailsSidebar: React.FC = ({ control, submitChanges, issueDetail, @@ -112,26 +113,44 @@ const IssueDetailSidebar: React.FC = ({ }); }; - const handleCycleChange = (cycleDetail: ICycle) => { - if (!workspaceSlug || !projectId || !issueDetail) return; + const handleCycleChange = useCallback( + (cycleDetail: ICycle) => { + if (!workspaceSlug || !projectId || !issueDetail) return; - issuesServices - .addIssueToCycle(workspaceSlug as string, projectId as string, cycleDetail.id, { - issues: [issueDetail.id], - }) - .then((res) => { - mutate(ISSUE_DETAILS(issueId as string)); - }); - }; + issuesServices + .addIssueToCycle(workspaceSlug as string, projectId as string, cycleDetail.id, { + issues: [issueDetail.id], + }) + .then((res) => { + mutate(ISSUE_DETAILS(issueId as string)); + }); + }, + [workspaceSlug, projectId, issueId, issueDetail] + ); + + const handleModuleChange = useCallback( + (moduleDetail: IModule) => { + if (!workspaceSlug || !projectId || !issueDetail) return; + + modulesService + .addIssuesToModule(workspaceSlug as string, projectId as string, moduleDetail.id, { + issues: [issueDetail.id], + }) + .then((res) => { + mutate(ISSUE_DETAILS(issueId as string)); + }); + }, + [workspaceSlug, projectId, issueId, issueDetail] + ); const isNotAllowed = userAuth.isGuest || userAuth.isViewer; return ( <> - setDeleteIssueModal(false)} isOpen={deleteIssueModal} - data={issueDetail} + data={issueDetail ?? null} />
@@ -175,12 +194,24 @@ const IssueDetailSidebar: React.FC = ({
- - - + + +
- = ({ watch={watchIssue} userAuth={userAuth} /> - i.id !== issueDetail?.id) ?? []} watch={watchIssue} userAuth={userAuth} /> - i.id !== issueDetail?.id) ?? []} watch={watchIssue} @@ -247,11 +278,16 @@ const IssueDetailSidebar: React.FC = ({
- +
@@ -280,7 +316,7 @@ const IssueDetailSidebar: React.FC = ({ > {singleLabel.name} @@ -336,7 +372,7 @@ const IssueDetailSidebar: React.FC = ({ > {label.name} @@ -386,11 +422,11 @@ const IssueDetailSidebar: React.FC = ({ - {watch("colour") && watch("colour") !== "" && ( + {watch("color") && watch("color") !== "" && ( )} @@ -408,7 +444,7 @@ const IssueDetailSidebar: React.FC = ({ > ( = ({ ); }; - -export default IssueDetailSidebar; diff --git a/apps/app/components/project/issues/issue-detail/add-as-sub-issue.tsx b/apps/app/components/issues/sub-issues-list-modal.tsx similarity index 89% rename from apps/app/components/project/issues/issue-detail/add-as-sub-issue.tsx rename to apps/app/components/issues/sub-issues-list-modal.tsx index 95eae4555..897a9d097 100644 --- a/apps/app/components/project/issues/issue-detail/add-as-sub-issue.tsx +++ b/apps/app/components/issues/sub-issues-list-modal.tsx @@ -10,6 +10,8 @@ import { Combobox, Dialog, Transition } from "@headlessui/react"; import { RectangleStackIcon, MagnifyingGlassIcon } from "@heroicons/react/24/outline"; // services import issuesServices from "services/issues.service"; +// helpers +import { orderArrayBy } from "helpers/array.helper"; // types import { IIssue, IssueResponse } from "types"; // constants @@ -17,11 +19,11 @@ import { PROJECT_ISSUES_LIST, SUB_ISSUES } from "constants/fetch-keys"; type Props = { isOpen: boolean; - setIsOpen: React.Dispatch>; + handleClose: () => void; parent: IIssue | undefined; }; -const AddAsSubIssue: React.FC = ({ isOpen, setIsOpen, parent }) => { +export const SubIssuesListModal: React.FC = ({ isOpen, handleClose, parent }) => { const [query, setQuery] = useState(""); const router = useRouter(); @@ -43,15 +45,28 @@ const AddAsSubIssue: React.FC = ({ isOpen, setIsOpen, parent }) => { []; const handleCommandPaletteClose = () => { - setIsOpen(false); + handleClose(); setQuery(""); }; - const addAsSubIssue = (issueId: string) => { + const addAsSubIssue = (issue: IIssue) => { if (!workspaceSlug || !projectId) return; + mutate( + SUB_ISSUES(parent?.id ?? ""), + (prevData) => { + let newSubIssues = [...(prevData as IIssue[])]; + newSubIssues.push(issue); + + newSubIssues = orderArrayBy(newSubIssues, "created_at", "descending"); + + return newSubIssues; + }, + false + ); + issuesServices - .patchIssue(workspaceSlug as string, projectId as string, issueId, { parent: parent?.id }) + .patchIssue(workspaceSlug as string, projectId as string, issue.id, { parent: parent?.id }) .then((res) => { mutate(SUB_ISSUES(parent?.id ?? "")); mutate( @@ -146,12 +161,12 @@ const AddAsSubIssue: React.FC = ({ isOpen, setIsOpen, parent }) => { }` } onClick={() => { - addAsSubIssue(issue.id); - setIsOpen(false); + addAsSubIssue(issue); + handleClose(); }} > = ({ isOpen, setIsOpen, parent }) => { ); }; - -export default AddAsSubIssue; diff --git a/apps/app/components/issues/sub-issue-list.tsx b/apps/app/components/issues/sub-issues-list.tsx similarity index 89% rename from apps/app/components/issues/sub-issue-list.tsx rename to apps/app/components/issues/sub-issues-list.tsx index f8944f379..a903f3b62 100644 --- a/apps/app/components/issues/sub-issue-list.tsx +++ b/apps/app/components/issues/sub-issues-list.tsx @@ -4,8 +4,7 @@ import { Disclosure, Transition } from "@headlessui/react"; import { ChevronRightIcon, PlusIcon } from "@heroicons/react/24/outline"; // components import { CustomMenu } from "components/ui"; -import { CreateUpdateIssueModal } from "components/issues"; -import AddAsSubIssue from "components/project/issues/issue-detail/add-as-sub-issue"; +import { CreateUpdateIssueModal, SubIssuesListModal } from "components/issues"; // types import { IIssue, UserAuth } from "types"; @@ -18,7 +17,7 @@ export interface SubIssueListProps { userAuth: UserAuth; } -export const SubIssueList: FC = ({ +export const SubIssuesList: FC = ({ issues = [], handleSubIssueRemove, parentIssue, @@ -28,7 +27,7 @@ export const SubIssueList: FC = ({ }) => { // states const [isIssueModalActive, setIssueModalActive] = useState(false); - const [isSubIssueModalActive, setSubIssueModalActive] = useState(false); + const [subIssuesListModal, setSubIssuesListModal] = useState(false); const [preloadedData, setPreloadedData] = useState | null>(null); const openIssueModal = () => { @@ -40,11 +39,11 @@ export const SubIssueList: FC = ({ }; const openSubIssueModal = () => { - setSubIssueModalActive(true); + setSubIssuesListModal(true); }; const closeSubIssueModal = () => { - setSubIssueModalActive(false); + setSubIssuesListModal(false); }; const isNotAllowed = userAuth.isGuest || userAuth.isViewer; @@ -56,9 +55,9 @@ export const SubIssueList: FC = ({ prePopulateData={{ ...preloadedData }} handleClose={closeIssueModal} /> - setSubIssuesListModal(false)} parent={parentIssue} /> @@ -88,7 +87,7 @@ export const SubIssueList: FC = ({ { - setSubIssueModalActive(true); + setSubIssuesListModal(true); }} > Add an existing issue @@ -114,7 +113,7 @@ export const SubIssueList: FC = ({ ) => void; + position?: "left" | "right"; + isNotAllowed: boolean; +}; + +export const ViewAssigneeSelect: React.FC = ({ + issue, + partialUpdateIssue, + position = "right", + isNotAllowed, +}) => { + const router = useRouter(); + const { workspaceSlug, projectId } = router.query; + + const { data: members } = useSWR( + projectId ? PROJECT_MEMBERS(projectId as string) : null, + workspaceSlug && projectId + ? () => projectService.projectMembers(workspaceSlug as string, projectId as string) + : null + ); + + return ( + { + const newData = issue.assignees ?? []; + + if (newData.includes(data)) newData.splice(newData.indexOf(data), 1); + else newData.push(data); + + partialUpdateIssue({ assignees_list: newData }); + }} + className="group relative flex-shrink-0" + disabled={isNotAllowed} + > + {({ open }) => ( +
+ +
+ +
+
+ + + + {members?.map((member) => ( + + `flex items-center gap-x-1 cursor-pointer select-none p-2 whitespace-nowrap ${ + active ? "bg-indigo-50" : "" + } ${ + selected || issue.assignees?.includes(member.member.id) + ? "bg-indigo-50 font-medium" + : "font-normal" + }` + } + value={member.member.id} + > + + {member.member.first_name && member.member.first_name !== "" + ? member.member.first_name + : member.member.email} + + ))} + + +
+ )} +
+ ); +}; diff --git a/apps/app/components/issues/view-select/due-date.tsx b/apps/app/components/issues/view-select/due-date.tsx new file mode 100644 index 000000000..9033e95e3 --- /dev/null +++ b/apps/app/components/issues/view-select/due-date.tsx @@ -0,0 +1,36 @@ +// ui +import { CustomDatePicker } from "components/ui"; +// helpers +import { findHowManyDaysLeft } from "helpers/date-time.helper"; +// types +import { IIssue } from "types"; + +type Props = { + issue: IIssue; + partialUpdateIssue: (formData: Partial) => void; + isNotAllowed: boolean; +}; + +export const ViewDueDateSelect: React.FC = ({ issue, partialUpdateIssue, isNotAllowed }) => ( +
+ + partialUpdateIssue({ + target_date: val, + }) + } + className={issue?.target_date ? "w-[6.5rem]" : "w-[3rem] text-center"} + disabled={isNotAllowed} + /> +
+); diff --git a/apps/app/components/issues/view-select/index.ts b/apps/app/components/issues/view-select/index.ts new file mode 100644 index 000000000..c5f427971 --- /dev/null +++ b/apps/app/components/issues/view-select/index.ts @@ -0,0 +1,4 @@ +export * from "./assignee"; +export * from "./due-date"; +export * from "./priority"; +export * from "./state"; diff --git a/apps/app/components/issues/view-select/priority.tsx b/apps/app/components/issues/view-select/priority.tsx new file mode 100644 index 000000000..5517494b2 --- /dev/null +++ b/apps/app/components/issues/view-select/priority.tsx @@ -0,0 +1,88 @@ +import React from "react"; + +// ui +import { Listbox, Transition } from "@headlessui/react"; +// icons +import { getPriorityIcon } from "components/icons/priority-icon"; +// types +import { IIssue } from "types"; +// constants +import { PRIORITIES } from "constants/project"; + +type Props = { + issue: IIssue; + partialUpdateIssue: (formData: Partial) => void; + position?: "left" | "right"; + isNotAllowed: boolean; +}; + +export const ViewPrioritySelect: React.FC = ({ + issue, + partialUpdateIssue, + position = "right", + isNotAllowed, +}) => ( + { + partialUpdateIssue({ priority: data }); + }} + className="group relative flex-shrink-0" + disabled={isNotAllowed} + > + {({ open }) => ( +
+ + {getPriorityIcon( + issue.priority && issue.priority !== "" ? issue.priority ?? "" : "None", + "text-sm" + )} + + + + + {PRIORITIES?.map((priority) => ( + + `flex cursor-pointer select-none items-center gap-x-2 px-3 py-2 capitalize ${ + active ? "bg-indigo-50" : "bg-white" + }` + } + value={priority} + > + {getPriorityIcon(priority, "text-sm")} + {priority ?? "None"} + + ))} + + +
+ )} +
+); diff --git a/apps/app/components/issues/view-select/state.tsx b/apps/app/components/issues/view-select/state.tsx new file mode 100644 index 000000000..ce54d8293 --- /dev/null +++ b/apps/app/components/issues/view-select/state.tsx @@ -0,0 +1,75 @@ +import { useRouter } from "next/router"; + +import useSWR from "swr"; + +// services +import stateService from "services/state.service"; +// ui +import { CustomSelect } from "components/ui"; +// helpers +import { addSpaceIfCamelCase } from "helpers/string.helper"; +// types +import { IIssue, IState } from "types"; +// fetch-keys +import { STATE_LIST } from "constants/fetch-keys"; + +type Props = { + issue: IIssue; + partialUpdateIssue: (formData: Partial) => void; + position?: "left" | "right"; + isNotAllowed: boolean; +}; + +export const ViewStateSelect: React.FC = ({ + issue, + partialUpdateIssue, + position, + isNotAllowed, +}) => { + const router = useRouter(); + const { workspaceSlug, projectId } = router.query; + + const { data: states } = useSWR( + workspaceSlug && projectId ? STATE_LIST(projectId as string) : null, + workspaceSlug + ? () => stateService.getStates(workspaceSlug as string, projectId as string) + : null + ); + + return ( + + s.id === issue.state)?.color, + }} + /> + {addSpaceIfCamelCase(states?.find((s) => s.id === issue.state)?.name ?? "")} + + } + value={issue.state} + onChange={(data: string) => { + partialUpdateIssue({ state: data }); + }} + maxHeight="md" + noChevron + disabled={isNotAllowed} + > + {states?.map((state) => ( + + <> + + {addSpaceIfCamelCase(state.name)} + + + ))} + + ); +}; diff --git a/apps/app/components/project/modules/confirm-module-deletion.tsx b/apps/app/components/modules/delete-module-modal.tsx similarity index 97% rename from apps/app/components/project/modules/confirm-module-deletion.tsx rename to apps/app/components/modules/delete-module-modal.tsx index df3f32fea..317f49a68 100644 --- a/apps/app/components/project/modules/confirm-module-deletion.tsx +++ b/apps/app/components/modules/delete-module-modal.tsx @@ -25,7 +25,7 @@ type Props = { data?: IModule; }; -const ConfirmModuleDeletion: React.FC = ({ isOpen, setIsOpen, data }) => { +export const DeleteModuleModal: React.FC = ({ isOpen, setIsOpen, data }) => { const [isDeleteLoading, setIsDeleteLoading] = useState(false); const router = useRouter(); @@ -152,5 +152,3 @@ const ConfirmModuleDeletion: React.FC = ({ isOpen, setIsOpen, data }) => ); }; - -export default ConfirmModuleDeletion; diff --git a/apps/app/components/modules/form.tsx b/apps/app/components/modules/form.tsx new file mode 100644 index 000000000..60fd93059 --- /dev/null +++ b/apps/app/components/modules/form.tsx @@ -0,0 +1,128 @@ +// react-hook-form +import { Controller, useForm } from "react-hook-form"; +// components +import { ModuleLeadSelect, ModuleMembersSelect, ModuleStatusSelect } from "components/modules"; +// ui +import { Button, CustomDatePicker, Input, TextArea } from "components/ui"; +// types +import { IModule } from "types"; + +type Props = { + handleFormSubmit: (values: Partial) => void; + handleClose: () => void; + status: boolean; +}; + +const defaultValues: Partial = { + name: "", + description: "", + status: null, + lead: null, + members_list: [], +}; + +export const ModuleForm: React.FC = ({ handleFormSubmit, handleClose, status }) => { + const { + register, + formState: { errors, isSubmitting }, + handleSubmit, + control, + reset, + } = useForm({ + defaultValues, + }); + + const handleCreateUpdateModule = async (formData: Partial) => { + await handleFormSubmit(formData); + + reset({ + ...defaultValues, + }); + }; + + return ( + +
+

+ {status ? "Update" : "Create"} Module +

+
+
+ +
+
+