mirror of
https://github.com/makeplane/plane
synced 2024-06-14 14:31:34 +00:00
Merge branch 'develop' of github.com:makeplane/plane into feat/notifications
This commit is contained in:
commit
ebc9f36dc2
@ -23,7 +23,6 @@ You can open a new issue with this [issue form](https://github.com/makeplane/pla
|
||||
- Python version 3.8+
|
||||
- Postgres version v14
|
||||
- Redis version v6.2.7
|
||||
- pnpm version 7.22.0
|
||||
|
||||
### Setup the project
|
||||
|
||||
|
10
README.md
10
README.md
@ -61,14 +61,6 @@ chmod +x setup.sh
|
||||
|
||||
> 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
|
||||
|
||||
```bash
|
||||
@ -165,4 +157,4 @@ Our [Code of Conduct](https://github.com/makeplane/plane/blob/master/CODE_OF_CON
|
||||
|
||||
## ⛓️ Security
|
||||
|
||||
If you believe you have found a security vulnerability in Plane, we encourage you to responsibly disclose this and not open a public issue. We will investigate all legitimate reports. Email security@plane.so to disclose any security vulnerabilities.
|
||||
If you believe you have found a security vulnerability in Plane, we encourage you to responsibly disclose this and not open a public issue. We will investigate all legitimate reports. Email engineering@plane.so to disclose any security vulnerabilities.
|
||||
|
@ -3,6 +3,7 @@ from django.db.models import (
|
||||
Count,
|
||||
Sum,
|
||||
F,
|
||||
Q
|
||||
)
|
||||
from django.db.models.functions import ExtractMonth
|
||||
|
||||
@ -59,10 +60,11 @@ class AnalyticsEndpoint(BaseAPIView):
|
||||
|
||||
colors = (
|
||||
State.objects.filter(
|
||||
~Q(name="Triage"),
|
||||
workspace__slug=slug, project_id__in=filters.get("project__in")
|
||||
).values(key, "color")
|
||||
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"]:
|
||||
|
@ -72,7 +72,7 @@ class SignUpEndpoint(BaseAPIView):
|
||||
# Check if the user already exists
|
||||
if User.objects.filter(email=email).exists():
|
||||
return Response(
|
||||
{"error": "User already exist please sign in"},
|
||||
{"error": "User with this email already exists"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
@ -7,7 +7,7 @@ from rest_framework.response import Response
|
||||
from sentry_sdk import capture_exception
|
||||
|
||||
# Django imports
|
||||
from django.db.models import Max
|
||||
from django.db.models import Max, Q
|
||||
|
||||
# Module imports
|
||||
from plane.api.views import BaseAPIView
|
||||
@ -42,16 +42,34 @@ from plane.utils.html_processor import strip_tags
|
||||
|
||||
|
||||
class ServiceIssueImportSummaryEndpoint(BaseAPIView):
|
||||
|
||||
def get(self, request, slug, service):
|
||||
try:
|
||||
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(
|
||||
integration__provider="github", workspace__slug=slug
|
||||
)
|
||||
|
||||
access_tokens_url = workspace_integration.metadata["access_tokens_url"]
|
||||
owner = request.GET.get("owner")
|
||||
repo = request.GET.get("repo")
|
||||
access_tokens_url = workspace_integration.metadata.get(
|
||||
"access_tokens_url", False
|
||||
)
|
||||
|
||||
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(
|
||||
access_tokens_url, owner, repo
|
||||
@ -309,11 +327,13 @@ class BulkImportIssuesEndpoint(BaseAPIView):
|
||||
|
||||
# Get the default state
|
||||
default_state = State.objects.filter(
|
||||
project_id=project_id, default=True
|
||||
~Q(name="Triage"), project_id=project_id, default=True
|
||||
).first()
|
||||
# if there is no default state assign any random state
|
||||
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
|
||||
last_id = IssueSequence.objects.filter(project_id=project_id).aggregate(
|
||||
|
@ -68,13 +68,12 @@ class InboxViewSet(BaseViewSet):
|
||||
inbox = Inbox.objects.get(
|
||||
workspace__slug=slug, project_id=project_id, pk=pk
|
||||
)
|
||||
|
||||
# Handle default inbox delete
|
||||
if inbox.is_default:
|
||||
return Response(
|
||||
{"error": "You cannot delete the default inbox"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
inbox.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
except Exception as e:
|
||||
@ -112,7 +111,6 @@ class InboxIssueViewSet(BaseViewSet):
|
||||
|
||||
def list(self, request, slug, project_id, inbox_id):
|
||||
try:
|
||||
order_by = request.GET.get("order_by", "created_at")
|
||||
filters = issue_filters(request.query_params, "GET")
|
||||
issues = (
|
||||
Issue.objects.filter(
|
||||
@ -120,23 +118,17 @@ class InboxIssueViewSet(BaseViewSet):
|
||||
workspace__slug=slug,
|
||||
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(
|
||||
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="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(
|
||||
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
|
||||
.order_by()
|
||||
@ -180,7 +172,8 @@ class InboxIssueViewSet(BaseViewSet):
|
||||
{"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",
|
||||
"medium",
|
||||
"high",
|
||||
@ -213,7 +206,6 @@ class InboxIssueViewSet(BaseViewSet):
|
||||
)
|
||||
|
||||
# Create an Issue Activity
|
||||
# Track the issue
|
||||
issue_activity.delay(
|
||||
type="issue.activity.created",
|
||||
requested_data=json.dumps(request.data, cls=DjangoJSONEncoder),
|
||||
@ -231,9 +223,7 @@ class InboxIssueViewSet(BaseViewSet):
|
||||
)
|
||||
|
||||
serializer = IssueStateInboxSerializer(issue)
|
||||
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
@ -260,7 +250,7 @@ class InboxIssueViewSet(BaseViewSet):
|
||||
pk=inbox_issue.issue_id, workspace__slug=slug, project_id=project_id
|
||||
)
|
||||
# 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
|
||||
issue_data = {
|
||||
"name": issue_data.get("name", issue.name),
|
||||
|
@ -16,6 +16,7 @@ from django.db.models import (
|
||||
CharField,
|
||||
When,
|
||||
Exists,
|
||||
Max,
|
||||
)
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
from django.utils.decorators import method_decorator
|
||||
@ -159,8 +160,9 @@ class IssueViewSet(BaseViewSet):
|
||||
filters = issue_filters(request.query_params, "GET")
|
||||
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]
|
||||
state_order = ["backlog", "unstarted", "started", "completed", "cancelled"]
|
||||
|
||||
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(
|
||||
priority_order=Case(
|
||||
*[
|
||||
@ -195,6 +203,45 @@ class IssueViewSet(BaseViewSet):
|
||||
output_field=CharField(),
|
||||
)
|
||||
).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:
|
||||
issue_queryset = issue_queryset.order_by(order_by_param)
|
||||
|
||||
@ -614,7 +661,9 @@ class SubIssuesEndpoint(BaseAPIView):
|
||||
)
|
||||
|
||||
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(
|
||||
state_count=Count(
|
||||
"state_issue",
|
||||
|
@ -3,13 +3,13 @@ from itertools import groupby
|
||||
|
||||
# Django imports
|
||||
from django.db import IntegrityError
|
||||
from django.db.models import Q
|
||||
|
||||
# Third party imports
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
from sentry_sdk import capture_exception
|
||||
|
||||
|
||||
# Module imports
|
||||
from . import BaseViewSet, BaseAPIView
|
||||
from plane.api.serializers import StateSerializer
|
||||
@ -34,6 +34,7 @@ class StateViewSet(BaseViewSet):
|
||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.filter(project_id=self.kwargs.get("project_id"))
|
||||
.filter(project__project_projectmember__member=self.request.user)
|
||||
.filter(~Q(name="Triage"))
|
||||
.select_related("project")
|
||||
.select_related("workspace")
|
||||
.distinct()
|
||||
@ -80,7 +81,8 @@ class StateViewSet(BaseViewSet):
|
||||
def destroy(self, request, slug, project_id, pk):
|
||||
try:
|
||||
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:
|
||||
|
39
apiserver/plane/db/migrations/0034_auto_20230628_1046.py
Normal file
39
apiserver/plane/db/migrations/0034_auto_20230628_1046.py
Normal 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',
|
||||
),
|
||||
]
|
@ -98,11 +98,13 @@ class Issue(ProjectBaseModel):
|
||||
from plane.db.models import State
|
||||
|
||||
default_state = State.objects.filter(
|
||||
project=self.project, default=True
|
||||
~models.Q(name="Triage"), project=self.project, default=True
|
||||
).first()
|
||||
# if there is no default state assign any random state
|
||||
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
|
||||
if random_state.group == "started":
|
||||
self.start_date = timezone.now().date()
|
||||
|
@ -63,6 +63,7 @@ if os.environ.get("SENTRY_DSN", False):
|
||||
send_default_pii=True,
|
||||
environment="local",
|
||||
traces_sample_rate=0.7,
|
||||
profiles_sample_rate=1.0,
|
||||
)
|
||||
|
||||
REDIS_HOST = "localhost"
|
||||
|
@ -84,6 +84,7 @@ if bool(os.environ.get("SENTRY_DSN", False)):
|
||||
traces_sample_rate=1,
|
||||
send_default_pii=True,
|
||||
environment="production",
|
||||
profiles_sample_rate=1.0,
|
||||
)
|
||||
|
||||
if DOCKERIZED and USE_MINIO:
|
||||
|
@ -66,6 +66,7 @@ sentry_sdk.init(
|
||||
traces_sample_rate=1,
|
||||
send_default_pii=True,
|
||||
environment="staging",
|
||||
profiles_sample_rate=1.0,
|
||||
)
|
||||
|
||||
# The AWS region to connect to.
|
||||
|
@ -113,7 +113,7 @@ def get_github_repo_details(access_tokens_url, owner, repo):
|
||||
last_url = labels_response.links.get("last").get("url")
|
||||
parsed_url = urlparse(last_url)
|
||||
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
|
||||
last_page_labels = requests.get(last_url, headers=headers).json()
|
||||
|
@ -1,7 +1,6 @@
|
||||
from django.utils.timezone import make_aware
|
||||
from django.utils.dateparse import parse_datetime
|
||||
|
||||
|
||||
def filter_state(params, filter, method):
|
||||
if method == "GET":
|
||||
states = params.get("state").split(",")
|
||||
@ -26,12 +25,27 @@ def filter_estimate_point(params, filter, method):
|
||||
|
||||
def filter_priority(params, filter, method):
|
||||
if method == "GET":
|
||||
priorties = params.get("priority").split(",")
|
||||
if len(priorties) and "" not in priorties:
|
||||
filter["priority__in"] = priorties
|
||||
priorities = params.get("priority").split(",")
|
||||
if len(priorities) and "" not in priorities:
|
||||
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:
|
||||
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
|
||||
|
||||
|
||||
|
@ -2,30 +2,30 @@
|
||||
|
||||
Django==3.2.19
|
||||
django-braces==1.15.0
|
||||
django-taggit==3.1.0
|
||||
psycopg2==2.9.5
|
||||
django-oauth-toolkit==2.2.0
|
||||
django-taggit==4.0.0
|
||||
psycopg2==2.9.6
|
||||
django-oauth-toolkit==2.3.0
|
||||
mistune==2.0.4
|
||||
djangorestframework==3.14.0
|
||||
redis==4.5.4
|
||||
redis==4.6.0
|
||||
django-nested-admin==4.0.2
|
||||
django-cors-headers==3.13.0
|
||||
django-cors-headers==4.1.0
|
||||
whitenoise==6.3.0
|
||||
django-allauth==0.52.0
|
||||
django-allauth==0.54.0
|
||||
faker==13.4.0
|
||||
django-filter==22.1
|
||||
django-filter==23.2
|
||||
jsonmodels==2.6.0
|
||||
djangorestframework-simplejwt==5.2.2
|
||||
sentry-sdk==1.14.0
|
||||
django-s3-storage==0.13.11
|
||||
sentry-sdk==1.26.0
|
||||
django-s3-storage==0.14.0
|
||||
django-crum==0.7.9
|
||||
django-guardian==2.4.0
|
||||
dj_rest_auth==2.2.5
|
||||
google-auth==2.16.0
|
||||
google-api-python-client==2.75.0
|
||||
django-redis==5.2.0
|
||||
uvicorn==0.20.0
|
||||
django-redis==5.3.0
|
||||
uvicorn==0.22.0
|
||||
channels==4.0.0
|
||||
openai==0.27.2
|
||||
slack-sdk==3.20.2
|
||||
celery==5.2.7
|
||||
openai==0.27.8
|
||||
slack-sdk==3.21.3
|
||||
celery==5.3.1
|
@ -1,11 +1,11 @@
|
||||
-r base.txt
|
||||
|
||||
dj-database-url==1.2.0
|
||||
dj-database-url==2.0.0
|
||||
gunicorn==20.1.0
|
||||
whitenoise==6.3.0
|
||||
django-storages==1.13.2
|
||||
boto3==1.26.136
|
||||
django-anymail==9.0
|
||||
boto3==1.26.163
|
||||
django-anymail==10.0
|
||||
twilio==7.16.2
|
||||
django-debug-toolbar==3.8.1
|
||||
gevent==22.10.2
|
||||
|
@ -20,7 +20,7 @@ ARG NEXT_PUBLIC_API_BASE_URL=http://localhost:8000
|
||||
COPY .gitignore .gitignore
|
||||
COPY --from=builder /app/out/json/ .
|
||||
COPY --from=builder /app/out/yarn.lock ./yarn.lock
|
||||
RUN yarn install
|
||||
RUN yarn install --network-timeout 500000
|
||||
|
||||
# Build the project
|
||||
COPY --from=builder /app/out/full/ .
|
||||
|
@ -237,7 +237,7 @@ export const AnalyticsSidebar: React.FC<Props> = ({
|
||||
{project?.name.charAt(0)}
|
||||
</span>
|
||||
)}
|
||||
<h5 className="break-all">
|
||||
<h5 className="break-words">
|
||||
{project.name}
|
||||
<span className="text-brand-secondary text-xs ml-1">
|
||||
({project.identifier})
|
||||
@ -276,7 +276,7 @@ export const AnalyticsSidebar: React.FC<Props> = ({
|
||||
{projectId ? (
|
||||
cycleId && cycleDetails ? (
|
||||
<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="flex items-center gap-2 text-xs">
|
||||
<h6 className="text-brand-secondary">Lead</h6>
|
||||
@ -304,7 +304,7 @@ export const AnalyticsSidebar: React.FC<Props> = ({
|
||||
</div>
|
||||
) : moduleId && moduleDetails ? (
|
||||
<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="flex items-center gap-2 text-xs">
|
||||
<h6 className="text-brand-secondary">Lead</h6>
|
||||
@ -352,7 +352,7 @@ export const AnalyticsSidebar: React.FC<Props> = ({
|
||||
{projectDetails?.name.charAt(0)}
|
||||
</span>
|
||||
)}
|
||||
<h4 className="font-medium break-all">{projectDetails?.name}</h4>
|
||||
<h4 className="font-medium break-words">{projectDetails?.name}</h4>
|
||||
</div>
|
||||
<div className="space-y-4 mt-4">
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
|
@ -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">
|
||||
<h3 className="break-all">
|
||||
<h3 className="break-words">
|
||||
Analytics for{" "}
|
||||
{cycleId ? cycleDetails?.name : moduleId ? moduleDetails?.name : projectDetails?.name}
|
||||
</h3>
|
||||
|
@ -33,7 +33,7 @@ export const AnalyticsLeaderboard: React.FC<Props> = ({ users, title }) => (
|
||||
{user.firstName !== "" ? user.firstName[0] : "?"}
|
||||
</div>
|
||||
)}
|
||||
<span className="break-all text-brand-secondary">
|
||||
<span className="break-words text-brand-secondary">
|
||||
{user.firstName !== "" ? `${user.firstName} ${user.lastName}` : "No assignee"}
|
||||
</span>
|
||||
</div>
|
||||
|
@ -41,6 +41,14 @@ export const AnalyticsYearWiseIssues: React.FC<Props> = ({ defaultAnalytics }) =
|
||||
colors={(datum) => datum.color}
|
||||
curve="monotoneX"
|
||||
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={{
|
||||
background: "rgb(var(--color-bg-base))",
|
||||
}}
|
||||
|
@ -52,7 +52,7 @@ const BreadcrumbItem: React.FC<BreadcrumbItemProps> = ({ title, link, icon }) =>
|
||||
<div className="max-w-64 px-3 text-sm">
|
||||
<p className={`${icon ? "flex items-center gap-2" : ""}`}>
|
||||
{icon}
|
||||
<span className="break-all">{title}</span>
|
||||
<span className="break-words">{title}</span>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
@ -9,6 +9,7 @@ import {
|
||||
ChatBubbleOvalLeftEllipsisIcon,
|
||||
DocumentTextIcon,
|
||||
FolderPlusIcon,
|
||||
InboxIcon,
|
||||
LinkIcon,
|
||||
MagnifyingGlassIcon,
|
||||
RocketLaunchIcon,
|
||||
@ -34,6 +35,7 @@ import { Dialog, Transition } from "@headlessui/react";
|
||||
// cmdk
|
||||
import { Command } from "cmdk";
|
||||
// hooks
|
||||
import useProjectDetails from "hooks/use-project-details";
|
||||
import useTheme from "hooks/use-theme";
|
||||
import useToast from "hooks/use-toast";
|
||||
import useUser from "hooks/use-user";
|
||||
@ -64,10 +66,11 @@ import {
|
||||
// services
|
||||
import issuesService from "services/issues.service";
|
||||
import workspaceService from "services/workspace.service";
|
||||
import inboxService from "services/inbox.service";
|
||||
// types
|
||||
import { IIssue, IWorkspaceSearchResults } from "types";
|
||||
// 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 = () => {
|
||||
const [isPaletteOpen, setIsPaletteOpen] = useState(false);
|
||||
@ -81,7 +84,7 @@ export const CommandPalette: React.FC = () => {
|
||||
const [deleteIssueModal, setDeleteIssueModal] = useState(false);
|
||||
const [isCreateUpdatePageModalOpen, setIsCreateUpdatePageModalOpen] = useState(false);
|
||||
|
||||
const [searchTerm, setSearchTerm] = React.useState<string>("");
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [results, setResults] = useState<IWorkspaceSearchResults>({
|
||||
results: {
|
||||
workspace: [],
|
||||
@ -105,6 +108,8 @@ export const CommandPalette: React.FC = () => {
|
||||
const { workspaceSlug, projectId, issueId, inboxId } = router.query;
|
||||
|
||||
const { user } = useUser();
|
||||
const { projectDetails } = useProjectDetails();
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
const { toggleCollapsed } = useTheme();
|
||||
|
||||
@ -116,6 +121,13 @@ export const CommandPalette: React.FC = () => {
|
||||
: 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(
|
||||
async (formData: Partial<IIssue>) => {
|
||||
if (!workspaceSlug || !projectId || !issueId) return;
|
||||
@ -321,9 +333,9 @@ export const CommandPalette: React.FC = () => {
|
||||
setDeleteIssueModal(true);
|
||||
};
|
||||
|
||||
const goToSettings = (path: string = "") => {
|
||||
const redirect = (path: string) => {
|
||||
setIsPaletteOpen(false);
|
||||
router.push(`/${workspaceSlug}/settings/${path}`);
|
||||
router.push(path);
|
||||
};
|
||||
|
||||
return (
|
||||
@ -396,7 +408,7 @@ export const CommandPalette: React.FC = () => {
|
||||
leaveFrom="opacity-100"
|
||||
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>
|
||||
|
||||
<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"
|
||||
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
|
||||
filter={(value, search) => {
|
||||
if (value.toLowerCase().includes(search.toLowerCase())) return 1;
|
||||
return 0;
|
||||
}}
|
||||
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
|
||||
if (e.key === "Escape" && !page && !searchTerm) {
|
||||
setIsPaletteOpen(false);
|
||||
@ -698,6 +710,24 @@ export const CommandPalette: React.FC = () => {
|
||||
<kbd>D</kbd>
|
||||
</Command.Item>
|
||||
</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 && (
|
||||
<>
|
||||
<Command.Item
|
||||
onSelect={() => goToSettings()}
|
||||
onSelect={() => redirect(`/${workspaceSlug}/settings`)}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-brand-secondary">
|
||||
@ -823,7 +853,7 @@ export const CommandPalette: React.FC = () => {
|
||||
</div>
|
||||
</Command.Item>
|
||||
<Command.Item
|
||||
onSelect={() => goToSettings("members")}
|
||||
onSelect={() => redirect(`/${workspaceSlug}/settings/members`)}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-brand-secondary">
|
||||
@ -832,7 +862,7 @@ export const CommandPalette: React.FC = () => {
|
||||
</div>
|
||||
</Command.Item>
|
||||
<Command.Item
|
||||
onSelect={() => goToSettings("billing")}
|
||||
onSelect={() => redirect(`/${workspaceSlug}/settings/billing`)}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-brand-secondary">
|
||||
@ -841,7 +871,7 @@ export const CommandPalette: React.FC = () => {
|
||||
</div>
|
||||
</Command.Item>
|
||||
<Command.Item
|
||||
onSelect={() => goToSettings("integrations")}
|
||||
onSelect={() => redirect(`/${workspaceSlug}/settings/integrations`)}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-brand-secondary">
|
||||
@ -850,12 +880,12 @@ export const CommandPalette: React.FC = () => {
|
||||
</div>
|
||||
</Command.Item>
|
||||
<Command.Item
|
||||
onSelect={() => goToSettings("import-export")}
|
||||
onSelect={() => redirect(`/${workspaceSlug}/settings/import-export`)}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-brand-secondary">
|
||||
<SettingIcon className="h-4 w-4 text-brand-secondary" />
|
||||
Import/ Export
|
||||
Import/Export
|
||||
</div>
|
||||
</Command.Item>
|
||||
</>
|
||||
|
@ -2,11 +2,12 @@
|
||||
import useProjectIssuesView from "hooks/use-issues-view";
|
||||
// components
|
||||
import { SingleBoard } from "components/core/board-view/single-board";
|
||||
// icons
|
||||
import { getStateGroupIcon } from "components/icons";
|
||||
// helpers
|
||||
import { addSpaceIfCamelCase } from "helpers/string.helper";
|
||||
// types
|
||||
import { ICurrentUserResponse, IIssue, IState, UserAuth } from "types";
|
||||
import { getStateGroupIcon } from "components/icons";
|
||||
|
||||
type Props = {
|
||||
type: "issue" | "cycle" | "module";
|
||||
|
@ -166,7 +166,7 @@ export const BoardHeader: React.FC<Props> = ({
|
||||
<ArrowsPointingOutIcon className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
{!isCompleted && (
|
||||
{!isCompleted && selectedGroup !== "created_by" && (
|
||||
<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"
|
||||
|
@ -145,43 +145,45 @@ export const SingleBoard: React.FC<Props> = ({
|
||||
{provided.placeholder}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
{type === "issue" ? (
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 font-medium text-brand-accent outline-none p-1"
|
||||
onClick={addIssueToState}
|
||||
>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
Add Issue
|
||||
</button>
|
||||
) : (
|
||||
!isCompleted && (
|
||||
<CustomMenu
|
||||
customButton={
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 font-medium text-brand-accent outline-none"
|
||||
>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
Add Issue
|
||||
</button>
|
||||
}
|
||||
position="left"
|
||||
noBorder
|
||||
{selectedGroup !== "created_by" && (
|
||||
<div>
|
||||
{type === "issue" ? (
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 font-medium text-brand-accent outline-none p-1"
|
||||
onClick={addIssueToState}
|
||||
>
|
||||
<CustomMenu.MenuItem onClick={addIssueToState}>
|
||||
Create new
|
||||
</CustomMenu.MenuItem>
|
||||
{openIssuesListModal && (
|
||||
<CustomMenu.MenuItem onClick={openIssuesListModal}>
|
||||
Add an existing issue
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
Add Issue
|
||||
</button>
|
||||
) : (
|
||||
!isCompleted && (
|
||||
<CustomMenu
|
||||
customButton={
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 font-medium text-brand-accent outline-none"
|
||||
>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
Add Issue
|
||||
</button>
|
||||
}
|
||||
position="left"
|
||||
noBorder
|
||||
>
|
||||
<CustomMenu.MenuItem onClick={addIssueToState}>
|
||||
Create new
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
</CustomMenu>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
{openIssuesListModal && (
|
||||
<CustomMenu.MenuItem onClick={openIssuesListModal}>
|
||||
Add an existing issue
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
</CustomMenu>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</StrictModeDroppable>
|
||||
|
@ -23,6 +23,7 @@ import {
|
||||
ViewAssigneeSelect,
|
||||
ViewDueDateSelect,
|
||||
ViewEstimateSelect,
|
||||
ViewLabelSelect,
|
||||
ViewPrioritySelect,
|
||||
ViewStateSelect,
|
||||
} from "components/issues";
|
||||
@ -44,7 +45,14 @@ import { LayerDiagonalIcon } from "components/icons";
|
||||
import { handleIssuesMutation } from "constants/issue";
|
||||
import { copyTextToClipboard, truncateText } from "helpers/string.helper";
|
||||
// types
|
||||
import { ICurrentUserResponse, IIssue, Properties, TIssueGroupByOptions, UserAuth } from "types";
|
||||
import {
|
||||
ICurrentUserResponse,
|
||||
IIssue,
|
||||
ISubIssueResponse,
|
||||
Properties,
|
||||
TIssueGroupByOptions,
|
||||
UserAuth,
|
||||
} from "types";
|
||||
// fetch-keys
|
||||
import {
|
||||
CYCLE_DETAILS,
|
||||
@ -52,6 +60,8 @@ import {
|
||||
MODULE_DETAILS,
|
||||
MODULE_ISSUES_WITH_PARAMS,
|
||||
PROJECT_ISSUES_LIST_WITH_PARAMS,
|
||||
SUB_ISSUES,
|
||||
VIEW_ISSUES,
|
||||
} from "constants/fetch-keys";
|
||||
|
||||
type Props = {
|
||||
@ -101,86 +111,71 @@ export const SingleBoardIssue: React.FC<Props> = ({
|
||||
const { orderBy, params } = useIssuesView();
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, cycleId, moduleId } = router.query;
|
||||
const { workspaceSlug, projectId, cycleId, moduleId, viewId } = router.query;
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const partialUpdateIssue = useCallback(
|
||||
(formData: Partial<IIssue>, issueId: string) => {
|
||||
(formData: Partial<IIssue>, issue: IIssue) => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
||||
if (cycleId)
|
||||
mutate<
|
||||
| {
|
||||
[key: string]: IIssue[];
|
||||
}
|
||||
| IIssue[]
|
||||
>(
|
||||
CYCLE_ISSUES_WITH_PARAMS(cycleId as string, params),
|
||||
(prevData) =>
|
||||
handleIssuesMutation(
|
||||
formData,
|
||||
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),
|
||||
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 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,
|
||||
groupTitle ?? "",
|
||||
selectedGroup,
|
||||
index,
|
||||
orderBy,
|
||||
prevData
|
||||
);
|
||||
},
|
||||
),
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
issuesService
|
||||
.patchIssue(workspaceSlug as string, projectId as string, issueId, formData, user)
|
||||
.patchIssue(workspaceSlug as string, projectId as string, issue.id, formData, user)
|
||||
.then(() => {
|
||||
if (cycleId) {
|
||||
mutate(CYCLE_ISSUES_WITH_PARAMS(cycleId as string, params));
|
||||
mutate(CYCLE_DETAILS(cycleId as string));
|
||||
} 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 as string, params));
|
||||
mutate(fetchKey);
|
||||
|
||||
if (cycleId) mutate(CYCLE_DETAILS(cycleId as string));
|
||||
if (moduleId) mutate(MODULE_DETAILS(moduleId as string));
|
||||
});
|
||||
},
|
||||
[
|
||||
@ -188,6 +183,7 @@ export const SingleBoardIssue: React.FC<Props> = ({
|
||||
projectId,
|
||||
cycleId,
|
||||
moduleId,
|
||||
viewId,
|
||||
groupTitle,
|
||||
index,
|
||||
selectedGroup,
|
||||
@ -338,11 +334,8 @@ export const SingleBoardIssue: React.FC<Props> = ({
|
||||
{issue.project_detail.identifier}-{issue.sequence_id}
|
||||
</div>
|
||||
)}
|
||||
<h5
|
||||
className="break-all text-sm group-hover:text-brand-accent"
|
||||
style={{ lineClamp: 3, WebkitLineClamp: 3 }}
|
||||
>
|
||||
{truncateText(issue.name, 100)}
|
||||
<h5 className="text-sm group-hover:text-brand-accent break-words line-clamp-3">
|
||||
{issue.name}
|
||||
</h5>
|
||||
</a>
|
||||
</Link>
|
||||
@ -373,30 +366,20 @@ export const SingleBoardIssue: React.FC<Props> = ({
|
||||
isNotAllowed={isNotAllowed}
|
||||
/>
|
||||
)}
|
||||
{properties.labels && issue.label_details.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{issue.label_details.map((label) => (
|
||||
<div
|
||||
key={label.id}
|
||||
className="group flex items-center gap-1 rounded-2xl border border-brand-base px-2 py-0.5 text-xs text-brand-secondary"
|
||||
>
|
||||
<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.labels && (
|
||||
<ViewLabelSelect
|
||||
issue={issue}
|
||||
partialUpdateIssue={partialUpdateIssue}
|
||||
isNotAllowed={isNotAllowed}
|
||||
user={user}
|
||||
selfPositioned
|
||||
/>
|
||||
)}
|
||||
{properties.assignee && (
|
||||
<ViewAssigneeSelect
|
||||
issue={issue}
|
||||
partialUpdateIssue={partialUpdateIssue}
|
||||
isNotAllowed={isNotAllowed}
|
||||
tooltipPosition="left"
|
||||
user={user}
|
||||
selfPositioned
|
||||
/>
|
||||
|
@ -12,6 +12,8 @@ import { Combobox, Dialog, Transition } from "@headlessui/react";
|
||||
import issuesServices from "services/issues.service";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
import useIssuesView from "hooks/use-issues-view";
|
||||
import useCalendarIssuesView from "hooks/use-calendar-issues-view";
|
||||
// ui
|
||||
import { DangerButton, SecondaryButton } from "components/ui";
|
||||
// icons
|
||||
@ -20,7 +22,15 @@ import { LayerDiagonalIcon } from "components/icons";
|
||||
// types
|
||||
import { ICurrentUserResponse, IIssue } from "types";
|
||||
// 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 = {
|
||||
delete_issue_ids: string[];
|
||||
@ -36,7 +46,7 @@ export const BulkDeleteIssuesModal: React.FC<Props> = ({ isOpen, setIsOpen, user
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
const { workspaceSlug, projectId, cycleId, moduleId, viewId } = router.query;
|
||||
|
||||
const { data: issues } = useSWR(
|
||||
workspaceSlug && projectId
|
||||
@ -48,6 +58,9 @@ export const BulkDeleteIssuesModal: React.FC<Props> = ({ isOpen, setIsOpen, user
|
||||
);
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
const { issueView, params } = useIssuesView();
|
||||
const { params: calendarParams } = useCalendarIssuesView();
|
||||
const { order_by, group_by, ...viewGanttParams } = params;
|
||||
|
||||
const {
|
||||
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[] =
|
||||
query === ""
|
||||
? issues ?? []
|
||||
@ -72,48 +160,6 @@ export const BulkDeleteIssuesModal: React.FC<Props> = ({ isOpen, setIsOpen, user
|
||||
.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 (
|
||||
<Transition.Root show={isOpen} as={React.Fragment} afterLeave={() => setQuery("")} appear>
|
||||
<Dialog as="div" className="relative z-20" onClose={handleClose}>
|
||||
|
@ -19,6 +19,7 @@ import {
|
||||
ViewAssigneeSelect,
|
||||
ViewDueDateSelect,
|
||||
ViewEstimateSelect,
|
||||
ViewLabelSelect,
|
||||
ViewPrioritySelect,
|
||||
ViewStateSelect,
|
||||
} from "components/issues";
|
||||
@ -28,12 +29,13 @@ import { LayerDiagonalIcon } from "components/icons";
|
||||
// helper
|
||||
import { copyTextToClipboard, truncateText } from "helpers/string.helper";
|
||||
// type
|
||||
import { ICurrentUserResponse, IIssue } from "types";
|
||||
import { ICurrentUserResponse, IIssue, ISubIssueResponse } from "types";
|
||||
// fetch-keys
|
||||
import {
|
||||
CYCLE_ISSUES_WITH_PARAMS,
|
||||
MODULE_ISSUES_WITH_PARAMS,
|
||||
PROJECT_ISSUES_LIST_WITH_PARAMS,
|
||||
SUB_ISSUES,
|
||||
VIEW_ISSUES,
|
||||
} from "constants/fetch-keys";
|
||||
|
||||
@ -68,7 +70,7 @@ export const SingleCalendarIssue: React.FC<Props> = ({
|
||||
const [properties] = useIssuesProperties(workspaceSlug as string, projectId as string);
|
||||
|
||||
const partialUpdateIssue = useCallback(
|
||||
(formData: Partial<IIssue>, issueId: string) => {
|
||||
(formData: Partial<IIssue>, issue: IIssue) => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
||||
const fetchKey = cycleId
|
||||
@ -79,25 +81,54 @@ export const SingleCalendarIssue: React.FC<Props> = ({
|
||||
? VIEW_ISSUES(viewId.toString(), params)
|
||||
: PROJECT_ISSUES_LIST_WITH_PARAMS(projectId.toString(), params);
|
||||
|
||||
mutate<IIssue[]>(
|
||||
fetchKey,
|
||||
(prevData) =>
|
||||
(prevData ?? []).map((p) => {
|
||||
if (p.id === issueId) {
|
||||
return {
|
||||
...p,
|
||||
...formData,
|
||||
assignees: formData?.assignees_list ?? p.assignees,
|
||||
};
|
||||
}
|
||||
if (issue.parent) {
|
||||
mutate<ISubIssueResponse>(
|
||||
SUB_ISSUES(issue.parent.toString()),
|
||||
(prevData) => {
|
||||
if (!prevData) return prevData;
|
||||
|
||||
return p;
|
||||
}),
|
||||
false
|
||||
);
|
||||
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,
|
||||
assignees: formData?.assignees_list ?? p.assignees,
|
||||
};
|
||||
}
|
||||
|
||||
return p;
|
||||
}),
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
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(() => {
|
||||
mutate(fetchKey);
|
||||
})
|
||||
@ -207,25 +238,14 @@ export const SingleCalendarIssue: React.FC<Props> = ({
|
||||
isNotAllowed={isNotAllowed}
|
||||
/>
|
||||
)}
|
||||
{properties.labels && issue.label_details.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{issue.label_details.map((label) => (
|
||||
<span
|
||||
key={label.id}
|
||||
className="group flex items-center gap-1 rounded-2xl border border-brand-base px-2 py-0.5 text-xs text-brand-secondary"
|
||||
>
|
||||
<span
|
||||
className="h-1.5 w-1.5 rounded-full"
|
||||
style={{
|
||||
backgroundColor: label?.color && label.color !== "" ? label.color : "#000",
|
||||
}}
|
||||
/>
|
||||
{label.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
""
|
||||
{properties.labels && (
|
||||
<ViewLabelSelect
|
||||
issue={issue}
|
||||
partialUpdateIssue={partialUpdateIssue}
|
||||
position="left"
|
||||
user={user}
|
||||
isNotAllowed={isNotAllowed}
|
||||
/>
|
||||
)}
|
||||
{properties.assignee && (
|
||||
<ViewAssigneeSelect
|
||||
|
@ -1,23 +1,24 @@
|
||||
import React, { useState } from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import { mutate } from "swr";
|
||||
|
||||
// react-hook-form
|
||||
import { Controller, SubmitHandler, useForm } from "react-hook-form";
|
||||
// headless ui
|
||||
import { Combobox, Dialog, Transition } from "@headlessui/react";
|
||||
// services
|
||||
import projectService from "services/project.service";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
import useIssuesView from "hooks/use-issues-view";
|
||||
import useDebounce from "hooks/use-debounce";
|
||||
// ui
|
||||
import { PrimaryButton, SecondaryButton } from "components/ui";
|
||||
import { Loader, PrimaryButton, SecondaryButton } from "components/ui";
|
||||
// icons
|
||||
import { MagnifyingGlassIcon } from "@heroicons/react/24/outline";
|
||||
import { MagnifyingGlassIcon, XMarkIcon } from "@heroicons/react/24/outline";
|
||||
import { LayerDiagonalIcon } from "components/icons";
|
||||
// types
|
||||
import { IIssue } from "types";
|
||||
import { ISearchIssueResponse, TProjectIssuesSearchParams } from "types";
|
||||
// fetch-keys
|
||||
import {
|
||||
CYCLE_DETAILS,
|
||||
@ -26,27 +27,30 @@ import {
|
||||
MODULE_ISSUES_WITH_PARAMS,
|
||||
} from "constants/fetch-keys";
|
||||
|
||||
type FormInput = {
|
||||
issues: string[];
|
||||
};
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
handleClose: () => void;
|
||||
issues: IIssue[];
|
||||
handleOnSubmit: any;
|
||||
searchParams: Partial<TProjectIssuesSearchParams>;
|
||||
handleOnSubmit: (data: ISearchIssueResponse[]) => Promise<void>;
|
||||
};
|
||||
|
||||
export const ExistingIssuesListModal: React.FC<Props> = ({
|
||||
isOpen,
|
||||
handleClose: onClose,
|
||||
issues,
|
||||
searchParams,
|
||||
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 { cycleId, moduleId } = router.query;
|
||||
const { workspaceSlug, projectId, cycleId, moduleId } = router.query;
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
@ -54,37 +58,30 @@ export const ExistingIssuesListModal: React.FC<Props> = ({
|
||||
|
||||
const handleClose = () => {
|
||||
onClose();
|
||||
setQuery("");
|
||||
reset();
|
||||
setSearchTerm("");
|
||||
setSelectedIssues([]);
|
||||
};
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
reset,
|
||||
control,
|
||||
formState: { isSubmitting },
|
||||
} = useForm<FormInput>({
|
||||
defaultValues: {
|
||||
issues: [],
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit: SubmitHandler<FormInput> = async (data) => {
|
||||
if (!data.issues || data.issues.length === 0) {
|
||||
const onSubmit = async () => {
|
||||
if (selectedIssues.length === 0) {
|
||||
setToastAlert({
|
||||
title: "Error",
|
||||
type: "error",
|
||||
message: "Please select atleast one issue",
|
||||
title: "Error!",
|
||||
message: "Please select at least one issue.",
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await handleOnSubmit(data);
|
||||
setIsSubmitting(true);
|
||||
|
||||
await handleOnSubmit(selectedIssues).finally(() => setIsSubmitting(false));
|
||||
|
||||
if (cycleId) {
|
||||
mutate(CYCLE_ISSUES_WITH_PARAMS(cycleId as string, params));
|
||||
mutate(CYCLE_DETAILS(cycleId as string));
|
||||
}
|
||||
|
||||
if (moduleId) {
|
||||
mutate(MODULE_ISSUES_WITH_PARAMS(moduleId as string, params));
|
||||
mutate(MODULE_DETAILS(moduleId as string));
|
||||
@ -95,18 +92,45 @@ export const ExistingIssuesListModal: React.FC<Props> = ({
|
||||
setToastAlert({
|
||||
title: "Success",
|
||||
type: "success",
|
||||
message: `Issue${data.issues.length > 1 ? "s" : ""} added successfully`,
|
||||
message: `Issue${selectedIssues.length > 1 ? "s" : ""} added successfully`,
|
||||
});
|
||||
};
|
||||
|
||||
const filteredIssues: IIssue[] =
|
||||
query === ""
|
||||
? issues ?? []
|
||||
: issues.filter((issue) => issue.name.toLowerCase().includes(query.toLowerCase())) ?? [];
|
||||
useEffect(() => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
||||
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 (
|
||||
<>
|
||||
<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}>
|
||||
<Transition.Child
|
||||
as={React.Fragment}
|
||||
@ -131,90 +155,136 @@ export const ExistingIssuesListModal: React.FC<Props> = ({
|
||||
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>
|
||||
<Controller
|
||||
control={control}
|
||||
name="issues"
|
||||
render={({ field }) => (
|
||||
<Combobox as="div" {...field} 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)}
|
||||
/>
|
||||
</div>
|
||||
<Combobox
|
||||
as="div"
|
||||
onChange={(val: ISearchIssueResponse) => {
|
||||
if (selectedIssues.some((i) => i.id === val.id))
|
||||
setSelectedIssues((prevData) => prevData.filter((i) => i.id !== val.id));
|
||||
else setSelectedIssues((prevData) => [...prevData, 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"
|
||||
/>
|
||||
<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="Type to search..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(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="mb-2 px-3 text-xs font-medium text-brand-base">
|
||||
Select issues to add
|
||||
</h2>
|
||||
)}
|
||||
<ul className="text-sm text-brand-base">
|
||||
{filteredIssues.map((issue) => (
|
||||
<Combobox.Option
|
||||
key={issue.id}
|
||||
as="label"
|
||||
htmlFor={`issue-${issue.id}`}
|
||||
value={issue.id}
|
||||
className={({ active, selected }) =>
|
||||
`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" : ""
|
||||
} ${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.name}
|
||||
</>
|
||||
)}
|
||||
</Combobox.Option>
|
||||
))}
|
||||
</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>
|
||||
<div className="text-brand-secondary text-[0.825rem] p-2">
|
||||
{selectedIssues.length > 0 ? (
|
||||
<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"
|
||||
>
|
||||
{issue.project__identifier}-{issue.sequence_id}
|
||||
<button
|
||||
type="button"
|
||||
className="group p-1"
|
||||
onClick={() =>
|
||||
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>
|
||||
)}
|
||||
/>
|
||||
{filteredIssues.length > 0 && (
|
||||
<div className="flex items-center justify-end gap-2 p-3">
|
||||
<SecondaryButton onClick={handleClose}>Cancel</SecondaryButton>
|
||||
<PrimaryButton onClick={handleSubmit(onSubmit)} loading={isSubmitting}>
|
||||
{isSubmitting ? "Adding..." : "Add selected issues"}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<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
|
||||
key={issue.id}
|
||||
as="label"
|
||||
htmlFor={`issue-${issue.id}`}
|
||||
value={issue}
|
||||
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" : ""
|
||||
} ${selected ? "text-brand-base" : ""}`
|
||||
}
|
||||
>
|
||||
<input type="checkbox" checked={selected} readOnly />
|
||||
<span
|
||||
className="block h-1.5 w-1.5 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor: issue.state__color,
|
||||
}}
|
||||
/>
|
||||
<span className="flex-shrink-0 text-xs">
|
||||
{issue.project__identifier}-{issue.sequence_id}
|
||||
</span>
|
||||
{issue.name}
|
||||
</Combobox.Option>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</Combobox.Options>
|
||||
</Combobox>
|
||||
{selectedIssues.length > 0 && (
|
||||
<div className="flex items-center justify-end gap-2 p-3">
|
||||
<SecondaryButton onClick={handleClose}>Cancel</SecondaryButton>
|
||||
<PrimaryButton onClick={onSubmit} loading={isSubmitting}>
|
||||
{isSubmitting ? "Adding..." : "Add selected issues"}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
)}
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
|
@ -135,7 +135,7 @@ export const FilterList: React.FC<any> = ({ filters, setFilters }) => {
|
||||
}`}
|
||||
>
|
||||
<span>{getPriorityIcon(priority)}</span>
|
||||
<span>{priority ? priority : "None"}</span>
|
||||
<span>{priority === "null" ? "None" : priority}</span>
|
||||
<span
|
||||
className="cursor-pointer"
|
||||
onClick={() =>
|
||||
|
@ -2,6 +2,7 @@ export * from "./board-view";
|
||||
export * from "./calendar-view";
|
||||
export * from "./gantt-chart-view";
|
||||
export * from "./list-view";
|
||||
export * from "./spreadsheet-view";
|
||||
export * from "./sidebar";
|
||||
export * from "./bulk-delete-issues-modal";
|
||||
export * from "./existing-issues-list-modal";
|
||||
|
@ -10,7 +10,7 @@ import { Popover, Transition } from "@headlessui/react";
|
||||
// components
|
||||
import { SelectFilters } from "components/views";
|
||||
// ui
|
||||
import { CustomMenu, ToggleSwitch } from "components/ui";
|
||||
import { CustomMenu, Icon, ToggleSwitch } from "components/ui";
|
||||
// icons
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
@ -83,6 +83,15 @@ export const IssuesFilterView: React.FC = () => {
|
||||
>
|
||||
<CalendarDaysIcon className="h-4 w-4 text-brand-secondary" />
|
||||
</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
|
||||
type="button"
|
||||
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"
|
||||
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="space-y-4 pb-3 text-xs">
|
||||
{issueView !== "calendar" && (
|
||||
{issueView !== "calendar" && issueView !== "spreadsheet" && (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-brand-secondary">Group by</h4>
|
||||
@ -221,7 +230,7 @@ export const IssuesFilterView: React.FC = () => {
|
||||
</CustomMenu>
|
||||
</div>
|
||||
|
||||
{issueView !== "calendar" && (
|
||||
{issueView !== "calendar" && issueView !== "spreadsheet" && (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-brand-secondary">Show empty states</h4>
|
||||
@ -252,6 +261,13 @@ export const IssuesFilterView: React.FC = () => {
|
||||
{Object.keys(properties).map((key) => {
|
||||
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 (
|
||||
<button
|
||||
key={key}
|
||||
|
@ -19,7 +19,14 @@ import useToast from "hooks/use-toast";
|
||||
import useIssuesView from "hooks/use-issues-view";
|
||||
import useUserAuth from "hooks/use-user-auth";
|
||||
// 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 { CreateUpdateViewModal } from "components/views";
|
||||
import { CycleIssuesGanttChartView, TransferIssues, TransferIssuesModal } from "components/cycles";
|
||||
@ -283,9 +290,17 @@ export const IssuesView: React.FC<Props> = ({
|
||||
const addIssueToState = useCallback(
|
||||
(groupTitle: string) => {
|
||||
setCreateIssueModal(true);
|
||||
|
||||
let preloadedValue: string | string[] = groupTitle;
|
||||
|
||||
if (selectedGroup === "labels") {
|
||||
if (groupTitle === "None") preloadedValue = [];
|
||||
else preloadedValue = [groupTitle];
|
||||
}
|
||||
|
||||
if (selectedGroup)
|
||||
setPreloadedData({
|
||||
[selectedGroup]: groupTitle,
|
||||
[selectedGroup]: preloadedValue,
|
||||
actionType: "createIssue",
|
||||
});
|
||||
else setPreloadedData({ actionType: "createIssue" });
|
||||
@ -443,7 +458,6 @@ export const IssuesView: React.FC<Props> = ({
|
||||
/>
|
||||
<CreateUpdateIssueModal
|
||||
isOpen={editIssueModal && issueToEdit?.actionType !== "delete"}
|
||||
prePopulateData={{ ...issueToEdit }}
|
||||
handleClose={() => setEditIssueModal(false)}
|
||||
data={issueToEdit}
|
||||
/>
|
||||
@ -556,6 +570,16 @@ export const IssuesView: React.FC<Props> = ({
|
||||
user={user}
|
||||
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 />
|
||||
)}
|
||||
|
@ -14,6 +14,7 @@ import {
|
||||
ViewAssigneeSelect,
|
||||
ViewDueDateSelect,
|
||||
ViewEstimateSelect,
|
||||
ViewLabelSelect,
|
||||
ViewPrioritySelect,
|
||||
ViewStateSelect,
|
||||
} from "components/issues/view-select";
|
||||
@ -36,7 +37,7 @@ import { LayerDiagonalIcon } from "components/icons";
|
||||
import { copyTextToClipboard, truncateText } from "helpers/string.helper";
|
||||
import { handleIssuesMutation } from "constants/issue";
|
||||
// types
|
||||
import { ICurrentUserResponse, IIssue, Properties, UserAuth } from "types";
|
||||
import { ICurrentUserResponse, IIssue, ISubIssueResponse, Properties, UserAuth } from "types";
|
||||
// fetch-keys
|
||||
import {
|
||||
CYCLE_DETAILS,
|
||||
@ -44,6 +45,8 @@ import {
|
||||
MODULE_DETAILS,
|
||||
MODULE_ISSUES_WITH_PARAMS,
|
||||
PROJECT_ISSUES_LIST_WITH_PARAMS,
|
||||
SUB_ISSUES,
|
||||
VIEW_ISSUES,
|
||||
} from "constants/fetch-keys";
|
||||
|
||||
type Props = {
|
||||
@ -80,24 +83,53 @@ export const SingleListIssue: React.FC<Props> = ({
|
||||
const [contextMenuPosition, setContextMenuPosition] = useState({ x: 0, y: 0 });
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, cycleId, moduleId } = router.query;
|
||||
const { workspaceSlug, projectId, cycleId, moduleId, viewId } = router.query;
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const { groupByProperty: selectedGroup, orderBy, params } = useIssueView();
|
||||
|
||||
const partialUpdateIssue = useCallback(
|
||||
(formData: Partial<IIssue>, issueId: string) => {
|
||||
(formData: Partial<IIssue>, issue: IIssue) => {
|
||||
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<
|
||||
| {
|
||||
[key: string]: IIssue[];
|
||||
}
|
||||
| IIssue[]
|
||||
>(
|
||||
CYCLE_ISSUES_WITH_PARAMS(cycleId as string, params),
|
||||
fetchKey,
|
||||
(prevData) =>
|
||||
handleIssuesMutation(
|
||||
formData,
|
||||
@ -109,49 +141,15 @@ export const SingleListIssue: React.FC<Props> = ({
|
||||
),
|
||||
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
|
||||
.patchIssue(workspaceSlug as string, projectId as string, issueId, formData, user)
|
||||
.patchIssue(workspaceSlug as string, projectId as string, issue.id, formData, user)
|
||||
.then(() => {
|
||||
if (cycleId) {
|
||||
mutate(CYCLE_ISSUES_WITH_PARAMS(cycleId as string, params));
|
||||
mutate(CYCLE_DETAILS(cycleId as string));
|
||||
} 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 as string, params));
|
||||
mutate(fetchKey);
|
||||
|
||||
if (cycleId) mutate(CYCLE_DETAILS(cycleId as string));
|
||||
if (moduleId) mutate(MODULE_DETAILS(moduleId as string));
|
||||
});
|
||||
},
|
||||
[
|
||||
@ -159,6 +157,7 @@ export const SingleListIssue: React.FC<Props> = ({
|
||||
projectId,
|
||||
cycleId,
|
||||
moduleId,
|
||||
viewId,
|
||||
groupTitle,
|
||||
index,
|
||||
selectedGroup,
|
||||
@ -275,25 +274,14 @@ export const SingleListIssue: React.FC<Props> = ({
|
||||
isNotAllowed={isNotAllowed}
|
||||
/>
|
||||
)}
|
||||
{properties.labels && issue.label_details.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{issue.label_details.map((label) => (
|
||||
<span
|
||||
key={label.id}
|
||||
className="group flex items-center gap-1 rounded-2xl border border-brand-base px-2 py-0.5 text-xs text-brand-secondary"
|
||||
>
|
||||
<span
|
||||
className="h-1.5 w-1.5 rounded-full"
|
||||
style={{
|
||||
backgroundColor: label?.color && label.color !== "" ? label.color : "#000",
|
||||
}}
|
||||
/>
|
||||
{label.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
""
|
||||
{properties.labels && (
|
||||
<ViewLabelSelect
|
||||
issue={issue}
|
||||
partialUpdateIssue={partialUpdateIssue}
|
||||
position="right"
|
||||
user={user}
|
||||
isNotAllowed={isNotAllowed}
|
||||
/>
|
||||
)}
|
||||
{properties.assignee && (
|
||||
<ViewAssigneeSelect
|
||||
|
@ -53,7 +53,7 @@ export const LinksList: React.FC<Props> = ({ links, handleDeleteLink, userAuth }
|
||||
<LinkIcon className="h-3.5 w-3.5" />
|
||||
</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">
|
||||
Added {timeAgo(link.created_at)}
|
||||
<br />
|
||||
|
@ -3,7 +3,7 @@ import React from "react";
|
||||
// ui
|
||||
import { LineGraph } from "components/ui";
|
||||
// helpers
|
||||
import { renderShortNumericDateFormat } from "helpers/date-time.helper";
|
||||
import { getDatesInRange, renderShortNumericDateFormat } from "helpers/date-time.helper";
|
||||
//types
|
||||
import { TCompletionChartDistribution } from "types";
|
||||
|
||||
@ -46,6 +46,27 @@ const ProgressChart: React.FC<Props> = ({ distribution, startDate, endDate, tota
|
||||
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 (
|
||||
<div className="w-full flex justify-center items-center">
|
||||
<LineGraph
|
||||
@ -72,27 +93,38 @@ const ProgressChart: React.FC<Props> = ({ distribution, startDate, endDate, tota
|
||||
id: "ideal",
|
||||
color: "#a9bbd0",
|
||||
fill: "transparent",
|
||||
data: [
|
||||
{
|
||||
x: chartData[0].currentDate,
|
||||
y: totalIssues,
|
||||
},
|
||||
{
|
||||
x: chartData[chartData.length - 1].currentDate,
|
||||
y: 0,
|
||||
},
|
||||
],
|
||||
data:
|
||||
chartData.length > 0
|
||||
? [
|
||||
{
|
||||
x: chartData[0].currentDate,
|
||||
y: totalIssues,
|
||||
},
|
||||
{
|
||||
x: chartData[chartData.length - 1].currentDate,
|
||||
y: 0,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
},
|
||||
]}
|
||||
layers={["grid", "markers", "areas", DashedLine, "slices", "points", "axes", "legends"]}
|
||||
axisBottom={{
|
||||
tickValues: chartData.map((item, index) => (index % 2 === 0 ? item.currentDate : "")),
|
||||
tickValues: generateXAxisTickValues(),
|
||||
}}
|
||||
enablePoints={false}
|
||||
enableArea
|
||||
colors={(datum) => datum.color ?? "#3F76FF"}
|
||||
customYAxisTickValues={[0, totalIssues]}
|
||||
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={{
|
||||
background: "transparent",
|
||||
axis: {
|
||||
|
4
apps/app/components/core/spreadsheet-view/index.ts
Normal file
4
apps/app/components/core/spreadsheet-view/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export * from "./spreadsheet-view";
|
||||
export * from "./single-issue";
|
||||
export * from "./spreadsheet-columns";
|
||||
export * from "./spreadsheet-issues";
|
350
apps/app/components/core/spreadsheet-view/single-issue.tsx
Normal file
350
apps/app/components/core/spreadsheet-view/single-issue.tsx
Normal 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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
141
apps/app/components/core/spreadsheet-view/spreadsheet-view.tsx
Normal file
141
apps/app/components/core/spreadsheet-view/spreadsheet-view.tsx
Normal 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>
|
||||
);
|
||||
};
|
@ -101,6 +101,13 @@ export const ActiveCycleDetails: React.FC = () => {
|
||||
: null
|
||||
) as { data: IIssue[] | undefined };
|
||||
|
||||
if (!currentCycle)
|
||||
return (
|
||||
<Loader>
|
||||
<Loader.Item height="250px" />
|
||||
</Loader>
|
||||
);
|
||||
|
||||
if (!cycle)
|
||||
return (
|
||||
<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>
|
||||
<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)}
|
||||
</h3>
|
||||
</Tooltip>
|
||||
@ -395,82 +402,87 @@ export const ActiveCycleDetails: React.FC = () => {
|
||||
<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">
|
||||
{issues ? (
|
||||
issues.map((issue) => (
|
||||
<div
|
||||
key={issue.id}
|
||||
className="flex flex-wrap rounded-md items-center justify-between gap-2 border border-brand-base bg-brand-surface-1 px-3 py-1.5"
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div>
|
||||
issues.length > 0 ? (
|
||||
issues.map((issue) => (
|
||||
<div
|
||||
key={issue.id}
|
||||
className="flex flex-wrap rounded-md items-center justify-between gap-2 border border-brand-base bg-brand-surface-1 px-3 py-1.5"
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div>
|
||||
<Tooltip
|
||||
tooltipHeading="Issue ID"
|
||||
tooltipContent={`${issue.project_detail?.identifier}-${issue.sequence_id}`}
|
||||
>
|
||||
<span className="flex-shrink-0 text-xs text-brand-secondary">
|
||||
{issue.project_detail?.identifier}-{issue.sequence_id}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Tooltip
|
||||
tooltipHeading="Issue ID"
|
||||
tooltipContent={`${issue.project_detail?.identifier}-${issue.sequence_id}`}
|
||||
position="top-left"
|
||||
tooltipHeading="Title"
|
||||
tooltipContent={issue.name}
|
||||
>
|
||||
<span className="flex-shrink-0 text-xs text-brand-secondary">
|
||||
{issue.project_detail?.identifier}-{issue.sequence_id}
|
||||
<span className="text-[0.825rem] text-brand-base">
|
||||
{truncateText(issue.name, 30)}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Tooltip
|
||||
position="top-left"
|
||||
tooltipHeading="Title"
|
||||
tooltipContent={issue.name}
|
||||
>
|
||||
<span className="text-[0.825rem] text-brand-base">
|
||||
{truncateText(issue.name, 30)}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div
|
||||
className={`grid h-6 w-6 place-items-center items-center rounded border shadow-sm flex-shrink-0 ${
|
||||
issue.priority === "urgent"
|
||||
? "border-red-500/20 bg-red-500/20 text-red-500"
|
||||
: "border-orange-500/20 bg-orange-500/20 text-orange-500"
|
||||
}`}
|
||||
>
|
||||
{getPriorityIcon(issue.priority, "text-sm")}
|
||||
</div>
|
||||
{issue.label_details.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{issue.label_details.map((label) => (
|
||||
<span
|
||||
key={label.id}
|
||||
className="group flex items-center gap-1 rounded-2xl border border-brand-base px-2 py-0.5 text-xs text-brand-secondary"
|
||||
>
|
||||
<span
|
||||
className="h-1.5 w-1.5 rounded-full"
|
||||
style={{
|
||||
backgroundColor:
|
||||
label?.color && label.color !== "" ? label.color : "#000",
|
||||
}}
|
||||
/>
|
||||
{label.name}
|
||||
</span>
|
||||
))}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div
|
||||
className={`grid h-6 w-6 place-items-center items-center rounded border shadow-sm flex-shrink-0 ${
|
||||
issue.priority === "urgent"
|
||||
? "border-red-500/20 bg-red-500/20 text-red-500"
|
||||
: "border-orange-500/20 bg-orange-500/20 text-orange-500"
|
||||
}`}
|
||||
>
|
||||
{getPriorityIcon(issue.priority, "text-sm")}
|
||||
</div>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
<div className={`flex items-center gap-2 text-brand-secondary`}>
|
||||
{issue.assignees &&
|
||||
issue.assignees.length > 0 &&
|
||||
Array.isArray(issue.assignees) ? (
|
||||
<div className="-my-0.5 flex items-center justify-center gap-2">
|
||||
<AssigneesList
|
||||
users={issue.assignee_details}
|
||||
length={3}
|
||||
showLength={false}
|
||||
/>
|
||||
{issue.label_details.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{issue.label_details.map((label) => (
|
||||
<span
|
||||
key={label.id}
|
||||
className="group flex items-center gap-1 rounded-2xl border border-brand-base px-2 py-0.5 text-xs text-brand-secondary"
|
||||
>
|
||||
<span
|
||||
className="h-1.5 w-1.5 rounded-full"
|
||||
style={{
|
||||
backgroundColor:
|
||||
label?.color && label.color !== "" ? label.color : "#000",
|
||||
}}
|
||||
/>
|
||||
{label.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
<div className={`flex items-center gap-2 text-brand-secondary`}>
|
||||
{issue.assignees &&
|
||||
issue.assignees.length > 0 &&
|
||||
Array.isArray(issue.assignees) ? (
|
||||
<div className="-my-0.5 flex items-center justify-center gap-2">
|
||||
<AssigneesList
|
||||
users={issue.assignee_details}
|
||||
length={3}
|
||||
showLength={false}
|
||||
/>
|
||||
</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.Item height="50px" />
|
||||
@ -481,27 +493,29 @@ export const ActiveCycleDetails: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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 rounded-full bg-green-600"
|
||||
style={{
|
||||
width:
|
||||
issues &&
|
||||
`${
|
||||
(issues.filter((issue) => issue?.state_detail?.group === "completed")
|
||||
?.length /
|
||||
issues.length) *
|
||||
100 ?? 0
|
||||
}%`,
|
||||
}}
|
||||
/>
|
||||
{issues && issues.length > 0 && (
|
||||
<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 rounded-full bg-green-600"
|
||||
style={{
|
||||
width:
|
||||
issues &&
|
||||
`${
|
||||
(issues.filter((issue) => issue?.state_detail?.group === "completed")
|
||||
?.length /
|
||||
issues.length) *
|
||||
100 ?? 0
|
||||
}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-16 text-end text-xs text-brand-secondary">
|
||||
{issues?.filter((issue) => issue?.state_detail?.group === "completed")?.length} of{" "}
|
||||
{issues?.length}
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-16 text-end text-xs text-brand-secondary">
|
||||
{issues?.filter((issue) => issue?.state_detail?.group === "completed")?.length} of{" "}
|
||||
{issues?.length}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<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">
|
||||
|
@ -1,4 +1,4 @@
|
||||
import React from "react";
|
||||
import React, { Fragment } from "react";
|
||||
|
||||
// headless ui
|
||||
import { Tab } from "@headlessui/react";
|
||||
@ -32,6 +32,7 @@ export const ActiveCycleProgressStats: React.FC<Props> = ({ cycle }) => {
|
||||
|
||||
return (
|
||||
<Tab.Group
|
||||
as={Fragment}
|
||||
defaultIndex={currentValue(tab)}
|
||||
onChange={(i) => {
|
||||
switch (i) {
|
||||
@ -68,81 +69,87 @@ export const ActiveCycleProgressStats: React.FC<Props> = ({ cycle }) => {
|
||||
Labels
|
||||
</Tab>
|
||||
</Tab.List>
|
||||
<Tab.Panels className="flex w-full px-4 pb-4">
|
||||
<Tab.Panel
|
||||
as="div"
|
||||
className="flex flex-col w-full mt-2 gap-1 overflow-y-scroll items-center text-brand-secondary"
|
||||
>
|
||||
{cycle.distribution.assignees.map((assignee, index) => {
|
||||
if (assignee.assignee_id)
|
||||
return (
|
||||
<SingleProgressStats
|
||||
key={assignee.assignee_id}
|
||||
title={
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar
|
||||
user={{
|
||||
id: assignee.assignee_id,
|
||||
avatar: assignee.avatar ?? "",
|
||||
first_name: assignee.first_name ?? "",
|
||||
last_name: assignee.last_name ?? "",
|
||||
}}
|
||||
/>
|
||||
<span>{assignee.first_name}</span>
|
||||
</div>
|
||||
}
|
||||
completed={assignee.completed_issues}
|
||||
total={assignee.total_issues}
|
||||
/>
|
||||
);
|
||||
else
|
||||
return (
|
||||
<SingleProgressStats
|
||||
key={`unassigned-${index}`}
|
||||
title={
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-5 w-5 rounded-full border-2 border-brand-base bg-brand-surface-2">
|
||||
<img
|
||||
src="/user.png"
|
||||
height="100%"
|
||||
width="100%"
|
||||
className="rounded-full"
|
||||
alt="User"
|
||||
{cycle.total_issues > 0 ? (
|
||||
<Tab.Panels as={Fragment}>
|
||||
<Tab.Panel
|
||||
as="div"
|
||||
className="w-full gap-1 overflow-y-scroll items-center text-brand-secondary p-4"
|
||||
>
|
||||
{cycle.distribution.assignees.map((assignee, index) => {
|
||||
if (assignee.assignee_id)
|
||||
return (
|
||||
<SingleProgressStats
|
||||
key={assignee.assignee_id}
|
||||
title={
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar
|
||||
user={{
|
||||
id: assignee.assignee_id,
|
||||
avatar: assignee.avatar ?? "",
|
||||
first_name: assignee.first_name ?? "",
|
||||
last_name: assignee.last_name ?? "",
|
||||
}}
|
||||
/>
|
||||
<span>{assignee.first_name}</span>
|
||||
</div>
|
||||
<span>No assignee</span>
|
||||
</div>
|
||||
}
|
||||
completed={assignee.completed_issues}
|
||||
total={assignee.total_issues}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Tab.Panel>
|
||||
<Tab.Panel
|
||||
as="div"
|
||||
className="flex flex-col w-full mt-2 gap-1 overflow-y-scroll items-center text-brand-secondary"
|
||||
>
|
||||
{cycle.distribution.labels.map((label, index) => (
|
||||
<SingleProgressStats
|
||||
key={label.label_id ?? `no-label-${index}`}
|
||||
title={
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="block h-3 w-3 rounded-full"
|
||||
style={{
|
||||
backgroundColor: label.color ?? "transparent",
|
||||
}}
|
||||
}
|
||||
completed={assignee.completed_issues}
|
||||
total={assignee.total_issues}
|
||||
/>
|
||||
<span className="text-xs">{label.label_name ?? "No labels"}</span>
|
||||
</div>
|
||||
}
|
||||
completed={label.completed_issues}
|
||||
total={label.total_issues}
|
||||
/>
|
||||
))}
|
||||
</Tab.Panel>
|
||||
</Tab.Panels>
|
||||
);
|
||||
else
|
||||
return (
|
||||
<SingleProgressStats
|
||||
key={`unassigned-${index}`}
|
||||
title={
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-5 w-5 rounded-full border-2 border-brand-base bg-brand-surface-2">
|
||||
<img
|
||||
src="/user.png"
|
||||
height="100%"
|
||||
width="100%"
|
||||
className="rounded-full"
|
||||
alt="User"
|
||||
/>
|
||||
</div>
|
||||
<span>No assignee</span>
|
||||
</div>
|
||||
}
|
||||
completed={assignee.completed_issues}
|
||||
total={assignee.total_issues}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Tab.Panel>
|
||||
<Tab.Panel
|
||||
as="div"
|
||||
className="w-full gap-1 overflow-y-scroll items-center text-brand-secondary p-4"
|
||||
>
|
||||
{cycle.distribution.labels.map((label, index) => (
|
||||
<SingleProgressStats
|
||||
key={label.label_id ?? `no-label-${index}`}
|
||||
title={
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="block h-3 w-3 rounded-full"
|
||||
style={{
|
||||
backgroundColor: label.color ?? "transparent",
|
||||
}}
|
||||
/>
|
||||
<span className="text-xs">{label.label_name ?? "No labels"}</span>
|
||||
</div>
|
||||
}
|
||||
completed={label.completed_issues}
|
||||
total={label.total_issues}
|
||||
/>
|
||||
))}
|
||||
</Tab.Panel>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
@ -143,7 +143,7 @@ export const DeleteCycleModal: React.FC<TConfirmCycleDeletionProps> = ({
|
||||
<div className="mt-2">
|
||||
<p className="text-sm text-brand-secondary">
|
||||
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}
|
||||
</span>
|
||||
? All of the data related to the cycle will be permanently removed. This
|
||||
|
@ -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 items-start justify-start 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>
|
||||
{!isCompleted && (
|
||||
<CustomMenu.MenuItem onClick={() => setCycleDeleteModal(true)}>
|
||||
@ -427,7 +431,7 @@ export const CycleDetailsSidebar: React.FC<Props> = ({
|
||||
</CustomMenu>
|
||||
</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}
|
||||
</span>
|
||||
</div>
|
||||
|
@ -150,8 +150,8 @@ export const SingleCycleCard: React.FC<TSingleStatProps> = ({
|
||||
}`}
|
||||
/>
|
||||
</span>
|
||||
<Tooltip tooltipContent={cycle.name} className="break-all" position="top-left">
|
||||
<h3 className="break-all text-lg font-semibold">
|
||||
<Tooltip tooltipContent={cycle.name} className="break-words" position="top-left">
|
||||
<h3 className="break-words text-lg font-semibold">
|
||||
{truncateText(cycle.name, 15)}
|
||||
</h3>
|
||||
</Tooltip>
|
||||
|
@ -172,13 +172,19 @@ export const SingleCycleList: React.FC<TSingleStatProps> = ({
|
||||
: ""
|
||||
}`}
|
||||
/>
|
||||
<div>
|
||||
<Tooltip tooltipContent={cycle.name} className="break-all" position="top-left">
|
||||
<h3 className="break-all text-base font-semibold">
|
||||
<div className="max-w-2xl">
|
||||
<Tooltip
|
||||
tooltipContent={cycle.name}
|
||||
className="break-words"
|
||||
position="top-left"
|
||||
>
|
||||
<h3 className="break-words w-full text-base font-semibold">
|
||||
{truncateText(cycle.name, 70)}
|
||||
</h3>
|
||||
</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>
|
||||
</span>
|
||||
<span className="flex items-center gap-4 capitalize">
|
||||
@ -282,12 +288,18 @@ export const SingleCycleList: React.FC<TSingleStatProps> = ({
|
||||
>
|
||||
{cycleStatus === "current" ? (
|
||||
<span className="flex gap-1">
|
||||
<RadialProgressBar
|
||||
progress={(cycle.completed_issues / cycle.total_issues) * 100}
|
||||
/>
|
||||
<span>
|
||||
{Math.floor((cycle.completed_issues / cycle.total_issues) * 100)} %
|
||||
</span>
|
||||
{cycle.total_issues > 0 ? (
|
||||
<>
|
||||
<RadialProgressBar
|
||||
progress={(cycle.completed_issues / cycle.total_issues) * 100}
|
||||
/>
|
||||
<span>
|
||||
{Math.floor((cycle.completed_issues / cycle.total_issues) * 100)} %
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="normal-case">No issues present</span>
|
||||
)}
|
||||
</span>
|
||||
) : cycleStatus === "upcoming" ? (
|
||||
<span className="flex gap-1">
|
||||
|
@ -74,9 +74,9 @@ export const DeleteEstimateModal: React.FC<Props> = ({
|
||||
</span>
|
||||
</div>
|
||||
<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-{" "}
|
||||
<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.
|
||||
This action cannot be undone.
|
||||
</p>
|
||||
|
@ -18,7 +18,7 @@ export const GanttChartBlocks: FC<{
|
||||
|
||||
return (
|
||||
<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` }}
|
||||
>
|
||||
<div className="w-full">
|
||||
|
92
apps/app/components/inbox/accept-issue-modal.tsx
Normal file
92
apps/app/components/inbox/accept-issue-modal.tsx
Normal 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>
|
||||
);
|
||||
};
|
@ -72,7 +72,7 @@ export const DeclineIssueModal: React.FC<Props> = ({ isOpen, handleClose, data,
|
||||
<span>
|
||||
<p className="text-sm text-brand-secondary">
|
||||
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}
|
||||
</span>
|
||||
{""}? This action cannot be undone.
|
||||
|
@ -127,7 +127,7 @@ export const DeleteIssueModal: React.FC<Props> = ({ isOpen, handleClose, data })
|
||||
<span>
|
||||
<p className="text-sm text-brand-secondary">
|
||||
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}
|
||||
</span>
|
||||
{""}? The issue will only be deleted from the inbox and this action cannot be
|
||||
|
@ -1,63 +1,81 @@
|
||||
// hooks
|
||||
import useInboxView from "hooks/use-inbox-view";
|
||||
// ui
|
||||
import { MultiLevelDropdown } from "components/ui";
|
||||
// icons
|
||||
import { getPriorityIcon } from "components/icons";
|
||||
// types
|
||||
import { IInboxFilterOptions } from "types";
|
||||
// constants
|
||||
import { PRIORITIES } from "constants/project";
|
||||
import { INBOX_STATUS } from "constants/inbox";
|
||||
|
||||
type Props = {
|
||||
filters: Partial<IInboxFilterOptions>;
|
||||
onSelect: (option: any) => void;
|
||||
direction?: "left" | "right";
|
||||
height?: "sm" | "md" | "rg" | "lg";
|
||||
};
|
||||
export const FiltersDropdown: React.FC = () => {
|
||||
const { filters, setFilters, filtersLength } = useInboxView();
|
||||
|
||||
export const FiltersDropdown: React.FC<Props> = ({ filters, onSelect, direction, height }) => (
|
||||
<MultiLevelDropdown
|
||||
label="Filters"
|
||||
onSelect={onSelect}
|
||||
direction={direction}
|
||||
height={height}
|
||||
options={[
|
||||
{
|
||||
id: "priority",
|
||||
label: "Priority",
|
||||
value: PRIORITIES,
|
||||
children: [
|
||||
...PRIORITIES.map((priority) => ({
|
||||
id: priority ?? "none",
|
||||
label: (
|
||||
<div className="flex items-center gap-2">
|
||||
{getPriorityIcon(priority)} {priority ?? "None"}
|
||||
</div>
|
||||
),
|
||||
value: {
|
||||
key: "priority",
|
||||
value: priority,
|
||||
},
|
||||
selected: filters?.priority?.includes(priority ?? "none"),
|
||||
})),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "inbox_status",
|
||||
label: "Status",
|
||||
value: INBOX_STATUS.map((status) => status.value),
|
||||
children: [
|
||||
...INBOX_STATUS.map((status) => ({
|
||||
id: status.key,
|
||||
label: status.label,
|
||||
value: {
|
||||
key: "inbox_status",
|
||||
value: status.value,
|
||||
},
|
||||
selected: filters?.inbox_status?.includes(status.value),
|
||||
})),
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<div className="relative">
|
||||
<MultiLevelDropdown
|
||||
label="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"
|
||||
options={[
|
||||
{
|
||||
id: "priority",
|
||||
label: "Priority",
|
||||
value: PRIORITIES,
|
||||
children: [
|
||||
...PRIORITIES.map((priority) => ({
|
||||
id: priority === null ? "null" : priority,
|
||||
label: (
|
||||
<div className="flex items-center gap-2">
|
||||
{getPriorityIcon(priority)} {priority ?? "None"}
|
||||
</div>
|
||||
),
|
||||
value: {
|
||||
key: "priority",
|
||||
value: priority === null ? "null" : priority,
|
||||
},
|
||||
selected: filters?.priority?.includes(priority === null ? "null" : priority),
|
||||
})),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "inbox_status",
|
||||
label: "Status",
|
||||
value: INBOX_STATUS.map((status) => status.value),
|
||||
children: [
|
||||
...INBOX_STATUS.map((status) => ({
|
||||
id: status.key,
|
||||
label: status.label,
|
||||
value: {
|
||||
key: "inbox_status",
|
||||
value: status.value,
|
||||
},
|
||||
selected: filters?.inbox_status?.includes(status.value),
|
||||
})),
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{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>
|
||||
);
|
||||
};
|
||||
|
@ -2,17 +2,28 @@ import { useEffect, useState } from "react";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import { mutate } from "swr";
|
||||
|
||||
// react-datepicker
|
||||
import DatePicker from "react-datepicker";
|
||||
// headless ui
|
||||
import { Popover } from "@headlessui/react";
|
||||
// contexts
|
||||
import { useProjectMyMembership } from "contexts/project-member.context";
|
||||
// services
|
||||
import inboxServices from "services/inbox.service";
|
||||
// hooks
|
||||
import useInboxView from "hooks/use-inbox-view";
|
||||
import useUserAuth from "hooks/use-user-auth";
|
||||
import useToast from "hooks/use-toast";
|
||||
// components
|
||||
import { FiltersDropdown } from "components/inbox";
|
||||
import {
|
||||
AcceptIssueModal,
|
||||
DeclineIssueModal,
|
||||
DeleteIssueModal,
|
||||
FiltersDropdown,
|
||||
SelectDuplicateInboxIssueModal,
|
||||
} from "components/inbox";
|
||||
// ui
|
||||
import { PrimaryButton, SecondaryButton } from "components/ui";
|
||||
// icons
|
||||
@ -26,47 +37,76 @@ import {
|
||||
TrashIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
// types
|
||||
import type { IInboxIssue } from "types";
|
||||
import type { IInboxIssueDetail, TInboxStatus } from "types";
|
||||
// fetch-keys
|
||||
import { INBOX_ISSUE_DETAILS } from "constants/fetch-keys";
|
||||
|
||||
type Props = {
|
||||
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);
|
||||
export const InboxActionHeader = () => {
|
||||
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 { inboxIssueId } = router.query;
|
||||
const { workspaceSlug, projectId, inboxId, inboxIssueId } = router.query;
|
||||
|
||||
const { memberRole } = useProjectMyMembership();
|
||||
const { filters, setFilters, filtersLength } = useInboxView();
|
||||
const { user } = useUserAuth();
|
||||
const { memberRole } = useProjectMyMembership();
|
||||
const { issues: inboxIssues, mutate: mutateInboxIssues } = useInboxView();
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const handleAcceptIssue = () => {
|
||||
setIsAccepting(true);
|
||||
const markInboxStatus = async (data: TInboxStatus) => {
|
||||
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(() => {
|
||||
if (!issue?.issue_inbox[0].snoozed_till) return;
|
||||
|
||||
@ -82,163 +122,174 @@ export const InboxActionHeader: React.FC<Props> = (props) => {
|
||||
tomorrow.setDate(today.getDate() + 1);
|
||||
|
||||
return (
|
||||
<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="flex items-center gap-2">
|
||||
<InboxIcon className="h-4 w-4 text-brand-secondary" />
|
||||
<h3 className="font-medium">Inbox</h3>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<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>
|
||||
{inboxIssueId && (
|
||||
<div className="flex justify-between items-center gap-4 px-4 col-span-3">
|
||||
<div className="flex items-center gap-x-2">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border border-brand-base bg-brand-surface-1 p-1.5 hover:bg-brand-surface-2"
|
||||
onClick={() => {
|
||||
const e = new KeyboardEvent("keydown", { key: "ArrowUp" });
|
||||
document.dispatchEvent(e);
|
||||
}}
|
||||
>
|
||||
<ChevronUpIcon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border border-brand-base bg-brand-surface-1 p-1.5 hover:bg-brand-surface-2"
|
||||
onClick={() => {
|
||||
const e = new KeyboardEvent("keydown", { key: "ArrowDown" });
|
||||
document.dispatchEvent(e);
|
||||
}}
|
||||
>
|
||||
<ChevronDownIcon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<div className="text-sm">
|
||||
{currentIssueIndex + 1}/{issueCount}
|
||||
</div>
|
||||
<>
|
||||
<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="col-span-1 flex justify-between p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<InboxIcon className="h-4 w-4 text-brand-secondary" />
|
||||
<h3 className="font-medium">Inbox</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
{isAllowed && (
|
||||
<div
|
||||
className={`flex-shrink-0 ${
|
||||
issueStatus === 0 || issueStatus === -2 ? "" : "opacity-70"
|
||||
}`}
|
||||
<FiltersDropdown />
|
||||
</div>
|
||||
{inboxIssueId && (
|
||||
<div className="flex justify-between items-center gap-4 px-4 col-span-3">
|
||||
<div className="flex items-center gap-x-2">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border border-brand-base bg-brand-surface-1 p-1.5 hover:bg-brand-surface-2"
|
||||
onClick={() => {
|
||||
const e = new KeyboardEvent("keydown", { key: "ArrowUp" });
|
||||
document.dispatchEvent(e);
|
||||
}}
|
||||
>
|
||||
<Popover className="relative">
|
||||
<Popover.Button
|
||||
as="button"
|
||||
type="button"
|
||||
disabled={!(issueStatus === 0 || issueStatus === -2)}
|
||||
<ChevronUpIcon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border border-brand-base bg-brand-surface-1 p-1.5 hover:bg-brand-surface-2"
|
||||
onClick={() => {
|
||||
const e = new KeyboardEvent("keydown", { key: "ArrowDown" });
|
||||
document.dispatchEvent(e);
|
||||
}}
|
||||
>
|
||||
<ChevronDownIcon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<div className="text-sm">
|
||||
{currentIssueIndex + 1}/{inboxIssues?.length ?? 0}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
{isAllowed && (issueStatus === 0 || issueStatus === -2) && (
|
||||
<div className="flex-shrink-0">
|
||||
<Popover className="relative">
|
||||
<Popover.Button as="button" type="button">
|
||||
<SecondaryButton className="flex gap-x-1 items-center" size="sm">
|
||||
<ClockIcon className="h-4 w-4 text-brand-secondary" />
|
||||
<span>Snooze</span>
|
||||
</SecondaryButton>
|
||||
</Popover.Button>
|
||||
<Popover.Panel className="w-80 p-2 absolute right-0 z-10 mt-2 rounded-md border border-brand-base bg-brand-surface-2 shadow-lg">
|
||||
{({ close }) => (
|
||||
<div className="w-full h-full flex flex-col gap-y-1">
|
||||
<DatePicker
|
||||
selected={date ? new Date(date) : null}
|
||||
onChange={(val) => {
|
||||
if (!val) return;
|
||||
setDate(val);
|
||||
}}
|
||||
dateFormat="dd-MM-yyyy"
|
||||
minDate={tomorrow}
|
||||
inline
|
||||
/>
|
||||
<PrimaryButton
|
||||
className="ml-auto"
|
||||
onClick={() => {
|
||||
close();
|
||||
markInboxStatus({
|
||||
status: 0,
|
||||
snoozed_till: new Date(date),
|
||||
});
|
||||
}}
|
||||
>
|
||||
Snooze
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
)}
|
||||
</Popover.Panel>
|
||||
</Popover>
|
||||
</div>
|
||||
)}
|
||||
{isAllowed && issueStatus === -2 && (
|
||||
<div className="flex-shrink-0">
|
||||
<SecondaryButton
|
||||
size="sm"
|
||||
className="flex gap-2 items-center"
|
||||
onClick={() => setSelectDuplicateIssue(true)}
|
||||
>
|
||||
<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" />
|
||||
<span>Snooze</span>
|
||||
</SecondaryButton>
|
||||
</Popover.Button>
|
||||
<Popover.Panel className="w-80 p-2 absolute right-0 z-10 mt-2 rounded-md border border-brand-base bg-brand-surface-2 shadow-lg">
|
||||
{({ close }) => (
|
||||
<div className="w-full h-full flex flex-col gap-y-1">
|
||||
<DatePicker
|
||||
selected={date ? new Date(date) : null}
|
||||
onChange={(val) => {
|
||||
if (!val) return;
|
||||
setDate(val);
|
||||
}}
|
||||
dateFormat="dd-MM-yyyy"
|
||||
minDate={tomorrow}
|
||||
inline
|
||||
/>
|
||||
<PrimaryButton
|
||||
className="ml-auto"
|
||||
onClick={() => {
|
||||
close();
|
||||
onSnooze(date);
|
||||
}}
|
||||
>
|
||||
Snooze
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
)}
|
||||
</Popover.Panel>
|
||||
</Popover>
|
||||
</div>
|
||||
)}
|
||||
{isAllowed && (
|
||||
<div className={`flex gap-3 flex-wrap ${issueStatus !== -2 ? "opacity-70" : ""}`}>
|
||||
<SecondaryButton
|
||||
size="sm"
|
||||
className="flex gap-2 items-center"
|
||||
onClick={onMarkAsDuplicate}
|
||||
disabled={issueStatus !== -2}
|
||||
>
|
||||
<StackedLayersHorizontalIcon className="h-4 w-4 text-brand-secondary" />
|
||||
<span>Mark as duplicate</span>
|
||||
</SecondaryButton>
|
||||
<SecondaryButton
|
||||
size="sm"
|
||||
className="flex gap-2 items-center"
|
||||
onClick={handleAcceptIssue}
|
||||
disabled={issueStatus !== -2}
|
||||
loading={isAccepting}
|
||||
>
|
||||
<CheckCircleIcon className="h-4 w-4 text-green-500" />
|
||||
<span>{isAccepting ? "Accepting..." : "Accept"}</span>
|
||||
</SecondaryButton>
|
||||
<SecondaryButton
|
||||
size="sm"
|
||||
className="flex gap-2 items-center"
|
||||
onClick={onDecline}
|
||||
disabled={issueStatus !== -2}
|
||||
>
|
||||
<XCircleIcon className="h-4 w-4 text-red-500" />
|
||||
<span>Decline</span>
|
||||
</SecondaryButton>
|
||||
</div>
|
||||
)}
|
||||
{(isAllowed || user?.id === issue?.created_by) && (
|
||||
<div className="flex-shrink-0">
|
||||
<SecondaryButton size="sm" className="flex gap-2 items-center" onClick={onDelete}>
|
||||
<TrashIcon className="h-4 w-4 text-red-500" />
|
||||
<span>Delete</span>
|
||||
</SecondaryButton>
|
||||
</div>
|
||||
)}
|
||||
<StackedLayersHorizontalIcon className="h-4 w-4 text-brand-secondary" />
|
||||
<span>Mark as duplicate</span>
|
||||
</SecondaryButton>
|
||||
</div>
|
||||
)}
|
||||
{isAllowed && (issueStatus === 0 || issueStatus === -2) && (
|
||||
<div className="flex-shrink-0">
|
||||
<SecondaryButton
|
||||
size="sm"
|
||||
className="flex gap-2 items-center"
|
||||
onClick={() => setAcceptIssueModal(true)}
|
||||
>
|
||||
<CheckCircleIcon className="h-4 w-4 text-green-500" />
|
||||
<span>Accept</span>
|
||||
</SecondaryButton>
|
||||
</div>
|
||||
)}
|
||||
{isAllowed && issueStatus === -2 && (
|
||||
<div className="flex-shrink-0">
|
||||
<SecondaryButton
|
||||
size="sm"
|
||||
className="flex gap-2 items-center"
|
||||
onClick={() => setDeclineIssueModal(true)}
|
||||
>
|
||||
<XCircleIcon className="h-4 w-4 text-red-500" />
|
||||
<span>Decline</span>
|
||||
</SecondaryButton>
|
||||
</div>
|
||||
)}
|
||||
{(isAllowed || user?.id === issue?.created_by) && (
|
||||
<div className="flex-shrink-0">
|
||||
<SecondaryButton
|
||||
size="sm"
|
||||
className="flex gap-2 items-center"
|
||||
onClick={() => setDeleteIssueModal(true)}
|
||||
>
|
||||
<TrashIcon className="h-4 w-4 text-red-500" />
|
||||
<span>Delete</span>
|
||||
</SecondaryButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -4,13 +4,21 @@ import Link from "next/link";
|
||||
// ui
|
||||
import { Tooltip } from "components/ui";
|
||||
// icons
|
||||
import { getPriorityIcon, getStateGroupIcon } from "components/icons";
|
||||
import { CalendarDaysIcon, ClockIcon } from "@heroicons/react/24/outline";
|
||||
import { getPriorityIcon } from "components/icons";
|
||||
import {
|
||||
CalendarDaysIcon,
|
||||
CheckCircleIcon,
|
||||
ClockIcon,
|
||||
DocumentDuplicateIcon,
|
||||
ExclamationTriangleIcon,
|
||||
XCircleIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
// helpers
|
||||
import { renderShortNumericDateFormat } from "helpers/date-time.helper";
|
||||
import { addSpaceIfCamelCase } from "helpers/string.helper";
|
||||
// types
|
||||
import type { IInboxIssue } from "types";
|
||||
// constants
|
||||
import { INBOX_STATUS } from "constants/inbox";
|
||||
|
||||
type Props = {
|
||||
issue: IInboxIssue;
|
||||
@ -30,93 +38,88 @@ export const InboxIssueCard: React.FC<Props> = (props) => {
|
||||
href={`/${workspaceSlug}/projects/${projectId}/inbox/${inboxId}?inboxIssueId=${issue.bridge_id}`}
|
||||
>
|
||||
<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
|
||||
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 ${
|
||||
active ? "bg-brand-accent bg-opacity-5" : " "
|
||||
} ${issue.issue_inbox[0].status !== -2 ? "opacity-60" : ""}`}
|
||||
>
|
||||
<div
|
||||
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 ${
|
||||
active ? "bg-brand-accent bg-opacity-5" : " "
|
||||
} ${issue.issue_inbox[0].status !== -2 ? "opacity-60" : ""}`}
|
||||
>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<p className="flex-shrink-0 text-brand-secondary text-xs">
|
||||
{issue.project_detail?.identifier}-{issue.sequence_id}
|
||||
</p>
|
||||
<h5 className="truncate text-sm">{issue.name}</h5>
|
||||
</div>
|
||||
<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"}`}>
|
||||
<div
|
||||
className={`grid h-6 w-6 place-items-center rounded border items-center shadow-sm ${
|
||||
issue.priority === "urgent"
|
||||
? "border-red-500/20 bg-red-500/20 text-red-500"
|
||||
: issue.priority === "high"
|
||||
? "border-orange-500/20 bg-orange-500/20 text-orange-500"
|
||||
: issue.priority === "medium"
|
||||
? "border-yellow-500/20 bg-yellow-500/20 text-yellow-500"
|
||||
: issue.priority === "low"
|
||||
? "border-green-500/20 bg-green-500/20 text-green-500"
|
||||
: "border-brand-base"
|
||||
}`}
|
||||
>
|
||||
{getPriorityIcon(
|
||||
issue.priority && issue.priority !== "" ? issue.priority ?? "" : "None",
|
||||
"text-sm"
|
||||
)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
tooltipHeading="Created at"
|
||||
tooltipContent={`${renderShortNumericDateFormat(issue.created_at ?? "")}`}
|
||||
>
|
||||
<div className="flex items-center gap-1 rounded border border-brand-base shadow-sm text-xs px-2 py-[0.19rem] text-brand-secondary">
|
||||
<CalendarDaysIcon className="h-3.5 w-3.5" />
|
||||
<span>{renderShortNumericDateFormat(issue.created_at ?? "")}</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
{issue.issue_inbox[0].snoozed_till && (
|
||||
<div
|
||||
className={`text-xs flex items-center gap-1 ${
|
||||
new Date(issue.issue_inbox[0].snoozed_till ?? "") < new Date()
|
||||
? "text-red-500"
|
||||
: "text-blue-500"
|
||||
}`}
|
||||
>
|
||||
<ClockIcon className="h-3.5 w-3.5" />
|
||||
<span>
|
||||
Snoozed till {renderShortNumericDateFormat(issue.issue_inbox[0].snoozed_till)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<p className="flex-shrink-0 text-brand-secondary text-xs">
|
||||
{issue.project_detail?.identifier}-{issue.sequence_id}
|
||||
</p>
|
||||
<h5 className="truncate text-sm">{issue.name}</h5>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Tooltip tooltipHeading="Priority" tooltipContent={`${issue.priority ?? "None"}`}>
|
||||
<div
|
||||
className={`grid h-6 w-6 place-items-center rounded border items-center shadow-sm ${
|
||||
issue.priority === "urgent"
|
||||
? "border-red-500/20 bg-red-500/20 text-red-500"
|
||||
: issue.priority === "high"
|
||||
? "border-orange-500/20 bg-orange-500/20 text-orange-500"
|
||||
: issue.priority === "medium"
|
||||
? "border-yellow-500/20 bg-yellow-500/20 text-yellow-500"
|
||||
: issue.priority === "low"
|
||||
? "border-green-500/20 bg-green-500/20 text-green-500"
|
||||
: "border-brand-base"
|
||||
}`}
|
||||
>
|
||||
{getPriorityIcon(
|
||||
issue.priority && issue.priority !== "" ? issue.priority ?? "" : "None",
|
||||
"text-sm"
|
||||
)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
tooltipHeading="Created at"
|
||||
tooltipContent={`${renderShortNumericDateFormat(issue.created_at ?? "")}`}
|
||||
>
|
||||
<div className="flex items-center gap-1 rounded border border-brand-base shadow-sm text-xs px-2 py-[0.19rem] text-brand-secondary">
|
||||
<CalendarDaysIcon className="h-3.5 w-3.5" />
|
||||
<span>{renderShortNumericDateFormat(issue.created_at ?? "")}</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div
|
||||
className={`text-xs flex items-center justify-end gap-1 w-full ${
|
||||
issueStatus === 0 && new Date(issue.issue_inbox[0].snoozed_till ?? "") < new Date()
|
||||
? "text-red-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" />
|
||||
<span>
|
||||
{new Date(issue.issue_inbox[0].snoozed_till ?? "") < new Date()
|
||||
? "Snoozed date passed"
|
||||
: "Snoozed"}
|
||||
</span>
|
||||
</>
|
||||
) : 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>
|
||||
</a>
|
||||
</Link>
|
||||
);
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect } from "react";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
import Router, { useRouter } from "next/router";
|
||||
|
||||
import useSWR, { mutate } from "swr";
|
||||
|
||||
@ -29,6 +29,7 @@ import {
|
||||
ClockIcon,
|
||||
DocumentDuplicateIcon,
|
||||
ExclamationTriangleIcon,
|
||||
InboxIcon,
|
||||
XCircleIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
// helpers
|
||||
@ -55,7 +56,7 @@ export const InboxMainContent: React.FC = () => {
|
||||
|
||||
const { user } = useUserAuth();
|
||||
const { memberRole } = useProjectMyMembership();
|
||||
const { params } = useInboxView();
|
||||
const { params, issues: inboxIssues } = useInboxView();
|
||||
|
||||
const { reset, control, watch } = useForm<IIssue>({
|
||||
defaultValues,
|
||||
@ -76,17 +77,6 @@ export const InboxMainContent: React.FC = () => {
|
||||
: 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(
|
||||
async (formData: Partial<IInboxIssue>) => {
|
||||
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;
|
||||
|
||||
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 (
|
||||
<>
|
||||
{issueDetails ? (
|
||||
@ -154,17 +222,17 @@ export const InboxMainContent: React.FC = () => {
|
||||
<div
|
||||
className={`flex items-center gap-2 p-3 text-sm border rounded-md ${
|
||||
issueStatus === -2
|
||||
? "text-orange-500 border-orange-500 bg-orange-500/10"
|
||||
? "text-yellow-500 border-yellow-500 bg-yellow-500/10"
|
||||
: issueStatus === -1
|
||||
? "text-red-500 border-red-500 bg-red-500/10"
|
||||
: issueStatus === 0
|
||||
? new Date(issueDetails.issue_inbox[0].snoozed_till ?? "") < new Date()
|
||||
? "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
|
||||
? "text-green-500 border-green-500 bg-green-500/10"
|
||||
: 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;
|
||||
};
|
||||
|
@ -1,3 +1,4 @@
|
||||
export * from "./accept-issue-modal";
|
||||
export * from "./decline-issue-modal";
|
||||
export * from "./delete-issue-modal";
|
||||
export * from "./filters-dropdown";
|
||||
|
@ -11,7 +11,7 @@ export const IssuesListSidebar = () => {
|
||||
const router = useRouter();
|
||||
const { inboxIssueId } = router.query;
|
||||
|
||||
const { issues: inboxIssues } = useInboxView();
|
||||
const { issues: inboxIssues, filtersLength } = useInboxView();
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col overflow-hidden">
|
||||
@ -29,7 +29,8 @@ export const IssuesListSidebar = () => {
|
||||
</div>
|
||||
) : (
|
||||
<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>
|
||||
)
|
||||
) : (
|
||||
|
@ -104,7 +104,7 @@ export const DeleteImportModal: React.FC<Props> = ({ isOpen, handleClose, data,
|
||||
<span>
|
||||
<p className="text-sm leading-7 text-brand-secondary">
|
||||
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}
|
||||
</span>
|
||||
? All of the data related to the import will be permanently removed. This
|
||||
|
@ -12,17 +12,19 @@ import issueServices from "services/issues.service";
|
||||
import useIssuesView from "hooks/use-issues-view";
|
||||
import useCalendarIssuesView from "hooks/use-calendar-issues-view";
|
||||
import useToast from "hooks/use-toast";
|
||||
import useSpreadsheetIssuesView from "hooks/use-spreadsheet-issues-view";
|
||||
// icons
|
||||
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
|
||||
// ui
|
||||
import { SecondaryButton, DangerButton } from "components/ui";
|
||||
// types
|
||||
import type { IIssue, ICurrentUserResponse } from "types";
|
||||
import type { IIssue, ICurrentUserResponse, ISubIssueResponse } from "types";
|
||||
// fetch-keys
|
||||
import {
|
||||
CYCLE_ISSUES_WITH_PARAMS,
|
||||
MODULE_ISSUES_WITH_PARAMS,
|
||||
PROJECT_ISSUES_LIST_WITH_PARAMS,
|
||||
SUB_ISSUES,
|
||||
VIEW_ISSUES,
|
||||
} from "constants/fetch-keys";
|
||||
|
||||
@ -41,6 +43,7 @@ export const DeleteIssueModal: React.FC<Props> = ({ isOpen, handleClose, data, u
|
||||
|
||||
const { issueView, params } = useIssuesView();
|
||||
const { params: calendarParams } = useCalendarIssuesView();
|
||||
const { params: spreadsheetParams } = useSpreadsheetIssuesView();
|
||||
|
||||
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),
|
||||
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 {
|
||||
if (cycleId) mutate(CYCLE_ISSUES_WITH_PARAMS(cycleId 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>
|
||||
<p className="text-sm text-brand-secondary">
|
||||
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}
|
||||
</span>
|
||||
{""}? All of the data related to the issue will be permanently removed. This
|
||||
|
@ -23,7 +23,6 @@ import {
|
||||
IssueStateSelect,
|
||||
} from "components/issues/select";
|
||||
import { CreateStateModal } from "components/states";
|
||||
import { CreateUpdateCycleModal } from "components/cycles";
|
||||
import { CreateLabelModal } from "components/labels";
|
||||
// ui
|
||||
import {
|
||||
@ -73,7 +72,6 @@ const defaultValues: Partial<IIssue> = {
|
||||
description_html: "<p></p>",
|
||||
estimate_point: null,
|
||||
state: "",
|
||||
cycle: null,
|
||||
priority: null,
|
||||
assignees: [],
|
||||
assignees_list: [],
|
||||
@ -122,7 +120,6 @@ export const IssueForm: FC<IssueFormProps> = ({
|
||||
}) => {
|
||||
// states
|
||||
const [mostSimilarIssue, setMostSimilarIssue] = useState<IIssue | undefined>();
|
||||
const [cycleModal, setCycleModal] = useState(false);
|
||||
const [stateModal, setStateModal] = useState(false);
|
||||
const [labelModal, setLabelModal] = useState(false);
|
||||
const [parentIssueListModalOpen, setParentIssueListModalOpen] = useState(false);
|
||||
@ -148,7 +145,7 @@ export const IssueForm: FC<IssueFormProps> = ({
|
||||
setValue,
|
||||
setFocus,
|
||||
} = useForm<IIssue>({
|
||||
defaultValues,
|
||||
defaultValues: initialData ?? defaultValues,
|
||||
reValidateMode: "onChange",
|
||||
});
|
||||
|
||||
@ -163,6 +160,8 @@ export const IssueForm: FC<IssueFormProps> = ({
|
||||
const handleCreateUpdateIssue = async (formData: Partial<IIssue>) => {
|
||||
await handleFormSubmit(formData);
|
||||
|
||||
setGptAssistantModal(false);
|
||||
|
||||
reset({
|
||||
...defaultValues,
|
||||
project: projectId,
|
||||
@ -198,7 +197,7 @@ export const IssueForm: FC<IssueFormProps> = ({
|
||||
projectId as string,
|
||||
{
|
||||
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
|
||||
)
|
||||
@ -250,11 +249,6 @@ export const IssueForm: FC<IssueFormProps> = ({
|
||||
projectId={projectId}
|
||||
user={user}
|
||||
/>
|
||||
<CreateUpdateCycleModal
|
||||
isOpen={cycleModal}
|
||||
handleClose={() => setCycleModal(false)}
|
||||
user={user}
|
||||
/>
|
||||
<CreateLabelModal
|
||||
isOpen={labelModal}
|
||||
handleClose={() => setLabelModal(false)}
|
||||
|
@ -17,6 +17,7 @@ import useIssuesView from "hooks/use-issues-view";
|
||||
import useCalendarIssuesView from "hooks/use-calendar-issues-view";
|
||||
import useToast from "hooks/use-toast";
|
||||
import useInboxView from "hooks/use-inbox-view";
|
||||
import useSpreadsheetIssuesView from "hooks/use-spreadsheet-issues-view";
|
||||
// components
|
||||
import { IssueForm } from "components/issues";
|
||||
// types
|
||||
@ -79,13 +80,19 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
|
||||
const { params: calendarParams } = useCalendarIssuesView();
|
||||
const { order_by, group_by, ...viewGanttParams } = params;
|
||||
const { params: inboxParams } = useInboxView();
|
||||
|
||||
if (cycleId) prePopulateData = { ...prePopulateData, cycle: cycleId as string };
|
||||
if (moduleId) prePopulateData = { ...prePopulateData, module: moduleId as string };
|
||||
const { params: spreadsheetParams } = useSpreadsheetIssuesView();
|
||||
|
||||
const { user } = useUser();
|
||||
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(
|
||||
workspaceSlug && activeProject
|
||||
? PROJECT_ISSUES_LIST(workspaceSlug as string, activeProject ?? "")
|
||||
@ -119,7 +126,7 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
|
||||
}, [handleClose]);
|
||||
|
||||
const addIssueToCycle = async (issueId: string, cycleId: string) => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
if (!workspaceSlug || !activeProject) return;
|
||||
|
||||
await issuesService
|
||||
.addIssueToCycle(
|
||||
@ -140,7 +147,7 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
|
||||
};
|
||||
|
||||
const addIssueToModule = async (issueId: string, moduleId: string) => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
if (!workspaceSlug || !activeProject) return;
|
||||
|
||||
await modulesService
|
||||
.addIssuesToModule(
|
||||
@ -161,7 +168,7 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
|
||||
};
|
||||
|
||||
const addIssueToInbox = async (formData: Partial<IIssue>) => {
|
||||
if (!workspaceSlug || !projectId || !inboxId) return;
|
||||
if (!workspaceSlug || !activeProject || !inboxId) return;
|
||||
|
||||
const payload = {
|
||||
issue: {
|
||||
@ -176,7 +183,7 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
|
||||
await inboxServices
|
||||
.createInboxIssue(
|
||||
workspaceSlug.toString(),
|
||||
projectId.toString(),
|
||||
activeProject.toString(),
|
||||
inboxId.toString(),
|
||||
payload,
|
||||
user
|
||||
@ -188,6 +195,10 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
|
||||
message: "Issue created successfully.",
|
||||
});
|
||||
|
||||
router.push(
|
||||
`/${workspaceSlug}/projects/${activeProject}/inbox/${inboxId}?inboxIssueId=${res.issue_inbox[0].id}`
|
||||
);
|
||||
|
||||
mutate(INBOX_ISSUES(inboxId.toString(), inboxParams));
|
||||
})
|
||||
.catch(() => {
|
||||
@ -205,7 +216,15 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
|
||||
? MODULE_ISSUES_WITH_PARAMS(moduleId.toString(), calendarParams)
|
||||
: viewId
|
||||
? 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
|
||||
? CYCLE_ISSUES_WITH_PARAMS(cycleId.toString())
|
||||
@ -213,10 +232,10 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
|
||||
? MODULE_ISSUES_WITH_PARAMS(moduleId.toString())
|
||||
: viewId
|
||||
? 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>) => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
if (!workspaceSlug || !activeProject) return;
|
||||
|
||||
if (inboxId) await addIssueToInbox(payload);
|
||||
else
|
||||
@ -230,6 +249,7 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
|
||||
|
||||
if (issueView === "calendar") mutate(calendarFetchKey);
|
||||
if (issueView === "gantt_chart") mutate(ganttFetchKey);
|
||||
if (issueView === "spreadsheet") mutate(spreadsheetFetchKey);
|
||||
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
@ -237,7 +257,8 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
|
||||
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));
|
||||
})
|
||||
@ -260,6 +281,8 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
|
||||
mutate<IIssue>(PROJECT_ISSUES_DETAILS, (prevData) => ({ ...prevData, ...res }), false);
|
||||
} else {
|
||||
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));
|
||||
}
|
||||
|
||||
@ -328,7 +351,7 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
|
||||
<IssueForm
|
||||
issues={issues ?? []}
|
||||
handleFormSubmit={handleFormSubmit}
|
||||
initialData={prePopulateData}
|
||||
initialData={data ?? prePopulateData}
|
||||
createMore={createMore}
|
||||
setCreateMore={setCreateMore}
|
||||
handleClose={handleClose}
|
||||
|
@ -44,14 +44,14 @@ export const MyIssuesListItem: React.FC<Props> = ({ issue, properties, projectId
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const partialUpdateIssue = useCallback(
|
||||
(formData: Partial<IIssue>, issueId: string) => {
|
||||
(formData: Partial<IIssue>, issue: IIssue) => {
|
||||
if (!workspaceSlug) return;
|
||||
|
||||
mutate<IIssue[]>(
|
||||
USER_ISSUE(workspaceSlug as string),
|
||||
(prevData) =>
|
||||
prevData?.map((p) => {
|
||||
if (p.id === issueId) return { ...p, ...formData };
|
||||
if (p.id === issue.id) return { ...p, ...formData };
|
||||
|
||||
return p;
|
||||
}),
|
||||
@ -59,7 +59,7 @@ export const MyIssuesListItem: React.FC<Props> = ({ issue, properties, projectId
|
||||
);
|
||||
|
||||
issuesService
|
||||
.patchIssue(workspaceSlug as string, projectId as string, issueId, formData, user)
|
||||
.patchIssue(workspaceSlug as string, projectId as string, issue.id, formData, user)
|
||||
.then((res) => {
|
||||
mutate(USER_ISSUE(workspaceSlug as string));
|
||||
})
|
||||
|
@ -1,23 +1,28 @@
|
||||
import React, { useState } from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
// headless ui
|
||||
import { Combobox, Dialog, Transition } from "@headlessui/react";
|
||||
// icons
|
||||
import { MagnifyingGlassIcon, RectangleStackIcon } from "@heroicons/react/24/outline";
|
||||
// ui
|
||||
import { PrimaryButton, SecondaryButton } from "components/ui";
|
||||
// types
|
||||
import { IIssue } from "types";
|
||||
// services
|
||||
import projectService from "services/project.service";
|
||||
// hooks
|
||||
import useDebounce from "hooks/use-debounce";
|
||||
// components
|
||||
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 = {
|
||||
isOpen: boolean;
|
||||
handleClose: () => void;
|
||||
value?: any;
|
||||
onChange: (...event: any[]) => void;
|
||||
issues: IIssue[];
|
||||
title?: string;
|
||||
multiple?: boolean;
|
||||
issueId?: string;
|
||||
customDisplay?: JSX.Element;
|
||||
};
|
||||
|
||||
@ -26,28 +31,60 @@ export const ParentIssuesListModal: React.FC<Props> = ({
|
||||
handleClose: onClose,
|
||||
value,
|
||||
onChange,
|
||||
issues,
|
||||
title = "Issues",
|
||||
multiple = false,
|
||||
issueId,
|
||||
customDisplay,
|
||||
}) => {
|
||||
const [query, setQuery] = useState("");
|
||||
const [values, setValues] = useState<string[]>([]);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
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 = () => {
|
||||
onClose();
|
||||
setQuery("");
|
||||
setValues([]);
|
||||
setSearchTerm("");
|
||||
};
|
||||
|
||||
const filteredIssues: IIssue[] =
|
||||
query === ""
|
||||
? issues ?? []
|
||||
: issues?.filter((issue) => issue.name.toLowerCase().includes(query.toLowerCase())) ?? [];
|
||||
useEffect(() => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
||||
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 (
|
||||
<>
|
||||
<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}>
|
||||
<Transition.Child
|
||||
as={React.Fragment}
|
||||
@ -72,131 +109,38 @@ export const ParentIssuesListModal: React.FC<Props> = ({
|
||||
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">
|
||||
{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>
|
||||
<Combobox value={value} onChange={onChange}>
|
||||
<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="Type to search..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
displayValue={() => ""}
|
||||
/>
|
||||
</div>
|
||||
{customDisplay && <div className="p-2">{customDisplay}</div>}
|
||||
<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>
|
||||
)}
|
||||
|
||||
{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}>
|
||||
<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 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" : ""}`
|
||||
}
|
||||
onClick={handleClose}
|
||||
>
|
||||
<>
|
||||
<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.name}
|
||||
</>
|
||||
</Combobox.Option>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
) : (
|
||||
{!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">
|
||||
@ -208,9 +152,45 @@ export const ParentIssuesListModal: React.FC<Props> = ({
|
||||
</h3>
|
||||
</div>
|
||||
)}
|
||||
</Combobox.Options>
|
||||
</Combobox>
|
||||
)}
|
||||
|
||||
{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
|
||||
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" : ""}`
|
||||
}
|
||||
onClick={handleClose}
|
||||
>
|
||||
<>
|
||||
<span
|
||||
className="block h-1.5 w-1.5 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor: issue.state__color,
|
||||
}}
|
||||
/>
|
||||
<span className="flex-shrink-0 text-xs">
|
||||
{issue.project__identifier}-{issue.sequence_id}
|
||||
</span>{" "}
|
||||
{issue.name}
|
||||
</>
|
||||
</Combobox.Option>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Combobox.Options>
|
||||
</Combobox>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
|
@ -6,6 +6,10 @@ import useSWR from "swr";
|
||||
|
||||
// headless ui
|
||||
import { Combobox, Transition } from "@headlessui/react";
|
||||
// services
|
||||
import issuesServices from "services/issues.service";
|
||||
// ui
|
||||
import { IssueLabelsList } from "components/ui";
|
||||
// icons
|
||||
import {
|
||||
CheckIcon,
|
||||
@ -14,13 +18,10 @@ import {
|
||||
RectangleGroupIcon,
|
||||
TagIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
// services
|
||||
import issuesServices from "services/issues.service";
|
||||
// types
|
||||
import type { IIssueLabels } from "types";
|
||||
// fetch-keys
|
||||
import { PROJECT_ISSUE_LABELS } from "constants/fetch-keys";
|
||||
import { IssueLabelsList } from "components/ui";
|
||||
|
||||
type Props = {
|
||||
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
|
@ -21,7 +21,6 @@ export const IssueParentSelect: React.FC<Props> = ({ control, isOpen, setIsOpen,
|
||||
isOpen={isOpen}
|
||||
handleClose={() => setIsOpen(false)}
|
||||
onChange={onChange}
|
||||
issues={issues}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
@ -3,299 +3,135 @@ import React, { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import useSWR from "swr";
|
||||
|
||||
// react-hook-form
|
||||
import { SubmitHandler, useForm, UseFormWatch } from "react-hook-form";
|
||||
// headless ui
|
||||
import { Combobox, Dialog, Transition } from "@headlessui/react";
|
||||
import { UseFormWatch } from "react-hook-form";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
// services
|
||||
import issuesService from "services/issues.service";
|
||||
// ui
|
||||
import { PrimaryButton, SecondaryButton } from "components/ui";
|
||||
import useProjectDetails from "hooks/use-project-details";
|
||||
// components
|
||||
import { ExistingIssuesListModal } from "components/core";
|
||||
// icons
|
||||
import { MagnifyingGlassIcon, XMarkIcon } from "@heroicons/react/24/outline";
|
||||
import { BlockedIcon, LayerDiagonalIcon } from "components/icons";
|
||||
import { XMarkIcon } from "@heroicons/react/24/outline";
|
||||
import { BlockedIcon } from "components/icons";
|
||||
// types
|
||||
import { IIssue, UserAuth } from "types";
|
||||
// fetch-keys
|
||||
import { PROJECT_ISSUES_LIST } from "constants/fetch-keys";
|
||||
|
||||
type FormInput = {
|
||||
blocked_issue_ids: string[];
|
||||
};
|
||||
import { BlockeIssue, IIssue, ISearchIssueResponse, UserAuth } from "types";
|
||||
|
||||
type Props = {
|
||||
issueId?: string;
|
||||
submitChanges: (formData: Partial<IIssue>) => void;
|
||||
issuesList: IIssue[];
|
||||
watch: UseFormWatch<IIssue>;
|
||||
userAuth: UserAuth;
|
||||
};
|
||||
|
||||
export const SidebarBlockedSelect: React.FC<Props> = ({
|
||||
issueId,
|
||||
submitChanges,
|
||||
issuesList,
|
||||
watch,
|
||||
userAuth,
|
||||
}) => {
|
||||
const [query, setQuery] = useState("");
|
||||
const [isBlockedModalOpen, setIsBlockedModalOpen] = useState(false);
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
const { projectDetails } = useProjectDetails();
|
||||
|
||||
const router = useRouter();
|
||||
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 = () => {
|
||||
setIsBlockedModalOpen(false);
|
||||
reset();
|
||||
};
|
||||
|
||||
const onSubmit: SubmitHandler<FormInput> = (data) => {
|
||||
if (!data.blocked_issue_ids || data.blocked_issue_ids.length === 0) {
|
||||
const onSubmit = async (data: ISearchIssueResponse[]) => {
|
||||
if (data.length === 0) {
|
||||
setToastAlert({
|
||||
title: "Error",
|
||||
type: "error",
|
||||
message: "Please select atleast one issue",
|
||||
message: "Please select at least one issue",
|
||||
});
|
||||
|
||||
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];
|
||||
submitChanges({ blocks_list: newBlocked });
|
||||
const newBlocked = [...watch("blocked_issues"), ...selectedIssues];
|
||||
|
||||
submitChanges({
|
||||
blocked_issues: newBlocked,
|
||||
blocks_list: newBlocked.map((i) => i.blocked_issue_detail?.id ?? ""),
|
||||
});
|
||||
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;
|
||||
|
||||
return (
|
||||
<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">
|
||||
<BlockedIcon height={16} width={16} />
|
||||
<p>Blocked by</p>
|
||||
</div>
|
||||
<div className="space-y-1 sm:basis-1/2">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{watch("blocked_list") && watch("blocked_list").length > 0
|
||||
? watch("blocked_list").map((issue) => (
|
||||
<div
|
||||
key={issue}
|
||||
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
|
||||
href={`/${workspaceSlug}/projects/${projectId}/issues/${
|
||||
issues?.find((i) => i.id === issue)?.id
|
||||
}`}
|
||||
>
|
||||
<a className="flex items-center gap-1">
|
||||
<BlockedIcon height={10} width={10} />
|
||||
{`${issues?.find((i) => i.id === issue)?.project_detail?.identifier}-${
|
||||
issues?.find((i) => i.id === issue)?.sequence_id
|
||||
}`}
|
||||
</a>
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
className="opacity-0 duration-300 group-hover:opacity-100"
|
||||
onClick={() => {
|
||||
const updatedBlocked: string[] = watch("blocked_list").filter(
|
||||
(i) => i !== issue
|
||||
);
|
||||
submitChanges({
|
||||
blocks_list: updatedBlocked,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<XMarkIcon className="h-2 w-2" />
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
: null}
|
||||
<>
|
||||
<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 items-center gap-x-2 text-sm text-brand-secondary sm:basis-1/2">
|
||||
<BlockedIcon height={16} width={16} />
|
||||
<p>Blocked by</p>
|
||||
</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="space-y-1 sm:basis-1/2">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{watch("blocked_issues") && watch("blocked_issues").length > 0
|
||||
? watch("blocked_issues").map((issue) => (
|
||||
<div
|
||||
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"
|
||||
>
|
||||
<Link
|
||||
href={`/${workspaceSlug}/projects/${projectId}/issues/${issue.blocked_issue_detail?.id}`}
|
||||
>
|
||||
<a className="flex items-center gap-1">
|
||||
<BlockedIcon height={10} width={10} />
|
||||
{`${projectDetails?.identifier}-${issue.blocked_issue_detail?.sequence_id}`}
|
||||
</a>
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
className="opacity-0 duration-300 group-hover:opacity-100"
|
||||
onClick={() => {
|
||||
const updatedBlocked = watch("blocked_issues").filter(
|
||||
(i) => i.blocked_issue_detail?.id !== issue.blocked_issue_detail?.id
|
||||
);
|
||||
|
||||
<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]);
|
||||
submitChanges({
|
||||
blocked_issues: updatedBlocked,
|
||||
blocks_list: updatedBlocked.map((i) => i.blocked_issue_detail?.id ?? ""),
|
||||
});
|
||||
}}
|
||||
>
|
||||
<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
|
||||
type="button"
|
||||
className={`flex w-full text-brand-secondary ${
|
||||
isNotAllowed ? "cursor-not-allowed" : "cursor-pointer hover:bg-brand-surface-2"
|
||||
} items-center justify-between gap-1 rounded-md border border-brand-base px-2 py-1 text-xs shadow-sm duration-300 focus:outline-none`}
|
||||
onClick={() => setIsBlockedModalOpen(true)}
|
||||
disabled={isNotAllowed}
|
||||
>
|
||||
Select issues
|
||||
</button>
|
||||
<XMarkIcon className="h-2 w-2" />
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
: null}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={`flex w-full text-brand-secondary ${
|
||||
isNotAllowed ? "cursor-not-allowed" : "cursor-pointer hover:bg-brand-surface-2"
|
||||
} items-center justify-between gap-1 rounded-md border border-brand-base px-2 py-1 text-xs shadow-sm duration-300 focus:outline-none`}
|
||||
onClick={() => setIsBlockedModalOpen(true)}
|
||||
disabled={isNotAllowed}
|
||||
>
|
||||
Select issues
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -3,296 +3,137 @@ import React, { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import useSWR from "swr";
|
||||
|
||||
// react-hook-form
|
||||
import { SubmitHandler, useForm, UseFormWatch } from "react-hook-form";
|
||||
// headless ui
|
||||
import { Combobox, Dialog, Transition } from "@headlessui/react";
|
||||
import { UseFormWatch } from "react-hook-form";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
// services
|
||||
import issuesServices from "services/issues.service";
|
||||
// ui
|
||||
import { PrimaryButton, SecondaryButton } from "components/ui";
|
||||
import useProjectDetails from "hooks/use-project-details";
|
||||
// components
|
||||
import { ExistingIssuesListModal } from "components/core";
|
||||
// icons
|
||||
import { MagnifyingGlassIcon, XMarkIcon } from "@heroicons/react/24/outline";
|
||||
import { BlockerIcon, LayerDiagonalIcon } from "components/icons";
|
||||
import { XMarkIcon } from "@heroicons/react/24/outline";
|
||||
import { BlockerIcon } from "components/icons";
|
||||
// types
|
||||
import { IIssue, UserAuth } from "types";
|
||||
// fetch-keys
|
||||
import { PROJECT_ISSUES_LIST } from "constants/fetch-keys";
|
||||
|
||||
type FormInput = {
|
||||
blocker_issue_ids: string[];
|
||||
};
|
||||
import { BlockeIssue, IIssue, ISearchIssueResponse, UserAuth } from "types";
|
||||
|
||||
type Props = {
|
||||
issueId?: string;
|
||||
submitChanges: (formData: Partial<IIssue>) => void;
|
||||
issuesList: IIssue[];
|
||||
watch: UseFormWatch<IIssue>;
|
||||
userAuth: UserAuth;
|
||||
};
|
||||
|
||||
export const SidebarBlockerSelect: React.FC<Props> = ({
|
||||
issueId,
|
||||
submitChanges,
|
||||
issuesList,
|
||||
watch,
|
||||
userAuth,
|
||||
}) => {
|
||||
const [query, setQuery] = useState("");
|
||||
const [isBlockerModalOpen, setIsBlockerModalOpen] = useState(false);
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
const { projectDetails } = useProjectDetails();
|
||||
|
||||
const router = useRouter();
|
||||
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 = () => {
|
||||
setIsBlockerModalOpen(false);
|
||||
reset();
|
||||
};
|
||||
|
||||
const onSubmit: SubmitHandler<FormInput> = (data) => {
|
||||
if (!data.blocker_issue_ids || data.blocker_issue_ids.length === 0) {
|
||||
const onSubmit = async (data: ISearchIssueResponse[]) => {
|
||||
if (data.length === 0) {
|
||||
setToastAlert({
|
||||
title: "Error",
|
||||
type: "error",
|
||||
message: "Please select atleast one issue",
|
||||
title: "Error!",
|
||||
message: "Please select at least one issue.",
|
||||
});
|
||||
|
||||
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];
|
||||
submitChanges({ blockers_list: newBlockers });
|
||||
const newBlockers = [...watch("blocker_issues"), ...selectedIssues];
|
||||
|
||||
submitChanges({
|
||||
blocker_issues: newBlockers,
|
||||
blockers_list: newBlockers.map((i) => i.blocker_issue_detail?.id ?? ""),
|
||||
});
|
||||
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;
|
||||
|
||||
return (
|
||||
<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">
|
||||
<BlockerIcon height={16} width={16} />
|
||||
<p>Blocking</p>
|
||||
</div>
|
||||
<div className="space-y-1 sm:basis-1/2">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{watch("blockers_list") && watch("blockers_list").length > 0
|
||||
? watch("blockers_list").map((issue) => (
|
||||
<div
|
||||
key={issue}
|
||||
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
|
||||
href={`/${workspaceSlug}/projects/${projectId}/issues/${
|
||||
issues?.find((i) => i.id === issue)?.id
|
||||
}`}
|
||||
>
|
||||
<a className="flex items-center gap-1">
|
||||
<BlockerIcon height={10} width={10} />
|
||||
{`${issues?.find((i) => i.id === issue)?.project_detail?.identifier}-${
|
||||
issues?.find((i) => i.id === issue)?.sequence_id
|
||||
}`}
|
||||
</a>
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
className="opacity-0 duration-300 group-hover:opacity-100"
|
||||
onClick={() => {
|
||||
const updatedBlockers: string[] = watch("blockers_list").filter(
|
||||
(i) => i !== issue
|
||||
);
|
||||
submitChanges({
|
||||
blockers_list: updatedBlockers,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<XMarkIcon className="h-2 w-2" />
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
: null}
|
||||
<>
|
||||
<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 items-center gap-x-2 text-sm text-brand-secondary sm:basis-1/2">
|
||||
<BlockerIcon height={16} width={16} />
|
||||
<p>Blocking</p>
|
||||
</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="space-y-1 sm:basis-1/2">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{watch("blocker_issues") && watch("blocker_issues").length > 0
|
||||
? watch("blocker_issues").map((issue) => (
|
||||
<div
|
||||
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"
|
||||
>
|
||||
<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"
|
||||
<Link
|
||||
href={`/${workspaceSlug}/projects/${projectId}/issues/${issue.blocker_issue_detail?.id}`}
|
||||
>
|
||||
{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>
|
||||
<a className="flex items-center gap-1">
|
||||
<BlockerIcon height={10} width={10} />
|
||||
{`${projectDetails?.identifier}-${issue.blocker_issue_detail?.sequence_id}`}
|
||||
</a>
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
className="opacity-0 duration-300 group-hover:opacity-100"
|
||||
onClick={() => {
|
||||
const updatedBlockers = watch("blocker_issues").filter(
|
||||
(i) => i.blocker_issue_detail?.id !== issue.blocker_issue_detail?.id
|
||||
);
|
||||
|
||||
{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
|
||||
type="button"
|
||||
className={`flex w-full text-brand-secondary ${
|
||||
isNotAllowed ? "cursor-not-allowed" : "cursor-pointer hover:bg-brand-surface-2"
|
||||
} items-center justify-between gap-1 rounded-md border border-brand-base px-2 py-1 text-xs shadow-sm duration-300 focus:outline-none`}
|
||||
onClick={() => setIsBlockerModalOpen(true)}
|
||||
disabled={isNotAllowed}
|
||||
>
|
||||
Select issues
|
||||
</button>
|
||||
submitChanges({
|
||||
blocker_issues: updatedBlockers,
|
||||
blockers_list: updatedBlockers.map(
|
||||
(i) => i.blocker_issue_detail?.id ?? ""
|
||||
),
|
||||
});
|
||||
}}
|
||||
>
|
||||
<XMarkIcon className="h-2 w-2" />
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
: null}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={`flex w-full text-brand-secondary ${
|
||||
isNotAllowed ? "cursor-not-allowed" : "cursor-pointer hover:bg-brand-surface-2"
|
||||
} items-center justify-between gap-1 rounded-md border border-brand-base px-2 py-1 text-xs shadow-sm duration-300 focus:outline-none`}
|
||||
onClick={() => setIsBlockerModalOpen(true)}
|
||||
disabled={isNotAllowed}
|
||||
>
|
||||
Select issues
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -20,7 +20,6 @@ import { PROJECT_ISSUES_LIST } from "constants/fetch-keys";
|
||||
type Props = {
|
||||
control: Control<IIssue, any>;
|
||||
submitChanges: (formData: Partial<IIssue>) => void;
|
||||
issuesList: IIssue[];
|
||||
customDisplay: JSX.Element;
|
||||
watch: UseFormWatch<IIssue>;
|
||||
userAuth: UserAuth;
|
||||
@ -29,7 +28,6 @@ type Props = {
|
||||
export const SidebarParentSelect: React.FC<Props> = ({
|
||||
control,
|
||||
submitChanges,
|
||||
issuesList,
|
||||
customDisplay,
|
||||
watch,
|
||||
userAuth,
|
||||
@ -37,7 +35,7 @@ export const SidebarParentSelect: React.FC<Props> = ({
|
||||
const [isParentModalOpen, setIsParentModalOpen] = useState(false);
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
const { workspaceSlug, projectId, issueId } = router.query;
|
||||
|
||||
const { data: issues } = useSWR(
|
||||
workspaceSlug && projectId
|
||||
@ -68,8 +66,7 @@ export const SidebarParentSelect: React.FC<Props> = ({
|
||||
submitChanges({ parent: val });
|
||||
onChange(val);
|
||||
}}
|
||||
issues={issuesList}
|
||||
title="Select Parent"
|
||||
issueId={issueId as string}
|
||||
value={value}
|
||||
customDisplay={customDisplay}
|
||||
/>
|
||||
|
@ -27,7 +27,7 @@ type Props = {
|
||||
|
||||
export const SidebarStateSelect: React.FC<Props> = ({ value, onChange, userAuth }) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
const { workspaceSlug, projectId, inboxIssueId } = router.query;
|
||||
|
||||
const { data: stateGroups } = useSWR(
|
||||
workspaceSlug && projectId ? STATES_LIST(projectId as string) : null,
|
||||
@ -50,15 +50,24 @@ export const SidebarStateSelect: React.FC<Props> = ({ value, onChange, userAuth
|
||||
<div className="sm:basis-1/2">
|
||||
<CustomSelect
|
||||
label={
|
||||
<div className="flex items-center gap-2 text-left text-brand-base">
|
||||
{getStateGroupIcon(
|
||||
selectedState?.group ?? "backlog",
|
||||
"16",
|
||||
"16",
|
||||
selectedState?.color ?? ""
|
||||
)}
|
||||
{addSpaceIfCamelCase(selectedState?.name ?? "")}
|
||||
</div>
|
||||
selectedState ? (
|
||||
<div className="flex items-center gap-2 text-left text-brand-base">
|
||||
{getStateGroupIcon(
|
||||
selectedState?.group ?? "backlog",
|
||||
"16",
|
||||
"16",
|
||||
selectedState?.color ?? ""
|
||||
)}
|
||||
{addSpaceIfCamelCase(selectedState?.name ?? "")}
|
||||
</div>
|
||||
) : inboxIssueId ? (
|
||||
<div className="flex items-center gap-2 text-left text-brand-base">
|
||||
{getStateGroupIcon("backlog", "16", "16", "#ff7700")}
|
||||
Triage
|
||||
</div>
|
||||
) : (
|
||||
"None"
|
||||
)
|
||||
}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
|
@ -370,14 +370,6 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
|
||||
<SidebarParentSelect
|
||||
control={control}
|
||||
submitChanges={submitChanges}
|
||||
issuesList={
|
||||
issues?.filter(
|
||||
(i) =>
|
||||
i.id !== issueDetail?.id &&
|
||||
i.id !== issueDetail?.parent &&
|
||||
i.parent !== issueDetail?.id
|
||||
) ?? []
|
||||
}
|
||||
customDisplay={
|
||||
issueDetail?.parent_detail ? (
|
||||
<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"
|
||||
onClick={() => submitChanges({ parent: null })}
|
||||
>
|
||||
<span className="text-brand-secondary">Selected:</span>{" "}
|
||||
{issueDetail.parent_detail?.name}
|
||||
<XMarkIcon className="h-3 w-3" />
|
||||
</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
|
||||
</div>
|
||||
)
|
||||
@ -400,16 +393,16 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
|
||||
)}
|
||||
{(fieldsToShow.includes("all") || fieldsToShow.includes("blocker")) && (
|
||||
<SidebarBlockerSelect
|
||||
issueId={issueId as string}
|
||||
submitChanges={submitChanges}
|
||||
issuesList={issues?.filter((i) => i.id !== issueDetail?.id) ?? []}
|
||||
watch={watchIssue}
|
||||
userAuth={memberRole}
|
||||
/>
|
||||
)}
|
||||
{(fieldsToShow.includes("all") || fieldsToShow.includes("blocked")) && (
|
||||
<SidebarBlockedSelect
|
||||
issueId={issueId as string}
|
||||
submitChanges={submitChanges}
|
||||
issuesList={issues?.filter((i) => i.id !== issueDetail?.id) ?? []}
|
||||
watch={watchIssue}
|
||||
userAuth={memberRole}
|
||||
/>
|
||||
|
@ -21,7 +21,7 @@ import { ChevronRightIcon, PlusIcon, XMarkIcon } from "@heroicons/react/24/outli
|
||||
// helpers
|
||||
import { orderArrayBy } from "helpers/array.helper";
|
||||
// types
|
||||
import { ICurrentUserResponse, IIssue, ISubIssueResponse } from "types";
|
||||
import { ICurrentUserResponse, IIssue, ISearchIssueResponse, ISubIssueResponse } from "types";
|
||||
// fetch-keys
|
||||
import { PROJECT_ISSUES_LIST, SUB_ISSUES } from "constants/fetch-keys";
|
||||
|
||||
@ -58,14 +58,16 @@ export const SubIssuesList: FC<Props> = ({ parentIssue, user }) => {
|
||||
: null
|
||||
);
|
||||
|
||||
const addAsSubIssue = async (data: { issues: string[] }) => {
|
||||
const addAsSubIssue = async (data: ISearchIssueResponse[]) => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
||||
const payload = {
|
||||
sub_issue_ids: data.map((i) => i.id),
|
||||
};
|
||||
|
||||
await issuesService
|
||||
.addSubIssues(workspaceSlug as string, projectId as string, parentIssue?.id ?? "", {
|
||||
sub_issue_ids: data.issues,
|
||||
})
|
||||
.then((res) => {
|
||||
.addSubIssues(workspaceSlug as string, projectId as string, parentIssue?.id ?? "", payload)
|
||||
.then(() => {
|
||||
mutate<ISubIssueResponse>(
|
||||
SUB_ISSUES(parentIssue?.id ?? ""),
|
||||
(prevData) => {
|
||||
@ -74,10 +76,12 @@ export const SubIssuesList: FC<Props> = ({ parentIssue, user }) => {
|
||||
|
||||
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);
|
||||
|
||||
if (issue) {
|
||||
newSubIssues.push(issue);
|
||||
|
||||
const issueGroup = issue.state_detail.group;
|
||||
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),
|
||||
(prevData) =>
|
||||
(prevData ?? []).map((p) => {
|
||||
if (data.issues.includes(p.id))
|
||||
if (payload.sub_issue_ids.includes(p.id))
|
||||
return {
|
||||
...p,
|
||||
parent: parentIssue.id,
|
||||
@ -188,14 +192,7 @@ export const SubIssuesList: FC<Props> = ({ parentIssue, user }) => {
|
||||
<ExistingIssuesListModal
|
||||
isOpen={subIssuesListModal}
|
||||
handleClose={() => setSubIssuesListModal(false)}
|
||||
issues={
|
||||
issues?.filter(
|
||||
(i) =>
|
||||
(i.parent === "" || i.parent === null) &&
|
||||
i.id !== parentIssue?.id &&
|
||||
i.id !== parentIssue?.parent
|
||||
) ?? []
|
||||
}
|
||||
searchParams={{ sub_issue: true, issue_id: parentIssue?.id }}
|
||||
handleOnSubmit={addAsSubIssue}
|
||||
/>
|
||||
{subIssuesResponse &&
|
||||
@ -285,7 +282,7 @@ export const SubIssuesList: FC<Props> = ({ parentIssue, user }) => {
|
||||
<span className="flex-shrink-0 text-brand-secondary">
|
||||
{issue.project_detail.identifier}-{issue.sequence_id}
|
||||
</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>
|
||||
|
||||
{!isNotAllowed && (
|
||||
|
@ -18,10 +18,11 @@ import { PROJECT_MEMBERS } from "constants/fetch-keys";
|
||||
|
||||
type Props = {
|
||||
issue: IIssue;
|
||||
partialUpdateIssue: (formData: Partial<IIssue>, issueId: string) => void;
|
||||
partialUpdateIssue: (formData: Partial<IIssue>, issue: IIssue) => void;
|
||||
position?: "left" | "right";
|
||||
tooltipPosition?: "top" | "bottom";
|
||||
selfPositioned?: boolean;
|
||||
tooltipPosition?: "left" | "right";
|
||||
customButton?: boolean;
|
||||
user: ICurrentUserResponse | undefined;
|
||||
isNotAllowed: boolean;
|
||||
};
|
||||
@ -31,9 +32,10 @@ export const ViewAssigneeSelect: React.FC<Props> = ({
|
||||
partialUpdateIssue,
|
||||
position = "left",
|
||||
selfPositioned = false,
|
||||
tooltipPosition = "right",
|
||||
tooltipPosition = "top",
|
||||
user,
|
||||
isNotAllowed,
|
||||
customButton = false,
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
@ -65,6 +67,38 @@ export const ViewAssigneeSelect: React.FC<Props> = ({
|
||||
),
|
||||
}));
|
||||
|
||||
const assigneeLabel = (
|
||||
<Tooltip
|
||||
position={tooltipPosition}
|
||||
tooltipHeading="Assignees"
|
||||
tooltipContent={
|
||||
issue.assignee_details.length > 0
|
||||
? issue.assignee_details
|
||||
.map((assignee) =>
|
||||
assignee?.first_name !== "" ? assignee?.first_name : assignee?.email
|
||||
)
|
||||
.join(", ")
|
||||
: "No Assignee"
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={`flex ${
|
||||
isNotAllowed ? "cursor-not-allowed" : "cursor-pointer"
|
||||
} items-center gap-2 text-brand-secondary`}
|
||||
>
|
||||
{issue.assignees && issue.assignees.length > 0 && Array.isArray(issue.assignees) ? (
|
||||
<div className="-my-0.5 flex items-center justify-center gap-2">
|
||||
<AssigneesList userIds={issue.assignees} length={5} showLength={true} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<UserGroupIcon className="h-4 w-4 text-brand-secondary" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
return (
|
||||
<CustomSearchSelect
|
||||
value={issue.assignees}
|
||||
@ -74,7 +108,7 @@ export const ViewAssigneeSelect: React.FC<Props> = ({
|
||||
if (newData.includes(data)) newData.splice(newData.indexOf(data), 1);
|
||||
else newData.push(data);
|
||||
|
||||
partialUpdateIssue({ assignees_list: data }, issue.id);
|
||||
partialUpdateIssue({ assignees_list: data }, issue);
|
||||
|
||||
trackEventServices.trackIssuePartialPropertyUpdateEvent(
|
||||
{
|
||||
@ -90,37 +124,7 @@ export const ViewAssigneeSelect: React.FC<Props> = ({
|
||||
);
|
||||
}}
|
||||
options={options}
|
||||
label={
|
||||
<Tooltip
|
||||
position={`top-${tooltipPosition}`}
|
||||
tooltipHeading="Assignees"
|
||||
tooltipContent={
|
||||
issue.assignee_details.length > 0
|
||||
? issue.assignee_details
|
||||
.map((assignee) =>
|
||||
assignee?.first_name !== "" ? assignee?.first_name : assignee?.email
|
||||
)
|
||||
.join(", ")
|
||||
: "No Assignee"
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={`flex ${
|
||||
isNotAllowed ? "cursor-not-allowed" : "cursor-pointer"
|
||||
} items-center gap-2 text-brand-secondary`}
|
||||
>
|
||||
{issue.assignees && issue.assignees.length > 0 && Array.isArray(issue.assignees) ? (
|
||||
<div className="-my-0.5 flex items-center justify-center gap-2">
|
||||
<AssigneesList userIds={issue.assignees} length={5} showLength={true} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<UserGroupIcon className="h-4 w-4 text-brand-secondary" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
}
|
||||
{...(customButton ? { customButton: assigneeLabel } : { label: assigneeLabel })}
|
||||
multiple
|
||||
noChevron
|
||||
position={position}
|
||||
|
@ -11,7 +11,9 @@ import { ICurrentUserResponse, IIssue } from "types";
|
||||
|
||||
type Props = {
|
||||
issue: IIssue;
|
||||
partialUpdateIssue: (formData: Partial<IIssue>, issueId: string) => void;
|
||||
partialUpdateIssue: (formData: Partial<IIssue>, issue: IIssue) => void;
|
||||
tooltipPosition?: "top" | "bottom";
|
||||
noBorder?: boolean;
|
||||
user: ICurrentUserResponse | undefined;
|
||||
isNotAllowed: boolean;
|
||||
};
|
||||
@ -19,6 +21,8 @@ type Props = {
|
||||
export const ViewDueDateSelect: React.FC<Props> = ({
|
||||
issue,
|
||||
partialUpdateIssue,
|
||||
tooltipPosition = "top",
|
||||
noBorder = false,
|
||||
user,
|
||||
isNotAllowed,
|
||||
}) => {
|
||||
@ -26,7 +30,11 @@ export const ViewDueDateSelect: React.FC<Props> = ({
|
||||
const { workspaceSlug } = router.query;
|
||||
|
||||
return (
|
||||
<Tooltip tooltipHeading="Due Date" tooltipContent={issue.target_date ?? "N/A"}>
|
||||
<Tooltip
|
||||
tooltipHeading="Due Date"
|
||||
tooltipContent={issue.target_date ?? "N/A"}
|
||||
position={tooltipPosition}
|
||||
>
|
||||
<div
|
||||
className={`group relative max-w-[6.5rem] ${
|
||||
issue.target_date === null
|
||||
@ -46,7 +54,7 @@ export const ViewDueDateSelect: React.FC<Props> = ({
|
||||
priority: issue.priority,
|
||||
state: issue.state,
|
||||
},
|
||||
issue.id
|
||||
issue
|
||||
);
|
||||
trackEventServices.trackIssuePartialPropertyUpdateEvent(
|
||||
{
|
||||
@ -62,6 +70,7 @@ export const ViewDueDateSelect: React.FC<Props> = ({
|
||||
);
|
||||
}}
|
||||
className={issue?.target_date ? "w-[6.5rem]" : "w-[5rem] text-center"}
|
||||
noBorder={noBorder}
|
||||
disabled={isNotAllowed}
|
||||
/>
|
||||
</div>
|
||||
|
@ -15,9 +15,11 @@ import { ICurrentUserResponse, IIssue } from "types";
|
||||
|
||||
type Props = {
|
||||
issue: IIssue;
|
||||
partialUpdateIssue: (formData: Partial<IIssue>, issueId: string) => void;
|
||||
partialUpdateIssue: (formData: Partial<IIssue>, issue: IIssue) => void;
|
||||
position?: "left" | "right";
|
||||
tooltipPosition?: "top" | "bottom";
|
||||
selfPositioned?: boolean;
|
||||
customButton?: boolean;
|
||||
user: ICurrentUserResponse | undefined;
|
||||
isNotAllowed: boolean;
|
||||
};
|
||||
@ -26,7 +28,9 @@ export const ViewEstimateSelect: React.FC<Props> = ({
|
||||
issue,
|
||||
partialUpdateIssue,
|
||||
position = "left",
|
||||
tooltipPosition = "top",
|
||||
selfPositioned = false,
|
||||
customButton = false,
|
||||
user,
|
||||
isNotAllowed,
|
||||
}) => {
|
||||
@ -37,13 +41,22 @@ export const ViewEstimateSelect: React.FC<Props> = ({
|
||||
|
||||
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;
|
||||
|
||||
return (
|
||||
<CustomSelect
|
||||
value={issue.estimate_point}
|
||||
onChange={(val: number) => {
|
||||
partialUpdateIssue({ estimate_point: val }, issue.id);
|
||||
partialUpdateIssue({ estimate_point: val }, issue);
|
||||
trackEventServices.trackIssuePartialPropertyUpdateEvent(
|
||||
{
|
||||
workspaceSlug,
|
||||
@ -57,14 +70,7 @@ export const ViewEstimateSelect: React.FC<Props> = ({
|
||||
user
|
||||
);
|
||||
}}
|
||||
label={
|
||||
<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>
|
||||
}
|
||||
{...(customButton ? { customButton: estimateLabels } : { label: estimateLabels })}
|
||||
maxHeight="md"
|
||||
noChevron
|
||||
disabled={isNotAllowed}
|
||||
|
@ -3,3 +3,4 @@ export * from "./due-date";
|
||||
export * from "./estimate";
|
||||
export * from "./priority";
|
||||
export * from "./state";
|
||||
export * from "./label";
|
||||
|
151
apps/app/components/issues/view-select/label.tsx
Normal file
151
apps/app/components/issues/view-select/label.tsx
Normal 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]"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
@ -12,12 +12,16 @@ import { ICurrentUserResponse, IIssue } from "types";
|
||||
import { PRIORITIES } from "constants/project";
|
||||
// services
|
||||
import trackEventServices from "services/track-event.service";
|
||||
// helper
|
||||
import { capitalizeFirstLetter } from "helpers/string.helper";
|
||||
|
||||
type Props = {
|
||||
issue: IIssue;
|
||||
partialUpdateIssue: (formData: Partial<IIssue>, issueId: string) => void;
|
||||
partialUpdateIssue: (formData: Partial<IIssue>, issue: IIssue) => void;
|
||||
position?: "left" | "right";
|
||||
tooltipPosition?: "top" | "bottom";
|
||||
selfPositioned?: boolean;
|
||||
noBorder?: boolean;
|
||||
user: ICurrentUserResponse | undefined;
|
||||
isNotAllowed: boolean;
|
||||
};
|
||||
@ -26,7 +30,9 @@ export const ViewPrioritySelect: React.FC<Props> = ({
|
||||
issue,
|
||||
partialUpdateIssue,
|
||||
position = "left",
|
||||
tooltipPosition = "top",
|
||||
selfPositioned = false,
|
||||
noBorder = false,
|
||||
user,
|
||||
isNotAllowed,
|
||||
}) => {
|
||||
@ -37,7 +43,7 @@ export const ViewPrioritySelect: React.FC<Props> = ({
|
||||
<CustomSelect
|
||||
value={issue.priority}
|
||||
onChange={(data: string) => {
|
||||
partialUpdateIssue({ priority: data }, issue.id);
|
||||
partialUpdateIssue({ priority: data }, issue);
|
||||
trackEventServices.trackIssuePartialPropertyUpdateEvent(
|
||||
{
|
||||
workspaceSlug,
|
||||
@ -55,10 +61,12 @@ export const ViewPrioritySelect: React.FC<Props> = ({
|
||||
customButton={
|
||||
<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"
|
||||
} items-center shadow-sm ${
|
||||
issue.priority === "urgent"
|
||||
} ${noBorder ? "" : "h-6 w-6 border shadow-sm"} ${
|
||||
noBorder
|
||||
? ""
|
||||
: issue.priority === "urgent"
|
||||
? "border-red-500/20 bg-red-500/20 text-red-500"
|
||||
: issue.priority === "high"
|
||||
? "border-orange-500/20 bg-orange-500/20 text-orange-500"
|
||||
@ -67,14 +75,23 @@ export const ViewPrioritySelect: React.FC<Props> = ({
|
||||
: issue.priority === "low"
|
||||
? "border-green-500/20 bg-green-500/20 text-green-500"
|
||||
: "border-brand-base"
|
||||
}`}
|
||||
} items-center`}
|
||||
>
|
||||
<Tooltip tooltipHeading="Priority" tooltipContent={issue.priority ?? "None"}>
|
||||
<span>
|
||||
<Tooltip
|
||||
tooltipHeading="Priority"
|
||||
tooltipContent={issue.priority ?? "None"}
|
||||
position={tooltipPosition}
|
||||
>
|
||||
<span className="flex gap-1 items-center text-brand-secondary text-xs">
|
||||
{getPriorityIcon(
|
||||
issue.priority && issue.priority !== "" ? issue.priority ?? "" : "None",
|
||||
"text-sm"
|
||||
)}
|
||||
{noBorder
|
||||
? issue.priority && issue.priority !== ""
|
||||
? capitalizeFirstLetter(issue.priority) ?? ""
|
||||
: "None"
|
||||
: ""}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</button>
|
||||
|
@ -19,9 +19,11 @@ import { STATES_LIST } from "constants/fetch-keys";
|
||||
|
||||
type Props = {
|
||||
issue: IIssue;
|
||||
partialUpdateIssue: (formData: Partial<IIssue>, issueId: string) => void;
|
||||
partialUpdateIssue: (formData: Partial<IIssue>, issue: IIssue) => void;
|
||||
position?: "left" | "right";
|
||||
tooltipPosition?: "top" | "bottom";
|
||||
selfPositioned?: boolean;
|
||||
customButton?: boolean;
|
||||
user: ICurrentUserResponse | undefined;
|
||||
isNotAllowed: boolean;
|
||||
};
|
||||
@ -30,7 +32,9 @@ export const ViewStateSelect: React.FC<Props> = ({
|
||||
issue,
|
||||
partialUpdateIssue,
|
||||
position = "left",
|
||||
tooltipPosition = "top",
|
||||
selfPositioned = false,
|
||||
customButton = false,
|
||||
user,
|
||||
isNotAllowed,
|
||||
}) => {
|
||||
@ -58,6 +62,20 @@ export const ViewStateSelect: React.FC<Props> = ({
|
||||
|
||||
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 (
|
||||
<CustomSearchSelect
|
||||
value={issue.state}
|
||||
@ -68,7 +86,7 @@ export const ViewStateSelect: React.FC<Props> = ({
|
||||
priority: issue.priority,
|
||||
target_date: issue.target_date,
|
||||
},
|
||||
issue.id
|
||||
issue
|
||||
);
|
||||
trackEventServices.trackIssuePartialPropertyUpdateEvent(
|
||||
{
|
||||
@ -101,18 +119,7 @@ export const ViewStateSelect: React.FC<Props> = ({
|
||||
}
|
||||
}}
|
||||
options={options}
|
||||
label={
|
||||
<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>
|
||||
}
|
||||
{...(customButton ? { customButton: stateLabel } : { label: stateLabel })}
|
||||
position={position}
|
||||
disabled={isNotAllowed}
|
||||
noChevron
|
||||
|
@ -111,7 +111,7 @@ export const DeleteModuleModal: React.FC<Props> = ({ isOpen, setIsOpen, data, us
|
||||
<div className="mt-2">
|
||||
<p className="text-sm text-brand-secondary">
|
||||
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}
|
||||
</span>
|
||||
? All of the data related to the module will be permanently removed. This
|
||||
|
@ -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 items-start justify-start 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.MenuItem onClick={() => setModuleDeleteModal(true)}>
|
||||
<span className="flex items-center justify-start gap-2">
|
||||
@ -339,7 +343,7 @@ export const ModuleDetailsSidebar: React.FC<Props> = ({ module, isOpen, moduleIs
|
||||
</CustomMenu>
|
||||
</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}
|
||||
</span>
|
||||
</div>
|
||||
|
@ -138,7 +138,7 @@ export const SingleModuleCard: React.FC<Props> = ({ module, handleEditModule, us
|
||||
<Tooltip tooltipContent={module.name} position="top-left">
|
||||
<Link href={`/${workspaceSlug}/projects/${module.project}/modules/${module.id}`}>
|
||||
<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)}
|
||||
</h3>
|
||||
</a>
|
||||
|
@ -195,7 +195,7 @@ export const CreateUpdateBlockInline: React.FC<Props> = ({
|
||||
projectId as string,
|
||||
{
|
||||
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
|
||||
)
|
||||
|
@ -136,7 +136,7 @@ export const DeletePageModal: React.FC<TConfirmPageDeletionProps> = ({
|
||||
<div className="mt-2">
|
||||
<p className="text-sm text-brand-secondary">
|
||||
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}
|
||||
</span>
|
||||
? All of the data related to the page will be permanently removed. This
|
||||
|
@ -41,7 +41,7 @@ export const RecentPagesList: React.FC<TPagesListProps> = ({ viewType }) => {
|
||||
if (pages[key].length === 0) return null;
|
||||
|
||||
return (
|
||||
<div key={key}>
|
||||
<div key={key} className="h-full overflow-hidden">
|
||||
<h2 className="text-xl font-semibold capitalize mb-2">
|
||||
{replaceUnderscoreIfSnakeCase(key)}
|
||||
</h2>
|
||||
|
@ -194,7 +194,7 @@ export const SinglePageBlock: React.FC<Props> = ({ block, projectDetails, index,
|
||||
projectId as string,
|
||||
{
|
||||
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
|
||||
)
|
||||
@ -417,7 +417,7 @@ export const SinglePageBlock: React.FC<Props> = ({ block, projectDetails, index,
|
||||
</div>
|
||||
<div className={`flex items-start gap-2 px-3 ${snapshot.isDragging ? "" : "py-4"}`}>
|
||||
<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)}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
|
@ -128,13 +128,13 @@ export const DeleteProjectModal: React.FC<TConfirmProjectDeletionProps> = ({
|
||||
<span>
|
||||
<p className="text-sm leading-7 text-brand-secondary">
|
||||
Are you sure you want to delete project{" "}
|
||||
<span className="break-all font-semibold">{selectedProject?.name}</span>? All
|
||||
of the data related to the project will be permanently removed. This action
|
||||
cannot be undone
|
||||
<span className="break-words font-semibold">{selectedProject?.name}</span>?
|
||||
All of the data related to the project will be permanently removed. This
|
||||
action cannot be undone
|
||||
</p>
|
||||
</span>
|
||||
<div className="text-brand-secondary">
|
||||
<p className="break-all text-sm ">
|
||||
<p className="break-words text-sm ">
|
||||
Enter the project name{" "}
|
||||
<span className="font-medium text-brand-base">{selectedProject?.name}</span>{" "}
|
||||
to continue:
|
||||
|
@ -195,7 +195,7 @@ export const SingleProjectCard: React.FC<ProjectCardProps> = ({
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mt-3.5 mb-7 break-all">
|
||||
<p className="mt-3.5 mb-7 break-words">
|
||||
{truncateText(project.description ?? "", 100)}
|
||||
</p>
|
||||
</a>
|
||||
|
@ -15,7 +15,7 @@ import stateService from "services/state.service";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
// ui
|
||||
import { CustomSelect, Input, PrimaryButton, SecondaryButton } from "components/ui";
|
||||
import { CustomSelect, Input, PrimaryButton, SecondaryButton, Tooltip } from "components/ui";
|
||||
// types
|
||||
import type { ICurrentUserResponse, IState, IStateResponse } from "types";
|
||||
// fetch-keys
|
||||
@ -28,6 +28,7 @@ type Props = {
|
||||
onClose: () => void;
|
||||
selectedGroup: StateGroup | null;
|
||||
user: ICurrentUserResponse | undefined;
|
||||
groupLength: number;
|
||||
};
|
||||
|
||||
export type StateGroup = "backlog" | "unstarted" | "started" | "completed" | "cancelled" | null;
|
||||
@ -43,6 +44,7 @@ export const CreateUpdateStateInline: React.FC<Props> = ({
|
||||
onClose,
|
||||
selectedGroup,
|
||||
user,
|
||||
groupLength,
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
@ -174,9 +176,8 @@ export const CreateUpdateStateInline: React.FC<Props> = ({
|
||||
{({ open }) => (
|
||||
<>
|
||||
<Popover.Button
|
||||
className={`group inline-flex items-center text-base font-medium focus:outline-none ${
|
||||
open ? "text-brand-base" : "text-brand-secondary"
|
||||
}`}
|
||||
className={`group inline-flex items-center text-base font-medium focus:outline-none ${open ? "text-brand-base" : "text-brand-secondary"
|
||||
}`}
|
||||
>
|
||||
{watch("color") && watch("color") !== "" && (
|
||||
<span
|
||||
@ -228,22 +229,27 @@ export const CreateUpdateStateInline: React.FC<Props> = ({
|
||||
name="group"
|
||||
control={control}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<CustomSelect
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
label={
|
||||
Object.keys(GROUP_CHOICES).find((k) => k === value.toString())
|
||||
? GROUP_CHOICES[value.toString() as keyof typeof GROUP_CHOICES]
|
||||
: "Select group"
|
||||
}
|
||||
input
|
||||
>
|
||||
{Object.keys(GROUP_CHOICES).map((key) => (
|
||||
<CustomSelect.Option key={key} value={key}>
|
||||
{GROUP_CHOICES[key as keyof typeof GROUP_CHOICES]}
|
||||
</CustomSelect.Option>
|
||||
))}
|
||||
</CustomSelect>
|
||||
<Tooltip tooltipContent={groupLength === 1 ? "Cannot have an empty group." : "Choose State"} >
|
||||
<div>
|
||||
<CustomSelect
|
||||
disabled={groupLength === 1}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
label={
|
||||
Object.keys(GROUP_CHOICES).find((k) => k === value.toString())
|
||||
? GROUP_CHOICES[value.toString() as keyof typeof GROUP_CHOICES]
|
||||
: "Select group"
|
||||
}
|
||||
input
|
||||
>
|
||||
{Object.keys(GROUP_CHOICES).map((key) => (
|
||||
<CustomSelect.Option key={key} value={key}>
|
||||
{GROUP_CHOICES[key as keyof typeof GROUP_CHOICES]}
|
||||
</CustomSelect.Option>
|
||||
))}
|
||||
</CustomSelect>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
@ -19,7 +19,9 @@ type Props = {
|
||||
noChevron?: boolean;
|
||||
position?: "left" | "right";
|
||||
verticalPosition?: "top" | "bottom";
|
||||
menuItemsClassName?: string;
|
||||
customButton?: JSX.Element;
|
||||
menuItemsWhiteBg?: boolean;
|
||||
};
|
||||
|
||||
type MenuItemProps = {
|
||||
@ -43,7 +45,9 @@ const CustomMenu = ({
|
||||
noChevron = false,
|
||||
position = "right",
|
||||
verticalPosition = "bottom",
|
||||
menuItemsClassName = "",
|
||||
customButton,
|
||||
menuItemsWhiteBg = false,
|
||||
}: Props) => (
|
||||
<Menu as="div" className={`relative w-min whitespace-nowrap text-left ${className}`}>
|
||||
{({ open }) => (
|
||||
@ -105,7 +109,7 @@ const CustomMenu = ({
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<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"
|
||||
} ${verticalPosition === "top" ? "bottom-full mb-1" : "mt-1"} ${
|
||||
height === "sm"
|
||||
@ -127,7 +131,11 @@ const CustomMenu = ({
|
||||
: width === "xl"
|
||||
? "w-48"
|
||||
: "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>
|
||||
</Menu.Items>
|
||||
|
@ -29,6 +29,7 @@ type CustomSearchSelectProps = {
|
||||
selfPositioned?: boolean;
|
||||
multiple?: boolean;
|
||||
footerOption?: JSX.Element;
|
||||
noResultIcon?: JSX.Element;
|
||||
dropdownWidth?: string;
|
||||
};
|
||||
export const CustomSearchSelect = ({
|
||||
@ -47,6 +48,7 @@ export const CustomSearchSelect = ({
|
||||
disabled = false,
|
||||
selfPositioned = false,
|
||||
multiple = false,
|
||||
noResultIcon,
|
||||
footerOption,
|
||||
dropdownWidth,
|
||||
}: CustomSearchSelectProps) => {
|
||||
@ -171,7 +173,10 @@ export const CustomSearchSelect = ({
|
||||
</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>
|
||||
|
@ -54,7 +54,7 @@ const CustomSelect = ({
|
||||
) : (
|
||||
<Listbox.Button
|
||||
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 ${
|
||||
input ? "border-brand-base px-3 py-2 text-sm" : "px-2.5 py-1 text-xs"
|
||||
} ${
|
||||
|
@ -11,6 +11,7 @@ type Props = {
|
||||
placeholder?: string;
|
||||
displayShortForm?: boolean;
|
||||
error?: boolean;
|
||||
noBorder?: boolean;
|
||||
className?: string;
|
||||
isClearable?: boolean;
|
||||
disabled?: boolean;
|
||||
@ -23,6 +24,7 @@ export const CustomDatePicker: React.FC<Props> = ({
|
||||
placeholder = "Select date",
|
||||
displayShortForm = false,
|
||||
error = false,
|
||||
noBorder = false,
|
||||
className = "",
|
||||
isClearable = true,
|
||||
disabled = false,
|
||||
@ -44,7 +46,9 @@ export const CustomDatePicker: React.FC<Props> = ({
|
||||
: ""
|
||||
} ${error ? "border-red-500 bg-red-100" : ""} ${
|
||||
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"
|
||||
isClearable={isClearable}
|
||||
disabled={disabled}
|
||||
|
@ -3,7 +3,7 @@ import { Fragment, useState } from "react";
|
||||
// headless ui
|
||||
import { Menu, Transition } from "@headlessui/react";
|
||||
// icons
|
||||
import { ChevronDownIcon } from "@heroicons/react/24/outline";
|
||||
import { CheckIcon, ChevronDownIcon } from "@heroicons/react/24/outline";
|
||||
import { ChevronRightIcon, ChevronLeftIcon } from "@heroicons/react/20/solid";
|
||||
|
||||
type MultiLevelDropdownProps = {
|
||||
@ -127,9 +127,14 @@ export const MultiLevelDropdown: React.FC<MultiLevelDropdownProps> = ({
|
||||
}}
|
||||
className={`${
|
||||
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}
|
||||
<CheckIcon
|
||||
className={`h-3.5 w-3.5 opacity-0 ${
|
||||
child.selected ? "opacity-100" : ""
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
@ -42,7 +42,7 @@ export const Tooltip: React.FC<Props> = ({
|
||||
disabled={disabled}
|
||||
content={
|
||||
<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"
|
||||
}`}
|
||||
>
|
||||
|
@ -115,7 +115,7 @@ export const DeleteViewModal: React.FC<Props> = ({ isOpen, data, setIsOpen, user
|
||||
<div className="mt-2">
|
||||
<p className="text-sm text-brand-secondary">
|
||||
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}
|
||||
</span>
|
||||
? All of the data related to the view will be permanently removed. This
|
||||
|
@ -70,7 +70,7 @@ export const SelectFilters: React.FC<Props> = ({
|
||||
value: PRIORITIES,
|
||||
children: [
|
||||
...PRIORITIES.map((priority) => ({
|
||||
id: priority ?? "none",
|
||||
id: priority === null ? "null" : priority,
|
||||
label: (
|
||||
<div className="flex items-center gap-2">
|
||||
{getPriorityIcon(priority)} {priority ?? "None"}
|
||||
@ -78,9 +78,9 @@ export const SelectFilters: React.FC<Props> = ({
|
||||
),
|
||||
value: {
|
||||
key: "priority",
|
||||
value: priority,
|
||||
value: priority === null ? "null" : priority,
|
||||
},
|
||||
selected: filters?.priority?.includes(priority ?? "none"),
|
||||
selected: filters?.priority?.includes(priority === null ? "null" : priority),
|
||||
})),
|
||||
],
|
||||
},
|
||||
|
@ -1,34 +1,141 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
// ui
|
||||
import { CalendarGraph } from "components/ui";
|
||||
import { Tooltip } from "components/ui";
|
||||
// helpers
|
||||
import { renderShortDateWithYearFormat } from "helpers/date-time.helper";
|
||||
import { renderDateFormat, renderShortNumericDateFormat } from "helpers/date-time.helper";
|
||||
// types
|
||||
import { IUserActivity } from "types";
|
||||
// constants
|
||||
import { DAYS, MONTHS } from "constants/project";
|
||||
|
||||
type Props = {
|
||||
activities: IUserActivity[] | undefined;
|
||||
};
|
||||
|
||||
export const ActivityGraph: React.FC<Props> = ({ activities }) => (
|
||||
<CalendarGraph
|
||||
data={
|
||||
activities?.map((activity) => ({
|
||||
day: activity.created_date,
|
||||
value: activity.activity_count,
|
||||
})) ?? []
|
||||
export const ActivityGraph: React.FC<Props> = ({ activities }) => {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [width, setWidth] = useState(0);
|
||||
|
||||
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()}
|
||||
height="200px"
|
||||
margin={{ bottom: 0, left: 10, right: 10, top: 0 }}
|
||||
tooltip={(datum) => (
|
||||
<div className="rounded-md border border-brand-base bg-brand-surface-2 p-2 text-xs">
|
||||
<span className="text-brand-secondary">{renderShortDateWithYearFormat(datum.day)}:</span>{" "}
|
||||
{datum.value}
|
||||
|
||||
return dates;
|
||||
};
|
||||
|
||||
const recentDates = [
|
||||
...getDatesOfMonth(recentMonths[0]),
|
||||
...getDatesOfMonth(recentMonths[1]),
|
||||
...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 className="flex items-center justify-between" style={{ width: `${width}px` }}>
|
||||
{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>
|
||||
)}
|
||||
theme={{
|
||||
background: "rgb(var(--color-bg-base))",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user