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
package-lock.json
.vscode
# Sentry
.sentryclirc

View File

@ -7,7 +7,7 @@
</p>
<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" />
</a>
<img alt="Discord" src="https://img.shields.io/github/commit-activity/m/makeplane/plane?style=for-the-badge" />
@ -48,4 +48,4 @@ Our [Code of Conduct](https://github.com/makeplane/plane/blob/master/CODE_OF_CON
## Security
If you believe you have found a security vulnerability in Plane, we encourage you to responsibly disclose this and not open a public issue. We will investigate all legitimate reports. Email security@plane.so to disclose any security vulnerabilities.
If you believe you have found a security vulnerability in Plane, we encourage you to responsibly disclose this and not open a public issue. We will investigate all legitimate reports. Email security@plane.so to disclose any security vulnerabilities.

View File

@ -1,11 +1,13 @@
# All the python scripts that are used for back migrations
import uuid
from plane.db.models import ProjectIdentifier
from plane.db.models import Issue, IssueComment
from plane.db.models import Issue, IssueComment, User
from django.contrib.auth.hashers import make_password
# Update description and description html values for old descriptions
def update_description():
try:
issues = Issue.objects.all()
updated_issues = []
@ -25,7 +27,6 @@ def update_description():
def update_comments():
try:
issue_comments = IssueComment.objects.all()
updated_issue_comments = []
@ -44,9 +45,11 @@ def update_comments():
def update_project_identifiers():
try:
project_identifiers = ProjectIdentifier.objects.filter(workspace_id=None).select_related("project", "project__workspace")
project_identifiers = ProjectIdentifier.objects.filter(
workspace_id=None
).select_related("project", "project__workspace")
updated_identifiers = []
for identifier in project_identifiers:
identifier.workspace_id = identifier.project.workspace_id
updated_identifiers.append(identifier)
@ -58,3 +61,21 @@ def update_project_identifiers():
except Exception as e:
print(e)
print("Failed")
def update_user_empty_password():
try:
users = User.objects.filter(password="")
updated_users = []
for user in users:
user.password = make_password(uuid.uuid4().hex)
user.is_password_autoset = True
updated_users.append(user)
User.objects.bulk_update(updated_users, ["password"], batch_size=50)
print("Success")
except Exception as e:
print(e)
print("Failed")

View File

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

View File

@ -15,12 +15,16 @@ from plane.api.serializers import APITokenSerializer
class ApiTokenEndpoint(BaseAPIView):
def post(self, request):
try:
label = request.data.get("label", str(uuid4().hex))
workspace = request.data.get("workspace", False)
if not workspace:
return Response(
{"error": "Workspace is required"}, status=status.HTTP_200_OK
)
api_token = APIToken.objects.create(
label=label,
user=request.user,
label=label, user=request.user, workspace_id=workspace
)
serializer = APITokenSerializer(api_token)

View File

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

View File

@ -1,5 +1,9 @@
# Python imports
import json
# Django imports
from django.db.models import OuterRef, Func, F
from django.core import serializers
# Third party imports
from rest_framework.response import Response
@ -11,10 +15,10 @@ from . import BaseViewSet
from plane.api.serializers import CycleSerializer, CycleIssueSerializer
from plane.api.permissions import ProjectEntityPermission
from plane.db.models import Cycle, CycleIssue, Issue
from plane.bgtasks.issue_activites_task import issue_activity
class CycleViewSet(BaseViewSet):
serializer_class = CycleSerializer
model = Cycle
permission_classes = [
@ -41,7 +45,6 @@ class CycleViewSet(BaseViewSet):
class CycleIssueViewSet(BaseViewSet):
serializer_class = CycleIssueSerializer
model = CycleIssue
@ -79,7 +82,6 @@ class CycleIssueViewSet(BaseViewSet):
def create(self, request, slug, project_id, cycle_id):
try:
issues = request.data.get("issues", [])
if not len(issues):
@ -91,29 +93,77 @@ class CycleIssueViewSet(BaseViewSet):
workspace__slug=slug, project_id=project_id, pk=cycle_id
)
issues = Issue.objects.filter(
pk__in=issues, workspace__slug=slug, project_id=project_id
)
# Get all CycleIssues already created
cycle_issues = list(CycleIssue.objects.filter(issue_id__in=issues))
records_to_update = []
update_cycle_issue_activity = []
record_to_create = []
# Delete old records in order to maintain the database integrity
CycleIssue.objects.filter(issue_id__in=issues).delete()
for issue in issues:
cycle_issue = [
cycle_issue
for cycle_issue in cycle_issues
if str(cycle_issue.issue_id) in issues
]
# Update only when cycle changes
if len(cycle_issue):
if cycle_issue[0].cycle_id != cycle_id:
update_cycle_issue_activity.append(
{
"old_cycle_id": str(cycle_issue[0].cycle_id),
"new_cycle_id": str(cycle_id),
"issue_id": str(cycle_issue[0].issue_id),
}
)
cycle_issue[0].cycle_id = cycle_id
records_to_update.append(cycle_issue[0])
else:
record_to_create.append(
CycleIssue(
project_id=project_id,
workspace=cycle.workspace,
created_by=request.user,
updated_by=request.user,
cycle=cycle,
issue_id=issue,
)
)
CycleIssue.objects.bulk_create(
[
CycleIssue(
project_id=project_id,
workspace=cycle.workspace,
created_by=request.user,
updated_by=request.user,
cycle=cycle,
issue=issue,
)
for issue in issues
],
record_to_create,
batch_size=10,
ignore_conflicts=True,
)
return Response({"message": "Success"}, status=status.HTTP_200_OK)
CycleIssue.objects.bulk_update(
records_to_update,
["cycle"],
batch_size=10,
)
# Capture Issue Activity
issue_activity.delay(
{
"type": "issue.activity",
"requested_data": json.dumps({"cycles_list": issues}),
"actor_id": str(self.request.user.id),
"issue_id": str(self.kwargs.get("pk", None)),
"project_id": str(self.kwargs.get("project_id", None)),
"current_instance": json.dumps(
{
"updated_cycle_issues": update_cycle_issue_activity,
"created_cycle_issues": serializers.serialize(
"json", record_to_create
),
}
),
},
)
# Return all Cycle Issues
return Response(
CycleIssueSerializer(self.get_queryset(), many=True).data,
status=status.HTTP_200_OK,
)
except Cycle.DoesNotExist:
return Response(

View File

@ -1,6 +1,10 @@
# Python imports
import json
# Django Imports
from django.db import IntegrityError
from django.db.models import Prefetch, F, OuterRef, Func
from django.core import serializers
# Third party imports
from rest_framework.response import Response
@ -22,10 +26,10 @@ from plane.db.models import (
Issue,
ModuleLink,
)
from plane.bgtasks.issue_activites_task import issue_activity
class ModuleViewSet(BaseViewSet):
model = Module
permission_classes = [
ProjectEntityPermission,
@ -95,7 +99,6 @@ class ModuleViewSet(BaseViewSet):
class ModuleIssueViewSet(BaseViewSet):
serializer_class = ModuleIssueSerializer
model = ModuleIssue
@ -148,29 +151,77 @@ class ModuleIssueViewSet(BaseViewSet):
workspace__slug=slug, project_id=project_id, pk=module_id
)
issues = Issue.objects.filter(
pk__in=issues, workspace__slug=slug, project_id=project_id
)
module_issues = list(ModuleIssue.objects.filter(issue_id__in=issues))
# Delete old records in order to maintain the database integrity
ModuleIssue.objects.filter(issue_id__in=issues).delete()
update_module_issue_activity = []
records_to_update = []
record_to_create = []
for issue in issues:
module_issue = [
module_issue
for module_issue in module_issues
if str(module_issue.issue_id) in issues
]
if len(module_issue):
if module_issue[0].module_id != module_id:
update_module_issue_activity.append(
{
"old_module_id": str(module_issue[0].module_id),
"new_module_id": str(module_id),
"issue_id": str(module_issue[0].issue_id),
}
)
module_issue[0].module_id = module_id
records_to_update.append(module_issue[0])
else:
record_to_create.append(
ModuleIssue(
module=module,
issue_id=issue,
project_id=project_id,
workspace=module.workspace,
created_by=request.user,
updated_by=request.user,
)
)
ModuleIssue.objects.bulk_create(
[
ModuleIssue(
module=module,
issue=issue,
project_id=project_id,
workspace=module.workspace,
created_by=request.user,
updated_by=request.user,
)
for issue in issues
],
record_to_create,
batch_size=10,
ignore_conflicts=True,
)
return Response({"message": "Success"}, status=status.HTTP_200_OK)
ModuleIssue.objects.bulk_update(
records_to_update,
["module"],
batch_size=10,
)
# Capture Issue Activity
issue_activity.delay(
{
"type": "issue.activity",
"requested_data": json.dumps({"modules_list": issues}),
"actor_id": str(self.request.user.id),
"issue_id": str(self.kwargs.get("pk", None)),
"project_id": str(self.kwargs.get("project_id", None)),
"current_instance": json.dumps(
{
"updated_module_issues": update_module_issue_activity,
"created_module_issues": serializers.serialize(
"json", record_to_create
),
}
),
},
)
return Response(
ModuleIssueSerializer(self.get_queryset(), many=True).data,
status=status.HTTP_200_OK,
)
except Module.DoesNotExist:
return Response(
{"error": "Module Does not exists"}, status=status.HTTP_400_BAD_REQUEST

View File

@ -6,7 +6,16 @@ from django_rq import job
from sentry_sdk import capture_exception
# Module imports
from plane.db.models import User, Issue, Project, Label, IssueActivity, State
from plane.db.models import (
User,
Issue,
Project,
Label,
IssueActivity,
State,
Cycle,
Module,
)
# Track Chnages in name
@ -44,7 +53,6 @@ def track_parent(
issue_activities,
):
if current_instance.get("parent") != requested_data.get("parent"):
if requested_data.get("parent") == None:
old_parent = Issue.objects.get(pk=current_instance.get("parent"))
issue_activities.append(
@ -134,7 +142,6 @@ def track_state(
issue_activities,
):
if current_instance.get("state") != requested_data.get("state"):
new_state = State.objects.get(pk=requested_data.get("state", None))
old_state = State.objects.get(pk=current_instance.get("state", None))
@ -167,7 +174,6 @@ def track_description(
if current_instance.get("description_html") != requested_data.get(
"description_html"
):
issue_activities.append(
IssueActivity(
issue_id=issue_id,
@ -274,7 +280,6 @@ def track_labels(
):
# Label Addition
if len(requested_data.get("labels_list")) > len(current_instance.get("labels")):
for label in requested_data.get("labels_list"):
if label not in current_instance.get("labels"):
label = Label.objects.get(pk=label)
@ -296,7 +301,6 @@ def track_labels(
# Label Removal
if len(requested_data.get("labels_list")) < len(current_instance.get("labels")):
for label in current_instance.get("labels"):
if label not in requested_data.get("labels_list"):
label = Label.objects.get(pk=label)
@ -326,12 +330,10 @@ def track_assignees(
actor,
issue_activities,
):
# Assignee Addition
if len(requested_data.get("assignees_list")) > len(
current_instance.get("assignees")
):
for assignee in requested_data.get("assignees_list"):
if assignee not in current_instance.get("assignees"):
assignee = User.objects.get(pk=assignee)
@ -354,7 +356,6 @@ def track_assignees(
if len(requested_data.get("assignees_list")) < len(
current_instance.get("assignees")
):
for assignee in current_instance.get("assignees"):
if assignee not in requested_data.get("assignees_list"):
assignee = User.objects.get(pk=assignee)
@ -386,7 +387,6 @@ def track_blocks(
if len(requested_data.get("blocks_list")) > len(
current_instance.get("blocked_issues")
):
for block in requested_data.get("blocks_list"):
if (
len(
@ -418,7 +418,6 @@ def track_blocks(
if len(requested_data.get("blocks_list")) < len(
current_instance.get("blocked_issues")
):
for blocked in current_instance.get("blocked_issues"):
if blocked.get("block") not in requested_data.get("blocks_list"):
issue = Issue.objects.get(pk=blocked.get("block"))
@ -450,7 +449,6 @@ def track_blockings(
if len(requested_data.get("blockers_list")) > len(
current_instance.get("blocker_issues")
):
for block in requested_data.get("blockers_list"):
if (
len(
@ -482,7 +480,6 @@ def track_blockings(
if len(requested_data.get("blockers_list")) < len(
current_instance.get("blocker_issues")
):
for blocked in current_instance.get("blocker_issues"):
if blocked.get("blocked_by") not in requested_data.get("blockers_list"):
issue = Issue.objects.get(pk=blocked.get("blocked_by"))
@ -502,6 +499,119 @@ def track_blockings(
)
def track_cycles(
requested_data,
current_instance,
issue_id,
project,
actor,
issue_activities,
):
# Updated Records:
updated_records = current_instance.get("updated_cycle_issues", [])
created_records = json.loads(current_instance.get("created_cycle_issues", []))
for updated_record in updated_records:
old_cycle = Cycle.objects.filter(
pk=updated_record.get("old_cycle_id", None)
).first()
new_cycle = Cycle.objects.filter(
pk=updated_record.get("new_cycle_id", None)
).first()
issue_activities.append(
IssueActivity(
issue_id=updated_record.get("issue_id"),
actor=actor,
verb="updated",
old_value=old_cycle.name,
new_value=new_cycle.name,
field="cycles",
project=project,
workspace=project.workspace,
comment=f"{actor.email} updated cycle from {old_cycle.name} to {new_cycle.name}",
old_identifier=old_cycle.id,
new_identifier=new_cycle.id,
)
)
for created_record in created_records:
cycle = Cycle.objects.filter(
pk=created_record.get("fields").get("cycle")
).first()
issue_activities.append(
IssueActivity(
issue_id=created_record.get("fields").get("issue"),
actor=actor,
verb="created",
old_value="",
new_value=cycle.name,
field="cycles",
project=project,
workspace=project.workspace,
comment=f"{actor.email} added cycle {cycle.name}",
new_identifier=cycle.id,
)
)
def track_modules(
requested_data,
current_instance,
issue_id,
project,
actor,
issue_activities,
):
# Updated Records:
updated_records = current_instance.get("updated_module_issues", [])
created_records = json.loads(current_instance.get("created_module_issues", []))
for updated_record in updated_records:
old_module = Module.objects.filter(
pk=updated_record.get("old_module_id", None)
).first()
new_module = Module.objects.filter(
pk=updated_record.get("new_module_id", None)
).first()
issue_activities.append(
IssueActivity(
issue_id=updated_record.get("issue_id"),
actor=actor,
verb="updated",
old_value=old_module.name,
new_value=new_module.name,
field="modules",
project=project,
workspace=project.workspace,
comment=f"{actor.email} updated module from {old_module.name} to {new_module.name}",
old_identifier=old_module.id,
new_identifier=new_module.id,
)
)
for created_record in created_records:
module = Module.objects.filter(
pk=created_record.get("fields").get("module")
).first()
issue_activities.append(
IssueActivity(
issue_id=created_record.get("fields").get("issue"),
actor=actor,
verb="created",
old_value="",
new_value=module.name,
field="modules",
project=project,
workspace=project.workspace,
comment=f"{actor.email} added module {module.name}",
new_identifier=module.id,
)
)
# Receive message from room group
@job("default")
def issue_activity(event):
@ -510,7 +620,7 @@ def issue_activity(event):
requested_data = json.loads(event.get("requested_data"))
current_instance = json.loads(event.get("current_instance"))
issue_id = event.get("issue_id")
issue_id = event.get("issue_id", None)
actor_id = event.get("actor_id")
project_id = event.get("project_id")
@ -530,6 +640,8 @@ def issue_activity(event):
"assignees_list": track_assignees,
"blocks_list": track_blocks,
"blockers_list": track_blockings,
"cycles_list": track_cycles,
"modules_list": track_modules,
}
for key in requested_data:

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,37 +1,38 @@
// TODO: Refactor this component: into a different file, use this file to export the components
import React, { useState, useCallback, useEffect } from "react";
// next
import { useRouter } from "next/router";
// swr
import useSWR from "swr";
// hooks
// headless ui
import { Combobox, Dialog, Transition } from "@headlessui/react";
// services
import userService from "services/user.service";
// hooks
import useTheme from "hooks/use-theme";
import useToast from "hooks/use-toast";
import useUser from "hooks/use-user";
// components
import ShortcutsModal from "components/command-palette/shortcuts";
import { BulkDeleteIssuesModal } from "components/core";
import { CreateProjectModal } from "components/project";
import { CreateUpdateIssueModal } from "components/issues";
import { CreateUpdateModuleModal } from "components/modules";
import CreateUpdateCycleModal from "components/project/cycles/create-update-cycle-modal";
// ui
import { Button } from "components/ui";
// icons
import {
FolderIcon,
RectangleStackIcon,
ClipboardDocumentListIcon,
MagnifyingGlassIcon,
} from "@heroicons/react/24/outline";
import useTheme from "hooks/use-theme";
import useToast from "hooks/use-toast";
import useUser from "hooks/use-user";
// services
import userService from "services/user.service";
// components
import ShortcutsModal from "components/command-palette/shortcuts";
import { CreateProjectModal } from "components/project";
import { CreateUpdateIssueModal } from "components/issues/modal";
import CreateUpdateCycleModal from "components/project/cycles/create-update-cycle-modal";
import CreateUpdateModuleModal from "components/project/modules/create-update-module-modal";
import BulkDeleteIssuesModal from "components/common/bulk-delete-issues-modal";
// headless ui
// helpers
import { copyTextToClipboard } from "helpers/string.helper";
// types
import { IIssue } from "types";
// ui
import { Button } from "components/ui";
// icons
// fetch-keys
import { USER_ISSUE } from "constants/fetch-keys";
@ -74,7 +75,7 @@ const CommandPalette: React.FC = () => {
name: "Add new issue...",
icon: RectangleStackIcon,
hide: !projectId,
shortcut: "I",
shortcut: "C",
onClick: () => {
setIsIssueModalOpen(true);
},
@ -111,7 +112,6 @@ const CommandPalette: React.FC = () => {
if (!router.query.issueId) return;
const url = new URL(window.location.href);
console.log(url);
copyTextToClipboard(url.href)
.then(() => {
setToastAlert({
@ -179,7 +179,6 @@ const CommandPalette: React.FC = () => {
<CreateUpdateModuleModal
isOpen={isCreateModuleModalOpen}
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-none text-xs font-semibold text-gray-500">
<kbd className="font-sans"></kbd>
<kbd className="font-sans">{action.shortcut}</kbd>
</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
import { addSpaceIfCamelCase } from "helpers/string.helper";
// types
import { IIssue, NestedKeyOf } from "types";
import { IIssue } from "types";
type Props = {
isCollapsed: boolean;
setIsCollapsed: React.Dispatch<React.SetStateAction<boolean>>;
groupedByIssues: {
[key: string]: IIssue[];
};
selectedGroup: NestedKeyOf<IIssue> | null;
groupTitle: string;
createdBy: string | null;
bgColor: string;
bgColor?: string;
addIssueToState: () => void;
provided?: DraggableProvided;
};
const BoardHeader: React.FC<Props> = ({
export const BoardHeader: React.FC<Props> = ({
isCollapsed,
setIsCollapsed,
provided,
groupedByIssues,
selectedGroup,
groupTitle,
createdBy,
bgColor,
@ -44,18 +40,6 @@ const BoardHeader: React.FC<Props> = ({
}`}
>
<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
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" : ""
@ -105,5 +89,3 @@ const BoardHeader: React.FC<Props> = ({
</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";
// next
import { useRouter } from "next/router";
// swr
import useSWR, { mutate } from "swr";
// react hook form
import { SubmitHandler, useForm } from "react-hook-form";
// services
// headless ui
import { Combobox, Dialog, Transition } from "@headlessui/react";
import { FolderIcon, MagnifyingGlassIcon } from "@heroicons/react/24/outline";
// services
import issuesServices from "services/issues.service";
import projectService from "services/project.service";
// hooks
import useToast from "hooks/use-toast";
// headless ui
// ui
import { Button } from "components/ui";
// icons
import { MagnifyingGlassIcon } from "@heroicons/react/24/outline";
import { LayerDiagonalIcon } from "components/icons";
// types
import { IIssue, IssueResponse } from "types";
// fetch keys
import { PROJECT_ISSUES_LIST, PROJECT_DETAILS } from "constants/fetch-keys";
import { PROJECT_ISSUES_LIST } from "constants/fetch-keys";
type FormInput = {
delete_issue_ids: string[];
@ -32,7 +31,7 @@ type Props = {
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
};
const BulkDeleteIssuesModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
export const BulkDeleteIssuesModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
const [query, setQuery] = useState("");
const router = useRouter();
@ -50,13 +49,6 @@ const BulkDeleteIssuesModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
: null
);
const { data: projectDetails } = useSWR(
workspaceSlug && projectId ? PROJECT_DETAILS(projectId as string) : null,
workspaceSlug && projectId
? () => projectService.getProject(workspaceSlug as string, projectId as string)
: null
);
const { setToastAlert } = useToast();
const {
@ -213,7 +205,7 @@ const BulkDeleteIssuesModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
}}
/>
<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}</span>
</div>
@ -226,7 +218,7 @@ const BulkDeleteIssuesModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
<LayerDiagonalIcon height="56" width="56" />
<h3 className="text-gray-500">
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>
</div>
)}
@ -256,5 +248,3 @@ const BulkDeleteIssuesModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
</Transition.Root>
);
};
export default BulkDeleteIssuesModal;

View File

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

View File

@ -17,13 +17,13 @@ import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper";
// types
import { IIssue, Properties } from "types";
// common
import { filterIssueOptions, groupByOptions, orderByOptions } from "constants/";
import { GROUP_BY_OPTIONS, ORDER_BY_OPTIONS, FILTER_ISSUE_OPTIONS } from "constants/issue";
type Props = {
issues?: IIssue[];
};
const View: React.FC<Props> = ({ issues }) => {
export const IssuesFilterView: React.FC<Props> = ({ issues }) => {
const router = useRouter();
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>
<CustomMenu
label={
groupByOptions.find((option) => option.key === groupByProperty)
GROUP_BY_OPTIONS.find((option) => option.key === groupByProperty)
?.name ?? "Select"
}
width="lg"
>
{groupByOptions.map((option) => (
<CustomMenu.MenuItem
key={option.key}
onClick={() => setGroupByProperty(option.key)}
>
{option.name}
</CustomMenu.MenuItem>
))}
{GROUP_BY_OPTIONS.map((option) =>
issueView === "kanban" && option.key === null ? null : (
<CustomMenu.MenuItem
key={option.key}
onClick={() => setGroupByProperty(option.key)}
>
{option.name}
</CustomMenu.MenuItem>
)
)}
</CustomMenu>
</div>
<div className="flex items-center justify-between">
<h4 className="text-sm text-gray-600">Order by</h4>
<CustomMenu
label={
orderByOptions.find((option) => option.key === orderBy)?.name ??
ORDER_BY_OPTIONS.find((option) => option.key === orderBy)?.name ??
"Select"
}
width="lg"
>
{orderByOptions.map((option) =>
{ORDER_BY_OPTIONS.map((option) =>
groupByProperty === "priority" &&
option.key === "priority" ? null : (
<CustomMenu.MenuItem
@ -140,12 +142,12 @@ const View: React.FC<Props> = ({ issues }) => {
<h4 className="text-sm text-gray-600">Issue type</h4>
<CustomMenu
label={
filterIssueOptions.find((option) => option.key === filterIssue)
FILTER_ISSUE_OPTIONS.find((option) => option.key === filterIssue)
?.name ?? "Select"
}
width="lg"
>
{filterIssueOptions.map((option) => (
{FILTER_ISSUE_OPTIONS.map((option) => (
<CustomMenu.MenuItem
key={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) {
const payload = {
...formValues,
start_date: formValues.start_date ? renderDateFormat(formValues.start_date) : null,
end_date: formValues.end_date ? renderDateFormat(formValues.end_date) : null,
};
if (initialData) {
updateCycle(initialData.id, payload);
} else {

View File

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

View File

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

View File

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

View File

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

View File

@ -16,7 +16,7 @@ import {
IssueStateSelect,
} from "components/issues/select";
import { CycleSelect as IssueCycleSelect } from "components/cycles/select";
import CreateUpdateStateModal from "components/project/issues/BoardView/state/create-update-state-modal";
import { CreateUpdateStateModal } from "components/states";
import CreateUpdateCycleModal from "components/project/cycles/create-update-cycle-modal";
// ui
import { Button, CustomDatePicker, CustomMenu, Input, Loader } from "components/ui";

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 "./sub-issue-list";
export * from "./form";
export * from "./modal";
export * from "./my-issues-list-item";
export * from "./parent-issues-list-modal";
export * from "./sidebar";
export * from "./sub-issues-list";
export * from "./sub-issues-list-modal";

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

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;
};
const IssuesListModal: React.FC<Props> = ({
export const ParentIssuesListModal: React.FC<Props> = ({
isOpen,
handleClose: onClose,
value,
@ -212,7 +212,7 @@ const IssuesListModal: React.FC<Props> = ({
<LayerDiagonalIcon height="56" width="56" />
<h3 className="text-gray-500">
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>
</div>
)}
@ -227,5 +227,3 @@ const IssuesListModal: React.FC<Props> = ({
</>
);
};
export default IssuesListModal;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -16,7 +16,7 @@ import issuesServices from "services/issues.service";
// ui
import { Button } from "components/ui";
// icons
import { FolderIcon, MagnifyingGlassIcon, XMarkIcon } from "@heroicons/react/24/outline";
import { MagnifyingGlassIcon, XMarkIcon } from "@heroicons/react/24/outline";
import { BlockerIcon, LayerDiagonalIcon } from "components/icons";
// types
import { IIssue, UserAuth } from "types";
@ -34,7 +34,12 @@ type Props = {
userAuth: UserAuth;
};
const SelectBlocker: React.FC<Props> = ({ submitChanges, issuesList, watch, userAuth }) => {
export const SidebarBlockerSelect: React.FC<Props> = ({
submitChanges,
issuesList,
watch,
userAuth,
}) => {
const [query, setQuery] = useState("");
const [isBlockerModalOpen, setIsBlockerModalOpen] = useState(false);
@ -261,7 +266,7 @@ const SelectBlocker: React.FC<Props> = ({ submitChanges, issuesList, watch, user
<LayerDiagonalIcon height="56" width="56" />
<h3 className="text-gray-500">
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>
</div>
)}
@ -299,5 +304,3 @@ const SelectBlocker: React.FC<Props> = ({ submitChanges, issuesList, watch, user
</div>
);
};
export default SelectBlocker;

View File

@ -22,7 +22,11 @@ type Props = {
userAuth: UserAuth;
};
const SelectCycle: React.FC<Props> = ({ issueDetail, handleCycleChange, userAuth }) => {
export const SidebarCycleSelect: React.FC<Props> = ({
issueDetail,
handleCycleChange,
userAuth,
}) => {
const router = useRouter();
const { workspaceSlug, projectId, issueId } = router.query;
@ -71,7 +75,7 @@ const SelectCycle: React.FC<Props> = ({ issueDetail, handleCycleChange, userAuth
onChange={(value: any) => {
value === null
? removeIssueFromCycle(issueCycle?.id ?? "", issueCycle?.cycle ?? "")
: handleCycleChange(cycles?.find((c) => c.id === value) as any);
: handleCycleChange(cycles?.find((c) => c.id === value) as ICycle);
}}
disabled={isNotAllowed}
>
@ -98,5 +102,3 @@ const SelectCycle: React.FC<Props> = ({ issueDetail, handleCycleChange, userAuth
</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
import issuesServices from "services/issues.service";
// components
import IssuesListModal from "components/project/issues/issues-list-modal";
import { ParentIssuesListModal } from "components/issues";
// icons
// types
import { IIssue, UserAuth } from "types";
@ -26,7 +26,7 @@ type Props = {
userAuth: UserAuth;
};
const SelectParent: React.FC<Props> = ({
export const SidebarParentSelect: React.FC<Props> = ({
control,
submitChanges,
issuesList,
@ -61,7 +61,7 @@ const SelectParent: React.FC<Props> = ({
control={control}
name="parent"
render={({ field: { value, onChange } }) => (
<IssuesListModal
<ParentIssuesListModal
isOpen={isParentModalOpen}
handleClose={() => setIsParentModalOpen(false)}
onChange={(val) => {
@ -93,5 +93,3 @@ const SelectParent: React.FC<Props> = ({
</div>
);
};
export default SelectParent;

View File

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

View File

@ -22,7 +22,7 @@ type Props = {
userAuth: UserAuth;
};
const SelectState: React.FC<Props> = ({ control, submitChanges, userAuth }) => {
export const SidebarStateSelect: React.FC<Props> = ({ control, submitChanges, userAuth }) => {
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
@ -100,5 +100,3 @@ const SelectState: React.FC<Props> = ({ control, submitChanges, userAuth }) => {
</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";
@ -14,18 +14,21 @@ import { Popover, Listbox, Transition } from "@headlessui/react";
import useToast from "hooks/use-toast";
// services
import issuesServices from "services/issues.service";
import modulesService from "services/modules.service";
// components
import ConfirmIssueDeletion from "components/project/issues/confirm-issue-deletion";
import SelectState from "components/project/issues/issue-detail/issue-detail-sidebar/select-state";
import SelectPriority from "components/project/issues/issue-detail/issue-detail-sidebar/select-priority";
import SelectParent from "components/project/issues/issue-detail/issue-detail-sidebar/select-parent";
import SelectCycle from "components/project/issues/issue-detail/issue-detail-sidebar/select-cycle";
import SelectAssignee from "components/project/issues/issue-detail/issue-detail-sidebar/select-assignee";
import SelectBlocker from "components/project/issues/issue-detail/issue-detail-sidebar/select-blocker";
import SelectBlocked from "components/project/issues/issue-detail/issue-detail-sidebar/select-blocked";
import {
DeleteIssueModal,
SidebarAssigneeSelect,
SidebarBlockedSelect,
SidebarBlockerSelect,
SidebarCycleSelect,
SidebarModuleSelect,
SidebarParentSelect,
SidebarPrioritySelect,
SidebarStateSelect,
} from "components/issues";
// ui
import { Input, Button, Spinner, CustomDatePicker } from "components/ui";
import DatePicker from "react-datepicker";
// icons
import {
TagIcon,
@ -39,12 +42,10 @@ import {
// helpers
import { copyTextToClipboard } from "helpers/string.helper";
// types
import type { ICycle, IIssue, IIssueLabels, UserAuth } from "types";
import type { ICycle, IIssue, IIssueLabels, IModule, UserAuth } from "types";
// fetch-keys
import { PROJECT_ISSUE_LABELS, PROJECT_ISSUES_LIST, ISSUE_DETAILS } from "constants/fetch-keys";
import "react-datepicker/dist/react-datepicker.css";
type Props = {
control: Control<IIssue, any>;
submitChanges: (formData: Partial<IIssue>) => void;
@ -55,10 +56,10 @@ type Props = {
const defaultValues: Partial<IIssueLabels> = {
name: "",
colour: "#ff0000",
color: "#ff0000",
};
const IssueDetailSidebar: React.FC<Props> = ({
export const IssueDetailsSidebar: React.FC<Props> = ({
control,
submitChanges,
issueDetail,
@ -112,26 +113,44 @@ const IssueDetailSidebar: React.FC<Props> = ({
});
};
const handleCycleChange = (cycleDetail: ICycle) => {
if (!workspaceSlug || !projectId || !issueDetail) return;
const handleCycleChange = useCallback(
(cycleDetail: ICycle) => {
if (!workspaceSlug || !projectId || !issueDetail) return;
issuesServices
.addIssueToCycle(workspaceSlug as string, projectId as string, cycleDetail.id, {
issues: [issueDetail.id],
})
.then((res) => {
mutate(ISSUE_DETAILS(issueId as string));
});
};
issuesServices
.addIssueToCycle(workspaceSlug as string, projectId as string, cycleDetail.id, {
issues: [issueDetail.id],
})
.then((res) => {
mutate(ISSUE_DETAILS(issueId as string));
});
},
[workspaceSlug, projectId, issueId, issueDetail]
);
const handleModuleChange = useCallback(
(moduleDetail: IModule) => {
if (!workspaceSlug || !projectId || !issueDetail) return;
modulesService
.addIssuesToModule(workspaceSlug as string, projectId as string, moduleDetail.id, {
issues: [issueDetail.id],
})
.then((res) => {
mutate(ISSUE_DETAILS(issueId as string));
});
},
[workspaceSlug, projectId, issueId, issueDetail]
);
const isNotAllowed = userAuth.isGuest || userAuth.isViewer;
return (
<>
<ConfirmIssueDeletion
<DeleteIssueModal
handleClose={() => setDeleteIssueModal(false)}
isOpen={deleteIssueModal}
data={issueDetail}
data={issueDetail ?? null}
/>
<div className="w-full divide-y-2 divide-gray-100 sticky top-5">
<div className="flex items-center justify-between pb-3">
@ -175,12 +194,24 @@ const IssueDetailSidebar: React.FC<Props> = ({
</div>
<div className="divide-y-2 divide-gray-100">
<div className="py-1">
<SelectState control={control} submitChanges={submitChanges} userAuth={userAuth} />
<SelectAssignee control={control} submitChanges={submitChanges} userAuth={userAuth} />
<SelectPriority control={control} submitChanges={submitChanges} userAuth={userAuth} />
<SidebarStateSelect
control={control}
submitChanges={submitChanges}
userAuth={userAuth}
/>
<SidebarAssigneeSelect
control={control}
submitChanges={submitChanges}
userAuth={userAuth}
/>
<SidebarPrioritySelect
control={control}
submitChanges={submitChanges}
userAuth={userAuth}
/>
</div>
<div className="py-1">
<SelectParent
<SidebarParentSelect
control={control}
submitChanges={submitChanges}
issuesList={
@ -210,13 +241,13 @@ const IssueDetailSidebar: React.FC<Props> = ({
watch={watchIssue}
userAuth={userAuth}
/>
<SelectBlocker
<SidebarBlockerSelect
submitChanges={submitChanges}
issuesList={issues?.results.filter((i) => i.id !== issueDetail?.id) ?? []}
watch={watchIssue}
userAuth={userAuth}
/>
<SelectBlocked
<SidebarBlockedSelect
submitChanges={submitChanges}
issuesList={issues?.results.filter((i) => i.id !== issueDetail?.id) ?? []}
watch={watchIssue}
@ -247,11 +278,16 @@ const IssueDetailSidebar: React.FC<Props> = ({
</div>
</div>
<div className="py-1">
<SelectCycle
<SidebarCycleSelect
issueDetail={issueDetail}
handleCycleChange={handleCycleChange}
userAuth={userAuth}
/>
<SidebarModuleSelect
issueDetail={issueDetail}
handleModuleChange={handleModuleChange}
userAuth={userAuth}
/>
</div>
</div>
<div className="space-y-3 pt-3">
@ -280,7 +316,7 @@ const IssueDetailSidebar: React.FC<Props> = ({
>
<span
className="h-2 w-2 flex-shrink-0 rounded-full"
style={{ backgroundColor: singleLabel?.colour ?? "green" }}
style={{ backgroundColor: singleLabel?.color ?? "green" }}
/>
{singleLabel.name}
<XMarkIcon className="h-2 w-2 group-hover:text-red-500" />
@ -336,7 +372,7 @@ const IssueDetailSidebar: React.FC<Props> = ({
>
<span
className="h-2 w-2 flex-shrink-0 rounded-full"
style={{ backgroundColor: label.colour ?? "green" }}
style={{ backgroundColor: label.color ?? "green" }}
/>
{label.name}
</Listbox.Option>
@ -386,11 +422,11 @@ const IssueDetailSidebar: React.FC<Props> = ({
<Popover.Button
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
className="h-5 w-5 rounded"
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">
<Controller
name="colour"
name="color"
control={controlLabel}
render={({ field: { value, onChange } }) => (
<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";
// services
import issuesServices from "services/issues.service";
// helpers
import { orderArrayBy } from "helpers/array.helper";
// types
import { IIssue, IssueResponse } from "types";
// constants
@ -17,11 +19,11 @@ import { PROJECT_ISSUES_LIST, SUB_ISSUES } from "constants/fetch-keys";
type Props = {
isOpen: boolean;
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
handleClose: () => void;
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 router = useRouter();
@ -43,15 +45,28 @@ const AddAsSubIssue: React.FC<Props> = ({ isOpen, setIsOpen, parent }) => {
[];
const handleCommandPaletteClose = () => {
setIsOpen(false);
handleClose();
setQuery("");
};
const addAsSubIssue = (issueId: string) => {
const addAsSubIssue = (issue: IIssue) => {
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
.patchIssue(workspaceSlug as string, projectId as string, issueId, { parent: parent?.id })
.patchIssue(workspaceSlug as string, projectId as string, issue.id, { parent: parent?.id })
.then((res) => {
mutate(SUB_ISSUES(parent?.id ?? ""));
mutate<IssueResponse>(
@ -146,12 +161,12 @@ const AddAsSubIssue: React.FC<Props> = ({ isOpen, setIsOpen, parent }) => {
}`
}
onClick={() => {
addAsSubIssue(issue.id);
setIsOpen(false);
addAsSubIssue(issue);
handleClose();
}}
>
<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={{
backgroundColor: issue.state_detail.color,
}}
@ -188,5 +203,3 @@ const AddAsSubIssue: React.FC<Props> = ({ isOpen, setIsOpen, parent }) => {
</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";
// components
import { CustomMenu } from "components/ui";
import { CreateUpdateIssueModal } from "components/issues";
import AddAsSubIssue from "components/project/issues/issue-detail/add-as-sub-issue";
import { CreateUpdateIssueModal, SubIssuesListModal } from "components/issues";
// types
import { IIssue, UserAuth } from "types";
@ -18,7 +17,7 @@ export interface SubIssueListProps {
userAuth: UserAuth;
}
export const SubIssueList: FC<SubIssueListProps> = ({
export const SubIssuesList: FC<SubIssueListProps> = ({
issues = [],
handleSubIssueRemove,
parentIssue,
@ -28,7 +27,7 @@ export const SubIssueList: FC<SubIssueListProps> = ({
}) => {
// states
const [isIssueModalActive, setIssueModalActive] = useState(false);
const [isSubIssueModalActive, setSubIssueModalActive] = useState(false);
const [subIssuesListModal, setSubIssuesListModal] = useState(false);
const [preloadedData, setPreloadedData] = useState<Partial<IIssue> | null>(null);
const openIssueModal = () => {
@ -40,11 +39,11 @@ export const SubIssueList: FC<SubIssueListProps> = ({
};
const openSubIssueModal = () => {
setSubIssueModalActive(true);
setSubIssuesListModal(true);
};
const closeSubIssueModal = () => {
setSubIssueModalActive(false);
setSubIssuesListModal(false);
};
const isNotAllowed = userAuth.isGuest || userAuth.isViewer;
@ -56,9 +55,9 @@ export const SubIssueList: FC<SubIssueListProps> = ({
prePopulateData={{ ...preloadedData }}
handleClose={closeIssueModal}
/>
<AddAsSubIssue
isOpen={isSubIssueModalActive}
setIsOpen={setSubIssueModalActive}
<SubIssuesListModal
isOpen={subIssuesListModal}
handleClose={() => setSubIssuesListModal(false)}
parent={parentIssue}
/>
<Disclosure defaultOpen={true}>
@ -88,7 +87,7 @@ export const SubIssueList: FC<SubIssueListProps> = ({
<CustomMenu ellipsis>
<CustomMenu.MenuItem
onClick={() => {
setSubIssueModalActive(true);
setSubIssuesListModal(true);
}}
>
Add an existing issue
@ -114,7 +113,7 @@ export const SubIssueList: FC<SubIssueListProps> = ({
<Link href={`/${workspaceSlug}/projects/${projectId}/issues/${issue.id}`}>
<a className="flex items-center gap-2 rounded text-xs">
<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={{
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;
};
const ConfirmModuleDeletion: React.FC<Props> = ({ isOpen, setIsOpen, data }) => {
export const DeleteModuleModal: React.FC<Props> = ({ isOpen, setIsOpen, data }) => {
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const router = useRouter();
@ -152,5 +152,3 @@ const ConfirmModuleDeletion: React.FC<Props> = ({ isOpen, setIsOpen, data }) =>
</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";
// react-hook-form
import { useForm } from "react-hook-form";
// headless ui
import { Dialog, Transition } from "@headlessui/react";
// hooks
// types
import type { IModule, ModuleLink } from "types";
// services
import modulesService from "services/modules.service";
// ui
import { Button, Input } from "components/ui";
// types
import type { IModule, ModuleLink } from "types";
// fetch-keys
import { MODULE_LIST } from "constants/fetch-keys";
import { MODULE_DETAILS } from "constants/fetch-keys";
type Props = {
isOpen: boolean;
@ -28,7 +28,7 @@ const defaultValues: ModuleLink = {
url: "",
};
const ModuleLinkModal: React.FC<Props> = ({ isOpen, module, handleClose }) => {
export const ModuleLinkModal: React.FC<Props> = ({ isOpen, module, handleClose }) => {
const router = useRouter();
const { workspaceSlug, projectId, moduleId } = router.query;
@ -43,23 +43,18 @@ const ModuleLinkModal: React.FC<Props> = ({ isOpen, module, handleClose }) => {
});
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> = {
links_list: [...previousLinks, formData],
links_list: [...(previousLinks ?? []), formData],
};
await modulesService
.patchModule(workspaceSlug as string, projectId as string, module.id, payload)
.then(() => {
mutate<IModule[]>(projectId && MODULE_LIST(projectId as string), (prevData) =>
(prevData ?? []).map((module) => {
if (module.id === moduleId) return { ...module, ...payload };
return module;
})
);
.patchModule(workspaceSlug as string, projectId as string, moduleId as string, payload)
.then((res) => {
mutate(MODULE_DETAILS(moduleId as string));
onClose();
})
.catch((err) => {
@ -163,5 +158,3 @@ const ModuleLinkModal: React.FC<Props> = ({ isOpen, module, handleClose }) => {
</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";
// react-hook-form
import { Controller, Control } from "react-hook-form";
import { UserIcon } from "@heroicons/react/24/outline";
// import type { Control } from "react-hook-form";
// service
import type { IModule } from "types";
// services
import projectServices from "services/project.service";
// ui
import SearchListbox from "components/search-listbox";
// icons
import { UserIcon } from "@heroicons/react/24/outline";
// types
import type { IModule } from "types";
// fetch-keys
import { PROJECT_MEMBERS } from "constants/fetch-keys";
@ -21,7 +21,7 @@ type Props = {
control: Control<IModule, any>;
};
const SelectLead: React.FC<Props> = ({ control }) => {
export const ModuleLeadSelect: React.FC<Props> = ({ control }) => {
const router = useRouter();
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";
// react-hook-form
import { Controller, Control } from "react-hook-form";
import { UserIcon } from "@heroicons/react/24/outline";
// import type { Control } from "react-hook-form";
// service
import type { IModule } from "types";
// services
import projectServices from "services/project.service";
// ui
import SearchListbox from "components/search-listbox";
// icons
import { UserIcon } from "@heroicons/react/24/outline";
// types
import type { IModule } from "types";
// fetch-keys
import { PROJECT_MEMBERS } from "constants/fetch-keys";
@ -21,7 +21,7 @@ type Props = {
control: Control<IModule, any>;
};
const SelectMembers: React.FC<Props> = ({ control }) => {
export const ModuleMembersSelect: React.FC<Props> = ({ control }) => {
const router = useRouter();
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
import type { IModule } from "types";
// constants
import { MODULE_STATUS } from "constants/";
import { MODULE_STATUS } from "constants/module";
type Props = {
control: Control<IModule, any>;
error?: FieldError;
};
const SelectStatus: React.FC<Props> = ({ control, error }) => (
export const ModuleStatusSelect: React.FC<Props> = ({ control, error }) => (
<Controller
control={control}
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;
};
const SelectLead: React.FC<Props> = ({ control, submitChanges, lead }) => {
export const SidebarLeadSelect: React.FC<Props> = ({ control, submitChanges, lead }) => {
const router = useRouter();
const { workspaceSlug } = router.query;
@ -164,5 +164,3 @@ const SelectLead: React.FC<Props> = ({ control, submitChanges, lead }) => {
</div>
);
};
export default SelectLead;

View File

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

View File

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

View File

@ -8,6 +8,22 @@ import { mutate } from "swr";
// react-hook-form
import { Controller, useForm } from "react-hook-form";
// 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 {
CalendarDaysIcon,
ChartPieIcon,
@ -15,20 +31,6 @@ import {
PlusIcon,
TrashIcon,
} 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
import { timeAgo } from "helpers/date-time.helper";
import { copyTextToClipboard } from "helpers/string.helper";
@ -36,9 +38,10 @@ import { groupBy } from "helpers/array.helper";
// types
import { IModule, ModuleIssueResponse } from "types";
// fetch-keys
import { MODULE_LIST } from "constants/fetch-keys";
import { MODULE_DETAILS } from "constants/fetch-keys";
const defaultValues: Partial<IModule> = {
lead: "",
members_list: [],
start_date: null,
target_date: null,
@ -52,7 +55,7 @@ type Props = {
handleDeleteModule: () => void;
};
const ModuleDetailSidebar: React.FC<Props> = ({
export const ModuleDetailsSidebar: React.FC<Props> = ({
module,
isOpen,
moduleIssues,
@ -69,14 +72,6 @@ const ModuleDetailSidebar: React.FC<Props> = ({
defaultValues,
});
useEffect(() => {
if (module)
reset({
...module,
members_list: module.members_list ?? module.members_detail?.map((m) => m.id),
});
}, [module, reset]);
const groupedIssues = {
backlog: [],
unstarted: [],
@ -87,29 +82,36 @@ const ModuleDetailSidebar: React.FC<Props> = ({
};
const submitChanges = (data: Partial<IModule>) => {
if (!workspaceSlug || !projectId || !module) return;
if (!workspaceSlug || !projectId || !moduleId) return;
mutate<IModule[]>(
projectId && MODULE_LIST(projectId as string),
(prevData) =>
(prevData ?? []).map((module) => {
if (module.id === moduleId) return { ...module, ...data };
return module;
}),
mutate<IModule>(
MODULE_DETAILS(moduleId as string),
(prevData) => ({
...(prevData as IModule),
...data,
}),
false
);
modulesService
.patchModule(workspaceSlug as string, projectId as string, module.id, data)
.patchModule(workspaceSlug as string, projectId as string, moduleId as string, data)
.then((res) => {
console.log(res);
mutate(MODULE_LIST(projectId as string));
mutate(MODULE_DETAILS(moduleId as string));
})
.catch((e) => {
console.log(e);
});
};
useEffect(() => {
if (module)
reset({
...module,
members_list: module.members_list ?? module.members_detail?.map((m) => m.id),
});
}, [module, reset]);
return (
<>
<ModuleLinkModal
@ -161,12 +163,12 @@ const ModuleDetailSidebar: React.FC<Props> = ({
</div>
<div className="divide-y-2 divide-gray-100 text-xs">
<div className="py-1">
<SelectLead
<SidebarLeadSelect
control={control}
submitChanges={submitChanges}
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 items-center gap-x-2 text-sm sm:basis-1/2">
<ChartPieIcon className="h-4 w-4 flex-shrink-0" />
@ -233,7 +235,11 @@ const ModuleDetailSidebar: React.FC<Props> = ({
</div>
</div>
<div className="py-1">
<SelectStatus control={control} submitChanges={submitChanges} watch={watch} />
<SidebarStatusSelect
control={control}
submitChanges={submitChanges}
watch={watch}
/>
</div>
<div className="py-1">
<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 Link from "next/link";
import Image from "next/image";
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
import { CalendarDaysIcon, TrashIcon } from "@heroicons/react/24/outline";
import User from "public/user.png";
// helpers
import { renderShortNumericDateFormat } from "helpers/date-time.helper";
// types
import { IModule, SelectModuleType } from "types";
// common
import { MODULE_STATUS } from "constants/";
import { MODULE_STATUS } from "constants/module";
type Props = {
module: IModule;
};
const SingleModuleCard: React.FC<Props> = ({ module }) => {
export const SingleModuleCard: React.FC<Props> = ({ module }) => {
const router = useRouter();
const { workspaceSlug } = router.query;
const [moduleDeleteModal, setModuleDeleteModal] = useState(false);
@ -40,7 +43,7 @@ const SingleModuleCard: React.FC<Props> = ({ module }) => {
<TrashIcon className="h-4 w-4" />
</button>
</div>
<ConfirmModuleDeletion
<DeleteModuleModal
isOpen={
moduleDeleteModal &&
!!selectedModuleForDelete &&
@ -147,5 +150,3 @@ const SingleModuleCard: React.FC<Props> = ({ module }) => {
</div>
);
};
export default SingleModuleCard;

View File

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

View File

@ -17,13 +17,13 @@ import { Button, Input, TextArea, CustomSelect } from "components/ui";
// components
import EmojiIconPicker from "components/emoji-icon-picker";
// helpers
import { getRandomEmoji } from "helpers/functions.helper";
import { getRandomEmoji } from "helpers/common.helper";
// types
import { IProject } from "types";
// fetch-keys
import { PROJECTS_LIST, WORKSPACE_MEMBERS_ME } from "constants/fetch-keys";
// constants
import { NETWORK_CHOICES } from "constants/";
import { NETWORK_CHOICES } from "constants/project";
type Props = {
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";
// ui
import { Loader, CustomDatePicker } from "components/ui";
//progress-bar
// progress-bar
import { CircularProgressbar } from "react-circular-progressbar";
import "react-circular-progressbar/dist/styles.css";
// helpers
@ -24,7 +24,7 @@ import { groupBy } from "helpers/array.helper";
// types
import { CycleIssueResponse, ICycle } from "types";
// fetch-keys
import { CYCLE_LIST } from "constants/fetch-keys";
import { CYCLE_DETAILS } from "constants/fetch-keys";
type Props = {
cycle: ICycle | undefined;
@ -57,23 +57,19 @@ const CycleDetailSidebar: React.FC<Props> = ({ cycle, isOpen, cycleIssues }) =>
};
const submitChanges = (data: Partial<ICycle>) => {
if (!workspaceSlug || !projectId || !module) return;
if (!workspaceSlug || !projectId || !cycleId) return;
mutate<ICycle[]>(
projectId && CYCLE_LIST(projectId as string),
(prevData) =>
(prevData ?? []).map((tempCycle) => {
if (tempCycle.id === cycleId) return { ...tempCycle, ...data };
return tempCycle;
}),
mutate<ICycle>(
CYCLE_DETAILS(cycleId as string),
(prevData) => ({ ...(prevData as ICycle), ...data }),
false
);
cyclesService
.patchCycle(workspaceSlug as string, projectId as string, cycle?.id ?? "", data)
.patchCycle(workspaceSlug as string, projectId as string, cycleId as string, data)
.then((res) => {
console.log(res);
mutate(CYCLE_LIST(projectId as string));
mutate(CYCLE_DETAILS(cycleId as string));
})
.catch((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">
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>
</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