Merge pull request #215 from makeplane/develop

Stage Release
This commit is contained in:
sriram veeraghanta 2023-01-31 18:13:57 +05:30 committed by GitHub
commit 09292025df
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
127 changed files with 2509 additions and 2113 deletions

View File

@ -49,7 +49,7 @@ USER root
RUN apk --update --no-cache add "bash~=5.1" RUN apk --update --no-cache add "bash~=5.1"
COPY ./bin ./bin/ COPY ./bin ./bin/
RUN chmod +x ./bin/channel-worker ./bin/takeoff ./bin/worker RUN chmod +x ./bin/takeoff ./bin/worker
USER captain USER captain

View File

@ -1,3 +1,2 @@
web: gunicorn -w 4 -k uvicorn.workers.UvicornWorker plane.asgi:application --bind 0.0.0.0:$PORT --config gunicorn.config.py --max-requests 10000 --max-requests-jitter 1000 --access-logfile - web: gunicorn -w 4 -k uvicorn.workers.UvicornWorker plane.asgi:application --bind 0.0.0.0:$PORT --config gunicorn.config.py --max-requests 10000 --max-requests-jitter 1000 --access-logfile -
worker: python manage.py rqworker worker: python manage.py rqworker
channel-worker: python manage.py runworker issue-activites

View File

@ -1,6 +0,0 @@
#!/bin/bash
set -e
python manage.py wait_for_db
python manage.py migrate
python manage.py runworker issue-activites

View File

@ -1 +0,0 @@
from .issue_consumer import IssueConsumer

View File

@ -1,547 +0,0 @@
from channels.generic.websocket import SyncConsumer
import json
from plane.db.models import IssueActivity, Project, User, Issue, State, Label
class IssueConsumer(SyncConsumer):
# Track Chnages in name
def track_name(
self,
requested_data,
current_instance,
issue_id,
project,
actor,
issue_activities,
):
if current_instance.get("name") != requested_data.get("name"):
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value=current_instance.get("name"),
new_value=requested_data.get("name"),
field="name",
project=project,
workspace=project.workspace,
comment=f"{actor.email} updated the start date to {requested_data.get('name')}",
)
)
# Track changes in parent issue
def track_parent(
self,
requested_data,
current_instance,
issue_id,
project,
actor,
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(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value=f"{project.identifier}-{old_parent.sequence_id}",
new_value=None,
field="parent",
project=project,
workspace=project.workspace,
comment=f"{actor.email} updated the parent issue to None",
old_identifier=old_parent.id,
new_identifier=None,
)
)
else:
new_parent = Issue.objects.get(pk=requested_data.get("parent"))
old_parent = Issue.objects.filter(
pk=current_instance.get("parent")
).first()
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value=f"{project.identifier}-{old_parent.sequence_id}"
if old_parent is not None
else None,
new_value=f"{project.identifier}-{new_parent.sequence_id}",
field="parent",
project=project,
workspace=project.workspace,
comment=f"{actor.email} updated the parent issue to {new_parent.name}",
old_identifier=old_parent.id
if old_parent is not None
else None,
new_identifier=new_parent.id,
)
)
# Track changes in priority
def track_priority(
self,
requested_data,
current_instance,
issue_id,
project,
actor,
issue_activities,
):
if current_instance.get("priority") != requested_data.get("priority"):
if requested_data.get("priority") == None:
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value=current_instance.get("parent"),
new_value=requested_data.get("parent"),
field="priority",
project=project,
workspace=project.workspace,
comment=f"{actor.email} updated the priority to None",
)
)
else:
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value=current_instance.get("priority"),
new_value=requested_data.get("priority"),
field="priority",
project=project,
workspace=project.workspace,
comment=f"{actor.email} updated the priority to {requested_data.get('priority')}",
)
)
# Track chnages in state of the issue
def track_state(
self,
requested_data,
current_instance,
issue_id,
project,
actor,
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))
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value=old_state.name,
new_value=new_state.name,
field="state",
project=project,
workspace=project.workspace,
comment=f"{actor.email} updated the state to {new_state.name}",
old_identifier=old_state.id,
new_identifier=new_state.id,
)
)
# Track issue description
def track_description(
self,
requested_data,
current_instance,
issue_id,
project,
actor,
issue_activities,
):
if current_instance.get("description_html") != requested_data.get("description_html"):
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value=current_instance.get("description_html"),
new_value=requested_data.get("description_html"),
field="description",
project=project,
workspace=project.workspace,
comment=f"{actor.email} updated the description to {requested_data.get('description_html')}",
)
)
# Track changes in issue target date
def track_target_date(
self,
requested_data,
current_instance,
issue_id,
project,
actor,
issue_activities,
):
if current_instance.get("target_date") != requested_data.get("target_date"):
if requested_data.get("target_date") == None:
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value=current_instance.get("target_date"),
new_value=requested_data.get("target_date"),
field="target_date",
project=project,
workspace=project.workspace,
comment=f"{actor.email} updated the target date to None",
)
)
else:
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value=current_instance.get("target_date"),
new_value=requested_data.get("target_date"),
field="target_date",
project=project,
workspace=project.workspace,
comment=f"{actor.email} updated the target date to {requested_data.get('target_date')}",
)
)
# Track changes in issue start date
def track_start_date(
self,
requested_data,
current_instance,
issue_id,
project,
actor,
issue_activities,
):
if current_instance.get("start_date") != requested_data.get("start_date"):
if requested_data.get("start_date") == None:
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value=current_instance.get("start_date"),
new_value=requested_data.get("start_date"),
field="start_date",
project=project,
workspace=project.workspace,
comment=f"{actor.email} updated the start date to None",
)
)
else:
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value=current_instance.get("start_date"),
new_value=requested_data.get("start_date"),
field="start_date",
project=project,
workspace=project.workspace,
comment=f"{actor.email} updated the start date to {requested_data.get('start_date')}",
)
)
# Track changes in issue labels
def track_labels(
self,
requested_data,
current_instance,
issue_id,
project,
actor,
issue_activities,
):
# 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)
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value="",
new_value=label.name,
field="labels",
project=project,
workspace=project.workspace,
comment=f"{actor.email} added label {label.name}",
new_identifier=label.id,
old_identifier=None,
)
)
# 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)
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value=label.name,
new_value="",
field="labels",
project=project,
workspace=project.workspace,
comment=f"{actor.email} removed label {label.name}",
old_identifier=label.id,
new_identifier=None,
)
)
# Track changes in issue assignees
def track_assignees(
self,
requested_data,
current_instance,
issue_id,
project,
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)
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value="",
new_value=assignee.email,
field="assignees",
project=project,
workspace=project.workspace,
comment=f"{actor.email} added assignee {assignee.email}",
new_identifier=actor.id,
)
)
# Assignee Removal
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)
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value=assignee.email,
new_value="",
field="assignee",
project=project,
workspace=project.workspace,
comment=f"{actor.email} removed assignee {assignee.email}",
old_identifier=actor.id,
)
)
# Track changes in blocking issues
def track_blocks(
self,
requested_data,
current_instance,
issue_id,
project,
actor,
issue_activities,
):
if len(requested_data.get("blocks_list")) > len(
current_instance.get("blocked_issues")
):
for block in requested_data.get("blocks_list"):
if (
len(
[
blocked
for blocked in current_instance.get("blocked_issues")
if blocked.get("block") == block
]
)
== 0
):
issue = Issue.objects.get(pk=block)
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value="",
new_value=f"{project.identifier}-{issue.sequence_id}",
field="blocks",
project=project,
workspace=project.workspace,
comment=f"{actor.email} added blocking issue {project.identifier}-{issue.sequence_id}",
new_identifier=issue.id,
)
)
# Blocked Issue Removal
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"))
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value=f"{project.identifier}-{issue.sequence_id}",
new_value="",
field="blocks",
project=project,
workspace=project.workspace,
comment=f"{actor.email} removed blocking issue {project.identifier}-{issue.sequence_id}",
old_identifier=issue.id,
)
)
# Track changes in blocked_by issues
def track_blockings(
self,
requested_data,
current_instance,
issue_id,
project,
actor,
issue_activities,
):
if len(requested_data.get("blockers_list")) > len(
current_instance.get("blocker_issues")
):
for block in requested_data.get("blockers_list"):
if (
len(
[
blocked
for blocked in current_instance.get("blocker_issues")
if blocked.get("blocked_by") == block
]
)
== 0
):
issue = Issue.objects.get(pk=block)
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value="",
new_value=f"{project.identifier}-{issue.sequence_id}",
field="blocking",
project=project,
workspace=project.workspace,
comment=f"{actor.email} added blocked by issue {project.identifier}-{issue.sequence_id}",
new_identifier=issue.id,
)
)
# Blocked Issue Removal
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"))
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value=f"{project.identifier}-{issue.sequence_id}",
new_value="",
field="blocking",
project=project,
workspace=project.workspace,
comment=f"{actor.email} removed blocked by issue {project.identifier}-{issue.sequence_id}",
old_identifier=issue.id,
)
)
# Receive message from room group
def issue_activity(self, event):
issue_activities = []
# Remove event type:
event.pop("type")
requested_data = json.loads(event.get("requested_data"))
current_instance = json.loads(event.get("current_instance"))
issue_id = event.get("issue_id")
actor_id = event.get("actor_id")
project_id = event.get("project_id")
actor = User.objects.get(pk=actor_id)
project = Project.objects.get(pk=project_id)
ISSUE_ACTIVITY_MAPPER = {
"name": self.track_name,
"parent": self.track_parent,
"priority": self.track_priority,
"state": self.track_state,
"description": self.track_description,
"target_date": self.track_target_date,
"start_date": self.track_start_date,
"labels_list": self.track_labels,
"assignees_list": self.track_assignees,
"blocks_list": self.track_blocks,
"blockers_list": self.track_blockings,
}
for key in requested_data:
func = ISSUE_ACTIVITY_MAPPER.get(key, None)
if func is not None:
func(
requested_data,
current_instance,
issue_id,
project,
actor,
issue_activities,
)
# Save all the values to database
IssueActivity.objects.bulk_create(issue_activities)

View File

@ -39,3 +39,5 @@ from .issue import (
) )
from .module import ModuleWriteSerializer, ModuleSerializer, ModuleIssueSerializer from .module import ModuleWriteSerializer, ModuleSerializer, ModuleIssueSerializer
from .api_token import APITokenSerializer

View File

@ -0,0 +1,8 @@
from .base import BaseSerializer
from plane.db.models import APIToken
class APITokenSerializer(BaseSerializer):
class Meta:
model = APIToken
fields = "__all__"

View File

@ -93,7 +93,7 @@ class ModuleWriteSerializer(BaseSerializer):
links = validated_data.pop("links_list", None) links = validated_data.pop("links_list", None)
if members is not None: if members is not None:
ModuleIssue.objects.filter(module=instance).delete() ModuleMember.objects.filter(module=instance).delete()
ModuleMember.objects.bulk_create( ModuleMember.objects.bulk_create(
[ [
ModuleMember( ModuleMember(

View File

@ -84,6 +84,9 @@ from plane.api.views import (
ModuleViewSet, ModuleViewSet,
ModuleIssueViewSet, ModuleIssueViewSet,
## End Modules ## End Modules
# Api Tokens
ApiTokenEndpoint,
## End Api Tokens
) )
@ -679,4 +682,8 @@ urlpatterns = [
name="project-module-issues", name="project-module-issues",
), ),
## End Modules ## End Modules
# API Tokens
path("api-tokens/", ApiTokenEndpoint.as_view(), name="api-token"),
path("api-tokens/<uuid:pk>/", ApiTokenEndpoint.as_view(), name="api-token"),
## End API Tokens
] ]

View File

@ -72,3 +72,5 @@ from .authentication import (
) )
from .module import ModuleViewSet, ModuleIssueViewSet from .module import ModuleViewSet, ModuleIssueViewSet
from .api_token import ApiTokenEndpoint

View File

@ -0,0 +1,62 @@
# Python import
from uuid import uuid4
# Third party
from rest_framework.response import Response
from rest_framework import status
from sentry_sdk import capture_exception
# Module import
from .base import BaseAPIView
from plane.db.models import APIToken
from plane.api.serializers import APITokenSerializer
class ApiTokenEndpoint(BaseAPIView):
def post(self, request):
try:
label = request.data.get("label", str(uuid4().hex))
api_token = APIToken.objects.create(
label=label,
user=request.user,
)
serializer = APITokenSerializer(api_token)
return Response(serializer.data, status=status.HTTP_201_CREATED)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
def get(self, request):
try:
api_tokens = APIToken.objects.filter(user=request.user)
serializer = APITokenSerializer(api_tokens, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
def delete(self, request, pk):
try:
api_token = APIToken.objects.get(pk=pk)
api_token.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
except APIToken.DoesNotExist:
return Response(
{"error": "Token does not exists"}, status=status.HTTP_400_BAD_REQUEST
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)

View File

@ -10,8 +10,6 @@ from django.core.serializers.json import DjangoJSONEncoder
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework import status from rest_framework import status
from sentry_sdk import capture_exception from sentry_sdk import capture_exception
from channels.layers import get_channel_layer
from asgiref.sync import async_to_sync
# Module imports # Module imports
from . import BaseViewSet, BaseAPIView from . import BaseViewSet, BaseAPIView
@ -42,6 +40,7 @@ from plane.db.models import (
CycleIssue, CycleIssue,
ModuleIssue, ModuleIssue,
) )
from plane.bgtasks.issue_activites_task import issue_activity
class IssueViewSet(BaseViewSet): class IssueViewSet(BaseViewSet):
@ -72,12 +71,12 @@ class IssueViewSet(BaseViewSet):
def perform_update(self, serializer): def perform_update(self, serializer):
requested_data = json.dumps(self.request.data, cls=DjangoJSONEncoder) requested_data = json.dumps(self.request.data, cls=DjangoJSONEncoder)
current_instance = Issue.objects.filter(pk=self.kwargs.get("pk", None)).first() current_instance = (
self.get_queryset().filter(pk=self.kwargs.get("pk", None)).first()
)
if current_instance is not None: if current_instance is not None:
channel_layer = get_channel_layer() issue_activity.delay(
async_to_sync(channel_layer.send)(
"issue-activites",
{ {
"type": "issue.activity", "type": "issue.activity",
"requested_data": requested_data, "requested_data": requested_data,

View File

@ -1,6 +1,6 @@
import os import os
from channels.routing import ProtocolTypeRouter, ChannelNameRouter from channels.routing import ProtocolTypeRouter
from django.core.asgi import get_asgi_application from django.core.asgi import get_asgi_application
django_asgi_app = get_asgi_application() django_asgi_app = get_asgi_application()
@ -10,15 +10,9 @@ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "plane.settings.production")
# Initialize Django ASGI application early to ensure the AppRegistry # Initialize Django ASGI application early to ensure the AppRegistry
# is populated before importing code that may import ORM models. # is populated before importing code that may import ORM models.
from plane.api.consumers import IssueConsumer
application = ProtocolTypeRouter( application = ProtocolTypeRouter(
{ {
"http": get_asgi_application(), "http": get_asgi_application(),
"channel": ChannelNameRouter(
{
"issue-activites": IssueConsumer.as_asgi(),
}
),
} }
) )

View File

@ -0,0 +1,553 @@
# Python imports
import json
# Third Party imports
from django_rq import job
from sentry_sdk import capture_exception
# Module imports
from plane.db.models import User, Issue, Project, Label, IssueActivity, State
# Track Chnages in name
def track_name(
requested_data,
current_instance,
issue_id,
project,
actor,
issue_activities,
):
if current_instance.get("name") != requested_data.get("name"):
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value=current_instance.get("name"),
new_value=requested_data.get("name"),
field="name",
project=project,
workspace=project.workspace,
comment=f"{actor.email} updated the start date to {requested_data.get('name')}",
)
)
# Track changes in parent issue
def track_parent(
requested_data,
current_instance,
issue_id,
project,
actor,
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(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value=f"{project.identifier}-{old_parent.sequence_id}",
new_value=None,
field="parent",
project=project,
workspace=project.workspace,
comment=f"{actor.email} updated the parent issue to None",
old_identifier=old_parent.id,
new_identifier=None,
)
)
else:
new_parent = Issue.objects.get(pk=requested_data.get("parent"))
old_parent = Issue.objects.filter(pk=current_instance.get("parent")).first()
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value=f"{project.identifier}-{old_parent.sequence_id}"
if old_parent is not None
else None,
new_value=f"{project.identifier}-{new_parent.sequence_id}",
field="parent",
project=project,
workspace=project.workspace,
comment=f"{actor.email} updated the parent issue to {new_parent.name}",
old_identifier=old_parent.id if old_parent is not None else None,
new_identifier=new_parent.id,
)
)
# Track changes in priority
def track_priority(
requested_data,
current_instance,
issue_id,
project,
actor,
issue_activities,
):
if current_instance.get("priority") != requested_data.get("priority"):
if requested_data.get("priority") == None:
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value=current_instance.get("priority"),
new_value=None,
field="priority",
project=project,
workspace=project.workspace,
comment=f"{actor.email} updated the priority to None",
)
)
else:
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value=current_instance.get("priority"),
new_value=requested_data.get("priority"),
field="priority",
project=project,
workspace=project.workspace,
comment=f"{actor.email} updated the priority to {requested_data.get('priority')}",
)
)
# Track chnages in state of the issue
def track_state(
requested_data,
current_instance,
issue_id,
project,
actor,
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))
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value=old_state.name,
new_value=new_state.name,
field="state",
project=project,
workspace=project.workspace,
comment=f"{actor.email} updated the state to {new_state.name}",
old_identifier=old_state.id,
new_identifier=new_state.id,
)
)
# Track issue description
def track_description(
requested_data,
current_instance,
issue_id,
project,
actor,
issue_activities,
):
if current_instance.get("description_html") != requested_data.get(
"description_html"
):
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value=current_instance.get("description_html"),
new_value=requested_data.get("description_html"),
field="description",
project=project,
workspace=project.workspace,
comment=f"{actor.email} updated the description to {requested_data.get('description_html')}",
)
)
# Track changes in issue target date
def track_target_date(
requested_data,
current_instance,
issue_id,
project,
actor,
issue_activities,
):
if current_instance.get("target_date") != requested_data.get("target_date"):
if requested_data.get("target_date") == None:
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value=current_instance.get("target_date"),
new_value=requested_data.get("target_date"),
field="target_date",
project=project,
workspace=project.workspace,
comment=f"{actor.email} updated the target date to None",
)
)
else:
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value=current_instance.get("target_date"),
new_value=requested_data.get("target_date"),
field="target_date",
project=project,
workspace=project.workspace,
comment=f"{actor.email} updated the target date to {requested_data.get('target_date')}",
)
)
# Track changes in issue start date
def track_start_date(
requested_data,
current_instance,
issue_id,
project,
actor,
issue_activities,
):
if current_instance.get("start_date") != requested_data.get("start_date"):
if requested_data.get("start_date") == None:
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value=current_instance.get("start_date"),
new_value=requested_data.get("start_date"),
field="start_date",
project=project,
workspace=project.workspace,
comment=f"{actor.email} updated the start date to None",
)
)
else:
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value=current_instance.get("start_date"),
new_value=requested_data.get("start_date"),
field="start_date",
project=project,
workspace=project.workspace,
comment=f"{actor.email} updated the start date to {requested_data.get('start_date')}",
)
)
# Track changes in issue labels
def track_labels(
requested_data,
current_instance,
issue_id,
project,
actor,
issue_activities,
):
# 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)
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value="",
new_value=label.name,
field="labels",
project=project,
workspace=project.workspace,
comment=f"{actor.email} added label {label.name}",
new_identifier=label.id,
old_identifier=None,
)
)
# 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)
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value=label.name,
new_value="",
field="labels",
project=project,
workspace=project.workspace,
comment=f"{actor.email} removed label {label.name}",
old_identifier=label.id,
new_identifier=None,
)
)
# Track changes in issue assignees
def track_assignees(
requested_data,
current_instance,
issue_id,
project,
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)
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value="",
new_value=assignee.email,
field="assignees",
project=project,
workspace=project.workspace,
comment=f"{actor.email} added assignee {assignee.email}",
new_identifier=actor.id,
)
)
# Assignee Removal
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)
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value=assignee.email,
new_value="",
field="assignee",
project=project,
workspace=project.workspace,
comment=f"{actor.email} removed assignee {assignee.email}",
old_identifier=actor.id,
)
)
# Track changes in blocking issues
def track_blocks(
requested_data,
current_instance,
issue_id,
project,
actor,
issue_activities,
):
if len(requested_data.get("blocks_list")) > len(
current_instance.get("blocked_issues")
):
for block in requested_data.get("blocks_list"):
if (
len(
[
blocked
for blocked in current_instance.get("blocked_issues")
if blocked.get("block") == block
]
)
== 0
):
issue = Issue.objects.get(pk=block)
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value="",
new_value=f"{project.identifier}-{issue.sequence_id}",
field="blocks",
project=project,
workspace=project.workspace,
comment=f"{actor.email} added blocking issue {project.identifier}-{issue.sequence_id}",
new_identifier=issue.id,
)
)
# Blocked Issue Removal
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"))
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value=f"{project.identifier}-{issue.sequence_id}",
new_value="",
field="blocks",
project=project,
workspace=project.workspace,
comment=f"{actor.email} removed blocking issue {project.identifier}-{issue.sequence_id}",
old_identifier=issue.id,
)
)
# Track changes in blocked_by issues
def track_blockings(
requested_data,
current_instance,
issue_id,
project,
actor,
issue_activities,
):
if len(requested_data.get("blockers_list")) > len(
current_instance.get("blocker_issues")
):
for block in requested_data.get("blockers_list"):
if (
len(
[
blocked
for blocked in current_instance.get("blocker_issues")
if blocked.get("blocked_by") == block
]
)
== 0
):
issue = Issue.objects.get(pk=block)
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value="",
new_value=f"{project.identifier}-{issue.sequence_id}",
field="blocking",
project=project,
workspace=project.workspace,
comment=f"{actor.email} added blocked by issue {project.identifier}-{issue.sequence_id}",
new_identifier=issue.id,
)
)
# Blocked Issue Removal
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"))
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value=f"{project.identifier}-{issue.sequence_id}",
new_value="",
field="blocking",
project=project,
workspace=project.workspace,
comment=f"{actor.email} removed blocked by issue {project.identifier}-{issue.sequence_id}",
old_identifier=issue.id,
)
)
# Receive message from room group
@job("default")
def issue_activity(event):
try:
issue_activities = []
requested_data = json.loads(event.get("requested_data"))
current_instance = json.loads(event.get("current_instance"))
issue_id = event.get("issue_id")
actor_id = event.get("actor_id")
project_id = event.get("project_id")
actor = User.objects.get(pk=actor_id)
project = Project.objects.get(pk=project_id)
ISSUE_ACTIVITY_MAPPER = {
"name": track_name,
"parent": track_parent,
"priority": track_priority,
"state": track_state,
"description": track_description,
"target_date": track_target_date,
"start_date": track_start_date,
"labels_list": track_labels,
"assignees_list": track_assignees,
"blocks_list": track_blocks,
"blockers_list": track_blockings,
}
for key in requested_data:
func = ISSUE_ACTIVITY_MAPPER.get(key, None)
if func is not None:
func(
requested_data,
current_instance,
issue_id,
project,
actor,
issue_activities,
)
# Save all the values to database
_ = IssueActivity.objects.bulk_create(issue_activities)
return
except Exception as e:
capture_exception(e)
return

View File

@ -1,52 +1,5 @@
from django.apps import AppConfig from django.apps import AppConfig
from fieldsignals import post_save_changed
class DbConfig(AppConfig): class DbConfig(AppConfig):
name = "plane.db" name = "plane.db"
# def ready(self):
# post_save_changed.connect(
# self.model_activity,
# sender=self.get_model("Issue"),
# )
# def model_activity(self, sender, instance, changed_fields, **kwargs):
# verb = "created" if instance._state.adding else "changed"
# import inspect
# for frame_record in inspect.stack():
# if frame_record[3] == "get_response":
# request = frame_record[0].f_locals["request"]
# REQUEST_METHOD = request.method
# if REQUEST_METHOD == "POST":
# self.get_model("IssueActivity").objects.create(
# issue=instance, project=instance.project, actor=instance.created_by
# )
# elif REQUEST_METHOD == "PATCH":
# try:
# del changed_fields["updated_at"]
# del changed_fields["updated_by"]
# except KeyError as e:
# pass
# for field_name, (old, new) in changed_fields.items():
# field = field_name
# old_value = old
# new_value = new
# self.get_model("IssueActivity").objects.create(
# issue=instance,
# verb=verb,
# field=field,
# old_value=old_value,
# new_value=new_value,
# project=instance.project,
# actor=instance.updated_by,
# )

View File

@ -0,0 +1,57 @@
# Generated by Django 3.2.16 on 2023-01-29 19:49
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import plane.db.models.api_token
import uuid
class Migration(migrations.Migration):
dependencies = [
('db', '0017_alter_workspace_unique_together'),
]
operations = [
migrations.AddField(
model_name='user',
name='is_bot',
field=models.BooleanField(default=False),
),
migrations.AlterField(
model_name='issue',
name='description',
field=models.JSONField(blank=True, null=True),
),
migrations.AlterField(
model_name='issue',
name='description_html',
field=models.TextField(blank=True, null=True),
),
migrations.AlterField(
model_name='issue',
name='description_stripped',
field=models.TextField(blank=True, null=True),
),
migrations.CreateModel(
name='APIToken',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')),
('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
('token', models.CharField(default=plane.db.models.api_token.generate_token, max_length=255, unique=True)),
('label', models.CharField(default=plane.db.models.api_token.generate_label_token, max_length=255)),
('user_type', models.PositiveSmallIntegerField(choices=[(0, 'Human'), (1, 'Bot')], default=0)),
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='apitoken_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='apitoken_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bot_tokens', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'API Token',
'verbose_name_plural': 'API Tokems',
'db_table': 'api_tokens',
'ordering': ('-created_at',),
},
),
]

View File

@ -0,0 +1,23 @@
# Generated by Django 3.2.16 on 2023-01-30 19:19
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('db', '0018_auto_20230130_0119'),
]
operations = [
migrations.AlterField(
model_name='issueactivity',
name='new_value',
field=models.TextField(blank=True, null=True, verbose_name='New Value'),
),
migrations.AlterField(
model_name='issueactivity',
name='old_value',
field=models.TextField(blank=True, null=True, verbose_name='Old Value'),
),
]

View File

@ -38,3 +38,5 @@ from .shortcut import Shortcut
from .view import View from .view import View
from .module import Module, ModuleMember, ModuleIssue, ModuleLink from .module import Module, ModuleMember, ModuleIssue, ModuleLink
from .api_token import APIToken

View File

@ -0,0 +1,39 @@
# Python imports
from uuid import uuid4
# Django imports
from django.db import models
from django.conf import settings
from .base import BaseModel
def generate_label_token():
return uuid4().hex
def generate_token():
return uuid4().hex + uuid4().hex
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(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="bot_tokens",
)
user_type = models.PositiveSmallIntegerField(
choices=((0, "Human"), (1, "Bot")), default=0
)
class Meta:
verbose_name = "API Token"
verbose_name_plural = "API Tokems"
db_table = "api_tokens"
ordering = ("-created_at",)
def __str__(self):
return str(self.user.name)

View File

@ -145,12 +145,8 @@ class IssueActivity(ProjectBaseModel):
field = models.CharField( field = models.CharField(
max_length=255, verbose_name="Field Name", blank=True, null=True max_length=255, verbose_name="Field Name", blank=True, null=True
) )
old_value = models.CharField( old_value = models.TextField(verbose_name="Old Value", blank=True, null=True)
max_length=255, verbose_name="Old Value", blank=True, null=True new_value = models.TextField(verbose_name="New Value", blank=True, null=True)
)
new_value = models.CharField(
max_length=255, verbose_name="New Value", blank=True, null=True
)
comment = models.TextField(verbose_name="Comment", blank=True) comment = models.TextField(verbose_name="Comment", blank=True)
attachments = ArrayField(models.URLField(), size=10, blank=True, default=list) attachments = ArrayField(models.URLField(), size=10, blank=True, default=list)

View File

@ -68,6 +68,7 @@ class User(AbstractBaseUser, PermissionsMixin):
last_workspace_id = models.UUIDField(null=True) last_workspace_id = models.UUIDField(null=True)
my_issues_prop = models.JSONField(null=True) my_issues_prop = models.JSONField(null=True)
role = models.CharField(max_length=300, null=True, blank=True) role = models.CharField(max_length=300, null=True, blank=True)
is_bot = models.BooleanField(default=False)
USERNAME_FIELD = "email" USERNAME_FIELD = "email"
@ -101,7 +102,7 @@ class User(AbstractBaseUser, PermissionsMixin):
@receiver(post_save, sender=User) @receiver(post_save, sender=User)
def send_welcome_email(sender, instance, created, **kwargs): def send_welcome_email(sender, instance, created, **kwargs):
try: try:
if created: if created and not instance.is_bot:
first_name = instance.first_name.capitalize() first_name = instance.first_name.capitalize()
to_email = instance.email to_email = instance.email
from_email_string = f"Team Plane <team@mailer.plane.so>" from_email_string = f"Team Plane <team@mailer.plane.so>"

View File

@ -34,9 +34,7 @@ INSTALLED_APPS = [
"rest_framework_simplejwt.token_blacklist", "rest_framework_simplejwt.token_blacklist",
"corsheaders", "corsheaders",
"taggit", "taggit",
"fieldsignals",
"django_rq", "django_rq",
"channels",
] ]
MIDDLEWARE = [ MIDDLEWARE = [

View File

@ -66,11 +66,3 @@ RQ_QUEUES = {
WEB_URL = "http://localhost:3000" WEB_URL = "http://localhost:3000"
CHANNEL_LAYERS = {
"default": {
"BACKEND": "channels_redis.core.RedisChannelLayer",
"CONFIG": {
"hosts": [(REDIS_HOST, REDIS_PORT)],
},
},
}

View File

@ -1,11 +1,8 @@
"""Production settings and globals.""" """Production settings and globals."""
import ssl
from typing import Optional
from urllib.parse import urlparse from urllib.parse import urlparse
import dj_database_url import dj_database_url
from urllib.parse import urlparse from urllib.parse import urlparse
from redis.asyncio.connection import Connection, RedisSSLContext
import sentry_sdk import sentry_sdk
from sentry_sdk.integrations.django import DjangoIntegration from sentry_sdk.integrations.django import DjangoIntegration
@ -186,64 +183,10 @@ RQ_QUEUES = {
} }
class CustomSSLConnection(Connection):
def __init__(
self,
ssl_context: Optional[str] = None,
**kwargs,
):
super().__init__(**kwargs)
self.ssl_context = RedisSSLContext(ssl_context)
class RedisSSLContext:
__slots__ = ("context",)
def __init__(
self,
ssl_context,
):
self.context = ssl_context
def get(self):
return self.context
url = urlparse(os.environ.get("REDIS_URL")) url = urlparse(os.environ.get("REDIS_URL"))
DOCKERIZED = os.environ.get("DOCKERIZED", False) # Set the variable true if running in docker-compose environment DOCKERIZED = os.environ.get(
"DOCKERIZED", False
if not DOCKERIZED: ) # Set the variable true if running in docker-compose environment
ssl_context = ssl.SSLContext()
ssl_context.check_hostname = False
CHANNEL_LAYERS = {
"default": {
"BACKEND": "channels_redis.core.RedisChannelLayer",
"CONFIG": {
"hosts": [
{
"host": url.hostname,
"port": url.port,
"username": url.username,
"password": url.password,
"connection_class": CustomSSLConnection,
"ssl_context": ssl_context,
}
],
},
},
}
else:
CHANNEL_LAYERS = {
"default": {
"BACKEND": "channels_redis.core.RedisChannelLayer",
"CONFIG": {
"hosts": [(os.environ.get("REDIS_URL"))],
},
},
}
WEB_URL = os.environ.get("WEB_URL") WEB_URL = os.environ.get("WEB_URL")

View File

@ -1,11 +1,8 @@
"""Production settings and globals.""" """Production settings and globals."""
import ssl
from typing import Optional
from urllib.parse import urlparse from urllib.parse import urlparse
import dj_database_url import dj_database_url
from urllib.parse import urlparse from urllib.parse import urlparse
from redis.asyncio.connection import Connection, RedisSSLContext
import sentry_sdk import sentry_sdk
from sentry_sdk.integrations.django import DjangoIntegration from sentry_sdk.integrations.django import DjangoIntegration
@ -186,52 +183,5 @@ RQ_QUEUES = {
} }
} }
class CustomSSLConnection(Connection):
def __init__(
self,
ssl_context: Optional[str] = None,
**kwargs,
):
super().__init__(**kwargs)
self.ssl_context = RedisSSLContext(ssl_context)
class RedisSSLContext:
__slots__ = (
"context",
)
def __init__(
self,
ssl_context,
):
self.context = ssl_context
def get(self):
return self.context
url = urlparse(os.environ.get("REDIS_URL"))
ssl_context = ssl.SSLContext()
ssl_context.check_hostname = False
CHANNEL_LAYERS = {
'default': {
'BACKEND': 'channels_redis.core.RedisChannelLayer',
'CONFIG': {
'hosts': [
{
'host': url.hostname,
'port': url.port,
'username': url.username,
'password': url.password,
'connection_class': CustomSSLConnection,
'ssl_context': ssl_context,
}
],
}
},
}
WEB_URL = os.environ.get("WEB_URL") WEB_URL = os.environ.get("WEB_URL")

View File

@ -20,12 +20,9 @@ sentry-sdk==1.13.0
django-s3-storage==0.13.6 django-s3-storage==0.13.6
django-crum==0.7.9 django-crum==0.7.9
django-guardian==2.4.0 django-guardian==2.4.0
django-fieldsignals==0.7.0
dj_rest_auth==2.2.5 dj_rest_auth==2.2.5
google-auth==2.9.1 google-auth==2.9.1
google-api-python-client==2.55.0 google-api-python-client==2.55.0
django-rq==2.5.1 django-rq==2.5.1
django-redis==5.2.0 django-redis==5.2.0
channels==4.0.0
channels-redis==4.0.0
uvicorn==0.20.0 uvicorn==0.20.0

View File

@ -5,6 +5,7 @@ import { CheckCircleIcon } from "@heroicons/react/20/solid";
import { Button, Input } from "components/ui"; import { Button, Input } from "components/ui";
// services // services
import authenticationService from "services/authentication.service"; import authenticationService from "services/authentication.service";
import useToast from "hooks/use-toast";
// icons // icons
// types // types
@ -16,6 +17,7 @@ type EmailCodeFormValues = {
export const EmailCodeForm = ({ onSuccess }: any) => { export const EmailCodeForm = ({ onSuccess }: any) => {
const [codeSent, setCodeSent] = useState(false); const [codeSent, setCodeSent] = useState(false);
const { setToastAlert } = useToast();
const { const {
register, register,
handleSubmit, handleSubmit,
@ -53,6 +55,11 @@ export const EmailCodeForm = ({ onSuccess }: any) => {
}) })
.catch((error) => { .catch((error) => {
console.log(error); console.log(error);
setToastAlert({
title: "Oops!",
type: "error",
message: "Enter the correct code to sign in",
});
setError("token" as keyof EmailCodeFormValues, { setError("token" as keyof EmailCodeFormValues, {
type: "manual", type: "manual",
message: error.error, message: error.error,

View File

@ -6,6 +6,7 @@ import { useForm } from "react-hook-form";
// ui // ui
import { Button, Input } from "components/ui"; import { Button, Input } from "components/ui";
import authenticationService from "services/authentication.service"; import authenticationService from "services/authentication.service";
import useToast from "hooks/use-toast";
// types // types
type EmailPasswordFormValues = { type EmailPasswordFormValues = {
@ -15,6 +16,7 @@ type EmailPasswordFormValues = {
}; };
export const EmailPasswordForm = ({ onSuccess }: any) => { export const EmailPasswordForm = ({ onSuccess }: any) => {
const { setToastAlert } = useToast();
const { const {
register, register,
handleSubmit, handleSubmit,
@ -38,6 +40,11 @@ export const EmailPasswordForm = ({ onSuccess }: any) => {
}) })
.catch((error) => { .catch((error) => {
console.log(error); console.log(error);
setToastAlert({
title: "Oops!",
type: "error",
message: "Enter the correct email address and password to sign in",
});
if (!error?.response?.data) return; if (!error?.response?.data) return;
Object.keys(error.response.data).forEach((key) => { Object.keys(error.response.data).forEach((key) => {
const err = error.response.data[key]; const err = error.response.data[key];

View File

@ -97,49 +97,56 @@ const CommandPalette: React.FC = () => {
const handleKeyDown = useCallback( const handleKeyDown = useCallback(
(e: KeyboardEvent) => { (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === "/") { if (
e.preventDefault(); !(e.target instanceof HTMLTextAreaElement) &&
setIsPaletteOpen(true); !(e.target instanceof HTMLInputElement) &&
} else if ((e.ctrlKey || e.metaKey) && e.key === "i") { !(e.target as Element).classList?.contains("remirror-editor")
e.preventDefault(); ) {
setIsIssueModalOpen(true); if ((e.ctrlKey || e.metaKey) && e.key === "k") {
} else if ((e.ctrlKey || e.metaKey) && e.key === "p") { e.preventDefault();
e.preventDefault(); setIsPaletteOpen(true);
setIsProjectModalOpen(true); } else if (e.ctrlKey && e.key === "c") {
} else if ((e.ctrlKey || e.metaKey) && e.key === "b") { console.log("Text copied");
e.preventDefault(); } else if (e.key === "c") {
toggleCollapsed(); e.preventDefault();
} else if ((e.ctrlKey || e.metaKey) && e.key === "h") { setIsIssueModalOpen(true);
e.preventDefault(); } else if (e.key === "p") {
setIsShortcutsModalOpen(true); e.preventDefault();
} else if ((e.ctrlKey || e.metaKey) && e.key === "q") { setIsProjectModalOpen(true);
e.preventDefault(); } else if ((e.ctrlKey || e.metaKey) && e.key === "b") {
setIsCreateCycleModalOpen(true); e.preventDefault();
} else if ((e.ctrlKey || e.metaKey) && e.key === "m") { toggleCollapsed();
e.preventDefault(); } else if (e.key === "h") {
setIsCreateModuleModalOpen(true); e.preventDefault();
} else if ((e.ctrlKey || e.metaKey) && e.key === "d") { setIsShortcutsModalOpen(true);
e.preventDefault(); } else if (e.key === "q") {
setIsBulkDeleteIssuesModalOpen(true); e.preventDefault();
} else if ((e.ctrlKey || e.metaKey) && e.altKey && e.key === "c") { setIsCreateCycleModalOpen(true);
e.preventDefault(); } else if (e.key === "m") {
e.preventDefault();
setIsCreateModuleModalOpen(true);
} else if (e.key === "Delete") {
e.preventDefault();
setIsBulkDeleteIssuesModalOpen(true);
} else if ((e.ctrlKey || e.metaKey) && e.altKey && e.key === "c") {
e.preventDefault();
if (!router.query.issueId) return;
if (!router.query.issueId) return; const url = new URL(window.location.href);
copyTextToClipboard(url.href)
const url = new URL(window.location.href); .then(() => {
copyTextToClipboard(url.href) setToastAlert({
.then(() => { type: "success",
setToastAlert({ title: "Copied to clipboard",
type: "success", });
title: "Copied to clipboard", })
.catch(() => {
setToastAlert({
type: "error",
title: "Some error occurred",
});
}); });
}) }
.catch(() => {
setToastAlert({
type: "error",
title: "Some error occurred",
});
});
} }
}, },
[toggleCollapsed, setToastAlert, router] [toggleCollapsed, setToastAlert, router]

View File

@ -15,7 +15,7 @@ const shortcuts = [
{ {
title: "Navigation", title: "Navigation",
shortcuts: [ shortcuts: [
{ keys: "ctrl,/", description: "To open navigator" }, { keys: "ctrl,cmd,k", description: "To open navigator" },
{ keys: "↑", description: "Move up" }, { keys: "↑", description: "Move up" },
{ keys: "↓", description: "Move down" }, { keys: "↓", description: "Move down" },
{ keys: "←", description: "Move left" }, { keys: "←", description: "Move left" },
@ -27,14 +27,14 @@ const shortcuts = [
{ {
title: "Common", title: "Common",
shortcuts: [ shortcuts: [
{ keys: "ctrl,p", description: "To create project" }, { keys: "p", description: "To create project" },
{ keys: "ctrl,i", description: "To create issue" }, { keys: "c", description: "To create issue" },
{ keys: "ctrl,q", description: "To create cycle" }, { keys: "q", description: "To create cycle" },
{ keys: "ctrl,m", description: "To create module" }, { keys: "m", description: "To create module" },
{ keys: "ctrl,d", description: "To bulk delete issues" }, { keys: "Delete", description: "To bulk delete issues" },
{ keys: "ctrl,h", description: "To open shortcuts guide" }, { keys: "h", description: "To open shortcuts guide" },
{ {
keys: "ctrl,alt,c", keys: "ctrl,cmd,alt,c",
description: "To copy issue url when on issue detail page.", description: "To copy issue url when on issue detail page.",
}, },
], ],

View File

@ -1,64 +1,68 @@
import React from "react"; import React from "react";
// next
import Link from "next/link"; import Link from "next/link";
import Image from "next/image"; import Image from "next/image";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
// swr
import useSWR from "swr"; import useSWR, { mutate } from "swr";
// react-beautiful-dnd // react-beautiful-dnd
import { DraggableStateSnapshot } from "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 // headless ui
import { Listbox, Transition } from "@headlessui/react"; import { Listbox, Transition } from "@headlessui/react";
// constants // constants
import { TrashIcon } from "@heroicons/react/24/outline"; import { TrashIcon } from "@heroicons/react/24/outline";
import { CalendarDaysIcon } from "@heroicons/react/20/solid";
// services // services
import issuesService from "services/issues.service"; import issuesService from "services/issues.service";
import stateService from "services/state.service"; import stateService from "services/state.service";
import projectService from "services/project.service"; import projectService from "services/project.service";
// components // components
import { AssigneesList } from "components/ui/avatar"; import { AssigneesList, CustomDatePicker } from "components/ui";
// helpers // helpers
import { renderShortNumericDateFormat, findHowManyDaysLeft } from "helpers/date-time.helper"; import { renderShortNumericDateFormat, findHowManyDaysLeft } from "helpers/date-time.helper";
import { addSpaceIfCamelCase } from "helpers/string.helper"; import { addSpaceIfCamelCase } from "helpers/string.helper";
// types // types
import { IIssue, IssueResponse, IUserLite, IWorkspaceMember, Properties } from "types"; import { IIssue, IUserLite, IWorkspaceMember, Properties, UserAuth } from "types";
// common // common
import { PRIORITIES } from "constants/"; import { PRIORITIES } from "constants/";
import { PROJECT_ISSUES_LIST, STATE_LIST, PROJECT_DETAILS } from "constants/fetch-keys"; import {
STATE_LIST,
PROJECT_DETAILS,
CYCLE_ISSUES,
MODULE_ISSUES,
PROJECT_ISSUES_LIST,
} from "constants/fetch-keys";
import { getPriorityIcon } from "constants/global"; import { getPriorityIcon } from "constants/global";
type Props = { type Props = {
type?: string;
typeId?: string;
issue: IIssue; issue: IIssue;
properties: Properties; properties: Properties;
snapshot?: DraggableStateSnapshot; snapshot?: DraggableStateSnapshot;
assignees: Partial<IUserLite>[] | (Partial<IUserLite> | undefined)[]; assignees: Partial<IUserLite>[] | (Partial<IUserLite> | undefined)[];
people: IWorkspaceMember[] | undefined; people: IWorkspaceMember[] | undefined;
handleDeleteIssue?: React.Dispatch<React.SetStateAction<string | undefined>>; handleDeleteIssue?: React.Dispatch<React.SetStateAction<string | undefined>>;
partialUpdateIssue: any; userAuth: UserAuth;
}; };
const SingleBoardIssue: React.FC<Props> = ({ const SingleBoardIssue: React.FC<Props> = ({
type,
typeId,
issue, issue,
properties, properties,
snapshot, snapshot,
assignees, assignees,
people, people,
handleDeleteIssue, handleDeleteIssue,
partialUpdateIssue, userAuth,
}) => { }) => {
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId } = router.query; const { workspaceSlug, projectId } = router.query;
const { data: issues } = useSWR<IssueResponse>(
workspaceSlug && projectId
? PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string)
: null,
workspaceSlug && projectId
? () => issuesService.getIssues(workspaceSlug as string, projectId as string)
: null
);
const { data: states } = useSWR( const { data: states } = useSWR(
workspaceSlug && projectId ? STATE_LIST(projectId as string) : null, workspaceSlug && projectId ? STATE_LIST(projectId as string) : null,
workspaceSlug && projectId workspaceSlug && projectId
@ -73,7 +77,25 @@ const SingleBoardIssue: React.FC<Props> = ({
: null : null
); );
const totalChildren = issues?.results.filter((i) => i.parent === issue.id).length; const partialUpdateIssue = (formData: Partial<IIssue>) => {
if (!workspaceSlug || !projectId) return;
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 ( return (
<div <div
@ -82,7 +104,7 @@ const SingleBoardIssue: React.FC<Props> = ({
}`} }`}
> >
<div className="group/card relative select-none p-2"> <div className="group/card relative select-none p-2">
{handleDeleteIssue && ( {handleDeleteIssue && !isNotAllowed && (
<div className="absolute top-1.5 right-1.5 z-10 opacity-0 group-hover/card:opacity-100"> <div className="absolute top-1.5 right-1.5 z-10 opacity-0 group-hover/card:opacity-100">
<button <button
type="button" type="button"
@ -114,15 +136,18 @@ const SingleBoardIssue: React.FC<Props> = ({
as="div" as="div"
value={issue.priority} value={issue.priority}
onChange={(data: string) => { onChange={(data: string) => {
partialUpdateIssue({ priority: data }, issue.id); partialUpdateIssue({ priority: data });
}} }}
className="group relative flex-shrink-0" className="group relative flex-shrink-0"
disabled={isNotAllowed}
> >
{({ open }) => ( {({ open }) => (
<> <>
<div> <div>
<Listbox.Button <Listbox.Button
className={`grid 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 ${ 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" issue.priority === "urgent"
? "bg-red-100 text-red-600" ? "bg-red-100 text-red-600"
: issue.priority === "high" : issue.priority === "high"
@ -171,14 +196,19 @@ const SingleBoardIssue: React.FC<Props> = ({
as="div" as="div"
value={issue.state} value={issue.state}
onChange={(data: string) => { onChange={(data: string) => {
partialUpdateIssue({ state: data }, issue.id); partialUpdateIssue({ state: data });
}} }}
className="group relative flex-shrink-0" className="group relative flex-shrink-0"
disabled={isNotAllowed}
> >
{({ open }) => ( {({ open }) => (
<> <>
<div> <div>
<Listbox.Button className="flex 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"> <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 <span
className="h-1.5 w-1.5 flex-shrink-0 rounded-full" className="h-1.5 w-1.5 flex-shrink-0 rounded-full"
style={{ style={{
@ -218,17 +248,18 @@ const SingleBoardIssue: React.FC<Props> = ({
</Listbox.Options> </Listbox.Options>
</Transition> </Transition>
</div> </div>
{/* <div className="absolute bottom-full right-0 mb-2 z-10 hidden group-hover:block p-2 bg-white shadow-md rounded-md whitespace-nowrap">
<h5 className="font-medium mb-1">State</h5>
<div>{issue.state_detail.name}</div>
</div> */}
</> </>
)} )}
</Listbox> </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 && ( {properties.due_date && (
<div <div
className={`group 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 ${ className={`group relative ${
issue.target_date === null issue.target_date === null
? "" ? ""
: issue.target_date < new Date().toISOString() : issue.target_date < new Date().toISOString()
@ -236,13 +267,42 @@ const SingleBoardIssue: React.FC<Props> = ({
: findHowManyDaysLeft(issue.target_date) <= 3 && "text-orange-400" : findHowManyDaysLeft(issue.target_date) <= 3 && "text-orange-400"
}`} }`}
> >
<CalendarDaysIcon className="h-4 w-4" /> <CustomDatePicker
{issue.target_date ? renderShortNumericDateFormat(issue.target_date) : "N/A"} placeholder="N/A"
value={issue?.target_date}
onChange={(val: Date) => {
partialUpdateIssue({
target_date: val
? `${val.getFullYear()}-${val.getMonth() + 1}-${val.getDate()}`
: null,
});
}}
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> </div>
)} )}
{properties.sub_issue_count && ( {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"> <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">
{totalChildren} {totalChildren === 1 ? "sub-issue" : "sub-issues"} {issue.sub_issues_count} {issue.sub_issues_count === 1 ? "sub-issue" : "sub-issues"}
</div> </div>
)} )}
{properties.assignee && ( {properties.assignee && (
@ -255,81 +315,82 @@ const SingleBoardIssue: React.FC<Props> = ({
if (newData.includes(data)) newData.splice(newData.indexOf(data), 1); if (newData.includes(data)) newData.splice(newData.indexOf(data), 1);
else newData.push(data); else newData.push(data);
partialUpdateIssue({ assignees_list: newData }, issue.id); partialUpdateIssue({ assignees_list: newData });
}} }}
className="group relative flex-shrink-0" className="group relative flex-shrink-0"
disabled={isNotAllowed}
> >
{({ open }) => ( {({ open }) => (
<> <div>
<div> <Listbox.Button>
<Listbox.Button> <div
<div className="flex cursor-pointer items-center gap-1 text-xs"> className={`flex ${
<AssigneesList users={assignees} length={3} /> isNotAllowed ? "cursor-not-allowed" : "cursor-pointer"
</div> } items-center gap-1 text-xs`}
</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"> <AssigneesList users={assignees} length={3} />
{people?.map((person) => ( </div>
<Listbox.Option </Listbox.Button>
key={person.id}
className={({ active }) => <Transition
`cursor-pointer select-none p-2 ${ show={open}
active ? "bg-indigo-50" : "bg-white" as={React.Fragment}
}` leave="transition ease-in duration-100"
} leaveFrom="opacity-100"
value={person.member.id} 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"
}`}
> >
<div {person.member.avatar && person.member.avatar !== "" ? (
className={`flex items-center gap-x-1 ${ <div className="relative h-4 w-4">
assignees.includes({ <Image
id: person.member.last_name, src={person.member.avatar}
first_name: person.member.first_name, alt="avatar"
last_name: person.member.last_name, className="rounded-full"
email: person.member.email, layout="fill"
avatar: person.member.avatar, objectFit="cover"
}) priority={false}
? "font-medium" loading="lazy"
: "font-normal" />
}`} </div>
> ) : (
{person.member.avatar && person.member.avatar !== "" ? ( <div className="grid h-4 w-4 place-items-center rounded-full bg-gray-700 capitalize text-white">
<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.first_name !== ""
? person.member.first_name ? person.member.first_name.charAt(0)
: person.member.email} : person.member.email.charAt(0)}
</p> </div>
</div> )}
</Listbox.Option> <p>
))} {person.member.first_name && person.member.first_name !== ""
</Listbox.Options> ? person.member.first_name
</Transition> : person.member.email}
</div> </p>
</> </div>
</Listbox.Option>
))}
</Listbox.Options>
</Transition>
</div>
)} )}
</Listbox> </Listbox>
)} )}

View File

@ -214,10 +214,7 @@ const BulkDeleteIssuesModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
<LayerDiagonalIcon height="56" width="56" /> <LayerDiagonalIcon height="56" width="56" />
<h3 className="text-gray-500"> <h3 className="text-gray-500">
No issues found. Create a new issue with{" "} No issues found. Create a new issue with{" "}
<pre className="inline rounded bg-gray-100 px-2 py-1"> <pre className="inline rounded bg-gray-100 px-2 py-1">C</pre>.
Ctrl/Command + I
</pre>
.
</h3> </h3>
</div> </div>
)} )}

View File

@ -77,11 +77,18 @@ const ExistingIssuesListModal: React.FC<Props> = ({
type: "error", type: "error",
message: "Please select atleast one issue", message: "Please select atleast one issue",
}); });
return; return;
} }
await handleOnSubmit(data); await handleOnSubmit(data);
handleClose(); handleClose();
setToastAlert({
title: "Success",
type: "success",
message: `Issue${data.issues.length > 1 ? "s" : ""} added successfully`,
});
}; };
const filteredIssues: IIssue[] = const filteredIssues: IIssue[] =
@ -182,10 +189,7 @@ const ExistingIssuesListModal: React.FC<Props> = ({
<LayerDiagonalIcon height="56" width="56" /> <LayerDiagonalIcon height="56" width="56" />
<h3 className="text-gray-500"> <h3 className="text-gray-500">
No issues found. Create a new issue with{" "} No issues found. Create a new issue with{" "}
<pre className="inline rounded bg-gray-100 px-2 py-1"> <pre className="inline rounded bg-gray-100 px-2 py-1">C</pre>.
Ctrl/Command + I
</pre>
.
</h3> </h3>
</div> </div>
)} )}

View File

@ -5,6 +5,9 @@ import { useRouter } from "next/router";
import useSWR, { mutate } from "swr"; import useSWR, { mutate } from "swr";
// react-datepicker
import DatePicker from "react-datepicker";
import "react-datepicker/dist/react-datepicker.css";
// services // services
import issuesService from "services/issues.service"; import issuesService from "services/issues.service";
import workspaceService from "services/workspace.service"; import workspaceService from "services/workspace.service";
@ -12,7 +15,7 @@ import stateService from "services/state.service";
// headless ui // headless ui
import { Listbox, Transition } from "@headlessui/react"; import { Listbox, Transition } from "@headlessui/react";
// ui // ui
import { CustomMenu, CustomSelect, AssigneesList, Avatar } from "components/ui"; import { CustomMenu, CustomSelect, AssigneesList, Avatar, CustomDatePicker } from "components/ui";
// components // components
import ConfirmIssueDeletion from "components/project/issues/confirm-issue-deletion"; import ConfirmIssueDeletion from "components/project/issues/confirm-issue-deletion";
// icons // icons
@ -21,7 +24,7 @@ import { CalendarDaysIcon } from "@heroicons/react/24/outline";
import { renderShortNumericDateFormat, findHowManyDaysLeft } from "helpers/date-time.helper"; import { renderShortNumericDateFormat, findHowManyDaysLeft } from "helpers/date-time.helper";
import { addSpaceIfCamelCase } from "helpers/string.helper"; import { addSpaceIfCamelCase } from "helpers/string.helper";
// types // types
import { IIssue, IWorkspaceMember, Properties } from "types"; import { IIssue, IWorkspaceMember, Properties, UserAuth } from "types";
// fetch-keys // fetch-keys
import { import {
CYCLE_ISSUES, CYCLE_ISSUES,
@ -41,6 +44,7 @@ type Props = {
properties: Properties; properties: Properties;
editIssue: () => void; editIssue: () => void;
removeIssue?: () => void; removeIssue?: () => void;
userAuth: UserAuth;
}; };
const SingleListIssue: React.FC<Props> = ({ const SingleListIssue: React.FC<Props> = ({
@ -50,6 +54,7 @@ const SingleListIssue: React.FC<Props> = ({
properties, properties,
editIssue, editIssue,
removeIssue, removeIssue,
userAuth,
}) => { }) => {
const [deleteIssue, setDeleteIssue] = useState<IIssue | undefined>(); const [deleteIssue, setDeleteIssue] = useState<IIssue | undefined>();
@ -86,6 +91,8 @@ const SingleListIssue: React.FC<Props> = ({
}); });
}; };
const isNotAllowed = userAuth.isGuest || userAuth.isViewer;
return ( return (
<> <>
<ConfirmIssueDeletion <ConfirmIssueDeletion
@ -121,12 +128,15 @@ const SingleListIssue: React.FC<Props> = ({
partialUpdateIssue({ priority: data }); partialUpdateIssue({ priority: data });
}} }}
className="group relative flex-shrink-0" className="group relative flex-shrink-0"
disabled={isNotAllowed}
> >
{({ open }) => ( {({ open }) => (
<> <>
<div> <div>
<Listbox.Button <Listbox.Button
className={`flex 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 ${ 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" issue.priority === "urgent"
? "bg-red-100 text-red-600" ? "bg-red-100 text-red-600"
: issue.priority === "high" : issue.priority === "high"
@ -210,6 +220,7 @@ const SingleListIssue: React.FC<Props> = ({
}} }}
maxHeight="md" maxHeight="md"
noChevron noChevron
disabled={isNotAllowed}
> >
{states?.map((state) => ( {states?.map((state) => (
<CustomSelect.Option key={state.id} value={state.id}> <CustomSelect.Option key={state.id} value={state.id}>
@ -226,9 +237,14 @@ const SingleListIssue: React.FC<Props> = ({
))} ))}
</CustomSelect> </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 && ( {properties.due_date && (
<div <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 ${ className={`group relative ${
issue.target_date === null issue.target_date === null
? "" ? ""
: issue.target_date < new Date().toISOString() : issue.target_date < new Date().toISOString()
@ -236,8 +252,37 @@ const SingleListIssue: React.FC<Props> = ({
: findHowManyDaysLeft(issue.target_date) <= 3 && "text-orange-400" : findHowManyDaysLeft(issue.target_date) <= 3 && "text-orange-400"
}`} }`}
> >
<CalendarDaysIcon className="h-4 w-4" /> <CustomDatePicker
{issue.target_date ? renderShortNumericDateFormat(issue.target_date) : "N/A"} placeholder="N/A"
value={issue?.target_date}
onChange={(val: Date) => {
partialUpdateIssue({
target_date: val
? `${val.getFullYear()}-${val.getMonth() + 1}-${val.getDate()}`
: null,
});
}}
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 className="absolute bottom-full right-0 z-10 mb-2 hidden whitespace-nowrap rounded-md bg-white p-2 shadow-md group-hover:block"> <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> <h5 className="mb-1 font-medium text-gray-900">Due date</h5>
<div>{renderShortNumericDateFormat(issue.target_date ?? "")}</div> <div>{renderShortNumericDateFormat(issue.target_date ?? "")}</div>
@ -253,7 +298,7 @@ const SingleListIssue: React.FC<Props> = ({
</div> </div>
)} )}
{properties.sub_issue_count && ( {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"> <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"} {issue.sub_issues_count} {issue.sub_issues_count === 1 ? "sub-issue" : "sub-issues"}
</div> </div>
)} )}
@ -270,12 +315,17 @@ const SingleListIssue: React.FC<Props> = ({
partialUpdateIssue({ assignees_list: newData }); partialUpdateIssue({ assignees_list: newData });
}} }}
className="group relative flex-shrink-0" className="group relative flex-shrink-0"
disabled={isNotAllowed}
> >
{({ open }) => ( {({ open }) => (
<> <>
<div> <div>
<Listbox.Button> <Listbox.Button>
<div className="flex cursor-pointer items-center gap-1 text-xs"> <div
className={`flex ${
isNotAllowed ? "cursor-not-allowed" : "cursor-pointer"
} items-center gap-1 text-xs`}
>
<AssigneesList userIds={issue.assignees ?? []} /> <AssigneesList userIds={issue.assignees ?? []} />
</div> </div>
</Listbox.Button> </Listbox.Button>
@ -325,7 +375,7 @@ const SingleListIssue: React.FC<Props> = ({
)} )}
</Listbox> </Listbox>
)} )}
{type && ( {type && !isNotAllowed && (
<CustomMenu width="auto" ellipsis> <CustomMenu width="auto" ellipsis>
<CustomMenu.MenuItem onClick={editIssue}>Edit</CustomMenu.MenuItem> <CustomMenu.MenuItem onClick={editIssue}>Edit</CustomMenu.MenuItem>
{type !== "issue" && ( {type !== "issue" && (

View File

@ -1,7 +1,11 @@
import { FC, useEffect, useState } from "react"; import { FC, useCallback, useEffect, useMemo } from "react";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
// types
import { IIssue } from "types"; // react-hook-form
import { useForm } from "react-hook-form";
// lodash
import debounce from "lodash.debounce";
// components // components
import { Loader, Input } from "components/ui"; import { Loader, Input } from "components/ui";
const RemirrorRichTextEditor = dynamic(() => import("components/rich-text-editor"), { const RemirrorRichTextEditor = dynamic(() => import("components/rich-text-editor"), {
@ -12,8 +16,9 @@ const RemirrorRichTextEditor = dynamic(() => import("components/rich-text-editor
</Loader> </Loader>
), ),
}); });
// hooks // types
import useDebounce from "hooks/use-debounce"; import { IIssue } from "types";
import useToast from "hooks/use-toast";
export interface IssueDescriptionFormValues { export interface IssueDescriptionFormValues {
name: string; name: string;
@ -23,32 +28,74 @@ export interface IssueDescriptionFormValues {
export interface IssueDetailsProps { export interface IssueDetailsProps {
issue: IIssue; issue: IIssue;
handleSubmit: (value: IssueDescriptionFormValues) => void; handleFormSubmit: (value: IssueDescriptionFormValues) => void;
} }
export const IssueDescriptionForm: FC<IssueDetailsProps> = ({ issue, handleSubmit }) => { export const IssueDescriptionForm: FC<IssueDetailsProps> = ({ issue, handleFormSubmit }) => {
// states const { setToastAlert } = useToast();
// const [issueFormValues, setIssueFormValues] = useState({
// name: issue.name,
// description: issue?.description,
// description_html: issue?.description_html,
// });
const [issueName, setIssueName] = useState(issue?.name); const {
const [issueDescription, setIssueDescription] = useState(issue?.description); handleSubmit,
const [issueDescriptionHTML, setIssueDescriptionHTML] = useState(issue?.description_html); watch,
setValue,
reset,
formState: { errors },
setError,
} = useForm<IIssue>({
defaultValues: {
name: "",
description: "",
description_html: "",
},
});
// hooks const handleDescriptionFormSubmit = useCallback(
const formValues = useDebounce( (formData: Partial<IIssue>) => {
{ name: issueName, description: issueDescription, description_html: issueDescriptionHTML }, if (!formData.name || formData.name === "") {
2000 setToastAlert({
type: "error",
title: "Error in saving!",
message: "Title is required.",
});
return;
}
if (formData.name.length > 255) {
setToastAlert({
type: "error",
title: "Error in saving!",
message: "Title cannot have more than 255 characters.",
});
return;
}
handleFormSubmit({
name: formData.name ?? "",
description: formData.description,
description_html: formData.description_html,
});
},
[handleFormSubmit, setToastAlert]
); );
const stringFromValues = JSON.stringify(formValues);
const debounceHandler = useMemo(
() => debounce(handleSubmit(handleDescriptionFormSubmit), 2000),
[handleSubmit, handleDescriptionFormSubmit]
);
useEffect(
() => () => {
debounceHandler.cancel();
},
[debounceHandler]
);
// reset form values
useEffect(() => { useEffect(() => {
handleSubmit(formValues); if (!issue) return;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [handleSubmit, stringFromValues]); reset(issue);
}, [issue, reset]);
return ( return (
<div> <div>
@ -56,18 +103,24 @@ export const IssueDescriptionForm: FC<IssueDetailsProps> = ({ issue, handleSubmi
id="name" id="name"
placeholder="Enter issue name" placeholder="Enter issue name"
name="name" name="name"
value={watch("name")}
autoComplete="off" autoComplete="off"
value={issueName} onChange={(e) => {
onChange={(e) => setIssueName(e.target.value)} setValue("name", e.target.value);
debounceHandler();
}}
mode="transparent" mode="transparent"
className="text-xl font-medium" className="text-xl font-medium"
required={true}
/> />
<span>{errors.name ? errors.name.message : null}</span>
<RemirrorRichTextEditor <RemirrorRichTextEditor
value={issueDescription} value={watch("description")}
placeholder="Enter Your Text..." placeholder="Describe the issue..."
onJSONChange={(json) => setIssueDescription(json)} onJSONChange={(json) => {
onHTMLChange={(html) => setIssueDescriptionHTML(html)} setValue("description", json);
debounceHandler();
}}
onHTMLChange={(html) => setValue("description_html", html)}
/> />
</div> </div>
); );

View File

@ -19,7 +19,7 @@ import { CycleSelect as IssueCycleSelect } from "components/cycles/select";
import CreateUpdateStateModal from "components/project/issues/BoardView/state/create-update-state-modal"; import CreateUpdateStateModal from "components/project/issues/BoardView/state/create-update-state-modal";
import CreateUpdateCycleModal from "components/project/cycles/create-update-cycle-modal"; import CreateUpdateCycleModal from "components/project/cycles/create-update-cycle-modal";
// ui // ui
import { Button, CustomMenu, Input, Loader } from "components/ui"; import { Button, CustomDatePicker, CustomMenu, Input, Loader } from "components/ui";
// icons // icons
import { XMarkIcon } from "@heroicons/react/24/outline"; import { XMarkIcon } from "@heroicons/react/24/outline";
// helpers // helpers
@ -194,10 +194,10 @@ export const IssueForm: FC<IssueFormProps> = ({
error={errors.name} error={errors.name}
register={register} register={register}
validations={{ validations={{
required: "Name is required", required: "Title is required",
maxLength: { maxLength: {
value: 255, value: 255,
message: "Name should be less than 255 characters", message: "Title should be less than 255 characters",
}, },
}} }}
/> />
@ -289,20 +289,25 @@ export const IssueForm: FC<IssueFormProps> = ({
<IssueLabelSelect value={value} onChange={onChange} projectId={projectId} /> <IssueLabelSelect value={value} onChange={onChange} projectId={projectId} />
)} )}
/> />
<Controller <div>
control={control} <Controller
name="target_date" control={control}
render={({ field: { value, onChange } }) => ( name="target_date"
<input render={({ field: { value, onChange } }) => (
type="date" <CustomDatePicker
value={value ?? ""} value={value}
onChange={(e: any) => { onChange={(val: Date) => {
onChange(e.target.value); onChange(
}} val
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" ? `${val.getFullYear()}-${val.getMonth() + 1}-${val.getDate()}`
/> : null
)} );
/> }}
className="max-w-[7rem]"
/>
)}
/>
</div>
<IssueParentSelect <IssueParentSelect
control={control} control={control}
isOpen={parentIssueListModalOpen} isOpen={parentIssueListModalOpen}

View File

@ -144,7 +144,7 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
setToastAlert({ setToastAlert({
title: "Success", title: "Success",
type: "success", type: "success",
message: `Issue ${data ? "updated" : "created"} successfully`, message: "Issue created successfully",
}); });
if (payload.assignees_list?.some((assignee) => assignee === user?.id)) mutate(USER_ISSUE); if (payload.assignees_list?.some((assignee) => assignee === user?.id)) mutate(USER_ISSUE);

View File

@ -4,30 +4,30 @@ import Image from "next/image";
import Module from "public/onboarding/module.png"; import Module from "public/onboarding/module.png";
const BreakIntoModules: React.FC = () => ( const BreakIntoModules: React.FC = () => (
<div className="h-full space-y-4"> <div className="h-full space-y-4">
<div className="relative h-1/2"> <div className="relative h-1/2">
<div <div
className="absolute bottom-0 z-10 h-8 w-full bg-white" className="absolute bottom-0 z-10 h-8 w-full bg-white"
style={{ style={{
background: "linear-gradient(0deg, #fff 84.2%, rgba(255, 255, 255, 0) 34.35%)", background: "linear-gradient(0deg, #fff 84.2%, rgba(255, 255, 255, 0) 34.35%)",
}} }}
/> />
<Image <Image
src={Module} src={Module}
className="h-full" className="h-full"
objectFit="contain" objectFit="contain"
layout="fill" layout="fill"
alt="Plane- Modules" alt="Plane- Modules"
/> />
</div>
<div className="mx-auto h-1/2 space-y-4 lg:w-1/2">
<h2 className="text-2xl font-medium">Break into Modules</h2>
<p className="text-sm text-gray-400">
Modules break your big think into Projects or Features, to help you organize better.
</p>
<p className="text-sm text-gray-400">4/5</p>
</div>
</div> </div>
); <div className="mx-auto h-1/2 space-y-4 lg:w-1/2">
<h2 className="text-2xl font-medium">Break into Modules</h2>
<p className="text-sm text-gray-400">
Modules break your big thing into Projects or Features, to help you organize better.
</p>
<p className="text-sm text-gray-400">4/5</p>
</div>
</div>
);
export default BreakIntoModules; export default BreakIntoModules;

View File

@ -46,14 +46,16 @@ export const ProjectCard: React.FC<ProjectCardProps> = (props) => {
<> <>
<div className="flex h-full w-full flex-col rounded-md border bg-white px-4 py-3"> <div className="flex h-full w-full flex-col rounded-md border bg-white px-4 py-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex gap-2 text-lg font-medium"> <div className="flex gap-2 text-lg font-medium">
<Link href={`/${workspaceSlug}/projects/${project.id}/issues`}> <Link href={`/${workspaceSlug}/projects/${project.id}/issues`}>
<a className="flex items-center gap-x-3"> <a className="flex items-center gap-x-3">
{project.icon && ( {project.icon && (
<span className="text-base">{String.fromCodePoint(parseInt(project.icon))}</span> <span className="text-base">{String.fromCodePoint(parseInt(project.icon))}</span>
)} )}
<span>{project.name}</span> <span className="w-3/4 max-w-[225px] md:max-w-[140px] xl:max-w-[225px] text-ellipsis overflow-hidden">
<span className="text-xs text-gray-500">{project.identifier}</span> {project.name}
</span>
<span className="text-xs text-gray-500 ">{project.identifier}</span>
</a> </a>
</Link> </Link>
</div> </div>

View File

@ -2,19 +2,19 @@ import React, { useEffect, useRef, useState } from "react";
import { mutate } from "swr"; import { mutate } from "swr";
// headless ui
import { Dialog, Transition } from "@headlessui/react"; import { Dialog, Transition } from "@headlessui/react";
// services // services
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
import type { IProject, IWorkspace } from "types";
import projectService from "services/project.service"; import projectService from "services/project.service";
// hooks // hooks
import useToast from "hooks/use-toast"; import useToast from "hooks/use-toast";
// icons // icons
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
// ui // ui
import { Button, Input } from "components/ui"; import { Button, Input } from "components/ui";
// types // types
// constants import type { IProject, IWorkspace } from "types";
// fetch-keys
import { PROJECTS_LIST } from "constants/fetch-keys"; import { PROJECTS_LIST } from "constants/fetch-keys";
type TConfirmProjectDeletionProps = { type TConfirmProjectDeletionProps = {
@ -86,7 +86,7 @@ const ConfirmProjectDeletion: React.FC<TConfirmProjectDeletionProps> = (props) =
<Transition.Root show={isOpen} as={React.Fragment}> <Transition.Root show={isOpen} as={React.Fragment}>
<Dialog <Dialog
as="div" as="div"
className="relative z-10" className="relative z-20"
initialFocus={cancelButtonRef} initialFocus={cancelButtonRef}
onClose={handleClose} onClose={handleClose}
> >
@ -102,7 +102,7 @@ const ConfirmProjectDeletion: React.FC<TConfirmProjectDeletionProps> = (props) =
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" /> <div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" />
</Transition.Child> </Transition.Child>
<div className="fixed inset-0 z-10 overflow-y-auto"> <div className="fixed inset-0 z-20 overflow-y-auto">
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0"> <div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
<Transition.Child <Transition.Child
as={React.Fragment} as={React.Fragment}

View File

@ -33,7 +33,7 @@ const ConfirmProjectMemberRemove: React.FC<Props> = ({ isOpen, onClose, data, ha
<Transition.Root show={isOpen} as={React.Fragment}> <Transition.Root show={isOpen} as={React.Fragment}>
<Dialog <Dialog
as="div" as="div"
className="relative z-10" className="relative z-20"
initialFocus={cancelButtonRef} initialFocus={cancelButtonRef}
onClose={handleClose} onClose={handleClose}
> >
@ -49,7 +49,7 @@ const ConfirmProjectMemberRemove: React.FC<Props> = ({ isOpen, onClose, data, ha
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" /> <div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" />
</Transition.Child> </Transition.Child>
<div className="fixed inset-0 z-10 overflow-y-auto"> <div className="fixed inset-0 z-20 overflow-y-auto">
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0"> <div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
<Transition.Child <Transition.Child
as={React.Fragment} as={React.Fragment}

View File

@ -93,9 +93,8 @@ export const CreateProjectModal: React.FC<Props> = (props) => {
const projectIdentifier = watch("identifier") ?? ""; const projectIdentifier = watch("identifier") ?? "";
useEffect(() => { useEffect(() => {
if (projectName && isChangeIdentifierRequired) { if (projectName && isChangeIdentifierRequired)
setValue("identifier", projectName.replace(/ /g, "").toUpperCase().substring(0, 3)); setValue("identifier", projectName.replace(/ /g, "").toUpperCase().substring(0, 3));
}
}, [projectName, projectIdentifier, setValue, isChangeIdentifierRequired]); }, [projectName, projectIdentifier, setValue, isChangeIdentifierRequired]);
useEffect(() => () => setIsChangeIdentifierRequired(true), [isOpen]); useEffect(() => () => setIsChangeIdentifierRequired(true), [isOpen]);
@ -185,7 +184,7 @@ export const CreateProjectModal: React.FC<Props> = (props) => {
</p> </p>
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center gap-3"> <div className="flex gap-3">
<div className="flex-shrink-0"> <div className="flex-shrink-0">
<label htmlFor="icon" className="mb-2 text-gray-500"> <label htmlFor="icon" className="mb-2 text-gray-500">
Icon Icon
@ -215,6 +214,10 @@ export const CreateProjectModal: React.FC<Props> = (props) => {
register={register} register={register}
validations={{ validations={{
required: "Name is required", required: "Name is required",
maxLength: {
value: 255,
message: "Name should be less than 255 characters",
},
}} }}
/> />
</div> </div>

View File

@ -13,7 +13,7 @@ import SingleBoard from "components/project/cycles/board-view/single-board";
// ui // ui
import { Spinner } from "components/ui"; import { Spinner } from "components/ui";
// types // types
import { CycleIssueResponse, IIssue, IProjectMember } from "types"; import { CycleIssueResponse, IIssue, IProjectMember, UserAuth } from "types";
import issuesService from "services/issues.service"; import issuesService from "services/issues.service";
// constants // constants
import { STATE_LIST, CYCLE_ISSUES } from "constants/fetch-keys"; import { STATE_LIST, CYCLE_ISSUES } from "constants/fetch-keys";
@ -23,8 +23,6 @@ type Props = {
members: IProjectMember[] | undefined; members: IProjectMember[] | undefined;
openCreateIssueModal: (issue?: IIssue, actionType?: "create" | "edit" | "delete") => void; openCreateIssueModal: (issue?: IIssue, actionType?: "create" | "edit" | "delete") => void;
openIssuesListModal: () => void; openIssuesListModal: () => void;
removeIssueFromCycle: (bridgeId: string) => void;
partialUpdateIssue: (formData: Partial<IIssue>, issueId: string) => void;
handleDeleteIssue: React.Dispatch<React.SetStateAction<string | undefined>>; handleDeleteIssue: React.Dispatch<React.SetStateAction<string | undefined>>;
setPreloadedData: React.Dispatch< setPreloadedData: React.Dispatch<
React.SetStateAction< React.SetStateAction<
@ -34,6 +32,7 @@ type Props = {
| null | null
> >
>; >;
userAuth: UserAuth;
}; };
const CyclesBoardView: React.FC<Props> = ({ const CyclesBoardView: React.FC<Props> = ({
@ -41,10 +40,9 @@ const CyclesBoardView: React.FC<Props> = ({
members, members,
openCreateIssueModal, openCreateIssueModal,
openIssuesListModal, openIssuesListModal,
removeIssueFromCycle,
partialUpdateIssue,
handleDeleteIssue, handleDeleteIssue,
setPreloadedData, setPreloadedData,
userAuth,
}) => { }) => {
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId, cycleId } = router.query; const { workspaceSlug, projectId, cycleId } = router.query;
@ -128,7 +126,7 @@ const CyclesBoardView: React.FC<Props> = ({
return ( return (
<> <>
{groupedByIssues ? ( {groupedByIssues ? (
<div className="h-screen w-full"> <div className="h-[calc(100vh-157px)] lg:h-[calc(100vh-115px)] w-full">
<DragDropContext onDragEnd={handleOnDragEnd}> <DragDropContext onDragEnd={handleOnDragEnd}>
<div className="h-full w-full overflow-hidden"> <div className="h-full w-full overflow-hidden">
<div className="h-full w-full"> <div className="h-full w-full">
@ -151,10 +149,8 @@ const CyclesBoardView: React.FC<Props> = ({
: "#000000" : "#000000"
} }
properties={properties} properties={properties}
removeIssueFromCycle={removeIssueFromCycle}
openIssuesListModal={openIssuesListModal} openIssuesListModal={openIssuesListModal}
openCreateIssueModal={openCreateIssueModal} openCreateIssueModal={openCreateIssueModal}
partialUpdateIssue={partialUpdateIssue}
handleDeleteIssue={handleDeleteIssue} handleDeleteIssue={handleDeleteIssue}
setPreloadedData={setPreloadedData} setPreloadedData={setPreloadedData}
stateId={ stateId={
@ -162,6 +158,7 @@ const CyclesBoardView: React.FC<Props> = ({
? states?.find((s) => s.name === singleGroup)?.id ?? null ? states?.find((s) => s.name === singleGroup)?.id ?? null
: null : null
} }
userAuth={userAuth}
/> />
))} ))}
</div> </div>

View File

@ -17,7 +17,7 @@ import { CustomMenu } from "components/ui";
// icons // icons
import { PlusIcon } from "@heroicons/react/24/outline"; import { PlusIcon } from "@heroicons/react/24/outline";
// types // types
import { IIssue, IWorkspaceMember, NestedKeyOf, Properties } from "types"; import { IIssue, IWorkspaceMember, NestedKeyOf, Properties, UserAuth } from "types";
// fetch-keys // fetch-keys
import { WORKSPACE_MEMBERS } from "constants/fetch-keys"; import { WORKSPACE_MEMBERS } from "constants/fetch-keys";
@ -32,8 +32,6 @@ type Props = {
bgColor?: string; bgColor?: string;
openCreateIssueModal: (issue?: IIssue, actionType?: "create" | "edit" | "delete") => void; openCreateIssueModal: (issue?: IIssue, actionType?: "create" | "edit" | "delete") => void;
openIssuesListModal: () => void; openIssuesListModal: () => void;
removeIssueFromCycle: (bridgeId: string) => void;
partialUpdateIssue: (formData: Partial<IIssue>, issueId: string) => void;
handleDeleteIssue: React.Dispatch<React.SetStateAction<string | undefined>>; handleDeleteIssue: React.Dispatch<React.SetStateAction<string | undefined>>;
setPreloadedData: React.Dispatch< setPreloadedData: React.Dispatch<
React.SetStateAction< React.SetStateAction<
@ -44,6 +42,7 @@ type Props = {
> >
>; >;
stateId: string | null; stateId: string | null;
userAuth: UserAuth;
}; };
const SingleModuleBoard: React.FC<Props> = ({ const SingleModuleBoard: React.FC<Props> = ({
@ -55,18 +54,17 @@ const SingleModuleBoard: React.FC<Props> = ({
bgColor, bgColor,
openCreateIssueModal, openCreateIssueModal,
openIssuesListModal, openIssuesListModal,
removeIssueFromCycle,
partialUpdateIssue,
handleDeleteIssue, handleDeleteIssue,
setPreloadedData, setPreloadedData,
stateId, stateId,
userAuth,
}) => { }) => {
// collapse/expand // collapse/expand
const [isCollapsed, setIsCollapsed] = useState(true); const [isCollapsed, setIsCollapsed] = useState(true);
const router = useRouter(); const router = useRouter();
const { workspaceSlug } = router.query; const { workspaceSlug, cycleId } = router.query;
if (selectedGroup === "priority") if (selectedGroup === "priority")
groupTitle === "high" groupTitle === "high"
@ -132,13 +130,15 @@ const SingleModuleBoard: React.FC<Props> = ({
{...provided.dragHandleProps} {...provided.dragHandleProps}
> >
<SingleIssue <SingleIssue
type="cycle"
typeId={cycleId as string}
issue={childIssue} issue={childIssue}
properties={properties} properties={properties}
snapshot={snapshot} snapshot={snapshot}
assignees={assignees} assignees={assignees}
people={people} people={people}
partialUpdateIssue={partialUpdateIssue}
handleDeleteIssue={handleDeleteIssue} handleDeleteIssue={handleDeleteIssue}
userAuth={userAuth}
/> />
</div> </div>
)} )}

View File

@ -5,12 +5,14 @@ import { useRouter } from "next/router";
import { mutate } from "swr"; import { mutate } from "swr";
// headless ui // headless ui
import { Dialog, Transition } from "@headlessui/react"; import { Dialog, Transition } from "@headlessui/react";
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
// services // services
import cycleService from "services/cycles.service"; import cycleService from "services/cycles.service";
// hooks
import useToast from "hooks/use-toast";
// ui // ui
import { Button } from "components/ui"; import { Button } from "components/ui";
// icons // icons
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
// types // types
import type { ICycle } from "types"; import type { ICycle } from "types";
type TConfirmCycleDeletionProps = { type TConfirmCycleDeletionProps = {
@ -21,15 +23,19 @@ type TConfirmCycleDeletionProps = {
// fetch-keys // fetch-keys
import { CYCLE_LIST } from "constants/fetch-keys"; import { CYCLE_LIST } from "constants/fetch-keys";
const ConfirmCycleDeletion: React.FC<TConfirmCycleDeletionProps> = (props) => { const ConfirmCycleDeletion: React.FC<TConfirmCycleDeletionProps> = ({
const { isOpen, setIsOpen, data } = props; isOpen,
setIsOpen,
data,
}) => {
const cancelButtonRef = useRef(null); const cancelButtonRef = useRef(null);
const [isDeleteLoading, setIsDeleteLoading] = useState(false); const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const router = useRouter(); const router = useRouter();
const { workspaceSlug } = router.query; const { workspaceSlug } = router.query;
const { setToastAlert } = useToast();
useEffect(() => { useEffect(() => {
data && setIsOpen(true); data && setIsOpen(true);
}, [data, setIsOpen]); }, [data, setIsOpen]);
@ -51,6 +57,12 @@ const ConfirmCycleDeletion: React.FC<TConfirmCycleDeletionProps> = (props) => {
false false
); );
handleClose(); handleClose();
setToastAlert({
title: "Success",
type: "success",
message: "Cycle deleted successfully",
});
}) })
.catch((error) => { .catch((error) => {
console.log(error); console.log(error);

View File

@ -1,20 +1,23 @@
import React, { useEffect } from "react"; import React, { useEffect } from "react";
// next
import { useRouter } from "next/router"; import { useRouter } from "next/router";
// swr
import { mutate } from "swr"; import { mutate } from "swr";
// react hook form // react hook form
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
// headless // headless ui
import { Dialog, Transition } from "@headlessui/react"; import { Dialog, Transition } from "@headlessui/react";
// types
import type { ICycle } from "types";
// services // services
import cycleService from "services/cycles.service"; import cycleService from "services/cycles.service";
import { Button, Input, TextArea, CustomSelect } from "components/ui"; // hooks
import useToast from "hooks/use-toast";
// ui // ui
import { Button, Input, TextArea, CustomSelect, CustomDatePicker } from "components/ui";
// common // common
import { renderDateFormat } from "helpers/date-time.helper"; import { renderDateFormat } from "helpers/date-time.helper";
// types
import type { ICycle } from "types";
// fetch keys // fetch keys
import { CYCLE_LIST } from "constants/fetch-keys"; import { CYCLE_LIST } from "constants/fetch-keys";
@ -29,14 +32,16 @@ const defaultValues: Partial<ICycle> = {
name: "", name: "",
description: "", description: "",
status: "draft", status: "draft",
start_date: new Date().toString(), start_date: null,
end_date: new Date().toString(), end_date: null,
}; };
const CreateUpdateCycleModal: React.FC<Props> = ({ isOpen, setIsOpen, data, projectId }) => { const CreateUpdateCycleModal: React.FC<Props> = ({ isOpen, setIsOpen, data, projectId }) => {
const router = useRouter(); const router = useRouter();
const { workspaceSlug } = router.query; const { workspaceSlug } = router.query;
const { setToastAlert } = useToast();
const { const {
register, register,
formState: { errors, isSubmitting }, formState: { errors, isSubmitting },
@ -69,7 +74,13 @@ const CreateUpdateCycleModal: React.FC<Props> = ({ isOpen, setIsOpen, data, proj
.createCycle(workspaceSlug as string, projectId, payload) .createCycle(workspaceSlug as string, projectId, payload)
.then((res) => { .then((res) => {
mutate<ICycle[]>(CYCLE_LIST(projectId), (prevData) => [res, ...(prevData ?? [])], false); mutate<ICycle[]>(CYCLE_LIST(projectId), (prevData) => [res, ...(prevData ?? [])], false);
handleClose(); handleClose();
setToastAlert({
title: "Success",
type: "success",
message: "Cycle created successfully",
});
}) })
.catch((err) => { .catch((err) => {
Object.keys(err).map((key) => { Object.keys(err).map((key) => {
@ -82,20 +93,14 @@ const CreateUpdateCycleModal: React.FC<Props> = ({ isOpen, setIsOpen, data, proj
await cycleService await cycleService
.updateCycle(workspaceSlug as string, projectId, data.id, payload) .updateCycle(workspaceSlug as string, projectId, data.id, payload)
.then((res) => { .then((res) => {
mutate<ICycle[]>( mutate(CYCLE_LIST(projectId));
CYCLE_LIST(projectId),
(prevData) => {
const newData = prevData?.map((item) => {
if (item.id === res.id) {
return res;
}
return item;
});
return newData;
},
false
);
handleClose(); handleClose();
setToastAlert({
title: "Success",
type: "success",
message: "Cycle updated successfully",
});
}) })
.catch((err) => { .catch((err) => {
Object.keys(err).map((key) => { Object.keys(err).map((key) => {
@ -157,6 +162,10 @@ const CreateUpdateCycleModal: React.FC<Props> = ({ isOpen, setIsOpen, data, proj
register={register} register={register}
validations={{ validations={{
required: "Name is required", required: "Name is required",
maxLength: {
value: 255,
message: "Name should be less than 255 characters",
},
}} }}
/> />
</div> </div>
@ -198,32 +207,62 @@ const CreateUpdateCycleModal: React.FC<Props> = ({ isOpen, setIsOpen, data, proj
</div> </div>
<div className="flex gap-x-2"> <div className="flex gap-x-2">
<div className="w-full"> <div className="w-full">
<Input <h6 className="text-gray-500">Start Date</h6>
id="start_date" <div className="w-full">
label="Start Date" <Controller
name="start_date" control={control}
type="date" name="start_date"
placeholder="Enter start date" rules={{ required: "Start date is required" }}
error={errors.start_date} render={({ field: { value, onChange } }) => (
register={register} <CustomDatePicker
validations={{ renderAs="input"
required: "Start date is required", value={value}
}} onChange={(val: Date) => {
/> onChange(
val
? `${val.getFullYear()}-${
val.getMonth() + 1
}-${val.getDate()}`
: null
);
}}
error={errors.start_date ? true : false}
/>
)}
/>
{errors.start_date && (
<h6 className="text-sm text-red-500">{errors.start_date.message}</h6>
)}
</div>
</div> </div>
<div className="w-full"> <div className="w-full">
<Input <h6 className="text-gray-500">End Date</h6>
id="end_date" <div className="w-full">
label="End Date" <Controller
name="end_date" control={control}
type="date" name="end_date"
placeholder="Enter end date" rules={{ required: "End date is required" }}
error={errors.end_date} render={({ field: { value, onChange } }) => (
register={register} <CustomDatePicker
validations={{ renderAs="input"
required: "End date is required", value={value}
}} onChange={(val: Date) => {
/> onChange(
val
? `${val.getFullYear()}-${
val.getMonth() + 1
}-${val.getDate()}`
: null
);
}}
error={errors.end_date ? true : false}
/>
)}
/>
{errors.end_date && (
<h6 className="text-sm text-red-500">{errors.end_date.message}</h6>
)}
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -13,14 +13,17 @@ import cyclesService from "services/cycles.service";
// hooks // hooks
import useToast from "hooks/use-toast"; import useToast from "hooks/use-toast";
// ui // ui
import { Loader } from "components/ui"; import { Loader, CustomDatePicker } from "components/ui";
//progress-bar
import { CircularProgressbar } from "react-circular-progressbar";
import "react-circular-progressbar/dist/styles.css";
// helpers // helpers
import { copyTextToClipboard } from "helpers/string.helper"; import { copyTextToClipboard } from "helpers/string.helper";
import { groupBy } from "helpers/array.helper"; import { groupBy } from "helpers/array.helper";
// types // types
import { CycleIssueResponse, ICycle } from "types"; import { CycleIssueResponse, ICycle } from "types";
// fetch-keys // fetch-keys
import { CYCLE_DETAIL } from "constants/fetch-keys"; import { CYCLE_LIST } from "constants/fetch-keys";
type Props = { type Props = {
cycle: ICycle | undefined; cycle: ICycle | undefined;
@ -35,9 +38,7 @@ const defaultValues: Partial<ICycle> = {
const CycleDetailSidebar: React.FC<Props> = ({ cycle, isOpen, cycleIssues }) => { const CycleDetailSidebar: React.FC<Props> = ({ cycle, isOpen, cycleIssues }) => {
const router = useRouter(); const router = useRouter();
const { const { workspaceSlug, projectId, cycleId } = router.query;
query: { workspaceSlug, projectId },
} = router;
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();
@ -57,11 +58,21 @@ const CycleDetailSidebar: React.FC<Props> = ({ cycle, isOpen, cycleIssues }) =>
const submitChanges = (data: Partial<ICycle>) => { const submitChanges = (data: Partial<ICycle>) => {
if (!workspaceSlug || !projectId || !module) return; if (!workspaceSlug || !projectId || !module) return;
mutate<ICycle[]>(
projectId && CYCLE_LIST(projectId as string),
(prevData) =>
(prevData ?? []).map((tempCycle) => {
if (tempCycle.id === cycleId) return { ...tempCycle, ...data };
return tempCycle;
}),
false
);
cyclesService cyclesService
.patchCycle(workspaceSlug as string, projectId as string, cycle?.id ?? "", data) .patchCycle(workspaceSlug as string, projectId as string, cycle?.id ?? "", data)
.then((res) => { .then((res) => {
console.log(res); console.log(res);
mutate(CYCLE_DETAIL); mutate(CYCLE_LIST(projectId as string));
}) })
.catch((e) => { .catch((e) => {
console.log(e); console.log(e);
@ -135,7 +146,13 @@ const CycleDetailSidebar: React.FC<Props> = ({ cycle, isOpen, cycleIssues }) =>
</div> </div>
<div className="flex items-center gap-2 sm:basis-1/2"> <div className="flex items-center gap-2 sm:basis-1/2">
<div className="grid flex-shrink-0 place-items-center"> <div className="grid flex-shrink-0 place-items-center">
<span className="h-4 w-4 rounded-full border-2 border-gray-300 border-r-blue-500" /> <span className="h-4 w-4">
<CircularProgressbar
value={groupedIssues.completed.length}
maxValue={cycleIssues?.length}
strokeWidth={10}
/>
</span>
</div> </div>
{groupedIssues.completed.length}/{cycleIssues?.length} {groupedIssues.completed.length}/{cycleIssues?.length}
</div> </div>
@ -151,16 +168,17 @@ const CycleDetailSidebar: React.FC<Props> = ({ cycle, isOpen, cycleIssues }) =>
<Controller <Controller
control={control} control={control}
name="start_date" name="start_date"
render={({ field: { value, onChange } }) => ( render={({ field: { value } }) => (
<input <CustomDatePicker
type="date" value={value}
id="cycleStartDate" onChange={(val: Date) => {
value={value ?? ""} submitChanges({
onChange={(e: any) => { start_date: val
submitChanges({ start_date: e.target.value }); ? `${val.getFullYear()}-${val.getMonth() + 1}-${val.getDate()}`
onChange(e.target.value); : null,
});
}} }}
className="w-full cursor-pointer rounded-md border bg-transparent 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" isClearable={false}
/> />
)} )}
/> />
@ -175,16 +193,17 @@ const CycleDetailSidebar: React.FC<Props> = ({ cycle, isOpen, cycleIssues }) =>
<Controller <Controller
control={control} control={control}
name="end_date" name="end_date"
render={({ field: { value, onChange } }) => ( render={({ field: { value } }) => (
<input <CustomDatePicker
type="date" value={value}
id="moduleEndDate" onChange={(val: Date) => {
value={value ?? ""} submitChanges({
onChange={(e: any) => { end_date: val
submitChanges({ end_date: e.target.value }); ? `${val.getFullYear()}-${val.getMonth() + 1}-${val.getDate()}`
onChange(e.target.value); : null,
});
}} }}
className="w-full cursor-pointer rounded-md border bg-transparent 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" isClearable={false}
/> />
)} )}
/> />

View File

@ -21,7 +21,7 @@ import { CustomMenu, Spinner } from "components/ui";
// helpers // helpers
import { addSpaceIfCamelCase } from "helpers/string.helper"; import { addSpaceIfCamelCase } from "helpers/string.helper";
// types // types
import { IIssue, IWorkspaceMember } from "types"; import { IIssue, IWorkspaceMember, UserAuth } from "types";
// fetch-keys // fetch-keys
import { WORKSPACE_MEMBERS, STATE_LIST } from "constants/fetch-keys"; import { WORKSPACE_MEMBERS, STATE_LIST } from "constants/fetch-keys";
@ -38,6 +38,7 @@ type Props = {
| null | null
> >
>; >;
userAuth: UserAuth;
}; };
const CyclesListView: React.FC<Props> = ({ const CyclesListView: React.FC<Props> = ({
@ -46,6 +47,7 @@ const CyclesListView: React.FC<Props> = ({
openIssuesListModal, openIssuesListModal,
removeIssueFromCycle, removeIssueFromCycle,
setPreloadedData, setPreloadedData,
userAuth,
}) => { }) => {
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId, cycleId } = router.query; const { workspaceSlug, projectId, cycleId } = router.query;
@ -140,6 +142,7 @@ const CyclesListView: React.FC<Props> = ({
properties={properties} properties={properties}
editIssue={() => openCreateIssueModal(issue, "edit")} editIssue={() => openCreateIssueModal(issue, "edit")}
removeIssue={() => removeIssueFromCycle(issue.bridge ?? "")} removeIssue={() => removeIssueFromCycle(issue.bridge ?? "")}
userAuth={userAuth}
/> />
); );
}) })

View File

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

View File

@ -71,7 +71,9 @@ const SingleStat: React.FC<TSingleStatProps> = (props) => {
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<Link href={`/${workspaceSlug}/projects/${projectId as string}/cycles/${cycle.id}`}> <Link href={`/${workspaceSlug}/projects/${projectId as string}/cycles/${cycle.id}`}>
<a> <a>
<h2 className="font-medium">{cycle.name}</h2> <h2 className="font-medium w-full max-w-[175px] lg:max-w-[225px] xl:max-w-[300px] text-ellipsis overflow-hidden">
{cycle.name}
</h2>
</a> </a>
</Link> </Link>
<CustomMenu width="auto" ellipsis> <CustomMenu width="auto" ellipsis>

View File

@ -21,17 +21,17 @@ import ConfirmIssueDeletion from "components/project/issues/confirm-issue-deleti
// ui // ui
import { Spinner } from "components/ui"; import { Spinner } from "components/ui";
// types // types
import type { IState, IIssue, IssueResponse } from "types"; import type { IState, IIssue, IssueResponse, UserAuth } from "types";
// fetch-keys // fetch-keys
import { STATE_LIST, PROJECT_ISSUES_LIST, PROJECT_MEMBERS } from "constants/fetch-keys"; import { STATE_LIST, PROJECT_ISSUES_LIST, PROJECT_MEMBERS } from "constants/fetch-keys";
type Props = { type Props = {
issues: IIssue[]; issues: IIssue[];
handleDeleteIssue: React.Dispatch<React.SetStateAction<string | undefined>>; handleDeleteIssue: React.Dispatch<React.SetStateAction<string | undefined>>;
partialUpdateIssue: (formData: Partial<IIssue>, issueId: string) => void; userAuth: UserAuth;
}; };
const BoardView: React.FC<Props> = ({ issues, handleDeleteIssue, partialUpdateIssue }) => { const BoardView: React.FC<Props> = ({ issues, handleDeleteIssue, userAuth }) => {
const [createIssueModal, setCreateIssueModal] = useState(false); const [createIssueModal, setCreateIssueModal] = useState(false);
const [isIssueDeletionOpen, setIsIssueDeletionOpen] = useState(false); const [isIssueDeletionOpen, setIsIssueDeletionOpen] = useState(false);
const [issueDeletionData, setIssueDeletionData] = useState<IIssue | undefined>(); const [issueDeletionData, setIssueDeletionData] = useState<IIssue | undefined>();
@ -68,7 +68,8 @@ const BoardView: React.FC<Props> = ({ issues, handleDeleteIssue, partialUpdateIs
const handleOnDragEnd = useCallback( const handleOnDragEnd = useCallback(
(result: DropResult) => { (result: DropResult) => {
if (!result.destination) return; if (!result.destination || !workspaceSlug || !projectId) return;
const { source, destination, type } = result; const { source, destination, type } = result;
if (destination.droppableId === "trashBox") { if (destination.droppableId === "trashBox") {
@ -94,7 +95,7 @@ const BoardView: React.FC<Props> = ({ issues, handleDeleteIssue, partialUpdateIs
newStates[destination.index].sequence = sequenceNumber; newStates[destination.index].sequence = sequenceNumber;
mutateState(newStates, false); mutateState(newStates, false);
if (!workspaceSlug) return;
stateServices stateServices
.patchState( .patchState(
workspaceSlug as string, workspaceSlug as string,
@ -140,18 +141,6 @@ const BoardView: React.FC<Props> = ({ issues, handleDeleteIssue, partialUpdateIs
draggedItem.state = destinationStateId; draggedItem.state = destinationStateId;
draggedItem.state_detail = destinationState; draggedItem.state_detail = destinationState;
// patch request
issuesServices.patchIssue(
workspaceSlug as string,
projectId as string,
draggedItem.id,
{
state: destinationStateId,
}
);
// mutate the issues
if (!workspaceSlug || !projectId) return;
mutate<IssueResponse>( mutate<IssueResponse>(
PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string), PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string),
(prevData) => { (prevData) => {
@ -175,6 +164,15 @@ const BoardView: React.FC<Props> = ({ issues, handleDeleteIssue, partialUpdateIs
}, },
false 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));
});
} }
} }
} }
@ -200,7 +198,7 @@ const BoardView: React.FC<Props> = ({ issues, handleDeleteIssue, partialUpdateIs
}} }}
/> />
{groupedByIssues ? ( {groupedByIssues ? (
<div className="h-screen w-full"> <div className="h-[calc(100vh-157px)] lg:h-[calc(100vh-115px)] w-full">
<DragDropContext onDragEnd={handleOnDragEnd}> <DragDropContext onDragEnd={handleOnDragEnd}>
<div className="h-full w-full overflow-hidden"> <div className="h-full w-full overflow-hidden">
<StrictModeDroppable droppableId="state" type="state" direction="horizontal"> <StrictModeDroppable droppableId="state" type="state" direction="horizontal">
@ -238,7 +236,7 @@ const BoardView: React.FC<Props> = ({ issues, handleDeleteIssue, partialUpdateIs
: "#000000" : "#000000"
} }
handleDeleteIssue={handleDeleteIssue} handleDeleteIssue={handleDeleteIssue}
partialUpdateIssue={partialUpdateIssue} userAuth={userAuth}
/> />
))} ))}
</div> </div>

View File

@ -15,7 +15,7 @@ import { PlusIcon } from "@heroicons/react/24/outline";
// services // services
import workspaceService from "services/workspace.service"; import workspaceService from "services/workspace.service";
// types // types
import { IIssue, Properties, NestedKeyOf, IWorkspaceMember } from "types"; import { IIssue, Properties, NestedKeyOf, IWorkspaceMember, UserAuth } from "types";
// fetch-keys // fetch-keys
import { WORKSPACE_MEMBERS } from "constants/fetch-keys"; import { WORKSPACE_MEMBERS } from "constants/fetch-keys";
@ -40,7 +40,7 @@ type Props = {
stateId: string | null; stateId: string | null;
createdBy: string | null; createdBy: string | null;
handleDeleteIssue: React.Dispatch<React.SetStateAction<string | undefined>>; handleDeleteIssue: React.Dispatch<React.SetStateAction<string | undefined>>;
partialUpdateIssue: (formData: Partial<IIssue>, childIssueId: string) => void; userAuth: UserAuth;
}; };
const SingleBoard: React.FC<Props> = ({ const SingleBoard: React.FC<Props> = ({
@ -55,7 +55,7 @@ const SingleBoard: React.FC<Props> = ({
stateId, stateId,
createdBy, createdBy,
handleDeleteIssue, handleDeleteIssue,
partialUpdateIssue, userAuth,
}) => { }) => {
// Collapse/Expand // Collapse/Expand
const [isCollapsed, setIsCollapsed] = useState(true); const [isCollapsed, setIsCollapsed] = useState(true);
@ -145,7 +145,7 @@ const SingleBoard: React.FC<Props> = ({
people={people} people={people}
assignees={assignees} assignees={assignees}
handleDeleteIssue={handleDeleteIssue} handleDeleteIssue={handleDeleteIssue}
partialUpdateIssue={partialUpdateIssue} userAuth={userAuth}
/> />
</div> </div>
)} )}

View File

@ -8,16 +8,18 @@ import useSWR, { mutate } from "swr";
import { Dialog, Transition } from "@headlessui/react"; import { Dialog, Transition } from "@headlessui/react";
// icons // icons
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline"; import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
// types
import type { IState } from "types";
// services // services
import stateServices from "services/state.service"; import stateServices from "services/state.service";
import issuesServices from "services/issues.service"; import issuesServices from "services/issues.service";
// hooks
import useToast from "hooks/use-toast";
// ui // ui
import { Button } from "components/ui"; import { Button } from "components/ui";
// helpers // helpers
import { groupBy } from "helpers/array.helper"; import { groupBy } from "helpers/array.helper";
// fetch api // types
import type { IState } from "types";
// fetch-keys
import { STATE_LIST, PROJECT_ISSUES_LIST } from "constants/fetch-keys"; import { STATE_LIST, PROJECT_ISSUES_LIST } from "constants/fetch-keys";
type Props = { type Props = {
@ -33,6 +35,8 @@ const ConfirmStateDeletion: React.FC<Props> = ({ isOpen, onClose, data }) => {
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId } = router.query; const { workspaceSlug, projectId } = router.query;
const { setToastAlert } = useToast();
const { data: issues } = useSWR( const { data: issues } = useSWR(
workspaceSlug && projectId workspaceSlug && projectId
? PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string) ? PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string)
@ -61,6 +65,12 @@ const ConfirmStateDeletion: React.FC<Props> = ({ isOpen, onClose, data }) => {
false false
); );
handleClose(); handleClose();
setToastAlert({
title: "Success",
type: "success",
message: "State deleted successfully",
});
}) })
.catch((error) => { .catch((error) => {
console.log(error); console.log(error);
@ -78,7 +88,7 @@ const ConfirmStateDeletion: React.FC<Props> = ({ isOpen, onClose, data }) => {
<Transition.Root show={isOpen} as={React.Fragment}> <Transition.Root show={isOpen} as={React.Fragment}>
<Dialog <Dialog
as="div" as="div"
className="relative z-10" className="relative z-20"
initialFocus={cancelButtonRef} initialFocus={cancelButtonRef}
onClose={handleClose} onClose={handleClose}
> >
@ -94,7 +104,7 @@ const ConfirmStateDeletion: React.FC<Props> = ({ isOpen, onClose, data }) => {
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" /> <div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" />
</Transition.Child> </Transition.Child>
<div className="fixed inset-0 z-10 overflow-y-auto"> <div className="fixed inset-0 z-20 overflow-y-auto">
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0"> <div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
<Transition.Child <Transition.Child
as={React.Fragment} as={React.Fragment}

View File

@ -2,20 +2,24 @@ import React, { useEffect } from "react";
import { mutate } from "swr"; import { mutate } from "swr";
// react-hook-form
import { useForm, Controller } from "react-hook-form"; import { useForm, Controller } from "react-hook-form";
// react-color
import { TwitterPicker } from "react-color"; import { TwitterPicker } from "react-color";
// headless ui
import { Popover, Transition } from "@headlessui/react"; import { Popover, Transition } from "@headlessui/react";
// constants
import type { IState } from "types";
import { GROUP_CHOICES } from "constants/";
import { STATE_LIST } from "constants/fetch-keys";
// services // services
import stateService from "services/state.service"; import stateService from "services/state.service";
// hooks
import useToast from "hooks/use-toast";
// ui // ui
import { Button, Input, Select } from "components/ui"; import { Button, CustomSelect, Input, Select } from "components/ui";
// types // types
import type { IState } from "types";
// fetch-keys
import { STATE_LIST } from "constants/fetch-keys";
// constants
import { GROUP_CHOICES } from "constants/";
type Props = { type Props = {
workspaceSlug?: string; workspaceSlug?: string;
@ -40,6 +44,8 @@ export const CreateUpdateStateInline: React.FC<Props> = ({
onClose, onClose,
selectedGroup, selectedGroup,
}) => { }) => {
const { setToastAlert } = useToast();
const { const {
register, register,
handleSubmit, handleSubmit,
@ -81,6 +87,12 @@ export const CreateUpdateStateInline: React.FC<Props> = ({
.then((res) => { .then((res) => {
mutate<IState[]>(STATE_LIST(projectId), (prevData) => [...(prevData ?? []), res]); mutate<IState[]>(STATE_LIST(projectId), (prevData) => [...(prevData ?? []), res]);
handleClose(); handleClose();
setToastAlert({
title: "Success",
type: "success",
message: "State created successfully",
});
}) })
.catch((err) => { .catch((err) => {
Object.keys(err).map((key) => { Object.keys(err).map((key) => {
@ -95,16 +107,14 @@ export const CreateUpdateStateInline: React.FC<Props> = ({
...payload, ...payload,
}) })
.then((res) => { .then((res) => {
mutate<IState[]>(STATE_LIST(projectId), (prevData) => { mutate(STATE_LIST(projectId));
const newData = prevData?.map((item) => {
if (item.id === res.id) {
return res;
}
return item;
});
return newData;
});
handleClose(); handleClose();
setToastAlert({
title: "Success",
type: "success",
message: "State updated successfully",
});
}) })
.catch((err) => { .catch((err) => {
Object.keys(err).map((key) => { Object.keys(err).map((key) => {
@ -173,18 +183,27 @@ export const CreateUpdateStateInline: React.FC<Props> = ({
autoComplete="off" autoComplete="off"
/> />
{data && ( {data && (
<Select <Controller
id="group"
name="group" name="group"
error={errors.group} control={control}
register={register} render={({ field: { value, onChange } }) => (
validations={{ <CustomSelect
required: true, value={value}
}} onChange={onChange}
options={Object.keys(GROUP_CHOICES).map((key) => ({ label={
value: key, Object.keys(GROUP_CHOICES).find((k) => k === value.toString())
label: GROUP_CHOICES[key as keyof typeof GROUP_CHOICES], ? GROUP_CHOICES[value.toString() as keyof typeof GROUP_CHOICES]
}))} : "Select group"
}
input
>
{Object.keys(GROUP_CHOICES).map((key) => (
<CustomSelect.Option key={key} value={key}>
{GROUP_CHOICES[key as keyof typeof GROUP_CHOICES]}
</CustomSelect.Option>
))}
</CustomSelect>
)}
/> />
)} )}
<Input <Input
@ -199,7 +218,7 @@ export const CreateUpdateStateInline: React.FC<Props> = ({
Cancel Cancel
</Button> </Button>
<Button theme="primary" disabled={isSubmitting} type="submit"> <Button theme="primary" disabled={isSubmitting} type="submit">
{isSubmitting ? "Loading..." : data ? "Update" : "Create"} {isSubmitting ? (data ? "Updating..." : "Creating...") : data ? "Update" : "Create"}
</Button> </Button>
</form> </form>
); );

View File

@ -3,20 +3,21 @@ import React, { useEffect, useRef, useState } from "react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { mutate } from "swr"; import { mutate } from "swr";
// headless ui // headless ui
import { Dialog, Transition } from "@headlessui/react"; import { Dialog, Transition } from "@headlessui/react";
// fetching keys
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
import type { CycleIssueResponse, IIssue, IssueResponse, ModuleIssueResponse } from "types";
import { CYCLE_ISSUES, PROJECT_ISSUES_LIST, MODULE_ISSUES } from "constants/fetch-keys";
// services // services
import issueServices from "services/issues.service"; import issueServices from "services/issues.service";
// hooks // hooks
import useToast from "hooks/use-toast"; import useToast from "hooks/use-toast";
// icons // icons
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
// ui // ui
import { Button } from "components/ui"; import { Button } from "components/ui";
// types // types
import type { CycleIssueResponse, IIssue, IssueResponse, ModuleIssueResponse } from "types";
// fetch-keys
import { CYCLE_ISSUES, PROJECT_ISSUES_LIST, MODULE_ISSUES } from "constants/fetch-keys";
type Props = { type Props = {
isOpen: boolean; isOpen: boolean;
@ -79,12 +80,12 @@ const ConfirmIssueDeletion: React.FC<Props> = (props) => {
); );
} }
handleClose();
setToastAlert({ setToastAlert({
title: "Success", title: "Success",
type: "success", type: "success",
message: "Issue deleted successfully", message: "Issue deleted successfully",
}); });
handleClose();
}) })
.catch((error) => { .catch((error) => {
console.log(error); console.log(error);

View File

@ -76,11 +76,6 @@ const activityDetails: {
}, },
}; };
const defaultValues: Partial<IIssueComment> = {
comment_html: "",
comment_json: "",
};
const IssueActivitySection: React.FC<{ const IssueActivitySection: React.FC<{
issueActivities: IIssueActivity[]; issueActivities: IIssueActivity[];
mutate: KeyedMutator<IIssueActivity[]>; mutate: KeyedMutator<IIssueActivity[]>;
@ -99,7 +94,7 @@ const IssueActivitySection: React.FC<{
comment.id, comment.id,
comment comment
) )
.then((response) => { .then((res) => {
mutate(); mutate();
}); });
}; };
@ -180,6 +175,10 @@ const IssueActivitySection: React.FC<{
? activity.new_value !== "" ? activity.new_value !== ""
? "marked this issue being blocked by" ? "marked this issue being blocked by"
: "removed blocker" : "removed blocker"
: activity.field === "target_date"
? activity.new_value && activity.new_value !== ""
? "set the due date to"
: "removed the due date"
: activityDetails[activity.field as keyof typeof activityDetails] : activityDetails[activity.field as keyof typeof activityDetails]
?.message}{" "} ?.message}{" "}
</span> </span>
@ -203,7 +202,9 @@ const IssueActivitySection: React.FC<{
) : activity.field === "assignee" ? ( ) : activity.field === "assignee" ? (
activity.old_value activity.old_value
) : activity.field === "target_date" ? ( ) : activity.field === "target_date" ? (
renderShortNumericDateFormat(activity.new_value as string) activity.new_value ? (
renderShortNumericDateFormat(activity.new_value as string)
) : null
) : activity.field === "description" ? ( ) : activity.field === "description" ? (
"" ""
) : ( ) : (

View File

@ -11,7 +11,7 @@ import { RectangleStackIcon, MagnifyingGlassIcon } from "@heroicons/react/24/out
// services // services
import issuesServices from "services/issues.service"; import issuesServices from "services/issues.service";
// types // types
import { IIssue, IssueResponse } from "types"; import { IIssue } from "types";
// constants // constants
import { PROJECT_ISSUES_LIST, SUB_ISSUES } from "constants/fetch-keys"; import { PROJECT_ISSUES_LIST, SUB_ISSUES } from "constants/fetch-keys";
@ -48,16 +48,16 @@ const AddAsSubIssue: React.FC<Props> = ({ isOpen, setIsOpen, parent }) => {
}; };
const addAsSubIssue = (issueId: string) => { const addAsSubIssue = (issueId: string) => {
if (workspaceSlug && projectId) { if (!workspaceSlug || !projectId) return;
issuesServices
.patchIssue(workspaceSlug as string, projectId as string, issueId, { parent: parent?.id }) issuesServices
.then((res) => { .patchIssue(workspaceSlug as string, projectId as string, issueId, { parent: parent?.id })
mutate(SUB_ISSUES(parent?.id ?? "")); .then((res) => {
}) mutate(SUB_ISSUES(parent?.id ?? ""));
.catch((e) => { })
console.log(e); .catch((e) => {
}); console.log(e);
} });
}; };
return ( return (
@ -140,6 +140,9 @@ const AddAsSubIssue: React.FC<Props> = ({ isOpen, setIsOpen, parent }) => {
backgroundColor: issue.state_detail.color, backgroundColor: issue.state_detail.color,
}} }}
/> />
<span className="flex-shrink-0 text-xs text-gray-500">
{issue.project_detail.identifier}-{issue.sequence_id}
</span>
{issue.name} {issue.name}
</Combobox.Option> </Combobox.Option>
); );

View File

@ -4,20 +4,12 @@ import { useRouter } from "next/router";
import useSWR, { mutate } from "swr"; import useSWR, { mutate } from "swr";
// react-hook-form
import { useForm, Controller, UseFormWatch, Control } from "react-hook-form"; import { useForm, Controller, UseFormWatch, Control } from "react-hook-form";
// react-color
import { TwitterPicker } from "react-color"; import { TwitterPicker } from "react-color";
// services // headless ui
import { Popover, Listbox, Transition } from "@headlessui/react"; import { Popover, Listbox, Transition } from "@headlessui/react";
import {
TagIcon,
ChevronDownIcon,
LinkIcon,
CalendarDaysIcon,
TrashIcon,
PlusIcon,
XMarkIcon,
} from "@heroicons/react/24/outline";
// hooks // hooks
import useToast from "hooks/use-toast"; import useToast from "hooks/use-toast";
// services // services
@ -31,10 +23,19 @@ import SelectCycle from "components/project/issues/issue-detail/issue-detail-sid
import SelectAssignee from "components/project/issues/issue-detail/issue-detail-sidebar/select-assignee"; 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 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 SelectBlocked from "components/project/issues/issue-detail/issue-detail-sidebar/select-blocked";
// headless ui
// ui // ui
import { Input, Button, Spinner } from "components/ui"; import { Input, Button, Spinner, CustomDatePicker } from "components/ui";
import DatePicker from "react-datepicker";
// icons // icons
import {
TagIcon,
ChevronDownIcon,
LinkIcon,
CalendarDaysIcon,
TrashIcon,
PlusIcon,
XMarkIcon,
} from "@heroicons/react/24/outline";
// helpers // helpers
import { copyTextToClipboard } from "helpers/string.helper"; import { copyTextToClipboard } from "helpers/string.helper";
// types // types
@ -42,6 +43,8 @@ import type { ICycle, IIssue, IIssueLabels } from "types";
// fetch-keys // fetch-keys
import { PROJECT_ISSUE_LABELS, PROJECT_ISSUES_LIST, ISSUE_DETAILS } from "constants/fetch-keys"; import { PROJECT_ISSUE_LABELS, PROJECT_ISSUES_LIST, ISSUE_DETAILS } from "constants/fetch-keys";
import "react-datepicker/dist/react-datepicker.css";
type Props = { type Props = {
control: Control<IIssue, any>; control: Control<IIssue, any>;
submitChanges: (formData: Partial<IIssue>) => void; submitChanges: (formData: Partial<IIssue>) => void;
@ -216,19 +219,37 @@ const IssueDetailSidebar: React.FC<Props> = ({
<p>Due date</p> <p>Due date</p>
</div> </div>
<div className="sm:basis-1/2"> <div className="sm:basis-1/2">
<Controller {/* <Controller
control={control} control={control}
name="target_date" name="target_date"
render={({ field: { value, onChange } }) => ( render={({ field: { value, onChange } }) => (
<input <DatePicker
type="date" selected={value ? new Date(value) : new Date()}
id="issueDate" onChange={(val: Date) => {
value={value ?? ""} submitChanges({
onChange={(e: any) => { target_date: `${val.getFullYear()}-${
submitChanges({ target_date: e.target.value }); val.getMonth() + 1
onChange(e.target.value); }-${val.getDate()}`,
});
onChange(`${val.getFullYear()}-${val.getMonth() + 1}-${val.getDate()}`);
}}
dateFormat="dd-MM-yyyy"
/>
)}
/> */}
<Controller
control={control}
name="target_date"
render={({ field: { value } }) => (
<CustomDatePicker
value={value}
onChange={(val: Date) => {
submitChanges({
target_date: val
? `${val.getFullYear()}-${val.getMonth() + 1}-${val.getDate()}`
: null,
});
}} }}
className="w-full cursor-pointer rounded-md 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"
/> />
)} )}
/> />

View File

@ -259,10 +259,7 @@ const SelectBlocked: React.FC<Props> = ({ submitChanges, issuesList, watch }) =>
<LayerDiagonalIcon height="56" width="56" /> <LayerDiagonalIcon height="56" width="56" />
<h3 className="text-gray-500"> <h3 className="text-gray-500">
No issues found. Create a new issue with{" "} No issues found. Create a new issue with{" "}
<pre className="inline rounded bg-gray-100 px-2 py-1"> <pre className="inline rounded bg-gray-100 px-2 py-1">C</pre>.
Ctrl/Command + I
</pre>
.
</h3> </h3>
</div> </div>
)} )}

View File

@ -258,10 +258,7 @@ const SelectBlocker: React.FC<Props> = ({ submitChanges, issuesList, watch }) =>
<LayerDiagonalIcon height="56" width="56" /> <LayerDiagonalIcon height="56" width="56" />
<h3 className="text-gray-500"> <h3 className="text-gray-500">
No issues found. Create a new issue with{" "} No issues found. Create a new issue with{" "}
<pre className="inline rounded bg-gray-100 px-2 py-1"> <pre className="inline rounded bg-gray-100 px-2 py-1">C</pre>.
Ctrl/Command + I
</pre>
.
</h3> </h3>
</div> </div>
)} )}

View File

@ -212,10 +212,7 @@ const IssuesListModal: React.FC<Props> = ({
<LayerDiagonalIcon height="56" width="56" /> <LayerDiagonalIcon height="56" width="56" />
<h3 className="text-gray-500"> <h3 className="text-gray-500">
No issues found. Create a new issue with{" "} No issues found. Create a new issue with{" "}
<pre className="inline rounded bg-gray-100 px-2 py-1"> <pre className="inline rounded bg-gray-100 px-2 py-1">C</pre>.
Ctrl/Command + I
</pre>
.
</h3> </h3>
</div> </div>
)} )}

View File

@ -20,7 +20,7 @@ import SingleListIssue from "components/common/list-view/single-issue";
// helpers // helpers
import { addSpaceIfCamelCase } from "helpers/string.helper"; import { addSpaceIfCamelCase } from "helpers/string.helper";
// types // types
import { IIssue, IWorkspaceMember } from "types"; import { IIssue, IWorkspaceMember, UserAuth } from "types";
// fetch-keys // fetch-keys
import { STATE_LIST, WORKSPACE_MEMBERS } from "constants/fetch-keys"; import { STATE_LIST, WORKSPACE_MEMBERS } from "constants/fetch-keys";
@ -28,10 +28,10 @@ import { STATE_LIST, WORKSPACE_MEMBERS } from "constants/fetch-keys";
type Props = { type Props = {
issues: IIssue[]; issues: IIssue[];
handleEditIssue: (issue: IIssue) => void; handleEditIssue: (issue: IIssue) => void;
partialUpdateIssue: (formData: Partial<IIssue>, issueId: string) => void; userAuth: UserAuth;
}; };
const ListView: React.FC<Props> = ({ issues, handleEditIssue }) => { const ListView: React.FC<Props> = ({ issues, handleEditIssue, userAuth }) => {
const [isCreateIssuesModalOpen, setIsCreateIssuesModalOpen] = useState(false); const [isCreateIssuesModalOpen, setIsCreateIssuesModalOpen] = useState(false);
const [preloadedData, setPreloadedData] = useState< const [preloadedData, setPreloadedData] = useState<
(Partial<IIssue> & { actionType: "createIssue" | "edit" | "delete" }) | undefined (Partial<IIssue> & { actionType: "createIssue" | "edit" | "delete" }) | undefined
@ -130,6 +130,7 @@ const ListView: React.FC<Props> = ({ issues, handleEditIssue }) => {
issue={issue} issue={issue}
properties={properties} properties={properties}
editIssue={() => handleEditIssue(issue)} editIssue={() => handleEditIssue(issue)}
userAuth={userAuth}
/> />
); );
}) })

View File

@ -17,7 +17,7 @@ import SingleBoard from "components/project/modules/board-view/single-board";
// ui // ui
import { Spinner } from "components/ui"; import { Spinner } from "components/ui";
// types // types
import { IIssue, IProjectMember, ModuleIssueResponse } from "types"; import { IIssue, IProjectMember, ModuleIssueResponse, UserAuth } from "types";
// constants // constants
import { STATE_LIST, MODULE_ISSUES } from "constants/fetch-keys"; import { STATE_LIST, MODULE_ISSUES } from "constants/fetch-keys";
@ -26,8 +26,6 @@ type Props = {
members: IProjectMember[] | undefined; members: IProjectMember[] | undefined;
openCreateIssueModal: (issue?: IIssue, actionType?: "create" | "edit" | "delete") => void; openCreateIssueModal: (issue?: IIssue, actionType?: "create" | "edit" | "delete") => void;
openIssuesListModal: () => void; openIssuesListModal: () => void;
removeIssueFromModule: (issueId: string) => void;
partialUpdateIssue: (formData: Partial<IIssue>, issueId: string) => void;
handleDeleteIssue: React.Dispatch<React.SetStateAction<string | undefined>>; handleDeleteIssue: React.Dispatch<React.SetStateAction<string | undefined>>;
setPreloadedData: React.Dispatch< setPreloadedData: React.Dispatch<
React.SetStateAction< React.SetStateAction<
@ -37,6 +35,7 @@ type Props = {
| null | null
> >
>; >;
userAuth: UserAuth;
}; };
const ModulesBoardView: React.FC<Props> = ({ const ModulesBoardView: React.FC<Props> = ({
@ -44,10 +43,9 @@ const ModulesBoardView: React.FC<Props> = ({
members, members,
openCreateIssueModal, openCreateIssueModal,
openIssuesListModal, openIssuesListModal,
removeIssueFromModule,
partialUpdateIssue,
handleDeleteIssue, handleDeleteIssue,
setPreloadedData, setPreloadedData,
userAuth,
}) => { }) => {
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId, moduleId } = router.query; const { workspaceSlug, projectId, moduleId } = router.query;
@ -131,7 +129,7 @@ const ModulesBoardView: React.FC<Props> = ({
return ( return (
<> <>
{groupedByIssues ? ( {groupedByIssues ? (
<div className="h-screen w-full"> <div className="h-[calc(100vh-157px)] lg:h-[calc(100vh-115px)] w-full">
<DragDropContext onDragEnd={handleOnDragEnd}> <DragDropContext onDragEnd={handleOnDragEnd}>
<div className="h-full w-full overflow-hidden"> <div className="h-full w-full overflow-hidden">
<div className="h-full w-full"> <div className="h-full w-full">
@ -154,10 +152,8 @@ const ModulesBoardView: React.FC<Props> = ({
: "#000000" : "#000000"
} }
properties={properties} properties={properties}
removeIssueFromModule={removeIssueFromModule}
openIssuesListModal={openIssuesListModal} openIssuesListModal={openIssuesListModal}
openCreateIssueModal={openCreateIssueModal} openCreateIssueModal={openCreateIssueModal}
partialUpdateIssue={partialUpdateIssue}
handleDeleteIssue={handleDeleteIssue} handleDeleteIssue={handleDeleteIssue}
setPreloadedData={setPreloadedData} setPreloadedData={setPreloadedData}
stateId={ stateId={
@ -165,6 +161,7 @@ const ModulesBoardView: React.FC<Props> = ({
? states?.find((s) => s.name === singleGroup)?.id ?? null ? states?.find((s) => s.name === singleGroup)?.id ?? null
: null : null
} }
userAuth={userAuth}
/> />
))} ))}
</div> </div>

View File

@ -17,7 +17,7 @@ import { CustomMenu } from "components/ui";
// icons // icons
import { PlusIcon } from "@heroicons/react/24/outline"; import { PlusIcon } from "@heroicons/react/24/outline";
// types // types
import { IIssue, IWorkspaceMember, NestedKeyOf, Properties } from "types"; import { IIssue, IWorkspaceMember, NestedKeyOf, Properties, UserAuth } from "types";
// fetch-keys // fetch-keys
import { WORKSPACE_MEMBERS } from "constants/fetch-keys"; import { WORKSPACE_MEMBERS } from "constants/fetch-keys";
@ -32,8 +32,6 @@ type Props = {
bgColor?: string; bgColor?: string;
openCreateIssueModal: (issue?: IIssue, actionType?: "create" | "edit" | "delete") => void; openCreateIssueModal: (issue?: IIssue, actionType?: "create" | "edit" | "delete") => void;
openIssuesListModal: () => void; openIssuesListModal: () => void;
removeIssueFromModule: (bridgeId: string) => void;
partialUpdateIssue: (formData: Partial<IIssue>, issueId: string) => void;
handleDeleteIssue: React.Dispatch<React.SetStateAction<string | undefined>>; handleDeleteIssue: React.Dispatch<React.SetStateAction<string | undefined>>;
setPreloadedData: React.Dispatch< setPreloadedData: React.Dispatch<
React.SetStateAction< React.SetStateAction<
@ -44,6 +42,7 @@ type Props = {
> >
>; >;
stateId: string | null; stateId: string | null;
userAuth: UserAuth;
}; };
const SingleModuleBoard: React.FC<Props> = ({ const SingleModuleBoard: React.FC<Props> = ({
@ -55,17 +54,16 @@ const SingleModuleBoard: React.FC<Props> = ({
bgColor, bgColor,
openCreateIssueModal, openCreateIssueModal,
openIssuesListModal, openIssuesListModal,
removeIssueFromModule,
partialUpdateIssue,
handleDeleteIssue, handleDeleteIssue,
setPreloadedData, setPreloadedData,
stateId, stateId,
userAuth,
}) => { }) => {
// Collapse/Expand // Collapse/Expand
const [isCollapsed, setIsCollapsed] = useState(true); const [isCollapsed, setIsCollapsed] = useState(true);
const router = useRouter(); const router = useRouter();
const { workspaceSlug } = router.query; const { workspaceSlug, moduleId } = router.query;
if (selectedGroup === "priority") if (selectedGroup === "priority")
groupTitle === "high" groupTitle === "high"
@ -112,10 +110,10 @@ const SingleModuleBoard: React.FC<Props> = ({
{...provided.droppableProps} {...provided.droppableProps}
ref={provided.innerRef} ref={provided.innerRef}
> >
{groupedByIssues[groupTitle].map((childIssue, index: number) => { {groupedByIssues[groupTitle].map((issue, index: number) => {
const assignees = [ const assignees = [
...(childIssue?.assignees_list ?? []), ...(issue?.assignees_list ?? []),
...(childIssue?.assignees ?? []), ...(issue?.assignees ?? []),
]?.map((assignee) => { ]?.map((assignee) => {
const tempPerson = people?.find((p) => p.member.id === assignee)?.member; const tempPerson = people?.find((p) => p.member.id === assignee)?.member;
@ -123,7 +121,7 @@ const SingleModuleBoard: React.FC<Props> = ({
}); });
return ( return (
<Draggable key={childIssue.id} draggableId={childIssue.id} index={index}> <Draggable key={issue.id} draggableId={issue.id} index={index}>
{(provided, snapshot) => ( {(provided, snapshot) => (
<div <div
ref={provided.innerRef} ref={provided.innerRef}
@ -131,13 +129,15 @@ const SingleModuleBoard: React.FC<Props> = ({
{...provided.dragHandleProps} {...provided.dragHandleProps}
> >
<SingleIssue <SingleIssue
issue={childIssue} type="module"
typeId={moduleId as string}
issue={issue}
properties={properties} properties={properties}
snapshot={snapshot} snapshot={snapshot}
assignees={assignees} assignees={assignees}
people={people} people={people}
partialUpdateIssue={partialUpdateIssue}
handleDeleteIssue={handleDeleteIssue} handleDeleteIssue={handleDeleteIssue}
userAuth={userAuth}
/> />
</div> </div>
)} )}

View File

@ -1,19 +1,21 @@
// react
import React, { useEffect, useRef, useState } from "react"; import React, { useEffect, useRef, useState } from "react";
// next
import { useRouter } from "next/router"; import { useRouter } from "next/router";
// swr
import { mutate } from "swr"; import { mutate } from "swr";
// services
// headless ui // headless ui
import { Dialog, Transition } from "@headlessui/react"; import { Dialog, Transition } from "@headlessui/react";
// services
import modulesService from "services/modules.service";
// hooks
import useToast from "hooks/use-toast";
// ui // ui
import { Button } from "components/ui";
// icons // icons
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline"; import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
// types // types
import type { IModule } from "types"; import type { IModule } from "types";
import { Button } from "components/ui";
import modulesService from "services/modules.service";
// fetch-keys // fetch-keys
import { MODULE_LIST } from "constants/fetch-keys"; import { MODULE_LIST } from "constants/fetch-keys";
@ -31,6 +33,8 @@ const ConfirmModuleDeletion: React.FC<Props> = ({ isOpen, setIsOpen, data }) =>
query: { workspaceSlug }, query: { workspaceSlug },
} = router; } = router;
const { setToastAlert } = useToast();
const cancelButtonRef = useRef(null); const cancelButtonRef = useRef(null);
const handleClose = () => { const handleClose = () => {
@ -48,6 +52,12 @@ const ConfirmModuleDeletion: React.FC<Props> = ({ isOpen, setIsOpen, data }) =>
mutate(MODULE_LIST(data.project)); mutate(MODULE_LIST(data.project));
router.push(`/${workspaceSlug}/projects/${data.project}/modules`); router.push(`/${workspaceSlug}/projects/${data.project}/modules`);
handleClose(); handleClose();
setToastAlert({
title: "Success",
type: "success",
message: "Module deleted successfully",
});
}) })
.catch((error) => { .catch((error) => {
console.log(error); console.log(error);

View File

@ -4,22 +4,25 @@ import { useRouter } from "next/router";
import { mutate } from "swr"; import { mutate } from "swr";
import { useForm } from "react-hook-form"; // react-hook-form
import { Controller, useForm } from "react-hook-form";
// headless ui
import { Dialog, Transition } from "@headlessui/react"; import { Dialog, Transition } from "@headlessui/react";
// types
import type { IModule } from "types";
// components // components
import SelectLead from "components/project/modules/create-update-module-modal/select-lead"; 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 SelectMembers from "components/project/modules/create-update-module-modal/select-members";
import SelectStatus from "components/project/modules/create-update-module-modal/select-status"; import SelectStatus from "components/project/modules/create-update-module-modal/select-status";
// ui // ui
import { Button, Input, TextArea } from "components/ui"; import { Button, CustomDatePicker, Input, TextArea } from "components/ui";
// services // services
import modulesService from "services/modules.service"; import modulesService from "services/modules.service";
// hooks
import useToast from "hooks/use-toast";
// helpers // helpers
import { renderDateFormat } from "helpers/date-time.helper"; import { renderDateFormat } from "helpers/date-time.helper";
// fetch keys // types
import type { IModule } from "types";
// fetch-keys
import { MODULE_LIST } from "constants/fetch-keys"; import { MODULE_LIST } from "constants/fetch-keys";
type Props = { type Props = {
@ -41,6 +44,8 @@ const CreateUpdateModuleModal: React.FC<Props> = ({ isOpen, setIsOpen, data, pro
const router = useRouter(); const router = useRouter();
const { workspaceSlug } = router.query; const { workspaceSlug } = router.query;
const { setToastAlert } = useToast();
const { const {
register, register,
formState: { errors, isSubmitting }, formState: { errors, isSubmitting },
@ -65,6 +70,12 @@ const CreateUpdateModuleModal: React.FC<Props> = ({ isOpen, setIsOpen, data, pro
.then(() => { .then(() => {
mutate(MODULE_LIST(projectId)); mutate(MODULE_LIST(projectId));
handleClose(); handleClose();
setToastAlert({
title: "Success",
type: "success",
message: "Module created successfully",
});
}) })
.catch((err) => { .catch((err) => {
Object.keys(err).map((key) => { Object.keys(err).map((key) => {
@ -91,6 +102,12 @@ const CreateUpdateModuleModal: React.FC<Props> = ({ isOpen, setIsOpen, data, pro
false false
); );
handleClose(); handleClose();
setToastAlert({
title: "Success",
type: "success",
message: "Module updated successfully",
});
}) })
.catch((err) => { .catch((err) => {
Object.keys(err).map((key) => { Object.keys(err).map((key) => {
@ -161,6 +178,10 @@ const CreateUpdateModuleModal: React.FC<Props> = ({ isOpen, setIsOpen, data, pro
register={register} register={register}
validations={{ validations={{
required: "Name is required", required: "Name is required",
maxLength: {
value: 255,
message: "Name should be less than 255 characters",
},
}} }}
/> />
</div> </div>
@ -176,32 +197,62 @@ const CreateUpdateModuleModal: React.FC<Props> = ({ isOpen, setIsOpen, data, pro
</div> </div>
<div className="flex gap-x-2"> <div className="flex gap-x-2">
<div className="w-full"> <div className="w-full">
<Input <h6 className="text-gray-500">Start Date</h6>
id="start_date" <div className="w-full">
label="Start Date" <Controller
name="start_date" control={control}
type="date" name="start_date"
placeholder="Enter start date" rules={{ required: "Start date is required" }}
error={errors.start_date} render={({ field: { value, onChange } }) => (
register={register} <CustomDatePicker
validations={{ renderAs="input"
required: "Start date is required", value={value}
}} onChange={(val: Date) => {
/> onChange(
val
? `${val.getFullYear()}-${
val.getMonth() + 1
}-${val.getDate()}`
: null
);
}}
error={errors.start_date ? true : false}
/>
)}
/>
{errors.start_date && (
<h6 className="text-sm text-red-500">{errors.start_date.message}</h6>
)}
</div>
</div> </div>
<div className="w-full"> <div className="w-full">
<Input <h6 className="text-gray-500">Target Date</h6>
id="target_date" <div className="w-full">
label="Target Date" <Controller
name="target_date" control={control}
type="date" name="target_date"
placeholder="Enter target date" rules={{ required: "Target date is required" }}
error={errors.target_date} render={({ field: { value, onChange } }) => (
register={register} <CustomDatePicker
validations={{ renderAs="input"
required: "Target date is required", value={value}
}} onChange={(val: Date) => {
/> onChange(
val
? `${val.getFullYear()}-${
val.getMonth() + 1
}-${val.getDate()}`
: null
);
}}
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> </div>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">

View File

@ -18,7 +18,7 @@ import { CustomMenu, Spinner } from "components/ui";
// helpers // helpers
import { addSpaceIfCamelCase } from "helpers/string.helper"; import { addSpaceIfCamelCase } from "helpers/string.helper";
// types // types
import { IIssue, IWorkspaceMember } from "types"; import { IIssue, IWorkspaceMember, UserAuth } from "types";
// fetch-keys // fetch-keys
import { STATE_LIST, WORKSPACE_MEMBERS } from "constants/fetch-keys"; import { STATE_LIST, WORKSPACE_MEMBERS } from "constants/fetch-keys";
@ -35,6 +35,7 @@ type Props = {
| null | null
> >
>; >;
userAuth: UserAuth;
}; };
const ModulesListView: React.FC<Props> = ({ const ModulesListView: React.FC<Props> = ({
@ -43,6 +44,7 @@ const ModulesListView: React.FC<Props> = ({
openIssuesListModal, openIssuesListModal,
removeIssueFromModule, removeIssueFromModule,
setPreloadedData, setPreloadedData,
userAuth,
}) => { }) => {
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId, moduleId } = router.query; const { workspaceSlug, projectId, moduleId } = router.query;
@ -137,6 +139,7 @@ const ModulesListView: React.FC<Props> = ({
properties={properties} properties={properties}
editIssue={() => openCreateIssueModal(issue, "edit")} editIssue={() => openCreateIssueModal(issue, "edit")}
removeIssue={() => removeIssueFromModule(issue.bridge ?? "")} removeIssue={() => removeIssueFromModule(issue.bridge ?? "")}
userAuth={userAuth}
/> />
); );
}) })

View File

@ -22,8 +22,11 @@ import SelectLead from "components/project/modules/module-detail-sidebar/select-
import SelectMembers from "components/project/modules/module-detail-sidebar/select-members"; import SelectMembers from "components/project/modules/module-detail-sidebar/select-members";
import SelectStatus from "components/project/modules/module-detail-sidebar/select-status"; import SelectStatus from "components/project/modules/module-detail-sidebar/select-status";
import ModuleLinkModal from "components/project/modules/module-link-modal"; 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 // ui
import { Loader } from "components/ui"; import { CustomDatePicker, Loader } from "components/ui";
// icons // icons
// helpers // helpers
import { timeAgo } from "helpers/date-time.helper"; import { timeAgo } from "helpers/date-time.helper";
@ -36,8 +39,8 @@ import { MODULE_LIST } from "constants/fetch-keys";
const defaultValues: Partial<IModule> = { const defaultValues: Partial<IModule> = {
members_list: [], members_list: [],
start_date: new Date().toString(), start_date: null,
target_date: new Date().toString(), target_date: null,
status: null, status: null,
}; };
@ -85,16 +88,21 @@ const ModuleDetailSidebar: React.FC<Props> = ({
const submitChanges = (data: Partial<IModule>) => { const submitChanges = (data: Partial<IModule>) => {
if (!workspaceSlug || !projectId || !module) return; if (!workspaceSlug || !projectId || !module) return;
mutate<IModule[]>(
projectId && MODULE_LIST(projectId as string),
(prevData) =>
(prevData ?? []).map((module) => {
if (module.id === moduleId) return { ...module, ...data };
return module;
}),
false
);
modulesService modulesService
.patchModule(workspaceSlug as string, projectId as string, module.id, data) .patchModule(workspaceSlug as string, projectId as string, module.id, data)
.then((res) => { .then((res) => {
console.log(res); console.log(res);
mutate<IModule[]>(projectId && MODULE_LIST(projectId as string), (prevData) => mutate(MODULE_LIST(projectId as string));
(prevData ?? []).map((module) => {
if (module.id === moduleId) return { ...module, ...data };
return module;
})
);
}) })
.catch((e) => { .catch((e) => {
console.log(e); console.log(e);
@ -161,7 +169,13 @@ const ModuleDetailSidebar: React.FC<Props> = ({
</div> </div>
<div className="flex items-center gap-2 sm:basis-1/2"> <div className="flex items-center gap-2 sm:basis-1/2">
<div className="grid flex-shrink-0 place-items-center"> <div className="grid flex-shrink-0 place-items-center">
<span className="h-4 w-4 rounded-full border-2 border-gray-300 border-r-blue-500" /> <span className="h-4 w-4">
<CircularProgressbar
value={groupedIssues.completed.length}
maxValue={moduleIssues?.length}
strokeWidth={10}
/>
</span>
</div> </div>
{groupedIssues.completed.length}/{moduleIssues?.length} {groupedIssues.completed.length}/{moduleIssues?.length}
</div> </div>
@ -177,16 +191,16 @@ const ModuleDetailSidebar: React.FC<Props> = ({
<Controller <Controller
control={control} control={control}
name="start_date" name="start_date"
render={({ field: { value, onChange } }) => ( render={({ field: { value } }) => (
<input <CustomDatePicker
type="date" value={value}
id="moduleStartDate" onChange={(val: Date) => {
value={value ?? ""} submitChanges({
onChange={(e: any) => { start_date: val
submitChanges({ start_date: e.target.value }); ? `${val.getFullYear()}-${val.getMonth() + 1}-${val.getDate()}`
onChange(e.target.value); : null,
});
}} }}
className="w-full cursor-pointer rounded-md border bg-transparent 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"
/> />
)} )}
/> />
@ -201,16 +215,16 @@ const ModuleDetailSidebar: React.FC<Props> = ({
<Controller <Controller
control={control} control={control}
name="target_date" name="target_date"
render={({ field: { value, onChange } }) => ( render={({ field: { value } }) => (
<input <CustomDatePicker
type="date" value={value}
id="moduleTargetDate" onChange={(val: Date) => {
value={value ?? ""} submitChanges({
onChange={(e: any) => { target_date: val
submitChanges({ target_date: e.target.value }); ? `${val.getFullYear()}-${val.getMonth() + 1}-${val.getDate()}`
onChange(e.target.value); : null,
});
}} }}
className="w-full cursor-pointer rounded-md border bg-transparent 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"
/> />
)} )}
/> />

View File

@ -12,9 +12,7 @@ import { UserGroupIcon } from "@heroicons/react/24/outline";
import workspaceService from "services/workspace.service"; import workspaceService from "services/workspace.service";
// headless ui // headless ui
// ui // ui
import { Spinner } from "components/ui"; import { AssigneesList, Spinner } from "components/ui";
// icons
import User from "public/user.png";
// types // types
import { IModule } from "types"; import { IModule } from "types";
// constants // constants
@ -64,52 +62,7 @@ const SelectMembers: React.FC<Props> = ({ control, submitChanges }) => {
> >
<div className="flex cursor-pointer items-center gap-1 text-xs"> <div className="flex cursor-pointer items-center gap-1 text-xs">
{value && Array.isArray(value) ? ( {value && Array.isArray(value) ? (
<> <AssigneesList userIds={value} length={10} />
{value.length > 0 ? (
value.map((member, index: number) => {
const person = people?.find((p) => p.member.id === member)?.member;
return (
<div
key={index}
className={`relative z-[1] h-5 w-5 rounded-full ${
index !== 0 ? "-ml-2.5" : ""
}`}
>
{person && person.avatar && person.avatar !== "" ? (
<div className="h-5 w-5 rounded-full border-2 border-white bg-white">
<Image
src={person.avatar}
height="100%"
width="100%"
className="rounded-full"
alt={person.first_name}
/>
</div>
) : (
<div
className={`grid h-5 w-5 place-items-center rounded-full border-2 border-white bg-gray-700 capitalize text-white`}
>
{person?.first_name && person.first_name !== ""
? person.first_name.charAt(0)
: person?.email.charAt(0)}
</div>
)}
</div>
);
})
) : (
<div className="h-5 w-5 rounded-full border-2 border-white bg-white">
<Image
src={User}
height="100%"
width="100%"
className="rounded-full"
alt="No user"
/>
</div>
)}
</>
) : null} ) : null}
</div> </div>
</span> </span>

View File

@ -1,14 +1,15 @@
import React from "react"; import React, { useState } from "react";
import Link from "next/link"; import Link from "next/link";
import Image from "next/image"; import Image from "next/image";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { CalendarDaysIcon, TrashIcon } from "@heroicons/react/24/outline";
import ConfirmModuleDeletion from "./confirm-module-deletion";
// icons // icons
import { CalendarDaysIcon } from "@heroicons/react/24/outline";
import User from "public/user.png"; import User from "public/user.png";
// helpers // helpers
import { renderShortNumericDateFormat } from "helpers/date-time.helper"; import { renderShortNumericDateFormat } from "helpers/date-time.helper";
// types // types
import { IModule } from "types"; import { IModule, SelectModuleType } from "types";
// common // common
import { MODULE_STATUS } from "constants/"; import { MODULE_STATUS } from "constants/";
@ -19,103 +20,131 @@ type Props = {
const SingleModuleCard: React.FC<Props> = ({ module }) => { const SingleModuleCard: React.FC<Props> = ({ module }) => {
const router = useRouter(); const router = useRouter();
const { workspaceSlug } = router.query; const { workspaceSlug } = router.query;
const [moduleDeleteModal, setModuleDeleteModal] = useState(false);
const [selectedModuleForDelete, setSelectedModuleForDelete] = useState<SelectModuleType>();
const handleDeleteModule = () => {
if (!module) return;
setSelectedModuleForDelete({ ...module, actionType: "delete" });
setModuleDeleteModal(true);
};
return ( return (
<Link href={`/${workspaceSlug}/projects/${module.project}/modules/${module.id}`}> <div className="group/card h-full w-full relative select-none p-2">
<a className="block cursor-pointer rounded-md border bg-white p-3"> <div className="absolute top-4 right-4 z-50 bg-red-200 opacity-0 group-hover/card:opacity-100">
{module.name} <button
<div className="mt-4 grid grid-cols-2 gap-2 text-xs md:grid-cols-4"> type="button"
<div className="space-y-2"> className="grid h-7 w-7 place-items-center bg-white p-1 text-red-500 outline-none duration-300 hover:bg-red-50"
<h6 className="text-gray-500">LEAD</h6> onClick={() => handleDeleteModule()}
<div> >
{module.lead ? ( <TrashIcon className="h-4 w-4" />
module.lead_detail?.avatar && module.lead_detail.avatar !== "" ? ( </button>
<div className="h-5 w-5 rounded-full border-2 border-white"> </div>
<ConfirmModuleDeletion
isOpen={
moduleDeleteModal &&
!!selectedModuleForDelete &&
selectedModuleForDelete.actionType === "delete"
}
setIsOpen={setModuleDeleteModal}
data={selectedModuleForDelete}
/>
<Link href={`/${workspaceSlug}/projects/${module.project}/modules/${module.id}`}>
<a className="flex flex-col cursor-pointer rounded-md border bg-white p-3 ">
<span className="w-3/4 text-ellipsis overflow-hidden">{module.name}</span>
<div className="mt-4 grid grid-cols-2 gap-2 text-xs md:grid-cols-4">
<div className="space-y-2">
<h6 className="text-gray-500">LEAD</h6>
<div>
{module.lead ? (
module.lead_detail?.avatar && module.lead_detail.avatar !== "" ? (
<div className="h-5 w-5 rounded-full border-2 border-white">
<Image
src={module.lead_detail.avatar}
height="100%"
width="100%"
className="rounded-full"
alt={module.lead_detail.first_name}
/>
</div>
) : (
<div className="grid h-5 w-5 place-items-center rounded-full border-2 border-white bg-gray-700 capitalize text-white">
{module.lead_detail?.first_name && module.lead_detail.first_name !== ""
? module.lead_detail.first_name.charAt(0)
: module.lead_detail?.email.charAt(0)}
</div>
)
) : (
"N/A"
)}
</div>
</div>
<div className="space-y-2">
<h6 className="text-gray-500">MEMBERS</h6>
<div className="flex items-center gap-1 text-xs">
{module.members && module.members.length > 0 ? (
module?.members_detail?.map((member, index: number) => (
<div
key={index}
className={`relative z-[1] h-5 w-5 rounded-full ${
index !== 0 ? "-ml-2.5" : ""
}`}
>
{member?.avatar && member.avatar !== "" ? (
<div className="h-5 w-5 rounded-full border-2 border-white bg-white">
<Image
src={member.avatar}
height="100%"
width="100%"
className="rounded-full"
alt={member?.first_name}
/>
</div>
) : (
<div className="grid h-5 w-5 place-items-center rounded-full border-2 border-white bg-gray-700 capitalize text-white">
{member?.first_name && member.first_name !== ""
? member.first_name.charAt(0)
: member?.email?.charAt(0)}
</div>
)}
</div>
))
) : (
<div className="h-5 w-5 rounded-full border-2 border-white bg-white">
<Image <Image
src={module.lead_detail.avatar} src={User}
height="100%" height="100%"
width="100%" width="100%"
className="rounded-full" className="rounded-full"
alt={module.lead_detail.first_name} alt="No user"
/> />
</div> </div>
) : ( )}
<div className="grid h-5 w-5 place-items-center rounded-full border-2 border-white bg-gray-700 capitalize text-white"> </div>
{module.lead_detail?.first_name && module.lead_detail.first_name !== "" </div>
? module.lead_detail.first_name.charAt(0) <div className="space-y-2">
: module.lead_detail?.email.charAt(0)} <h6 className="text-gray-500">END DATE</h6>
</div> <div className="flex w-min cursor-pointer items-center gap-1 whitespace-nowrap rounded border px-1.5 py-0.5 text-xs shadow-sm">
) <CalendarDaysIcon className="h-3 w-3" />
) : ( {renderShortNumericDateFormat(module.target_date ?? "")}
"N/A" </div>
)} </div>
<div className="space-y-2">
<h6 className="text-gray-500">STATUS</h6>
<div className="flex items-center gap-2 capitalize">
<span
className="h-2 w-2 flex-shrink-0 rounded-full"
style={{
backgroundColor: MODULE_STATUS.find((s) => s.value === module.status)?.color,
}}
/>
{module.status}
</div>
</div> </div>
</div> </div>
<div className="space-y-2"> </a>
<h6 className="text-gray-500">MEMBERS</h6> </Link>
<div className="flex items-center gap-1 text-xs"> </div>
{module.members && module.members.length > 0 ? (
module?.members_detail?.map((member, index: number) => (
<div
key={index}
className={`relative z-[1] h-5 w-5 rounded-full ${
index !== 0 ? "-ml-2.5" : ""
}`}
>
{member?.avatar && member.avatar !== "" ? (
<div className="h-5 w-5 rounded-full border-2 border-white bg-white">
<Image
src={member.avatar}
height="100%"
width="100%"
className="rounded-full"
alt={member?.first_name}
/>
</div>
) : (
<div className="grid h-5 w-5 place-items-center rounded-full border-2 border-white bg-gray-700 capitalize text-white">
{member?.first_name && member.first_name !== ""
? member.first_name.charAt(0)
: member?.email?.charAt(0)}
</div>
)}
</div>
))
) : (
<div className="h-5 w-5 rounded-full border-2 border-white bg-white">
<Image
src={User}
height="100%"
width="100%"
className="rounded-full"
alt="No user"
/>
</div>
)}
</div>
</div>
<div className="space-y-2">
<h6 className="text-gray-500">END DATE</h6>
<div className="flex w-min cursor-pointer items-center gap-1 whitespace-nowrap rounded border px-1.5 py-0.5 text-xs shadow-sm">
<CalendarDaysIcon className="h-3 w-3" />
{renderShortNumericDateFormat(module.target_date ?? "")}
</div>
</div>
<div className="space-y-2">
<h6 className="text-gray-500">STATUS</h6>
<div className="flex items-center gap-2 capitalize">
<span
className="h-2 w-2 flex-shrink-0 rounded-full"
style={{
backgroundColor: MODULE_STATUS.find((s) => s.value === module.status)?.color,
}}
/>
{module.status}
</div>
</div>
</div>
</a>
</Link>
); );
}; };

View File

@ -9,7 +9,7 @@ import { useForm, Controller } from "react-hook-form";
import { Dialog, Transition, Listbox } from "@headlessui/react"; import { Dialog, Transition, Listbox } from "@headlessui/react";
// ui // ui
import { ChevronDownIcon, CheckIcon } from "@heroicons/react/20/solid"; import { ChevronDownIcon, CheckIcon } from "@heroicons/react/20/solid";
import { Button, Select, TextArea } from "components/ui"; import { Button, CustomSelect, Select, TextArea } from "components/ui";
// hooks // hooks
import useToast from "hooks/use-toast"; import useToast from "hooks/use-toast";
// services // services
@ -106,7 +106,7 @@ const SendProjectInvitationModal: React.FC<Props> = ({ isOpen, setIsOpen, member
return ( return (
<Transition.Root show={isOpen} as={React.Fragment}> <Transition.Root show={isOpen} as={React.Fragment}>
<Dialog as="div" className="relative z-10" onClose={handleClose}> <Dialog as="div" className="relative z-20" onClose={handleClose}>
<Transition.Child <Transition.Child
as={React.Fragment} as={React.Fragment}
enter="ease-out duration-300" enter="ease-out duration-300"
@ -119,7 +119,7 @@ const SendProjectInvitationModal: React.FC<Props> = ({ isOpen, setIsOpen, member
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" /> <div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" />
</Transition.Child> </Transition.Child>
<div className="fixed inset-0 z-10 overflow-y-auto"> <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"> <div className="flex min-h-full items-center justify-center p-4 text-center sm:p-0">
<Transition.Child <Transition.Child
as={React.Fragment} as={React.Fragment}
@ -196,40 +196,17 @@ const SendProjectInvitationModal: React.FC<Props> = ({ isOpen, setIsOpen, member
uninvitedPeople?.map((person) => ( uninvitedPeople?.map((person) => (
<Listbox.Option <Listbox.Option
key={person.member.id} key={person.member.id}
className={({ active }) => className={({ active, selected }) =>
`${ `${active ? "bg-indigo-50" : ""} ${
active ? "bg-theme text-white" : "text-gray-900" selected ? "bg-indigo-50 font-medium" : ""
} relative cursor-default select-none py-2 pl-3 pr-9 text-left` } text-gray-900 cursor-default select-none p-2`
} }
value={{ value={{
id: person.member.id, id: person.member.id,
email: person.member.email, email: person.member.email,
}} }}
> >
{({ selected, active }) => ( {person.member.email}
<>
<span
className={`${
selected ? "font-semibold" : "font-normal"
} block truncate`}
>
{person.member.email}
</span>
{selected ? (
<span
className={`absolute inset-y-0 right-0 flex items-center pr-4 ${
active ? "text-white" : "text-theme"
}`}
>
<CheckIcon
className="h-5 w-5"
aria-hidden="true"
/>
</span>
) : null}
</>
)}
</Listbox.Option> </Listbox.Option>
)) ))
)} )}
@ -246,22 +223,28 @@ const SendProjectInvitationModal: React.FC<Props> = ({ isOpen, setIsOpen, member
/> />
</div> </div>
<div> <div>
<div> <h6 className="text-gray-500">Role</h6>
<Select <Controller
id="role" name="role"
label="Role" control={control}
name="role" render={({ field }) => (
error={errors.role} <CustomSelect
register={register} {...field}
validations={{ label={
required: "Role is required", <span className="capitalize">
}} {field.value ? ROLE[field.value] : "Select role"}
options={Object.entries(ROLE).map(([key, value]) => ({ </span>
value: key, }
label: value, input
}))} >
/> {Object.entries(ROLE).map(([key, label]) => (
</div> <CustomSelect.Option key={key} value={key}>
{label}
</CustomSelect.Option>
))}
</CustomSelect>
)}
/>
</div> </div>
<div> <div>
<TextArea <TextArea

View File

@ -47,6 +47,7 @@ export interface IRemirrorRichTextEditor {
value?: any; value?: any;
showToolbar?: boolean; showToolbar?: boolean;
editable?: boolean; editable?: boolean;
customClassName?: string;
} }
const RemirrorRichTextEditor: FC<IRemirrorRichTextEditor> = (props) => { const RemirrorRichTextEditor: FC<IRemirrorRichTextEditor> = (props) => {
@ -60,6 +61,7 @@ const RemirrorRichTextEditor: FC<IRemirrorRichTextEditor> = (props) => {
value = "", value = "",
showToolbar = true, showToolbar = true,
editable = true, editable = true,
customClassName,
} = props; } = props;
const [imageLoader, setImageLoader] = useState(false); const [imageLoader, setImageLoader] = useState(false);
@ -91,7 +93,7 @@ const RemirrorRichTextEditor: FC<IRemirrorRichTextEditor> = (props) => {
() => () =>
new Promise(async (resolve, reject) => { new Promise(async (resolve, reject) => {
const imageUrl = await fileService const imageUrl = await fileService
.uploadFile(workspaceSlug as string, formData) // TODO: verify why workspaceSlug is required for uploading a file .uploadFile(workspaceSlug as string, formData)
.then((response) => response.asset); .then((response) => response.asset);
resolve({ resolve({
@ -173,7 +175,7 @@ const RemirrorRichTextEditor: FC<IRemirrorRichTextEditor> = (props) => {
<Remirror <Remirror
manager={manager} manager={manager}
initialContent={state} initialContent={state}
classNames={["p-4 focus:outline-none"]} classNames={[`p-4 focus:outline-none ${customClassName}`]}
editable={editable} editable={editable}
onBlur={() => { onBlur={() => {
onBlur(jsonValue, htmlValue); onBlur(jsonValue, htmlValue);

View File

@ -14,6 +14,7 @@ type CustomSelectProps = {
width?: "auto" | string; width?: "auto" | string;
input?: boolean; input?: boolean;
noChevron?: boolean; noChevron?: boolean;
disabled?: boolean;
}; };
const CustomSelect = ({ const CustomSelect = ({
@ -26,11 +27,20 @@ const CustomSelect = ({
width = "auto", width = "auto",
input = false, input = false,
noChevron = false, noChevron = false,
disabled = false,
}: CustomSelectProps) => ( }: CustomSelectProps) => (
<Listbox as="div" value={value} onChange={onChange} className="relative flex-shrink-0 text-left"> <Listbox
as="div"
value={value}
onChange={onChange}
className="relative flex-shrink-0 text-left"
disabled={disabled}
>
<div> <div>
<Listbox.Button <Listbox.Button
className={`flex w-full cursor-pointer items-center justify-between gap-1 rounded-md border shadow-sm duration-300 hover:bg-gray-100 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 ${ className={`flex w-full ${
disabled ? "cursor-not-allowed" : "cursor-pointer"
} items-center justify-between gap-1 rounded-md border shadow-sm duration-300 hover:bg-gray-100 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 ${
input ? "border-gray-300 px-3 py-2 text-sm" : "px-2 py-1 text-xs" input ? "border-gray-300 px-3 py-2 text-sm" : "px-2 py-1 text-xs"
} ${ } ${
textAlignment === "right" textAlignment === "right"

View File

@ -0,0 +1,40 @@
// react-datepicker
import DatePicker from "react-datepicker";
import "react-datepicker/dist/react-datepicker.css";
type Props = {
renderAs?: "input" | "button";
value: Date | string | null | undefined;
onChange: (arg: Date) => void;
placeholder?: string;
displayShortForm?: boolean;
error?: boolean;
className?: string;
isClearable?: boolean;
};
export const CustomDatePicker: React.FC<Props> = ({
renderAs = "button",
value,
onChange,
placeholder = "Select date",
displayShortForm = false,
error = false,
className = "",
isClearable = true,
}) => (
<DatePicker
placeholderText={placeholder}
selected={value ? new Date(value) : null}
onChange={onChange}
dateFormat="dd-MM-yyyy"
className={`${className} ${
renderAs === "input"
? "block bg-transparent text-sm focus:outline-none rounded-md border border-gray-300 px-3 py-2 w-full cursor-pointer"
: renderAs === "button"
? "w-full rounded-md border px-2 py-1 text-xs shadow-sm hover:bg-gray-100 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 duration-300 cursor-pointer"
: ""
} ${error ? "border-red-500 bg-red-200" : ""} bg-transparent caret-transparent`}
isClearable={isClearable}
/>
);

View File

@ -13,3 +13,4 @@ export * from "./spinner";
export * from "./text-area"; export * from "./text-area";
export * from "./tooltip"; export * from "./tooltip";
export * from "./avatar"; export * from "./avatar";
export * from "./datepicker";

View File

@ -1,39 +1,73 @@
import React from "react"; import React, { useEffect, useState } from "react";
type Props = { export type Props = {
direction?: "top" | "right" | "bottom" | "left";
content: string | React.ReactNode;
margin?: string;
children: React.ReactNode; children: React.ReactNode;
content: React.ReactNode; customStyle?: string;
position?: "top" | "bottom" | "left" | "right";
}; };
const Tooltip: React.FC<Props> = ({ children, content, position = "top" }) => ( const Tooltip: React.FC<Props> = ({
<div className="relative group"> content,
<div direction = "top",
className={`fixed pointer-events-none transition-opacity opacity-0 group-hover:opacity-100 bg-black text-white px-3 py-1 rounded ${ children,
position === "right" margin = "24px",
? "left-14" customStyle,
: position === "left" }) => {
? "right-14" const [active, setActive] = useState(false);
: position === "top" const [styleConfig, setStyleConfig] = useState("top-[calc(-100%-24px)]");
? "bottom-14" let timeout: any;
: "top-14"
}`} const showToolTip = () => {
> timeout = setTimeout(() => {
<p className="truncate text-xs">{content}</p> setActive(true);
<span }, 300);
className={`absolute w-2 h-2 bg-black ${ };
position === "top"
? "top-full left-1/2 transform -translate-y-1/2 -translate-x-1/2 rotate-45" const hideToolTip = () => {
: position === "bottom" clearInterval(timeout);
? "bottom-full left-1/2 transform translate-y-1/2 -translate-x-1/2 rotate-45" setActive(false);
: position === "left" };
? "left-full top-1/2 transform translate-x-1/2 -translate-y-1/2 rotate-45"
: "right-full top-1/2 transform translate-x-1/2 -translate-y-1/2 rotate-45" const tooltipStyles = {
}`} top: `
/> left-[50%] translate-x-[-50%] before:contents-[""] before:border-solid
</div> before:border-transparent before:h-0 before:w-0 before:absolute before:pointer-events-none
before:border-[6px] before:left-[50%] before:ml-[calc(6px*-1)] before:top-full before:border-t-black`,
right: `
right-[-100%] top-[50%]
translate-x-0 translate-y-[-50%] `,
bottom: `
left-[50%] translate-x-[-50%] before:contents-[""] before:border-solid
before:border-transparent before:h-0 before:w-0 before:absolute before:pointer-events-none
before:border-[6px] before:left-[50%] before:ml-[calc(6px*-1)] before:bottom-full before:border-b-black`,
left: `
left-[-100%] top-[50%]
translate-x-0 translate-y-[-50%] `,
};
useEffect(() => {
const styleConfig = direction + "-[calc(-100%-" + margin + ")]";
setStyleConfig(styleConfig);
}, [margin, direction]);
return (
<div className="inline-block relative" onMouseEnter={showToolTip} onMouseLeave={hideToolTip}>
{children} {children}
{active && (
<div
className={`absolute p-[6px] text-xs z-20 rounded leading-1 text-white bg-black text-center w-max max-w-[300px]
${tooltipStyles[direction]} ${customStyle ? customStyle : ""} ${styleConfig}`}
>
{content}
</div>
)}
</div> </div>
); );
};
export default Tooltip; export default Tooltip;

View File

@ -4,19 +4,20 @@ import { useRouter } from "next/router";
import { mutate } from "swr"; import { mutate } from "swr";
// headless ui
import { Dialog, Transition } from "@headlessui/react"; import { Dialog, Transition } from "@headlessui/react";
// constants
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
import type { IWorkspace } from "types";
import { USER_WORKSPACES } from "constants/fetch-keys";
// services // services
import workspaceService from "services/workspace.service"; import workspaceService from "services/workspace.service";
// hooks // hooks
import useToast from "hooks/use-toast"; import useToast from "hooks/use-toast";
// icons // icons
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
// ui // ui
import { Button, Input } from "components/ui"; import { Button, Input } from "components/ui";
// types // types
import type { IWorkspace } from "types";
// fetch-keys
import { USER_WORKSPACES } from "constants/fetch-keys";
type Props = { type Props = {
isOpen: boolean; isOpen: boolean;
@ -78,7 +79,7 @@ const ConfirmWorkspaceDeletion: React.FC<Props> = ({ isOpen, data, onClose }) =>
<Transition.Root show={isOpen} as={React.Fragment}> <Transition.Root show={isOpen} as={React.Fragment}>
<Dialog <Dialog
as="div" as="div"
className="relative z-10" className="relative z-20"
initialFocus={cancelButtonRef} initialFocus={cancelButtonRef}
onClose={handleClose} onClose={handleClose}
> >
@ -94,7 +95,7 @@ const ConfirmWorkspaceDeletion: React.FC<Props> = ({ isOpen, data, onClose }) =>
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" /> <div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" />
</Transition.Child> </Transition.Child>
<div className="fixed inset-0 z-10 overflow-y-auto"> <div className="fixed inset-0 z-20 overflow-y-auto">
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0"> <div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
<Transition.Child <Transition.Child
as={React.Fragment} as={React.Fragment}

View File

@ -33,7 +33,7 @@ const ConfirmWorkspaceMemberRemove: React.FC<Props> = ({ isOpen, onClose, data,
<Transition.Root show={isOpen} as={React.Fragment}> <Transition.Root show={isOpen} as={React.Fragment}>
<Dialog <Dialog
as="div" as="div"
className="relative z-10" className="relative z-20"
initialFocus={cancelButtonRef} initialFocus={cancelButtonRef}
onClose={handleClose} onClose={handleClose}
> >
@ -49,7 +49,7 @@ const ConfirmWorkspaceMemberRemove: React.FC<Props> = ({ isOpen, onClose, data,
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" /> <div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" />
</Transition.Child> </Transition.Child>
<div className="fixed inset-0 z-10 overflow-y-auto"> <div className="fixed inset-0 z-20 overflow-y-auto">
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0"> <div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
<Transition.Child <Transition.Child
as={React.Fragment} as={React.Fragment}

View File

@ -53,6 +53,8 @@ export const WorkspaceHelpSection: FC<WorkspaceHelpSectionProps> = (props) => {
// hooks // hooks
useOutsideClickDetector(helpOptionsRef, () => setIsNeedHelpOpen(false)); useOutsideClickDetector(helpOptionsRef, () => setIsNeedHelpOpen(false));
const helpOptionMode = sidebarCollapse ? "left-full" : "left-[-75px]";
return ( return (
<div <div
className={`flex w-full items-center self-baseline bg-primary px-2 py-2 ${ className={`flex w-full items-center self-baseline bg-primary px-2 py-2 ${
@ -107,14 +109,14 @@ export const WorkspaceHelpSection: FC<WorkspaceHelpSectionProps> = (props) => {
leaveTo="transform opacity-0 scale-95" leaveTo="transform opacity-0 scale-95"
> >
<div <div
className="absolute bottom-0 left-full space-y-2 rounded-sm bg-white py-3 shadow-md" className={`absolute bottom-2 ${helpOptionMode} space-y-2 rounded-sm bg-white py-3 shadow-md`}
ref={helpOptionsRef} ref={helpOptionsRef}
> >
{helpOptions.map(({ name, Icon, href }) => ( {helpOptions.map(({ name, Icon, href }) => (
<Link href={href} key={name}> <Link href={href} key={name}>
<a <a
target="_blank" target="_blank"
className="mx-3 flex items-center gap-x-2 rounded-md px-2 py-2 text-xs hover:bg-gray-100" className="mx-3 flex items-center gap-x-2 rounded-md whitespace-nowrap px-2 py-2 text-xs hover:bg-gray-100"
> >
<Icon className="h-5 w-5 text-gray-500" /> <Icon className="h-5 w-5 text-gray-500" />
<span className="text-sm">{name}</span> <span className="text-sm">{name}</span>
@ -132,7 +134,7 @@ export const WorkspaceHelpSection: FC<WorkspaceHelpSectionProps> = (props) => {
title="Help" title="Help"
> >
<QuestionMarkCircleIcon className="h-4 w-4 text-gray-500" /> <QuestionMarkCircleIcon className="h-4 w-4 text-gray-500" />
{!sidebarCollapse && <span>Need help?</span>} {!sidebarCollapse && <span>Help ?</span>}
</button> </button>
</div> </div>
</div> </div>

View File

@ -79,7 +79,7 @@ const SendWorkspaceInvitationModal: React.FC<Props> = ({
return ( return (
<Transition.Root show={isOpen} as={React.Fragment}> <Transition.Root show={isOpen} as={React.Fragment}>
<Dialog as="div" className="relative z-10" onClose={handleClose}> <Dialog as="div" className="relative z-20" onClose={handleClose}>
<Transition.Child <Transition.Child
as={React.Fragment} as={React.Fragment}
enter="ease-out duration-300" enter="ease-out duration-300"
@ -92,7 +92,7 @@ const SendWorkspaceInvitationModal: React.FC<Props> = ({
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" /> <div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" />
</Transition.Child> </Transition.Child>
<div className="fixed inset-0 z-10 overflow-y-auto"> <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"> <div className="flex min-h-full items-center justify-center p-4 text-center sm:p-0">
<Transition.Child <Transition.Child
as={React.Fragment} as={React.Fragment}

View File

@ -23,7 +23,7 @@ export const PROJECT_ISSUES_DETAILS = (issueId: string) => `PROJECT_ISSUES_DETAI
export const PROJECT_ISSUES_PROPERTIES = (projectId: string) => export const PROJECT_ISSUES_PROPERTIES = (projectId: string) =>
`PROJECT_ISSUES_PROPERTIES_${projectId}`; `PROJECT_ISSUES_PROPERTIES_${projectId}`;
export const PROJECT_ISSUES_COMMENTS = (issueId: string) => `PROJECT_ISSUES_COMMENTS_${issueId}`; export const PROJECT_ISSUES_COMMENTS = (issueId: string) => `PROJECT_ISSUES_COMMENTS_${issueId}`;
export const PROJECT_ISSUES_ACTIVITY = "PROJECT_ISSUES_ACTIVITY"; export const PROJECT_ISSUES_ACTIVITY = (issueId: string) => `PROJECT_ISSUES_ACTIVITY_${issueId}`;
export const PROJECT_ISSUE_BY_STATE = (projectId: string) => `PROJECT_ISSUE_BY_STATE_${projectId}`; export const PROJECT_ISSUE_BY_STATE = (projectId: string) => `PROJECT_ISSUE_BY_STATE_${projectId}`;
export const PROJECT_ISSUE_LABELS = (projectId: string) => `PROJECT_ISSUE_LABELS_${projectId}`; export const PROJECT_ISSUE_LABELS = (projectId: string) => `PROJECT_ISSUE_LABELS_${projectId}`;

View File

@ -1,4 +1,4 @@
import { createContext, useCallback, useReducer } from "react"; import { createContext, useCallback, useEffect, useReducer } from "react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
@ -24,6 +24,7 @@ type IssueViewProps = {
type ReducerActionType = { type ReducerActionType = {
type: type:
| "REHYDRATE_THEME"
| "SET_ISSUE_VIEW" | "SET_ISSUE_VIEW"
| "SET_ORDER_BY_PROPERTY" | "SET_ORDER_BY_PROPERTY"
| "SET_FILTER_ISSUES" | "SET_FILTER_ISSUES"
@ -65,6 +66,12 @@ export const reducer: ReducerFunctionType = (state, action) => {
const { type, payload } = action; const { type, payload } = action;
switch (type) { switch (type) {
case "REHYDRATE_THEME": {
let collapsed: any = localStorage.getItem("collapsed");
collapsed = collapsed ? JSON.parse(collapsed) : false;
return { ...initialState, ...payload, collapsed };
}
case "SET_ISSUE_VIEW": { case "SET_ISSUE_VIEW": {
const newState = { const newState = {
...state, ...state,
@ -260,6 +267,13 @@ export const IssueViewContextProvider: React.FC<{ children: React.ReactNode }> =
saveDataToServer(workspaceSlug as string, projectId as string, myViewProps?.default_props); saveDataToServer(workspaceSlug as string, projectId as string, myViewProps?.default_props);
}, [projectId, workspaceSlug, myViewProps]); }, [projectId, workspaceSlug, myViewProps]);
useEffect(() => {
dispatch({
type: "REHYDRATE_THEME",
payload: myViewProps?.view_props,
});
}, [myViewProps]);
return ( return (
<issueViewContext.Provider <issueViewContext.Provider
value={{ value={{

View File

@ -13,7 +13,7 @@ const initialValues: Properties = {
assignee: true, assignee: true,
priority: false, priority: false,
due_date: false, due_date: false,
cycle: false, // cycle: false,
sub_issue_count: false, sub_issue_count: false,
}; };
@ -86,7 +86,7 @@ const useIssuesProperties = (workspaceSlug?: string, projectId?: string) => {
assignee: properties.assignee, assignee: properties.assignee,
priority: properties.priority, priority: properties.priority,
due_date: properties.due_date, due_date: properties.due_date,
cycle: properties.cycle, // cycle: properties.cycle,
sub_issue_count: properties.sub_issue_count, sub_issue_count: properties.sub_issue_count,
}; };

View File

@ -1,39 +0,0 @@
import { useState, useEffect, useCallback } from "react";
// TODO: No Use of this
const useLocalStorage = <T,>(
key: string,
initialValue?: T extends Function ? never : T | (() => T)
) => {
const [value, setValue] = useState<T | string>("");
useEffect(() => {
const data = window.localStorage.getItem(key);
if (data !== null && data !== "undefined") setValue(JSON.parse(data));
else setValue(typeof initialValue === "function" ? initialValue() : initialValue);
}, [key, initialValue]);
const updateState = useCallback(
(value: T) => {
if (!value) window.localStorage.removeItem(key);
else window.localStorage.setItem(key, JSON.stringify(value));
setValue(value);
window.dispatchEvent(new Event(`local-storage-change-${key}`));
},
[key]
);
const reHydrateState = useCallback(() => {
const data = window.localStorage.getItem(key);
if (data !== null) setValue(JSON.parse(data));
}, [key]);
useEffect(() => {
window.addEventListener(`local-storage-change-${key}`, reHydrateState);
return () => window.removeEventListener(`local-storage-change-${key}`, reHydrateState);
}, [reHydrateState, key]);
return [value, updateState];
};
export default useLocalStorage;

View File

@ -21,7 +21,7 @@ const initialValues: Properties = {
assignee: true, assignee: true,
priority: false, priority: false,
due_date: false, due_date: false,
cycle: false, // cycle: false,
sub_issue_count: false, sub_issue_count: false,
}; };

View File

@ -84,6 +84,8 @@ const Sidebar: React.FC<Props> = ({ toggleSidebar, setToggleSidebar }) => {
const [isNeedHelpOpen, setIsNeedHelpOpen] = useState(false); const [isNeedHelpOpen, setIsNeedHelpOpen] = useState(false);
const helpOptionMode = sidebarCollapse ? "left-full" : "left-[-75px]";
return ( return (
<nav className="relative z-20 h-screen"> <nav className="relative z-20 h-screen">
<div <div
@ -148,14 +150,14 @@ const Sidebar: React.FC<Props> = ({ toggleSidebar, setToggleSidebar }) => {
leaveTo="transform opacity-0 scale-95" leaveTo="transform opacity-0 scale-95"
> >
<div <div
className="absolute bottom-0 left-full space-y-2 rounded-sm bg-white py-3 shadow-md" className={`absolute bottom-2 ${helpOptionMode} space-y-2 rounded-sm bg-white py-3 shadow-md`}
ref={helpOptionsRef} ref={helpOptionsRef}
> >
{helpOptions.map(({ name, Icon, href }) => ( {helpOptions.map(({ name, Icon, href }) => (
<Link href={href} key={name}> <Link href={href} key={name}>
<a <a
target="_blank" target="_blank"
className="mx-3 flex items-center gap-x-2 rounded-md px-2 py-2 text-xs hover:bg-gray-100" className="mx-3 flex items-center gap-x-2 rounded-md whitespace-nowrap px-2 py-2 text-xs hover:bg-gray-100"
> >
<Icon className="h-5 w-5 text-gray-500" /> <Icon className="h-5 w-5 text-gray-500" />
<span className="text-sm">{name}</span> <span className="text-sm">{name}</span>
@ -173,7 +175,7 @@ const Sidebar: React.FC<Props> = ({ toggleSidebar, setToggleSidebar }) => {
title="Help" title="Help"
> >
<QuestionMarkCircleIcon className="h-4 w-4 text-gray-500" /> <QuestionMarkCircleIcon className="h-4 w-4 text-gray-500" />
{!sidebarCollapse && <span>Need help?</span>} {!sidebarCollapse && <span>Help ?</span>}
</button> </button>
</div> </div>
</div> </div>

View File

@ -15,13 +15,18 @@
"@remirror/extension-react-tables": "^2.2.11", "@remirror/extension-react-tables": "^2.2.11",
"@remirror/pm": "^2.0.3", "@remirror/pm": "^2.0.3",
"@remirror/react": "^2.0.24", "@remirror/react": "^2.0.24",
"@types/lodash.debounce": "^4.0.7",
"@types/react-datepicker": "^4.8.0",
"axios": "^1.1.3", "axios": "^1.1.3",
"js-cookie": "^3.0.1", "js-cookie": "^3.0.1",
"lodash.debounce": "^4.0.8",
"next": "12.3.2", "next": "12.3.2",
"next-pwa": "^5.6.0", "next-pwa": "^5.6.0",
"react": "18.2.0", "react": "18.2.0",
"react-beautiful-dnd": "^13.1.1", "react-beautiful-dnd": "^13.1.1",
"react-circular-progressbar": "^2.1.0",
"react-color": "^2.19.3", "react-color": "^2.19.3",
"react-datepicker": "^4.8.0",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"react-dropzone": "^14.2.3", "react-dropzone": "^14.2.3",
"react-hook-form": "^7.38.0", "react-hook-form": "^7.38.0",

View File

@ -164,8 +164,7 @@ const WorkspacePage: NextPage = () => {
<LayerDiagonalIcon height="56" width="56" /> <LayerDiagonalIcon height="56" width="56" />
<h3 className="text-gray-500"> <h3 className="text-gray-500">
No issues found. Create a new issue with{" "} No issues found. Create a new issue with{" "}
<pre className="inline rounded bg-gray-100 px-2 py-1">Ctrl/Command + I</pre> <pre className="inline rounded bg-gray-100 px-2 py-1">C</pre>.
.
</h3> </h3>
</div> </div>
) )

View File

@ -163,9 +163,8 @@ const MyIssuesPage: NextPage = () => {
title="Create a new issue" title="Create a new issue"
description={ description={
<span> <span>
Use{" "} Use <pre className="inline rounded bg-gray-100 px-2 py-1">C</pre> shortcut
<pre className="inline rounded bg-gray-100 px-2 py-1">Ctrl/Command + I</pre>{" "} to create a new issue
shortcut to create a new issue
</span> </span>
} }
Icon={PlusIcon} Icon={PlusIcon}

View File

@ -1,156 +0,0 @@
// FIXME: remove this page
import React, { useState } from "react";
// next
import Link from "next/link";
import { useRouter } from "next/router";
import useSWR from "swr";
import type { NextPage } from "next";
// services
import workspaceService from "services/workspace.service";
// hoc
// import withAuthWrapper from "lib/hoc/withAuthWrapper";
// layouts
import AppLayout from "layouts/app-layout";
// ui
import { Button } from "components/ui";
// swr
import { USER_WORKSPACES } from "constants/fetch-keys";
const MyWorkspacesInvites: NextPage = () => {
const router = useRouter();
const [invitationsRespond, setInvitationsRespond] = useState<any>([]);
const { data: workspaces } = useSWR(USER_WORKSPACES, () => workspaceService.userWorkspaces(), {
shouldRetryOnError: false,
});
const { data: workspaceInvitations, mutate: mutateInvitations } = useSWR<any[]>(
"WORKSPACE_INVITATIONS",
() => workspaceService.userWorkspaceInvitations()
);
const handleInvitation = (workspace_invitation: any, action: string) => {
if (action === "accepted") {
setInvitationsRespond((prevData: any) => [...prevData, workspace_invitation.workspace.id]);
} else if (action === "withdraw") {
setInvitationsRespond((prevData: any) =>
prevData.filter((item: string) => item !== workspace_invitation.workspace.id)
);
}
};
const submitInvitations = () => {
workspaceService
.joinWorkspaces({ workspace_ids: invitationsRespond })
.then(async (res) => {
console.log(res);
await mutateInvitations();
router.push("/");
})
.catch((err: any) => console.log(err));
};
return (
<AppLayout
meta={{
title: "Plane - My Workspace Invites",
}}
>
<div className="flex h-full w-full flex-col items-center justify-center">
<div className="relative rounded bg-gray-50 px-4 pt-5 pb-4 text-left shadow sm:w-full sm:max-w-2xl sm:p-6">
{(workspaceInvitations as any)?.length > 0 ? (
<>
<div>
<div className="mt-3 sm:mt-5">
<div className="mt-2">
<h2 className="mb-4 text-lg">Workspace Invitations</h2>
<div className="space-y-2">
{workspaceInvitations?.map((item: any) => (
<div className="relative flex items-start" key={item.id}>
<div className="flex h-5 items-center">
<input
id={`${item.id}`}
aria-describedby="workspaces"
name={`${item.id}`}
checked={
item.workspace.accepted ||
invitationsRespond.includes(item.workspace.id)
}
value={item.workspace.name}
onChange={() =>
handleInvitation(
item,
item.accepted
? "withdraw"
: invitationsRespond.includes(item.workspace.id)
? "withdraw"
: "accepted"
)
}
type="checkbox"
className="h-4 w-4 rounded border-gray-300 text-theme focus:ring-indigo-500"
/>
</div>
<div className="ml-3 flex w-full justify-between text-sm">
<label htmlFor={`${item.id}`} className="font-medium text-gray-700">
{item.workspace.name}
</label>
<div>
{invitationsRespond.includes(item.workspace.id) ? (
<div className="flex gap-x-2">
<p>Accepted</p>
<button
type="button"
onClick={() => handleInvitation(item, "withdraw")}
>
Withdraw
</button>
</div>
) : (
<button
type="button"
onClick={() => handleInvitation(item, "accepted")}
>
Join
</button>
)}
</div>
</div>
</div>
))}
</div>
</div>
</div>
</div>
<div className="mt-4 flex justify-between">
<Link href={workspaces?.length === 0 ? "/create-workspace" : "/"}>
<button type="button" className="text-sm text-gray-700">
Skip
</button>
</Link>
<Button onClick={submitInvitations}>Submit</Button>
</div>
</>
) : (
<div>
<span>No Invitaions Found</span>
<p>
<Link href="/">
<a>Click Here </a>
</Link>
<span>to redirect home</span>
</p>
</div>
)}
</div>
</div>
</AppLayout>
);
};
export default MyWorkspacesInvites;

View File

@ -4,6 +4,8 @@ import { useRouter } from "next/router";
import useSWR, { mutate } from "swr"; import useSWR, { mutate } from "swr";
// lib
import { requiredAdmin, requiredAuth } from "lib/auth";
// layouts // layouts
import AppLayout from "layouts/app-layout"; import AppLayout from "layouts/app-layout";
// contexts // contexts
@ -32,7 +34,8 @@ import { CustomMenu, EmptySpace, EmptySpaceItem, Spinner } from "components/ui";
import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs"; import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs";
// icons // icons
// types // types
import { CycleIssueResponse, IIssue, SelectIssue } from "types"; import { CycleIssueResponse, IIssue, SelectIssue, UserAuth } from "types";
import { NextPageContext } from "next";
// fetch-keys // fetch-keys
import { import {
CYCLE_ISSUES, CYCLE_ISSUES,
@ -42,12 +45,12 @@ import {
PROJECT_DETAILS, PROJECT_DETAILS,
} from "constants/fetch-keys"; } from "constants/fetch-keys";
const SingleCycle: React.FC = () => { const SingleCycle: React.FC<UserAuth> = (props) => {
const [isIssueModalOpen, setIsIssueModalOpen] = useState(false); const [isIssueModalOpen, setIsIssueModalOpen] = useState(false);
const [selectedIssues, setSelectedIssues] = useState<SelectIssue>(); const [selectedIssues, setSelectedIssues] = useState<SelectIssue>();
const [cycleIssuesListModal, setCycleIssuesListModal] = useState(false); const [cycleIssuesListModal, setCycleIssuesListModal] = useState(false);
const [deleteIssue, setDeleteIssue] = useState<string | undefined>(undefined); const [deleteIssue, setDeleteIssue] = useState<string | undefined>(undefined);
const [cycleSidebar, setCycleSidebar] = useState(false); const [cycleSidebar, setCycleSidebar] = useState(true);
const [preloadedData, setPreloadedData] = useState< const [preloadedData, setPreloadedData] = useState<
(Partial<IIssue> & { actionType: "createIssue" | "edit" | "delete" }) | null (Partial<IIssue> & { actionType: "createIssue" | "edit" | "delete" }) | null
@ -109,18 +112,6 @@ const SingleCycle: React.FC = () => {
} }
); );
const partialUpdateIssue = (formData: Partial<IIssue>, issueId: string) => {
if (!workspaceSlug || !projectId) return;
issuesServices
.patchIssue(workspaceSlug as string, projectId as string, issueId, formData)
.then(() => {
mutate(CYCLE_ISSUES(cycleId as string));
})
.catch((error) => {
console.log(error);
});
};
const openCreateIssueModal = ( const openCreateIssueModal = (
issue?: IIssue, issue?: IIssue,
actionType: "create" | "edit" | "delete" = "create" actionType: "create" | "edit" | "delete" = "create"
@ -256,16 +247,16 @@ const SingleCycle: React.FC = () => {
openIssuesListModal={openIssuesListModal} openIssuesListModal={openIssuesListModal}
removeIssueFromCycle={removeIssueFromCycle} removeIssueFromCycle={removeIssueFromCycle}
setPreloadedData={setPreloadedData} setPreloadedData={setPreloadedData}
userAuth={props}
/> />
<CyclesBoardView <CyclesBoardView
issues={cycleIssuesArray ?? []} issues={cycleIssuesArray ?? []}
removeIssueFromCycle={removeIssueFromCycle}
members={members} members={members}
openCreateIssueModal={openCreateIssueModal} openCreateIssueModal={openCreateIssueModal}
openIssuesListModal={openIssuesListModal} openIssuesListModal={openIssuesListModal}
handleDeleteIssue={setDeleteIssue} handleDeleteIssue={setDeleteIssue}
partialUpdateIssue={partialUpdateIssue}
setPreloadedData={setPreloadedData} setPreloadedData={setPreloadedData}
userAuth={props}
/> />
</div> </div>
) : ( ) : (
@ -309,4 +300,32 @@ const SingleCycle: React.FC = () => {
); );
}; };
export const getServerSideProps = async (ctx: NextPageContext) => {
const user = await requiredAuth(ctx.req?.headers.cookie);
const redirectAfterSignIn = ctx.req?.url;
if (!user) {
return {
redirect: {
destination: `/signin?next=${redirectAfterSignIn}`,
permanent: false,
},
};
}
const projectId = ctx.query.projectId as string;
const workspaceSlug = ctx.query.workspaceSlug as string;
const memberDetail = await requiredAdmin(workspaceSlug, projectId, ctx.req?.headers.cookie);
return {
props: {
isOwner: memberDetail?.role === 20,
isMember: memberDetail?.role === 15,
isViewer: memberDetail?.role === 10,
isGuest: memberDetail?.role === 5,
},
};
};
export default SingleCycle; export default SingleCycle;

View File

@ -178,8 +178,8 @@ const ProjectCycles: NextPage = () => {
title="Create a new cycle" title="Create a new cycle"
description={ description={
<span> <span>
Use <pre className="inline rounded bg-gray-100 px-2 py-1">Ctrl/Command + Q</pre>{" "} Use <pre className="inline rounded bg-gray-100 px-2 py-1">Q</pre> shortcut to
shortcut to create a new cycle create a new cycle
</span> </span>
} }
Icon={PlusIcon} Icon={PlusIcon}

View File

@ -1,12 +1,14 @@
import React, { useCallback, useEffect, useState } from "react"; import React, { useCallback, useEffect, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import useSWR, { mutate } from "swr"; import useSWR, { mutate } from "swr";
// react-hook-form
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { ChevronLeftIcon, ChevronRightIcon, PlusIcon } from "@heroicons/react/24/outline";
// services // services
import issuesService from "services/issues.service"; import issuesService from "services/issues.service";
import projectService from "services/project.service";
// lib // lib
import { requiredAuth } from "lib/auth"; import { requiredAuth } from "lib/auth";
// layouts // layouts
@ -25,12 +27,13 @@ import {
// ui // ui
import { Loader, HeaderButton, CustomMenu } from "components/ui"; import { Loader, HeaderButton, CustomMenu } from "components/ui";
import { Breadcrumbs } from "components/breadcrumbs"; import { Breadcrumbs } from "components/breadcrumbs";
// icons
import { ChevronLeftIcon, ChevronRightIcon, PlusIcon } from "@heroicons/react/24/outline";
// types // types
import { IIssue } from "types"; import { IIssue } from "types";
import type { NextPage, NextPageContext } from "next"; import type { NextPage, NextPageContext } from "next";
// fetch-keys // fetch-keys
import { import {
PROJECT_DETAILS,
PROJECT_ISSUES_LIST, PROJECT_ISSUES_LIST,
PROJECT_ISSUES_ACTIVITY, PROJECT_ISSUES_ACTIVITY,
ISSUE_DETAILS, ISSUE_DETAILS,
@ -52,8 +55,6 @@ const defaultValues = {
}; };
const IssueDetailsPage: NextPage = () => { const IssueDetailsPage: NextPage = () => {
const router = useRouter();
const { workspaceSlug, projectId, issueId } = router.query;
// states // states
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [isAddAsSubIssueOpen, setIsAddAsSubIssueOpen] = useState(false); const [isAddAsSubIssueOpen, setIsAddAsSubIssueOpen] = useState(false);
@ -61,15 +62,14 @@ const IssueDetailsPage: NextPage = () => {
(Partial<IIssue> & { actionType: "createIssue" | "edit" | "delete" }) | undefined (Partial<IIssue> & { actionType: "createIssue" | "edit" | "delete" }) | undefined
>(undefined); >(undefined);
const router = useRouter();
const { workspaceSlug, projectId, issueId } = router.query;
const { data: issueDetails, mutate: mutateIssueDetails } = useSWR<IIssue | undefined>( const { data: issueDetails, mutate: mutateIssueDetails } = useSWR<IIssue | undefined>(
issueId && workspaceSlug && projectId ? ISSUE_DETAILS(issueId as string) : null, workspaceSlug && projectId && issueId ? ISSUE_DETAILS(issueId as string) : null,
issueId && workspaceSlug && projectId workspaceSlug && projectId && issueId
? () => ? () =>
issuesService.retrieve( issuesService.retrieve(workspaceSlug as string, projectId as string, issueId as string)
workspaceSlug?.toString(),
projectId?.toString(),
issueId?.toString()
)
: null : null
); );
@ -81,13 +81,6 @@ const IssueDetailsPage: NextPage = () => {
: null : null
); );
const { data: activeProject } = useSWR(
workspaceSlug && projectId ? PROJECT_DETAILS(projectId as string) : null,
workspaceSlug && projectId
? () => projectService.getProject(workspaceSlug as string, projectId as string)
: null
);
const { data: issues } = useSWR( const { data: issues } = useSWR(
workspaceSlug && projectId workspaceSlug && projectId
? PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string) ? PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string)
@ -98,7 +91,7 @@ const IssueDetailsPage: NextPage = () => {
); );
const { data: issueActivities, mutate: mutateIssueActivities } = useSWR( const { data: issueActivities, mutate: mutateIssueActivities } = useSWR(
workspaceSlug && projectId && issueId ? PROJECT_ISSUES_ACTIVITY : null, workspaceSlug && projectId && issueId ? PROJECT_ISSUES_ACTIVITY(issueId as string) : null,
workspaceSlug && projectId && issueId workspaceSlug && projectId && issueId
? () => ? () =>
issuesService.getIssueActivities( issuesService.getIssueActivities(
@ -141,12 +134,22 @@ const IssueDetailsPage: NextPage = () => {
const submitChanges = useCallback( const submitChanges = useCallback(
(formData: Partial<IIssue>) => { (formData: Partial<IIssue>) => {
if (!workspaceSlug || !activeProject || !issueId) return; if (!workspaceSlug || !projectId || !issueId) return;
mutate(
ISSUE_DETAILS(issueId as string),
(prevData: IIssue) => ({
...prevData,
...formData,
}),
false
);
const payload = { ...formData }; const payload = { ...formData };
issuesService issuesService
.patchIssue(workspaceSlug as string, projectId as string, issueId as string, payload) .patchIssue(workspaceSlug as string, projectId as string, issueId as string, payload)
.then((res) => { .then((res) => {
console.log(res);
mutateIssueDetails(); mutateIssueDetails();
mutateIssueActivities(); mutateIssueActivities();
}) })
@ -154,40 +157,22 @@ const IssueDetailsPage: NextPage = () => {
console.error(e); console.error(e);
}); });
}, },
[activeProject, workspaceSlug, issueId, projectId, mutateIssueDetails, mutateIssueActivities] [workspaceSlug, issueId, projectId, mutateIssueDetails, mutateIssueActivities]
); );
const handleSubIssueRemove = (issueId: string) => { const handleSubIssueRemove = (issueId: string) => {
if (workspaceSlug && activeProject) { if (!workspaceSlug || !projectId) return;
issuesService
.patchIssue(workspaceSlug as string, activeProject.id, issueId, { parent: null })
.then((res) => {
mutate(SUB_ISSUES(issueDetails?.id ?? ""));
mutateIssueActivities();
})
.catch((e) => {
console.error(e);
});
}
};
/** issuesService
* Handling the debounce submit by updating the issue with name, description and description_html .patchIssue(workspaceSlug as string, projectId as string, issueId, { parent: null })
* @param values IssueDescriptionFormValues .then((res) => {
*/ mutate(SUB_ISSUES(issueDetails?.id ?? ""));
const handleDescriptionFormSubmit = useCallback( mutateIssueActivities();
(values: IssueDescriptionFormValues) => { })
if (workspaceSlug && projectId && issueId) { .catch((e) => {
issuesService console.error(e);
.updateIssue(workspaceSlug?.toString(), projectId.toString(), issueId.toString(), values) });
.then((res) => { };
console.log(res);
mutateIssueActivities();
});
}
},
[workspaceSlug, projectId, issueId, mutateIssueActivities]
);
return ( return (
<AppLayout <AppLayout
@ -196,11 +181,11 @@ const IssueDetailsPage: NextPage = () => {
breadcrumbs={ breadcrumbs={
<Breadcrumbs> <Breadcrumbs>
<Breadcrumbs.BreadcrumbItem <Breadcrumbs.BreadcrumbItem
title={`${activeProject?.name ?? "Project"} Issues`} title={`${issueDetails?.project_detail.name ?? "Project"} Issues`}
link={`/${workspaceSlug}/projects/${activeProject?.id}/issues`} link={`/${workspaceSlug}/projects/${projectId as string}/issues`}
/> />
<Breadcrumbs.BreadcrumbItem <Breadcrumbs.BreadcrumbItem
title={`Issue ${activeProject?.identifier ?? "Project"}-${ title={`Issue ${issueDetails?.project_detail.identifier ?? "Project"}-${
issueDetails?.sequence_id ?? "..." issueDetails?.sequence_id ?? "..."
} Details`} } Details`}
/> />
@ -249,14 +234,16 @@ const IssueDetailsPage: NextPage = () => {
parent={issueDetails} parent={issueDetails}
/> />
)} )}
{issueDetails && activeProject ? ( {issueDetails && projectId ? (
<div className="flex h-full"> <div className="flex h-full">
<div className="basis-2/3 space-y-5 divide-y-2 p-5"> <div className="basis-2/3 space-y-5 divide-y-2 p-5">
<div className="rounded-lg"> <div className="rounded-lg">
{issueDetails?.parent && issueDetails.parent !== "" ? ( {issueDetails?.parent && issueDetails.parent !== "" ? (
<div className="mb-5 flex w-min items-center gap-2 whitespace-nowrap rounded bg-gray-100 p-2 text-xs"> <div className="mb-5 flex w-min items-center gap-2 whitespace-nowrap rounded bg-gray-100 p-2 text-xs">
<Link <Link
href={`/${workspaceSlug}/projects/${activeProject.id}/issues/${issueDetails.parent}`} href={`/${workspaceSlug}/projects/${projectId as string}/issues/${
issueDetails.parent
}`}
> >
<a className="flex items-center gap-2"> <a className="flex items-center gap-2">
<span <span
@ -266,7 +253,7 @@ const IssueDetailsPage: NextPage = () => {
}} }}
/> />
<span className="flex-shrink-0 text-gray-600"> <span className="flex-shrink-0 text-gray-600">
{activeProject.identifier}- {issueDetails.project_detail.identifier}-
{issues?.results.find((i) => i.id === issueDetails.parent)?.sequence_id} {issues?.results.find((i) => i.id === issueDetails.parent)?.sequence_id}
</span> </span>
<span className="truncate font-medium"> <span className="truncate font-medium">
@ -282,10 +269,12 @@ const IssueDetailsPage: NextPage = () => {
siblingIssues.map((issue) => ( siblingIssues.map((issue) => (
<CustomMenu.MenuItem key={issue.id}> <CustomMenu.MenuItem key={issue.id}>
<Link <Link
href={`/${workspaceSlug}/projects/${activeProject.id}/issues/${issue.id}`} href={`/${workspaceSlug}/projects/${projectId as string}/issues/${
issue.id
}`}
> >
<a> <a>
{activeProject.identifier}-{issue.sequence_id} {issueDetails.project_detail.identifier}-{issue.sequence_id}
</a> </a>
</Link> </Link>
</CustomMenu.MenuItem> </CustomMenu.MenuItem>
@ -298,10 +287,7 @@ const IssueDetailsPage: NextPage = () => {
</CustomMenu> </CustomMenu>
</div> </div>
) : null} ) : null}
<IssueDescriptionForm <IssueDescriptionForm issue={issueDetails} handleFormSubmit={submitChanges} />
issue={issueDetails}
handleSubmit={handleDescriptionFormSubmit}
/>
<div className="mt-2"> <div className="mt-2">
{issueId && workspaceSlug && projectId && subIssues?.length > 0 ? ( {issueId && workspaceSlug && projectId && subIssues?.length > 0 ? (
<SubIssueList <SubIssueList
@ -358,7 +344,6 @@ const IssueDetailsPage: NextPage = () => {
</div> </div>
</div> </div>
<div className="basis-1/3 space-y-5 border-l p-5"> <div className="basis-1/3 space-y-5 border-l p-5">
{/* TODO add flex-grow, if needed */}
<IssueDetailSidebar <IssueDetailSidebar
control={control} control={control}
issueDetail={issueDetails} issueDetail={issueDetails}

View File

@ -4,7 +4,7 @@ import useSWR, { mutate } from "swr";
import { RectangleStackIcon } from "@heroicons/react/24/outline"; import { RectangleStackIcon } from "@heroicons/react/24/outline";
import { PlusIcon } from "@heroicons/react/20/solid"; import { PlusIcon } from "@heroicons/react/20/solid";
// lib // lib
import { requiredAuth } from "lib/auth"; import { requiredAdmin, requiredAuth } from "lib/auth";
// services // services
import issuesServices from "services/issues.service"; import issuesServices from "services/issues.service";
import projectService from "services/project.service"; import projectService from "services/project.service";
@ -22,12 +22,12 @@ import View from "components/core/view";
import { Spinner, EmptySpace, EmptySpaceItem, HeaderButton } from "components/ui"; import { Spinner, EmptySpace, EmptySpaceItem, HeaderButton } from "components/ui";
import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs"; import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs";
// types // types
import type { IIssue, IssueResponse } from "types"; import type { IIssue, IssueResponse, UserAuth } from "types";
import type { NextPage, NextPageContext } from "next"; import type { NextPage, NextPageContext } from "next";
// fetch-keys // fetch-keys
import { PROJECT_DETAILS, PROJECT_ISSUES_LIST } from "constants/fetch-keys"; import { PROJECT_DETAILS, PROJECT_ISSUES_LIST } from "constants/fetch-keys";
const ProjectIssues: NextPage = () => { const ProjectIssues: NextPage<UserAuth> = (props) => {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [selectedIssue, setSelectedIssue] = useState< const [selectedIssue, setSelectedIssue] = useState<
(IIssue & { actionType: "edit" | "delete" }) | undefined (IIssue & { actionType: "edit" | "delete" }) | undefined
@ -63,26 +63,6 @@ const ProjectIssues: NextPage = () => {
} }
}, [isOpen]); }, [isOpen]);
const partialUpdateIssue = (formData: Partial<IIssue>, issueId: string) => {
if (!workspaceSlug || !projectId) return;
issuesServices
.patchIssue(workspaceSlug as string, projectId as string, issueId, formData)
.then((response) => {
mutate<IssueResponse>(
PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string),
(prevData) => ({
...(prevData as IssueResponse),
results:
prevData?.results.map((issue) => (issue.id === response.id ? response : issue)) ?? [],
}),
false
);
})
.catch((error) => {
console.log(error);
});
};
const handleEditIssue = (issue: IIssue) => { const handleEditIssue = (issue: IIssue) => {
setIsOpen(true); setIsOpen(true);
setSelectedIssue({ ...issue, actionType: "edit" }); setSelectedIssue({ ...issue, actionType: "edit" });
@ -134,12 +114,12 @@ const ProjectIssues: NextPage = () => {
<ListView <ListView
issues={projectIssues?.results.filter((p) => p.parent === null) ?? []} issues={projectIssues?.results.filter((p) => p.parent === null) ?? []}
handleEditIssue={handleEditIssue} handleEditIssue={handleEditIssue}
partialUpdateIssue={partialUpdateIssue} userAuth={props}
/> />
<BoardView <BoardView
issues={projectIssues?.results.filter((p) => p.parent === null) ?? []} issues={projectIssues?.results.filter((p) => p.parent === null) ?? []}
handleDeleteIssue={setDeleteIssue} handleDeleteIssue={setDeleteIssue}
partialUpdateIssue={partialUpdateIssue} userAuth={props}
/> />
</> </>
) : ( ) : (
@ -153,8 +133,8 @@ const ProjectIssues: NextPage = () => {
title="Create a new issue" title="Create a new issue"
description={ description={
<span> <span>
Use <pre className="inline rounded bg-gray-100 px-2 py-1">Ctrl/Command + I</pre>{" "} Use <pre className="inline rounded bg-gray-100 px-2 py-1">C</pre> shortcut to
shortcut to create a new issue create a new issue
</span> </span>
} }
Icon={PlusIcon} Icon={PlusIcon}
@ -170,7 +150,6 @@ const ProjectIssues: NextPage = () => {
export const getServerSideProps = async (ctx: NextPageContext) => { export const getServerSideProps = async (ctx: NextPageContext) => {
const user = await requiredAuth(ctx.req?.headers.cookie); const user = await requiredAuth(ctx.req?.headers.cookie);
const redirectAfterSignIn = ctx.req?.url; const redirectAfterSignIn = ctx.req?.url;
if (!user) { if (!user) {
@ -182,9 +161,17 @@ export const getServerSideProps = async (ctx: NextPageContext) => {
}; };
} }
const projectId = ctx.query.projectId as string;
const workspaceSlug = ctx.query.workspaceSlug as string;
const memberDetail = await requiredAdmin(workspaceSlug, projectId, ctx.req?.headers.cookie);
return { return {
props: { props: {
user, isOwner: memberDetail?.role === 20,
isMember: memberDetail?.role === 15,
isViewer: memberDetail?.role === 10,
isGuest: memberDetail?.role === 5,
}, },
}; };
}; };

View File

@ -3,6 +3,9 @@ import React, { useState } from "react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import useSWR, { mutate } from "swr"; import useSWR, { mutate } from "swr";
// lib
import { requiredAdmin, requiredAuth } from "lib/auth";
// services // services
import modulesService from "services/modules.service"; import modulesService from "services/modules.service";
import projectService from "services/project.service"; import projectService from "services/project.service";
@ -32,7 +35,15 @@ import {
RectangleStackIcon, RectangleStackIcon,
} from "@heroicons/react/24/outline"; } from "@heroicons/react/24/outline";
// types // types
import { IIssue, IModule, ModuleIssueResponse, SelectIssue, SelectModuleType } from "types"; import {
IIssue,
IModule,
ModuleIssueResponse,
SelectIssue,
SelectModuleType,
UserAuth,
} from "types";
import { NextPageContext } from "next";
// fetch-keys // fetch-keys
import { import {
MODULE_DETAIL, MODULE_DETAIL,
@ -42,7 +53,7 @@ import {
PROJECT_MEMBERS, PROJECT_MEMBERS,
} from "constants/fetch-keys"; } from "constants/fetch-keys";
const SingleModule = () => { const SingleModule: React.FC<UserAuth> = (props) => {
const [moduleSidebar, setModuleSidebar] = useState(true); const [moduleSidebar, setModuleSidebar] = useState(true);
const [moduleDeleteModal, setModuleDeleteModal] = useState(false); const [moduleDeleteModal, setModuleDeleteModal] = useState(false);
const [selectedIssues, setSelectedIssues] = useState<SelectIssue>(null); const [selectedIssues, setSelectedIssues] = useState<SelectIssue>(null);
@ -128,18 +139,6 @@ const SingleModule = () => {
} }
}; };
const partialUpdateIssue = (formData: Partial<IIssue>, issueId: string) => {
if (!workspaceSlug || !projectId) return;
issuesService
.patchIssue(workspaceSlug as string, projectId as string, issueId, formData)
.then(() => {
mutate(MODULE_ISSUES(moduleId as string));
})
.catch((error) => {
console.log(error);
});
};
const openCreateIssueModal = ( const openCreateIssueModal = (
issue?: IIssue, issue?: IIssue,
actionType: "create" | "edit" | "delete" = "create" actionType: "create" | "edit" | "delete" = "create"
@ -279,16 +278,16 @@ const SingleModule = () => {
openIssuesListModal={openIssuesListModal} openIssuesListModal={openIssuesListModal}
removeIssueFromModule={removeIssueFromModule} removeIssueFromModule={removeIssueFromModule}
setPreloadedData={setPreloadedData} setPreloadedData={setPreloadedData}
userAuth={props}
/> />
<ModulesBoardView <ModulesBoardView
issues={moduleIssuesArray ?? []} issues={moduleIssuesArray ?? []}
removeIssueFromModule={removeIssueFromModule}
members={members} members={members}
openCreateIssueModal={openCreateIssueModal} openCreateIssueModal={openCreateIssueModal}
openIssuesListModal={openIssuesListModal} openIssuesListModal={openIssuesListModal}
handleDeleteIssue={setDeleteIssue} handleDeleteIssue={setDeleteIssue}
partialUpdateIssue={partialUpdateIssue}
setPreloadedData={setPreloadedData} setPreloadedData={setPreloadedData}
userAuth={props}
/> />
</div> </div>
) : ( ) : (
@ -333,4 +332,32 @@ const SingleModule = () => {
); );
}; };
export const getServerSideProps = async (ctx: NextPageContext) => {
const user = await requiredAuth(ctx.req?.headers.cookie);
const redirectAfterSignIn = ctx.req?.url;
if (!user) {
return {
redirect: {
destination: `/signin?next=${redirectAfterSignIn}`,
permanent: false,
},
};
}
const projectId = ctx.query.projectId as string;
const workspaceSlug = ctx.query.workspaceSlug as string;
const memberDetail = await requiredAdmin(workspaceSlug, projectId, ctx.req?.headers.cookie);
return {
props: {
isOwner: memberDetail?.role === 20,
isMember: memberDetail?.role === 15,
isViewer: memberDetail?.role === 10,
isGuest: memberDetail?.role === 5,
},
};
};
export default SingleModule; export default SingleModule;

View File

@ -86,8 +86,8 @@ const ProjectModules: NextPage = () => {
title="Create a new module" title="Create a new module"
description={ description={
<span> <span>
Use <pre className="inline rounded bg-gray-100 px-2 py-1">Ctrl/Command + M</pre>{" "} Use <pre className="inline rounded bg-gray-100 px-2 py-1">M</pre> shortcut to
shortcut to create a new module create a new module
</span> </span>
} }
Icon={PlusIcon} Icon={PlusIcon}

View File

@ -6,7 +6,6 @@ import Image from "next/image";
import useSWR, { mutate } from "swr"; import useSWR, { mutate } from "swr";
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
import type { NextPageContext, NextPage } from "next";
// lib // lib
import { requiredAdmin } from "lib/auth"; import { requiredAdmin } from "lib/auth";
// layouts // layouts
@ -21,6 +20,7 @@ import { Button, CustomSelect, Loader } from "components/ui";
import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs"; import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs";
// types // types
import { IProject, IWorkspace } from "types"; import { IProject, IWorkspace } from "types";
import type { NextPageContext, NextPage } from "next";
// fetch-keys // fetch-keys
import { PROJECTS_LIST, PROJECT_DETAILS, WORKSPACE_MEMBERS } from "constants/fetch-keys"; import { PROJECTS_LIST, PROJECT_DETAILS, WORKSPACE_MEMBERS } from "constants/fetch-keys";
@ -88,24 +88,9 @@ const ControlSettings: NextPage<TControlSettingsProps> = (props) => {
await projectService await projectService
.updateProject(workspaceSlug as string, projectId as string, payload) .updateProject(workspaceSlug as string, projectId as string, payload)
.then((res) => { .then((res) => {
mutate<IProject>( mutate(PROJECT_DETAILS(projectId as string));
PROJECT_DETAILS(projectId as string), mutate(PROJECTS_LIST(workspaceSlug as string));
(prevData) => ({ ...prevData, ...res }),
false
);
mutate<IProject[]>(
PROJECTS_LIST(workspaceSlug as string),
(prevData) => {
const newData = prevData?.map((item) => {
if (item.id === res.id) {
return res;
}
return item;
});
return newData;
},
false
);
setToastAlert({ setToastAlert({
title: "Success", title: "Success",
type: "success", type: "success",

View File

@ -4,8 +4,6 @@ import { useRouter } from "next/router";
import useSWR from "swr"; import useSWR from "swr";
import { PencilSquareIcon, PlusIcon, TrashIcon } from "@heroicons/react/24/outline"; import { PencilSquareIcon, PlusIcon, TrashIcon } from "@heroicons/react/24/outline";
// types
import type { NextPage, NextPageContext } from "next";
import { IState } from "types"; import { IState } from "types";
// services // services
import stateService from "services/state.service"; import stateService from "services/state.service";
@ -26,6 +24,8 @@ import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs";
// helpers // helpers
import { addSpaceIfCamelCase } from "helpers/string.helper"; import { addSpaceIfCamelCase } from "helpers/string.helper";
import { groupBy } from "helpers/array.helper"; import { groupBy } from "helpers/array.helper";
// types
import type { NextPage, NextPageContext } from "next";
// fetch-keys // fetch-keys
import { PROJECT_DETAILS, STATE_LIST } from "constants/fetch-keys"; import { PROJECT_DETAILS, STATE_LIST } from "constants/fetch-keys";
@ -106,6 +106,18 @@ const StatesSettings: NextPage<TStateSettingsProps> = (props) => {
</button> </button>
</div> </div>
<div className="space-y-1 rounded-xl border p-1 md:w-2/3"> <div className="space-y-1 rounded-xl border p-1 md:w-2/3">
{key === activeGroup && (
<CreateUpdateStateInline
projectId={activeProject.id}
onClose={() => {
setActiveGroup(null);
setSelectedState(null);
}}
workspaceSlug={workspaceSlug as string}
data={null}
selectedGroup={key as keyof StateGroup}
/>
)}
{groupedStates[key]?.map((state) => {groupedStates[key]?.map((state) =>
state.id !== selectedState ? ( state.id !== selectedState ? (
<div <div
@ -147,18 +159,6 @@ const StatesSettings: NextPage<TStateSettingsProps> = (props) => {
</div> </div>
) )
)} )}
{key === activeGroup && (
<CreateUpdateStateInline
projectId={activeProject.id}
onClose={() => {
setActiveGroup(null);
setSelectedState(null);
}}
workspaceSlug={workspaceSlug as string}
data={null}
selectedGroup={key as keyof StateGroup}
/>
)}
</div> </div>
</div> </div>
)) ))

View File

@ -88,9 +88,8 @@ const ProjectsPage: NextPage = () => {
title="Create a new project" title="Create a new project"
description={ description={
<span> <span>
Use{" "} Use <pre className="inline rounded bg-gray-100 px-2 py-1">P</pre> shortcut to
<pre className="inline rounded bg-gray-100 px-2 py-1">Ctrl/Command + P</pre>{" "} create a new project
shortcut to create a new project
</span> </span>
} }
Icon={PlusIcon} Icon={PlusIcon}

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