Merge pull request #215 from makeplane/develop

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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
]

View File

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

View File

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

View File

@ -10,8 +10,6 @@ from django.core.serializers.json import DjangoJSONEncoder
from rest_framework.response import Response
from rest_framework 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,

View File

@ -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(),
}
),
}
)

View File

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

View File

@ -1,52 +1,5 @@
from django.apps import AppConfig
from 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,
# )

View File

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

View File

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

View File

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

View File

@ -0,0 +1,39 @@
# Python imports
from uuid import uuid4
# Django imports
from django.db import models
from django.conf import settings
from .base import BaseModel
def generate_label_token():
return uuid4().hex
def generate_token():
return uuid4().hex + uuid4().hex
class APIToken(BaseModel):
token = models.CharField(max_length=255, unique=True, default=generate_token)
label = models.CharField(max_length=255, default=generate_label_token)
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="bot_tokens",
)
user_type = models.PositiveSmallIntegerField(
choices=((0, "Human"), (1, "Bot")), default=0
)
class Meta:
verbose_name = "API Token"
verbose_name_plural = "API Tokems"
db_table = "api_tokens"
ordering = ("-created_at",)
def __str__(self):
return str(self.user.name)

View File

@ -145,12 +145,8 @@ class IssueActivity(ProjectBaseModel):
field = models.CharField(
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)

View File

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

View File

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

View File

@ -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)],
},
},
}

View File

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

View File

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

View File

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

View File

@ -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,

View File

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

View File

@ -97,33 +97,39 @@ const CommandPalette: React.FC = () => {
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === "/") {
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.metaKey) && e.key === "i") {
} else if (e.ctrlKey && e.key === "c") {
console.log("Text copied");
} else if (e.key === "c") {
e.preventDefault();
setIsIssueModalOpen(true);
} else if ((e.ctrlKey || e.metaKey) && e.key === "p") {
} else if (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") {
} else if (e.key === "h") {
e.preventDefault();
setIsShortcutsModalOpen(true);
} else if ((e.ctrlKey || e.metaKey) && e.key === "q") {
} else if (e.key === "q") {
e.preventDefault();
setIsCreateCycleModalOpen(true);
} else if ((e.ctrlKey || e.metaKey) && e.key === "m") {
} else if (e.key === "m") {
e.preventDefault();
setIsCreateModuleModalOpen(true);
} else if ((e.ctrlKey || e.metaKey) && e.key === "d") {
} 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;
const url = new URL(window.location.href);
@ -141,6 +147,7 @@ const CommandPalette: React.FC = () => {
});
});
}
}
},
[toggleCollapsed, setToastAlert, router]
);

View File

@ -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.",
},
],

View File

@ -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,15 +315,19 @@ 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">
<div
className={`flex ${
isNotAllowed ? "cursor-not-allowed" : "cursor-pointer"
} items-center gap-1 text-xs`}
>
<AssigneesList users={assignees} length={3} />
</div>
</Listbox.Button>
@ -280,9 +344,7 @@ const SingleBoardIssue: React.FC<Props> = ({
<Listbox.Option
key={person.id}
className={({ active }) =>
`cursor-pointer select-none p-2 ${
active ? "bg-indigo-50" : "bg-white"
}`
`cursor-pointer select-none p-2 ${active ? "bg-indigo-50" : "bg-white"}`
}
value={person.member.id}
>
@ -329,7 +391,6 @@ const SingleBoardIssue: React.FC<Props> = ({
</Listbox.Options>
</Transition>
</div>
</>
)}
</Listbox>
)}

View File

@ -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>
)}

View File

@ -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>
)}

View File

@ -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" && (

View File

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

View File

@ -19,7 +19,7 @@ import { CycleSelect as IssueCycleSelect } from "components/cycles/select";
import CreateUpdateStateModal from "components/project/issues/BoardView/state/create-update-state-modal";
import 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} />
)}
/>
<div>
<Controller
control={control}
name="target_date"
render={({ field: { value, onChange } }) => (
<input
type="date"
value={value ?? ""}
onChange={(e: any) => {
onChange(e.target.value);
<CustomDatePicker
value={value}
onChange={(val: Date) => {
onChange(
val
? `${val.getFullYear()}-${val.getMonth() + 1}-${val.getDate()}`
: null
);
}}
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"
className="max-w-[7rem]"
/>
)}
/>
</div>
<IssueParentSelect
control={control}
isOpen={parentIssueListModalOpen}

View File

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

View File

@ -23,7 +23,7 @@ const BreakIntoModules: React.FC = () => (
<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.
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>

View File

@ -52,7 +52,9 @@ export const ProjectCard: React.FC<ProjectCardProps> = (props) => {
{project.icon && (
<span className="text-base">{String.fromCodePoint(parseInt(project.icon))}</span>
)}
<span>{project.name}</span>
<span className="w-3/4 max-w-[225px] md:max-w-[140px] xl:max-w-[225px] text-ellipsis overflow-hidden">
{project.name}
</span>
<span className="text-xs text-gray-500 ">{project.identifier}</span>
</a>
</Link>

View File

@ -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}

View File

@ -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}

View File

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

View File

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

View File

@ -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>
)}

View File

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

View File

@ -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"
<h6 className="text-gray-500">Start Date</h6>
<div className="w-full">
<Controller
control={control}
name="start_date"
type="date"
placeholder="Enter start date"
error={errors.start_date}
register={register}
validations={{
required: "Start date is required",
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"
<h6 className="text-gray-500">End Date</h6>
<div className="w-full">
<Controller
control={control}
name="end_date"
type="date"
placeholder="Enter end date"
error={errors.end_date}
register={register}
validations={{
required: "End date is required",
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>

View File

@ -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}
/>
)}
/>

View File

@ -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}
/>
);
})

View File

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

View File

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

View File

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

View File

@ -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>
)}

View File

@ -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}

View File

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

View File

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

View File

@ -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" ? (
activity.new_value ? (
renderShortNumericDateFormat(activity.new_value as string)
) : null
) : activity.field === "description" ? (
""
) : (

View File

@ -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,7 +48,8 @@ const AddAsSubIssue: React.FC<Props> = ({ isOpen, setIsOpen, parent }) => {
};
const addAsSubIssue = (issueId: string) => {
if (workspaceSlug && projectId) {
if (!workspaceSlug || !projectId) return;
issuesServices
.patchIssue(workspaceSlug as string, projectId as string, issueId, { parent: parent?.id })
.then((res) => {
@ -57,7 +58,6 @@ const AddAsSubIssue: React.FC<Props> = ({ isOpen, setIsOpen, parent }) => {
.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>
);

View File

@ -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"
/>
)}
/>

View File

@ -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>
)}

View File

@ -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>
)}

View File

@ -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>
)}

View File

@ -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}
/>
);
})

View File

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

View File

@ -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>
)}

View File

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

View File

@ -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"
<h6 className="text-gray-500">Start Date</h6>
<div className="w-full">
<Controller
control={control}
name="start_date"
type="date"
placeholder="Enter start date"
error={errors.start_date}
register={register}
validations={{
required: "Start date is required",
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"
<h6 className="text-gray-500">Target Date</h6>
<div className="w-full">
<Controller
control={control}
name="target_date"
type="date"
placeholder="Enter target date"
error={errors.target_date}
register={register}
validations={{
required: "Target date is required",
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">

View File

@ -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}
/>
);
})

View File

@ -22,8 +22,11 @@ import SelectLead from "components/project/modules/module-detail-sidebar/select-
import SelectMembers from "components/project/modules/module-detail-sidebar/select-members";
import 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"
/>
)}
/>

View File

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

View File

@ -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,11 +20,38 @@ 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 (
<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="block cursor-pointer rounded-md border bg-white p-3">
{module.name}
<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>
@ -116,6 +144,7 @@ const SingleModuleCard: React.FC<Props> = ({ module }) => {
</div>
</a>
</Link>
</div>
);
};

View File

@ -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}
</>
)}
</Listbox.Option>
))
)}
@ -246,23 +223,29 @@ const SendProjectInvitationModal: React.FC<Props> = ({ isOpen, setIsOpen, member
/>
</div>
<div>
<div>
<Select
id="role"
label="Role"
<h6 className="text-gray-500">Role</h6>
<Controller
name="role"
error={errors.role}
register={register}
validations={{
required: "Role is required",
}}
options={Object.entries(ROLE).map(([key, value]) => ({
value: key,
label: value,
}))}
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>
<div>
<TextArea
id="message"

View File

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

View File

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

View File

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

View File

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

View File

@ -1,39 +1,73 @@
import React from "react";
import React, { useEffect, useState } from "react";
type Props = {
export type Props = {
direction?: "top" | "right" | "bottom" | "left";
content: string | React.ReactNode;
margin?: string;
children: React.ReactNode;
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;

View File

@ -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}

View File

@ -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}

View File

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

View File

@ -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}

View File

@ -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}`;

View File

@ -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={{

View File

@ -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,
};

View File

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

View File

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

View File

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

View File

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

View File

@ -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>
)

View File

@ -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}

View File

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

View File

@ -4,6 +4,8 @@ import { useRouter } from "next/router";
import useSWR, { mutate } from "swr";
// 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;

View File

@ -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}

View File

@ -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,13 +157,14 @@ const IssueDetailsPage: NextPage = () => {
console.error(e);
});
},
[activeProject, workspaceSlug, issueId, projectId, mutateIssueDetails, mutateIssueActivities]
[workspaceSlug, issueId, projectId, mutateIssueDetails, mutateIssueActivities]
);
const handleSubIssueRemove = (issueId: string) => {
if (workspaceSlug && activeProject) {
if (!workspaceSlug || !projectId) return;
issuesService
.patchIssue(workspaceSlug as string, activeProject.id, issueId, { parent: null })
.patchIssue(workspaceSlug as string, projectId as string, issueId, { parent: null })
.then((res) => {
mutate(SUB_ISSUES(issueDetails?.id ?? ""));
mutateIssueActivities();
@ -168,27 +172,8 @@ const IssueDetailsPage: NextPage = () => {
.catch((e) => {
console.error(e);
});
}
};
/**
* 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]
);
return (
<AppLayout
noPadding={true}
@ -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}

View File

@ -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,
},
};
};

View File

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

View File

@ -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}

View File

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

View File

@ -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>
))

View File

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