mirror of
https://github.com/makeplane/plane
synced 2024-06-14 14:31:34 +00:00
commit
09292025df
@ -49,7 +49,7 @@ USER root
|
||||
RUN apk --update --no-cache add "bash~=5.1"
|
||||
COPY ./bin ./bin/
|
||||
|
||||
RUN chmod +x ./bin/channel-worker ./bin/takeoff ./bin/worker
|
||||
RUN chmod +x ./bin/takeoff ./bin/worker
|
||||
|
||||
USER captain
|
||||
|
||||
|
@ -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 -
|
||||
worker: python manage.py rqworker
|
||||
channel-worker: python manage.py runworker issue-activites
|
||||
worker: python manage.py rqworker
|
@ -1,6 +0,0 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
python manage.py wait_for_db
|
||||
python manage.py migrate
|
||||
python manage.py runworker issue-activites
|
@ -1 +0,0 @@
|
||||
from .issue_consumer import IssueConsumer
|
@ -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)
|
@ -38,4 +38,6 @@ from .issue import (
|
||||
IssueStateSerializer,
|
||||
)
|
||||
|
||||
from .module import ModuleWriteSerializer, ModuleSerializer, ModuleIssueSerializer
|
||||
from .module import ModuleWriteSerializer, ModuleSerializer, ModuleIssueSerializer
|
||||
|
||||
from .api_token import APITokenSerializer
|
8
apiserver/plane/api/serializers/api_token.py
Normal file
8
apiserver/plane/api/serializers/api_token.py
Normal file
@ -0,0 +1,8 @@
|
||||
from .base import BaseSerializer
|
||||
from plane.db.models import APIToken
|
||||
|
||||
|
||||
class APITokenSerializer(BaseSerializer):
|
||||
class Meta:
|
||||
model = APIToken
|
||||
fields = "__all__"
|
@ -93,7 +93,7 @@ class ModuleWriteSerializer(BaseSerializer):
|
||||
links = validated_data.pop("links_list", None)
|
||||
|
||||
if members is not None:
|
||||
ModuleIssue.objects.filter(module=instance).delete()
|
||||
ModuleMember.objects.filter(module=instance).delete()
|
||||
ModuleMember.objects.bulk_create(
|
||||
[
|
||||
ModuleMember(
|
||||
|
@ -84,6 +84,9 @@ from plane.api.views import (
|
||||
ModuleViewSet,
|
||||
ModuleIssueViewSet,
|
||||
## End Modules
|
||||
# Api Tokens
|
||||
ApiTokenEndpoint,
|
||||
## End Api Tokens
|
||||
)
|
||||
|
||||
|
||||
@ -679,4 +682,8 @@ urlpatterns = [
|
||||
name="project-module-issues",
|
||||
),
|
||||
## 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
|
||||
]
|
||||
|
@ -72,3 +72,5 @@ from .authentication import (
|
||||
)
|
||||
|
||||
from .module import ModuleViewSet, ModuleIssueViewSet
|
||||
|
||||
from .api_token import ApiTokenEndpoint
|
62
apiserver/plane/api/views/api_token.py
Normal file
62
apiserver/plane/api/views/api_token.py
Normal 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,
|
||||
)
|
@ -10,8 +10,6 @@ from django.core.serializers.json import DjangoJSONEncoder
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
from sentry_sdk import capture_exception
|
||||
from channels.layers import get_channel_layer
|
||||
from asgiref.sync import async_to_sync
|
||||
|
||||
# Module imports
|
||||
from . import BaseViewSet, BaseAPIView
|
||||
@ -42,6 +40,7 @@ from plane.db.models import (
|
||||
CycleIssue,
|
||||
ModuleIssue,
|
||||
)
|
||||
from plane.bgtasks.issue_activites_task import issue_activity
|
||||
|
||||
|
||||
class IssueViewSet(BaseViewSet):
|
||||
@ -72,12 +71,12 @@ class IssueViewSet(BaseViewSet):
|
||||
|
||||
def perform_update(self, serializer):
|
||||
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:
|
||||
|
||||
channel_layer = get_channel_layer()
|
||||
async_to_sync(channel_layer.send)(
|
||||
"issue-activites",
|
||||
issue_activity.delay(
|
||||
{
|
||||
"type": "issue.activity",
|
||||
"requested_data": requested_data,
|
||||
|
@ -1,6 +1,6 @@
|
||||
import os
|
||||
|
||||
from channels.routing import ProtocolTypeRouter, ChannelNameRouter
|
||||
from channels.routing import ProtocolTypeRouter
|
||||
from django.core.asgi import 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
|
||||
# is populated before importing code that may import ORM models.
|
||||
|
||||
from plane.api.consumers import IssueConsumer
|
||||
|
||||
application = ProtocolTypeRouter(
|
||||
{
|
||||
"http": get_asgi_application(),
|
||||
"channel": ChannelNameRouter(
|
||||
{
|
||||
"issue-activites": IssueConsumer.as_asgi(),
|
||||
}
|
||||
),
|
||||
}
|
||||
)
|
||||
|
553
apiserver/plane/bgtasks/issue_activites_task.py
Normal file
553
apiserver/plane/bgtasks/issue_activites_task.py
Normal 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
|
@ -1,52 +1,5 @@
|
||||
from django.apps import AppConfig
|
||||
from fieldsignals import post_save_changed
|
||||
|
||||
|
||||
class DbConfig(AppConfig):
|
||||
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,
|
||||
# )
|
||||
|
57
apiserver/plane/db/migrations/0018_auto_20230130_0119.py
Normal file
57
apiserver/plane/db/migrations/0018_auto_20230130_0119.py
Normal 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',),
|
||||
},
|
||||
),
|
||||
]
|
23
apiserver/plane/db/migrations/0019_auto_20230131_0049.py
Normal file
23
apiserver/plane/db/migrations/0019_auto_20230131_0049.py
Normal 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'),
|
||||
),
|
||||
]
|
@ -38,3 +38,5 @@ from .shortcut import Shortcut
|
||||
from .view import View
|
||||
|
||||
from .module import Module, ModuleMember, ModuleIssue, ModuleLink
|
||||
|
||||
from .api_token import APIToken
|
39
apiserver/plane/db/models/api_token.py
Normal file
39
apiserver/plane/db/models/api_token.py
Normal 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)
|
@ -145,12 +145,8 @@ class IssueActivity(ProjectBaseModel):
|
||||
field = models.CharField(
|
||||
max_length=255, verbose_name="Field Name", blank=True, null=True
|
||||
)
|
||||
old_value = models.CharField(
|
||||
max_length=255, verbose_name="Old Value", blank=True, null=True
|
||||
)
|
||||
new_value = models.CharField(
|
||||
max_length=255, verbose_name="New Value", blank=True, null=True
|
||||
)
|
||||
old_value = models.TextField(verbose_name="Old Value", blank=True, null=True)
|
||||
new_value = models.TextField(verbose_name="New Value", blank=True, null=True)
|
||||
|
||||
comment = models.TextField(verbose_name="Comment", blank=True)
|
||||
attachments = ArrayField(models.URLField(), size=10, blank=True, default=list)
|
||||
|
@ -68,6 +68,7 @@ class User(AbstractBaseUser, PermissionsMixin):
|
||||
last_workspace_id = models.UUIDField(null=True)
|
||||
my_issues_prop = models.JSONField(null=True)
|
||||
role = models.CharField(max_length=300, null=True, blank=True)
|
||||
is_bot = models.BooleanField(default=False)
|
||||
|
||||
USERNAME_FIELD = "email"
|
||||
|
||||
@ -101,7 +102,7 @@ class User(AbstractBaseUser, PermissionsMixin):
|
||||
@receiver(post_save, sender=User)
|
||||
def send_welcome_email(sender, instance, created, **kwargs):
|
||||
try:
|
||||
if created:
|
||||
if created and not instance.is_bot:
|
||||
first_name = instance.first_name.capitalize()
|
||||
to_email = instance.email
|
||||
from_email_string = f"Team Plane <team@mailer.plane.so>"
|
||||
|
@ -34,9 +34,7 @@ INSTALLED_APPS = [
|
||||
"rest_framework_simplejwt.token_blacklist",
|
||||
"corsheaders",
|
||||
"taggit",
|
||||
"fieldsignals",
|
||||
"django_rq",
|
||||
"channels",
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
|
@ -66,11 +66,3 @@ RQ_QUEUES = {
|
||||
|
||||
WEB_URL = "http://localhost:3000"
|
||||
|
||||
CHANNEL_LAYERS = {
|
||||
"default": {
|
||||
"BACKEND": "channels_redis.core.RedisChannelLayer",
|
||||
"CONFIG": {
|
||||
"hosts": [(REDIS_HOST, REDIS_PORT)],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
@ -1,11 +1,8 @@
|
||||
"""Production settings and globals."""
|
||||
import ssl
|
||||
from typing import Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import dj_database_url
|
||||
from urllib.parse import urlparse
|
||||
from redis.asyncio.connection import Connection, RedisSSLContext
|
||||
|
||||
import sentry_sdk
|
||||
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"))
|
||||
|
||||
DOCKERIZED = os.environ.get("DOCKERIZED", False) # Set the variable true if running in docker-compose environment
|
||||
|
||||
if not DOCKERIZED:
|
||||
|
||||
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"))],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
DOCKERIZED = os.environ.get(
|
||||
"DOCKERIZED", False
|
||||
) # Set the variable true if running in docker-compose environment
|
||||
|
||||
WEB_URL = os.environ.get("WEB_URL")
|
||||
|
@ -1,11 +1,8 @@
|
||||
"""Production settings and globals."""
|
||||
import ssl
|
||||
from typing import Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import dj_database_url
|
||||
from urllib.parse import urlparse
|
||||
from redis.asyncio.connection import Connection, RedisSSLContext
|
||||
|
||||
import sentry_sdk
|
||||
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")
|
||||
|
@ -20,12 +20,9 @@ sentry-sdk==1.13.0
|
||||
django-s3-storage==0.13.6
|
||||
django-crum==0.7.9
|
||||
django-guardian==2.4.0
|
||||
django-fieldsignals==0.7.0
|
||||
dj_rest_auth==2.2.5
|
||||
google-auth==2.9.1
|
||||
google-api-python-client==2.55.0
|
||||
django-rq==2.5.1
|
||||
django-redis==5.2.0
|
||||
channels==4.0.0
|
||||
channels-redis==4.0.0
|
||||
uvicorn==0.20.0
|
@ -5,6 +5,7 @@ import { CheckCircleIcon } from "@heroicons/react/20/solid";
|
||||
import { Button, Input } from "components/ui";
|
||||
// services
|
||||
import authenticationService from "services/authentication.service";
|
||||
import useToast from "hooks/use-toast";
|
||||
// icons
|
||||
|
||||
// types
|
||||
@ -16,6 +17,7 @@ type EmailCodeFormValues = {
|
||||
|
||||
export const EmailCodeForm = ({ onSuccess }: any) => {
|
||||
const [codeSent, setCodeSent] = useState(false);
|
||||
const { setToastAlert } = useToast();
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
@ -53,6 +55,11 @@ export const EmailCodeForm = ({ onSuccess }: any) => {
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log(error);
|
||||
setToastAlert({
|
||||
title: "Oops!",
|
||||
type: "error",
|
||||
message: "Enter the correct code to sign in",
|
||||
});
|
||||
setError("token" as keyof EmailCodeFormValues, {
|
||||
type: "manual",
|
||||
message: error.error,
|
||||
|
@ -6,6 +6,7 @@ import { useForm } from "react-hook-form";
|
||||
// ui
|
||||
import { Button, Input } from "components/ui";
|
||||
import authenticationService from "services/authentication.service";
|
||||
import useToast from "hooks/use-toast";
|
||||
|
||||
// types
|
||||
type EmailPasswordFormValues = {
|
||||
@ -15,6 +16,7 @@ type EmailPasswordFormValues = {
|
||||
};
|
||||
|
||||
export const EmailPasswordForm = ({ onSuccess }: any) => {
|
||||
const { setToastAlert } = useToast();
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
@ -38,6 +40,11 @@ export const EmailPasswordForm = ({ onSuccess }: any) => {
|
||||
})
|
||||
.catch((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;
|
||||
Object.keys(error.response.data).forEach((key) => {
|
||||
const err = error.response.data[key];
|
||||
|
@ -97,49 +97,56 @@ const CommandPalette: React.FC = () => {
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "/") {
|
||||
e.preventDefault();
|
||||
setIsPaletteOpen(true);
|
||||
} else if ((e.ctrlKey || e.metaKey) && e.key === "i") {
|
||||
e.preventDefault();
|
||||
setIsIssueModalOpen(true);
|
||||
} else if ((e.ctrlKey || e.metaKey) && e.key === "p") {
|
||||
e.preventDefault();
|
||||
setIsProjectModalOpen(true);
|
||||
} else if ((e.ctrlKey || e.metaKey) && e.key === "b") {
|
||||
e.preventDefault();
|
||||
toggleCollapsed();
|
||||
} else if ((e.ctrlKey || e.metaKey) && e.key === "h") {
|
||||
e.preventDefault();
|
||||
setIsShortcutsModalOpen(true);
|
||||
} else if ((e.ctrlKey || e.metaKey) && e.key === "q") {
|
||||
e.preventDefault();
|
||||
setIsCreateCycleModalOpen(true);
|
||||
} else if ((e.ctrlKey || e.metaKey) && e.key === "m") {
|
||||
e.preventDefault();
|
||||
setIsCreateModuleModalOpen(true);
|
||||
} else if ((e.ctrlKey || e.metaKey) && e.key === "d") {
|
||||
e.preventDefault();
|
||||
setIsBulkDeleteIssuesModalOpen(true);
|
||||
} else if ((e.ctrlKey || e.metaKey) && e.altKey && e.key === "c") {
|
||||
e.preventDefault();
|
||||
if (
|
||||
!(e.target instanceof HTMLTextAreaElement) &&
|
||||
!(e.target instanceof HTMLInputElement) &&
|
||||
!(e.target as Element).classList?.contains("remirror-editor")
|
||||
) {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "k") {
|
||||
e.preventDefault();
|
||||
setIsPaletteOpen(true);
|
||||
} else if (e.ctrlKey && e.key === "c") {
|
||||
console.log("Text copied");
|
||||
} else if (e.key === "c") {
|
||||
e.preventDefault();
|
||||
setIsIssueModalOpen(true);
|
||||
} else if (e.key === "p") {
|
||||
e.preventDefault();
|
||||
setIsProjectModalOpen(true);
|
||||
} else if ((e.ctrlKey || e.metaKey) && e.key === "b") {
|
||||
e.preventDefault();
|
||||
toggleCollapsed();
|
||||
} else if (e.key === "h") {
|
||||
e.preventDefault();
|
||||
setIsShortcutsModalOpen(true);
|
||||
} else if (e.key === "q") {
|
||||
e.preventDefault();
|
||||
setIsCreateCycleModalOpen(true);
|
||||
} 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)
|
||||
.then(() => {
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
title: "Copied to clipboard",
|
||||
const url = new URL(window.location.href);
|
||||
copyTextToClipboard(url.href)
|
||||
.then(() => {
|
||||
setToastAlert({
|
||||
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]
|
||||
|
@ -15,7 +15,7 @@ const shortcuts = [
|
||||
{
|
||||
title: "Navigation",
|
||||
shortcuts: [
|
||||
{ keys: "ctrl,/", description: "To open navigator" },
|
||||
{ keys: "ctrl,cmd,k", description: "To open navigator" },
|
||||
{ keys: "↑", description: "Move up" },
|
||||
{ keys: "↓", description: "Move down" },
|
||||
{ keys: "←", description: "Move left" },
|
||||
@ -27,14 +27,14 @@ const shortcuts = [
|
||||
{
|
||||
title: "Common",
|
||||
shortcuts: [
|
||||
{ keys: "ctrl,p", description: "To create project" },
|
||||
{ keys: "ctrl,i", description: "To create issue" },
|
||||
{ keys: "ctrl,q", description: "To create cycle" },
|
||||
{ keys: "ctrl,m", description: "To create module" },
|
||||
{ keys: "ctrl,d", description: "To bulk delete issues" },
|
||||
{ keys: "ctrl,h", description: "To open shortcuts guide" },
|
||||
{ keys: "p", description: "To create project" },
|
||||
{ keys: "c", description: "To create issue" },
|
||||
{ keys: "q", description: "To create cycle" },
|
||||
{ keys: "m", description: "To create module" },
|
||||
{ keys: "Delete", description: "To bulk delete issues" },
|
||||
{ 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.",
|
||||
},
|
||||
],
|
||||
|
@ -1,64 +1,68 @@
|
||||
import React from "react";
|
||||
// next
|
||||
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/router";
|
||||
// swr
|
||||
import useSWR from "swr";
|
||||
|
||||
import useSWR, { mutate } from "swr";
|
||||
|
||||
// react-beautiful-dnd
|
||||
import { DraggableStateSnapshot } from "react-beautiful-dnd";
|
||||
// react-datepicker
|
||||
import DatePicker from "react-datepicker";
|
||||
import "react-datepicker/dist/react-datepicker.css";
|
||||
// headless ui
|
||||
import { Listbox, Transition } from "@headlessui/react";
|
||||
// constants
|
||||
import { TrashIcon } from "@heroicons/react/24/outline";
|
||||
import { CalendarDaysIcon } from "@heroicons/react/20/solid";
|
||||
// services
|
||||
import issuesService from "services/issues.service";
|
||||
import stateService from "services/state.service";
|
||||
import projectService from "services/project.service";
|
||||
// components
|
||||
import { AssigneesList } from "components/ui/avatar";
|
||||
import { AssigneesList, CustomDatePicker } from "components/ui";
|
||||
// helpers
|
||||
import { renderShortNumericDateFormat, findHowManyDaysLeft } from "helpers/date-time.helper";
|
||||
import { addSpaceIfCamelCase } from "helpers/string.helper";
|
||||
// types
|
||||
import { IIssue, IssueResponse, IUserLite, IWorkspaceMember, Properties } from "types";
|
||||
import { IIssue, IUserLite, IWorkspaceMember, Properties, UserAuth } from "types";
|
||||
// common
|
||||
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";
|
||||
|
||||
type Props = {
|
||||
type?: string;
|
||||
typeId?: string;
|
||||
issue: IIssue;
|
||||
properties: Properties;
|
||||
snapshot?: DraggableStateSnapshot;
|
||||
assignees: Partial<IUserLite>[] | (Partial<IUserLite> | undefined)[];
|
||||
people: IWorkspaceMember[] | undefined;
|
||||
handleDeleteIssue?: React.Dispatch<React.SetStateAction<string | undefined>>;
|
||||
partialUpdateIssue: any;
|
||||
userAuth: UserAuth;
|
||||
};
|
||||
|
||||
const SingleBoardIssue: React.FC<Props> = ({
|
||||
type,
|
||||
typeId,
|
||||
issue,
|
||||
properties,
|
||||
snapshot,
|
||||
assignees,
|
||||
people,
|
||||
handleDeleteIssue,
|
||||
partialUpdateIssue,
|
||||
userAuth,
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
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(
|
||||
workspaceSlug && projectId ? STATE_LIST(projectId as string) : null,
|
||||
workspaceSlug && projectId
|
||||
@ -73,7 +77,25 @@ const SingleBoardIssue: React.FC<Props> = ({
|
||||
: 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 (
|
||||
<div
|
||||
@ -82,7 +104,7 @@ const SingleBoardIssue: React.FC<Props> = ({
|
||||
}`}
|
||||
>
|
||||
<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">
|
||||
<button
|
||||
type="button"
|
||||
@ -114,15 +136,18 @@ const SingleBoardIssue: React.FC<Props> = ({
|
||||
as="div"
|
||||
value={issue.priority}
|
||||
onChange={(data: string) => {
|
||||
partialUpdateIssue({ priority: data }, issue.id);
|
||||
partialUpdateIssue({ priority: data });
|
||||
}}
|
||||
className="group relative flex-shrink-0"
|
||||
disabled={isNotAllowed}
|
||||
>
|
||||
{({ open }) => (
|
||||
<>
|
||||
<div>
|
||||
<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"
|
||||
? "bg-red-100 text-red-600"
|
||||
: issue.priority === "high"
|
||||
@ -171,14 +196,19 @@ const SingleBoardIssue: React.FC<Props> = ({
|
||||
as="div"
|
||||
value={issue.state}
|
||||
onChange={(data: string) => {
|
||||
partialUpdateIssue({ state: data }, issue.id);
|
||||
partialUpdateIssue({ state: data });
|
||||
}}
|
||||
className="group relative flex-shrink-0"
|
||||
disabled={isNotAllowed}
|
||||
>
|
||||
{({ open }) => (
|
||||
<>
|
||||
<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
|
||||
className="h-1.5 w-1.5 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
@ -218,17 +248,18 @@ const SingleBoardIssue: React.FC<Props> = ({
|
||||
</Listbox.Options>
|
||||
</Transition>
|
||||
</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>
|
||||
)}
|
||||
{/* {properties.cycle && !typeId && (
|
||||
<div className="flex flex-shrink-0 items-center gap-1 rounded border px-2 py-1 text-xs shadow-sm duration-300 hover:bg-gray-100 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500">
|
||||
{issue.issue_cycle ? issue.issue_cycle.cycle_detail.name : "None"}
|
||||
</div>
|
||||
)} */}
|
||||
{properties.due_date && (
|
||||
<div
|
||||
className={`group 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 < new Date().toISOString()
|
||||
@ -236,13 +267,42 @@ const SingleBoardIssue: React.FC<Props> = ({
|
||||
: findHowManyDaysLeft(issue.target_date) <= 3 && "text-orange-400"
|
||||
}`}
|
||||
>
|
||||
<CalendarDaysIcon className="h-4 w-4" />
|
||||
{issue.target_date ? renderShortNumericDateFormat(issue.target_date) : "N/A"}
|
||||
<CustomDatePicker
|
||||
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>
|
||||
)}
|
||||
{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">
|
||||
{totalChildren} {totalChildren === 1 ? "sub-issue" : "sub-issues"}
|
||||
{issue.sub_issues_count} {issue.sub_issues_count === 1 ? "sub-issue" : "sub-issues"}
|
||||
</div>
|
||||
)}
|
||||
{properties.assignee && (
|
||||
@ -255,81 +315,82 @@ const SingleBoardIssue: React.FC<Props> = ({
|
||||
if (newData.includes(data)) newData.splice(newData.indexOf(data), 1);
|
||||
else newData.push(data);
|
||||
|
||||
partialUpdateIssue({ assignees_list: newData }, issue.id);
|
||||
partialUpdateIssue({ assignees_list: newData });
|
||||
}}
|
||||
className="group relative flex-shrink-0"
|
||||
disabled={isNotAllowed}
|
||||
>
|
||||
{({ open }) => (
|
||||
<>
|
||||
<div>
|
||||
<Listbox.Button>
|
||||
<div className="flex cursor-pointer items-center gap-1 text-xs">
|
||||
<AssigneesList users={assignees} length={3} />
|
||||
</div>
|
||||
</Listbox.Button>
|
||||
|
||||
<Transition
|
||||
show={open}
|
||||
as={React.Fragment}
|
||||
leave="transition ease-in duration-100"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
<div>
|
||||
<Listbox.Button>
|
||||
<div
|
||||
className={`flex ${
|
||||
isNotAllowed ? "cursor-not-allowed" : "cursor-pointer"
|
||||
} items-center gap-1 text-xs`}
|
||||
>
|
||||
<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}
|
||||
<AssigneesList users={assignees} length={3} />
|
||||
</div>
|
||||
</Listbox.Button>
|
||||
|
||||
<Transition
|
||||
show={open}
|
||||
as={React.Fragment}
|
||||
leave="transition ease-in duration-100"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<Listbox.Options className="absolute left-0 z-20 mt-1 max-h-28 overflow-auto rounded-md bg-white py-1 text-xs shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
|
||||
{people?.map((person) => (
|
||||
<Listbox.Option
|
||||
key={person.id}
|
||||
className={({ active }) =>
|
||||
`cursor-pointer select-none p-2 ${active ? "bg-indigo-50" : "bg-white"}`
|
||||
}
|
||||
value={person.member.id}
|
||||
>
|
||||
<div
|
||||
className={`flex items-center gap-x-1 ${
|
||||
assignees.includes({
|
||||
id: person.member.last_name,
|
||||
first_name: person.member.first_name,
|
||||
last_name: person.member.last_name,
|
||||
email: person.member.email,
|
||||
avatar: person.member.avatar,
|
||||
})
|
||||
? "font-medium"
|
||||
: "font-normal"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`flex items-center gap-x-1 ${
|
||||
assignees.includes({
|
||||
id: person.member.last_name,
|
||||
first_name: person.member.first_name,
|
||||
last_name: person.member.last_name,
|
||||
email: person.member.email,
|
||||
avatar: person.member.avatar,
|
||||
})
|
||||
? "font-medium"
|
||||
: "font-normal"
|
||||
}`}
|
||||
>
|
||||
{person.member.avatar && person.member.avatar !== "" ? (
|
||||
<div className="relative h-4 w-4">
|
||||
<Image
|
||||
src={person.member.avatar}
|
||||
alt="avatar"
|
||||
className="rounded-full"
|
||||
layout="fill"
|
||||
objectFit="cover"
|
||||
priority={false}
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid h-4 w-4 place-items-center rounded-full bg-gray-700 capitalize text-white">
|
||||
{person.member.first_name && person.member.first_name !== ""
|
||||
? person.member.first_name.charAt(0)
|
||||
: person.member.email.charAt(0)}
|
||||
</div>
|
||||
)}
|
||||
<p>
|
||||
{person.member.avatar && person.member.avatar !== "" ? (
|
||||
<div className="relative h-4 w-4">
|
||||
<Image
|
||||
src={person.member.avatar}
|
||||
alt="avatar"
|
||||
className="rounded-full"
|
||||
layout="fill"
|
||||
objectFit="cover"
|
||||
priority={false}
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid h-4 w-4 place-items-center rounded-full bg-gray-700 capitalize text-white">
|
||||
{person.member.first_name && person.member.first_name !== ""
|
||||
? person.member.first_name
|
||||
: person.member.email}
|
||||
</p>
|
||||
</div>
|
||||
</Listbox.Option>
|
||||
))}
|
||||
</Listbox.Options>
|
||||
</Transition>
|
||||
</div>
|
||||
</>
|
||||
? person.member.first_name.charAt(0)
|
||||
: person.member.email.charAt(0)}
|
||||
</div>
|
||||
)}
|
||||
<p>
|
||||
{person.member.first_name && person.member.first_name !== ""
|
||||
? person.member.first_name
|
||||
: person.member.email}
|
||||
</p>
|
||||
</div>
|
||||
</Listbox.Option>
|
||||
))}
|
||||
</Listbox.Options>
|
||||
</Transition>
|
||||
</div>
|
||||
)}
|
||||
</Listbox>
|
||||
)}
|
||||
|
@ -214,10 +214,7 @@ const BulkDeleteIssuesModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
|
||||
<LayerDiagonalIcon height="56" width="56" />
|
||||
<h3 className="text-gray-500">
|
||||
No issues found. Create a new issue with{" "}
|
||||
<pre className="inline rounded bg-gray-100 px-2 py-1">
|
||||
Ctrl/Command + I
|
||||
</pre>
|
||||
.
|
||||
<pre className="inline rounded bg-gray-100 px-2 py-1">C</pre>.
|
||||
</h3>
|
||||
</div>
|
||||
)}
|
||||
|
@ -77,11 +77,18 @@ const ExistingIssuesListModal: React.FC<Props> = ({
|
||||
type: "error",
|
||||
message: "Please select atleast one issue",
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await handleOnSubmit(data);
|
||||
handleClose();
|
||||
|
||||
setToastAlert({
|
||||
title: "Success",
|
||||
type: "success",
|
||||
message: `Issue${data.issues.length > 1 ? "s" : ""} added successfully`,
|
||||
});
|
||||
};
|
||||
|
||||
const filteredIssues: IIssue[] =
|
||||
@ -182,10 +189,7 @@ const ExistingIssuesListModal: React.FC<Props> = ({
|
||||
<LayerDiagonalIcon height="56" width="56" />
|
||||
<h3 className="text-gray-500">
|
||||
No issues found. Create a new issue with{" "}
|
||||
<pre className="inline rounded bg-gray-100 px-2 py-1">
|
||||
Ctrl/Command + I
|
||||
</pre>
|
||||
.
|
||||
<pre className="inline rounded bg-gray-100 px-2 py-1">C</pre>.
|
||||
</h3>
|
||||
</div>
|
||||
)}
|
||||
|
@ -5,6 +5,9 @@ import { useRouter } from "next/router";
|
||||
|
||||
import useSWR, { mutate } from "swr";
|
||||
|
||||
// react-datepicker
|
||||
import DatePicker from "react-datepicker";
|
||||
import "react-datepicker/dist/react-datepicker.css";
|
||||
// services
|
||||
import issuesService from "services/issues.service";
|
||||
import workspaceService from "services/workspace.service";
|
||||
@ -12,7 +15,7 @@ import stateService from "services/state.service";
|
||||
// headless ui
|
||||
import { Listbox, Transition } from "@headlessui/react";
|
||||
// ui
|
||||
import { CustomMenu, CustomSelect, AssigneesList, Avatar } from "components/ui";
|
||||
import { CustomMenu, CustomSelect, AssigneesList, Avatar, CustomDatePicker } from "components/ui";
|
||||
// components
|
||||
import ConfirmIssueDeletion from "components/project/issues/confirm-issue-deletion";
|
||||
// icons
|
||||
@ -21,7 +24,7 @@ import { CalendarDaysIcon } from "@heroicons/react/24/outline";
|
||||
import { renderShortNumericDateFormat, findHowManyDaysLeft } from "helpers/date-time.helper";
|
||||
import { addSpaceIfCamelCase } from "helpers/string.helper";
|
||||
// types
|
||||
import { IIssue, IWorkspaceMember, Properties } from "types";
|
||||
import { IIssue, IWorkspaceMember, Properties, UserAuth } from "types";
|
||||
// fetch-keys
|
||||
import {
|
||||
CYCLE_ISSUES,
|
||||
@ -41,6 +44,7 @@ type Props = {
|
||||
properties: Properties;
|
||||
editIssue: () => void;
|
||||
removeIssue?: () => void;
|
||||
userAuth: UserAuth;
|
||||
};
|
||||
|
||||
const SingleListIssue: React.FC<Props> = ({
|
||||
@ -50,6 +54,7 @@ const SingleListIssue: React.FC<Props> = ({
|
||||
properties,
|
||||
editIssue,
|
||||
removeIssue,
|
||||
userAuth,
|
||||
}) => {
|
||||
const [deleteIssue, setDeleteIssue] = useState<IIssue | undefined>();
|
||||
|
||||
@ -86,6 +91,8 @@ const SingleListIssue: React.FC<Props> = ({
|
||||
});
|
||||
};
|
||||
|
||||
const isNotAllowed = userAuth.isGuest || userAuth.isViewer;
|
||||
|
||||
return (
|
||||
<>
|
||||
<ConfirmIssueDeletion
|
||||
@ -121,12 +128,15 @@ const SingleListIssue: React.FC<Props> = ({
|
||||
partialUpdateIssue({ priority: data });
|
||||
}}
|
||||
className="group relative flex-shrink-0"
|
||||
disabled={isNotAllowed}
|
||||
>
|
||||
{({ open }) => (
|
||||
<>
|
||||
<div>
|
||||
<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"
|
||||
? "bg-red-100 text-red-600"
|
||||
: issue.priority === "high"
|
||||
@ -210,6 +220,7 @@ const SingleListIssue: React.FC<Props> = ({
|
||||
}}
|
||||
maxHeight="md"
|
||||
noChevron
|
||||
disabled={isNotAllowed}
|
||||
>
|
||||
{states?.map((state) => (
|
||||
<CustomSelect.Option key={state.id} value={state.id}>
|
||||
@ -226,9 +237,14 @@ const SingleListIssue: React.FC<Props> = ({
|
||||
))}
|
||||
</CustomSelect>
|
||||
)}
|
||||
{/* {properties.cycle && !typeId && (
|
||||
<div className="flex flex-shrink-0 items-center gap-1 rounded border px-2 py-1 text-xs shadow-sm duration-300 hover:bg-gray-100 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500">
|
||||
{issue.issue_cycle ? issue.issue_cycle.cycle_detail.name : "None"}
|
||||
</div>
|
||||
)} */}
|
||||
{properties.due_date && (
|
||||
<div
|
||||
className={`group 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 < new Date().toISOString()
|
||||
@ -236,8 +252,37 @@ const SingleListIssue: React.FC<Props> = ({
|
||||
: findHowManyDaysLeft(issue.target_date) <= 3 && "text-orange-400"
|
||||
}`}
|
||||
>
|
||||
<CalendarDaysIcon className="h-4 w-4" />
|
||||
{issue.target_date ? renderShortNumericDateFormat(issue.target_date) : "N/A"}
|
||||
<CustomDatePicker
|
||||
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">
|
||||
<h5 className="mb-1 font-medium text-gray-900">Due date</h5>
|
||||
<div>{renderShortNumericDateFormat(issue.target_date ?? "")}</div>
|
||||
@ -253,7 +298,7 @@ const SingleListIssue: React.FC<Props> = ({
|
||||
</div>
|
||||
)}
|
||||
{properties.sub_issue_count && (
|
||||
<div className="flex flex-shrink-0 items-center gap-1 rounded border px-2 py-1 text-xs shadow-sm duration-300 hover:bg-gray-100 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500">
|
||||
<div className="flex flex-shrink-0 items-center gap-1 rounded border px-2 py-1 text-xs shadow-sm">
|
||||
{issue.sub_issues_count} {issue.sub_issues_count === 1 ? "sub-issue" : "sub-issues"}
|
||||
</div>
|
||||
)}
|
||||
@ -270,12 +315,17 @@ const SingleListIssue: React.FC<Props> = ({
|
||||
partialUpdateIssue({ assignees_list: newData });
|
||||
}}
|
||||
className="group relative flex-shrink-0"
|
||||
disabled={isNotAllowed}
|
||||
>
|
||||
{({ open }) => (
|
||||
<>
|
||||
<div>
|
||||
<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 ?? []} />
|
||||
</div>
|
||||
</Listbox.Button>
|
||||
@ -325,7 +375,7 @@ const SingleListIssue: React.FC<Props> = ({
|
||||
)}
|
||||
</Listbox>
|
||||
)}
|
||||
{type && (
|
||||
{type && !isNotAllowed && (
|
||||
<CustomMenu width="auto" ellipsis>
|
||||
<CustomMenu.MenuItem onClick={editIssue}>Edit</CustomMenu.MenuItem>
|
||||
{type !== "issue" && (
|
||||
|
@ -1,7 +1,11 @@
|
||||
import { FC, useEffect, useState } from "react";
|
||||
import { FC, useCallback, useEffect, useMemo } from "react";
|
||||
|
||||
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
|
||||
import { Loader, Input } from "components/ui";
|
||||
const RemirrorRichTextEditor = dynamic(() => import("components/rich-text-editor"), {
|
||||
@ -12,8 +16,9 @@ const RemirrorRichTextEditor = dynamic(() => import("components/rich-text-editor
|
||||
</Loader>
|
||||
),
|
||||
});
|
||||
// hooks
|
||||
import useDebounce from "hooks/use-debounce";
|
||||
// types
|
||||
import { IIssue } from "types";
|
||||
import useToast from "hooks/use-toast";
|
||||
|
||||
export interface IssueDescriptionFormValues {
|
||||
name: string;
|
||||
@ -23,32 +28,74 @@ export interface IssueDescriptionFormValues {
|
||||
|
||||
export interface IssueDetailsProps {
|
||||
issue: IIssue;
|
||||
handleSubmit: (value: IssueDescriptionFormValues) => void;
|
||||
handleFormSubmit: (value: IssueDescriptionFormValues) => void;
|
||||
}
|
||||
|
||||
export const IssueDescriptionForm: FC<IssueDetailsProps> = ({ issue, handleSubmit }) => {
|
||||
// states
|
||||
// const [issueFormValues, setIssueFormValues] = useState({
|
||||
// name: issue.name,
|
||||
// description: issue?.description,
|
||||
// description_html: issue?.description_html,
|
||||
// });
|
||||
export const IssueDescriptionForm: FC<IssueDetailsProps> = ({ issue, handleFormSubmit }) => {
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const [issueName, setIssueName] = useState(issue?.name);
|
||||
const [issueDescription, setIssueDescription] = useState(issue?.description);
|
||||
const [issueDescriptionHTML, setIssueDescriptionHTML] = useState(issue?.description_html);
|
||||
const {
|
||||
handleSubmit,
|
||||
watch,
|
||||
setValue,
|
||||
reset,
|
||||
formState: { errors },
|
||||
setError,
|
||||
} = useForm<IIssue>({
|
||||
defaultValues: {
|
||||
name: "",
|
||||
description: "",
|
||||
description_html: "",
|
||||
},
|
||||
});
|
||||
|
||||
// hooks
|
||||
const formValues = useDebounce(
|
||||
{ name: issueName, description: issueDescription, description_html: issueDescriptionHTML },
|
||||
2000
|
||||
const handleDescriptionFormSubmit = useCallback(
|
||||
(formData: Partial<IIssue>) => {
|
||||
if (!formData.name || formData.name === "") {
|
||||
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(() => {
|
||||
handleSubmit(formValues);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [handleSubmit, stringFromValues]);
|
||||
if (!issue) return;
|
||||
|
||||
reset(issue);
|
||||
}, [issue, reset]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
@ -56,18 +103,24 @@ export const IssueDescriptionForm: FC<IssueDetailsProps> = ({ issue, handleSubmi
|
||||
id="name"
|
||||
placeholder="Enter issue name"
|
||||
name="name"
|
||||
value={watch("name")}
|
||||
autoComplete="off"
|
||||
value={issueName}
|
||||
onChange={(e) => setIssueName(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setValue("name", e.target.value);
|
||||
debounceHandler();
|
||||
}}
|
||||
mode="transparent"
|
||||
className="text-xl font-medium"
|
||||
required={true}
|
||||
/>
|
||||
<span>{errors.name ? errors.name.message : null}</span>
|
||||
<RemirrorRichTextEditor
|
||||
value={issueDescription}
|
||||
placeholder="Enter Your Text..."
|
||||
onJSONChange={(json) => setIssueDescription(json)}
|
||||
onHTMLChange={(html) => setIssueDescriptionHTML(html)}
|
||||
value={watch("description")}
|
||||
placeholder="Describe the issue..."
|
||||
onJSONChange={(json) => {
|
||||
setValue("description", json);
|
||||
debounceHandler();
|
||||
}}
|
||||
onHTMLChange={(html) => setValue("description_html", html)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
@ -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 CreateUpdateCycleModal from "components/project/cycles/create-update-cycle-modal";
|
||||
// ui
|
||||
import { Button, CustomMenu, Input, Loader } from "components/ui";
|
||||
import { Button, CustomDatePicker, CustomMenu, Input, Loader } from "components/ui";
|
||||
// icons
|
||||
import { XMarkIcon } from "@heroicons/react/24/outline";
|
||||
// helpers
|
||||
@ -194,10 +194,10 @@ export const IssueForm: FC<IssueFormProps> = ({
|
||||
error={errors.name}
|
||||
register={register}
|
||||
validations={{
|
||||
required: "Name is required",
|
||||
required: "Title is required",
|
||||
maxLength: {
|
||||
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} />
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="target_date"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<input
|
||||
type="date"
|
||||
value={value ?? ""}
|
||||
onChange={(e: any) => {
|
||||
onChange(e.target.value);
|
||||
}}
|
||||
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"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<div>
|
||||
<Controller
|
||||
control={control}
|
||||
name="target_date"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<CustomDatePicker
|
||||
value={value}
|
||||
onChange={(val: Date) => {
|
||||
onChange(
|
||||
val
|
||||
? `${val.getFullYear()}-${val.getMonth() + 1}-${val.getDate()}`
|
||||
: null
|
||||
);
|
||||
}}
|
||||
className="max-w-[7rem]"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<IssueParentSelect
|
||||
control={control}
|
||||
isOpen={parentIssueListModalOpen}
|
||||
|
@ -144,7 +144,7 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
|
||||
setToastAlert({
|
||||
title: "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);
|
||||
|
@ -4,30 +4,30 @@ import Image from "next/image";
|
||||
import Module from "public/onboarding/module.png";
|
||||
|
||||
const BreakIntoModules: React.FC = () => (
|
||||
<div className="h-full space-y-4">
|
||||
<div className="relative h-1/2">
|
||||
<div
|
||||
className="absolute bottom-0 z-10 h-8 w-full bg-white"
|
||||
style={{
|
||||
background: "linear-gradient(0deg, #fff 84.2%, rgba(255, 255, 255, 0) 34.35%)",
|
||||
}}
|
||||
/>
|
||||
<Image
|
||||
src={Module}
|
||||
className="h-full"
|
||||
objectFit="contain"
|
||||
layout="fill"
|
||||
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 className="h-full space-y-4">
|
||||
<div className="relative h-1/2">
|
||||
<div
|
||||
className="absolute bottom-0 z-10 h-8 w-full bg-white"
|
||||
style={{
|
||||
background: "linear-gradient(0deg, #fff 84.2%, rgba(255, 255, 255, 0) 34.35%)",
|
||||
}}
|
||||
/>
|
||||
<Image
|
||||
src={Module}
|
||||
className="h-full"
|
||||
objectFit="contain"
|
||||
layout="fill"
|
||||
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 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;
|
||||
|
@ -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 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`}>
|
||||
<a className="flex items-center gap-x-3">
|
||||
{project.icon && (
|
||||
<span className="text-base">{String.fromCodePoint(parseInt(project.icon))}</span>
|
||||
)}
|
||||
<span>{project.name}</span>
|
||||
<span className="text-xs text-gray-500">{project.identifier}</span>
|
||||
<span className="w-3/4 max-w-[225px] md:max-w-[140px] xl:max-w-[225px] text-ellipsis overflow-hidden">
|
||||
{project.name}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500 ">{project.identifier}</span>
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
|
@ -2,19 +2,19 @@ import React, { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { mutate } from "swr";
|
||||
|
||||
// headless ui
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
|
||||
// services
|
||||
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
|
||||
import type { IProject, IWorkspace } from "types";
|
||||
import projectService from "services/project.service";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
// icons
|
||||
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
|
||||
// ui
|
||||
import { Button, Input } from "components/ui";
|
||||
// types
|
||||
// constants
|
||||
import type { IProject, IWorkspace } from "types";
|
||||
// fetch-keys
|
||||
import { PROJECTS_LIST } from "constants/fetch-keys";
|
||||
|
||||
type TConfirmProjectDeletionProps = {
|
||||
@ -86,7 +86,7 @@ const ConfirmProjectDeletion: React.FC<TConfirmProjectDeletionProps> = (props) =
|
||||
<Transition.Root show={isOpen} as={React.Fragment}>
|
||||
<Dialog
|
||||
as="div"
|
||||
className="relative z-10"
|
||||
className="relative z-20"
|
||||
initialFocus={cancelButtonRef}
|
||||
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" />
|
||||
</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">
|
||||
<Transition.Child
|
||||
as={React.Fragment}
|
||||
|
@ -33,7 +33,7 @@ const ConfirmProjectMemberRemove: React.FC<Props> = ({ isOpen, onClose, data, ha
|
||||
<Transition.Root show={isOpen} as={React.Fragment}>
|
||||
<Dialog
|
||||
as="div"
|
||||
className="relative z-10"
|
||||
className="relative z-20"
|
||||
initialFocus={cancelButtonRef}
|
||||
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" />
|
||||
</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">
|
||||
<Transition.Child
|
||||
as={React.Fragment}
|
||||
|
@ -93,9 +93,8 @@ export const CreateProjectModal: React.FC<Props> = (props) => {
|
||||
const projectIdentifier = watch("identifier") ?? "";
|
||||
|
||||
useEffect(() => {
|
||||
if (projectName && isChangeIdentifierRequired) {
|
||||
if (projectName && isChangeIdentifierRequired)
|
||||
setValue("identifier", projectName.replace(/ /g, "").toUpperCase().substring(0, 3));
|
||||
}
|
||||
}, [projectName, projectIdentifier, setValue, isChangeIdentifierRequired]);
|
||||
|
||||
useEffect(() => () => setIsChangeIdentifierRequired(true), [isOpen]);
|
||||
@ -185,7 +184,7 @@ export const CreateProjectModal: React.FC<Props> = (props) => {
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex gap-3">
|
||||
<div className="flex-shrink-0">
|
||||
<label htmlFor="icon" className="mb-2 text-gray-500">
|
||||
Icon
|
||||
@ -215,6 +214,10 @@ export const CreateProjectModal: React.FC<Props> = (props) => {
|
||||
register={register}
|
||||
validations={{
|
||||
required: "Name is required",
|
||||
maxLength: {
|
||||
value: 255,
|
||||
message: "Name should be less than 255 characters",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
@ -13,7 +13,7 @@ import SingleBoard from "components/project/cycles/board-view/single-board";
|
||||
// ui
|
||||
import { Spinner } from "components/ui";
|
||||
// types
|
||||
import { CycleIssueResponse, IIssue, IProjectMember } from "types";
|
||||
import { CycleIssueResponse, IIssue, IProjectMember, UserAuth } from "types";
|
||||
import issuesService from "services/issues.service";
|
||||
// constants
|
||||
import { STATE_LIST, CYCLE_ISSUES } from "constants/fetch-keys";
|
||||
@ -23,8 +23,6 @@ type Props = {
|
||||
members: IProjectMember[] | undefined;
|
||||
openCreateIssueModal: (issue?: IIssue, actionType?: "create" | "edit" | "delete") => void;
|
||||
openIssuesListModal: () => void;
|
||||
removeIssueFromCycle: (bridgeId: string) => void;
|
||||
partialUpdateIssue: (formData: Partial<IIssue>, issueId: string) => void;
|
||||
handleDeleteIssue: React.Dispatch<React.SetStateAction<string | undefined>>;
|
||||
setPreloadedData: React.Dispatch<
|
||||
React.SetStateAction<
|
||||
@ -34,6 +32,7 @@ type Props = {
|
||||
| null
|
||||
>
|
||||
>;
|
||||
userAuth: UserAuth;
|
||||
};
|
||||
|
||||
const CyclesBoardView: React.FC<Props> = ({
|
||||
@ -41,10 +40,9 @@ const CyclesBoardView: React.FC<Props> = ({
|
||||
members,
|
||||
openCreateIssueModal,
|
||||
openIssuesListModal,
|
||||
removeIssueFromCycle,
|
||||
partialUpdateIssue,
|
||||
handleDeleteIssue,
|
||||
setPreloadedData,
|
||||
userAuth,
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, cycleId } = router.query;
|
||||
@ -128,7 +126,7 @@ const CyclesBoardView: React.FC<Props> = ({
|
||||
return (
|
||||
<>
|
||||
{groupedByIssues ? (
|
||||
<div className="h-screen w-full">
|
||||
<div className="h-[calc(100vh-157px)] lg:h-[calc(100vh-115px)] w-full">
|
||||
<DragDropContext onDragEnd={handleOnDragEnd}>
|
||||
<div className="h-full w-full overflow-hidden">
|
||||
<div className="h-full w-full">
|
||||
@ -151,10 +149,8 @@ const CyclesBoardView: React.FC<Props> = ({
|
||||
: "#000000"
|
||||
}
|
||||
properties={properties}
|
||||
removeIssueFromCycle={removeIssueFromCycle}
|
||||
openIssuesListModal={openIssuesListModal}
|
||||
openCreateIssueModal={openCreateIssueModal}
|
||||
partialUpdateIssue={partialUpdateIssue}
|
||||
handleDeleteIssue={handleDeleteIssue}
|
||||
setPreloadedData={setPreloadedData}
|
||||
stateId={
|
||||
@ -162,6 +158,7 @@ const CyclesBoardView: React.FC<Props> = ({
|
||||
? states?.find((s) => s.name === singleGroup)?.id ?? null
|
||||
: null
|
||||
}
|
||||
userAuth={userAuth}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
@ -17,7 +17,7 @@ import { CustomMenu } from "components/ui";
|
||||
// icons
|
||||
import { PlusIcon } from "@heroicons/react/24/outline";
|
||||
// types
|
||||
import { IIssue, IWorkspaceMember, NestedKeyOf, Properties } from "types";
|
||||
import { IIssue, IWorkspaceMember, NestedKeyOf, Properties, UserAuth } from "types";
|
||||
// fetch-keys
|
||||
import { WORKSPACE_MEMBERS } from "constants/fetch-keys";
|
||||
|
||||
@ -32,8 +32,6 @@ type Props = {
|
||||
bgColor?: string;
|
||||
openCreateIssueModal: (issue?: IIssue, actionType?: "create" | "edit" | "delete") => void;
|
||||
openIssuesListModal: () => void;
|
||||
removeIssueFromCycle: (bridgeId: string) => void;
|
||||
partialUpdateIssue: (formData: Partial<IIssue>, issueId: string) => void;
|
||||
handleDeleteIssue: React.Dispatch<React.SetStateAction<string | undefined>>;
|
||||
setPreloadedData: React.Dispatch<
|
||||
React.SetStateAction<
|
||||
@ -44,6 +42,7 @@ type Props = {
|
||||
>
|
||||
>;
|
||||
stateId: string | null;
|
||||
userAuth: UserAuth;
|
||||
};
|
||||
|
||||
const SingleModuleBoard: React.FC<Props> = ({
|
||||
@ -55,18 +54,17 @@ const SingleModuleBoard: React.FC<Props> = ({
|
||||
bgColor,
|
||||
openCreateIssueModal,
|
||||
openIssuesListModal,
|
||||
removeIssueFromCycle,
|
||||
partialUpdateIssue,
|
||||
handleDeleteIssue,
|
||||
setPreloadedData,
|
||||
stateId,
|
||||
userAuth,
|
||||
}) => {
|
||||
// collapse/expand
|
||||
const [isCollapsed, setIsCollapsed] = useState(true);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const { workspaceSlug } = router.query;
|
||||
const { workspaceSlug, cycleId } = router.query;
|
||||
|
||||
if (selectedGroup === "priority")
|
||||
groupTitle === "high"
|
||||
@ -132,13 +130,15 @@ const SingleModuleBoard: React.FC<Props> = ({
|
||||
{...provided.dragHandleProps}
|
||||
>
|
||||
<SingleIssue
|
||||
type="cycle"
|
||||
typeId={cycleId as string}
|
||||
issue={childIssue}
|
||||
properties={properties}
|
||||
snapshot={snapshot}
|
||||
assignees={assignees}
|
||||
people={people}
|
||||
partialUpdateIssue={partialUpdateIssue}
|
||||
handleDeleteIssue={handleDeleteIssue}
|
||||
userAuth={userAuth}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
@ -5,12 +5,14 @@ import { useRouter } from "next/router";
|
||||
import { mutate } from "swr";
|
||||
// headless ui
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
|
||||
// services
|
||||
import cycleService from "services/cycles.service";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
// ui
|
||||
import { Button } from "components/ui";
|
||||
// icons
|
||||
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
|
||||
// types
|
||||
import type { ICycle } from "types";
|
||||
type TConfirmCycleDeletionProps = {
|
||||
@ -21,15 +23,19 @@ type TConfirmCycleDeletionProps = {
|
||||
// fetch-keys
|
||||
import { CYCLE_LIST } from "constants/fetch-keys";
|
||||
|
||||
const ConfirmCycleDeletion: React.FC<TConfirmCycleDeletionProps> = (props) => {
|
||||
const { isOpen, setIsOpen, data } = props;
|
||||
|
||||
const ConfirmCycleDeletion: React.FC<TConfirmCycleDeletionProps> = ({
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
data,
|
||||
}) => {
|
||||
const cancelButtonRef = useRef(null);
|
||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug } = router.query;
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
data && setIsOpen(true);
|
||||
}, [data, setIsOpen]);
|
||||
@ -51,6 +57,12 @@ const ConfirmCycleDeletion: React.FC<TConfirmCycleDeletionProps> = (props) => {
|
||||
false
|
||||
);
|
||||
handleClose();
|
||||
|
||||
setToastAlert({
|
||||
title: "Success",
|
||||
type: "success",
|
||||
message: "Cycle deleted successfully",
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log(error);
|
||||
|
@ -1,20 +1,23 @@
|
||||
import React, { useEffect } from "react";
|
||||
// next
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
// swr
|
||||
|
||||
import { mutate } from "swr";
|
||||
|
||||
// react hook form
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
// headless
|
||||
// headless ui
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
// types
|
||||
import type { ICycle } from "types";
|
||||
// services
|
||||
import cycleService from "services/cycles.service";
|
||||
import { Button, Input, TextArea, CustomSelect } from "components/ui";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
// ui
|
||||
import { Button, Input, TextArea, CustomSelect, CustomDatePicker } from "components/ui";
|
||||
// common
|
||||
import { renderDateFormat } from "helpers/date-time.helper";
|
||||
// types
|
||||
import type { ICycle } from "types";
|
||||
// fetch keys
|
||||
import { CYCLE_LIST } from "constants/fetch-keys";
|
||||
|
||||
@ -29,14 +32,16 @@ const defaultValues: Partial<ICycle> = {
|
||||
name: "",
|
||||
description: "",
|
||||
status: "draft",
|
||||
start_date: new Date().toString(),
|
||||
end_date: new Date().toString(),
|
||||
start_date: null,
|
||||
end_date: null,
|
||||
};
|
||||
|
||||
const CreateUpdateCycleModal: React.FC<Props> = ({ isOpen, setIsOpen, data, projectId }) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug } = router.query;
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const {
|
||||
register,
|
||||
formState: { errors, isSubmitting },
|
||||
@ -69,7 +74,13 @@ const CreateUpdateCycleModal: React.FC<Props> = ({ isOpen, setIsOpen, data, proj
|
||||
.createCycle(workspaceSlug as string, projectId, payload)
|
||||
.then((res) => {
|
||||
mutate<ICycle[]>(CYCLE_LIST(projectId), (prevData) => [res, ...(prevData ?? [])], false);
|
||||
|
||||
handleClose();
|
||||
setToastAlert({
|
||||
title: "Success",
|
||||
type: "success",
|
||||
message: "Cycle created successfully",
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
Object.keys(err).map((key) => {
|
||||
@ -82,20 +93,14 @@ const CreateUpdateCycleModal: React.FC<Props> = ({ isOpen, setIsOpen, data, proj
|
||||
await cycleService
|
||||
.updateCycle(workspaceSlug as string, projectId, data.id, payload)
|
||||
.then((res) => {
|
||||
mutate<ICycle[]>(
|
||||
CYCLE_LIST(projectId),
|
||||
(prevData) => {
|
||||
const newData = prevData?.map((item) => {
|
||||
if (item.id === res.id) {
|
||||
return res;
|
||||
}
|
||||
return item;
|
||||
});
|
||||
return newData;
|
||||
},
|
||||
false
|
||||
);
|
||||
mutate(CYCLE_LIST(projectId));
|
||||
handleClose();
|
||||
|
||||
setToastAlert({
|
||||
title: "Success",
|
||||
type: "success",
|
||||
message: "Cycle updated successfully",
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
Object.keys(err).map((key) => {
|
||||
@ -157,6 +162,10 @@ const CreateUpdateCycleModal: React.FC<Props> = ({ isOpen, setIsOpen, data, proj
|
||||
register={register}
|
||||
validations={{
|
||||
required: "Name is required",
|
||||
maxLength: {
|
||||
value: 255,
|
||||
message: "Name should be less than 255 characters",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@ -198,32 +207,62 @@ const CreateUpdateCycleModal: React.FC<Props> = ({ isOpen, setIsOpen, data, proj
|
||||
</div>
|
||||
<div className="flex gap-x-2">
|
||||
<div className="w-full">
|
||||
<Input
|
||||
id="start_date"
|
||||
label="Start Date"
|
||||
name="start_date"
|
||||
type="date"
|
||||
placeholder="Enter start date"
|
||||
error={errors.start_date}
|
||||
register={register}
|
||||
validations={{
|
||||
required: "Start date is required",
|
||||
}}
|
||||
/>
|
||||
<h6 className="text-gray-500">Start Date</h6>
|
||||
<div className="w-full">
|
||||
<Controller
|
||||
control={control}
|
||||
name="start_date"
|
||||
rules={{ required: "Start date is required" }}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<CustomDatePicker
|
||||
renderAs="input"
|
||||
value={value}
|
||||
onChange={(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 className="w-full">
|
||||
<Input
|
||||
id="end_date"
|
||||
label="End Date"
|
||||
name="end_date"
|
||||
type="date"
|
||||
placeholder="Enter end date"
|
||||
error={errors.end_date}
|
||||
register={register}
|
||||
validations={{
|
||||
required: "End date is required",
|
||||
}}
|
||||
/>
|
||||
<h6 className="text-gray-500">End Date</h6>
|
||||
<div className="w-full">
|
||||
<Controller
|
||||
control={control}
|
||||
name="end_date"
|
||||
rules={{ required: "End date is required" }}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<CustomDatePicker
|
||||
renderAs="input"
|
||||
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>
|
||||
|
@ -13,14 +13,17 @@ import cyclesService from "services/cycles.service";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
// 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
|
||||
import { copyTextToClipboard } from "helpers/string.helper";
|
||||
import { groupBy } from "helpers/array.helper";
|
||||
// types
|
||||
import { CycleIssueResponse, ICycle } from "types";
|
||||
// fetch-keys
|
||||
import { CYCLE_DETAIL } from "constants/fetch-keys";
|
||||
import { CYCLE_LIST } from "constants/fetch-keys";
|
||||
|
||||
type Props = {
|
||||
cycle: ICycle | undefined;
|
||||
@ -35,9 +38,7 @@ const defaultValues: Partial<ICycle> = {
|
||||
|
||||
const CycleDetailSidebar: React.FC<Props> = ({ cycle, isOpen, cycleIssues }) => {
|
||||
const router = useRouter();
|
||||
const {
|
||||
query: { workspaceSlug, projectId },
|
||||
} = router;
|
||||
const { workspaceSlug, projectId, cycleId } = router.query;
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
@ -57,11 +58,21 @@ const CycleDetailSidebar: React.FC<Props> = ({ cycle, isOpen, cycleIssues }) =>
|
||||
const submitChanges = (data: Partial<ICycle>) => {
|
||||
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
|
||||
.patchCycle(workspaceSlug as string, projectId as string, cycle?.id ?? "", data)
|
||||
.then((res) => {
|
||||
console.log(res);
|
||||
mutate(CYCLE_DETAIL);
|
||||
mutate(CYCLE_LIST(projectId as string));
|
||||
})
|
||||
.catch((e) => {
|
||||
console.log(e);
|
||||
@ -135,7 +146,13 @@ const CycleDetailSidebar: React.FC<Props> = ({ cycle, isOpen, cycleIssues }) =>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 sm:basis-1/2">
|
||||
<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>
|
||||
{groupedIssues.completed.length}/{cycleIssues?.length}
|
||||
</div>
|
||||
@ -151,16 +168,17 @@ const CycleDetailSidebar: React.FC<Props> = ({ cycle, isOpen, cycleIssues }) =>
|
||||
<Controller
|
||||
control={control}
|
||||
name="start_date"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<input
|
||||
type="date"
|
||||
id="cycleStartDate"
|
||||
value={value ?? ""}
|
||||
onChange={(e: any) => {
|
||||
submitChanges({ start_date: e.target.value });
|
||||
onChange(e.target.value);
|
||||
render={({ field: { value } }) => (
|
||||
<CustomDatePicker
|
||||
value={value}
|
||||
onChange={(val: Date) => {
|
||||
submitChanges({
|
||||
start_date: val
|
||||
? `${val.getFullYear()}-${val.getMonth() + 1}-${val.getDate()}`
|
||||
: 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
|
||||
control={control}
|
||||
name="end_date"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<input
|
||||
type="date"
|
||||
id="moduleEndDate"
|
||||
value={value ?? ""}
|
||||
onChange={(e: any) => {
|
||||
submitChanges({ end_date: e.target.value });
|
||||
onChange(e.target.value);
|
||||
render={({ field: { value } }) => (
|
||||
<CustomDatePicker
|
||||
value={value}
|
||||
onChange={(val: Date) => {
|
||||
submitChanges({
|
||||
end_date: val
|
||||
? `${val.getFullYear()}-${val.getMonth() + 1}-${val.getDate()}`
|
||||
: 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}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
@ -21,7 +21,7 @@ import { CustomMenu, Spinner } from "components/ui";
|
||||
// helpers
|
||||
import { addSpaceIfCamelCase } from "helpers/string.helper";
|
||||
// types
|
||||
import { IIssue, IWorkspaceMember } from "types";
|
||||
import { IIssue, IWorkspaceMember, UserAuth } from "types";
|
||||
// fetch-keys
|
||||
import { WORKSPACE_MEMBERS, STATE_LIST } from "constants/fetch-keys";
|
||||
|
||||
@ -38,6 +38,7 @@ type Props = {
|
||||
| null
|
||||
>
|
||||
>;
|
||||
userAuth: UserAuth;
|
||||
};
|
||||
|
||||
const CyclesListView: React.FC<Props> = ({
|
||||
@ -46,6 +47,7 @@ const CyclesListView: React.FC<Props> = ({
|
||||
openIssuesListModal,
|
||||
removeIssueFromCycle,
|
||||
setPreloadedData,
|
||||
userAuth,
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, cycleId } = router.query;
|
||||
@ -140,6 +142,7 @@ const CyclesListView: React.FC<Props> = ({
|
||||
properties={properties}
|
||||
editIssue={() => openCreateIssueModal(issue, "edit")}
|
||||
removeIssue={() => removeIssueFromCycle(issue.bridge ?? "")}
|
||||
userAuth={userAuth}
|
||||
/>
|
||||
);
|
||||
})
|
||||
|
@ -64,7 +64,7 @@ const CycleStatsView: React.FC<TCycleStatsViewProps> = ({
|
||||
)}
|
||||
<h3 className="text-gray-500">
|
||||
No {type} {type === "current" ? "cycle" : "cycles"} yet. Create with{" "}
|
||||
<pre className="inline rounded bg-gray-100 px-2 py-1">Ctrl/Command + Q</pre>.
|
||||
<pre className="inline rounded bg-gray-100 px-2 py-1">Q</pre>.
|
||||
</h3>
|
||||
</div>
|
||||
)}
|
||||
|
@ -71,7 +71,9 @@ const SingleStat: React.FC<TSingleStatProps> = (props) => {
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Link href={`/${workspaceSlug}/projects/${projectId as string}/cycles/${cycle.id}`}>
|
||||
<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>
|
||||
</Link>
|
||||
<CustomMenu width="auto" ellipsis>
|
||||
|
@ -21,17 +21,17 @@ import ConfirmIssueDeletion from "components/project/issues/confirm-issue-deleti
|
||||
// ui
|
||||
import { Spinner } from "components/ui";
|
||||
// types
|
||||
import type { IState, IIssue, IssueResponse } from "types";
|
||||
import type { IState, IIssue, IssueResponse, UserAuth } from "types";
|
||||
// fetch-keys
|
||||
import { STATE_LIST, PROJECT_ISSUES_LIST, PROJECT_MEMBERS } from "constants/fetch-keys";
|
||||
|
||||
type Props = {
|
||||
issues: IIssue[];
|
||||
handleDeleteIssue: React.Dispatch<React.SetStateAction<string | undefined>>;
|
||||
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 [isIssueDeletionOpen, setIsIssueDeletionOpen] = useState(false);
|
||||
const [issueDeletionData, setIssueDeletionData] = useState<IIssue | undefined>();
|
||||
@ -68,7 +68,8 @@ const BoardView: React.FC<Props> = ({ issues, handleDeleteIssue, partialUpdateIs
|
||||
|
||||
const handleOnDragEnd = useCallback(
|
||||
(result: DropResult) => {
|
||||
if (!result.destination) return;
|
||||
if (!result.destination || !workspaceSlug || !projectId) return;
|
||||
|
||||
const { source, destination, type } = result;
|
||||
|
||||
if (destination.droppableId === "trashBox") {
|
||||
@ -94,7 +95,7 @@ const BoardView: React.FC<Props> = ({ issues, handleDeleteIssue, partialUpdateIs
|
||||
newStates[destination.index].sequence = sequenceNumber;
|
||||
|
||||
mutateState(newStates, false);
|
||||
if (!workspaceSlug) return;
|
||||
|
||||
stateServices
|
||||
.patchState(
|
||||
workspaceSlug as string,
|
||||
@ -140,18 +141,6 @@ const BoardView: React.FC<Props> = ({ issues, handleDeleteIssue, partialUpdateIs
|
||||
draggedItem.state = destinationStateId;
|
||||
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>(
|
||||
PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string),
|
||||
(prevData) => {
|
||||
@ -175,6 +164,15 @@ const BoardView: React.FC<Props> = ({ issues, handleDeleteIssue, partialUpdateIs
|
||||
},
|
||||
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 ? (
|
||||
<div className="h-screen w-full">
|
||||
<div className="h-[calc(100vh-157px)] lg:h-[calc(100vh-115px)] w-full">
|
||||
<DragDropContext onDragEnd={handleOnDragEnd}>
|
||||
<div className="h-full w-full overflow-hidden">
|
||||
<StrictModeDroppable droppableId="state" type="state" direction="horizontal">
|
||||
@ -238,7 +236,7 @@ const BoardView: React.FC<Props> = ({ issues, handleDeleteIssue, partialUpdateIs
|
||||
: "#000000"
|
||||
}
|
||||
handleDeleteIssue={handleDeleteIssue}
|
||||
partialUpdateIssue={partialUpdateIssue}
|
||||
userAuth={userAuth}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
@ -15,7 +15,7 @@ import { PlusIcon } from "@heroicons/react/24/outline";
|
||||
// services
|
||||
import workspaceService from "services/workspace.service";
|
||||
// types
|
||||
import { IIssue, Properties, NestedKeyOf, IWorkspaceMember } from "types";
|
||||
import { IIssue, Properties, NestedKeyOf, IWorkspaceMember, UserAuth } from "types";
|
||||
// fetch-keys
|
||||
import { WORKSPACE_MEMBERS } from "constants/fetch-keys";
|
||||
|
||||
@ -40,7 +40,7 @@ type Props = {
|
||||
stateId: string | null;
|
||||
createdBy: string | null;
|
||||
handleDeleteIssue: React.Dispatch<React.SetStateAction<string | undefined>>;
|
||||
partialUpdateIssue: (formData: Partial<IIssue>, childIssueId: string) => void;
|
||||
userAuth: UserAuth;
|
||||
};
|
||||
|
||||
const SingleBoard: React.FC<Props> = ({
|
||||
@ -55,7 +55,7 @@ const SingleBoard: React.FC<Props> = ({
|
||||
stateId,
|
||||
createdBy,
|
||||
handleDeleteIssue,
|
||||
partialUpdateIssue,
|
||||
userAuth,
|
||||
}) => {
|
||||
// Collapse/Expand
|
||||
const [isCollapsed, setIsCollapsed] = useState(true);
|
||||
@ -145,7 +145,7 @@ const SingleBoard: React.FC<Props> = ({
|
||||
people={people}
|
||||
assignees={assignees}
|
||||
handleDeleteIssue={handleDeleteIssue}
|
||||
partialUpdateIssue={partialUpdateIssue}
|
||||
userAuth={userAuth}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
@ -8,16 +8,18 @@ import useSWR, { mutate } from "swr";
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
// icons
|
||||
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
|
||||
// types
|
||||
import type { IState } from "types";
|
||||
// services
|
||||
import stateServices from "services/state.service";
|
||||
import issuesServices from "services/issues.service";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
// ui
|
||||
import { Button } from "components/ui";
|
||||
// helpers
|
||||
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";
|
||||
|
||||
type Props = {
|
||||
@ -33,6 +35,8 @@ const ConfirmStateDeletion: React.FC<Props> = ({ isOpen, onClose, data }) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const { data: issues } = useSWR(
|
||||
workspaceSlug && projectId
|
||||
? PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string)
|
||||
@ -61,6 +65,12 @@ const ConfirmStateDeletion: React.FC<Props> = ({ isOpen, onClose, data }) => {
|
||||
false
|
||||
);
|
||||
handleClose();
|
||||
|
||||
setToastAlert({
|
||||
title: "Success",
|
||||
type: "success",
|
||||
message: "State deleted successfully",
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log(error);
|
||||
@ -78,7 +88,7 @@ const ConfirmStateDeletion: React.FC<Props> = ({ isOpen, onClose, data }) => {
|
||||
<Transition.Root show={isOpen} as={React.Fragment}>
|
||||
<Dialog
|
||||
as="div"
|
||||
className="relative z-10"
|
||||
className="relative z-20"
|
||||
initialFocus={cancelButtonRef}
|
||||
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" />
|
||||
</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">
|
||||
<Transition.Child
|
||||
as={React.Fragment}
|
||||
|
@ -2,20 +2,24 @@ import React, { useEffect } from "react";
|
||||
|
||||
import { mutate } from "swr";
|
||||
|
||||
// react-hook-form
|
||||
import { useForm, Controller } from "react-hook-form";
|
||||
|
||||
// react-color
|
||||
import { TwitterPicker } from "react-color";
|
||||
|
||||
// headless ui
|
||||
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
|
||||
import stateService from "services/state.service";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
// ui
|
||||
import { Button, Input, Select } from "components/ui";
|
||||
import { Button, CustomSelect, Input, Select } from "components/ui";
|
||||
// types
|
||||
import type { IState } from "types";
|
||||
// fetch-keys
|
||||
import { STATE_LIST } from "constants/fetch-keys";
|
||||
// constants
|
||||
import { GROUP_CHOICES } from "constants/";
|
||||
|
||||
type Props = {
|
||||
workspaceSlug?: string;
|
||||
@ -40,6 +44,8 @@ export const CreateUpdateStateInline: React.FC<Props> = ({
|
||||
onClose,
|
||||
selectedGroup,
|
||||
}) => {
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
@ -81,6 +87,12 @@ export const CreateUpdateStateInline: React.FC<Props> = ({
|
||||
.then((res) => {
|
||||
mutate<IState[]>(STATE_LIST(projectId), (prevData) => [...(prevData ?? []), res]);
|
||||
handleClose();
|
||||
|
||||
setToastAlert({
|
||||
title: "Success",
|
||||
type: "success",
|
||||
message: "State created successfully",
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
Object.keys(err).map((key) => {
|
||||
@ -95,16 +107,14 @@ export const CreateUpdateStateInline: React.FC<Props> = ({
|
||||
...payload,
|
||||
})
|
||||
.then((res) => {
|
||||
mutate<IState[]>(STATE_LIST(projectId), (prevData) => {
|
||||
const newData = prevData?.map((item) => {
|
||||
if (item.id === res.id) {
|
||||
return res;
|
||||
}
|
||||
return item;
|
||||
});
|
||||
return newData;
|
||||
});
|
||||
mutate(STATE_LIST(projectId));
|
||||
handleClose();
|
||||
|
||||
setToastAlert({
|
||||
title: "Success",
|
||||
type: "success",
|
||||
message: "State updated successfully",
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
Object.keys(err).map((key) => {
|
||||
@ -173,18 +183,27 @@ export const CreateUpdateStateInline: React.FC<Props> = ({
|
||||
autoComplete="off"
|
||||
/>
|
||||
{data && (
|
||||
<Select
|
||||
id="group"
|
||||
<Controller
|
||||
name="group"
|
||||
error={errors.group}
|
||||
register={register}
|
||||
validations={{
|
||||
required: true,
|
||||
}}
|
||||
options={Object.keys(GROUP_CHOICES).map((key) => ({
|
||||
value: key,
|
||||
label: GROUP_CHOICES[key as keyof typeof GROUP_CHOICES],
|
||||
}))}
|
||||
control={control}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<CustomSelect
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
label={
|
||||
Object.keys(GROUP_CHOICES).find((k) => k === value.toString())
|
||||
? 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
|
||||
@ -199,7 +218,7 @@ export const CreateUpdateStateInline: React.FC<Props> = ({
|
||||
Cancel
|
||||
</Button>
|
||||
<Button theme="primary" disabled={isSubmitting} type="submit">
|
||||
{isSubmitting ? "Loading..." : data ? "Update" : "Create"}
|
||||
{isSubmitting ? (data ? "Updating..." : "Creating...") : data ? "Update" : "Create"}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
|
@ -3,20 +3,21 @@ import React, { useEffect, useRef, useState } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import { mutate } from "swr";
|
||||
|
||||
// headless ui
|
||||
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
|
||||
import issueServices from "services/issues.service";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
// icons
|
||||
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
|
||||
// ui
|
||||
import { Button } from "components/ui";
|
||||
// types
|
||||
import type { CycleIssueResponse, IIssue, IssueResponse, ModuleIssueResponse } from "types";
|
||||
// fetch-keys
|
||||
import { CYCLE_ISSUES, PROJECT_ISSUES_LIST, MODULE_ISSUES } from "constants/fetch-keys";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
@ -79,12 +80,12 @@ const ConfirmIssueDeletion: React.FC<Props> = (props) => {
|
||||
);
|
||||
}
|
||||
|
||||
handleClose();
|
||||
setToastAlert({
|
||||
title: "Success",
|
||||
type: "success",
|
||||
message: "Issue deleted successfully",
|
||||
});
|
||||
handleClose();
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log(error);
|
||||
|
@ -76,11 +76,6 @@ const activityDetails: {
|
||||
},
|
||||
};
|
||||
|
||||
const defaultValues: Partial<IIssueComment> = {
|
||||
comment_html: "",
|
||||
comment_json: "",
|
||||
};
|
||||
|
||||
const IssueActivitySection: React.FC<{
|
||||
issueActivities: IIssueActivity[];
|
||||
mutate: KeyedMutator<IIssueActivity[]>;
|
||||
@ -99,7 +94,7 @@ const IssueActivitySection: React.FC<{
|
||||
comment.id,
|
||||
comment
|
||||
)
|
||||
.then((response) => {
|
||||
.then((res) => {
|
||||
mutate();
|
||||
});
|
||||
};
|
||||
@ -180,6 +175,10 @@ const IssueActivitySection: React.FC<{
|
||||
? activity.new_value !== ""
|
||||
? "marked this issue being blocked by"
|
||||
: "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]
|
||||
?.message}{" "}
|
||||
</span>
|
||||
@ -203,7 +202,9 @@ const IssueActivitySection: React.FC<{
|
||||
) : activity.field === "assignee" ? (
|
||||
activity.old_value
|
||||
) : activity.field === "target_date" ? (
|
||||
renderShortNumericDateFormat(activity.new_value as string)
|
||||
activity.new_value ? (
|
||||
renderShortNumericDateFormat(activity.new_value as string)
|
||||
) : null
|
||||
) : activity.field === "description" ? (
|
||||
""
|
||||
) : (
|
||||
|
@ -11,7 +11,7 @@ import { RectangleStackIcon, MagnifyingGlassIcon } from "@heroicons/react/24/out
|
||||
// services
|
||||
import issuesServices from "services/issues.service";
|
||||
// types
|
||||
import { IIssue, IssueResponse } from "types";
|
||||
import { IIssue } from "types";
|
||||
// constants
|
||||
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) => {
|
||||
if (workspaceSlug && projectId) {
|
||||
issuesServices
|
||||
.patchIssue(workspaceSlug as string, projectId as string, issueId, { parent: parent?.id })
|
||||
.then((res) => {
|
||||
mutate(SUB_ISSUES(parent?.id ?? ""));
|
||||
})
|
||||
.catch((e) => {
|
||||
console.log(e);
|
||||
});
|
||||
}
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
||||
issuesServices
|
||||
.patchIssue(workspaceSlug as string, projectId as string, issueId, { parent: parent?.id })
|
||||
.then((res) => {
|
||||
mutate(SUB_ISSUES(parent?.id ?? ""));
|
||||
})
|
||||
.catch((e) => {
|
||||
console.log(e);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
@ -140,6 +140,9 @@ const AddAsSubIssue: React.FC<Props> = ({ isOpen, setIsOpen, parent }) => {
|
||||
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}
|
||||
</Combobox.Option>
|
||||
);
|
||||
|
@ -4,20 +4,12 @@ import { useRouter } from "next/router";
|
||||
|
||||
import useSWR, { mutate } from "swr";
|
||||
|
||||
// react-hook-form
|
||||
import { useForm, Controller, UseFormWatch, Control } from "react-hook-form";
|
||||
|
||||
// react-color
|
||||
import { TwitterPicker } from "react-color";
|
||||
// services
|
||||
// headless ui
|
||||
import { Popover, Listbox, Transition } from "@headlessui/react";
|
||||
import {
|
||||
TagIcon,
|
||||
ChevronDownIcon,
|
||||
LinkIcon,
|
||||
CalendarDaysIcon,
|
||||
TrashIcon,
|
||||
PlusIcon,
|
||||
XMarkIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
// 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 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";
|
||||
// headless ui
|
||||
// ui
|
||||
import { Input, Button, Spinner } from "components/ui";
|
||||
import { Input, Button, Spinner, CustomDatePicker } from "components/ui";
|
||||
import DatePicker from "react-datepicker";
|
||||
// icons
|
||||
import {
|
||||
TagIcon,
|
||||
ChevronDownIcon,
|
||||
LinkIcon,
|
||||
CalendarDaysIcon,
|
||||
TrashIcon,
|
||||
PlusIcon,
|
||||
XMarkIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
// helpers
|
||||
import { copyTextToClipboard } from "helpers/string.helper";
|
||||
// types
|
||||
@ -42,6 +43,8 @@ import type { ICycle, IIssue, IIssueLabels } from "types";
|
||||
// fetch-keys
|
||||
import { PROJECT_ISSUE_LABELS, PROJECT_ISSUES_LIST, ISSUE_DETAILS } from "constants/fetch-keys";
|
||||
|
||||
import "react-datepicker/dist/react-datepicker.css";
|
||||
|
||||
type Props = {
|
||||
control: Control<IIssue, any>;
|
||||
submitChanges: (formData: Partial<IIssue>) => void;
|
||||
@ -216,19 +219,37 @@ const IssueDetailSidebar: React.FC<Props> = ({
|
||||
<p>Due date</p>
|
||||
</div>
|
||||
<div className="sm:basis-1/2">
|
||||
<Controller
|
||||
{/* <Controller
|
||||
control={control}
|
||||
name="target_date"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<input
|
||||
type="date"
|
||||
id="issueDate"
|
||||
value={value ?? ""}
|
||||
onChange={(e: any) => {
|
||||
submitChanges({ target_date: e.target.value });
|
||||
onChange(e.target.value);
|
||||
<DatePicker
|
||||
selected={value ? new Date(value) : new Date()}
|
||||
onChange={(val: Date) => {
|
||||
submitChanges({
|
||||
target_date: `${val.getFullYear()}-${
|
||||
val.getMonth() + 1
|
||||
}-${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"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
@ -259,10 +259,7 @@ const SelectBlocked: React.FC<Props> = ({ submitChanges, issuesList, watch }) =>
|
||||
<LayerDiagonalIcon height="56" width="56" />
|
||||
<h3 className="text-gray-500">
|
||||
No issues found. Create a new issue with{" "}
|
||||
<pre className="inline rounded bg-gray-100 px-2 py-1">
|
||||
Ctrl/Command + I
|
||||
</pre>
|
||||
.
|
||||
<pre className="inline rounded bg-gray-100 px-2 py-1">C</pre>.
|
||||
</h3>
|
||||
</div>
|
||||
)}
|
||||
|
@ -258,10 +258,7 @@ const SelectBlocker: React.FC<Props> = ({ submitChanges, issuesList, watch }) =>
|
||||
<LayerDiagonalIcon height="56" width="56" />
|
||||
<h3 className="text-gray-500">
|
||||
No issues found. Create a new issue with{" "}
|
||||
<pre className="inline rounded bg-gray-100 px-2 py-1">
|
||||
Ctrl/Command + I
|
||||
</pre>
|
||||
.
|
||||
<pre className="inline rounded bg-gray-100 px-2 py-1">C</pre>.
|
||||
</h3>
|
||||
</div>
|
||||
)}
|
||||
|
@ -212,10 +212,7 @@ const IssuesListModal: React.FC<Props> = ({
|
||||
<LayerDiagonalIcon height="56" width="56" />
|
||||
<h3 className="text-gray-500">
|
||||
No issues found. Create a new issue with{" "}
|
||||
<pre className="inline rounded bg-gray-100 px-2 py-1">
|
||||
Ctrl/Command + I
|
||||
</pre>
|
||||
.
|
||||
<pre className="inline rounded bg-gray-100 px-2 py-1">C</pre>.
|
||||
</h3>
|
||||
</div>
|
||||
)}
|
||||
|
@ -20,7 +20,7 @@ import SingleListIssue from "components/common/list-view/single-issue";
|
||||
// helpers
|
||||
import { addSpaceIfCamelCase } from "helpers/string.helper";
|
||||
// types
|
||||
import { IIssue, IWorkspaceMember } from "types";
|
||||
import { IIssue, IWorkspaceMember, UserAuth } from "types";
|
||||
// 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 = {
|
||||
issues: IIssue[];
|
||||
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 [preloadedData, setPreloadedData] = useState<
|
||||
(Partial<IIssue> & { actionType: "createIssue" | "edit" | "delete" }) | undefined
|
||||
@ -130,6 +130,7 @@ const ListView: React.FC<Props> = ({ issues, handleEditIssue }) => {
|
||||
issue={issue}
|
||||
properties={properties}
|
||||
editIssue={() => handleEditIssue(issue)}
|
||||
userAuth={userAuth}
|
||||
/>
|
||||
);
|
||||
})
|
||||
|
@ -17,7 +17,7 @@ import SingleBoard from "components/project/modules/board-view/single-board";
|
||||
// ui
|
||||
import { Spinner } from "components/ui";
|
||||
// types
|
||||
import { IIssue, IProjectMember, ModuleIssueResponse } from "types";
|
||||
import { IIssue, IProjectMember, ModuleIssueResponse, UserAuth } from "types";
|
||||
// constants
|
||||
import { STATE_LIST, MODULE_ISSUES } from "constants/fetch-keys";
|
||||
|
||||
@ -26,8 +26,6 @@ type Props = {
|
||||
members: IProjectMember[] | undefined;
|
||||
openCreateIssueModal: (issue?: IIssue, actionType?: "create" | "edit" | "delete") => void;
|
||||
openIssuesListModal: () => void;
|
||||
removeIssueFromModule: (issueId: string) => void;
|
||||
partialUpdateIssue: (formData: Partial<IIssue>, issueId: string) => void;
|
||||
handleDeleteIssue: React.Dispatch<React.SetStateAction<string | undefined>>;
|
||||
setPreloadedData: React.Dispatch<
|
||||
React.SetStateAction<
|
||||
@ -37,6 +35,7 @@ type Props = {
|
||||
| null
|
||||
>
|
||||
>;
|
||||
userAuth: UserAuth;
|
||||
};
|
||||
|
||||
const ModulesBoardView: React.FC<Props> = ({
|
||||
@ -44,10 +43,9 @@ const ModulesBoardView: React.FC<Props> = ({
|
||||
members,
|
||||
openCreateIssueModal,
|
||||
openIssuesListModal,
|
||||
removeIssueFromModule,
|
||||
partialUpdateIssue,
|
||||
handleDeleteIssue,
|
||||
setPreloadedData,
|
||||
userAuth,
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, moduleId } = router.query;
|
||||
@ -131,7 +129,7 @@ const ModulesBoardView: React.FC<Props> = ({
|
||||
return (
|
||||
<>
|
||||
{groupedByIssues ? (
|
||||
<div className="h-screen w-full">
|
||||
<div className="h-[calc(100vh-157px)] lg:h-[calc(100vh-115px)] w-full">
|
||||
<DragDropContext onDragEnd={handleOnDragEnd}>
|
||||
<div className="h-full w-full overflow-hidden">
|
||||
<div className="h-full w-full">
|
||||
@ -154,10 +152,8 @@ const ModulesBoardView: React.FC<Props> = ({
|
||||
: "#000000"
|
||||
}
|
||||
properties={properties}
|
||||
removeIssueFromModule={removeIssueFromModule}
|
||||
openIssuesListModal={openIssuesListModal}
|
||||
openCreateIssueModal={openCreateIssueModal}
|
||||
partialUpdateIssue={partialUpdateIssue}
|
||||
handleDeleteIssue={handleDeleteIssue}
|
||||
setPreloadedData={setPreloadedData}
|
||||
stateId={
|
||||
@ -165,6 +161,7 @@ const ModulesBoardView: React.FC<Props> = ({
|
||||
? states?.find((s) => s.name === singleGroup)?.id ?? null
|
||||
: null
|
||||
}
|
||||
userAuth={userAuth}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
@ -17,7 +17,7 @@ import { CustomMenu } from "components/ui";
|
||||
// icons
|
||||
import { PlusIcon } from "@heroicons/react/24/outline";
|
||||
// types
|
||||
import { IIssue, IWorkspaceMember, NestedKeyOf, Properties } from "types";
|
||||
import { IIssue, IWorkspaceMember, NestedKeyOf, Properties, UserAuth } from "types";
|
||||
// fetch-keys
|
||||
import { WORKSPACE_MEMBERS } from "constants/fetch-keys";
|
||||
|
||||
@ -32,8 +32,6 @@ type Props = {
|
||||
bgColor?: string;
|
||||
openCreateIssueModal: (issue?: IIssue, actionType?: "create" | "edit" | "delete") => void;
|
||||
openIssuesListModal: () => void;
|
||||
removeIssueFromModule: (bridgeId: string) => void;
|
||||
partialUpdateIssue: (formData: Partial<IIssue>, issueId: string) => void;
|
||||
handleDeleteIssue: React.Dispatch<React.SetStateAction<string | undefined>>;
|
||||
setPreloadedData: React.Dispatch<
|
||||
React.SetStateAction<
|
||||
@ -44,6 +42,7 @@ type Props = {
|
||||
>
|
||||
>;
|
||||
stateId: string | null;
|
||||
userAuth: UserAuth;
|
||||
};
|
||||
|
||||
const SingleModuleBoard: React.FC<Props> = ({
|
||||
@ -55,17 +54,16 @@ const SingleModuleBoard: React.FC<Props> = ({
|
||||
bgColor,
|
||||
openCreateIssueModal,
|
||||
openIssuesListModal,
|
||||
removeIssueFromModule,
|
||||
partialUpdateIssue,
|
||||
handleDeleteIssue,
|
||||
setPreloadedData,
|
||||
stateId,
|
||||
userAuth,
|
||||
}) => {
|
||||
// Collapse/Expand
|
||||
const [isCollapsed, setIsCollapsed] = useState(true);
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug } = router.query;
|
||||
const { workspaceSlug, moduleId } = router.query;
|
||||
|
||||
if (selectedGroup === "priority")
|
||||
groupTitle === "high"
|
||||
@ -112,10 +110,10 @@ const SingleModuleBoard: React.FC<Props> = ({
|
||||
{...provided.droppableProps}
|
||||
ref={provided.innerRef}
|
||||
>
|
||||
{groupedByIssues[groupTitle].map((childIssue, index: number) => {
|
||||
{groupedByIssues[groupTitle].map((issue, index: number) => {
|
||||
const assignees = [
|
||||
...(childIssue?.assignees_list ?? []),
|
||||
...(childIssue?.assignees ?? []),
|
||||
...(issue?.assignees_list ?? []),
|
||||
...(issue?.assignees ?? []),
|
||||
]?.map((assignee) => {
|
||||
const tempPerson = people?.find((p) => p.member.id === assignee)?.member;
|
||||
|
||||
@ -123,7 +121,7 @@ const SingleModuleBoard: React.FC<Props> = ({
|
||||
});
|
||||
|
||||
return (
|
||||
<Draggable key={childIssue.id} draggableId={childIssue.id} index={index}>
|
||||
<Draggable key={issue.id} draggableId={issue.id} index={index}>
|
||||
{(provided, snapshot) => (
|
||||
<div
|
||||
ref={provided.innerRef}
|
||||
@ -131,13 +129,15 @@ const SingleModuleBoard: React.FC<Props> = ({
|
||||
{...provided.dragHandleProps}
|
||||
>
|
||||
<SingleIssue
|
||||
issue={childIssue}
|
||||
type="module"
|
||||
typeId={moduleId as string}
|
||||
issue={issue}
|
||||
properties={properties}
|
||||
snapshot={snapshot}
|
||||
assignees={assignees}
|
||||
people={people}
|
||||
partialUpdateIssue={partialUpdateIssue}
|
||||
handleDeleteIssue={handleDeleteIssue}
|
||||
userAuth={userAuth}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
@ -1,19 +1,21 @@
|
||||
// react
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
// next
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
// swr
|
||||
|
||||
import { mutate } from "swr";
|
||||
// services
|
||||
|
||||
// headless ui
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
// services
|
||||
import modulesService from "services/modules.service";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
// ui
|
||||
import { Button } from "components/ui";
|
||||
// icons
|
||||
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
|
||||
// types
|
||||
import type { IModule } from "types";
|
||||
import { Button } from "components/ui";
|
||||
import modulesService from "services/modules.service";
|
||||
// fetch-keys
|
||||
import { MODULE_LIST } from "constants/fetch-keys";
|
||||
|
||||
@ -31,6 +33,8 @@ const ConfirmModuleDeletion: React.FC<Props> = ({ isOpen, setIsOpen, data }) =>
|
||||
query: { workspaceSlug },
|
||||
} = router;
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const cancelButtonRef = useRef(null);
|
||||
|
||||
const handleClose = () => {
|
||||
@ -48,6 +52,12 @@ const ConfirmModuleDeletion: React.FC<Props> = ({ isOpen, setIsOpen, data }) =>
|
||||
mutate(MODULE_LIST(data.project));
|
||||
router.push(`/${workspaceSlug}/projects/${data.project}/modules`);
|
||||
handleClose();
|
||||
|
||||
setToastAlert({
|
||||
title: "Success",
|
||||
type: "success",
|
||||
message: "Module deleted successfully",
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log(error);
|
||||
|
@ -4,22 +4,25 @@ import { useRouter } from "next/router";
|
||||
|
||||
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";
|
||||
// types
|
||||
import type { IModule } from "types";
|
||||
// components
|
||||
import SelectLead from "components/project/modules/create-update-module-modal/select-lead";
|
||||
import SelectMembers from "components/project/modules/create-update-module-modal/select-members";
|
||||
import SelectStatus from "components/project/modules/create-update-module-modal/select-status";
|
||||
// ui
|
||||
import { Button, Input, TextArea } from "components/ui";
|
||||
import { Button, CustomDatePicker, Input, TextArea } from "components/ui";
|
||||
// services
|
||||
import modulesService from "services/modules.service";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
// helpers
|
||||
import { renderDateFormat } from "helpers/date-time.helper";
|
||||
// fetch keys
|
||||
// types
|
||||
import type { IModule } from "types";
|
||||
// fetch-keys
|
||||
import { MODULE_LIST } from "constants/fetch-keys";
|
||||
|
||||
type Props = {
|
||||
@ -41,6 +44,8 @@ const CreateUpdateModuleModal: React.FC<Props> = ({ isOpen, setIsOpen, data, pro
|
||||
const router = useRouter();
|
||||
const { workspaceSlug } = router.query;
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const {
|
||||
register,
|
||||
formState: { errors, isSubmitting },
|
||||
@ -65,6 +70,12 @@ const CreateUpdateModuleModal: React.FC<Props> = ({ isOpen, setIsOpen, data, pro
|
||||
.then(() => {
|
||||
mutate(MODULE_LIST(projectId));
|
||||
handleClose();
|
||||
|
||||
setToastAlert({
|
||||
title: "Success",
|
||||
type: "success",
|
||||
message: "Module created successfully",
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
Object.keys(err).map((key) => {
|
||||
@ -91,6 +102,12 @@ const CreateUpdateModuleModal: React.FC<Props> = ({ isOpen, setIsOpen, data, pro
|
||||
false
|
||||
);
|
||||
handleClose();
|
||||
|
||||
setToastAlert({
|
||||
title: "Success",
|
||||
type: "success",
|
||||
message: "Module updated successfully",
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
Object.keys(err).map((key) => {
|
||||
@ -161,6 +178,10 @@ const CreateUpdateModuleModal: React.FC<Props> = ({ isOpen, setIsOpen, data, pro
|
||||
register={register}
|
||||
validations={{
|
||||
required: "Name is required",
|
||||
maxLength: {
|
||||
value: 255,
|
||||
message: "Name should be less than 255 characters",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@ -176,32 +197,62 @@ const CreateUpdateModuleModal: React.FC<Props> = ({ isOpen, setIsOpen, data, pro
|
||||
</div>
|
||||
<div className="flex gap-x-2">
|
||||
<div className="w-full">
|
||||
<Input
|
||||
id="start_date"
|
||||
label="Start Date"
|
||||
name="start_date"
|
||||
type="date"
|
||||
placeholder="Enter start date"
|
||||
error={errors.start_date}
|
||||
register={register}
|
||||
validations={{
|
||||
required: "Start date is required",
|
||||
}}
|
||||
/>
|
||||
<h6 className="text-gray-500">Start Date</h6>
|
||||
<div className="w-full">
|
||||
<Controller
|
||||
control={control}
|
||||
name="start_date"
|
||||
rules={{ required: "Start date is required" }}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<CustomDatePicker
|
||||
renderAs="input"
|
||||
value={value}
|
||||
onChange={(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 className="w-full">
|
||||
<Input
|
||||
id="target_date"
|
||||
label="Target Date"
|
||||
name="target_date"
|
||||
type="date"
|
||||
placeholder="Enter target date"
|
||||
error={errors.target_date}
|
||||
register={register}
|
||||
validations={{
|
||||
required: "Target date is required",
|
||||
}}
|
||||
/>
|
||||
<h6 className="text-gray-500">Target Date</h6>
|
||||
<div className="w-full">
|
||||
<Controller
|
||||
control={control}
|
||||
name="target_date"
|
||||
rules={{ required: "Target date is required" }}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<CustomDatePicker
|
||||
renderAs="input"
|
||||
value={value}
|
||||
onChange={(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 className="flex flex-wrap items-center gap-2">
|
||||
|
@ -18,7 +18,7 @@ import { CustomMenu, Spinner } from "components/ui";
|
||||
// helpers
|
||||
import { addSpaceIfCamelCase } from "helpers/string.helper";
|
||||
// types
|
||||
import { IIssue, IWorkspaceMember } from "types";
|
||||
import { IIssue, IWorkspaceMember, UserAuth } from "types";
|
||||
// fetch-keys
|
||||
import { STATE_LIST, WORKSPACE_MEMBERS } from "constants/fetch-keys";
|
||||
|
||||
@ -35,6 +35,7 @@ type Props = {
|
||||
| null
|
||||
>
|
||||
>;
|
||||
userAuth: UserAuth;
|
||||
};
|
||||
|
||||
const ModulesListView: React.FC<Props> = ({
|
||||
@ -43,6 +44,7 @@ const ModulesListView: React.FC<Props> = ({
|
||||
openIssuesListModal,
|
||||
removeIssueFromModule,
|
||||
setPreloadedData,
|
||||
userAuth,
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, moduleId } = router.query;
|
||||
@ -137,6 +139,7 @@ const ModulesListView: React.FC<Props> = ({
|
||||
properties={properties}
|
||||
editIssue={() => openCreateIssueModal(issue, "edit")}
|
||||
removeIssue={() => removeIssueFromModule(issue.bridge ?? "")}
|
||||
userAuth={userAuth}
|
||||
/>
|
||||
);
|
||||
})
|
||||
|
@ -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 SelectStatus from "components/project/modules/module-detail-sidebar/select-status";
|
||||
import ModuleLinkModal from "components/project/modules/module-link-modal";
|
||||
//progress-bar
|
||||
import { CircularProgressbar } from "react-circular-progressbar";
|
||||
import "react-circular-progressbar/dist/styles.css";
|
||||
// ui
|
||||
import { Loader } from "components/ui";
|
||||
import { CustomDatePicker, Loader } from "components/ui";
|
||||
// icons
|
||||
// helpers
|
||||
import { timeAgo } from "helpers/date-time.helper";
|
||||
@ -36,8 +39,8 @@ import { MODULE_LIST } from "constants/fetch-keys";
|
||||
|
||||
const defaultValues: Partial<IModule> = {
|
||||
members_list: [],
|
||||
start_date: new Date().toString(),
|
||||
target_date: new Date().toString(),
|
||||
start_date: null,
|
||||
target_date: null,
|
||||
status: null,
|
||||
};
|
||||
|
||||
@ -85,16 +88,21 @@ const ModuleDetailSidebar: React.FC<Props> = ({
|
||||
const submitChanges = (data: Partial<IModule>) => {
|
||||
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
|
||||
.patchModule(workspaceSlug as string, projectId as string, module.id, data)
|
||||
.then((res) => {
|
||||
console.log(res);
|
||||
mutate<IModule[]>(projectId && MODULE_LIST(projectId as string), (prevData) =>
|
||||
(prevData ?? []).map((module) => {
|
||||
if (module.id === moduleId) return { ...module, ...data };
|
||||
return module;
|
||||
})
|
||||
);
|
||||
mutate(MODULE_LIST(projectId as string));
|
||||
})
|
||||
.catch((e) => {
|
||||
console.log(e);
|
||||
@ -161,7 +169,13 @@ const ModuleDetailSidebar: React.FC<Props> = ({
|
||||
</div>
|
||||
<div className="flex items-center gap-2 sm:basis-1/2">
|
||||
<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>
|
||||
{groupedIssues.completed.length}/{moduleIssues?.length}
|
||||
</div>
|
||||
@ -177,16 +191,16 @@ const ModuleDetailSidebar: React.FC<Props> = ({
|
||||
<Controller
|
||||
control={control}
|
||||
name="start_date"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<input
|
||||
type="date"
|
||||
id="moduleStartDate"
|
||||
value={value ?? ""}
|
||||
onChange={(e: any) => {
|
||||
submitChanges({ start_date: e.target.value });
|
||||
onChange(e.target.value);
|
||||
render={({ field: { value } }) => (
|
||||
<CustomDatePicker
|
||||
value={value}
|
||||
onChange={(val: Date) => {
|
||||
submitChanges({
|
||||
start_date: val
|
||||
? `${val.getFullYear()}-${val.getMonth() + 1}-${val.getDate()}`
|
||||
: 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
|
||||
control={control}
|
||||
name="target_date"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<input
|
||||
type="date"
|
||||
id="moduleTargetDate"
|
||||
value={value ?? ""}
|
||||
onChange={(e: any) => {
|
||||
submitChanges({ target_date: e.target.value });
|
||||
onChange(e.target.value);
|
||||
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 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"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
@ -12,9 +12,7 @@ import { UserGroupIcon } from "@heroicons/react/24/outline";
|
||||
import workspaceService from "services/workspace.service";
|
||||
// headless ui
|
||||
// ui
|
||||
import { Spinner } from "components/ui";
|
||||
// icons
|
||||
import User from "public/user.png";
|
||||
import { AssigneesList, Spinner } from "components/ui";
|
||||
// types
|
||||
import { IModule } from "types";
|
||||
// constants
|
||||
@ -64,52 +62,7 @@ const SelectMembers: React.FC<Props> = ({ control, submitChanges }) => {
|
||||
>
|
||||
<div className="flex cursor-pointer items-center gap-1 text-xs">
|
||||
{value && Array.isArray(value) ? (
|
||||
<>
|
||||
{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>
|
||||
)}
|
||||
</>
|
||||
<AssigneesList userIds={value} length={10} />
|
||||
) : null}
|
||||
</div>
|
||||
</span>
|
||||
|
@ -1,14 +1,15 @@
|
||||
import React from "react";
|
||||
import React, { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/router";
|
||||
import { CalendarDaysIcon, TrashIcon } from "@heroicons/react/24/outline";
|
||||
import ConfirmModuleDeletion from "./confirm-module-deletion";
|
||||
// icons
|
||||
import { CalendarDaysIcon } from "@heroicons/react/24/outline";
|
||||
import User from "public/user.png";
|
||||
// helpers
|
||||
import { renderShortNumericDateFormat } from "helpers/date-time.helper";
|
||||
// types
|
||||
import { IModule } from "types";
|
||||
import { IModule, SelectModuleType } from "types";
|
||||
// common
|
||||
import { MODULE_STATUS } from "constants/";
|
||||
|
||||
@ -19,103 +20,131 @@ type Props = {
|
||||
const SingleModuleCard: React.FC<Props> = ({ module }) => {
|
||||
const router = useRouter();
|
||||
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 (
|
||||
<Link href={`/${workspaceSlug}/projects/${module.project}/modules/${module.id}`}>
|
||||
<a className="block cursor-pointer rounded-md border bg-white p-3">
|
||||
{module.name}
|
||||
<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">
|
||||
<div className="group/card h-full w-full relative select-none p-2">
|
||||
<div className="absolute top-4 right-4 z-50 bg-red-200 opacity-0 group-hover/card:opacity-100">
|
||||
<button
|
||||
type="button"
|
||||
className="grid h-7 w-7 place-items-center bg-white p-1 text-red-500 outline-none duration-300 hover:bg-red-50"
|
||||
onClick={() => handleDeleteModule()}
|
||||
>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</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
|
||||
src={module.lead_detail.avatar}
|
||||
src={User}
|
||||
height="100%"
|
||||
width="100%"
|
||||
className="rounded-full"
|
||||
alt={module.lead_detail.first_name}
|
||||
alt="No user"
|
||||
/>
|
||||
</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">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>
|
||||
<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
|
||||
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>
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -9,7 +9,7 @@ import { useForm, Controller } from "react-hook-form";
|
||||
import { Dialog, Transition, Listbox } from "@headlessui/react";
|
||||
// ui
|
||||
import { ChevronDownIcon, CheckIcon } from "@heroicons/react/20/solid";
|
||||
import { Button, Select, TextArea } from "components/ui";
|
||||
import { Button, CustomSelect, Select, TextArea } from "components/ui";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
// services
|
||||
@ -106,7 +106,7 @@ const SendProjectInvitationModal: React.FC<Props> = ({ isOpen, setIsOpen, member
|
||||
|
||||
return (
|
||||
<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
|
||||
as={React.Fragment}
|
||||
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" />
|
||||
</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">
|
||||
<Transition.Child
|
||||
as={React.Fragment}
|
||||
@ -196,40 +196,17 @@ const SendProjectInvitationModal: React.FC<Props> = ({ isOpen, setIsOpen, member
|
||||
uninvitedPeople?.map((person) => (
|
||||
<Listbox.Option
|
||||
key={person.member.id}
|
||||
className={({ active }) =>
|
||||
`${
|
||||
active ? "bg-theme text-white" : "text-gray-900"
|
||||
} relative cursor-default select-none py-2 pl-3 pr-9 text-left`
|
||||
className={({ active, selected }) =>
|
||||
`${active ? "bg-indigo-50" : ""} ${
|
||||
selected ? "bg-indigo-50 font-medium" : ""
|
||||
} text-gray-900 cursor-default select-none p-2`
|
||||
}
|
||||
value={{
|
||||
id: person.member.id,
|
||||
email: person.member.email,
|
||||
}}
|
||||
>
|
||||
{({ selected, active }) => (
|
||||
<>
|
||||
<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}
|
||||
</>
|
||||
)}
|
||||
{person.member.email}
|
||||
</Listbox.Option>
|
||||
))
|
||||
)}
|
||||
@ -246,22 +223,28 @@ const SendProjectInvitationModal: React.FC<Props> = ({ isOpen, setIsOpen, member
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div>
|
||||
<Select
|
||||
id="role"
|
||||
label="Role"
|
||||
name="role"
|
||||
error={errors.role}
|
||||
register={register}
|
||||
validations={{
|
||||
required: "Role is required",
|
||||
}}
|
||||
options={Object.entries(ROLE).map(([key, value]) => ({
|
||||
value: key,
|
||||
label: value,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
<h6 className="text-gray-500">Role</h6>
|
||||
<Controller
|
||||
name="role"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<CustomSelect
|
||||
{...field}
|
||||
label={
|
||||
<span className="capitalize">
|
||||
{field.value ? ROLE[field.value] : "Select role"}
|
||||
</span>
|
||||
}
|
||||
input
|
||||
>
|
||||
{Object.entries(ROLE).map(([key, label]) => (
|
||||
<CustomSelect.Option key={key} value={key}>
|
||||
{label}
|
||||
</CustomSelect.Option>
|
||||
))}
|
||||
</CustomSelect>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<TextArea
|
||||
|
@ -47,6 +47,7 @@ export interface IRemirrorRichTextEditor {
|
||||
value?: any;
|
||||
showToolbar?: boolean;
|
||||
editable?: boolean;
|
||||
customClassName?: string;
|
||||
}
|
||||
|
||||
const RemirrorRichTextEditor: FC<IRemirrorRichTextEditor> = (props) => {
|
||||
@ -60,6 +61,7 @@ const RemirrorRichTextEditor: FC<IRemirrorRichTextEditor> = (props) => {
|
||||
value = "",
|
||||
showToolbar = true,
|
||||
editable = true,
|
||||
customClassName,
|
||||
} = props;
|
||||
|
||||
const [imageLoader, setImageLoader] = useState(false);
|
||||
@ -91,7 +93,7 @@ const RemirrorRichTextEditor: FC<IRemirrorRichTextEditor> = (props) => {
|
||||
() =>
|
||||
new Promise(async (resolve, reject) => {
|
||||
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);
|
||||
|
||||
resolve({
|
||||
@ -173,7 +175,7 @@ const RemirrorRichTextEditor: FC<IRemirrorRichTextEditor> = (props) => {
|
||||
<Remirror
|
||||
manager={manager}
|
||||
initialContent={state}
|
||||
classNames={["p-4 focus:outline-none"]}
|
||||
classNames={[`p-4 focus:outline-none ${customClassName}`]}
|
||||
editable={editable}
|
||||
onBlur={() => {
|
||||
onBlur(jsonValue, htmlValue);
|
||||
|
@ -14,6 +14,7 @@ type CustomSelectProps = {
|
||||
width?: "auto" | string;
|
||||
input?: boolean;
|
||||
noChevron?: boolean;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
const CustomSelect = ({
|
||||
@ -26,11 +27,20 @@ const CustomSelect = ({
|
||||
width = "auto",
|
||||
input = false,
|
||||
noChevron = false,
|
||||
disabled = false,
|
||||
}: 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>
|
||||
<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"
|
||||
} ${
|
||||
textAlignment === "right"
|
||||
|
40
apps/app/components/ui/datepicker.tsx
Normal file
40
apps/app/components/ui/datepicker.tsx
Normal 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}
|
||||
/>
|
||||
);
|
@ -13,3 +13,4 @@ export * from "./spinner";
|
||||
export * from "./text-area";
|
||||
export * from "./tooltip";
|
||||
export * from "./avatar";
|
||||
export * from "./datepicker";
|
||||
|
@ -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;
|
||||
content: React.ReactNode;
|
||||
position?: "top" | "bottom" | "left" | "right";
|
||||
customStyle?: string;
|
||||
};
|
||||
|
||||
const Tooltip: React.FC<Props> = ({ children, content, position = "top" }) => (
|
||||
<div className="relative group">
|
||||
<div
|
||||
className={`fixed pointer-events-none transition-opacity opacity-0 group-hover:opacity-100 bg-black text-white px-3 py-1 rounded ${
|
||||
position === "right"
|
||||
? "left-14"
|
||||
: position === "left"
|
||||
? "right-14"
|
||||
: position === "top"
|
||||
? "bottom-14"
|
||||
: "top-14"
|
||||
}`}
|
||||
>
|
||||
<p className="truncate text-xs">{content}</p>
|
||||
<span
|
||||
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"
|
||||
: position === "bottom"
|
||||
? "bottom-full left-1/2 transform translate-y-1/2 -translate-x-1/2 rotate-45"
|
||||
: 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"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
const Tooltip: React.FC<Props> = ({
|
||||
content,
|
||||
direction = "top",
|
||||
children,
|
||||
margin = "24px",
|
||||
customStyle,
|
||||
}) => {
|
||||
const [active, setActive] = useState(false);
|
||||
const [styleConfig, setStyleConfig] = useState("top-[calc(-100%-24px)]");
|
||||
let timeout: any;
|
||||
|
||||
const showToolTip = () => {
|
||||
timeout = setTimeout(() => {
|
||||
setActive(true);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
const hideToolTip = () => {
|
||||
clearInterval(timeout);
|
||||
setActive(false);
|
||||
};
|
||||
|
||||
const tooltipStyles = {
|
||||
top: `
|
||||
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: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}
|
||||
{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>
|
||||
);
|
||||
};
|
||||
|
||||
export default Tooltip;
|
||||
|
@ -4,19 +4,20 @@ import { useRouter } from "next/router";
|
||||
|
||||
import { mutate } from "swr";
|
||||
|
||||
// headless ui
|
||||
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
|
||||
import workspaceService from "services/workspace.service";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
// icons
|
||||
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
|
||||
// ui
|
||||
import { Button, Input } from "components/ui";
|
||||
// types
|
||||
import type { IWorkspace } from "types";
|
||||
// fetch-keys
|
||||
import { USER_WORKSPACES } from "constants/fetch-keys";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
@ -78,7 +79,7 @@ const ConfirmWorkspaceDeletion: React.FC<Props> = ({ isOpen, data, onClose }) =>
|
||||
<Transition.Root show={isOpen} as={React.Fragment}>
|
||||
<Dialog
|
||||
as="div"
|
||||
className="relative z-10"
|
||||
className="relative z-20"
|
||||
initialFocus={cancelButtonRef}
|
||||
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" />
|
||||
</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">
|
||||
<Transition.Child
|
||||
as={React.Fragment}
|
||||
|
@ -33,7 +33,7 @@ const ConfirmWorkspaceMemberRemove: React.FC<Props> = ({ isOpen, onClose, data,
|
||||
<Transition.Root show={isOpen} as={React.Fragment}>
|
||||
<Dialog
|
||||
as="div"
|
||||
className="relative z-10"
|
||||
className="relative z-20"
|
||||
initialFocus={cancelButtonRef}
|
||||
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" />
|
||||
</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">
|
||||
<Transition.Child
|
||||
as={React.Fragment}
|
||||
|
@ -53,6 +53,8 @@ export const WorkspaceHelpSection: FC<WorkspaceHelpSectionProps> = (props) => {
|
||||
// hooks
|
||||
useOutsideClickDetector(helpOptionsRef, () => setIsNeedHelpOpen(false));
|
||||
|
||||
const helpOptionMode = sidebarCollapse ? "left-full" : "left-[-75px]";
|
||||
|
||||
return (
|
||||
<div
|
||||
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"
|
||||
>
|
||||
<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}
|
||||
>
|
||||
{helpOptions.map(({ name, Icon, href }) => (
|
||||
<Link href={href} key={name}>
|
||||
<a
|
||||
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" />
|
||||
<span className="text-sm">{name}</span>
|
||||
@ -132,7 +134,7 @@ export const WorkspaceHelpSection: FC<WorkspaceHelpSectionProps> = (props) => {
|
||||
title="Help"
|
||||
>
|
||||
<QuestionMarkCircleIcon className="h-4 w-4 text-gray-500" />
|
||||
{!sidebarCollapse && <span>Need help?</span>}
|
||||
{!sidebarCollapse && <span>Help ?</span>}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -79,7 +79,7 @@ const SendWorkspaceInvitationModal: React.FC<Props> = ({
|
||||
|
||||
return (
|
||||
<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
|
||||
as={React.Fragment}
|
||||
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" />
|
||||
</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">
|
||||
<Transition.Child
|
||||
as={React.Fragment}
|
||||
|
@ -23,7 +23,7 @@ export const PROJECT_ISSUES_DETAILS = (issueId: string) => `PROJECT_ISSUES_DETAI
|
||||
export const PROJECT_ISSUES_PROPERTIES = (projectId: string) =>
|
||||
`PROJECT_ISSUES_PROPERTIES_${projectId}`;
|
||||
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_LABELS = (projectId: string) => `PROJECT_ISSUE_LABELS_${projectId}`;
|
||||
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { createContext, useCallback, useReducer } from "react";
|
||||
import { createContext, useCallback, useEffect, useReducer } from "react";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
@ -24,6 +24,7 @@ type IssueViewProps = {
|
||||
|
||||
type ReducerActionType = {
|
||||
type:
|
||||
| "REHYDRATE_THEME"
|
||||
| "SET_ISSUE_VIEW"
|
||||
| "SET_ORDER_BY_PROPERTY"
|
||||
| "SET_FILTER_ISSUES"
|
||||
@ -65,6 +66,12 @@ export const reducer: ReducerFunctionType = (state, action) => {
|
||||
const { type, payload } = action;
|
||||
|
||||
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": {
|
||||
const newState = {
|
||||
...state,
|
||||
@ -260,6 +267,13 @@ export const IssueViewContextProvider: React.FC<{ children: React.ReactNode }> =
|
||||
saveDataToServer(workspaceSlug as string, projectId as string, myViewProps?.default_props);
|
||||
}, [projectId, workspaceSlug, myViewProps]);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch({
|
||||
type: "REHYDRATE_THEME",
|
||||
payload: myViewProps?.view_props,
|
||||
});
|
||||
}, [myViewProps]);
|
||||
|
||||
return (
|
||||
<issueViewContext.Provider
|
||||
value={{
|
||||
|
@ -13,7 +13,7 @@ const initialValues: Properties = {
|
||||
assignee: true,
|
||||
priority: false,
|
||||
due_date: false,
|
||||
cycle: false,
|
||||
// cycle: false,
|
||||
sub_issue_count: false,
|
||||
};
|
||||
|
||||
@ -86,7 +86,7 @@ const useIssuesProperties = (workspaceSlug?: string, projectId?: string) => {
|
||||
assignee: properties.assignee,
|
||||
priority: properties.priority,
|
||||
due_date: properties.due_date,
|
||||
cycle: properties.cycle,
|
||||
// cycle: properties.cycle,
|
||||
sub_issue_count: properties.sub_issue_count,
|
||||
};
|
||||
|
||||
|
@ -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;
|
@ -21,7 +21,7 @@ const initialValues: Properties = {
|
||||
assignee: true,
|
||||
priority: false,
|
||||
due_date: false,
|
||||
cycle: false,
|
||||
// cycle: false,
|
||||
sub_issue_count: false,
|
||||
};
|
||||
|
||||
|
@ -84,6 +84,8 @@ const Sidebar: React.FC<Props> = ({ toggleSidebar, setToggleSidebar }) => {
|
||||
|
||||
const [isNeedHelpOpen, setIsNeedHelpOpen] = useState(false);
|
||||
|
||||
const helpOptionMode = sidebarCollapse ? "left-full" : "left-[-75px]";
|
||||
|
||||
return (
|
||||
<nav className="relative z-20 h-screen">
|
||||
<div
|
||||
@ -148,14 +150,14 @@ const Sidebar: React.FC<Props> = ({ toggleSidebar, setToggleSidebar }) => {
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<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}
|
||||
>
|
||||
{helpOptions.map(({ name, Icon, href }) => (
|
||||
<Link href={href} key={name}>
|
||||
<a
|
||||
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" />
|
||||
<span className="text-sm">{name}</span>
|
||||
@ -173,7 +175,7 @@ const Sidebar: React.FC<Props> = ({ toggleSidebar, setToggleSidebar }) => {
|
||||
title="Help"
|
||||
>
|
||||
<QuestionMarkCircleIcon className="h-4 w-4 text-gray-500" />
|
||||
{!sidebarCollapse && <span>Need help?</span>}
|
||||
{!sidebarCollapse && <span>Help ?</span>}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -15,13 +15,18 @@
|
||||
"@remirror/extension-react-tables": "^2.2.11",
|
||||
"@remirror/pm": "^2.0.3",
|
||||
"@remirror/react": "^2.0.24",
|
||||
"@types/lodash.debounce": "^4.0.7",
|
||||
"@types/react-datepicker": "^4.8.0",
|
||||
"axios": "^1.1.3",
|
||||
"js-cookie": "^3.0.1",
|
||||
"lodash.debounce": "^4.0.8",
|
||||
"next": "12.3.2",
|
||||
"next-pwa": "^5.6.0",
|
||||
"react": "18.2.0",
|
||||
"react-beautiful-dnd": "^13.1.1",
|
||||
"react-circular-progressbar": "^2.1.0",
|
||||
"react-color": "^2.19.3",
|
||||
"react-datepicker": "^4.8.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react-dropzone": "^14.2.3",
|
||||
"react-hook-form": "^7.38.0",
|
||||
|
@ -164,8 +164,7 @@ const WorkspacePage: NextPage = () => {
|
||||
<LayerDiagonalIcon height="56" width="56" />
|
||||
<h3 className="text-gray-500">
|
||||
No issues found. Create a new issue with{" "}
|
||||
<pre className="inline rounded bg-gray-100 px-2 py-1">Ctrl/Command + I</pre>
|
||||
.
|
||||
<pre className="inline rounded bg-gray-100 px-2 py-1">C</pre>.
|
||||
</h3>
|
||||
</div>
|
||||
)
|
||||
|
@ -163,9 +163,8 @@ const MyIssuesPage: NextPage = () => {
|
||||
title="Create a new issue"
|
||||
description={
|
||||
<span>
|
||||
Use{" "}
|
||||
<pre className="inline rounded bg-gray-100 px-2 py-1">Ctrl/Command + I</pre>{" "}
|
||||
shortcut to create a new issue
|
||||
Use <pre className="inline rounded bg-gray-100 px-2 py-1">C</pre> shortcut
|
||||
to create a new issue
|
||||
</span>
|
||||
}
|
||||
Icon={PlusIcon}
|
||||
|
@ -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;
|
@ -4,6 +4,8 @@ import { useRouter } from "next/router";
|
||||
|
||||
import useSWR, { mutate } from "swr";
|
||||
|
||||
// lib
|
||||
import { requiredAdmin, requiredAuth } from "lib/auth";
|
||||
// layouts
|
||||
import AppLayout from "layouts/app-layout";
|
||||
// contexts
|
||||
@ -32,7 +34,8 @@ import { CustomMenu, EmptySpace, EmptySpaceItem, Spinner } from "components/ui";
|
||||
import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs";
|
||||
// icons
|
||||
// types
|
||||
import { CycleIssueResponse, IIssue, SelectIssue } from "types";
|
||||
import { CycleIssueResponse, IIssue, SelectIssue, UserAuth } from "types";
|
||||
import { NextPageContext } from "next";
|
||||
// fetch-keys
|
||||
import {
|
||||
CYCLE_ISSUES,
|
||||
@ -42,12 +45,12 @@ import {
|
||||
PROJECT_DETAILS,
|
||||
} from "constants/fetch-keys";
|
||||
|
||||
const SingleCycle: React.FC = () => {
|
||||
const SingleCycle: React.FC<UserAuth> = (props) => {
|
||||
const [isIssueModalOpen, setIsIssueModalOpen] = useState(false);
|
||||
const [selectedIssues, setSelectedIssues] = useState<SelectIssue>();
|
||||
const [cycleIssuesListModal, setCycleIssuesListModal] = useState(false);
|
||||
const [deleteIssue, setDeleteIssue] = useState<string | undefined>(undefined);
|
||||
const [cycleSidebar, setCycleSidebar] = useState(false);
|
||||
const [cycleSidebar, setCycleSidebar] = useState(true);
|
||||
|
||||
const [preloadedData, setPreloadedData] = useState<
|
||||
(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 = (
|
||||
issue?: IIssue,
|
||||
actionType: "create" | "edit" | "delete" = "create"
|
||||
@ -256,16 +247,16 @@ const SingleCycle: React.FC = () => {
|
||||
openIssuesListModal={openIssuesListModal}
|
||||
removeIssueFromCycle={removeIssueFromCycle}
|
||||
setPreloadedData={setPreloadedData}
|
||||
userAuth={props}
|
||||
/>
|
||||
<CyclesBoardView
|
||||
issues={cycleIssuesArray ?? []}
|
||||
removeIssueFromCycle={removeIssueFromCycle}
|
||||
members={members}
|
||||
openCreateIssueModal={openCreateIssueModal}
|
||||
openIssuesListModal={openIssuesListModal}
|
||||
handleDeleteIssue={setDeleteIssue}
|
||||
partialUpdateIssue={partialUpdateIssue}
|
||||
setPreloadedData={setPreloadedData}
|
||||
userAuth={props}
|
||||
/>
|
||||
</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;
|
||||
|
@ -178,8 +178,8 @@ const ProjectCycles: NextPage = () => {
|
||||
title="Create a new cycle"
|
||||
description={
|
||||
<span>
|
||||
Use <pre className="inline rounded bg-gray-100 px-2 py-1">Ctrl/Command + Q</pre>{" "}
|
||||
shortcut to create a new cycle
|
||||
Use <pre className="inline rounded bg-gray-100 px-2 py-1">Q</pre> shortcut to
|
||||
create a new cycle
|
||||
</span>
|
||||
}
|
||||
Icon={PlusIcon}
|
||||
|
@ -1,12 +1,14 @@
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import useSWR, { mutate } from "swr";
|
||||
|
||||
// react-hook-form
|
||||
import { useForm } from "react-hook-form";
|
||||
import { ChevronLeftIcon, ChevronRightIcon, PlusIcon } from "@heroicons/react/24/outline";
|
||||
// services
|
||||
import issuesService from "services/issues.service";
|
||||
import projectService from "services/project.service";
|
||||
// lib
|
||||
import { requiredAuth } from "lib/auth";
|
||||
// layouts
|
||||
@ -25,12 +27,13 @@ import {
|
||||
// ui
|
||||
import { Loader, HeaderButton, CustomMenu } from "components/ui";
|
||||
import { Breadcrumbs } from "components/breadcrumbs";
|
||||
// icons
|
||||
import { ChevronLeftIcon, ChevronRightIcon, PlusIcon } from "@heroicons/react/24/outline";
|
||||
// types
|
||||
import { IIssue } from "types";
|
||||
import type { NextPage, NextPageContext } from "next";
|
||||
// fetch-keys
|
||||
import {
|
||||
PROJECT_DETAILS,
|
||||
PROJECT_ISSUES_LIST,
|
||||
PROJECT_ISSUES_ACTIVITY,
|
||||
ISSUE_DETAILS,
|
||||
@ -52,8 +55,6 @@ const defaultValues = {
|
||||
};
|
||||
|
||||
const IssueDetailsPage: NextPage = () => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, issueId } = router.query;
|
||||
// states
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isAddAsSubIssueOpen, setIsAddAsSubIssueOpen] = useState(false);
|
||||
@ -61,15 +62,14 @@ const IssueDetailsPage: NextPage = () => {
|
||||
(Partial<IIssue> & { actionType: "createIssue" | "edit" | "delete" }) | undefined
|
||||
>(undefined);
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, issueId } = router.query;
|
||||
|
||||
const { data: issueDetails, mutate: mutateIssueDetails } = useSWR<IIssue | undefined>(
|
||||
issueId && workspaceSlug && projectId ? ISSUE_DETAILS(issueId as string) : null,
|
||||
issueId && workspaceSlug && projectId
|
||||
workspaceSlug && projectId && issueId ? ISSUE_DETAILS(issueId as string) : null,
|
||||
workspaceSlug && projectId && issueId
|
||||
? () =>
|
||||
issuesService.retrieve(
|
||||
workspaceSlug?.toString(),
|
||||
projectId?.toString(),
|
||||
issueId?.toString()
|
||||
)
|
||||
issuesService.retrieve(workspaceSlug as string, projectId as string, issueId as string)
|
||||
: null
|
||||
);
|
||||
|
||||
@ -81,13 +81,6 @@ const IssueDetailsPage: NextPage = () => {
|
||||
: 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(
|
||||
workspaceSlug && projectId
|
||||
? PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string)
|
||||
@ -98,7 +91,7 @@ const IssueDetailsPage: NextPage = () => {
|
||||
);
|
||||
|
||||
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
|
||||
? () =>
|
||||
issuesService.getIssueActivities(
|
||||
@ -141,12 +134,22 @@ const IssueDetailsPage: NextPage = () => {
|
||||
|
||||
const submitChanges = useCallback(
|
||||
(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 };
|
||||
issuesService
|
||||
.patchIssue(workspaceSlug as string, projectId as string, issueId as string, payload)
|
||||
.then((res) => {
|
||||
console.log(res);
|
||||
mutateIssueDetails();
|
||||
mutateIssueActivities();
|
||||
})
|
||||
@ -154,40 +157,22 @@ const IssueDetailsPage: NextPage = () => {
|
||||
console.error(e);
|
||||
});
|
||||
},
|
||||
[activeProject, workspaceSlug, issueId, projectId, mutateIssueDetails, mutateIssueActivities]
|
||||
[workspaceSlug, issueId, projectId, mutateIssueDetails, mutateIssueActivities]
|
||||
);
|
||||
|
||||
const handleSubIssueRemove = (issueId: string) => {
|
||||
if (workspaceSlug && activeProject) {
|
||||
issuesService
|
||||
.patchIssue(workspaceSlug as string, activeProject.id, issueId, { parent: null })
|
||||
.then((res) => {
|
||||
mutate(SUB_ISSUES(issueDetails?.id ?? ""));
|
||||
mutateIssueActivities();
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
});
|
||||
}
|
||||
};
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
||||
/**
|
||||
* Handling the debounce submit by updating the issue with name, description and description_html
|
||||
* @param values IssueDescriptionFormValues
|
||||
*/
|
||||
const handleDescriptionFormSubmit = useCallback(
|
||||
(values: IssueDescriptionFormValues) => {
|
||||
if (workspaceSlug && projectId && issueId) {
|
||||
issuesService
|
||||
.updateIssue(workspaceSlug?.toString(), projectId.toString(), issueId.toString(), values)
|
||||
.then((res) => {
|
||||
console.log(res);
|
||||
mutateIssueActivities();
|
||||
});
|
||||
}
|
||||
},
|
||||
[workspaceSlug, projectId, issueId, mutateIssueActivities]
|
||||
);
|
||||
issuesService
|
||||
.patchIssue(workspaceSlug as string, projectId as string, issueId, { parent: null })
|
||||
.then((res) => {
|
||||
mutate(SUB_ISSUES(issueDetails?.id ?? ""));
|
||||
mutateIssueActivities();
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<AppLayout
|
||||
@ -196,11 +181,11 @@ const IssueDetailsPage: NextPage = () => {
|
||||
breadcrumbs={
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
title={`${activeProject?.name ?? "Project"} Issues`}
|
||||
link={`/${workspaceSlug}/projects/${activeProject?.id}/issues`}
|
||||
title={`${issueDetails?.project_detail.name ?? "Project"} Issues`}
|
||||
link={`/${workspaceSlug}/projects/${projectId as string}/issues`}
|
||||
/>
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
title={`Issue ${activeProject?.identifier ?? "Project"}-${
|
||||
title={`Issue ${issueDetails?.project_detail.identifier ?? "Project"}-${
|
||||
issueDetails?.sequence_id ?? "..."
|
||||
} Details`}
|
||||
/>
|
||||
@ -249,14 +234,16 @@ const IssueDetailsPage: NextPage = () => {
|
||||
parent={issueDetails}
|
||||
/>
|
||||
)}
|
||||
{issueDetails && activeProject ? (
|
||||
{issueDetails && projectId ? (
|
||||
<div className="flex h-full">
|
||||
<div className="basis-2/3 space-y-5 divide-y-2 p-5">
|
||||
<div className="rounded-lg">
|
||||
{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">
|
||||
<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">
|
||||
<span
|
||||
@ -266,7 +253,7 @@ const IssueDetailsPage: NextPage = () => {
|
||||
}}
|
||||
/>
|
||||
<span className="flex-shrink-0 text-gray-600">
|
||||
{activeProject.identifier}-
|
||||
{issueDetails.project_detail.identifier}-
|
||||
{issues?.results.find((i) => i.id === issueDetails.parent)?.sequence_id}
|
||||
</span>
|
||||
<span className="truncate font-medium">
|
||||
@ -282,10 +269,12 @@ const IssueDetailsPage: NextPage = () => {
|
||||
siblingIssues.map((issue) => (
|
||||
<CustomMenu.MenuItem key={issue.id}>
|
||||
<Link
|
||||
href={`/${workspaceSlug}/projects/${activeProject.id}/issues/${issue.id}`}
|
||||
href={`/${workspaceSlug}/projects/${projectId as string}/issues/${
|
||||
issue.id
|
||||
}`}
|
||||
>
|
||||
<a>
|
||||
{activeProject.identifier}-{issue.sequence_id}
|
||||
{issueDetails.project_detail.identifier}-{issue.sequence_id}
|
||||
</a>
|
||||
</Link>
|
||||
</CustomMenu.MenuItem>
|
||||
@ -298,10 +287,7 @@ const IssueDetailsPage: NextPage = () => {
|
||||
</CustomMenu>
|
||||
</div>
|
||||
) : null}
|
||||
<IssueDescriptionForm
|
||||
issue={issueDetails}
|
||||
handleSubmit={handleDescriptionFormSubmit}
|
||||
/>
|
||||
<IssueDescriptionForm issue={issueDetails} handleFormSubmit={submitChanges} />
|
||||
<div className="mt-2">
|
||||
{issueId && workspaceSlug && projectId && subIssues?.length > 0 ? (
|
||||
<SubIssueList
|
||||
@ -358,7 +344,6 @@ const IssueDetailsPage: NextPage = () => {
|
||||
</div>
|
||||
</div>
|
||||
<div className="basis-1/3 space-y-5 border-l p-5">
|
||||
{/* TODO add flex-grow, if needed */}
|
||||
<IssueDetailSidebar
|
||||
control={control}
|
||||
issueDetail={issueDetails}
|
||||
|
@ -4,7 +4,7 @@ import useSWR, { mutate } from "swr";
|
||||
import { RectangleStackIcon } from "@heroicons/react/24/outline";
|
||||
import { PlusIcon } from "@heroicons/react/20/solid";
|
||||
// lib
|
||||
import { requiredAuth } from "lib/auth";
|
||||
import { requiredAdmin, requiredAuth } from "lib/auth";
|
||||
// services
|
||||
import issuesServices from "services/issues.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 { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs";
|
||||
// types
|
||||
import type { IIssue, IssueResponse } from "types";
|
||||
import type { IIssue, IssueResponse, UserAuth } from "types";
|
||||
import type { NextPage, NextPageContext } from "next";
|
||||
// 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 [selectedIssue, setSelectedIssue] = useState<
|
||||
(IIssue & { actionType: "edit" | "delete" }) | undefined
|
||||
@ -63,26 +63,6 @@ const ProjectIssues: NextPage = () => {
|
||||
}
|
||||
}, [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) => {
|
||||
setIsOpen(true);
|
||||
setSelectedIssue({ ...issue, actionType: "edit" });
|
||||
@ -134,12 +114,12 @@ const ProjectIssues: NextPage = () => {
|
||||
<ListView
|
||||
issues={projectIssues?.results.filter((p) => p.parent === null) ?? []}
|
||||
handleEditIssue={handleEditIssue}
|
||||
partialUpdateIssue={partialUpdateIssue}
|
||||
userAuth={props}
|
||||
/>
|
||||
<BoardView
|
||||
issues={projectIssues?.results.filter((p) => p.parent === null) ?? []}
|
||||
handleDeleteIssue={setDeleteIssue}
|
||||
partialUpdateIssue={partialUpdateIssue}
|
||||
userAuth={props}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
@ -153,8 +133,8 @@ const ProjectIssues: NextPage = () => {
|
||||
title="Create a new issue"
|
||||
description={
|
||||
<span>
|
||||
Use <pre className="inline rounded bg-gray-100 px-2 py-1">Ctrl/Command + I</pre>{" "}
|
||||
shortcut to create a new issue
|
||||
Use <pre className="inline rounded bg-gray-100 px-2 py-1">C</pre> shortcut to
|
||||
create a new issue
|
||||
</span>
|
||||
}
|
||||
Icon={PlusIcon}
|
||||
@ -170,7 +150,6 @@ const ProjectIssues: NextPage = () => {
|
||||
|
||||
export const getServerSideProps = async (ctx: NextPageContext) => {
|
||||
const user = await requiredAuth(ctx.req?.headers.cookie);
|
||||
|
||||
const redirectAfterSignIn = ctx.req?.url;
|
||||
|
||||
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 {
|
||||
props: {
|
||||
user,
|
||||
isOwner: memberDetail?.role === 20,
|
||||
isMember: memberDetail?.role === 15,
|
||||
isViewer: memberDetail?.role === 10,
|
||||
isGuest: memberDetail?.role === 5,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
@ -3,6 +3,9 @@ import React, { useState } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import useSWR, { mutate } from "swr";
|
||||
|
||||
// lib
|
||||
import { requiredAdmin, requiredAuth } from "lib/auth";
|
||||
// services
|
||||
import modulesService from "services/modules.service";
|
||||
import projectService from "services/project.service";
|
||||
@ -32,7 +35,15 @@ import {
|
||||
RectangleStackIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
// types
|
||||
import { IIssue, IModule, ModuleIssueResponse, SelectIssue, SelectModuleType } from "types";
|
||||
import {
|
||||
IIssue,
|
||||
IModule,
|
||||
ModuleIssueResponse,
|
||||
SelectIssue,
|
||||
SelectModuleType,
|
||||
UserAuth,
|
||||
} from "types";
|
||||
import { NextPageContext } from "next";
|
||||
// fetch-keys
|
||||
import {
|
||||
MODULE_DETAIL,
|
||||
@ -42,7 +53,7 @@ import {
|
||||
PROJECT_MEMBERS,
|
||||
} from "constants/fetch-keys";
|
||||
|
||||
const SingleModule = () => {
|
||||
const SingleModule: React.FC<UserAuth> = (props) => {
|
||||
const [moduleSidebar, setModuleSidebar] = useState(true);
|
||||
const [moduleDeleteModal, setModuleDeleteModal] = useState(false);
|
||||
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 = (
|
||||
issue?: IIssue,
|
||||
actionType: "create" | "edit" | "delete" = "create"
|
||||
@ -279,16 +278,16 @@ const SingleModule = () => {
|
||||
openIssuesListModal={openIssuesListModal}
|
||||
removeIssueFromModule={removeIssueFromModule}
|
||||
setPreloadedData={setPreloadedData}
|
||||
userAuth={props}
|
||||
/>
|
||||
<ModulesBoardView
|
||||
issues={moduleIssuesArray ?? []}
|
||||
removeIssueFromModule={removeIssueFromModule}
|
||||
members={members}
|
||||
openCreateIssueModal={openCreateIssueModal}
|
||||
openIssuesListModal={openIssuesListModal}
|
||||
handleDeleteIssue={setDeleteIssue}
|
||||
partialUpdateIssue={partialUpdateIssue}
|
||||
setPreloadedData={setPreloadedData}
|
||||
userAuth={props}
|
||||
/>
|
||||
</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;
|
||||
|
@ -86,8 +86,8 @@ const ProjectModules: NextPage = () => {
|
||||
title="Create a new module"
|
||||
description={
|
||||
<span>
|
||||
Use <pre className="inline rounded bg-gray-100 px-2 py-1">Ctrl/Command + M</pre>{" "}
|
||||
shortcut to create a new module
|
||||
Use <pre className="inline rounded bg-gray-100 px-2 py-1">M</pre> shortcut to
|
||||
create a new module
|
||||
</span>
|
||||
}
|
||||
Icon={PlusIcon}
|
||||
|
@ -6,7 +6,6 @@ import Image from "next/image";
|
||||
import useSWR, { mutate } from "swr";
|
||||
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import type { NextPageContext, NextPage } from "next";
|
||||
// lib
|
||||
import { requiredAdmin } from "lib/auth";
|
||||
// layouts
|
||||
@ -21,6 +20,7 @@ import { Button, CustomSelect, Loader } from "components/ui";
|
||||
import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs";
|
||||
// types
|
||||
import { IProject, IWorkspace } from "types";
|
||||
import type { NextPageContext, NextPage } from "next";
|
||||
// fetch-keys
|
||||
import { PROJECTS_LIST, PROJECT_DETAILS, WORKSPACE_MEMBERS } from "constants/fetch-keys";
|
||||
|
||||
@ -88,24 +88,9 @@ const ControlSettings: NextPage<TControlSettingsProps> = (props) => {
|
||||
await projectService
|
||||
.updateProject(workspaceSlug as string, projectId as string, payload)
|
||||
.then((res) => {
|
||||
mutate<IProject>(
|
||||
PROJECT_DETAILS(projectId 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
|
||||
);
|
||||
mutate(PROJECT_DETAILS(projectId as string));
|
||||
mutate(PROJECTS_LIST(workspaceSlug as string));
|
||||
|
||||
setToastAlert({
|
||||
title: "Success",
|
||||
type: "success",
|
||||
|
@ -4,8 +4,6 @@ import { useRouter } from "next/router";
|
||||
import useSWR from "swr";
|
||||
import { PencilSquareIcon, PlusIcon, TrashIcon } from "@heroicons/react/24/outline";
|
||||
|
||||
// types
|
||||
import type { NextPage, NextPageContext } from "next";
|
||||
import { IState } from "types";
|
||||
// services
|
||||
import stateService from "services/state.service";
|
||||
@ -26,6 +24,8 @@ import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs";
|
||||
// helpers
|
||||
import { addSpaceIfCamelCase } from "helpers/string.helper";
|
||||
import { groupBy } from "helpers/array.helper";
|
||||
// types
|
||||
import type { NextPage, NextPageContext } from "next";
|
||||
// fetch-keys
|
||||
import { PROJECT_DETAILS, STATE_LIST } from "constants/fetch-keys";
|
||||
|
||||
@ -106,6 +106,18 @@ const StatesSettings: NextPage<TStateSettingsProps> = (props) => {
|
||||
</button>
|
||||
</div>
|
||||
<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) =>
|
||||
state.id !== selectedState ? (
|
||||
<div
|
||||
@ -147,18 +159,6 @@ const StatesSettings: NextPage<TStateSettingsProps> = (props) => {
|
||||
</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>
|
||||
))
|
||||
|
@ -88,9 +88,8 @@ const ProjectsPage: NextPage = () => {
|
||||
title="Create a new project"
|
||||
description={
|
||||
<span>
|
||||
Use{" "}
|
||||
<pre className="inline rounded bg-gray-100 px-2 py-1">Ctrl/Command + P</pre>{" "}
|
||||
shortcut to create a new project
|
||||
Use <pre className="inline rounded bg-gray-100 px-2 py-1">P</pre> shortcut to
|
||||
create a new project
|
||||
</span>
|
||||
}
|
||||
Icon={PlusIcon}
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user