Merge branch 'develop' of github.com:makeplane/plane into feat/notifications

This commit is contained in:
pablohashescobar 2023-07-03 17:07:29 +05:30
commit ebc9f36dc2
134 changed files with 3877 additions and 2313 deletions

View File

@ -23,7 +23,6 @@ You can open a new issue with this [issue form](https://github.com/makeplane/pla
- Python version 3.8+ - Python version 3.8+
- Postgres version v14 - Postgres version v14
- Redis version v6.2.7 - Redis version v6.2.7
- pnpm version 7.22.0
### Setup the project ### Setup the project

View File

@ -61,14 +61,6 @@ chmod +x setup.sh
> If running in a cloud env replace localhost with public facing IP address of the VM > If running in a cloud env replace localhost with public facing IP address of the VM
- Export Environment Variables
```bash
set -a
source .env
set +a
```
- Run Docker compose up - Run Docker compose up
```bash ```bash
@ -165,4 +157,4 @@ Our [Code of Conduct](https://github.com/makeplane/plane/blob/master/CODE_OF_CON
## ⛓️ Security ## ⛓️ Security
If you believe you have found a security vulnerability in Plane, we encourage you to responsibly disclose this and not open a public issue. We will investigate all legitimate reports. Email security@plane.so to disclose any security vulnerabilities. If you believe you have found a security vulnerability in Plane, we encourage you to responsibly disclose this and not open a public issue. We will investigate all legitimate reports. Email engineering@plane.so to disclose any security vulnerabilities.

View File

@ -3,6 +3,7 @@ from django.db.models import (
Count, Count,
Sum, Sum,
F, F,
Q
) )
from django.db.models.functions import ExtractMonth from django.db.models.functions import ExtractMonth
@ -59,10 +60,11 @@ class AnalyticsEndpoint(BaseAPIView):
colors = ( colors = (
State.objects.filter( State.objects.filter(
~Q(name="Triage"),
workspace__slug=slug, project_id__in=filters.get("project__in") workspace__slug=slug, project_id__in=filters.get("project__in")
).values(key, "color") ).values(key, "color")
if filters.get("project__in", False) if filters.get("project__in", False)
else State.objects.filter(workspace__slug=slug).values(key, "color") else State.objects.filter(~Q(name="Triage"), workspace__slug=slug).values(key, "color")
) )
if x_axis in ["labels__name"] or segment in ["labels__name"]: if x_axis in ["labels__name"] or segment in ["labels__name"]:

View File

@ -72,7 +72,7 @@ class SignUpEndpoint(BaseAPIView):
# Check if the user already exists # Check if the user already exists
if User.objects.filter(email=email).exists(): if User.objects.filter(email=email).exists():
return Response( return Response(
{"error": "User already exist please sign in"}, {"error": "User with this email already exists"},
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,
) )

View File

@ -7,7 +7,7 @@ from rest_framework.response import Response
from sentry_sdk import capture_exception from sentry_sdk import capture_exception
# Django imports # Django imports
from django.db.models import Max from django.db.models import Max, Q
# Module imports # Module imports
from plane.api.views import BaseAPIView from plane.api.views import BaseAPIView
@ -42,16 +42,34 @@ from plane.utils.html_processor import strip_tags
class ServiceIssueImportSummaryEndpoint(BaseAPIView): class ServiceIssueImportSummaryEndpoint(BaseAPIView):
def get(self, request, slug, service): def get(self, request, slug, service):
try: try:
if service == "github": if service == "github":
owner = request.GET.get("owner", False)
repo = request.GET.get("repo", False)
if not owner or not repo:
return Response(
{"error": "Owner and repo are required"},
status=status.HTTP_400_BAD_REQUEST,
)
workspace_integration = WorkspaceIntegration.objects.get( workspace_integration = WorkspaceIntegration.objects.get(
integration__provider="github", workspace__slug=slug integration__provider="github", workspace__slug=slug
) )
access_tokens_url = workspace_integration.metadata["access_tokens_url"] access_tokens_url = workspace_integration.metadata.get(
owner = request.GET.get("owner") "access_tokens_url", False
repo = request.GET.get("repo") )
if not access_tokens_url:
return Response(
{
"error": "There was an error during the installation of the GitHub app. To resolve this issue, we recommend reinstalling the GitHub app."
},
status=status.HTTP_400_BAD_REQUEST,
)
issue_count, labels, collaborators = get_github_repo_details( issue_count, labels, collaborators = get_github_repo_details(
access_tokens_url, owner, repo access_tokens_url, owner, repo
@ -309,11 +327,13 @@ class BulkImportIssuesEndpoint(BaseAPIView):
# Get the default state # Get the default state
default_state = State.objects.filter( default_state = State.objects.filter(
project_id=project_id, default=True ~Q(name="Triage"), project_id=project_id, default=True
).first() ).first()
# if there is no default state assign any random state # if there is no default state assign any random state
if default_state is None: if default_state is None:
default_state = State.objects.filter(project_id=project_id).first() default_state = State.objects.filter(
~Q(name="Triage"), sproject_id=project_id
).first()
# Get the maximum sequence_id # Get the maximum sequence_id
last_id = IssueSequence.objects.filter(project_id=project_id).aggregate( last_id = IssueSequence.objects.filter(project_id=project_id).aggregate(

View File

@ -68,13 +68,12 @@ class InboxViewSet(BaseViewSet):
inbox = Inbox.objects.get( inbox = Inbox.objects.get(
workspace__slug=slug, project_id=project_id, pk=pk workspace__slug=slug, project_id=project_id, pk=pk
) )
# Handle default inbox delete
if inbox.is_default: if inbox.is_default:
return Response( return Response(
{"error": "You cannot delete the default inbox"}, {"error": "You cannot delete the default inbox"},
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,
) )
inbox.delete() inbox.delete()
return Response(status=status.HTTP_204_NO_CONTENT) return Response(status=status.HTTP_204_NO_CONTENT)
except Exception as e: except Exception as e:
@ -112,7 +111,6 @@ class InboxIssueViewSet(BaseViewSet):
def list(self, request, slug, project_id, inbox_id): def list(self, request, slug, project_id, inbox_id):
try: try:
order_by = request.GET.get("order_by", "created_at")
filters = issue_filters(request.query_params, "GET") filters = issue_filters(request.query_params, "GET")
issues = ( issues = (
Issue.objects.filter( Issue.objects.filter(
@ -120,23 +118,17 @@ class InboxIssueViewSet(BaseViewSet):
workspace__slug=slug, workspace__slug=slug,
project_id=project_id, project_id=project_id,
) )
.filter(**filters)
.annotate(bridge_id=F("issue_inbox__id"))
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels")
.order_by("issue_inbox__snoozed_till", "issue_inbox__status")
.annotate( .annotate(
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
.order_by() .order_by()
.annotate(count=Func(F("id"), function="Count")) .annotate(count=Func(F("id"), function="Count"))
.values("count") .values("count")
) )
.annotate(bridge_id=F("issue_inbox__id"))
.filter(project_id=project_id)
.filter(workspace__slug=slug)
.select_related("project")
.select_related("workspace")
.select_related("state")
.select_related("parent")
.prefetch_related("assignees")
.prefetch_related("labels")
.order_by(order_by)
.filter(**filters)
.annotate( .annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id")) link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by() .order_by()
@ -180,7 +172,8 @@ class InboxIssueViewSet(BaseViewSet):
{"error": "Name is required"}, status=status.HTTP_400_BAD_REQUEST {"error": "Name is required"}, status=status.HTTP_400_BAD_REQUEST
) )
if not request.data.get("issue", {}).get("priority", "low") in [ # Check for valid priority
if not request.data.get("issue", {}).get("priority", None) in [
"low", "low",
"medium", "medium",
"high", "high",
@ -213,7 +206,6 @@ class InboxIssueViewSet(BaseViewSet):
) )
# Create an Issue Activity # Create an Issue Activity
# Track the issue
issue_activity.delay( issue_activity.delay(
type="issue.activity.created", type="issue.activity.created",
requested_data=json.dumps(request.data, cls=DjangoJSONEncoder), requested_data=json.dumps(request.data, cls=DjangoJSONEncoder),
@ -231,9 +223,7 @@ class InboxIssueViewSet(BaseViewSet):
) )
serializer = IssueStateInboxSerializer(issue) serializer = IssueStateInboxSerializer(issue)
return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.data, status=status.HTTP_200_OK)
except Exception as e: except Exception as e:
capture_exception(e) capture_exception(e)
return Response( return Response(
@ -260,7 +250,7 @@ class InboxIssueViewSet(BaseViewSet):
pk=inbox_issue.issue_id, workspace__slug=slug, project_id=project_id pk=inbox_issue.issue_id, workspace__slug=slug, project_id=project_id
) )
# Only allow guests and viewers to edit name and description # Only allow guests and viewers to edit name and description
if project_member <= 10: if project_member.role <= 10:
# viewers and guests since only viewers and guests # viewers and guests since only viewers and guests
issue_data = { issue_data = {
"name": issue_data.get("name", issue.name), "name": issue_data.get("name", issue.name),

View File

@ -16,6 +16,7 @@ from django.db.models import (
CharField, CharField,
When, When,
Exists, Exists,
Max,
) )
from django.core.serializers.json import DjangoJSONEncoder from django.core.serializers.json import DjangoJSONEncoder
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
@ -159,8 +160,9 @@ class IssueViewSet(BaseViewSet):
filters = issue_filters(request.query_params, "GET") filters = issue_filters(request.query_params, "GET")
show_sub_issues = request.GET.get("show_sub_issues", "true") show_sub_issues = request.GET.get("show_sub_issues", "true")
# Custom ordering for priority # Custom ordering for priority and state
priority_order = ["urgent", "high", "medium", "low", None] priority_order = ["urgent", "high", "medium", "low", None]
state_order = ["backlog", "unstarted", "started", "completed", "cancelled"]
order_by_param = request.GET.get("order_by", "-created_at") order_by_param = request.GET.get("order_by", "-created_at")
@ -185,7 +187,13 @@ class IssueViewSet(BaseViewSet):
) )
) )
if order_by_param == "priority": # Priority Ordering
if order_by_param == "priority" or order_by_param == "-priority":
priority_order = (
priority_order
if order_by_param == "priority"
else priority_order[::-1]
)
issue_queryset = issue_queryset.annotate( issue_queryset = issue_queryset.annotate(
priority_order=Case( priority_order=Case(
*[ *[
@ -195,6 +203,45 @@ class IssueViewSet(BaseViewSet):
output_field=CharField(), output_field=CharField(),
) )
).order_by("priority_order") ).order_by("priority_order")
# State Ordering
elif order_by_param in [
"state__name",
"state__group",
"-state__name",
"-state__group",
]:
state_order = (
state_order
if order_by_param in ["state__name", "state__group"]
else state_order[::-1]
)
issue_queryset = issue_queryset.annotate(
state_order=Case(
*[
When(state__group=state_group, then=Value(i))
for i, state_group in enumerate(state_order)
],
default=Value(len(state_order)),
output_field=CharField(),
)
).order_by("state_order")
# assignee and label ordering
elif order_by_param in [
"labels__name",
"-labels__name",
"assignees__first_name",
"-assignees__first_name",
]:
issue_queryset = issue_queryset.annotate(
max_values=Max(
order_by_param[1::]
if order_by_param.startswith("-")
else order_by_param
)
).order_by(
"-max_values" if order_by_param.startswith("-") else "max_values"
)
else: else:
issue_queryset = issue_queryset.order_by(order_by_param) issue_queryset = issue_queryset.order_by(order_by_param)
@ -614,7 +661,9 @@ class SubIssuesEndpoint(BaseAPIView):
) )
state_distribution = ( state_distribution = (
State.objects.filter(workspace__slug=slug, project_id=project_id) State.objects.filter(
~Q(name="Triage"), workspace__slug=slug, project_id=project_id
)
.annotate( .annotate(
state_count=Count( state_count=Count(
"state_issue", "state_issue",

View File

@ -3,13 +3,13 @@ from itertools import groupby
# Django imports # Django imports
from django.db import IntegrityError from django.db import IntegrityError
from django.db.models import Q
# Third party imports # Third party imports
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework import status from rest_framework import status
from sentry_sdk import capture_exception from sentry_sdk import capture_exception
# Module imports # Module imports
from . import BaseViewSet, BaseAPIView from . import BaseViewSet, BaseAPIView
from plane.api.serializers import StateSerializer from plane.api.serializers import StateSerializer
@ -34,6 +34,7 @@ class StateViewSet(BaseViewSet):
.filter(workspace__slug=self.kwargs.get("slug")) .filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id")) .filter(project_id=self.kwargs.get("project_id"))
.filter(project__project_projectmember__member=self.request.user) .filter(project__project_projectmember__member=self.request.user)
.filter(~Q(name="Triage"))
.select_related("project") .select_related("project")
.select_related("workspace") .select_related("workspace")
.distinct() .distinct()
@ -80,7 +81,8 @@ class StateViewSet(BaseViewSet):
def destroy(self, request, slug, project_id, pk): def destroy(self, request, slug, project_id, pk):
try: try:
state = State.objects.get( state = State.objects.get(
pk=pk, project_id=project_id, workspace__slug=slug ~Q(name="Triage"),
pk=pk, project_id=project_id, workspace__slug=slug,
) )
if state.default: if state.default:

View File

@ -0,0 +1,39 @@
# Generated by Django 3.2.19 on 2023-06-28 05:16
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('db', '0033_auto_20230618_2125'),
]
operations = [
migrations.RemoveField(
model_name='timelineissue',
name='created_by',
),
migrations.RemoveField(
model_name='timelineissue',
name='issue',
),
migrations.RemoveField(
model_name='timelineissue',
name='project',
),
migrations.RemoveField(
model_name='timelineissue',
name='updated_by',
),
migrations.RemoveField(
model_name='timelineissue',
name='workspace',
),
migrations.DeleteModel(
name='Shortcut',
),
migrations.DeleteModel(
name='TimelineIssue',
),
]

View File

@ -98,11 +98,13 @@ class Issue(ProjectBaseModel):
from plane.db.models import State from plane.db.models import State
default_state = State.objects.filter( default_state = State.objects.filter(
project=self.project, default=True ~models.Q(name="Triage"), project=self.project, default=True
).first() ).first()
# if there is no default state assign any random state # if there is no default state assign any random state
if default_state is None: if default_state is None:
random_state = State.objects.filter(project=self.project).first() random_state = State.objects.filter(
~models.Q(name="Triage"), project=self.project
).first()
self.state = random_state self.state = random_state
if random_state.group == "started": if random_state.group == "started":
self.start_date = timezone.now().date() self.start_date = timezone.now().date()

View File

@ -63,6 +63,7 @@ if os.environ.get("SENTRY_DSN", False):
send_default_pii=True, send_default_pii=True,
environment="local", environment="local",
traces_sample_rate=0.7, traces_sample_rate=0.7,
profiles_sample_rate=1.0,
) )
REDIS_HOST = "localhost" REDIS_HOST = "localhost"

View File

@ -84,6 +84,7 @@ if bool(os.environ.get("SENTRY_DSN", False)):
traces_sample_rate=1, traces_sample_rate=1,
send_default_pii=True, send_default_pii=True,
environment="production", environment="production",
profiles_sample_rate=1.0,
) )
if DOCKERIZED and USE_MINIO: if DOCKERIZED and USE_MINIO:

View File

@ -66,6 +66,7 @@ sentry_sdk.init(
traces_sample_rate=1, traces_sample_rate=1,
send_default_pii=True, send_default_pii=True,
environment="staging", environment="staging",
profiles_sample_rate=1.0,
) )
# The AWS region to connect to. # The AWS region to connect to.

View File

@ -113,7 +113,7 @@ def get_github_repo_details(access_tokens_url, owner, repo):
last_url = labels_response.links.get("last").get("url") last_url = labels_response.links.get("last").get("url")
parsed_url = urlparse(last_url) parsed_url = urlparse(last_url)
last_page_value = parse_qs(parsed_url.query)["page"][0] last_page_value = parse_qs(parsed_url.query)["page"][0]
total_labels = total_labels + 100 * (last_page_value - 1) total_labels = total_labels + 100 * (int(last_page_value) - 1)
# Get labels in last page # Get labels in last page
last_page_labels = requests.get(last_url, headers=headers).json() last_page_labels = requests.get(last_url, headers=headers).json()

View File

@ -1,7 +1,6 @@
from django.utils.timezone import make_aware from django.utils.timezone import make_aware
from django.utils.dateparse import parse_datetime from django.utils.dateparse import parse_datetime
def filter_state(params, filter, method): def filter_state(params, filter, method):
if method == "GET": if method == "GET":
states = params.get("state").split(",") states = params.get("state").split(",")
@ -26,12 +25,27 @@ def filter_estimate_point(params, filter, method):
def filter_priority(params, filter, method): def filter_priority(params, filter, method):
if method == "GET": if method == "GET":
priorties = params.get("priority").split(",") priorities = params.get("priority").split(",")
if len(priorties) and "" not in priorties: if len(priorities) and "" not in priorities:
filter["priority__in"] = priorties if len(priorities) == 1 and "null" in priorities:
filter["priority__isnull"] = True
elif len(priorities) > 1 and "null" in priorities:
filter["priority__isnull"] = True
filter["priority__in"] = [p for p in priorities if p != "null"]
else:
filter["priority__in"] = [p for p in priorities if p != "null"]
else: else:
if params.get("priority", None) and len(params.get("priority")): if params.get("priority", None) and len(params.get("priority")):
filter["priority__in"] = params.get("priority") priorities = params.get("priority")
if len(priorities) == 1 and "null" in priorities:
filter["priority__isnull"] = True
elif len(priorities) > 1 and "null" in priorities:
filter["priority__isnull"] = True
filter["priority__in"] = [p for p in priorities if p != "null"]
else:
filter["priority__in"] = [p for p in priorities if p != "null"]
return filter return filter

View File

@ -2,30 +2,30 @@
Django==3.2.19 Django==3.2.19
django-braces==1.15.0 django-braces==1.15.0
django-taggit==3.1.0 django-taggit==4.0.0
psycopg2==2.9.5 psycopg2==2.9.6
django-oauth-toolkit==2.2.0 django-oauth-toolkit==2.3.0
mistune==2.0.4 mistune==2.0.4
djangorestframework==3.14.0 djangorestframework==3.14.0
redis==4.5.4 redis==4.6.0
django-nested-admin==4.0.2 django-nested-admin==4.0.2
django-cors-headers==3.13.0 django-cors-headers==4.1.0
whitenoise==6.3.0 whitenoise==6.3.0
django-allauth==0.52.0 django-allauth==0.54.0
faker==13.4.0 faker==13.4.0
django-filter==22.1 django-filter==23.2
jsonmodels==2.6.0 jsonmodels==2.6.0
djangorestframework-simplejwt==5.2.2 djangorestframework-simplejwt==5.2.2
sentry-sdk==1.14.0 sentry-sdk==1.26.0
django-s3-storage==0.13.11 django-s3-storage==0.14.0
django-crum==0.7.9 django-crum==0.7.9
django-guardian==2.4.0 django-guardian==2.4.0
dj_rest_auth==2.2.5 dj_rest_auth==2.2.5
google-auth==2.16.0 google-auth==2.16.0
google-api-python-client==2.75.0 google-api-python-client==2.75.0
django-redis==5.2.0 django-redis==5.3.0
uvicorn==0.20.0 uvicorn==0.22.0
channels==4.0.0 channels==4.0.0
openai==0.27.2 openai==0.27.8
slack-sdk==3.20.2 slack-sdk==3.21.3
celery==5.2.7 celery==5.3.1

View File

@ -1,11 +1,11 @@
-r base.txt -r base.txt
dj-database-url==1.2.0 dj-database-url==2.0.0
gunicorn==20.1.0 gunicorn==20.1.0
whitenoise==6.3.0 whitenoise==6.3.0
django-storages==1.13.2 django-storages==1.13.2
boto3==1.26.136 boto3==1.26.163
django-anymail==9.0 django-anymail==10.0
twilio==7.16.2 twilio==7.16.2
django-debug-toolbar==3.8.1 django-debug-toolbar==3.8.1
gevent==22.10.2 gevent==22.10.2

View File

@ -20,7 +20,7 @@ ARG NEXT_PUBLIC_API_BASE_URL=http://localhost:8000
COPY .gitignore .gitignore COPY .gitignore .gitignore
COPY --from=builder /app/out/json/ . COPY --from=builder /app/out/json/ .
COPY --from=builder /app/out/yarn.lock ./yarn.lock COPY --from=builder /app/out/yarn.lock ./yarn.lock
RUN yarn install RUN yarn install --network-timeout 500000
# Build the project # Build the project
COPY --from=builder /app/out/full/ . COPY --from=builder /app/out/full/ .

View File

@ -237,7 +237,7 @@ export const AnalyticsSidebar: React.FC<Props> = ({
{project?.name.charAt(0)} {project?.name.charAt(0)}
</span> </span>
)} )}
<h5 className="break-all"> <h5 className="break-words">
{project.name} {project.name}
<span className="text-brand-secondary text-xs ml-1"> <span className="text-brand-secondary text-xs ml-1">
({project.identifier}) ({project.identifier})
@ -276,7 +276,7 @@ export const AnalyticsSidebar: React.FC<Props> = ({
{projectId ? ( {projectId ? (
cycleId && cycleDetails ? ( cycleId && cycleDetails ? (
<div className="hidden md:block h-full overflow-y-auto"> <div className="hidden md:block h-full overflow-y-auto">
<h4 className="font-medium break-all">Analytics for {cycleDetails.name}</h4> <h4 className="font-medium break-words">Analytics for {cycleDetails.name}</h4>
<div className="space-y-4 mt-4"> <div className="space-y-4 mt-4">
<div className="flex items-center gap-2 text-xs"> <div className="flex items-center gap-2 text-xs">
<h6 className="text-brand-secondary">Lead</h6> <h6 className="text-brand-secondary">Lead</h6>
@ -304,7 +304,7 @@ export const AnalyticsSidebar: React.FC<Props> = ({
</div> </div>
) : moduleId && moduleDetails ? ( ) : moduleId && moduleDetails ? (
<div className="hidden md:block h-full overflow-y-auto"> <div className="hidden md:block h-full overflow-y-auto">
<h4 className="font-medium break-all">Analytics for {moduleDetails.name}</h4> <h4 className="font-medium break-words">Analytics for {moduleDetails.name}</h4>
<div className="space-y-4 mt-4"> <div className="space-y-4 mt-4">
<div className="flex items-center gap-2 text-xs"> <div className="flex items-center gap-2 text-xs">
<h6 className="text-brand-secondary">Lead</h6> <h6 className="text-brand-secondary">Lead</h6>
@ -352,7 +352,7 @@ export const AnalyticsSidebar: React.FC<Props> = ({
{projectDetails?.name.charAt(0)} {projectDetails?.name.charAt(0)}
</span> </span>
)} )}
<h4 className="font-medium break-all">{projectDetails?.name}</h4> <h4 className="font-medium break-words">{projectDetails?.name}</h4>
</div> </div>
<div className="space-y-4 mt-4"> <div className="space-y-4 mt-4">
<div className="flex items-center gap-2 text-xs"> <div className="flex items-center gap-2 text-xs">

View File

@ -160,7 +160,7 @@ export const AnalyticsProjectModal: React.FC<Props> = ({ isOpen, onClose }) => {
}`} }`}
> >
<div className="flex items-center justify-between gap-4 bg-brand-base px-5 py-4 text-sm"> <div className="flex items-center justify-between gap-4 bg-brand-base px-5 py-4 text-sm">
<h3 className="break-all"> <h3 className="break-words">
Analytics for{" "} Analytics for{" "}
{cycleId ? cycleDetails?.name : moduleId ? moduleDetails?.name : projectDetails?.name} {cycleId ? cycleDetails?.name : moduleId ? moduleDetails?.name : projectDetails?.name}
</h3> </h3>

View File

@ -33,7 +33,7 @@ export const AnalyticsLeaderboard: React.FC<Props> = ({ users, title }) => (
{user.firstName !== "" ? user.firstName[0] : "?"} {user.firstName !== "" ? user.firstName[0] : "?"}
</div> </div>
)} )}
<span className="break-all text-brand-secondary"> <span className="break-words text-brand-secondary">
{user.firstName !== "" ? `${user.firstName} ${user.lastName}` : "No assignee"} {user.firstName !== "" ? `${user.firstName} ${user.lastName}` : "No assignee"}
</span> </span>
</div> </div>

View File

@ -41,6 +41,14 @@ export const AnalyticsYearWiseIssues: React.FC<Props> = ({ defaultAnalytics }) =
colors={(datum) => datum.color} colors={(datum) => datum.color}
curve="monotoneX" curve="monotoneX"
margin={{ top: 20 }} margin={{ top: 20 }}
enableSlices="x"
sliceTooltip={(datum) => (
<div className="rounded-md border border-brand-base bg-brand-surface-2 p-2 text-xs">
{datum.slice.points[0].data.yFormatted}
<span className="text-brand-secondary"> issues closed in </span>
{datum.slice.points[0].data.xFormatted}
</div>
)}
theme={{ theme={{
background: "rgb(var(--color-bg-base))", background: "rgb(var(--color-bg-base))",
}} }}

View File

@ -52,7 +52,7 @@ const BreadcrumbItem: React.FC<BreadcrumbItemProps> = ({ title, link, icon }) =>
<div className="max-w-64 px-3 text-sm"> <div className="max-w-64 px-3 text-sm">
<p className={`${icon ? "flex items-center gap-2" : ""}`}> <p className={`${icon ? "flex items-center gap-2" : ""}`}>
{icon} {icon}
<span className="break-all">{title}</span> <span className="break-words">{title}</span>
</p> </p>
</div> </div>
)} )}

View File

@ -9,6 +9,7 @@ import {
ChatBubbleOvalLeftEllipsisIcon, ChatBubbleOvalLeftEllipsisIcon,
DocumentTextIcon, DocumentTextIcon,
FolderPlusIcon, FolderPlusIcon,
InboxIcon,
LinkIcon, LinkIcon,
MagnifyingGlassIcon, MagnifyingGlassIcon,
RocketLaunchIcon, RocketLaunchIcon,
@ -34,6 +35,7 @@ import { Dialog, Transition } from "@headlessui/react";
// cmdk // cmdk
import { Command } from "cmdk"; import { Command } from "cmdk";
// hooks // hooks
import useProjectDetails from "hooks/use-project-details";
import useTheme from "hooks/use-theme"; import useTheme from "hooks/use-theme";
import useToast from "hooks/use-toast"; import useToast from "hooks/use-toast";
import useUser from "hooks/use-user"; import useUser from "hooks/use-user";
@ -64,10 +66,11 @@ import {
// services // services
import issuesService from "services/issues.service"; import issuesService from "services/issues.service";
import workspaceService from "services/workspace.service"; import workspaceService from "services/workspace.service";
import inboxService from "services/inbox.service";
// types // types
import { IIssue, IWorkspaceSearchResults } from "types"; import { IIssue, IWorkspaceSearchResults } from "types";
// fetch keys // fetch keys
import { ISSUE_DETAILS, PROJECT_ISSUES_ACTIVITY } from "constants/fetch-keys"; import { INBOX_LIST, ISSUE_DETAILS, PROJECT_ISSUES_ACTIVITY } from "constants/fetch-keys";
export const CommandPalette: React.FC = () => { export const CommandPalette: React.FC = () => {
const [isPaletteOpen, setIsPaletteOpen] = useState(false); const [isPaletteOpen, setIsPaletteOpen] = useState(false);
@ -81,7 +84,7 @@ export const CommandPalette: React.FC = () => {
const [deleteIssueModal, setDeleteIssueModal] = useState(false); const [deleteIssueModal, setDeleteIssueModal] = useState(false);
const [isCreateUpdatePageModalOpen, setIsCreateUpdatePageModalOpen] = useState(false); const [isCreateUpdatePageModalOpen, setIsCreateUpdatePageModalOpen] = useState(false);
const [searchTerm, setSearchTerm] = React.useState<string>(""); const [searchTerm, setSearchTerm] = useState("");
const [results, setResults] = useState<IWorkspaceSearchResults>({ const [results, setResults] = useState<IWorkspaceSearchResults>({
results: { results: {
workspace: [], workspace: [],
@ -105,6 +108,8 @@ export const CommandPalette: React.FC = () => {
const { workspaceSlug, projectId, issueId, inboxId } = router.query; const { workspaceSlug, projectId, issueId, inboxId } = router.query;
const { user } = useUser(); const { user } = useUser();
const { projectDetails } = useProjectDetails();
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();
const { toggleCollapsed } = useTheme(); const { toggleCollapsed } = useTheme();
@ -116,6 +121,13 @@ export const CommandPalette: React.FC = () => {
: null : null
); );
const { data: inboxList } = useSWR(
workspaceSlug && projectId ? INBOX_LIST(projectId as string) : null,
workspaceSlug && projectId
? () => inboxService.getInboxes(workspaceSlug as string, projectId as string)
: null
);
const updateIssue = useCallback( const updateIssue = useCallback(
async (formData: Partial<IIssue>) => { async (formData: Partial<IIssue>) => {
if (!workspaceSlug || !projectId || !issueId) return; if (!workspaceSlug || !projectId || !issueId) return;
@ -321,9 +333,9 @@ export const CommandPalette: React.FC = () => {
setDeleteIssueModal(true); setDeleteIssueModal(true);
}; };
const goToSettings = (path: string = "") => { const redirect = (path: string) => {
setIsPaletteOpen(false); setIsPaletteOpen(false);
router.push(`/${workspaceSlug}/settings/${path}`); router.push(path);
}; };
return ( return (
@ -396,7 +408,7 @@ export const CommandPalette: React.FC = () => {
leaveFrom="opacity-100" leaveFrom="opacity-100"
leaveTo="opacity-0" leaveTo="opacity-0"
> >
<div className="fixed inset-0 bg-[#131313] bg-opacity-50 transition-opacity" /> <div className="fixed inset-0 bg-brand-backdrop bg-opacity-50 transition-opacity" />
</Transition.Child> </Transition.Child>
<div className="fixed inset-0 z-30 overflow-y-auto p-4 sm:p-6 md:p-20"> <div className="fixed inset-0 z-30 overflow-y-auto p-4 sm:p-6 md:p-20">
@ -409,14 +421,14 @@ export const CommandPalette: React.FC = () => {
leaveFrom="opacity-100 translate-y-0 sm:scale-100" leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
> >
<Dialog.Panel className="relative mx-auto max-w-2xl transform divide-y divide-brand-base divide-opacity-10 rounded-xl border border-brand-base bg-brand-surface-2 shadow-2xl transition-all"> <Dialog.Panel className="relative mx-auto max-w-2xl transform divide-y divide-brand-base divide-opacity-10 rounded-xl border border-brand-base bg-brand-base shadow-2xl transition-all">
<Command <Command
filter={(value, search) => { filter={(value, search) => {
if (value.toLowerCase().includes(search.toLowerCase())) return 1; if (value.toLowerCase().includes(search.toLowerCase())) return 1;
return 0; return 0;
}} }}
onKeyDown={(e) => { onKeyDown={(e) => {
// when seach is empty and page is undefined // when search is empty and page is undefined
// when user tries to close the modal with esc // when user tries to close the modal with esc
if (e.key === "Escape" && !page && !searchTerm) { if (e.key === "Escape" && !page && !searchTerm) {
setIsPaletteOpen(false); setIsPaletteOpen(false);
@ -698,6 +710,24 @@ export const CommandPalette: React.FC = () => {
<kbd>D</kbd> <kbd>D</kbd>
</Command.Item> </Command.Item>
</Command.Group> </Command.Group>
{projectDetails && projectDetails.inbox_view && (
<Command.Group heading="Inbox">
<Command.Item
onSelect={() =>
redirect(
`/${workspaceSlug}/projects/${projectId}/inbox/${inboxList?.[0]?.id}`
)
}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-brand-secondary">
<InboxIcon className="h-4 w-4" color="#6b7280" />
Open inbox
</div>
</Command.Item>
</Command.Group>
)}
</> </>
)} )}
@ -814,7 +844,7 @@ export const CommandPalette: React.FC = () => {
{page === "settings" && workspaceSlug && ( {page === "settings" && workspaceSlug && (
<> <>
<Command.Item <Command.Item
onSelect={() => goToSettings()} onSelect={() => redirect(`/${workspaceSlug}/settings`)}
className="focus:outline-none" className="focus:outline-none"
> >
<div className="flex items-center gap-2 text-brand-secondary"> <div className="flex items-center gap-2 text-brand-secondary">
@ -823,7 +853,7 @@ export const CommandPalette: React.FC = () => {
</div> </div>
</Command.Item> </Command.Item>
<Command.Item <Command.Item
onSelect={() => goToSettings("members")} onSelect={() => redirect(`/${workspaceSlug}/settings/members`)}
className="focus:outline-none" className="focus:outline-none"
> >
<div className="flex items-center gap-2 text-brand-secondary"> <div className="flex items-center gap-2 text-brand-secondary">
@ -832,7 +862,7 @@ export const CommandPalette: React.FC = () => {
</div> </div>
</Command.Item> </Command.Item>
<Command.Item <Command.Item
onSelect={() => goToSettings("billing")} onSelect={() => redirect(`/${workspaceSlug}/settings/billing`)}
className="focus:outline-none" className="focus:outline-none"
> >
<div className="flex items-center gap-2 text-brand-secondary"> <div className="flex items-center gap-2 text-brand-secondary">
@ -841,7 +871,7 @@ export const CommandPalette: React.FC = () => {
</div> </div>
</Command.Item> </Command.Item>
<Command.Item <Command.Item
onSelect={() => goToSettings("integrations")} onSelect={() => redirect(`/${workspaceSlug}/settings/integrations`)}
className="focus:outline-none" className="focus:outline-none"
> >
<div className="flex items-center gap-2 text-brand-secondary"> <div className="flex items-center gap-2 text-brand-secondary">
@ -850,7 +880,7 @@ export const CommandPalette: React.FC = () => {
</div> </div>
</Command.Item> </Command.Item>
<Command.Item <Command.Item
onSelect={() => goToSettings("import-export")} onSelect={() => redirect(`/${workspaceSlug}/settings/import-export`)}
className="focus:outline-none" className="focus:outline-none"
> >
<div className="flex items-center gap-2 text-brand-secondary"> <div className="flex items-center gap-2 text-brand-secondary">

View File

@ -2,11 +2,12 @@
import useProjectIssuesView from "hooks/use-issues-view"; import useProjectIssuesView from "hooks/use-issues-view";
// components // components
import { SingleBoard } from "components/core/board-view/single-board"; import { SingleBoard } from "components/core/board-view/single-board";
// icons
import { getStateGroupIcon } from "components/icons";
// helpers // helpers
import { addSpaceIfCamelCase } from "helpers/string.helper"; import { addSpaceIfCamelCase } from "helpers/string.helper";
// types // types
import { ICurrentUserResponse, IIssue, IState, UserAuth } from "types"; import { ICurrentUserResponse, IIssue, IState, UserAuth } from "types";
import { getStateGroupIcon } from "components/icons";
type Props = { type Props = {
type: "issue" | "cycle" | "module"; type: "issue" | "cycle" | "module";

View File

@ -166,7 +166,7 @@ export const BoardHeader: React.FC<Props> = ({
<ArrowsPointingOutIcon className="h-4 w-4" /> <ArrowsPointingOutIcon className="h-4 w-4" />
)} )}
</button> </button>
{!isCompleted && ( {!isCompleted && selectedGroup !== "created_by" && (
<button <button
type="button" type="button"
className="grid h-7 w-7 place-items-center rounded p-1 text-brand-secondary outline-none duration-300 hover:bg-brand-surface-2" className="grid h-7 w-7 place-items-center rounded p-1 text-brand-secondary outline-none duration-300 hover:bg-brand-surface-2"

View File

@ -145,6 +145,7 @@ export const SingleBoard: React.FC<Props> = ({
{provided.placeholder} {provided.placeholder}
</span> </span>
</div> </div>
{selectedGroup !== "created_by" && (
<div> <div>
{type === "issue" ? ( {type === "issue" ? (
<button <button
@ -182,6 +183,7 @@ export const SingleBoard: React.FC<Props> = ({
) )
)} )}
</div> </div>
)}
</div> </div>
)} )}
</StrictModeDroppable> </StrictModeDroppable>

View File

@ -23,6 +23,7 @@ import {
ViewAssigneeSelect, ViewAssigneeSelect,
ViewDueDateSelect, ViewDueDateSelect,
ViewEstimateSelect, ViewEstimateSelect,
ViewLabelSelect,
ViewPrioritySelect, ViewPrioritySelect,
ViewStateSelect, ViewStateSelect,
} from "components/issues"; } from "components/issues";
@ -44,7 +45,14 @@ import { LayerDiagonalIcon } from "components/icons";
import { handleIssuesMutation } from "constants/issue"; import { handleIssuesMutation } from "constants/issue";
import { copyTextToClipboard, truncateText } from "helpers/string.helper"; import { copyTextToClipboard, truncateText } from "helpers/string.helper";
// types // types
import { ICurrentUserResponse, IIssue, Properties, TIssueGroupByOptions, UserAuth } from "types"; import {
ICurrentUserResponse,
IIssue,
ISubIssueResponse,
Properties,
TIssueGroupByOptions,
UserAuth,
} from "types";
// fetch-keys // fetch-keys
import { import {
CYCLE_DETAILS, CYCLE_DETAILS,
@ -52,6 +60,8 @@ import {
MODULE_DETAILS, MODULE_DETAILS,
MODULE_ISSUES_WITH_PARAMS, MODULE_ISSUES_WITH_PARAMS,
PROJECT_ISSUES_LIST_WITH_PARAMS, PROJECT_ISSUES_LIST_WITH_PARAMS,
SUB_ISSUES,
VIEW_ISSUES,
} from "constants/fetch-keys"; } from "constants/fetch-keys";
type Props = { type Props = {
@ -101,86 +111,71 @@ export const SingleBoardIssue: React.FC<Props> = ({
const { orderBy, params } = useIssuesView(); const { orderBy, params } = useIssuesView();
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId, cycleId, moduleId } = router.query; const { workspaceSlug, projectId, cycleId, moduleId, viewId } = router.query;
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();
const partialUpdateIssue = useCallback( const partialUpdateIssue = useCallback(
(formData: Partial<IIssue>, issueId: string) => { (formData: Partial<IIssue>, issue: IIssue) => {
if (!workspaceSlug || !projectId) return; if (!workspaceSlug || !projectId) return;
if (cycleId) const fetchKey = cycleId
mutate< ? CYCLE_ISSUES_WITH_PARAMS(cycleId.toString(), params)
| { : moduleId
[key: string]: IIssue[]; ? MODULE_ISSUES_WITH_PARAMS(moduleId.toString(), params)
} : viewId
| IIssue[] ? VIEW_ISSUES(viewId.toString(), params)
>( : PROJECT_ISSUES_LIST_WITH_PARAMS(projectId.toString(), params);
CYCLE_ISSUES_WITH_PARAMS(cycleId as string, params),
(prevData) => if (issue.parent) {
handleIssuesMutation( mutate<ISubIssueResponse>(
formData, SUB_ISSUES(issue.parent.toString()),
groupTitle ?? "",
selectedGroup,
index,
orderBy,
prevData
),
false
);
else if (moduleId)
mutate<
| {
[key: string]: IIssue[];
}
| IIssue[]
>(
MODULE_ISSUES_WITH_PARAMS(moduleId as string),
(prevData) =>
handleIssuesMutation(
formData,
groupTitle ?? "",
selectedGroup,
index,
orderBy,
prevData
),
false
);
else {
mutate<
| {
[key: string]: IIssue[];
}
| IIssue[]
>(
PROJECT_ISSUES_LIST_WITH_PARAMS(projectId as string, params),
(prevData) => { (prevData) => {
if (!prevData) return prevData; if (!prevData) return prevData;
return handleIssuesMutation( return {
...prevData,
sub_issues: (prevData.sub_issues ?? []).map((i) => {
if (i.id === issue.id) {
return {
...i,
...formData,
};
}
return i;
}),
};
},
false
);
} else {
mutate<
| {
[key: string]: IIssue[];
}
| IIssue[]
>(
fetchKey,
(prevData) =>
handleIssuesMutation(
formData, formData,
groupTitle ?? "", groupTitle ?? "",
selectedGroup, selectedGroup,
index, index,
orderBy, orderBy,
prevData prevData
); ),
},
false false
); );
} }
issuesService issuesService
.patchIssue(workspaceSlug as string, projectId as string, issueId, formData, user) .patchIssue(workspaceSlug as string, projectId as string, issue.id, formData, user)
.then(() => { .then(() => {
if (cycleId) { mutate(fetchKey);
mutate(CYCLE_ISSUES_WITH_PARAMS(cycleId as string, params));
mutate(CYCLE_DETAILS(cycleId as string)); if (cycleId) mutate(CYCLE_DETAILS(cycleId as string));
} else if (moduleId) { if (moduleId) mutate(MODULE_DETAILS(moduleId as string));
mutate(MODULE_ISSUES_WITH_PARAMS(moduleId as string, params));
mutate(MODULE_DETAILS(moduleId as string));
} else mutate(PROJECT_ISSUES_LIST_WITH_PARAMS(projectId as string, params));
}); });
}, },
[ [
@ -188,6 +183,7 @@ export const SingleBoardIssue: React.FC<Props> = ({
projectId, projectId,
cycleId, cycleId,
moduleId, moduleId,
viewId,
groupTitle, groupTitle,
index, index,
selectedGroup, selectedGroup,
@ -338,11 +334,8 @@ export const SingleBoardIssue: React.FC<Props> = ({
{issue.project_detail.identifier}-{issue.sequence_id} {issue.project_detail.identifier}-{issue.sequence_id}
</div> </div>
)} )}
<h5 <h5 className="text-sm group-hover:text-brand-accent break-words line-clamp-3">
className="break-all text-sm group-hover:text-brand-accent" {issue.name}
style={{ lineClamp: 3, WebkitLineClamp: 3 }}
>
{truncateText(issue.name, 100)}
</h5> </h5>
</a> </a>
</Link> </Link>
@ -373,30 +366,20 @@ export const SingleBoardIssue: React.FC<Props> = ({
isNotAllowed={isNotAllowed} isNotAllowed={isNotAllowed}
/> />
)} )}
{properties.labels && issue.label_details.length > 0 && ( {properties.labels && (
<div className="flex flex-wrap gap-1"> <ViewLabelSelect
{issue.label_details.map((label) => ( issue={issue}
<div partialUpdateIssue={partialUpdateIssue}
key={label.id} isNotAllowed={isNotAllowed}
className="group flex items-center gap-1 rounded-2xl border border-brand-base px-2 py-0.5 text-xs text-brand-secondary" user={user}
> selfPositioned
<span
className="h-1.5 w-1.5 flex-shrink-0 rounded-full"
style={{
backgroundColor: label?.color && label.color !== "" ? label.color : "#000",
}}
/> />
{label.name}
</div>
))}
</div>
)} )}
{properties.assignee && ( {properties.assignee && (
<ViewAssigneeSelect <ViewAssigneeSelect
issue={issue} issue={issue}
partialUpdateIssue={partialUpdateIssue} partialUpdateIssue={partialUpdateIssue}
isNotAllowed={isNotAllowed} isNotAllowed={isNotAllowed}
tooltipPosition="left"
user={user} user={user}
selfPositioned selfPositioned
/> />

View File

@ -12,6 +12,8 @@ import { Combobox, Dialog, Transition } from "@headlessui/react";
import issuesServices from "services/issues.service"; import issuesServices from "services/issues.service";
// hooks // hooks
import useToast from "hooks/use-toast"; import useToast from "hooks/use-toast";
import useIssuesView from "hooks/use-issues-view";
import useCalendarIssuesView from "hooks/use-calendar-issues-view";
// ui // ui
import { DangerButton, SecondaryButton } from "components/ui"; import { DangerButton, SecondaryButton } from "components/ui";
// icons // icons
@ -20,7 +22,15 @@ import { LayerDiagonalIcon } from "components/icons";
// types // types
import { ICurrentUserResponse, IIssue } from "types"; import { ICurrentUserResponse, IIssue } from "types";
// fetch keys // fetch keys
import { PROJECT_ISSUES_LIST } from "constants/fetch-keys"; import {
CYCLE_DETAILS,
CYCLE_ISSUES_WITH_PARAMS,
MODULE_DETAILS,
MODULE_ISSUES_WITH_PARAMS,
PROJECT_ISSUES_LIST,
PROJECT_ISSUES_LIST_WITH_PARAMS,
VIEW_ISSUES,
} from "constants/fetch-keys";
type FormInput = { type FormInput = {
delete_issue_ids: string[]; delete_issue_ids: string[];
@ -36,7 +46,7 @@ export const BulkDeleteIssuesModal: React.FC<Props> = ({ isOpen, setIsOpen, user
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId } = router.query; const { workspaceSlug, projectId, cycleId, moduleId, viewId } = router.query;
const { data: issues } = useSWR( const { data: issues } = useSWR(
workspaceSlug && projectId workspaceSlug && projectId
@ -48,6 +58,9 @@ export const BulkDeleteIssuesModal: React.FC<Props> = ({ isOpen, setIsOpen, user
); );
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();
const { issueView, params } = useIssuesView();
const { params: calendarParams } = useCalendarIssuesView();
const { order_by, group_by, ...viewGanttParams } = params;
const { const {
handleSubmit, handleSubmit,
@ -61,6 +74,81 @@ export const BulkDeleteIssuesModal: React.FC<Props> = ({ isOpen, setIsOpen, user
}, },
}); });
const handleClose = () => {
setIsOpen(false);
setQuery("");
reset();
};
const handleDelete: SubmitHandler<FormInput> = async (data) => {
if (!workspaceSlug || !projectId) return;
if (!data.delete_issue_ids || data.delete_issue_ids.length === 0) {
setToastAlert({
type: "error",
title: "Error!",
message: "Please select at least one issue.",
});
return;
}
if (!Array.isArray(data.delete_issue_ids)) data.delete_issue_ids = [data.delete_issue_ids];
const calendarFetchKey = cycleId
? CYCLE_ISSUES_WITH_PARAMS(cycleId.toString(), calendarParams)
: moduleId
? MODULE_ISSUES_WITH_PARAMS(moduleId.toString(), calendarParams)
: viewId
? VIEW_ISSUES(viewId.toString(), calendarParams)
: PROJECT_ISSUES_LIST_WITH_PARAMS(projectId?.toString() ?? "", calendarParams);
const ganttFetchKey = cycleId
? CYCLE_ISSUES_WITH_PARAMS(cycleId.toString())
: moduleId
? MODULE_ISSUES_WITH_PARAMS(moduleId.toString())
: viewId
? VIEW_ISSUES(viewId.toString(), viewGanttParams)
: PROJECT_ISSUES_LIST_WITH_PARAMS(projectId?.toString() ?? "");
await issuesServices
.bulkDeleteIssues(
workspaceSlug as string,
projectId as string,
{
issue_ids: data.delete_issue_ids,
},
user
)
.then(() => {
setToastAlert({
type: "success",
title: "Success!",
message: "Issues deleted successfully!",
});
if (issueView === "calendar") mutate(calendarFetchKey);
else if (issueView === "gantt_chart") mutate(ganttFetchKey);
else {
if (cycleId) {
mutate(CYCLE_ISSUES_WITH_PARAMS(cycleId.toString(), params));
mutate(CYCLE_DETAILS(cycleId.toString()));
} else if (moduleId) {
mutate(MODULE_ISSUES_WITH_PARAMS(moduleId as string, params));
mutate(MODULE_DETAILS(moduleId as string));
} else mutate(PROJECT_ISSUES_LIST_WITH_PARAMS(projectId.toString(), params));
}
handleClose();
})
.catch(() =>
setToastAlert({
type: "error",
title: "Error!",
message: "Something went wrong. Please try again.",
})
);
};
const filteredIssues: IIssue[] = const filteredIssues: IIssue[] =
query === "" query === ""
? issues ?? [] ? issues ?? []
@ -72,48 +160,6 @@ export const BulkDeleteIssuesModal: React.FC<Props> = ({ isOpen, setIsOpen, user
.includes(query.toLowerCase()) .includes(query.toLowerCase())
) ?? []; ) ?? [];
const handleClose = () => {
setIsOpen(false);
setQuery("");
reset();
};
const handleDelete: SubmitHandler<FormInput> = async (data) => {
if (!data.delete_issue_ids || data.delete_issue_ids.length === 0) {
setToastAlert({
title: "Error",
type: "error",
message: "Please select atleast one issue",
});
return;
}
if (!Array.isArray(data.delete_issue_ids)) data.delete_issue_ids = [data.delete_issue_ids];
if (workspaceSlug && projectId) {
await issuesServices
.bulkDeleteIssues(
workspaceSlug as string,
projectId as string,
{
issue_ids: data.delete_issue_ids,
},
user
)
.then((res) => {
setToastAlert({
title: "Success",
type: "success",
message: res.message,
});
handleClose();
})
.catch((e) => {
console.log(e);
});
}
};
return ( return (
<Transition.Root show={isOpen} as={React.Fragment} afterLeave={() => setQuery("")} appear> <Transition.Root show={isOpen} as={React.Fragment} afterLeave={() => setQuery("")} appear>
<Dialog as="div" className="relative z-20" onClose={handleClose}> <Dialog as="div" className="relative z-20" onClose={handleClose}>

View File

@ -19,6 +19,7 @@ import {
ViewAssigneeSelect, ViewAssigneeSelect,
ViewDueDateSelect, ViewDueDateSelect,
ViewEstimateSelect, ViewEstimateSelect,
ViewLabelSelect,
ViewPrioritySelect, ViewPrioritySelect,
ViewStateSelect, ViewStateSelect,
} from "components/issues"; } from "components/issues";
@ -28,12 +29,13 @@ import { LayerDiagonalIcon } from "components/icons";
// helper // helper
import { copyTextToClipboard, truncateText } from "helpers/string.helper"; import { copyTextToClipboard, truncateText } from "helpers/string.helper";
// type // type
import { ICurrentUserResponse, IIssue } from "types"; import { ICurrentUserResponse, IIssue, ISubIssueResponse } from "types";
// fetch-keys // fetch-keys
import { import {
CYCLE_ISSUES_WITH_PARAMS, CYCLE_ISSUES_WITH_PARAMS,
MODULE_ISSUES_WITH_PARAMS, MODULE_ISSUES_WITH_PARAMS,
PROJECT_ISSUES_LIST_WITH_PARAMS, PROJECT_ISSUES_LIST_WITH_PARAMS,
SUB_ISSUES,
VIEW_ISSUES, VIEW_ISSUES,
} from "constants/fetch-keys"; } from "constants/fetch-keys";
@ -68,7 +70,7 @@ export const SingleCalendarIssue: React.FC<Props> = ({
const [properties] = useIssuesProperties(workspaceSlug as string, projectId as string); const [properties] = useIssuesProperties(workspaceSlug as string, projectId as string);
const partialUpdateIssue = useCallback( const partialUpdateIssue = useCallback(
(formData: Partial<IIssue>, issueId: string) => { (formData: Partial<IIssue>, issue: IIssue) => {
if (!workspaceSlug || !projectId) return; if (!workspaceSlug || !projectId) return;
const fetchKey = cycleId const fetchKey = cycleId
@ -79,11 +81,33 @@ export const SingleCalendarIssue: React.FC<Props> = ({
? VIEW_ISSUES(viewId.toString(), params) ? VIEW_ISSUES(viewId.toString(), params)
: PROJECT_ISSUES_LIST_WITH_PARAMS(projectId.toString(), params); : PROJECT_ISSUES_LIST_WITH_PARAMS(projectId.toString(), params);
if (issue.parent) {
mutate<ISubIssueResponse>(
SUB_ISSUES(issue.parent.toString()),
(prevData) => {
if (!prevData) return prevData;
return {
...prevData,
sub_issues: (prevData.sub_issues ?? []).map((i) => {
if (i.id === issue.id) {
return {
...i,
...formData,
};
}
return i;
}),
};
},
false
);
} else {
mutate<IIssue[]>( mutate<IIssue[]>(
fetchKey, fetchKey,
(prevData) => (prevData) =>
(prevData ?? []).map((p) => { (prevData ?? []).map((p) => {
if (p.id === issueId) { if (p.id === issue.id) {
return { return {
...p, ...p,
...formData, ...formData,
@ -95,9 +119,16 @@ export const SingleCalendarIssue: React.FC<Props> = ({
}), }),
false false
); );
}
issuesService issuesService
.patchIssue(workspaceSlug as string, projectId as string, issueId as string, formData, user) .patchIssue(
workspaceSlug as string,
projectId as string,
issue.id as string,
formData,
user
)
.then(() => { .then(() => {
mutate(fetchKey); mutate(fetchKey);
}) })
@ -207,25 +238,14 @@ export const SingleCalendarIssue: React.FC<Props> = ({
isNotAllowed={isNotAllowed} isNotAllowed={isNotAllowed}
/> />
)} )}
{properties.labels && issue.label_details.length > 0 ? ( {properties.labels && (
<div className="flex flex-wrap gap-1"> <ViewLabelSelect
{issue.label_details.map((label) => ( issue={issue}
<span partialUpdateIssue={partialUpdateIssue}
key={label.id} position="left"
className="group flex items-center gap-1 rounded-2xl border border-brand-base px-2 py-0.5 text-xs text-brand-secondary" user={user}
> isNotAllowed={isNotAllowed}
<span
className="h-1.5 w-1.5 rounded-full"
style={{
backgroundColor: label?.color && label.color !== "" ? label.color : "#000",
}}
/> />
{label.name}
</span>
))}
</div>
) : (
""
)} )}
{properties.assignee && ( {properties.assignee && (
<ViewAssigneeSelect <ViewAssigneeSelect

View File

@ -1,23 +1,24 @@
import React, { useState } from "react"; import React, { useEffect, useState } from "react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { mutate } from "swr"; import { mutate } from "swr";
// react-hook-form
import { Controller, SubmitHandler, useForm } from "react-hook-form";
// headless ui // headless ui
import { Combobox, Dialog, Transition } from "@headlessui/react"; import { Combobox, Dialog, Transition } from "@headlessui/react";
// services
import projectService from "services/project.service";
// hooks // hooks
import useToast from "hooks/use-toast"; import useToast from "hooks/use-toast";
import useIssuesView from "hooks/use-issues-view"; import useIssuesView from "hooks/use-issues-view";
import useDebounce from "hooks/use-debounce";
// ui // ui
import { PrimaryButton, SecondaryButton } from "components/ui"; import { Loader, PrimaryButton, SecondaryButton } from "components/ui";
// icons // icons
import { MagnifyingGlassIcon } from "@heroicons/react/24/outline"; import { MagnifyingGlassIcon, XMarkIcon } from "@heroicons/react/24/outline";
import { LayerDiagonalIcon } from "components/icons"; import { LayerDiagonalIcon } from "components/icons";
// types // types
import { IIssue } from "types"; import { ISearchIssueResponse, TProjectIssuesSearchParams } from "types";
// fetch-keys // fetch-keys
import { import {
CYCLE_DETAILS, CYCLE_DETAILS,
@ -26,27 +27,30 @@ import {
MODULE_ISSUES_WITH_PARAMS, MODULE_ISSUES_WITH_PARAMS,
} from "constants/fetch-keys"; } from "constants/fetch-keys";
type FormInput = {
issues: string[];
};
type Props = { type Props = {
isOpen: boolean; isOpen: boolean;
handleClose: () => void; handleClose: () => void;
issues: IIssue[]; searchParams: Partial<TProjectIssuesSearchParams>;
handleOnSubmit: any; handleOnSubmit: (data: ISearchIssueResponse[]) => Promise<void>;
}; };
export const ExistingIssuesListModal: React.FC<Props> = ({ export const ExistingIssuesListModal: React.FC<Props> = ({
isOpen, isOpen,
handleClose: onClose, handleClose: onClose,
issues, searchParams,
handleOnSubmit, handleOnSubmit,
}) => { }) => {
const [query, setQuery] = useState(""); const [searchTerm, setSearchTerm] = useState("");
const [issues, setIssues] = useState<ISearchIssueResponse[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isSearching, setIsSearching] = useState(false);
const [selectedIssues, setSelectedIssues] = useState<ISearchIssueResponse[]>([]);
const [isSubmitting, setIsSubmitting] = useState(false);
const debouncedSearchTerm: string = useDebounce(searchTerm, 500);
const router = useRouter(); const router = useRouter();
const { cycleId, moduleId } = router.query; const { workspaceSlug, projectId, cycleId, moduleId } = router.query;
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();
@ -54,37 +58,30 @@ export const ExistingIssuesListModal: React.FC<Props> = ({
const handleClose = () => { const handleClose = () => {
onClose(); onClose();
setQuery(""); setSearchTerm("");
reset(); setSelectedIssues([]);
}; };
const { const onSubmit = async () => {
handleSubmit, if (selectedIssues.length === 0) {
reset,
control,
formState: { isSubmitting },
} = useForm<FormInput>({
defaultValues: {
issues: [],
},
});
const onSubmit: SubmitHandler<FormInput> = async (data) => {
if (!data.issues || data.issues.length === 0) {
setToastAlert({ setToastAlert({
title: "Error",
type: "error", type: "error",
message: "Please select atleast one issue", title: "Error!",
message: "Please select at least one issue.",
}); });
return; return;
} }
await handleOnSubmit(data); setIsSubmitting(true);
await handleOnSubmit(selectedIssues).finally(() => setIsSubmitting(false));
if (cycleId) { if (cycleId) {
mutate(CYCLE_ISSUES_WITH_PARAMS(cycleId as string, params)); mutate(CYCLE_ISSUES_WITH_PARAMS(cycleId as string, params));
mutate(CYCLE_DETAILS(cycleId as string)); mutate(CYCLE_DETAILS(cycleId as string));
} }
if (moduleId) { if (moduleId) {
mutate(MODULE_ISSUES_WITH_PARAMS(moduleId as string, params)); mutate(MODULE_ISSUES_WITH_PARAMS(moduleId as string, params));
mutate(MODULE_DETAILS(moduleId as string)); mutate(MODULE_DETAILS(moduleId as string));
@ -95,18 +92,45 @@ export const ExistingIssuesListModal: React.FC<Props> = ({
setToastAlert({ setToastAlert({
title: "Success", title: "Success",
type: "success", type: "success",
message: `Issue${data.issues.length > 1 ? "s" : ""} added successfully`, message: `Issue${selectedIssues.length > 1 ? "s" : ""} added successfully`,
}); });
}; };
const filteredIssues: IIssue[] = useEffect(() => {
query === "" if (!workspaceSlug || !projectId) return;
? issues ?? []
: issues.filter((issue) => issue.name.toLowerCase().includes(query.toLowerCase())) ?? []; setIsLoading(true);
if (debouncedSearchTerm) {
setIsSearching(true);
projectService
.projectIssuesSearch(workspaceSlug as string, projectId as string, {
search: debouncedSearchTerm,
...searchParams,
})
.then((res) => {
setIssues(res);
})
.finally(() => {
setIsLoading(false);
setIsSearching(false);
});
} else {
setIssues([]);
setIsLoading(false);
setIsSearching(false);
}
}, [debouncedSearchTerm, workspaceSlug, projectId, searchParams]);
return ( return (
<> <>
<Transition.Root show={isOpen} as={React.Fragment} afterLeave={() => setQuery("")} appear> <Transition.Root
show={isOpen}
as={React.Fragment}
afterLeave={() => setSearchTerm("")}
appear
>
<Dialog as="div" className="relative z-20" onClose={handleClose}> <Dialog as="div" className="relative z-20" onClose={handleClose}>
<Transition.Child <Transition.Child
as={React.Fragment} as={React.Fragment}
@ -131,12 +155,14 @@ export const ExistingIssuesListModal: React.FC<Props> = ({
leaveTo="opacity-0 scale-95" leaveTo="opacity-0 scale-95"
> >
<Dialog.Panel className="relative mx-auto max-w-2xl transform rounded-xl border border-brand-base bg-brand-base shadow-2xl transition-all"> <Dialog.Panel className="relative mx-auto max-w-2xl transform rounded-xl border border-brand-base bg-brand-base shadow-2xl transition-all">
<form> <Combobox
<Controller as="div"
control={control} onChange={(val: ISearchIssueResponse) => {
name="issues" if (selectedIssues.some((i) => i.id === val.id))
render={({ field }) => ( setSelectedIssues((prevData) => prevData.filter((i) => i.id !== val.id));
<Combobox as="div" {...field} multiple> else setSelectedIssues((prevData) => [...prevData, val]);
}}
>
<div className="relative m-1"> <div className="relative m-1">
<MagnifyingGlassIcon <MagnifyingGlassIcon
className="pointer-events-none absolute top-3.5 left-4 h-5 w-5 text-brand-base text-opacity-40" className="pointer-events-none absolute top-3.5 left-4 h-5 w-5 text-brand-base text-opacity-40"
@ -144,77 +170,121 @@ export const ExistingIssuesListModal: React.FC<Props> = ({
/> />
<Combobox.Input <Combobox.Input
className="h-12 w-full border-0 bg-transparent pl-11 pr-4 text-brand-base placeholder-gray-500 outline-none focus:ring-0 sm:text-sm" className="h-12 w-full border-0 bg-transparent pl-11 pr-4 text-brand-base placeholder-gray-500 outline-none focus:ring-0 sm:text-sm"
placeholder="Search..." placeholder="Type to search..."
onChange={(e) => setQuery(e.target.value)} value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/> />
</div> </div>
<Combobox.Options <div className="text-brand-secondary text-[0.825rem] p-2">
static {selectedIssues.length > 0 ? (
className="max-h-80 scroll-py-2 divide-y divide-brand-base overflow-y-auto" <div className="flex items-center gap-2 flex-wrap mt-1">
{selectedIssues.map((issue) => (
<div
key={issue.id}
className="flex items-center gap-1 text-xs border border-brand-base bg-brand-surface-2 pl-2 py-1 rounded-md text-brand-base whitespace-nowrap"
> >
{filteredIssues.length > 0 ? ( {issue.project__identifier}-{issue.sequence_id}
<li className="p-2"> <button
{query === "" && ( type="button"
<h2 className="mb-2 px-3 text-xs font-medium text-brand-base"> className="group p-1"
Select issues to add onClick={() =>
</h2> setSelectedIssues((prevData) =>
prevData.filter((i) => i.id !== issue.id)
)
}
>
<XMarkIcon className="h-3 w-3 text-brand-secondary group-hover:text-brand-base" />
</button>
</div>
))}
</div>
) : (
<div className="w-min text-xs border border-brand-base bg-brand-surface-2 p-2 rounded-md whitespace-nowrap">
No issues selected
</div>
)} )}
<ul className="text-sm text-brand-base"> </div>
{filteredIssues.map((issue) => (
<Combobox.Options static className="max-h-80 scroll-py-2 overflow-y-auto mt-2">
{debouncedSearchTerm !== "" && (
<h5 className="text-[0.825rem] text-brand-secondary mx-2">
Search results for{" "}
<span className="text-brand-base">
{'"'}
{debouncedSearchTerm}
{'"'}
</span>{" "}
in project:
</h5>
)}
{!isLoading &&
issues.length === 0 &&
searchTerm !== "" &&
debouncedSearchTerm !== "" && (
<div className="flex flex-col items-center justify-center gap-4 px-3 py-8 text-center">
<LayerDiagonalIcon height="52" width="52" />
<h3 className="text-brand-secondary">
No issues found. Create a new issue with{" "}
<pre className="inline rounded bg-brand-surface-2 px-2 py-1 text-sm">
C
</pre>
.
</h3>
</div>
)}
{isLoading || isSearching ? (
<Loader className="space-y-3 p-3">
<Loader.Item height="40px" />
<Loader.Item height="40px" />
<Loader.Item height="40px" />
<Loader.Item height="40px" />
</Loader>
) : (
<ul className={`text-sm text-brand-base ${issues.length > 0 ? "p-2" : ""}`}>
{issues.map((issue) => {
const selected = selectedIssues.some((i) => i.id === issue.id);
return (
<Combobox.Option <Combobox.Option
key={issue.id} key={issue.id}
as="label" as="label"
htmlFor={`issue-${issue.id}`} htmlFor={`issue-${issue.id}`}
value={issue.id} value={issue}
className={({ active, selected }) => className={({ active }) =>
`flex w-full cursor-pointer select-none items-center gap-2 rounded-md px-3 py-2 text-brand-secondary ${ `flex w-full cursor-pointer select-none items-center gap-2 rounded-md px-3 py-2 text-brand-secondary ${
active ? "bg-brand-surface-2 text-brand-base" : "" active ? "bg-brand-surface-2 text-brand-base" : ""
} ${selected ? "text-brand-base" : ""}` } ${selected ? "text-brand-base" : ""}`
} }
> >
{({ selected }) => (
<>
<input type="checkbox" checked={selected} readOnly /> <input type="checkbox" checked={selected} readOnly />
<span <span
className="block h-1.5 w-1.5 flex-shrink-0 rounded-full" className="block h-1.5 w-1.5 flex-shrink-0 rounded-full"
style={{ style={{
backgroundColor: issue.state_detail.color, backgroundColor: issue.state__color,
}} }}
/> />
<span className="flex-shrink-0 text-xs"> <span className="flex-shrink-0 text-xs">
{issue.project_detail.identifier}-{issue.sequence_id} {issue.project__identifier}-{issue.sequence_id}
</span> </span>
{issue.name} {issue.name}
</>
)}
</Combobox.Option> </Combobox.Option>
))} );
})}
</ul> </ul>
</li>
) : (
<div className="flex flex-col items-center justify-center gap-4 px-3 py-8 text-center">
<LayerDiagonalIcon height="52" width="52" />
<h3 className="text-sm text-brand-secondary">
No issues found. Create a new issue with{" "}
<pre className="inline rounded bg-brand-surface-2 px-2 py-1">C</pre>
.
</h3>
</div>
)} )}
</Combobox.Options> </Combobox.Options>
</Combobox> </Combobox>
)} {selectedIssues.length > 0 && (
/>
{filteredIssues.length > 0 && (
<div className="flex items-center justify-end gap-2 p-3"> <div className="flex items-center justify-end gap-2 p-3">
<SecondaryButton onClick={handleClose}>Cancel</SecondaryButton> <SecondaryButton onClick={handleClose}>Cancel</SecondaryButton>
<PrimaryButton onClick={handleSubmit(onSubmit)} loading={isSubmitting}> <PrimaryButton onClick={onSubmit} loading={isSubmitting}>
{isSubmitting ? "Adding..." : "Add selected issues"} {isSubmitting ? "Adding..." : "Add selected issues"}
</PrimaryButton> </PrimaryButton>
</div> </div>
)} )}
</form>
</Dialog.Panel> </Dialog.Panel>
</Transition.Child> </Transition.Child>
</div> </div>

View File

@ -135,7 +135,7 @@ export const FilterList: React.FC<any> = ({ filters, setFilters }) => {
}`} }`}
> >
<span>{getPriorityIcon(priority)}</span> <span>{getPriorityIcon(priority)}</span>
<span>{priority ? priority : "None"}</span> <span>{priority === "null" ? "None" : priority}</span>
<span <span
className="cursor-pointer" className="cursor-pointer"
onClick={() => onClick={() =>

View File

@ -2,6 +2,7 @@ export * from "./board-view";
export * from "./calendar-view"; export * from "./calendar-view";
export * from "./gantt-chart-view"; export * from "./gantt-chart-view";
export * from "./list-view"; export * from "./list-view";
export * from "./spreadsheet-view";
export * from "./sidebar"; export * from "./sidebar";
export * from "./bulk-delete-issues-modal"; export * from "./bulk-delete-issues-modal";
export * from "./existing-issues-list-modal"; export * from "./existing-issues-list-modal";

View File

@ -10,7 +10,7 @@ import { Popover, Transition } from "@headlessui/react";
// components // components
import { SelectFilters } from "components/views"; import { SelectFilters } from "components/views";
// ui // ui
import { CustomMenu, ToggleSwitch } from "components/ui"; import { CustomMenu, Icon, ToggleSwitch } from "components/ui";
// icons // icons
import { import {
ChevronDownIcon, ChevronDownIcon,
@ -83,6 +83,15 @@ export const IssuesFilterView: React.FC = () => {
> >
<CalendarDaysIcon className="h-4 w-4 text-brand-secondary" /> <CalendarDaysIcon className="h-4 w-4 text-brand-secondary" />
</button> </button>
<button
type="button"
className={`grid h-7 w-7 place-items-center rounded p-1 outline-none duration-300 hover:bg-brand-surface-2 ${
issueView === "spreadsheet" ? "bg-brand-surface-2" : ""
}`}
onClick={() => setIssueView("spreadsheet")}
>
<Icon iconName="table_chart" className="text-brand-secondary" />
</button>
<button <button
type="button" type="button"
className={`grid h-7 w-7 place-items-center rounded outline-none duration-300 hover:bg-brand-surface-2 ${ className={`grid h-7 w-7 place-items-center rounded outline-none duration-300 hover:bg-brand-surface-2 ${
@ -146,10 +155,10 @@ export const IssuesFilterView: React.FC = () => {
leaveFrom="opacity-100 translate-y-0" leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1" leaveTo="opacity-0 translate-y-1"
> >
<Popover.Panel className="absolute right-0 z-20 mt-1 w-screen max-w-xs transform rounded-lg border border-brand-base bg-brand-surface-1 p-3 shadow-lg"> <Popover.Panel className="absolute right-0 z-30 mt-1 w-screen max-w-xs transform rounded-lg border border-brand-base bg-brand-surface-1 p-3 shadow-lg">
<div className="relative divide-y-2 divide-brand-base"> <div className="relative divide-y-2 divide-brand-base">
<div className="space-y-4 pb-3 text-xs"> <div className="space-y-4 pb-3 text-xs">
{issueView !== "calendar" && ( {issueView !== "calendar" && issueView !== "spreadsheet" && (
<> <>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h4 className="text-brand-secondary">Group by</h4> <h4 className="text-brand-secondary">Group by</h4>
@ -221,7 +230,7 @@ export const IssuesFilterView: React.FC = () => {
</CustomMenu> </CustomMenu>
</div> </div>
{issueView !== "calendar" && ( {issueView !== "calendar" && issueView !== "spreadsheet" && (
<> <>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h4 className="text-brand-secondary">Show empty states</h4> <h4 className="text-brand-secondary">Show empty states</h4>
@ -252,6 +261,13 @@ export const IssuesFilterView: React.FC = () => {
{Object.keys(properties).map((key) => { {Object.keys(properties).map((key) => {
if (key === "estimate" && !isEstimateActive) return null; if (key === "estimate" && !isEstimateActive) return null;
if (
(issueView === "spreadsheet" && key === "attachment_count") ||
(issueView === "spreadsheet" && key === "link") ||
(issueView === "spreadsheet" && key === "sub_issue_count")
)
return null;
return ( return (
<button <button
key={key} key={key}

View File

@ -19,7 +19,14 @@ import useToast from "hooks/use-toast";
import useIssuesView from "hooks/use-issues-view"; import useIssuesView from "hooks/use-issues-view";
import useUserAuth from "hooks/use-user-auth"; import useUserAuth from "hooks/use-user-auth";
// components // components
import { AllLists, AllBoards, FilterList, CalendarView, GanttChartView } from "components/core"; import {
AllLists,
AllBoards,
FilterList,
CalendarView,
GanttChartView,
SpreadsheetView,
} from "components/core";
import { CreateUpdateIssueModal, DeleteIssueModal } from "components/issues"; import { CreateUpdateIssueModal, DeleteIssueModal } from "components/issues";
import { CreateUpdateViewModal } from "components/views"; import { CreateUpdateViewModal } from "components/views";
import { CycleIssuesGanttChartView, TransferIssues, TransferIssuesModal } from "components/cycles"; import { CycleIssuesGanttChartView, TransferIssues, TransferIssuesModal } from "components/cycles";
@ -283,9 +290,17 @@ export const IssuesView: React.FC<Props> = ({
const addIssueToState = useCallback( const addIssueToState = useCallback(
(groupTitle: string) => { (groupTitle: string) => {
setCreateIssueModal(true); setCreateIssueModal(true);
let preloadedValue: string | string[] = groupTitle;
if (selectedGroup === "labels") {
if (groupTitle === "None") preloadedValue = [];
else preloadedValue = [groupTitle];
}
if (selectedGroup) if (selectedGroup)
setPreloadedData({ setPreloadedData({
[selectedGroup]: groupTitle, [selectedGroup]: preloadedValue,
actionType: "createIssue", actionType: "createIssue",
}); });
else setPreloadedData({ actionType: "createIssue" }); else setPreloadedData({ actionType: "createIssue" });
@ -443,7 +458,6 @@ export const IssuesView: React.FC<Props> = ({
/> />
<CreateUpdateIssueModal <CreateUpdateIssueModal
isOpen={editIssueModal && issueToEdit?.actionType !== "delete"} isOpen={editIssueModal && issueToEdit?.actionType !== "delete"}
prePopulateData={{ ...issueToEdit }}
handleClose={() => setEditIssueModal(false)} handleClose={() => setEditIssueModal(false)}
data={issueToEdit} data={issueToEdit}
/> />
@ -556,6 +570,16 @@ export const IssuesView: React.FC<Props> = ({
user={user} user={user}
userAuth={memberRole} userAuth={memberRole}
/> />
) : issueView === "spreadsheet" ? (
<SpreadsheetView
type={type}
handleEditIssue={handleEditIssue}
handleDeleteIssue={handleDeleteIssue}
openIssuesListModal={type !== "issue" ? openIssuesListModal : null}
isCompleted={isCompleted}
user={user}
userAuth={memberRole}
/>
) : ( ) : (
issueView === "gantt_chart" && <GanttChartView /> issueView === "gantt_chart" && <GanttChartView />
)} )}

View File

@ -14,6 +14,7 @@ import {
ViewAssigneeSelect, ViewAssigneeSelect,
ViewDueDateSelect, ViewDueDateSelect,
ViewEstimateSelect, ViewEstimateSelect,
ViewLabelSelect,
ViewPrioritySelect, ViewPrioritySelect,
ViewStateSelect, ViewStateSelect,
} from "components/issues/view-select"; } from "components/issues/view-select";
@ -36,7 +37,7 @@ import { LayerDiagonalIcon } from "components/icons";
import { copyTextToClipboard, truncateText } from "helpers/string.helper"; import { copyTextToClipboard, truncateText } from "helpers/string.helper";
import { handleIssuesMutation } from "constants/issue"; import { handleIssuesMutation } from "constants/issue";
// types // types
import { ICurrentUserResponse, IIssue, Properties, UserAuth } from "types"; import { ICurrentUserResponse, IIssue, ISubIssueResponse, Properties, UserAuth } from "types";
// fetch-keys // fetch-keys
import { import {
CYCLE_DETAILS, CYCLE_DETAILS,
@ -44,6 +45,8 @@ import {
MODULE_DETAILS, MODULE_DETAILS,
MODULE_ISSUES_WITH_PARAMS, MODULE_ISSUES_WITH_PARAMS,
PROJECT_ISSUES_LIST_WITH_PARAMS, PROJECT_ISSUES_LIST_WITH_PARAMS,
SUB_ISSUES,
VIEW_ISSUES,
} from "constants/fetch-keys"; } from "constants/fetch-keys";
type Props = { type Props = {
@ -80,24 +83,53 @@ export const SingleListIssue: React.FC<Props> = ({
const [contextMenuPosition, setContextMenuPosition] = useState({ x: 0, y: 0 }); const [contextMenuPosition, setContextMenuPosition] = useState({ x: 0, y: 0 });
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId, cycleId, moduleId } = router.query; const { workspaceSlug, projectId, cycleId, moduleId, viewId } = router.query;
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();
const { groupByProperty: selectedGroup, orderBy, params } = useIssueView(); const { groupByProperty: selectedGroup, orderBy, params } = useIssueView();
const partialUpdateIssue = useCallback( const partialUpdateIssue = useCallback(
(formData: Partial<IIssue>, issueId: string) => { (formData: Partial<IIssue>, issue: IIssue) => {
if (!workspaceSlug || !projectId) return; if (!workspaceSlug || !projectId) return;
if (cycleId) const fetchKey = cycleId
? CYCLE_ISSUES_WITH_PARAMS(cycleId.toString(), params)
: moduleId
? MODULE_ISSUES_WITH_PARAMS(moduleId.toString(), params)
: viewId
? VIEW_ISSUES(viewId.toString(), params)
: PROJECT_ISSUES_LIST_WITH_PARAMS(projectId.toString(), params);
if (issue.parent) {
mutate<ISubIssueResponse>(
SUB_ISSUES(issue.parent.toString()),
(prevData) => {
if (!prevData) return prevData;
return {
...prevData,
sub_issues: (prevData.sub_issues ?? []).map((i) => {
if (i.id === issue.id) {
return {
...i,
...formData,
};
}
return i;
}),
};
},
false
);
} else {
mutate< mutate<
| { | {
[key: string]: IIssue[]; [key: string]: IIssue[];
} }
| IIssue[] | IIssue[]
>( >(
CYCLE_ISSUES_WITH_PARAMS(cycleId as string, params), fetchKey,
(prevData) => (prevData) =>
handleIssuesMutation( handleIssuesMutation(
formData, formData,
@ -109,49 +141,15 @@ export const SingleListIssue: React.FC<Props> = ({
), ),
false false
); );
if (moduleId)
mutate<
| {
[key: string]: IIssue[];
} }
| IIssue[]
>(
MODULE_ISSUES_WITH_PARAMS(moduleId as string, params),
(prevData) =>
handleIssuesMutation(
formData,
groupTitle ?? "",
selectedGroup,
index,
orderBy,
prevData
),
false
);
mutate<
| {
[key: string]: IIssue[];
}
| IIssue[]
>(
PROJECT_ISSUES_LIST_WITH_PARAMS(projectId as string, params),
(prevData) =>
handleIssuesMutation(formData, groupTitle ?? "", selectedGroup, index, orderBy, prevData),
false
);
issuesService issuesService
.patchIssue(workspaceSlug as string, projectId as string, issueId, formData, user) .patchIssue(workspaceSlug as string, projectId as string, issue.id, formData, user)
.then(() => { .then(() => {
if (cycleId) { mutate(fetchKey);
mutate(CYCLE_ISSUES_WITH_PARAMS(cycleId as string, params));
mutate(CYCLE_DETAILS(cycleId as string)); if (cycleId) mutate(CYCLE_DETAILS(cycleId as string));
} else if (moduleId) { if (moduleId) mutate(MODULE_DETAILS(moduleId as string));
mutate(MODULE_ISSUES_WITH_PARAMS(moduleId as string, params));
mutate(MODULE_DETAILS(moduleId as string));
} else mutate(PROJECT_ISSUES_LIST_WITH_PARAMS(projectId as string, params));
}); });
}, },
[ [
@ -159,6 +157,7 @@ export const SingleListIssue: React.FC<Props> = ({
projectId, projectId,
cycleId, cycleId,
moduleId, moduleId,
viewId,
groupTitle, groupTitle,
index, index,
selectedGroup, selectedGroup,
@ -275,25 +274,14 @@ export const SingleListIssue: React.FC<Props> = ({
isNotAllowed={isNotAllowed} isNotAllowed={isNotAllowed}
/> />
)} )}
{properties.labels && issue.label_details.length > 0 ? ( {properties.labels && (
<div className="flex flex-wrap gap-1"> <ViewLabelSelect
{issue.label_details.map((label) => ( issue={issue}
<span partialUpdateIssue={partialUpdateIssue}
key={label.id} position="right"
className="group flex items-center gap-1 rounded-2xl border border-brand-base px-2 py-0.5 text-xs text-brand-secondary" user={user}
> isNotAllowed={isNotAllowed}
<span
className="h-1.5 w-1.5 rounded-full"
style={{
backgroundColor: label?.color && label.color !== "" ? label.color : "#000",
}}
/> />
{label.name}
</span>
))}
</div>
) : (
""
)} )}
{properties.assignee && ( {properties.assignee && (
<ViewAssigneeSelect <ViewAssigneeSelect

View File

@ -53,7 +53,7 @@ export const LinksList: React.FC<Props> = ({ links, handleDeleteLink, userAuth }
<LinkIcon className="h-3.5 w-3.5" /> <LinkIcon className="h-3.5 w-3.5" />
</div> </div>
<div> <div>
<h5 className="w-4/5 break-all">{link.title}</h5> <h5 className="w-4/5 break-words">{link.title}</h5>
<p className="mt-0.5 text-brand-secondary"> <p className="mt-0.5 text-brand-secondary">
Added {timeAgo(link.created_at)} Added {timeAgo(link.created_at)}
<br /> <br />

View File

@ -3,7 +3,7 @@ import React from "react";
// ui // ui
import { LineGraph } from "components/ui"; import { LineGraph } from "components/ui";
// helpers // helpers
import { renderShortNumericDateFormat } from "helpers/date-time.helper"; import { getDatesInRange, renderShortNumericDateFormat } from "helpers/date-time.helper";
//types //types
import { TCompletionChartDistribution } from "types"; import { TCompletionChartDistribution } from "types";
@ -46,6 +46,27 @@ const ProgressChart: React.FC<Props> = ({ distribution, startDate, endDate, tota
pending: distribution[key], pending: distribution[key],
})); }));
const generateXAxisTickValues = () => {
const dates = getDatesInRange(startDate, endDate);
const maxDates = 4;
const totalDates = dates.length;
if (totalDates <= maxDates) return dates.map((d) => renderShortNumericDateFormat(d));
else {
const interval = Math.ceil(totalDates / maxDates);
const limitedDates = [];
for (let i = 0; i < totalDates; i += interval)
limitedDates.push(renderShortNumericDateFormat(dates[i]));
if (!limitedDates.includes(renderShortNumericDateFormat(dates[totalDates - 1])))
limitedDates.push(renderShortNumericDateFormat(dates[totalDates - 1]));
return limitedDates;
}
};
return ( return (
<div className="w-full flex justify-center items-center"> <div className="w-full flex justify-center items-center">
<LineGraph <LineGraph
@ -72,7 +93,9 @@ const ProgressChart: React.FC<Props> = ({ distribution, startDate, endDate, tota
id: "ideal", id: "ideal",
color: "#a9bbd0", color: "#a9bbd0",
fill: "transparent", fill: "transparent",
data: [ data:
chartData.length > 0
? [
{ {
x: chartData[0].currentDate, x: chartData[0].currentDate,
y: totalIssues, y: totalIssues,
@ -81,18 +104,27 @@ const ProgressChart: React.FC<Props> = ({ distribution, startDate, endDate, tota
x: chartData[chartData.length - 1].currentDate, x: chartData[chartData.length - 1].currentDate,
y: 0, y: 0,
}, },
], ]
: [],
}, },
]} ]}
layers={["grid", "markers", "areas", DashedLine, "slices", "points", "axes", "legends"]} layers={["grid", "markers", "areas", DashedLine, "slices", "points", "axes", "legends"]}
axisBottom={{ axisBottom={{
tickValues: chartData.map((item, index) => (index % 2 === 0 ? item.currentDate : "")), tickValues: generateXAxisTickValues(),
}} }}
enablePoints={false} enablePoints={false}
enableArea enableArea
colors={(datum) => datum.color ?? "#3F76FF"} colors={(datum) => datum.color ?? "#3F76FF"}
customYAxisTickValues={[0, totalIssues]} customYAxisTickValues={[0, totalIssues]}
gridXValues={chartData.map((item, index) => (index % 2 === 0 ? item.currentDate : ""))} gridXValues={chartData.map((item, index) => (index % 2 === 0 ? item.currentDate : ""))}
enableSlices="x"
sliceTooltip={(datum) => (
<div className="rounded-md border border-brand-base bg-brand-surface-2 p-2 text-xs">
{datum.slice.points[0].data.yFormatted}
<span className="text-brand-secondary"> issues pending on </span>
{datum.slice.points[0].data.xFormatted}
</div>
)}
theme={{ theme={{
background: "transparent", background: "transparent",
axis: { axis: {

View File

@ -0,0 +1,4 @@
export * from "./spreadsheet-view";
export * from "./single-issue";
export * from "./spreadsheet-columns";
export * from "./spreadsheet-issues";

View File

@ -0,0 +1,350 @@
import React, { useCallback, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/router";
import { mutate } from "swr";
// components
import {
ViewAssigneeSelect,
ViewDueDateSelect,
ViewEstimateSelect,
ViewLabelSelect,
ViewPrioritySelect,
ViewStateSelect,
} from "components/issues";
import { Popover2 } from "@blueprintjs/popover2";
// icons
import { Icon } from "components/ui";
import {
EllipsisHorizontalIcon,
LinkIcon,
PencilIcon,
TrashIcon,
} from "@heroicons/react/24/outline";
// hooks
import useSpreadsheetIssuesView from "hooks/use-spreadsheet-issues-view";
import useToast from "hooks/use-toast";
// services
import issuesService from "services/issues.service";
// constant
import {
CYCLE_DETAILS,
CYCLE_ISSUES_WITH_PARAMS,
MODULE_DETAILS,
MODULE_ISSUES_WITH_PARAMS,
PROJECT_ISSUES_LIST_WITH_PARAMS,
SUB_ISSUES,
VIEW_ISSUES,
} from "constants/fetch-keys";
// types
import { ICurrentUserResponse, IIssue, ISubIssueResponse, Properties, UserAuth } from "types";
// helper
import { copyTextToClipboard } from "helpers/string.helper";
type Props = {
issue: IIssue;
index: number;
expanded: boolean;
handleToggleExpand: (issueId: string) => void;
properties: Properties;
handleEditIssue: (issue: IIssue) => void;
handleDeleteIssue: (issue: IIssue) => void;
gridTemplateColumns: string;
isCompleted?: boolean;
user: ICurrentUserResponse | undefined;
userAuth: UserAuth;
nestingLevel: number;
};
export const SingleSpreadsheetIssue: React.FC<Props> = ({
issue,
index,
expanded,
handleToggleExpand,
properties,
handleEditIssue,
handleDeleteIssue,
gridTemplateColumns,
isCompleted = false,
user,
userAuth,
nestingLevel,
}) => {
const [isOpen, setIsOpen] = useState(false);
const router = useRouter();
const { workspaceSlug, projectId, cycleId, moduleId, viewId } = router.query;
const { params } = useSpreadsheetIssuesView();
const { setToastAlert } = useToast();
const partialUpdateIssue = useCallback(
(formData: Partial<IIssue>, issue: IIssue) => {
if (!workspaceSlug || !projectId) return;
const fetchKey = cycleId
? CYCLE_ISSUES_WITH_PARAMS(cycleId.toString(), params)
: moduleId
? MODULE_ISSUES_WITH_PARAMS(moduleId.toString(), params)
: viewId
? VIEW_ISSUES(viewId.toString(), params)
: PROJECT_ISSUES_LIST_WITH_PARAMS(projectId.toString(), params);
if (issue.parent) {
mutate<ISubIssueResponse>(
SUB_ISSUES(issue.parent.toString()),
(prevData) => {
if (!prevData) return prevData;
return {
...prevData,
sub_issues: (prevData.sub_issues ?? []).map((i) => {
if (i.id === issue.id) {
return {
...i,
...formData,
};
}
return i;
}),
};
},
false
);
} else {
mutate<IIssue[]>(
fetchKey,
(prevData) =>
(prevData ?? []).map((p) => {
if (p.id === issue.id) {
return {
...p,
...formData,
};
}
return p;
}),
false
);
}
issuesService
.patchIssue(
workspaceSlug as string,
projectId as string,
issue.id as string,
formData,
user
)
.then(() => {
if (issue.parent) {
mutate(SUB_ISSUES(issue.parent as string));
} else {
mutate(fetchKey);
if (cycleId) mutate(CYCLE_DETAILS(cycleId as string));
if (moduleId) mutate(MODULE_DETAILS(moduleId as string));
}
})
.catch((error) => {
console.log(error);
});
},
[workspaceSlug, projectId, cycleId, moduleId, viewId, params, user]
);
const handleCopyText = () => {
const originURL =
typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
copyTextToClipboard(
`${originURL}/${workspaceSlug}/projects/${projectId}/issues/${issue.id}`
).then(() => {
setToastAlert({
type: "success",
title: "Link Copied!",
message: "Issue link copied to clipboard.",
});
});
};
const paddingLeft = `${nestingLevel * 68}px`;
const tooltipPosition = index === 0 ? "bottom" : "top";
const isNotAllowed = userAuth.isGuest || userAuth.isViewer;
return (
<div
className="relative group grid auto-rows-[minmax(44px,1fr)] hover:rounded-sm hover:bg-brand-surface-2 border-b border-brand-base w-full min-w-max"
style={{ gridTemplateColumns }}
>
<div className="flex gap-1.5 items-center px-4 sticky z-[1] left-0 text-brand-secondary bg-brand-base group-hover:text-brand-base group-hover:bg-brand-surface-2 border-brand-base w-full">
<div className="flex gap-1.5 items-center" style={issue.parent ? { paddingLeft } : {}}>
<div className="relative flex items-center cursor-pointer text-xs text-center hover:text-brand-base w-14">
{properties.key && (
<span className="flex items-center justify-center opacity-100 group-hover:opacity-0">
{issue.project_detail?.identifier}-{issue.sequence_id}
</span>
)}
{!isNotAllowed && !isCompleted && (
<div className="absolute top-0 left-2.5 opacity-0 group-hover:opacity-100">
<Popover2
isOpen={isOpen}
canEscapeKeyClose
onInteraction={(nextOpenState) => setIsOpen(nextOpenState)}
content={
<div
className={`flex flex-col gap-1.5 overflow-y-scroll whitespace-nowrap rounded-md border p-1 text-xs shadow-lg focus:outline-none max-h-44 min-w-full border-brand-base bg-brand-surface-1`}
>
<button
type="button"
className="hover:text-brand-muted-1 w-full select-none gap-2 truncate rounded px-1 py-1.5 text-left text-brand-secondary hover:bg-brand-surface-2"
onClick={() => {
handleEditIssue(issue);
setIsOpen(false);
}}
>
<div className="flex items-center justify-start gap-2">
<PencilIcon className="h-4 w-4" />
<span>Edit issue</span>
</div>
</button>
<button
type="button"
className="hover:text-brand-muted-1 w-full select-none gap-2 truncate rounded px-1 py-1.5 text-left text-brand-secondary hover:bg-brand-surface-2"
onClick={() => {
handleDeleteIssue(issue);
setIsOpen(false);
}}
>
<div className="flex items-center justify-start gap-2">
<TrashIcon className="h-4 w-4" />
<span>Delete issue</span>
</div>
</button>
<button
type="button"
className="hover:text-brand-muted-1 w-full select-none gap-2 truncate rounded px-1 py-1.5 text-left text-brand-secondary hover:bg-brand-surface-2"
onClick={() => {
handleCopyText();
setIsOpen(false);
}}
>
<div className="flex items-center justify-start gap-2">
<LinkIcon className="h-4 w-4" />
<span>Copy issue link</span>
</div>
</button>
</div>
}
placement="bottom-start"
>
<EllipsisHorizontalIcon className="h-5 w-5 text-brand-secondary" />
</Popover2>
</div>
)}
</div>
{issue.sub_issues_count > 0 && (
<div className="h-6 w-6 flex justify-center items-center">
<button
className="h-5 w-5 hover:bg-brand-surface-1 hover:text-brand-base rounded-sm cursor-pointer"
onClick={() => handleToggleExpand(issue.id)}
>
<Icon iconName="chevron_right" className={`${expanded ? "rotate-90" : ""}`} />
</button>
</div>
)}
</div>
<Link href={`/${workspaceSlug}/projects/${issue?.project_detail?.id}/issues/${issue.id}`}>
<a className="truncate text-brand-base cursor-pointer w-full text-[0.825rem]">
{issue.name}
</a>
</Link>
</div>
{properties.state && (
<div className="flex items-center text-xs text-brand-secondary text-center p-2 group-hover:bg-brand-surface-2 border-brand-base">
<ViewStateSelect
issue={issue}
partialUpdateIssue={partialUpdateIssue}
position="left"
tooltipPosition={tooltipPosition}
customButton
user={user}
isNotAllowed={isNotAllowed}
/>
</div>
)}
{properties.priority && (
<div className="flex items-center text-xs text-brand-secondary text-center p-2 group-hover:bg-brand-surface-2 border-brand-base">
<ViewPrioritySelect
issue={issue}
partialUpdateIssue={partialUpdateIssue}
position="left"
tooltipPosition={tooltipPosition}
noBorder
user={user}
isNotAllowed={isNotAllowed}
/>
</div>
)}
{properties.assignee && (
<div className="flex items-center text-xs text-brand-secondary text-center p-2 group-hover:bg-brand-surface-2 border-brand-base">
<ViewAssigneeSelect
issue={issue}
partialUpdateIssue={partialUpdateIssue}
position="left"
tooltipPosition={tooltipPosition}
customButton
user={user}
isNotAllowed={isNotAllowed}
/>
</div>
)}
{properties.labels && (
<div className="flex items-center text-xs text-brand-secondary text-center p-2 group-hover:bg-brand-surface-2 border-brand-base">
<ViewLabelSelect
issue={issue}
partialUpdateIssue={partialUpdateIssue}
position="left"
tooltipPosition={tooltipPosition}
customButton
user={user}
isNotAllowed={isNotAllowed}
/>
</div>
)}
{properties.due_date && (
<div className="flex items-center text-xs text-brand-secondary text-center p-2 group-hover:bg-brand-surface-2 border-brand-base">
<ViewDueDateSelect
issue={issue}
partialUpdateIssue={partialUpdateIssue}
tooltipPosition={tooltipPosition}
noBorder
user={user}
isNotAllowed={isNotAllowed}
/>
</div>
)}
{properties.estimate && (
<div className="flex items-center text-xs text-brand-secondary text-center p-2 group-hover:bg-brand-surface-2 border-brand-base">
<ViewEstimateSelect
issue={issue}
partialUpdateIssue={partialUpdateIssue}
position="left"
tooltipPosition={tooltipPosition}
user={user}
isNotAllowed={isNotAllowed}
/>
</div>
)}
</div>
);
};

View File

@ -0,0 +1,277 @@
import React from "react";
// hooks
import useSpreadsheetIssuesView from "hooks/use-spreadsheet-issues-view";
import useLocalStorage from "hooks/use-local-storage";
// component
import { CustomMenu, Icon } from "components/ui";
// icon
import { CheckIcon, ChevronDownIcon } from "@heroicons/react/24/outline";
// types
import { TIssueOrderByOptions } from "types";
type Props = {
columnData: any;
gridTemplateColumns: string;
};
export const SpreadsheetColumns: React.FC<Props> = ({ columnData, gridTemplateColumns }) => {
const { storedValue: selectedMenuItem, setValue: setSelectedMenuItem } = useLocalStorage(
"spreadsheetViewSorting",
""
);
const { storedValue: activeSortingProperty, setValue: setActiveSortingProperty } =
useLocalStorage("spreadsheetViewActiveSortingProperty", "");
const { orderBy, setOrderBy } = useSpreadsheetIssuesView();
const handleOrderBy = (order: TIssueOrderByOptions, itemKey: string) => {
setOrderBy(order);
setSelectedMenuItem(`${order}_${itemKey}`);
setActiveSortingProperty(order === "-created_at" ? "" : itemKey);
};
return (
<div
className={`grid auto-rows-[minmax(36px,1fr)] w-full min-w-max`}
style={{ gridTemplateColumns }}
>
{columnData.map((col: any) => {
if (col.isActive) {
return (
<div
className={`bg-brand-surface-1 w-full ${
col.propertyName === "title" ? "sticky left-0 z-20 bg-brand-surface-1 pl-24" : ""
}`}
>
{col.propertyName === "title" ? (
<div
className={`flex items-center justify-start gap-1.5 cursor-default text-sm text-brand-secondary text-current w-full py-2.5 px-2`}
>
{col.colName}
</div>
) : (
<CustomMenu
className="!w-full"
customButton={
<div
className={`relative group flex items-center justify-start gap-1.5 cursor-pointer text-sm text-brand-secondary text-current hover:text-brand-base w-full py-3 px-2 ${
activeSortingProperty === col.propertyName ? "bg-brand-surface-2" : ""
}`}
>
{activeSortingProperty === col.propertyName && (
<div className="absolute top-1 right-1.5">
<Icon
iconName="filter_list"
className="flex items-center justify-center h-3.5 w-3.5 rounded-full bg-brand-accent text-xs text-white"
/>
</div>
)}
{col.icon ? (
<col.icon
className={`text-brand-secondary group-hover:text-brand-base ${
col.propertyName === "estimate" ? "-rotate-90" : ""
}`}
aria-hidden="true"
height="14"
width="14"
/>
) : col.propertyName === "priority" ? (
<span className="text-sm material-symbols-rounded text-brand-secondary">
signal_cellular_alt
</span>
) : (
""
)}
{col.colName}
<ChevronDownIcon className="h-3 w-3" aria-hidden="true" />
</div>
}
menuItemsWhiteBg
width="xl"
>
<CustomMenu.MenuItem
className={`${
selectedMenuItem === `${col.ascendingOrder}_${col.propertyName}`
? "bg-brand-surface-2"
: ""
}`}
key={col.propertyName}
onClick={() => {
handleOrderBy(col.ascendingOrder, col.propertyName);
}}
>
<div
className={`group flex gap-1.5 px-1 items-center justify-between ${
selectedMenuItem === `${col.ascendingOrder}_${col.propertyName}`
? "text-brand-base"
: "text-brand-secondary hover:text-brand-base"
}`}
>
<div className="flex gap-2 items-center">
{col.propertyName === "assignee" || col.propertyName === "labels" ? (
<>
<span className="relative flex items-center h-6 w-6">
<Icon
iconName="east"
className="absolute left-0 rotate-90 text-xs leading-3"
/>
<Icon iconName="sort" className="absolute right-0 text-sm" />
</span>
<span>A</span>
<Icon iconName="east" className="text-sm" />
<span>Z</span>
</>
) : col.propertyName === "due_date" ? (
<>
<span className="relative flex items-center h-6 w-6">
<Icon
iconName="east"
className="absolute left-0 rotate-90 text-xs leading-3"
/>
<Icon iconName="sort" className="absolute right-0 text-sm" />
</span>
<span>New</span>
<Icon iconName="east" className="text-sm" />
<span>Old</span>
</>
) : (
<>
<span className="relative flex items-center h-6 w-6">
<Icon
iconName="east"
className="absolute left-0 rotate-90 text-xs leading-3"
/>
<Icon iconName="sort" className="absolute right-0 text-sm" />
</span>
<span>First</span>
<Icon iconName="east" className="text-sm" />
<span>Last</span>
</>
)}
</div>
<CheckIcon
className={`h-3.5 w-3.5 opacity-0 group-hover:opacity-100 ${
selectedMenuItem === `${col.ascendingOrder}_${col.propertyName}`
? "opacity-100"
: ""
}`}
/>
</div>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem
className={`mt-0.5 ${
selectedMenuItem === `${col.descendingOrder}_${col.propertyName}`
? "bg-brand-surface-2"
: ""
}`}
key={col.property}
onClick={() => {
handleOrderBy(col.descendingOrder, col.propertyName);
}}
>
<div
className={`group flex gap-1.5 px-1 items-center justify-between ${
selectedMenuItem === `${col.descendingOrder}_${col.propertyName}`
? "text-brand-base"
: "text-brand-secondary hover:text-brand-base"
}`}
>
<div className="flex gap-2 items-center">
{col.propertyName === "assignee" || col.propertyName === "labels" ? (
<>
<span className="relative flex items-center h-6 w-6">
<Icon
iconName="east"
className="absolute left-0 -rotate-90 text-xs leading-3"
/>
<Icon
iconName="sort"
className="absolute rotate-180 transform scale-x-[-1] right-0 text-sm"
/>
</span>
<span>Z</span>
<Icon iconName="east" className="text-sm" />
<span>A</span>
</>
) : col.propertyName === "due_date" ? (
<>
<span className="relative flex items-center h-6 w-6">
<Icon
iconName="east"
className="absolute left-0 -rotate-90 text-xs leading-3"
/>
<Icon
iconName="sort"
className="absolute rotate-180 transform scale-x-[-1] right-0 text-sm"
/>
</span>
<span>Old</span>
<Icon iconName="east" className="text-sm" />
<span>New</span>
</>
) : (
<>
<span className="relative flex items-center h-6 w-6">
<Icon
iconName="east"
className="absolute left-0 -rotate-90 text-xs leading-3"
/>
<Icon
iconName="sort"
className="absolute rotate-180 transform scale-x-[-1] right-0 text-sm"
/>
</span>
<span>Last</span>
<Icon iconName="east" className="text-sm" />
<span>First</span>
</>
)}
</div>
<CheckIcon
className={`h-3.5 w-3.5 opacity-0 group-hover:opacity-100 ${
selectedMenuItem === `${col.descendingOrder}_${col.propertyName}`
? "opacity-100"
: ""
}`}
/>
</div>
</CustomMenu.MenuItem>
{selectedMenuItem &&
selectedMenuItem !== "" &&
orderBy !== "-created_at" &&
selectedMenuItem.includes(col.propertyName) && (
<CustomMenu.MenuItem
className={`mt-0.5${
selectedMenuItem === `-created_at_${col.propertyName}`
? "bg-brand-surface-2"
: ""
}`}
key={col.property}
onClick={() => {
handleOrderBy("-created_at", col.propertyName);
}}
>
<div className={`group flex gap-1.5 px-1 items-center justify-between `}>
<div className="flex gap-1.5 items-center">
<span className="relative flex items-center justify-center h-6 w-6">
<Icon iconName="ink_eraser" className="text-sm" />
</span>
<span>Clear sorting</span>
</div>
</div>
</CustomMenu.MenuItem>
)}
</CustomMenu>
)}
</div>
);
}
})}
</div>
);
};

View File

@ -0,0 +1,98 @@
import React, { useState } from "react";
// components
import { SingleSpreadsheetIssue } from "components/core";
// hooks
import useSubIssue from "hooks/use-sub-issue";
// types
import { ICurrentUserResponse, IIssue, Properties, UserAuth } from "types";
type Props = {
key: string;
issue: IIssue;
index: number;
expandedIssues: string[];
setExpandedIssues: React.Dispatch<React.SetStateAction<string[]>>;
properties: Properties;
handleEditIssue: (issue: IIssue) => void;
handleDeleteIssue: (issue: IIssue) => void;
gridTemplateColumns: string;
isCompleted?: boolean;
user: ICurrentUserResponse | undefined;
userAuth: UserAuth;
nestingLevel?: number;
};
export const SpreadsheetIssues: React.FC<Props> = ({
key,
index,
issue,
expandedIssues,
setExpandedIssues,
gridTemplateColumns,
properties,
handleEditIssue,
handleDeleteIssue,
isCompleted = false,
user,
userAuth,
nestingLevel = 0,
}) => {
const handleToggleExpand = (issueId: string) => {
setExpandedIssues((prevState) => {
const newArray = [...prevState];
const index = newArray.indexOf(issueId);
if (index > -1) {
newArray.splice(index, 1);
} else {
newArray.push(issueId);
}
return newArray;
});
};
const isExpanded = expandedIssues.indexOf(issue.id) > -1;
const { subIssues, isLoading } = useSubIssue(issue.id, isExpanded);
return (
<div>
<SingleSpreadsheetIssue
issue={issue}
index={index}
expanded={isExpanded}
handleToggleExpand={handleToggleExpand}
gridTemplateColumns={gridTemplateColumns}
properties={properties}
handleEditIssue={handleEditIssue}
handleDeleteIssue={handleDeleteIssue}
isCompleted={isCompleted}
user={user}
userAuth={userAuth}
nestingLevel={nestingLevel}
/>
{isExpanded &&
!isLoading &&
subIssues &&
subIssues.length > 0 &&
subIssues.map((subIssue: IIssue, subIndex: number) => (
<SpreadsheetIssues
key={subIssue.id}
issue={subIssue}
index={index}
expandedIssues={expandedIssues}
setExpandedIssues={setExpandedIssues}
gridTemplateColumns={gridTemplateColumns}
properties={properties}
handleEditIssue={handleEditIssue}
handleDeleteIssue={handleDeleteIssue}
isCompleted={isCompleted}
user={user}
userAuth={userAuth}
nestingLevel={nestingLevel + 1}
/>
))}
</div>
);
};

View File

@ -0,0 +1,141 @@
import React, { useState } from "react";
// next
import { useRouter } from "next/router";
// components
import { SpreadsheetColumns, SpreadsheetIssues } from "components/core";
import { CustomMenu, Icon, Spinner } from "components/ui";
// hooks
import useIssuesProperties from "hooks/use-issue-properties";
import useSpreadsheetIssuesView from "hooks/use-spreadsheet-issues-view";
// types
import { ICurrentUserResponse, IIssue, Properties, UserAuth } from "types";
// constants
import { SPREADSHEET_COLUMN } from "constants/spreadsheet";
// icon
import { PlusIcon } from "@heroicons/react/24/outline";
type Props = {
type: "issue" | "cycle" | "module";
handleEditIssue: (issue: IIssue) => void;
handleDeleteIssue: (issue: IIssue) => void;
openIssuesListModal?: (() => void) | null;
isCompleted?: boolean;
user: ICurrentUserResponse | undefined;
userAuth: UserAuth;
};
export const SpreadsheetView: React.FC<Props> = ({
type,
handleEditIssue,
handleDeleteIssue,
openIssuesListModal,
isCompleted = false,
user,
userAuth,
}) => {
const [expandedIssues, setExpandedIssues] = useState<string[]>([]);
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { spreadsheetIssues } = useSpreadsheetIssuesView();
const [properties] = useIssuesProperties(workspaceSlug as string, projectId as string);
const columnData = SPREADSHEET_COLUMN.map((column) => ({
...column,
isActive: properties
? column.propertyName === "labels"
? properties[column.propertyName as keyof Properties]
: column.propertyName === "title"
? true
: properties[column.propertyName as keyof Properties]
: false,
}));
const gridTemplateColumns = columnData
.filter((column) => column.isActive)
.map((column) => column.colSize)
.join(" ");
return (
<div className="h-full rounded-lg text-brand-secondary overflow-x-auto whitespace-nowrap bg-brand-base">
<div className="sticky z-[2] top-0 border-b border-brand-base bg-brand-surface-1 w-full min-w-max">
<SpreadsheetColumns columnData={columnData} gridTemplateColumns={gridTemplateColumns} />
</div>
{spreadsheetIssues ? (
<div className="flex flex-col h-full w-full bg-brand-base rounded-sm ">
{spreadsheetIssues.map((issue: IIssue, index) => (
<SpreadsheetIssues
key={`${issue.id}_${index}`}
index={index}
issue={issue}
expandedIssues={expandedIssues}
setExpandedIssues={setExpandedIssues}
gridTemplateColumns={gridTemplateColumns}
properties={properties}
handleEditIssue={handleEditIssue}
handleDeleteIssue={handleDeleteIssue}
isCompleted={isCompleted}
user={user}
userAuth={userAuth}
/>
))}
<div
className="relative group grid auto-rows-[minmax(44px,1fr)] hover:rounded-sm hover:bg-brand-surface-2 border-b border-brand-base w-full min-w-max"
style={{ gridTemplateColumns }}
>
{type === "issue" ? (
<button
className="flex gap-1.5 items-center pl-7 py-2.5 text-sm sticky left-0 z-[1] text-brand-secondary bg-brand-base group-hover:text-brand-base group-hover:bg-brand-surface-2 border-brand-base w-full"
onClick={() => {
const e = new KeyboardEvent("keydown", { key: "c" });
document.dispatchEvent(e);
}}
>
<PlusIcon className="h-4 w-4" />
Add Issue
</button>
) : (
!isCompleted && (
<CustomMenu
className="sticky left-0 z-[1]"
customButton={
<button
className="flex gap-1.5 items-center pl-7 py-2.5 text-sm sticky left-0 z-[1] text-brand-secondary bg-brand-base group-hover:text-brand-base group-hover:bg-brand-surface-2 border-brand-base w-full"
type="button"
>
<PlusIcon className="h-4 w-4" />
Add Issue
</button>
}
position="left"
menuItemsClassName="left-5 !w-36"
noBorder
>
<CustomMenu.MenuItem
onClick={() => {
const e = new KeyboardEvent("keydown", { key: "c" });
document.dispatchEvent(e);
}}
>
Create new
</CustomMenu.MenuItem>
{openIssuesListModal && (
<CustomMenu.MenuItem onClick={openIssuesListModal}>
Add an existing issue
</CustomMenu.MenuItem>
)}
</CustomMenu>
)
)}
</div>
</div>
) : (
<Spinner />
)}
</div>
);
};

View File

@ -101,6 +101,13 @@ export const ActiveCycleDetails: React.FC = () => {
: null : null
) as { data: IIssue[] | undefined }; ) as { data: IIssue[] | undefined };
if (!currentCycle)
return (
<Loader>
<Loader.Item height="250px" />
</Loader>
);
if (!cycle) if (!cycle)
return ( return (
<div className="flex w-full items-center justify-start rounded-[10px] bg-brand-surface-2 px-6 py-4"> <div className="flex w-full items-center justify-start rounded-[10px] bg-brand-surface-2 px-6 py-4">
@ -226,7 +233,7 @@ export const ActiveCycleDetails: React.FC = () => {
/> />
</span> </span>
<Tooltip tooltipContent={cycle.name} position="top-left"> <Tooltip tooltipContent={cycle.name} position="top-left">
<h3 className="break-all text-lg font-semibold"> <h3 className="break-words text-lg font-semibold">
{truncateText(cycle.name, 70)} {truncateText(cycle.name, 70)}
</h3> </h3>
</Tooltip> </Tooltip>
@ -395,6 +402,7 @@ export const ActiveCycleDetails: React.FC = () => {
<div className="text-brand-primary">High Priority Issues</div> <div className="text-brand-primary">High Priority Issues</div>
<div className="my-3 flex max-h-[240px] min-h-[240px] flex-col gap-2.5 overflow-y-scroll rounded-md"> <div className="my-3 flex max-h-[240px] min-h-[240px] flex-col gap-2.5 overflow-y-scroll rounded-md">
{issues ? ( {issues ? (
issues.length > 0 ? (
issues.map((issue) => ( issues.map((issue) => (
<div <div
key={issue.id} key={issue.id}
@ -421,7 +429,6 @@ export const ActiveCycleDetails: React.FC = () => {
</span> </span>
</Tooltip> </Tooltip>
</div> </div>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<div <div
className={`grid h-6 w-6 place-items-center items-center rounded border shadow-sm flex-shrink-0 ${ className={`grid h-6 w-6 place-items-center items-center rounded border shadow-sm flex-shrink-0 ${
@ -471,6 +478,11 @@ export const ActiveCycleDetails: React.FC = () => {
</div> </div>
</div> </div>
)) ))
) : (
<div className="grid place-items-center text-brand-secondary text-sm text-center">
No issues present in the cycle.
</div>
)
) : ( ) : (
<Loader className="space-y-3"> <Loader className="space-y-3">
<Loader.Item height="50px" /> <Loader.Item height="50px" />
@ -481,6 +493,7 @@ export const ActiveCycleDetails: React.FC = () => {
</div> </div>
</div> </div>
{issues && issues.length > 0 && (
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<div className="h-1 w-full rounded-full bg-brand-surface-2"> <div className="h-1 w-full rounded-full bg-brand-surface-2">
<div <div
@ -502,6 +515,7 @@ export const ActiveCycleDetails: React.FC = () => {
{issues?.length} {issues?.length}
</div> </div>
</div> </div>
)}
</div> </div>
<div className="flex flex-col justify-between border-brand-base p-4"> <div className="flex flex-col justify-between border-brand-base p-4">
<div className="flex items-start justify-between gap-4 py-1.5 text-xs"> <div className="flex items-start justify-between gap-4 py-1.5 text-xs">

View File

@ -1,4 +1,4 @@
import React from "react"; import React, { Fragment } from "react";
// headless ui // headless ui
import { Tab } from "@headlessui/react"; import { Tab } from "@headlessui/react";
@ -32,6 +32,7 @@ export const ActiveCycleProgressStats: React.FC<Props> = ({ cycle }) => {
return ( return (
<Tab.Group <Tab.Group
as={Fragment}
defaultIndex={currentValue(tab)} defaultIndex={currentValue(tab)}
onChange={(i) => { onChange={(i) => {
switch (i) { switch (i) {
@ -68,10 +69,11 @@ export const ActiveCycleProgressStats: React.FC<Props> = ({ cycle }) => {
Labels Labels
</Tab> </Tab>
</Tab.List> </Tab.List>
<Tab.Panels className="flex w-full px-4 pb-4"> {cycle.total_issues > 0 ? (
<Tab.Panels as={Fragment}>
<Tab.Panel <Tab.Panel
as="div" as="div"
className="flex flex-col w-full mt-2 gap-1 overflow-y-scroll items-center text-brand-secondary" className="w-full gap-1 overflow-y-scroll items-center text-brand-secondary p-4"
> >
{cycle.distribution.assignees.map((assignee, index) => { {cycle.distribution.assignees.map((assignee, index) => {
if (assignee.assignee_id) if (assignee.assignee_id)
@ -121,7 +123,7 @@ export const ActiveCycleProgressStats: React.FC<Props> = ({ cycle }) => {
</Tab.Panel> </Tab.Panel>
<Tab.Panel <Tab.Panel
as="div" as="div"
className="flex flex-col w-full mt-2 gap-1 overflow-y-scroll items-center text-brand-secondary" className="w-full gap-1 overflow-y-scroll items-center text-brand-secondary p-4"
> >
{cycle.distribution.labels.map((label, index) => ( {cycle.distribution.labels.map((label, index) => (
<SingleProgressStats <SingleProgressStats
@ -143,6 +145,11 @@ export const ActiveCycleProgressStats: React.FC<Props> = ({ cycle }) => {
))} ))}
</Tab.Panel> </Tab.Panel>
</Tab.Panels> </Tab.Panels>
) : (
<div className="grid place-items-center text-brand-secondary text-sm text-center mt-4">
No issues present in the cycle.
</div>
)}
</Tab.Group> </Tab.Group>
); );
}; };

View File

@ -143,7 +143,7 @@ export const DeleteCycleModal: React.FC<TConfirmCycleDeletionProps> = ({
<div className="mt-2"> <div className="mt-2">
<p className="text-sm text-brand-secondary"> <p className="text-sm text-brand-secondary">
Are you sure you want to delete cycle-{" "} Are you sure you want to delete cycle-{" "}
<span className="break-all font-medium text-brand-base"> <span className="break-words font-medium text-brand-base">
{data?.name} {data?.name}
</span> </span>
? All of the data related to the cycle will be permanently removed. This ? All of the data related to the cycle will be permanently removed. This

View File

@ -408,7 +408,11 @@ export const CycleDetailsSidebar: React.FC<Props> = ({
<div className="flex w-full flex-col gap-6 px-6 py-6"> <div className="flex w-full flex-col gap-6 px-6 py-6">
<div className="flex w-full flex-col items-start justify-start gap-2"> <div className="flex w-full flex-col items-start justify-start gap-2">
<div className="flex w-full items-start justify-between gap-2"> <div className="flex w-full items-start justify-between gap-2">
<h4 className="text-xl font-semibold text-brand-base">{cycle.name}</h4> <div className="max-w-[300px]">
<h4 className="text-xl font-semibold text-brand-base break-words w-full">
{cycle.name}
</h4>
</div>
<CustomMenu width="lg" ellipsis> <CustomMenu width="lg" ellipsis>
{!isCompleted && ( {!isCompleted && (
<CustomMenu.MenuItem onClick={() => setCycleDeleteModal(true)}> <CustomMenu.MenuItem onClick={() => setCycleDeleteModal(true)}>
@ -427,7 +431,7 @@ export const CycleDetailsSidebar: React.FC<Props> = ({
</CustomMenu> </CustomMenu>
</div> </div>
<span className="whitespace-normal text-sm leading-5 text-brand-secondary"> <span className="whitespace-normal text-sm leading-5 text-brand-secondary break-words w-full">
{cycle.description} {cycle.description}
</span> </span>
</div> </div>

View File

@ -150,8 +150,8 @@ export const SingleCycleCard: React.FC<TSingleStatProps> = ({
}`} }`}
/> />
</span> </span>
<Tooltip tooltipContent={cycle.name} className="break-all" position="top-left"> <Tooltip tooltipContent={cycle.name} className="break-words" position="top-left">
<h3 className="break-all text-lg font-semibold"> <h3 className="break-words text-lg font-semibold">
{truncateText(cycle.name, 15)} {truncateText(cycle.name, 15)}
</h3> </h3>
</Tooltip> </Tooltip>

View File

@ -172,13 +172,19 @@ export const SingleCycleList: React.FC<TSingleStatProps> = ({
: "" : ""
}`} }`}
/> />
<div> <div className="max-w-2xl">
<Tooltip tooltipContent={cycle.name} className="break-all" position="top-left"> <Tooltip
<h3 className="break-all text-base font-semibold"> tooltipContent={cycle.name}
className="break-words"
position="top-left"
>
<h3 className="break-words w-full text-base font-semibold">
{truncateText(cycle.name, 70)} {truncateText(cycle.name, 70)}
</h3> </h3>
</Tooltip> </Tooltip>
<p className="mt-2 text-brand-secondary">{cycle.description}</p> <p className="mt-2 text-brand-secondary break-words w-full">
{cycle.description}
</p>
</div> </div>
</span> </span>
<span className="flex items-center gap-4 capitalize"> <span className="flex items-center gap-4 capitalize">
@ -282,12 +288,18 @@ export const SingleCycleList: React.FC<TSingleStatProps> = ({
> >
{cycleStatus === "current" ? ( {cycleStatus === "current" ? (
<span className="flex gap-1"> <span className="flex gap-1">
{cycle.total_issues > 0 ? (
<>
<RadialProgressBar <RadialProgressBar
progress={(cycle.completed_issues / cycle.total_issues) * 100} progress={(cycle.completed_issues / cycle.total_issues) * 100}
/> />
<span> <span>
{Math.floor((cycle.completed_issues / cycle.total_issues) * 100)} % {Math.floor((cycle.completed_issues / cycle.total_issues) * 100)} %
</span> </span>
</>
) : (
<span className="normal-case">No issues present</span>
)}
</span> </span>
) : cycleStatus === "upcoming" ? ( ) : cycleStatus === "upcoming" ? (
<span className="flex gap-1"> <span className="flex gap-1">

View File

@ -74,9 +74,9 @@ export const DeleteEstimateModal: React.FC<Props> = ({
</span> </span>
</div> </div>
<span> <span>
<p className="break-all text-sm leading-7 text-brand-secondary"> <p className="break-words text-sm leading-7 text-brand-secondary">
Are you sure you want to delete estimate-{" "} Are you sure you want to delete estimate-{" "}
<span className="break-all font-medium text-brand-base">{data.name}</span> <span className="break-words font-medium text-brand-base">{data.name}</span>
{""}? All of the data related to the estiamte will be permanently removed. {""}? All of the data related to the estiamte will be permanently removed.
This action cannot be undone. This action cannot be undone.
</p> </p>

View File

@ -18,7 +18,7 @@ export const GanttChartBlocks: FC<{
return ( return (
<div <div
className="relative z-10 mt-[58px] h-full w-[4000px] divide-x divide-gray-300 overflow-hidden overflow-y-auto" className="relative z-[5] mt-[58px] h-full w-[4000px] divide-x divide-gray-300 overflow-hidden overflow-y-auto"
style={{ width: `${itemsContainerWidth}px` }} style={{ width: `${itemsContainerWidth}px` }}
> >
<div className="w-full"> <div className="w-full">

View File

@ -0,0 +1,92 @@
import React, { useState } from "react";
// headless ui
import { Dialog, Transition } from "@headlessui/react";
// icons
import { CheckCircleIcon } from "@heroicons/react/24/outline";
// ui
import { SecondaryButton, PrimaryButton } from "components/ui";
// types
import type { IInboxIssue } from "types";
type Props = {
isOpen: boolean;
handleClose: () => void;
data: IInboxIssue | undefined;
onSubmit: () => Promise<void>;
};
export const AcceptIssueModal: React.FC<Props> = ({ isOpen, handleClose, data, onSubmit }) => {
const [isAccepting, setIsAccepting] = useState(false);
const onClose = () => {
setIsAccepting(false);
handleClose();
};
const handleAccept = () => {
setIsAccepting(true);
onSubmit().finally(() => setIsAccepting(false));
};
return (
<Transition.Root show={isOpen} as={React.Fragment}>
<Dialog as="div" className="relative z-20" onClose={onClose}>
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-brand-backdrop bg-opacity-50 transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-10 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}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="relative transform overflow-hidden rounded-lg border border-brand-base bg-brand-base text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-2xl">
<div className="flex flex-col gap-6 p-6">
<div className="flex w-full items-center justify-start gap-6">
<span className="place-items-center rounded-full bg-green-500/20 p-4">
<CheckCircleIcon className="h-6 w-6 text-green-600" aria-hidden="true" />
</span>
<span className="flex items-center justify-start">
<h3 className="text-xl font-medium 2xl:text-2xl">Accept Issue</h3>
</span>
</div>
<span>
<p className="text-sm text-brand-secondary">
Are you sure you want to accept issue{" "}
<span className="break-all font-medium text-brand-base">
{data?.project_detail?.identifier}-{data?.sequence_id}
</span>
{""}? Once accepted, this issue will be added to the project issues list.
</p>
</span>
<div className="flex justify-end gap-2">
<SecondaryButton onClick={onClose}>Cancel</SecondaryButton>
<PrimaryButton onClick={handleAccept} loading={isAccepting}>
{isAccepting ? "Accepting..." : "Accept Issue"}
</PrimaryButton>
</div>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
};

View File

@ -72,7 +72,7 @@ export const DeclineIssueModal: React.FC<Props> = ({ isOpen, handleClose, data,
<span> <span>
<p className="text-sm text-brand-secondary"> <p className="text-sm text-brand-secondary">
Are you sure you want to decline issue{" "} Are you sure you want to decline issue{" "}
<span className="break-all font-medium text-brand-base"> <span className="break-words font-medium text-brand-base">
{data?.project_detail?.identifier}-{data?.sequence_id} {data?.project_detail?.identifier}-{data?.sequence_id}
</span> </span>
{""}? This action cannot be undone. {""}? This action cannot be undone.

View File

@ -127,7 +127,7 @@ export const DeleteIssueModal: React.FC<Props> = ({ isOpen, handleClose, data })
<span> <span>
<p className="text-sm text-brand-secondary"> <p className="text-sm text-brand-secondary">
Are you sure you want to delete issue{" "} Are you sure you want to delete issue{" "}
<span className="break-all font-medium text-brand-base"> <span className="break-words font-medium text-brand-base">
{data?.project_detail?.identifier}-{data?.sequence_id} {data?.project_detail?.identifier}-{data?.sequence_id}
</span> </span>
{""}? The issue will only be deleted from the inbox and this action cannot be {""}? The issue will only be deleted from the inbox and this action cannot be

View File

@ -1,26 +1,37 @@
// hooks
import useInboxView from "hooks/use-inbox-view";
// ui // ui
import { MultiLevelDropdown } from "components/ui"; import { MultiLevelDropdown } from "components/ui";
// icons // icons
import { getPriorityIcon } from "components/icons"; import { getPriorityIcon } from "components/icons";
// types
import { IInboxFilterOptions } from "types";
// constants // constants
import { PRIORITIES } from "constants/project"; import { PRIORITIES } from "constants/project";
import { INBOX_STATUS } from "constants/inbox"; import { INBOX_STATUS } from "constants/inbox";
type Props = { export const FiltersDropdown: React.FC = () => {
filters: Partial<IInboxFilterOptions>; const { filters, setFilters, filtersLength } = useInboxView();
onSelect: (option: any) => void;
direction?: "left" | "right";
height?: "sm" | "md" | "rg" | "lg";
};
export const FiltersDropdown: React.FC<Props> = ({ filters, onSelect, direction, height }) => ( return (
<div className="relative">
<MultiLevelDropdown <MultiLevelDropdown
label="Filters" label="Filters"
onSelect={onSelect} onSelect={(option) => {
direction={direction} const key = option.key as keyof typeof filters;
height={height}
const valueExists = (filters[key] as any[])?.includes(option.value);
if (valueExists) {
setFilters({
[option.key]: ((filters[key] ?? []) as any[])?.filter((val) => val !== option.value),
});
} else {
setFilters({
[option.key]: [...((filters[key] ?? []) as any[]), option.value],
});
}
}}
direction="right"
height="rg"
options={[ options={[
{ {
id: "priority", id: "priority",
@ -28,7 +39,7 @@ export const FiltersDropdown: React.FC<Props> = ({ filters, onSelect, direction,
value: PRIORITIES, value: PRIORITIES,
children: [ children: [
...PRIORITIES.map((priority) => ({ ...PRIORITIES.map((priority) => ({
id: priority ?? "none", id: priority === null ? "null" : priority,
label: ( label: (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{getPriorityIcon(priority)} {priority ?? "None"} {getPriorityIcon(priority)} {priority ?? "None"}
@ -36,9 +47,9 @@ export const FiltersDropdown: React.FC<Props> = ({ filters, onSelect, direction,
), ),
value: { value: {
key: "priority", key: "priority",
value: priority, value: priority === null ? "null" : priority,
}, },
selected: filters?.priority?.includes(priority ?? "none"), selected: filters?.priority?.includes(priority === null ? "null" : priority),
})), })),
], ],
}, },
@ -60,4 +71,11 @@ export const FiltersDropdown: React.FC<Props> = ({ filters, onSelect, direction,
}, },
]} ]}
/> />
{filtersLength > 0 && (
<div className="absolute -top-2 -right-2 h-4 w-4 text-[0.65rem] grid place-items-center rounded-full text-brand-base bg-brand-surface-2 border border-brand-base z-10">
<span>{filtersLength}</span>
</div>
)}
</div>
); );
};

View File

@ -2,17 +2,28 @@ import { useEffect, useState } from "react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { mutate } from "swr";
// react-datepicker // react-datepicker
import DatePicker from "react-datepicker"; import DatePicker from "react-datepicker";
// headless ui // headless ui
import { Popover } from "@headlessui/react"; import { Popover } from "@headlessui/react";
// contexts // contexts
import { useProjectMyMembership } from "contexts/project-member.context"; import { useProjectMyMembership } from "contexts/project-member.context";
// services
import inboxServices from "services/inbox.service";
// hooks // hooks
import useInboxView from "hooks/use-inbox-view"; import useInboxView from "hooks/use-inbox-view";
import useUserAuth from "hooks/use-user-auth"; import useUserAuth from "hooks/use-user-auth";
import useToast from "hooks/use-toast";
// components // components
import { FiltersDropdown } from "components/inbox"; import {
AcceptIssueModal,
DeclineIssueModal,
DeleteIssueModal,
FiltersDropdown,
SelectDuplicateInboxIssueModal,
} from "components/inbox";
// ui // ui
import { PrimaryButton, SecondaryButton } from "components/ui"; import { PrimaryButton, SecondaryButton } from "components/ui";
// icons // icons
@ -26,46 +37,75 @@ import {
TrashIcon, TrashIcon,
} from "@heroicons/react/24/outline"; } from "@heroicons/react/24/outline";
// types // types
import type { IInboxIssue } from "types"; import type { IInboxIssueDetail, TInboxStatus } from "types";
// fetch-keys
import { INBOX_ISSUE_DETAILS } from "constants/fetch-keys";
type Props = { export const InboxActionHeader = () => {
issueCount: number;
currentIssueIndex: number;
issue?: IInboxIssue;
onAccept: () => Promise<void>;
onDecline: () => void;
onMarkAsDuplicate: () => void;
onSnooze: (date: Date | string) => void;
onDelete: () => void;
};
export const InboxActionHeader: React.FC<Props> = (props) => {
const {
issueCount,
currentIssueIndex,
onAccept,
onDecline,
onMarkAsDuplicate,
onSnooze,
onDelete,
issue,
} = props;
const [isAccepting, setIsAccepting] = useState(false);
const [date, setDate] = useState(new Date()); const [date, setDate] = useState(new Date());
const [selectDuplicateIssue, setSelectDuplicateIssue] = useState(false);
const [acceptIssueModal, setAcceptIssueModal] = useState(false);
const [declineIssueModal, setDeclineIssueModal] = useState(false);
const [deleteIssueModal, setDeleteIssueModal] = useState(false);
const router = useRouter(); const router = useRouter();
const { inboxIssueId } = router.query; const { workspaceSlug, projectId, inboxId, inboxIssueId } = router.query;
const { memberRole } = useProjectMyMembership();
const { filters, setFilters, filtersLength } = useInboxView();
const { user } = useUserAuth(); const { user } = useUserAuth();
const { memberRole } = useProjectMyMembership();
const { issues: inboxIssues, mutate: mutateInboxIssues } = useInboxView();
const { setToastAlert } = useToast();
const handleAcceptIssue = () => { const markInboxStatus = async (data: TInboxStatus) => {
setIsAccepting(true); if (!workspaceSlug || !projectId || !inboxId || !inboxIssueId) return;
onAccept().finally(() => setIsAccepting(false)); mutate<IInboxIssueDetail>(
INBOX_ISSUE_DETAILS(inboxId as string, inboxIssueId as string),
(prevData) => {
if (!prevData) return prevData;
return {
...prevData,
issue_inbox: [{ ...prevData.issue_inbox[0], ...data }],
}; };
},
false
);
mutateInboxIssues(
(prevData) =>
(prevData ?? []).map((i) =>
i.bridge_id === inboxIssueId
? { ...i, issue_inbox: [{ ...i.issue_inbox[0], ...data }] }
: i
),
false
);
await inboxServices
.markInboxStatus(
workspaceSlug.toString(),
projectId.toString(),
inboxId.toString(),
inboxIssues?.find((inboxIssue) => inboxIssue.bridge_id === inboxIssueId)?.bridge_id!,
data,
user
)
.catch(() =>
setToastAlert({
type: "error",
title: "Error!",
message: "Something went wrong while updating inbox status. Please try again.",
})
)
.finally(() => {
mutate(INBOX_ISSUE_DETAILS(inboxId as string, inboxIssueId as string));
mutateInboxIssues();
});
};
const issue = inboxIssues?.find((issue) => issue.bridge_id === inboxIssueId);
const currentIssueIndex =
inboxIssues?.findIndex((issue) => issue.bridge_id === inboxIssueId) ?? 0;
useEffect(() => { useEffect(() => {
if (!issue?.issue_inbox[0].snoozed_till) return; if (!issue?.issue_inbox[0].snoozed_till) return;
@ -82,41 +122,53 @@ export const InboxActionHeader: React.FC<Props> = (props) => {
tomorrow.setDate(today.getDate() + 1); tomorrow.setDate(today.getDate() + 1);
return ( return (
<>
<SelectDuplicateInboxIssueModal
isOpen={selectDuplicateIssue}
onClose={() => setSelectDuplicateIssue(false)}
value={
inboxIssues?.find((inboxIssue) => inboxIssue.bridge_id === inboxIssueId)?.issue_inbox[0]
.duplicate_to
}
onSubmit={(dupIssueId: string) => {
markInboxStatus({
status: 2,
duplicate_to: dupIssueId,
}).finally(() => setSelectDuplicateIssue(false));
}}
/>
<AcceptIssueModal
isOpen={acceptIssueModal}
handleClose={() => setAcceptIssueModal(false)}
data={inboxIssues?.find((i) => i.bridge_id === inboxIssueId)}
onSubmit={async () => {
await markInboxStatus({
status: 1,
}).finally(() => setAcceptIssueModal(false));
}}
/>
<DeclineIssueModal
isOpen={declineIssueModal}
handleClose={() => setDeclineIssueModal(false)}
data={inboxIssues?.find((i) => i.bridge_id === inboxIssueId)}
onSubmit={async () => {
await markInboxStatus({
status: -1,
}).finally(() => setDeclineIssueModal(false));
}}
/>
<DeleteIssueModal
isOpen={deleteIssueModal}
handleClose={() => setDeleteIssueModal(false)}
data={inboxIssues?.find((i) => i.bridge_id === inboxIssueId)}
/>
<div className="grid grid-cols-4 border-b border-brand-base divide-x divide-brand-base"> <div className="grid grid-cols-4 border-b border-brand-base divide-x divide-brand-base">
<div className="col-span-1 flex justify-between p-4"> <div className="col-span-1 flex justify-between p-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<InboxIcon className="h-4 w-4 text-brand-secondary" /> <InboxIcon className="h-4 w-4 text-brand-secondary" />
<h3 className="font-medium">Inbox</h3> <h3 className="font-medium">Inbox</h3>
</div> </div>
<div className="relative"> <FiltersDropdown />
<FiltersDropdown
filters={filters}
onSelect={(option) => {
const key = option.key as keyof typeof filters;
const valueExists = (filters[key] as any[])?.includes(option.value);
if (valueExists) {
setFilters({
[option.key]: ((filters[key] ?? []) as any[])?.filter(
(val) => val !== option.value
),
});
} else {
setFilters({
[option.key]: [...((filters[key] ?? []) as any[]), option.value],
});
}
}}
direction="right"
height="rg"
/>
{filtersLength > 0 && (
<div className="absolute -top-2 -right-2 h-4 w-4 text-[0.65rem] grid place-items-center rounded-full text-brand-base bg-brand-surface-2 border border-brand-base z-10">
<span>{filtersLength}</span>
</div>
)}
</div>
</div> </div>
{inboxIssueId && ( {inboxIssueId && (
<div className="flex justify-between items-center gap-4 px-4 col-span-3"> <div className="flex justify-between items-center gap-4 px-4 col-span-3">
@ -142,28 +194,15 @@ export const InboxActionHeader: React.FC<Props> = (props) => {
<ChevronDownIcon className="h-3.5 w-3.5" /> <ChevronDownIcon className="h-3.5 w-3.5" />
</button> </button>
<div className="text-sm"> <div className="text-sm">
{currentIssueIndex + 1}/{issueCount} {currentIssueIndex + 1}/{inboxIssues?.length ?? 0}
</div> </div>
</div> </div>
<div className="flex items-center gap-3 flex-wrap"> <div className="flex items-center gap-3 flex-wrap">
{isAllowed && ( {isAllowed && (issueStatus === 0 || issueStatus === -2) && (
<div <div className="flex-shrink-0">
className={`flex-shrink-0 ${
issueStatus === 0 || issueStatus === -2 ? "" : "opacity-70"
}`}
>
<Popover className="relative"> <Popover className="relative">
<Popover.Button <Popover.Button as="button" type="button">
as="button" <SecondaryButton className="flex gap-x-1 items-center" size="sm">
type="button"
disabled={!(issueStatus === 0 || issueStatus === -2)}
>
<SecondaryButton
className={`flex gap-x-1 items-center ${
issueStatus === 0 || issueStatus === -2 ? "" : "cursor-not-allowed"
}`}
size="sm"
>
<ClockIcon className="h-4 w-4 text-brand-secondary" /> <ClockIcon className="h-4 w-4 text-brand-secondary" />
<span>Snooze</span> <span>Snooze</span>
</SecondaryButton> </SecondaryButton>
@ -185,7 +224,10 @@ export const InboxActionHeader: React.FC<Props> = (props) => {
className="ml-auto" className="ml-auto"
onClick={() => { onClick={() => {
close(); close();
onSnooze(date); markInboxStatus({
status: 0,
snoozed_till: new Date(date),
});
}} }}
> >
Snooze Snooze
@ -196,32 +238,36 @@ export const InboxActionHeader: React.FC<Props> = (props) => {
</Popover> </Popover>
</div> </div>
)} )}
{isAllowed && ( {isAllowed && issueStatus === -2 && (
<div className={`flex gap-3 flex-wrap ${issueStatus !== -2 ? "opacity-70" : ""}`}> <div className="flex-shrink-0">
<SecondaryButton <SecondaryButton
size="sm" size="sm"
className="flex gap-2 items-center" className="flex gap-2 items-center"
onClick={onMarkAsDuplicate} onClick={() => setSelectDuplicateIssue(true)}
disabled={issueStatus !== -2}
> >
<StackedLayersHorizontalIcon className="h-4 w-4 text-brand-secondary" /> <StackedLayersHorizontalIcon className="h-4 w-4 text-brand-secondary" />
<span>Mark as duplicate</span> <span>Mark as duplicate</span>
</SecondaryButton> </SecondaryButton>
</div>
)}
{isAllowed && (issueStatus === 0 || issueStatus === -2) && (
<div className="flex-shrink-0">
<SecondaryButton <SecondaryButton
size="sm" size="sm"
className="flex gap-2 items-center" className="flex gap-2 items-center"
onClick={handleAcceptIssue} onClick={() => setAcceptIssueModal(true)}
disabled={issueStatus !== -2}
loading={isAccepting}
> >
<CheckCircleIcon className="h-4 w-4 text-green-500" /> <CheckCircleIcon className="h-4 w-4 text-green-500" />
<span>{isAccepting ? "Accepting..." : "Accept"}</span> <span>Accept</span>
</SecondaryButton> </SecondaryButton>
</div>
)}
{isAllowed && issueStatus === -2 && (
<div className="flex-shrink-0">
<SecondaryButton <SecondaryButton
size="sm" size="sm"
className="flex gap-2 items-center" className="flex gap-2 items-center"
onClick={onDecline} onClick={() => setDeclineIssueModal(true)}
disabled={issueStatus !== -2}
> >
<XCircleIcon className="h-4 w-4 text-red-500" /> <XCircleIcon className="h-4 w-4 text-red-500" />
<span>Decline</span> <span>Decline</span>
@ -230,7 +276,11 @@ export const InboxActionHeader: React.FC<Props> = (props) => {
)} )}
{(isAllowed || user?.id === issue?.created_by) && ( {(isAllowed || user?.id === issue?.created_by) && (
<div className="flex-shrink-0"> <div className="flex-shrink-0">
<SecondaryButton size="sm" className="flex gap-2 items-center" onClick={onDelete}> <SecondaryButton
size="sm"
className="flex gap-2 items-center"
onClick={() => setDeleteIssueModal(true)}
>
<TrashIcon className="h-4 w-4 text-red-500" /> <TrashIcon className="h-4 w-4 text-red-500" />
<span>Delete</span> <span>Delete</span>
</SecondaryButton> </SecondaryButton>
@ -240,5 +290,6 @@ export const InboxActionHeader: React.FC<Props> = (props) => {
</div> </div>
)} )}
</div> </div>
</>
); );
}; };

View File

@ -4,13 +4,21 @@ import Link from "next/link";
// ui // ui
import { Tooltip } from "components/ui"; import { Tooltip } from "components/ui";
// icons // icons
import { getPriorityIcon, getStateGroupIcon } from "components/icons"; import { getPriorityIcon } from "components/icons";
import { CalendarDaysIcon, ClockIcon } from "@heroicons/react/24/outline"; import {
CalendarDaysIcon,
CheckCircleIcon,
ClockIcon,
DocumentDuplicateIcon,
ExclamationTriangleIcon,
XCircleIcon,
} from "@heroicons/react/24/outline";
// helpers // helpers
import { renderShortNumericDateFormat } from "helpers/date-time.helper"; import { renderShortNumericDateFormat } from "helpers/date-time.helper";
import { addSpaceIfCamelCase } from "helpers/string.helper";
// types // types
import type { IInboxIssue } from "types"; import type { IInboxIssue } from "types";
// constants
import { INBOX_STATUS } from "constants/inbox";
type Props = { type Props = {
issue: IInboxIssue; issue: IInboxIssue;
@ -30,20 +38,6 @@ export const InboxIssueCard: React.FC<Props> = (props) => {
href={`/${workspaceSlug}/projects/${projectId}/inbox/${inboxId}?inboxIssueId=${issue.bridge_id}`} href={`/${workspaceSlug}/projects/${projectId}/inbox/${inboxId}?inboxIssueId=${issue.bridge_id}`}
> >
<a> <a>
<Tooltip
tooltipContent={
issueStatus === -2
? "Pending issue"
: issueStatus === -1
? "Declined issue"
: issueStatus === 0
? "Snoozed issue"
: issueStatus === 1
? "Accepted issue"
: "Marked as duplicate"
}
position="right"
>
<div <div
id={issue.id} id={issue.id}
className={`relative min-h-[5rem] cursor-pointer select-none space-y-3 py-2 px-4 border-b border-brand-base hover:bg-brand-accent hover:bg-opacity-10 ${ className={`relative min-h-[5rem] cursor-pointer select-none space-y-3 py-2 px-4 border-b border-brand-base hover:bg-brand-accent hover:bg-opacity-10 ${
@ -57,20 +51,6 @@ export const InboxIssueCard: React.FC<Props> = (props) => {
<h5 className="truncate text-sm">{issue.name}</h5> <h5 className="truncate text-sm">{issue.name}</h5>
</div> </div>
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<Tooltip
tooltipHeading="State"
tooltipContent={addSpaceIfCamelCase(issue.state_detail?.name ?? "Triage")}
>
<div className="flex items-center gap-2 rounded border border-brand-base shadow-sm text-xs px-2 py-[0.19rem] text-brand-secondary">
{getStateGroupIcon(
issue.state_detail?.group ?? "backlog",
"14",
"14",
issue.state_detail?.color
)}
{issue.state_detail?.name ?? "Triage"}
</div>
</Tooltip>
<Tooltip tooltipHeading="Priority" tooltipContent={`${issue.priority ?? "None"}`}> <Tooltip tooltipHeading="Priority" tooltipContent={`${issue.priority ?? "None"}`}>
<div <div
className={`grid h-6 w-6 place-items-center rounded border items-center shadow-sm ${ className={`grid h-6 w-6 place-items-center rounded border items-center shadow-sm ${
@ -100,23 +80,46 @@ export const InboxIssueCard: React.FC<Props> = (props) => {
<span>{renderShortNumericDateFormat(issue.created_at ?? "")}</span> <span>{renderShortNumericDateFormat(issue.created_at ?? "")}</span>
</div> </div>
</Tooltip> </Tooltip>
{issue.issue_inbox[0].snoozed_till && ( </div>
<div <div
className={`text-xs flex items-center gap-1 ${ className={`text-xs flex items-center justify-end gap-1 w-full ${
new Date(issue.issue_inbox[0].snoozed_till ?? "") < new Date() issueStatus === 0 && new Date(issue.issue_inbox[0].snoozed_till ?? "") < new Date()
? "text-red-500" ? "text-red-500"
: "text-blue-500" : INBOX_STATUS.find((s) => s.value === issueStatus)?.textColor
}`} }`}
> >
{issueStatus === -2 ? (
<>
<ExclamationTriangleIcon className="h-3.5 w-3.5" />
<span>Pending</span>
</>
) : issueStatus === -1 ? (
<>
<XCircleIcon className="h-3.5 w-3.5" />
<span>Declined</span>
</>
) : issueStatus === 0 ? (
<>
<ClockIcon className="h-3.5 w-3.5" /> <ClockIcon className="h-3.5 w-3.5" />
<span> <span>
Snoozed till {renderShortNumericDateFormat(issue.issue_inbox[0].snoozed_till)} {new Date(issue.issue_inbox[0].snoozed_till ?? "") < new Date()
? "Snoozed date passed"
: "Snoozed"}
</span> </span>
</div> </>
) : issueStatus === 1 ? (
<>
<CheckCircleIcon className="h-3.5 w-3.5" />
<span>Accepted</span>
</>
) : (
<>
<DocumentDuplicateIcon className="h-3.5 w-3.5" />
<span>Duplicate</span>
</>
)} )}
</div> </div>
</div> </div>
</Tooltip>
</a> </a>
</Link> </Link>
); );

View File

@ -1,6 +1,6 @@
import { useCallback, useEffect } from "react"; import { useCallback, useEffect } from "react";
import { useRouter } from "next/router"; import Router, { useRouter } from "next/router";
import useSWR, { mutate } from "swr"; import useSWR, { mutate } from "swr";
@ -29,6 +29,7 @@ import {
ClockIcon, ClockIcon,
DocumentDuplicateIcon, DocumentDuplicateIcon,
ExclamationTriangleIcon, ExclamationTriangleIcon,
InboxIcon,
XCircleIcon, XCircleIcon,
} from "@heroicons/react/24/outline"; } from "@heroicons/react/24/outline";
// helpers // helpers
@ -55,7 +56,7 @@ export const InboxMainContent: React.FC = () => {
const { user } = useUserAuth(); const { user } = useUserAuth();
const { memberRole } = useProjectMyMembership(); const { memberRole } = useProjectMyMembership();
const { params } = useInboxView(); const { params, issues: inboxIssues } = useInboxView();
const { reset, control, watch } = useForm<IIssue>({ const { reset, control, watch } = useForm<IIssue>({
defaultValues, defaultValues,
@ -76,17 +77,6 @@ export const InboxMainContent: React.FC = () => {
: null : null
); );
useEffect(() => {
if (!issueDetails || !inboxIssueId) return;
reset({
...issueDetails,
assignees_list:
issueDetails.assignees_list ?? (issueDetails.assignee_details ?? []).map((user) => user.id),
labels_list: issueDetails.labels_list ?? issueDetails.labels,
});
}, [issueDetails, reset, inboxIssueId]);
const submitChanges = useCallback( const submitChanges = useCallback(
async (formData: Partial<IInboxIssue>) => { async (formData: Partial<IInboxIssue>) => {
if (!workspaceSlug || !projectId || !inboxIssueId || !inboxId || !issueDetails) return; if (!workspaceSlug || !projectId || !inboxIssueId || !inboxId || !issueDetails) return;
@ -144,8 +134,86 @@ export const InboxMainContent: React.FC = () => {
] ]
); );
const onKeyDown = useCallback(
(e: KeyboardEvent) => {
if (!inboxIssues || !inboxIssueId) return;
const currentIssueIndex = inboxIssues.findIndex((issue) => issue.bridge_id === inboxIssueId);
switch (e.key) {
case "ArrowUp":
Router.push({
pathname: `/${workspaceSlug}/projects/${projectId}/inbox/${inboxId}`,
query: {
inboxIssueId:
currentIssueIndex === 0
? inboxIssues[inboxIssues.length - 1].bridge_id
: inboxIssues[currentIssueIndex - 1].bridge_id,
},
});
break;
case "ArrowDown":
Router.push({
pathname: `/${workspaceSlug}/projects/${projectId}/inbox/${inboxId}`,
query: {
inboxIssueId:
currentIssueIndex === inboxIssues.length - 1
? inboxIssues[0].bridge_id
: inboxIssues[currentIssueIndex + 1].bridge_id,
},
});
break;
default:
break;
}
},
[workspaceSlug, projectId, inboxIssueId, inboxId, inboxIssues]
);
useEffect(() => {
document.addEventListener("keydown", onKeyDown);
return () => {
document.removeEventListener("keydown", onKeyDown);
};
}, [onKeyDown]);
useEffect(() => {
if (!issueDetails || !inboxIssueId) return;
reset({
...issueDetails,
assignees_list:
issueDetails.assignees_list ?? (issueDetails.assignee_details ?? []).map((user) => user.id),
labels_list: issueDetails.labels_list ?? issueDetails.labels,
});
}, [issueDetails, reset, inboxIssueId]);
const issueStatus = issueDetails?.issue_inbox[0].status; const issueStatus = issueDetails?.issue_inbox[0].status;
if (!inboxIssueId)
return (
<div className="h-full p-4 grid place-items-center text-brand-secondary">
<div className="grid h-full place-items-center">
<div className="my-5 flex flex-col items-center gap-4">
<InboxIcon height={60} width={60} />
{inboxIssues && inboxIssues.length > 0 ? (
<span className="text-brand-secondary">
{inboxIssues?.length} issues found. Select an issue from the sidebar to view its
details.
</span>
) : (
<span className="text-brand-secondary">
No issues found. Use{" "}
<pre className="inline rounded bg-brand-surface-2 px-2 py-1">C</pre> shortcut to
create a new issue
</span>
)}
</div>
</div>
</div>
);
return ( return (
<> <>
{issueDetails ? ( {issueDetails ? (
@ -154,17 +222,17 @@ export const InboxMainContent: React.FC = () => {
<div <div
className={`flex items-center gap-2 p-3 text-sm border rounded-md ${ className={`flex items-center gap-2 p-3 text-sm border rounded-md ${
issueStatus === -2 issueStatus === -2
? "text-orange-500 border-orange-500 bg-orange-500/10" ? "text-yellow-500 border-yellow-500 bg-yellow-500/10"
: issueStatus === -1 : issueStatus === -1
? "text-red-500 border-red-500 bg-red-500/10" ? "text-red-500 border-red-500 bg-red-500/10"
: issueStatus === 0 : issueStatus === 0
? new Date(issueDetails.issue_inbox[0].snoozed_till ?? "") < new Date() ? new Date(issueDetails.issue_inbox[0].snoozed_till ?? "") < new Date()
? "text-red-500 border-red-500 bg-red-500/10" ? "text-red-500 border-red-500 bg-red-500/10"
: "text-blue-500 border-blue-500 bg-blue-500/10" : "text-brand-secondary border-gray-500 bg-gray-500/10"
: issueStatus === 1 : issueStatus === 1
? "text-green-500 border-green-500 bg-green-500/10" ? "text-green-500 border-green-500 bg-green-500/10"
: issueStatus === 2 : issueStatus === 2
? "text-yellow-500 border-yellow-500 bg-yellow-500/10" ? "text-brand-secondary border-gray-500 bg-gray-500/10"
: "" : ""
}`} }`}
> >
@ -266,6 +334,4 @@ export const InboxMainContent: React.FC = () => {
)} )}
</> </>
); );
return null;
}; };

View File

@ -1,3 +1,4 @@
export * from "./accept-issue-modal";
export * from "./decline-issue-modal"; export * from "./decline-issue-modal";
export * from "./delete-issue-modal"; export * from "./delete-issue-modal";
export * from "./filters-dropdown"; export * from "./filters-dropdown";

View File

@ -11,7 +11,7 @@ export const IssuesListSidebar = () => {
const router = useRouter(); const router = useRouter();
const { inboxIssueId } = router.query; const { inboxIssueId } = router.query;
const { issues: inboxIssues } = useInboxView(); const { issues: inboxIssues, filtersLength } = useInboxView();
return ( return (
<div className="h-full flex flex-col overflow-hidden"> <div className="h-full flex flex-col overflow-hidden">
@ -29,7 +29,8 @@ export const IssuesListSidebar = () => {
</div> </div>
) : ( ) : (
<div className="h-full p-4 grid place-items-center text-center text-sm text-brand-secondary"> <div className="h-full p-4 grid place-items-center text-center text-sm text-brand-secondary">
No issues found for the selected filters. Try changing the filters. {filtersLength > 0 &&
"No issues found for the selected filters. Try changing the filters."}
</div> </div>
) )
) : ( ) : (

View File

@ -104,7 +104,7 @@ export const DeleteImportModal: React.FC<Props> = ({ isOpen, handleClose, data,
<span> <span>
<p className="text-sm leading-7 text-brand-secondary"> <p className="text-sm leading-7 text-brand-secondary">
Are you sure you want to delete import from{" "} Are you sure you want to delete import from{" "}
<span className="break-all font-semibold capitalize text-brand-base"> <span className="break-words font-semibold capitalize text-brand-base">
{data?.service} {data?.service}
</span> </span>
? All of the data related to the import will be permanently removed. This ? All of the data related to the import will be permanently removed. This

View File

@ -12,17 +12,19 @@ import issueServices from "services/issues.service";
import useIssuesView from "hooks/use-issues-view"; import useIssuesView from "hooks/use-issues-view";
import useCalendarIssuesView from "hooks/use-calendar-issues-view"; import useCalendarIssuesView from "hooks/use-calendar-issues-view";
import useToast from "hooks/use-toast"; import useToast from "hooks/use-toast";
import useSpreadsheetIssuesView from "hooks/use-spreadsheet-issues-view";
// icons // icons
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline"; import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
// ui // ui
import { SecondaryButton, DangerButton } from "components/ui"; import { SecondaryButton, DangerButton } from "components/ui";
// types // types
import type { IIssue, ICurrentUserResponse } from "types"; import type { IIssue, ICurrentUserResponse, ISubIssueResponse } from "types";
// fetch-keys // fetch-keys
import { import {
CYCLE_ISSUES_WITH_PARAMS, CYCLE_ISSUES_WITH_PARAMS,
MODULE_ISSUES_WITH_PARAMS, MODULE_ISSUES_WITH_PARAMS,
PROJECT_ISSUES_LIST_WITH_PARAMS, PROJECT_ISSUES_LIST_WITH_PARAMS,
SUB_ISSUES,
VIEW_ISSUES, VIEW_ISSUES,
} from "constants/fetch-keys"; } from "constants/fetch-keys";
@ -41,6 +43,7 @@ export const DeleteIssueModal: React.FC<Props> = ({ isOpen, handleClose, data, u
const { issueView, params } = useIssuesView(); const { issueView, params } = useIssuesView();
const { params: calendarParams } = useCalendarIssuesView(); const { params: calendarParams } = useCalendarIssuesView();
const { params: spreadsheetParams } = useSpreadsheetIssuesView();
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();
@ -74,6 +77,36 @@ export const DeleteIssueModal: React.FC<Props> = ({ isOpen, handleClose, data, u
(prevData) => (prevData ?? []).filter((p) => p.id !== data.id), (prevData) => (prevData ?? []).filter((p) => p.id !== data.id),
false false
); );
} else if (issueView === "spreadsheet") {
const spreadsheetFetchKey = cycleId
? CYCLE_ISSUES_WITH_PARAMS(cycleId.toString(), spreadsheetParams)
: moduleId
? MODULE_ISSUES_WITH_PARAMS(moduleId.toString(), spreadsheetParams)
: viewId
? VIEW_ISSUES(viewId.toString(), spreadsheetParams)
: PROJECT_ISSUES_LIST_WITH_PARAMS(projectId?.toString() ?? "", spreadsheetParams);
if (data.parent) {
mutate<ISubIssueResponse>(
SUB_ISSUES(data.parent.toString()),
(prevData) => {
if (!prevData) return prevData;
const updatedArray = (prevData.sub_issues ?? []).filter((i) => i.id !== data.id);
return {
...prevData,
sub_issues: updatedArray,
};
},
false
);
mutate<IIssue[]>(spreadsheetFetchKey);
} else {
mutate<IIssue[]>(
spreadsheetFetchKey,
(prevData) => (prevData ?? []).filter((p) => p.id !== data.id),
false
);
}
} else { } else {
if (cycleId) mutate(CYCLE_ISSUES_WITH_PARAMS(cycleId as string, params)); if (cycleId) mutate(CYCLE_ISSUES_WITH_PARAMS(cycleId as string, params));
else if (moduleId) mutate(MODULE_ISSUES_WITH_PARAMS(moduleId as string, params)); else if (moduleId) mutate(MODULE_ISSUES_WITH_PARAMS(moduleId as string, params));
@ -135,7 +168,7 @@ export const DeleteIssueModal: React.FC<Props> = ({ isOpen, handleClose, data, u
<span> <span>
<p className="text-sm text-brand-secondary"> <p className="text-sm text-brand-secondary">
Are you sure you want to delete issue{" "} Are you sure you want to delete issue{" "}
<span className="break-all font-medium text-brand-base"> <span className="break-words font-medium text-brand-base">
{data?.project_detail.identifier}-{data?.sequence_id} {data?.project_detail.identifier}-{data?.sequence_id}
</span> </span>
{""}? All of the data related to the issue will be permanently removed. This {""}? All of the data related to the issue will be permanently removed. This

View File

@ -23,7 +23,6 @@ import {
IssueStateSelect, IssueStateSelect,
} from "components/issues/select"; } from "components/issues/select";
import { CreateStateModal } from "components/states"; import { CreateStateModal } from "components/states";
import { CreateUpdateCycleModal } from "components/cycles";
import { CreateLabelModal } from "components/labels"; import { CreateLabelModal } from "components/labels";
// ui // ui
import { import {
@ -73,7 +72,6 @@ const defaultValues: Partial<IIssue> = {
description_html: "<p></p>", description_html: "<p></p>",
estimate_point: null, estimate_point: null,
state: "", state: "",
cycle: null,
priority: null, priority: null,
assignees: [], assignees: [],
assignees_list: [], assignees_list: [],
@ -122,7 +120,6 @@ export const IssueForm: FC<IssueFormProps> = ({
}) => { }) => {
// states // states
const [mostSimilarIssue, setMostSimilarIssue] = useState<IIssue | undefined>(); const [mostSimilarIssue, setMostSimilarIssue] = useState<IIssue | undefined>();
const [cycleModal, setCycleModal] = useState(false);
const [stateModal, setStateModal] = useState(false); const [stateModal, setStateModal] = useState(false);
const [labelModal, setLabelModal] = useState(false); const [labelModal, setLabelModal] = useState(false);
const [parentIssueListModalOpen, setParentIssueListModalOpen] = useState(false); const [parentIssueListModalOpen, setParentIssueListModalOpen] = useState(false);
@ -148,7 +145,7 @@ export const IssueForm: FC<IssueFormProps> = ({
setValue, setValue,
setFocus, setFocus,
} = useForm<IIssue>({ } = useForm<IIssue>({
defaultValues, defaultValues: initialData ?? defaultValues,
reValidateMode: "onChange", reValidateMode: "onChange",
}); });
@ -163,6 +160,8 @@ export const IssueForm: FC<IssueFormProps> = ({
const handleCreateUpdateIssue = async (formData: Partial<IIssue>) => { const handleCreateUpdateIssue = async (formData: Partial<IIssue>) => {
await handleFormSubmit(formData); await handleFormSubmit(formData);
setGptAssistantModal(false);
reset({ reset({
...defaultValues, ...defaultValues,
project: projectId, project: projectId,
@ -198,7 +197,7 @@ export const IssueForm: FC<IssueFormProps> = ({
projectId as string, projectId as string,
{ {
prompt: issueName, prompt: issueName,
task: "Generate a proper description for this issue in context of a project management software.", task: "Generate a proper description for this issue.",
}, },
user user
) )
@ -250,11 +249,6 @@ export const IssueForm: FC<IssueFormProps> = ({
projectId={projectId} projectId={projectId}
user={user} user={user}
/> />
<CreateUpdateCycleModal
isOpen={cycleModal}
handleClose={() => setCycleModal(false)}
user={user}
/>
<CreateLabelModal <CreateLabelModal
isOpen={labelModal} isOpen={labelModal}
handleClose={() => setLabelModal(false)} handleClose={() => setLabelModal(false)}

View File

@ -17,6 +17,7 @@ import useIssuesView from "hooks/use-issues-view";
import useCalendarIssuesView from "hooks/use-calendar-issues-view"; import useCalendarIssuesView from "hooks/use-calendar-issues-view";
import useToast from "hooks/use-toast"; import useToast from "hooks/use-toast";
import useInboxView from "hooks/use-inbox-view"; import useInboxView from "hooks/use-inbox-view";
import useSpreadsheetIssuesView from "hooks/use-spreadsheet-issues-view";
// components // components
import { IssueForm } from "components/issues"; import { IssueForm } from "components/issues";
// types // types
@ -79,13 +80,19 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
const { params: calendarParams } = useCalendarIssuesView(); const { params: calendarParams } = useCalendarIssuesView();
const { order_by, group_by, ...viewGanttParams } = params; const { order_by, group_by, ...viewGanttParams } = params;
const { params: inboxParams } = useInboxView(); const { params: inboxParams } = useInboxView();
const { params: spreadsheetParams } = useSpreadsheetIssuesView();
if (cycleId) prePopulateData = { ...prePopulateData, cycle: cycleId as string };
if (moduleId) prePopulateData = { ...prePopulateData, module: moduleId as string };
const { user } = useUser(); const { user } = useUser();
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();
if (cycleId) prePopulateData = { ...prePopulateData, cycle: cycleId as string };
if (moduleId) prePopulateData = { ...prePopulateData, module: moduleId as string };
if (router.asPath.includes("my-issues"))
prePopulateData = {
...prePopulateData,
assignees: [...(prePopulateData?.assignees ?? []), user?.id ?? ""],
};
const { data: issues } = useSWR( const { data: issues } = useSWR(
workspaceSlug && activeProject workspaceSlug && activeProject
? PROJECT_ISSUES_LIST(workspaceSlug as string, activeProject ?? "") ? PROJECT_ISSUES_LIST(workspaceSlug as string, activeProject ?? "")
@ -119,7 +126,7 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
}, [handleClose]); }, [handleClose]);
const addIssueToCycle = async (issueId: string, cycleId: string) => { const addIssueToCycle = async (issueId: string, cycleId: string) => {
if (!workspaceSlug || !projectId) return; if (!workspaceSlug || !activeProject) return;
await issuesService await issuesService
.addIssueToCycle( .addIssueToCycle(
@ -140,7 +147,7 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
}; };
const addIssueToModule = async (issueId: string, moduleId: string) => { const addIssueToModule = async (issueId: string, moduleId: string) => {
if (!workspaceSlug || !projectId) return; if (!workspaceSlug || !activeProject) return;
await modulesService await modulesService
.addIssuesToModule( .addIssuesToModule(
@ -161,7 +168,7 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
}; };
const addIssueToInbox = async (formData: Partial<IIssue>) => { const addIssueToInbox = async (formData: Partial<IIssue>) => {
if (!workspaceSlug || !projectId || !inboxId) return; if (!workspaceSlug || !activeProject || !inboxId) return;
const payload = { const payload = {
issue: { issue: {
@ -176,7 +183,7 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
await inboxServices await inboxServices
.createInboxIssue( .createInboxIssue(
workspaceSlug.toString(), workspaceSlug.toString(),
projectId.toString(), activeProject.toString(),
inboxId.toString(), inboxId.toString(),
payload, payload,
user user
@ -188,6 +195,10 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
message: "Issue created successfully.", message: "Issue created successfully.",
}); });
router.push(
`/${workspaceSlug}/projects/${activeProject}/inbox/${inboxId}?inboxIssueId=${res.issue_inbox[0].id}`
);
mutate(INBOX_ISSUES(inboxId.toString(), inboxParams)); mutate(INBOX_ISSUES(inboxId.toString(), inboxParams));
}) })
.catch(() => { .catch(() => {
@ -205,7 +216,15 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
? MODULE_ISSUES_WITH_PARAMS(moduleId.toString(), calendarParams) ? MODULE_ISSUES_WITH_PARAMS(moduleId.toString(), calendarParams)
: viewId : viewId
? VIEW_ISSUES(viewId.toString(), calendarParams) ? VIEW_ISSUES(viewId.toString(), calendarParams)
: PROJECT_ISSUES_LIST_WITH_PARAMS(projectId?.toString() ?? "", calendarParams); : PROJECT_ISSUES_LIST_WITH_PARAMS(activeProject?.toString() ?? "", calendarParams);
const spreadsheetFetchKey = cycleId
? CYCLE_ISSUES_WITH_PARAMS(cycleId.toString(), spreadsheetParams)
: moduleId
? MODULE_ISSUES_WITH_PARAMS(moduleId.toString(), spreadsheetParams)
: viewId
? VIEW_ISSUES(viewId.toString(), spreadsheetParams)
: PROJECT_ISSUES_LIST_WITH_PARAMS(activeProject?.toString() ?? "", spreadsheetParams);
const ganttFetchKey = cycleId const ganttFetchKey = cycleId
? CYCLE_ISSUES_WITH_PARAMS(cycleId.toString()) ? CYCLE_ISSUES_WITH_PARAMS(cycleId.toString())
@ -213,10 +232,10 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
? MODULE_ISSUES_WITH_PARAMS(moduleId.toString()) ? MODULE_ISSUES_WITH_PARAMS(moduleId.toString())
: viewId : viewId
? VIEW_ISSUES(viewId.toString(), viewGanttParams) ? VIEW_ISSUES(viewId.toString(), viewGanttParams)
: PROJECT_ISSUES_LIST_WITH_PARAMS(projectId?.toString() ?? ""); : PROJECT_ISSUES_LIST_WITH_PARAMS(activeProject?.toString() ?? "");
const createIssue = async (payload: Partial<IIssue>) => { const createIssue = async (payload: Partial<IIssue>) => {
if (!workspaceSlug || !projectId) return; if (!workspaceSlug || !activeProject) return;
if (inboxId) await addIssueToInbox(payload); if (inboxId) await addIssueToInbox(payload);
else else
@ -230,6 +249,7 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
if (issueView === "calendar") mutate(calendarFetchKey); if (issueView === "calendar") mutate(calendarFetchKey);
if (issueView === "gantt_chart") mutate(ganttFetchKey); if (issueView === "gantt_chart") mutate(ganttFetchKey);
if (issueView === "spreadsheet") mutate(spreadsheetFetchKey);
setToastAlert({ setToastAlert({
type: "success", type: "success",
@ -237,7 +257,8 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
message: "Issue created successfully.", message: "Issue created successfully.",
}); });
if (payload.assignees_list?.some((assignee) => assignee === user?.id)) mutate(USER_ISSUE); if (payload.assignees_list?.some((assignee) => assignee === user?.id))
mutate(USER_ISSUE(workspaceSlug as string));
if (payload.parent && payload.parent !== "") mutate(SUB_ISSUES(payload.parent)); if (payload.parent && payload.parent !== "") mutate(SUB_ISSUES(payload.parent));
}) })
@ -260,6 +281,8 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
mutate<IIssue>(PROJECT_ISSUES_DETAILS, (prevData) => ({ ...prevData, ...res }), false); mutate<IIssue>(PROJECT_ISSUES_DETAILS, (prevData) => ({ ...prevData, ...res }), false);
} else { } else {
if (issueView === "calendar") mutate(calendarFetchKey); if (issueView === "calendar") mutate(calendarFetchKey);
if (issueView === "spreadsheet") mutate(spreadsheetFetchKey);
if (payload.parent) mutate(SUB_ISSUES(payload.parent.toString()));
mutate(PROJECT_ISSUES_LIST_WITH_PARAMS(activeProject ?? "", params)); mutate(PROJECT_ISSUES_LIST_WITH_PARAMS(activeProject ?? "", params));
} }
@ -328,7 +351,7 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
<IssueForm <IssueForm
issues={issues ?? []} issues={issues ?? []}
handleFormSubmit={handleFormSubmit} handleFormSubmit={handleFormSubmit}
initialData={prePopulateData} initialData={data ?? prePopulateData}
createMore={createMore} createMore={createMore}
setCreateMore={setCreateMore} setCreateMore={setCreateMore}
handleClose={handleClose} handleClose={handleClose}

View File

@ -44,14 +44,14 @@ export const MyIssuesListItem: React.FC<Props> = ({ issue, properties, projectId
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();
const partialUpdateIssue = useCallback( const partialUpdateIssue = useCallback(
(formData: Partial<IIssue>, issueId: string) => { (formData: Partial<IIssue>, issue: IIssue) => {
if (!workspaceSlug) return; if (!workspaceSlug) return;
mutate<IIssue[]>( mutate<IIssue[]>(
USER_ISSUE(workspaceSlug as string), USER_ISSUE(workspaceSlug as string),
(prevData) => (prevData) =>
prevData?.map((p) => { prevData?.map((p) => {
if (p.id === issueId) return { ...p, ...formData }; if (p.id === issue.id) return { ...p, ...formData };
return p; return p;
}), }),
@ -59,7 +59,7 @@ export const MyIssuesListItem: React.FC<Props> = ({ issue, properties, projectId
); );
issuesService issuesService
.patchIssue(workspaceSlug as string, projectId as string, issueId, formData, user) .patchIssue(workspaceSlug as string, projectId as string, issue.id, formData, user)
.then((res) => { .then((res) => {
mutate(USER_ISSUE(workspaceSlug as string)); mutate(USER_ISSUE(workspaceSlug as string));
}) })

View File

@ -1,23 +1,28 @@
import React, { useState } from "react"; import React, { useEffect, useState } from "react";
import { useRouter } from "next/router";
// headless ui // headless ui
import { Combobox, Dialog, Transition } from "@headlessui/react"; import { Combobox, Dialog, Transition } from "@headlessui/react";
// icons // services
import { MagnifyingGlassIcon, RectangleStackIcon } from "@heroicons/react/24/outline"; import projectService from "services/project.service";
// ui // hooks
import { PrimaryButton, SecondaryButton } from "components/ui"; import useDebounce from "hooks/use-debounce";
// types // components
import { IIssue } from "types";
import { LayerDiagonalIcon } from "components/icons"; import { LayerDiagonalIcon } from "components/icons";
// ui
import { Loader } from "components/ui";
// icons
import { MagnifyingGlassIcon } from "@heroicons/react/24/outline";
// types
import { ISearchIssueResponse } from "types";
type Props = { type Props = {
isOpen: boolean; isOpen: boolean;
handleClose: () => void; handleClose: () => void;
value?: any; value?: any;
onChange: (...event: any[]) => void; onChange: (...event: any[]) => void;
issues: IIssue[]; issueId?: string;
title?: string;
multiple?: boolean;
customDisplay?: JSX.Element; customDisplay?: JSX.Element;
}; };
@ -26,28 +31,60 @@ export const ParentIssuesListModal: React.FC<Props> = ({
handleClose: onClose, handleClose: onClose,
value, value,
onChange, onChange,
issues, issueId,
title = "Issues",
multiple = false,
customDisplay, customDisplay,
}) => { }) => {
const [query, setQuery] = useState(""); const [searchTerm, setSearchTerm] = useState("");
const [values, setValues] = useState<string[]>([]); const [issues, setIssues] = useState<ISearchIssueResponse[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isSearching, setIsSearching] = useState(false);
const debouncedSearchTerm: string = useDebounce(searchTerm, 500);
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const handleClose = () => { const handleClose = () => {
onClose(); onClose();
setQuery(""); setSearchTerm("");
setValues([]);
}; };
const filteredIssues: IIssue[] = useEffect(() => {
query === "" if (!workspaceSlug || !projectId) return;
? issues ?? []
: issues?.filter((issue) => issue.name.toLowerCase().includes(query.toLowerCase())) ?? []; setIsLoading(true);
if (debouncedSearchTerm) {
setIsSearching(true);
projectService
.projectIssuesSearch(workspaceSlug as string, projectId as string, {
search: debouncedSearchTerm,
parent: true,
issue_id: issueId,
})
.then((res) => {
setIssues(res);
})
.finally(() => {
setIsLoading(false);
setIsSearching(false);
});
} else {
setIssues([]);
setIsLoading(false);
setIsSearching(false);
}
}, [debouncedSearchTerm, workspaceSlug, projectId, issueId]);
return ( return (
<> <>
<Transition.Root show={isOpen} as={React.Fragment} afterLeave={() => setQuery("")} appear> <Transition.Root
show={isOpen}
as={React.Fragment}
afterLeave={() => setSearchTerm("")}
appear
>
<Dialog as="div" className="relative z-20" onClose={handleClose}> <Dialog as="div" className="relative z-20" onClose={handleClose}>
<Transition.Child <Transition.Child
as={React.Fragment} as={React.Fragment}
@ -72,82 +109,6 @@ export const ParentIssuesListModal: React.FC<Props> = ({
leaveTo="opacity-0 scale-95" leaveTo="opacity-0 scale-95"
> >
<Dialog.Panel className="relative mx-auto max-w-2xl transform rounded-xl border border-brand-base bg-brand-base shadow-2xl transition-all"> <Dialog.Panel className="relative mx-auto max-w-2xl transform rounded-xl border border-brand-base bg-brand-base shadow-2xl transition-all">
{multiple ? (
<>
<Combobox value={value} onChange={() => ({})} multiple>
<div className="relative m-1">
<MagnifyingGlassIcon
className="pointer-events-none absolute top-3.5 left-4 h-5 w-5 text-brand-base text-opacity-40"
aria-hidden="true"
/>
<Combobox.Input
className="h-12 w-full border-0 bg-transparent pl-11 pr-4 text-brand-base placeholder-gray-500 outline-none focus:ring-0 sm:text-sm"
placeholder="Search..."
onChange={(e) => setQuery(e.target.value)}
displayValue={() => ""}
/>
</div>
{customDisplay && <div className="p-3">{customDisplay}</div>}
<Combobox.Options
static
className="max-h-80 scroll-py-2 divide-y divide-gray-500 divide-opacity-10 overflow-y-auto"
>
{filteredIssues.length > 0 && (
<li className="p-2">
{query === "" && (
<h2 className="mt-4 mb-2 px-3 text-xs font-medium">{title}</h2>
)}
<ul className="text-sm">
{filteredIssues.map((issue) => (
<Combobox.Option
key={issue.id}
value={issue.id}
className={({ active, selected }) =>
`flex cursor-pointer select-none items-center gap-2 rounded-md px-3 py-2 text-brand-secondary ${
active ? "bg-brand-surface-2 text-brand-base" : ""
} ${selected ? "text-brand-base" : ""}`
}
>
{({ selected }) => (
<>
<input type="checkbox" checked={selected} readOnly />
<span
className="block h-1.5 w-1.5 flex-shrink-0 rounded-full"
style={{
backgroundColor: issue.state_detail.color,
}}
/>
<span className="flex-shrink-0 text-xs">
{issue.project_detail?.identifier}-{issue.sequence_id}
</span>{" "}
{issue.id}
</>
)}
</Combobox.Option>
))}
</ul>
</li>
)}
</Combobox.Options>
{query !== "" && filteredIssues.length === 0 && (
<div className="py-14 px-6 text-center sm:px-14">
<RectangleStackIcon
className="mx-auto h-6 w-6 text-brand-base text-opacity-40"
aria-hidden="true"
/>
<p className="mt-4 text-sm text-brand-base">
We couldn{"'"}t find any issue with that term. Please try again.
</p>
</div>
)}
</Combobox>
<div className="flex items-center justify-end gap-2 p-3">
<SecondaryButton onClick={handleClose}>Cancel</SecondaryButton>
<PrimaryButton onClick={() => onChange(values)}>Add issues</PrimaryButton>
</div>
</>
) : (
<Combobox value={value} onChange={onChange}> <Combobox value={value} onChange={onChange}>
<div className="relative m-1"> <div className="relative m-1">
<MagnifyingGlassIcon <MagnifyingGlassIcon
@ -156,20 +117,52 @@ export const ParentIssuesListModal: React.FC<Props> = ({
/> />
<Combobox.Input <Combobox.Input
className="h-12 w-full border-0 bg-transparent pl-11 pr-4 text-brand-base placeholder-gray-500 outline-none focus:ring-0 sm:text-sm" className="h-12 w-full border-0 bg-transparent pl-11 pr-4 text-brand-base placeholder-gray-500 outline-none focus:ring-0 sm:text-sm"
placeholder="Search..." placeholder="Type to search..."
onChange={(e) => setQuery(e.target.value)} value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
displayValue={() => ""} displayValue={() => ""}
/> />
</div> </div>
{customDisplay && <div className="p-3">{customDisplay}</div>} {customDisplay && <div className="p-2">{customDisplay}</div>}
<Combobox.Options static className="max-h-80 scroll-py-2 overflow-y-auto"> <Combobox.Options static className="max-h-80 scroll-py-2 overflow-y-auto mt-2">
{filteredIssues.length > 0 ? ( {debouncedSearchTerm !== "" && (
<li className="p-2"> <h5 className="text-[0.825rem] text-brand-secondary mx-2">
{query === "" && ( Search results for{" "}
<h2 className="mt-4 mb-2 px-3 text-xs font-medium">{title}</h2> <span className="text-brand-base">
{'"'}
{debouncedSearchTerm}
{'"'}
</span>{" "}
in project:
</h5>
)} )}
<ul className="text-sm">
{filteredIssues.map((issue) => ( {!isLoading &&
issues.length === 0 &&
searchTerm !== "" &&
debouncedSearchTerm !== "" && (
<div className="flex flex-col items-center justify-center gap-4 px-3 py-8 text-center">
<LayerDiagonalIcon height="52" width="52" />
<h3 className="text-brand-secondary">
No issues found. Create a new issue with{" "}
<pre className="inline rounded bg-brand-surface-2 px-2 py-1 text-sm">
C
</pre>
.
</h3>
</div>
)}
{isLoading || isSearching ? (
<Loader className="space-y-3 p-3">
<Loader.Item height="40px" />
<Loader.Item height="40px" />
<Loader.Item height="40px" />
<Loader.Item height="40px" />
</Loader>
) : (
<ul className={`text-sm ${issues.length > 0 ? "p-2" : ""}`}>
{issues.map((issue) => (
<Combobox.Option <Combobox.Option
key={issue.id} key={issue.id}
value={issue.id} value={issue.id}
@ -184,33 +177,20 @@ export const ParentIssuesListModal: React.FC<Props> = ({
<span <span
className="block h-1.5 w-1.5 flex-shrink-0 rounded-full" className="block h-1.5 w-1.5 flex-shrink-0 rounded-full"
style={{ style={{
backgroundColor: issue.state_detail.color, backgroundColor: issue.state__color,
}} }}
/> />
<span className="flex-shrink-0 text-xs"> <span className="flex-shrink-0 text-xs">
{issue.project_detail?.identifier}-{issue.sequence_id} {issue.project__identifier}-{issue.sequence_id}
</span>{" "} </span>{" "}
{issue.name} {issue.name}
</> </>
</Combobox.Option> </Combobox.Option>
))} ))}
</ul> </ul>
</li>
) : (
<div className="flex flex-col items-center justify-center gap-4 px-3 py-8 text-center">
<LayerDiagonalIcon height="52" width="52" />
<h3 className="text-brand-secondary">
No issues found. Create a new issue with{" "}
<pre className="inline rounded bg-brand-surface-2 px-2 py-1 text-sm">
C
</pre>
.
</h3>
</div>
)} )}
</Combobox.Options> </Combobox.Options>
</Combobox> </Combobox>
)}
</Dialog.Panel> </Dialog.Panel>
</Transition.Child> </Transition.Child>
</div> </div>

View File

@ -6,6 +6,10 @@ import useSWR from "swr";
// headless ui // headless ui
import { Combobox, Transition } from "@headlessui/react"; import { Combobox, Transition } from "@headlessui/react";
// services
import issuesServices from "services/issues.service";
// ui
import { IssueLabelsList } from "components/ui";
// icons // icons
import { import {
CheckIcon, CheckIcon,
@ -14,13 +18,10 @@ import {
RectangleGroupIcon, RectangleGroupIcon,
TagIcon, TagIcon,
} from "@heroicons/react/24/outline"; } from "@heroicons/react/24/outline";
// services
import issuesServices from "services/issues.service";
// types // types
import type { IIssueLabels } from "types"; import type { IIssueLabels } from "types";
// fetch-keys // fetch-keys
import { PROJECT_ISSUE_LABELS } from "constants/fetch-keys"; import { PROJECT_ISSUE_LABELS } from "constants/fetch-keys";
import { IssueLabelsList } from "components/ui";
type Props = { type Props = {
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>; setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;

View File

@ -21,7 +21,6 @@ export const IssueParentSelect: React.FC<Props> = ({ control, isOpen, setIsOpen,
isOpen={isOpen} isOpen={isOpen}
handleClose={() => setIsOpen(false)} handleClose={() => setIsOpen(false)}
onChange={onChange} onChange={onChange}
issues={issues}
/> />
)} )}
/> />

View File

@ -3,107 +3,82 @@ import React, { useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import useSWR from "swr";
// react-hook-form // react-hook-form
import { SubmitHandler, useForm, UseFormWatch } from "react-hook-form"; import { UseFormWatch } from "react-hook-form";
// headless ui
import { Combobox, Dialog, Transition } from "@headlessui/react";
// hooks // hooks
import useToast from "hooks/use-toast"; import useToast from "hooks/use-toast";
// services import useProjectDetails from "hooks/use-project-details";
import issuesService from "services/issues.service"; // components
// ui import { ExistingIssuesListModal } from "components/core";
import { PrimaryButton, SecondaryButton } from "components/ui";
// icons // icons
import { MagnifyingGlassIcon, XMarkIcon } from "@heroicons/react/24/outline"; import { XMarkIcon } from "@heroicons/react/24/outline";
import { BlockedIcon, LayerDiagonalIcon } from "components/icons"; import { BlockedIcon } from "components/icons";
// types // types
import { IIssue, UserAuth } from "types"; import { BlockeIssue, IIssue, ISearchIssueResponse, UserAuth } from "types";
// fetch-keys
import { PROJECT_ISSUES_LIST } from "constants/fetch-keys";
type FormInput = {
blocked_issue_ids: string[];
};
type Props = { type Props = {
issueId?: string;
submitChanges: (formData: Partial<IIssue>) => void; submitChanges: (formData: Partial<IIssue>) => void;
issuesList: IIssue[];
watch: UseFormWatch<IIssue>; watch: UseFormWatch<IIssue>;
userAuth: UserAuth; userAuth: UserAuth;
}; };
export const SidebarBlockedSelect: React.FC<Props> = ({ export const SidebarBlockedSelect: React.FC<Props> = ({
issueId,
submitChanges, submitChanges,
issuesList,
watch, watch,
userAuth, userAuth,
}) => { }) => {
const [query, setQuery] = useState("");
const [isBlockedModalOpen, setIsBlockedModalOpen] = useState(false); const [isBlockedModalOpen, setIsBlockedModalOpen] = useState(false);
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();
const { projectDetails } = useProjectDetails();
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId } = router.query; const { workspaceSlug, projectId } = router.query;
const { data: issues } = useSWR(
workspaceSlug && projectId
? PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string)
: null,
workspaceSlug && projectId
? () => issuesService.getIssues(workspaceSlug as string, projectId as string)
: null
);
const {
handleSubmit,
reset,
watch: watchBlocked,
setValue,
} = useForm<FormInput>({
defaultValues: {
blocked_issue_ids: [],
},
});
const handleClose = () => { const handleClose = () => {
setIsBlockedModalOpen(false); setIsBlockedModalOpen(false);
reset();
}; };
const onSubmit: SubmitHandler<FormInput> = (data) => { const onSubmit = async (data: ISearchIssueResponse[]) => {
if (!data.blocked_issue_ids || data.blocked_issue_ids.length === 0) { if (data.length === 0) {
setToastAlert({ setToastAlert({
title: "Error", title: "Error",
type: "error", type: "error",
message: "Please select at least one issue", message: "Please select at least one issue",
}); });
return; return;
} }
if (!Array.isArray(data.blocked_issue_ids)) data.blocked_issue_ids = [data.blocked_issue_ids]; const selectedIssues: BlockeIssue[] = data.map((i) => ({
blocked_issue_detail: {
id: i.id,
name: i.name,
sequence_id: i.sequence_id,
},
}));
const newBlocked = [...watch("blocked_list"), ...data.blocked_issue_ids]; const newBlocked = [...watch("blocked_issues"), ...selectedIssues];
submitChanges({ blocks_list: newBlocked });
submitChanges({
blocked_issues: newBlocked,
blocks_list: newBlocked.map((i) => i.blocked_issue_detail?.id ?? ""),
});
handleClose(); handleClose();
}; };
const filteredIssues: IIssue[] =
query === ""
? issuesList
: issuesList.filter(
(issue) =>
issue.name.toLowerCase().includes(query.toLowerCase()) ||
`${issue.project_detail.identifier}-${issue.sequence_id}`
.toLowerCase()
.includes(query.toLowerCase())
);
const isNotAllowed = userAuth.isGuest || userAuth.isViewer; const isNotAllowed = userAuth.isGuest || userAuth.isViewer;
return ( return (
<>
<ExistingIssuesListModal
isOpen={isBlockedModalOpen}
handleClose={() => setIsBlockedModalOpen(false)}
searchParams={{ blocker_blocked_by: true, issue_id: issueId }}
handleOnSubmit={onSubmit}
/>
<div className="flex flex-wrap items-start py-2"> <div className="flex flex-wrap items-start py-2">
<div className="flex items-center gap-x-2 text-sm text-brand-secondary sm:basis-1/2"> <div className="flex items-center gap-x-2 text-sm text-brand-secondary sm:basis-1/2">
<BlockedIcon height={16} width={16} /> <BlockedIcon height={16} width={16} />
@ -111,33 +86,31 @@ export const SidebarBlockedSelect: React.FC<Props> = ({
</div> </div>
<div className="space-y-1 sm:basis-1/2"> <div className="space-y-1 sm:basis-1/2">
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{watch("blocked_list") && watch("blocked_list").length > 0 {watch("blocked_issues") && watch("blocked_issues").length > 0
? watch("blocked_list").map((issue) => ( ? watch("blocked_issues").map((issue) => (
<div <div
key={issue} key={issue.blocked_issue_detail?.id}
className="group flex cursor-pointer items-center gap-1 rounded-2xl border border-brand-base px-1.5 py-0.5 text-xs text-red-500 duration-300 hover:border-red-500/20 hover:bg-red-500/20" className="group flex cursor-pointer items-center gap-1 rounded-2xl border border-brand-base px-1.5 py-0.5 text-xs text-red-500 duration-300 hover:border-red-500/20 hover:bg-red-500/20"
> >
<Link <Link
href={`/${workspaceSlug}/projects/${projectId}/issues/${ href={`/${workspaceSlug}/projects/${projectId}/issues/${issue.blocked_issue_detail?.id}`}
issues?.find((i) => i.id === issue)?.id
}`}
> >
<a className="flex items-center gap-1"> <a className="flex items-center gap-1">
<BlockedIcon height={10} width={10} /> <BlockedIcon height={10} width={10} />
{`${issues?.find((i) => i.id === issue)?.project_detail?.identifier}-${ {`${projectDetails?.identifier}-${issue.blocked_issue_detail?.sequence_id}`}
issues?.find((i) => i.id === issue)?.sequence_id
}`}
</a> </a>
</Link> </Link>
<button <button
type="button" type="button"
className="opacity-0 duration-300 group-hover:opacity-100" className="opacity-0 duration-300 group-hover:opacity-100"
onClick={() => { onClick={() => {
const updatedBlocked: string[] = watch("blocked_list").filter( const updatedBlocked = watch("blocked_issues").filter(
(i) => i !== issue (i) => i.blocked_issue_detail?.id !== issue.blocked_issue_detail?.id
); );
submitChanges({ submitChanges({
blocks_list: updatedBlocked, blocked_issues: updatedBlocked,
blocks_list: updatedBlocked.map((i) => i.blocked_issue_detail?.id ?? ""),
}); });
}} }}
> >
@ -147,144 +120,6 @@ export const SidebarBlockedSelect: React.FC<Props> = ({
)) ))
: null} : null}
</div> </div>
<Transition.Root
show={isBlockedModalOpen}
as={React.Fragment}
afterLeave={() => setQuery("")}
appear
>
<Dialog as="div" className="relative z-20" onClose={handleClose}>
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-brand-backdrop bg-opacity-50 transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-20 overflow-y-auto p-4 sm:p-6 md:p-20">
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<Dialog.Panel className="relative mx-auto max-w-2xl transform rounded-xl border border-brand-base bg-brand-base shadow-2xl transition-all">
<form>
<Combobox
onChange={(val: string) => {
const selectedIssues = watchBlocked("blocked_issue_ids");
if (selectedIssues.includes(val))
setValue(
"blocked_issue_ids",
selectedIssues.filter((i) => i !== val)
);
else setValue("blocked_issue_ids", [...selectedIssues, val]);
}}
>
<div className="relative m-1">
<MagnifyingGlassIcon
className="pointer-events-none absolute top-3.5 left-4 h-5 w-5 text-brand-base text-opacity-40"
aria-hidden="true"
/>
<input
type="text"
className="h-12 w-full border-0 bg-transparent pl-11 pr-4 text-brand-base placeholder-gray-500 outline-none focus:ring-0 sm:text-sm"
placeholder="Search..."
onChange={(e) => setQuery(e.target.value)}
/>
</div>
<Combobox.Options
static
className="max-h-80 scroll-py-2 divide-y divide-brand-base overflow-y-auto"
>
{filteredIssues.length > 0 ? (
<li className="p-2">
{query === "" && (
<h2 className="mt-4 mb-2 px-3 text-xs font-semibold text-brand-base">
Select blocked issues
</h2>
)}
<ul className="text-sm text-brand-base">
{filteredIssues.map((issue) => {
if (
!watch("blocked_list").includes(issue.id) &&
!watch("blockers_list").includes(issue.id)
) {
return (
<Combobox.Option
key={issue.id}
as="div"
value={issue.id}
className={({ active }) =>
`flex w-full cursor-pointer select-none items-center gap-2 rounded-md px-3 py-2 text-brand-secondary ${
active ? "bg-brand-surface-2 text-brand-base" : ""
}`
}
>
<div className="flex items-center gap-2">
<input
type="checkbox"
checked={watchBlocked("blocked_issue_ids").includes(
issue.id
)}
readOnly
/>
<span
className="block h-1.5 w-1.5 flex-shrink-0 rounded-full"
style={{
backgroundColor: issue.state_detail.color,
}}
/>
<span className="flex-shrink-0 text-xs text-brand-secondary">
{
issues?.find((i) => i.id === issue.id)?.project_detail
?.identifier
}
-{issue.sequence_id}
</span>
<span>{issue.name}</span>
</div>
</Combobox.Option>
);
}
})}
</ul>
</li>
) : (
<div className="flex flex-col items-center justify-center gap-4 px-3 py-8 text-center">
<LayerDiagonalIcon height="56" width="56" />
<h3 className="text-sm text-brand-secondary">
No issues found. Create a new issue with{" "}
<pre className="inline rounded bg-brand-surface-2 px-2 py-1">C</pre>.
</h3>
</div>
)}
</Combobox.Options>
</Combobox>
{filteredIssues.length > 0 && (
<div className="flex items-center justify-end gap-2 p-3">
<SecondaryButton onClick={handleClose}>Cancel</SecondaryButton>
<PrimaryButton onClick={handleSubmit(onSubmit)}>
Add selected issues
</PrimaryButton>
</div>
)}
</form>
</Dialog.Panel>
</Transition.Child>
</div>
</Dialog>
</Transition.Root>
<button <button
type="button" type="button"
className={`flex w-full text-brand-secondary ${ className={`flex w-full text-brand-secondary ${
@ -297,5 +132,6 @@ export const SidebarBlockedSelect: React.FC<Props> = ({
</button> </button>
</div> </div>
</div> </div>
</>
); );
}; };

View File

@ -3,107 +3,82 @@ import React, { useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import useSWR from "swr";
// react-hook-form // react-hook-form
import { SubmitHandler, useForm, UseFormWatch } from "react-hook-form"; import { UseFormWatch } from "react-hook-form";
// headless ui
import { Combobox, Dialog, Transition } from "@headlessui/react";
// hooks // hooks
import useToast from "hooks/use-toast"; import useToast from "hooks/use-toast";
// services import useProjectDetails from "hooks/use-project-details";
import issuesServices from "services/issues.service"; // components
// ui import { ExistingIssuesListModal } from "components/core";
import { PrimaryButton, SecondaryButton } from "components/ui";
// icons // icons
import { MagnifyingGlassIcon, XMarkIcon } from "@heroicons/react/24/outline"; import { XMarkIcon } from "@heroicons/react/24/outline";
import { BlockerIcon, LayerDiagonalIcon } from "components/icons"; import { BlockerIcon } from "components/icons";
// types // types
import { IIssue, UserAuth } from "types"; import { BlockeIssue, IIssue, ISearchIssueResponse, UserAuth } from "types";
// fetch-keys
import { PROJECT_ISSUES_LIST } from "constants/fetch-keys";
type FormInput = {
blocker_issue_ids: string[];
};
type Props = { type Props = {
issueId?: string;
submitChanges: (formData: Partial<IIssue>) => void; submitChanges: (formData: Partial<IIssue>) => void;
issuesList: IIssue[];
watch: UseFormWatch<IIssue>; watch: UseFormWatch<IIssue>;
userAuth: UserAuth; userAuth: UserAuth;
}; };
export const SidebarBlockerSelect: React.FC<Props> = ({ export const SidebarBlockerSelect: React.FC<Props> = ({
issueId,
submitChanges, submitChanges,
issuesList,
watch, watch,
userAuth, userAuth,
}) => { }) => {
const [query, setQuery] = useState("");
const [isBlockerModalOpen, setIsBlockerModalOpen] = useState(false); const [isBlockerModalOpen, setIsBlockerModalOpen] = useState(false);
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();
const { projectDetails } = useProjectDetails();
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId } = router.query; const { workspaceSlug, projectId } = router.query;
const { data: issues } = useSWR(
workspaceSlug && projectId
? PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string)
: null,
workspaceSlug && projectId
? () => issuesServices.getIssues(workspaceSlug as string, projectId as string)
: null
);
const {
handleSubmit,
reset,
watch: watchBlocker,
setValue,
} = useForm<FormInput>({
defaultValues: {
blocker_issue_ids: [],
},
});
const handleClose = () => { const handleClose = () => {
setIsBlockerModalOpen(false); setIsBlockerModalOpen(false);
reset();
}; };
const onSubmit: SubmitHandler<FormInput> = (data) => { const onSubmit = async (data: ISearchIssueResponse[]) => {
if (!data.blocker_issue_ids || data.blocker_issue_ids.length === 0) { if (data.length === 0) {
setToastAlert({ setToastAlert({
title: "Error",
type: "error", type: "error",
message: "Please select atleast one issue", title: "Error!",
message: "Please select at least one issue.",
}); });
return; return;
} }
if (!Array.isArray(data.blocker_issue_ids)) data.blocker_issue_ids = [data.blocker_issue_ids]; const selectedIssues: BlockeIssue[] = data.map((i) => ({
blocker_issue_detail: {
id: i.id,
name: i.name,
sequence_id: i.sequence_id,
},
}));
const newBlockers = [...watch("blockers_list"), ...data.blocker_issue_ids]; const newBlockers = [...watch("blocker_issues"), ...selectedIssues];
submitChanges({ blockers_list: newBlockers });
submitChanges({
blocker_issues: newBlockers,
blockers_list: newBlockers.map((i) => i.blocker_issue_detail?.id ?? ""),
});
handleClose(); handleClose();
}; };
const filteredIssues: IIssue[] =
query === ""
? issuesList
: issuesList.filter(
(issue) =>
issue.name.toLowerCase().includes(query.toLowerCase()) ||
`${issue.project_detail.identifier}-${issue.sequence_id}`
.toLowerCase()
.includes(query.toLowerCase())
);
const isNotAllowed = userAuth.isGuest || userAuth.isViewer; const isNotAllowed = userAuth.isGuest || userAuth.isViewer;
return ( return (
<>
<ExistingIssuesListModal
isOpen={isBlockerModalOpen}
handleClose={() => setIsBlockerModalOpen(false)}
searchParams={{ blocker_blocked_by: true, issue_id: issueId }}
handleOnSubmit={onSubmit}
/>
<div className="flex flex-wrap items-start py-2"> <div className="flex flex-wrap items-start py-2">
<div className="flex items-center gap-x-2 text-sm text-brand-secondary sm:basis-1/2"> <div className="flex items-center gap-x-2 text-sm text-brand-secondary sm:basis-1/2">
<BlockerIcon height={16} width={16} /> <BlockerIcon height={16} width={16} />
@ -111,33 +86,33 @@ export const SidebarBlockerSelect: React.FC<Props> = ({
</div> </div>
<div className="space-y-1 sm:basis-1/2"> <div className="space-y-1 sm:basis-1/2">
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{watch("blockers_list") && watch("blockers_list").length > 0 {watch("blocker_issues") && watch("blocker_issues").length > 0
? watch("blockers_list").map((issue) => ( ? watch("blocker_issues").map((issue) => (
<div <div
key={issue} key={issue.blocker_issue_detail?.id}
className="group flex cursor-pointer items-center gap-1 rounded-2xl border border-brand-base px-1.5 py-0.5 text-xs text-yellow-500 duration-300 hover:border-yellow-500/20 hover:bg-yellow-500/20" className="group flex cursor-pointer items-center gap-1 rounded-2xl border border-brand-base px-1.5 py-0.5 text-xs text-yellow-500 duration-300 hover:border-yellow-500/20 hover:bg-yellow-500/20"
> >
<Link <Link
href={`/${workspaceSlug}/projects/${projectId}/issues/${ href={`/${workspaceSlug}/projects/${projectId}/issues/${issue.blocker_issue_detail?.id}`}
issues?.find((i) => i.id === issue)?.id
}`}
> >
<a className="flex items-center gap-1"> <a className="flex items-center gap-1">
<BlockerIcon height={10} width={10} /> <BlockerIcon height={10} width={10} />
{`${issues?.find((i) => i.id === issue)?.project_detail?.identifier}-${ {`${projectDetails?.identifier}-${issue.blocker_issue_detail?.sequence_id}`}
issues?.find((i) => i.id === issue)?.sequence_id
}`}
</a> </a>
</Link> </Link>
<button <button
type="button" type="button"
className="opacity-0 duration-300 group-hover:opacity-100" className="opacity-0 duration-300 group-hover:opacity-100"
onClick={() => { onClick={() => {
const updatedBlockers: string[] = watch("blockers_list").filter( const updatedBlockers = watch("blocker_issues").filter(
(i) => i !== issue (i) => i.blocker_issue_detail?.id !== issue.blocker_issue_detail?.id
); );
submitChanges({ submitChanges({
blockers_list: updatedBlockers, blocker_issues: updatedBlockers,
blockers_list: updatedBlockers.map(
(i) => i.blocker_issue_detail?.id ?? ""
),
}); });
}} }}
> >
@ -147,141 +122,6 @@ export const SidebarBlockerSelect: React.FC<Props> = ({
)) ))
: null} : null}
</div> </div>
<Transition.Root
show={isBlockerModalOpen}
as={React.Fragment}
afterLeave={() => setQuery("")}
appear
>
<Dialog as="div" className="relative z-20" onClose={handleClose}>
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-brand-backdrop bg-opacity-50 transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-20 overflow-y-auto p-4 sm:p-6 md:p-20">
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<Dialog.Panel className="relative mx-auto max-w-2xl transform rounded-xl border border-brand-base bg-brand-base shadow-2xl transition-all">
<Combobox
onChange={(val: string) => {
const selectedIssues = watchBlocker("blocker_issue_ids");
if (selectedIssues.includes(val))
setValue(
"blocker_issue_ids",
selectedIssues.filter((i) => i !== val)
);
else setValue("blocker_issue_ids", [...selectedIssues, val]);
}}
>
<div className="relative m-1">
<MagnifyingGlassIcon
className="pointer-events-none absolute top-3.5 left-4 h-5 w-5 text-brand-base text-opacity-40"
aria-hidden="true"
/>
<input
type="text"
className="h-12 w-full border-0 bg-transparent pl-11 pr-4 text-brand-base placeholder-gray-500 outline-none focus:ring-0 sm:text-sm"
placeholder="Search..."
onChange={(e) => setQuery(e.target.value)}
/>
</div>
<Combobox.Options
static
className="max-h-80 scroll-py-2 divide-y divide-brand-base overflow-y-auto"
>
{filteredIssues.length > 0 ? (
<li className="p-2">
{query === "" && (
<h2 className="mt-4 mb-2 px-3 text-xs font-semibold text-brand-base">
Select blocker issues
</h2>
)}
<ul className="text-sm text-brand-base">
{filteredIssues.map((issue) => {
if (
!watch("blockers_list").includes(issue.id) &&
!watch("blocked_list").includes(issue.id)
)
return (
<Combobox.Option
key={issue.id}
as="div"
value={issue.id}
className={({ active }) =>
`flex w-full cursor-pointer select-none items-center gap-2 rounded-md px-3 py-2 text-brand-secondary ${
active ? "bg-brand-surface-2 text-brand-base" : ""
} `
}
>
<div className="flex items-center gap-2">
<input
type="checkbox"
checked={watchBlocker("blocker_issue_ids").includes(
issue.id
)}
readOnly
/>
<span
className="block h-1.5 w-1.5 flex-shrink-0 rounded-full"
style={{
backgroundColor: issue.state_detail.color,
}}
/>
<span className="flex-shrink-0 text-xs text-brand-secondary">
{
issues?.find((i) => i.id === issue.id)?.project_detail
?.identifier
}
-{issue.sequence_id}
</span>
<span className="text-brand-muted-1">{issue.name}</span>
</div>
</Combobox.Option>
);
})}
</ul>
</li>
) : (
<div className="flex flex-col items-center justify-center gap-4 px-3 py-8 text-center">
<LayerDiagonalIcon height="56" width="56" />
<h3 className="text-sm text-brand-secondary">
No issues found. Create a new issue with{" "}
<pre className="inline rounded bg-brand-surface-2 px-2 py-1">C</pre>.
</h3>
</div>
)}
</Combobox.Options>
</Combobox>
{filteredIssues.length > 0 && (
<div className="flex items-center justify-end gap-2 p-3">
<SecondaryButton onClick={handleClose}>Cancel</SecondaryButton>
<PrimaryButton onClick={handleSubmit(onSubmit)}>
Add selected issues
</PrimaryButton>
</div>
)}
</Dialog.Panel>
</Transition.Child>
</div>
</Dialog>
</Transition.Root>
<button <button
type="button" type="button"
className={`flex w-full text-brand-secondary ${ className={`flex w-full text-brand-secondary ${
@ -294,5 +134,6 @@ export const SidebarBlockerSelect: React.FC<Props> = ({
</button> </button>
</div> </div>
</div> </div>
</>
); );
}; };

View File

@ -20,7 +20,6 @@ import { PROJECT_ISSUES_LIST } from "constants/fetch-keys";
type Props = { type Props = {
control: Control<IIssue, any>; control: Control<IIssue, any>;
submitChanges: (formData: Partial<IIssue>) => void; submitChanges: (formData: Partial<IIssue>) => void;
issuesList: IIssue[];
customDisplay: JSX.Element; customDisplay: JSX.Element;
watch: UseFormWatch<IIssue>; watch: UseFormWatch<IIssue>;
userAuth: UserAuth; userAuth: UserAuth;
@ -29,7 +28,6 @@ type Props = {
export const SidebarParentSelect: React.FC<Props> = ({ export const SidebarParentSelect: React.FC<Props> = ({
control, control,
submitChanges, submitChanges,
issuesList,
customDisplay, customDisplay,
watch, watch,
userAuth, userAuth,
@ -37,7 +35,7 @@ export const SidebarParentSelect: React.FC<Props> = ({
const [isParentModalOpen, setIsParentModalOpen] = useState(false); const [isParentModalOpen, setIsParentModalOpen] = useState(false);
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId } = router.query; const { workspaceSlug, projectId, issueId } = router.query;
const { data: issues } = useSWR( const { data: issues } = useSWR(
workspaceSlug && projectId workspaceSlug && projectId
@ -68,8 +66,7 @@ export const SidebarParentSelect: React.FC<Props> = ({
submitChanges({ parent: val }); submitChanges({ parent: val });
onChange(val); onChange(val);
}} }}
issues={issuesList} issueId={issueId as string}
title="Select Parent"
value={value} value={value}
customDisplay={customDisplay} customDisplay={customDisplay}
/> />

View File

@ -27,7 +27,7 @@ type Props = {
export const SidebarStateSelect: React.FC<Props> = ({ value, onChange, userAuth }) => { export const SidebarStateSelect: React.FC<Props> = ({ value, onChange, userAuth }) => {
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId } = router.query; const { workspaceSlug, projectId, inboxIssueId } = router.query;
const { data: stateGroups } = useSWR( const { data: stateGroups } = useSWR(
workspaceSlug && projectId ? STATES_LIST(projectId as string) : null, workspaceSlug && projectId ? STATES_LIST(projectId as string) : null,
@ -50,6 +50,7 @@ export const SidebarStateSelect: React.FC<Props> = ({ value, onChange, userAuth
<div className="sm:basis-1/2"> <div className="sm:basis-1/2">
<CustomSelect <CustomSelect
label={ label={
selectedState ? (
<div className="flex items-center gap-2 text-left text-brand-base"> <div className="flex items-center gap-2 text-left text-brand-base">
{getStateGroupIcon( {getStateGroupIcon(
selectedState?.group ?? "backlog", selectedState?.group ?? "backlog",
@ -59,6 +60,14 @@ export const SidebarStateSelect: React.FC<Props> = ({ value, onChange, userAuth
)} )}
{addSpaceIfCamelCase(selectedState?.name ?? "")} {addSpaceIfCamelCase(selectedState?.name ?? "")}
</div> </div>
) : inboxIssueId ? (
<div className="flex items-center gap-2 text-left text-brand-base">
{getStateGroupIcon("backlog", "16", "16", "#ff7700")}
Triage
</div>
) : (
"None"
)
} }
value={value} value={value}
onChange={onChange} onChange={onChange}

View File

@ -370,14 +370,6 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
<SidebarParentSelect <SidebarParentSelect
control={control} control={control}
submitChanges={submitChanges} submitChanges={submitChanges}
issuesList={
issues?.filter(
(i) =>
i.id !== issueDetail?.id &&
i.id !== issueDetail?.parent &&
i.parent !== issueDetail?.id
) ?? []
}
customDisplay={ customDisplay={
issueDetail?.parent_detail ? ( issueDetail?.parent_detail ? (
<button <button
@ -385,11 +377,12 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
className="flex items-center gap-2 rounded bg-brand-surface-2 px-3 py-2 text-xs" className="flex items-center gap-2 rounded bg-brand-surface-2 px-3 py-2 text-xs"
onClick={() => submitChanges({ parent: null })} onClick={() => submitChanges({ parent: null })}
> >
<span className="text-brand-secondary">Selected:</span>{" "}
{issueDetail.parent_detail?.name} {issueDetail.parent_detail?.name}
<XMarkIcon className="h-3 w-3" /> <XMarkIcon className="h-3 w-3" />
</button> </button>
) : ( ) : (
<div className="inline-block rounded bg-brand-surface-1 px-3 py-2 text-xs"> <div className="inline-block rounded bg-brand-surface-1 px-3 py-2 text-xs text-brand-secondary">
No parent selected No parent selected
</div> </div>
) )
@ -400,16 +393,16 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
)} )}
{(fieldsToShow.includes("all") || fieldsToShow.includes("blocker")) && ( {(fieldsToShow.includes("all") || fieldsToShow.includes("blocker")) && (
<SidebarBlockerSelect <SidebarBlockerSelect
issueId={issueId as string}
submitChanges={submitChanges} submitChanges={submitChanges}
issuesList={issues?.filter((i) => i.id !== issueDetail?.id) ?? []}
watch={watchIssue} watch={watchIssue}
userAuth={memberRole} userAuth={memberRole}
/> />
)} )}
{(fieldsToShow.includes("all") || fieldsToShow.includes("blocked")) && ( {(fieldsToShow.includes("all") || fieldsToShow.includes("blocked")) && (
<SidebarBlockedSelect <SidebarBlockedSelect
issueId={issueId as string}
submitChanges={submitChanges} submitChanges={submitChanges}
issuesList={issues?.filter((i) => i.id !== issueDetail?.id) ?? []}
watch={watchIssue} watch={watchIssue}
userAuth={memberRole} userAuth={memberRole}
/> />

View File

@ -21,7 +21,7 @@ import { ChevronRightIcon, PlusIcon, XMarkIcon } from "@heroicons/react/24/outli
// helpers // helpers
import { orderArrayBy } from "helpers/array.helper"; import { orderArrayBy } from "helpers/array.helper";
// types // types
import { ICurrentUserResponse, IIssue, ISubIssueResponse } from "types"; import { ICurrentUserResponse, IIssue, ISearchIssueResponse, ISubIssueResponse } from "types";
// fetch-keys // fetch-keys
import { PROJECT_ISSUES_LIST, SUB_ISSUES } from "constants/fetch-keys"; import { PROJECT_ISSUES_LIST, SUB_ISSUES } from "constants/fetch-keys";
@ -58,14 +58,16 @@ export const SubIssuesList: FC<Props> = ({ parentIssue, user }) => {
: null : null
); );
const addAsSubIssue = async (data: { issues: string[] }) => { const addAsSubIssue = async (data: ISearchIssueResponse[]) => {
if (!workspaceSlug || !projectId) return; if (!workspaceSlug || !projectId) return;
const payload = {
sub_issue_ids: data.map((i) => i.id),
};
await issuesService await issuesService
.addSubIssues(workspaceSlug as string, projectId as string, parentIssue?.id ?? "", { .addSubIssues(workspaceSlug as string, projectId as string, parentIssue?.id ?? "", payload)
sub_issue_ids: data.issues, .then(() => {
})
.then((res) => {
mutate<ISubIssueResponse>( mutate<ISubIssueResponse>(
SUB_ISSUES(parentIssue?.id ?? ""), SUB_ISSUES(parentIssue?.id ?? ""),
(prevData) => { (prevData) => {
@ -74,10 +76,12 @@ export const SubIssuesList: FC<Props> = ({ parentIssue, user }) => {
const stateDistribution = { ...prevData.state_distribution }; const stateDistribution = { ...prevData.state_distribution };
data.issues.forEach((issueId: string) => { payload.sub_issue_ids.forEach((issueId: string) => {
const issue = issues?.find((i) => i.id === issueId); const issue = issues?.find((i) => i.id === issueId);
if (issue) { if (issue) {
newSubIssues.push(issue); newSubIssues.push(issue);
const issueGroup = issue.state_detail.group; const issueGroup = issue.state_detail.group;
stateDistribution[issueGroup] = stateDistribution[issueGroup] + 1; stateDistribution[issueGroup] = stateDistribution[issueGroup] + 1;
} }
@ -96,7 +100,7 @@ export const SubIssuesList: FC<Props> = ({ parentIssue, user }) => {
PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string), PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string),
(prevData) => (prevData) =>
(prevData ?? []).map((p) => { (prevData ?? []).map((p) => {
if (data.issues.includes(p.id)) if (payload.sub_issue_ids.includes(p.id))
return { return {
...p, ...p,
parent: parentIssue.id, parent: parentIssue.id,
@ -188,14 +192,7 @@ export const SubIssuesList: FC<Props> = ({ parentIssue, user }) => {
<ExistingIssuesListModal <ExistingIssuesListModal
isOpen={subIssuesListModal} isOpen={subIssuesListModal}
handleClose={() => setSubIssuesListModal(false)} handleClose={() => setSubIssuesListModal(false)}
issues={ searchParams={{ sub_issue: true, issue_id: parentIssue?.id }}
issues?.filter(
(i) =>
(i.parent === "" || i.parent === null) &&
i.id !== parentIssue?.id &&
i.id !== parentIssue?.parent
) ?? []
}
handleOnSubmit={addAsSubIssue} handleOnSubmit={addAsSubIssue}
/> />
{subIssuesResponse && {subIssuesResponse &&
@ -285,7 +282,7 @@ export const SubIssuesList: FC<Props> = ({ parentIssue, user }) => {
<span className="flex-shrink-0 text-brand-secondary"> <span className="flex-shrink-0 text-brand-secondary">
{issue.project_detail.identifier}-{issue.sequence_id} {issue.project_detail.identifier}-{issue.sequence_id}
</span> </span>
<span className="max-w-sm break-all font-medium">{issue.name}</span> <span className="max-w-sm break-words font-medium">{issue.name}</span>
</div> </div>
{!isNotAllowed && ( {!isNotAllowed && (

View File

@ -18,10 +18,11 @@ import { PROJECT_MEMBERS } from "constants/fetch-keys";
type Props = { type Props = {
issue: IIssue; issue: IIssue;
partialUpdateIssue: (formData: Partial<IIssue>, issueId: string) => void; partialUpdateIssue: (formData: Partial<IIssue>, issue: IIssue) => void;
position?: "left" | "right"; position?: "left" | "right";
tooltipPosition?: "top" | "bottom";
selfPositioned?: boolean; selfPositioned?: boolean;
tooltipPosition?: "left" | "right"; customButton?: boolean;
user: ICurrentUserResponse | undefined; user: ICurrentUserResponse | undefined;
isNotAllowed: boolean; isNotAllowed: boolean;
}; };
@ -31,9 +32,10 @@ export const ViewAssigneeSelect: React.FC<Props> = ({
partialUpdateIssue, partialUpdateIssue,
position = "left", position = "left",
selfPositioned = false, selfPositioned = false,
tooltipPosition = "right", tooltipPosition = "top",
user, user,
isNotAllowed, isNotAllowed,
customButton = false,
}) => { }) => {
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId } = router.query; const { workspaceSlug, projectId } = router.query;
@ -65,34 +67,9 @@ export const ViewAssigneeSelect: React.FC<Props> = ({
), ),
})); }));
return ( const assigneeLabel = (
<CustomSearchSelect
value={issue.assignees}
onChange={(data: any) => {
const newData = issue.assignees ?? [];
if (newData.includes(data)) newData.splice(newData.indexOf(data), 1);
else newData.push(data);
partialUpdateIssue({ assignees_list: data }, issue.id);
trackEventServices.trackIssuePartialPropertyUpdateEvent(
{
workspaceSlug,
workspaceId: issue.workspace,
projectId: issue.project_detail.id,
projectIdentifier: issue.project_detail.identifier,
projectName: issue.project_detail.name,
issueId: issue.id,
},
"ISSUE_PROPERTY_UPDATE_ASSIGNEE",
user
);
}}
options={options}
label={
<Tooltip <Tooltip
position={`top-${tooltipPosition}`} position={tooltipPosition}
tooltipHeading="Assignees" tooltipHeading="Assignees"
tooltipContent={ tooltipContent={
issue.assignee_details.length > 0 issue.assignee_details.length > 0
@ -120,7 +97,34 @@ export const ViewAssigneeSelect: React.FC<Props> = ({
)} )}
</div> </div>
</Tooltip> </Tooltip>
} );
return (
<CustomSearchSelect
value={issue.assignees}
onChange={(data: any) => {
const newData = issue.assignees ?? [];
if (newData.includes(data)) newData.splice(newData.indexOf(data), 1);
else newData.push(data);
partialUpdateIssue({ assignees_list: data }, issue);
trackEventServices.trackIssuePartialPropertyUpdateEvent(
{
workspaceSlug,
workspaceId: issue.workspace,
projectId: issue.project_detail.id,
projectIdentifier: issue.project_detail.identifier,
projectName: issue.project_detail.name,
issueId: issue.id,
},
"ISSUE_PROPERTY_UPDATE_ASSIGNEE",
user
);
}}
options={options}
{...(customButton ? { customButton: assigneeLabel } : { label: assigneeLabel })}
multiple multiple
noChevron noChevron
position={position} position={position}

View File

@ -11,7 +11,9 @@ import { ICurrentUserResponse, IIssue } from "types";
type Props = { type Props = {
issue: IIssue; issue: IIssue;
partialUpdateIssue: (formData: Partial<IIssue>, issueId: string) => void; partialUpdateIssue: (formData: Partial<IIssue>, issue: IIssue) => void;
tooltipPosition?: "top" | "bottom";
noBorder?: boolean;
user: ICurrentUserResponse | undefined; user: ICurrentUserResponse | undefined;
isNotAllowed: boolean; isNotAllowed: boolean;
}; };
@ -19,6 +21,8 @@ type Props = {
export const ViewDueDateSelect: React.FC<Props> = ({ export const ViewDueDateSelect: React.FC<Props> = ({
issue, issue,
partialUpdateIssue, partialUpdateIssue,
tooltipPosition = "top",
noBorder = false,
user, user,
isNotAllowed, isNotAllowed,
}) => { }) => {
@ -26,7 +30,11 @@ export const ViewDueDateSelect: React.FC<Props> = ({
const { workspaceSlug } = router.query; const { workspaceSlug } = router.query;
return ( return (
<Tooltip tooltipHeading="Due Date" tooltipContent={issue.target_date ?? "N/A"}> <Tooltip
tooltipHeading="Due Date"
tooltipContent={issue.target_date ?? "N/A"}
position={tooltipPosition}
>
<div <div
className={`group relative max-w-[6.5rem] ${ className={`group relative max-w-[6.5rem] ${
issue.target_date === null issue.target_date === null
@ -46,7 +54,7 @@ export const ViewDueDateSelect: React.FC<Props> = ({
priority: issue.priority, priority: issue.priority,
state: issue.state, state: issue.state,
}, },
issue.id issue
); );
trackEventServices.trackIssuePartialPropertyUpdateEvent( trackEventServices.trackIssuePartialPropertyUpdateEvent(
{ {
@ -62,6 +70,7 @@ export const ViewDueDateSelect: React.FC<Props> = ({
); );
}} }}
className={issue?.target_date ? "w-[6.5rem]" : "w-[5rem] text-center"} className={issue?.target_date ? "w-[6.5rem]" : "w-[5rem] text-center"}
noBorder={noBorder}
disabled={isNotAllowed} disabled={isNotAllowed}
/> />
</div> </div>

View File

@ -15,9 +15,11 @@ import { ICurrentUserResponse, IIssue } from "types";
type Props = { type Props = {
issue: IIssue; issue: IIssue;
partialUpdateIssue: (formData: Partial<IIssue>, issueId: string) => void; partialUpdateIssue: (formData: Partial<IIssue>, issue: IIssue) => void;
position?: "left" | "right"; position?: "left" | "right";
tooltipPosition?: "top" | "bottom";
selfPositioned?: boolean; selfPositioned?: boolean;
customButton?: boolean;
user: ICurrentUserResponse | undefined; user: ICurrentUserResponse | undefined;
isNotAllowed: boolean; isNotAllowed: boolean;
}; };
@ -26,7 +28,9 @@ export const ViewEstimateSelect: React.FC<Props> = ({
issue, issue,
partialUpdateIssue, partialUpdateIssue,
position = "left", position = "left",
tooltipPosition = "top",
selfPositioned = false, selfPositioned = false,
customButton = false,
user, user,
isNotAllowed, isNotAllowed,
}) => { }) => {
@ -37,13 +41,22 @@ export const ViewEstimateSelect: React.FC<Props> = ({
const estimateValue = estimatePoints?.find((e) => e.key === issue.estimate_point)?.value; const estimateValue = estimatePoints?.find((e) => e.key === issue.estimate_point)?.value;
const estimateLabels = (
<Tooltip tooltipHeading="Estimate" tooltipContent={estimateValue} position={tooltipPosition}>
<div className="flex items-center gap-1 text-brand-secondary">
<PlayIcon className="h-3.5 w-3.5 -rotate-90" />
{estimateValue ?? "None"}
</div>
</Tooltip>
);
if (!isEstimateActive) return null; if (!isEstimateActive) return null;
return ( return (
<CustomSelect <CustomSelect
value={issue.estimate_point} value={issue.estimate_point}
onChange={(val: number) => { onChange={(val: number) => {
partialUpdateIssue({ estimate_point: val }, issue.id); partialUpdateIssue({ estimate_point: val }, issue);
trackEventServices.trackIssuePartialPropertyUpdateEvent( trackEventServices.trackIssuePartialPropertyUpdateEvent(
{ {
workspaceSlug, workspaceSlug,
@ -57,14 +70,7 @@ export const ViewEstimateSelect: React.FC<Props> = ({
user user
); );
}} }}
label={ {...(customButton ? { customButton: estimateLabels } : { label: estimateLabels })}
<Tooltip tooltipHeading="Estimate" tooltipContent={estimateValue}>
<div className="flex items-center gap-1 text-brand-secondary">
<PlayIcon className="h-3.5 w-3.5 -rotate-90" />
{estimateValue ?? "Estimate"}
</div>
</Tooltip>
}
maxHeight="md" maxHeight="md"
noChevron noChevron
disabled={isNotAllowed} disabled={isNotAllowed}

View File

@ -3,3 +3,4 @@ export * from "./due-date";
export * from "./estimate"; export * from "./estimate";
export * from "./priority"; export * from "./priority";
export * from "./state"; export * from "./state";
export * from "./label";

View File

@ -0,0 +1,151 @@
import React, { useState } from "react";
import { useRouter } from "next/router";
import useSWR from "swr";
// services
import issuesService from "services/issues.service";
// component
import { CreateLabelModal } from "components/labels";
// ui
import { CustomSearchSelect, Tooltip } from "components/ui";
// icons
import { PlusIcon, TagIcon } from "@heroicons/react/24/outline";
// types
import { ICurrentUserResponse, IIssue, IIssueLabels } from "types";
// fetch-keys
import { PROJECT_ISSUE_LABELS } from "constants/fetch-keys";
type Props = {
issue: IIssue;
partialUpdateIssue: (formData: Partial<IIssue>, issue: IIssue) => void;
position?: "left" | "right";
selfPositioned?: boolean;
tooltipPosition?: "top" | "bottom";
customButton?: boolean;
user: ICurrentUserResponse | undefined;
isNotAllowed: boolean;
};
export const ViewLabelSelect: React.FC<Props> = ({
issue,
partialUpdateIssue,
position = "left",
selfPositioned = false,
tooltipPosition = "top",
user,
isNotAllowed,
customButton = false,
}) => {
const [labelModal, setLabelModal] = useState(false);
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { data: issueLabels } = useSWR<IIssueLabels[]>(
projectId ? PROJECT_ISSUE_LABELS(projectId.toString()) : null,
workspaceSlug && projectId
? () => issuesService.getIssueLabels(workspaceSlug as string, projectId as string)
: null
);
const options = issueLabels?.map((label) => ({
value: label.id,
query: label.name,
content: (
<div className="flex items-center justify-start gap-2">
<span
className="h-2.5 w-2.5 flex-shrink-0 rounded-full"
style={{
backgroundColor: label.color,
}}
/>
<span>{label.name}</span>
</div>
),
}));
const labelsLabel = (
<Tooltip
position={tooltipPosition}
tooltipHeading="Labels"
tooltipContent={
issue.label_details.length > 0
? issue.label_details.map((label) => label.name ?? "").join(", ")
: "No Label"
}
>
<div
className={`flex ${
isNotAllowed ? "cursor-not-allowed" : "cursor-pointer"
} items-center gap-2 text-brand-secondary`}
>
{issue.label_details.length > 0 ? (
<>
{issue.label_details.slice(0, 4).map((label, index) => (
<div className={`flex h-4 w-4 rounded-full ${index ? "-ml-3.5" : ""}`}>
<span
className={`h-4 w-4 flex-shrink-0 rounded-full border group-hover:bg-brand-surface-2 border-brand-base
`}
style={{
backgroundColor: label?.color && label.color !== "" ? label.color : "#000000",
}}
/>
</div>
))}
{issue.label_details.length > 4 ? <span>+{issue.label_details.length - 4}</span> : null}
</>
) : (
<>
<TagIcon className="h-3.5 w-3.5 text-brand-secondary" />
</>
)}
</div>
</Tooltip>
);
const footerOption = (
<button
type="button"
className="flex w-full select-none items-center rounded py-2 px-1 hover:bg-brand-surface-2"
onClick={() => setLabelModal(true)}
>
<span className="flex items-center justify-start gap-1 text-brand-secondary">
<PlusIcon className="h-4 w-4" aria-hidden="true" />
<span>Create New Label</span>
</span>
</button>
);
const noResultIcon = <TagIcon className="h-3.5 w-3.5 text-brand-secondary" />;
return (
<>
{projectId && (
<CreateLabelModal
isOpen={labelModal}
handleClose={() => setLabelModal(false)}
projectId={projectId.toString()}
user={user}
/>
)}
<CustomSearchSelect
value={issue.labels}
onChange={(data: string[]) => {
partialUpdateIssue({ labels_list: data }, issue);
}}
options={options}
{...(customButton ? { customButton: labelsLabel } : { label: labelsLabel })}
multiple
noChevron
position={position}
disabled={isNotAllowed}
selfPositioned={selfPositioned}
footerOption={footerOption}
noResultIcon={noResultIcon}
dropdownWidth="w-full min-w-[12rem]"
/>
</>
);
};

View File

@ -12,12 +12,16 @@ import { ICurrentUserResponse, IIssue } from "types";
import { PRIORITIES } from "constants/project"; import { PRIORITIES } from "constants/project";
// services // services
import trackEventServices from "services/track-event.service"; import trackEventServices from "services/track-event.service";
// helper
import { capitalizeFirstLetter } from "helpers/string.helper";
type Props = { type Props = {
issue: IIssue; issue: IIssue;
partialUpdateIssue: (formData: Partial<IIssue>, issueId: string) => void; partialUpdateIssue: (formData: Partial<IIssue>, issue: IIssue) => void;
position?: "left" | "right"; position?: "left" | "right";
tooltipPosition?: "top" | "bottom";
selfPositioned?: boolean; selfPositioned?: boolean;
noBorder?: boolean;
user: ICurrentUserResponse | undefined; user: ICurrentUserResponse | undefined;
isNotAllowed: boolean; isNotAllowed: boolean;
}; };
@ -26,7 +30,9 @@ export const ViewPrioritySelect: React.FC<Props> = ({
issue, issue,
partialUpdateIssue, partialUpdateIssue,
position = "left", position = "left",
tooltipPosition = "top",
selfPositioned = false, selfPositioned = false,
noBorder = false,
user, user,
isNotAllowed, isNotAllowed,
}) => { }) => {
@ -37,7 +43,7 @@ export const ViewPrioritySelect: React.FC<Props> = ({
<CustomSelect <CustomSelect
value={issue.priority} value={issue.priority}
onChange={(data: string) => { onChange={(data: string) => {
partialUpdateIssue({ priority: data }, issue.id); partialUpdateIssue({ priority: data }, issue);
trackEventServices.trackIssuePartialPropertyUpdateEvent( trackEventServices.trackIssuePartialPropertyUpdateEvent(
{ {
workspaceSlug, workspaceSlug,
@ -55,10 +61,12 @@ export const ViewPrioritySelect: React.FC<Props> = ({
customButton={ customButton={
<button <button
type="button" type="button"
className={`grid h-6 w-6 place-items-center rounded border ${ className={`grid place-items-center rounded ${
isNotAllowed ? "cursor-not-allowed" : "cursor-pointer" isNotAllowed ? "cursor-not-allowed" : "cursor-pointer"
} items-center shadow-sm ${ } ${noBorder ? "" : "h-6 w-6 border shadow-sm"} ${
issue.priority === "urgent" noBorder
? ""
: issue.priority === "urgent"
? "border-red-500/20 bg-red-500/20 text-red-500" ? "border-red-500/20 bg-red-500/20 text-red-500"
: issue.priority === "high" : issue.priority === "high"
? "border-orange-500/20 bg-orange-500/20 text-orange-500" ? "border-orange-500/20 bg-orange-500/20 text-orange-500"
@ -67,14 +75,23 @@ export const ViewPrioritySelect: React.FC<Props> = ({
: issue.priority === "low" : issue.priority === "low"
? "border-green-500/20 bg-green-500/20 text-green-500" ? "border-green-500/20 bg-green-500/20 text-green-500"
: "border-brand-base" : "border-brand-base"
}`} } items-center`}
> >
<Tooltip tooltipHeading="Priority" tooltipContent={issue.priority ?? "None"}> <Tooltip
<span> tooltipHeading="Priority"
tooltipContent={issue.priority ?? "None"}
position={tooltipPosition}
>
<span className="flex gap-1 items-center text-brand-secondary text-xs">
{getPriorityIcon( {getPriorityIcon(
issue.priority && issue.priority !== "" ? issue.priority ?? "" : "None", issue.priority && issue.priority !== "" ? issue.priority ?? "" : "None",
"text-sm" "text-sm"
)} )}
{noBorder
? issue.priority && issue.priority !== ""
? capitalizeFirstLetter(issue.priority) ?? ""
: "None"
: ""}
</span> </span>
</Tooltip> </Tooltip>
</button> </button>

View File

@ -19,9 +19,11 @@ import { STATES_LIST } from "constants/fetch-keys";
type Props = { type Props = {
issue: IIssue; issue: IIssue;
partialUpdateIssue: (formData: Partial<IIssue>, issueId: string) => void; partialUpdateIssue: (formData: Partial<IIssue>, issue: IIssue) => void;
position?: "left" | "right"; position?: "left" | "right";
tooltipPosition?: "top" | "bottom";
selfPositioned?: boolean; selfPositioned?: boolean;
customButton?: boolean;
user: ICurrentUserResponse | undefined; user: ICurrentUserResponse | undefined;
isNotAllowed: boolean; isNotAllowed: boolean;
}; };
@ -30,7 +32,9 @@ export const ViewStateSelect: React.FC<Props> = ({
issue, issue,
partialUpdateIssue, partialUpdateIssue,
position = "left", position = "left",
tooltipPosition = "top",
selfPositioned = false, selfPositioned = false,
customButton = false,
user, user,
isNotAllowed, isNotAllowed,
}) => { }) => {
@ -58,6 +62,20 @@ export const ViewStateSelect: React.FC<Props> = ({
const selectedOption = states?.find((s) => s.id === issue.state); const selectedOption = states?.find((s) => s.id === issue.state);
const stateLabel = (
<Tooltip
tooltipHeading="State"
tooltipContent={addSpaceIfCamelCase(selectedOption?.name ?? "")}
position={tooltipPosition}
>
<div className="flex items-center cursor-pointer gap-2 text-brand-secondary">
{selectedOption &&
getStateGroupIcon(selectedOption.group, "16", "16", selectedOption.color)}
{selectedOption?.name ?? "State"}
</div>
</Tooltip>
);
return ( return (
<CustomSearchSelect <CustomSearchSelect
value={issue.state} value={issue.state}
@ -68,7 +86,7 @@ export const ViewStateSelect: React.FC<Props> = ({
priority: issue.priority, priority: issue.priority,
target_date: issue.target_date, target_date: issue.target_date,
}, },
issue.id issue
); );
trackEventServices.trackIssuePartialPropertyUpdateEvent( trackEventServices.trackIssuePartialPropertyUpdateEvent(
{ {
@ -101,18 +119,7 @@ export const ViewStateSelect: React.FC<Props> = ({
} }
}} }}
options={options} options={options}
label={ {...(customButton ? { customButton: stateLabel } : { label: stateLabel })}
<Tooltip
tooltipHeading="State"
tooltipContent={addSpaceIfCamelCase(selectedOption?.name ?? "")}
>
<div className="flex items-center gap-2 text-brand-secondary">
{selectedOption &&
getStateGroupIcon(selectedOption.group, "16", "16", selectedOption.color)}
{selectedOption?.name ?? "State"}
</div>
</Tooltip>
}
position={position} position={position}
disabled={isNotAllowed} disabled={isNotAllowed}
noChevron noChevron

View File

@ -111,7 +111,7 @@ export const DeleteModuleModal: React.FC<Props> = ({ isOpen, setIsOpen, data, us
<div className="mt-2"> <div className="mt-2">
<p className="text-sm text-brand-secondary"> <p className="text-sm text-brand-secondary">
Are you sure you want to delete module-{" "} Are you sure you want to delete module-{" "}
<span className="break-all font-medium text-brand-base"> <span className="break-words font-medium text-brand-base">
{data?.name} {data?.name}
</span> </span>
? All of the data related to the module will be permanently removed. This ? All of the data related to the module will be permanently removed. This

View File

@ -322,7 +322,11 @@ export const ModuleDetailsSidebar: React.FC<Props> = ({ module, isOpen, moduleIs
<div className="flex w-full flex-col gap-6 px-6 py-6"> <div className="flex w-full flex-col gap-6 px-6 py-6">
<div className="flex w-full flex-col items-start justify-start gap-2"> <div className="flex w-full flex-col items-start justify-start gap-2">
<div className="flex w-full items-start justify-between gap-2 "> <div className="flex w-full items-start justify-between gap-2 ">
<h4 className="text-xl font-semibold text-brand-base">{module.name}</h4> <div className="max-w-[300px]">
<h4 className="text-xl font-semibold break-words w-full text-brand-base">
{module.name}
</h4>
</div>
<CustomMenu width="lg" ellipsis> <CustomMenu width="lg" ellipsis>
<CustomMenu.MenuItem onClick={() => setModuleDeleteModal(true)}> <CustomMenu.MenuItem onClick={() => setModuleDeleteModal(true)}>
<span className="flex items-center justify-start gap-2"> <span className="flex items-center justify-start gap-2">
@ -339,7 +343,7 @@ export const ModuleDetailsSidebar: React.FC<Props> = ({ module, isOpen, moduleIs
</CustomMenu> </CustomMenu>
</div> </div>
<span className="whitespace-normal text-sm leading-5 text-brand-secondary"> <span className="whitespace-normal text-sm leading-5 text-brand-secondary break-words w-full">
{module.description} {module.description}
</span> </span>
</div> </div>

View File

@ -138,7 +138,7 @@ export const SingleModuleCard: React.FC<Props> = ({ module, handleEditModule, us
<Tooltip tooltipContent={module.name} position="top-left"> <Tooltip tooltipContent={module.name} position="top-left">
<Link href={`/${workspaceSlug}/projects/${module.project}/modules/${module.id}`}> <Link href={`/${workspaceSlug}/projects/${module.project}/modules/${module.id}`}>
<a className="w-auto max-w-[calc(100%-9rem)]"> <a className="w-auto max-w-[calc(100%-9rem)]">
<h3 className="truncate break-all text-lg font-semibold text-brand-base"> <h3 className="truncate break-words text-lg font-semibold text-brand-base">
{truncateText(module.name, 75)} {truncateText(module.name, 75)}
</h3> </h3>
</a> </a>

View File

@ -195,7 +195,7 @@ export const CreateUpdateBlockInline: React.FC<Props> = ({
projectId as string, projectId as string,
{ {
prompt: watch("name"), prompt: watch("name"),
task: "Generate a proper description for this issue in context of a project management software.", task: "Generate a proper description for this issue.",
}, },
user user
) )

View File

@ -136,7 +136,7 @@ export const DeletePageModal: React.FC<TConfirmPageDeletionProps> = ({
<div className="mt-2"> <div className="mt-2">
<p className="text-sm text-brand-secondary"> <p className="text-sm text-brand-secondary">
Are you sure you want to delete Page-{" "} Are you sure you want to delete Page-{" "}
<span className="break-all font-medium text-brand-base"> <span className="break-words font-medium text-brand-base">
{data?.name} {data?.name}
</span> </span>
? All of the data related to the page will be permanently removed. This ? All of the data related to the page will be permanently removed. This

View File

@ -41,7 +41,7 @@ export const RecentPagesList: React.FC<TPagesListProps> = ({ viewType }) => {
if (pages[key].length === 0) return null; if (pages[key].length === 0) return null;
return ( return (
<div key={key}> <div key={key} className="h-full overflow-hidden">
<h2 className="text-xl font-semibold capitalize mb-2"> <h2 className="text-xl font-semibold capitalize mb-2">
{replaceUnderscoreIfSnakeCase(key)} {replaceUnderscoreIfSnakeCase(key)}
</h2> </h2>

View File

@ -194,7 +194,7 @@ export const SinglePageBlock: React.FC<Props> = ({ block, projectDetails, index,
projectId as string, projectId as string,
{ {
prompt: block.name, prompt: block.name,
task: "Generate a proper description for this issue in context of a project management software.", task: "Generate a proper description for this issue.",
}, },
user user
) )
@ -417,7 +417,7 @@ export const SinglePageBlock: React.FC<Props> = ({ block, projectDetails, index,
</div> </div>
<div className={`flex items-start gap-2 px-3 ${snapshot.isDragging ? "" : "py-4"}`}> <div className={`flex items-start gap-2 px-3 ${snapshot.isDragging ? "" : "py-4"}`}>
<div <div
className="w-full cursor-pointer overflow-hidden break-all px-4" className="w-full cursor-pointer overflow-hidden break-words px-4"
onClick={() => setCreateBlockForm(true)} onClick={() => setCreateBlockForm(true)}
> >
<div className="flex items-center"> <div className="flex items-center">

View File

@ -128,13 +128,13 @@ export const DeleteProjectModal: React.FC<TConfirmProjectDeletionProps> = ({
<span> <span>
<p className="text-sm leading-7 text-brand-secondary"> <p className="text-sm leading-7 text-brand-secondary">
Are you sure you want to delete project{" "} Are you sure you want to delete project{" "}
<span className="break-all font-semibold">{selectedProject?.name}</span>? All <span className="break-words font-semibold">{selectedProject?.name}</span>?
of the data related to the project will be permanently removed. This action All of the data related to the project will be permanently removed. This
cannot be undone action cannot be undone
</p> </p>
</span> </span>
<div className="text-brand-secondary"> <div className="text-brand-secondary">
<p className="break-all text-sm "> <p className="break-words text-sm ">
Enter the project name{" "} Enter the project name{" "}
<span className="font-medium text-brand-base">{selectedProject?.name}</span>{" "} <span className="font-medium text-brand-base">{selectedProject?.name}</span>{" "}
to continue: to continue:

View File

@ -195,7 +195,7 @@ export const SingleProjectCard: React.FC<ProjectCardProps> = ({
</span> </span>
) : null} ) : null}
</div> </div>
<p className="mt-3.5 mb-7 break-all"> <p className="mt-3.5 mb-7 break-words">
{truncateText(project.description ?? "", 100)} {truncateText(project.description ?? "", 100)}
</p> </p>
</a> </a>

View File

@ -15,7 +15,7 @@ import stateService from "services/state.service";
// hooks // hooks
import useToast from "hooks/use-toast"; import useToast from "hooks/use-toast";
// ui // ui
import { CustomSelect, Input, PrimaryButton, SecondaryButton } from "components/ui"; import { CustomSelect, Input, PrimaryButton, SecondaryButton, Tooltip } from "components/ui";
// types // types
import type { ICurrentUserResponse, IState, IStateResponse } from "types"; import type { ICurrentUserResponse, IState, IStateResponse } from "types";
// fetch-keys // fetch-keys
@ -28,6 +28,7 @@ type Props = {
onClose: () => void; onClose: () => void;
selectedGroup: StateGroup | null; selectedGroup: StateGroup | null;
user: ICurrentUserResponse | undefined; user: ICurrentUserResponse | undefined;
groupLength: number;
}; };
export type StateGroup = "backlog" | "unstarted" | "started" | "completed" | "cancelled" | null; export type StateGroup = "backlog" | "unstarted" | "started" | "completed" | "cancelled" | null;
@ -43,6 +44,7 @@ export const CreateUpdateStateInline: React.FC<Props> = ({
onClose, onClose,
selectedGroup, selectedGroup,
user, user,
groupLength,
}) => { }) => {
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId } = router.query; const { workspaceSlug, projectId } = router.query;
@ -174,8 +176,7 @@ export const CreateUpdateStateInline: React.FC<Props> = ({
{({ open }) => ( {({ open }) => (
<> <>
<Popover.Button <Popover.Button
className={`group inline-flex items-center text-base font-medium focus:outline-none ${ className={`group inline-flex items-center text-base font-medium focus:outline-none ${open ? "text-brand-base" : "text-brand-secondary"
open ? "text-brand-base" : "text-brand-secondary"
}`} }`}
> >
{watch("color") && watch("color") !== "" && ( {watch("color") && watch("color") !== "" && (
@ -228,7 +229,10 @@ export const CreateUpdateStateInline: React.FC<Props> = ({
name="group" name="group"
control={control} control={control}
render={({ field: { value, onChange } }) => ( render={({ field: { value, onChange } }) => (
<Tooltip tooltipContent={groupLength === 1 ? "Cannot have an empty group." : "Choose State"} >
<div>
<CustomSelect <CustomSelect
disabled={groupLength === 1}
value={value} value={value}
onChange={onChange} onChange={onChange}
label={ label={
@ -244,6 +248,8 @@ export const CreateUpdateStateInline: React.FC<Props> = ({
</CustomSelect.Option> </CustomSelect.Option>
))} ))}
</CustomSelect> </CustomSelect>
</div>
</Tooltip>
)} )}
/> />
)} )}

View File

@ -19,7 +19,9 @@ type Props = {
noChevron?: boolean; noChevron?: boolean;
position?: "left" | "right"; position?: "left" | "right";
verticalPosition?: "top" | "bottom"; verticalPosition?: "top" | "bottom";
menuItemsClassName?: string;
customButton?: JSX.Element; customButton?: JSX.Element;
menuItemsWhiteBg?: boolean;
}; };
type MenuItemProps = { type MenuItemProps = {
@ -43,7 +45,9 @@ const CustomMenu = ({
noChevron = false, noChevron = false,
position = "right", position = "right",
verticalPosition = "bottom", verticalPosition = "bottom",
menuItemsClassName = "",
customButton, customButton,
menuItemsWhiteBg = false,
}: Props) => ( }: Props) => (
<Menu as="div" className={`relative w-min whitespace-nowrap text-left ${className}`}> <Menu as="div" className={`relative w-min whitespace-nowrap text-left ${className}`}>
{({ open }) => ( {({ open }) => (
@ -105,7 +109,7 @@ const CustomMenu = ({
leaveTo="transform opacity-0 scale-95" leaveTo="transform opacity-0 scale-95"
> >
<Menu.Items <Menu.Items
className={`absolute z-20 overflow-y-scroll whitespace-nowrap rounded-md border border-brand-base bg-brand-surface-1 p-1 text-xs shadow-lg focus:outline-none ${ className={`absolute z-20 overflow-y-scroll whitespace-nowrap rounded-md border p-1 text-xs shadow-lg focus:outline-none ${
position === "left" ? "left-0 origin-top-left" : "right-0 origin-top-right" position === "left" ? "left-0 origin-top-left" : "right-0 origin-top-right"
} ${verticalPosition === "top" ? "bottom-full mb-1" : "mt-1"} ${ } ${verticalPosition === "top" ? "bottom-full mb-1" : "mt-1"} ${
height === "sm" height === "sm"
@ -127,7 +131,11 @@ const CustomMenu = ({
: width === "xl" : width === "xl"
? "w-48" ? "w-48"
: "min-w-full" : "min-w-full"
}`} } ${
menuItemsWhiteBg
? "border-brand-surface-1 bg-brand-base"
: "border-brand-base bg-brand-surface-1"
} ${menuItemsClassName}`}
> >
<div className="py-1">{children}</div> <div className="py-1">{children}</div>
</Menu.Items> </Menu.Items>

View File

@ -29,6 +29,7 @@ type CustomSearchSelectProps = {
selfPositioned?: boolean; selfPositioned?: boolean;
multiple?: boolean; multiple?: boolean;
footerOption?: JSX.Element; footerOption?: JSX.Element;
noResultIcon?: JSX.Element;
dropdownWidth?: string; dropdownWidth?: string;
}; };
export const CustomSearchSelect = ({ export const CustomSearchSelect = ({
@ -47,6 +48,7 @@ export const CustomSearchSelect = ({
disabled = false, disabled = false,
selfPositioned = false, selfPositioned = false,
multiple = false, multiple = false,
noResultIcon,
footerOption, footerOption,
dropdownWidth, dropdownWidth,
}: CustomSearchSelectProps) => { }: CustomSearchSelectProps) => {
@ -171,7 +173,10 @@ export const CustomSearchSelect = ({
</Combobox.Option> </Combobox.Option>
)) ))
) : ( ) : (
<p className="text-center text-brand-secondary">No matching results</p> <span className="flex items-center gap-2 p-1">
{noResultIcon && noResultIcon}
<p className="text-left text-brand-secondary ">No matching results</p>
</span>
) )
) : ( ) : (
<p className="text-center text-brand-secondary">Loading...</p> <p className="text-center text-brand-secondary">Loading...</p>

View File

@ -54,7 +54,7 @@ const CustomSelect = ({
) : ( ) : (
<Listbox.Button <Listbox.Button
className={`flex w-full ${ className={`flex w-full ${
disabled ? "cursor-not-allowed" : "cursor-pointer hover:bg-brand-surface-2" disabled ? "cursor-not-allowed text-brand-secondary" : "cursor-pointer hover:bg-brand-surface-2"
} items-center justify-between gap-1 rounded-md border border-brand-base shadow-sm duration-300 focus:outline-none ${ } items-center justify-between gap-1 rounded-md border border-brand-base shadow-sm duration-300 focus:outline-none ${
input ? "border-brand-base px-3 py-2 text-sm" : "px-2.5 py-1 text-xs" input ? "border-brand-base px-3 py-2 text-sm" : "px-2.5 py-1 text-xs"
} ${ } ${

View File

@ -11,6 +11,7 @@ type Props = {
placeholder?: string; placeholder?: string;
displayShortForm?: boolean; displayShortForm?: boolean;
error?: boolean; error?: boolean;
noBorder?: boolean;
className?: string; className?: string;
isClearable?: boolean; isClearable?: boolean;
disabled?: boolean; disabled?: boolean;
@ -23,6 +24,7 @@ export const CustomDatePicker: React.FC<Props> = ({
placeholder = "Select date", placeholder = "Select date",
displayShortForm = false, displayShortForm = false,
error = false, error = false,
noBorder = false,
className = "", className = "",
isClearable = true, isClearable = true,
disabled = false, disabled = false,
@ -44,7 +46,9 @@ export const CustomDatePicker: React.FC<Props> = ({
: "" : ""
} ${error ? "border-red-500 bg-red-100" : ""} ${ } ${error ? "border-red-500 bg-red-100" : ""} ${
disabled ? "cursor-not-allowed" : "cursor-pointer" disabled ? "cursor-not-allowed" : "cursor-pointer"
} w-full rounded-md border border-brand-base bg-transparent caret-transparent ${className}`} } ${
noBorder ? "" : "border border-brand-base"
} w-full rounded-md bg-transparent caret-transparent ${className}`}
dateFormat="dd-MM-yyyy" dateFormat="dd-MM-yyyy"
isClearable={isClearable} isClearable={isClearable}
disabled={disabled} disabled={disabled}

View File

@ -3,7 +3,7 @@ import { Fragment, useState } from "react";
// headless ui // headless ui
import { Menu, Transition } from "@headlessui/react"; import { Menu, Transition } from "@headlessui/react";
// icons // icons
import { ChevronDownIcon } from "@heroicons/react/24/outline"; import { CheckIcon, ChevronDownIcon } from "@heroicons/react/24/outline";
import { ChevronRightIcon, ChevronLeftIcon } from "@heroicons/react/20/solid"; import { ChevronRightIcon, ChevronLeftIcon } from "@heroicons/react/20/solid";
type MultiLevelDropdownProps = { type MultiLevelDropdownProps = {
@ -127,9 +127,14 @@ export const MultiLevelDropdown: React.FC<MultiLevelDropdownProps> = ({
}} }}
className={`${ className={`${
child.selected ? "bg-brand-surface-2" : "" child.selected ? "bg-brand-surface-2" : ""
} flex w-full items-center whitespace-nowrap break-all rounded px-1 py-1.5 text-left capitalize text-brand-secondary hover:bg-brand-surface-2`} } flex w-full items-center justify-between whitespace-nowrap break-words rounded px-1 py-1.5 text-left capitalize text-brand-secondary hover:bg-brand-surface-2`}
> >
{child.label} {child.label}
<CheckIcon
className={`h-3.5 w-3.5 opacity-0 ${
child.selected ? "opacity-100" : ""
}`}
/>
</button> </button>
))} ))}
</div> </div>

View File

@ -42,7 +42,7 @@ export const Tooltip: React.FC<Props> = ({
disabled={disabled} disabled={disabled}
content={ content={
<div <div
className={`${className} relative flex max-w-[600px] flex-col items-start justify-center gap-1 rounded-md p-2 text-left text-xs shadow-md ${ className={`${className} relative z-50 flex max-w-[600px] flex-col items-start justify-center gap-1 rounded-md p-2 text-left text-xs shadow-md ${
theme === "light" ? "text-brand-muted-1 bg-brand-surface-2" : "bg-black text-white" theme === "light" ? "text-brand-muted-1 bg-brand-surface-2" : "bg-black text-white"
}`} }`}
> >

View File

@ -115,7 +115,7 @@ export const DeleteViewModal: React.FC<Props> = ({ isOpen, data, setIsOpen, user
<div className="mt-2"> <div className="mt-2">
<p className="text-sm text-brand-secondary"> <p className="text-sm text-brand-secondary">
Are you sure you want to delete view-{" "} Are you sure you want to delete view-{" "}
<span className="break-all font-medium text-brand-base"> <span className="break-words font-medium text-brand-base">
{data?.name} {data?.name}
</span> </span>
? All of the data related to the view will be permanently removed. This ? All of the data related to the view will be permanently removed. This

View File

@ -70,7 +70,7 @@ export const SelectFilters: React.FC<Props> = ({
value: PRIORITIES, value: PRIORITIES,
children: [ children: [
...PRIORITIES.map((priority) => ({ ...PRIORITIES.map((priority) => ({
id: priority ?? "none", id: priority === null ? "null" : priority,
label: ( label: (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{getPriorityIcon(priority)} {priority ?? "None"} {getPriorityIcon(priority)} {priority ?? "None"}
@ -78,9 +78,9 @@ export const SelectFilters: React.FC<Props> = ({
), ),
value: { value: {
key: "priority", key: "priority",
value: priority, value: priority === null ? "null" : priority,
}, },
selected: filters?.priority?.includes(priority ?? "none"), selected: filters?.priority?.includes(priority === null ? "null" : priority),
})), })),
], ],
}, },

View File

@ -1,34 +1,141 @@
import { useEffect, useRef, useState } from "react";
// ui // ui
import { CalendarGraph } from "components/ui"; import { Tooltip } from "components/ui";
// helpers // helpers
import { renderShortDateWithYearFormat } from "helpers/date-time.helper"; import { renderDateFormat, renderShortNumericDateFormat } from "helpers/date-time.helper";
// types // types
import { IUserActivity } from "types"; import { IUserActivity } from "types";
// constants
import { DAYS, MONTHS } from "constants/project";
type Props = { type Props = {
activities: IUserActivity[] | undefined; activities: IUserActivity[] | undefined;
}; };
export const ActivityGraph: React.FC<Props> = ({ activities }) => ( export const ActivityGraph: React.FC<Props> = ({ activities }) => {
<CalendarGraph const ref = useRef<HTMLDivElement>(null);
data={
activities?.map((activity) => ({ const [width, setWidth] = useState(0);
day: activity.created_date,
value: activity.activity_count, const today = new Date();
})) ?? [] const lastMonth = new Date(today.getFullYear(), today.getMonth() - 1, 1);
const twoMonthsAgo = new Date(today.getFullYear(), today.getMonth() - 2, 1);
const threeMonthsAgo = new Date(today.getFullYear(), today.getMonth() - 3, 1);
const fourMonthsAgo = new Date(today.getFullYear(), today.getMonth() - 4, 1);
const fiveMonthsAgo = new Date(today.getFullYear(), today.getMonth() - 5, 1);
const recentMonths = [
fiveMonthsAgo,
fourMonthsAgo,
threeMonthsAgo,
twoMonthsAgo,
lastMonth,
today,
];
const getDatesOfMonth = (dateOfMonth: Date) => {
const month = dateOfMonth.getMonth();
const year = dateOfMonth.getFullYear();
const dates = [];
const date = new Date(year, month, 1);
while (date.getMonth() === month && date < new Date()) {
dates.push(renderDateFormat(new Date(date)));
date.setDate(date.getDate() + 1);
} }
from={activities?.length ? activities[0].created_date : new Date()}
to={activities?.length ? activities[activities.length - 1].created_date : new Date()} return dates;
height="200px" };
margin={{ bottom: 0, left: 10, right: 10, top: 0 }}
tooltip={(datum) => ( const recentDates = [
<div className="rounded-md border border-brand-base bg-brand-surface-2 p-2 text-xs"> ...getDatesOfMonth(recentMonths[0]),
<span className="text-brand-secondary">{renderShortDateWithYearFormat(datum.day)}:</span>{" "} ...getDatesOfMonth(recentMonths[1]),
{datum.value} ...getDatesOfMonth(recentMonths[2]),
...getDatesOfMonth(recentMonths[3]),
...getDatesOfMonth(recentMonths[4]),
...getDatesOfMonth(recentMonths[5]),
];
const activitiesIntensity = (activityCount: number) => {
if (activityCount <= 3) return "opacity-20";
else if (activityCount > 3 && activityCount <= 6) return "opacity-40";
else if (activityCount > 6 && activityCount <= 9) return "opacity-80";
else return "";
};
const addPaddingTiles = () => {
const firstDateDay = new Date(recentDates[0]).getDay();
for (let i = 0; i < firstDateDay; i++) recentDates.unshift("");
};
addPaddingTiles();
useEffect(() => {
if (!ref.current) return;
setWidth(ref.current.offsetWidth);
}, [ref]);
return (
<div className="grid place-items-center overflow-x-scroll">
<div className="flex items-start gap-4">
<div className="flex flex-col gap-2 pt-6">
{DAYS.map((day, index) => (
<h6 key={day} className="h-4 text-xs">
{index % 2 === 0 && day.substring(0, 3)}
</h6>
))}
</div> </div>
)} <div>
theme={{ <div className="flex items-center justify-between" style={{ width: `${width}px` }}>
background: "rgb(var(--color-bg-base))", {recentMonths.map((month, index) => (
}} <h6 key={index} className="w-full text-xs">
{MONTHS[month.getMonth()].substring(0, 3)}
</h6>
))}
</div>
<div
className="mt-2 grid w-full grid-flow-col gap-2"
style={{ gridTemplateRows: "repeat(7, minmax(0, 1fr))" }}
ref={ref}
>
{recentDates.map((date, index) => {
const isActive = activities?.find((a) => a.created_date === date);
return (
<Tooltip
key={`${date}-${index}`}
tooltipContent={`${
isActive ? isActive.activity_count : 0
} activities on ${renderShortNumericDateFormat(date)}`}
theme="dark"
>
<div
className={`${
date === "" ? "pointer-events-none opacity-0" : ""
} h-4 w-4 rounded ${
isActive
? `bg-brand-accent ${activitiesIntensity(isActive.activity_count)}`
: "bg-brand-surface-2"
}`}
/> />
</Tooltip>
); );
})}
</div>
<div className="mt-8 flex items-center gap-2 text-xs">
<span>Less</span>
<span className="h-4 w-4 rounded bg-brand-surface-2" />
<span className="h-4 w-4 rounded bg-brand-accent opacity-20" />
<span className="h-4 w-4 rounded bg-brand-accent opacity-40" />
<span className="h-4 w-4 rounded bg-brand-accent opacity-80" />
<span className="h-4 w-4 rounded bg-brand-accent" />
<span>More</span>
</div>
</div>
</div>
</div>
);
};

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