Compare commits
68
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
11faf3f810 | ||
|
|
30ea1adf61 | ||
|
|
546aa40aa3 | ||
|
|
78669363b1 | ||
|
|
bca749986a | ||
|
|
229938114d | ||
|
|
c5e418ab47 | ||
|
|
ecdd1f1d03 | ||
|
|
e687cd6f6a | ||
|
|
9b6721790f | ||
|
|
05df65577a | ||
|
|
52d21b9dda | ||
|
|
51f10d5f36 | ||
|
|
9275e6f373 | ||
|
|
4aef8c2242 | ||
|
|
780573dadd | ||
|
|
5e625ab132 | ||
|
|
34123681cf | ||
|
|
c72ff782ac | ||
|
|
6eb72507a5 | ||
|
|
26b18b431b | ||
|
|
1bae9289f5 | ||
|
|
5c5bcb33e3 | ||
|
|
bed5f76082 | ||
|
|
124c2f772e | ||
|
|
b38898753f | ||
|
|
86a120f11e | ||
|
|
da603dc3f8 | ||
|
|
2f3970f641 | ||
|
|
d759438ebd | ||
|
|
0102f1d693 | ||
|
|
53e443d816 | ||
|
|
98b9957753 | ||
|
|
509af4662d | ||
|
|
d1f2a819f5 | ||
|
|
a42bff675b | ||
|
|
c71a2137e6 | ||
|
|
5e1f0a2604 | ||
|
|
ccab382d7d | ||
|
|
a7aa78a349 | ||
|
|
5ae963c451 | ||
|
|
07c097c9ad | ||
|
|
fc92d7d1a0 | ||
|
|
9ba8f5c21f | ||
|
|
059b8c793a | ||
|
|
93da220c4a | ||
|
|
0feab162ff | ||
|
|
55a1291b1d | ||
|
|
b12a00cf4a | ||
|
|
52ee8c5615 | ||
|
|
68108c9fe9 | ||
|
|
88fbe85087 | ||
|
|
9b423cea4b | ||
|
|
9d891ecce1 | ||
|
|
16a7bd3bda | ||
|
|
6e9f3971a5 | ||
|
|
fd3f6ab7a4 | ||
|
|
d05d814efe | ||
|
|
6394f517a2 | ||
|
|
379d258375 | ||
|
|
6a27827c16 | ||
|
|
206ca98307 | ||
|
|
473c32d3d4 | ||
|
|
124d1c30aa | ||
|
|
fd7274ba1c | ||
|
|
46d93525be | ||
|
|
dbbce439ff | ||
|
|
4c0857233e |
@@ -19,14 +19,14 @@
|
||||
<p>
|
||||
<a href="https://app.plane.so/#gh-light-mode-only" target="_blank">
|
||||
<img
|
||||
src="https://ik.imagekit.io/killbluedog/Plane_Screen.png?updatedAt=1684942001069"
|
||||
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/Plane+Screen.png"
|
||||
alt="Plane Screens"
|
||||
width="100%"
|
||||
/>
|
||||
</a>
|
||||
<a href="https://app.plane.so/#gh-dark-mode-only" target="_blank">
|
||||
<img
|
||||
src="https://ik.imagekit.io/killbluedog/Plane_Screens_Dark_Mode.png?updatedAt=1684942388044"
|
||||
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/Plane+Screens+Dark+Mode.png"
|
||||
alt="Plane Screens"
|
||||
width="100%"
|
||||
/>
|
||||
@@ -86,7 +86,7 @@ docker compose up -d
|
||||
<p>
|
||||
<a href="https://plane.so" target="_blank">
|
||||
<img
|
||||
src="https://ik.imagekit.io/killbluedog/Plane_Views_Dark_Mode.png?updatedAt=1684943050275"
|
||||
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/Plane+Views+Dark+Mode.png"
|
||||
alt="Plane Views"
|
||||
width="100%"
|
||||
/>
|
||||
@@ -95,7 +95,7 @@ docker compose up -d
|
||||
<p>
|
||||
<a href="https://plane.so" target="_blank">
|
||||
<img
|
||||
src="https://ik.imagekit.io/killbluedog/Plane_Issue_Detail_Dark_Mode.png?updatedAt=1684943050202"
|
||||
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/Plane+Issue+Detail+Dark+Mode.png"
|
||||
alt="Plane Issue Details"
|
||||
width="100%"
|
||||
/>
|
||||
@@ -104,7 +104,7 @@ docker compose up -d
|
||||
<p>
|
||||
<a href="https://plane.so" target="_blank">
|
||||
<img
|
||||
src="https://ik.imagekit.io/killbluedog/Plane_Cycles___Modules_Dark_Mode.png?updatedAt=1684943050281"
|
||||
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/Plane+Cycles+%26+Modules+Dark+Mode.png"
|
||||
alt="Plane Cycles and Modules"
|
||||
width="100%"
|
||||
/>
|
||||
@@ -113,7 +113,7 @@ docker compose up -d
|
||||
<p>
|
||||
<a href="https://plane.so" target="_blank">
|
||||
<img
|
||||
src="https://ik.imagekit.io/killbluedog/Plane_Analytics_Dark_Mode.png?updatedAt=1684944596824"
|
||||
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/Plane+Analytics+Dark+Mode.png"
|
||||
alt="Plane Analytics"
|
||||
width="100%"
|
||||
/>
|
||||
@@ -122,7 +122,7 @@ docker compose up -d
|
||||
<p>
|
||||
<a href="https://plane.so" target="_blank">
|
||||
<img
|
||||
src="https://ik.imagekit.io/killbluedog/Plane_Pages_Dark_Mode.png?updatedAt=1684943050202"
|
||||
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/Plane+Pages+Dark+Mode.png"
|
||||
alt="Plane Pages"
|
||||
width="100%"
|
||||
/>
|
||||
@@ -132,7 +132,7 @@ docker compose up -d
|
||||
<p>
|
||||
<a href="https://plane.so" target="_blank">
|
||||
<img
|
||||
src="https://ik.imagekit.io/killbluedog/Plane_Commad_K_Dark_Mode.png?updatedAt=1684943050312"
|
||||
src="https://plane-marketing.s3.ap-south-1.amazonaws.com/Plane+Commad+K+Dark+Mode.png"
|
||||
alt="Plane Command Menu"
|
||||
width="100%"
|
||||
/>
|
||||
|
||||
@@ -49,7 +49,7 @@ USER root
|
||||
RUN apk --no-cache add "bash~=5.2"
|
||||
COPY ./bin ./bin/
|
||||
|
||||
RUN chmod +x ./bin/takeoff ./bin/worker
|
||||
RUN chmod +x ./bin/takeoff ./bin/worker ./bin/beat
|
||||
RUN chmod -R 777 /code
|
||||
|
||||
USER captain
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
python manage.py wait_for_db
|
||||
celery -A plane beat -l info
|
||||
@@ -6,4 +6,4 @@ python manage.py migrate
|
||||
# Create a Default User
|
||||
python bin/user_script.py
|
||||
|
||||
exec gunicorn -w 8 -k uvicorn.workers.UvicornWorker plane.asgi:application --bind 0.0.0.0:8000 --config gunicorn.config.py --max-requests 1200 --max-requests-jitter 1000 --access-logfile -
|
||||
exec gunicorn -w 8 -k uvicorn.workers.UvicornWorker plane.asgi:application --bind 0.0.0.0:8000 --max-requests 1200 --max-requests-jitter 1000 --access-logfile -
|
||||
|
||||
@@ -706,9 +706,6 @@ class CycleDateCheckEndpoint(BaseAPIView):
|
||||
|
||||
|
||||
class CycleFavoriteViewSet(BaseViewSet):
|
||||
permission_classes = [
|
||||
ProjectEntityPermission,
|
||||
]
|
||||
|
||||
serializer_class = CycleFavoriteSerializer
|
||||
model = CycleFavorite
|
||||
|
||||
@@ -30,31 +30,6 @@ class GPTIntegrationEndpoint(BaseAPIView):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
count = 0
|
||||
|
||||
# If logger is enabled check for request limit
|
||||
if settings.LOGGER_BASE_URL:
|
||||
try:
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
response = requests.post(
|
||||
settings.LOGGER_BASE_URL,
|
||||
json={"user_id": str(request.user.id)},
|
||||
headers=headers,
|
||||
)
|
||||
count = response.json().get("count", 0)
|
||||
if not response.json().get("success", False):
|
||||
return Response(
|
||||
{
|
||||
"error": "You have surpassed the monthly limit for AI assistance"
|
||||
},
|
||||
status=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
|
||||
prompt = request.data.get("prompt", False)
|
||||
task = request.data.get("task", False)
|
||||
|
||||
@@ -82,7 +57,6 @@ class GPTIntegrationEndpoint(BaseAPIView):
|
||||
{
|
||||
"response": text,
|
||||
"response_html": text_html,
|
||||
"count": count,
|
||||
"project_detail": ProjectLiteSerializer(project).data,
|
||||
"workspace_detail": WorkspaceLiteSerializer(workspace).data,
|
||||
},
|
||||
|
||||
@@ -480,9 +480,6 @@ class ModuleLinkViewSet(BaseViewSet):
|
||||
|
||||
|
||||
class ModuleFavoriteViewSet(BaseViewSet):
|
||||
permission_classes = [
|
||||
ProjectEntityPermission,
|
||||
]
|
||||
|
||||
serializer_class = ModuleFavoriteSerializer
|
||||
model = ModuleFavorite
|
||||
|
||||
@@ -30,17 +30,16 @@ class NotificationViewSet(BaseViewSet):
|
||||
|
||||
def list(self, request, slug):
|
||||
try:
|
||||
order_by = request.GET.get("order_by", "-created_at")
|
||||
snoozed = request.GET.get("snoozed", "false")
|
||||
archived = request.GET.get("archived", "false")
|
||||
read = request.GET.get("read", "false")
|
||||
read = request.GET.get("read", "true")
|
||||
|
||||
# Filter type
|
||||
type = request.GET.get("type", "all")
|
||||
|
||||
notifications = Notification.objects.filter(
|
||||
workspace__slug=slug, receiver_id=request.user.id
|
||||
).order_by(order_by)
|
||||
).order_by("snoozed_till", "-created_at")
|
||||
|
||||
# Filter for snoozed notifications
|
||||
if snoozed == "false":
|
||||
@@ -53,8 +52,6 @@ class NotificationViewSet(BaseViewSet):
|
||||
Q(snoozed_till__lt=timezone.now()) | Q(snoozed_till__isnull=False)
|
||||
)
|
||||
|
||||
if read == "true":
|
||||
notifications = notifications.filter(read_at__isnull=False)
|
||||
if read == "false":
|
||||
notifications = notifications.filter(read_at__isnull=True)
|
||||
|
||||
@@ -213,10 +210,11 @@ class UnreadNotificationEndpoint(BaseAPIView):
|
||||
def get(self, request, slug):
|
||||
try:
|
||||
# Watching Issues Count
|
||||
watching_notification_count = Notification.objects.filter(
|
||||
watching_issues_count = Notification.objects.filter(
|
||||
workspace__slug=slug,
|
||||
receiver_id=request.user.id,
|
||||
read_at__isnull=True,
|
||||
archived_at__isnull=True,
|
||||
entity_identifier__in=IssueSubscriber.objects.filter(
|
||||
workspace__slug=slug, subscriber_id=request.user.id
|
||||
).values_list("issue_id", flat=True),
|
||||
@@ -227,6 +225,7 @@ class UnreadNotificationEndpoint(BaseAPIView):
|
||||
workspace__slug=slug,
|
||||
receiver_id=request.user.id,
|
||||
read_at__isnull=True,
|
||||
archived_at__isnull=True,
|
||||
entity_identifier__in=IssueAssignee.objects.filter(
|
||||
workspace__slug=slug, assignee_id=request.user.id
|
||||
).values_list("issue_id", flat=True),
|
||||
@@ -237,6 +236,7 @@ class UnreadNotificationEndpoint(BaseAPIView):
|
||||
workspace__slug=slug,
|
||||
receiver_id=request.user.id,
|
||||
read_at__isnull=True,
|
||||
archived_at__isnull=True,
|
||||
entity_identifier__in=Issue.objects.filter(
|
||||
workspace__slug=slug, created_by=request.user
|
||||
).values_list("pk", flat=True),
|
||||
@@ -244,7 +244,7 @@ class UnreadNotificationEndpoint(BaseAPIView):
|
||||
|
||||
return Response(
|
||||
{
|
||||
"watching_notifications": watching_notification_count,
|
||||
"watching_issues": watching_issues_count,
|
||||
"my_issues": my_issues_count,
|
||||
"created_issues": created_issues_count,
|
||||
},
|
||||
|
||||
@@ -160,32 +160,32 @@ class ProjectViewSet(BaseViewSet):
|
||||
states = [
|
||||
{
|
||||
"name": "Backlog",
|
||||
"color": "#5e6ad2",
|
||||
"color": "#A3A3A3",
|
||||
"sequence": 15000,
|
||||
"group": "backlog",
|
||||
"default": True,
|
||||
},
|
||||
{
|
||||
"name": "Todo",
|
||||
"color": "#eb5757",
|
||||
"color": "#3A3A3A",
|
||||
"sequence": 25000,
|
||||
"group": "unstarted",
|
||||
},
|
||||
{
|
||||
"name": "In Progress",
|
||||
"color": "#26b5ce",
|
||||
"color": "#F59E0B",
|
||||
"sequence": 35000,
|
||||
"group": "started",
|
||||
},
|
||||
{
|
||||
"name": "Done",
|
||||
"color": "#f2c94c",
|
||||
"color": "#16A34A",
|
||||
"sequence": 45000,
|
||||
"group": "completed",
|
||||
},
|
||||
{
|
||||
"name": "Cancelled",
|
||||
"color": "#4cb782",
|
||||
"color": "#EF4444",
|
||||
"sequence": 55000,
|
||||
"group": "cancelled",
|
||||
},
|
||||
|
||||
@@ -1028,23 +1028,26 @@ def issue_activity(
|
||||
actor = User.objects.get(pk=actor_id)
|
||||
project = Project.objects.get(pk=project_id)
|
||||
|
||||
if type not in [
|
||||
"cycle.activity.created",
|
||||
"cycle.activity.deleted",
|
||||
"module.activity.created",
|
||||
"module.activity.deleted",
|
||||
]:
|
||||
issue = Issue.objects.filter(pk=issue_id, project_id=project_id).first()
|
||||
|
||||
try:
|
||||
issue = Issue.objects.get(pk=issue_id)
|
||||
issue.updated_at = timezone.now()
|
||||
issue.save(update_fields=["updated_at"])
|
||||
except Exception as e:
|
||||
pass
|
||||
if issue is not None:
|
||||
issue.updated_at = timezone.now()
|
||||
issue.save(update_fields=["updated_at"])
|
||||
|
||||
|
||||
if subscriber:
|
||||
# add the user to issue subscriber
|
||||
try:
|
||||
_ = IssueSubscriber.objects.get_or_create(
|
||||
issue_id=issue_id, subscriber=actor
|
||||
)
|
||||
except Exception as e:
|
||||
pass
|
||||
if subscriber:
|
||||
# add the user to issue subscriber
|
||||
try:
|
||||
_ = IssueSubscriber.objects.get_or_create(
|
||||
issue_id=issue_id, subscriber=actor
|
||||
)
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
ACTIVITY_MAPPER = {
|
||||
"issue.activity.created": create_issue_activity,
|
||||
@@ -1096,66 +1099,80 @@ def issue_activity(
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
|
||||
# Create Notifications
|
||||
bulk_notifications = []
|
||||
if type not in [
|
||||
"cycle.activity.created",
|
||||
"cycle.activity.deleted",
|
||||
"module.activity.created",
|
||||
"module.activity.deleted",
|
||||
]:
|
||||
# Create Notifications
|
||||
bulk_notifications = []
|
||||
|
||||
issue_subscribers = list(
|
||||
IssueSubscriber.objects.filter(project=project, issue_id=issue_id)
|
||||
.exclude(subscriber_id=actor_id)
|
||||
.values_list("subscriber", flat=True)
|
||||
)
|
||||
issue_subscribers = list(
|
||||
IssueSubscriber.objects.filter(project=project, issue_id=issue_id)
|
||||
.exclude(subscriber_id=actor_id)
|
||||
.values_list("subscriber", flat=True)
|
||||
)
|
||||
|
||||
issue_assignees = list(
|
||||
IssueAssignee.objects.filter(project=project, issue_id=issue_id)
|
||||
.exclude(assignee_id=actor_id)
|
||||
.values_list("assignee", flat=True)
|
||||
)
|
||||
issue_assignees = list(
|
||||
IssueAssignee.objects.filter(project=project, issue_id=issue_id)
|
||||
.exclude(assignee_id=actor_id)
|
||||
.values_list("assignee", flat=True)
|
||||
)
|
||||
|
||||
issue_subscribers = issue_subscribers + issue_assignees
|
||||
issue_subscribers = issue_subscribers + issue_assignees
|
||||
|
||||
# Add bot filtering
|
||||
if issue.created_by_id is not None and not issue.created_by.is_bot:
|
||||
issue_subscribers = issue_subscribers + [issue.created_by_id]
|
||||
issue = Issue.objects.filter(pk=issue_id, project_id=project_id).first()
|
||||
|
||||
issue = Issue.objects.get(project=project, pk=issue_id)
|
||||
for subscriber in issue_subscribers:
|
||||
for issue_activity in issue_activities_created:
|
||||
bulk_notifications.append(
|
||||
Notification(
|
||||
workspace=project.workspace,
|
||||
sender="in_app:issue_activities",
|
||||
triggered_by_id=actor_id,
|
||||
receiver_id=subscriber,
|
||||
entity_identifier=issue_id,
|
||||
entity_name="issue",
|
||||
project=project,
|
||||
title=issue_activity.comment,
|
||||
data={
|
||||
"issue": {
|
||||
"id": str(issue_id),
|
||||
"name": str(issue.name),
|
||||
"identifier": str(project.identifier),
|
||||
"sequence_id": issue.sequence_id,
|
||||
"state_name": issue.state.name,
|
||||
"state_group": issue.state.group,
|
||||
# Add bot filtering
|
||||
if (
|
||||
issue is not None
|
||||
and issue.created_by_id is not None
|
||||
and not issue.created_by.is_bot
|
||||
and str(issue.created_by_id) != str(actor_id)
|
||||
):
|
||||
issue_subscribers = issue_subscribers + [issue.created_by_id]
|
||||
|
||||
for subscriber in issue_subscribers:
|
||||
for issue_activity in issue_activities_created:
|
||||
bulk_notifications.append(
|
||||
Notification(
|
||||
workspace=project.workspace,
|
||||
sender="in_app:issue_activities",
|
||||
triggered_by_id=actor_id,
|
||||
receiver_id=subscriber,
|
||||
entity_identifier=issue_id,
|
||||
entity_name="issue",
|
||||
project=project,
|
||||
title=issue_activity.comment,
|
||||
data={
|
||||
"issue": {
|
||||
"id": str(issue_id),
|
||||
"name": str(issue.name),
|
||||
"identifier": str(project.identifier),
|
||||
"sequence_id": issue.sequence_id,
|
||||
"state_name": issue.state.name,
|
||||
"state_group": issue.state.group,
|
||||
},
|
||||
"issue_activity": {
|
||||
"id": str(issue_activity.id),
|
||||
"verb": str(issue_activity.verb),
|
||||
"field": str(issue_activity.field),
|
||||
"actor": str(issue_activity.actor_id),
|
||||
"new_value": str(issue_activity.new_value),
|
||||
"old_value": str(issue_activity.old_value),
|
||||
"issue_comment": str(
|
||||
issue_activity.issue_comment.comment_stripped
|
||||
if issue_activity.issue_comment is not None
|
||||
else ""
|
||||
),
|
||||
},
|
||||
},
|
||||
"issue_activity": {
|
||||
"id": str(issue_activity.id),
|
||||
"verb": str(issue_activity.verb),
|
||||
"field": str(issue_activity.field),
|
||||
"actor": str(issue_activity.actor_id),
|
||||
"new_value": str(issue_activity.new_value),
|
||||
"old_value": str(issue_activity.old_value),
|
||||
"issue_comment": str(
|
||||
issue_activity.issue_comment.comment_stripped if issue_activity.issue_comment is not None else ""
|
||||
),
|
||||
},
|
||||
},
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
# Bulk create notifications
|
||||
Notification.objects.bulk_create(bulk_notifications, batch_size=100)
|
||||
# Bulk create notifications
|
||||
Notification.objects.bulk_create(bulk_notifications, batch_size=100)
|
||||
|
||||
return
|
||||
except Exception as e:
|
||||
|
||||
@@ -49,6 +49,11 @@ def archive_old_issues():
|
||||
Q(issue_module__module__target_date__lt=timezone.now().date())
|
||||
& Q(issue_module__isnull=False)
|
||||
),
|
||||
).filter(
|
||||
Q(issue_inbox__status=1)
|
||||
| Q(issue_inbox__status=-1)
|
||||
| Q(issue_inbox__status=2)
|
||||
| Q(issue_inbox__isnull=True)
|
||||
)
|
||||
|
||||
# Check if Issues
|
||||
@@ -65,7 +70,7 @@ def archive_old_issues():
|
||||
[
|
||||
issue_activity.delay(
|
||||
type="issue.activity.updated",
|
||||
requested_data=json.dumps({"archived_at": issue.archived_at}),
|
||||
requested_data=json.dumps({"archived_at": str(issue.archived_at)}),
|
||||
actor_id=str(project.created_by_id),
|
||||
issue_id=issue.id,
|
||||
project_id=project_id,
|
||||
@@ -111,6 +116,11 @@ def close_old_issues():
|
||||
Q(issue_module__module__target_date__lt=timezone.now().date())
|
||||
& Q(issue_module__isnull=False)
|
||||
),
|
||||
).filter(
|
||||
Q(issue_inbox__status=1)
|
||||
| Q(issue_inbox__status=-1)
|
||||
| Q(issue_inbox__status=2)
|
||||
| Q(issue_inbox__isnull=True)
|
||||
)
|
||||
|
||||
# Check if Issues
|
||||
@@ -130,7 +140,7 @@ def close_old_issues():
|
||||
[
|
||||
issue_activity.delay(
|
||||
type="issue.activity.updated",
|
||||
requested_data=json.dumps({"closed_to": issue.state_id}),
|
||||
requested_data=json.dumps({"closed_to": str(issue.state_id)}),
|
||||
actor_id=str(project.created_by_id),
|
||||
issue_id=issue.id,
|
||||
project_id=project_id,
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
# Generated by Django 4.2.3 on 2023-07-19 06:52
|
||||
|
||||
from django.conf import settings
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import plane.db.models.user
|
||||
import uuid
|
||||
|
||||
|
||||
|
||||
def onboarding_default_steps(apps, schema_editor):
|
||||
default_onboarding_schema = {
|
||||
"workspace_join": True,
|
||||
"profile_complete": True,
|
||||
"workspace_create": True,
|
||||
"workspace_invite": True,
|
||||
}
|
||||
|
||||
Model = apps.get_model("db", "User")
|
||||
updated_user = []
|
||||
for obj in Model.objects.filter(is_onboarded=True):
|
||||
obj.onboarding_step = default_onboarding_schema
|
||||
obj.is_tour_completed = True
|
||||
updated_user.append(obj)
|
||||
|
||||
Model.objects.bulk_update(updated_user, ["onboarding_step", "is_tour_completed"], batch_size=100)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("db", "0036_alter_workspace_organization_size"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="issue",
|
||||
name="archived_at",
|
||||
field=models.DateField(null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="project",
|
||||
name="archive_in",
|
||||
field=models.IntegerField(
|
||||
default=0,
|
||||
validators=[
|
||||
django.core.validators.MinValueValidator(0),
|
||||
django.core.validators.MaxValueValidator(12),
|
||||
],
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="project",
|
||||
name="close_in",
|
||||
field=models.IntegerField(
|
||||
default=0,
|
||||
validators=[
|
||||
django.core.validators.MinValueValidator(0),
|
||||
django.core.validators.MaxValueValidator(12),
|
||||
],
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="project",
|
||||
name="default_state",
|
||||
field=models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="default_state",
|
||||
to="db.state",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="is_tour_completed",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="onboarding_step",
|
||||
field=models.JSONField(default=plane.db.models.user.get_default_onboarding),
|
||||
),
|
||||
migrations.RunPython(onboarding_default_steps),
|
||||
migrations.CreateModel(
|
||||
name="Notification",
|
||||
fields=[
|
||||
(
|
||||
"created_at",
|
||||
models.DateTimeField(auto_now_add=True, verbose_name="Created At"),
|
||||
),
|
||||
(
|
||||
"updated_at",
|
||||
models.DateTimeField(
|
||||
auto_now=True, verbose_name="Last Modified At"
|
||||
),
|
||||
),
|
||||
(
|
||||
"id",
|
||||
models.UUIDField(
|
||||
db_index=True,
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
unique=True,
|
||||
),
|
||||
),
|
||||
("data", models.JSONField(null=True)),
|
||||
("entity_identifier", models.UUIDField(null=True)),
|
||||
("entity_name", models.CharField(max_length=255)),
|
||||
("title", models.TextField()),
|
||||
("message", models.JSONField(null=True)),
|
||||
("message_html", models.TextField(blank=True, default="<p></p>")),
|
||||
("message_stripped", models.TextField(blank=True, null=True)),
|
||||
("sender", models.CharField(max_length=255)),
|
||||
("read_at", models.DateTimeField(null=True)),
|
||||
("snoozed_till", models.DateTimeField(null=True)),
|
||||
("archived_at", models.DateTimeField(null=True)),
|
||||
(
|
||||
"created_by",
|
||||
models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="%(class)s_created_by",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="Created By",
|
||||
),
|
||||
),
|
||||
(
|
||||
"project",
|
||||
models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="notifications",
|
||||
to="db.project",
|
||||
),
|
||||
),
|
||||
(
|
||||
"receiver",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="received_notifications",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
(
|
||||
"triggered_by",
|
||||
models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="triggered_notifications",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
(
|
||||
"updated_by",
|
||||
models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="%(class)s_updated_by",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="Last Modified By",
|
||||
),
|
||||
),
|
||||
(
|
||||
"workspace",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="notifications",
|
||||
to="db.workspace",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Notification",
|
||||
"verbose_name_plural": "Notifications",
|
||||
"db_table": "notifications",
|
||||
"ordering": ("-created_at",),
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="IssueSubscriber",
|
||||
fields=[
|
||||
(
|
||||
"created_at",
|
||||
models.DateTimeField(auto_now_add=True, verbose_name="Created At"),
|
||||
),
|
||||
(
|
||||
"updated_at",
|
||||
models.DateTimeField(
|
||||
auto_now=True, verbose_name="Last Modified At"
|
||||
),
|
||||
),
|
||||
(
|
||||
"id",
|
||||
models.UUIDField(
|
||||
db_index=True,
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
unique=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"created_by",
|
||||
models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="%(class)s_created_by",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="Created By",
|
||||
),
|
||||
),
|
||||
(
|
||||
"issue",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="issue_subscribers",
|
||||
to="db.issue",
|
||||
),
|
||||
),
|
||||
(
|
||||
"project",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="project_%(class)s",
|
||||
to="db.project",
|
||||
),
|
||||
),
|
||||
(
|
||||
"subscriber",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="issue_subscribers",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
(
|
||||
"updated_by",
|
||||
models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="%(class)s_updated_by",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="Last Modified By",
|
||||
),
|
||||
),
|
||||
(
|
||||
"workspace",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="workspace_%(class)s",
|
||||
to="db.workspace",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Issue Subscriber",
|
||||
"verbose_name_plural": "Issue Subscribers",
|
||||
"db_table": "issue_subscribers",
|
||||
"ordering": ("-created_at",),
|
||||
"unique_together": {("issue", "subscriber")},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,35 @@
|
||||
# Generated by Django 4.2.3 on 2023-07-20 09:35
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def restructure_theming(apps, schema_editor):
|
||||
Model = apps.get_model("db", "User")
|
||||
updated_user = []
|
||||
for obj in Model.objects.exclude(theme={}).all():
|
||||
current_theme = obj.theme
|
||||
updated_theme = {
|
||||
"primary": current_theme.get("accent", ""),
|
||||
"background": current_theme.get("bgBase", ""),
|
||||
"sidebarBackground": current_theme.get("sidebar", ""),
|
||||
"text": current_theme.get("textBase", ""),
|
||||
"sidebarText": current_theme.get("textBase", ""),
|
||||
"palette": f"""{current_theme.get("bgBase","")},{current_theme.get("textBase", "")},{current_theme.get("accent", "")},{current_theme.get("sidebar","")},{current_theme.get("textBase", "")}""",
|
||||
"darkPalette": current_theme.get("darkPalette", "")
|
||||
}
|
||||
obj.theme = updated_theme
|
||||
updated_user.append(obj)
|
||||
|
||||
Model.objects.bulk_update(
|
||||
updated_user, ["theme"], batch_size=100
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("db", "0037_issue_archived_at_project_archive_in_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(restructure_theming)
|
||||
]
|
||||
@@ -17,7 +17,7 @@ EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
|
||||
|
||||
DATABASES = {
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.postgresql_psycopg2",
|
||||
"ENGINE": "django.db.backends.postgresql",
|
||||
"NAME": os.environ.get("PGUSER", "plane"),
|
||||
"USER": "",
|
||||
"PASSWORD": "",
|
||||
|
||||
@@ -17,7 +17,7 @@ DEBUG = int(os.environ.get("DEBUG", 0)) == 1
|
||||
|
||||
DATABASES = {
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.postgresql_psycopg2",
|
||||
"ENGINE": "django.db.backends.postgresql",
|
||||
"NAME": "plane",
|
||||
"USER": os.environ.get("PGUSER", ""),
|
||||
"PASSWORD": os.environ.get("PGPASSWORD", ""),
|
||||
|
||||
@@ -16,7 +16,7 @@ from .common import * # noqa
|
||||
DEBUG = int(os.environ.get("DEBUG", 1)) == 1
|
||||
DATABASES = {
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.postgresql_psycopg2",
|
||||
"ENGINE": "django.db.backends.postgresql",
|
||||
"NAME": os.environ.get("PGUSER", "plane"),
|
||||
"USER": "",
|
||||
"PASSWORD": "",
|
||||
|
||||
@@ -30,3 +30,5 @@ openai==0.27.8
|
||||
slack-sdk==3.21.3
|
||||
celery==5.3.1
|
||||
django_celery_beat==2.5.0
|
||||
psycopg-binary==3.1.9
|
||||
psycopg-c==3.1.9
|
||||
@@ -7,5 +7,5 @@ django-storages==1.13.2
|
||||
boto3==1.27.0
|
||||
django-anymail==10.0
|
||||
django-debug-toolbar==4.1.0
|
||||
gevent==22.10.2
|
||||
gevent==23.7.0
|
||||
psycogreen==1.0.2
|
||||
@@ -120,7 +120,7 @@ export const EmailCodeForm = ({ handleSignIn }: any) => {
|
||||
Please check your inbox at <span className="font-medium">{watch("email")}</span>
|
||||
</p>
|
||||
)}
|
||||
<form className="space-y-4 mt-10 w-full sm:w-[360px] mx-auto">
|
||||
<form className="space-y-4 mt-10 sm:w-[360px] mx-auto">
|
||||
<div className="space-y-1">
|
||||
<Input
|
||||
id="email"
|
||||
@@ -136,7 +136,7 @@ export const EmailCodeForm = ({ handleSignIn }: any) => {
|
||||
}}
|
||||
error={errors.email}
|
||||
placeholder="Enter your email address..."
|
||||
className="border-custom-border-300"
|
||||
className="border-custom-border-300 h-[46px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -152,7 +152,7 @@ export const EmailCodeForm = ({ handleSignIn }: any) => {
|
||||
}}
|
||||
error={errors.token}
|
||||
placeholder="Enter code..."
|
||||
className="border-custom-border-300"
|
||||
className="border-custom-border-300 h-[46px]"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@@ -178,7 +178,7 @@ export const EmailCodeForm = ({ handleSignIn }: any) => {
|
||||
) : errorResendingCode ? (
|
||||
"Please try again later"
|
||||
) : (
|
||||
"Resend code"
|
||||
<span className="font-medium">Resend code</span>
|
||||
)}
|
||||
</button>
|
||||
</>
|
||||
@@ -186,7 +186,7 @@ export const EmailCodeForm = ({ handleSignIn }: any) => {
|
||||
{codeSent ? (
|
||||
<PrimaryButton
|
||||
type="submit"
|
||||
className="w-full text-center"
|
||||
className="w-full text-center h-[46px]"
|
||||
size="md"
|
||||
onClick={handleSubmit(handleSignin)}
|
||||
disabled={!isValid && isDirty}
|
||||
@@ -196,7 +196,7 @@ export const EmailCodeForm = ({ handleSignIn }: any) => {
|
||||
</PrimaryButton>
|
||||
) : (
|
||||
<PrimaryButton
|
||||
className="w-full text-center"
|
||||
className="w-full text-center h-[46px]"
|
||||
size="md"
|
||||
onClick={() => {
|
||||
handleSubmit(onSubmit)().then(() => {
|
||||
|
||||
@@ -71,7 +71,7 @@ export const EmailPasswordForm: React.FC<Props> = ({ onSubmit }) => {
|
||||
}}
|
||||
error={errors.email}
|
||||
placeholder="Enter your email address..."
|
||||
className="border-custom-border-300"
|
||||
className="border-custom-border-300 h-[46px]"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
@@ -85,7 +85,7 @@ export const EmailPasswordForm: React.FC<Props> = ({ onSubmit }) => {
|
||||
}}
|
||||
error={errors.password}
|
||||
placeholder="Enter your password..."
|
||||
className="border-custom-border-300"
|
||||
className="border-custom-border-300 h-[46px]"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-right text-xs">
|
||||
@@ -108,7 +108,7 @@ export const EmailPasswordForm: React.FC<Props> = ({ onSubmit }) => {
|
||||
<div>
|
||||
<PrimaryButton
|
||||
type="submit"
|
||||
className="w-full text-center"
|
||||
className="w-full text-center h-[46px]"
|
||||
disabled={!isValid && isDirty}
|
||||
loading={isSubmitting}
|
||||
>
|
||||
|
||||
@@ -78,17 +78,17 @@ export const EmailResetPasswordForm: React.FC<Props> = ({ setIsResettingPassword
|
||||
}}
|
||||
error={errors.email}
|
||||
placeholder="Enter registered email address.."
|
||||
className="border-custom-border-300"
|
||||
className="border-custom-border-300 h-[46px]"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-5 flex flex-col-reverse sm:flex-row items-center gap-2">
|
||||
<SecondaryButton
|
||||
className="w-full text-center"
|
||||
className="w-full text-center h-[46px]"
|
||||
onClick={() => setIsResettingPassword(false)}
|
||||
>
|
||||
Go Back
|
||||
</SecondaryButton>
|
||||
<PrimaryButton type="submit" className="w-full text-center" loading={isSubmitting}>
|
||||
<PrimaryButton type="submit" className="w-full text-center h-[46px]" loading={isSubmitting}>
|
||||
{isSubmitting ? "Sending link..." : "Send reset link"}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
|
||||
@@ -44,9 +44,9 @@ export const GithubLoginButton: FC<GithubLoginButtonProps> = ({ handleSignIn })
|
||||
<Link
|
||||
href={`https://github.com/login/oauth/authorize?client_id=${NEXT_PUBLIC_GITHUB_ID}&redirect_uri=${loginCallBackURL}&scope=read:user,user:email`}
|
||||
>
|
||||
<button className="flex w-full items-center justify-center gap-2 rounded border border-custom-border-300 p-2 text-sm font-medium text-custom-text-100 duration-300 hover:bg-custom-background-80">
|
||||
<button className="flex w-full items-center justify-center gap-2 rounded border border-custom-border-300 p-2 text-sm font-medium text-custom-text-100 duration-300 hover:bg-custom-background-80 h-[46px]">
|
||||
<Image
|
||||
src={theme === "light" ? githubBlackImage : githubWhiteImage}
|
||||
src={theme === "dark" ? githubWhiteImage : githubBlackImage}
|
||||
height={20}
|
||||
width={20}
|
||||
alt="GitHub Logo"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { FC, CSSProperties, useEffect, useRef, useCallback, useState } from "react";
|
||||
// next
|
||||
|
||||
import Script from "next/script";
|
||||
|
||||
export interface IGoogleLoginButton {
|
||||
@@ -8,18 +8,18 @@ export interface IGoogleLoginButton {
|
||||
styles?: CSSProperties;
|
||||
}
|
||||
|
||||
export const GoogleLoginButton: FC<IGoogleLoginButton> = (props) => {
|
||||
const { handleSignIn } = props;
|
||||
|
||||
export const GoogleLoginButton: FC<IGoogleLoginButton> = ({ handleSignIn }) => {
|
||||
const googleSignInButton = useRef<HTMLDivElement>(null);
|
||||
const [gsiScriptLoaded, setGsiScriptLoaded] = useState(false);
|
||||
|
||||
const loadScript = useCallback(() => {
|
||||
if (!googleSignInButton.current || gsiScriptLoaded) return;
|
||||
|
||||
window?.google?.accounts.id.initialize({
|
||||
client_id: process.env.NEXT_PUBLIC_GOOGLE_CLIENTID || "",
|
||||
callback: handleSignIn,
|
||||
});
|
||||
|
||||
window?.google?.accounts.id.renderButton(
|
||||
googleSignInButton.current,
|
||||
{
|
||||
@@ -28,10 +28,12 @@ export const GoogleLoginButton: FC<IGoogleLoginButton> = (props) => {
|
||||
size: "large",
|
||||
logo_alignment: "center",
|
||||
width: "360",
|
||||
text: "continue_with",
|
||||
text: "signin_with",
|
||||
} as GsiButtonConfiguration // customization attributes
|
||||
);
|
||||
|
||||
window?.google?.accounts.id.prompt(); // also display the One Tap dialog
|
||||
|
||||
setGsiScriptLoaded(true);
|
||||
}, [handleSignIn, gsiScriptLoaded]);
|
||||
|
||||
|
||||
@@ -9,54 +9,46 @@ type Props = {
|
||||
defaultAnalytics: IDefaultAnalyticsResponse;
|
||||
};
|
||||
|
||||
export const AnalyticsYearWiseIssues: React.FC<Props> = ({ defaultAnalytics }) => {
|
||||
const currentMonth = new Date().getMonth();
|
||||
const startMonth = Math.floor(currentMonth / 3) * 3 + 1;
|
||||
const quarterMonthsList = [startMonth, startMonth + 1, startMonth + 2];
|
||||
|
||||
return (
|
||||
<div className="py-3 border border-custom-border-200 rounded-[10px]">
|
||||
<h1 className="px-3 text-base font-medium">Issues closed in a year</h1>
|
||||
{defaultAnalytics.issue_completed_month_wise.length > 0 ? (
|
||||
<LineGraph
|
||||
data={[
|
||||
{
|
||||
id: "issues_closed",
|
||||
color: "rgb(var(--color-primary-100))",
|
||||
data: MONTHS_LIST.map((month) => ({
|
||||
x: month.label.substring(0, 3),
|
||||
y:
|
||||
defaultAnalytics.issue_completed_month_wise.find(
|
||||
(data) => data.month === month.value
|
||||
)?.count || 0,
|
||||
})),
|
||||
},
|
||||
]}
|
||||
customYAxisTickValues={defaultAnalytics.issue_completed_month_wise.map((data) => {
|
||||
if (quarterMonthsList.includes(data.month)) return data.count;
|
||||
|
||||
return 0;
|
||||
})}
|
||||
height="300px"
|
||||
colors={(datum) => datum.color}
|
||||
curve="monotoneX"
|
||||
margin={{ top: 20 }}
|
||||
enableSlices="x"
|
||||
sliceTooltip={(datum) => (
|
||||
<div className="rounded-md border border-custom-border-200 bg-custom-background-80 p-2 text-xs">
|
||||
{datum.slice.points[0].data.yFormatted}
|
||||
<span className="text-custom-text-200"> issues closed in </span>
|
||||
{datum.slice.points[0].data.xFormatted}
|
||||
</div>
|
||||
)}
|
||||
theme={{
|
||||
background: "rgb(var(--color-background-100))",
|
||||
}}
|
||||
enableArea
|
||||
/>
|
||||
) : (
|
||||
<div className="text-custom-text-200 text-center text-sm py-8">No matching data found.</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export const AnalyticsYearWiseIssues: React.FC<Props> = ({ defaultAnalytics }) => (
|
||||
<div className="py-3 border border-custom-border-200 rounded-[10px]">
|
||||
<h1 className="px-3 text-base font-medium">Issues closed in a year</h1>
|
||||
{defaultAnalytics.issue_completed_month_wise.length > 0 ? (
|
||||
<LineGraph
|
||||
data={[
|
||||
{
|
||||
id: "issues_closed",
|
||||
color: "rgb(var(--color-primary-100))",
|
||||
data: MONTHS_LIST.map((month) => ({
|
||||
x: month.label.substring(0, 3),
|
||||
y:
|
||||
defaultAnalytics.issue_completed_month_wise.find(
|
||||
(data) => data.month === month.value
|
||||
)?.count || 0,
|
||||
})),
|
||||
},
|
||||
]}
|
||||
customYAxisTickValues={defaultAnalytics.issue_completed_month_wise.map(
|
||||
(data) => data.count
|
||||
)}
|
||||
height="300px"
|
||||
colors={(datum) => datum.color}
|
||||
curve="monotoneX"
|
||||
margin={{ top: 20 }}
|
||||
enableSlices="x"
|
||||
sliceTooltip={(datum) => (
|
||||
<div className="rounded-md border border-custom-border-200 bg-custom-background-80 p-2 text-xs">
|
||||
{datum.slice.points[0].data.yFormatted}
|
||||
<span className="text-custom-text-200"> issues closed in </span>
|
||||
{datum.slice.points[0].data.xFormatted}
|
||||
</div>
|
||||
)}
|
||||
theme={{
|
||||
background: "rgb(var(--color-background-100))",
|
||||
}}
|
||||
enableArea
|
||||
/>
|
||||
) : (
|
||||
<div className="text-custom-text-200 text-center text-sm py-8">No matching data found.</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -380,7 +380,6 @@ export const CommandPalette: React.FC = () => {
|
||||
user={user}
|
||||
/>
|
||||
)}
|
||||
|
||||
<CreateUpdateIssueModal
|
||||
isOpen={isIssueModalOpen}
|
||||
handleClose={() => setIsIssueModalOpen(false)}
|
||||
|
||||
@@ -356,7 +356,7 @@ export const SingleBoardIssue: React.FC<Props> = ({
|
||||
selfPositioned
|
||||
/>
|
||||
)}
|
||||
{properties.due_date && (
|
||||
{properties.due_date && issue.target_date && (
|
||||
<ViewDueDateSelect
|
||||
issue={issue}
|
||||
partialUpdateIssue={partialUpdateIssue}
|
||||
@@ -364,7 +364,7 @@ export const SingleBoardIssue: React.FC<Props> = ({
|
||||
isNotAllowed={isNotAllowed}
|
||||
/>
|
||||
)}
|
||||
{properties.labels && (
|
||||
{properties.labels && issue.labels.length > 0 && (
|
||||
<ViewLabelSelect
|
||||
issue={issue}
|
||||
partialUpdateIssue={partialUpdateIssue}
|
||||
@@ -382,7 +382,7 @@ export const SingleBoardIssue: React.FC<Props> = ({
|
||||
selfPositioned
|
||||
/>
|
||||
)}
|
||||
{properties.estimate && (
|
||||
{properties.estimate && issue.estimate_point !== null && (
|
||||
<ViewEstimateSelect
|
||||
issue={issue}
|
||||
partialUpdateIssue={partialUpdateIssue}
|
||||
@@ -391,7 +391,7 @@ export const SingleBoardIssue: React.FC<Props> = ({
|
||||
selfPositioned
|
||||
/>
|
||||
)}
|
||||
{properties.sub_issue_count && (
|
||||
{properties.sub_issue_count && issue.sub_issues_count > 0 && (
|
||||
<div className="flex cursor-default items-center rounded-md border border-custom-border-200 px-2.5 py-1 text-xs shadow-sm">
|
||||
<Tooltip tooltipHeading="Sub-issue" tooltipContent={`${issue.sub_issues_count}`}>
|
||||
<div className="flex items-center gap-1 text-custom-text-200">
|
||||
@@ -401,7 +401,7 @@ export const SingleBoardIssue: React.FC<Props> = ({
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
{properties.link && (
|
||||
{properties.link && issue.link_count > 0 && (
|
||||
<div className="flex cursor-default items-center rounded-md border border-custom-border-200 px-2.5 py-1 text-xs shadow-sm">
|
||||
<Tooltip tooltipHeading="Link" tooltipContent={`${issue.link_count}`}>
|
||||
<div className="flex items-center gap-1 text-custom-text-200">
|
||||
@@ -411,7 +411,7 @@ export const SingleBoardIssue: React.FC<Props> = ({
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
{properties.attachment_count && (
|
||||
{properties.attachment_count && issue.attachment_count > 0 && (
|
||||
<div className="flex cursor-default items-center rounded-md border border-custom-border-200 px-2.5 py-1 text-xs shadow-sm">
|
||||
<Tooltip tooltipHeading="Attachment" tooltipContent={`${issue.attachment_count}`}>
|
||||
<div className="flex items-center gap-1 text-custom-text-200">
|
||||
|
||||
@@ -231,7 +231,7 @@ export const SingleCalendarIssue: React.FC<Props> = ({
|
||||
/>
|
||||
)}
|
||||
|
||||
{properties.due_date && (
|
||||
{properties.due_date && issue.target_date && (
|
||||
<ViewDueDateSelect
|
||||
issue={issue}
|
||||
partialUpdateIssue={partialUpdateIssue}
|
||||
@@ -239,7 +239,7 @@ export const SingleCalendarIssue: React.FC<Props> = ({
|
||||
isNotAllowed={isNotAllowed}
|
||||
/>
|
||||
)}
|
||||
{properties.labels && (
|
||||
{properties.labels && issue.labels.length > 0 && (
|
||||
<ViewLabelSelect
|
||||
issue={issue}
|
||||
partialUpdateIssue={partialUpdateIssue}
|
||||
@@ -257,7 +257,7 @@ export const SingleCalendarIssue: React.FC<Props> = ({
|
||||
isNotAllowed={isNotAllowed}
|
||||
/>
|
||||
)}
|
||||
{properties.estimate && (
|
||||
{properties.estimate && issue.estimate_point !== null && (
|
||||
<ViewEstimateSelect
|
||||
issue={issue}
|
||||
partialUpdateIssue={partialUpdateIssue}
|
||||
@@ -266,7 +266,7 @@ export const SingleCalendarIssue: React.FC<Props> = ({
|
||||
isNotAllowed={isNotAllowed}
|
||||
/>
|
||||
)}
|
||||
{properties.sub_issue_count && (
|
||||
{properties.sub_issue_count && issue.sub_issues_count > 0 && (
|
||||
<div className="flex cursor-default items-center rounded-md border border-custom-border-200 px-2.5 py-1 text-xs shadow-sm">
|
||||
<Tooltip tooltipHeading="Sub-issue" tooltipContent={`${issue.sub_issues_count}`}>
|
||||
<div className="flex items-center gap-1 text-custom-text-200">
|
||||
@@ -276,7 +276,7 @@ export const SingleCalendarIssue: React.FC<Props> = ({
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
{properties.link && (
|
||||
{properties.link && issue.link_count > 0 && (
|
||||
<div className="flex cursor-default items-center rounded-md border border-custom-border-200 px-2.5 py-1 text-xs shadow-sm">
|
||||
<Tooltip tooltipHeading="Links" tooltipContent={`${issue.link_count}`}>
|
||||
<div className="flex items-center gap-1 text-custom-text-200">
|
||||
@@ -286,7 +286,7 @@ export const SingleCalendarIssue: React.FC<Props> = ({
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
{properties.attachment_count && (
|
||||
{properties.attachment_count && issue.attachment_count > 0 && (
|
||||
<div className="flex cursor-default items-center rounded-md border border-custom-border-200 px-2.5 py-1 text-xs shadow-sm">
|
||||
<Tooltip tooltipHeading="Attachments" tooltipContent={`${issue.attachment_count}`}>
|
||||
<div className="flex items-center gap-1 text-custom-text-200">
|
||||
|
||||
@@ -11,14 +11,16 @@ import useEstimateOption from "hooks/use-estimate-option";
|
||||
// components
|
||||
import { SelectFilters } from "components/views";
|
||||
// ui
|
||||
import { CustomMenu, Icon, ToggleSwitch, Tooltip } from "components/ui";
|
||||
import { CustomMenu, ToggleSwitch, Tooltip } from "components/ui";
|
||||
// icons
|
||||
import { ChevronDownIcon } from "@heroicons/react/24/outline";
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
ListBulletIcon,
|
||||
Squares2X2Icon,
|
||||
CalendarDaysIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
CalendarMonthOutlined,
|
||||
FormatListBulletedOutlined,
|
||||
GridViewOutlined,
|
||||
TableChartOutlined,
|
||||
WaterfallChartOutlined,
|
||||
} from "@mui/icons-material";
|
||||
// helpers
|
||||
import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper";
|
||||
import { checkIfArraysHaveSameElements } from "helpers/array.helper";
|
||||
@@ -27,26 +29,26 @@ import { Properties, TIssueViewOptions } from "types";
|
||||
// constants
|
||||
import { GROUP_BY_OPTIONS, ORDER_BY_OPTIONS, FILTER_ISSUE_OPTIONS } from "constants/issue";
|
||||
|
||||
const issueViewOptions: { type: TIssueViewOptions; icon: any }[] = [
|
||||
const issueViewOptions: { type: TIssueViewOptions; Icon: any }[] = [
|
||||
{
|
||||
type: "list",
|
||||
icon: <ListBulletIcon className="h-4 w-4" />,
|
||||
Icon: FormatListBulletedOutlined,
|
||||
},
|
||||
{
|
||||
type: "kanban",
|
||||
icon: <Squares2X2Icon className="h-4 w-4" />,
|
||||
Icon: GridViewOutlined,
|
||||
},
|
||||
{
|
||||
type: "calendar",
|
||||
icon: <CalendarDaysIcon className="h-4 w-4" />,
|
||||
Icon: CalendarMonthOutlined,
|
||||
},
|
||||
{
|
||||
type: "spreadsheet",
|
||||
icon: <Icon iconName="table_chart" />,
|
||||
Icon: TableChartOutlined,
|
||||
},
|
||||
{
|
||||
type: "gantt_chart",
|
||||
icon: <Icon iconName="waterfall_chart" className="rotate-90" />,
|
||||
Icon: WaterfallChartOutlined,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -98,7 +100,12 @@ export const IssuesFilterView: React.FC = () => {
|
||||
}`}
|
||||
onClick={() => setIssueView(option.type)}
|
||||
>
|
||||
{option.icon}
|
||||
<option.Icon
|
||||
sx={{
|
||||
fontSize: 16,
|
||||
}}
|
||||
className={option.type === "gantt_chart" ? "rotate-90" : ""}
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
))}
|
||||
@@ -177,7 +184,6 @@ export const IssuesFilterView: React.FC = () => {
|
||||
GROUP_BY_OPTIONS.find((option) => option.key === groupByProperty)
|
||||
?.name ?? "Select"
|
||||
}
|
||||
width="lg"
|
||||
>
|
||||
{GROUP_BY_OPTIONS.map((option) =>
|
||||
issueView === "kanban" && option.key === null ? null : (
|
||||
@@ -198,7 +204,6 @@ export const IssuesFilterView: React.FC = () => {
|
||||
ORDER_BY_OPTIONS.find((option) => option.key === orderBy)?.name ??
|
||||
"Select"
|
||||
}
|
||||
width="lg"
|
||||
>
|
||||
{ORDER_BY_OPTIONS.map((option) =>
|
||||
groupByProperty === "priority" && option.key === "priority" ? null : (
|
||||
@@ -223,7 +228,6 @@ export const IssuesFilterView: React.FC = () => {
|
||||
FILTER_ISSUE_OPTIONS.find((option) => option.key === filters.type)
|
||||
?.name ?? "Select"
|
||||
}
|
||||
width="lg"
|
||||
>
|
||||
{FILTER_ISSUE_OPTIONS.map((option) => (
|
||||
<CustomMenu.MenuItem
|
||||
|
||||
@@ -31,11 +31,12 @@ import { CreateUpdateIssueModal, DeleteIssueModal } from "components/issues";
|
||||
import { CreateUpdateViewModal } from "components/views";
|
||||
import { TransferIssues, TransferIssuesModal } from "components/cycles";
|
||||
// ui
|
||||
import { EmptyState, PrimaryButton, Spinner } from "components/ui";
|
||||
import { EmptyState, PrimaryButton, Spinner, Icon } from "components/ui";
|
||||
// icons
|
||||
import { PlusIcon, TrashIcon } from "@heroicons/react/24/outline";
|
||||
// images
|
||||
import emptyIssue from "public/empty-state/issue.svg";
|
||||
import emptyIssueArchive from "public/empty-state/issue-archive.svg";
|
||||
// helpers
|
||||
import { getStatesList } from "helpers/state.helper";
|
||||
import { orderArrayBy } from "helpers/array.helper";
|
||||
@@ -577,9 +578,25 @@ export const IssuesView: React.FC<Props> = ({
|
||||
issueView === "gantt_chart" && <GanttChartView />
|
||||
)}
|
||||
</>
|
||||
) : router.pathname.includes("archived-issues") ? (
|
||||
<EmptyState
|
||||
title="Archived Issues will be shown here"
|
||||
description="All the issues that have been in the completed or canceled groups for the configured period of time can be viewed here."
|
||||
image={emptyIssueArchive}
|
||||
buttonText="Go to Automation Settings"
|
||||
onClick={() => {
|
||||
router.push(`/${workspaceSlug}/projects/${projectId}/settings/automations`);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<EmptyState
|
||||
title="Project issues will appear here"
|
||||
title={
|
||||
cycleId
|
||||
? "Cycle issues will appear here"
|
||||
: moduleId
|
||||
? "Module issues will appear here"
|
||||
: "Project issues will appear here"
|
||||
}
|
||||
description="Issues help you track individual pieces of work. With Issues, keep track of what's going on, who is working on it, and what's done."
|
||||
image={emptyIssue}
|
||||
buttonText="New Issue"
|
||||
|
||||
@@ -271,7 +271,7 @@ export const SingleListIssue: React.FC<Props> = ({
|
||||
isNotAllowed={isNotAllowed}
|
||||
/>
|
||||
)}
|
||||
{properties.due_date && (
|
||||
{properties.due_date && issue.target_date && (
|
||||
<ViewDueDateSelect
|
||||
issue={issue}
|
||||
partialUpdateIssue={partialUpdateIssue}
|
||||
@@ -279,7 +279,7 @@ export const SingleListIssue: React.FC<Props> = ({
|
||||
isNotAllowed={isNotAllowed}
|
||||
/>
|
||||
)}
|
||||
{properties.labels && (
|
||||
{properties.labels && issue.labels.length > 0 && (
|
||||
<ViewLabelSelect
|
||||
issue={issue}
|
||||
partialUpdateIssue={partialUpdateIssue}
|
||||
@@ -297,7 +297,7 @@ export const SingleListIssue: React.FC<Props> = ({
|
||||
isNotAllowed={isNotAllowed}
|
||||
/>
|
||||
)}
|
||||
{properties.estimate && (
|
||||
{properties.estimate && issue.estimate_point !== null && (
|
||||
<ViewEstimateSelect
|
||||
issue={issue}
|
||||
partialUpdateIssue={partialUpdateIssue}
|
||||
@@ -306,7 +306,7 @@ export const SingleListIssue: React.FC<Props> = ({
|
||||
isNotAllowed={isNotAllowed}
|
||||
/>
|
||||
)}
|
||||
{properties.sub_issue_count && (
|
||||
{properties.sub_issue_count && issue.sub_issues_count > 0 && (
|
||||
<div className="flex cursor-default items-center rounded-md border border-custom-border-200 px-2.5 py-1 text-xs shadow-sm">
|
||||
<Tooltip tooltipHeading="Sub-issue" tooltipContent={`${issue.sub_issues_count}`}>
|
||||
<div className="flex items-center gap-1 text-custom-text-200">
|
||||
@@ -316,7 +316,7 @@ export const SingleListIssue: React.FC<Props> = ({
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
{properties.link && (
|
||||
{properties.link && issue.link_count > 0 && (
|
||||
<div className="flex cursor-default items-center rounded-md border border-custom-border-200 px-2.5 py-1 text-xs shadow-sm">
|
||||
<Tooltip tooltipHeading="Links" tooltipContent={`${issue.link_count}`}>
|
||||
<div className="flex items-center gap-1 text-custom-text-200">
|
||||
@@ -326,7 +326,7 @@ export const SingleListIssue: React.FC<Props> = ({
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
{properties.attachment_count && (
|
||||
{properties.attachment_count && issue.attachment_count > 0 && (
|
||||
<div className="flex cursor-default items-center rounded-md border border-custom-border-200 px-2.5 py-1 text-xs shadow-sm">
|
||||
<Tooltip tooltipHeading="Attachments" tooltipContent={`${issue.attachment_count}`}>
|
||||
<div className="flex items-center gap-1 text-custom-text-200">
|
||||
|
||||
@@ -21,11 +21,11 @@ type Props = {
|
||||
};
|
||||
|
||||
const defaultValues: ICustomTheme = {
|
||||
background: "#292d3e",
|
||||
text: "#ffffff",
|
||||
primary: "#7d57c1",
|
||||
sidebarBackground: "#292d3e",
|
||||
sidebarText: "#ffffff",
|
||||
background: "#0d101b",
|
||||
text: "#c5c5c5",
|
||||
primary: "#3f76ff",
|
||||
sidebarBackground: "#0d101b",
|
||||
sidebarText: "#c5c5c5",
|
||||
darkPalette: false,
|
||||
palette: "",
|
||||
};
|
||||
@@ -69,8 +69,8 @@ export const CustomThemeSelector: React.FC<Props> = ({ preLoadedData }) => {
|
||||
return { ...prevData, ...res };
|
||||
}, false);
|
||||
|
||||
applyTheme(payload.palette, darkPalette);
|
||||
setTheme("custom");
|
||||
applyTheme(payload.palette, darkPalette);
|
||||
})
|
||||
.catch((err) => console.log(err));
|
||||
};
|
||||
|
||||
@@ -71,7 +71,21 @@ export const ThemeSwitch: React.FC<Props> = ({
|
||||
}
|
||||
onChange={({ value, type }: { value: string; type: string }) => {
|
||||
if (value === "custom") {
|
||||
if (user?.theme.palette) setPreLoadedData(user.theme);
|
||||
if (user?.theme.palette) {
|
||||
setPreLoadedData({
|
||||
background: user.theme.background !== "" ? user.theme.background : "#0d101b",
|
||||
text: user.theme.text !== "" ? user.theme.text : "#c5c5c5",
|
||||
primary: user.theme.primary !== "" ? user.theme.primary : "#3f76ff",
|
||||
sidebarBackground:
|
||||
user.theme.sidebarBackground !== "" ? user.theme.sidebarBackground : "#0d101b",
|
||||
sidebarText: user.theme.sidebarText !== "" ? user.theme.sidebarText : "#c5c5c5",
|
||||
darkPalette: false,
|
||||
palette:
|
||||
user.theme.palette !== ",,,,"
|
||||
? user.theme.palette
|
||||
: "#0d101b,#c5c5c5,#3f76ff,#0d101b,#c5c5c5",
|
||||
});
|
||||
}
|
||||
|
||||
if (!customThemeSelectorOptions) setCustomThemeSelectorOptions(true);
|
||||
} else {
|
||||
@@ -80,11 +94,14 @@ export const ThemeSwitch: React.FC<Props> = ({
|
||||
for (let i = 10; i <= 900; i >= 100 ? (i += 100) : (i += 10)) {
|
||||
document.documentElement.style.removeProperty(`--color-background-${i}`);
|
||||
document.documentElement.style.removeProperty(`--color-text-${i}`);
|
||||
document.documentElement.style.removeProperty(`--color-border-${i}`);
|
||||
document.documentElement.style.removeProperty(`--color-primary-${i}`);
|
||||
document.documentElement.style.removeProperty(`--color-sidebar-background-${i}`);
|
||||
document.documentElement.style.removeProperty(`--color-sidebar-text-${i}`);
|
||||
document.documentElement.style.removeProperty(`--color-sidebar-border-${i}`);
|
||||
}
|
||||
}
|
||||
|
||||
setTheme(value);
|
||||
document.documentElement.style.setProperty("color-scheme", type);
|
||||
}}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import React from "react";
|
||||
|
||||
import type { Props } from "./types";
|
||||
|
||||
export const ArchiveIcon: React.FC<Props> = ({ width = "24", height = "24", className, color }) => (
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M5.75 19.5C5.41667 19.5 5.125 19.375 4.875 19.125C4.625 18.875 4.5 18.5833 4.5 18.25V7.35417C4.5 7.14583 4.52083 6.96875 4.5625 6.82292C4.60417 6.67708 4.68056 6.54167 4.79167 6.41667L5.95833 4.83333C6.06944 4.70833 6.19792 4.62153 6.34375 4.57292C6.48958 4.52431 6.6624 4.5 6.86221 4.5H17.1378C17.3376 4.5 17.5069 4.52431 17.6458 4.57292C17.7847 4.62153 17.9097 4.70833 18.0208 4.83333L19.2083 6.41667C19.3194 6.54167 19.3958 6.67708 19.4375 6.82292C19.4792 6.96875 19.5 7.14583 19.5 7.35417V18.25C19.5 18.5833 19.375 18.875 19.125 19.125C18.875 19.375 18.5833 19.5 18.25 19.5H5.75ZM6.10417 6.70833H17.875L17.1165 5.75H6.85417L6.10417 6.70833ZM5.75 7.95833V18.25H18.25V7.95833H5.75ZM12 16.375L15.25 13.125L14.4167 12.2917L12.625 14.0833V9.89583H11.375V14.0833L9.58333 12.2917L8.75 13.125L12 16.375Z"
|
||||
fill={color ? color : "currentColor"}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
@@ -0,0 +1,24 @@
|
||||
import React from "react";
|
||||
|
||||
import type { Props } from "./types";
|
||||
|
||||
export const BellNotificationIcon: React.FC<Props> = ({
|
||||
width = "24",
|
||||
height = "24",
|
||||
color = "rgb(var(--color-text-200))",
|
||||
className,
|
||||
}) => (
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
className={className}
|
||||
viewBox="0 0 22 22"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M4.35425 17.4154C4.15946 17.4154 3.99618 17.3491 3.8644 17.2166C3.73263 17.084 3.66675 16.9198 3.66675 16.7239C3.66675 16.5279 3.73263 16.365 3.8644 16.2352C3.99618 16.1053 4.15946 16.0404 4.35425 16.0404H5.59175V9.02786C5.59175 7.77509 5.96987 6.64071 6.72612 5.62474C7.48237 4.60877 8.47925 3.97092 9.71675 3.7112V3.04661C9.71675 2.69523 9.84099 2.40495 10.0895 2.17578C10.338 1.94661 10.6397 1.83203 10.9947 1.83203C11.3497 1.83203 11.6532 1.94661 11.9053 2.17578C12.1574 2.40495 12.2834 2.69523 12.2834 3.04661V3.7112C13.5209 3.97092 14.5216 4.60877 15.2855 5.62474C16.0494 6.64071 16.4313 7.77509 16.4313 9.02786V16.0404H17.6459C17.8407 16.0404 18.004 16.1066 18.1358 16.2392C18.2675 16.3717 18.3334 16.536 18.3334 16.7319C18.3334 16.9278 18.2675 17.0907 18.1358 17.2206C18.004 17.3504 17.8407 17.4154 17.6459 17.4154H4.35425ZM11.0001 20.1654C10.5112 20.1654 10.0834 19.9859 9.71675 19.6268C9.35008 19.2678 9.16675 18.8362 9.16675 18.332H12.8334C12.8334 18.8362 12.6539 19.2678 12.2949 19.6268C11.9358 19.9859 11.5042 20.1654 11.0001 20.1654ZM6.96675 16.0404H15.0563V9.02786C15.0563 7.88203 14.6706 6.91571 13.899 6.12891C13.1275 5.3421 12.1727 4.9487 11.0345 4.9487C9.89626 4.9487 8.93376 5.3421 8.14696 6.12891C7.36015 6.91571 6.96675 7.88203 6.96675 9.02786V16.0404Z"
|
||||
fill={color}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
@@ -0,0 +1,19 @@
|
||||
import React from "react";
|
||||
|
||||
import type { Props } from "./types";
|
||||
|
||||
export const ClockIcon: React.FC<Props> = ({ width = "24", height = "24", className, color }) => (
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M12.6876 11.7513V8.1888C12.6876 8.00825 12.6286 7.85894 12.5105 7.74088C12.3924 7.62283 12.2431 7.5638 12.0626 7.5638C11.882 7.5638 11.7327 7.62283 11.6147 7.74088C11.4966 7.85894 11.4376 8.00825 11.4376 8.1888V12.0013C11.4376 12.0846 11.4515 12.161 11.4792 12.2305C11.507 12.2999 11.5487 12.3694 11.6042 12.4388L14.6042 15.543C14.7292 15.6819 14.8855 15.7478 15.073 15.7409C15.2605 15.7339 15.4167 15.668 15.5417 15.543C15.6667 15.418 15.7292 15.2652 15.7292 15.0846C15.7292 14.9041 15.6667 14.7513 15.5417 14.6263L12.6876 11.7513ZM12.0001 20.3346C10.8612 20.3346 9.7848 20.1159 8.77091 19.6784C7.75703 19.2409 6.87161 18.6437 6.11466 17.8867C5.35772 17.1298 4.7605 16.2444 4.323 15.2305C3.8855 14.2166 3.66675 13.1402 3.66675 12.0013C3.66675 10.8624 3.8855 9.78602 4.323 8.77213C4.7605 7.75825 5.35772 6.87283 6.11466 6.11589C6.87161 5.35894 7.75703 4.76172 8.77091 4.32422C9.7848 3.88672 10.8612 3.66797 12.0001 3.66797C13.139 3.66797 14.2154 3.88672 15.2292 4.32422C16.2431 4.76172 17.1286 5.35894 17.8855 6.11589C18.6424 6.87283 19.2397 7.75825 19.6772 8.77213C20.1147 9.78602 20.3334 10.8624 20.3334 12.0013C20.3334 13.1402 20.1147 14.2166 19.6772 15.2305C19.2397 16.2444 18.6424 17.1298 17.8855 17.8867C17.1286 18.6437 16.2431 19.2409 15.2292 19.6784C14.2154 20.1159 13.139 20.3346 12.0001 20.3346ZM12.0001 19.0846C13.9445 19.0846 15.6112 18.3902 17.0001 17.0013C18.389 15.6124 19.0834 13.9457 19.0834 12.0013C19.0834 10.0569 18.389 8.39019 17.0001 7.0013C15.6112 5.61241 13.9445 4.91797 12.0001 4.91797C10.0556 4.91797 8.38897 5.61241 7.00008 7.0013C5.61119 8.39019 4.91675 10.0569 4.91675 12.0013C4.91675 13.9457 5.61119 15.6124 7.00008 17.0013C8.38897 18.3902 10.0556 19.0846 12.0001 19.0846Z"
|
||||
fill={color ? color : "currentColor"}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
@@ -82,3 +82,9 @@ export * from "./command-icon";
|
||||
export * from "./color-picker-icon";
|
||||
export * from "./inbox-icon";
|
||||
export * from "./stacked-layers-horizontal-icon";
|
||||
export * from "./sort-icon";
|
||||
export * from "./x-mark-icon";
|
||||
export * from "./archive-icon";
|
||||
export * from "./clock-icon";
|
||||
export * from "./bell-icon";
|
||||
export * from "./single-comment-icon";
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import React from "react";
|
||||
|
||||
import type { Props } from "./types";
|
||||
|
||||
export const SingleCommentCard: React.FC<Props> = ({
|
||||
width = "24",
|
||||
height = "24",
|
||||
className,
|
||||
color,
|
||||
}) => (
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M3.66663 20.3346V4.91797C3.66663 4.58464 3.79163 4.29297 4.04163 4.04297C4.29163 3.79297 4.58329 3.66797 4.91663 3.66797H19.0833C19.4166 3.66797 19.7083 3.79297 19.9583 4.04297C20.2083 4.29297 20.3333 4.58464 20.3333 4.91797V15.7513C20.3333 16.0846 20.2083 16.3763 19.9583 16.6263C19.7083 16.8763 19.4166 17.0013 19.0833 17.0013H6.99996L3.66663 20.3346ZM6.45829 15.7513H19.0833V4.91797H4.91663V17.418L6.45829 15.7513Z"
|
||||
fill={color ? color : "currentColor"}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
@@ -0,0 +1,19 @@
|
||||
import React from "react";
|
||||
|
||||
import type { Props } from "./types";
|
||||
|
||||
export const SortIcon: React.FC<Props> = ({ width = "24", height = "24", className, color }) => (
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M10.9583 17C10.7778 17 10.6285 16.941 10.5104 16.8229C10.3924 16.7049 10.3333 16.5556 10.3333 16.375C10.3333 16.1944 10.3924 16.0451 10.5104 15.9271C10.6285 15.809 10.7778 15.75 10.9583 15.75H13.0417C13.2222 15.75 13.3715 15.809 13.4896 15.9271C13.6076 16.0451 13.6667 16.1944 13.6667 16.375C13.6667 16.5556 13.6076 16.7049 13.4896 16.8229C13.3715 16.941 13.2222 17 13.0417 17H10.9583ZM5.125 8.25C4.94444 8.25 4.79514 8.19097 4.67708 8.07292C4.55903 7.95486 4.5 7.80556 4.5 7.625C4.5 7.44444 4.55903 7.29514 4.67708 7.17708C4.79514 7.05903 4.94444 7 5.125 7H18.875C19.0556 7 19.2049 7.05903 19.3229 7.17708C19.441 7.29514 19.5 7.44444 19.5 7.625C19.5 7.80556 19.441 7.95486 19.3229 8.07292C19.2049 8.19097 19.0556 8.25 18.875 8.25H5.125ZM7.625 12.625C7.44444 12.625 7.29514 12.566 7.17708 12.4479C7.05903 12.3299 7 12.1806 7 12C7 11.8194 7.05903 11.6701 7.17708 11.5521C7.29514 11.434 7.44444 11.375 7.625 11.375H16.375C16.5556 11.375 16.7049 11.434 16.8229 11.5521C16.941 11.6701 17 11.8194 17 12C17 12.1806 16.941 12.3299 16.8229 12.4479C16.7049 12.566 16.5556 12.625 16.375 12.625H7.625Z"
|
||||
fill={color ? color : "currentColor"}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
@@ -0,0 +1,19 @@
|
||||
import React from "react";
|
||||
|
||||
import type { Props } from "./types";
|
||||
|
||||
export const XMarkIcon: React.FC<Props> = ({ width = "24", height = "24", className, color }) => (
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M12 12.875L7.625 17.25C7.5 17.375 7.35417 17.4375 7.1875 17.4375C7.02083 17.4375 6.875 17.375 6.75 17.25C6.625 17.125 6.5625 16.9792 6.5625 16.8125C6.5625 16.6458 6.625 16.5 6.75 16.375L11.125 12L6.75 7.625C6.625 7.5 6.5625 7.35417 6.5625 7.1875C6.5625 7.02083 6.625 6.875 6.75 6.75C6.875 6.625 7.02083 6.5625 7.1875 6.5625C7.35417 6.5625 7.5 6.625 7.625 6.75L12 11.125L16.375 6.75C16.5 6.625 16.6458 6.5625 16.8125 6.5625C16.9792 6.5625 17.125 6.625 17.25 6.75C17.375 6.875 17.4375 7.02083 17.4375 7.1875C17.4375 7.35417 17.375 7.5 17.25 7.625L12.875 12L17.25 16.375C17.375 16.5 17.4375 16.6458 17.4375 16.8125C17.4375 16.9792 17.375 17.125 17.25 17.25C17.125 17.375 16.9792 17.4375 16.8125 17.4375C16.6458 17.4375 16.5 17.375 16.375 17.25L12 12.875Z"
|
||||
fill={color ? color : "currentColor"}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
@@ -1,6 +1,5 @@
|
||||
import React, { ChangeEvent, FC, useState, useEffect, useRef } from "react";
|
||||
import React, { FC, useState, useEffect, useRef } from "react";
|
||||
|
||||
import Link from "next/link";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
@@ -12,12 +11,12 @@ import aiService from "services/ai.service";
|
||||
import useToast from "hooks/use-toast";
|
||||
// components
|
||||
import { GptAssistantModal } from "components/core";
|
||||
import { ParentIssuesListModal } from "components/issues";
|
||||
import {
|
||||
IssueAssigneeSelect,
|
||||
IssueDateSelect,
|
||||
IssueEstimateSelect,
|
||||
IssueLabelSelect,
|
||||
IssueParentSelect,
|
||||
IssuePrioritySelect,
|
||||
IssueProjectSelect,
|
||||
IssueStateSelect,
|
||||
@@ -35,10 +34,8 @@ import {
|
||||
} from "components/ui";
|
||||
// icons
|
||||
import { SparklesIcon, XMarkIcon } from "@heroicons/react/24/outline";
|
||||
// helpers
|
||||
import { cosineSimilarity } from "helpers/string.helper";
|
||||
// types
|
||||
import type { ICurrentUserResponse, IIssue } from "types";
|
||||
import type { ICurrentUserResponse, IIssue, ISearchIssueResponse } from "types";
|
||||
// rich-text-editor
|
||||
const RemirrorRichTextEditor = dynamic(() => import("components/rich-text-editor"), {
|
||||
ssr: false,
|
||||
@@ -72,6 +69,7 @@ const defaultValues: Partial<IIssue> = {
|
||||
description_html: "<p></p>",
|
||||
estimate_point: null,
|
||||
state: "",
|
||||
parent: null,
|
||||
priority: null,
|
||||
assignees: [],
|
||||
assignees_list: [],
|
||||
@@ -82,7 +80,6 @@ const defaultValues: Partial<IIssue> = {
|
||||
export interface IssueFormProps {
|
||||
handleFormSubmit: (values: Partial<IIssue>) => Promise<void>;
|
||||
initialData?: Partial<IIssue>;
|
||||
issues: IIssue[];
|
||||
projectId: string;
|
||||
setActiveProject: React.Dispatch<React.SetStateAction<string | null>>;
|
||||
createMore: boolean;
|
||||
@@ -108,7 +105,6 @@ export interface IssueFormProps {
|
||||
export const IssueForm: FC<IssueFormProps> = ({
|
||||
handleFormSubmit,
|
||||
initialData,
|
||||
issues = [],
|
||||
projectId,
|
||||
setActiveProject,
|
||||
createMore,
|
||||
@@ -118,11 +114,10 @@ export const IssueForm: FC<IssueFormProps> = ({
|
||||
user,
|
||||
fieldsToShow,
|
||||
}) => {
|
||||
// states
|
||||
const [mostSimilarIssue, setMostSimilarIssue] = useState<IIssue | undefined>();
|
||||
const [stateModal, setStateModal] = useState(false);
|
||||
const [labelModal, setLabelModal] = useState(false);
|
||||
const [parentIssueListModalOpen, setParentIssueListModalOpen] = useState(false);
|
||||
const [selectedParentIssue, setSelectedParentIssue] = useState<ISearchIssueResponse | null>(null);
|
||||
|
||||
const [gptAssistantModal, setGptAssistantModal] = useState(false);
|
||||
const [iAmFeelingLucky, setIAmFeelingLucky] = useState(false);
|
||||
@@ -151,12 +146,6 @@ export const IssueForm: FC<IssueFormProps> = ({
|
||||
|
||||
const issueName = watch("name");
|
||||
|
||||
const handleTitleChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value;
|
||||
const similarIssue = issues?.find((i: IIssue) => cosineSimilarity(i.name, value) > 0.7);
|
||||
setMostSimilarIssue(similarIssue);
|
||||
};
|
||||
|
||||
const handleCreateUpdateIssue = async (formData: Partial<IIssue>) => {
|
||||
await handleFormSubmit(formData);
|
||||
|
||||
@@ -254,6 +243,10 @@ export const IssueForm: FC<IssueFormProps> = ({
|
||||
handleClose={() => setLabelModal(false)}
|
||||
projectId={projectId}
|
||||
user={user}
|
||||
onSuccess={(response) => {
|
||||
setValue("labels", [...watch("labels"), response.id]);
|
||||
setValue("labels_list", [...watch("labels_list"), response.id]);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
@@ -279,26 +272,28 @@ export const IssueForm: FC<IssueFormProps> = ({
|
||||
</div>
|
||||
{watch("parent") &&
|
||||
watch("parent") !== "" &&
|
||||
(fieldsToShow.includes("all") || fieldsToShow.includes("parent")) && (
|
||||
(fieldsToShow.includes("all") || fieldsToShow.includes("parent")) &&
|
||||
selectedParentIssue && (
|
||||
<div className="flex w-min items-center gap-2 whitespace-nowrap rounded bg-custom-background-80 p-2 text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="block h-1.5 w-1.5 rounded-full"
|
||||
style={{
|
||||
backgroundColor: issues.find((i) => i.id === watch("parent"))?.state_detail
|
||||
.color,
|
||||
backgroundColor: selectedParentIssue.state__color,
|
||||
}}
|
||||
/>
|
||||
<span className="flex-shrink-0 text-custom-text-200">
|
||||
{/* {projects?.find((p) => p.id === projectId)?.identifier}- */}
|
||||
{issues.find((i) => i.id === watch("parent"))?.sequence_id}
|
||||
{selectedParentIssue.project__identifier}-{selectedParentIssue.sequence_id}
|
||||
</span>
|
||||
<span className="truncate font-medium">
|
||||
{issues.find((i) => i.id === watch("parent"))?.name.substring(0, 50)}
|
||||
{selectedParentIssue.name.substring(0, 50)}
|
||||
</span>
|
||||
<XMarkIcon
|
||||
className="h-3 w-3 cursor-pointer"
|
||||
onClick={() => setValue("parent", null)}
|
||||
onClick={() => {
|
||||
setValue("parent", null);
|
||||
setSelectedParentIssue(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -310,7 +305,6 @@ export const IssueForm: FC<IssueFormProps> = ({
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
onChange={handleTitleChange}
|
||||
className="resize-none text-xl"
|
||||
placeholder="Title"
|
||||
autoComplete="off"
|
||||
@@ -324,38 +318,11 @@ export const IssueForm: FC<IssueFormProps> = ({
|
||||
},
|
||||
}}
|
||||
/>
|
||||
{mostSimilarIssue && (
|
||||
<div className="flex items-center gap-x-2">
|
||||
<p className="text-sm text-custom-text-200">
|
||||
<Link
|
||||
href={`/${workspaceSlug}/projects/${projectId}/issues/${mostSimilarIssue.id}`}
|
||||
>
|
||||
<a target="_blank" type="button" className="inline text-left">
|
||||
<span>Did you mean </span>
|
||||
<span className="italic">
|
||||
{mostSimilarIssue.project_detail.identifier}-
|
||||
{mostSimilarIssue.sequence_id}: {mostSimilarIssue.name}{" "}
|
||||
</span>
|
||||
?
|
||||
</a>
|
||||
</Link>
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="text-sm text-custom-primary"
|
||||
onClick={() => {
|
||||
setMostSimilarIssue(undefined);
|
||||
}}
|
||||
>
|
||||
No
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{(fieldsToShow.includes("all") || fieldsToShow.includes("description")) && (
|
||||
<div className="relative">
|
||||
<div className="-mb-2 flex justify-end">
|
||||
<div className="flex justify-end">
|
||||
{issueName && issueName !== "" && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -491,11 +458,20 @@ export const IssueForm: FC<IssueFormProps> = ({
|
||||
</div>
|
||||
)}
|
||||
{(fieldsToShow.includes("all") || fieldsToShow.includes("parent")) && (
|
||||
<IssueParentSelect
|
||||
<Controller
|
||||
control={control}
|
||||
isOpen={parentIssueListModalOpen}
|
||||
setIsOpen={setParentIssueListModalOpen}
|
||||
issues={issues ?? []}
|
||||
name="parent"
|
||||
render={({ field: { onChange } }) => (
|
||||
<ParentIssuesListModal
|
||||
isOpen={parentIssueListModalOpen}
|
||||
handleClose={() => setParentIssueListModalOpen(false)}
|
||||
onChange={(issue) => {
|
||||
onChange(issue.id);
|
||||
setSelectedParentIssue(issue);
|
||||
}}
|
||||
projectId={projectId}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{(fieldsToShow.includes("all") || fieldsToShow.includes("parent")) && (
|
||||
|
||||
@@ -58,7 +58,7 @@ export const IssueMainContent: React.FC<Props> = ({
|
||||
<>
|
||||
<div className="rounded-lg">
|
||||
{issueDetails?.parent && issueDetails.parent !== "" ? (
|
||||
<div className="mb-5 flex w-min items-center gap-2 whitespace-nowrap rounded bg-custom-background-80 p-2 text-xs">
|
||||
<div className="mb-5 flex w-min items-center gap-2 whitespace-nowrap rounded bg-custom-background-90 p-2 text-xs">
|
||||
<Link href={`/${workspaceSlug}/projects/${projectId}/issues/${issueDetails.parent}`}>
|
||||
<a className="flex items-center gap-2 text-custom-text-200">
|
||||
<span
|
||||
@@ -76,24 +76,36 @@ export const IssueMainContent: React.FC<Props> = ({
|
||||
</a>
|
||||
</Link>
|
||||
|
||||
<CustomMenu ellipsis position="left">
|
||||
{siblingIssues && siblingIssues.length > 0 ? (
|
||||
siblingIssues.map((issue: IIssue) => (
|
||||
<CustomMenu.MenuItem key={issue.id}>
|
||||
<Link
|
||||
href={`/${workspaceSlug}/projects/${projectId as string}/issues/${issue.id}`}
|
||||
>
|
||||
<a>
|
||||
{issueDetails.project_detail.identifier}-{issue.sequence_id}
|
||||
</a>
|
||||
</Link>
|
||||
</CustomMenu.MenuItem>
|
||||
))
|
||||
<CustomMenu position="left" ellipsis>
|
||||
{siblingIssues && siblingIssues.sub_issues.length > 0 ? (
|
||||
<>
|
||||
<h2 className="text-custom-text-200 px-1 mb-2">Sibling issues</h2>
|
||||
{siblingIssues.sub_issues.map((issue) => {
|
||||
if (issue.id !== issueDetails.id)
|
||||
return (
|
||||
<CustomMenu.MenuItem
|
||||
key={issue.id}
|
||||
renderAs="a"
|
||||
href={`/${workspaceSlug}/projects/${projectId as string}/issues/${
|
||||
issue.id
|
||||
}`}
|
||||
>
|
||||
{issueDetails.project_detail.identifier}-{issue.sequence_id}
|
||||
</CustomMenu.MenuItem>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
) : (
|
||||
<CustomMenu.MenuItem className="flex items-center gap-2 whitespace-nowrap p-2 text-left text-xs text-custom-text-200">
|
||||
No other sibling issues
|
||||
</CustomMenu.MenuItem>
|
||||
<p className="flex items-center gap-2 whitespace-nowrap px-1 text-left text-xs text-custom-text-200 py-1">
|
||||
No sibling issues
|
||||
</p>
|
||||
)}
|
||||
<CustomMenu.MenuItem
|
||||
renderAs="button"
|
||||
onClick={() => submitChanges({ parent: null })}
|
||||
>
|
||||
Remove parent issue
|
||||
</CustomMenu.MenuItem>
|
||||
</CustomMenu>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useEffect, useState } from "react";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import useSWR, { mutate } from "swr";
|
||||
import { mutate } from "swr";
|
||||
|
||||
// headless ui
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
@@ -94,15 +94,6 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
|
||||
assignees: [...(prePopulateData?.assignees ?? []), user?.id ?? ""],
|
||||
};
|
||||
|
||||
const { data: issues } = useSWR(
|
||||
workspaceSlug && activeProject
|
||||
? PROJECT_ISSUES_LIST(workspaceSlug as string, activeProject ?? "")
|
||||
: null,
|
||||
workspaceSlug && activeProject
|
||||
? () => issuesService.getIssues(workspaceSlug as string, activeProject ?? "")
|
||||
: null
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (projects && projects.length > 0)
|
||||
setActiveProject(projects?.find((p) => p.id === projectId)?.id ?? projects?.[0].id ?? null);
|
||||
@@ -271,7 +262,7 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
|
||||
|
||||
const updateIssue = async (payload: Partial<IIssue>) => {
|
||||
await issuesService
|
||||
.updateIssue(workspaceSlug as string, activeProject ?? "", data?.id ?? "", payload, user)
|
||||
.patchIssue(workspaceSlug as string, activeProject ?? "", data?.id ?? "", payload, user)
|
||||
.then((res) => {
|
||||
if (isUpdatingSingleIssue) {
|
||||
mutate<IIssue>(PROJECT_ISSUES_DETAILS, (prevData) => ({ ...prevData, ...res }), false);
|
||||
@@ -317,6 +308,8 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
|
||||
else await updateIssue(payload);
|
||||
};
|
||||
|
||||
if (!projects || projects.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Transition.Root show={isOpen} as={React.Fragment}>
|
||||
<Dialog as="div" className="relative z-20" onClose={() => handleClose()}>
|
||||
@@ -345,7 +338,6 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
|
||||
>
|
||||
<Dialog.Panel className="relative transform rounded-lg border border-custom-border-200 bg-custom-background-100 p-5 text-left shadow-xl transition-all sm:w-full sm:max-w-2xl">
|
||||
<IssueForm
|
||||
issues={issues ?? []}
|
||||
handleFormSubmit={handleFormSubmit}
|
||||
initialData={data ?? prePopulateData}
|
||||
createMore={createMore}
|
||||
|
||||
@@ -126,7 +126,7 @@ export const MyIssuesListItem: React.FC<Props> = ({ issue, properties, projectId
|
||||
isNotAllowed={isNotAllowed}
|
||||
/>
|
||||
)}
|
||||
{properties.due_date && (
|
||||
{properties.due_date && issue.target_date && (
|
||||
<ViewDueDateSelect
|
||||
issue={issue}
|
||||
partialUpdateIssue={partialUpdateIssue}
|
||||
@@ -134,7 +134,7 @@ export const MyIssuesListItem: React.FC<Props> = ({ issue, properties, projectId
|
||||
isNotAllowed={isNotAllowed}
|
||||
/>
|
||||
)}
|
||||
{properties.labels && issue.label_details.length > 0 ? (
|
||||
{properties.labels && issue.labels.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{issue.label_details.map((label) => (
|
||||
<span
|
||||
@@ -151,8 +151,6 @@ export const MyIssuesListItem: React.FC<Props> = ({ issue, properties, projectId
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
{properties.assignee && (
|
||||
<div className="flex cursor-default items-center rounded-md border border-custom-border-200 px-2 py-1 text-xs shadow-sm">
|
||||
@@ -175,7 +173,7 @@ export const MyIssuesListItem: React.FC<Props> = ({ issue, properties, projectId
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
{properties.sub_issue_count && (
|
||||
{properties.sub_issue_count && issue.sub_issues_count > 0 && (
|
||||
<div className="flex cursor-default items-center rounded-md border border-custom-border-200 px-2.5 py-1 text-xs shadow-sm">
|
||||
<Tooltip tooltipHeading="Sub-issue" tooltipContent={`${issue.sub_issues_count}`}>
|
||||
<div className="flex items-center gap-1 text-custom-text-200">
|
||||
@@ -185,7 +183,7 @@ export const MyIssuesListItem: React.FC<Props> = ({ issue, properties, projectId
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
{properties.link && (
|
||||
{properties.link && issue.link_count > 0 && (
|
||||
<div className="flex cursor-default items-center rounded-md border border-custom-border-200 px-2 py-1 text-xs shadow-sm">
|
||||
<Tooltip tooltipHeading="Link" tooltipContent={`${issue.link_count}`}>
|
||||
<div className="flex items-center gap-1 text-custom-text-200">
|
||||
@@ -195,7 +193,7 @@ export const MyIssuesListItem: React.FC<Props> = ({ issue, properties, projectId
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
{properties.attachment_count && (
|
||||
{properties.attachment_count && issue.attachment_count > 0 && (
|
||||
<div className="flex cursor-default items-center rounded-md border border-custom-border-200 px-2 py-1 text-xs shadow-sm">
|
||||
<Tooltip tooltipHeading="Attachment" tooltipContent={`${issue.attachment_count}`}>
|
||||
<div className="flex items-center gap-1 text-custom-text-200">
|
||||
|
||||
@@ -21,7 +21,8 @@ type Props = {
|
||||
isOpen: boolean;
|
||||
handleClose: () => void;
|
||||
value?: any;
|
||||
onChange: (...event: any[]) => void;
|
||||
onChange: (issue: ISearchIssueResponse) => void;
|
||||
projectId: string;
|
||||
issueId?: string;
|
||||
customDisplay?: JSX.Element;
|
||||
};
|
||||
@@ -31,6 +32,7 @@ export const ParentIssuesListModal: React.FC<Props> = ({
|
||||
handleClose: onClose,
|
||||
value,
|
||||
onChange,
|
||||
projectId,
|
||||
issueId,
|
||||
customDisplay,
|
||||
}) => {
|
||||
@@ -42,7 +44,7 @@ export const ParentIssuesListModal: React.FC<Props> = ({
|
||||
const debouncedSearchTerm: string = useDebounce(searchTerm, 500);
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
const { workspaceSlug } = router.query;
|
||||
|
||||
const handleClose = () => {
|
||||
onClose();
|
||||
@@ -109,7 +111,13 @@ 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-custom-border-200 bg-custom-background-100 shadow-2xl transition-all">
|
||||
<Combobox value={value} onChange={onChange}>
|
||||
<Combobox
|
||||
value={value}
|
||||
onChange={(val) => {
|
||||
onChange(val);
|
||||
handleClose();
|
||||
}}
|
||||
>
|
||||
<div className="relative m-1">
|
||||
<MagnifyingGlassIcon
|
||||
className="pointer-events-none absolute top-3.5 left-4 h-5 w-5 text-custom-text-100 text-opacity-40"
|
||||
@@ -165,13 +173,12 @@ export const ParentIssuesListModal: React.FC<Props> = ({
|
||||
{issues.map((issue) => (
|
||||
<Combobox.Option
|
||||
key={issue.id}
|
||||
value={issue.id}
|
||||
value={issue}
|
||||
className={({ active, selected }) =>
|
||||
`flex cursor-pointer select-none items-center gap-2 rounded-md px-3 py-2 text-custom-text-200 ${
|
||||
active ? "bg-custom-background-80 text-custom-text-100" : ""
|
||||
} ${selected ? "text-custom-text-100" : ""}`
|
||||
}
|
||||
onClick={handleClose}
|
||||
>
|
||||
<>
|
||||
<span
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
export * from "./assignee";
|
||||
export * from "./date";
|
||||
export * from "./estimate"
|
||||
export * from "./estimate";
|
||||
export * from "./label";
|
||||
export * from "./parent";
|
||||
export * from "./priority";
|
||||
export * from "./project";
|
||||
export * from "./state";
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import React from "react";
|
||||
import { Controller, Control } from "react-hook-form";
|
||||
// components
|
||||
import { ParentIssuesListModal } from "components/issues";
|
||||
// types
|
||||
import type { IIssue } from "types";
|
||||
|
||||
type Props = {
|
||||
control: Control<IIssue, any>;
|
||||
isOpen: boolean;
|
||||
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
issues: IIssue[];
|
||||
};
|
||||
|
||||
export const IssueParentSelect: React.FC<Props> = ({ control, isOpen, setIsOpen, issues }) => (
|
||||
<Controller
|
||||
control={control}
|
||||
name="parent"
|
||||
render={({ field: { onChange } }) => (
|
||||
<ParentIssuesListModal
|
||||
isOpen={isOpen}
|
||||
handleClose={() => setIsOpen(false)}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
@@ -2,51 +2,31 @@ import React, { useState } from "react";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import useSWR from "swr";
|
||||
|
||||
import { Control, Controller, UseFormWatch } from "react-hook-form";
|
||||
// fetch keys
|
||||
// icons
|
||||
import { UserIcon } from "@heroicons/react/24/outline";
|
||||
// services
|
||||
import issuesServices from "services/issues.service";
|
||||
// components
|
||||
import { ParentIssuesListModal } from "components/issues";
|
||||
// icons
|
||||
// types
|
||||
import { IIssue, UserAuth } from "types";
|
||||
// fetch-keys
|
||||
import { PROJECT_ISSUES_LIST } from "constants/fetch-keys";
|
||||
import { IIssue, ISearchIssueResponse, UserAuth } from "types";
|
||||
|
||||
type Props = {
|
||||
control: Control<IIssue, any>;
|
||||
submitChanges: (formData: Partial<IIssue>) => void;
|
||||
customDisplay: JSX.Element;
|
||||
watch: UseFormWatch<IIssue>;
|
||||
onChange: (value: string) => void;
|
||||
issueDetails: IIssue | undefined;
|
||||
userAuth: UserAuth;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export const SidebarParentSelect: React.FC<Props> = ({
|
||||
control,
|
||||
submitChanges,
|
||||
customDisplay,
|
||||
watch,
|
||||
onChange,
|
||||
issueDetails,
|
||||
userAuth,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const [isParentModalOpen, setIsParentModalOpen] = useState(false);
|
||||
const [selectedParentIssue, setSelectedParentIssue] = useState<ISearchIssueResponse | null>(null);
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, issueId } = 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 { projectId, issueId } = router.query;
|
||||
|
||||
const isNotAllowed = userAuth.isGuest || userAuth.isViewer || disabled;
|
||||
|
||||
@@ -57,22 +37,15 @@ export const SidebarParentSelect: React.FC<Props> = ({
|
||||
<p>Parent</p>
|
||||
</div>
|
||||
<div className="sm:basis-1/2">
|
||||
<Controller
|
||||
control={control}
|
||||
name="parent"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<ParentIssuesListModal
|
||||
isOpen={isParentModalOpen}
|
||||
handleClose={() => setIsParentModalOpen(false)}
|
||||
onChange={(val) => {
|
||||
submitChanges({ parent: val });
|
||||
onChange(val);
|
||||
}}
|
||||
issueId={issueId as string}
|
||||
value={value}
|
||||
customDisplay={customDisplay}
|
||||
/>
|
||||
)}
|
||||
<ParentIssuesListModal
|
||||
isOpen={isParentModalOpen}
|
||||
handleClose={() => setIsParentModalOpen(false)}
|
||||
onChange={(issue) => {
|
||||
onChange(issue.id);
|
||||
setSelectedParentIssue(issue);
|
||||
}}
|
||||
issueId={issueId as string}
|
||||
projectId={projectId as string}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@@ -82,10 +55,10 @@ export const SidebarParentSelect: React.FC<Props> = ({
|
||||
onClick={() => setIsParentModalOpen(true)}
|
||||
disabled={isNotAllowed}
|
||||
>
|
||||
{watch("parent") && watch("parent") !== "" ? (
|
||||
`${issues?.find((i) => i.id === watch("parent"))?.project_detail?.identifier}-${
|
||||
issues?.find((i) => i.id === watch("parent"))?.sequence_id
|
||||
}`
|
||||
{selectedParentIssue ? (
|
||||
`${selectedParentIssue.project__identifier}-${selectedParentIssue.sequence_id}`
|
||||
) : issueDetails?.parent ? (
|
||||
`${issueDetails.project_detail.identifier}-${issueDetails.parent_detail?.sequence_id}`
|
||||
) : (
|
||||
<span className="text-custom-text-200">Select issue</span>
|
||||
)}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Controller, UseFormWatch } from "react-hook-form";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
import useUserAuth from "hooks/use-user-auth";
|
||||
import useUserIssueNotificationSubscription from "hooks/use-issue-notification-subscription";
|
||||
// services
|
||||
import issuesService from "services/issues.service";
|
||||
import modulesService from "services/modules.service";
|
||||
@@ -30,15 +31,9 @@ import {
|
||||
SidebarLabelSelect,
|
||||
} from "components/issues";
|
||||
// ui
|
||||
import { CustomDatePicker } from "components/ui";
|
||||
import { CustomDatePicker, Icon } from "components/ui";
|
||||
// icons
|
||||
import {
|
||||
LinkIcon,
|
||||
CalendarDaysIcon,
|
||||
TrashIcon,
|
||||
PlusIcon,
|
||||
XMarkIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { LinkIcon, CalendarDaysIcon, TrashIcon, PlusIcon } from "@heroicons/react/24/outline";
|
||||
// helpers
|
||||
import { copyTextToClipboard } from "helpers/string.helper";
|
||||
// types
|
||||
@@ -66,6 +61,7 @@ type Props = {
|
||||
| "link"
|
||||
| "delete"
|
||||
| "all"
|
||||
| "subscribe"
|
||||
)[];
|
||||
uneditable?: boolean;
|
||||
};
|
||||
@@ -86,6 +82,9 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
|
||||
|
||||
const { user } = useUserAuth();
|
||||
|
||||
const { loading, handleSubscribe, handleUnsubscribe, subscribed } =
|
||||
useUserIssueNotificationSubscription(workspaceSlug, projectId, issueId);
|
||||
|
||||
const { memberRole } = useProjectMyMembership();
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
@@ -232,6 +231,22 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
|
||||
{issueDetail?.project_detail?.identifier}-{issueDetail?.sequence_id}
|
||||
</h4>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{issueDetail?.created_by !== user?.id &&
|
||||
!issueDetail?.assignees.includes(user?.id ?? "") &&
|
||||
!router.pathname.includes("[archivedIssueId]") &&
|
||||
(fieldsToShow.includes("all") || fieldsToShow.includes("subscribe")) && (
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md flex items-center gap-2 border border-custom-primary-100 px-2 py-1 text-xs text-custom-primary-100 shadow-sm duration-300 focus:outline-none"
|
||||
onClick={() => {
|
||||
if (subscribed) handleUnsubscribe();
|
||||
else handleSubscribe();
|
||||
}}
|
||||
>
|
||||
<Icon iconName="notifications" />
|
||||
{loading ? "Loading..." : subscribed ? "Unsubscribe" : "Subscribe"}
|
||||
</button>
|
||||
)}
|
||||
{(fieldsToShow.includes("all") || fieldsToShow.includes("link")) && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -318,29 +333,20 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
|
||||
{showSecondSection && (
|
||||
<div className="py-1">
|
||||
{(fieldsToShow.includes("all") || fieldsToShow.includes("parent")) && (
|
||||
<SidebarParentSelect
|
||||
<Controller
|
||||
control={control}
|
||||
submitChanges={submitChanges}
|
||||
customDisplay={
|
||||
issueDetail?.parent_detail ? (
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 rounded bg-custom-background-80 px-3 py-2 text-xs"
|
||||
onClick={() => submitChanges({ parent: null })}
|
||||
>
|
||||
<span className="text-custom-text-200">Selected:</span>{" "}
|
||||
{issueDetail.parent_detail?.name}
|
||||
<XMarkIcon className="h-3 w-3" />
|
||||
</button>
|
||||
) : (
|
||||
<div className="inline-block rounded bg-custom-background-90 px-3 py-2 text-xs text-custom-text-200">
|
||||
No parent selected
|
||||
</div>
|
||||
)
|
||||
}
|
||||
watch={watchIssue}
|
||||
userAuth={memberRole}
|
||||
disabled={uneditable}
|
||||
name="parent"
|
||||
render={({ field: { onChange } }) => (
|
||||
<SidebarParentSelect
|
||||
onChange={(val: string) => {
|
||||
submitChanges({ parent: val });
|
||||
onChange(val);
|
||||
}}
|
||||
issueDetails={issueDetail}
|
||||
userAuth={memberRole}
|
||||
disabled={uneditable}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{(fieldsToShow.includes("all") || fieldsToShow.includes("blocker")) && (
|
||||
|
||||
@@ -272,7 +272,7 @@ export const SubIssuesList: FC<Props> = ({ parentIssue, user, disabled = false }
|
||||
key={issue.id}
|
||||
href={`/${workspaceSlug}/projects/${projectId}/issues/${issue.id}`}
|
||||
>
|
||||
<a className="group flex items-center justify-between gap-2 rounded p-2 hover:bg-custom-background-100">
|
||||
<a className="group flex items-center justify-between gap-2 rounded p-2 hover:bg-custom-background-90">
|
||||
<div className="flex items-center gap-2 rounded text-xs">
|
||||
<span
|
||||
className="block h-1.5 w-1.5 flex-shrink-0 rounded-full"
|
||||
@@ -316,6 +316,7 @@ export const SubIssuesList: FC<Props> = ({ parentIssue, user, disabled = false }
|
||||
Add sub-issue
|
||||
</>
|
||||
}
|
||||
buttonClassName="whitespace-nowrap"
|
||||
position="left"
|
||||
noBorder
|
||||
noChevron
|
||||
|
||||
@@ -26,6 +26,7 @@ type Props = {
|
||||
isOpen: boolean;
|
||||
projectId: string;
|
||||
handleClose: () => void;
|
||||
onSuccess?: (response: IIssueLabels) => void;
|
||||
user: ICurrentUserResponse | undefined;
|
||||
};
|
||||
|
||||
@@ -34,7 +35,13 @@ const defaultValues: Partial<IState> = {
|
||||
color: "rgb(var(--color-text-200))",
|
||||
};
|
||||
|
||||
export const CreateLabelModal: React.FC<Props> = ({ isOpen, projectId, handleClose, user }) => {
|
||||
export const CreateLabelModal: React.FC<Props> = ({
|
||||
isOpen,
|
||||
projectId,
|
||||
handleClose,
|
||||
user,
|
||||
onSuccess,
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug } = router.query;
|
||||
|
||||
@@ -66,6 +73,7 @@ export const CreateLabelModal: React.FC<Props> = ({ isOpen, projectId, handleClo
|
||||
false
|
||||
);
|
||||
onClose();
|
||||
if (onSuccess) onSuccess(res);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log(error);
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./notification-card";
|
||||
export * from "./notification-popover";
|
||||
export * from "./select-snooze-till-modal";
|
||||
@@ -0,0 +1,269 @@
|
||||
import React from "react";
|
||||
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
|
||||
// icons
|
||||
import { CustomMenu, Icon, Tooltip } from "components/ui";
|
||||
|
||||
// helper
|
||||
import { stripHTML, replaceUnderscoreIfSnakeCase, truncateText } from "helpers/string.helper";
|
||||
import {
|
||||
formatDateDistance,
|
||||
render12HourFormatTime,
|
||||
renderLongDateFormat,
|
||||
renderShortDate,
|
||||
renderShortDateWithYearFormat,
|
||||
} from "helpers/date-time.helper";
|
||||
|
||||
// type
|
||||
import type { IUserNotification } from "types";
|
||||
|
||||
type NotificationCardProps = {
|
||||
notification: IUserNotification;
|
||||
markNotificationReadStatus: (notificationId: string) => Promise<void>;
|
||||
markNotificationArchivedStatus: (notificationId: string) => Promise<void>;
|
||||
setSelectedNotificationForSnooze: (notificationId: string) => void;
|
||||
markSnoozeNotification: (notificationId: string, dateTime?: Date | undefined) => Promise<void>;
|
||||
};
|
||||
|
||||
const snoozeOptions = [
|
||||
{
|
||||
label: "1 day",
|
||||
value: new Date(new Date().getTime() + 24 * 60 * 60 * 1000),
|
||||
},
|
||||
{
|
||||
label: "3 days",
|
||||
value: new Date(new Date().getTime() + 3 * 24 * 60 * 60 * 1000),
|
||||
},
|
||||
{
|
||||
label: "5 days",
|
||||
value: new Date(new Date().getTime() + 5 * 24 * 60 * 60 * 1000),
|
||||
},
|
||||
{
|
||||
label: "1 week",
|
||||
value: new Date(new Date().getTime() + 7 * 24 * 60 * 60 * 1000),
|
||||
},
|
||||
{
|
||||
label: "2 weeks",
|
||||
value: new Date(new Date().getTime() + 14 * 24 * 60 * 60 * 1000),
|
||||
},
|
||||
{
|
||||
label: "Custom",
|
||||
value: null,
|
||||
},
|
||||
];
|
||||
|
||||
export const NotificationCard: React.FC<NotificationCardProps> = (props) => {
|
||||
const {
|
||||
notification,
|
||||
markNotificationReadStatus,
|
||||
markNotificationArchivedStatus,
|
||||
setSelectedNotificationForSnooze,
|
||||
markSnoozeNotification,
|
||||
} = props;
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug } = router.query;
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={() => {
|
||||
markNotificationReadStatus(notification.id);
|
||||
router.push(
|
||||
`/${workspaceSlug}/projects/${notification.project}/issues/${notification.data.issue.id}`
|
||||
);
|
||||
}}
|
||||
className={`group w-full flex items-center gap-4 p-3 pl-6 relative cursor-pointer ${
|
||||
notification.read_at === null ? "bg-custom-primary-70/5" : "hover:bg-custom-background-200"
|
||||
}`}
|
||||
>
|
||||
{notification.read_at === null && (
|
||||
<span className="absolute top-1/2 left-2 -translate-y-1/2 w-1.5 h-1.5 bg-custom-primary-100 rounded-full" />
|
||||
)}
|
||||
<div className="relative w-12 h-12 rounded-full">
|
||||
{notification.triggered_by_details.avatar &&
|
||||
notification.triggered_by_details.avatar !== "" ? (
|
||||
<div className="h-12 w-12 rounded-full">
|
||||
<Image
|
||||
src={notification.triggered_by_details.avatar}
|
||||
alt="Profile Image"
|
||||
layout="fill"
|
||||
objectFit="cover"
|
||||
className="rounded-full"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-12 h-12 bg-custom-background-80 rounded-full flex justify-center items-center">
|
||||
<span className="text-custom-text-100 font-medium text-lg">
|
||||
{notification.triggered_by_details.first_name[0].toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2.5 w-full overflow-hidden">
|
||||
<div className="text-sm w-full break-words">
|
||||
<span className="font-semibold">
|
||||
{notification.triggered_by_details.first_name}{" "}
|
||||
{notification.triggered_by_details.last_name}{" "}
|
||||
</span>
|
||||
{notification.data.issue_activity.field !== "comment" &&
|
||||
notification.data.issue_activity.verb}{" "}
|
||||
{notification.data.issue_activity.field === "comment"
|
||||
? "commented"
|
||||
: notification.data.issue_activity.field === "None"
|
||||
? null
|
||||
: replaceUnderscoreIfSnakeCase(notification.data.issue_activity.field)}{" "}
|
||||
{notification.data.issue_activity.field !== "comment" &&
|
||||
notification.data.issue_activity.field !== "None"
|
||||
? "to"
|
||||
: ""}
|
||||
<span className="font-semibold">
|
||||
{" "}
|
||||
{notification.data.issue_activity.field !== "None" ? (
|
||||
notification.data.issue_activity.field !== "comment" ? (
|
||||
notification.data.issue_activity.field === "target_date" ? (
|
||||
renderShortDateWithYearFormat(notification.data.issue_activity.new_value)
|
||||
) : notification.data.issue_activity.field === "attachment" ? (
|
||||
"the issue"
|
||||
) : stripHTML(notification.data.issue_activity.new_value).length > 55 ? (
|
||||
stripHTML(notification.data.issue_activity.new_value).slice(0, 50) + "..."
|
||||
) : (
|
||||
stripHTML(notification.data.issue_activity.new_value)
|
||||
)
|
||||
) : (
|
||||
<span>
|
||||
{`"`}
|
||||
{notification.data.issue_activity.new_value.length > 55
|
||||
? notification?.data?.issue_activity?.issue_comment?.slice(0, 50) + "..."
|
||||
: notification.data.issue_activity.issue_comment}
|
||||
{`"`}
|
||||
</span>
|
||||
)
|
||||
) : (
|
||||
"the issue and assigned it to you."
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between gap-2 text-xs">
|
||||
<p className="text-custom-text-300">
|
||||
{truncateText(
|
||||
`${notification.data.issue.identifier}-${notification.data.issue.sequence_id} ${notification.data.issue.name}`,
|
||||
50
|
||||
)}
|
||||
</p>
|
||||
{notification.snoozed_till ? (
|
||||
<p className="text-custom-text-300 flex items-center justify-end gap-x-1 flex-shrink-0">
|
||||
<Icon iconName="schedule" className="!text-base -my-0.5" />
|
||||
<span>
|
||||
Till {renderShortDate(notification.snoozed_till)},{" "}
|
||||
{render12HourFormatTime(notification.snoozed_till)}
|
||||
</span>
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-custom-text-300 flex-shrink-0">
|
||||
{formatDateDistance(notification.created_at)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute py-1 gap-x-3 right-3 top-3 hidden group-hover:flex">
|
||||
{[
|
||||
{
|
||||
id: 1,
|
||||
name: notification.read_at ? "Mark as unread" : "Mark as read",
|
||||
icon: "chat_bubble",
|
||||
onClick: () => {
|
||||
markNotificationReadStatus(notification.id).then(() => {
|
||||
setToastAlert({
|
||||
title: notification.read_at
|
||||
? "Notification marked as unread"
|
||||
: "Notification marked as read",
|
||||
type: "success",
|
||||
});
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: notification.archived_at ? "Unarchive" : "Archive",
|
||||
icon: notification.archived_at ? "unarchive" : "archive",
|
||||
onClick: () => {
|
||||
markNotificationArchivedStatus(notification.id).then(() => {
|
||||
setToastAlert({
|
||||
title: notification.archived_at
|
||||
? "Notification un-archived"
|
||||
: "Notification archived",
|
||||
type: "success",
|
||||
});
|
||||
});
|
||||
},
|
||||
},
|
||||
].map((item) => (
|
||||
<Tooltip tooltipContent={item.name}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
item.onClick();
|
||||
}}
|
||||
key={item.id}
|
||||
className="text-sm flex w-full items-center gap-x-2 bg-custom-background-80 hover:bg-custom-background-100 p-0.5 rounded outline-none"
|
||||
>
|
||||
<Icon iconName={item.icon} className="h-5 w-5 text-custom-text-300" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
))}
|
||||
|
||||
<Tooltip tooltipContent="Snooze">
|
||||
<div>
|
||||
<CustomMenu
|
||||
menuButtonOnClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
customButton={
|
||||
<button
|
||||
type="button"
|
||||
className="text-sm flex w-full items-center gap-x-2 bg-custom-background-80 hover:bg-custom-background-100 p-0.5 rounded"
|
||||
>
|
||||
<Icon iconName="schedule" className="h-5 w-5 text-custom-text-300" />
|
||||
</button>
|
||||
}
|
||||
optionsClassName="!z-20"
|
||||
>
|
||||
{snoozeOptions.map((item) => (
|
||||
<CustomMenu.MenuItem
|
||||
key={item.label}
|
||||
renderAs="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
if (!item.value) {
|
||||
setSelectedNotificationForSnooze(notification.id);
|
||||
return;
|
||||
}
|
||||
|
||||
markSnoozeNotification(notification.id, item.value).then(() => {
|
||||
setToastAlert({
|
||||
title: `Notification snoozed till ${renderLongDateFormat(item.value)}`,
|
||||
type: "success",
|
||||
});
|
||||
});
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</CustomMenu.MenuItem>
|
||||
))}
|
||||
</CustomMenu>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,302 @@
|
||||
import React, { Fragment } from "react";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
// hooks
|
||||
import useTheme from "hooks/use-theme";
|
||||
|
||||
import { Popover, Transition } from "@headlessui/react";
|
||||
|
||||
// hooks
|
||||
import useWorkspaceMembers from "hooks/use-workspace-members";
|
||||
import useUserNotification from "hooks/use-user-notifications";
|
||||
|
||||
// components
|
||||
import { Icon, Loader, EmptyState, Tooltip } from "components/ui";
|
||||
import { SnoozeNotificationModal, NotificationCard } from "components/notifications";
|
||||
// icons
|
||||
import { NotificationsOutlined } from "@mui/icons-material";
|
||||
// images
|
||||
import emptyNotification from "public/empty-state/notification.svg";
|
||||
// helpers
|
||||
import { getNumberCount } from "helpers/string.helper";
|
||||
|
||||
// type
|
||||
import type { NotificationType } from "types";
|
||||
|
||||
export const NotificationPopover = () => {
|
||||
const {
|
||||
notifications,
|
||||
archived,
|
||||
readNotification,
|
||||
selectedNotificationForSnooze,
|
||||
selectedTab,
|
||||
setArchived,
|
||||
setReadNotification,
|
||||
setSelectedNotificationForSnooze,
|
||||
setSelectedTab,
|
||||
setSnoozed,
|
||||
snoozed,
|
||||
notificationsMutate,
|
||||
markNotificationArchivedStatus,
|
||||
markNotificationReadStatus,
|
||||
markSnoozeNotification,
|
||||
notificationCount,
|
||||
totalNotificationCount,
|
||||
} = useUserNotification();
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug } = router.query;
|
||||
|
||||
const { isOwner, isMember } = useWorkspaceMembers(workspaceSlug?.toString() ?? "");
|
||||
|
||||
// theme context
|
||||
const { collapsed: sidebarCollapse } = useTheme();
|
||||
|
||||
const notificationTabs: Array<{
|
||||
label: string;
|
||||
value: NotificationType;
|
||||
unreadCount?: number;
|
||||
}> = [
|
||||
{
|
||||
label: "My Issues",
|
||||
value: "assigned",
|
||||
unreadCount: notificationCount?.my_issues,
|
||||
},
|
||||
{
|
||||
label: "Created by me",
|
||||
value: "created",
|
||||
unreadCount: notificationCount?.created_issues,
|
||||
},
|
||||
{
|
||||
label: "Subscribed",
|
||||
value: "watching",
|
||||
unreadCount: notificationCount?.watching_issues,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<SnoozeNotificationModal
|
||||
isOpen={selectedNotificationForSnooze !== null}
|
||||
onClose={() => setSelectedNotificationForSnooze(null)}
|
||||
onSubmit={markSnoozeNotification}
|
||||
notification={
|
||||
notifications?.find(
|
||||
(notification) => notification.id === selectedNotificationForSnooze
|
||||
) || null
|
||||
}
|
||||
onSuccess={() => {
|
||||
notificationsMutate();
|
||||
setSelectedNotificationForSnooze(null);
|
||||
}}
|
||||
/>
|
||||
<Popover className="relative w-full">
|
||||
{({ open: isActive, close: closePopover }) => (
|
||||
<>
|
||||
<Popover.Button
|
||||
className={`group flex w-full items-center gap-2.5 rounded-md px-3 py-2 text-sm font-medium outline-none ${
|
||||
isActive
|
||||
? "bg-custom-primary-100/10 text-custom-primary-100"
|
||||
: "text-custom-sidebar-text-200 hover:bg-custom-sidebar-background-80"
|
||||
} ${sidebarCollapse ? "justify-center" : ""}`}
|
||||
>
|
||||
<NotificationsOutlined fontSize="small" />
|
||||
{sidebarCollapse ? null : <span>Notifications</span>}
|
||||
{totalNotificationCount && totalNotificationCount > 0 ? (
|
||||
<span className="ml-auto bg-custom-primary-300 rounded-full text-xs text-white px-1.5">
|
||||
{getNumberCount(totalNotificationCount)}
|
||||
</span>
|
||||
) : null}
|
||||
</Popover.Button>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-200"
|
||||
enterFrom="opacity-0 translate-y-1"
|
||||
enterTo="opacity-100 translate-y-0"
|
||||
leave="transition ease-in duration-150"
|
||||
leaveFrom="opacity-100 translate-y-0"
|
||||
leaveTo="opacity-0 translate-y-1"
|
||||
>
|
||||
<Popover.Panel className="absolute bg-custom-background-100 flex flex-col left-0 md:left-full ml-8 z-10 top-0 md:w-[36rem] w-[20rem] h-[50vh] border border-custom-border-300 shadow-lg rounded-xl">
|
||||
<div className="flex items-center justify-between px-5 pt-5">
|
||||
<h2 className="text-xl font-semibold mb-2">Notifications</h2>
|
||||
<div className="flex gap-x-4 justify-center items-center text-custom-text-200">
|
||||
<Tooltip tooltipContent="Refresh">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
notificationsMutate();
|
||||
|
||||
const target = e.target as HTMLButtonElement;
|
||||
target?.classList.add("animate-spin");
|
||||
setTimeout(() => {
|
||||
target?.classList.remove("animate-spin");
|
||||
}, 1000);
|
||||
}}
|
||||
>
|
||||
<Icon iconName="refresh" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip tooltipContent="Unread notifications">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSnoozed(false);
|
||||
setArchived(false);
|
||||
setReadNotification((prev) => !prev);
|
||||
}}
|
||||
>
|
||||
<Icon iconName="filter_list" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip tooltipContent="Snoozed notifications">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setArchived(false);
|
||||
setReadNotification(false);
|
||||
setSnoozed((prev) => !prev);
|
||||
}}
|
||||
>
|
||||
<Icon iconName="schedule" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip tooltipContent="Archived notifications">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSnoozed(false);
|
||||
setReadNotification(false);
|
||||
setArchived((prev) => !prev);
|
||||
}}
|
||||
>
|
||||
<Icon iconName="archive" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<button type="button" onClick={() => closePopover()}>
|
||||
<Icon iconName="close" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-b border-custom-border-300 w-full px-5 mt-5">
|
||||
{snoozed || archived || readNotification ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSnoozed(false);
|
||||
setArchived(false);
|
||||
setReadNotification(false);
|
||||
}}
|
||||
>
|
||||
<h4 className="flex items-center gap-2 pb-4">
|
||||
<Icon iconName="arrow_back" />
|
||||
<span className="ml-2 font-medium">
|
||||
{snoozed
|
||||
? "Snoozed Notifications"
|
||||
: readNotification
|
||||
? "Unread Notifications"
|
||||
: "Archived Notifications"}
|
||||
</span>
|
||||
</h4>
|
||||
</button>
|
||||
) : (
|
||||
<nav className="flex space-x-5 overflow-x-auto" aria-label="Tabs">
|
||||
{notificationTabs.map((tab) =>
|
||||
tab.value === "created" ? (
|
||||
isMember || isOwner ? (
|
||||
<button
|
||||
type="button"
|
||||
key={tab.value}
|
||||
onClick={() => setSelectedTab(tab.value)}
|
||||
className={`whitespace-nowrap border-b-2 pb-4 px-1 text-sm font-medium outline-none ${
|
||||
tab.value === selectedTab
|
||||
? "border-custom-primary-100 text-custom-primary-100"
|
||||
: "border-transparent text-custom-text-200"
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
{tab.unreadCount && tab.unreadCount > 0 ? (
|
||||
<span
|
||||
className={`ml-2 rounded-full text-xs px-2 py-0.5 ${
|
||||
tab.value === selectedTab
|
||||
? "bg-custom-primary-100 text-white"
|
||||
: "bg-custom-background-80 text-custom-text-200"
|
||||
}`}
|
||||
>
|
||||
{getNumberCount(tab.unreadCount)}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
) : null
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
key={tab.value}
|
||||
onClick={() => setSelectedTab(tab.value)}
|
||||
className={`whitespace-nowrap border-b-2 pb-4 px-1 text-sm font-medium ${
|
||||
tab.value === selectedTab
|
||||
? "border-custom-primary-100 text-custom-primary-100"
|
||||
: "border-transparent text-custom-text-200"
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
{tab.unreadCount && tab.unreadCount > 0 ? (
|
||||
<span
|
||||
className={`ml-2 rounded-full text-xs px-2 py-0.5 ${
|
||||
tab.value === selectedTab
|
||||
? "bg-custom-primary-100 text-white"
|
||||
: "bg-custom-background-80 text-custom-text-200"
|
||||
}`}
|
||||
>
|
||||
{getNumberCount(tab.unreadCount)}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</nav>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{notifications ? (
|
||||
notifications.length > 0 ? (
|
||||
<div className="divide-y divide-custom-border-100 overflow-y-auto h-full">
|
||||
{notifications.map((notification) => (
|
||||
<NotificationCard
|
||||
key={notification.id}
|
||||
notification={notification}
|
||||
markNotificationArchivedStatus={markNotificationArchivedStatus}
|
||||
markNotificationReadStatus={markNotificationReadStatus}
|
||||
setSelectedNotificationForSnooze={setSelectedNotificationForSnooze}
|
||||
markSnoozeNotification={markSnoozeNotification}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid h-full w-full place-items-center overflow-hidden scale-75">
|
||||
<EmptyState
|
||||
title="You're updated with all the notifications"
|
||||
description="You have read all the notifications."
|
||||
image={emptyNotification}
|
||||
isFullScreen={false}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<Loader className="p-5 space-y-4 overflow-y-auto">
|
||||
<Loader.Item height="50px" />
|
||||
<Loader.Item height="50px" />
|
||||
<Loader.Item height="50px" />
|
||||
<Loader.Item height="50px" />
|
||||
<Loader.Item height="50px" />
|
||||
</Loader>
|
||||
)}
|
||||
</Popover.Panel>
|
||||
</Transition>
|
||||
</>
|
||||
)}
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,288 @@
|
||||
import React, { Fragment } from "react";
|
||||
|
||||
// next
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
// react hook form
|
||||
import { useForm, Controller } from "react-hook-form";
|
||||
|
||||
import { Transition, Dialog, Listbox } from "@headlessui/react";
|
||||
|
||||
// date helper
|
||||
import { getAllTimeIn30MinutesInterval } from "helpers/date-time.helper";
|
||||
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
|
||||
// components
|
||||
import {
|
||||
PrimaryButton,
|
||||
SecondaryButton,
|
||||
Icon,
|
||||
CustomDatePicker,
|
||||
CustomSelect,
|
||||
} from "components/ui";
|
||||
|
||||
// types
|
||||
import type { IUserNotification } from "types";
|
||||
|
||||
type SnoozeModalProps = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
notification: IUserNotification | null;
|
||||
onSubmit: (notificationId: string, dateTime?: Date | undefined) => Promise<void>;
|
||||
};
|
||||
|
||||
type FormValues = {
|
||||
time: string | null;
|
||||
date: Date | null;
|
||||
period: "AM" | "PM";
|
||||
};
|
||||
|
||||
const defaultValues: FormValues = {
|
||||
time: null,
|
||||
date: null,
|
||||
period: "AM",
|
||||
};
|
||||
|
||||
const timeStamps = getAllTimeIn30MinutesInterval();
|
||||
|
||||
export const SnoozeNotificationModal: React.FC<SnoozeModalProps> = (props) => {
|
||||
const { isOpen, onClose, notification, onSuccess, onSubmit: handleSubmitSnooze } = props;
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug } = router.query;
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const {
|
||||
formState: { isSubmitting },
|
||||
reset,
|
||||
handleSubmit,
|
||||
control,
|
||||
watch,
|
||||
setValue,
|
||||
} = useForm({
|
||||
defaultValues,
|
||||
});
|
||||
|
||||
const getTimeStamp = () => {
|
||||
const today = new Date();
|
||||
const formDataDate = watch("date");
|
||||
|
||||
if (!formDataDate) return timeStamps;
|
||||
|
||||
const isToday = today.toDateString() === new Date(formDataDate).toDateString();
|
||||
|
||||
if (!isToday) return timeStamps;
|
||||
|
||||
const hours = today.getHours();
|
||||
const minutes = today.getMinutes();
|
||||
|
||||
return timeStamps.filter((optionTime) => {
|
||||
let optionHours = parseInt(optionTime.value.split(":")[0]);
|
||||
const optionMinutes = parseInt(optionTime.value.split(":")[1]);
|
||||
|
||||
const period = watch("period");
|
||||
|
||||
if (period === "PM" && optionHours !== 12) optionHours += 12;
|
||||
|
||||
if (optionHours < hours) return false;
|
||||
if (optionHours === hours && optionMinutes < minutes) return false;
|
||||
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
const onSubmit = async (formData: FormValues) => {
|
||||
if (!workspaceSlug || !notification || !formData.date || !formData.time) return;
|
||||
|
||||
const period = formData.period;
|
||||
|
||||
const time = formData.time.split(":");
|
||||
const hours = parseInt(
|
||||
`${period === "AM" ? time[0] : parseInt(time[0]) + 12 === 24 ? "00" : parseInt(time[0]) + 12}`
|
||||
);
|
||||
const minutes = parseInt(time[1]);
|
||||
|
||||
const dateTime = new Date(formData.date);
|
||||
dateTime.setHours(hours);
|
||||
dateTime.setMinutes(minutes);
|
||||
|
||||
await handleSubmitSnooze(notification.id, dateTime).then(() => {
|
||||
handleClose();
|
||||
onSuccess();
|
||||
setToastAlert({
|
||||
title: "Notification snoozed",
|
||||
message: "Notification snoozed successfully",
|
||||
type: "success",
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
onClose();
|
||||
const timeout = setTimeout(() => {
|
||||
reset({ ...defaultValues });
|
||||
clearTimeout(timeout);
|
||||
}, 500);
|
||||
};
|
||||
|
||||
return (
|
||||
<Transition.Root show={isOpen} as={React.Fragment}>
|
||||
<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-custom-backdrop bg-opacity-50 transition-opacity" />
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0 z-20 overflow-y-auto">
|
||||
<div className="flex items-center justify-center min-h-full p-4 text-center">
|
||||
<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 rounded-lg border border-custom-border-100 bg-custom-background-100 p-5 text-left shadow-xl transition-all sm:w-full sm:max-w-2xl">
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="flex justify-between items-center">
|
||||
<Dialog.Title
|
||||
as="h3"
|
||||
className="text-lg font-medium leading-6 text-custom-text-100"
|
||||
>
|
||||
Customize Snooze Time
|
||||
</Dialog.Title>
|
||||
|
||||
<div>
|
||||
<button type="button" onClick={handleClose}>
|
||||
<Icon iconName="close" className="w-5 h-5 text-custom-text-100" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex items-center gap-3">
|
||||
<div className="flex-1">
|
||||
<h6 className="block text-sm font-medium text-custom-text-400 mb-2">
|
||||
Pick a date
|
||||
</h6>
|
||||
<Controller
|
||||
name="date"
|
||||
control={control}
|
||||
rules={{ required: "Please select a date" }}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<CustomDatePicker
|
||||
placeholder="Select date"
|
||||
value={value}
|
||||
onChange={(val) => {
|
||||
setValue("time", null);
|
||||
onChange(val);
|
||||
}}
|
||||
className="px-3 py-2 w-full rounded-md border border-custom-border-300 bg-custom-background-100 text-custom-text-100 focus:outline-none !text-sm"
|
||||
noBorder
|
||||
minDate={new Date()}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h6 className="block text-sm font-medium text-custom-text-400 mb-2">
|
||||
Pick a time
|
||||
</h6>
|
||||
<Controller
|
||||
control={control}
|
||||
name="time"
|
||||
rules={{ required: "Please select a time" }}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<CustomSelect
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
label={
|
||||
<div className="truncate">
|
||||
{value ? (
|
||||
<span>
|
||||
{value} {watch("period").toLowerCase()}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-custom-text-400 text-sm">
|
||||
Select a time
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
width="w-full"
|
||||
input
|
||||
>
|
||||
<div className="w-full rounded overflow-hidden h-9 mb-2 flex">
|
||||
<div
|
||||
onClick={() => {
|
||||
setValue("period", "AM");
|
||||
}}
|
||||
className={`w-1/2 h-full cursor-pointer flex justify-center items-center text-center ${
|
||||
watch("period") === "AM"
|
||||
? "bg-custom-primary-100/90 text-custom-primary-0"
|
||||
: "bg-custom-background-80"
|
||||
}`}
|
||||
>
|
||||
AM
|
||||
</div>
|
||||
<div
|
||||
onClick={() => {
|
||||
setValue("period", "PM");
|
||||
}}
|
||||
className={`w-1/2 h-full cursor-pointer flex justify-center items-center text-center ${
|
||||
watch("period") === "PM"
|
||||
? "bg-custom-primary-100/90 text-custom-primary-0"
|
||||
: "bg-custom-background-80"
|
||||
}`}
|
||||
>
|
||||
PM
|
||||
</div>
|
||||
</div>
|
||||
{getTimeStamp().length > 0 ? (
|
||||
getTimeStamp().map((time, index) => (
|
||||
<CustomSelect.Option key={`${time}-${index}`} value={time.value}>
|
||||
<div className="flex items-center">
|
||||
<span className="ml-3 block truncate">{time.label}</span>
|
||||
</div>
|
||||
</CustomSelect.Option>
|
||||
))
|
||||
) : (
|
||||
<p className="text-custom-text-200 text-center p-3">
|
||||
No available time for this date.
|
||||
</p>
|
||||
)}
|
||||
</CustomSelect>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex items-center justify-between gap-2">
|
||||
<div className="w-full flex items-center gap-2 justify-end">
|
||||
<SecondaryButton onClick={handleClose}>Cancel</SecondaryButton>
|
||||
<PrimaryButton type="submit" loading={isSubmitting}>
|
||||
{isSubmitting ? "Submitting..." : "Submit"}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition.Root>
|
||||
);
|
||||
};
|
||||
@@ -41,7 +41,7 @@ export const InviteMembers: React.FC<Props> = ({ workspace, user, stepChange })
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
formState: { isSubmitting, errors },
|
||||
formState: { isSubmitting, errors, isValid },
|
||||
} = useForm<FormValues>();
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
@@ -204,10 +204,10 @@ export const InviteMembers: React.FC<Props> = ({ workspace, user, stepChange })
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<PrimaryButton type="submit" loading={isSubmitting} size="md">
|
||||
<PrimaryButton type="submit" disabled={!isValid} loading={isSubmitting} size="md">
|
||||
{isSubmitting ? "Sending..." : "Send Invite"}
|
||||
</PrimaryButton>
|
||||
<SecondaryButton size="md" onClick={nextStep} outline>
|
||||
<SecondaryButton className="border border-none bg-transparent" size="md" onClick={nextStep}>
|
||||
Skip this step
|
||||
</SecondaryButton>
|
||||
</div>
|
||||
|
||||
@@ -88,7 +88,7 @@ export const JoinWorkspaces: React.FC<Props> = ({ stepChange }) => {
|
||||
<div className="w-full space-y-7 sm:space-y-10">
|
||||
<h5 className="sm:text-lg">We see that someone has invited you to</h5>
|
||||
<h4 className="text-xl sm:text-2xl font-semibold">Join a workspace</h4>
|
||||
<div className="md:w-3/5 space-y-4">
|
||||
<div className="max-h-[37vh] overflow-y-auto md:w-3/5 space-y-4">
|
||||
{invitations &&
|
||||
invitations.map((invitation) => {
|
||||
const isSelected = invitationsRespond.includes(invitation.id);
|
||||
@@ -146,7 +146,11 @@ export const JoinWorkspaces: React.FC<Props> = ({ stepChange }) => {
|
||||
>
|
||||
Accept & Join
|
||||
</PrimaryButton>
|
||||
<SecondaryButton size="md" onClick={finishOnboarding} outline>
|
||||
<SecondaryButton
|
||||
className="border border-none bg-transparent"
|
||||
size="md"
|
||||
onClick={finishOnboarding}
|
||||
>
|
||||
Skip for now
|
||||
</SecondaryButton>
|
||||
</div>
|
||||
|
||||
@@ -53,7 +53,7 @@ const TOUR_STEPS: {
|
||||
key: "modules",
|
||||
title: "Break into modules",
|
||||
description:
|
||||
"Modules break your big think into Projects or Features, to help you organize better.",
|
||||
"Modules break your big thing into Projects or Features, to help you organize better.",
|
||||
image: ModulesTour,
|
||||
prevStep: "cycles",
|
||||
nextStep: "views",
|
||||
@@ -93,7 +93,7 @@ export const TourRoot: React.FC<Props> = ({ onComplete }) => {
|
||||
<Image src={PlaneWhiteLogo} alt="Plane White Logo" />
|
||||
</div>
|
||||
<div className="h-2/5 overflow-y-auto p-6">
|
||||
<h3 className="font-medium text-lg">
|
||||
<h3 className="font-semibold sm:text-xl">
|
||||
Welcome to Plane, {user?.first_name} {user?.last_name}
|
||||
</h3>
|
||||
<p className="text-custom-text-200 text-sm mt-3">
|
||||
@@ -132,7 +132,7 @@ export const TourRoot: React.FC<Props> = ({ onComplete }) => {
|
||||
<Image src={currentStep?.image} alt={currentStep?.title} />
|
||||
</div>
|
||||
<div className="flex flex-col h-1/2 sm:h-2/5 p-4 overflow-y-auto">
|
||||
<h3 className="font-medium text-lg">{currentStep?.title}</h3>
|
||||
<h3 className="font-semibold sm:text-xl">{currentStep?.title}</h3>
|
||||
<p className="text-custom-text-200 text-sm mt-3">{currentStep?.description}</p>
|
||||
<div className="h-full flex items-end justify-between gap-4 mt-3">
|
||||
<div className="flex items-center gap-4">
|
||||
|
||||
@@ -1,32 +1,37 @@
|
||||
// icons
|
||||
import { ContrastIcon, LayerDiagonalIcon, PeopleGroupIcon, ViewListIcon } from "components/icons";
|
||||
import { DocumentTextIcon } from "@heroicons/react/24/outline";
|
||||
import {
|
||||
ArticleOutlined,
|
||||
ContrastOutlined,
|
||||
DatasetOutlined,
|
||||
FilterNoneOutlined,
|
||||
PhotoFilterOutlined,
|
||||
} from "@mui/icons-material";
|
||||
// types
|
||||
import { TTourSteps } from "./root";
|
||||
|
||||
const sidebarOptions: {
|
||||
key: TTourSteps;
|
||||
icon: any;
|
||||
Icon: any;
|
||||
}[] = [
|
||||
{
|
||||
key: "issues",
|
||||
icon: LayerDiagonalIcon,
|
||||
Icon: FilterNoneOutlined,
|
||||
},
|
||||
{
|
||||
key: "cycles",
|
||||
icon: ContrastIcon,
|
||||
Icon: ContrastOutlined,
|
||||
},
|
||||
{
|
||||
key: "modules",
|
||||
icon: PeopleGroupIcon,
|
||||
Icon: DatasetOutlined,
|
||||
},
|
||||
{
|
||||
key: "views",
|
||||
icon: ViewListIcon,
|
||||
Icon: PhotoFilterOutlined,
|
||||
},
|
||||
{
|
||||
key: "pages",
|
||||
icon: DocumentTextIcon,
|
||||
Icon: ArticleOutlined,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -53,13 +58,10 @@ export const TourSidebar: React.FC<Props> = ({ step, setStep }) => (
|
||||
}`}
|
||||
onClick={() => setStep(option.key)}
|
||||
>
|
||||
<option.icon
|
||||
className={`h-5 w-5 flex-shrink-0 ${
|
||||
step === option.key ? "text-custom-primary-100" : "text-custom-text-200"
|
||||
}`}
|
||||
color={`${
|
||||
step === option.key ? "rgb(var(--color-primary-100))" : "rgb(var(--color-text-200))"
|
||||
}`}
|
||||
<option.Icon
|
||||
sx={{
|
||||
fontSize: 18,
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{option.key}
|
||||
|
||||
@@ -35,7 +35,7 @@ export const UserDetails: React.FC<Props> = ({ user }) => {
|
||||
handleSubmit,
|
||||
control,
|
||||
reset,
|
||||
formState: { errors, isSubmitting },
|
||||
formState: { errors, isSubmitting, isValid },
|
||||
} = useForm<IUser>({
|
||||
defaultValues,
|
||||
});
|
||||
@@ -164,7 +164,7 @@ export const UserDetails: React.FC<Props> = ({ user }) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PrimaryButton type="submit" size="md" disabled={isSubmitting}>
|
||||
<PrimaryButton type="submit" size="md" disabled={!isValid} loading={isSubmitting}>
|
||||
{isSubmitting ? "Updating..." : "Continue"}
|
||||
</PrimaryButton>
|
||||
</form>
|
||||
|
||||
@@ -38,6 +38,10 @@ export const Workspace: React.FC<Props> = ({ user, updateLastWorkspace, stepChan
|
||||
defaultValues={defaultValues}
|
||||
setDefaultValues={setDefaultValues}
|
||||
user={user}
|
||||
primaryButtonText={{
|
||||
loading: "Creating...",
|
||||
default: "Continue",
|
||||
}}
|
||||
secondaryButton={
|
||||
<SecondaryButton onClick={() => stepChange({ profile_complete: false })}>
|
||||
Back
|
||||
|
||||
@@ -175,7 +175,11 @@ export const DeleteProjectModal: React.FC<TConfirmProjectDeletionProps> = ({
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<SecondaryButton onClick={handleClose}>Cancel</SecondaryButton>
|
||||
<DangerButton onClick={handleDeletion} loading={isDeleteLoading || !canDelete}>
|
||||
<DangerButton
|
||||
onClick={handleDeletion}
|
||||
disabled={!canDelete}
|
||||
loading={isDeleteLoading}
|
||||
>
|
||||
{isDeleteLoading ? "Deleting..." : "Delete Project"}
|
||||
</DangerButton>
|
||||
</div>
|
||||
|
||||
@@ -10,9 +10,19 @@ import projectService from "services/project.service";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
// ui
|
||||
import { CustomMenu, Icon, Tooltip } from "components/ui";
|
||||
import { CustomMenu, Tooltip } from "components/ui";
|
||||
// icons
|
||||
import { LinkIcon, StarIcon, TrashIcon } from "@heroicons/react/24/outline";
|
||||
import {
|
||||
ArchiveOutlined,
|
||||
ArticleOutlined,
|
||||
ContrastOutlined,
|
||||
DatasetOutlined,
|
||||
ExpandMoreOutlined,
|
||||
FilterNoneOutlined,
|
||||
PhotoFilterOutlined,
|
||||
SettingsOutlined,
|
||||
} from "@mui/icons-material";
|
||||
// helpers
|
||||
import { truncateText } from "helpers/string.helper";
|
||||
import { renderEmoji } from "helpers/emoji.helper";
|
||||
@@ -33,32 +43,32 @@ const navigation = (workspaceSlug: string, projectId: string) => [
|
||||
{
|
||||
name: "Issues",
|
||||
href: `/${workspaceSlug}/projects/${projectId}/issues`,
|
||||
icon: "stack",
|
||||
Icon: FilterNoneOutlined,
|
||||
},
|
||||
{
|
||||
name: "Cycles",
|
||||
href: `/${workspaceSlug}/projects/${projectId}/cycles`,
|
||||
icon: "contrast",
|
||||
Icon: ContrastOutlined,
|
||||
},
|
||||
{
|
||||
name: "Modules",
|
||||
href: `/${workspaceSlug}/projects/${projectId}/modules`,
|
||||
icon: "dataset",
|
||||
Icon: DatasetOutlined,
|
||||
},
|
||||
{
|
||||
name: "Views",
|
||||
href: `/${workspaceSlug}/projects/${projectId}/views`,
|
||||
icon: "photo_filter",
|
||||
Icon: PhotoFilterOutlined,
|
||||
},
|
||||
{
|
||||
name: "Pages",
|
||||
href: `/${workspaceSlug}/projects/${projectId}/pages`,
|
||||
icon: "article",
|
||||
Icon: ArticleOutlined,
|
||||
},
|
||||
{
|
||||
name: "Settings",
|
||||
href: `/${workspaceSlug}/projects/${projectId}/settings`,
|
||||
icon: "settings",
|
||||
Icon: SettingsOutlined,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -164,8 +174,8 @@ export const SingleSidebarProject: React.FC<Props> = ({
|
||||
)}
|
||||
</div>
|
||||
{!sidebarCollapse && (
|
||||
<Icon
|
||||
iconName="expand_more"
|
||||
<ExpandMoreOutlined
|
||||
fontSize="small"
|
||||
className={`${open ? "rotate-180" : ""} text-custom-text-200 duration-300`}
|
||||
/>
|
||||
)}
|
||||
@@ -211,7 +221,7 @@ export const SingleSidebarProject: React.FC<Props> = ({
|
||||
}
|
||||
>
|
||||
<div className="flex items-center justify-start gap-2">
|
||||
<Icon iconName="archive" className="h-4 w-4" />
|
||||
<ArchiveOutlined fontSize="small" />
|
||||
<span>Archived Issues</span>
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
@@ -248,13 +258,17 @@ export const SingleSidebarProject: React.FC<Props> = ({
|
||||
disabled={!sidebarCollapse}
|
||||
>
|
||||
<div
|
||||
className={`group flex items-center rounded-md px-2 py-1.5 gap-2 text-xs font-medium outline-none ${
|
||||
className={`group flex items-center rounded-md px-2 py-1.5 gap-2.5 text-xs font-medium outline-none ${
|
||||
router.asPath.includes(item.href)
|
||||
? "bg-custom-primary-100/10 text-custom-primary-100"
|
||||
: "text-custom-sidebar-text-200 hover:bg-custom-sidebar-background-80 focus:bg-custom-sidebar-background-80"
|
||||
} ${sidebarCollapse ? "justify-center" : ""}`}
|
||||
>
|
||||
<Icon iconName={item.icon} />
|
||||
<item.Icon
|
||||
sx={{
|
||||
fontSize: 18,
|
||||
}}
|
||||
/>
|
||||
{!sidebarCollapse && item.name}
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
||||
@@ -19,11 +19,7 @@ export const PrimaryButton: React.FC<ButtonProps> = ({
|
||||
: size === "md"
|
||||
? "rounded-md px-3.5 py-2 text-sm"
|
||||
: "rounded-lg px-4 py-2 text-base"
|
||||
} ${
|
||||
disabled
|
||||
? "cursor-not-allowed bg-opacity-70 border-opacity-70 hover:bg-opacity-70 hover:border-opacity-70"
|
||||
: ""
|
||||
} ${
|
||||
} ${disabled ? "cursor-not-allowed opacity-70 hover:opacity-70" : ""} ${
|
||||
outline
|
||||
? "bg-transparent text-custom-primary hover:bg-custom-primary hover:text-white"
|
||||
: "text-white bg-custom-primary hover:border-opacity-90 hover:bg-opacity-90"
|
||||
|
||||
@@ -1,187 +0,0 @@
|
||||
import React from "react";
|
||||
// next
|
||||
import Link from "next/link";
|
||||
// headless ui
|
||||
import { Menu, Transition } from "@headlessui/react";
|
||||
// icons
|
||||
import { ChevronDownIcon } from "@heroicons/react/24/outline";
|
||||
import { Icon } from "./icon";
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
label?: string | JSX.Element;
|
||||
className?: string;
|
||||
ellipsis?: boolean;
|
||||
verticalEllipsis?: boolean;
|
||||
height?: "sm" | "md" | "rg" | "lg";
|
||||
width?: "sm" | "md" | "lg" | "xl" | "auto";
|
||||
textAlignment?: "left" | "center" | "right";
|
||||
noBorder?: boolean;
|
||||
noChevron?: boolean;
|
||||
position?: "left" | "right";
|
||||
verticalPosition?: "top" | "bottom";
|
||||
menuItemsClassName?: string;
|
||||
customButton?: JSX.Element;
|
||||
menuItemsWhiteBg?: boolean;
|
||||
};
|
||||
|
||||
type MenuItemProps = {
|
||||
children: JSX.Element | string;
|
||||
renderAs?: "button" | "a";
|
||||
href?: string;
|
||||
onClick?: (args?: any) => void;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const CustomMenu = ({
|
||||
children,
|
||||
label,
|
||||
className = "",
|
||||
ellipsis = false,
|
||||
verticalEllipsis = false,
|
||||
height = "md",
|
||||
width = "auto",
|
||||
textAlignment,
|
||||
noBorder = false,
|
||||
noChevron = false,
|
||||
position = "right",
|
||||
verticalPosition = "bottom",
|
||||
menuItemsClassName = "",
|
||||
customButton,
|
||||
menuItemsWhiteBg = false,
|
||||
}: Props) => (
|
||||
<Menu as="div" className={`relative w-min whitespace-nowrap text-left ${className}`}>
|
||||
{({ open }) => (
|
||||
<>
|
||||
{customButton ? (
|
||||
<Menu.Button as="div">{customButton}</Menu.Button>
|
||||
) : (
|
||||
<div>
|
||||
{ellipsis || verticalEllipsis ? (
|
||||
<Menu.Button
|
||||
type="button"
|
||||
className="relative grid place-items-center rounded p-1 text-custom-text-200 hover:bg-custom-background-80 outline-none"
|
||||
>
|
||||
<Icon
|
||||
iconName="more_horiz"
|
||||
className={`${verticalEllipsis ? "rotate-90" : ""} text-brand-secondary`}
|
||||
/>
|
||||
</Menu.Button>
|
||||
) : (
|
||||
<Menu.Button
|
||||
type="button"
|
||||
className={`flex cursor-pointer items-center justify-between gap-1 px-2.5 py-1 text-xs duration-300 hover:bg-custom-background-80 ${
|
||||
open ? "bg-custom-background-90 text-custom-text-100" : "text-custom-text-200"
|
||||
} ${
|
||||
textAlignment === "right"
|
||||
? "text-right"
|
||||
: textAlignment === "center"
|
||||
? "text-center"
|
||||
: "text-left"
|
||||
} ${
|
||||
noBorder
|
||||
? "rounded-md"
|
||||
: "rounded-md border border-custom-border-200 shadow-sm focus:outline-none"
|
||||
} ${
|
||||
width === "sm"
|
||||
? "w-10"
|
||||
: width === "md"
|
||||
? "w-20"
|
||||
: width === "lg"
|
||||
? "w-32"
|
||||
: width === "xl"
|
||||
? "w-48"
|
||||
: "w-full"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
{!noChevron && <ChevronDownIcon className="h-3 w-3" aria-hidden="true" />}
|
||||
</Menu.Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Transition
|
||||
as={React.Fragment}
|
||||
enter="transition ease-out duration-100"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<Menu.Items
|
||||
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"
|
||||
? "max-h-28"
|
||||
: height === "md"
|
||||
? "max-h-44"
|
||||
: height === "rg"
|
||||
? "max-h-56"
|
||||
: height === "lg"
|
||||
? "max-h-80"
|
||||
: ""
|
||||
} ${
|
||||
width === "sm"
|
||||
? "w-10"
|
||||
: width === "md"
|
||||
? "w-20"
|
||||
: width === "lg"
|
||||
? "w-32"
|
||||
: width === "xl"
|
||||
? "w-48"
|
||||
: "min-w-full"
|
||||
} ${
|
||||
menuItemsWhiteBg
|
||||
? "border-custom-border-200 bg-custom-background-100"
|
||||
: "border-custom-border-200 bg-custom-background-90"
|
||||
} ${menuItemsClassName}`}
|
||||
>
|
||||
<div className="py-1">{children}</div>
|
||||
</Menu.Items>
|
||||
</Transition>
|
||||
</>
|
||||
)}
|
||||
</Menu>
|
||||
);
|
||||
|
||||
const MenuItem: React.FC<MenuItemProps> = ({
|
||||
children,
|
||||
renderAs,
|
||||
href,
|
||||
onClick,
|
||||
className = "",
|
||||
}) => (
|
||||
<Menu.Item as="div">
|
||||
{({ active, close }) =>
|
||||
renderAs === "a" ? (
|
||||
<Link href={href ?? ""}>
|
||||
<a
|
||||
className={`${className} ${
|
||||
active ? "bg-custom-background-80" : ""
|
||||
} hover:text-custom-text-200 inline-block w-full select-none gap-2 truncate rounded px-1 py-1.5 text-left text-custom-text-200 hover:bg-custom-background-80`}
|
||||
onClick={close}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
</Link>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className={`${className} ${
|
||||
active ? "bg-custom-background-80" : ""
|
||||
} hover:text-custom-text-200 w-full select-none gap-2 truncate rounded px-1 py-1.5 text-left text-custom-text-200 hover:bg-custom-background-80`}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
</Menu.Item>
|
||||
);
|
||||
|
||||
CustomMenu.MenuItem = MenuItem;
|
||||
|
||||
export { CustomMenu };
|
||||
@@ -15,6 +15,7 @@ type Props = {
|
||||
className?: string;
|
||||
isClearable?: boolean;
|
||||
disabled?: boolean;
|
||||
minDate?: Date;
|
||||
};
|
||||
|
||||
export const CustomDatePicker: React.FC<Props> = ({
|
||||
@@ -28,6 +29,7 @@ export const CustomDatePicker: React.FC<Props> = ({
|
||||
className = "",
|
||||
isClearable = true,
|
||||
disabled = false,
|
||||
minDate,
|
||||
}) => (
|
||||
<DatePicker
|
||||
placeholderText={placeholder}
|
||||
@@ -52,5 +54,6 @@ export const CustomDatePicker: React.FC<Props> = ({
|
||||
dateFormat="MMM dd, yyyy"
|
||||
isClearable={isClearable}
|
||||
disabled={disabled}
|
||||
minDate={minDate}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -4,15 +4,17 @@ import Link from "next/link";
|
||||
|
||||
// headless ui
|
||||
import { Menu, Transition } from "@headlessui/react";
|
||||
// ui
|
||||
import { DropdownProps } from "components/ui";
|
||||
// icons
|
||||
import { DropdownProps, Icon } from "components/ui";
|
||||
import { ChevronDownIcon } from "@heroicons/react/24/outline";
|
||||
import { ExpandMoreOutlined, MoreHorizOutlined } from "@mui/icons-material";
|
||||
|
||||
export type CustomMenuProps = DropdownProps & {
|
||||
children: React.ReactNode;
|
||||
ellipsis?: boolean;
|
||||
noBorder?: boolean;
|
||||
verticalEllipsis?: boolean;
|
||||
menuButtonOnClick?: (...args: any) => void;
|
||||
};
|
||||
|
||||
const CustomMenu = ({
|
||||
@@ -32,34 +34,38 @@ const CustomMenu = ({
|
||||
verticalEllipsis = false,
|
||||
verticalPosition = "bottom",
|
||||
width = "auto",
|
||||
menuButtonOnClick,
|
||||
}: CustomMenuProps) => (
|
||||
<Menu as="div" className={`${selfPositioned ? "" : "relative"} w-min text-left ${className}`}>
|
||||
{({ open }) => (
|
||||
<>
|
||||
{customButton ? (
|
||||
<Menu.Button as="div">{customButton}</Menu.Button>
|
||||
<Menu.Button as="button" onClick={menuButtonOnClick}>
|
||||
{customButton}
|
||||
</Menu.Button>
|
||||
) : (
|
||||
<>
|
||||
{ellipsis || verticalEllipsis ? (
|
||||
<Menu.Button
|
||||
type="button"
|
||||
onClick={menuButtonOnClick}
|
||||
disabled={disabled}
|
||||
className={`relative grid place-items-center rounded p-1 text-custom-text-200 outline-none ${
|
||||
disabled ? "cursor-not-allowed" : "cursor-pointer hover:bg-custom-background-80"
|
||||
} ${buttonClassName}`}
|
||||
>
|
||||
<Icon
|
||||
iconName="more_horiz"
|
||||
<MoreHorizOutlined
|
||||
fontSize="small"
|
||||
className={`${verticalEllipsis ? "rotate-90" : ""} text-custom-text-200`}
|
||||
/>
|
||||
</Menu.Button>
|
||||
) : (
|
||||
<Menu.Button
|
||||
type="button"
|
||||
className={`flex items-center justify-between gap-1 rounded-md px-2.5 py-1 text-xs duration-300 ${
|
||||
className={`flex items-center justify-between gap-1 rounded-md px-2.5 py-1 text-xs whitespace-nowrap duration-300 ${
|
||||
open ? "bg-custom-background-90 text-custom-text-100" : "text-custom-text-200"
|
||||
} ${
|
||||
noBorder ? "" : "border border-custom-border-100 shadow-sm focus:outline-none"
|
||||
noBorder ? "" : "border border-custom-border-300 shadow-sm focus:outline-none"
|
||||
} ${
|
||||
disabled
|
||||
? "cursor-not-allowed text-custom-text-200"
|
||||
@@ -67,7 +73,14 @@ const CustomMenu = ({
|
||||
} ${buttonClassName}`}
|
||||
>
|
||||
{label}
|
||||
{!noChevron && <ChevronDownIcon className="h-3 w-3" aria-hidden="true" />}
|
||||
{!noChevron && (
|
||||
<ExpandMoreOutlined
|
||||
sx={{
|
||||
fontSize: 14,
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</Menu.Button>
|
||||
)}
|
||||
</>
|
||||
@@ -83,7 +96,7 @@ const CustomMenu = ({
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<Menu.Items
|
||||
className={`absolute z-10 overflow-y-scroll whitespace-nowrap rounded-md border border-custom-border-100 p-1 text-xs shadow-lg focus:outline-none bg-custom-background-90 ${
|
||||
className={`absolute z-10 overflow-y-scroll whitespace-nowrap rounded-md border border-custom-border-300 p-1 text-xs shadow-lg focus:outline-none bg-custom-background-90 ${
|
||||
position === "left" ? "left-0 origin-top-left" : "right-0 origin-top-right"
|
||||
} ${verticalPosition === "top" ? "bottom-full mb-1" : "mt-1"} ${
|
||||
maxHeight === "lg"
|
||||
@@ -106,7 +119,7 @@ const CustomMenu = ({
|
||||
);
|
||||
|
||||
type MenuItemProps = {
|
||||
children: JSX.Element | string;
|
||||
children: React.ReactNode;
|
||||
renderAs?: "button" | "a";
|
||||
href?: string;
|
||||
onClick?: (args?: any) => void;
|
||||
|
||||
@@ -69,7 +69,7 @@ export const CustomSearchSelect = ({
|
||||
<Combobox.Button as="div">{customButton}</Combobox.Button>
|
||||
) : (
|
||||
<Combobox.Button
|
||||
className={`flex items-center justify-between gap-1 w-full rounded-md shadow-sm border border-custom-border-100 duration-300 focus:outline-none ${
|
||||
className={`flex items-center justify-between gap-1 w-full rounded-md shadow-sm border border-custom-border-300 duration-300 focus:outline-none ${
|
||||
input ? "px-3 py-2 text-sm" : "px-2.5 py-1 text-xs"
|
||||
} ${
|
||||
disabled
|
||||
@@ -94,7 +94,7 @@ export const CustomSearchSelect = ({
|
||||
leaveTo="opacity-0 translate-y-1"
|
||||
>
|
||||
<Combobox.Options
|
||||
className={`absolute z-10 min-w-[10rem] border border-custom-border-100 p-2 rounded-md bg-custom-background-90 text-xs shadow-lg focus:outline-none ${
|
||||
className={`absolute z-10 min-w-[10rem] border border-custom-border-300 p-2 rounded-md bg-custom-background-90 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"} ${
|
||||
width === "auto" ? "min-w-[8rem] whitespace-nowrap" : width
|
||||
|
||||
@@ -44,7 +44,7 @@ const CustomSelect = ({
|
||||
<Listbox.Button as="div">{customButton}</Listbox.Button>
|
||||
) : (
|
||||
<Listbox.Button
|
||||
className={`flex items-center justify-between gap-1 w-full rounded-md border border-custom-border-100 shadow-sm duration-300 focus:outline-none ${
|
||||
className={`flex items-center justify-between gap-1 w-full rounded-md border border-custom-border-300 shadow-sm duration-300 focus:outline-none ${
|
||||
input ? "px-3 py-2 text-sm" : "px-2.5 py-1 text-xs"
|
||||
} ${
|
||||
disabled
|
||||
@@ -68,7 +68,7 @@ const CustomSelect = ({
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<Listbox.Options
|
||||
className={`absolute z-10 border border-custom-border-100 mt-1 origin-top-right overflow-y-auto rounded-md bg-custom-background-90 text-xs shadow-lg focus:outline-none ${
|
||||
className={`absolute z-10 border border-custom-border-300 mt-1 origin-top-right overflow-y-auto rounded-md bg-custom-background-90 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"} ${
|
||||
maxHeight === "lg"
|
||||
|
||||
@@ -9,7 +9,7 @@ type Props = {
|
||||
title: string;
|
||||
description: React.ReactNode | string;
|
||||
image: any;
|
||||
buttonText: string;
|
||||
buttonText?: string;
|
||||
buttonIcon?: any;
|
||||
onClick?: () => void;
|
||||
isFullScreen?: boolean;
|
||||
@@ -33,10 +33,12 @@ export const EmptyState: React.FC<Props> = ({
|
||||
<Image src={image} className="w-52 sm:w-60" alt={buttonText} />
|
||||
<h6 className="text-xl font-semibold mt-6 sm:mt-8 mb-3">{title}</h6>
|
||||
<p className="text-custom-text-300 mb-7 sm:mb-8">{description}</p>
|
||||
<PrimaryButton className="flex items-center gap-1.5" onClick={onClick}>
|
||||
{buttonIcon}
|
||||
{buttonText}
|
||||
</PrimaryButton>
|
||||
{buttonText && (
|
||||
<PrimaryButton className="flex items-center gap-1.5" onClick={onClick}>
|
||||
{buttonIcon}
|
||||
{buttonText}
|
||||
</PrimaryButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
+2991
File diff suppressed because it is too large
Load Diff
@@ -35,7 +35,7 @@ export const ProductUpdatesModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0 z-20 overflow-y-auto">
|
||||
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
|
||||
<div className="grid place-items-center min-h-full text-center p-4">
|
||||
<Transition.Child
|
||||
as={React.Fragment}
|
||||
enter="ease-out duration-300"
|
||||
@@ -45,8 +45,8 @@ export const ProductUpdatesModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
|
||||
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 bg-custom-background-80 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg">
|
||||
<div className="max-h-[600px] overflow-y-auto bg-custom-background-80 p-5">
|
||||
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-custom-background-100 border border-custom-border-100 text-left shadow-xl transition-all grid place-items-center sm:w-full sm:max-w-2xl">
|
||||
<div className="max-h-[90vh] overflow-y-auto p-5">
|
||||
<div className="sm:flex sm:items-start">
|
||||
<div className="flex w-full flex-col gap-y-4 text-center sm:text-left">
|
||||
<Dialog.Title
|
||||
|
||||
@@ -9,7 +9,7 @@ import workspaceService from "services/workspace.service";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
// ui
|
||||
import { CustomSelect, Input, PrimaryButton, SecondaryButton } from "components/ui";
|
||||
import { CustomSelect, Input, PrimaryButton } from "components/ui";
|
||||
// types
|
||||
import { ICurrentUserResponse, IWorkspace } from "types";
|
||||
// fetch-keys
|
||||
@@ -27,6 +27,10 @@ type Props = {
|
||||
setDefaultValues: Dispatch<SetStateAction<any>>;
|
||||
user: ICurrentUserResponse | undefined;
|
||||
secondaryButton?: React.ReactNode;
|
||||
primaryButtonText?: {
|
||||
loading: string;
|
||||
default: string;
|
||||
};
|
||||
};
|
||||
|
||||
const restrictedUrls = [
|
||||
@@ -49,6 +53,10 @@ export const CreateWorkspaceForm: React.FC<Props> = ({
|
||||
setDefaultValues,
|
||||
user,
|
||||
secondaryButton,
|
||||
primaryButtonText = {
|
||||
loading: "Creating...",
|
||||
default: "Create Workspace",
|
||||
},
|
||||
}) => {
|
||||
const [slugError, setSlugError] = useState(false);
|
||||
const [invalidSlug, setInvalidSlug] = useState(false);
|
||||
@@ -61,8 +69,8 @@ export const CreateWorkspaceForm: React.FC<Props> = ({
|
||||
control,
|
||||
setValue,
|
||||
getValues,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<IWorkspace>({ defaultValues });
|
||||
formState: { errors, isSubmitting, isValid },
|
||||
} = useForm<IWorkspace>({ defaultValues, mode: "onChange" });
|
||||
|
||||
const handleCreateWorkspace = async (formData: IWorkspace) => {
|
||||
await workspaceService
|
||||
@@ -202,8 +210,8 @@ export const CreateWorkspaceForm: React.FC<Props> = ({
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
{secondaryButton}
|
||||
<PrimaryButton type="submit" size="md" disabled={isSubmitting}>
|
||||
{isSubmitting ? "Creating..." : "Create Workspace"}
|
||||
<PrimaryButton type="submit" size="md" disabled={!isValid} loading={isSubmitting}>
|
||||
{isSubmitting ? primaryButtonText.loading : primaryButtonText.default}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -1,34 +1,16 @@
|
||||
import React, { useRef, useState } from "react";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import Link from "next/link";
|
||||
|
||||
import useSWR from "swr";
|
||||
|
||||
// headless ui
|
||||
import { Transition } from "@headlessui/react";
|
||||
// services
|
||||
import workspaceService from "services/workspace.service";
|
||||
// hooks
|
||||
import useTheme from "hooks/use-theme";
|
||||
import useUser from "hooks/use-user";
|
||||
import useOutsideClickDetector from "hooks/use-outside-click-detector";
|
||||
// components
|
||||
import UpgradeToProModal from "./upgrade-to-pro-modal";
|
||||
// ui
|
||||
import { CircularProgress } from "components/ui";
|
||||
// icons
|
||||
import {
|
||||
ArrowLongLeftIcon,
|
||||
ChatBubbleOvalLeftEllipsisIcon,
|
||||
RocketLaunchIcon,
|
||||
ArrowUpCircleIcon,
|
||||
XMarkIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { QuestionMarkCircleIcon, DocumentIcon, DiscordIcon, GithubIcon } from "components/icons";
|
||||
// fetch-keys
|
||||
import { WORKSPACE_DETAILS } from "constants/fetch-keys";
|
||||
import { Bolt, HelpOutlineOutlined, WestOutlined } from "@mui/icons-material";
|
||||
import { ChatBubbleOvalLeftEllipsisIcon } from "@heroicons/react/24/outline";
|
||||
import { DocumentIcon, DiscordIcon, GithubIcon } from "components/icons";
|
||||
|
||||
const helpOptions = [
|
||||
{
|
||||
@@ -59,150 +41,74 @@ export interface WorkspaceHelpSectionProps {
|
||||
}
|
||||
|
||||
export const WorkspaceHelpSection: React.FC<WorkspaceHelpSectionProps> = ({ setSidebarActive }) => {
|
||||
const [alert, setAlert] = useState(false);
|
||||
const [isNeedHelpOpen, setIsNeedHelpOpen] = useState(false);
|
||||
|
||||
const helpOptionsRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug } = router.query;
|
||||
|
||||
const { collapsed: sidebarCollapse, toggleCollapsed } = useTheme();
|
||||
|
||||
useOutsideClickDetector(helpOptionsRef, () => setIsNeedHelpOpen(false));
|
||||
|
||||
const { user } = useUser();
|
||||
|
||||
const [upgradeModal, setUpgradeModal] = useState(false);
|
||||
|
||||
const { data: workspaceDetails } = useSWR(
|
||||
workspaceSlug ? WORKSPACE_DETAILS(workspaceSlug as string) : null,
|
||||
workspaceSlug ? () => workspaceService.getWorkspace(workspaceSlug as string) : null
|
||||
);
|
||||
|
||||
const issueNumber = workspaceDetails?.total_issues || 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<UpgradeToProModal
|
||||
isOpen={upgradeModal}
|
||||
onClose={() => setUpgradeModal(false)}
|
||||
user={user}
|
||||
issueNumber={issueNumber}
|
||||
/>
|
||||
{!sidebarCollapse && (alert || issueNumber >= 750) && (
|
||||
<div
|
||||
className={`border-t border-custom-sidebar-border-200 p-4 ${
|
||||
issueNumber >= 750
|
||||
? "bg-red-500/10 text-red-600"
|
||||
: issueNumber >= 500
|
||||
? "bg-yellow-500/10 text-yellow-600"
|
||||
: "text-green-600"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<CircularProgress progress={(issueNumber / 1024) * 100} />
|
||||
<div>Free Plan</div>
|
||||
{issueNumber < 750 && (
|
||||
<div
|
||||
className="ml-auto text-custom-text-200 cursor-pointer"
|
||||
onClick={() => setAlert(false)}
|
||||
>
|
||||
<XMarkIcon className="h-4 w-4" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-custom-text-200 text-xs mt-2">
|
||||
This workspace has used {issueNumber} of its 1024 issues creation limit (
|
||||
{((issueNumber / 1024) * 100).toFixed(0)}
|
||||
%).
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`flex w-full items-center justify-between gap-1 self-baseline border-t border-custom-border-200 bg-custom-sidebar-background-100 px-4 py-2 ${
|
||||
className={`flex w-full items-center justify-between gap-1 self-baseline border-t border-custom-border-200 bg-custom-sidebar-background-100 py-2 px-4 ${
|
||||
sidebarCollapse ? "flex-col" : ""
|
||||
}`}
|
||||
>
|
||||
{alert || issueNumber >= 750 ? (
|
||||
<button
|
||||
type="button"
|
||||
className={`flex items-center gap-2 rounded-md px-2.5 py-1.5 font-medium outline-none text-sm ${
|
||||
issueNumber >= 750
|
||||
? "bg-red-500/10 text-red-500"
|
||||
: "bg-blue-500/10 text-custom-primary-100"
|
||||
} ${sidebarCollapse ? "w-full justify-center" : ""}`}
|
||||
title="Shortcuts"
|
||||
onClick={() => setUpgradeModal(true)}
|
||||
>
|
||||
<ArrowUpCircleIcon className="h-4 w-4" />
|
||||
{!sidebarCollapse && <span>Learn more</span>}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className={`flex items-center gap-2 rounded-md px-2.5 py-1.5 font-medium outline-none text-sm ${
|
||||
issueNumber >= 750
|
||||
? "bg-red-500/10 text-red-600"
|
||||
: issueNumber >= 500
|
||||
? "bg-yellow-500/10 text-yellow-600"
|
||||
: "bg-green-500/10 text-green-600"
|
||||
}
|
||||
${sidebarCollapse ? "w-full justify-center" : ""}`}
|
||||
title="Shortcuts"
|
||||
onClick={() => setAlert(true)}
|
||||
>
|
||||
<CircularProgress
|
||||
progress={(issueNumber / 1024) * 100 > 100 ? 100 : (issueNumber / 1024) * 100}
|
||||
/>
|
||||
{!sidebarCollapse && <span>Free Plan</span>}
|
||||
</button>
|
||||
{!sidebarCollapse && (
|
||||
<div className="w-1/2 text-center rounded-md px-2.5 py-1.5 font-medium outline-none text-sm bg-green-500/10 text-green-500">
|
||||
Free Plan
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={`flex items-center gap-x-1 rounded-md px-2 py-2 text-xs font-medium text-custom-text-200 outline-none hover:bg-custom-background-90 hover:text-custom-text-100 ${
|
||||
sidebarCollapse ? "w-full justify-center" : ""
|
||||
<div
|
||||
className={`flex items-center gap-1 ${
|
||||
sidebarCollapse ? "flex-col justify-center" : "justify-evenly w-1/2"
|
||||
}`}
|
||||
onClick={() => {
|
||||
const e = new KeyboardEvent("keydown", {
|
||||
key: "h",
|
||||
});
|
||||
document.dispatchEvent(e);
|
||||
}}
|
||||
title="Shortcuts"
|
||||
>
|
||||
<RocketLaunchIcon className="h-4 w-4 text-custom-text-200" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`flex items-center gap-x-1 rounded-md px-2 py-2 text-xs font-medium text-custom-text-200 outline-none hover:bg-custom-background-90 hover:text-custom-text-100 ${
|
||||
sidebarCollapse ? "w-full justify-center" : ""
|
||||
}`}
|
||||
onClick={() => setIsNeedHelpOpen((prev) => !prev)}
|
||||
title="Help"
|
||||
>
|
||||
<QuestionMarkCircleIcon className="h-4 w-4 text-custom-text-200" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-3 rounded-md px-2 py-2 text-xs font-medium text-custom-text-200 outline-none hover:bg-custom-background-90 hover:text-custom-text-100 md:hidden"
|
||||
onClick={() => setSidebarActive(false)}
|
||||
>
|
||||
<ArrowLongLeftIcon className="h-4 w-4 flex-shrink-0 text-custom-text-200 group-hover:text-custom-text-100" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`hidden items-center gap-3 rounded-md px-2 py-2 text-xs font-medium text-custom-text-200 outline-none hover:bg-custom-background-90 hover:text-custom-text-100 md:flex ${
|
||||
sidebarCollapse ? "w-full justify-center" : ""
|
||||
}`}
|
||||
onClick={() => toggleCollapsed()}
|
||||
>
|
||||
<ArrowLongLeftIcon
|
||||
className={`h-4 w-4 flex-shrink-0 text-custom-text-200 duration-300 group-hover:text-custom-text-100 ${
|
||||
sidebarCollapse ? "rotate-180" : ""
|
||||
<button
|
||||
type="button"
|
||||
className={`rounded-md p-1.5 text-custom-text-200 hover:text-custom-text-100 hover:bg-custom-background-90 outline-none ${
|
||||
sidebarCollapse ? "w-full" : ""
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
onClick={() => {
|
||||
const e = new KeyboardEvent("keydown", {
|
||||
key: "h",
|
||||
});
|
||||
document.dispatchEvent(e);
|
||||
}}
|
||||
>
|
||||
<Bolt fontSize="small" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`rounded-md p-1.5 text-custom-text-200 hover:text-custom-text-100 hover:bg-custom-background-90 outline-none ${
|
||||
sidebarCollapse ? "w-full" : ""
|
||||
}`}
|
||||
onClick={() => setIsNeedHelpOpen((prev) => !prev)}
|
||||
>
|
||||
<HelpOutlineOutlined fontSize="small" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md p-1.5 text-custom-text-200 hover:text-custom-text-100 hover:bg-custom-background-90 outline-none md:hidden"
|
||||
onClick={() => setSidebarActive(false)}
|
||||
>
|
||||
<WestOutlined fontSize="small" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`hidden md:flex rounded-md p-1.5 text-custom-text-200 hover:text-custom-text-100 hover:bg-custom-background-90 outline-none ${
|
||||
sidebarCollapse ? "w-full" : ""
|
||||
}`}
|
||||
onClick={() => toggleCollapsed()}
|
||||
>
|
||||
<WestOutlined
|
||||
fontSize="small"
|
||||
className={`duration-300 ${sidebarCollapse ? "rotate-180" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<Transition
|
||||
|
||||
@@ -36,7 +36,7 @@ export const IssuesPieChart: React.FC<Props> = ({ groupedIssues }) => (
|
||||
activeInnerRadiusOffset={5}
|
||||
colors={(datum) => datum.data.color}
|
||||
tooltip={(datum) => (
|
||||
<div className="flex items-center gap-2 rounded-md border border-custom-border-200 bg-custom-background-80 p-2 text-xs">
|
||||
<div className="flex items-center gap-2 rounded-md border border-custom-border-200 bg-custom-background-90 p-2 text-xs">
|
||||
<span className="text-custom-text-200 capitalize">{datum.datum.label} issues:</span>{" "}
|
||||
{datum.datum.value}
|
||||
</div>
|
||||
@@ -59,7 +59,7 @@ export const IssuesPieChart: React.FC<Props> = ({ groupedIssues }) => (
|
||||
className="h-2 w-2"
|
||||
style={{ backgroundColor: STATE_GROUP_COLORS[cell.state_group] }}
|
||||
/>
|
||||
<div className="capitalize text-custom-text-200 text-xs">
|
||||
<div className="capitalize text-custom-text-200 text-xs whitespace-nowrap">
|
||||
{cell.state_group}- {cell.state_count}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,11 +3,13 @@ import { Fragment } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import Link from "next/link";
|
||||
|
||||
// next-themes
|
||||
import { useTheme } from "next-themes";
|
||||
// headless ui
|
||||
import { Menu, Transition } from "@headlessui/react";
|
||||
// hooks
|
||||
import useUser from "hooks/use-user";
|
||||
import useTheme from "hooks/use-theme";
|
||||
import useThemeHook from "hooks/use-theme";
|
||||
import useWorkspaces from "hooks/use-workspaces";
|
||||
import useToast from "hooks/use-toast";
|
||||
// services
|
||||
@@ -41,14 +43,15 @@ const userLinks = (workspaceSlug: string) => [
|
||||
export const WorkspaceSidebarDropdown = () => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug } = router.query;
|
||||
// fetching user details
|
||||
|
||||
const { user, mutateUser } = useUser();
|
||||
// fetching theme context
|
||||
const { collapsed: sidebarCollapse } = useTheme();
|
||||
|
||||
const { collapsed: sidebarCollapse } = useThemeHook();
|
||||
|
||||
const { setTheme } = useTheme();
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
// fetching workspaces
|
||||
const { activeWorkspace, workspaces } = useWorkspaces();
|
||||
|
||||
const handleWorkspaceNavigation = (workspace: IWorkspace) => {
|
||||
@@ -75,6 +78,7 @@ export const WorkspaceSidebarDropdown = () => {
|
||||
.then(() => {
|
||||
mutateUser(undefined);
|
||||
router.push("/");
|
||||
setTheme("dark");
|
||||
})
|
||||
.catch(() =>
|
||||
setToastAlert({
|
||||
@@ -135,8 +139,8 @@ export const WorkspaceSidebarDropdown = () => {
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<Menu.Items
|
||||
className="fixed left-2 z-20 mt-1 flex w-full max-w-[17rem] origin-top-left flex-col rounded-md
|
||||
border border-custom-sidebar-border-200 bg-custom-sidebar-background-90 shadow-lg focus:outline-none"
|
||||
className="fixed left-4 z-20 mt-1 flex flex-col w-full max-w-[17rem] origin-top-left rounded-md
|
||||
border border-custom-sidebar-border-200 bg-custom-sidebar-background-90 shadow-lg outline-none"
|
||||
>
|
||||
<div className="flex flex-col items-start justify-start gap-3 p-3">
|
||||
<div className="text-sm text-custom-sidebar-text-200">{user?.email}</div>
|
||||
|
||||
@@ -1,48 +1,51 @@
|
||||
import React from "react";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
// hooks
|
||||
import useTheme from "hooks/use-theme";
|
||||
// components
|
||||
import { Icon, Tooltip } from "components/ui";
|
||||
import { NotificationPopover } from "components/notifications";
|
||||
// ui
|
||||
import { Tooltip } from "components/ui";
|
||||
// icons
|
||||
import {
|
||||
BarChartRounded,
|
||||
GridViewOutlined,
|
||||
TaskAltOutlined,
|
||||
WorkOutlineOutlined,
|
||||
} from "@mui/icons-material";
|
||||
|
||||
const workspaceLinks = (workspaceSlug: string) => [
|
||||
{
|
||||
Icon: GridViewOutlined,
|
||||
name: "Dashboard",
|
||||
href: `/${workspaceSlug}`,
|
||||
},
|
||||
{
|
||||
Icon: BarChartRounded,
|
||||
name: "Analytics",
|
||||
href: `/${workspaceSlug}/analytics`,
|
||||
},
|
||||
{
|
||||
Icon: WorkOutlineOutlined,
|
||||
name: "Projects",
|
||||
href: `/${workspaceSlug}/projects`,
|
||||
},
|
||||
{
|
||||
Icon: TaskAltOutlined,
|
||||
name: "My Issues",
|
||||
href: `/${workspaceSlug}/me/my-issues`,
|
||||
},
|
||||
];
|
||||
|
||||
export const WorkspaceSidebarMenu = () => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug } = router.query;
|
||||
|
||||
// theme context
|
||||
const { collapsed: sidebarCollapse } = useTheme();
|
||||
|
||||
const workspaceLinks = (workspaceSlug: string) => [
|
||||
{
|
||||
icon: "grid_view",
|
||||
name: "Dashboard",
|
||||
href: `/${workspaceSlug}`,
|
||||
},
|
||||
{
|
||||
icon: "bar_chart",
|
||||
name: "Analytics",
|
||||
href: `/${workspaceSlug}/analytics`,
|
||||
},
|
||||
{
|
||||
icon: "work",
|
||||
name: "Projects",
|
||||
href: `/${workspaceSlug}/projects`,
|
||||
},
|
||||
{
|
||||
icon: "task_alt",
|
||||
name: "My Issues",
|
||||
href: `/${workspaceSlug}/me/my-issues`,
|
||||
},
|
||||
{
|
||||
icon: "settings",
|
||||
name: "Settings",
|
||||
href: `/${workspaceSlug}/settings`,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="w-full cursor-pointer space-y-2 px-4 mt-5">
|
||||
{workspaceLinks(workspaceSlug as string).map((link, index) => {
|
||||
@@ -67,7 +70,7 @@ export const WorkspaceSidebarMenu = () => {
|
||||
: "text-custom-sidebar-text-200 hover:bg-custom-sidebar-background-80 focus:bg-custom-sidebar-background-80"
|
||||
} ${sidebarCollapse ? "justify-center" : ""}`}
|
||||
>
|
||||
<Icon iconName={`${link.icon}`} />
|
||||
{<link.Icon fontSize="small" />}
|
||||
{!sidebarCollapse && link.name}
|
||||
</div>
|
||||
</Tooltip>
|
||||
@@ -75,6 +78,8 @@ export const WorkspaceSidebarMenu = () => {
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
|
||||
<NotificationPopover />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,248 +0,0 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
// headless ui
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
// icons
|
||||
import { XCircleIcon, RocketLaunchIcon } from "@heroicons/react/24/outline";
|
||||
import { CheckCircleIcon } from "@heroicons/react/24/solid";
|
||||
// ui
|
||||
import { CircularProgress } from "components/ui";
|
||||
// types
|
||||
import type { ICurrentUserResponse, IWorkspace } from "types";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
supabase: any;
|
||||
}
|
||||
}
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
user: ICurrentUserResponse | undefined;
|
||||
issueNumber: number;
|
||||
};
|
||||
|
||||
const UpgradeToProModal: React.FC<Props> = ({ isOpen, onClose, user, issueNumber }) => {
|
||||
const [supabaseClient, setSupabaseClient] = useState<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Create a Supabase client
|
||||
if (process.env.NEXT_PUBLIC_SUPABASE_URL && process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY) {
|
||||
const { createClient } = window.supabase;
|
||||
const supabase = createClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY,
|
||||
{
|
||||
auth: {
|
||||
autoRefreshToken: false,
|
||||
persistSession: false,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (supabase) {
|
||||
setSupabaseClient(supabase);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleClose = () => {
|
||||
onClose();
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
const proFeatures = [
|
||||
"Everything in free",
|
||||
"Unlimited users",
|
||||
"Unlimited file uploads",
|
||||
"Priority Support",
|
||||
"Custom Theming",
|
||||
"Access to Roadmap",
|
||||
"Plane AI (GPT unlimited)",
|
||||
];
|
||||
|
||||
const [errorMessage, setErrorMessage] = useState<null | { status: String; message: string }>(
|
||||
null
|
||||
);
|
||||
const [loader, setLoader] = useState(false);
|
||||
const submitEmail = async () => {
|
||||
setLoader(true);
|
||||
const payload = { email: user?.email || "" };
|
||||
|
||||
if (supabaseClient) {
|
||||
if (payload?.email) {
|
||||
const emailExists = await supabaseClient
|
||||
.from("web-waitlist")
|
||||
.select("id,email,count")
|
||||
.eq("email", payload?.email);
|
||||
if (emailExists.data.length === 0) {
|
||||
const emailCreation = await supabaseClient
|
||||
.from("web-waitlist")
|
||||
.insert([{ email: payload?.email, count: 1, last_visited: new Date() }])
|
||||
.select("id,email,count");
|
||||
if (emailCreation.status === 201)
|
||||
setErrorMessage({ status: "success", message: "Successfully registered." });
|
||||
else setErrorMessage({ status: "insert_error", message: "Insertion Error." });
|
||||
} else {
|
||||
const emailCountUpdate = await supabaseClient
|
||||
.from("web-waitlist")
|
||||
.upsert({
|
||||
id: emailExists.data[0]?.id,
|
||||
count: emailExists.data[0]?.count + 1,
|
||||
last_visited: new Date(),
|
||||
})
|
||||
.select("id,email,count");
|
||||
if (emailCountUpdate.status === 201)
|
||||
setErrorMessage({
|
||||
status: "email_already_exists",
|
||||
message: "Email already exists.",
|
||||
});
|
||||
else setErrorMessage({ status: "update_error", message: "Update Error." });
|
||||
}
|
||||
} else setErrorMessage({ status: "email_required", message: "Please provide email." });
|
||||
} else
|
||||
setErrorMessage({
|
||||
status: "supabase_error",
|
||||
message: "Network error. Please try again later.",
|
||||
});
|
||||
|
||||
setLoader(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Transition.Root show={isOpen} as={React.Fragment}>
|
||||
<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-custom-backdrop bg-opacity-50 transition-opacity" />
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0 z-20 overflow-y-auto">
|
||||
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
|
||||
<Transition.Child
|
||||
as={React.Fragment}
|
||||
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-custom-border-200 bg-custom-background-100 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-4xl">
|
||||
<div className="flex flex-wrap">
|
||||
<div className="w-full md:w-3/5 p-6 flex flex-col gap-y-6">
|
||||
<div className="flex gap-2">
|
||||
<div
|
||||
className={`font-semibold outline-none text-sm mt-1.5 ${
|
||||
issueNumber >= 750
|
||||
? "text-red-600"
|
||||
: issueNumber >= 500
|
||||
? "text-yellow-600"
|
||||
: "text-green-600"
|
||||
}`}
|
||||
title="Shortcuts"
|
||||
>
|
||||
<CircularProgress
|
||||
progress={
|
||||
(issueNumber / 1024) * 100 > 100 ? 100 : (issueNumber / 1024) * 100
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="">
|
||||
<div className="font-semibold text-lg">Upgrade to pro</div>
|
||||
<div className="text-custom-text-200 text-sm">
|
||||
This workspace has used {issueNumber} of its 1024 issues creation limit (
|
||||
{((issueNumber / 1024) * 100).toFixed(2)}%).
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
onClick={handleClose}
|
||||
className="w-5 h-5 text-custom-text-200 cursor-pointer mt-1.5 md:hidden block ml-auto"
|
||||
>
|
||||
<XCircleIcon />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 pt-6">
|
||||
<div
|
||||
className={`font-semibold outline-none text-sm mt-1.5 w-5 h-5 text-[#892FFF] flex-shrink-0`}
|
||||
title="Shortcuts"
|
||||
>
|
||||
<RocketLaunchIcon />
|
||||
</div>
|
||||
<div className="">
|
||||
<div className="font-semibold text-lg">Order summary</div>
|
||||
<div className="text-custom-text-200 text-sm">
|
||||
Priority support, file uploads, and access to premium features.
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap my-4">
|
||||
{proFeatures.map((feature, index) => (
|
||||
<div key={index} className="w-1/2 py-2 flex gap-2 my-1.5">
|
||||
<div className="w-5 h-5 mt-0.5 text-green-600">
|
||||
<CheckCircleIcon />
|
||||
</div>
|
||||
<div>{feature}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full md:w-2/5 bg-custom-background-90 p-6 flex flex-col">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="font-semibold text-lg">Summary</div>
|
||||
<div
|
||||
onClick={handleClose}
|
||||
className="w-5 h-5 text-custom-text-200 cursor-pointer mt-1.5 hidden md:block"
|
||||
>
|
||||
<XCircleIcon />
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-custom-text-200 text-sm mt-4">
|
||||
Plane application is currently in dev-mode. We will soon introduce Pro plans
|
||||
once general availability has been established. Stay tuned for more updates.
|
||||
In the meantime, Plane remains free and unrestricted.
|
||||
<br /> <br />
|
||||
We{"'"}ll ensure a smooth transition from the community version to the Pro
|
||||
plan for you.
|
||||
</div>
|
||||
<button
|
||||
disabled={loader}
|
||||
onClick={() => submitEmail()}
|
||||
type="button"
|
||||
className="mt-5 md:mt-auto whitespace-nowrap max-w-min items-center gap-x-1 rounded-md px-3 py-2 font-medium outline-none text-sm bg-custom-primary-100 text-white"
|
||||
>
|
||||
{loader ? "Loading.." : " Join waitlist"}
|
||||
</button>
|
||||
{errorMessage && (
|
||||
<div
|
||||
className={`mt-1 text-sm ${
|
||||
errorMessage && errorMessage?.status === "success"
|
||||
? "text-green-500"
|
||||
: " text-red-500"
|
||||
}`}
|
||||
>
|
||||
{errorMessage?.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition.Root>
|
||||
);
|
||||
};
|
||||
|
||||
export default UpgradeToProModal;
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IAnalyticsParams, IJiraMetadata } from "types";
|
||||
import { IAnalyticsParams, IJiraMetadata, INotificationParams } from "types";
|
||||
|
||||
const paramsToKey = (params: any) => {
|
||||
const { state, priority, assignees, created_by, labels, target_date, sub_issue } = params;
|
||||
@@ -223,3 +223,24 @@ export const DEFAULT_ANALYTICS = (workspaceSlug: string, params?: Partial<IAnaly
|
||||
`DEFAULT_ANALYTICS_${workspaceSlug.toUpperCase()}_${params?.project?.toString()}_${
|
||||
params?.cycle
|
||||
}_${params?.module}`;
|
||||
|
||||
// notifications
|
||||
export const USER_WORKSPACE_NOTIFICATIONS = (
|
||||
workspaceSlug: string,
|
||||
params: INotificationParams
|
||||
) => {
|
||||
const { type, snoozed, archived, read } = params;
|
||||
|
||||
return `USER_WORKSPACE_NOTIFICATIONS_${workspaceSlug.toUpperCase()}_TYPE_${(
|
||||
type ?? "assigned"
|
||||
).toUpperCase()}_SNOOZED_${snoozed}_ARCHIVED_${archived}_READ_${read}`;
|
||||
};
|
||||
|
||||
export const USER_WORKSPACE_NOTIFICATIONS_DETAILS = (
|
||||
workspaceSlug: string,
|
||||
notificationId: string
|
||||
) =>
|
||||
`USER_WORKSPACE_NOTIFICATIONS_DETAILS_${workspaceSlug.toUpperCase()}_${notificationId.toUpperCase()}`;
|
||||
|
||||
export const UNREAD_NOTIFICATIONS_COUNT = (workspaceSlug: string) =>
|
||||
`UNREAD_NOTIFICATIONS_COUNT_${workspaceSlug.toUpperCase()}`;
|
||||
|
||||
@@ -17,12 +17,12 @@ export const CHARTS_THEME: Theme = {
|
||||
background: "rgb(var(--color-background-80))",
|
||||
color: "rgb(var(--color-text-200))",
|
||||
fontSize: "0.8rem",
|
||||
border: "1px solid rgb(var(--color-background-80))",
|
||||
border: "1px solid rgb(var(--color-border-300))",
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
line: {
|
||||
stroke: "rgb(var(--color-background-80))",
|
||||
stroke: "rgb(var(--color-border-100))",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -94,7 +94,12 @@ export const ThemeContextProvider: React.FC<{ children: React.ReactNode }> = ({
|
||||
const theme = localStorage.getItem("theme");
|
||||
if (theme && theme === "custom") {
|
||||
if (user && user.theme.palette) {
|
||||
applyTheme(user.theme.palette, user.theme.darkPalette);
|
||||
applyTheme(
|
||||
user.theme.palette !== ",,,,"
|
||||
? user.theme.palette
|
||||
: "#0d101b,#c5c5c5,#3f76ff,#0d101b,#c5c5c5",
|
||||
user.theme.darkPalette
|
||||
);
|
||||
}
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
@@ -92,7 +92,7 @@ export const timeAgo = (time: any) => {
|
||||
list_choice = 2;
|
||||
}
|
||||
var i = 0,
|
||||
format;
|
||||
format: any[];
|
||||
while ((format = time_formats[i++]))
|
||||
if (seconds < format[0]) {
|
||||
if (typeof format[2] == "string") return format[list_choice];
|
||||
@@ -101,6 +101,35 @@ export const timeAgo = (time: any) => {
|
||||
return time;
|
||||
};
|
||||
|
||||
export const formatDateDistance = (date: string | Date) => {
|
||||
const today = new Date();
|
||||
const eventDate = new Date(date);
|
||||
const timeDiff = Math.abs(eventDate.getTime() - today.getTime());
|
||||
const days = Math.ceil(timeDiff / (1000 * 3600 * 24));
|
||||
|
||||
if (days < 1) {
|
||||
const hours = Math.ceil(timeDiff / (1000 * 3600));
|
||||
if (hours < 1) {
|
||||
const minutes = Math.ceil(timeDiff / (1000 * 60));
|
||||
if (minutes < 1) {
|
||||
return "Just now";
|
||||
} else {
|
||||
return `${minutes}m`;
|
||||
}
|
||||
} else {
|
||||
return `${hours}h`;
|
||||
}
|
||||
} else if (days < 7) {
|
||||
return `${days}d`;
|
||||
} else if (days < 30) {
|
||||
return `${Math.floor(days / 7)}w`;
|
||||
} else if (days < 365) {
|
||||
return `${Math.floor(days / 30)}m`;
|
||||
} else {
|
||||
return `${Math.floor(days / 365)}y`;
|
||||
}
|
||||
};
|
||||
|
||||
export const getDateRangeStatus = (
|
||||
startDate: string | null | undefined,
|
||||
endDate: string | null | undefined
|
||||
@@ -188,7 +217,7 @@ export const render12HourFormatTime = (date: string | Date): string => {
|
||||
if (hours > 12) hours -= 12;
|
||||
}
|
||||
|
||||
return hours + ":" + minutes + " " + period;
|
||||
return hours + ":" + (minutes < 10 ? `0${minutes}` : minutes) + " " + period;
|
||||
};
|
||||
|
||||
export const render24HourFormatTime = (date: string | Date): string => {
|
||||
@@ -214,7 +243,7 @@ export const isDateGreaterThanToday = (dateStr: string) => {
|
||||
return date > today;
|
||||
};
|
||||
|
||||
export const renderLongDateFormat = (dateString: string) => {
|
||||
export const renderLongDateFormat = (dateString: string | Date) => {
|
||||
const date = new Date(dateString);
|
||||
const day = date.getDate();
|
||||
const year = date.getFullYear();
|
||||
@@ -247,3 +276,101 @@ export const renderLongDateFormat = (dateString: string) => {
|
||||
}
|
||||
return `${day}${suffix} ${monthName} ${year}`;
|
||||
};
|
||||
|
||||
/**
|
||||
*
|
||||
* @returns {Array} Array of time objects with label and value as keys
|
||||
*/
|
||||
|
||||
export const getTimestampAfterCurrentTime = (): Array<{
|
||||
label: string;
|
||||
value: Date;
|
||||
}> => {
|
||||
const current = new Date();
|
||||
const time = [];
|
||||
for (let i = 0; i < 24; i++) {
|
||||
const newTime = new Date(current.getTime() + i * 60 * 60 * 1000);
|
||||
time.push({
|
||||
label: newTime.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }),
|
||||
value: newTime,
|
||||
});
|
||||
}
|
||||
return time;
|
||||
};
|
||||
|
||||
/**
|
||||
* @returns {Array} Array of date objects with label and value as keys
|
||||
* @description Returns an array of date objects starting from current date to 7 days after
|
||||
*/
|
||||
|
||||
export const getDatesAfterCurrentDate = (): Array<{
|
||||
label: string;
|
||||
value: Date;
|
||||
}> => {
|
||||
const current = new Date();
|
||||
const date = [];
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const newDate = new Date(Math.round(current.getTime() / (30 * 60 * 1000)) * 30 * 60 * 1000);
|
||||
date.push({
|
||||
label: newDate.toLocaleDateString([], {
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
}),
|
||||
value: newDate,
|
||||
});
|
||||
}
|
||||
return date;
|
||||
};
|
||||
|
||||
/**
|
||||
* @returns {boolean} true if date is valid
|
||||
* @description Returns true if date is valid
|
||||
* @param {string} date
|
||||
* @example checkIfStringIsDate("2021-01-01") // true
|
||||
* @example checkIfStringIsDate("2021-01-32") // false
|
||||
*/
|
||||
|
||||
export const checkIfStringIsDate = (date: string): boolean =>
|
||||
new Date(date).toString() !== "Invalid Date";
|
||||
|
||||
// return an array of dates starting from 12:00 to 23:30 with 30 minutes interval as dates
|
||||
export const getDatesWith30MinutesInterval = (): Array<Date> => {
|
||||
const dates = [];
|
||||
const current = new Date();
|
||||
for (let i = 0; i < 24; i++) {
|
||||
const newDate = new Date(current.getTime() + i * 60 * 60 * 1000);
|
||||
dates.push(newDate);
|
||||
}
|
||||
return dates;
|
||||
};
|
||||
|
||||
export const getAllTimeIn30MinutesInterval = (): Array<{
|
||||
label: string;
|
||||
value: string;
|
||||
}> => [
|
||||
{ label: "12:00", value: "12:00" },
|
||||
{ label: "12:30", value: "12:30" },
|
||||
{ label: "01:00", value: "01:00" },
|
||||
{ label: "01:30", value: "01:30" },
|
||||
{ label: "02:00", value: "02:00" },
|
||||
{ label: "02:30", value: "02:30" },
|
||||
{ label: "03:00", value: "03:00" },
|
||||
{ label: "03:30", value: "03:30" },
|
||||
{ label: "04:00", value: "04:00" },
|
||||
{ label: "04:30", value: "04:30" },
|
||||
{ label: "05:00", value: "05:00" },
|
||||
{ label: "05:30", value: "05:30" },
|
||||
{ label: "06:00", value: "06:00" },
|
||||
{ label: "06:30", value: "06:30" },
|
||||
{ label: "07:00", value: "07:00" },
|
||||
{ label: "07:30", value: "07:30" },
|
||||
{ label: "08:00", value: "08:00" },
|
||||
{ label: "08:30", value: "08:30" },
|
||||
{ label: "09:00", value: "09:00" },
|
||||
{ label: "09:30", value: "09:30" },
|
||||
{ label: "10:00", value: "10:00" },
|
||||
{ label: "10:30", value: "10:30" },
|
||||
{ label: "11:00", value: "11:00" },
|
||||
{ label: "11:30", value: "11:30" },
|
||||
];
|
||||
|
||||
@@ -49,45 +49,6 @@ export const copyTextToClipboard = async (text: string) => {
|
||||
await navigator.clipboard.writeText(text);
|
||||
};
|
||||
|
||||
const wordsVector = (str: string) => {
|
||||
const words = str.split(" ");
|
||||
const vector: any = {};
|
||||
for (let i = 0; i < words.length; i++) {
|
||||
const word = words[i];
|
||||
if (vector[word]) {
|
||||
vector[word] += 1;
|
||||
} else {
|
||||
vector[word] = 1;
|
||||
}
|
||||
}
|
||||
return vector;
|
||||
};
|
||||
|
||||
export const cosineSimilarity = (a: string, b: string) => {
|
||||
const vectorA = wordsVector(a.trim());
|
||||
const vectorB = wordsVector(b.trim());
|
||||
|
||||
const vectorAKeys = Object.keys(vectorA);
|
||||
const vectorBKeys = Object.keys(vectorB);
|
||||
|
||||
const union = vectorAKeys.concat(vectorBKeys);
|
||||
|
||||
let dotProduct = 0;
|
||||
let magnitudeA = 0;
|
||||
let magnitudeB = 0;
|
||||
|
||||
for (let i = 0; i < union.length; i++) {
|
||||
const key = union[i];
|
||||
const valueA = vectorA[key] || 0;
|
||||
const valueB = vectorB[key] || 0;
|
||||
dotProduct += valueA * valueB;
|
||||
magnitudeA += valueA * valueA;
|
||||
magnitudeB += valueB * valueB;
|
||||
}
|
||||
|
||||
return dotProduct / Math.sqrt(magnitudeA * magnitudeB);
|
||||
};
|
||||
|
||||
export const generateRandomColor = (string: string): string => {
|
||||
if (!string) return "rgb(var(--color-primary-100))";
|
||||
|
||||
@@ -118,3 +79,36 @@ export const getFirstCharacters = (str: string) => {
|
||||
return words[0].charAt(0) + words[1].charAt(0);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @description: This function will remove all the HTML tags from the string
|
||||
* @param {string} html
|
||||
* @return {string}
|
||||
* @example:
|
||||
* const html = "<p>Some text</p>";
|
||||
* const text = stripHTML(html);
|
||||
* console.log(text); // Some text
|
||||
*/
|
||||
|
||||
export const stripHTML = (html: string) => {
|
||||
const tmp = document.createElement("DIV");
|
||||
tmp.innerHTML = html;
|
||||
return tmp.textContent || tmp.innerText || "";
|
||||
};
|
||||
|
||||
/**
|
||||
* @description: This function return number count in string if number is more than 100 then it will return 99+
|
||||
* @param {number} number
|
||||
* @return {string}
|
||||
* @example:
|
||||
* const number = 100;
|
||||
* const text = getNumberCount(number);
|
||||
* console.log(text); // 99+
|
||||
*/
|
||||
|
||||
export const getNumberCount = (number: number): string => {
|
||||
if (number > 99) {
|
||||
return "99+";
|
||||
}
|
||||
return number.toString();
|
||||
};
|
||||
|
||||
@@ -96,7 +96,7 @@ export const applyTheme = (palette: string, isDarkPalette: boolean) => {
|
||||
?.style.setProperty(`--color-sidebar-text-${shade}`, sidebarTextRgbValues);
|
||||
|
||||
if (i >= 100 && i <= 400) {
|
||||
const borderShade = (shade + 100) as keyof TShades;
|
||||
const borderShade = i === 100 ? 70 : i === 200 ? 80 : i === 300 ? 90 : 100;
|
||||
|
||||
document
|
||||
.querySelector<HTMLElement>("[data-theme='custom']")
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { useCallback } from "react";
|
||||
|
||||
import useSWR from "swr";
|
||||
|
||||
// hooks
|
||||
import useUserAuth from "hooks/use-user-auth";
|
||||
// services
|
||||
import userNotificationServices from "services/notifications.service";
|
||||
|
||||
const useUserIssueNotificationSubscription = (
|
||||
workspaceSlug?: string | string[] | null,
|
||||
projectId?: string | string[] | null,
|
||||
issueId?: string | string[] | null
|
||||
) => {
|
||||
const { user } = useUserAuth();
|
||||
|
||||
const { data, error, mutate } = useSWR(
|
||||
workspaceSlug && projectId && issueId
|
||||
? `SUBSCRIPTION_STATUE_${workspaceSlug}_${projectId}_${issueId}`
|
||||
: null,
|
||||
workspaceSlug && projectId && issueId
|
||||
? () =>
|
||||
userNotificationServices.getIssueNotificationSubscriptionStatus(
|
||||
workspaceSlug.toString(),
|
||||
projectId.toString(),
|
||||
issueId.toString()
|
||||
)
|
||||
: null
|
||||
);
|
||||
|
||||
const handleUnsubscribe = useCallback(() => {
|
||||
if (!workspaceSlug || !projectId || !issueId) return;
|
||||
|
||||
mutate(
|
||||
{
|
||||
subscribed: false,
|
||||
},
|
||||
false
|
||||
);
|
||||
|
||||
userNotificationServices
|
||||
.unsubscribeFromIssueNotifications(
|
||||
workspaceSlug.toString(),
|
||||
projectId.toString(),
|
||||
issueId.toString()
|
||||
)
|
||||
.then(() => {
|
||||
mutate({
|
||||
subscribed: false,
|
||||
});
|
||||
});
|
||||
}, [workspaceSlug, projectId, issueId, mutate]);
|
||||
|
||||
const handleSubscribe = useCallback(() => {
|
||||
if (!workspaceSlug || !projectId || !issueId || !user) return;
|
||||
|
||||
mutate(
|
||||
{
|
||||
subscribed: true,
|
||||
},
|
||||
false
|
||||
);
|
||||
|
||||
userNotificationServices
|
||||
.subscribeToIssueNotifications(
|
||||
workspaceSlug.toString(),
|
||||
projectId.toString(),
|
||||
issueId.toString()
|
||||
)
|
||||
.then(() => {
|
||||
mutate({
|
||||
subscribed: true,
|
||||
});
|
||||
});
|
||||
}, [workspaceSlug, projectId, issueId, mutate, user]);
|
||||
|
||||
return {
|
||||
loading: !data && !error,
|
||||
subscribed: data?.subscribed,
|
||||
handleSubscribe,
|
||||
handleUnsubscribe,
|
||||
} as const;
|
||||
};
|
||||
|
||||
export default useUserIssueNotificationSubscription;
|
||||
@@ -0,0 +1,199 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
// swr
|
||||
import useSWR from "swr";
|
||||
|
||||
// services
|
||||
import userNotificationServices from "services/notifications.service";
|
||||
|
||||
// fetch-keys
|
||||
import { UNREAD_NOTIFICATIONS_COUNT, USER_WORKSPACE_NOTIFICATIONS } from "constants/fetch-keys";
|
||||
|
||||
// type
|
||||
import type { NotificationType } from "types";
|
||||
|
||||
const useUserNotification = () => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug } = router.query;
|
||||
|
||||
const [snoozed, setSnoozed] = useState<boolean>(false);
|
||||
const [archived, setArchived] = useState<boolean>(false);
|
||||
const [readNotification, setReadNotification] = useState<boolean>(false);
|
||||
const [selectedNotificationForSnooze, setSelectedNotificationForSnooze] = useState<string | null>(
|
||||
null
|
||||
);
|
||||
const [selectedTab, setSelectedTab] = useState<NotificationType>("assigned");
|
||||
|
||||
const params = {
|
||||
type: snoozed || archived || readNotification ? undefined : selectedTab,
|
||||
snoozed,
|
||||
archived,
|
||||
read: !readNotification ? undefined : false,
|
||||
};
|
||||
|
||||
const { data: notifications, mutate: notificationsMutate } = useSWR(
|
||||
workspaceSlug ? USER_WORKSPACE_NOTIFICATIONS(workspaceSlug.toString(), params) : null,
|
||||
workspaceSlug
|
||||
? () => userNotificationServices.getUserNotifications(workspaceSlug.toString(), params)
|
||||
: null
|
||||
);
|
||||
|
||||
const { data: notificationCount, mutate: mutateNotificationCount } = useSWR(
|
||||
workspaceSlug ? UNREAD_NOTIFICATIONS_COUNT(workspaceSlug.toString()) : null,
|
||||
() =>
|
||||
workspaceSlug
|
||||
? userNotificationServices.getUnreadNotificationsCount(workspaceSlug.toString())
|
||||
: null
|
||||
);
|
||||
|
||||
const markNotificationReadStatus = async (notificationId: string) => {
|
||||
if (!workspaceSlug) return;
|
||||
const isRead =
|
||||
notifications?.find((notification) => notification.id === notificationId)?.read_at !== null;
|
||||
|
||||
if (isRead) {
|
||||
notificationsMutate(
|
||||
(prev) =>
|
||||
prev?.map((prevNotification) => {
|
||||
if (prevNotification.id === notificationId) {
|
||||
return {
|
||||
...prevNotification,
|
||||
read_at: null,
|
||||
};
|
||||
}
|
||||
return prevNotification;
|
||||
}),
|
||||
false
|
||||
);
|
||||
await userNotificationServices
|
||||
.markUserNotificationAsUnread(workspaceSlug.toString(), notificationId)
|
||||
.catch(() => {
|
||||
throw new Error("Something went wrong");
|
||||
})
|
||||
.finally(() => {
|
||||
notificationsMutate();
|
||||
mutateNotificationCount();
|
||||
});
|
||||
} else {
|
||||
notificationsMutate(
|
||||
(prev) =>
|
||||
prev?.map((prevNotification) => {
|
||||
if (prevNotification.id === notificationId) {
|
||||
return {
|
||||
...prevNotification,
|
||||
read_at: new Date(),
|
||||
};
|
||||
}
|
||||
return prevNotification;
|
||||
}),
|
||||
false
|
||||
);
|
||||
await userNotificationServices
|
||||
.markUserNotificationAsRead(workspaceSlug.toString(), notificationId)
|
||||
.catch(() => {
|
||||
throw new Error("Something went wrong");
|
||||
})
|
||||
.finally(() => {
|
||||
notificationsMutate();
|
||||
mutateNotificationCount();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const markNotificationArchivedStatus = async (notificationId: string) => {
|
||||
if (!workspaceSlug) return;
|
||||
const isArchived =
|
||||
notifications?.find((notification) => notification.id === notificationId)?.archived_at !==
|
||||
null;
|
||||
|
||||
if (isArchived) {
|
||||
await userNotificationServices
|
||||
.markUserNotificationAsUnarchived(workspaceSlug.toString(), notificationId)
|
||||
.catch(() => {
|
||||
throw new Error("Something went wrong");
|
||||
})
|
||||
.finally(() => {
|
||||
notificationsMutate();
|
||||
mutateNotificationCount();
|
||||
});
|
||||
} else {
|
||||
notificationsMutate(
|
||||
(prev) => prev?.filter((prevNotification) => prevNotification.id !== notificationId),
|
||||
false
|
||||
);
|
||||
await userNotificationServices
|
||||
.markUserNotificationAsArchived(workspaceSlug.toString(), notificationId)
|
||||
.catch(() => {
|
||||
throw new Error("Something went wrong");
|
||||
})
|
||||
.finally(() => {
|
||||
notificationsMutate();
|
||||
mutateNotificationCount();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const markSnoozeNotification = async (notificationId: string, dateTime?: Date) => {
|
||||
if (!workspaceSlug) return;
|
||||
|
||||
const isSnoozed =
|
||||
notifications?.find((notification) => notification.id === notificationId)?.snoozed_till !==
|
||||
null;
|
||||
|
||||
if (isSnoozed) {
|
||||
await userNotificationServices
|
||||
.patchUserNotification(workspaceSlug.toString(), notificationId, {
|
||||
snoozed_till: null,
|
||||
})
|
||||
.finally(() => {
|
||||
notificationsMutate();
|
||||
mutateNotificationCount();
|
||||
});
|
||||
} else {
|
||||
notificationsMutate(
|
||||
(prevData) => prevData?.filter((prev) => prev.id !== notificationId) || [],
|
||||
false
|
||||
);
|
||||
await userNotificationServices
|
||||
.patchUserNotification(workspaceSlug.toString(), notificationId, {
|
||||
snoozed_till: dateTime,
|
||||
})
|
||||
.catch(() => {
|
||||
new Error("Something went wrong");
|
||||
})
|
||||
.finally(() => {
|
||||
notificationsMutate();
|
||||
mutateNotificationCount();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
notifications,
|
||||
notificationsMutate,
|
||||
markNotificationReadStatus,
|
||||
markNotificationArchivedStatus,
|
||||
markSnoozeNotification,
|
||||
snoozed,
|
||||
setSnoozed,
|
||||
archived,
|
||||
setArchived,
|
||||
readNotification,
|
||||
setReadNotification,
|
||||
selectedNotificationForSnooze,
|
||||
setSelectedNotificationForSnooze,
|
||||
selectedTab,
|
||||
setSelectedTab,
|
||||
totalNotificationCount: notificationCount
|
||||
? notificationCount.created_issues +
|
||||
notificationCount.watching_issues +
|
||||
notificationCount.my_issues
|
||||
: null,
|
||||
notificationCount,
|
||||
mutateNotificationCount,
|
||||
};
|
||||
};
|
||||
|
||||
export default useUserNotification;
|
||||
@@ -14,6 +14,8 @@
|
||||
"@headlessui/react": "^1.7.3",
|
||||
"@heroicons/react": "^2.0.12",
|
||||
"@jitsu/nextjs": "^3.1.5",
|
||||
"@mui/icons-material": "^5.14.1",
|
||||
"@mui/material": "^5.14.1",
|
||||
"@nivo/bar": "0.80.0",
|
||||
"@nivo/calendar": "0.80.0",
|
||||
"@nivo/core": "0.80.0",
|
||||
|
||||
@@ -2,9 +2,12 @@ import React, { useEffect, useState } from "react";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
|
||||
import useSWR, { mutate } from "swr";
|
||||
|
||||
// next-themes
|
||||
import { useTheme } from "next-themes";
|
||||
// layouts
|
||||
import { WorkspaceAuthorizationLayout } from "layouts/auth-layout";
|
||||
// services
|
||||
@@ -22,8 +25,12 @@ import {
|
||||
import { TourRoot } from "components/onboarding";
|
||||
// ui
|
||||
import { PrimaryButton, ProductUpdatesModal } from "components/ui";
|
||||
// icons
|
||||
import { BoltOutlined, GridViewOutlined } from "@mui/icons-material";
|
||||
// images
|
||||
import emptyDashboard from "public/empty-state/dashboard.svg";
|
||||
import githubBlackImage from "/public/logos/github-black.png";
|
||||
import githubWhiteImage from "/public/logos/github-white.png";
|
||||
// helpers
|
||||
import { render12HourFormatTime, renderShortDate } from "helpers/date-time.helper";
|
||||
// types
|
||||
@@ -41,6 +48,8 @@ const WorkspacePage: NextPage = () => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug } = router.query;
|
||||
|
||||
const { theme } = useTheme();
|
||||
|
||||
const { user } = useUser();
|
||||
const { projects } = useProjects();
|
||||
|
||||
@@ -60,7 +69,36 @@ const WorkspacePage: NextPage = () => {
|
||||
}, [month, workspaceSlug]);
|
||||
|
||||
return (
|
||||
<WorkspaceAuthorizationLayout noHeader>
|
||||
<WorkspaceAuthorizationLayout
|
||||
left={
|
||||
<div className="flex items-center gap-2 pl-3">
|
||||
<GridViewOutlined fontSize="small" />
|
||||
Dashboard
|
||||
</div>
|
||||
}
|
||||
right={
|
||||
<div className="flex items-center gap-3 px-3">
|
||||
<button
|
||||
onClick={() => setIsProductUpdatesModalOpen(true)}
|
||||
className="flex items-center gap-1.5 bg-custom-background-80 text-xs font-medium py-1.5 px-3 rounded"
|
||||
>
|
||||
<BoltOutlined fontSize="small" className="-my-1" />
|
||||
What{"'"}s New?
|
||||
</button>
|
||||
<Link href="https://github.com/makeplane/plane" target="_blank" rel="noopener noreferrer">
|
||||
<a className="flex items-center gap-1.5 bg-custom-background-80 text-xs font-medium py-1.5 px-3 rounded">
|
||||
<Image
|
||||
src={theme === "dark" ? githubWhiteImage : githubBlackImage}
|
||||
height={16}
|
||||
width={16}
|
||||
alt="GitHub Logo"
|
||||
/>
|
||||
Star us on GitHub
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{isProductUpdatesModalOpen && (
|
||||
<ProductUpdatesModal
|
||||
isOpen={isProductUpdatesModalOpen}
|
||||
@@ -93,27 +131,6 @@ const WorkspacePage: NextPage = () => {
|
||||
projects.length > 0 ? (
|
||||
<div className="p-8">
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="text-custom-text-200 flex flex-col justify-between gap-x-2 gap-y-6 rounded-lg border border-custom-border-200 bg-custom-background-100 px-4 py-6 md:flex-row md:items-center md:py-3">
|
||||
<p className="font-medium text-custom-text-100">
|
||||
Plane is open source, support us by starring us on GitHub.
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setIsProductUpdatesModalOpen(true)}
|
||||
className="rounded-md border-2 border-custom-border-200 px-3 py-1.5 text-sm font-medium hover:text-custom-text-100 duration-300"
|
||||
>
|
||||
What{"'"}s New?
|
||||
</button>
|
||||
<a
|
||||
href="https://github.com/makeplane/plane"
|
||||
target="_blank"
|
||||
className="rounded-md border-2 border-custom-border-200 px-3 py-1.5 text-sm font-medium hover:text-custom-text-100 duration-300"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Star us on GitHub
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<IssuesStats data={workspaceDashboardData} />
|
||||
<div className="grid grid-cols-1 gap-8 lg:grid-cols-2">
|
||||
<IssuesList issues={workspaceDashboardData?.overdue_issues} type="overdue" />
|
||||
|
||||
@@ -72,7 +72,8 @@ const MyIssuesPage: NextPage = () => {
|
||||
<h4 className="text-sm text-custom-text-200">Properties</h4>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{Object.keys(properties).map((key) => {
|
||||
if (key === "estimate") return null;
|
||||
if (key === "estimate" || key === "created_on" || key === "updated_on")
|
||||
return null;
|
||||
|
||||
return (
|
||||
<button
|
||||
|
||||
@@ -22,7 +22,22 @@ const ProfilePreferences = () => {
|
||||
|
||||
useEffect(() => {
|
||||
if (theme === "custom") {
|
||||
if (myProfile?.theme.palette) setPreLoadedData(myProfile.theme);
|
||||
if (myProfile?.theme.palette)
|
||||
setPreLoadedData({
|
||||
background: myProfile.theme.background !== "" ? myProfile.theme.background : "#0d101b",
|
||||
text: myProfile.theme.text !== "" ? myProfile.theme.text : "#c5c5c5",
|
||||
primary: myProfile.theme.primary !== "" ? myProfile.theme.primary : "#3f76ff",
|
||||
sidebarBackground:
|
||||
myProfile.theme.sidebarBackground !== ""
|
||||
? myProfile.theme.sidebarBackground
|
||||
: "#0d101b",
|
||||
sidebarText: myProfile.theme.sidebarText !== "" ? myProfile.theme.sidebarText : "#c5c5c5",
|
||||
darkPalette: false,
|
||||
palette:
|
||||
myProfile.theme.palette !== ",,,,"
|
||||
? myProfile.theme.palette
|
||||
: "#0d101b,#c5c5c5,#3f76ff,#0d101b,#c5c5c5",
|
||||
});
|
||||
if (!customThemeSelectorOptions) setCustomThemeSelectorOptions(true);
|
||||
}
|
||||
}, [myProfile, theme, customThemeSelectorOptions]);
|
||||
|
||||
+13
-7
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useEffect } from "react";
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
@@ -39,6 +39,8 @@ const defaultValues = {
|
||||
};
|
||||
|
||||
const ArchivedIssueDetailsPage: NextPage = () => {
|
||||
const [isRestoring, setIsRestoring] = useState(false);
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, archivedIssueId } = router.query;
|
||||
|
||||
@@ -117,6 +119,8 @@ const ArchivedIssueDetailsPage: NextPage = () => {
|
||||
const handleUnArchive = async () => {
|
||||
if (!workspaceSlug || !projectId || !archivedIssueId) return;
|
||||
|
||||
setIsRestoring(true);
|
||||
|
||||
await issuesService
|
||||
.unarchiveIssue(workspaceSlug as string, projectId as string, archivedIssueId as string)
|
||||
.then(() => {
|
||||
@@ -133,7 +137,8 @@ const ArchivedIssueDetailsPage: NextPage = () => {
|
||||
title: "Error!",
|
||||
message: "Something went wrong. Please try again.",
|
||||
});
|
||||
});
|
||||
})
|
||||
.finally(() => setIsRestoring(false));
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -153,8 +158,8 @@ const ArchivedIssueDetailsPage: NextPage = () => {
|
||||
}
|
||||
>
|
||||
{issueDetails && projectId ? (
|
||||
<div className="flex h-full">
|
||||
<div className="w-2/3 space-y-2 p-5">
|
||||
<div className="flex h-full overflow-hidden">
|
||||
<div className="w-2/3 h-full overflow-y-auto space-y-2 divide-y-2 divide-custom-border-300 p-5">
|
||||
{issueDetails.archived_at && (
|
||||
<div className="flex items-center justify-between gap-2 px-2.5 py-2 text-sm border rounded-md text-custom-text-200 border-custom-border-200 bg-custom-background-90">
|
||||
<div className="flex gap-2 items-center">
|
||||
@@ -164,13 +169,14 @@ const ArchivedIssueDetailsPage: NextPage = () => {
|
||||
<button
|
||||
className="flex items-center gap-2 p-1.5 text-sm rounded-md border border-custom-border-200"
|
||||
onClick={handleUnArchive}
|
||||
disabled={isRestoring}
|
||||
>
|
||||
<Icon iconName="history" />
|
||||
<p>Restore Issue</p>
|
||||
<span>{isRestoring ? "Restoring..." : "Restore Issue"}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-5 divide-y-2 divide-custom-border-200 opacity-60">
|
||||
<div className="space-y-5 divide-y-2 divide-custom-border-200 opacity-60 pointer-events-none">
|
||||
<IssueMainContent
|
||||
issueDetails={issueDetails}
|
||||
submitChanges={submitChanges}
|
||||
@@ -178,7 +184,7 @@ const ArchivedIssueDetailsPage: NextPage = () => {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-1/3 space-y-5 border-l border-custom-border-200 p-5">
|
||||
<div className="w-1/3 h-full space-y-5 border-l border-custom-border-300 p-5 overflow-hidden">
|
||||
<IssueDetailsSidebar
|
||||
control={control}
|
||||
issueDetail={issueDetails}
|
||||
|
||||
@@ -120,10 +120,10 @@ const IssueDetailsPage: NextPage = () => {
|
||||
>
|
||||
{issueDetails && projectId ? (
|
||||
<div className="flex h-full overflow-hidden">
|
||||
<div className="w-2/3 h-full overflow-y-auto space-y-5 divide-y-2 divide-custom-border-200 p-5">
|
||||
<div className="w-2/3 h-full overflow-y-auto space-y-5 divide-y-2 divide-custom-border-300 p-5">
|
||||
<IssueMainContent issueDetails={issueDetails} submitChanges={submitChanges} />
|
||||
</div>
|
||||
<div className="w-1/3 h-full space-y-5 border-l border-custom-border-200 p-5 overflow-hidden">
|
||||
<div className="w-1/3 h-full space-y-5 border-l border-custom-border-300 p-5 overflow-hidden">
|
||||
<IssueDetailsSidebar
|
||||
control={control}
|
||||
issueDetail={issueDetails}
|
||||
|
||||
@@ -553,6 +553,11 @@ const SinglePage: NextPage = () => {
|
||||
handleClose={() => setLabelModal(false)}
|
||||
projectId={projectId}
|
||||
user={user}
|
||||
onSuccess={(response) => {
|
||||
partialUpdatePage({
|
||||
labels_list: [...(pageDetails.labels ?? []), response.id],
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -44,28 +44,18 @@ const BillingSettings: NextPage = () => {
|
||||
<section className="space-y-8">
|
||||
<div>
|
||||
<h3 className="text-2xl font-semibold leading-6">Billing & Plans</h3>
|
||||
<p className="mt-4 text-sm text-custom-text-200">[Free launch preview] plan Pro</p>
|
||||
<p className="mt-4 text-sm text-custom-text-200">Free launch preview</p>
|
||||
</div>
|
||||
<div className="space-y-8 md:w-2/3">
|
||||
<div>
|
||||
<div className="w-80 rounded-md border border-custom-border-200 bg-custom-background-100 p-4 text-center">
|
||||
<h4 className="text-md mb-1 leading-6">Payment due</h4>
|
||||
<h2 className="text-3xl font-extrabold">--</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-md mb-1 leading-6">Current plan</h4>
|
||||
<p className="mb-3 text-sm text-custom-text-200">
|
||||
You are currently using the free plan
|
||||
</p>
|
||||
<a href="https://plane.so/pricing" target="_blank" rel="noreferrer">
|
||||
<SecondaryButton outline>View Plans and Upgrade</SecondaryButton>
|
||||
<SecondaryButton outline>View Plans</SecondaryButton>
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-md mb-1 leading-6">Billing history</h4>
|
||||
<p className="mb-3 text-sm text-custom-text-200">There are no invoices to display</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -207,7 +207,10 @@ const WorkspaceSettings: NextPage = () => {
|
||||
{isImageUploading ? "Uploading..." : "Upload"}
|
||||
</SecondaryButton>
|
||||
{activeWorkspace.logo && activeWorkspace.logo !== "" && (
|
||||
<DangerButton onClick={() => handleDelete(activeWorkspace.logo)}>
|
||||
<DangerButton
|
||||
onClick={() => handleDelete(activeWorkspace.logo)}
|
||||
loading={isImageRemoving}
|
||||
>
|
||||
{isImageRemoving ? "Removing..." : "Remove"}
|
||||
</DangerButton>
|
||||
)}
|
||||
@@ -276,8 +279,8 @@ const WorkspaceSettings: NextPage = () => {
|
||||
</div>
|
||||
<div className="grid grid-cols-12 gap-4 sm:gap-16">
|
||||
<div className="col-span-12 sm:col-span-6">
|
||||
<h4 className="text-lg font-semibold">Company Size</h4>
|
||||
<p className="text-sm text-custom-text-200">How big is your company?</p>
|
||||
<h4 className="text-lg font-semibold">Organization Size</h4>
|
||||
<p className="text-sm text-custom-text-200">What size is your organization?</p>
|
||||
</div>
|
||||
<div className="col-span-12 sm:col-span-6">
|
||||
<Controller
|
||||
@@ -287,7 +290,10 @@ const WorkspaceSettings: NextPage = () => {
|
||||
<CustomSelect
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
label={ORGANIZATION_SIZE.find((c) => c === value) ?? "Select company size"}
|
||||
label={
|
||||
ORGANIZATION_SIZE.find((c) => c === value) ?? "Select organization size"
|
||||
}
|
||||
width="w-full"
|
||||
input
|
||||
>
|
||||
{ORGANIZATION_SIZE?.map((item) => (
|
||||
|
||||
@@ -45,7 +45,7 @@ Router.events.on("routeChangeComplete", NProgress.done);
|
||||
function MyApp({ Component, pageProps }: AppProps) {
|
||||
return (
|
||||
// <UserProvider>
|
||||
<ThemeProvider themes={THEMES} defaultTheme="light">
|
||||
<ThemeProvider themes={THEMES} defaultTheme="dark">
|
||||
<ToastContextProvider>
|
||||
<ThemeContextProvider>
|
||||
<CrispWithNoSSR />
|
||||
|
||||
@@ -138,18 +138,18 @@ const HomePage: NextPage = () => {
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
<div className="grid place-items-center h-full overflow-y-auto px-7">
|
||||
<div className="grid place-items-center h-full overflow-y-auto py-5 px-7">
|
||||
<div>
|
||||
{parseInt(process.env.NEXT_PUBLIC_ENABLE_OAUTH || "0") ? (
|
||||
<>
|
||||
<h1 className="text-center text-2xl sm:text-2.5xl font-semibold text-custom-text-100">
|
||||
Sign in to Plane
|
||||
</h1>
|
||||
<div className="divide-y divide-custom-border-200">
|
||||
<div className="flex flex-col divide-y divide-custom-border-200">
|
||||
<div className="pb-7">
|
||||
<EmailCodeForm handleSignIn={handleEmailCodeSignIn} />
|
||||
</div>
|
||||
<div className="space-y-4 pt-7 w-full sm:w-[360px] mx-auto">
|
||||
<div className="flex flex-col items-center justify-center gap-4 pt-7 sm:w-[360px] mx-auto overflow-hidden">
|
||||
<GoogleLoginButton handleSignIn={handleGoogleSignIn} />
|
||||
<GithubLoginButton handleSignIn={handleGitHubSignIn} />
|
||||
</div>
|
||||
|
||||
@@ -105,91 +105,93 @@ const OnBoard: NextPage = () => {
|
||||
{user?.email}
|
||||
</div>
|
||||
</div>
|
||||
{invitations && invitations.length > 0 ? (
|
||||
<div className="relative flex justify-center sm:justify-start sm:items-center h-full px-8 pb-8 sm:p-0 sm:pr-[8.33%] sm:w-10/12 md:w-9/12 lg:w-4/5">
|
||||
<div className="w-full space-y-10">
|
||||
<h5 className="text-lg">We see that someone has invited you to</h5>
|
||||
<h4 className="text-2xl font-semibold">Join a workspace</h4>
|
||||
<div className="md:w-3/5 space-y-4">
|
||||
{invitations.map((invitation) => {
|
||||
const isSelected = invitationsRespond.includes(invitation.id);
|
||||
{invitations ? (
|
||||
invitations.length > 0 ? (
|
||||
<div className="relative flex justify-center sm:justify-start sm:items-center h-full px-8 pb-8 sm:p-0 sm:pr-[8.33%] sm:w-10/12 md:w-9/12 lg:w-4/5">
|
||||
<div className="w-full space-y-10">
|
||||
<h5 className="text-lg">We see that someone has invited you to</h5>
|
||||
<h4 className="text-2xl font-semibold">Join a workspace</h4>
|
||||
<div className="max-h-[37vh] md:w-3/5 space-y-4 overflow-y-auto">
|
||||
{invitations.map((invitation) => {
|
||||
const isSelected = invitationsRespond.includes(invitation.id);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={invitation.id}
|
||||
className={`flex cursor-pointer items-center gap-2 border py-5 px-3.5 rounded ${
|
||||
isSelected
|
||||
? "border-custom-primary-100"
|
||||
: "border-custom-border-200 hover:bg-custom-background-80"
|
||||
}`}
|
||||
onClick={() =>
|
||||
handleInvitation(invitation, isSelected ? "withdraw" : "accepted")
|
||||
}
|
||||
>
|
||||
<div className="flex-shrink-0">
|
||||
<div className="grid place-items-center h-9 w-9 rounded">
|
||||
{invitation.workspace.logo && invitation.workspace.logo !== "" ? (
|
||||
<img
|
||||
src={invitation.workspace.logo}
|
||||
height="100%"
|
||||
width="100%"
|
||||
className="rounded"
|
||||
alt={invitation.workspace.name}
|
||||
/>
|
||||
) : (
|
||||
<span className="grid place-items-center h-9 w-9 py-1.5 px-3 rounded bg-gray-700 uppercase text-white">
|
||||
{invitation.workspace.name[0]}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium">
|
||||
{truncateText(invitation.workspace.name, 30)}
|
||||
</div>
|
||||
<p className="text-xs text-custom-text-200">{ROLE[invitation.role]}</p>
|
||||
</div>
|
||||
<span
|
||||
className={`flex-shrink-0 ${
|
||||
isSelected ? "text-custom-primary-100" : "text-custom-text-200"
|
||||
return (
|
||||
<div
|
||||
key={invitation.id}
|
||||
className={`flex cursor-pointer items-center gap-2 border py-5 px-3.5 rounded ${
|
||||
isSelected
|
||||
? "border-custom-primary-100"
|
||||
: "border-custom-border-200 hover:bg-custom-background-80"
|
||||
}`}
|
||||
onClick={() =>
|
||||
handleInvitation(invitation, isSelected ? "withdraw" : "accepted")
|
||||
}
|
||||
>
|
||||
<CheckCircleIcon className="h-5 w-5" />
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<PrimaryButton
|
||||
type="submit"
|
||||
size="md"
|
||||
onClick={submitInvitations}
|
||||
disabled={isJoiningWorkspaces || invitationsRespond.length === 0}
|
||||
>
|
||||
Accept & Join
|
||||
</PrimaryButton>
|
||||
<Link href="/">
|
||||
<a>
|
||||
<SecondaryButton size="md" outline>
|
||||
Go Home
|
||||
</SecondaryButton>
|
||||
</a>
|
||||
</Link>
|
||||
<div className="flex-shrink-0">
|
||||
<div className="grid place-items-center h-9 w-9 rounded">
|
||||
{invitation.workspace.logo && invitation.workspace.logo !== "" ? (
|
||||
<img
|
||||
src={invitation.workspace.logo}
|
||||
height="100%"
|
||||
width="100%"
|
||||
className="rounded"
|
||||
alt={invitation.workspace.name}
|
||||
/>
|
||||
) : (
|
||||
<span className="grid place-items-center h-9 w-9 py-1.5 px-3 rounded bg-gray-700 uppercase text-white">
|
||||
{invitation.workspace.name[0]}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium">
|
||||
{truncateText(invitation.workspace.name, 30)}
|
||||
</div>
|
||||
<p className="text-xs text-custom-text-200">{ROLE[invitation.role]}</p>
|
||||
</div>
|
||||
<span
|
||||
className={`flex-shrink-0 ${
|
||||
isSelected ? "text-custom-primary-100" : "text-custom-text-200"
|
||||
}`}
|
||||
>
|
||||
<CheckCircleIcon className="h-5 w-5" />
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<PrimaryButton
|
||||
type="submit"
|
||||
size="md"
|
||||
onClick={submitInvitations}
|
||||
disabled={isJoiningWorkspaces || invitationsRespond.length === 0}
|
||||
>
|
||||
Accept & Join
|
||||
</PrimaryButton>
|
||||
<Link href="/">
|
||||
<a>
|
||||
<SecondaryButton size="md" outline>
|
||||
Go Home
|
||||
</SecondaryButton>
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="fixed top-0 left-0 h-full w-full grid place-items-center">
|
||||
<EmptyState
|
||||
title="No pending invites"
|
||||
description="You can see here if someone invites you to a workspace."
|
||||
image={emptyInvitation}
|
||||
buttonText="Back to Dashboard"
|
||||
onClick={() => router.push("/")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
) : (
|
||||
<div className="fixed top-0 left-0 h-full w-full grid place-items-center">
|
||||
<EmptyState
|
||||
title="No pending invites"
|
||||
description="You can see here if someone invites you to a workspace."
|
||||
image={emptyInvitation}
|
||||
buttonText="Back to Dashboard"
|
||||
onClick={() => router.push("/")}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
</DefaultLayout>
|
||||
</UserAuthorizationLayout>
|
||||
|
||||
@@ -100,7 +100,7 @@ const ResetPasswordPage: NextPage = () => {
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
<div className="grid place-items-center h-full w-full overflow-y-auto px-7">
|
||||
<div className="grid place-items-center h-full w-full overflow-y-auto py-5 px-7">
|
||||
<div className="w-full">
|
||||
<h1 className="text-center text-2xl sm:text-2.5xl font-semibold text-custom-text-100">
|
||||
Reset your password
|
||||
@@ -121,7 +121,7 @@ const ResetPasswordPage: NextPage = () => {
|
||||
}}
|
||||
error={errors.password}
|
||||
placeholder="Enter new password..."
|
||||
className="border-custom-border-300"
|
||||
className="border-custom-border-300 h-[46px]"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
@@ -135,10 +135,14 @@ const ResetPasswordPage: NextPage = () => {
|
||||
}}
|
||||
error={errors.confirmPassword}
|
||||
placeholder="Confirm new password..."
|
||||
className="border-custom-border-300"
|
||||
className="border-custom-border-300 h-[46px]"
|
||||
/>
|
||||
</div>
|
||||
<PrimaryButton type="submit" className="w-full text-center" loading={isSubmitting}>
|
||||
<PrimaryButton
|
||||
type="submit"
|
||||
className="w-full text-center h-[46px]"
|
||||
loading={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? "Resetting..." : "Reset"}
|
||||
</PrimaryButton>
|
||||
</form>
|
||||
|
||||
@@ -86,7 +86,7 @@ const SignUp: NextPage = () => {
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
<div className="grid place-items-center h-full w-full overflow-y-auto px-7">
|
||||
<div className="grid place-items-center h-full w-full overflow-y-auto py-5 px-7">
|
||||
<div>
|
||||
<EmailPasswordForm onSubmit={handleSignUp} />
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<svg width="100" height="100" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M18.75 87.5C17.0833 87.5 15.625 86.875 14.375 85.625C13.125 84.375 12.5 82.9167 12.5 81.25V26.7708C12.5 25.7292 12.6042 24.8438 12.8125 24.1146C13.0208 23.3854 13.4028 22.7083 13.9583 22.0833L19.7917 14.1667C20.3472 13.5417 20.9896 13.1076 21.7188 12.8646C22.4479 12.6215 23.312 12.5 24.311 12.5H75.689C76.688 12.5 77.5347 12.6215 78.2292 12.8646C78.9236 13.1076 79.5486 13.5417 80.1042 14.1667L86.0417 22.0833C86.5972 22.7083 86.9792 23.3854 87.1875 24.1146C87.3958 24.8438 87.5 25.7292 87.5 26.7708V81.25C87.5 82.9167 86.875 84.375 85.625 85.625C84.375 86.875 82.9167 87.5 81.25 87.5H18.75ZM20.5208 23.5417H79.375L75.5824 18.75H24.2708L20.5208 23.5417ZM18.75 29.7917V81.25H81.25V29.7917H18.75ZM50 71.875L66.25 55.625L62.0833 51.4583L53.125 60.4167V39.4792H46.875V60.4167L37.9167 51.4583L33.75 55.625L50 71.875Z" fill="#ACB5BD"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 947 B |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user