release: Stage Release (#251)

* feat: manual ordering for issues in kanban

* refactor: issues folder structure

* refactor: modules and states folder structure

* refactor: datepicker code

* fix: create issue modal bug

* feat: custom progress bar added

* refactor: created global component for kanban board

* refactor: update cycle and module issue create

* refactor: return modules created

* refactor: integrated global kanban view everywhere

* refactor: integrated global list view everywhere

* refactor: removed unnecessary api calls

* refactor: update nomenclature for consistency

* refactor: global select component for issue view

* refactor: track cycles and modules for issue

* fix: tracking new cycles and modules in activities

* feat: segregate api token workspace

* fix: workpsace id during token creation

* refactor: update model association to cascade on delete

* feat: sentry integrated (#235)

* feat: sentry integrated

* fix: removed unnecessary env variable

* fix: update remirror description to save empty string and empty paragraph (#237)

* Update README.md

* fix: description and comment_json default value to remove warnings

* feat: link option in remirror (#240)

* feat: link option in remirror

* fix: removed link import from remirror toolbar

* feat: module and cycle settings under project

* fix:  module issue assignment

* fix: module issue updation and activity logging

* fix: typo while creating module issues

* fix: string comparison for update operation

* fix: ui fixes (#246)

* style: shortcut command label bg color change

* sidebar shortcut ui fix

---------

Co-authored-by: Anmol Singh Bhatia <anmolsinghbhatia1001@gmail.com>

* fix: update empty passwords to hashed string and add hashing for magic sign in

* refactor: remove print logs from back migrations

* build(deps): bump django in /apiserver/requirements

Bumps [django](https://github.com/django/django) from 3.2.16 to 3.2.17.
- [Release notes](https://github.com/django/django/releases)
- [Commits](https://github.com/django/django/compare/3.2.16...3.2.17)

---
updated-dependencies:
- dependency-name: django
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>

* feat: cycles and modules toggle in settings, refactor: folder structure (#247)

* feat: link option in remirror

* fix: removed link import from remirror toolbar

* refactor: constants folder

* refactor: layouts folder structure

* fix: issue view context

* feat: cycles and modules toggle in settings

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: pablohashescobar <nikhilschacko@gmail.com>
Co-authored-by: Aaryan Khandelwal <aaryankhandu123@gmail.com>
Co-authored-by: Anmol Singh Bhatia <anmolsinghbhatia1001@gmail.com>
Co-authored-by: Aaryan Khandelwal <65252264+aaryan610@users.noreply.github.com>
Co-authored-by: pablohashescobar <118773738+pablohashescobar@users.noreply.github.com>
Co-authored-by: sphynxux <122926002+sphynxux@users.noreply.github.com>
Co-authored-by: Anmol Singh Bhatia <121005188+anmolsinghbhatia@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
This commit is contained in:
sriram veeraghanta 2023-02-08 10:15:18 +05:30 committed by GitHub
parent 6966666bf5
commit d3b73dc32f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
177 changed files with 4767 additions and 5404 deletions

3
.gitignore vendored
View File

@ -62,3 +62,6 @@ yarn-error.log
*.sln *.sln
package-lock.json package-lock.json
.vscode .vscode
# Sentry
.sentryclirc

View File

@ -7,7 +7,7 @@
</p> </p>
<p align="center"> <p align="center">
<a href="https://discord.com/invite/29tPNhaV"> <a href="https://discord.com/invite/A92xrEGCge">
<img alt="Discord" src="https://img.shields.io/discord/1031547764020084846?color=5865F2&label=Discord&style=for-the-badge" /> <img alt="Discord" src="https://img.shields.io/discord/1031547764020084846?color=5865F2&label=Discord&style=for-the-badge" />
</a> </a>
<img alt="Discord" src="https://img.shields.io/github/commit-activity/m/makeplane/plane?style=for-the-badge" /> <img alt="Discord" src="https://img.shields.io/github/commit-activity/m/makeplane/plane?style=for-the-badge" />

View File

@ -1,11 +1,13 @@
# All the python scripts that are used for back migrations # All the python scripts that are used for back migrations
import uuid
from plane.db.models import ProjectIdentifier 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 # Update description and description html values for old descriptions
def update_description(): def update_description():
try: try:
issues = Issue.objects.all() issues = Issue.objects.all()
updated_issues = [] updated_issues = []
@ -25,7 +27,6 @@ def update_description():
def update_comments(): def update_comments():
try: try:
issue_comments = IssueComment.objects.all() issue_comments = IssueComment.objects.all()
updated_issue_comments = [] updated_issue_comments = []
@ -44,7 +45,9 @@ def update_comments():
def update_project_identifiers(): def update_project_identifiers():
try: 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 = [] updated_identifiers = []
for identifier in project_identifiers: for identifier in project_identifiers:
@ -58,3 +61,21 @@ def update_project_identifiers():
except Exception as e: except Exception as e:
print(e) print(e)
print("Failed") 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")

View File

@ -40,12 +40,12 @@ class IssueFlatSerializer(BaseSerializer):
"start_date", "start_date",
"target_date", "target_date",
"sequence_id", "sequence_id",
"sort_order",
] ]
# Issue Serializer with state details # Issue Serializer with state details
class IssueStateSerializer(BaseSerializer): class IssueStateSerializer(BaseSerializer):
state_detail = StateSerializer(read_only=True, source="state") state_detail = StateSerializer(read_only=True, source="state")
project_detail = ProjectSerializer(read_only=True, source="project") project_detail = ProjectSerializer(read_only=True, source="project")
@ -57,7 +57,6 @@ class IssueStateSerializer(BaseSerializer):
##TODO: Find a better way to write this serializer ##TODO: Find a better way to write this serializer
## Find a better approach to save manytomany? ## Find a better approach to save manytomany?
class IssueCreateSerializer(BaseSerializer): class IssueCreateSerializer(BaseSerializer):
state_detail = StateSerializer(read_only=True, source="state") state_detail = StateSerializer(read_only=True, source="state")
created_by_detail = UserLiteSerializer(read_only=True, source="created_by") created_by_detail = UserLiteSerializer(read_only=True, source="created_by")
project_detail = ProjectSerializer(read_only=True, source="project") project_detail = ProjectSerializer(read_only=True, source="project")
@ -176,7 +175,6 @@ class IssueCreateSerializer(BaseSerializer):
return issue return issue
def update(self, instance, validated_data): def update(self, instance, validated_data):
blockers = validated_data.pop("blockers_list", None) blockers = validated_data.pop("blockers_list", None)
assignees = validated_data.pop("assignees_list", None) assignees = validated_data.pop("assignees_list", None)
labels = validated_data.pop("labels_list", None) labels = validated_data.pop("labels_list", None)
@ -254,7 +252,6 @@ class IssueCreateSerializer(BaseSerializer):
class IssueActivitySerializer(BaseSerializer): class IssueActivitySerializer(BaseSerializer):
actor_detail = UserLiteSerializer(read_only=True, source="actor") actor_detail = UserLiteSerializer(read_only=True, source="actor")
class Meta: class Meta:
@ -263,7 +260,6 @@ class IssueActivitySerializer(BaseSerializer):
class IssueCommentSerializer(BaseSerializer): class IssueCommentSerializer(BaseSerializer):
actor_detail = UserLiteSerializer(read_only=True, source="actor") actor_detail = UserLiteSerializer(read_only=True, source="actor")
issue_detail = IssueFlatSerializer(read_only=True, source="issue") issue_detail = IssueFlatSerializer(read_only=True, source="issue")
project_detail = ProjectSerializer(read_only=True, source="project") project_detail = ProjectSerializer(read_only=True, source="project")
@ -319,7 +315,6 @@ class LabelSerializer(BaseSerializer):
class IssueLabelSerializer(BaseSerializer): class IssueLabelSerializer(BaseSerializer):
# label_details = LabelSerializer(read_only=True, source="label") # label_details = LabelSerializer(read_only=True, source="label")
class Meta: class Meta:
@ -332,7 +327,6 @@ class IssueLabelSerializer(BaseSerializer):
class BlockedIssueSerializer(BaseSerializer): class BlockedIssueSerializer(BaseSerializer):
blocked_issue_detail = IssueFlatSerializer(source="block", read_only=True) blocked_issue_detail = IssueFlatSerializer(source="block", read_only=True)
class Meta: class Meta:
@ -341,7 +335,6 @@ class BlockedIssueSerializer(BaseSerializer):
class BlockerIssueSerializer(BaseSerializer): class BlockerIssueSerializer(BaseSerializer):
blocker_issue_detail = IssueFlatSerializer(source="blocked_by", read_only=True) blocker_issue_detail = IssueFlatSerializer(source="blocked_by", read_only=True)
class Meta: class Meta:
@ -350,7 +343,6 @@ class BlockerIssueSerializer(BaseSerializer):
class IssueAssigneeSerializer(BaseSerializer): class IssueAssigneeSerializer(BaseSerializer):
assignee_details = UserLiteSerializer(read_only=True, source="assignee") assignee_details = UserLiteSerializer(read_only=True, source="assignee")
class Meta: class Meta:
@ -373,7 +365,6 @@ class CycleBaseSerializer(BaseSerializer):
class IssueCycleDetailSerializer(BaseSerializer): class IssueCycleDetailSerializer(BaseSerializer):
cycle_detail = CycleBaseSerializer(read_only=True, source="cycle") cycle_detail = CycleBaseSerializer(read_only=True, source="cycle")
class Meta: class Meta:
@ -404,7 +395,6 @@ class ModuleBaseSerializer(BaseSerializer):
class IssueModuleDetailSerializer(BaseSerializer): class IssueModuleDetailSerializer(BaseSerializer):
module_detail = ModuleBaseSerializer(read_only=True, source="module") module_detail = ModuleBaseSerializer(read_only=True, source="module")
class Meta: class Meta:

View File

@ -15,12 +15,16 @@ from plane.api.serializers import APITokenSerializer
class ApiTokenEndpoint(BaseAPIView): class ApiTokenEndpoint(BaseAPIView):
def post(self, request): def post(self, request):
try: try:
label = request.data.get("label", str(uuid4().hex)) 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( api_token = APIToken.objects.create(
label=label, label=label, user=request.user, workspace_id=workspace
user=request.user,
) )
serializer = APITokenSerializer(api_token) serializer = APITokenSerializer(api_token)

View File

@ -9,6 +9,7 @@ from django.utils import timezone
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.validators import validate_email from django.core.validators import validate_email
from django.conf import settings from django.conf import settings
from django.contrib.auth.hashers import make_password
# Third party imports # Third party imports
from rest_framework.response import Response from rest_framework.response import Response
@ -35,12 +36,10 @@ def get_tokens_for_user(user):
class SignUpEndpoint(BaseAPIView): class SignUpEndpoint(BaseAPIView):
permission_classes = (AllowAny,) permission_classes = (AllowAny,)
def post(self, request): def post(self, request):
try: try:
email = request.data.get("email", False) email = request.data.get("email", False)
password = request.data.get("password", False) password = request.data.get("password", False)
@ -216,14 +215,12 @@ class SignOutEndpoint(BaseAPIView):
class MagicSignInGenerateEndpoint(BaseAPIView): class MagicSignInGenerateEndpoint(BaseAPIView):
permission_classes = [ permission_classes = [
AllowAny, AllowAny,
] ]
def post(self, request): def post(self, request):
try: try:
email = request.data.get("email", False) email = request.data.get("email", False)
if not email: if not email:
@ -269,7 +266,6 @@ class MagicSignInGenerateEndpoint(BaseAPIView):
ri.set(key, json.dumps(value), ex=expiry) ri.set(key, json.dumps(value), ex=expiry)
else: else:
value = {"current_attempt": 0, "email": email, "token": token} value = {"current_attempt": 0, "email": email, "token": token}
expiry = 600 expiry = 600
@ -293,14 +289,12 @@ class MagicSignInGenerateEndpoint(BaseAPIView):
class MagicSignInEndpoint(BaseAPIView): class MagicSignInEndpoint(BaseAPIView):
permission_classes = [ permission_classes = [
AllowAny, AllowAny,
] ]
def post(self, request): def post(self, request):
try: try:
user_token = request.data.get("token", "").strip().lower() user_token = request.data.get("token", "").strip().lower()
key = request.data.get("key", False) key = request.data.get("key", False)
@ -313,19 +307,20 @@ class MagicSignInEndpoint(BaseAPIView):
ri = redis_instance() ri = redis_instance()
if ri.exists(key): if ri.exists(key):
data = json.loads(ri.get(key)) data = json.loads(ri.get(key))
token = data["token"] token = data["token"]
email = data["email"] email = data["email"]
if str(token) == str(user_token): if str(token) == str(user_token):
if User.objects.filter(email=email).exists(): if User.objects.filter(email=email).exists():
user = User.objects.get(email=email) user = User.objects.get(email=email)
else: else:
user = User.objects.create( 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() user.last_active = timezone.now()

View File

@ -1,5 +1,9 @@
# Python imports
import json
# Django imports # Django imports
from django.db.models import OuterRef, Func, F from django.db.models import OuterRef, Func, F
from django.core import serializers
# Third party imports # Third party imports
from rest_framework.response import Response from rest_framework.response import Response
@ -11,10 +15,10 @@ from . import BaseViewSet
from plane.api.serializers import CycleSerializer, CycleIssueSerializer from plane.api.serializers import CycleSerializer, CycleIssueSerializer
from plane.api.permissions import ProjectEntityPermission from plane.api.permissions import ProjectEntityPermission
from plane.db.models import Cycle, CycleIssue, Issue from plane.db.models import Cycle, CycleIssue, Issue
from plane.bgtasks.issue_activites_task import issue_activity
class CycleViewSet(BaseViewSet): class CycleViewSet(BaseViewSet):
serializer_class = CycleSerializer serializer_class = CycleSerializer
model = Cycle model = Cycle
permission_classes = [ permission_classes = [
@ -41,7 +45,6 @@ class CycleViewSet(BaseViewSet):
class CycleIssueViewSet(BaseViewSet): class CycleIssueViewSet(BaseViewSet):
serializer_class = CycleIssueSerializer serializer_class = CycleIssueSerializer
model = CycleIssue model = CycleIssue
@ -79,7 +82,6 @@ class CycleIssueViewSet(BaseViewSet):
def create(self, request, slug, project_id, cycle_id): def create(self, request, slug, project_id, cycle_id):
try: try:
issues = request.data.get("issues", []) issues = request.data.get("issues", [])
if not len(issues): if not len(issues):
@ -91,29 +93,77 @@ class CycleIssueViewSet(BaseViewSet):
workspace__slug=slug, project_id=project_id, pk=cycle_id workspace__slug=slug, project_id=project_id, pk=cycle_id
) )
issues = Issue.objects.filter( # Get all CycleIssues already created
pk__in=issues, workspace__slug=slug, project_id=project_id 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 for issue in issues:
CycleIssue.objects.filter(issue_id__in=issues).delete() 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.objects.bulk_create(
[ record_to_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
],
batch_size=10, batch_size=10,
ignore_conflicts=True, 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: except Cycle.DoesNotExist:
return Response( return Response(

View File

@ -1,6 +1,10 @@
# Python imports
import json
# Django Imports # Django Imports
from django.db import IntegrityError from django.db import IntegrityError
from django.db.models import Prefetch, F, OuterRef, Func from django.db.models import Prefetch, F, OuterRef, Func
from django.core import serializers
# Third party imports # Third party imports
from rest_framework.response import Response from rest_framework.response import Response
@ -22,10 +26,10 @@ from plane.db.models import (
Issue, Issue,
ModuleLink, ModuleLink,
) )
from plane.bgtasks.issue_activites_task import issue_activity
class ModuleViewSet(BaseViewSet): class ModuleViewSet(BaseViewSet):
model = Module model = Module
permission_classes = [ permission_classes = [
ProjectEntityPermission, ProjectEntityPermission,
@ -95,7 +99,6 @@ class ModuleViewSet(BaseViewSet):
class ModuleIssueViewSet(BaseViewSet): class ModuleIssueViewSet(BaseViewSet):
serializer_class = ModuleIssueSerializer serializer_class = ModuleIssueSerializer
model = ModuleIssue model = ModuleIssue
@ -148,29 +151,77 @@ class ModuleIssueViewSet(BaseViewSet):
workspace__slug=slug, project_id=project_id, pk=module_id workspace__slug=slug, project_id=project_id, pk=module_id
) )
issues = Issue.objects.filter( module_issues = list(ModuleIssue.objects.filter(issue_id__in=issues))
pk__in=issues, workspace__slug=slug, project_id=project_id
)
# Delete old records in order to maintain the database integrity update_module_issue_activity = []
ModuleIssue.objects.filter(issue_id__in=issues).delete() 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.objects.bulk_create(
[ record_to_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
],
batch_size=10, batch_size=10,
ignore_conflicts=True, 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: except Module.DoesNotExist:
return Response( return Response(
{"error": "Module Does not exists"}, status=status.HTTP_400_BAD_REQUEST {"error": "Module Does not exists"}, status=status.HTTP_400_BAD_REQUEST

View File

@ -6,7 +6,16 @@ from django_rq import job
from sentry_sdk import capture_exception from sentry_sdk import capture_exception
# Module imports # 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 # Track Chnages in name
@ -44,7 +53,6 @@ def track_parent(
issue_activities, issue_activities,
): ):
if current_instance.get("parent") != requested_data.get("parent"): if current_instance.get("parent") != requested_data.get("parent"):
if requested_data.get("parent") == None: if requested_data.get("parent") == None:
old_parent = Issue.objects.get(pk=current_instance.get("parent")) old_parent = Issue.objects.get(pk=current_instance.get("parent"))
issue_activities.append( issue_activities.append(
@ -134,7 +142,6 @@ def track_state(
issue_activities, issue_activities,
): ):
if current_instance.get("state") != requested_data.get("state"): if current_instance.get("state") != requested_data.get("state"):
new_state = State.objects.get(pk=requested_data.get("state", None)) new_state = State.objects.get(pk=requested_data.get("state", None))
old_state = State.objects.get(pk=current_instance.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( if current_instance.get("description_html") != requested_data.get(
"description_html" "description_html"
): ):
issue_activities.append( issue_activities.append(
IssueActivity( IssueActivity(
issue_id=issue_id, issue_id=issue_id,
@ -274,7 +280,6 @@ def track_labels(
): ):
# Label Addition # Label Addition
if len(requested_data.get("labels_list")) > len(current_instance.get("labels")): if len(requested_data.get("labels_list")) > len(current_instance.get("labels")):
for label in requested_data.get("labels_list"): for label in requested_data.get("labels_list"):
if label not in current_instance.get("labels"): if label not in current_instance.get("labels"):
label = Label.objects.get(pk=label) label = Label.objects.get(pk=label)
@ -296,7 +301,6 @@ def track_labels(
# Label Removal # Label Removal
if len(requested_data.get("labels_list")) < len(current_instance.get("labels")): if len(requested_data.get("labels_list")) < len(current_instance.get("labels")):
for label in current_instance.get("labels"): for label in current_instance.get("labels"):
if label not in requested_data.get("labels_list"): if label not in requested_data.get("labels_list"):
label = Label.objects.get(pk=label) label = Label.objects.get(pk=label)
@ -326,12 +330,10 @@ def track_assignees(
actor, actor,
issue_activities, issue_activities,
): ):
# Assignee Addition # Assignee Addition
if len(requested_data.get("assignees_list")) > len( if len(requested_data.get("assignees_list")) > len(
current_instance.get("assignees") current_instance.get("assignees")
): ):
for assignee in requested_data.get("assignees_list"): for assignee in requested_data.get("assignees_list"):
if assignee not in current_instance.get("assignees"): if assignee not in current_instance.get("assignees"):
assignee = User.objects.get(pk=assignee) assignee = User.objects.get(pk=assignee)
@ -354,7 +356,6 @@ def track_assignees(
if len(requested_data.get("assignees_list")) < len( if len(requested_data.get("assignees_list")) < len(
current_instance.get("assignees") current_instance.get("assignees")
): ):
for assignee in current_instance.get("assignees"): for assignee in current_instance.get("assignees"):
if assignee not in requested_data.get("assignees_list"): if assignee not in requested_data.get("assignees_list"):
assignee = User.objects.get(pk=assignee) assignee = User.objects.get(pk=assignee)
@ -386,7 +387,6 @@ def track_blocks(
if len(requested_data.get("blocks_list")) > len( if len(requested_data.get("blocks_list")) > len(
current_instance.get("blocked_issues") current_instance.get("blocked_issues")
): ):
for block in requested_data.get("blocks_list"): for block in requested_data.get("blocks_list"):
if ( if (
len( len(
@ -418,7 +418,6 @@ def track_blocks(
if len(requested_data.get("blocks_list")) < len( if len(requested_data.get("blocks_list")) < len(
current_instance.get("blocked_issues") current_instance.get("blocked_issues")
): ):
for blocked in current_instance.get("blocked_issues"): for blocked in current_instance.get("blocked_issues"):
if blocked.get("block") not in requested_data.get("blocks_list"): if blocked.get("block") not in requested_data.get("blocks_list"):
issue = Issue.objects.get(pk=blocked.get("block")) issue = Issue.objects.get(pk=blocked.get("block"))
@ -450,7 +449,6 @@ def track_blockings(
if len(requested_data.get("blockers_list")) > len( if len(requested_data.get("blockers_list")) > len(
current_instance.get("blocker_issues") current_instance.get("blocker_issues")
): ):
for block in requested_data.get("blockers_list"): for block in requested_data.get("blockers_list"):
if ( if (
len( len(
@ -482,7 +480,6 @@ def track_blockings(
if len(requested_data.get("blockers_list")) < len( if len(requested_data.get("blockers_list")) < len(
current_instance.get("blocker_issues") current_instance.get("blocker_issues")
): ):
for blocked in 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"): if blocked.get("blocked_by") not in requested_data.get("blockers_list"):
issue = Issue.objects.get(pk=blocked.get("blocked_by")) 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 # Receive message from room group
@job("default") @job("default")
def issue_activity(event): def issue_activity(event):
@ -510,7 +620,7 @@ def issue_activity(event):
requested_data = json.loads(event.get("requested_data")) requested_data = json.loads(event.get("requested_data"))
current_instance = json.loads(event.get("current_instance")) 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") actor_id = event.get("actor_id")
project_id = event.get("project_id") project_id = event.get("project_id")
@ -530,6 +640,8 @@ def issue_activity(event):
"assignees_list": track_assignees, "assignees_list": track_assignees,
"blocks_list": track_blocks, "blocks_list": track_blocks,
"blockers_list": track_blockings, "blockers_list": track_blockings,
"cycles_list": track_cycles,
"modules_list": track_modules,
} }
for key in requested_data: for key in requested_data:

View File

@ -17,7 +17,6 @@ def generate_token():
class APIToken(BaseModel): class APIToken(BaseModel):
token = models.CharField(max_length=255, unique=True, default=generate_token) token = models.CharField(max_length=255, unique=True, default=generate_token)
label = models.CharField(max_length=255, default=generate_label_token) label = models.CharField(max_length=255, default=generate_label_token)
user = models.ForeignKey( user = models.ForeignKey(
@ -28,6 +27,9 @@ class APIToken(BaseModel):
user_type = models.PositiveSmallIntegerField( user_type = models.PositiveSmallIntegerField(
choices=((0, "Human"), (1, "Bot")), default=0 choices=((0, "Human"), (1, "Bot")), default=0
) )
workspace = models.ForeignKey(
"db.Workspace", related_name="api_tokens", on_delete=models.CASCADE, null=True
)
class Meta: class Meta:
verbose_name = "API Token" verbose_name = "API Token"

View File

@ -9,6 +9,7 @@ from django.dispatch import receiver
from . import ProjectBaseModel from . import ProjectBaseModel
from plane.utils.html_processor import strip_tags from plane.utils.html_processor import strip_tags
# TODO: Handle identifiers for Bulk Inserts - nk # TODO: Handle identifiers for Bulk Inserts - nk
class Issue(ProjectBaseModel): class Issue(ProjectBaseModel):
PRIORITY_CHOICES = ( PRIORITY_CHOICES = (
@ -32,8 +33,8 @@ class Issue(ProjectBaseModel):
related_name="state_issue", related_name="state_issue",
) )
name = models.CharField(max_length=255, verbose_name="Issue Name") name = models.CharField(max_length=255, verbose_name="Issue Name")
description = models.JSONField(blank=True, null=True) description = models.JSONField(blank=True, default=dict)
description_html = models.TextField(blank=True, null=True) description_html = models.TextField(blank=True, default="<p></p>")
description_stripped = models.TextField(blank=True, null=True) description_stripped = models.TextField(blank=True, null=True)
priority = models.CharField( priority = models.CharField(
max_length=30, max_length=30,
@ -56,6 +57,7 @@ class Issue(ProjectBaseModel):
labels = models.ManyToManyField( labels = models.ManyToManyField(
"db.Label", blank=True, related_name="labels", through="IssueLabel" "db.Label", blank=True, related_name="labels", through="IssueLabel"
) )
sort_order = models.FloatField(default=65535)
class Meta: class Meta:
verbose_name = "Issue" verbose_name = "Issue"
@ -196,8 +198,8 @@ class TimelineIssue(ProjectBaseModel):
class IssueComment(ProjectBaseModel): class IssueComment(ProjectBaseModel):
comment_stripped = models.TextField(verbose_name="Comment", blank=True) comment_stripped = models.TextField(verbose_name="Comment", blank=True)
comment_json = models.JSONField(blank=True, null=True) comment_json = models.JSONField(blank=True, default=dict)
comment_html = models.TextField(blank=True) comment_html = models.TextField(blank=True, default="<p></p>")
attachments = ArrayField(models.URLField(), size=10, blank=True, default=list) attachments = ArrayField(models.URLField(), size=10, blank=True, default=list)
issue = models.ForeignKey(Issue, on_delete=models.CASCADE) issue = models.ForeignKey(Issue, on_delete=models.CASCADE)
# System can also create comment # System can also create comment
@ -246,7 +248,6 @@ class IssueProperty(ProjectBaseModel):
class Label(ProjectBaseModel): class Label(ProjectBaseModel):
parent = models.ForeignKey( parent = models.ForeignKey(
"self", "self",
on_delete=models.CASCADE, on_delete=models.CASCADE,
@ -256,7 +257,7 @@ class Label(ProjectBaseModel):
) )
name = models.CharField(max_length=255) name = models.CharField(max_length=255)
description = models.TextField(blank=True) description = models.TextField(blank=True)
colour = models.CharField(max_length=255, blank=True) color = models.CharField(max_length=255, blank=True)
class Meta: class Meta:
verbose_name = "Label" verbose_name = "Label"
@ -269,7 +270,6 @@ class Label(ProjectBaseModel):
class IssueLabel(ProjectBaseModel): class IssueLabel(ProjectBaseModel):
issue = models.ForeignKey( issue = models.ForeignKey(
"db.Issue", on_delete=models.CASCADE, related_name="label_issue" "db.Issue", on_delete=models.CASCADE, related_name="label_issue"
) )
@ -288,7 +288,6 @@ class IssueLabel(ProjectBaseModel):
class IssueSequence(ProjectBaseModel): class IssueSequence(ProjectBaseModel):
issue = models.ForeignKey( issue = models.ForeignKey(
Issue, on_delete=models.SET_NULL, related_name="issue_sequence", null=True 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 # TODO: Find a better method to save the model
@receiver(post_save, sender=Issue) @receiver(post_save, sender=Issue)
def create_issue_sequence(sender, instance, created, **kwargs): def create_issue_sequence(sender, instance, created, **kwargs):
if created: if created:
IssueSequence.objects.create( IssueSequence.objects.create(
issue=instance, sequence=instance.sequence_id, project=instance.project issue=instance, sequence=instance.sequence_id, project=instance.project

View File

@ -29,7 +29,6 @@ def get_default_props():
class Project(BaseModel): class Project(BaseModel):
NETWORK_CHOICES = ((0, "Secret"), (2, "Public")) NETWORK_CHOICES = ((0, "Secret"), (2, "Public"))
name = models.CharField(max_length=255, verbose_name="Project Name") name = models.CharField(max_length=255, verbose_name="Project Name")
description = models.TextField(verbose_name="Project Description", blank=True) description = models.TextField(verbose_name="Project Description", blank=True)
@ -63,6 +62,8 @@ class Project(BaseModel):
blank=True, blank=True,
) )
icon = models.CharField(max_length=255, null=True, 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): def __str__(self):
"""Return name of the project""" """Return name of the project"""
@ -82,7 +83,6 @@ class Project(BaseModel):
class ProjectBaseModel(BaseModel): class ProjectBaseModel(BaseModel):
project = models.ForeignKey( project = models.ForeignKey(
Project, on_delete=models.CASCADE, related_name="project_%(class)s" Project, on_delete=models.CASCADE, related_name="project_%(class)s"
) )
@ -117,7 +117,6 @@ class ProjectMemberInvite(ProjectBaseModel):
class ProjectMember(ProjectBaseModel): class ProjectMember(ProjectBaseModel):
member = models.ForeignKey( member = models.ForeignKey(
settings.AUTH_USER_MODEL, settings.AUTH_USER_MODEL,
on_delete=models.CASCADE, on_delete=models.CASCADE,
@ -141,9 +140,9 @@ class ProjectMember(ProjectBaseModel):
"""Return members of the project""" """Return members of the project"""
return f"{self.member.email} <{self.project.name}>" return f"{self.member.email} <{self.project.name}>"
# TODO: Remove workspace relation later # TODO: Remove workspace relation later
class ProjectIdentifier(AuditModel): class ProjectIdentifier(AuditModel):
workspace = models.ForeignKey( workspace = models.ForeignKey(
"db.Workspace", models.CASCADE, related_name="project_identifiers", null=True "db.Workspace", models.CASCADE, related_name="project_identifiers", null=True
) )

View File

@ -1,6 +1,6 @@
# base requirements # base requirements
Django==3.2.16 Django==3.2.17
django-braces==1.15.0 django-braces==1.15.0
django-taggit==2.1.0 django-taggit==2.1.0
psycopg2==2.9.3 psycopg2==2.9.3

View File

@ -35,7 +35,6 @@ export const EmailCodeForm = ({ onSuccess }: any) => {
}); });
const onSubmit = ({ email }: EmailCodeFormValues) => { const onSubmit = ({ email }: EmailCodeFormValues) => {
console.log(email);
authenticationService authenticationService
.emailCode({ email }) .emailCode({ email })
.then((res) => { .then((res) => {

View File

@ -1,37 +1,38 @@
// TODO: Refactor this component: into a different file, use this file to export the components // TODO: Refactor this component: into a different file, use this file to export the components
import React, { useState, useCallback, useEffect } from "react"; import React, { useState, useCallback, useEffect } from "react";
// next
import { useRouter } from "next/router"; import { useRouter } from "next/router";
// swr
import useSWR from "swr"; import useSWR from "swr";
// hooks
// headless ui
import { Combobox, Dialog, Transition } from "@headlessui/react"; 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 { import {
FolderIcon, FolderIcon,
RectangleStackIcon, RectangleStackIcon,
ClipboardDocumentListIcon, ClipboardDocumentListIcon,
MagnifyingGlassIcon, MagnifyingGlassIcon,
} from "@heroicons/react/24/outline"; } 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 // helpers
import { copyTextToClipboard } from "helpers/string.helper"; import { copyTextToClipboard } from "helpers/string.helper";
// types // types
import { IIssue } from "types"; import { IIssue } from "types";
// ui
import { Button } from "components/ui";
// icons
// fetch-keys // fetch-keys
import { USER_ISSUE } from "constants/fetch-keys"; import { USER_ISSUE } from "constants/fetch-keys";
@ -74,7 +75,7 @@ const CommandPalette: React.FC = () => {
name: "Add new issue...", name: "Add new issue...",
icon: RectangleStackIcon, icon: RectangleStackIcon,
hide: !projectId, hide: !projectId,
shortcut: "I", shortcut: "C",
onClick: () => { onClick: () => {
setIsIssueModalOpen(true); setIsIssueModalOpen(true);
}, },
@ -111,7 +112,6 @@ const CommandPalette: React.FC = () => {
if (!router.query.issueId) return; if (!router.query.issueId) return;
const url = new URL(window.location.href); const url = new URL(window.location.href);
console.log(url);
copyTextToClipboard(url.href) copyTextToClipboard(url.href)
.then(() => { .then(() => {
setToastAlert({ setToastAlert({
@ -179,7 +179,6 @@ const CommandPalette: React.FC = () => {
<CreateUpdateModuleModal <CreateUpdateModuleModal
isOpen={isCreateModuleModalOpen} isOpen={isCreateModuleModalOpen}
setIsOpen={setIsCreateModuleModalOpen} setIsOpen={setIsCreateModuleModalOpen}
projectId={projectId as string}
/> />
</> </>
)} )}
@ -330,7 +329,6 @@ const CommandPalette: React.FC = () => {
/> />
<span className="ml-3 flex-auto truncate">{action.name}</span> <span className="ml-3 flex-auto truncate">{action.name}</span>
<span className="ml-3 flex-none text-xs font-semibold text-gray-500"> <span className="ml-3 flex-none text-xs font-semibold text-gray-500">
<kbd className="font-sans"></kbd>
<kbd className="font-sans">{action.shortcut}</kbd> <kbd className="font-sans">{action.shortcut}</kbd>
</span> </span>
</> </>

View File

@ -1,3 +0,0 @@
const SingleBoard = () => <></>;
export default SingleBoard;

View File

@ -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<IUserLite>[] | (Partial<IUserLite> | undefined)[];
people: IWorkspaceMember[] | undefined;
handleDeleteIssue?: React.Dispatch<React.SetStateAction<string | undefined>>;
userAuth: UserAuth;
};
const SingleBoardIssue: React.FC<Props> = ({
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<IIssue>) => {
if (!workspaceSlug || !projectId) return;
if (typeId) {
mutate<CycleIssueResponse[]>(
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<ModuleIssueResponse[]>(
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<IssueResponse>(
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 (
<div
className={`rounded border bg-white shadow-sm ${
snapshot && snapshot.isDragging ? "border-theme bg-indigo-50 shadow-lg" : ""
}`}
>
<div className="group/card relative select-none p-2">
{handleDeleteIssue && !isNotAllowed && (
<div className="absolute top-1.5 right-1.5 z-10 opacity-0 group-hover/card:opacity-100">
<button
type="button"
className="grid h-7 w-7 place-items-center rounded bg-white p-1 text-red-500 outline-none duration-300 hover:bg-red-50"
onClick={() => handleDeleteIssue(issue.id)}
>
<TrashIcon className="h-4 w-4" />
</button>
</div>
)}
<Link href={`/${workspaceSlug}/projects/${issue.project}/issues/${issue.id}`}>
<a>
{properties.key && (
<div className="mb-2 text-xs font-medium text-gray-500">
{projectDetails?.identifier}-{issue.sequence_id}
</div>
)}
<h5
className="mb-3 text-sm group-hover:text-theme"
style={{ lineClamp: 3, WebkitLineClamp: 3 }}
>
{issue.name}
</h5>
</a>
</Link>
<div className="flex flex-wrap items-center gap-x-1 gap-y-2 text-xs">
{properties.priority && (
<Listbox
as="div"
value={issue.priority}
onChange={(data: string) => {
partialUpdateIssue({ priority: data });
}}
className="group relative flex-shrink-0"
disabled={isNotAllowed}
>
{({ open }) => (
<>
<div>
<Listbox.Button
className={`grid ${
isNotAllowed ? "cursor-not-allowed" : "cursor-pointer"
} place-items-center rounded px-2 py-1 capitalize shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 ${
issue.priority === "urgent"
? "bg-red-100 text-red-600"
: issue.priority === "high"
? "bg-orange-100 text-orange-500"
: issue.priority === "medium"
? "bg-yellow-100 text-yellow-500"
: issue.priority === "low"
? "bg-green-100 text-green-500"
: "bg-gray-100"
}`}
>
{getPriorityIcon(issue?.priority ?? "None")}
</Listbox.Button>
<Transition
show={open}
as={React.Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Listbox.Options className="absolute z-20 mt-1 max-h-28 overflow-auto rounded-md bg-white py-1 text-xs shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
{PRIORITIES?.map((priority) => (
<Listbox.Option
key={priority}
className={({ active }) =>
`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}
</Listbox.Option>
))}
</Listbox.Options>
</Transition>
</div>
</>
)}
</Listbox>
)}
{properties.state && (
<Listbox
as="div"
value={issue.state}
onChange={(data: string) => {
partialUpdateIssue({ state: data });
}}
className="group relative flex-shrink-0"
disabled={isNotAllowed}
>
{({ open }) => (
<>
<div>
<Listbox.Button
className={`flex ${
isNotAllowed ? "cursor-not-allowed" : "cursor-pointer"
} items-center gap-1 rounded border px-2 py-1 text-xs shadow-sm duration-300 hover:bg-gray-100 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500`}
>
<span
className="h-1.5 w-1.5 flex-shrink-0 rounded-full"
style={{
backgroundColor: issue.state_detail.color,
}}
/>
{addSpaceIfCamelCase(issue.state_detail.name)}
</Listbox.Button>
<Transition
show={open}
as={React.Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Listbox.Options className="absolute z-20 mt-1 max-h-28 overflow-auto rounded-md bg-white py-1 text-xs shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
{states?.map((state) => (
<Listbox.Option
key={state.id}
className={({ active }) =>
`flex cursor-pointer select-none items-center gap-2 px-3 py-2 ${
active ? "bg-indigo-50" : "bg-white"
}`
}
value={state.id}
>
<span
className="h-1.5 w-1.5 flex-shrink-0 rounded-full"
style={{
backgroundColor: state.color,
}}
/>
{addSpaceIfCamelCase(state.name)}
</Listbox.Option>
))}
</Listbox.Options>
</Transition>
</div>
</>
)}
</Listbox>
)}
{/* {properties.cycle && !typeId && (
<div className="flex flex-shrink-0 items-center gap-1 rounded border px-2 py-1 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.issue_cycle ? issue.issue_cycle.cycle_detail.name : "None"}
</div>
)} */}
{properties.due_date && (
<div
className={`group relative ${
issue.target_date === null
? ""
: issue.target_date < new Date().toISOString()
? "text-red-600"
: findHowManyDaysLeft(issue.target_date) <= 3 && "text-orange-400"
}`}
>
<CustomDatePicker
placeholder="N/A"
value={issue?.target_date}
onChange={(val) =>
partialUpdateIssue({
target_date: val,
})
}
className={issue?.target_date ? "w-[6.5rem]" : "w-[3rem] text-center"}
/>
{/* <DatePicker
placeholderText="N/A"
value={
issue?.target_date ? `${renderShortNumericDateFormat(issue.target_date)}` : "N/A"
}
selected={issue?.target_date ? new Date(issue.target_date) : null}
onChange={(val: Date) => {
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
/> */}
</div>
)}
{properties.sub_issue_count && (
<div className="flex flex-shrink-0 items-center gap-1 rounded border px-2 py-1 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.sub_issues_count} {issue.sub_issues_count === 1 ? "sub-issue" : "sub-issues"}
</div>
)}
{properties.assignee && (
<Listbox
as="div"
value={issue.assignees}
onChange={(data: any) => {
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 }) => (
<div>
<Listbox.Button>
<div
className={`flex ${
isNotAllowed ? "cursor-not-allowed" : "cursor-pointer"
} items-center gap-1 text-xs`}
>
<AssigneesList users={assignees} length={3} />
</div>
</Listbox.Button>
<Transition
show={open}
as={React.Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Listbox.Options className="absolute left-0 z-20 mt-1 max-h-28 overflow-auto rounded-md bg-white py-1 text-xs shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
{people?.map((person) => (
<Listbox.Option
key={person.id}
className={({ active }) =>
`cursor-pointer select-none p-2 ${active ? "bg-indigo-50" : "bg-white"}`
}
value={person.member.id}
>
<div
className={`flex items-center gap-x-1 ${
assignees.includes({
id: person.member.last_name,
first_name: person.member.first_name,
last_name: person.member.last_name,
email: person.member.email,
avatar: person.member.avatar,
})
? "font-medium"
: "font-normal"
}`}
>
{person.member.avatar && person.member.avatar !== "" ? (
<div className="relative h-4 w-4">
<Image
src={person.member.avatar}
alt="avatar"
className="rounded-full"
layout="fill"
objectFit="cover"
priority={false}
loading="lazy"
/>
</div>
) : (
<div className="grid h-4 w-4 place-items-center rounded-full bg-gray-700 capitalize text-white">
{person.member.first_name && person.member.first_name !== ""
? person.member.first_name.charAt(0)
: person.member.email.charAt(0)}
</div>
)}
<p>
{person.member.first_name && person.member.first_name !== ""
? person.member.first_name
: person.member.email}
</p>
</div>
</Listbox.Option>
))}
</Listbox.Options>
</Transition>
</div>
)}
</Listbox>
)}
</div>
</div>
</div>
);
};
export default SingleBoardIssue;

View File

@ -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<Props> = ({
type,
typeId,
issue,
properties,
editIssue,
removeIssue,
userAuth,
}) => {
const [deleteIssue, setDeleteIssue] = useState<IIssue | undefined>();
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<IWorkspaceMember[]>(
workspaceSlug ? WORKSPACE_MEMBERS : null,
workspaceSlug ? () => workspaceService.workspaceMembers(workspaceSlug as string) : null
);
const partialUpdateIssue = (formData: Partial<IIssue>) => {
if (!workspaceSlug || !projectId) return;
if (typeId) {
mutate<CycleIssueResponse[]>(
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<ModuleIssueResponse[]>(
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<IssueResponse>(
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 (
<>
<ConfirmIssueDeletion
handleClose={() => setDeleteIssue(undefined)}
isOpen={!!deleteIssue}
data={deleteIssue}
/>
<div className="flex items-center justify-between gap-2 px-4 py-3 text-sm">
<div className="flex items-center gap-2">
<span
className={`block h-1.5 w-1.5 flex-shrink-0 rounded-full`}
style={{
backgroundColor: issue.state_detail.color,
}}
/>
<Link href={`/${workspaceSlug}/projects/${issue?.project_detail?.id}/issues/${issue.id}`}>
<a className="group relative flex items-center gap-2">
{properties.key && (
<span className="flex-shrink-0 text-xs text-gray-500">
{issue.project_detail?.identifier}-{issue.sequence_id}
</span>
)}
<span>{issue.name}</span>
</a>
</Link>
</div>
<div className="flex flex-shrink-0 flex-wrap items-center gap-x-1 gap-y-2 text-xs">
{properties.priority && (
<Listbox
as="div"
value={issue.priority}
onChange={(data: string) => {
partialUpdateIssue({ priority: data });
}}
className="group relative flex-shrink-0"
disabled={isNotAllowed}
>
{({ open }) => (
<>
<div>
<Listbox.Button
className={`flex ${
isNotAllowed ? "cursor-not-allowed" : "cursor-pointer"
} items-center gap-x-2 rounded px-2 py-0.5 capitalize shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 ${
issue.priority === "urgent"
? "bg-red-100 text-red-600"
: issue.priority === "high"
? "bg-orange-100 text-orange-500"
: issue.priority === "medium"
? "bg-yellow-100 text-yellow-500"
: issue.priority === "low"
? "bg-green-100 text-green-500"
: "bg-gray-100"
}`}
>
{getPriorityIcon(
issue.priority && issue.priority !== "" ? issue.priority ?? "" : "None",
"text-sm"
)}
</Listbox.Button>
<Transition
show={open}
as={React.Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Listbox.Options className="absolute right-0 z-10 mt-1 max-h-48 w-36 overflow-auto rounded-md bg-white py-1 text-xs shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
{PRIORITIES?.map((priority) => (
<Listbox.Option
key={priority}
className={({ active }) =>
`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"}
</Listbox.Option>
))}
</Listbox.Options>
</Transition>
</div>
<div className="absolute bottom-full right-0 z-10 mb-2 hidden whitespace-nowrap rounded-md bg-white p-2 shadow-md group-hover:block">
<h5 className="mb-1 font-medium text-gray-900">Priority</h5>
<div
className={`capitalize ${
issue.priority === "urgent"
? "text-red-600"
: issue.priority === "high"
? "text-orange-500"
: issue.priority === "medium"
? "text-yellow-500"
: issue.priority === "low"
? "text-green-500"
: ""
}`}
>
{issue.priority ?? "None"}
</div>
</div>
</>
)}
</Listbox>
)}
{properties.state && (
<CustomSelect
label={
<>
<span
className="h-1.5 w-1.5 flex-shrink-0 rounded-full"
style={{
backgroundColor: issue.state_detail.color,
}}
/>
{addSpaceIfCamelCase(issue.state_detail.name)}
</>
}
value={issue.state}
onChange={(data: string) => {
partialUpdateIssue({ state: data });
}}
maxHeight="md"
noChevron
disabled={isNotAllowed}
>
{states?.map((state) => (
<CustomSelect.Option key={state.id} value={state.id}>
<>
<span
className="h-1.5 w-1.5 flex-shrink-0 rounded-full"
style={{
backgroundColor: state.color,
}}
/>
{addSpaceIfCamelCase(state.name)}
</>
</CustomSelect.Option>
))}
</CustomSelect>
)}
{/* {properties.cycle && !typeId && (
<div className="flex flex-shrink-0 items-center gap-1 rounded border px-2 py-1 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.issue_cycle ? issue.issue_cycle.cycle_detail.name : "None"}
</div>
)} */}
{properties.due_date && (
<div
className={`group relative ${
issue.target_date === null
? ""
: issue.target_date < new Date().toISOString()
? "text-red-600"
: findHowManyDaysLeft(issue.target_date) <= 3 && "text-orange-400"
}`}
>
<CustomDatePicker
placeholder="N/A"
value={issue?.target_date}
onChange={(val) =>
partialUpdateIssue({
target_date: val,
})
}
className={issue?.target_date ? "w-[6.5rem]" : "w-[3rem] text-center"}
/>
<div className="absolute bottom-full right-0 z-10 mb-2 hidden whitespace-nowrap rounded-md bg-white p-2 shadow-md group-hover:block">
<h5 className="mb-1 font-medium text-gray-900">Due date</h5>
<div>{renderShortNumericDateFormat(issue.target_date ?? "")}</div>
<div>
{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"}
</div>
</div>
</div>
)}
{properties.sub_issue_count && (
<div className="flex flex-shrink-0 items-center gap-1 rounded border px-2 py-1 text-xs shadow-sm">
{issue.sub_issues_count} {issue.sub_issues_count === 1 ? "sub-issue" : "sub-issues"}
</div>
)}
{properties.assignee && (
<Listbox
as="div"
value={issue.assignees}
onChange={(data: any) => {
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 }) => (
<>
<div>
<Listbox.Button>
<div
className={`flex ${
isNotAllowed ? "cursor-not-allowed" : "cursor-pointer"
} items-center gap-1 text-xs`}
>
<AssigneesList userIds={issue.assignees ?? []} />
</div>
</Listbox.Button>
<Transition
show={open}
as={React.Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Listbox.Options className="absolute right-0 z-10 mt-1 max-h-48 overflow-auto rounded-md bg-white py-1 text-xs shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
{people?.map((person) => (
<Listbox.Option
key={person.id}
className={({ active, selected }) =>
`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}
>
<Avatar user={person.member} />
<p>
{person.member.first_name && person.member.first_name !== ""
? person.member.first_name
: person.member.email}
</p>
</Listbox.Option>
))}
</Listbox.Options>
</Transition>
</div>
<div className="absolute bottom-full right-0 z-10 mb-2 hidden whitespace-nowrap rounded-md bg-white p-2 shadow-md group-hover:block">
<h5 className="mb-1 font-medium">Assigned to</h5>
<div>
{issue.assignee_details?.length > 0
? issue.assignee_details.map((assignee) => assignee.first_name).join(", ")
: "No one"}
</div>
</div>
</>
)}
</Listbox>
)}
{type && !isNotAllowed && (
<CustomMenu width="auto" ellipsis>
<CustomMenu.MenuItem onClick={editIssue}>Edit</CustomMenu.MenuItem>
{type !== "issue" && (
<CustomMenu.MenuItem onClick={removeIssue}>
<>Remove from {type}</>
</CustomMenu.MenuItem>
)}
<CustomMenu.MenuItem onClick={() => setDeleteIssue(issue)}>
Delete permanently
</CustomMenu.MenuItem>
</CustomMenu>
)}
</div>
</div>
</>
);
};
export default SingleListIssue;

View File

@ -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<Props> = ({
type,
issues,
states,
members,
addIssueToState,
openIssuesListModal,
handleDeleteIssue,
handleOnDragEnd,
userAuth,
}) => {
const { groupedByIssues, groupByProperty: selectedGroup, orderBy } = useIssueView(issues);
return (
<>
{groupedByIssues ? (
<div className="h-[calc(100vh-157px)] lg:h-[calc(100vh-115px)] w-full">
<DragDropContext onDragEnd={handleOnDragEnd}>
<div className="h-full w-full overflow-hidden">
<div className="h-full w-full">
<div className="flex h-full gap-x-4 overflow-x-auto overflow-y-hidden">
{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 (
<SingleBoard
key={index}
type={type}
bgColor={bgColor}
groupTitle={singleGroup}
groupedByIssues={groupedByIssues}
selectedGroup={selectedGroup}
members={members}
addIssueToState={() => addIssueToState(singleGroup, stateId)}
handleDeleteIssue={handleDeleteIssue}
openIssuesListModal={openIssuesListModal ?? null}
orderBy={orderBy}
userAuth={userAuth}
/>
);
})}
</div>
</div>
</div>
</DragDropContext>
</div>
) : (
<div className="flex h-full w-full items-center justify-center">Loading...</div>
)}
</>
);
};

View File

@ -12,27 +12,23 @@ import {
// helpers // helpers
import { addSpaceIfCamelCase } from "helpers/string.helper"; import { addSpaceIfCamelCase } from "helpers/string.helper";
// types // types
import { IIssue, NestedKeyOf } from "types"; import { IIssue } from "types";
type Props = { type Props = {
isCollapsed: boolean; isCollapsed: boolean;
setIsCollapsed: React.Dispatch<React.SetStateAction<boolean>>; setIsCollapsed: React.Dispatch<React.SetStateAction<boolean>>;
groupedByIssues: { groupedByIssues: {
[key: string]: IIssue[]; [key: string]: IIssue[];
}; };
selectedGroup: NestedKeyOf<IIssue> | null;
groupTitle: string; groupTitle: string;
createdBy: string | null; createdBy: string | null;
bgColor: string; bgColor?: string;
addIssueToState: () => void; addIssueToState: () => void;
provided?: DraggableProvided;
}; };
const BoardHeader: React.FC<Props> = ({ export const BoardHeader: React.FC<Props> = ({
isCollapsed, isCollapsed,
setIsCollapsed, setIsCollapsed,
provided,
groupedByIssues, groupedByIssues,
selectedGroup,
groupTitle, groupTitle,
createdBy, createdBy,
bgColor, bgColor,
@ -44,18 +40,6 @@ const BoardHeader: React.FC<Props> = ({
}`} }`}
> >
<div className={`flex items-center ${!isCollapsed ? "flex-col gap-2" : "gap-1"}`}> <div className={`flex items-center ${!isCollapsed ? "flex-col gap-2" : "gap-1"}`}>
{provided && (
<button
type="button"
{...provided.dragHandleProps}
className={`grid h-7 w-7 place-items-center rounded p-1 outline-none duration-300 hover:bg-gray-200 ${
!isCollapsed ? "" : "rotate-90"
} ${selectedGroup !== "state_detail.name" ? "hidden" : ""}`}
>
<EllipsisHorizontalIcon className="h-4 w-4 text-gray-600" />
<EllipsisHorizontalIcon className="mt-[-0.7rem] h-4 w-4 text-gray-600" />
</button>
)}
<div <div
className={`flex cursor-pointer items-center gap-x-1 rounded-md bg-slate-900 px-2 ${ className={`flex cursor-pointer items-center gap-x-1 rounded-md bg-slate-900 px-2 ${
!isCollapsed ? "mb-2 flex-col gap-y-2 py-2" : "" !isCollapsed ? "mb-2 flex-col gap-y-2 py-2" : ""
@ -105,5 +89,3 @@ const BoardHeader: React.FC<Props> = ({
</div> </div>
</div> </div>
); );
export default BoardHeader;

View File

@ -0,0 +1,4 @@
export * from "./all-boards";
export * from "./board-header";
export * from "./single-board";
export * from "./single-issue";

View File

@ -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<IIssue> | null;
members: IProjectMember[] | undefined;
addIssueToState: () => void;
handleDeleteIssue: (issue: IIssue) => void;
openIssuesListModal?: (() => void) | null;
orderBy: NestedKeyOf<IIssue> | "manual" | null;
userAuth: UserAuth;
};
export const SingleBoard: React.FC<Props> = ({
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 (
<div className={`h-full flex-shrink-0 rounded ${!isCollapsed ? "" : "w-80 border bg-gray-50"}`}>
<div className={`${!isCollapsed ? "" : "flex h-full flex-col space-y-3 overflow-y-auto"}`}>
<BoardHeader
addIssueToState={addIssueToState}
bgColor={bgColor}
createdBy={createdBy}
groupTitle={groupTitle}
groupedByIssues={groupedByIssues}
isCollapsed={isCollapsed}
setIsCollapsed={setIsCollapsed}
/>
<StrictModeDroppable key={groupTitle} droppableId={groupTitle}>
{(provided, snapshot) => (
<div
className={`relative mt-3 h-full space-y-3 px-3 pb-3 ${
snapshot.isDraggingOver ? "bg-indigo-50 bg-opacity-50" : ""
} ${!isCollapsed ? "hidden" : "block"}`}
ref={provided.innerRef}
{...provided.droppableProps}
>
{groupedByIssues[groupTitle].map((issue, index: number) => (
<SingleBoardIssue
key={index}
index={index}
type={type}
issue={issue}
selectedGroup={selectedGroup}
properties={properties}
handleDeleteIssue={handleDeleteIssue}
orderBy={orderBy}
userAuth={userAuth}
/>
))}
<span
style={{
display: orderBy === "manual" ? "inline" : "none",
}}
>
{provided.placeholder}
</span>
{type === "issue" ? (
<button
type="button"
className="flex items-center rounded p-2 text-xs font-medium outline-none duration-300 hover:bg-gray-100"
onClick={addIssueToState}
>
<PlusIcon className="mr-1 h-3 w-3" />
Create
</button>
) : (
<CustomMenu
label={
<span className="flex items-center gap-1">
<PlusIcon className="h-3 w-3" />
Add issue
</span>
}
className="mt-1"
optionsPosition="left"
noBorder
>
<CustomMenu.MenuItem onClick={addIssueToState}>Create new</CustomMenu.MenuItem>
{openIssuesListModal && (
<CustomMenu.MenuItem onClick={openIssuesListModal}>
Add an existing issue
</CustomMenu.MenuItem>
)}
</CustomMenu>
)}
</div>
)}
</StrictModeDroppable>
</div>
</div>
);
};

View File

@ -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<IIssue> | null;
properties: Properties;
handleDeleteIssue: (issue: IIssue) => void;
orderBy: NestedKeyOf<IIssue> | "manual" | null;
userAuth: UserAuth;
};
export const SingleBoardIssue: React.FC<Props> = ({
index,
type,
issue,
selectedGroup,
properties,
handleDeleteIssue,
orderBy,
userAuth,
}) => {
const router = useRouter();
const { workspaceSlug, projectId, cycleId, moduleId } = router.query;
const partialUpdateIssue = useCallback(
(formData: Partial<IIssue>) => {
if (!workspaceSlug || !projectId) return;
if (cycleId)
mutate<CycleIssueResponse[]>(
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<ModuleIssueResponse[]>(
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<IssueResponse>(
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 (
<Draggable
key={issue.id}
draggableId={issue.id}
index={index}
isDragDisabled={selectedGroup === "created_by"}
>
{(provided, snapshot) => (
<div
className={`rounded border bg-white shadow-sm ${
snapshot.isDragging ? "border-theme bg-indigo-50 shadow-lg" : ""
}`}
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
style={getStyle(provided.draggableProps.style, snapshot)}
>
<div className="group/card relative select-none p-2">
{!isNotAllowed && (
<div className="absolute top-1.5 right-1.5 z-10 opacity-0 group-hover/card:opacity-100">
<button
type="button"
className="grid h-7 w-7 place-items-center rounded bg-white p-1 text-red-500 outline-none duration-300 hover:bg-red-50"
onClick={() => handleDeleteIssue(issue)}
>
<TrashIcon className="h-4 w-4" />
</button>
</div>
)}
<Link href={`/${workspaceSlug}/projects/${issue.project}/issues/${issue.id}`}>
<a>
{properties.key && (
<div className="mb-2 text-xs font-medium text-gray-500">
{issue.project_detail.identifier}-{issue.sequence_id}
</div>
)}
<h5
className="mb-3 text-sm group-hover:text-theme"
style={{ lineClamp: 3, WebkitLineClamp: 3 }}
>
{issue.name}
</h5>
</a>
</Link>
<div className="flex flex-wrap items-center gap-x-1 gap-y-2 text-xs">
{properties.priority && (
<ViewPrioritySelect
issue={issue}
partialUpdateIssue={partialUpdateIssue}
isNotAllowed={isNotAllowed}
position="left"
/>
)}
{properties.state && (
<ViewStateSelect
issue={issue}
partialUpdateIssue={partialUpdateIssue}
isNotAllowed={isNotAllowed}
/>
)}
{properties.due_date && (
<ViewDueDateSelect
issue={issue}
partialUpdateIssue={partialUpdateIssue}
isNotAllowed={isNotAllowed}
/>
)}
{properties.sub_issue_count && (
<div className="flex flex-shrink-0 items-center gap-1 rounded border px-2 py-1 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.sub_issues_count}{" "}
{issue.sub_issues_count === 1 ? "sub-issue" : "sub-issues"}
</div>
)}
{properties.assignee && (
<ViewAssigneeSelect
issue={issue}
partialUpdateIssue={partialUpdateIssue}
isNotAllowed={isNotAllowed}
/>
)}
</div>
</div>
</div>
)}
</Draggable>
);
};

View File

@ -1,27 +1,26 @@
// react
import React, { useState } from "react"; import React, { useState } from "react";
// next
import { useRouter } from "next/router"; import { useRouter } from "next/router";
// swr
import useSWR, { mutate } from "swr"; import useSWR, { mutate } from "swr";
// react hook form // react hook form
import { SubmitHandler, useForm } from "react-hook-form"; import { SubmitHandler, useForm } from "react-hook-form";
// services // headless ui
import { Combobox, Dialog, Transition } from "@headlessui/react"; import { Combobox, Dialog, Transition } from "@headlessui/react";
import { FolderIcon, MagnifyingGlassIcon } from "@heroicons/react/24/outline"; // services
import issuesServices from "services/issues.service"; import issuesServices from "services/issues.service";
import projectService from "services/project.service";
// hooks // hooks
import useToast from "hooks/use-toast"; import useToast from "hooks/use-toast";
// headless ui
// ui // ui
import { Button } from "components/ui"; import { Button } from "components/ui";
// icons // icons
import { MagnifyingGlassIcon } from "@heroicons/react/24/outline";
import { LayerDiagonalIcon } from "components/icons"; import { LayerDiagonalIcon } from "components/icons";
// types // types
import { IIssue, IssueResponse } from "types"; import { IIssue, IssueResponse } from "types";
// fetch keys // fetch keys
import { PROJECT_ISSUES_LIST, PROJECT_DETAILS } from "constants/fetch-keys"; import { PROJECT_ISSUES_LIST } from "constants/fetch-keys";
type FormInput = { type FormInput = {
delete_issue_ids: string[]; delete_issue_ids: string[];
@ -32,7 +31,7 @@ type Props = {
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>; setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
}; };
const BulkDeleteIssuesModal: React.FC<Props> = ({ isOpen, setIsOpen }) => { export const BulkDeleteIssuesModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const router = useRouter(); const router = useRouter();
@ -50,13 +49,6 @@ const BulkDeleteIssuesModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
: null : 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 { setToastAlert } = useToast();
const { const {
@ -213,7 +205,7 @@ const BulkDeleteIssuesModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
}} }}
/> />
<span className="flex-shrink-0 text-xs text-gray-500"> <span className="flex-shrink-0 text-xs text-gray-500">
{projectDetails?.identifier}-{issue.sequence_id} {issue.project_detail.identifier}-{issue.sequence_id}
</span> </span>
<span>{issue.name}</span> <span>{issue.name}</span>
</div> </div>
@ -226,7 +218,7 @@ const BulkDeleteIssuesModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
<LayerDiagonalIcon height="56" width="56" /> <LayerDiagonalIcon height="56" width="56" />
<h3 className="text-gray-500"> <h3 className="text-gray-500">
No issues found. Create a new issue with{" "} No issues found. Create a new issue with{" "}
<pre className="inline rounded bg-gray-100 px-2 py-1">C</pre>. <pre className="inline rounded bg-gray-200 px-2 py-1">C</pre>.
</h3> </h3>
</div> </div>
)} )}
@ -256,5 +248,3 @@ const BulkDeleteIssuesModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
</Transition.Root> </Transition.Root>
); );
}; };
export default BulkDeleteIssuesModal;

View File

@ -1,24 +1,17 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { useRouter } from "next/router";
import useSWR from "swr";
// react-hook-form // react-hook-form
import { Controller, SubmitHandler, useForm } from "react-hook-form"; import { Controller, SubmitHandler, useForm } from "react-hook-form";
// hooks // hooks
import { Combobox, Dialog, Transition } from "@headlessui/react"; import { Combobox, Dialog, Transition } from "@headlessui/react";
import { MagnifyingGlassIcon, RectangleStackIcon } from "@heroicons/react/24/outline"; import { MagnifyingGlassIcon, RectangleStackIcon } from "@heroicons/react/24/outline";
import useToast from "hooks/use-toast"; import useToast from "hooks/use-toast";
// services
import projectService from "services/project.service";
// headless ui // headless ui
// ui // ui
import { Button } from "components/ui"; import { Button } from "components/ui";
import { LayerDiagonalIcon } from "components/icons"; import { LayerDiagonalIcon } from "components/icons";
// types // types
import { IIssue } from "types"; import { IIssue } from "types";
// fetch-keys
import { PROJECT_DETAILS } from "constants/fetch-keys";
type FormInput = { type FormInput = {
issues: string[]; issues: string[];
@ -32,7 +25,7 @@ type Props = {
handleOnSubmit: any; handleOnSubmit: any;
}; };
const ExistingIssuesListModal: React.FC<Props> = ({ export const ExistingIssuesListModal: React.FC<Props> = ({
isOpen, isOpen,
handleClose: onClose, handleClose: onClose,
issues, issues,
@ -41,16 +34,6 @@ const ExistingIssuesListModal: React.FC<Props> = ({
}) => { }) => {
const [query, setQuery] = useState(""); 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 { setToastAlert } = useToast();
const handleClose = () => { const handleClose = () => {
@ -175,7 +158,7 @@ const ExistingIssuesListModal: React.FC<Props> = ({
}} }}
/> />
<span className="flex-shrink-0 text-xs text-gray-500"> <span className="flex-shrink-0 text-xs text-gray-500">
{projectDetails?.identifier}-{issue.sequence_id} {issue.project_detail.identifier}-{issue.sequence_id}
</span> </span>
{issue.name} {issue.name}
</> </>
@ -189,7 +172,7 @@ const ExistingIssuesListModal: React.FC<Props> = ({
<LayerDiagonalIcon height="56" width="56" /> <LayerDiagonalIcon height="56" width="56" />
<h3 className="text-gray-500"> <h3 className="text-gray-500">
No issues found. Create a new issue with{" "} No issues found. Create a new issue with{" "}
<pre className="inline rounded bg-gray-100 px-2 py-1">C</pre>. <pre className="inline rounded bg-gray-200 px-2 py-1">C</pre>.
</h3> </h3>
</div> </div>
)} )}
@ -233,5 +216,3 @@ const ExistingIssuesListModal: React.FC<Props> = ({
</> </>
); );
}; };
export default ExistingIssuesListModal;

View File

@ -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"; export * from "./not-authorized-view";

View File

@ -17,13 +17,13 @@ import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper";
// types // types
import { IIssue, Properties } from "types"; import { IIssue, Properties } from "types";
// common // common
import { filterIssueOptions, groupByOptions, orderByOptions } from "constants/"; import { GROUP_BY_OPTIONS, ORDER_BY_OPTIONS, FILTER_ISSUE_OPTIONS } from "constants/issue";
type Props = { type Props = {
issues?: IIssue[]; issues?: IIssue[];
}; };
const View: React.FC<Props> = ({ issues }) => { export const IssuesFilterView: React.FC<Props> = ({ issues }) => {
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId } = router.query; const { workspaceSlug, projectId } = router.query;
@ -99,31 +99,33 @@ const View: React.FC<Props> = ({ issues }) => {
<h4 className="text-sm text-gray-600">Group by</h4> <h4 className="text-sm text-gray-600">Group by</h4>
<CustomMenu <CustomMenu
label={ label={
groupByOptions.find((option) => option.key === groupByProperty) GROUP_BY_OPTIONS.find((option) => option.key === groupByProperty)
?.name ?? "Select" ?.name ?? "Select"
} }
width="lg" width="lg"
> >
{groupByOptions.map((option) => ( {GROUP_BY_OPTIONS.map((option) =>
<CustomMenu.MenuItem issueView === "kanban" && option.key === null ? null : (
key={option.key} <CustomMenu.MenuItem
onClick={() => setGroupByProperty(option.key)} key={option.key}
> onClick={() => setGroupByProperty(option.key)}
{option.name} >
</CustomMenu.MenuItem> {option.name}
))} </CustomMenu.MenuItem>
)
)}
</CustomMenu> </CustomMenu>
</div> </div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h4 className="text-sm text-gray-600">Order by</h4> <h4 className="text-sm text-gray-600">Order by</h4>
<CustomMenu <CustomMenu
label={ label={
orderByOptions.find((option) => option.key === orderBy)?.name ?? ORDER_BY_OPTIONS.find((option) => option.key === orderBy)?.name ??
"Select" "Select"
} }
width="lg" width="lg"
> >
{orderByOptions.map((option) => {ORDER_BY_OPTIONS.map((option) =>
groupByProperty === "priority" && groupByProperty === "priority" &&
option.key === "priority" ? null : ( option.key === "priority" ? null : (
<CustomMenu.MenuItem <CustomMenu.MenuItem
@ -140,12 +142,12 @@ const View: React.FC<Props> = ({ issues }) => {
<h4 className="text-sm text-gray-600">Issue type</h4> <h4 className="text-sm text-gray-600">Issue type</h4>
<CustomMenu <CustomMenu
label={ label={
filterIssueOptions.find((option) => option.key === filterIssue) FILTER_ISSUE_OPTIONS.find((option) => option.key === filterIssue)
?.name ?? "Select" ?.name ?? "Select"
} }
width="lg" width="lg"
> >
{filterIssueOptions.map((option) => ( {FILTER_ISSUE_OPTIONS.map((option) => (
<CustomMenu.MenuItem <CustomMenu.MenuItem
key={option.key} key={option.key}
onClick={() => setFilterIssue(option.key)} onClick={() => setFilterIssue(option.key)}
@ -203,5 +205,3 @@ const View: React.FC<Props> = ({ issues }) => {
</> </>
); );
}; };
export default View;

View File

@ -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<Props> = ({
type = "issue",
issues,
openIssuesListModal,
userAuth,
}) => {
// create issue modal
const [createIssueModal, setCreateIssueModal] = useState(false);
const [preloadedData, setPreloadedData] = useState<
(Partial<IIssue> & { 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<IIssue | null>(null);
const router = useRouter();
const { workspaceSlug, projectId, cycleId, moduleId } = router.query;
const { issueView, groupedByIssues, groupByProperty: selectedGroup } = useIssueView(issues);
const { data: states } = useSWR<IState[]>(
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<CycleIssueResponse[]>(
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<ModuleIssueResponse[]>(
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<IssueResponse>(
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<CycleIssueResponse[]>(
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<ModuleIssueResponse[]>(
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<IssueResponse>(
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<CycleIssueResponse[]>(
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<ModuleIssueResponse[]>(
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 (
<>
<CreateUpdateIssueModal
isOpen={createIssueModal && preloadedData?.actionType === "createIssue"}
handleClose={() => setCreateIssueModal(false)}
prePopulateData={{
...preloadedData,
}}
/>
<CreateUpdateIssueModal
isOpen={editIssueModal && issueToEdit?.actionType !== "delete"}
prePopulateData={{ ...issueToEdit }}
handleClose={() => setEditIssueModal(false)}
data={issueToEdit}
/>
<DeleteIssueModal
handleClose={() => setDeleteIssueModal(false)}
isOpen={deleteIssueModal}
data={issueToDelete}
/>
{issueView === "list" ? (
<AllLists
type={type}
issues={issues}
states={states}
members={members}
addIssueToState={addIssueToState}
handleEditIssue={handleEditIssue}
handleDeleteIssue={handleDeleteIssue}
openIssuesListModal={type !== "issue" ? openIssuesListModal : null}
removeIssue={
type === "cycle"
? removeIssueFromCycle
: type === "module"
? removeIssueFromModule
: null
}
userAuth={userAuth}
/>
) : (
<AllBoards
type={type}
issues={issues}
states={states}
members={members}
addIssueToState={addIssueToState}
openIssuesListModal={type !== "issue" ? openIssuesListModal : null}
handleDeleteIssue={handleDeleteIssue}
handleOnDragEnd={handleOnDragEnd}
userAuth={userAuth}
/>
)}
</>
);
};

View File

@ -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<Props> = ({
type,
issues,
states,
members,
addIssueToState,
openIssuesListModal,
handleEditIssue,
handleDeleteIssue,
removeIssue,
userAuth,
}) => {
const { groupedByIssues, groupByProperty: selectedGroup } = useIssueView(issues);
return (
<div className="flex flex-col space-y-5">
{Object.keys(groupedByIssues).map((singleGroup) => {
const stateId =
selectedGroup === "state_detail.name"
? states?.find((s) => s.name === singleGroup)?.id ?? null
: null;
return (
<SingleList
key={singleGroup}
type={type}
groupTitle={singleGroup}
groupedByIssues={groupedByIssues}
selectedGroup={selectedGroup}
members={members}
addIssueToState={() => addIssueToState(singleGroup, stateId)}
handleEditIssue={handleEditIssue}
handleDeleteIssue={handleDeleteIssue}
openIssuesListModal={type !== "issue" ? openIssuesListModal : null}
removeIssue={removeIssue}
userAuth={userAuth}
/>
);
})}
</div>
);
};

View File

@ -0,0 +1,3 @@
export * from "./all-lists";
export * from "./single-issue";
export * from "./single-list";

View File

@ -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<Props> = ({
type,
issue,
properties,
editIssue,
removeIssue,
handleDeleteIssue,
userAuth,
}) => {
const router = useRouter();
const { workspaceSlug, projectId, cycleId, moduleId } = router.query;
const partialUpdateIssue = useCallback(
(formData: Partial<IIssue>) => {
if (!workspaceSlug || !projectId) return;
if (cycleId)
mutate<CycleIssueResponse[]>(
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<ModuleIssueResponse[]>(
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<IssueResponse>(
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 (
<div className="flex items-center justify-between gap-2 px-4 py-3 text-sm">
<div className="flex items-center gap-2">
<span
className="block h-1.5 w-1.5 flex-shrink-0 rounded-full"
style={{
backgroundColor: issue.state_detail.color,
}}
/>
<Link href={`/${workspaceSlug}/projects/${issue?.project_detail?.id}/issues/${issue.id}`}>
<a className="group relative flex items-center gap-2">
{properties.key && (
<span className="flex-shrink-0 text-xs text-gray-500">
{issue.project_detail?.identifier}-{issue.sequence_id}
</span>
)}
<span>{issue.name}</span>
</a>
</Link>
</div>
<div className="flex flex-shrink-0 flex-wrap items-center gap-x-1 gap-y-2 text-xs">
{properties.priority && (
<ViewPrioritySelect
issue={issue}
partialUpdateIssue={partialUpdateIssue}
isNotAllowed={isNotAllowed}
/>
)}
{properties.state && (
<ViewStateSelect
issue={issue}
partialUpdateIssue={partialUpdateIssue}
isNotAllowed={isNotAllowed}
/>
)}
{properties.due_date && (
<ViewDueDateSelect
issue={issue}
partialUpdateIssue={partialUpdateIssue}
isNotAllowed={isNotAllowed}
/>
)}
{properties.sub_issue_count && (
<div className="flex flex-shrink-0 items-center gap-1 rounded border px-2 py-1 text-xs shadow-sm">
{issue.sub_issues_count} {issue.sub_issues_count === 1 ? "sub-issue" : "sub-issues"}
</div>
)}
{properties.assignee && (
<ViewAssigneeSelect
issue={issue}
partialUpdateIssue={partialUpdateIssue}
isNotAllowed={isNotAllowed}
/>
)}
{type && !isNotAllowed && (
<CustomMenu width="auto" ellipsis>
<CustomMenu.MenuItem onClick={editIssue}>Edit</CustomMenu.MenuItem>
{type !== "issue" && removeIssue && (
<CustomMenu.MenuItem onClick={removeIssue}>
<>Remove from {type}</>
</CustomMenu.MenuItem>
)}
<CustomMenu.MenuItem onClick={() => handleDeleteIssue(issue)}>
Delete permanently
</CustomMenu.MenuItem>
</CustomMenu>
)}
</div>
</div>
);
};

View File

@ -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<IIssue> | 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<Props> = ({
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 (
<Disclosure key={groupTitle} as="div" defaultOpen>
{({ open }) => (
<div className="rounded-lg bg-white">
<div className="rounded-t-lg bg-gray-100 px-4 py-3">
<Disclosure.Button>
<div className="flex items-center gap-x-2">
<span>
<ChevronDownIcon
className={`h-4 w-4 text-gray-500 ${!open ? "-rotate-90 transform" : ""}`}
/>
</span>
{selectedGroup !== null ? (
<h2 className="font-medium capitalize leading-5">
{groupTitle === null || groupTitle === "null"
? "None"
: createdBy
? createdBy
: addSpaceIfCamelCase(groupTitle)}
</h2>
) : (
<h2 className="font-medium leading-5">All Issues</h2>
)}
<p className="text-sm text-gray-500">
{groupedByIssues[groupTitle as keyof IIssue].length}
</p>
</div>
</Disclosure.Button>
</div>
<Transition
show={open}
enter="transition duration-100 ease-out"
enterFrom="transform opacity-0"
enterTo="transform opacity-100"
leave="transition duration-75 ease-out"
leaveFrom="transform opacity-100"
leaveTo="transform opacity-0"
>
<Disclosure.Panel>
<div className="divide-y-2">
{groupedByIssues[groupTitle] ? (
groupedByIssues[groupTitle].length > 0 ? (
groupedByIssues[groupTitle].map((issue: IIssue) => (
<SingleListIssue
key={issue.id}
type={type}
issue={issue}
properties={properties}
editIssue={() => handleEditIssue(issue)}
handleDeleteIssue={handleDeleteIssue}
removeIssue={() => {
removeIssue && removeIssue(issue.bridge);
}}
userAuth={userAuth}
/>
))
) : (
<p className="px-4 py-3 text-sm text-gray-500">No issues.</p>
)
) : (
<div className="flex h-full w-full items-center justify-center">Loading...</div>
)}
</div>
</Disclosure.Panel>
</Transition>
<div className="p-3">
{type === "issue" ? (
<button
type="button"
className="flex items-center gap-1 rounded px-2 py-1 text-xs font-medium hover:bg-gray-100"
onClick={addIssueToState}
>
<PlusIcon className="h-3 w-3" />
Add issue
</button>
) : (
<CustomMenu
label={
<span className="flex items-center gap-1">
<PlusIcon className="h-3 w-3" />
Add issue
</span>
}
optionsPosition="left"
noBorder
>
<CustomMenu.MenuItem onClick={addIssueToState}>Create new</CustomMenu.MenuItem>
{openIssuesListModal && (
<CustomMenu.MenuItem onClick={openIssuesListModal}>
Add an existing issue
</CustomMenu.MenuItem>
)}
</CustomMenu>
)}
</div>
</div>
)}
</Disclosure>
);
};

View File

@ -61,9 +61,8 @@ export const CycleModal: React.FC<CycleModalProps> = (props) => {
if (workspaceSlug && projectId) { if (workspaceSlug && projectId) {
const payload = { const payload = {
...formValues, ...formValues,
start_date: formValues.start_date ? renderDateFormat(formValues.start_date) : null,
end_date: formValues.end_date ? renderDateFormat(formValues.end_date) : null,
}; };
if (initialData) { if (initialData) {
updateCycle(initialData.id, payload); updateCycle(initialData.id, payload);
} else { } else {

View File

@ -1,4 +1,5 @@
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
// react beautiful dnd // react beautiful dnd
import { Droppable, DroppableProps } from "react-beautiful-dnd"; import { Droppable, DroppableProps } from "react-beautiful-dnd";
@ -14,9 +15,7 @@ const StrictModeDroppable = ({ children, ...props }: DroppableProps) => {
}; };
}, []); }, []);
if (!enabled) { if (!enabled) return null;
return null;
}
return <Droppable {...props}>{children}</Droppable>; return <Droppable {...props}>{children}</Droppable>;
}; };

View File

@ -7,7 +7,7 @@ import { Props } from "./types";
import emojis from "./emojis.json"; import emojis from "./emojis.json";
// helpers // helpers
import { getRecentEmojis, saveRecentEmoji } from "./helpers"; import { getRecentEmojis, saveRecentEmoji } from "./helpers";
import { getRandomEmoji } from "helpers/functions.helper"; import { getRandomEmoji } from "helpers/common.helper";
// hooks // hooks
import useOutsideClickDetector from "hooks/use-outside-click-detector"; import useOutsideClickDetector from "hooks/use-outside-click-detector";

View File

@ -8,17 +8,18 @@ import {
CalendarDaysIcon, CalendarDaysIcon,
ChartBarIcon, ChartBarIcon,
ChatBubbleBottomCenterTextIcon, ChatBubbleBottomCenterTextIcon,
RectangleGroupIcon,
Squares2X2Icon, Squares2X2Icon,
UserIcon, UserIcon,
} from "@heroicons/react/24/outline"; } from "@heroicons/react/24/outline";
// services // services
import issuesServices from "services/issues.service"; import issuesServices from "services/issues.service";
// components // components
import CommentCard from "components/project/issues/issue-detail/comment/issue-comment-card"; import { CommentCard } from "components/issues/comment";
// ui // ui
import { Loader } from "components/ui"; import { Loader } from "components/ui";
// icons // icons
import { BlockedIcon, BlockerIcon, TagIcon, UserGroupIcon } from "components/icons"; import { BlockedIcon, BlockerIcon, CyclesIcon, TagIcon, UserGroupIcon } from "components/icons";
// helpers // helpers
import { renderShortNumericDateFormat, timeAgo } from "helpers/date-time.helper"; import { renderShortNumericDateFormat, timeAgo } from "helpers/date-time.helper";
import { addSpaceIfCamelCase } from "helpers/string.helper"; import { addSpaceIfCamelCase } from "helpers/string.helper";
@ -47,9 +48,17 @@ const activityDetails: {
message: "marked this issue is blocking", message: "marked this issue is blocking",
icon: <BlockerIcon height="16" width="16" />, icon: <BlockerIcon height="16" width="16" />,
}, },
cycles: {
message: "set the cycle to",
icon: <CyclesIcon height="16" width="16" />,
},
labels: { labels: {
icon: <TagIcon height="16" width="16" />, icon: <TagIcon height="16" width="16" />,
}, },
modules: {
message: "set the module to",
icon: <RectangleGroupIcon className="h-4 w-4" />,
},
state: { state: {
message: "set the state to", message: "set the state to",
icon: <Squares2X2Icon className="h-4 w-4" />, icon: <Squares2X2Icon className="h-4 w-4" />,
@ -76,10 +85,12 @@ const activityDetails: {
}, },
}; };
const IssueActivitySection: React.FC<{ type Props = {
issueActivities: IIssueActivity[]; issueActivities: IIssueActivity[];
mutate: KeyedMutator<IIssueActivity[]>; mutate: KeyedMutator<IIssueActivity[]>;
}> = ({ issueActivities, mutate }) => { };
export const IssueActivitySection: React.FC<Props> = ({ issueActivities, mutate }) => {
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId, issueId } = router.query; const { workspaceSlug, projectId, issueId } = router.query;
@ -183,7 +194,9 @@ const IssueActivitySection: React.FC<{
?.message}{" "} ?.message}{" "}
</span> </span>
<span className="font-medium"> <span className="font-medium">
{activity.verb === "created" ? ( {activity.verb === "created" &&
activity.field !== "cycles" &&
activity.field !== "modules" ? (
<span className="text-gray-600">created this issue.</span> <span className="text-gray-600">created this issue.</span>
) : activity.field === "description" ? null : activity.field === "state" ? ( ) : activity.field === "description" ? null : activity.field === "state" ? (
activity.new_value ? ( activity.new_value ? (
@ -216,7 +229,7 @@ const IssueActivitySection: React.FC<{
</div> </div>
</div> </div>
); );
} else if ("comment_json" in activity) { } else if ("comment_json" in activity)
return ( return (
<CommentCard <CommentCard
key={activity.id} key={activity.id}
@ -225,7 +238,6 @@ const IssueActivitySection: React.FC<{
handleCommentDeletion={onCommentDelete} handleCommentDeletion={onCommentDelete}
/> />
); );
}
})} })}
</div> </div>
) : ( ) : (
@ -247,5 +259,3 @@ const IssueActivitySection: React.FC<{
</> </>
); );
}; };
export default IssueActivitySection;

View File

@ -10,7 +10,7 @@ import issuesServices from "services/issues.service";
// ui // ui
import { Loader } from "components/ui"; import { Loader } from "components/ui";
// helpers // helpers
import { debounce } from "helpers/functions.helper"; import { debounce } from "helpers/common.helper";
// types // types
import type { IIssueActivity, IIssueComment } from "types"; import type { IIssueActivity, IIssueComment } from "types";
import type { KeyedMutator } from "swr"; import type { KeyedMutator } from "swr";
@ -28,7 +28,8 @@ const defaultValues: Partial<IIssueComment> = {
comment_html: "", comment_html: "",
comment_json: "", comment_json: "",
}; };
const AddIssueComment: React.FC<{
export const AddComment: React.FC<{
mutate: KeyedMutator<IIssueActivity[]>; mutate: KeyedMutator<IIssueActivity[]>;
}> = ({ mutate }) => { }> = ({ mutate }) => {
const { const {
@ -111,5 +112,3 @@ const AddIssueComment: React.FC<{
</div> </div>
); );
}; };
export default AddIssueComment;

View File

@ -24,7 +24,7 @@ type Props = {
handleCommentDeletion: (comment: string) => void; handleCommentDeletion: (comment: string) => void;
}; };
const CommentCard: React.FC<Props> = ({ comment, onSubmit, handleCommentDeletion }) => { export const CommentCard: React.FC<Props> = ({ comment, onSubmit, handleCommentDeletion }) => {
const { user } = useUser(); const { user } = useUser();
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
@ -130,5 +130,3 @@ const CommentCard: React.FC<Props> = ({ comment, onSubmit, handleCommentDeletion
</div> </div>
); );
}; };
export default CommentCard;

View File

@ -0,0 +1,2 @@
export * from "./add-comment";
export * from "./comment-card";

View File

@ -17,20 +17,20 @@ import { Button } from "components/ui";
// types // types
import type { CycleIssueResponse, IIssue, IssueResponse, ModuleIssueResponse } from "types"; import type { CycleIssueResponse, IIssue, IssueResponse, ModuleIssueResponse } from "types";
// fetch-keys // 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 = { type Props = {
isOpen: boolean; isOpen: boolean;
handleClose: () => void; handleClose: () => void;
data?: IIssue; data: IIssue | null;
}; };
const ConfirmIssueDeletion: React.FC<Props> = ({ isOpen, handleClose, data }) => { export const DeleteIssueModal: React.FC<Props> = ({ isOpen, handleClose, data }) => {
const cancelButtonRef = useRef(null); const cancelButtonRef = useRef(null);
const [isDeleteLoading, setIsDeleteLoading] = useState(false); const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const router = useRouter(); const router = useRouter();
const { workspaceSlug } = router.query; const { workspaceSlug, projectId: queryProjectId } = router.query;
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();
@ -43,15 +43,40 @@ const ConfirmIssueDeletion: React.FC<Props> = ({ isOpen, handleClose, data }) =>
handleClose(); handleClose();
}; };
console.log(data);
const handleDeletion = async () => { const handleDeletion = async () => {
setIsDeleteLoading(true); setIsDeleteLoading(true);
if (!data || !workspaceSlug) return; if (!data || !workspaceSlug) return;
const projectId = data.project; const projectId = data.project;
await issueServices await issueServices
.deleteIssue(workspaceSlug as string, projectId, data.id) .deleteIssue(workspaceSlug as string, projectId, data.id)
.then(() => { .then(() => {
const cycleId = data?.cycle;
const moduleId = data?.module;
if (cycleId) {
mutate<CycleIssueResponse[]>(
CYCLE_ISSUES(cycleId),
(prevData) => prevData?.filter((i) => i.issue !== data.id),
false
);
}
if (moduleId) {
mutate<ModuleIssueResponse[]>(
MODULE_ISSUES(moduleId),
(prevData) => prevData?.filter((i) => i.issue !== data.id),
false
);
}
if (!queryProjectId)
mutate<IIssue[]>(
USER_ISSUE(workspaceSlug as string),
(prevData) => prevData?.filter((i) => i.id !== data.id),
false
);
mutate<IssueResponse>( mutate<IssueResponse>(
PROJECT_ISSUES_LIST(workspaceSlug as string, projectId), PROJECT_ISSUES_LIST(workspaceSlug as string, projectId),
(prevData) => ({ (prevData) => ({
@ -62,24 +87,6 @@ const ConfirmIssueDeletion: React.FC<Props> = ({ isOpen, handleClose, data }) =>
false false
); );
const moduleId = data?.module;
const cycleId = data?.cycle;
if (moduleId) {
mutate<ModuleIssueResponse[]>(
MODULE_ISSUES(moduleId),
(prevData) => prevData?.filter((i) => i.issue !== data.id),
false
);
}
if (cycleId) {
mutate<CycleIssueResponse[]>(
CYCLE_ISSUES(cycleId),
(prevData) => prevData?.filter((i) => i.issue !== data.id),
false
);
}
handleClose(); handleClose();
setToastAlert({ setToastAlert({
title: "Success", title: "Success",
@ -173,5 +180,3 @@ const ConfirmIssueDeletion: React.FC<Props> = ({ isOpen, handleClose, data }) =>
</Transition.Root> </Transition.Root>
); );
}; };
export default ConfirmIssueDeletion;

View File

@ -16,7 +16,7 @@ import {
IssueStateSelect, IssueStateSelect,
} from "components/issues/select"; } from "components/issues/select";
import { CycleSelect as IssueCycleSelect } from "components/cycles/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"; import CreateUpdateCycleModal from "components/project/cycles/create-update-cycle-modal";
// ui // ui
import { Button, CustomDatePicker, CustomMenu, Input, Loader } from "components/ui"; import { Button, CustomDatePicker, CustomMenu, Input, Loader } from "components/ui";

View File

@ -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 "./description-form";
export * from "./sub-issue-list";
export * from "./form"; export * from "./form";
export * from "./modal"; 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";

View File

@ -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> = (props) => {
// const { type, issue, properties, editIssue, handleDeleteIssue, removeIssue } = props;
const { issue, properties } = props;
// router
const router = useRouter();
const { workspaceSlug } = router.query;
return (
<div key={issue.id} className="flex items-center justify-between gap-2 px-4 py-3 text-sm">
<div className="flex items-center gap-2">
<span
className={`block h-1.5 w-1.5 flex-shrink-0 rounded-full`}
style={{
backgroundColor: issue.state_detail.color,
}}
/>
<Link href={`/${workspaceSlug}/projects/${issue?.project_detail?.id}/issues/${issue.id}`}>
<a className="group relative flex items-center gap-2">
{properties?.key && (
<span className="flex-shrink-0 text-xs text-gray-500">
{issue.project_detail?.identifier}-{issue.sequence_id}
</span>
)}
<span>{issue.name}</span>
</a>
</Link>
</div>
<div className="flex flex-shrink-0 flex-wrap items-center gap-x-1 gap-y-2 text-xs">
{properties.priority && (
<div
className={`group relative flex flex-shrink-0 cursor-pointer items-center gap-1 rounded px-2 py-1 text-xs capitalize shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 ${
issue.priority === "urgent"
? "bg-red-100 text-red-600"
: issue.priority === "high"
? "bg-orange-100 text-orange-500"
: issue.priority === "medium"
? "bg-yellow-100 text-yellow-500"
: issue.priority === "low"
? "bg-green-100 text-green-500"
: "bg-gray-100"
}`}
>
{getPriorityIcon(issue.priority)}
<div className="absolute bottom-full right-0 z-10 mb-2 hidden whitespace-nowrap rounded-md bg-white p-2 shadow-md group-hover:block">
<h5 className="mb-1 font-medium text-gray-900">Priority</h5>
<div
className={`capitalize ${
issue.priority === "urgent"
? "text-red-600"
: issue.priority === "high"
? "text-orange-500"
: issue.priority === "medium"
? "text-yellow-500"
: issue.priority === "low"
? "text-green-500"
: ""
}`}
>
{issue.priority ?? "None"}
</div>
</div>
</div>
)}
{properties.state && (
<div className="group relative flex flex-shrink-0 cursor-pointer items-center gap-1 rounded border px-2 py-1 text-xs shadow-sm duration-300 hover:bg-gray-100 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500">
<span
className="h-1.5 w-1.5 flex-shrink-0 rounded-full"
style={{
backgroundColor: issue?.state_detail?.color,
}}
/>
{addSpaceIfCamelCase(issue?.state_detail.name)}
<div className="absolute bottom-full right-0 z-10 mb-2 hidden whitespace-nowrap rounded-md bg-white p-2 shadow-md group-hover:block">
<h5 className="mb-1 font-medium">State</h5>
<div>{issue?.state_detail.name}</div>
</div>
</div>
)}
{properties.due_date && (
<div
className={`group group relative flex flex-shrink-0 cursor-pointer items-center gap-1 rounded border px-2 py-1 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 === null
? ""
: issue.target_date < new Date().toISOString()
? "text-red-600"
: findHowManyDaysLeft(issue.target_date) <= 3 && "text-orange-400"
}`}
>
<CalendarDaysIcon className="h-4 w-4" />
{issue.target_date ? renderShortNumericDateFormat(issue.target_date) : "N/A"}
<div className="absolute bottom-full right-0 z-10 mb-2 hidden whitespace-nowrap rounded-md bg-white p-2 shadow-md group-hover:block">
<h5 className="mb-1 font-medium text-gray-900">Due date</h5>
<div>{renderShortNumericDateFormat(issue.target_date ?? "")}</div>
<div>
{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")}
</div>
</div>
</div>
)}
{properties.sub_issue_count && (
<div className="flex flex-shrink-0 items-center gap-1 rounded border px-2 py-1 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?.sub_issues_count} {issue?.sub_issues_count === 1 ? "sub-issue" : "sub-issues"}
</div>
)}
{properties.assignee && (
<div className="flex items-center gap-1">
<AssigneesList userIds={issue.assignees ?? []} />
</div>
)}
</div>
</div>
);
};

View File

@ -16,11 +16,7 @@ import issuesService from "services/issues.service";
import useUser from "hooks/use-user"; import useUser from "hooks/use-user";
import useToast from "hooks/use-toast"; import useToast from "hooks/use-toast";
// components // 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"; import { IssueForm } from "components/issues";
// common
import { renderDateFormat } from "helpers/date-time.helper";
// types // types
import type { IIssue, IssueResponse } from "types"; import type { IIssue, IssueResponse } from "types";
// fetch keys // fetch keys
@ -54,7 +50,10 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
const [activeProject, setActiveProject] = useState<string | null>(null); const [activeProject, setActiveProject] = useState<string | null>(null);
const router = useRouter(); 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 { user } = useUser();
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();
@ -176,7 +175,7 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
.then((res) => { .then((res) => {
if (isUpdatingSingleIssue) { if (isUpdatingSingleIssue) {
mutate<IIssue>(PROJECT_ISSUES_DETAILS, (prevData) => ({ ...prevData, ...res }), false); mutate<IIssue>(PROJECT_ISSUES_DETAILS, (prevData) => ({ ...prevData, ...res }), false);
} else } else {
mutate<IssueResponse>( mutate<IssueResponse>(
PROJECT_ISSUES_LIST(workspaceSlug as string, activeProject ?? ""), PROJECT_ISSUES_LIST(workspaceSlug as string, activeProject ?? ""),
(prevData) => ({ (prevData) => ({
@ -187,8 +186,10 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
}), }),
}) })
); );
}
if (payload.cycle && payload.cycle !== "") addIssueToCycle(res.id, payload.cycle); if (payload.cycle && payload.cycle !== "") addIssueToCycle(res.id, payload.cycle);
if (payload.module && payload.module !== "") addIssueToModule(res.id, payload.module);
if (!createMore) handleClose(); if (!createMore) handleClose();
@ -206,15 +207,16 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
}; };
const handleFormSubmit = async (formData: Partial<IIssue>) => { const handleFormSubmit = async (formData: Partial<IIssue>) => {
if (workspaceSlug && activeProject) { if (!workspaceSlug || !activeProject) return;
const payload: Partial<IIssue> = {
...formData,
target_date: formData.target_date ? renderDateFormat(formData.target_date ?? "") : null,
};
if (!data) await createIssue(payload); const payload: Partial<IIssue> = {
else await updateIssue(payload); ...formData,
} description: formData.description ? formData.description : "",
description_html: formData.description_html ? formData.description_html : "<p></p>",
};
if (!data) await createIssue(payload);
else await updateIssue(payload);
}; };
return ( return (

View File

@ -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<Props> = ({
issue,
properties,
projectId,
handleDeleteIssue,
}) => {
const router = useRouter();
const { workspaceSlug } = router.query;
const partialUpdateIssue = useCallback(
(formData: Partial<IIssue>) => {
if (!workspaceSlug) return;
mutate<IIssue[]>(
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 (
<div key={issue.id} className="flex items-center justify-between gap-2 px-4 py-3 text-sm">
<div className="flex items-center gap-2">
<span
className={`block h-1.5 w-1.5 flex-shrink-0 rounded-full`}
style={{
backgroundColor: issue.state_detail.color,
}}
/>
<Link href={`/${workspaceSlug}/projects/${issue?.project_detail?.id}/issues/${issue.id}`}>
<a className="group relative flex items-center gap-2">
{properties?.key && (
<span className="flex-shrink-0 text-xs text-gray-500">
{issue.project_detail?.identifier}-{issue.sequence_id}
</span>
)}
<span>{issue.name}</span>
</a>
</Link>
</div>
<div className="flex flex-shrink-0 flex-wrap items-center gap-x-1 gap-y-2 text-xs">
{properties.priority && (
<ViewPrioritySelect
issue={issue}
partialUpdateIssue={partialUpdateIssue}
isNotAllowed={isNotAllowed}
/>
)}
{properties.state && (
<ViewStateSelect
issue={issue}
partialUpdateIssue={partialUpdateIssue}
isNotAllowed={isNotAllowed}
/>
)}
{properties.due_date && (
<ViewDueDateSelect
issue={issue}
partialUpdateIssue={partialUpdateIssue}
isNotAllowed={isNotAllowed}
/>
)}
{properties.sub_issue_count && (
<div className="flex flex-shrink-0 items-center gap-1 rounded border px-2 py-1 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?.sub_issues_count} {issue?.sub_issues_count === 1 ? "sub-issue" : "sub-issues"}
</div>
)}
{properties.assignee && (
<div className="flex items-center gap-1">
<AssigneesList userIds={issue.assignees ?? []} />
</div>
)}
<CustomMenu width="auto" ellipsis>
<CustomMenu.MenuItem onClick={handleDeleteIssue}>Delete permanently</CustomMenu.MenuItem>
</CustomMenu>
</div>
</div>
);
};

View File

@ -21,7 +21,7 @@ type Props = {
customDisplay?: JSX.Element; customDisplay?: JSX.Element;
}; };
const IssuesListModal: React.FC<Props> = ({ export const ParentIssuesListModal: React.FC<Props> = ({
isOpen, isOpen,
handleClose: onClose, handleClose: onClose,
value, value,
@ -212,7 +212,7 @@ const IssuesListModal: React.FC<Props> = ({
<LayerDiagonalIcon height="56" width="56" /> <LayerDiagonalIcon height="56" width="56" />
<h3 className="text-gray-500"> <h3 className="text-gray-500">
No issues found. Create a new issue with{" "} No issues found. Create a new issue with{" "}
<pre className="inline rounded bg-gray-100 px-2 py-1">C</pre>. <pre className="inline rounded bg-gray-200 px-2 py-1">C</pre>.
</h3> </h3>
</div> </div>
)} )}
@ -227,5 +227,3 @@ const IssuesListModal: React.FC<Props> = ({
</> </>
); );
}; };
export default IssuesListModal;

View File

@ -1,6 +1,6 @@
export * from "./assignee"; export * from "./assignee";
export * from "./label"; export * from "./label";
export * from "./parent-issue"; export * from "./parent";
export * from "./priority"; export * from "./priority";
export * from "./project"; export * from "./project";
export * from "./state"; export * from "./state";

View File

@ -72,7 +72,7 @@ export const IssueLabelSelect: React.FC<Props> = ({ value, onChange, projectId }
const options = issueLabels?.map((label) => ({ const options = issueLabels?.map((label) => ({
value: label.id, value: label.id,
display: label.name, display: label.name,
color: label.colour, color: label.color,
})); }));
const filteredOptions = const filteredOptions =

View File

@ -1,7 +1,7 @@
import React from "react"; import React from "react";
import { Controller, Control } from "react-hook-form"; import { Controller, Control } from "react-hook-form";
// components // components
import IssuesListModal from "components/project/issues/issues-list-modal"; import { ParentIssuesListModal } from "components/issues";
// types // types
import type { IIssue } from "types"; import type { IIssue } from "types";
@ -17,7 +17,7 @@ export const IssueParentSelect: React.FC<Props> = ({ control, isOpen, setIsOpen,
control={control} control={control}
name="parent" name="parent"
render={({ field: { onChange } }) => ( render={({ field: { onChange } }) => (
<IssuesListModal <ParentIssuesListModal
isOpen={isOpen} isOpen={isOpen}
handleClose={() => setIsOpen(false)} handleClose={() => setIsOpen(false)}
onChange={onChange} onChange={onChange}

View File

@ -2,9 +2,10 @@ import React from "react";
// headless ui // headless ui
import { Listbox, Transition } from "@headlessui/react"; import { Listbox, Transition } from "@headlessui/react";
// icons
import { getPriorityIcon } from "components/icons/priority-icon";
// constants // constants
import { getPriorityIcon } from "constants/global"; import { PRIORITIES } from "constants/project";
import { PRIORITIES } from "constants/";
type Props = { type Props = {
value: string | null; value: string | null;

View File

@ -27,7 +27,7 @@ type Props = {
userAuth: UserAuth; userAuth: UserAuth;
}; };
const SelectAssignee: React.FC<Props> = ({ control, submitChanges, userAuth }) => { export const SidebarAssigneeSelect: React.FC<Props> = ({ control, submitChanges, userAuth }) => {
const router = useRouter(); const router = useRouter();
const { workspaceSlug } = router.query; const { workspaceSlug } = router.query;
@ -143,5 +143,3 @@ const SelectAssignee: React.FC<Props> = ({ control, submitChanges, userAuth }) =
</div> </div>
); );
}; };
export default SelectAssignee;

View File

@ -16,7 +16,7 @@ import issuesService from "services/issues.service";
// ui // ui
import { Button } from "components/ui"; import { Button } from "components/ui";
// icons // icons
import { FolderIcon, MagnifyingGlassIcon, XMarkIcon } from "@heroicons/react/24/outline"; import { MagnifyingGlassIcon, XMarkIcon } from "@heroicons/react/24/outline";
import { BlockedIcon, LayerDiagonalIcon } from "components/icons"; import { BlockedIcon, LayerDiagonalIcon } from "components/icons";
// types // types
import { IIssue, UserAuth } from "types"; import { IIssue, UserAuth } from "types";
@ -34,7 +34,12 @@ type Props = {
userAuth: UserAuth; userAuth: UserAuth;
}; };
const SelectBlocked: React.FC<Props> = ({ submitChanges, issuesList, watch, userAuth }) => { export const SidebarBlockedSelect: React.FC<Props> = ({
submitChanges,
issuesList,
watch,
userAuth,
}) => {
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [isBlockedModalOpen, setIsBlockedModalOpen] = useState(false); const [isBlockedModalOpen, setIsBlockedModalOpen] = useState(false);
@ -172,7 +177,6 @@ const SelectBlocked: React.FC<Props> = ({ submitChanges, issuesList, watch, user
<form> <form>
<Combobox <Combobox
onChange={(val: string) => { onChange={(val: string) => {
console.log("Triggered");
const selectedIssues = watchBlocked("blocked_issue_ids"); const selectedIssues = watchBlocked("blocked_issue_ids");
if (selectedIssues.includes(val)) if (selectedIssues.includes(val))
setValue( setValue(
@ -262,7 +266,7 @@ const SelectBlocked: React.FC<Props> = ({ submitChanges, issuesList, watch, user
<LayerDiagonalIcon height="56" width="56" /> <LayerDiagonalIcon height="56" width="56" />
<h3 className="text-gray-500"> <h3 className="text-gray-500">
No issues found. Create a new issue with{" "} No issues found. Create a new issue with{" "}
<pre className="inline rounded bg-gray-100 px-2 py-1">C</pre>. <pre className="inline rounded bg-gray-200 px-2 py-1">C</pre>.
</h3> </h3>
</div> </div>
)} )}
@ -301,5 +305,3 @@ const SelectBlocked: React.FC<Props> = ({ submitChanges, issuesList, watch, user
</div> </div>
); );
}; };
export default SelectBlocked;

View File

@ -16,7 +16,7 @@ import issuesServices from "services/issues.service";
// ui // ui
import { Button } from "components/ui"; import { Button } from "components/ui";
// icons // icons
import { FolderIcon, MagnifyingGlassIcon, XMarkIcon } from "@heroicons/react/24/outline"; import { MagnifyingGlassIcon, XMarkIcon } from "@heroicons/react/24/outline";
import { BlockerIcon, LayerDiagonalIcon } from "components/icons"; import { BlockerIcon, LayerDiagonalIcon } from "components/icons";
// types // types
import { IIssue, UserAuth } from "types"; import { IIssue, UserAuth } from "types";
@ -34,7 +34,12 @@ type Props = {
userAuth: UserAuth; userAuth: UserAuth;
}; };
const SelectBlocker: React.FC<Props> = ({ submitChanges, issuesList, watch, userAuth }) => { export const SidebarBlockerSelect: React.FC<Props> = ({
submitChanges,
issuesList,
watch,
userAuth,
}) => {
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [isBlockerModalOpen, setIsBlockerModalOpen] = useState(false); const [isBlockerModalOpen, setIsBlockerModalOpen] = useState(false);
@ -261,7 +266,7 @@ const SelectBlocker: React.FC<Props> = ({ submitChanges, issuesList, watch, user
<LayerDiagonalIcon height="56" width="56" /> <LayerDiagonalIcon height="56" width="56" />
<h3 className="text-gray-500"> <h3 className="text-gray-500">
No issues found. Create a new issue with{" "} No issues found. Create a new issue with{" "}
<pre className="inline rounded bg-gray-100 px-2 py-1">C</pre>. <pre className="inline rounded bg-gray-200 px-2 py-1">C</pre>.
</h3> </h3>
</div> </div>
)} )}
@ -299,5 +304,3 @@ const SelectBlocker: React.FC<Props> = ({ submitChanges, issuesList, watch, user
</div> </div>
); );
}; };
export default SelectBlocker;

View File

@ -22,7 +22,11 @@ type Props = {
userAuth: UserAuth; userAuth: UserAuth;
}; };
const SelectCycle: React.FC<Props> = ({ issueDetail, handleCycleChange, userAuth }) => { export const SidebarCycleSelect: React.FC<Props> = ({
issueDetail,
handleCycleChange,
userAuth,
}) => {
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId, issueId } = router.query; const { workspaceSlug, projectId, issueId } = router.query;
@ -71,7 +75,7 @@ const SelectCycle: React.FC<Props> = ({ issueDetail, handleCycleChange, userAuth
onChange={(value: any) => { onChange={(value: any) => {
value === null value === null
? removeIssueFromCycle(issueCycle?.id ?? "", issueCycle?.cycle ?? "") ? 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} disabled={isNotAllowed}
> >
@ -98,5 +102,3 @@ const SelectCycle: React.FC<Props> = ({ issueDetail, handleCycleChange, userAuth
</div> </div>
); );
}; };
export default SelectCycle;

View File

@ -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";

View File

@ -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<Props> = ({
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 (
<div className="flex flex-wrap items-center py-2">
<div className="flex items-center gap-x-2 text-sm sm:basis-1/2">
<RectangleGroupIcon className="h-4 w-4 flex-shrink-0" />
<p>Module</p>
</div>
<div className="space-y-1 sm:basis-1/2">
<CustomSelect
label={
<span
className={`hidden truncate text-left sm:block ${issueModule ? "" : "text-gray-900"}`}
>
{modules?.find((m) => m.id === issueModule?.module)?.name ?? "None"}
</span>
}
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 ? (
<>
<CustomSelect.Option value={null} className="capitalize">
None
</CustomSelect.Option>
{modules.map((option) => (
<CustomSelect.Option key={option.id} value={option.id}>
{option.name}
</CustomSelect.Option>
))}
</>
) : (
<div className="text-center">No modules found</div>
)
) : (
<Spinner />
)}
</CustomSelect>
</div>
</div>
);
};

View File

@ -10,7 +10,7 @@ import { UserIcon } from "@heroicons/react/24/outline";
// services // services
import issuesServices from "services/issues.service"; import issuesServices from "services/issues.service";
// components // components
import IssuesListModal from "components/project/issues/issues-list-modal"; import { ParentIssuesListModal } from "components/issues";
// icons // icons
// types // types
import { IIssue, UserAuth } from "types"; import { IIssue, UserAuth } from "types";
@ -26,7 +26,7 @@ type Props = {
userAuth: UserAuth; userAuth: UserAuth;
}; };
const SelectParent: React.FC<Props> = ({ export const SidebarParentSelect: React.FC<Props> = ({
control, control,
submitChanges, submitChanges,
issuesList, issuesList,
@ -61,7 +61,7 @@ const SelectParent: React.FC<Props> = ({
control={control} control={control}
name="parent" name="parent"
render={({ field: { value, onChange } }) => ( render={({ field: { value, onChange } }) => (
<IssuesListModal <ParentIssuesListModal
isOpen={isParentModalOpen} isOpen={isParentModalOpen}
handleClose={() => setIsParentModalOpen(false)} handleClose={() => setIsParentModalOpen(false)}
onChange={(val) => { onChange={(val) => {
@ -93,5 +93,3 @@ const SelectParent: React.FC<Props> = ({
</div> </div>
); );
}; };
export default SelectParent;

View File

@ -1,17 +1,16 @@
// react
import React from "react"; import React from "react";
// react-hook-form // react-hook-form
import { Control, Controller, UseFormWatch } from "react-hook-form"; import { Control, Controller } from "react-hook-form";
// ui // ui
import { ChartBarIcon } from "@heroicons/react/24/outline";
import { CustomSelect } from "components/ui"; import { CustomSelect } from "components/ui";
// icons // icons
import { ChartBarIcon } from "@heroicons/react/24/outline";
import { getPriorityIcon } from "components/icons/priority-icon";
// types // types
import { IIssue, UserAuth } from "types"; import { IIssue, UserAuth } from "types";
// common
// constants // constants
import { getPriorityIcon } from "constants/global"; import { PRIORITIES } from "constants/project";
import { PRIORITIES } from "constants/";
type Props = { type Props = {
control: Control<IIssue, any>; control: Control<IIssue, any>;
@ -19,7 +18,7 @@ type Props = {
userAuth: UserAuth; userAuth: UserAuth;
}; };
const SelectPriority: React.FC<Props> = ({ control, submitChanges, userAuth }) => { export const SidebarPrioritySelect: React.FC<Props> = ({ control, submitChanges, userAuth }) => {
const isNotAllowed = userAuth.isGuest || userAuth.isViewer; const isNotAllowed = userAuth.isGuest || userAuth.isViewer;
return ( return (
@ -65,5 +64,3 @@ const SelectPriority: React.FC<Props> = ({ control, submitChanges, userAuth }) =
</div> </div>
); );
}; };
export default SelectPriority;

View File

@ -22,7 +22,7 @@ type Props = {
userAuth: UserAuth; userAuth: UserAuth;
}; };
const SelectState: React.FC<Props> = ({ control, submitChanges, userAuth }) => { export const SidebarStateSelect: React.FC<Props> = ({ control, submitChanges, userAuth }) => {
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId } = router.query; const { workspaceSlug, projectId } = router.query;
@ -100,5 +100,3 @@ const SelectState: React.FC<Props> = ({ control, submitChanges, userAuth }) => {
</div> </div>
); );
}; };
export default SelectState;

View File

@ -1,4 +1,4 @@
import React, { useState } from "react"; import React, { useCallback, useState } from "react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
@ -14,18 +14,21 @@ import { Popover, Listbox, Transition } from "@headlessui/react";
import useToast from "hooks/use-toast"; import useToast from "hooks/use-toast";
// services // services
import issuesServices from "services/issues.service"; import issuesServices from "services/issues.service";
import modulesService from "services/modules.service";
// components // components
import ConfirmIssueDeletion from "components/project/issues/confirm-issue-deletion"; import {
import SelectState from "components/project/issues/issue-detail/issue-detail-sidebar/select-state"; DeleteIssueModal,
import SelectPriority from "components/project/issues/issue-detail/issue-detail-sidebar/select-priority"; SidebarAssigneeSelect,
import SelectParent from "components/project/issues/issue-detail/issue-detail-sidebar/select-parent"; SidebarBlockedSelect,
import SelectCycle from "components/project/issues/issue-detail/issue-detail-sidebar/select-cycle"; SidebarBlockerSelect,
import SelectAssignee from "components/project/issues/issue-detail/issue-detail-sidebar/select-assignee"; SidebarCycleSelect,
import SelectBlocker from "components/project/issues/issue-detail/issue-detail-sidebar/select-blocker"; SidebarModuleSelect,
import SelectBlocked from "components/project/issues/issue-detail/issue-detail-sidebar/select-blocked"; SidebarParentSelect,
SidebarPrioritySelect,
SidebarStateSelect,
} from "components/issues";
// ui // ui
import { Input, Button, Spinner, CustomDatePicker } from "components/ui"; import { Input, Button, Spinner, CustomDatePicker } from "components/ui";
import DatePicker from "react-datepicker";
// icons // icons
import { import {
TagIcon, TagIcon,
@ -39,12 +42,10 @@ import {
// helpers // helpers
import { copyTextToClipboard } from "helpers/string.helper"; import { copyTextToClipboard } from "helpers/string.helper";
// types // types
import type { ICycle, IIssue, IIssueLabels, UserAuth } from "types"; import type { ICycle, IIssue, IIssueLabels, IModule, UserAuth } from "types";
// fetch-keys // fetch-keys
import { PROJECT_ISSUE_LABELS, PROJECT_ISSUES_LIST, ISSUE_DETAILS } from "constants/fetch-keys"; import { PROJECT_ISSUE_LABELS, PROJECT_ISSUES_LIST, ISSUE_DETAILS } from "constants/fetch-keys";
import "react-datepicker/dist/react-datepicker.css";
type Props = { type Props = {
control: Control<IIssue, any>; control: Control<IIssue, any>;
submitChanges: (formData: Partial<IIssue>) => void; submitChanges: (formData: Partial<IIssue>) => void;
@ -55,10 +56,10 @@ type Props = {
const defaultValues: Partial<IIssueLabels> = { const defaultValues: Partial<IIssueLabels> = {
name: "", name: "",
colour: "#ff0000", color: "#ff0000",
}; };
const IssueDetailSidebar: React.FC<Props> = ({ export const IssueDetailsSidebar: React.FC<Props> = ({
control, control,
submitChanges, submitChanges,
issueDetail, issueDetail,
@ -112,26 +113,44 @@ const IssueDetailSidebar: React.FC<Props> = ({
}); });
}; };
const handleCycleChange = (cycleDetail: ICycle) => { const handleCycleChange = useCallback(
if (!workspaceSlug || !projectId || !issueDetail) return; (cycleDetail: ICycle) => {
if (!workspaceSlug || !projectId || !issueDetail) return;
issuesServices issuesServices
.addIssueToCycle(workspaceSlug as string, projectId as string, cycleDetail.id, { .addIssueToCycle(workspaceSlug as string, projectId as string, cycleDetail.id, {
issues: [issueDetail.id], issues: [issueDetail.id],
}) })
.then((res) => { .then((res) => {
mutate(ISSUE_DETAILS(issueId as string)); 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; const isNotAllowed = userAuth.isGuest || userAuth.isViewer;
return ( return (
<> <>
<ConfirmIssueDeletion <DeleteIssueModal
handleClose={() => setDeleteIssueModal(false)} handleClose={() => setDeleteIssueModal(false)}
isOpen={deleteIssueModal} isOpen={deleteIssueModal}
data={issueDetail} data={issueDetail ?? null}
/> />
<div className="w-full divide-y-2 divide-gray-100 sticky top-5"> <div className="w-full divide-y-2 divide-gray-100 sticky top-5">
<div className="flex items-center justify-between pb-3"> <div className="flex items-center justify-between pb-3">
@ -175,12 +194,24 @@ const IssueDetailSidebar: React.FC<Props> = ({
</div> </div>
<div className="divide-y-2 divide-gray-100"> <div className="divide-y-2 divide-gray-100">
<div className="py-1"> <div className="py-1">
<SelectState control={control} submitChanges={submitChanges} userAuth={userAuth} /> <SidebarStateSelect
<SelectAssignee control={control} submitChanges={submitChanges} userAuth={userAuth} /> control={control}
<SelectPriority control={control} submitChanges={submitChanges} userAuth={userAuth} /> submitChanges={submitChanges}
userAuth={userAuth}
/>
<SidebarAssigneeSelect
control={control}
submitChanges={submitChanges}
userAuth={userAuth}
/>
<SidebarPrioritySelect
control={control}
submitChanges={submitChanges}
userAuth={userAuth}
/>
</div> </div>
<div className="py-1"> <div className="py-1">
<SelectParent <SidebarParentSelect
control={control} control={control}
submitChanges={submitChanges} submitChanges={submitChanges}
issuesList={ issuesList={
@ -210,13 +241,13 @@ const IssueDetailSidebar: React.FC<Props> = ({
watch={watchIssue} watch={watchIssue}
userAuth={userAuth} userAuth={userAuth}
/> />
<SelectBlocker <SidebarBlockerSelect
submitChanges={submitChanges} submitChanges={submitChanges}
issuesList={issues?.results.filter((i) => i.id !== issueDetail?.id) ?? []} issuesList={issues?.results.filter((i) => i.id !== issueDetail?.id) ?? []}
watch={watchIssue} watch={watchIssue}
userAuth={userAuth} userAuth={userAuth}
/> />
<SelectBlocked <SidebarBlockedSelect
submitChanges={submitChanges} submitChanges={submitChanges}
issuesList={issues?.results.filter((i) => i.id !== issueDetail?.id) ?? []} issuesList={issues?.results.filter((i) => i.id !== issueDetail?.id) ?? []}
watch={watchIssue} watch={watchIssue}
@ -247,11 +278,16 @@ const IssueDetailSidebar: React.FC<Props> = ({
</div> </div>
</div> </div>
<div className="py-1"> <div className="py-1">
<SelectCycle <SidebarCycleSelect
issueDetail={issueDetail} issueDetail={issueDetail}
handleCycleChange={handleCycleChange} handleCycleChange={handleCycleChange}
userAuth={userAuth} userAuth={userAuth}
/> />
<SidebarModuleSelect
issueDetail={issueDetail}
handleModuleChange={handleModuleChange}
userAuth={userAuth}
/>
</div> </div>
</div> </div>
<div className="space-y-3 pt-3"> <div className="space-y-3 pt-3">
@ -280,7 +316,7 @@ const IssueDetailSidebar: React.FC<Props> = ({
> >
<span <span
className="h-2 w-2 flex-shrink-0 rounded-full" className="h-2 w-2 flex-shrink-0 rounded-full"
style={{ backgroundColor: singleLabel?.colour ?? "green" }} style={{ backgroundColor: singleLabel?.color ?? "green" }}
/> />
{singleLabel.name} {singleLabel.name}
<XMarkIcon className="h-2 w-2 group-hover:text-red-500" /> <XMarkIcon className="h-2 w-2 group-hover:text-red-500" />
@ -336,7 +372,7 @@ const IssueDetailSidebar: React.FC<Props> = ({
> >
<span <span
className="h-2 w-2 flex-shrink-0 rounded-full" className="h-2 w-2 flex-shrink-0 rounded-full"
style={{ backgroundColor: label.colour ?? "green" }} style={{ backgroundColor: label.color ?? "green" }}
/> />
{label.name} {label.name}
</Listbox.Option> </Listbox.Option>
@ -386,11 +422,11 @@ const IssueDetailSidebar: React.FC<Props> = ({
<Popover.Button <Popover.Button
className={`flex items-center gap-1 rounded-md bg-white p-1 outline-none focus:ring-2 focus:ring-indigo-500`} className={`flex items-center gap-1 rounded-md bg-white p-1 outline-none focus:ring-2 focus:ring-indigo-500`}
> >
{watch("colour") && watch("colour") !== "" && ( {watch("color") && watch("color") !== "" && (
<span <span
className="h-5 w-5 rounded" className="h-5 w-5 rounded"
style={{ style={{
backgroundColor: watch("colour") ?? "green", backgroundColor: watch("color") ?? "green",
}} }}
/> />
)} )}
@ -408,7 +444,7 @@ const IssueDetailSidebar: React.FC<Props> = ({
> >
<Popover.Panel className="absolute right-0 bottom-8 z-10 mt-1 max-w-xs transform px-2 sm:px-0"> <Popover.Panel className="absolute right-0 bottom-8 z-10 mt-1 max-w-xs transform px-2 sm:px-0">
<Controller <Controller
name="colour" name="color"
control={controlLabel} control={controlLabel}
render={({ field: { value, onChange } }) => ( render={({ field: { value, onChange } }) => (
<TwitterPicker <TwitterPicker
@ -446,5 +482,3 @@ const IssueDetailSidebar: React.FC<Props> = ({
</> </>
); );
}; };
export default IssueDetailSidebar;

View File

@ -10,6 +10,8 @@ import { Combobox, Dialog, Transition } from "@headlessui/react";
import { RectangleStackIcon, MagnifyingGlassIcon } from "@heroicons/react/24/outline"; import { RectangleStackIcon, MagnifyingGlassIcon } from "@heroicons/react/24/outline";
// services // services
import issuesServices from "services/issues.service"; import issuesServices from "services/issues.service";
// helpers
import { orderArrayBy } from "helpers/array.helper";
// types // types
import { IIssue, IssueResponse } from "types"; import { IIssue, IssueResponse } from "types";
// constants // constants
@ -17,11 +19,11 @@ import { PROJECT_ISSUES_LIST, SUB_ISSUES } from "constants/fetch-keys";
type Props = { type Props = {
isOpen: boolean; isOpen: boolean;
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>; handleClose: () => void;
parent: IIssue | undefined; parent: IIssue | undefined;
}; };
const AddAsSubIssue: React.FC<Props> = ({ isOpen, setIsOpen, parent }) => { export const SubIssuesListModal: React.FC<Props> = ({ isOpen, handleClose, parent }) => {
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const router = useRouter(); const router = useRouter();
@ -43,15 +45,28 @@ const AddAsSubIssue: React.FC<Props> = ({ isOpen, setIsOpen, parent }) => {
[]; [];
const handleCommandPaletteClose = () => { const handleCommandPaletteClose = () => {
setIsOpen(false); handleClose();
setQuery(""); setQuery("");
}; };
const addAsSubIssue = (issueId: string) => { const addAsSubIssue = (issue: IIssue) => {
if (!workspaceSlug || !projectId) return; if (!workspaceSlug || !projectId) return;
mutate<IIssue[]>(
SUB_ISSUES(parent?.id ?? ""),
(prevData) => {
let newSubIssues = [...(prevData as IIssue[])];
newSubIssues.push(issue);
newSubIssues = orderArrayBy(newSubIssues, "created_at", "descending");
return newSubIssues;
},
false
);
issuesServices 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) => { .then((res) => {
mutate(SUB_ISSUES(parent?.id ?? "")); mutate(SUB_ISSUES(parent?.id ?? ""));
mutate<IssueResponse>( mutate<IssueResponse>(
@ -146,12 +161,12 @@ const AddAsSubIssue: React.FC<Props> = ({ isOpen, setIsOpen, parent }) => {
}` }`
} }
onClick={() => { onClick={() => {
addAsSubIssue(issue.id); addAsSubIssue(issue);
setIsOpen(false); handleClose();
}} }}
> >
<span <span
className={`block h-1.5 w-1.5 rounded-full`} className="block flex-shrink-0 h-1.5 w-1.5 rounded-full"
style={{ style={{
backgroundColor: issue.state_detail.color, backgroundColor: issue.state_detail.color,
}} }}
@ -188,5 +203,3 @@ const AddAsSubIssue: React.FC<Props> = ({ isOpen, setIsOpen, parent }) => {
</Transition.Root> </Transition.Root>
); );
}; };
export default AddAsSubIssue;

View File

@ -4,8 +4,7 @@ import { Disclosure, Transition } from "@headlessui/react";
import { ChevronRightIcon, PlusIcon } from "@heroicons/react/24/outline"; import { ChevronRightIcon, PlusIcon } from "@heroicons/react/24/outline";
// components // components
import { CustomMenu } from "components/ui"; import { CustomMenu } from "components/ui";
import { CreateUpdateIssueModal } from "components/issues"; import { CreateUpdateIssueModal, SubIssuesListModal } from "components/issues";
import AddAsSubIssue from "components/project/issues/issue-detail/add-as-sub-issue";
// types // types
import { IIssue, UserAuth } from "types"; import { IIssue, UserAuth } from "types";
@ -18,7 +17,7 @@ export interface SubIssueListProps {
userAuth: UserAuth; userAuth: UserAuth;
} }
export const SubIssueList: FC<SubIssueListProps> = ({ export const SubIssuesList: FC<SubIssueListProps> = ({
issues = [], issues = [],
handleSubIssueRemove, handleSubIssueRemove,
parentIssue, parentIssue,
@ -28,7 +27,7 @@ export const SubIssueList: FC<SubIssueListProps> = ({
}) => { }) => {
// states // states
const [isIssueModalActive, setIssueModalActive] = useState(false); const [isIssueModalActive, setIssueModalActive] = useState(false);
const [isSubIssueModalActive, setSubIssueModalActive] = useState(false); const [subIssuesListModal, setSubIssuesListModal] = useState(false);
const [preloadedData, setPreloadedData] = useState<Partial<IIssue> | null>(null); const [preloadedData, setPreloadedData] = useState<Partial<IIssue> | null>(null);
const openIssueModal = () => { const openIssueModal = () => {
@ -40,11 +39,11 @@ export const SubIssueList: FC<SubIssueListProps> = ({
}; };
const openSubIssueModal = () => { const openSubIssueModal = () => {
setSubIssueModalActive(true); setSubIssuesListModal(true);
}; };
const closeSubIssueModal = () => { const closeSubIssueModal = () => {
setSubIssueModalActive(false); setSubIssuesListModal(false);
}; };
const isNotAllowed = userAuth.isGuest || userAuth.isViewer; const isNotAllowed = userAuth.isGuest || userAuth.isViewer;
@ -56,9 +55,9 @@ export const SubIssueList: FC<SubIssueListProps> = ({
prePopulateData={{ ...preloadedData }} prePopulateData={{ ...preloadedData }}
handleClose={closeIssueModal} handleClose={closeIssueModal}
/> />
<AddAsSubIssue <SubIssuesListModal
isOpen={isSubIssueModalActive} isOpen={subIssuesListModal}
setIsOpen={setSubIssueModalActive} handleClose={() => setSubIssuesListModal(false)}
parent={parentIssue} parent={parentIssue}
/> />
<Disclosure defaultOpen={true}> <Disclosure defaultOpen={true}>
@ -88,7 +87,7 @@ export const SubIssueList: FC<SubIssueListProps> = ({
<CustomMenu ellipsis> <CustomMenu ellipsis>
<CustomMenu.MenuItem <CustomMenu.MenuItem
onClick={() => { onClick={() => {
setSubIssueModalActive(true); setSubIssuesListModal(true);
}} }}
> >
Add an existing issue Add an existing issue
@ -114,7 +113,7 @@ export const SubIssueList: FC<SubIssueListProps> = ({
<Link href={`/${workspaceSlug}/projects/${projectId}/issues/${issue.id}`}> <Link href={`/${workspaceSlug}/projects/${projectId}/issues/${issue.id}`}>
<a className="flex items-center gap-2 rounded text-xs"> <a className="flex items-center gap-2 rounded text-xs">
<span <span
className={`block h-1.5 w-1.5 rounded-full`} className="block flex-shrink-0 h-1.5 w-1.5 rounded-full"
style={{ style={{
backgroundColor: issue.state_detail.color, backgroundColor: issue.state_detail.color,
}} }}

View File

@ -0,0 +1,106 @@
import React from "react";
import { useRouter } from "next/router";
import useSWR from "swr";
// headless ui
import { Listbox, Transition } from "@headlessui/react";
// services
import projectService from "services/project.service";
// ui
import { AssigneesList, Avatar } from "components/ui";
// types
import { IIssue } from "types";
// fetch-keys
import { PROJECT_MEMBERS } from "constants/fetch-keys";
type Props = {
issue: IIssue;
partialUpdateIssue: (formData: Partial<IIssue>) => void;
position?: "left" | "right";
isNotAllowed: boolean;
};
export const ViewAssigneeSelect: React.FC<Props> = ({
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 (
<Listbox
as="div"
value={issue.assignees}
onChange={(data: any) => {
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 }) => (
<div>
<Listbox.Button>
<div
className={`flex ${
isNotAllowed ? "cursor-not-allowed" : "cursor-pointer"
} items-center gap-1 text-xs`}
>
<AssigneesList userIds={issue.assignees ?? []} />
</div>
</Listbox.Button>
<Transition
show={open}
as={React.Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Listbox.Options
className={`absolute z-10 mt-1 max-h-48 overflow-auto rounded-md bg-white py-1 text-xs shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none ${
position === "left" ? "left-0" : "right-0"
}`}
>
{members?.map((member) => (
<Listbox.Option
key={member.member.id}
className={({ active, selected }) =>
`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}
>
<Avatar user={member.member} />
{member.member.first_name && member.member.first_name !== ""
? member.member.first_name
: member.member.email}
</Listbox.Option>
))}
</Listbox.Options>
</Transition>
</div>
)}
</Listbox>
);
};

View File

@ -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<IIssue>) => void;
isNotAllowed: boolean;
};
export const ViewDueDateSelect: React.FC<Props> = ({ issue, partialUpdateIssue, isNotAllowed }) => (
<div
className={`group relative ${
issue.target_date === null
? ""
: issue.target_date < new Date().toISOString()
? "text-red-600"
: findHowManyDaysLeft(issue.target_date) <= 3 && "text-orange-400"
}`}
>
<CustomDatePicker
placeholder="N/A"
value={issue?.target_date}
onChange={(val) =>
partialUpdateIssue({
target_date: val,
})
}
className={issue?.target_date ? "w-[6.5rem]" : "w-[3rem] text-center"}
disabled={isNotAllowed}
/>
</div>
);

View File

@ -0,0 +1,4 @@
export * from "./assignee";
export * from "./due-date";
export * from "./priority";
export * from "./state";

View File

@ -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<IIssue>) => void;
position?: "left" | "right";
isNotAllowed: boolean;
};
export const ViewPrioritySelect: React.FC<Props> = ({
issue,
partialUpdateIssue,
position = "right",
isNotAllowed,
}) => (
<Listbox
as="div"
value={issue.priority}
onChange={(data: string) => {
partialUpdateIssue({ priority: data });
}}
className="group relative flex-shrink-0"
disabled={isNotAllowed}
>
{({ open }) => (
<div>
<Listbox.Button
className={`flex ${
isNotAllowed ? "cursor-not-allowed" : "cursor-pointer"
} items-center gap-x-2 rounded px-2 py-0.5 capitalize shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 ${
issue.priority === "urgent"
? "bg-red-100 text-red-600"
: issue.priority === "high"
? "bg-orange-100 text-orange-500"
: issue.priority === "medium"
? "bg-yellow-100 text-yellow-500"
: issue.priority === "low"
? "bg-green-100 text-green-500"
: "bg-gray-100"
}`}
>
{getPriorityIcon(
issue.priority && issue.priority !== "" ? issue.priority ?? "" : "None",
"text-sm"
)}
</Listbox.Button>
<Transition
show={open}
as={React.Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Listbox.Options
className={`absolute z-10 mt-1 max-h-48 w-36 overflow-auto rounded-md bg-white py-1 text-xs shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none ${
position === "left" ? "left-0" : "right-0"
}`}
>
{PRIORITIES?.map((priority) => (
<Listbox.Option
key={priority}
className={({ active }) =>
`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"}
</Listbox.Option>
))}
</Listbox.Options>
</Transition>
</div>
)}
</Listbox>
);

View File

@ -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<IIssue>) => void;
position?: "left" | "right";
isNotAllowed: boolean;
};
export const ViewStateSelect: React.FC<Props> = ({
issue,
partialUpdateIssue,
position,
isNotAllowed,
}) => {
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { data: states } = useSWR<IState[]>(
workspaceSlug && projectId ? STATE_LIST(projectId as string) : null,
workspaceSlug
? () => stateService.getStates(workspaceSlug as string, projectId as string)
: null
);
return (
<CustomSelect
label={
<>
<span
className="h-1.5 w-1.5 flex-shrink-0 rounded-full"
style={{
backgroundColor: states?.find((s) => 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) => (
<CustomSelect.Option key={state.id} value={state.id}>
<>
<span
className="h-1.5 w-1.5 flex-shrink-0 rounded-full"
style={{
backgroundColor: state.color,
}}
/>
{addSpaceIfCamelCase(state.name)}
</>
</CustomSelect.Option>
))}
</CustomSelect>
);
};

View File

@ -25,7 +25,7 @@ type Props = {
data?: IModule; data?: IModule;
}; };
const ConfirmModuleDeletion: React.FC<Props> = ({ isOpen, setIsOpen, data }) => { export const DeleteModuleModal: React.FC<Props> = ({ isOpen, setIsOpen, data }) => {
const [isDeleteLoading, setIsDeleteLoading] = useState(false); const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const router = useRouter(); const router = useRouter();
@ -152,5 +152,3 @@ const ConfirmModuleDeletion: React.FC<Props> = ({ isOpen, setIsOpen, data }) =>
</Transition.Root> </Transition.Root>
); );
}; };
export default ConfirmModuleDeletion;

View File

@ -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<IModule>) => void;
handleClose: () => void;
status: boolean;
};
const defaultValues: Partial<IModule> = {
name: "",
description: "",
status: null,
lead: null,
members_list: [],
};
export const ModuleForm: React.FC<Props> = ({ handleFormSubmit, handleClose, status }) => {
const {
register,
formState: { errors, isSubmitting },
handleSubmit,
control,
reset,
} = useForm<IModule>({
defaultValues,
});
const handleCreateUpdateModule = async (formData: Partial<IModule>) => {
await handleFormSubmit(formData);
reset({
...defaultValues,
});
};
return (
<form onSubmit={handleSubmit(handleCreateUpdateModule)}>
<div className="space-y-5">
<h3 className="text-lg font-medium leading-6 text-gray-900">
{status ? "Update" : "Create"} Module
</h3>
<div className="space-y-3">
<div>
<Input
id="name"
label="Name"
name="name"
type="name"
placeholder="Enter name"
autoComplete="off"
error={errors.name}
register={register}
validations={{
required: "Name is required",
maxLength: {
value: 255,
message: "Name should be less than 255 characters",
},
}}
/>
</div>
<div>
<TextArea
id="description"
name="description"
label="Description"
placeholder="Enter description"
error={errors.description}
register={register}
/>
</div>
<div className="flex gap-x-2">
<div className="w-full">
<h6 className="text-gray-500">Start Date</h6>
<div className="w-full">
<Controller
control={control}
name="start_date"
render={({ field: { value, onChange } }) => (
<CustomDatePicker renderAs="input" value={value} onChange={onChange} />
)}
/>
</div>
</div>
<div className="w-full">
<h6 className="text-gray-500">Target Date</h6>
<div className="w-full">
<Controller
control={control}
name="target_date"
render={({ field: { value, onChange } }) => (
<CustomDatePicker renderAs="input" value={value} onChange={onChange} />
)}
/>
</div>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<ModuleStatusSelect control={control} error={errors.status} />
<ModuleLeadSelect control={control} />
<ModuleMembersSelect control={control} />
</div>
</div>
</div>
<div className="mt-5 flex justify-end gap-2">
<Button theme="secondary" onClick={handleClose}>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting}>
{status
? isSubmitting
? "Updating Module..."
: "Update Module"
: isSubmitting
? "Creating Module..."
: "Create Module"}
</Button>
</div>
</form>
);
};

View File

@ -0,0 +1,8 @@
export * from "./select";
export * from "./sidebar-select";
export * from "./delete-module-modal";
export * from "./form";
export * from "./modal";
export * from "./module-link-modal";
export * from "./sidebar";
export * from "./single-module-card";

View File

@ -0,0 +1,168 @@
import React, { useEffect } from "react";
import { useRouter } from "next/router";
import { mutate } from "swr";
// react-hook-form
import { useForm } from "react-hook-form";
// headless ui
import { Dialog, Transition } from "@headlessui/react";
// components
import { ModuleForm } from "components/modules";
// services
import modulesService from "services/modules.service";
// hooks
import useToast from "hooks/use-toast";
// helpers
import { renderDateFormat } from "helpers/date-time.helper";
// types
import type { IModule } from "types";
// fetch-keys
import { MODULE_LIST } from "constants/fetch-keys";
type Props = {
isOpen: boolean;
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
data?: IModule;
};
const defaultValues: Partial<IModule> = {
name: "",
description: "",
status: null,
lead: null,
members_list: [],
};
export const CreateUpdateModuleModal: React.FC<Props> = ({ isOpen, setIsOpen, data }) => {
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { setToastAlert } = useToast();
const handleClose = () => {
setIsOpen(false);
reset(defaultValues);
};
const { reset, setError } = useForm<IModule>({
defaultValues,
});
const createModule = async (payload: Partial<IModule>) => {
await modulesService
.createModule(workspaceSlug as string, projectId as string, payload)
.then(() => {
mutate(MODULE_LIST(projectId as string));
handleClose();
setToastAlert({
title: "Success",
type: "success",
message: "Module created successfully",
});
})
.catch((err) => {
Object.keys(err).map((key) => {
setError(key as keyof typeof defaultValues, {
message: err[key].join(", "),
});
});
});
};
const updateModule = async (payload: Partial<IModule>) => {
await modulesService
.updateModule(workspaceSlug as string, projectId as string, data?.id ?? "", payload)
.then((res) => {
mutate<IModule[]>(
MODULE_LIST(projectId as string),
(prevData) => {
const newData = prevData?.map((item) => {
if (item.id === res.id) {
return res;
}
return item;
});
return newData;
},
false
);
handleClose();
setToastAlert({
title: "Success",
type: "success",
message: "Module updated successfully",
});
})
.catch((err) => {
Object.keys(err).map((key) => {
setError(key as keyof typeof defaultValues, {
message: err[key].join(", "),
});
});
});
};
const handleFormSubmit = async (formData: Partial<IModule>) => {
if (!workspaceSlug || !projectId) return;
const payload: Partial<IModule> = {
...formData,
};
if (!data) await createModule(payload);
else await updateModule(payload);
};
useEffect(() => {
if (data) {
setIsOpen(true);
reset(data);
} else {
reset(defaultValues);
}
}, [data, setIsOpen, reset]);
return (
<Transition.Root show={isOpen} as={React.Fragment}>
<Dialog as="div" className="relative z-20" onClose={handleClose}>
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-20 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4 text-center sm:p-0">
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="relative transform rounded-lg bg-white px-5 py-8 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-2xl sm:p-6">
<ModuleForm
handleFormSubmit={handleFormSubmit}
handleClose={handleClose}
status={data ? true : false}
/>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
};

View File

@ -4,18 +4,18 @@ import { useRouter } from "next/router";
import { mutate } from "swr"; import { mutate } from "swr";
// react-hook-form
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
// headless ui
import { Dialog, Transition } from "@headlessui/react"; import { Dialog, Transition } from "@headlessui/react";
// hooks
// types
import type { IModule, ModuleLink } from "types";
// services // services
import modulesService from "services/modules.service"; import modulesService from "services/modules.service";
// ui // ui
import { Button, Input } from "components/ui"; import { Button, Input } from "components/ui";
// types
import type { IModule, ModuleLink } from "types";
// fetch-keys // fetch-keys
import { MODULE_LIST } from "constants/fetch-keys"; import { MODULE_DETAILS } from "constants/fetch-keys";
type Props = { type Props = {
isOpen: boolean; isOpen: boolean;
@ -28,7 +28,7 @@ const defaultValues: ModuleLink = {
url: "", url: "",
}; };
const ModuleLinkModal: React.FC<Props> = ({ isOpen, module, handleClose }) => { export const ModuleLinkModal: React.FC<Props> = ({ isOpen, module, handleClose }) => {
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId, moduleId } = router.query; const { workspaceSlug, projectId, moduleId } = router.query;
@ -43,23 +43,18 @@ const ModuleLinkModal: React.FC<Props> = ({ isOpen, module, handleClose }) => {
}); });
const onSubmit = async (formData: ModuleLink) => { const onSubmit = async (formData: ModuleLink) => {
if (!workspaceSlug || !projectId || !module) return; if (!workspaceSlug || !projectId || !moduleId) return;
const previousLinks = module.link_module.map((l) => ({ title: l.title, url: l.url })); const previousLinks = module?.link_module.map((l) => ({ title: l.title, url: l.url }));
const payload: Partial<IModule> = { const payload: Partial<IModule> = {
links_list: [...previousLinks, formData], links_list: [...(previousLinks ?? []), formData],
}; };
await modulesService await modulesService
.patchModule(workspaceSlug as string, projectId as string, module.id, payload) .patchModule(workspaceSlug as string, projectId as string, moduleId as string, payload)
.then(() => { .then((res) => {
mutate<IModule[]>(projectId && MODULE_LIST(projectId as string), (prevData) => mutate(MODULE_DETAILS(moduleId as string));
(prevData ?? []).map((module) => {
if (module.id === moduleId) return { ...module, ...payload };
return module;
})
);
onClose(); onClose();
}) })
.catch((err) => { .catch((err) => {
@ -163,5 +158,3 @@ const ModuleLinkModal: React.FC<Props> = ({ isOpen, module, handleClose }) => {
</Transition.Root> </Transition.Root>
); );
}; };
export default ModuleLinkModal;

View File

@ -0,0 +1,3 @@
export * from "./select-lead";
export * from "./select-members";
export * from "./select-status";

View File

@ -4,16 +4,16 @@ import { useRouter } from "next/router";
import useSWR from "swr"; import useSWR from "swr";
// react-hook-form
import { Controller, Control } from "react-hook-form"; import { Controller, Control } from "react-hook-form";
import { UserIcon } from "@heroicons/react/24/outline"; // services
// import type { Control } from "react-hook-form";
// service
import type { IModule } from "types";
import projectServices from "services/project.service"; import projectServices from "services/project.service";
// ui // ui
import SearchListbox from "components/search-listbox"; import SearchListbox from "components/search-listbox";
// icons // icons
import { UserIcon } from "@heroicons/react/24/outline";
// types // types
import type { IModule } from "types";
// fetch-keys // fetch-keys
import { PROJECT_MEMBERS } from "constants/fetch-keys"; import { PROJECT_MEMBERS } from "constants/fetch-keys";
@ -21,7 +21,7 @@ type Props = {
control: Control<IModule, any>; control: Control<IModule, any>;
}; };
const SelectLead: React.FC<Props> = ({ control }) => { export const ModuleLeadSelect: React.FC<Props> = ({ control }) => {
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId } = router.query; const { workspaceSlug, projectId } = router.query;
@ -55,5 +55,3 @@ const SelectLead: React.FC<Props> = ({ control }) => {
/> />
); );
}; };
export default SelectLead;

View File

@ -4,16 +4,16 @@ import { useRouter } from "next/router";
import useSWR from "swr"; import useSWR from "swr";
// react-hook-form
import { Controller, Control } from "react-hook-form"; import { Controller, Control } from "react-hook-form";
import { UserIcon } from "@heroicons/react/24/outline"; // services
// import type { Control } from "react-hook-form";
// service
import type { IModule } from "types";
import projectServices from "services/project.service"; import projectServices from "services/project.service";
// ui // ui
import SearchListbox from "components/search-listbox"; import SearchListbox from "components/search-listbox";
// icons // icons
import { UserIcon } from "@heroicons/react/24/outline";
// types // types
import type { IModule } from "types";
// fetch-keys // fetch-keys
import { PROJECT_MEMBERS } from "constants/fetch-keys"; import { PROJECT_MEMBERS } from "constants/fetch-keys";
@ -21,7 +21,7 @@ type Props = {
control: Control<IModule, any>; control: Control<IModule, any>;
}; };
const SelectMembers: React.FC<Props> = ({ control }) => { export const ModuleMembersSelect: React.FC<Props> = ({ control }) => {
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId } = router.query; const { workspaceSlug, projectId } = router.query;
@ -56,5 +56,3 @@ const SelectMembers: React.FC<Props> = ({ control }) => {
/> />
); );
}; };
export default SelectMembers;

View File

@ -9,14 +9,14 @@ import { Squares2X2Icon } from "@heroicons/react/24/outline";
// types // types
import type { IModule } from "types"; import type { IModule } from "types";
// constants // constants
import { MODULE_STATUS } from "constants/"; import { MODULE_STATUS } from "constants/module";
type Props = { type Props = {
control: Control<IModule, any>; control: Control<IModule, any>;
error?: FieldError; error?: FieldError;
}; };
const SelectStatus: React.FC<Props> = ({ control, error }) => ( export const ModuleStatusSelect: React.FC<Props> = ({ control, error }) => (
<Controller <Controller
control={control} control={control}
rules={{ required: true }} rules={{ required: true }}
@ -45,4 +45,3 @@ const SelectStatus: React.FC<Props> = ({ control, error }) => (
)} )}
/> />
); );
export default SelectStatus;

View File

@ -0,0 +1,3 @@
export * from "./select-lead";
export * from "./select-members";
export * from "./select-status";

View File

@ -25,7 +25,7 @@ type Props = {
lead: IUserLite | null; lead: IUserLite | null;
}; };
const SelectLead: React.FC<Props> = ({ control, submitChanges, lead }) => { export const SidebarLeadSelect: React.FC<Props> = ({ control, submitChanges, lead }) => {
const router = useRouter(); const router = useRouter();
const { workspaceSlug } = router.query; const { workspaceSlug } = router.query;
@ -164,5 +164,3 @@ const SelectLead: React.FC<Props> = ({ control, submitChanges, lead }) => {
</div> </div>
); );
}; };
export default SelectLead;

View File

@ -23,7 +23,7 @@ type Props = {
submitChanges: (formData: Partial<IModule>) => void; submitChanges: (formData: Partial<IModule>) => void;
}; };
const SelectMembers: React.FC<Props> = ({ control, submitChanges }) => { export const SidebarMembersSelect: React.FC<Props> = ({ control, submitChanges }) => {
const router = useRouter(); const router = useRouter();
const { workspaceSlug } = router.query; const { workspaceSlug } = router.query;
@ -132,5 +132,3 @@ const SelectMembers: React.FC<Props> = ({ control, submitChanges }) => {
</div> </div>
); );
}; };
export default SelectMembers;

View File

@ -10,7 +10,7 @@ import { CustomSelect } from "components/ui";
import { IModule } from "types"; import { IModule } from "types";
// common // common
// constants // constants
import { MODULE_STATUS } from "constants/"; import { MODULE_STATUS } from "constants/module";
type Props = { type Props = {
control: Control<Partial<IModule>, any>; control: Control<Partial<IModule>, any>;
@ -18,7 +18,7 @@ type Props = {
watch: UseFormWatch<Partial<IModule>>; watch: UseFormWatch<Partial<IModule>>;
}; };
const SelectStatus: React.FC<Props> = ({ control, submitChanges, watch }) => ( export const SidebarStatusSelect: React.FC<Props> = ({ control, submitChanges, watch }) => (
<div className="flex flex-wrap items-center py-2"> <div className="flex flex-wrap items-center py-2">
<div className="flex items-center gap-x-2 text-sm sm:basis-1/2"> <div className="flex items-center gap-x-2 text-sm sm:basis-1/2">
<Squares2X2Icon className="h-4 w-4 flex-shrink-0" /> <Squares2X2Icon className="h-4 w-4 flex-shrink-0" />
@ -67,5 +67,3 @@ const SelectStatus: React.FC<Props> = ({ control, submitChanges, watch }) => (
</div> </div>
</div> </div>
); );
export default SelectStatus;

View File

@ -8,6 +8,22 @@ import { mutate } from "swr";
// react-hook-form // react-hook-form
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
// services // services
import modulesService from "services/modules.service";
// hooks
import useToast from "hooks/use-toast";
// components
import {
ModuleLinkModal,
SidebarLeadSelect,
SidebarMembersSelect,
SidebarStatusSelect,
} from "components/modules";
// progress-bar
import { CircularProgressbar } from "react-circular-progressbar";
import "react-circular-progressbar/dist/styles.css";
// ui
import { CustomDatePicker, Loader } from "components/ui";
// icons
import { import {
CalendarDaysIcon, CalendarDaysIcon,
ChartPieIcon, ChartPieIcon,
@ -15,20 +31,6 @@ import {
PlusIcon, PlusIcon,
TrashIcon, TrashIcon,
} from "@heroicons/react/24/outline"; } from "@heroicons/react/24/outline";
import modulesService from "services/modules.service";
// hooks
import useToast from "hooks/use-toast";
// components
import SelectLead from "components/project/modules/module-detail-sidebar/select-lead";
import SelectMembers from "components/project/modules/module-detail-sidebar/select-members";
import SelectStatus from "components/project/modules/module-detail-sidebar/select-status";
import ModuleLinkModal from "components/project/modules/module-link-modal";
//progress-bar
import { CircularProgressbar } from "react-circular-progressbar";
import "react-circular-progressbar/dist/styles.css";
// ui
import { CustomDatePicker, Loader } from "components/ui";
// icons
// helpers // helpers
import { timeAgo } from "helpers/date-time.helper"; import { timeAgo } from "helpers/date-time.helper";
import { copyTextToClipboard } from "helpers/string.helper"; import { copyTextToClipboard } from "helpers/string.helper";
@ -36,9 +38,10 @@ import { groupBy } from "helpers/array.helper";
// types // types
import { IModule, ModuleIssueResponse } from "types"; import { IModule, ModuleIssueResponse } from "types";
// fetch-keys // fetch-keys
import { MODULE_LIST } from "constants/fetch-keys"; import { MODULE_DETAILS } from "constants/fetch-keys";
const defaultValues: Partial<IModule> = { const defaultValues: Partial<IModule> = {
lead: "",
members_list: [], members_list: [],
start_date: null, start_date: null,
target_date: null, target_date: null,
@ -52,7 +55,7 @@ type Props = {
handleDeleteModule: () => void; handleDeleteModule: () => void;
}; };
const ModuleDetailSidebar: React.FC<Props> = ({ export const ModuleDetailsSidebar: React.FC<Props> = ({
module, module,
isOpen, isOpen,
moduleIssues, moduleIssues,
@ -69,14 +72,6 @@ const ModuleDetailSidebar: React.FC<Props> = ({
defaultValues, defaultValues,
}); });
useEffect(() => {
if (module)
reset({
...module,
members_list: module.members_list ?? module.members_detail?.map((m) => m.id),
});
}, [module, reset]);
const groupedIssues = { const groupedIssues = {
backlog: [], backlog: [],
unstarted: [], unstarted: [],
@ -87,29 +82,36 @@ const ModuleDetailSidebar: React.FC<Props> = ({
}; };
const submitChanges = (data: Partial<IModule>) => { const submitChanges = (data: Partial<IModule>) => {
if (!workspaceSlug || !projectId || !module) return; if (!workspaceSlug || !projectId || !moduleId) return;
mutate<IModule[]>( mutate<IModule>(
projectId && MODULE_LIST(projectId as string), MODULE_DETAILS(moduleId as string),
(prevData) => (prevData) => ({
(prevData ?? []).map((module) => { ...(prevData as IModule),
if (module.id === moduleId) return { ...module, ...data }; ...data,
return module; }),
}),
false false
); );
modulesService modulesService
.patchModule(workspaceSlug as string, projectId as string, module.id, data) .patchModule(workspaceSlug as string, projectId as string, moduleId as string, data)
.then((res) => { .then((res) => {
console.log(res); console.log(res);
mutate(MODULE_LIST(projectId as string)); mutate(MODULE_DETAILS(moduleId as string));
}) })
.catch((e) => { .catch((e) => {
console.log(e); console.log(e);
}); });
}; };
useEffect(() => {
if (module)
reset({
...module,
members_list: module.members_list ?? module.members_detail?.map((m) => m.id),
});
}, [module, reset]);
return ( return (
<> <>
<ModuleLinkModal <ModuleLinkModal
@ -161,12 +163,12 @@ const ModuleDetailSidebar: React.FC<Props> = ({
</div> </div>
<div className="divide-y-2 divide-gray-100 text-xs"> <div className="divide-y-2 divide-gray-100 text-xs">
<div className="py-1"> <div className="py-1">
<SelectLead <SidebarLeadSelect
control={control} control={control}
submitChanges={submitChanges} submitChanges={submitChanges}
lead={module.lead_detail} lead={module.lead_detail}
/> />
<SelectMembers control={control} submitChanges={submitChanges} /> <SidebarMembersSelect control={control} submitChanges={submitChanges} />
<div className="flex flex-wrap items-center py-2"> <div className="flex flex-wrap items-center py-2">
<div className="flex items-center gap-x-2 text-sm sm:basis-1/2"> <div className="flex items-center gap-x-2 text-sm sm:basis-1/2">
<ChartPieIcon className="h-4 w-4 flex-shrink-0" /> <ChartPieIcon className="h-4 w-4 flex-shrink-0" />
@ -233,7 +235,11 @@ const ModuleDetailSidebar: React.FC<Props> = ({
</div> </div>
</div> </div>
<div className="py-1"> <div className="py-1">
<SelectStatus control={control} submitChanges={submitChanges} watch={watch} /> <SidebarStatusSelect
control={control}
submitChanges={submitChanges}
watch={watch}
/>
</div> </div>
<div className="py-1"> <div className="py-1">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
@ -302,5 +308,3 @@ const ModuleDetailSidebar: React.FC<Props> = ({
</> </>
); );
}; };
export default ModuleDetailSidebar;

View File

@ -1,23 +1,26 @@
import React, { useState } from "react"; import React, { useState } from "react";
import Link from "next/link"; import Link from "next/link";
import Image from "next/image"; import Image from "next/image";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { CalendarDaysIcon, TrashIcon } from "@heroicons/react/24/outline";
import ConfirmModuleDeletion from "./confirm-module-deletion"; // components
import { DeleteModuleModal } from "components/modules";
// icons // icons
import { CalendarDaysIcon, TrashIcon } from "@heroicons/react/24/outline";
import User from "public/user.png"; import User from "public/user.png";
// helpers // helpers
import { renderShortNumericDateFormat } from "helpers/date-time.helper"; import { renderShortNumericDateFormat } from "helpers/date-time.helper";
// types // types
import { IModule, SelectModuleType } from "types"; import { IModule, SelectModuleType } from "types";
// common // common
import { MODULE_STATUS } from "constants/"; import { MODULE_STATUS } from "constants/module";
type Props = { type Props = {
module: IModule; module: IModule;
}; };
const SingleModuleCard: React.FC<Props> = ({ module }) => { export const SingleModuleCard: React.FC<Props> = ({ module }) => {
const router = useRouter(); const router = useRouter();
const { workspaceSlug } = router.query; const { workspaceSlug } = router.query;
const [moduleDeleteModal, setModuleDeleteModal] = useState(false); const [moduleDeleteModal, setModuleDeleteModal] = useState(false);
@ -40,7 +43,7 @@ const SingleModuleCard: React.FC<Props> = ({ module }) => {
<TrashIcon className="h-4 w-4" /> <TrashIcon className="h-4 w-4" />
</button> </button>
</div> </div>
<ConfirmModuleDeletion <DeleteModuleModal
isOpen={ isOpen={
moduleDeleteModal && moduleDeleteModal &&
!!selectedModuleForDelete && !!selectedModuleForDelete &&
@ -147,5 +150,3 @@ const SingleModuleCard: React.FC<Props> = ({ module }) => {
</div> </div>
); );
}; };
export default SingleModuleCard;

View File

@ -16,10 +16,10 @@ import workspaceService from "services/workspace.service";
import { CustomSelect, Input } from "components/ui"; import { CustomSelect, Input } from "components/ui";
// types // types
import { IWorkspace, IWorkspaceMemberInvitation } from "types"; import { IWorkspace, IWorkspaceMemberInvitation } from "types";
// constants
import { companySize } from "constants/";
// fetch-keys // fetch-keys
import { USER_WORKSPACE_INVITATIONS } from "constants/fetch-keys"; import { USER_WORKSPACE_INVITATIONS } from "constants/fetch-keys";
// constants
import { COMPANY_SIZE } from "constants/workspace";
type Props = { type Props = {
setStep: React.Dispatch<React.SetStateAction<number>>; setStep: React.Dispatch<React.SetStateAction<number>>;
@ -186,7 +186,7 @@ const Workspace: React.FC<Props> = ({ setStep, setWorkspace }) => {
label={value ? value.toString() : "Select company size"} label={value ? value.toString() : "Select company size"}
input input
> >
{companySize?.map((item) => ( {COMPANY_SIZE?.map((item) => (
<CustomSelect.Option key={item.value} value={item.value}> <CustomSelect.Option key={item.value} value={item.value}>
{item.label} {item.label}
</CustomSelect.Option> </CustomSelect.Option>

View File

@ -17,13 +17,13 @@ import { Button, Input, TextArea, CustomSelect } from "components/ui";
// components // components
import EmojiIconPicker from "components/emoji-icon-picker"; import EmojiIconPicker from "components/emoji-icon-picker";
// helpers // helpers
import { getRandomEmoji } from "helpers/functions.helper"; import { getRandomEmoji } from "helpers/common.helper";
// types // types
import { IProject } from "types"; import { IProject } from "types";
// fetch-keys // fetch-keys
import { PROJECTS_LIST, WORKSPACE_MEMBERS_ME } from "constants/fetch-keys"; import { PROJECTS_LIST, WORKSPACE_MEMBERS_ME } from "constants/fetch-keys";
// constants // constants
import { NETWORK_CHOICES } from "constants/"; import { NETWORK_CHOICES } from "constants/project";
type Props = { type Props = {
isOpen: boolean; isOpen: boolean;

View File

@ -1,178 +0,0 @@
import React, { useCallback } from "react";
// swr
import useSWR, { mutate } from "swr";
// services
import { useRouter } from "next/router";
import { DragDropContext, DropResult } from "react-beautiful-dnd";
import stateService from "services/state.service";
// hooks
import useIssuesProperties from "hooks/use-issue-properties";
import useIssueView from "hooks/use-issue-view";
// components
import SingleBoard from "components/project/cycles/board-view/single-board";
// ui
import { Spinner } from "components/ui";
// types
import { CycleIssueResponse, IIssue, IProjectMember, UserAuth } from "types";
import issuesService from "services/issues.service";
// constants
import { STATE_LIST, CYCLE_ISSUES } from "constants/fetch-keys";
type Props = {
issues: IIssue[];
members: IProjectMember[] | undefined;
openCreateIssueModal: (issue?: IIssue, actionType?: "create" | "edit" | "delete") => void;
openIssuesListModal: () => void;
handleDeleteIssue: React.Dispatch<React.SetStateAction<string | undefined>>;
setPreloadedData: React.Dispatch<
React.SetStateAction<
| (Partial<IIssue> & {
actionType: "createIssue" | "edit" | "delete";
})
| null
>
>;
userAuth: UserAuth;
};
const CyclesBoardView: React.FC<Props> = ({
issues,
members,
openCreateIssueModal,
openIssuesListModal,
handleDeleteIssue,
setPreloadedData,
userAuth,
}) => {
const router = useRouter();
const { workspaceSlug, projectId, cycleId } = router.query;
const [properties] = useIssuesProperties(workspaceSlug as string, projectId as string);
const { issueView, groupedByIssues, groupByProperty: selectedGroup } = useIssueView(issues);
const { data: states } = useSWR(
workspaceSlug && projectId ? STATE_LIST(projectId as string) : null,
workspaceSlug && projectId
? () => stateService.getStates(workspaceSlug as string, projectId as string)
: null
);
const handleOnDragEnd = useCallback(
(result: DropResult) => {
if (!result.destination) return;
const { source, destination } = result;
if (source.droppableId !== destination.droppableId) {
const sourceGroup = source.droppableId; // source group id
const destinationGroup = destination.droppableId; // destination group id
if (!sourceGroup || !destinationGroup) return;
// removed/dragged item
const removedItem = groupedByIssues[source.droppableId][source.index];
if (selectedGroup === "priority") {
// update the removed item for mutation
removedItem.priority = destinationGroup;
// patch request
issuesService.patchIssue(workspaceSlug as string, projectId as string, removedItem.id, {
priority: destinationGroup,
});
} 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;
removedItem.state = destinationStateId;
removedItem.state_detail = destinationState;
// patch request
issuesService.patchIssue(workspaceSlug as string, projectId as string, removedItem.id, {
state: destinationStateId,
});
if (!cycleId) return;
mutate<CycleIssueResponse[]>(
CYCLE_ISSUES(cycleId as string),
(prevData) => {
if (!prevData) return prevData;
const updatedIssues = prevData.map((issue) => {
if (issue.issue_detail.id === removedItem.id) {
return {
...issue,
issue_detail: removedItem,
};
}
return issue;
});
return [...updatedIssues];
},
false
);
}
// remove item from the source group
groupedByIssues[source.droppableId].splice(source.index, 1);
// add item to the destination group
groupedByIssues[destination.droppableId].splice(destination.index, 0, removedItem);
}
},
[workspaceSlug, groupedByIssues, projectId, selectedGroup, states, cycleId]
);
if (issueView !== "kanban") return <></>;
return (
<>
{groupedByIssues ? (
<div className="h-[calc(100vh-157px)] lg:h-[calc(100vh-115px)] w-full">
<DragDropContext onDragEnd={handleOnDragEnd}>
<div className="h-full w-full overflow-hidden">
<div className="h-full w-full">
<div className="flex h-full gap-x-4 overflow-x-auto overflow-y-hidden">
{Object.keys(groupedByIssues).map((singleGroup) => (
<SingleBoard
key={singleGroup}
selectedGroup={selectedGroup}
groupTitle={singleGroup}
createdBy={
selectedGroup === "created_by"
? members?.find((m) => m.member.id === singleGroup)?.member.first_name ??
"loading..."
: null
}
groupedByIssues={groupedByIssues}
bgColor={
selectedGroup === "state_detail.name"
? states?.find((s) => s.name === singleGroup)?.color
: "#000000"
}
properties={properties}
openIssuesListModal={openIssuesListModal}
openCreateIssueModal={openCreateIssueModal}
handleDeleteIssue={handleDeleteIssue}
setPreloadedData={setPreloadedData}
stateId={
selectedGroup === "state_detail.name"
? states?.find((s) => s.name === singleGroup)?.id ?? null
: null
}
userAuth={userAuth}
/>
))}
</div>
</div>
</div>
</DragDropContext>
</div>
) : (
<div className="flex h-full w-full items-center justify-center">
<Spinner />
</div>
)}
</>
);
};
export default CyclesBoardView;

View File

@ -1,186 +0,0 @@
import React, { useState } from "react";
import { useRouter } from "next/router";
import useSWR from "swr";
// react-beautiful-dnd
import { Draggable } from "react-beautiful-dnd";
// services
import workspaceService from "services/workspace.service";
// components
import SingleIssue from "components/common/board-view/single-issue";
import StrictModeDroppable from "components/dnd/StrictModeDroppable";
import BoardHeader from "components/common/board-view/board-header";
// ui
import { CustomMenu } from "components/ui";
// icons
import { PlusIcon } from "@heroicons/react/24/outline";
// types
import { IIssue, IWorkspaceMember, NestedKeyOf, Properties, UserAuth } from "types";
// fetch-keys
import { WORKSPACE_MEMBERS } from "constants/fetch-keys";
type Props = {
properties: Properties;
groupedByIssues: {
[key: string]: IIssue[];
};
selectedGroup: NestedKeyOf<IIssue> | null;
groupTitle: string;
createdBy: string | null;
bgColor?: string;
openCreateIssueModal: (issue?: IIssue, actionType?: "create" | "edit" | "delete") => void;
openIssuesListModal: () => void;
handleDeleteIssue: React.Dispatch<React.SetStateAction<string | undefined>>;
setPreloadedData: React.Dispatch<
React.SetStateAction<
| (Partial<IIssue> & {
actionType: "createIssue" | "edit" | "delete";
})
| null
>
>;
stateId: string | null;
userAuth: UserAuth;
};
const SingleModuleBoard: React.FC<Props> = ({
properties,
groupedByIssues,
selectedGroup,
groupTitle,
createdBy,
bgColor,
openCreateIssueModal,
openIssuesListModal,
handleDeleteIssue,
setPreloadedData,
stateId,
userAuth,
}) => {
// collapse/expand
const [isCollapsed, setIsCollapsed] = useState(true);
const router = useRouter();
const { workspaceSlug, cycleId } = router.query;
if (selectedGroup === "priority")
groupTitle === "high"
? (bgColor = "#dc2626")
: groupTitle === "medium"
? (bgColor = "#f97316")
: groupTitle === "low"
? (bgColor = "#22c55e")
: (bgColor = "#ff0000");
const { data: people } = useSWR<IWorkspaceMember[]>(
workspaceSlug ? WORKSPACE_MEMBERS : null,
workspaceSlug ? () => workspaceService.workspaceMembers(workspaceSlug as string) : null
);
return (
<div className={`h-full flex-shrink-0 rounded ${!isCollapsed ? "" : "w-80 border bg-gray-50"}`}>
<div className={`${!isCollapsed ? "" : "flex h-full flex-col space-y-3 overflow-y-auto"}`}>
<BoardHeader
addIssueToState={() => {
openCreateIssueModal();
if (selectedGroup !== null) {
setPreloadedData({
state: stateId !== null ? stateId : undefined,
[selectedGroup]: groupTitle,
actionType: "createIssue",
});
}
}}
bgColor={bgColor ?? ""}
createdBy={createdBy}
groupTitle={groupTitle}
groupedByIssues={groupedByIssues}
isCollapsed={isCollapsed}
setIsCollapsed={setIsCollapsed}
selectedGroup={selectedGroup}
/>
<StrictModeDroppable key={groupTitle} droppableId={groupTitle}>
{(provided, snapshot) => (
<div
className={`mt-3 h-full space-y-3 overflow-y-auto px-3 pb-3 ${
snapshot.isDraggingOver ? "bg-indigo-50 bg-opacity-50" : ""
} ${!isCollapsed ? "hidden" : "block"}`}
{...provided.droppableProps}
ref={provided.innerRef}
>
{groupedByIssues[groupTitle].map((childIssue, index: number) => {
const assignees = [
...(childIssue?.assignees_list ?? []),
...(childIssue?.assignees ?? []),
]?.map((assignee) => {
const tempPerson = people?.find((p) => p.member.id === assignee)?.member;
return tempPerson;
});
return (
<Draggable key={childIssue.id} draggableId={childIssue.id} index={index}>
{(provided, snapshot) => (
<div
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
>
<SingleIssue
type="cycle"
typeId={cycleId as string}
issue={childIssue}
properties={properties}
snapshot={snapshot}
assignees={assignees}
people={people}
handleDeleteIssue={handleDeleteIssue}
userAuth={userAuth}
/>
</div>
)}
</Draggable>
);
})}
{provided.placeholder}
<CustomMenu
label={
<span className="flex items-center gap-1">
<PlusIcon className="h-3 w-3" />
Add issue
</span>
}
className="mt-1"
optionsPosition="left"
noBorder
>
<CustomMenu.MenuItem
onClick={() => {
openCreateIssueModal();
if (selectedGroup !== null) {
setPreloadedData({
state: stateId !== null ? stateId : undefined,
[selectedGroup]: groupTitle,
actionType: "createIssue",
});
}
}}
>
Create new
</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={() => openIssuesListModal()}>
Add an existing issue
</CustomMenu.MenuItem>
</CustomMenu>
</div>
)}
</StrictModeDroppable>
</div>
</div>
);
};
export default SingleModuleBoard;

View File

@ -15,7 +15,7 @@ import cyclesService from "services/cycles.service";
import useToast from "hooks/use-toast"; import useToast from "hooks/use-toast";
// ui // ui
import { Loader, CustomDatePicker } from "components/ui"; import { Loader, CustomDatePicker } from "components/ui";
//progress-bar // progress-bar
import { CircularProgressbar } from "react-circular-progressbar"; import { CircularProgressbar } from "react-circular-progressbar";
import "react-circular-progressbar/dist/styles.css"; import "react-circular-progressbar/dist/styles.css";
// helpers // helpers
@ -24,7 +24,7 @@ import { groupBy } from "helpers/array.helper";
// types // types
import { CycleIssueResponse, ICycle } from "types"; import { CycleIssueResponse, ICycle } from "types";
// fetch-keys // fetch-keys
import { CYCLE_LIST } from "constants/fetch-keys"; import { CYCLE_DETAILS } from "constants/fetch-keys";
type Props = { type Props = {
cycle: ICycle | undefined; cycle: ICycle | undefined;
@ -57,23 +57,19 @@ const CycleDetailSidebar: React.FC<Props> = ({ cycle, isOpen, cycleIssues }) =>
}; };
const submitChanges = (data: Partial<ICycle>) => { const submitChanges = (data: Partial<ICycle>) => {
if (!workspaceSlug || !projectId || !module) return; if (!workspaceSlug || !projectId || !cycleId) return;
mutate<ICycle[]>( mutate<ICycle>(
projectId && CYCLE_LIST(projectId as string), CYCLE_DETAILS(cycleId as string),
(prevData) => (prevData) => ({ ...(prevData as ICycle), ...data }),
(prevData ?? []).map((tempCycle) => {
if (tempCycle.id === cycleId) return { ...tempCycle, ...data };
return tempCycle;
}),
false false
); );
cyclesService cyclesService
.patchCycle(workspaceSlug as string, projectId as string, cycle?.id ?? "", data) .patchCycle(workspaceSlug as string, projectId as string, cycleId as string, data)
.then((res) => { .then((res) => {
console.log(res); console.log(res);
mutate(CYCLE_LIST(projectId as string)); mutate(CYCLE_DETAILS(cycleId as string));
}) })
.catch((e) => { .catch((e) => {
console.log(e); console.log(e);

View File

@ -1,199 +0,0 @@
import React from "react";
import { useRouter } from "next/router";
import useSWR from "swr";
// headless ui
import { Disclosure, Transition } from "@headlessui/react";
// icons
import { PlusIcon, ChevronDownIcon } from "@heroicons/react/20/solid";
// services
import workspaceService from "services/workspace.service";
import stateService from "services/state.service";
// hooks
import useIssuesProperties from "hooks/use-issue-properties";
import useIssueView from "hooks/use-issue-view";
// components
import SingleListIssue from "components/common/list-view/single-issue";
// ui
import { CustomMenu, Spinner } from "components/ui";
// helpers
import { addSpaceIfCamelCase } from "helpers/string.helper";
// types
import { IIssue, IWorkspaceMember, UserAuth } from "types";
// fetch-keys
import { WORKSPACE_MEMBERS, STATE_LIST } from "constants/fetch-keys";
type Props = {
issues: IIssue[];
openCreateIssueModal: (issue?: IIssue, actionType?: "create" | "edit" | "delete") => void;
openIssuesListModal: () => void;
removeIssueFromCycle: (bridgeId: string) => void;
setPreloadedData: React.Dispatch<
React.SetStateAction<
| (Partial<IIssue> & {
actionType: "createIssue" | "edit" | "delete";
})
| null
>
>;
userAuth: UserAuth;
};
const CyclesListView: React.FC<Props> = ({
issues,
openCreateIssueModal,
openIssuesListModal,
removeIssueFromCycle,
setPreloadedData,
userAuth,
}) => {
const router = useRouter();
const { workspaceSlug, projectId, cycleId } = router.query;
const [properties] = useIssuesProperties(workspaceSlug as string, projectId as string);
const { issueView, groupedByIssues, groupByProperty: selectedGroup } = useIssueView(issues);
const { data: people } = useSWR<IWorkspaceMember[]>(
workspaceSlug ? WORKSPACE_MEMBERS : null,
workspaceSlug ? () => workspaceService.workspaceMembers(workspaceSlug as string) : null
);
const { data: states } = useSWR(
workspaceSlug && projectId ? STATE_LIST(projectId as string) : null,
workspaceSlug && projectId
? () => stateService.getStates(workspaceSlug as string, projectId as string)
: null
);
if (issueView !== "list") return <></>;
return (
<div className="flex flex-col space-y-5">
{Object.keys(groupedByIssues).map((singleGroup) => {
const stateId =
selectedGroup === "state_detail.name"
? states?.find((s) => s.name === singleGroup)?.id ?? null
: null;
return (
<Disclosure key={singleGroup} as="div" defaultOpen>
{({ open }) => (
<div className="rounded-lg bg-white">
<div className="rounded-t-lg bg-gray-100 px-4 py-3">
<Disclosure.Button>
<div className="flex items-center gap-x-2">
<span>
<ChevronDownIcon
className={`h-4 w-4 text-gray-500 ${!open ? "-rotate-90 transform" : ""}`}
/>
</span>
{selectedGroup !== null ? (
<h2 className="font-medium capitalize leading-5">
{singleGroup === null || singleGroup === "null"
? selectedGroup === "priority" && "No priority"
: addSpaceIfCamelCase(singleGroup)}
</h2>
) : (
<h2 className="font-medium leading-5">All Issues</h2>
)}
<p className="text-sm text-gray-500">
{groupedByIssues[singleGroup as keyof IIssue].length}
</p>
</div>
</Disclosure.Button>
</div>
<Transition
show={open}
enter="transition duration-100 ease-out"
enterFrom="transform opacity-0"
enterTo="transform opacity-100"
leave="transition duration-75 ease-out"
leaveFrom="transform opacity-100"
leaveTo="transform opacity-0"
>
<Disclosure.Panel>
<div className="divide-y-2">
{groupedByIssues[singleGroup] ? (
groupedByIssues[singleGroup].length > 0 ? (
groupedByIssues[singleGroup].map((issue) => {
const assignees = [
...(issue?.assignees_list ?? []),
...(issue?.assignees ?? []),
]?.map((assignee) => {
const tempPerson = people?.find(
(p) => p.member.id === assignee
)?.member;
return {
avatar: tempPerson?.avatar,
first_name: tempPerson?.first_name,
email: tempPerson?.email,
};
});
return (
<SingleListIssue
key={issue.id}
type="cycle"
typeId={cycleId as string}
issue={issue}
properties={properties}
editIssue={() => openCreateIssueModal(issue, "edit")}
removeIssue={() => removeIssueFromCycle(issue.bridge ?? "")}
userAuth={userAuth}
/>
);
})
) : (
<p className="px-4 py-3 text-sm text-gray-500">No issues.</p>
)
) : (
<div className="flex h-full w-full items-center justify-center">
<Spinner />
</div>
)}
</div>
</Disclosure.Panel>
</Transition>
<div className="p-3">
<CustomMenu
label={
<span className="flex items-center gap-1">
<PlusIcon className="h-3 w-3" />
Add issue
</span>
}
optionsPosition="left"
noBorder
>
<CustomMenu.MenuItem
onClick={() => {
openCreateIssueModal();
if (selectedGroup !== null) {
setPreloadedData({
state: stateId !== null ? stateId : undefined,
[selectedGroup]: singleGroup,
actionType: "createIssue",
});
}
}}
>
Create new
</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={() => openIssuesListModal()}>
Add an existing issue
</CustomMenu.MenuItem>
</CustomMenu>
</div>
</div>
)}
</Disclosure>
);
})}
</div>
);
};
export default CyclesListView;

View File

@ -64,7 +64,7 @@ const CycleStatsView: React.FC<TCycleStatsViewProps> = ({
)} )}
<h3 className="text-gray-500"> <h3 className="text-gray-500">
No {type} {type === "current" ? "cycle" : "cycles"} yet. Create with{" "} No {type} {type === "current" ? "cycle" : "cycles"} yet. Create with{" "}
<pre className="inline rounded bg-gray-100 px-2 py-1">Q</pre>. <pre className="inline rounded bg-gray-200 px-2 py-1">Q</pre>.
</h3> </h3>
</div> </div>
)} )}

View File

@ -1,259 +0,0 @@
import React, { useCallback, useState } from "react";
import { useRouter } from "next/router";
import useSWR, { mutate } from "swr";
// react-beautiful-dnd
import { DragDropContext, DropResult } from "react-beautiful-dnd";
// hook
import useIssuesProperties from "hooks/use-issue-properties";
import useIssueView from "hooks/use-issue-view";
// services
import stateServices from "services/state.service";
import issuesServices from "services/issues.service";
import projectService from "services/project.service";
// components
import SingleBoard from "components/project/issues/BoardView/single-board";
import StrictModeDroppable from "components/dnd/StrictModeDroppable";
import { CreateUpdateIssueModal } from "components/issues/modal";
import ConfirmIssueDeletion from "components/project/issues/confirm-issue-deletion";
// ui
import { Spinner } from "components/ui";
// types
import type { IState, IIssue, IssueResponse, UserAuth } from "types";
// fetch-keys
import { STATE_LIST, PROJECT_ISSUES_LIST, PROJECT_MEMBERS } from "constants/fetch-keys";
type Props = {
issues: IIssue[];
handleDeleteIssue: React.Dispatch<React.SetStateAction<string | undefined>>;
userAuth: UserAuth;
};
const BoardView: React.FC<Props> = ({ issues, handleDeleteIssue, userAuth }) => {
const [createIssueModal, setCreateIssueModal] = useState(false);
const [isIssueDeletionOpen, setIsIssueDeletionOpen] = useState(false);
const [issueDeletionData, setIssueDeletionData] = useState<IIssue | undefined>();
const [preloadedData, setPreloadedData] = useState<
(Partial<IIssue> & { actionType: "createIssue" | "edit" | "delete" }) | undefined
>(undefined);
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { issueView, groupedByIssues, groupByProperty: selectedGroup } = useIssueView(issues);
const { data: states, mutate: mutateState } = useSWR<IState[]>(
workspaceSlug && projectId ? STATE_LIST(projectId as string) : null,
workspaceSlug
? () => stateServices.getStates(workspaceSlug as string, projectId as string)
: null
);
const { data: members } = useSWR(
workspaceSlug && projectId ? PROJECT_MEMBERS(projectId as string) : null,
workspaceSlug && projectId
? () => projectService.projectMembers(workspaceSlug as string, projectId as string)
: null,
{
onErrorRetry(err, _, __, revalidate, revalidateOpts) {
if (err?.status === 403) return;
setTimeout(() => revalidate(revalidateOpts), 5000);
},
}
);
const [properties] = useIssuesProperties(workspaceSlug as string, projectId as string);
const handleOnDragEnd = useCallback(
(result: DropResult) => {
if (!result.destination || !workspaceSlug || !projectId) return;
const { source, destination, type } = result;
if (destination.droppableId === "trashBox") {
// setIssueDeletionData(draggedItem);
setIsIssueDeletionOpen(true);
} else {
if (type === "state") {
const newStates = Array.from(states ?? []);
const [reorderedState] = newStates.splice(source.index, 1);
newStates.splice(destination.index, 0, reorderedState);
const prevSequenceNumber = newStates[destination.index - 1]?.sequence;
const nextSequenceNumber = newStates[destination.index + 1]?.sequence;
const sequenceNumber =
prevSequenceNumber && nextSequenceNumber
? (prevSequenceNumber + nextSequenceNumber) / 2
: nextSequenceNumber
? nextSequenceNumber - 15000 / 2
: prevSequenceNumber
? prevSequenceNumber + 15000 / 2
: 15000;
newStates[destination.index].sequence = sequenceNumber;
mutateState(newStates, false);
stateServices
.patchState(
workspaceSlug as string,
projectId as string,
newStates[destination.index].id,
{
sequence: sequenceNumber,
}
)
.then((response) => {
console.log(response);
})
.catch((err) => {
console.error(err);
});
} else {
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;
// patch request
issuesServices.patchIssue(
workspaceSlug as string,
projectId as string,
draggedItem.id,
{
priority: destinationGroup,
}
);
} 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;
mutate<IssueResponse>(
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
issuesServices
.patchIssue(workspaceSlug as string, projectId as string, draggedItem.id, {
state: destinationStateId,
})
.then((res) => {
mutate(PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string));
});
}
}
}
}
},
[workspaceSlug, mutateState, groupedByIssues, projectId, selectedGroup, states]
);
if (issueView !== "kanban") return <></>;
return (
<>
<ConfirmIssueDeletion
isOpen={isIssueDeletionOpen}
handleClose={() => setIsIssueDeletionOpen(false)}
data={issueDeletionData}
/>
<CreateUpdateIssueModal
isOpen={createIssueModal && preloadedData?.actionType === "createIssue"}
handleClose={() => setCreateIssueModal(false)}
prePopulateData={{
...preloadedData,
}}
/>
{groupedByIssues ? (
<div className="h-[calc(100vh-157px)] lg:h-[calc(100vh-115px)] w-full">
<DragDropContext onDragEnd={handleOnDragEnd}>
<div className="h-full w-full overflow-hidden">
<StrictModeDroppable droppableId="state" type="state" direction="horizontal">
{(provided) => (
<div
className="h-full w-full"
{...provided.droppableProps}
ref={provided.innerRef}
>
<div className="flex h-full gap-x-4 overflow-x-auto overflow-y-hidden">
{Object.keys(groupedByIssues).map((singleGroup, index) => (
<SingleBoard
key={singleGroup}
selectedGroup={selectedGroup}
groupTitle={singleGroup}
createdBy={
selectedGroup === "created_by"
? members?.find((m) => m.member.id === singleGroup)?.member
.first_name ?? "loading..."
: null
}
groupedByIssues={groupedByIssues}
index={index}
setIsIssueOpen={setCreateIssueModal}
properties={properties}
setPreloadedData={setPreloadedData}
stateId={
selectedGroup === "state_detail.name"
? states?.find((s) => s.name === singleGroup)?.id ?? null
: null
}
bgColor={
selectedGroup === "state_detail.name"
? states?.find((s) => s.name === singleGroup)?.color
: "#000000"
}
handleDeleteIssue={handleDeleteIssue}
userAuth={userAuth}
/>
))}
</div>
{provided.placeholder}
</div>
)}
</StrictModeDroppable>
</div>
</DragDropContext>
</div>
) : (
<div className="flex h-full w-full items-center justify-center">
<Spinner />
</div>
)}
</>
);
};
export default BoardView;

View File

@ -1,174 +0,0 @@
import React, { useState } from "react";
import { useRouter } from "next/router";
import useSWR from "swr";
// react-beautiful-dnd
import { Draggable } from "react-beautiful-dnd";
// components
import StrictModeDroppable from "components/dnd/StrictModeDroppable";
import SingleIssue from "components/common/board-view/single-issue";
import BoardHeader from "components/common/board-view/board-header";
// icons
import { PlusIcon } from "@heroicons/react/24/outline";
// services
import workspaceService from "services/workspace.service";
// types
import { IIssue, Properties, NestedKeyOf, IWorkspaceMember, UserAuth } from "types";
// fetch-keys
import { WORKSPACE_MEMBERS } from "constants/fetch-keys";
type Props = {
selectedGroup: NestedKeyOf<IIssue> | null;
groupTitle: string;
groupedByIssues: {
[key: string]: IIssue[];
};
index: number;
setIsIssueOpen: React.Dispatch<React.SetStateAction<boolean>>;
properties: Properties;
setPreloadedData: React.Dispatch<
React.SetStateAction<
| (Partial<IIssue> & {
actionType: "createIssue" | "edit" | "delete";
})
| undefined
>
>;
bgColor?: string;
stateId: string | null;
createdBy: string | null;
handleDeleteIssue: React.Dispatch<React.SetStateAction<string | undefined>>;
userAuth: UserAuth;
};
const SingleBoard: React.FC<Props> = ({
selectedGroup,
groupTitle,
groupedByIssues,
index,
setIsIssueOpen,
properties,
setPreloadedData,
bgColor = "#0f2b16",
stateId,
createdBy,
handleDeleteIssue,
userAuth,
}) => {
// Collapse/Expand
const [isCollapsed, setIsCollapsed] = useState(true);
const router = useRouter();
const { workspaceSlug } = router.query;
if (selectedGroup === "priority")
groupTitle === "high"
? (bgColor = "#dc2626")
: groupTitle === "medium"
? (bgColor = "#f97316")
: groupTitle === "low"
? (bgColor = "#22c55e")
: (bgColor = "#ff0000");
const { data: people } = useSWR<IWorkspaceMember[]>(
workspaceSlug ? WORKSPACE_MEMBERS : null,
workspaceSlug ? () => workspaceService.workspaceMembers(workspaceSlug as string) : null
);
const addIssueToState = () => {
setIsIssueOpen(true);
if (selectedGroup)
setPreloadedData({
state: stateId !== null ? stateId : undefined,
[selectedGroup]: groupTitle,
actionType: "createIssue",
});
};
return (
<Draggable draggableId={groupTitle} index={index}>
{(provided, snapshot) => (
<div
className={`h-full flex-shrink-0 rounded ${
snapshot.isDragging ? "border-theme shadow-lg" : ""
} ${!isCollapsed ? "" : "w-80 border bg-gray-50"}`}
ref={provided.innerRef}
{...provided.draggableProps}
>
<div
className={`${!isCollapsed ? "" : "flex h-full flex-col space-y-3 overflow-y-auto"}`}
>
<BoardHeader
addIssueToState={addIssueToState}
bgColor={bgColor}
createdBy={createdBy}
groupTitle={groupTitle}
groupedByIssues={groupedByIssues}
isCollapsed={isCollapsed}
setIsCollapsed={setIsCollapsed}
selectedGroup={selectedGroup}
provided={provided}
/>
<StrictModeDroppable key={groupTitle} droppableId={groupTitle}>
{(provided, snapshot) => (
<div
className={`mt-3 h-full space-y-3 overflow-y-auto px-3 pb-3 ${
snapshot.isDraggingOver ? "bg-indigo-50 bg-opacity-50" : ""
} ${!isCollapsed ? "hidden" : "block"}`}
{...provided.droppableProps}
ref={provided.innerRef}
>
{groupedByIssues[groupTitle].map((childIssue, index: number) => {
const assignees = [
...(childIssue?.assignees_list ?? []),
...(childIssue?.assignees ?? []),
]?.map((assignee) => {
const tempPerson = people?.find((p) => p.member.id === assignee)?.member;
return tempPerson;
});
return (
<Draggable key={childIssue.id} draggableId={childIssue.id} index={index}>
{(provided, snapshot) => (
<div
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
>
<SingleIssue
issue={childIssue}
properties={properties}
snapshot={snapshot}
people={people}
assignees={assignees}
handleDeleteIssue={handleDeleteIssue}
userAuth={userAuth}
/>
</div>
)}
</Draggable>
);
})}
{provided.placeholder}
<button
type="button"
className="flex items-center rounded p-2 text-xs font-medium outline-none duration-300 hover:bg-gray-100"
onClick={addIssueToState}
>
<PlusIcon className="mr-1 h-3 w-3" />
Create
</button>
</div>
)}
</StrictModeDroppable>
</div>
</div>
)}
</Draggable>
);
};
export default SingleBoard;

View File

@ -1,80 +0,0 @@
import React from "react";
import { useRouter } from "next/router";
import useSWR from "swr";
import { Control, Controller } from "react-hook-form";
// constants
import { RectangleGroupIcon } from "@heroicons/react/24/outline";
// services
import modulesService from "services/modules.service";
// ui
import { Spinner, CustomSelect } from "components/ui";
// icons
// types
import { IIssue, IModule } from "types";
import { MODULE_LIST } from "constants/fetch-keys";
type Props = {
control: Control<IIssue, any>;
handleModuleChange: (module: IModule) => void;
};
const SelectModule: React.FC<Props> = ({ control, handleModuleChange }) => {
const router = useRouter();
const { workspaceSlug, projectId } = 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
);
return (
<div className="flex flex-wrap items-center py-2">
<div className="flex items-center gap-x-2 text-sm sm:basis-1/2">
<RectangleGroupIcon className="h-4 w-4 flex-shrink-0" />
<p>Module</p>
</div>
<div className="sm:basis-1/2">
<Controller
control={control}
name="issue_module"
render={({ field: { value } }) => (
<CustomSelect
label={
<span
className={`hidden truncate text-left sm:block ${value ? "" : "text-gray-900"}`}
>
{value ? modules?.find((m) => m.id === value?.module_detail.id)?.name : "None"}
</span>
}
value={value}
onChange={(value: any) => {
handleModuleChange(modules?.find((m) => m.id === value) as any);
}}
>
{modules ? (
modules.length > 0 ? (
modules.map((option) => (
<CustomSelect.Option key={option.id} value={option.id}>
{option.name}
</CustomSelect.Option>
))
) : (
<div className="text-center">No cycles found</div>
)
) : (
<Spinner />
)}
</CustomSelect>
)}
/>
</div>
</div>
);
};
export default SelectModule;

View File

@ -1,184 +0,0 @@
import React, { useState } from "react";
import { useRouter } from "next/router";
import useSWR from "swr";
import { Disclosure, Transition } from "@headlessui/react";
// hooks
import { ChevronDownIcon, PlusIcon } from "@heroicons/react/24/outline";
import useIssuesProperties from "hooks/use-issue-properties";
import useIssueView from "hooks/use-issue-view";
// services
import stateService from "services/state.service";
import workspaceService from "services/workspace.service";
// ui
import { Spinner } from "components/ui";
// components
import { CreateUpdateIssueModal } from "components/issues/modal";
import SingleListIssue from "components/common/list-view/single-issue";
// helpers
import { addSpaceIfCamelCase } from "helpers/string.helper";
// types
import { IIssue, IWorkspaceMember, UserAuth } from "types";
// fetch-keys
import { STATE_LIST, WORKSPACE_MEMBERS } from "constants/fetch-keys";
// types
type Props = {
issues: IIssue[];
handleEditIssue: (issue: IIssue) => void;
userAuth: UserAuth;
};
const ListView: React.FC<Props> = ({ issues, handleEditIssue, userAuth }) => {
const [isCreateIssuesModalOpen, setIsCreateIssuesModalOpen] = useState(false);
const [preloadedData, setPreloadedData] = useState<
(Partial<IIssue> & { actionType: "createIssue" | "edit" | "delete" }) | undefined
>(undefined);
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { issueView, groupedByIssues, groupByProperty: selectedGroup } = useIssueView(issues);
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<IWorkspaceMember[]>(
workspaceSlug ? WORKSPACE_MEMBERS : null,
workspaceSlug ? () => workspaceService.workspaceMembers(workspaceSlug as string) : null
);
const [properties] = useIssuesProperties(workspaceSlug as string, projectId as string);
if (issueView !== "list") return <></>;
return (
<>
<CreateUpdateIssueModal
isOpen={isCreateIssuesModalOpen && preloadedData?.actionType === "createIssue"}
handleClose={() => setIsCreateIssuesModalOpen(false)}
prePopulateData={{
...preloadedData,
}}
/>
<div className="flex flex-col space-y-5">
{Object.keys(groupedByIssues).map((singleGroup) => (
<Disclosure key={singleGroup} as="div" defaultOpen>
{({ open }) => (
<div className="rounded-lg bg-white">
<div className="rounded-t-lg bg-gray-100 px-4 py-3">
<Disclosure.Button>
<div className="flex items-center gap-x-2">
<span>
<ChevronDownIcon
className={`h-4 w-4 text-gray-500 ${!open ? "-rotate-90 transform" : ""}`}
/>
</span>
{selectedGroup !== null ? (
<h2 className="font-medium capitalize leading-5">
{singleGroup === null || singleGroup === "null"
? selectedGroup === "priority" && "No priority"
: selectedGroup === "created_by"
? people?.find((p) => p.member.id === singleGroup)?.member
?.first_name ?? "Loading..."
: addSpaceIfCamelCase(singleGroup)}
</h2>
) : (
<h2 className="font-medium leading-5">All Issues</h2>
)}
<p className="text-sm text-gray-500">
{groupedByIssues[singleGroup as keyof IIssue].length}
</p>
</div>
</Disclosure.Button>
</div>
<Transition
show={open}
enter="transition duration-100 ease-out"
enterFrom="transform opacity-0"
enterTo="transform opacity-100"
leave="transition duration-75 ease-out"
leaveFrom="transform opacity-100"
leaveTo="transform opacity-0"
>
<Disclosure.Panel>
<div className="divide-y-2">
{groupedByIssues[singleGroup] ? (
groupedByIssues[singleGroup].length > 0 ? (
groupedByIssues[singleGroup].map((issue: IIssue) => {
const assignees = [
...(issue?.assignees_list ?? []),
...(issue?.assignees ?? []),
]?.map((assignee) => {
const tempPerson = people?.find(
(p) => p.member.id === assignee
)?.member;
return tempPerson;
});
return (
<SingleListIssue
key={issue.id}
type="issue"
issue={issue}
properties={properties}
editIssue={() => handleEditIssue(issue)}
userAuth={userAuth}
/>
);
})
) : (
<p className="px-4 py-3 text-sm text-gray-500">No issues.</p>
)
) : (
<div className="flex h-full w-full items-center justify-center">
<Spinner />
</div>
)}
</div>
</Disclosure.Panel>
</Transition>
<div className="p-3">
<button
type="button"
className="flex items-center gap-1 rounded px-2 py-1 text-xs font-medium hover:bg-gray-100"
onClick={() => {
setIsCreateIssuesModalOpen(true);
if (selectedGroup !== null) {
const stateId =
selectedGroup === "state_detail.name"
? states?.find((s) => s.name === singleGroup)?.id ?? null
: null;
setPreloadedData({
state: stateId !== null ? stateId : undefined,
[selectedGroup]: singleGroup,
actionType: "createIssue",
});
} else {
setPreloadedData({
actionType: "createIssue",
});
}
}}
>
<PlusIcon className="h-3 w-3" />
Add issue
</button>
</div>
</div>
)}
</Disclosure>
))}
</div>
</>
);
};
export default ListView;

View File

@ -1,101 +0,0 @@
import React from "react";
import { useRouter } from "next/router";
import useSWR from "swr";
// headless ui
import { Listbox, Transition } from "@headlessui/react";
// services
import stateServices from "services/state.service";
// 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;
updateIssues: (
workspaceSlug: string,
projectId: string,
issueId: string,
issue: Partial<IIssue>
) => void;
};
const ChangeStateDropdown: React.FC<Props> = ({ issue, updateIssues }) => {
const router = useRouter();
const { workspaceSlug } = router.query;
const { data: states } = useSWR<IState[]>(
workspaceSlug ? STATE_LIST(issue.project) : null,
workspaceSlug ? () => stateServices.getStates(workspaceSlug as string, issue.project) : null
);
return (
<>
<Listbox
as="div"
value={issue.state}
onChange={(data: string) => {
if (!workspaceSlug) return;
updateIssues(workspaceSlug as string, issue.project, issue.id, {
state: data,
state_detail: states?.find((state) => state.id === data),
});
}}
className="flex-shrink-0"
>
{({ open }) => (
<>
<div>
<Listbox.Button
className="inline-flex items-center whitespace-nowrap rounded-full border bg-gray-50 px-2 py-1 text-xs font-medium text-gray-500 hover:bg-gray-100"
style={{
border: `2px solid ${issue.state_detail.color}`,
backgroundColor: `${issue.state_detail.color}20`,
}}
>
<span
className={`hidden w-16 capitalize sm:block ${
issue.state ? "" : "text-gray-900"
}`}
>
{addSpaceIfCamelCase(issue.state_detail.name)}
</span>
</Listbox.Button>
<Transition
show={open}
as={React.Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Listbox.Options className="fixed z-10 mt-1 max-h-28 overflow-auto rounded-md bg-white py-1 text-xs shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
{states?.map((state) => (
<Listbox.Option
key={state.id}
className={({ active }) =>
`cursor-pointer select-none px-3 py-2 ${
active ? "bg-indigo-50" : "bg-white"
}`
}
value={state.id}
>
{addSpaceIfCamelCase(state.name)}
</Listbox.Option>
))}
</Listbox.Options>
</Transition>
</div>
</>
)}
</Listbox>
</>
);
};
export default ChangeStateDropdown;

View File

@ -1,181 +0,0 @@
import React, { useCallback } from "react";
import { useRouter } from "next/router";
import useSWR, { mutate } from "swr";
// react-beautiful-dnd
import { DragDropContext, DropResult } from "react-beautiful-dnd";
// services
import stateService from "services/state.service";
import issuesService from "services/issues.service";
// hooks
import useIssuesProperties from "hooks/use-issue-properties";
import useIssueView from "hooks/use-issue-view";
// components
import SingleBoard from "components/project/modules/board-view/single-board";
// ui
import { Spinner } from "components/ui";
// types
import { IIssue, IProjectMember, ModuleIssueResponse, UserAuth } from "types";
// constants
import { STATE_LIST, MODULE_ISSUES } from "constants/fetch-keys";
type Props = {
issues: IIssue[];
members: IProjectMember[] | undefined;
openCreateIssueModal: (issue?: IIssue, actionType?: "create" | "edit" | "delete") => void;
openIssuesListModal: () => void;
handleDeleteIssue: React.Dispatch<React.SetStateAction<string | undefined>>;
setPreloadedData: React.Dispatch<
React.SetStateAction<
| (Partial<IIssue> & {
actionType: "createIssue" | "edit" | "delete";
})
| null
>
>;
userAuth: UserAuth;
};
const ModulesBoardView: React.FC<Props> = ({
issues,
members,
openCreateIssueModal,
openIssuesListModal,
handleDeleteIssue,
setPreloadedData,
userAuth,
}) => {
const router = useRouter();
const { workspaceSlug, projectId, moduleId } = router.query;
const [properties] = useIssuesProperties(workspaceSlug as string, projectId as string);
const { issueView, groupedByIssues, groupByProperty: selectedGroup } = useIssueView(issues);
const { data: states } = useSWR(
workspaceSlug && projectId ? STATE_LIST(projectId as string) : null,
workspaceSlug && projectId
? () => stateService.getStates(workspaceSlug as string, projectId as string)
: null
);
const handleOnDragEnd = useCallback(
(result: DropResult) => {
if (!result.destination) return;
const { source, destination } = result;
if (source.droppableId !== destination.droppableId) {
const sourceGroup = source.droppableId; // source group id
const destinationGroup = destination.droppableId; // destination group id
if (!sourceGroup || !destinationGroup) return;
// removed/dragged item
const removedItem = groupedByIssues[source.droppableId][source.index];
if (selectedGroup === "priority") {
// update the removed item for mutation
removedItem.priority = destinationGroup;
// patch request
issuesService.patchIssue(workspaceSlug as string, projectId as string, removedItem.id, {
priority: destinationGroup,
});
} 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;
removedItem.state = destinationStateId;
removedItem.state_detail = destinationState;
// patch request
issuesService.patchIssue(workspaceSlug as string, projectId as string, removedItem.id, {
state: destinationStateId,
});
if (!moduleId) return;
mutate<ModuleIssueResponse[]>(
MODULE_ISSUES(moduleId as string),
(prevData) => {
if (!prevData) return prevData;
const updatedIssues = prevData.map((issue) => {
if (issue.issue_detail.id === removedItem.id) {
return {
...issue,
issue_detail: removedItem,
};
}
return issue;
});
return [...updatedIssues];
},
false
);
}
// remove item from the source group
groupedByIssues[source.droppableId].splice(source.index, 1);
// add item to the destination group
groupedByIssues[destination.droppableId].splice(destination.index, 0, removedItem);
}
},
[workspaceSlug, groupedByIssues, projectId, selectedGroup, states, moduleId]
);
if (issueView !== "kanban") return <></>;
return (
<>
{groupedByIssues ? (
<div className="h-[calc(100vh-157px)] lg:h-[calc(100vh-115px)] w-full">
<DragDropContext onDragEnd={handleOnDragEnd}>
<div className="h-full w-full overflow-hidden">
<div className="h-full w-full">
<div className="flex h-full gap-x-4 overflow-x-auto overflow-y-hidden">
{Object.keys(groupedByIssues).map((singleGroup) => (
<SingleBoard
key={singleGroup}
selectedGroup={selectedGroup}
groupTitle={singleGroup}
createdBy={
selectedGroup === "created_by"
? members?.find((m) => m.member.id === singleGroup)?.member.first_name ??
"loading..."
: null
}
groupedByIssues={groupedByIssues}
bgColor={
selectedGroup === "state_detail.name"
? states?.find((s) => s.name === singleGroup)?.color
: "#000000"
}
properties={properties}
openIssuesListModal={openIssuesListModal}
openCreateIssueModal={openCreateIssueModal}
handleDeleteIssue={handleDeleteIssue}
setPreloadedData={setPreloadedData}
stateId={
selectedGroup === "state_detail.name"
? states?.find((s) => s.name === singleGroup)?.id ?? null
: null
}
userAuth={userAuth}
/>
))}
</div>
</div>
</div>
</DragDropContext>
</div>
) : (
<div className="flex h-full w-full items-center justify-center">
<Spinner />
</div>
)}
</>
);
};
export default ModulesBoardView;

View File

@ -1,185 +0,0 @@
import React, { useState } from "react";
import { useRouter } from "next/router";
import useSWR from "swr";
// react-beautiful-dnd
import { Draggable } from "react-beautiful-dnd";
// services
import workspaceService from "services/workspace.service";
// components
import SingleIssue from "components/common/board-view/single-issue";
import StrictModeDroppable from "components/dnd/StrictModeDroppable";
import BoardHeader from "components/common/board-view/board-header";
// ui
import { CustomMenu } from "components/ui";
// icons
import { PlusIcon } from "@heroicons/react/24/outline";
// types
import { IIssue, IWorkspaceMember, NestedKeyOf, Properties, UserAuth } from "types";
// fetch-keys
import { WORKSPACE_MEMBERS } from "constants/fetch-keys";
type Props = {
properties: Properties;
groupedByIssues: {
[key: string]: IIssue[];
};
selectedGroup: NestedKeyOf<IIssue> | null;
groupTitle: string;
createdBy: string | null;
bgColor?: string;
openCreateIssueModal: (issue?: IIssue, actionType?: "create" | "edit" | "delete") => void;
openIssuesListModal: () => void;
handleDeleteIssue: React.Dispatch<React.SetStateAction<string | undefined>>;
setPreloadedData: React.Dispatch<
React.SetStateAction<
| (Partial<IIssue> & {
actionType: "createIssue" | "edit" | "delete";
})
| null
>
>;
stateId: string | null;
userAuth: UserAuth;
};
const SingleModuleBoard: React.FC<Props> = ({
properties,
groupedByIssues,
selectedGroup,
groupTitle,
createdBy,
bgColor,
openCreateIssueModal,
openIssuesListModal,
handleDeleteIssue,
setPreloadedData,
stateId,
userAuth,
}) => {
// Collapse/Expand
const [isCollapsed, setIsCollapsed] = useState(true);
const router = useRouter();
const { workspaceSlug, moduleId } = router.query;
if (selectedGroup === "priority")
groupTitle === "high"
? (bgColor = "#dc2626")
: groupTitle === "medium"
? (bgColor = "#f97316")
: groupTitle === "low"
? (bgColor = "#22c55e")
: (bgColor = "#ff0000");
const { data: people } = useSWR<IWorkspaceMember[]>(
workspaceSlug ? WORKSPACE_MEMBERS : null,
workspaceSlug ? () => workspaceService.workspaceMembers(workspaceSlug as string) : null
);
return (
<div className={`h-full flex-shrink-0 rounded ${!isCollapsed ? "" : "w-80 border bg-gray-50"}`}>
<div className={`${!isCollapsed ? "" : "flex h-full flex-col space-y-3 overflow-y-auto"}`}>
<BoardHeader
addIssueToState={() => {
openCreateIssueModal();
if (selectedGroup !== null) {
setPreloadedData({
state: stateId !== null ? stateId : undefined,
[selectedGroup]: groupTitle,
actionType: "createIssue",
});
}
}}
bgColor={bgColor ?? ""}
createdBy={createdBy}
groupTitle={groupTitle}
groupedByIssues={groupedByIssues}
isCollapsed={isCollapsed}
setIsCollapsed={setIsCollapsed}
selectedGroup={selectedGroup}
/>
<StrictModeDroppable key={groupTitle} droppableId={groupTitle}>
{(provided, snapshot) => (
<div
className={`mt-3 h-full space-y-3 overflow-y-auto px-3 pb-3 ${
snapshot.isDraggingOver ? "bg-indigo-50 bg-opacity-50" : ""
} ${!isCollapsed ? "hidden" : "block"}`}
{...provided.droppableProps}
ref={provided.innerRef}
>
{groupedByIssues[groupTitle].map((issue, index: number) => {
const assignees = [
...(issue?.assignees_list ?? []),
...(issue?.assignees ?? []),
]?.map((assignee) => {
const tempPerson = people?.find((p) => p.member.id === assignee)?.member;
return tempPerson;
});
return (
<Draggable key={issue.id} draggableId={issue.id} index={index}>
{(provided, snapshot) => (
<div
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
>
<SingleIssue
type="module"
typeId={moduleId as string}
issue={issue}
properties={properties}
snapshot={snapshot}
assignees={assignees}
people={people}
handleDeleteIssue={handleDeleteIssue}
userAuth={userAuth}
/>
</div>
)}
</Draggable>
);
})}
{provided.placeholder}
<CustomMenu
label={
<span className="flex items-center gap-1">
<PlusIcon className="h-3 w-3" />
Add issue
</span>
}
className="mt-1"
optionsPosition="left"
noBorder
>
<CustomMenu.MenuItem
onClick={() => {
openCreateIssueModal();
if (selectedGroup !== null) {
setPreloadedData({
state: stateId !== null ? stateId : undefined,
[selectedGroup]: groupTitle,
actionType: "createIssue",
});
}
}}
>
Create new
</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={() => openIssuesListModal()}>
Add an existing issue
</CustomMenu.MenuItem>
</CustomMenu>
</div>
)}
</StrictModeDroppable>
</div>
</div>
);
};
export default SingleModuleBoard;

View File

@ -1,273 +0,0 @@
import React, { useEffect } from "react";
import { useRouter } from "next/router";
import { mutate } from "swr";
// react-hook-form
import { Controller, useForm } from "react-hook-form";
// headless ui
import { Dialog, Transition } from "@headlessui/react";
// components
import SelectLead from "components/project/modules/create-update-module-modal/select-lead";
import SelectMembers from "components/project/modules/create-update-module-modal/select-members";
import SelectStatus from "components/project/modules/create-update-module-modal/select-status";
// ui
import { Button, CustomDatePicker, Input, TextArea } from "components/ui";
// services
import modulesService from "services/modules.service";
// hooks
import useToast from "hooks/use-toast";
// helpers
import { renderDateFormat } from "helpers/date-time.helper";
// types
import type { IModule } from "types";
// fetch-keys
import { MODULE_LIST } from "constants/fetch-keys";
type Props = {
isOpen: boolean;
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
projectId: string;
data?: IModule;
};
const defaultValues: Partial<IModule> = {
name: "",
description: "",
status: null,
lead: null,
members_list: [],
};
const CreateUpdateModuleModal: React.FC<Props> = ({ isOpen, setIsOpen, data, projectId }) => {
const router = useRouter();
const { workspaceSlug } = router.query;
const { setToastAlert } = useToast();
const {
register,
formState: { errors, isSubmitting },
handleSubmit,
control,
reset,
setError,
} = useForm<IModule>({
defaultValues,
});
const onSubmit = async (formData: IModule) => {
if (!workspaceSlug) return;
const payload = {
...formData,
start_date: formData.start_date ? renderDateFormat(formData.start_date) : null,
target_date: formData.target_date ? renderDateFormat(formData.target_date) : null,
};
if (!data) {
await modulesService
.createModule(workspaceSlug as string, projectId, payload)
.then(() => {
mutate(MODULE_LIST(projectId));
handleClose();
setToastAlert({
title: "Success",
type: "success",
message: "Module created successfully",
});
})
.catch((err) => {
Object.keys(err).map((key) => {
setError(key as keyof typeof defaultValues, {
message: err[key].join(", "),
});
});
});
} else {
await modulesService
.updateModule(workspaceSlug as string, projectId, data.id, payload)
.then((res) => {
mutate<IModule[]>(
MODULE_LIST(projectId),
(prevData) => {
const newData = prevData?.map((item) => {
if (item.id === res.id) {
return res;
}
return item;
});
return newData;
},
false
);
handleClose();
setToastAlert({
title: "Success",
type: "success",
message: "Module updated successfully",
});
})
.catch((err) => {
Object.keys(err).map((key) => {
setError(key as keyof typeof defaultValues, {
message: err[key].join(", "),
});
});
});
}
};
const handleClose = () => {
setIsOpen(false);
reset(defaultValues);
};
useEffect(() => {
if (data) {
setIsOpen(true);
reset(data);
} else {
reset(defaultValues);
}
}, [data, setIsOpen, reset]);
return (
<Transition.Root show={isOpen} as={React.Fragment}>
<Dialog as="div" className="relative z-20" onClose={handleClose}>
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-20 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4 text-center sm:p-0">
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="relative transform rounded-lg bg-white px-5 py-8 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-2xl sm:p-6">
<form onSubmit={handleSubmit(onSubmit)}>
<div className="space-y-5">
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-gray-900">
{data ? "Update" : "Create"} Module
</Dialog.Title>
<div className="space-y-3">
<div>
<Input
id="name"
label="Name"
name="name"
type="name"
placeholder="Enter name"
autoComplete="off"
error={errors.name}
register={register}
validations={{
required: "Name is required",
maxLength: {
value: 255,
message: "Name should be less than 255 characters",
},
}}
/>
</div>
<div>
<TextArea
id="description"
name="description"
label="Description"
placeholder="Enter description"
error={errors.description}
register={register}
/>
</div>
<div className="flex gap-x-2">
<div className="w-full">
<h6 className="text-gray-500">Start Date</h6>
<div className="w-full">
<Controller
control={control}
name="start_date"
rules={{ required: "Start date is required" }}
render={({ field: { value, onChange } }) => (
<CustomDatePicker
renderAs="input"
value={value}
onChange={onChange}
error={errors.start_date ? true : false}
/>
)}
/>
{errors.start_date && (
<h6 className="text-sm text-red-500">{errors.start_date.message}</h6>
)}
</div>
</div>
<div className="w-full">
<h6 className="text-gray-500">Target Date</h6>
<div className="w-full">
<Controller
control={control}
name="target_date"
rules={{ required: "Target date is required" }}
render={({ field: { value, onChange } }) => (
<CustomDatePicker
renderAs="input"
value={value}
onChange={onChange}
error={errors.target_date ? true : false}
/>
)}
/>
{errors.target_date && (
<h6 className="text-sm text-red-500">{errors.target_date.message}</h6>
)}
</div>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<SelectStatus control={control} error={errors.status} />
<SelectLead control={control} />
<SelectMembers control={control} />
</div>
</div>
</div>
<div className="mt-5 flex justify-end gap-2">
<Button theme="secondary" onClick={handleClose}>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting}>
{data
? isSubmitting
? "Updating Module..."
: "Update Module"
: isSubmitting
? "Creating Module..."
: "Create Module"}
</Button>
</div>
</form>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
};
export default CreateUpdateModuleModal;

View File

@ -1,196 +0,0 @@
import React from "react";
import { useRouter } from "next/router";
import useSWR from "swr";
// icons
import { Disclosure, Transition } from "@headlessui/react";
import { PlusIcon, ChevronDownIcon } from "@heroicons/react/20/solid";
// services
import workspaceService from "services/workspace.service";
import stateService from "services/state.service";
// hooks
import useIssuesProperties from "hooks/use-issue-properties";
import useIssueView from "hooks/use-issue-view";
// components
import SingleListIssue from "components/common/list-view/single-issue";
// ui
import { CustomMenu, Spinner } from "components/ui";
// helpers
import { addSpaceIfCamelCase } from "helpers/string.helper";
// types
import { IIssue, IWorkspaceMember, UserAuth } from "types";
// fetch-keys
import { STATE_LIST, WORKSPACE_MEMBERS } from "constants/fetch-keys";
type Props = {
issues: IIssue[];
openCreateIssueModal: (issue?: IIssue, actionType?: "create" | "edit" | "delete") => void;
openIssuesListModal: () => void;
removeIssueFromModule: (issueId: string) => void;
setPreloadedData: React.Dispatch<
React.SetStateAction<
| (Partial<IIssue> & {
actionType: "createIssue" | "edit" | "delete";
})
| null
>
>;
userAuth: UserAuth;
};
const ModulesListView: React.FC<Props> = ({
issues,
openCreateIssueModal,
openIssuesListModal,
removeIssueFromModule,
setPreloadedData,
userAuth,
}) => {
const router = useRouter();
const { workspaceSlug, projectId, moduleId } = router.query;
const [properties] = useIssuesProperties(workspaceSlug as string, projectId as string);
const { issueView, groupedByIssues, groupByProperty: selectedGroup } = useIssueView(issues);
const { data: people } = useSWR<IWorkspaceMember[]>(
workspaceSlug ? WORKSPACE_MEMBERS : null,
workspaceSlug ? () => workspaceService.workspaceMembers(workspaceSlug as string) : null
);
const { data: states } = useSWR(
workspaceSlug && projectId ? STATE_LIST(projectId as string) : null,
workspaceSlug && projectId
? () => stateService.getStates(workspaceSlug as string, projectId as string)
: null
);
if (issueView !== "list") return <></>;
return (
<div className="flex h-full flex-col space-y-5">
{Object.keys(groupedByIssues).map((singleGroup) => {
const stateId =
selectedGroup === "state_detail.name"
? states?.find((s) => s.name === singleGroup)?.id ?? null
: null;
return (
<Disclosure key={singleGroup} as="div" defaultOpen>
{({ open }) => (
<div className="rounded-lg bg-white">
<div className="rounded-t-lg bg-gray-100 px-4 py-3">
<Disclosure.Button>
<div className="flex items-center gap-x-2">
<span>
<ChevronDownIcon
className={`h-4 w-4 text-gray-500 ${!open ? "-rotate-90 transform" : ""}`}
/>
</span>
{selectedGroup !== null ? (
<h2 className="font-medium capitalize leading-5">
{singleGroup === null || singleGroup === "null"
? selectedGroup === "priority" && "No priority"
: addSpaceIfCamelCase(singleGroup)}
</h2>
) : (
<h2 className="font-medium leading-5">All Issues</h2>
)}
<p className="text-sm text-gray-500">
{groupedByIssues[singleGroup as keyof IIssue].length}
</p>
</div>
</Disclosure.Button>
</div>
<Transition
show={open}
enter="transition duration-100 ease-out"
enterFrom="transform opacity-0"
enterTo="transform opacity-100"
leave="transition duration-75 ease-out"
leaveFrom="transform opacity-100"
leaveTo="transform opacity-0"
>
<Disclosure.Panel>
<div className="divide-y-2">
{groupedByIssues[singleGroup] ? (
groupedByIssues[singleGroup].length > 0 ? (
groupedByIssues[singleGroup].map((issue) => {
const assignees = [
...(issue?.assignees_list ?? []),
...(issue?.assignees ?? []),
]?.map((assignee) => {
const tempPerson = people?.find(
(p) => p.member.id === assignee
)?.member;
return {
avatar: tempPerson?.avatar,
first_name: tempPerson?.first_name,
email: tempPerson?.email,
};
});
return (
<SingleListIssue
key={issue.id}
type="module"
typeId={moduleId as string}
issue={issue}
properties={properties}
editIssue={() => openCreateIssueModal(issue, "edit")}
removeIssue={() => removeIssueFromModule(issue.bridge ?? "")}
userAuth={userAuth}
/>
);
})
) : (
<p className="px-4 py-3 text-sm text-gray-500">No issues.</p>
)
) : (
<div className="flex h-full w-full items-center justify-center">
<Spinner />
</div>
)}
</div>
</Disclosure.Panel>
</Transition>
<div className="p-3">
<CustomMenu
label={
<span className="flex items-center gap-1">
<PlusIcon className="h-3 w-3" />
Add issue
</span>
}
optionsPosition="left"
noBorder
>
<CustomMenu.MenuItem
onClick={() => {
openCreateIssueModal();
if (selectedGroup !== null) {
setPreloadedData({
state: stateId !== null ? stateId : undefined,
[selectedGroup]: singleGroup,
actionType: "createIssue",
});
}
}}
>
Create new
</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={() => openIssuesListModal()}>
Add an existing issue
</CustomMenu.MenuItem>
</CustomMenu>
</div>
</div>
)}
</Disclosure>
);
})}
</div>
);
};
export default ModulesListView;

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