Merge branch 'chore/api_endpoints' of github.com:makeplane/plane into develop-deploy

This commit is contained in:
pablohashescobar 2023-11-22 15:35:08 +05:30
commit 9891722b5e
95 changed files with 2051 additions and 1595 deletions

View File

@ -308,5 +308,5 @@ class IssueActivitySerializer(BaseSerializer):
model = IssueActivity
exclude = [
"created_by",
"udpated_by",
"updated_by",
]

View File

@ -13,7 +13,7 @@ urlpatterns = [
name="cycles",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/cycles/<uuid:cycle_id>/",
"workspaces/<str:slug>/projects/<uuid:project_id>/cycles/<uuid:pk>/",
CycleAPIEndpoint.as_view(),
name="cycles",
),
@ -23,7 +23,7 @@ urlpatterns = [
name="cycle-issues",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/cycles/<uuid:cycle_id>/cycle-issues/<uuid:pk>/",
"workspaces/<str:slug>/projects/<uuid:project_id>/cycles/<uuid:cycle_id>/cycle-issues/<uuid:issue_id>/",
CycleIssueAPIEndpoint.as_view(),
name="cycle-issues",
),

View File

@ -20,22 +20,22 @@ urlpatterns = [
name="issue",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issue-labels/",
"workspaces/<str:slug>/projects/<uuid:project_id>/labels/",
LabelAPIEndpoint.as_view(),
name="label",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issue-labels/<uuid:pk>/",
"workspaces/<str:slug>/projects/<uuid:project_id>/labels/<uuid:pk>/",
LabelAPIEndpoint.as_view(),
name="label",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/issue-links/",
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/links/",
IssueLinkAPIEndpoint.as_view(),
name="link",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/issue-links/<uuid:pk>/",
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/links/<uuid:pk>/",
IssueLinkAPIEndpoint.as_view(),
name="link",
),
@ -50,12 +50,12 @@ urlpatterns = [
name="comment",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/activites/",
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/activities/",
IssueActivityAPIEndpoint.as_view(),
name="activity",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/activites/<uuid:pk>/",
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/activities/<uuid:pk>/",
IssueActivityAPIEndpoint.as_view(),
name="activity",
),

View File

@ -19,7 +19,7 @@ urlpatterns = [
name="module-issues",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/modules/<uuid:module_id>/module-issues/<uuid:pk>/",
"workspaces/<str:slug>/projects/<uuid:project_id>/modules/<uuid:module_id>/module-issues/<uuid:issue_id>/",
ModuleIssueAPIEndpoint.as_view(),
name="module-issues",
),

View File

@ -8,4 +8,9 @@ urlpatterns = [
StateAPIEndpoint.as_view(),
name="states",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/states/<uuid:state_id>/",
StateAPIEndpoint.as_view(),
name="states",
),
]

View File

@ -7,7 +7,6 @@ from django.conf import settings
from django.db import IntegrityError
from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.utils import timezone
from django.core.serializers.json import DjangoJSONEncoder
# Third party imports
from rest_framework.views import APIView
@ -36,20 +35,43 @@ class TimezoneMixin:
else:
timezone.deactivate()
class WebhookMixin:
webhook_event = None
def finalize_response(self, request, response, *args, **kwargs):
response = super().finalize_response(request, response, *args, **kwargs)
# Check for the case should webhook be sent
if (
self.webhook_event
and self.request.method in ["POST", "PATCH", "DELETE"]
and self.request.method in ["POST", "PATCH"]
and response.status_code in [200, 201, 204]
):
# Get the id
object_id = (
response.data.get("id") if isinstance(response.data, dict) else None
)
# Push the object to delay
send_webhook.delay(
event=self.webhook_event,
event_data=json.dumps(response.data, cls=DjangoJSONEncoder),
event_id=object_id,
action=self.request.method,
slug=self.workspace_slug,
)
# Check for the case should webhook be sent
if (
self.webhook_event
and self.request.method in ["DELETE"]
and response.status_code in [204]
):
# Get the id
object_id = self.kwargs.get("pk")
# Push the object to delay
send_webhook.delay(
event=self.webhook_event,
event_id=object_id,
action=self.request.method,
slug=self.workspace_slug,
)
@ -57,7 +79,6 @@ class WebhookMixin:
return response
class BaseAPIView(TimezoneMixin, APIView, BasePaginator):
authentication_classes = [
APIKeyAuthentication,
@ -139,13 +160,13 @@ class BaseAPIView(TimezoneMixin, APIView, BasePaginator):
response = super().finalize_response(request, response, *args, **kwargs)
# Add custom headers if they exist in the request META
ratelimit_remaining = request.META.get('X-RateLimit-Remaining')
ratelimit_remaining = request.META.get("X-RateLimit-Remaining")
if ratelimit_remaining is not None:
response['X-RateLimit-Remaining'] = ratelimit_remaining
response["X-RateLimit-Remaining"] = ratelimit_remaining
ratelimit_reset = request.META.get('X-RateLimit-Reset')
ratelimit_reset = request.META.get("X-RateLimit-Reset")
if ratelimit_reset is not None:
response['X-RateLimit-Reset'] = ratelimit_reset
response["X-RateLimit-Reset"] = ratelimit_reset
return response
@ -169,4 +190,4 @@ class BaseAPIView(TimezoneMixin, APIView, BasePaginator):
expand = [
expand for expand in self.request.GET.get("expand", "").split(",") if expand
]
return expand if expand else None
return expand if expand else None

View File

@ -141,7 +141,6 @@ class CycleAPIEndpoint(WebhookMixin, BaseAPIView):
)
queryset = self.get_queryset()
cycle_view = request.GET.get("cycle_view", "all")
queryset = queryset.order_by("-is_favorite", "-created_at")
# Current Cycle
if cycle_view == "current":
@ -292,7 +291,7 @@ class CycleAPIEndpoint(WebhookMixin, BaseAPIView):
}
),
actor_id=str(request.user.id),
issue_id=str(pk),
issue_id=None,
project_id=str(project_id),
current_instance=None,
epoch=int(timezone.now().timestamp()),
@ -304,8 +303,8 @@ class CycleAPIEndpoint(WebhookMixin, BaseAPIView):
class CycleIssueAPIEndpoint(WebhookMixin, BaseAPIView):
"""
This viewset automatically provides `list`, `create`, `retrieve`,
`update` and `destroy` actions related to cycle issues.
This viewset automatically provides `list`, `create`,
and `destroy` actions related to cycle issues.
"""
@ -456,7 +455,7 @@ class CycleIssueAPIEndpoint(WebhookMixin, BaseAPIView):
# Capture Issue Activity
issue_activity.delay(
type="cycle.activity.created",
requested_data=json.dumps({"cycles_list": issues}),
requested_data=json.dumps({"cycles_list": str(issues)}),
actor_id=str(self.request.user.id),
issue_id=None,
project_id=str(self.kwargs.get("project_id", None)),
@ -477,9 +476,9 @@ class CycleIssueAPIEndpoint(WebhookMixin, BaseAPIView):
status=status.HTTP_200_OK,
)
def delete(self, request, slug, project_id, cycle_id, pk):
def delete(self, request, slug, project_id, cycle_id, issue_id):
cycle_issue = CycleIssue.objects.get(
pk=pk, workspace__slug=slug, project_id=project_id, cycle_id=cycle_id
issue_id=issue_id, workspace__slug=slug, project_id=project_id, cycle_id=cycle_id
)
issue_id = cycle_issue.issue_id
cycle_issue.delete()
@ -492,7 +491,7 @@ class CycleIssueAPIEndpoint(WebhookMixin, BaseAPIView):
}
),
actor_id=str(self.request.user.id),
issue_id=str(self.kwargs.get("pk", None)),
issue_id=str(issue_id),
project_id=str(self.kwargs.get("project_id", None)),
current_instance=None,
epoch=int(timezone.now().timestamp()),

View File

@ -40,7 +40,6 @@ from plane.db.models import (
IssueComment,
IssueActivity,
)
from plane.utils.issue_filters import issue_filters
from plane.bgtasks.issue_activites_task import issue_activity
from plane.api.serializers import (
IssueSerializer,
@ -246,6 +245,7 @@ class IssueAPIEndpoint(WebhookMixin, BaseAPIView):
current_instance = json.dumps(
IssueSerializer(issue).data, cls=DjangoJSONEncoder
)
issue.delete()
issue_activity.delay(
type="issue.activity.deleted",
requested_data=json.dumps({"issue_id": str(pk)}),
@ -255,7 +255,6 @@ class IssueAPIEndpoint(WebhookMixin, BaseAPIView):
current_instance=current_instance,
epoch=int(timezone.now().timestamp()),
)
issue.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@ -274,7 +273,7 @@ class LabelAPIEndpoint(BaseAPIView):
def get_queryset(self):
return (
Project.objects.filter(workspace__slug=self.kwargs.get("slug"))
Label.objects.filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id"))
.filter(project__project_projectmember__member=self.request.user)
.select_related("project")
@ -298,29 +297,29 @@ class LabelAPIEndpoint(BaseAPIView):
)
def get(self, request, slug, project_id, pk=None):
if pk:
label = self.get_queryset().get(pk=pk)
serializer = LabelSerializer(
label,
fields=self.fields,
expand=self.expand,
if pk is None:
return self.paginate(
request=request,
queryset=(self.get_queryset()),
on_results=lambda labels: LabelSerializer(
labels,
many=True,
fields=self.fields,
expand=self.expand,
).data,
)
return Response(serializer.data, status=status.HTTP_200_OK)
return self.paginate(
request=request,
queryset=(self.get_queryset()),
on_results=lambda labels: LabelSerializer(
labels,
many=True,
fields=self.fields,
expand=self.expand,
).data,
)
label = self.get_queryset().get(pk=pk)
serializer = LabelSerializer(label, fields=self.fields, expand=self.expand,)
return Response(serializer.data, status=status.HTTP_200_OK)
def patch(self, request, slug, project_id, pk=None):
label = self.get_queryset().get(pk=pk)
serializer = LabelSerializer(label, data=request.data, partial=True)
return Response(serializer.data, status=status.HTTP_200_OK)
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def delete(self, request, slug, project_id, pk=None):
label = self.get_queryset().get(pk=pk)
@ -352,25 +351,31 @@ class IssueLinkAPIEndpoint(BaseAPIView):
.distinct()
)
def get(self, request, slug, project_id, pk=None):
if pk:
label = self.get_queryset().get(pk=pk)
def get(self, request, slug, project_id, issue_id, pk=None):
if pk is None:
issue_links = self.get_queryset()
serializer = IssueLinkSerializer(
label,
issue_links,
fields=self.fields,
expand=self.expand,
)
return Response(serializer.data, status=status.HTTP_200_OK)
return self.paginate(
request=request,
queryset=(self.get_queryset()),
on_results=lambda issue_links: IssueLinkSerializer(
issue_links,
many=True,
fields=self.fields,
expand=self.expand,
).data,
return self.paginate(
request=request,
queryset=(self.get_queryset()),
on_results=lambda issue_links: IssueLinkSerializer(
issue_links,
many=True,
fields=self.fields,
expand=self.expand,
).data,
)
issue_link = self.get_queryset().get(pk=pk)
serializer = IssueLinkSerializer(
issue_link,
fields=self.fields,
expand=self.expand,
)
return Response(serializer.data, status=status.HTTP_200_OK)
def post(self, request, slug, project_id, issue_id):
serializer = IssueLinkSerializer(data=request.data)
@ -445,7 +450,7 @@ class IssueCommentAPIEndpoint(WebhookMixin, BaseAPIView):
serializer_class = IssueCommentSerializer
model = IssueComment
webhook_event = "issue-comment"
webhook_event = "issue_comment"
permission_classes = [
ProjectLitePermission,
]
@ -583,7 +588,7 @@ class IssueActivityAPIEndpoint(BaseAPIView):
serializer = IssueActivitySerializer(issue_activities)
return Response(serializer.data, status=status.HTTP_200_OK)
self.paginate(
return self.paginate(
request=request,
queryset=(issue_activities),
on_results=lambda issue_activity: IssueActivitySerializer(

View File

@ -129,6 +129,14 @@ class ModuleAPIEndpoint(WebhookMixin, BaseAPIView):
serializer = ModuleSerializer(module)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def patch(self, request, slug, project_id, pk):
module = Module.objects.get(pk=pk, project_id=project_id, workspace__slug=slug)
serializer = ModuleSerializer(module, data=request.data)
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def get(self, request, slug, project_id, pk=None):
if pk:
@ -168,7 +176,7 @@ class ModuleAPIEndpoint(WebhookMixin, BaseAPIView):
}
),
actor_id=str(request.user.id),
issue_id=str(pk),
issue_id=None,
project_id=str(project_id),
current_instance=None,
epoch=int(timezone.now().timestamp()),
@ -323,7 +331,7 @@ class ModuleIssueAPIEndpoint(WebhookMixin, BaseAPIView):
# Capture Issue Activity
issue_activity.delay(
type="module.activity.created",
requested_data=json.dumps({"modules_list": issues}),
requested_data=json.dumps({"modules_list": str(issues)}),
actor_id=str(self.request.user.id),
issue_id=None,
project_id=str(self.kwargs.get("project_id", None)),
@ -343,9 +351,9 @@ class ModuleIssueAPIEndpoint(WebhookMixin, BaseAPIView):
status=status.HTTP_200_OK,
)
def delete(self, request, slug, project_id, module_id, pk):
def delete(self, request, slug, project_id, module_id, issue_id):
module_issue = ModuleIssue.objects.get(
workspace__slug=slug, project_id=project_id, module_id=module_id, pk=pk
workspace__slug=slug, project_id=project_id, module_id=module_id, issue_id=issue_id
)
module_issue.delete()
issue_activity.delay(
@ -357,7 +365,7 @@ class ModuleIssueAPIEndpoint(WebhookMixin, BaseAPIView):
}
),
actor_id=str(request.user.id),
issue_id=str(pk),
issue_id=str(issue_id),
project_id=str(project_id),
current_instance=None,
epoch=int(timezone.now().timestamp()),

View File

@ -281,4 +281,9 @@ class ProjectAPIEndpoint(WebhookMixin, BaseAPIView):
return Response(
{"identifier": "The project identifier is already taken"},
status=status.HTTP_410_GONE,
)
)
def delete(self, request, slug, project_id):
project = Project.objects.get(pk=project_id, workspace__slug=slug)
project.delete()
return Response(status=status.HTTP_204_NO_CONTENT)

View File

@ -23,10 +23,8 @@ class StateAPIEndpoint(BaseAPIView):
]
def get_queryset(self):
return self.filter_queryset(
super()
.get_queryset()
.filter(workspace__slug=self.kwargs.get("slug"))
return (
State.objects.filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id"))
.filter(project__project_projectmember__member=self.request.user)
.filter(~Q(name="Triage"))
@ -42,9 +40,9 @@ class StateAPIEndpoint(BaseAPIView):
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def get(self, request, slug, project_id, pk=None):
if pk:
serializer = StateSerializer(self.get_queryset().get(pk=pk))
def get(self, request, slug, project_id, state_id=None):
if state_id:
serializer = StateSerializer(self.get_queryset().get(pk=state_id))
return Response(serializer.data, status=status.HTTP_200_OK)
return self.paginate(
request=request,
@ -57,10 +55,10 @@ class StateAPIEndpoint(BaseAPIView):
).data,
)
def delete(self, request, slug, project_id, pk):
def delete(self, request, slug, project_id, state_id):
state = State.objects.get(
~Q(name="Triage"),
pk=pk,
pk=state_id,
project_id=project_id,
workspace__slug=slug,
)
@ -69,7 +67,7 @@ class StateAPIEndpoint(BaseAPIView):
return Response({"error": "Default state cannot be deleted"}, status=False)
# Check for any issues in the state
issue_exist = Issue.issue_objects.filter(state=pk).exists()
issue_exist = Issue.issue_objects.filter(state=state_id).exists()
if issue_exist:
return Response(
@ -80,8 +78,8 @@ class StateAPIEndpoint(BaseAPIView):
state.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
def patch(self, request, slug, project_id, pk=None):
state = State.objects.filter(workspace__slug=slug, project_id=project_id, pk=pk)
def patch(self, request, slug, project_id, state_id=None):
state = State.objects.filter(workspace__slug=slug, project_id=project_id, pk=state_id)
serializer = StateSerializer(state, data=request.data, partial=True)
if serializer.is_valid():
serializer.save()

View File

@ -1,3 +1,9 @@
# Python imports
import urllib
import socket
import ipaddress
from urllib.parse import urlparse
# Third party imports
from rest_framework import serializers
@ -9,6 +15,42 @@ from plane.db.models.webhook import validate_domain, validate_schema
class WebhookSerializer(DynamicBaseSerializer):
url = serializers.URLField(validators=[validate_schema, validate_domain])
def validate(self, data):
url = data.get("url", None)
# Extract the hostname from the URL
hostname = urlparse(url).hostname
if not hostname:
raise serializers.ValidationError({"url": "Invalid URL: No hostname found."})
# Resolve the hostname to IP addresses
try:
ip_addresses = socket.getaddrinfo(hostname, None)
except socket.gaierror:
raise serializers.ValidationError({"url": "Hostname could not be resolved."})
if not ip_addresses:
raise serializers.ValidationError({"url": "No IP addresses found for the hostname."})
for addr in ip_addresses:
ip = ipaddress.ip_address(addr[4][0])
if ip.is_private or ip.is_loopback:
raise serializers.ValidationError({"url": "URL resolves to a blocked IP address."})
# Additional validation for multiple request domains and their subdomains
request = self.context.get('request')
disallowed_domains = ['plane.so',] # Add your disallowed domains here
if request:
request_host = request.get_host().split(':')[0] # Remove port if present
disallowed_domains.append(request_host)
# Check if hostname is a subdomain or exact match of any disallowed domain
if any(hostname == domain or hostname.endswith('.' + domain) for domain in disallowed_domains):
raise serializers.ValidationError({"url": "URL domain or its subdomain is not allowed."})
return data
class Meta:
model = Webhook
fields = "__all__"

View File

@ -46,22 +46,26 @@ class WebhookMixin:
def finalize_response(self, request, response, *args, **kwargs):
response = super().finalize_response(request, response, *args, **kwargs)
if (
self.webhook_event
and self.request.method in ["POST", "PATCH", "DELETE"]
and self.request.method in ["POST", "PATCH"]
and response.status_code in [200, 201, 204]
):
# Get the id
object_id = (
response.data.get("id") if isinstance(response.data, dict) else None
)
send_webhook.delay(
event=self.webhook_event,
event_data=json.dumps(response.data, cls=DjangoJSONEncoder),
event_id=object_id,
action=self.request.method,
slug=self.workspace_slug,
)
return response
class BaseViewSet(TimezoneMixin, ModelViewSet, BasePaginator):
model = None

View File

@ -688,7 +688,6 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet):
pk=pk, workspace__slug=slug, project_id=project_id, cycle_id=cycle_id
)
issue_id = cycle_issue.issue_id
cycle_issue.delete()
issue_activity.delay(
type="cycle.activity.deleted",
requested_data=json.dumps(
@ -698,11 +697,12 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet):
}
),
actor_id=str(self.request.user.id),
issue_id=str(self.kwargs.get("pk", None)),
issue_id=str(cycle_issue.issue_id),
project_id=str(self.kwargs.get("project_id", None)),
current_instance=None,
epoch=int(timezone.now().timestamp()),
)
cycle_issue.delete()
return Response(status=status.HTTP_204_NO_CONTENT)

View File

@ -1,6 +1,6 @@
# Python imports
import requests
import os
# Third party imports
from openai import OpenAI
from rest_framework.response import Response
@ -85,14 +85,22 @@ class ReleaseNotesEndpoint(BaseAPIView):
class UnsplashEndpoint(BaseAPIView):
def get(self, request):
instance_configuration = InstanceConfiguration.objects.values("key", "value")
unsplash_access_key = get_configuration_value(instance_configuration, "UNSPLASH_ACCESS_KEY", os.environ.get("UNSPLASH_ACCESS_KEY"))
# Check unsplash access key
if not unsplash_access_key:
return Response([], status=status.HTTP_200_OK)
# Query parameters
query = request.GET.get("query", False)
page = request.GET.get("page", 1)
per_page = request.GET.get("per_page", 20)
url = (
f"https://api.unsplash.com/search/photos/?client_id={settings.UNSPLASH_ACCESS_KEY}&query={query}&page=${page}&per_page={per_page}"
f"https://api.unsplash.com/search/photos/?client_id={unsplash_access_key}&query={query}&page=${page}&per_page={per_page}"
if query
else f"https://api.unsplash.com/photos/?client_id={settings.UNSPLASH_ACCESS_KEY}&page={page}&per_page={per_page}"
else f"https://api.unsplash.com/photos/?client_id={unsplash_access_key}&page={page}&per_page={per_page}"
)
headers = {

View File

@ -298,6 +298,7 @@ class IssueViewSet(WebhookMixin, BaseViewSet):
current_instance = json.dumps(
IssueSerializer(issue).data, cls=DjangoJSONEncoder
)
issue.delete()
issue_activity.delay(
type="issue.activity.deleted",
requested_data=json.dumps({"issue_id": str(pk)}),
@ -307,7 +308,6 @@ class IssueViewSet(WebhookMixin, BaseViewSet):
current_instance=current_instance,
epoch=int(timezone.now().timestamp()),
)
issue.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@ -596,7 +596,7 @@ class IssueActivityEndpoint(BaseAPIView):
class IssueCommentViewSet(WebhookMixin, BaseViewSet):
serializer_class = IssueCommentSerializer
model = IssueComment
webhook_event = "issue-comment"
webhook_event = "issue_comment"
permission_classes = [
ProjectLitePermission,
]

View File

@ -461,7 +461,6 @@ class ModuleIssueViewSet(BaseViewSet):
module_issue = ModuleIssue.objects.get(
workspace__slug=slug, project_id=project_id, module_id=module_id, pk=pk
)
module_issue.delete()
issue_activity.delay(
type="module.activity.deleted",
requested_data=json.dumps(
@ -471,11 +470,12 @@ class ModuleIssueViewSet(BaseViewSet):
}
),
actor_id=str(request.user.id),
issue_id=str(pk),
issue_id=str(module_issue.issue_id),
project_id=str(project_id),
current_instance=None,
epoch=int(timezone.now().timestamp()),
)
module_issue.delete()
return Response(status=status.HTTP_204_NO_CONTENT)

View File

@ -48,7 +48,7 @@ class UserEndpoint(BaseViewSet):
if WorkspaceMember.objects.filter(member=request.user, is_active=True).exists():
return Response(
{
"error": "User cannot deactivate account as user is active in some workspaces"
"error": "You cannot deactivate account as you are a member in some workspaces."
},
status=status.HTTP_400_BAD_REQUEST,
)

View File

@ -20,9 +20,10 @@ class WebhookEndpoint(BaseAPIView):
def post(self, request, slug):
workspace = Workspace.objects.get(slug=slug)
try:
serializer = WebhookSerializer(data=request.data)
serializer = WebhookSerializer(
data=request.data, context={"request": request}
)
if serializer.is_valid():
serializer.save(workspace_id=workspace.id)
return Response(serializer.data, status=status.HTTP_201_CREATED)
@ -79,6 +80,7 @@ class WebhookEndpoint(BaseAPIView):
serializer = WebhookSerializer(
webhook,
data=request.data,
context={request: request},
partial=True,
fields=(
"id",

View File

@ -590,7 +590,7 @@ class WorkSpaceMemberViewSet(BaseViewSet):
member_with_role=Count(
"project_projectmember",
filter=Q(
project_projectmember__member_id=request.user.id,
project_projectmember__member_id=workspace_member.id,
project_projectmember__role=20,
),
),
@ -600,7 +600,7 @@ class WorkSpaceMemberViewSet(BaseViewSet):
):
return Response(
{
"error": "User is part of some projects where they are the only admin you should leave that project first"
"error": "User is a part of some projects where they are the only admin, they should either leave that project or promote another user to admin."
},
status=status.HTTP_400_BAD_REQUEST,
)
@ -635,7 +635,7 @@ class WorkSpaceMemberViewSet(BaseViewSet):
):
return Response(
{
"error": "You cannot leave the workspace as your the only admin of the workspace you will have to either delete the workspace or create an another admin"
"error": "You cannot leave the workspace as you are the only admin of the workspace you will have to either delete the workspace or promote another user to admin."
},
status=status.HTTP_400_BAD_REQUEST,
)
@ -656,7 +656,7 @@ class WorkSpaceMemberViewSet(BaseViewSet):
):
return Response(
{
"error": "User is part of some projects where they are the only admin you should leave that project first"
"error": "You are a part of some projects where you are the only admin, you should either leave the project or promote another user to admin."
},
status=status.HTTP_400_BAD_REQUEST,
)

View File

@ -21,8 +21,6 @@ def email_verification(first_name, email, token, current_site):
realtivelink = "/request-email-verification/" + "?token=" + str(token)
abs_url = current_site + realtivelink
from_email_string = settings.EMAIL_FROM
subject = "Verify your Email!"
context = {

View File

@ -19,8 +19,6 @@ def forgot_password(first_name, email, uidb64, token, current_site):
realtivelink = f"/accounts/reset-password/?uidb64={uidb64}&token={token}"
abs_url = current_site + realtivelink
from_email_string = settings.EMAIL_FROM
subject = "Reset Your Password - Plane"
context = {

View File

@ -190,6 +190,7 @@ def notifications(type, issue_id, project_id, actor_id, subscriber, issue_activi
issue_activities_created) if issue_activities_created is not None else None
)
if type not in [
"issue.activity.deleted",
"cycle.activity.created",
"cycle.activity.deleted",
"module.activity.created",

View File

@ -25,8 +25,6 @@ def project_invitation(email, project_id, token, current_site, invitor):
relativelink = f"/project-invitations/?invitation_id={project_member_invite.id}&email={email}&slug={project.workspace.slug}&project_id={str(project_id)}"
abs_url = current_site + relativelink
from_email_string = settings.EMAIL_FROM
subject = f"{user.first_name or user.display_name or user.email} invited you to join {project.name} on Plane"
context = {

View File

@ -2,15 +2,63 @@ import requests
import uuid
import hashlib
import json
import hmac
# Django imports
from django.conf import settings
from django.core.serializers.json import DjangoJSONEncoder
# Third party imports
from celery import shared_task
from sentry_sdk import capture_exception
from plane.db.models import Webhook, WebhookLog
from plane.db.models import (
Webhook,
WebhookLog,
Project,
Issue,
Cycle,
Module,
ModuleIssue,
CycleIssue,
IssueComment,
)
from plane.api.serializers import (
ProjectSerializer,
IssueSerializer,
CycleSerializer,
ModuleSerializer,
CycleIssueSerializer,
ModuleIssueSerializer,
IssueCommentSerializer,
)
SERIALIZER_MAPPER = {
"project": ProjectSerializer,
"issue": IssueSerializer,
"cycle": CycleSerializer,
"module": ModuleSerializer,
"cycle_issue": CycleIssueSerializer,
"module_issue": ModuleIssueSerializer,
"issue_comment": IssueCommentSerializer,
}
MODEL_MAPPER = {
"project": Project,
"issue": Issue,
"cycle": Cycle,
"module": Module,
"cycle_issue": CycleIssue,
"module_issue": ModuleIssue,
"issue_comment": IssueComment,
}
def get_model_data(event, event_id):
model = MODEL_MAPPER.get(event)
queryset = model.objects.get(pk=event_id)
serializer = SERIALIZER_MAPPER.get(event)
return serializer(queryset).data
@shared_task(
@ -20,7 +68,7 @@ from plane.db.models import Webhook, WebhookLog
max_retries=5,
retry_jitter=True,
)
def webhook_task(self, webhook, slug, event, event_data, action):
def webhook_task(self, webhook, slug, event, event_id, action):
try:
webhook = Webhook.objects.get(id=webhook, workspace__slug=slug)
@ -31,19 +79,26 @@ def webhook_task(self, webhook, slug, event, event_data, action):
"X-Plane-Event": event,
}
# Your secret key
event_data = get_model_data(event=event, event_id=event_id)
# # Your secret key
event_data = (
json.loads(json.dumps(event_data, cls=DjangoJSONEncoder))
if event_data is not None
else None
)
# Use HMAC for generating signature
if webhook.secret_key:
# Concatenate the data and the secret key
message = event_data + webhook.secret_key
# Create a SHA-256 hash of the message
sha256 = hashlib.sha256()
sha256.update(message.encode("utf-8"))
signature = sha256.hexdigest()
event_data_json = json.dumps(event_data) if event_data is not None else '{}'
hmac_signature = hmac.new(
webhook.secret_key.encode("utf-8"),
event_data_json.encode("utf-8"),
hashlib.sha256
)
signature = hmac_signature.hexdigest()
headers["X-Plane-Signature"] = signature
event_data = json.loads(event_data) if event_data is not None else None
action = {
"POST": "create",
"PATCH": "update",
@ -103,6 +158,7 @@ def webhook_task(self, webhook, slug, event, event_data, action):
raise requests.RequestException()
except Exception as e:
print(e)
if settings.DEBUG:
print(e)
capture_exception(e)
@ -110,7 +166,7 @@ def webhook_task(self, webhook, slug, event, event_data, action):
@shared_task()
def send_webhook(event, event_data, action, slug):
def send_webhook(event, event_id, action, slug):
try:
webhooks = Webhook.objects.filter(workspace__slug=slug, is_active=True)
@ -130,7 +186,7 @@ def send_webhook(event, event_data, action, slug):
webhooks = webhooks.filter(issue_comment=True)
for webhook in webhooks:
webhook_task.delay(webhook.id, slug, event, event_data, action)
webhook_task.delay(webhook.id, slug, event, event_id, action)
except Exception as e:
if settings.DEBUG:

View File

@ -32,9 +32,6 @@ def workspace_invitation(email, workspace_id, token, current_site, invitor):
# The complete url including the domain
abs_url = current_site + relative_link
# The email from
from_email_string = settings.EMAIL_FROM
# Subject of the email
subject = f"{user.first_name or user.display_name or user.email} invited you to join {workspace.name} on Plane"

View File

@ -16,7 +16,6 @@ def generate_token():
def validate_schema(value):
parsed_url = urlparse(value)
print(parsed_url)
if parsed_url.scheme not in ["http", "https"]:
raise ValidationError("Invalid schema. Only HTTP and HTTPS are allowed.")

View File

@ -30,9 +30,11 @@ class Command(BaseCommand):
"EMAIL_USE_TLS": os.environ.get("EMAIL_USE_TLS", "1"),
"EMAIL_USE_SSL": os.environ.get("EMAIL_USE_SSL", "0"),
# Open AI Settings
"OPENAI_API_BASE": os.environ.get("", "https://api.openai.com/v1"),
"OPENAI_API_KEY": os.environ.get("OPENAI_API_KEY", "sk-"),
"OPENAI_API_BASE": os.environ.get("OPENAI_API_BASE", "https://api.openai.com/v1"),
"OPENAI_API_KEY": os.environ.get("OPENAI_API_KEY", ""),
"GPT_ENGINE": os.environ.get("GPT_ENGINE", "gpt-3.5-turbo"),
# Unsplash Access Key
"UNSPLASH_ACCESS_KEY": os.environ.get("UNSPLASH_ACESS_KEY", "")
}
for key, value in config_keys.items():

View File

@ -1,19 +1,21 @@
import { Icon } from "lucide-react"
import { Icon } from "lucide-react";
interface IAlertLabelProps {
Icon: Icon,
backgroundColor: string,
textColor?: string,
label: string,
Icon?: Icon;
backgroundColor: string;
textColor?: string;
label: string;
}
export const AlertLabel = ({ Icon, backgroundColor,textColor, label }: IAlertLabelProps) => {
export const AlertLabel = (props: IAlertLabelProps) => {
const { Icon, backgroundColor, textColor, label } = props;
return (
<div className={`text-xs flex items-center gap-1 ${backgroundColor} p-0.5 pl-3 pr-3 mr-1 rounded`}>
<Icon size={12} />
<span className={`normal-case ${textColor}`}>{label}</span>
<div
className={`h-7 flex items-center gap-2 font-medium py-0.5 px-3 rounded-full text-xs ${backgroundColor} ${textColor}`}
>
{Icon && <Icon className="h-3 w-3" />}
<span>{label}</span>
</div>
)
}
);
};

View File

@ -8,33 +8,33 @@ interface ContentBrowserProps {
markings: IMarking[];
}
export const ContentBrowser = ({
editor,
markings,
}: ContentBrowserProps) => (
<div className="mt-4 flex w-[250px] flex-col h-full">
<h2 className="ml-4 border-b border-solid border-custom-border py-5 font-medium leading-[85.714%] tracking-tight max-md:ml-2.5">
Table of Contents
</h2>
<div className="mt-3 h-0.5 w-full self-stretch border-custom-border" />
{markings.length !== 0 ? (
markings.map((marking) =>
marking.level === 1 ? (
<HeadingComp
onClick={() => scrollSummary(editor, marking)}
heading={marking.text}
/>
export const ContentBrowser = (props: ContentBrowserProps) => {
const { editor, markings } = props;
return (
<div className="flex flex-col h-full overflow-hidden">
<h2 className="font-medium">Table of Contents</h2>
<div className="h-full overflow-y-auto">
{markings.length !== 0 ? (
markings.map((marking) =>
marking.level === 1 ? (
<HeadingComp
onClick={() => scrollSummary(editor, marking)}
heading={marking.text}
/>
) : (
<SubheadingComp
onClick={() => scrollSummary(editor, marking)}
subHeading={marking.text}
/>
),
)
) : (
<SubheadingComp
onClick={() => scrollSummary(editor, marking)}
subHeading={marking.text}
/>
)
)
) : (
<p className="ml-3 mr-3 flex h-full items-center px-5 text-center text-xs text-gray-500">
{"Headings will be displayed here for Navigation"}
</p>
)}
</div>
);
<p className="mt-3 text-xs text-custom-text-400">
Headings will be displayed here for navigation
</p>
)}
</div>
</div>
);
};

View File

@ -1,79 +1,90 @@
import { Editor } from "@tiptap/react"
import { Lock, ArchiveIcon, MenuSquare } from "lucide-react"
import { useRef, useState } from "react"
import { usePopper } from "react-popper"
import { IMarking, UploadImage } from ".."
import { FixedMenu } from "../menu"
import { DocumentDetails } from "../types/editor-types"
import { AlertLabel } from "./alert-label"
import { ContentBrowser } from "./content-browser"
import { IVerticalDropdownItemProps, VerticalDropdownMenu } from "./vertical-dropdown-menu"
import { Editor } from "@tiptap/react";
import { Archive, Info, Lock } from "lucide-react";
import { IMarking, UploadImage } from "..";
import { FixedMenu } from "../menu";
import { DocumentDetails } from "../types/editor-types";
import { AlertLabel } from "./alert-label";
import {
IVerticalDropdownItemProps,
VerticalDropdownMenu,
} from "./vertical-dropdown-menu";
import { SummaryPopover } from "./summary-popover";
import { InfoPopover } from "./info-popover";
interface IEditorHeader {
editor: Editor,
KanbanMenuOptions: IVerticalDropdownItemProps[],
sidePeakVisible: boolean,
setSidePeakVisible: (currentState: boolean) => void,
markings: IMarking[],
isLocked: boolean,
isArchived: boolean,
archivedAt?: Date,
readonly: boolean,
uploadFile?: UploadImage,
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void,
documentDetails: DocumentDetails
editor: Editor;
KanbanMenuOptions: IVerticalDropdownItemProps[];
sidePeekVisible: boolean;
setSidePeekVisible: (sidePeekState: boolean) => void;
markings: IMarking[];
isLocked: boolean;
isArchived: boolean;
archivedAt?: Date;
readonly: boolean;
uploadFile?: UploadImage;
setIsSubmitting?: (
isSubmitting: "submitting" | "submitted" | "saved",
) => void;
documentDetails: DocumentDetails;
}
export const EditorHeader = ({ documentDetails, archivedAt, editor, sidePeakVisible, readonly, setSidePeakVisible, markings, uploadFile, setIsSubmitting, KanbanMenuOptions, isArchived, isLocked }: IEditorHeader) => {
const summaryMenuRef = useRef(null);
const summaryButtonRef = useRef(null);
const [summaryPopoverVisible, setSummaryPopoverVisible] = useState(false);
const { styles: summaryPopoverStyles, attributes: summaryPopoverAttributes } = usePopper(summaryButtonRef.current, summaryMenuRef.current, {
placement: "bottom-start"
})
export const EditorHeader = (props: IEditorHeader) => {
const {
documentDetails,
archivedAt,
editor,
sidePeekVisible,
readonly,
setSidePeekVisible,
markings,
uploadFile,
setIsSubmitting,
KanbanMenuOptions,
isArchived,
isLocked,
} = props;
return (
<div className="flex items-center border-b border-custom-border-200 py-2 px-5">
<div className="flex-shrink-0 w-56 lg:w-80">
<SummaryPopover
editor={editor}
markings={markings}
sidePeekVisible={sidePeekVisible}
setSidePeekVisible={setSidePeekVisible}
/>
</div>
<div className="border-custom-border self-stretch flex flex-col border-b border-solid max-md:max-w-full">
<div
className="self-center flex ml-0 w-full items-start justify-between gap-5 max-md:max-w-full max-md:flex-wrap max-md:justify-center">
<div className={"flex flex-row items-center"}>
<div
onMouseEnter={() => setSummaryPopoverVisible(true)}
onMouseLeave={() => setSummaryPopoverVisible(false)}
>
<button
ref={summaryButtonRef}
className={"p-2 text-custom-text-300 hover:bg-custom-primary-100/5 active:bg-custom-primary-100/5 transition-colors"}
onClick={() => {
setSidePeakVisible(!sidePeakVisible)
setSummaryPopoverVisible(false)
}}
>
<MenuSquare
size={20}
/>
</button>
{summaryPopoverVisible &&
<div style={summaryPopoverStyles.popper} {...summaryPopoverAttributes.popper} className="z-10 h-[300px] w-[300px] ml-[40px] mt-[40px] shadow-xl rounded border-custom-border border-solid border-2 bg-custom-background-100 border-b pl-3 pr-3 pb-3 overflow-scroll">
<ContentBrowser editor={editor} markings={markings} />
</div>
}
</div>
{isLocked && <AlertLabel Icon={Lock} backgroundColor={"bg-red-200"} label={"Locked"} />}
{(isArchived && archivedAt) && <AlertLabel Icon={ArchiveIcon} backgroundColor={"bg-blue-200"} label={`Archived at ${new Date(archivedAt).toLocaleString()}`} />}
</div>
<div className="flex-shrink-0">
{!readonly && uploadFile && (
<FixedMenu
editor={editor}
uploadFile={uploadFile}
setIsSubmitting={setIsSubmitting}
/>
)}
</div>
{(!readonly && uploadFile) && <FixedMenu editor={editor} uploadFile={uploadFile} setIsSubmitting={setIsSubmitting} />}
<div className="self-center flex items-start gap-3 my-auto max-md:justify-center"
>
{!isArchived && <p className="text-sm text-custom-text-300">{`Last updated at ${new Date(documentDetails.last_updated_at).toLocaleString()}`}</p>}
<VerticalDropdownMenu items={KanbanMenuOptions} />
</div>
<div className="flex-grow flex items-center justify-end gap-3">
{isLocked && (
<AlertLabel
Icon={Lock}
backgroundColor="bg-custom-background-80"
textColor="text-custom-text-300"
label="Locked"
/>
)}
{isArchived && archivedAt && (
<AlertLabel
Icon={Archive}
backgroundColor="bg-blue-500/20"
textColor="text-blue-500"
label={`Archived at ${new Date(archivedAt).toLocaleString()}`}
/>
)}
{!isArchived && <InfoPopover documentDetails={documentDetails} />}
<VerticalDropdownMenu items={KanbanMenuOptions} />
</div>
</div>
)
}
);
};

View File

@ -0,0 +1,9 @@
export * from "./alert-label";
export * from "./content-browser";
export * from "./editor-header";
export * from "./heading-component";
export * from "./info-popover";
export * from "./page-renderer";
export * from "./summary-popover";
export * from "./summary-side-bar";
export * from "./vertical-dropdown-menu";

View File

@ -0,0 +1,79 @@
import { useState } from "react";
import { usePopper } from "react-popper";
import { Calendar, History, Info } from "lucide-react";
// types
import { DocumentDetails } from "../types/editor-types";
type Props = {
documentDetails: DocumentDetails;
};
// function to render a Date in the format- 25 May 2023 at 2:53PM
const renderDate = (date: Date): string => {
const options: Intl.DateTimeFormatOptions = {
day: "numeric",
month: "long",
year: "numeric",
hour: "numeric",
minute: "numeric",
hour12: true,
};
const formattedDate: string = new Intl.DateTimeFormat(
"en-US",
options,
).format(date);
return formattedDate;
};
export const InfoPopover: React.FC<Props> = (props) => {
const { documentDetails } = props;
const [isPopoverOpen, setIsPopoverOpen] = useState<boolean>(false);
const [referenceElement, setReferenceElement] =
useState<HTMLButtonElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(
null,
);
const { styles: infoPopoverStyles, attributes: infoPopoverAttributes } =
usePopper(referenceElement, popperElement, {
placement: "bottom-start",
});
return (
<div
onMouseEnter={() => setIsPopoverOpen(true)}
onMouseLeave={() => setIsPopoverOpen(false)}
>
<button type="button" ref={setReferenceElement} className="block mt-1.5">
<Info className="h-3.5 w-3.5" />
</button>
{isPopoverOpen && (
<div
className="z-10 w-64 shadow-custom-shadow-rg rounded border-[0.5px] border-custom-border-200 bg-custom-background-100 p-3 space-y-2.5"
ref={setPopperElement}
style={infoPopoverStyles.popper}
{...infoPopoverAttributes.popper}
>
<div className="space-y-1.5">
<h6 className="text-custom-text-400 text-xs">Last updated on</h6>
<h5 className="text-sm flex items-center gap-1">
<History className="h-3 w-3" />
{renderDate(new Date(documentDetails.last_updated_at))}
</h5>
</div>
<div className="space-y-1.5">
<h6 className="text-custom-text-400 text-xs">Created on</h6>
<h5 className="text-sm flex items-center gap-1">
<Calendar className="h-3 w-3" />
{renderDate(new Date(documentDetails.created_on))}
</h5>
</div>
</div>
)}
</div>
);
};

View File

@ -1,33 +1,35 @@
import { EditorContainer, EditorContentWrapper } from "@plane/editor-core"
import { Editor } from "@tiptap/react"
import { DocumentDetails } from "../types/editor-types"
import { EditorContainer, EditorContentWrapper } from "@plane/editor-core";
import { Editor } from "@tiptap/react";
import { DocumentDetails } from "../types/editor-types";
interface IPageRenderer {
sidePeakVisible: boolean,
documentDetails: DocumentDetails ,
editor: Editor,
editorClassNames: string,
editorContentCustomClassNames?: string
documentDetails: DocumentDetails;
editor: Editor;
editorClassNames: string;
editorContentCustomClassNames?: string;
}
export const PageRenderer = ({ sidePeakVisible, documentDetails, editor, editorClassNames, editorContentCustomClassNames }: IPageRenderer) => {
export const PageRenderer = (props: IPageRenderer) => {
const {
documentDetails,
editor,
editorClassNames,
editorContentCustomClassNames,
} = props;
return (
<div className={`flex h-[88vh] flex-col w-full max-md:w-full max-md:ml-0 transition-all duration-200 ease-in-out ${sidePeakVisible ? 'ml-[3%] ' : 'ml-0'}`}>
<div className="items-start mt-4 h-full flex flex-col w-fit max-md:max-w-full overflow-auto">
<div className="flex flex-col py-2 max-md:max-w-full">
<h1
className="border-none outline-none bg-transparent text-4xl font-bold leading-8 tracking-tight self-center w-[700px] max-w-full"
>{documentDetails.title}</h1>
</div>
<div className="border-custom-border border-b border-solid self-stretch w-full h-0.5 mt-3" />
<div className="flex flex-col max-md:max-w-full">
<EditorContainer editor={editor} editorClassNames={editorClassNames}>
<div className="flex flex-col">
<EditorContentWrapper editor={editor} editorContentCustomClassNames={editorContentCustomClassNames} />
</div>
</EditorContainer >
</div>
<div className="h-full w-full overflow-y-auto pl-7 py-5">
<h1 className="text-4xl font-bold break-all pr-5 -mt-2">
{documentDetails.title}
</h1>
<div className="flex flex-col h-full w-full pr-5">
<EditorContainer editor={editor} editorClassNames={editorClassNames}>
<EditorContentWrapper
editor={editor}
editorContentCustomClassNames={editorContentCustomClassNames}
/>
</EditorContainer>
</div>
</div>
)
}
);
};

View File

@ -1,67 +0,0 @@
import React, { Fragment, useState } from "react";
import { usePopper } from "react-popper";
import { Popover, Transition } from "@headlessui/react";
import { Placement } from "@popperjs/core";
// ui
import { Button } from "@plane/ui";
// icons
import { ChevronUp, MenuIcon } from "lucide-react";
type Props = {
children: React.ReactNode;
title?: string;
placement?: Placement;
};
export const SummaryPopover: React.FC<Props> = (props) => {
const { children, title = "SummaryPopover", placement } = props;
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
const { styles, attributes } = usePopper(referenceElement, popperElement, {
placement: placement ?? "auto",
});
return (
<Popover as="div">
{({ open }) => {
if (open) {
}
return (
<>
<Popover.Button as={React.Fragment}>
<Button
ref={setReferenceElement}
variant="neutral-primary"
size="sm"
>
<MenuIcon size={20} />
</Button>
</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>
<div
className="z-10 bg-custom-background-100 border border-custom-border-200 shadow-custom-shadow-rg rounded overflow-hidden"
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
<div className="w-[18.75rem] max-h-[37.5rem] flex flex-col overflow-hidden">{children}</div>
</div>
</Popover.Panel>
</Transition>
</>
);
}}
</Popover>
);
};

View File

@ -0,0 +1,57 @@
import { useState } from "react";
import { Editor } from "@tiptap/react";
import { usePopper } from "react-popper";
import { List } from "lucide-react";
// components
import { ContentBrowser } from "./content-browser";
// types
import { IMarking } from "..";
type Props = {
editor: Editor;
markings: IMarking[];
sidePeekVisible: boolean;
setSidePeekVisible: (sidePeekState: boolean) => void;
};
export const SummaryPopover: React.FC<Props> = (props) => {
const { editor, markings, sidePeekVisible, setSidePeekVisible } = props;
const [referenceElement, setReferenceElement] =
useState<HTMLButtonElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(
null,
);
const { styles: summaryPopoverStyles, attributes: summaryPopoverAttributes } =
usePopper(referenceElement, popperElement, {
placement: "bottom-start",
});
return (
<div className="group/summary-popover w-min whitespace-nowrap">
<button
type="button"
ref={setReferenceElement}
className={`h-7 w-7 grid place-items-center rounded ${
sidePeekVisible
? "bg-custom-primary-100/20 text-custom-primary-100"
: "text-custom-text-300"
}`}
onClick={() => setSidePeekVisible(!sidePeekVisible)}
>
<List className="h-4 w-4" />
</button>
{!sidePeekVisible && (
<div
className="hidden group-hover/summary-popover:block z-10 h-80 w-64 shadow-custom-shadow-rg rounded border-[0.5px] border-custom-border-200 bg-custom-background-100 p-3"
ref={setPopperElement}
style={summaryPopoverStyles.popper}
{...summaryPopoverAttributes.popper}
>
<ContentBrowser editor={editor} markings={markings} />
</div>
)}
</div>
);
};

View File

@ -1,18 +1,25 @@
import { Editor } from "@tiptap/react"
import { IMarking } from ".."
import { ContentBrowser } from "./content-browser"
import { Editor } from "@tiptap/react";
import { IMarking } from "..";
import { ContentBrowser } from "./content-browser";
interface ISummarySideBarProps {
editor: Editor,
markings: IMarking[],
sidePeakVisible: boolean
editor: Editor;
markings: IMarking[];
sidePeekVisible: boolean;
}
export const SummarySideBar = ({ editor, markings, sidePeakVisible }: ISummarySideBarProps) => {
export const SummarySideBar = ({
editor,
markings,
sidePeekVisible,
}: ISummarySideBarProps) => {
return (
<div className={`flex flex-col items-stretch w-[21%] max-md:w-full max-md:ml-0 border-custom-border border-r border-solid transition-all duration-200 ease-in-out transform ${sidePeakVisible ? 'translate-x-0' : '-translate-x-full'}`}>
<div
className={`h-full px-5 pt-5 transition-all duration-200 transform overflow-hidden ${
sidePeekVisible ? "translate-x-0" : "-translate-x-full"
}`}
>
<ContentBrowser editor={editor} markings={markings} />
</div>
)
}
);
};

View File

@ -1,41 +1,52 @@
import { Button, CustomMenu } from "@plane/ui"
import { ChevronUp, Icon, MoreVertical } from "lucide-react"
import { Button, CustomMenu } from "@plane/ui";
import { ChevronUp, Icon, MoreVertical } from "lucide-react";
type TMenuItems = "archive_page" | "unarchive_page" | "lock_page" | "unlock_page" | "copy_markdown" | "close_page" | "copy_page_link" | "duplicate_page"
type TMenuItems =
| "archive_page"
| "unarchive_page"
| "lock_page"
| "unlock_page"
| "copy_markdown"
| "close_page"
| "copy_page_link"
| "duplicate_page";
export interface IVerticalDropdownItemProps {
key: number,
type: TMenuItems,
Icon: Icon,
label: string,
action: () => Promise<void> | void
key: number;
type: TMenuItems;
Icon: Icon;
label: string;
action: () => Promise<void> | void;
}
export interface IVerticalDropdownMenuProps {
items: IVerticalDropdownItemProps[],
items: IVerticalDropdownItemProps[];
}
const VerticalDropdownItem = ({ Icon, label, action }: IVerticalDropdownItemProps) => {
const VerticalDropdownItem = ({
Icon,
label,
action,
}: IVerticalDropdownItemProps) => {
return (
<CustomMenu.MenuItem>
<Button variant={"neutral-primary"} onClick={action} className="flex flex-row border-none items-center m-1 max-md:pr-5 cursor-pointer">
<Icon size={16} />
<div className="text-custom-text-300 ml-2 mr-2 leading-5 tracking-tight whitespace-nowrap self-start text-md">
{label}
</div>
</Button>
<CustomMenu.MenuItem onClick={action} className="flex items-center gap-2">
<Icon className="h-3 w-3" />
<div className="text-custom-text-300">{label}</div>
</CustomMenu.MenuItem>
)
}
);
};
export const VerticalDropdownMenu = ({ items }: IVerticalDropdownMenuProps) => {
return (
<CustomMenu maxHeight={"lg"} className={"h-4"} placement={"bottom-start"} optionsClassName={"border-custom-border border-r border-solid transition-all duration-200 ease-in-out "} customButton={
<MoreVertical size={18}/>
}>
<CustomMenu
maxHeight={"lg"}
className={"h-4"}
placement={"bottom-start"}
optionsClassName={
"border-custom-border border-r border-solid transition-all duration-200 ease-in-out "
}
customButton={<MoreVertical size={14} />}
>
{items.map((item, index) => (
<VerticalDropdownItem
key={index}
@ -46,5 +57,5 @@ export const VerticalDropdownMenu = ({ items }: IVerticalDropdownMenuProps) => {
/>
))}
</CustomMenu>
)
}
);
};

View File

@ -1,34 +1,40 @@
"use client"
import React, { useState } from 'react';
import { cn, getEditorClassNames, useEditor } from '@plane/editor-core';
import { DocumentEditorExtensions } from './extensions';
import { IDuplicationConfig, IPageArchiveConfig, IPageLockConfig } from './types/menu-actions';
import { EditorHeader } from './components/editor-header';
import { useEditorMarkings } from './hooks/use-editor-markings';
import { SummarySideBar } from './components/summary-side-bar';
import { DocumentDetails } from './types/editor-types';
import { PageRenderer } from './components/page-renderer';
import { getMenuOptions } from './utils/menu-options';
import { useRouter } from 'next/router';
"use client";
import React, { useState } from "react";
import { cn, getEditorClassNames, useEditor } from "@plane/editor-core";
import { DocumentEditorExtensions } from "./extensions";
import {
IDuplicationConfig,
IPageArchiveConfig,
IPageLockConfig,
} from "./types/menu-actions";
import { EditorHeader } from "./components/editor-header";
import { useEditorMarkings } from "./hooks/use-editor-markings";
import { SummarySideBar } from "./components/summary-side-bar";
import { DocumentDetails } from "./types/editor-types";
import { PageRenderer } from "./components/page-renderer";
import { getMenuOptions } from "./utils/menu-options";
import { useRouter } from "next/router";
export type UploadImage = (file: File) => Promise<string>;
export type DeleteImage = (assetUrlWithWorkspaceId: string) => Promise<any>;
interface IDocumentEditor {
documentDetails: DocumentDetails,
documentDetails: DocumentDetails;
value: string;
uploadFile: UploadImage;
deleteFile: DeleteImage;
customClassName?: string;
editorContentCustomClassNames?: string;
onChange: (json: any, html: string) => void;
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void;
setIsSubmitting?: (
isSubmitting: "submitting" | "submitted" | "saved",
) => void;
setShouldShowAlert?: (showAlert: boolean) => void;
forwardedRef?: any;
debouncedUpdatesEnabled?: boolean;
duplicationConfig?: IDuplicationConfig,
pageLockConfig?: IPageLockConfig,
pageArchiveConfig?: IPageArchiveConfig
duplicationConfig?: IDuplicationConfig;
pageLockConfig?: IPageLockConfig;
pageArchiveConfig?: IPageArchiveConfig;
}
interface DocumentEditorProps extends IDocumentEditor {
forwardedRef?: React.Ref<EditorHandle>;
@ -40,10 +46,10 @@ interface EditorHandle {
}
export interface IMarking {
type: "heading",
level: number,
text: string,
sequence: number
type: "heading";
level: number;
text: string;
sequence: number;
}
const DocumentEditor = ({
@ -60,21 +66,20 @@ const DocumentEditor = ({
forwardedRef,
duplicationConfig,
pageLockConfig,
pageArchiveConfig
pageArchiveConfig,
}: IDocumentEditor) => {
// const [alert, setAlert] = useState<string>("")
const { markings, updateMarkings } = useEditorMarkings()
const [sidePeakVisible, setSidePeakVisible] = useState(true)
const router = useRouter()
const { markings, updateMarkings } = useEditorMarkings();
const [sidePeekVisible, setSidePeekVisible] = useState(true);
const router = useRouter();
const editor = useEditor({
onChange(json, html) {
updateMarkings(json)
onChange(json, html)
updateMarkings(json);
onChange(json, html);
},
onStart(json) {
updateMarkings(json)
updateMarkings(json);
},
debouncedUpdatesEnabled,
setIsSubmitting,
@ -87,65 +92,66 @@ const DocumentEditor = ({
});
if (!editor) {
return null
return null;
}
const KanbanMenuOptions = getMenuOptions(
{
editor: editor,
router: router,
duplicationConfig: duplicationConfig,
pageLockConfig: pageLockConfig,
pageArchiveConfig: pageArchiveConfig,
}
)
const editorClassNames = getEditorClassNames({ noBorder: true, borderOnFocus: false, customClassName });
const KanbanMenuOptions = getMenuOptions({
editor: editor,
router: router,
duplicationConfig: duplicationConfig,
pageLockConfig: pageLockConfig,
pageArchiveConfig: pageArchiveConfig,
});
const editorClassNames = getEditorClassNames({
noBorder: true,
borderOnFocus: false,
customClassName,
});
if (!editor) return null;
return (
<div className="flex flex-col">
<div className="top-0 sticky z-10 bg-custom-background-100">
<EditorHeader
readonly={false}
KanbanMenuOptions={KanbanMenuOptions}
editor={editor}
sidePeakVisible={sidePeakVisible}
setSidePeakVisible={setSidePeakVisible}
markings={markings}
uploadFile={uploadFile}
setIsSubmitting={setIsSubmitting}
isLocked={!pageLockConfig ? false : pageLockConfig.is_locked}
isArchived={!pageArchiveConfig ? false : pageArchiveConfig.is_archived}
archivedAt={pageArchiveConfig && pageArchiveConfig.archived_at}
documentDetails={documentDetails}
/>
</div>
<div className="self-center items-stretch w-full max-md:max-w-full h-full">
<div className={cn("gap-5 flex max-md:flex-col max-md:items-stretch max-md:gap-0 h-full", { "justify-center": !sidePeakVisible })}>
<div className="h-full w-full flex flex-col overflow-hidden">
<EditorHeader
readonly={false}
KanbanMenuOptions={KanbanMenuOptions}
editor={editor}
sidePeekVisible={sidePeekVisible}
setSidePeekVisible={(val) => setSidePeekVisible(val)}
markings={markings}
uploadFile={uploadFile}
setIsSubmitting={setIsSubmitting}
isLocked={!pageLockConfig ? false : pageLockConfig.is_locked}
isArchived={!pageArchiveConfig ? false : pageArchiveConfig.is_archived}
archivedAt={pageArchiveConfig && pageArchiveConfig.archived_at}
documentDetails={documentDetails}
/>
<div className="h-full w-full flex overflow-hidden">
<div className="flex-shrink-0 h-full w-56 lg:w-80">
<SummarySideBar
editor={editor}
markings={markings}
sidePeakVisible={sidePeakVisible}
sidePeekVisible={sidePeekVisible}
/>
</div>
<div className="h-full w-full">
<PageRenderer
editor={editor}
editorContentCustomClassNames={editorContentCustomClassNames}
editorClassNames={editorClassNames}
sidePeakVisible={sidePeakVisible}
documentDetails={documentDetails}
/>
{/* Page Element */}
</div>
<div className="hidden lg:block flex-shrink-0 w-56 lg:w-80" />
</div>
</div>
);
}
};
const DocumentEditorWithRef = React.forwardRef<EditorHandle, IDocumentEditor>((props, ref) => (
<DocumentEditor {...props} forwardedRef={ref} />
));
const DocumentEditorWithRef = React.forwardRef<EditorHandle, IDocumentEditor>(
(props, ref) => <DocumentEditor {...props} forwardedRef={ref} />,
);
DocumentEditorWithRef.displayName = "DocumentEditorWithRef";
export { DocumentEditor, DocumentEditorWithRef }
export { DocumentEditor, DocumentEditorWithRef };

View File

@ -1,7 +1,22 @@
import { Editor } from "@tiptap/react";
import { BoldIcon, Heading1, Heading2, Heading3 } from "lucide-react";
import { BoldIcon } from "lucide-react";
import { BoldItem, BulletListItem, cn, CodeItem, ImageItem, ItalicItem, NumberedListItem, QuoteItem, StrikeThroughItem, TableItem, UnderLineItem, HeadingOneItem, HeadingTwoItem, HeadingThreeItem } from "@plane/editor-core";
import {
BoldItem,
BulletListItem,
cn,
CodeItem,
ImageItem,
ItalicItem,
NumberedListItem,
QuoteItem,
StrikeThroughItem,
TableItem,
UnderLineItem,
HeadingOneItem,
HeadingTwoItem,
HeadingThreeItem,
} from "@plane/editor-core";
import { UploadImage } from "..";
export interface BubbleMenuItem {
@ -14,77 +29,69 @@ export interface BubbleMenuItem {
type EditorBubbleMenuProps = {
editor: Editor;
uploadFile: UploadImage;
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void;
}
setIsSubmitting?: (
isSubmitting: "submitting" | "submitted" | "saved",
) => void;
};
export const FixedMenu = (props: EditorBubbleMenuProps) => {
const { editor, uploadFile, setIsSubmitting } = props;
const basicMarkItems: BubbleMenuItem[] = [
HeadingOneItem(props.editor),
HeadingTwoItem(props.editor),
HeadingThreeItem(props.editor),
BoldItem(props.editor),
ItalicItem(props.editor),
UnderLineItem(props.editor),
StrikeThroughItem(props.editor),
HeadingOneItem(editor),
HeadingTwoItem(editor),
HeadingThreeItem(editor),
BoldItem(editor),
ItalicItem(editor),
UnderLineItem(editor),
StrikeThroughItem(editor),
];
const listItems: BubbleMenuItem[] = [
BulletListItem(props.editor),
NumberedListItem(props.editor),
BulletListItem(editor),
NumberedListItem(editor),
];
const userActionItems: BubbleMenuItem[] = [
QuoteItem(props.editor),
CodeItem(props.editor),
QuoteItem(editor),
CodeItem(editor),
];
const complexItems: BubbleMenuItem[] = [
TableItem(props.editor),
ImageItem(props.editor, props.uploadFile, props.setIsSubmitting),
TableItem(editor),
ImageItem(editor, uploadFile, setIsSubmitting),
];
// const handleAccessChange = (accessKey: string) => {
// props.commentAccessSpecifier?.onAccessChange(accessKey);
// };
return (
<div
className="flex w-fit rounded bg-custom-background-100"
>
<div className="flex">
{basicMarkItems.map((item, index) => (
<div className="flex items-center divide-x divide-custom-border-200">
<div className="flex items-center gap-0.5 pr-2">
{basicMarkItems.map((item) => (
<button
key={index}
key={item.name}
type="button"
onClick={item.command}
className={cn(
"p-2 text-custom-text-300 hover:bg-custom-primary-100/5 active:bg-custom-primary-100/5 transition-colors",
"h-7 w-7 grid place-items-center text-custom-text-300 hover:bg-custom-background-80 rounded",
{
"text-custom-text-100 bg-custom-primary-100/5": item.isActive(),
}
"text-custom-text-100 bg-custom-background-80": item.isActive(),
},
)}
>
<item.icon
size={ item.icon === Heading1 || item.icon === Heading2 || item.icon === Heading3 ? 20 : 15}
className={cn({
"text-custom-text-100": item.isActive(),
})}
/>
<item.icon className="h-4 w-4" />
</button>
))}
</div>
<div className="flex">
{listItems.map((item, index) => (
<div className="flex items-center gap-0.5 px-2">
{listItems.map((item) => (
<button
key={index}
key={item.name}
type="button"
onClick={item.command}
className={cn(
"p-2 text-custom-text-300 hover:bg-custom-primary-100/5 active:bg-custom-primary-100/5 transition-colors",
"h-7 w-7 grid place-items-center text-custom-text-300 hover:bg-custom-background-80 rounded",
{
"text-custom-text-100 bg-custom-primary-100/5": item.isActive(),
}
"text-custom-text-100 bg-custom-background-80": item.isActive(),
},
)}
>
<item.icon
@ -95,17 +102,17 @@ export const FixedMenu = (props: EditorBubbleMenuProps) => {
</button>
))}
</div>
<div className="flex">
{userActionItems.map((item, index) => (
<div className="flex items-center gap-0.5 px-2">
{userActionItems.map((item) => (
<button
key={index}
key={item.name}
type="button"
onClick={item.command}
className={cn(
"p-2 text-custom-text-300 hover:bg-custom-primary-100/5 active:bg-custom-primary-100/5 transition-colors",
"h-7 w-7 grid place-items-center text-custom-text-300 hover:bg-custom-background-80 rounded",
{
"text-custom-text-100 bg-custom-primary-100/5": item.isActive(),
}
"text-custom-text-100 bg-custom-background-80": item.isActive(),
},
)}
>
<item.icon
@ -116,17 +123,17 @@ export const FixedMenu = (props: EditorBubbleMenuProps) => {
</button>
))}
</div>
<div className="flex">
{complexItems.map((item, index) => (
<div className="flex items-center gap-0.5 pl-2">
{complexItems.map((item) => (
<button
key={index}
key={item.name}
type="button"
onClick={item.command}
className={cn(
"p-2 text-custom-text-300 hover:bg-custom-primary-100/5 active:bg-custom-primary-100/5 transition-colors",
"h-7 w-7 grid place-items-center text-custom-text-300 hover:bg-custom-background-80 rounded",
{
"text-custom-text-100 bg-custom-primary-100/5": item.isActive(),
}
"text-custom-text-100 bg-custom-background-80": item.isActive(),
},
)}
>
<item.icon

View File

@ -1,27 +1,31 @@
import { cn, getEditorClassNames, useReadOnlyEditor } from "@plane/editor-core"
import { cn, getEditorClassNames, useReadOnlyEditor } from "@plane/editor-core";
import { useRouter } from "next/router";
import { useState, forwardRef, useEffect } from 'react'
import { useState, forwardRef, useEffect } from "react";
import { EditorHeader } from "../components/editor-header";
import { PageRenderer } from "../components/page-renderer";
import { SummarySideBar } from "../components/summary-side-bar";
import { useEditorMarkings } from "../hooks/use-editor-markings";
import { DocumentDetails } from "../types/editor-types";
import { IPageArchiveConfig, IPageLockConfig, IDuplicationConfig } from "../types/menu-actions";
import {
IPageArchiveConfig,
IPageLockConfig,
IDuplicationConfig,
} from "../types/menu-actions";
import { getMenuOptions } from "../utils/menu-options";
interface IDocumentReadOnlyEditor {
value: string,
noBorder: boolean,
borderOnFocus: boolean,
customClassName: string,
documentDetails: DocumentDetails,
pageLockConfig?: IPageLockConfig,
pageArchiveConfig?: IPageArchiveConfig,
pageDuplicationConfig?: IDuplicationConfig,
value: string;
noBorder: boolean;
borderOnFocus: boolean;
customClassName: string;
documentDetails: DocumentDetails;
pageLockConfig?: IPageLockConfig;
pageArchiveConfig?: IPageArchiveConfig;
pageDuplicationConfig?: IDuplicationConfig;
}
interface DocumentReadOnlyEditorProps extends IDocumentReadOnlyEditor {
forwardedRef?: React.Ref<EditorHandle>
forwardedRef?: React.Ref<EditorHandle>;
}
interface EditorHandle {
@ -40,32 +44,30 @@ const DocumentReadOnlyEditor = ({
pageLockConfig,
pageArchiveConfig,
}: DocumentReadOnlyEditorProps) => {
const router = useRouter()
const [sidePeakVisible, setSidePeakVisible] = useState(true)
const { markings, updateMarkings } = useEditorMarkings()
const router = useRouter();
const [sidePeekVisible, setSidePeekVisible] = useState(true);
const { markings, updateMarkings } = useEditorMarkings();
const editor = useReadOnlyEditor({
value,
forwardedRef,
})
});
useEffect(() => {
if (editor) {
updateMarkings(editor.getJSON())
updateMarkings(editor.getJSON());
}
}, [editor?.getJSON()])
}, [editor?.getJSON()]);
if (!editor) {
return null
return null;
}
const editorClassNames = getEditorClassNames({
noBorder,
borderOnFocus,
customClassName
})
customClassName,
});
const KanbanMenuOptions = getMenuOptions({
editor: editor,
@ -73,43 +75,42 @@ const DocumentReadOnlyEditor = ({
pageArchiveConfig: pageArchiveConfig,
pageLockConfig: pageLockConfig,
duplicationConfig: pageDuplicationConfig,
})
});
return (
<div className="flex flex-col">
<div className="top-0 sticky z-10 bg-custom-background-100">
<EditorHeader
isLocked={!pageLockConfig ? false : pageLockConfig.is_locked}
isArchived={!pageArchiveConfig ? false : pageArchiveConfig.is_archived}
readonly={true}
editor={editor}
sidePeakVisible={sidePeakVisible}
setSidePeakVisible={setSidePeakVisible}
KanbanMenuOptions={KanbanMenuOptions}
markings={markings}
documentDetails={documentDetails}
archivedAt={pageArchiveConfig && pageArchiveConfig.archived_at}
/>
</div>
<div className="self-center items-stretch w-full max-md:max-w-full overflow-y-hidden">
<div className={cn("gap-5 flex max-md:flex-col max-md:items-stretch max-md:gap-0 overflow-y-hidden", { "justify-center": !sidePeakVisible })}>
<div className="h-full w-full flex flex-col overflow-hidden">
<EditorHeader
isLocked={!pageLockConfig ? false : pageLockConfig.is_locked}
isArchived={!pageArchiveConfig ? false : pageArchiveConfig.is_archived}
readonly={true}
editor={editor}
sidePeekVisible={sidePeekVisible}
setSidePeekVisible={setSidePeekVisible}
KanbanMenuOptions={KanbanMenuOptions}
markings={markings}
documentDetails={documentDetails}
archivedAt={pageArchiveConfig && pageArchiveConfig.archived_at}
/>
<div className="h-full w-full flex overflow-hidden">
<div className="flex-shrink-0 h-full w-56 lg:w-80">
<SummarySideBar
editor={editor}
markings={markings}
sidePeakVisible={sidePeakVisible}
sidePeekVisible={sidePeekVisible}
/>
</div>
<div className="h-full w-full">
<PageRenderer
editor={editor}
editorClassNames={editorClassNames}
sidePeakVisible={sidePeakVisible}
documentDetails={documentDetails}
/>
</div>
<div className="hidden lg:block flex-shrink-0 w-56 lg:w-80" />
</div>
</div>
)
}
);
};
const DocumentReadOnlyEditorWithRef = forwardRef<
EditorHandle,
@ -118,4 +119,4 @@ const DocumentReadOnlyEditorWithRef = forwardRef<
DocumentReadOnlyEditorWithRef.displayName = "DocumentReadOnlyEditorWithRef";
export { DocumentReadOnlyEditor, DocumentReadOnlyEditorWithRef }
export { DocumentReadOnlyEditor, DocumentReadOnlyEditorWithRef };

View File

@ -1,75 +1,94 @@
import { Editor } from "@tiptap/react"
import { Archive, ArchiveIcon, ArchiveRestoreIcon, ClipboardIcon, Copy, Link, Lock, Unlock, XCircle } from "lucide-react"
import { NextRouter } from "next/router"
import { IVerticalDropdownItemProps } from "../components/vertical-dropdown-menu"
import { IDuplicationConfig, IPageArchiveConfig, IPageLockConfig } from "../types/menu-actions"
import { copyMarkdownToClipboard, CopyPageLink } from "./menu-actions"
import { Editor } from "@tiptap/react";
import {
Archive,
ArchiveIcon,
ArchiveRestoreIcon,
ClipboardIcon,
Copy,
Link,
Lock,
Unlock,
XCircle,
} from "lucide-react";
import { NextRouter } from "next/router";
import { IVerticalDropdownItemProps } from "../components/vertical-dropdown-menu";
import {
IDuplicationConfig,
IPageArchiveConfig,
IPageLockConfig,
} from "../types/menu-actions";
import { copyMarkdownToClipboard, CopyPageLink } from "./menu-actions";
export interface MenuOptionsProps{
editor: Editor,
router: NextRouter,
duplicationConfig?: IDuplicationConfig,
pageLockConfig?: IPageLockConfig ,
pageArchiveConfig?: IPageArchiveConfig,
export interface MenuOptionsProps {
editor: Editor;
router: NextRouter;
duplicationConfig?: IDuplicationConfig;
pageLockConfig?: IPageLockConfig;
pageArchiveConfig?: IPageArchiveConfig;
}
export const getMenuOptions = ({ editor, router, duplicationConfig, pageLockConfig, pageArchiveConfig } : MenuOptionsProps) => {
export const getMenuOptions = ({
editor,
router,
duplicationConfig,
pageLockConfig,
pageArchiveConfig,
}: MenuOptionsProps) => {
const KanbanMenuOptions: IVerticalDropdownItemProps[] = [
{
key: 1,
key: 1,
type: "copy_markdown",
Icon: ClipboardIcon,
action: () => copyMarkdownToClipboard(editor),
label: "Copy Markdown"
label: "Copy markdown",
},
// {
// key: 2,
// type: "close_page",
// Icon: XCircle,
// action: () => router.back(),
// label: "Close page",
// },
{
key: 2,
type: "close_page",
Icon: XCircle,
action: () => router.back(),
label: "Close the page"
},
{
key: 3,
key: 3,
type: "copy_page_link",
Icon: Link,
action: () => CopyPageLink(),
label: "Copy Page Link"
label: "Copy page link",
},
]
];
// If duplicateConfig is given, page duplication will be allowed
if (duplicationConfig) {
KanbanMenuOptions.push({
key: KanbanMenuOptions.length++,
key: KanbanMenuOptions.length++,
type: "duplicate_page",
Icon: Copy,
action: duplicationConfig.action,
label: "Make a copy"
})
label: "Make a copy",
});
}
// If Lock Configuration is given then, lock page option will be available in the kanban menu
if (pageLockConfig) {
KanbanMenuOptions.push({
key: KanbanMenuOptions.length++,
key: KanbanMenuOptions.length++,
type: pageLockConfig.is_locked ? "unlock_page" : "lock_page",
Icon: pageLockConfig.is_locked ? Unlock : Lock,
label: pageLockConfig.is_locked ? "Unlock Page" : "Lock Page",
action: pageLockConfig.action
})
label: pageLockConfig.is_locked ? "Unlock page" : "Lock page",
action: pageLockConfig.action,
});
}
// Archiving will be visible in the menu bar config once the pageArchiveConfig is given.
if (pageArchiveConfig) {
KanbanMenuOptions.push({
key: KanbanMenuOptions.length++,
key: KanbanMenuOptions.length++,
type: pageArchiveConfig.is_archived ? "unarchive_page" : "archive_page",
Icon: pageArchiveConfig.is_archived ? ArchiveRestoreIcon : Archive,
label: pageArchiveConfig.is_archived ? "Restore Page" : "Archive Page",
label: pageArchiveConfig.is_archived ? "Restore page" : "Archive page",
action: pageArchiveConfig.action,
})
});
}
return KanbanMenuOptions
}
return KanbanMenuOptions;
};

View File

@ -34,7 +34,7 @@ export const SelectProject: React.FC<Props> = ({ value, onChange, projects }) =>
.join(", ")
: "All projects"
}
optionsClassName="min-w-full"
optionsClassName="min-w-full max-w-[20rem]"
multiple
/>
);

View File

@ -11,7 +11,7 @@ import emptyApiTokens from "public/empty-state/api-token.svg";
const ApiTokenEmptyState = () => {
const router = useRouter();
return (
<div className={`flex items-center justify-center mx-auto border bg-custom-background-90 py-10 px-16 w-full`}>
<div className={`flex items-center justify-center mx-auto rounded-sm border border-custom-border-200 bg-custom-background-90 py-10 px-16 w-full`}>
<div className="text-center flex flex-col items-center w-full">
<Image src={emptyApiTokens} className="w-52 sm:w-60" alt="empty" />
<h6 className="text-xl font-semibold mt-6 sm:mt-8 mb-3">No API Tokens</h6>

View File

@ -0,0 +1,83 @@
import { Command } from "cmdk";
import { FileText, GithubIcon, MessageSquare, Rocket } from "lucide-react";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
// ui
import { DiscordIcon } from "@plane/ui";
type Props = {
closePalette: () => void;
};
export const CommandPaletteHelpActions: React.FC<Props> = (props) => {
const { closePalette } = props;
const {
commandPalette: { toggleShortcutModal },
} = useMobxStore();
return (
<Command.Group heading="Help">
<Command.Item
onSelect={() => {
closePalette();
toggleShortcutModal(true);
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<Rocket className="h-3.5 w-3.5" />
Open keyboard shortcuts
</div>
</Command.Item>
<Command.Item
onSelect={() => {
closePalette();
window.open("https://docs.plane.so/", "_blank");
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<FileText className="h-3.5 w-3.5" />
Open Plane documentation
</div>
</Command.Item>
<Command.Item
onSelect={() => {
closePalette();
window.open("https://discord.com/invite/A92xrEGCge", "_blank");
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<DiscordIcon className="h-4 w-4" color="rgb(var(--color-text-200))" />
Join our Discord
</div>
</Command.Item>
<Command.Item
onSelect={() => {
closePalette();
window.open("https://github.com/makeplane/plane/issues/new/choose", "_blank");
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<GithubIcon className="h-4 w-4" color="rgb(var(--color-text-200))" />
Report a bug
</div>
</Command.Item>
<Command.Item
onSelect={() => {
closePalette();
(window as any)?.$crisp.push(["do", "chat:open"]);
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<MessageSquare className="h-3.5 w-3.5" />
Chat with us
</div>
</Command.Item>
</Command.Group>
);
};

View File

@ -0,0 +1,6 @@
export * from "./issue-actions";
export * from "./help-actions";
export * from "./project-actions";
export * from "./search-results";
export * from "./theme-actions";
export * from "./workspace-settings-actions";

View File

@ -0,0 +1,166 @@
import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
import { Command } from "cmdk";
import { LinkIcon, Signal, Trash2, UserMinus2, UserPlus2 } from "lucide-react";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
// hooks
import useToast from "hooks/use-toast";
// ui
import { DoubleCircleIcon, UserGroupIcon } from "@plane/ui";
// helpers
import { copyTextToClipboard } from "helpers/string.helper";
// types
import { IIssue } from "types";
type Props = {
closePalette: () => void;
issueDetails: IIssue | undefined;
pages: string[];
setPages: (pages: string[]) => void;
setPlaceholder: (placeholder: string) => void;
setSearchTerm: (searchTerm: string) => void;
};
export const CommandPaletteIssueActions: React.FC<Props> = observer((props) => {
const { closePalette, issueDetails, pages, setPages, setPlaceholder, setSearchTerm } = props;
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const {
commandPalette: { toggleCommandPaletteModal, toggleDeleteIssueModal },
issueDetail: { updateIssue },
user: { currentUser },
} = useMobxStore();
const { setToastAlert } = useToast();
const handleUpdateIssue = async (formData: Partial<IIssue>) => {
if (!workspaceSlug || !projectId || !issueDetails) return;
const payload = { ...formData };
await updateIssue(workspaceSlug.toString(), projectId.toString(), issueDetails.id, payload).catch((e) => {
console.error(e);
});
};
const handleIssueAssignees = (assignee: string) => {
if (!issueDetails || !assignee) return;
closePalette();
const updatedAssignees = issueDetails.assignees ?? [];
if (updatedAssignees.includes(assignee)) updatedAssignees.splice(updatedAssignees.indexOf(assignee), 1);
else updatedAssignees.push(assignee);
handleUpdateIssue({ assignees: updatedAssignees });
};
const deleteIssue = () => {
toggleCommandPaletteModal(false);
toggleDeleteIssueModal(true);
};
const copyIssueUrlToClipboard = () => {
if (!router.query.issueId) return;
const url = new URL(window.location.href);
copyTextToClipboard(url.href)
.then(() => {
setToastAlert({
type: "success",
title: "Copied to clipboard",
});
})
.catch(() => {
setToastAlert({
type: "error",
title: "Some error occurred",
});
});
};
return (
<Command.Group heading="Issue actions">
<Command.Item
onSelect={() => {
setPlaceholder("Change state...");
setSearchTerm("");
setPages([...pages, "change-issue-state"]);
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<DoubleCircleIcon className="h-3.5 w-3.5" />
Change state...
</div>
</Command.Item>
<Command.Item
onSelect={() => {
setPlaceholder("Change priority...");
setSearchTerm("");
setPages([...pages, "change-issue-priority"]);
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<Signal className="h-3.5 w-3.5" />
Change priority...
</div>
</Command.Item>
<Command.Item
onSelect={() => {
setPlaceholder("Assign to...");
setSearchTerm("");
setPages([...pages, "change-issue-assignee"]);
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<UserGroupIcon className="h-3.5 w-3.5" />
Assign to...
</div>
</Command.Item>
<Command.Item
onSelect={() => {
handleIssueAssignees(currentUser?.id ?? "");
setSearchTerm("");
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
{issueDetails?.assignees.includes(currentUser?.id ?? "") ? (
<>
<UserMinus2 className="h-3.5 w-3.5" />
Un-assign from me
</>
) : (
<>
<UserPlus2 className="h-3.5 w-3.5" />
Assign to me
</>
)}
</div>
</Command.Item>
<Command.Item onSelect={deleteIssue} className="focus:outline-none">
<div className="flex items-center gap-2 text-custom-text-200">
<Trash2 className="h-3.5 w-3.5" />
Delete issue
</div>
</Command.Item>
<Command.Item
onSelect={() => {
closePalette();
copyIssueUrlToClipboard();
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<LinkIcon className="h-3.5 w-3.5" />
Copy issue URL
</div>
</Command.Item>
</Command.Group>
);
});

View File

@ -0,0 +1,79 @@
import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
import { Command } from "cmdk";
import { Check } from "lucide-react";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
// ui
import { Avatar } from "@plane/ui";
// types
import { IIssue } from "types";
type Props = {
closePalette: () => void;
issue: IIssue;
};
export const ChangeIssueAssignee: React.FC<Props> = observer((props) => {
const { closePalette, issue } = props;
// router
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
// store
const {
issueDetail: { updateIssue },
projectMember: { projectMembers },
} = useMobxStore();
const options =
projectMembers?.map(({ member }) => ({
value: member.id,
query: member.display_name,
content: (
<>
<div className="flex items-center gap-2">
<Avatar name={member.display_name} src={member.avatar} showTooltip={false} />
{member.display_name}
</div>
{issue.assignees.includes(member.id) && (
<div>
<Check className="h-3 w-3" />
</div>
)}
</>
),
})) ?? [];
const handleUpdateIssue = async (formData: Partial<IIssue>) => {
if (!workspaceSlug || !projectId || !issue) return;
const payload = { ...formData };
await updateIssue(workspaceSlug.toString(), projectId.toString(), issue.id, payload).catch((e) => {
console.error(e);
});
};
const handleIssueAssignees = (assignee: string) => {
const updatedAssignees = issue.assignees ?? [];
if (updatedAssignees.includes(assignee)) updatedAssignees.splice(updatedAssignees.indexOf(assignee), 1);
else updatedAssignees.push(assignee);
handleUpdateIssue({ assignees: updatedAssignees });
closePalette();
};
return (
<>
{options.map((option: any) => (
<Command.Item
key={option.value}
onSelect={() => handleIssueAssignees(option.value)}
className="focus:outline-none"
>
{option.content}
</Command.Item>
))}
</>
);
});

View File

@ -0,0 +1,56 @@
import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
import { Command } from "cmdk";
import { Check } from "lucide-react";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
// ui
import { PriorityIcon } from "@plane/ui";
// types
import { IIssue, TIssuePriorities } from "types";
// constants
import { PRIORITIES } from "constants/project";
type Props = {
closePalette: () => void;
issue: IIssue;
};
export const ChangeIssuePriority: React.FC<Props> = observer((props) => {
const { closePalette, issue } = props;
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const {
issueDetail: { updateIssue },
} = useMobxStore();
const submitChanges = async (formData: Partial<IIssue>) => {
if (!workspaceSlug || !projectId || !issue) return;
const payload = { ...formData };
await updateIssue(workspaceSlug.toString(), projectId.toString(), issue.id, payload).catch((e) => {
console.error(e);
});
};
const handleIssueState = (priority: TIssuePriorities) => {
submitChanges({ priority });
closePalette();
};
return (
<>
{PRIORITIES.map((priority) => (
<Command.Item key={priority} onSelect={() => handleIssueState(priority)} className="focus:outline-none">
<div className="flex items-center space-x-3">
<PriorityIcon priority={priority} />
<span className="capitalize">{priority ?? "None"}</span>
</div>
<div>{priority === issue.priority && <Check className="h-3 w-3" />}</div>
</Command.Item>
))}
</>
);
});

View File

@ -0,0 +1,65 @@
import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
// cmdk
import { Command } from "cmdk";
// ui
import { Spinner, StateGroupIcon } from "@plane/ui";
// icons
import { Check } from "lucide-react";
// types
import { IIssue } from "types";
type Props = {
closePalette: () => void;
issue: IIssue;
};
export const ChangeIssueState: React.FC<Props> = observer((props) => {
const { closePalette, issue } = props;
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const {
projectState: { projectStates },
issueDetail: { updateIssue },
} = useMobxStore();
const submitChanges = async (formData: Partial<IIssue>) => {
if (!workspaceSlug || !projectId || !issue) return;
const payload = { ...formData };
await updateIssue(workspaceSlug.toString(), projectId.toString(), issue.id, payload).catch((e) => {
console.error(e);
});
};
const handleIssueState = (stateId: string) => {
submitChanges({ state: stateId });
closePalette();
};
return (
<>
{projectStates ? (
projectStates.length > 0 ? (
projectStates.map((state) => (
<Command.Item key={state.id} onSelect={() => handleIssueState(state.id)} className="focus:outline-none">
<div className="flex items-center space-x-3">
<StateGroupIcon stateGroup={state.group} color={state.color} height="16px" width="16px" />
<p>{state.name}</p>
</div>
<div>{state.id === issue.state && <Check className="h-3 w-3" />}</div>
</Command.Item>
))
) : (
<div className="text-center">No states found</div>
)
) : (
<Spinner />
)}
</>
);
});

View File

@ -0,0 +1,4 @@
export * from "./actions-list";
export * from "./change-state";
export * from "./change-priority";
export * from "./change-assignee";

View File

@ -0,0 +1,83 @@
import { Command } from "cmdk";
import { ContrastIcon, FileText } from "lucide-react";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
// ui
import { DiceIcon, PhotoFilterIcon } from "@plane/ui";
type Props = {
closePalette: () => void;
};
export const CommandPaletteProjectActions: React.FC<Props> = (props) => {
const { closePalette } = props;
const {
commandPalette: { toggleCreateCycleModal, toggleCreateModuleModal, toggleCreatePageModal, toggleCreateViewModal },
} = useMobxStore();
return (
<>
<Command.Group heading="Cycle">
<Command.Item
onSelect={() => {
closePalette();
toggleCreateCycleModal(true);
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<ContrastIcon className="h-3.5 w-3.5" />
Create new cycle
</div>
<kbd>Q</kbd>
</Command.Item>
</Command.Group>
<Command.Group heading="Module">
<Command.Item
onSelect={() => {
closePalette();
toggleCreateModuleModal(true);
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<DiceIcon className="h-3.5 w-3.5" />
Create new module
</div>
<kbd>M</kbd>
</Command.Item>
</Command.Group>
<Command.Group heading="View">
<Command.Item
onSelect={() => {
closePalette();
toggleCreateViewModal(true);
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<PhotoFilterIcon className="h-3.5 w-3.5" />
Create new view
</div>
<kbd>V</kbd>
</Command.Item>
</Command.Group>
<Command.Group heading="Page">
<Command.Item
onSelect={() => {
closePalette();
toggleCreatePageModal(true);
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<FileText className="h-3.5 w-3.5" />
Create new page
</div>
<kbd>D</kbd>
</Command.Item>
</Command.Group>
</>
);
};

View File

@ -0,0 +1,49 @@
import { useRouter } from "next/router";
import { Command } from "cmdk";
// helpers
import { commandGroups } from "components/command-palette";
// types
import { IWorkspaceSearchResults } from "types";
type Props = {
closePalette: () => void;
results: IWorkspaceSearchResults;
};
export const CommandPaletteSearchResults: React.FC<Props> = (props) => {
const { closePalette, results } = props;
const router = useRouter();
return (
<>
{Object.keys(results.results).map((key) => {
const section = (results.results as any)[key];
const currentSection = commandGroups[key];
if (section.length > 0) {
return (
<Command.Group key={key} heading={`${currentSection.title} search`}>
{section.map((item: any) => (
<Command.Item
key={item.id}
onSelect={() => {
closePalette();
router.push(currentSection.path(item));
}}
value={`${key}-${item?.id}-${item.name}-${item.project__identifier ?? ""}-${item.sequence_id ?? ""}`}
className="focus:outline-none"
>
<div className="flex items-center gap-2 overflow-hidden text-custom-text-200">
{currentSection.icon}
<p className="block flex-1 truncate">{currentSection.itemName(item)}</p>
</div>
</Command.Item>
))}
</Command.Group>
);
}
})}
</>
);
};

View File

@ -1,4 +1,4 @@
import React, { FC, Dispatch, SetStateAction, useEffect, useState } from "react";
import React, { FC, useEffect, useState } from "react";
import { Command } from "cmdk";
import { useTheme } from "next-themes";
import { Settings } from "lucide-react";
@ -10,22 +10,25 @@ import { useMobxStore } from "lib/mobx/store-provider";
import { THEME_OPTIONS } from "constants/themes";
type Props = {
setIsPaletteOpen: Dispatch<SetStateAction<boolean>>;
closePalette: () => void;
};
export const ChangeInterfaceTheme: FC<Props> = observer((props) => {
const { setIsPaletteOpen } = props;
// store
const { user: userStore } = useMobxStore();
export const CommandPaletteThemeActions: FC<Props> = observer((props) => {
const { closePalette } = props;
// states
const [mounted, setMounted] = useState(false);
// store
const {
user: { updateCurrentUserTheme },
} = useMobxStore();
// hooks
const { setTheme } = useTheme();
const { setToastAlert } = useToast();
const updateUserTheme = (newTheme: string) => {
const updateUserTheme = async (newTheme: string) => {
setTheme(newTheme);
return userStore.updateCurrentUserTheme(newTheme).catch(() => {
return updateCurrentUserTheme(newTheme).catch(() => {
setToastAlert({
title: "Failed to save user theme settings!",
type: "error",
@ -47,7 +50,7 @@ export const ChangeInterfaceTheme: FC<Props> = observer((props) => {
key={theme.value}
onSelect={() => {
updateUserTheme(theme.value);
setIsPaletteOpen(false);
closePalette();
}}
className="focus:outline-none"
>

View File

@ -0,0 +1,61 @@
import { useRouter } from "next/router";
import { Command } from "cmdk";
// icons
import { SettingIcon } from "components/icons";
type Props = {
closePalette: () => void;
};
export const CommandPaletteWorkspaceSettingsActions: React.FC<Props> = (props) => {
const { closePalette } = props;
const router = useRouter();
const { workspaceSlug } = router.query;
const redirect = (path: string) => {
closePalette();
router.push(path);
};
return (
<>
<Command.Item onSelect={() => redirect(`/${workspaceSlug}/settings`)} className="focus:outline-none">
<div className="flex items-center gap-2 text-custom-text-200">
<SettingIcon className="h-4 w-4 text-custom-text-200" />
General
</div>
</Command.Item>
<Command.Item onSelect={() => redirect(`/${workspaceSlug}/settings/members`)} className="focus:outline-none">
<div className="flex items-center gap-2 text-custom-text-200">
<SettingIcon className="h-4 w-4 text-custom-text-200" />
Members
</div>
</Command.Item>
<Command.Item onSelect={() => redirect(`/${workspaceSlug}/settings/billing`)} className="focus:outline-none">
<div className="flex items-center gap-2 text-custom-text-200">
<SettingIcon className="h-4 w-4 text-custom-text-200" />
Billing and Plans
</div>
</Command.Item>
<Command.Item onSelect={() => redirect(`/${workspaceSlug}/settings/integrations`)} className="focus:outline-none">
<div className="flex items-center gap-2 text-custom-text-200">
<SettingIcon className="h-4 w-4 text-custom-text-200" />
Integrations
</div>
</Command.Item>
<Command.Item onSelect={() => redirect(`/${workspaceSlug}/settings/imports`)} className="focus:outline-none">
<div className="flex items-center gap-2 text-custom-text-200">
<SettingIcon className="h-4 w-4 text-custom-text-200" />
Import
</div>
</Command.Item>
<Command.Item onSelect={() => redirect(`/${workspaceSlug}/settings/exports`)} className="focus:outline-none">
<div className="flex items-center gap-2 text-custom-text-200">
<SettingIcon className="h-4 w-4 text-custom-text-200" />
Export
</div>
</Command.Item>
</>
);
};

View File

@ -1,22 +1,10 @@
import React, { useCallback, useEffect, useState } from "react";
import React, { useEffect, useState } from "react";
import { useRouter } from "next/router";
import useSWR, { mutate } from "swr";
import useSWR from "swr";
import { Command } from "cmdk";
import { Dialog, Transition } from "@headlessui/react";
import { observer } from "mobx-react-lite";
import {
FileText,
FolderPlus,
LinkIcon,
MessageSquare,
Rocket,
Search,
Settings,
Signal,
Trash2,
UserMinus2,
UserPlus2,
} from "lucide-react";
import { FolderPlus, Search, Settings } from "lucide-react";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
// services
@ -24,47 +12,29 @@ import { WorkspaceService } from "services/workspace.service";
import { IssueService } from "services/issue";
// hooks
import useDebounce from "hooks/use-debounce";
import useToast from "hooks/use-toast";
// components
import {
ChangeInterfaceTheme,
CommandPaletteThemeActions,
ChangeIssueAssignee,
ChangeIssuePriority,
ChangeIssueState,
commandGroups,
CommandPaletteHelpActions,
CommandPaletteIssueActions,
CommandPaletteProjectActions,
CommandPaletteWorkspaceSettingsActions,
CommandPaletteSearchResults,
} from "components/command-palette";
import {
ContrastIcon,
DiceIcon,
DoubleCircleIcon,
LayersIcon,
Loader,
PhotoFilterIcon,
ToggleSwitch,
Tooltip,
UserGroupIcon,
} from "@plane/ui";
// icons
import { DiscordIcon, GithubIcon, SettingIcon } from "components/icons";
// helpers
import { copyTextToClipboard } from "helpers/string.helper";
import { LayersIcon, Loader, ToggleSwitch, Tooltip } from "@plane/ui";
// types
import { IIssue, IWorkspaceSearchResults } from "types";
import { IWorkspaceSearchResults } from "types";
// fetch-keys
import { ISSUE_DETAILS, PROJECT_ISSUES_ACTIVITY } from "constants/fetch-keys";
type Props = {
deleteIssue: () => void;
isPaletteOpen: boolean;
closePalette: () => void;
};
import { ISSUE_DETAILS } from "constants/fetch-keys";
// services
const workspaceService = new WorkspaceService();
const issueService = new IssueService();
export const CommandModal: React.FC<Props> = observer((props) => {
const { deleteIssue, isPaletteOpen, closePalette } = props;
export const CommandModal: React.FC = observer(() => {
// states
const [placeholder, setPlaceholder] = useState("Type a command or search...");
const [resultsCount, setResultsCount] = useState(0);
@ -85,8 +55,14 @@ export const CommandModal: React.FC<Props> = observer((props) => {
const [isWorkspaceLevel, setIsWorkspaceLevel] = useState(false);
const [pages, setPages] = useState<string[]>([]);
const { user: userStore, commandPalette: commandPaletteStore } = useMobxStore();
const user = userStore.currentUser ?? undefined;
const {
commandPalette: {
isCommandPaletteOpen,
toggleCommandPaletteModal,
toggleCreateIssueModal,
toggleCreateProjectModal,
},
} = useMobxStore();
// router
const router = useRouter();
@ -96,64 +72,16 @@ export const CommandModal: React.FC<Props> = observer((props) => {
const debouncedSearchTerm = useDebounce(searchTerm, 500);
const { setToastAlert } = useToast();
// TODO: update this to mobx store
const { data: issueDetails } = useSWR(
workspaceSlug && projectId && issueId ? ISSUE_DETAILS(issueId as string) : null,
workspaceSlug && projectId && issueId ? ISSUE_DETAILS(issueId.toString()) : null,
workspaceSlug && projectId && issueId
? () => issueService.retrieve(workspaceSlug as string, projectId as string, issueId as string)
? () => issueService.retrieve(workspaceSlug.toString(), projectId.toString(), issueId.toString())
: null
);
const updateIssue = useCallback(
async (formData: Partial<IIssue>) => {
if (!workspaceSlug || !projectId || !issueId) return;
mutate<IIssue>(
ISSUE_DETAILS(issueId as string),
(prevData) => {
if (!prevData) return prevData;
return {
...prevData,
...formData,
};
},
false
);
const payload = { ...formData };
await issueService
.patchIssue(workspaceSlug as string, projectId as string, issueId as string, payload)
.then(() => {
mutate(PROJECT_ISSUES_ACTIVITY(issueId as string));
mutate(ISSUE_DETAILS(issueId as string));
})
.catch((e) => {
console.error(e);
});
},
[workspaceSlug, issueId, projectId]
);
const handleIssueAssignees = (assignee: string) => {
if (!issueDetails) return;
closePalette();
const updatedAssignees = issueDetails.assignees ?? [];
if (updatedAssignees.includes(assignee)) {
updatedAssignees.splice(updatedAssignees.indexOf(assignee), 1);
} else {
updatedAssignees.push(assignee);
}
updateIssue({ assignees: updatedAssignees });
};
const redirect = (path: string) => {
closePalette();
router.push(path);
const closePalette = () => {
toggleCommandPaletteModal(false);
};
const createNewWorkspace = () => {
@ -161,25 +89,6 @@ export const CommandModal: React.FC<Props> = observer((props) => {
router.push("/create-workspace");
};
const copyIssueUrlToClipboard = useCallback(() => {
if (!router.query.issueId) return;
const url = new URL(window.location.href);
copyTextToClipboard(url.href)
.then(() => {
setToastAlert({
type: "success",
title: "Copied to clipboard",
});
})
.catch(() => {
setToastAlert({
type: "error",
title: "Some error occurred",
});
});
}, [router, setToastAlert]);
useEffect(
() => {
if (!workspaceSlug) return;
@ -189,7 +98,7 @@ export const CommandModal: React.FC<Props> = observer((props) => {
if (debouncedSearchTerm) {
setIsSearching(true);
workspaceService
.searchWorkspace(workspaceSlug as string, {
.searchWorkspace(workspaceSlug.toString(), {
...(projectId ? { project_id: projectId.toString() } : {}),
search: debouncedSearchTerm,
workspace_search: !projectId ? true : isWorkspaceLevel,
@ -225,16 +134,8 @@ export const CommandModal: React.FC<Props> = observer((props) => {
[debouncedSearchTerm, isWorkspaceLevel, projectId, workspaceSlug] // Only call effect if debounced search term changes
);
if (!user) return null;
return (
<Transition.Root
show={isPaletteOpen}
afterLeave={() => {
setSearchTerm("");
}}
as={React.Fragment}
>
<Transition.Root show={isCommandPaletteOpen} afterLeave={() => setSearchTerm("")} as={React.Fragment}>
<Dialog as="div" className="relative z-30" onClose={() => closePalette()}>
<Transition.Child
as={React.Fragment}
@ -268,9 +169,8 @@ export const CommandModal: React.FC<Props> = observer((props) => {
onKeyDown={(e) => {
// when search is empty and page is undefined
// when user tries to close the modal with esc
if (e.key === "Escape" && !page && !searchTerm) {
closePalette();
}
if (e.key === "Escape" && !page && !searchTerm) closePalette();
// Escape goes to previous page
// Backspace goes to previous page when search is empty
if (e.key === "Escape" || (e.key === "Backspace" && !searchTerm)) {
@ -318,9 +218,7 @@ export const CommandModal: React.FC<Props> = observer((props) => {
className="w-full border-0 border-b border-custom-border-200 bg-transparent p-4 pl-11 text-custom-text-100 placeholder:text-custom-text-400 outline-none focus:ring-0 text-sm"
placeholder={placeholder}
value={searchTerm}
onValueChange={(e) => {
setSearchTerm(e);
}}
onValueChange={(e) => setSearchTerm(e)}
autoFocus
tabIndex={1}
/>
@ -340,7 +238,7 @@ export const CommandModal: React.FC<Props> = observer((props) => {
)}
{!isLoading && resultsCount === 0 && searchTerm !== "" && debouncedSearchTerm !== "" && (
<div className="my-4 text-center text-custom-text-200">No results found.</div>
<div className="my-4 text-center text-custom-text-200 text-sm">No results found.</div>
)}
{(isLoading || isSearching) && (
@ -354,125 +252,28 @@ export const CommandModal: React.FC<Props> = observer((props) => {
</Command.Loading>
)}
{debouncedSearchTerm !== "" &&
Object.keys(results.results).map((key) => {
const section = (results.results as any)[key];
const currentSection = commandGroups[key];
if (section.length > 0) {
return (
<Command.Group key={key} heading={currentSection.title}>
{section.map((item: any) => (
<Command.Item
key={item.id}
onSelect={() => {
closePalette();
router.push(currentSection.path(item));
}}
value={`${key}-${item?.name}`}
className="focus:outline-none"
>
<div className="flex items-center gap-2 overflow-hidden text-custom-text-200">
{currentSection.icon}
<p className="block flex-1 truncate">{currentSection.itemName(item)}</p>
</div>
</Command.Item>
))}
</Command.Group>
);
}
})}
{debouncedSearchTerm !== "" && (
<CommandPaletteSearchResults closePalette={closePalette} results={results} />
)}
{!page && (
<>
{/* issue actions */}
{issueId && (
<Command.Group heading="Issue actions">
<Command.Item
onSelect={() => {
closePalette();
setPlaceholder("Change state...");
setSearchTerm("");
setPages([...pages, "change-issue-state"]);
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<DoubleCircleIcon className="h-3.5 w-3.5" />
Change state...
</div>
</Command.Item>
<Command.Item
onSelect={() => {
setPlaceholder("Change priority...");
setSearchTerm("");
setPages([...pages, "change-issue-priority"]);
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<Signal className="h-3.5 w-3.5" />
Change priority...
</div>
</Command.Item>
<Command.Item
onSelect={() => {
setPlaceholder("Assign to...");
setSearchTerm("");
setPages([...pages, "change-issue-assignee"]);
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<UserGroupIcon className="h-3.5 w-3.5" />
Assign to...
</div>
</Command.Item>
<Command.Item
onSelect={() => {
handleIssueAssignees(user.id);
setSearchTerm("");
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
{issueDetails?.assignees.includes(user.id) ? (
<>
<UserMinus2 className="h-3.5 w-3.5" />
Un-assign from me
</>
) : (
<>
<UserPlus2 className="h-3.5 w-3.5" />
Assign to me
</>
)}
</div>
</Command.Item>
<Command.Item onSelect={deleteIssue} className="focus:outline-none">
<div className="flex items-center gap-2 text-custom-text-200">
<Trash2 className="h-3.5 w-3.5" />
Delete issue
</div>
</Command.Item>
<Command.Item
onSelect={() => {
closePalette();
copyIssueUrlToClipboard();
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<LinkIcon className="h-3.5 w-3.5" />
Copy issue URL
</div>
</Command.Item>
</Command.Group>
<CommandPaletteIssueActions
closePalette={closePalette}
issueDetails={issueDetails}
pages={pages}
setPages={(newPages) => setPages(newPages)}
setPlaceholder={(newPlaceholder) => setPlaceholder(newPlaceholder)}
setSearchTerm={(newSearchTerm) => setSearchTerm(newSearchTerm)}
/>
)}
<Command.Group heading="Issue">
<Command.Item
onSelect={() => {
closePalette();
commandPaletteStore.toggleCreateIssueModal(true);
toggleCreateIssueModal(true);
}}
className="focus:bg-custom-background-80"
>
@ -489,7 +290,7 @@ export const CommandModal: React.FC<Props> = observer((props) => {
<Command.Item
onSelect={() => {
closePalette();
commandPaletteStore.toggleCreateProjectModal(true);
toggleCreateProjectModal(true);
}}
className="focus:outline-none"
>
@ -502,70 +303,8 @@ export const CommandModal: React.FC<Props> = observer((props) => {
</Command.Group>
)}
{projectId && (
<>
<Command.Group heading="Cycle">
<Command.Item
onSelect={() => {
closePalette();
commandPaletteStore.toggleCreateCycleModal(true);
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<ContrastIcon className="h-3.5 w-3.5" />
Create new cycle
</div>
<kbd>Q</kbd>
</Command.Item>
</Command.Group>
<Command.Group heading="Module">
<Command.Item
onSelect={() => {
closePalette();
commandPaletteStore.toggleCreateModuleModal(true);
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<DiceIcon className="h-3.5 w-3.5" />
Create new module
</div>
<kbd>M</kbd>
</Command.Item>
</Command.Group>
<Command.Group heading="View">
<Command.Item
onSelect={() => {
closePalette();
commandPaletteStore.toggleCreateViewModal(true);
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<PhotoFilterIcon className="h-3.5 w-3.5" />
Create new view
</div>
<kbd>V</kbd>
</Command.Item>
</Command.Group>
<Command.Group heading="Page">
<Command.Item
onSelect={() => {
closePalette();
commandPaletteStore.toggleCreatePageModal(true);
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<FileText className="h-3.5 w-3.5" />
Create new page
</div>
<kbd>D</kbd>
</Command.Item>
</Command.Group>
</>
)}
{/* project actions */}
{projectId && <CommandPaletteProjectActions closePalette={closePalette} />}
<Command.Group heading="Workspace Settings">
<Command.Item
@ -603,139 +342,37 @@ export const CommandModal: React.FC<Props> = observer((props) => {
</div>
</Command.Item>
</Command.Group>
<Command.Group heading="Help">
<Command.Item
onSelect={() => {
closePalette();
commandPaletteStore.toggleShortcutModal(true);
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<Rocket className="h-3.5 w-3.5" />
Open keyboard shortcuts
</div>
</Command.Item>
<Command.Item
onSelect={() => {
closePalette();
window.open("https://docs.plane.so/", "_blank");
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<FileText className="h-3.5 w-3.5" />
Open Plane documentation
</div>
</Command.Item>
<Command.Item
onSelect={() => {
closePalette();
window.open("https://discord.com/invite/A92xrEGCge", "_blank");
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<DiscordIcon className="h-4 w-4" color="rgb(var(--color-text-200))" />
Join our Discord
</div>
</Command.Item>
<Command.Item
onSelect={() => {
closePalette();
window.open("https://github.com/makeplane/plane/issues/new/choose", "_blank");
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<GithubIcon className="h-4 w-4" color="rgb(var(--color-text-200))" />
Report a bug
</div>
</Command.Item>
<Command.Item
onSelect={() => {
closePalette();
(window as any).$crisp.push(["do", "chat:open"]);
}}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<MessageSquare className="h-3.5 w-3.5" />
Chat with us
</div>
</Command.Item>
</Command.Group>
{/* help options */}
<CommandPaletteHelpActions closePalette={closePalette} />
</>
)}
{/* workspace settings actions */}
{page === "settings" && workspaceSlug && (
<>
<Command.Item
onSelect={() => redirect(`/${workspaceSlug}/settings`)}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<SettingIcon className="h-4 w-4 text-custom-text-200" />
General
</div>
</Command.Item>
<Command.Item
onSelect={() => redirect(`/${workspaceSlug}/settings/members`)}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<SettingIcon className="h-4 w-4 text-custom-text-200" />
Members
</div>
</Command.Item>
<Command.Item
onSelect={() => redirect(`/${workspaceSlug}/settings/billing`)}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<SettingIcon className="h-4 w-4 text-custom-text-200" />
Billing and Plans
</div>
</Command.Item>
<Command.Item
onSelect={() => redirect(`/${workspaceSlug}/settings/integrations`)}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<SettingIcon className="h-4 w-4 text-custom-text-200" />
Integrations
</div>
</Command.Item>
<Command.Item
onSelect={() => redirect(`/${workspaceSlug}/settings/imports`)}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<SettingIcon className="h-4 w-4 text-custom-text-200" />
Import
</div>
</Command.Item>
<Command.Item
onSelect={() => redirect(`/${workspaceSlug}/settings/exports`)}
className="focus:outline-none"
>
<div className="flex items-center gap-2 text-custom-text-200">
<SettingIcon className="h-4 w-4 text-custom-text-200" />
Export
</div>
</Command.Item>
</>
<CommandPaletteWorkspaceSettingsActions closePalette={closePalette} />
)}
{/* issue details page actions */}
{page === "change-issue-state" && issueDetails && (
<ChangeIssueState issue={issueDetails} setIsPaletteOpen={closePalette} user={user} />
<ChangeIssueState closePalette={closePalette} issue={issueDetails} />
)}
{page === "change-issue-priority" && issueDetails && (
<ChangeIssuePriority issue={issueDetails} setIsPaletteOpen={closePalette} user={user} />
<ChangeIssuePriority closePalette={closePalette} issue={issueDetails} />
)}
{page === "change-issue-assignee" && issueDetails && (
<ChangeIssueAssignee issue={issueDetails} setIsPaletteOpen={closePalette} user={user} />
<ChangeIssueAssignee closePalette={closePalette} issue={issueDetails} />
)}
{/* theme actions */}
{page === "change-interface-theme" && (
<CommandPaletteThemeActions
closePalette={() => {
closePalette();
setPages((pages) => pages.slice(0, -1));
}}
/>
)}
{page === "change-interface-theme" && <ChangeInterfaceTheme setIsPaletteOpen={closePalette} />}
</Command.List>
</Command>
</div>

View File

@ -32,7 +32,6 @@ export const CommandPalette: FC = observer(() => {
// store
const { commandPalette, theme: themeStore } = useMobxStore();
const {
isCommandPaletteOpen,
toggleCommandPaletteModal,
isCreateIssueModalOpen,
toggleCreateIssueModal,
@ -156,11 +155,6 @@ export const CommandPalette: FC = observer(() => {
if (!user) return null;
const deleteIssue = () => {
toggleCommandPaletteModal(false);
toggleDeleteIssueModal(true);
};
return (
<>
<ShortcutsModal
@ -231,13 +225,7 @@ export const CommandPalette: FC = observer(() => {
}}
user={user}
/>
<CommandModal
deleteIssue={deleteIssue}
isPaletteOpen={isCommandPaletteOpen}
closePalette={() => {
toggleCommandPaletteModal(false);
}}
/>
<CommandModal />
</>
);
});

View File

@ -20,9 +20,7 @@ export const commandGroups: {
icon: <ContrastIcon className="h-3 w-3" />,
itemName: (cycle: IWorkspaceDefaultSearchResult) => (
<h6>
<span className="text-custom-text-200 text-xs">{cycle.project__identifier}</span>
{"- "}
{cycle.name}
<span className="text-custom-text-300 text-xs">{cycle.project__identifier}</span> {cycle.name}
</h6>
),
path: (cycle: IWorkspaceDefaultSearchResult) =>
@ -33,8 +31,9 @@ export const commandGroups: {
icon: <LayersIcon className="h-3 w-3" />,
itemName: (issue: IWorkspaceIssueSearchResult) => (
<h6>
<span className="text-custom-text-200 text-xs">{issue.project__identifier}</span>
{"- "}
<span className="text-custom-text-300 text-xs">
{issue.project__identifier}-{issue.sequence_id}
</span>{" "}
{issue.name}
</h6>
),
@ -46,9 +45,7 @@ export const commandGroups: {
icon: <PhotoFilterIcon className="h-3 w-3" />,
itemName: (view: IWorkspaceDefaultSearchResult) => (
<h6>
<span className="text-custom-text-200 text-xs">{view.project__identifier}</span>
{"- "}
{view.name}
<span className="text-custom-text-300 text-xs">{view.project__identifier}</span> {view.name}
</h6>
),
path: (view: IWorkspaceDefaultSearchResult) =>
@ -59,9 +56,7 @@ export const commandGroups: {
icon: <DiceIcon className="h-3 w-3" />,
itemName: (module: IWorkspaceDefaultSearchResult) => (
<h6>
<span className="text-custom-text-200 text-xs">{module.project__identifier}</span>
{"- "}
{module.name}
<span className="text-custom-text-300 text-xs">{module.project__identifier}</span> {module.name}
</h6>
),
path: (module: IWorkspaceDefaultSearchResult) =>
@ -72,9 +67,7 @@ export const commandGroups: {
icon: <FileText className="h-3 w-3" />,
itemName: (page: IWorkspaceDefaultSearchResult) => (
<h6>
<span className="text-custom-text-200 text-xs">{page.project__identifier}</span>
{"- "}
{page.name}
<span className="text-custom-text-300 text-xs">{page.project__identifier}</span> {page.name}
</h6>
),
path: (page: IWorkspaceDefaultSearchResult) =>

View File

@ -1,5 +1,4 @@
export * from "./issue";
export * from "./change-interface-theme";
export * from "./actions";
export * from "./command-modal";
export * from "./command-pallette";
export * from "./helpers";

View File

@ -1,111 +0,0 @@
import { Dispatch, SetStateAction, useCallback, FC } from "react";
import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
import { mutate } from "swr";
import { Command } from "cmdk";
import { Check } from "lucide-react";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
// services
import { IssueService } from "services/issue";
// ui
import { Avatar } from "@plane/ui";
// types
import { IUser, IIssue } from "types";
// constants
import { ISSUE_DETAILS, PROJECT_ISSUES_ACTIVITY } from "constants/fetch-keys";
type Props = {
setIsPaletteOpen: Dispatch<SetStateAction<boolean>>;
issue: IIssue;
user: IUser | undefined;
};
// services
const issueService = new IssueService();
export const ChangeIssueAssignee: FC<Props> = observer((props) => {
const { setIsPaletteOpen, issue } = props;
// router
const router = useRouter();
const { workspaceSlug, projectId, issueId } = router.query;
// store
const {
projectMember: { projectMembers },
} = useMobxStore();
const options =
projectMembers?.map(({ member }) => ({
value: member.id,
query: member.display_name,
content: (
<>
<div className="flex items-center gap-2">
<Avatar name={member.display_name} src={member.avatar} showTooltip={false} />
{member.display_name}
</div>
{issue.assignees.includes(member.id) && (
<div>
<Check className="h-3 w-3" />
</div>
)}
</>
),
})) ?? [];
const updateIssue = useCallback(
async (formData: Partial<IIssue>) => {
if (!workspaceSlug || !projectId || !issueId) return;
mutate<IIssue>(
ISSUE_DETAILS(issueId as string),
async (prevData) => {
if (!prevData) return prevData;
return {
...prevData,
...formData,
};
},
false
);
const payload = { ...formData };
await issueService
.patchIssue(workspaceSlug as string, projectId as string, issueId as string, payload)
.then(() => {
mutate(PROJECT_ISSUES_ACTIVITY(issueId as string));
})
.catch((e) => {
console.error(e);
});
},
[workspaceSlug, issueId, projectId]
);
const handleIssueAssignees = (assignee: string) => {
const updatedAssignees = issue.assignees ?? [];
if (updatedAssignees.includes(assignee)) {
updatedAssignees.splice(updatedAssignees.indexOf(assignee), 1);
} else {
updatedAssignees.push(assignee);
}
updateIssue({ assignees: updatedAssignees });
setIsPaletteOpen(false);
};
return (
<>
{options.map((option: any) => (
<Command.Item
key={option.value}
onSelect={() => handleIssueAssignees(option.value)}
className="focus:outline-none"
>
{option.content}
</Command.Item>
))}
</>
);
});

View File

@ -1,78 +0,0 @@
import React, { Dispatch, SetStateAction, useCallback } from "react";
import { useRouter } from "next/router";
import { mutate } from "swr";
// cmdk
import { Command } from "cmdk";
// services
import { IssueService } from "services/issue";
// types
import { IIssue, IUser, TIssuePriorities } from "types";
// constants
import { ISSUE_DETAILS, PROJECT_ISSUES_ACTIVITY } from "constants/fetch-keys";
import { PRIORITIES } from "constants/project";
// icons
import { PriorityIcon } from "@plane/ui";
import { Check } from "lucide-react";
type Props = {
setIsPaletteOpen: Dispatch<SetStateAction<boolean>>;
issue: IIssue;
user: IUser;
};
// services
const issueService = new IssueService();
export const ChangeIssuePriority: React.FC<Props> = ({ setIsPaletteOpen, issue }) => {
const router = useRouter();
const { workspaceSlug, projectId, issueId } = router.query;
const submitChanges = useCallback(
async (formData: Partial<IIssue>) => {
if (!workspaceSlug || !projectId || !issueId) return;
mutate<IIssue>(
ISSUE_DETAILS(issueId as string),
async (prevData) => {
if (!prevData) return prevData;
return {
...prevData,
...formData,
};
},
false
);
const payload = { ...formData };
await issueService
.patchIssue(workspaceSlug as string, projectId as string, issueId as string, payload)
.then(() => {
mutate(PROJECT_ISSUES_ACTIVITY(issueId as string));
})
.catch((e) => {
console.error(e);
});
},
[workspaceSlug, issueId, projectId]
);
const handleIssueState = (priority: TIssuePriorities) => {
submitChanges({ priority });
setIsPaletteOpen(false);
};
return (
<>
{PRIORITIES.map((priority) => (
<Command.Item key={priority} onSelect={() => handleIssueState(priority)} className="focus:outline-none">
<div className="flex items-center space-x-3">
<PriorityIcon priority={priority} />
<span className="capitalize">{priority ?? "None"}</span>
</div>
<div>{priority === issue.priority && <Check className="h-3 w-3" />}</div>
</Command.Item>
))}
</>
);
};

View File

@ -1,93 +0,0 @@
import React, { Dispatch, SetStateAction, useCallback } from "react";
import { useRouter } from "next/router";
import useSWR, { mutate } from "swr";
// cmdk
import { Command } from "cmdk";
// services
import { IssueService } from "services/issue";
import { ProjectStateService } from "services/project";
// ui
import { Spinner, StateGroupIcon } from "@plane/ui";
// icons
import { Check } from "lucide-react";
// types
import { IUser, IIssue } from "types";
// fetch keys
import { ISSUE_DETAILS, PROJECT_ISSUES_ACTIVITY, STATES_LIST } from "constants/fetch-keys";
type Props = {
setIsPaletteOpen: Dispatch<SetStateAction<boolean>>;
issue: IIssue;
user: IUser | undefined;
};
// services
const issueService = new IssueService();
const stateService = new ProjectStateService();
export const ChangeIssueState: React.FC<Props> = ({ setIsPaletteOpen, issue }) => {
const router = useRouter();
const { workspaceSlug, projectId, issueId } = router.query;
const { data: states, mutate: mutateStates } = useSWR(
workspaceSlug && projectId ? STATES_LIST(projectId as string) : null,
workspaceSlug && projectId ? () => stateService.getStates(workspaceSlug as string, projectId as string) : null
);
const submitChanges = useCallback(
async (formData: Partial<IIssue>) => {
if (!workspaceSlug || !projectId || !issueId) return;
mutate<IIssue>(
ISSUE_DETAILS(issueId as string),
async (prevData) => {
if (!prevData) return prevData;
return {
...prevData,
...formData,
};
},
false
);
const payload = { ...formData };
await issueService
.patchIssue(workspaceSlug as string, projectId as string, issueId as string, payload)
.then(() => {
mutateStates();
mutate(PROJECT_ISSUES_ACTIVITY(issueId as string));
})
.catch((e) => {
console.error(e);
});
},
[workspaceSlug, issueId, projectId, mutateStates]
);
const handleIssueState = (stateId: string) => {
submitChanges({ state: stateId });
setIsPaletteOpen(false);
};
return (
<>
{states ? (
states.length > 0 ? (
states.map((state) => (
<Command.Item key={state.id} onSelect={() => handleIssueState(state.id)} className="focus:outline-none">
<div className="flex items-center space-x-3">
<StateGroupIcon stateGroup={state.group} color={state.color} height="16px" width="16px" />
<p>{state.name}</p>
</div>
<div>{state.id === issue.state && <Check className="h-3 w-3" />}</div>
</Command.Item>
))
) : (
<div className="text-center">No states found</div>
)
) : (
<Spinner />
)}
</>
);
};

View File

@ -1,3 +0,0 @@
export * from "./change-issue-state";
export * from "./change-issue-priority";
export * from "./change-issue-assignee";

View File

@ -92,7 +92,7 @@ export const GptAssistantModal: React.FC<Props> = (props) => {
.catch((err) => {
const error = err?.data?.error;
if (err.status === 429)
if (err?.status === 429)
setToastAlert({
type: "error",
title: "Error!",

View File

@ -1,4 +1,6 @@
import { Fragment, useState } from "react";
// next
import { useRouter } from "next/router";
import { Dialog, Transition } from "@headlessui/react";
import { observer } from "mobx-react-lite";
import { AlertTriangle } from "lucide-react";
@ -27,6 +29,8 @@ export const CycleDeleteModal: React.FC<ICycleDelete> = observer((props) => {
const { setToastAlert } = useToast();
// states
const [loader, setLoader] = useState(false);
const router = useRouter();
const { cycleId } = router.query;
const formSubmit = async () => {
setLoader(true);
@ -38,6 +42,9 @@ export const CycleDeleteModal: React.FC<ICycleDelete> = observer((props) => {
title: "Success!",
message: "Cycle deleted successfully.",
});
if (cycleId) router.replace(`/${workspaceSlug}/projects/${projectId}/cycles`);
handleClose();
} catch (error) {
setToastAlert({

View File

@ -11,7 +11,7 @@ import { IntegrationService } from "services/integrations";
import useToast from "hooks/use-toast";
import useIntegrationPopup from "hooks/use-integration-popup";
// ui
import { Button, Loader } from "@plane/ui";
import { Button, Loader, Tooltip } from "@plane/ui";
// icons
import GithubLogo from "public/services/github.png";
import SlackLogo from "public/services/slack.png";
@ -46,8 +46,11 @@ const integrationService = new IntegrationService();
export const SingleIntegrationCard: React.FC<Props> = observer(({ integration }) => {
const {
appConfig: { envConfig },
user: { currentWorkspaceRole },
} = useMobxStore();
const isUserAdmin = currentWorkspaceRole === 20;
const [deletingIntegration, setDeletingIntegration] = useState(false);
const router = useRouter();
@ -127,13 +130,40 @@ export const SingleIntegrationCard: React.FC<Props> = observer(({ integration })
{workspaceIntegrations ? (
isInstalled ? (
<Button variant="danger" onClick={handleRemoveIntegration} loading={deletingIntegration}>
{deletingIntegration ? "Uninstalling..." : "Uninstall"}
</Button>
<Tooltip
disabled={isUserAdmin}
tooltipContent={!isUserAdmin ? "You don't have permission to perform this" : null}
>
<Button
className={`${!isUserAdmin ? "hover:cursor-not-allowed" : ""}`}
variant="danger"
onClick={() => {
if (!isUserAdmin) return;
handleRemoveIntegration;
}}
disabled={!isUserAdmin}
loading={deletingIntegration}
>
{deletingIntegration ? "Uninstalling..." : "Uninstall"}
</Button>
</Tooltip>
) : (
<Button variant="primary" onClick={startAuth} loading={isInstalling}>
{isInstalling ? "Installing..." : "Install"}
</Button>
<Tooltip
disabled={isUserAdmin}
tooltipContent={!isUserAdmin ? "You don't have permission to perform this" : null}
>
<Button
className={`${!isUserAdmin ? "hover:cursor-not-allowed" : ""}`}
variant="primary"
onClick={() => {
if (!isUserAdmin) return;
startAuth();
}}
loading={isInstalling}
>
{isInstalling ? "Installing..." : "Install"}
</Button>
</Tooltip>
)
) : (
<Loader>

View File

@ -31,6 +31,8 @@ import type { IUser, IIssue, ISearchIssueResponse } from "types";
// components
import { RichTextEditorWithRef } from "@plane/rich-text-editor";
import useEditorSuggestions from "hooks/use-editor-suggestions";
import { observer } from "mobx-react-lite";
import { useMobxStore } from "lib/mobx/store-provider";
const aiService = new AIService();
const fileService = new FileService();
@ -89,7 +91,7 @@ interface IssueFormProps {
)[];
}
export const DraftIssueForm: FC<IssueFormProps> = (props) => {
export const DraftIssueForm: FC<IssueFormProps> = observer((props) => {
const {
handleFormSubmit,
data,
@ -100,30 +102,30 @@ export const DraftIssueForm: FC<IssueFormProps> = (props) => {
createMore,
setCreateMore,
status,
user,
fieldsToShow,
handleDiscard,
} = props;
// states
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);
// hooks
const { setValue: setLocalStorageValue } = useLocalStorage("draftedIssue", {});
const { setToastAlert } = useToast();
const editorSuggestions = useEditorSuggestions();
// refs
const editorRef = useRef<any>(null);
// router
const router = useRouter();
const { workspaceSlug } = router.query;
const { setToastAlert } = useToast();
const editorSuggestions = useEditorSuggestions();
// store
const {
appConfig: { envConfig },
} = useMobxStore();
// form info
const {
formState: { errors, isSubmitting },
handleSubmit,
@ -440,21 +442,23 @@ export const DraftIssueForm: FC<IssueFormProps> = (props) => {
/>
)}
/>
<GptAssistantModal
isOpen={gptAssistantModal}
handleClose={() => {
setGptAssistantModal(false);
// this is done so that the title do not reset after gpt popover closed
reset(getValues());
}}
inset="top-2 left-0"
content=""
htmlContent={watch("description_html")}
onResponse={(response) => {
handleAiAssistance(response);
}}
projectId={projectId}
/>
{envConfig?.has_openai_configured && (
<GptAssistantModal
isOpen={gptAssistantModal}
handleClose={() => {
setGptAssistantModal(false);
// this is done so that the title do not reset after gpt popover closed
reset(getValues());
}}
inset="top-2 left-0"
content=""
htmlContent={watch("description_html")}
onResponse={(response) => {
handleAiAssistance(response);
}}
projectId={projectId}
/>
)}
</div>
)}
<div className="flex flex-wrap items-center gap-2">
@ -623,4 +627,4 @@ export const DraftIssueForm: FC<IssueFormProps> = (props) => {
</form>
</>
);
};
});

View File

@ -94,28 +94,29 @@ export const IssueForm: FC<IssueFormProps> = observer((props) => {
fieldsToShow,
handleFormDirty,
} = props;
// states
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);
// refs
const editorRef = useRef<any>(null);
// router
const router = useRouter();
const { workspaceSlug } = router.query;
const { user: userStore } = useMobxStore();
// store
const {
user: userStore,
appConfig: { envConfig },
} = useMobxStore();
const user = userStore.currentUser;
console.log("envConfig", envConfig);
// hooks
const editorSuggestion = useEditorSuggestions();
const { setToastAlert } = useToast();
// form info
const {
formState: { errors, isSubmitting, isDirty },
handleSubmit,
@ -396,21 +397,23 @@ export const IssueForm: FC<IssueFormProps> = observer((props) => {
/>
)}
/>
<GptAssistantModal
isOpen={gptAssistantModal}
handleClose={() => {
setGptAssistantModal(false);
// this is done so that the title do not reset after gpt popover closed
reset(getValues());
}}
inset="top-2 left-0"
content=""
htmlContent={watch("description_html")}
onResponse={(response) => {
handleAiAssistance(response);
}}
projectId={projectId}
/>
{envConfig?.has_openai_configured && (
<GptAssistantModal
isOpen={gptAssistantModal}
handleClose={() => {
setGptAssistantModal(false);
// this is done so that the title do not reset after gpt popover closed
reset(getValues());
}}
inset="top-2 left-0"
content=""
htmlContent={watch("description_html")}
onResponse={(response) => {
handleAiAssistance(response);
}}
projectId={projectId}
/>
)}
</div>
)}
<div className="flex flex-wrap items-center gap-2">

View File

@ -225,6 +225,7 @@ export const IssueView: FC<IIssueView> = observer((props) => {
size="sm"
prependIcon={<Bell className="h-3 w-3" />}
variant="outline-primary"
className="hover:!bg-custom-primary-100/20"
onClick={() =>
issueSubscription && issueSubscription.subscribed
? issueSubscriptionRemove()

View File

@ -33,7 +33,7 @@ import {
import { CustomDatePicker } from "components/ui";
// icons
import { Bell, CalendarDays, LinkIcon, Plus, Signal, Tag, Trash2, Triangle, User2 } from "lucide-react";
import { ContrastIcon, DiceIcon, DoubleCircleIcon, UserGroupIcon } from "@plane/ui";
import { Button, ContrastIcon, DiceIcon, DoubleCircleIcon, UserGroupIcon } from "@plane/ui";
// helpers
import { copyTextToClipboard } from "helpers/string.helper";
// types
@ -273,17 +273,18 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
!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"
<Button
size="sm"
prependIcon={<Bell className="h-3 w-3" />}
variant="outline-primary"
className="hover:!bg-custom-primary-100/20"
onClick={() => {
if (subscribed) handleUnsubscribe();
else handleSubscribe();
}}
>
<Bell className="h-3.5 w-3.5" />
{loading ? "Loading..." : subscribed ? "Unsubscribe" : "Subscribe"}
</button>
</Button>
)}
{(fieldsToShow.includes("all") || fieldsToShow.includes("link")) && (
<button

View File

@ -429,10 +429,7 @@ export const ModuleDetailsSidebar: React.FC<Props> = observer((props) => {
<Disclosure>
{({ open }) => (
<div className={`relative flex h-full w-full flex-col ${open ? "" : "flex-row"}`}>
<Disclosure.Button
className="flex w-full items-center justify-between gap-2 p-1.5"
disabled={!isStartValid || !isEndValid}
>
<Disclosure.Button className="flex w-full items-center justify-between gap-2 p-1.5">
<div className="flex items-center justify-start gap-2 text-sm">
<span className="font-medium text-custom-text-200">Links</span>
</div>

View File

@ -168,7 +168,7 @@ export const NotificationCard: React.FC<NotificationCardProps> = (props) => {
onClick: () => {
markNotificationReadStatusToggle(notification.id).then(() => {
setToastAlert({
title: notification.read_at ? "Notification marked as unread" : "Notification marked as read",
title: notification.read_at ? "Notification marked as read" : "Notification marked as unread",
type: "success",
});
});

View File

@ -19,6 +19,7 @@ import { IUser, IPageBlock } from "types";
// fetch-keys
import { PAGE_BLOCKS_LIST } from "constants/fetch-keys";
import useEditorSuggestions from "hooks/use-editor-suggestions";
import { useMobxStore } from "lib/mobx/store-provider";
type Props = {
handleClose: () => void;
@ -40,19 +41,24 @@ const pagesService = new PageService();
const issueService = new IssueService();
const fileService = new FileService();
export const CreateUpdateBlockInline: FC<Props> = ({ handleClose, data, handleAiAssistance, setIsSyncing, focus }) => {
export const CreateUpdateBlockInline: FC<Props> = (props) => {
const { handleClose, data, handleAiAssistance, setIsSyncing, focus } = props;
// states
const [iAmFeelingLucky, setIAmFeelingLucky] = useState(false);
const [gptAssistantModal, setGptAssistantModal] = useState(false);
// store
const {
appConfig: { envConfig },
} = useMobxStore();
// refs
const editorRef = useRef<any>(null);
// router
const router = useRouter();
const { workspaceSlug, projectId, pageId } = router.query;
// hooks
const editorSuggestion = useEditorSuggestions();
const { setToastAlert } = useToast();
// form info
const {
handleSubmit,
control,
@ -222,9 +228,7 @@ export const CreateUpdateBlockInline: FC<Props> = ({ handleClose, data, handleAi
else handleSubmit(createPageBlock)();
}
};
window.addEventListener("keydown", submitForm);
return () => {
window.removeEventListener("keydown", submitForm);
};
@ -345,26 +349,28 @@ export const CreateUpdateBlockInline: FC<Props> = ({ handleClose, data, handleAi
</Button>
</div>
</form>
<GptAssistantModal
block={data ? data : undefined}
isOpen={gptAssistantModal}
handleClose={() => setGptAssistantModal(false)}
inset="top-8 left-0"
content={watch("description_html")}
htmlContent={watch("description_html")}
onResponse={(response) => {
if (data && handleAiAssistance) {
handleAiAssistance(response);
editorRef.current?.setEditorValue(`${watch("description_html")}<p>${response}</p>` ?? "");
} else {
setValue("description", {});
setValue("description_html", `${watch("description_html")}<p>${response}</p>`);
{envConfig?.has_openai_configured && (
<GptAssistantModal
block={data ? data : undefined}
isOpen={gptAssistantModal}
handleClose={() => setGptAssistantModal(false)}
inset="top-8 left-0"
content={watch("description_html")}
htmlContent={watch("description_html")}
onResponse={(response) => {
if (data && handleAiAssistance) {
handleAiAssistance(response);
editorRef.current?.setEditorValue(`${watch("description_html")}<p>${response}</p>` ?? "");
} else {
setValue("description", {});
setValue("description_html", `${watch("description_html")}<p>${response}</p>`);
editorRef.current?.setEditorValue(watch("description_html") ?? "");
}
}}
projectId={projectId?.toString() ?? ""}
/>
editorRef.current?.setEditorValue(watch("description_html") ?? "");
}
}}
projectId={projectId?.toString() ?? ""}
/>
)}
</div>
);
};

View File

@ -38,7 +38,7 @@ export const CreateUpdatePageModal: FC<Props> = (props) => {
const createProjectPage = async (payload: IPage) => {
if (!workspaceSlug) return;
createPage(workspaceSlug.toString(), projectId, payload)
await createPage(workspaceSlug.toString(), projectId, payload)
.then((res) => {
router.push(`/${workspaceSlug}/projects/${projectId}/pages/${res.id}`);
onClose();
@ -67,7 +67,7 @@ export const CreateUpdatePageModal: FC<Props> = (props) => {
const updateProjectPage = async (payload: IPage) => {
if (!data || !workspaceSlug) return;
return updatePage(workspaceSlug.toString(), projectId, data.id, payload)
await updatePage(workspaceSlug.toString(), projectId, data.id, payload)
.then((res) => {
onClose();
setToastAlert({

View File

@ -1,9 +1,9 @@
import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
// ui
import { Button, Input, Tooltip } from "@plane/ui";
// types
import { IPage } from "types";
// constants
import { PAGE_ACCESS_SPECIFIERS } from "constants/page";
type Props = {
@ -18,31 +18,21 @@ const defaultValues = {
access: 0,
};
export const PageForm: React.FC<Props> = ({ handleFormSubmit, handleClose, data }) => {
export const PageForm: React.FC<Props> = (props) => {
const { handleFormSubmit, handleClose, data } = props;
const {
formState: { errors, isSubmitting },
handleSubmit,
control,
reset,
} = useForm<IPage>({
defaultValues,
defaultValues: { ...defaultValues, ...data },
});
const handleCreateUpdatePage = async (formData: IPage) => {
await handleFormSubmit(formData);
reset({
...defaultValues,
});
};
useEffect(() => {
reset({
...defaultValues,
...data,
});
}, [data, reset]);
return (
<form onSubmit={handleSubmit(handleCreateUpdatePage)}>
<div className="space-y-4">

View File

@ -38,8 +38,9 @@ export const RecentPagesList: FC = observer(() => {
<>
{Object.keys(recentProjectPages).map((key) => {
if (recentProjectPages[key].length === 0) return null;
return (
<div key={key} className="h-full overflow-hidden pb-9">
<div key={key} className="overflow-hidden">
<h2 className="text-xl font-semibold capitalize mb-2">{replaceUnderscoreIfSnakeCase(key)}</h2>
<PagesListView pages={recentProjectPages[key]} />
</div>

View File

@ -113,7 +113,7 @@ export const ProjectCard: React.FC<ProjectCardProps> = observer((props) => {
className="absolute top-0 left-0 h-full w-full object-cover rounded-t"
/>
<div className="absolute h-10 w-full bottom-4 z-10 flex items-center justify-between px-4">
<div className="absolute h-10 w-full bottom-4 z-10 flex items-center justify-between gap-3 px-4">
<div className="flex items-center gap-2.5 flex-grow truncate">
<div className="h-9 w-9 flex item-center justify-center rounded bg-white/90 flex-shrink-0">
<span className="flex items-center justify-center">

View File

@ -21,22 +21,21 @@ export const JoinProjectModal: React.FC<TJoinProjectModalProps> = (props) => {
// states
const [isJoiningLoading, setIsJoiningLoading] = useState(false);
// store
const { project: projectStore } = useMobxStore();
const {
project: { joinProject },
} = useMobxStore();
// router
const router = useRouter();
const handleJoin = () => {
setIsJoiningLoading(true);
projectStore
.joinProject(workspaceSlug, [project.id])
joinProject(workspaceSlug, [project.id])
.then(() => {
setIsJoiningLoading(false);
router.push(`/${workspaceSlug}/projects/${project.id}/issues`);
handleClose();
})
.catch(() => {
.finally(() => {
setIsJoiningLoading(false);
});
};
@ -73,8 +72,9 @@ export const JoinProjectModal: React.FC<TJoinProjectModalProps> = (props) => {
Join Project?
</Dialog.Title>
<p>
Are you sure you want to join the project <span className="font-semibold break-words">{project?.name}</span>?
Please click the &apos;Join Project&apos; button below to continue.
Are you sure you want to join the project{" "}
<span className="font-semibold break-words">{project?.name}</span>? Please click the &apos;Join
Project&apos; button below to continue.
</p>
<div className="space-y-3" />
</div>

View File

@ -11,7 +11,7 @@ import { ConfirmProjectMemberRemove } from "components/project";
// ui
import { CustomSelect, Tooltip } from "@plane/ui";
// icons
import { ChevronDown, XCircle } from "lucide-react";
import { ChevronDown, Dot, XCircle } from "lucide-react";
// constants
import { ROLE } from "constants/workspace";
// types
@ -116,7 +116,15 @@ export const ProjectMemberListItem: React.FC<Props> = observer((props) => {
) : (
<h4 className="text-sm cursor-default">{member.display_name || member.email}</h4>
)}
<p className="mt-0.5 text-xs text-custom-sidebar-text-300">{member.email ?? member.display_name}</p>
<div className="flex items-center">
<p className="text-xs text-custom-text-300">{member.display_name}</p>
{isAdmin && (
<>
<Dot height={16} width={16} className="text-custom-text-300" />
<p className="text-xs text-custom-text-300">{member.email}</p>
</>
)}
</div>
</div>
</div>

View File

@ -1,11 +1,22 @@
import { useState } from "react";
import { useRef, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/router";
import { DraggableProvided, DraggableStateSnapshot } from "@hello-pangea/dnd";
import { Disclosure, Transition } from "@headlessui/react";
import { observer } from "mobx-react-lite";
// icons
import { MoreVertical, PenSquare, LinkIcon, Star, FileText, Settings, Share2, LogOut, ChevronDown } from "lucide-react";
import {
MoreVertical,
PenSquare,
LinkIcon,
Star,
FileText,
Settings,
Share2,
LogOut,
ChevronDown,
MoreHorizontal,
} from "lucide-react";
// hooks
import useToast from "hooks/use-toast";
// helpers
@ -17,6 +28,7 @@ import { useMobxStore } from "lib/mobx/store-provider";
// components
import { CustomMenu, Tooltip, ArchiveIcon, PhotoFilterIcon, DiceIcon, ContrastIcon, LayersIcon } from "@plane/ui";
import { LeaveProjectModal, PublishProjectModal } from "components/project";
import useOutsideClickDetector from "hooks/use-outside-click-detector";
type Props = {
project: IProject;
@ -72,12 +84,15 @@ export const ProjectSidebarListItem: React.FC<Props> = observer((props) => {
// states
const [leaveProjectModalOpen, setLeaveProjectModal] = useState(false);
const [publishModalOpen, setPublishModal] = useState(false);
const [isMenuActive, setIsMenuActive] = useState(false);
const isAdmin = project.member_role === 20;
const isViewerOrGuest = project.member_role === 10 || project.member_role === 5;
const isCollapsed = themeStore.sidebarCollapsed;
const actionSectionRef = useRef<HTMLDivElement | null>(null);
const handleAddToFavorites = () => {
if (!workspaceSlug) return;
@ -110,6 +125,8 @@ export const ProjectSidebarListItem: React.FC<Props> = observer((props) => {
setLeaveProjectModal(false);
};
useOutsideClickDetector(actionSectionRef, () => setIsMenuActive(false));
return (
<>
<PublishProjectModal isOpen={publishModalOpen} project={project} onClose={() => setPublishModal(false)} />
@ -120,7 +137,7 @@ export const ProjectSidebarListItem: React.FC<Props> = observer((props) => {
<div
className={`group relative text-custom-sidebar-text-10 px-2 py-1 w-full flex items-center hover:bg-custom-sidebar-background-80 rounded-md ${
snapshot?.isDragging ? "opacity-60" : ""
}`}
} ${isMenuActive ? "!bg-custom-sidebar-background-80" : ""}`}
>
{provided && (
<Tooltip
@ -131,7 +148,9 @@ export const ProjectSidebarListItem: React.FC<Props> = observer((props) => {
type="button"
className={`absolute top-1/2 -translate-y-1/2 -left-2.5 hidden rounded p-0.5 text-custom-sidebar-text-400 ${
isCollapsed ? "" : "group-hover:!flex"
} ${project.sort_order === null ? "opacity-60 cursor-not-allowed" : ""}`}
} ${project.sort_order === null ? "opacity-60 cursor-not-allowed" : ""} ${
isMenuActive ? "!flex" : ""
}`}
{...provided?.dragHandleProps}
>
<MoreVertical className="h-3.5" />
@ -169,9 +188,9 @@ export const ProjectSidebarListItem: React.FC<Props> = observer((props) => {
</div>
{!isCollapsed && (
<ChevronDown
className={`h-4 w-4 flex-shrink-0 ${
open ? "rotate-180" : ""
} !hidden group-hover:!block text-custom-sidebar-text-400 duration-300`}
className={`h-4 w-4 flex-shrink-0 hidden ${open ? "rotate-180" : ""} ${
isMenuActive ? "!block" : ""
} group-hover:!block mb-0.5 text-custom-sidebar-text-400 duration-300`}
/>
)}
</Disclosure.Button>
@ -179,7 +198,16 @@ export const ProjectSidebarListItem: React.FC<Props> = observer((props) => {
{!isCollapsed && (
<CustomMenu
className="hidden group-hover:block flex-shrink-0"
customButton={
<div
ref={actionSectionRef}
className="w-full cursor-pointer text-custom-sidebar-text-400 px-1 mt-1.5 my-auto duration-300"
onClick={() => setIsMenuActive(!isMenuActive)}
>
<MoreHorizontal className="h-3.5 w-3.5" />
</div>
}
className={`hidden group-hover:block flex-shrink-0 ${isMenuActive ? "!block" : ""}`}
buttonClassName="!text-custom-sidebar-text-400 hover:text-custom-sidebar-text-400"
ellipsis
placement="bottom-start"

View File

@ -1,28 +1,32 @@
import { FC } from "react";
import Link from "next/link";
import { Button } from "@plane/ui";
// next
import { useRouter } from "next/router";
import Image from "next/image";
import EmptyWebhookLogo from "public/empty-state/issue.svg";
// ui
import { Button } from "@plane/ui";
// assets
import EmptyWebhook from "public/empty-state/web-hook.svg";
interface IWebHookLists {
workspaceSlug: string;
}
export const EmptyWebhooks: FC<IWebHookLists> = (props) => {
const { workspaceSlug } = props;
export const EmptyWebhooks = () => {
const router = useRouter();
return (
<div className="flex items-start justify-center">
<div className="flex p-10 flex-col items-center justify-center rounded-[4px] border border-custom-border-200 bg-custom-color-background-90">
<Image width="178" height="116" src={EmptyWebhookLogo} alt="empty-webhook image" />
<div className="mt-4 text-base font-semibold">No Webhooks</div>
<p className="text-sm text-neutral-600">Create webhooks to receive real-time updates and automate actions</p>
<Link href={`/${workspaceSlug}/settings/webhooks/create`}>
<Button variant="primary" className="mt-2">
Add Webhook
</Button>
</Link>
<div className={`flex items-center justify-center mx-auto rounded-sm border border-custom-border-200 bg-custom-background-90 py-10 px-16 w-full`}>
<div className="text-center flex flex-col items-center w-full">
<Image src={EmptyWebhook} className="w-52 sm:w-60" alt="empty" />
<h6 className="text-xl font-semibold mt-6 sm:mt-8 mb-3">No Webhooks</h6>
{
<p className="text-custom-text-300 mb-7 sm:mb-8">
Create webhooks to receive real-time updates and automate actions
</p>
}
<Button
className="flex items-center gap-1.5"
onClick={() => {
router.push(`${router.asPath}/create/`);
}}
>
Add Webhook
</Button>
</div>
</div>
);

View File

@ -1,10 +1,10 @@
import { FC, useState } from "react";
import { FC } from "react";
import { ToggleSwitch } from "@plane/ui";
import { Pencil, XCircle } from "lucide-react";
import { IWebhook } from "types";
import Link from "next/link";
import { RootStore } from "store/root";
import { useMobxStore } from "lib/mobx/store-provider";
// types
import { IWebhook } from "types";
interface IWebhookListItem {
workspaceSlug: string;

View File

@ -23,7 +23,10 @@ export const IssuesStats: React.FC<Props> = ({ data }) => {
<h4 className="text-sm">Issues assigned to you</h4>
<h5 className="mt-2 text-2xl font-semibold">
{data ? (
<div className="cursor-pointer" onClick={() => router.push(`/${workspaceSlug}/me/my-issues`)}>
<div
className="cursor-pointer"
onClick={() => router.push(`/${workspaceSlug}/workspace-views/assigned`)}
>
{data.assigned_issues_count}
</div>
) : (

View File

@ -11,7 +11,7 @@ import { ConfirmWorkspaceMemberRemove } from "components/workspace";
// ui
import { CustomSelect, Tooltip } from "@plane/ui";
// icons
import { ChevronDown, XCircle } from "lucide-react";
import { ChevronDown, Dot, XCircle } from "lucide-react";
// constants
import { ROLE } from "constants/workspace";
import { TUserWorkspaceRole } from "types";
@ -132,7 +132,15 @@ export const WorkspaceMembersListItem: FC<Props> = (props) => {
) : (
<h4 className="text-sm cursor-default">{member.display_name || member.email}</h4>
)}
<p className="mt-0.5 text-xs text-custom-sidebar-text-300">{member.email ?? member.display_name}</p>
<div className="flex items-center">
<p className="text-xs text-custom-text-300">{member.display_name}</p>
{isAdmin && (
<>
<Dot height={16} width={16} className="text-custom-text-300" />
<p className="text-xs text-custom-text-300">{member.email}</p>
</>
)}
</div>
</div>
</div>
<div className="flex items-center gap-2 text-xs">

View File

@ -56,7 +56,7 @@ export const WorkspaceMembersList: FC<{ searchQuery: string }> = observer(({ sea
);
return (
<div className="divide-y-[0.5px] divide-custom-border-200">
<div className="divide-y-[0.5px] divide-custom-border-100">
{workspaceMembersWithInvitations.length > 0
? searchedMembers?.map((member) => <WorkspaceMembersListItem key={member.id} member={member} />)
: null}

View File

@ -239,7 +239,7 @@ export const ISSUE_DISPLAY_FILTERS_BY_LAYOUT: {
filters: ["priority", "state_group", "labels", "start_date", "target_date"],
display_properties: true,
display_filters: {
group_by: ["state_detail.group", "priority", "project", "labels", null],
group_by: ["state_detail.group", "priority", "project", "labels"],
order_by: ["sort_order", "-created_at", "-updated_at", "start_date", "priority"],
type: [null, "active", "backlog"],
},
@ -282,7 +282,7 @@ export const ISSUE_DISPLAY_FILTERS_BY_LAYOUT: {
filters: ["priority", "state_group", "labels", "start_date", "target_date"],
display_properties: true,
display_filters: {
group_by: ["state_detail.group", "priority", "project", "labels", null],
group_by: ["state_detail.group", "priority", "project", "labels"],
order_by: ["sort_order", "-created_at", "-updated_at", "start_date", "priority"],
type: [null, "active", "backlog"],
},

View File

@ -303,7 +303,8 @@ const ProfilePage: NextPageWithLayout = () => {
value={value}
onChange={onChange}
label={value ? value.toString() : "Select your role"}
buttonClassName={errors.role ? "border-red-500 bg-red-500/10" : ""}
buttonClassName={errors.role ? "border-red-500 bg-red-500/10" : "border-none"}
className="rounded-md border !border-custom-border-200"
width="w-full"
input
>
@ -369,6 +370,8 @@ const ProfilePage: NextPageWithLayout = () => {
options={timeZoneOptions}
onChange={onChange}
optionsClassName="w-full"
buttonClassName={"border-none"}
className="rounded-md border !border-custom-border-200"
input
/>
)}

View File

@ -1,12 +1,13 @@
import React, { useEffect, useRef, useState, ReactElement } from "react";
import { useRouter } from "next/router";
import useSWR, { mutate } from "swr";
import useSWR from "swr";
import { Controller, useForm } from "react-hook-form";
// services
import { PageService } from "services/page.service";
import { useDebouncedCallback } from "use-debounce";
import { FileService } from "services/file.service";
// hooks
import useUser from "hooks/use-user";
import { useDebouncedCallback } from "use-debounce";
// layouts
import { AppLayout } from "layouts/app-layout";
// components
@ -24,7 +25,6 @@ import { NextPageWithLayout } from "types/app";
import { IPage } from "types";
// fetch-keys
import { PAGE_DETAILS } from "constants/fetch-keys";
import { FileService } from "services/file.service";
// services
const fileService = new FileService();
@ -45,10 +45,14 @@ const PageDetailsPage: NextPageWithLayout = () => {
});
// =================== Fetching Page Details ======================
const { data: pageDetails, error } = useSWR(
workspaceSlug && projectId && pageId ? PAGE_DETAILS(pageId as string) : null,
workspaceSlug && projectId
? () => pageService.getPageDetails(workspaceSlug as string, projectId as string, pageId as string)
const {
data: pageDetails,
mutate: mutatePageDetails,
error,
} = useSWR(
workspaceSlug && projectId && pageId ? PAGE_DETAILS(pageId.toString()) : null,
workspaceSlug && projectId && pageId
? () => pageService.getPageDetails(workspaceSlug.toString(), projectId.toString(), pageId.toString())
: null
);
@ -57,20 +61,23 @@ const PageDetailsPage: NextPageWithLayout = () => {
if (!formData.name || formData.name.length === 0 || formData.name === "") return;
await pageService.patchPage(workspaceSlug as string, projectId as string, pageId as string, formData).then(() => {
mutate<IPage>(
PAGE_DETAILS(pageId as string),
(prevData) => ({
...prevData,
...formData,
}),
false
);
});
await pageService
.patchPage(workspaceSlug.toString(), projectId.toString(), pageId.toString(), formData)
.then(() => {
mutatePageDetails(
(prevData) => ({
...prevData,
...formData,
}),
false
);
});
};
const createPage = async (payload: Partial<IPage>) => {
await pageService.createPage(workspaceSlug as string, projectId as string, payload);
if (!workspaceSlug || !projectId) return;
await pageService.createPage(workspaceSlug.toString(), projectId.toString(), payload);
};
// ================ Page Menu Actions ==================
@ -84,79 +91,79 @@ const PageDetailsPage: NextPageWithLayout = () => {
};
const archivePage = async () => {
if (!workspaceSlug || !projectId || !pageId) return;
try {
await pageService.archivePage(workspaceSlug as string, projectId as string, pageId as string).then(() => {
mutate<IPage>(
PAGE_DETAILS(pageId as string),
(prevData) => {
if (prevData && prevData.is_locked) {
prevData.archived_at = renderDateFormat(new Date());
return prevData;
}
},
true
);
});
mutatePageDetails((prevData) => {
if (!prevData) return;
return {
...prevData,
archived_at: renderDateFormat(new Date()),
};
}, true);
await pageService.archivePage(workspaceSlug.toString(), projectId.toString(), pageId.toString());
} catch (e) {
console.log(e);
mutatePageDetails();
}
};
const unArchivePage = async () => {
if (!workspaceSlug || !projectId || !pageId) return;
try {
await pageService.restorePage(workspaceSlug as string, projectId as string, pageId as string).then(() => {
mutate<IPage>(
PAGE_DETAILS(pageId as string),
(prevData) => {
if (prevData && prevData.is_locked) {
prevData.archived_at = null;
return prevData;
}
},
true
);
});
mutatePageDetails((prevData) => {
if (!prevData) return;
return {
...prevData,
archived_at: null,
};
}, false);
await pageService.restorePage(workspaceSlug.toString(), projectId.toString(), pageId.toString());
} catch (e) {
console.log(e);
mutatePageDetails();
}
};
// ========================= Page Lock ==========================
const lockPage = async () => {
if (!workspaceSlug || !projectId || !pageId) return;
try {
await pageService.lockPage(workspaceSlug as string, projectId as string, pageId as string).then(() => {
mutate<IPage>(
PAGE_DETAILS(pageId as string),
(prevData) => {
if (prevData && prevData.is_locked) {
prevData.is_locked = true;
}
return prevData;
},
true
);
});
mutatePageDetails((prevData) => {
if (!prevData) return;
return {
...prevData,
is_locked: true,
};
}, false);
await pageService.lockPage(workspaceSlug.toString(), projectId.toString(), pageId.toString());
} catch (e) {
console.log(e);
mutatePageDetails();
}
};
const unlockPage = async () => {
if (!workspaceSlug || !projectId || !pageId) return;
try {
await pageService.unlockPage(workspaceSlug as string, projectId as string, pageId as string).then(() => {
mutate<IPage>(
PAGE_DETAILS(pageId as string),
(prevData) => {
if (prevData && prevData.is_locked) {
prevData.is_locked = false;
return prevData;
}
},
true
);
});
mutatePageDetails((prevData) => {
if (!prevData) return;
return {
...prevData,
is_locked: false,
};
}, false);
await pageService.unlockPage(workspaceSlug.toString(), projectId.toString(), pageId.toString());
} catch (e) {
console.log(e);
mutatePageDetails();
}
};
@ -185,8 +192,8 @@ const PageDetailsPage: NextPageWithLayout = () => {
}}
/>
) : pageDetails ? (
<div className="flex h-full flex-col justify-between pl-5 pr-5">
<div className="h-full w-full">
<div className="flex h-full flex-col justify-between">
<div className="h-full w-full overflow-hidden">
{pageDetails.is_locked || pageDetails.archived_at ? (
<DocumentReadOnlyEditorWithRef
ref={editorRef}
@ -235,7 +242,7 @@ const PageDetailsPage: NextPageWithLayout = () => {
debouncedUpdatesEnabled={false}
setIsSubmitting={setIsSubmitting}
value={!value || value === "" ? "<p></p>" : value}
customClassName="tracking-tight self-center w-full max-w-full px-0"
customClassName="tracking-tight self-center px-0 h-full w-full"
onChange={(_description_json: Object, description_html: string) => {
onChange(description_html);
setIsSubmitting("submitting");

View File

@ -90,7 +90,7 @@ const ProjectPagesPage: NextPageWithLayout = observer(() => {
projectId={projectId.toString()}
/>
)}
<div className="space-y-5 p-8 h-full overflow-hidden flex flex-col">
<div className="space-y-5 p-6 h-full overflow-hidden flex flex-col">
<div className="flex gap-4 justify-between">
<h3 className="text-2xl font-semibold text-custom-text-100">Pages</h3>
</div>

View File

@ -31,7 +31,7 @@ const WebhooksPage: NextPage = observer(() => {
return (
<AppLayout header={<WorkspaceSettingHeader title="Webhook Settings" />}>
<WorkspaceSettingLayout>
<div className="w-full overflow-y-auto py-3 pr-4">
<div className="w-full overflow-y-auto py-3 pr-9">
{loader ? (
<div className="flex h-full w-ful items-center justify-center">
<Spinner />
@ -41,10 +41,8 @@ const WebhooksPage: NextPage = observer(() => {
{Object.keys(webhooks).length > 0 ? (
<WebhookLists workspaceSlug={workspaceSlug} />
) : (
<div className="flex justify-center w-full h-full items-center">
<div className="w-auto h-fit">
<EmptyWebhooks workspaceSlug={workspaceSlug} />
</div>
<div className="py-5 mx-auto">
<EmptyWebhooks />
</div>
)}
</>

View File

@ -0,0 +1,49 @@
<svg width="128" height="130" viewBox="0 0 128 130" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M60.5893 36.7461C84.3294 36.7461 103.575 55.9912 103.575 79.7313C103.575 103.471 84.3294 122.716 60.5893 122.716C36.8493 122.716 3.99396 99.179 17.6042 79.7313C31.2144 60.2836 36.8493 36.7461 60.5893 36.7461Z" fill="#F2F2F2"/>
<path d="M118.122 38.7891C117.736 38.7891 117.423 39.1025 117.423 39.488V61.8543C117.423 62.2398 117.736 62.5532 118.122 62.5532C118.507 62.5532 118.821 62.2398 118.821 61.8543V39.488C118.821 39.1025 118.507 38.7891 118.122 38.7891Z" fill="#525252"/>
<path d="M0.698946 22.0195C0.313467 22.0195 0 22.333 0 22.7185V28.31C0 28.6955 0.313467 29.009 0.698946 29.009C1.08443 29.009 1.39789 28.6955 1.39789 28.31V22.7185C1.39789 22.333 1.08443 22.0195 0.698946 22.0195Z" fill="#525252"/>
<path d="M0.698946 38.7891C0.313467 38.7891 0 39.1025 0 39.488V50.3217C0 50.7072 0.313467 51.0206 0.698946 51.0206C1.08443 51.0206 1.39789 50.7072 1.39789 50.3217V39.488C1.39789 39.1025 1.08443 38.7891 0.698946 38.7891Z" fill="#525252"/>
<path d="M0.698946 54.168C0.313467 54.168 0 54.4814 0 54.8669V65.7006C0 66.0861 0.313467 66.3995 0.698946 66.3995C1.08443 66.3995 1.39789 66.0861 1.39789 65.7006V54.8669C1.39789 54.4814 1.08443 54.168 0.698946 54.168Z" fill="#525252"/>
<path d="M63.385 0C62.9995 0 62.686 0.313467 62.686 0.698946V11.5326C62.686 11.9181 62.9995 12.2316 63.385 12.2316C63.7705 12.2316 64.0839 11.9181 64.0839 11.5326V0.698946C64.0839 0.313467 63.7705 0 63.385 0Z" fill="#525252"/>
<path d="M41.7948 76.8354H17.604V76.1365H41.7948C43.8962 76.1365 45.6059 74.4267 45.6059 72.325V55.9688H46.3048V72.325C46.3048 74.812 44.2817 76.8354 41.7948 76.8354Z" fill="#396AE6"/>
<path d="M87.9532 112.289H32.9336V112.988H87.9532V112.289Z" fill="#396AE6"/>
<path d="M122.861 72.1605H93.4733C91.8874 72.1605 90.5972 70.8703 90.5972 69.2844V40.2551C90.5972 38.6691 91.8874 37.3789 93.4733 37.3789H122.861C124.447 37.3789 125.737 38.6691 125.737 40.2551V69.2844C125.737 70.8703 124.447 72.1605 122.861 72.1605Z" fill="#E6E6E6"/>
<path d="M98.9462 38.2344C94.8084 38.2344 91.4541 41.5887 91.4541 45.7265V68.667C91.4541 70.1223 92.6339 71.3021 94.0892 71.3021H112.059C119.14 71.3021 124.88 65.5618 124.88 58.4809V40.8695C124.88 39.4142 123.701 38.2344 122.245 38.2344L98.9462 38.2344Z" fill="white"/>
<path d="M116.038 46.6717H100.235C99.9333 46.6717 99.688 46.4263 99.688 46.1248C99.688 45.8233 99.9333 45.5781 100.235 45.5781H116.038C116.34 45.5781 116.585 45.8233 116.585 46.1248C116.585 46.4263 116.34 46.6717 116.038 46.6717Z" fill="#E6E6E6"/>
<path d="M116.038 58.3318H100.235C99.9333 58.3318 99.688 58.0865 99.688 57.785C99.688 57.4835 99.9333 57.2383 100.235 57.2383H116.038C116.34 57.2383 116.585 57.4835 116.585 57.785C116.585 58.0865 116.34 58.3318 116.038 58.3318Z" fill="#E6E6E6"/>
<path d="M122.456 52.5076H93.8173C93.5158 52.5076 93.2705 52.2623 93.2705 51.9608C93.2705 51.6593 93.5158 51.4141 93.8173 51.4141H122.456C122.757 51.4141 123.002 51.6593 123.002 51.9608C123.002 52.2623 122.757 52.5076 122.456 52.5076Z" fill="#E6E6E6"/>
<path d="M122.748 63.0701H112.402C112.101 63.0701 111.855 62.8248 111.855 62.5233C111.855 62.2218 112.101 61.9766 112.402 61.9766H122.748C123.049 61.9766 123.295 62.2218 123.295 62.5233C123.295 62.8248 123.049 63.0701 122.748 63.0701Z" fill="#E6E6E6"/>
<path d="M61.1334 130.002H37.7975C36.5382 130.002 35.5137 128.977 35.5137 127.718V104.667C35.5137 103.407 36.5382 102.383 37.7975 102.383H61.1334C62.3928 102.383 63.4173 103.407 63.4173 104.667V127.718C63.4173 128.977 62.3928 130.002 61.1334 130.002Z" fill="#E6E6E6"/>
<path d="M42.1436 103.062C38.8579 103.062 36.1943 105.726 36.1943 109.012V127.228C36.1943 128.384 37.1312 129.321 38.2868 129.321H52.5561C58.1788 129.321 62.737 124.762 62.737 119.14V105.155C62.737 103.999 61.8002 103.063 60.6445 103.063L42.1436 103.062Z" fill="white"/>
<path d="M55.7156 109.759H43.1666C42.9272 109.759 42.7324 109.564 42.7324 109.325C42.7324 109.085 42.9272 108.891 43.1666 108.891H55.7156C55.955 108.891 56.1497 109.085 56.1497 109.325C56.1497 109.564 55.955 109.759 55.7156 109.759Z" fill="#E6E6E6"/>
<path d="M55.7156 119.017H43.1666C42.9272 119.017 42.7324 118.822 42.7324 118.583C42.7324 118.343 42.9272 118.148 43.1666 118.148H55.7156C55.955 118.148 56.1497 118.343 56.1497 118.583C56.1497 118.822 55.955 119.017 55.7156 119.017Z" fill="#E6E6E6"/>
<path d="M60.8116 114.392H38.0709C37.8315 114.392 37.6367 114.197 37.6367 113.958C37.6367 113.718 37.8315 113.523 38.0709 113.523H60.8116C61.051 113.523 61.2457 113.718 61.2457 113.958C61.2457 114.197 61.051 114.392 60.8116 114.392Z" fill="#E6E6E6"/>
<path d="M61.0434 122.786H52.8283C52.5889 122.786 52.394 122.592 52.394 122.352C52.394 122.113 52.5888 121.918 52.8283 121.918H61.0434C61.2828 121.918 61.4775 122.113 61.4775 122.352C61.4775 122.592 61.2828 122.786 61.0434 122.786Z" fill="#E6E6E6"/>
<path d="M44.8224 55.7926H27.5645C26.6332 55.7926 25.8755 55.0349 25.8755 54.1036V37.0562C25.8755 36.1249 26.6332 35.3672 27.5645 35.3672H44.8224C45.7538 35.3672 46.5114 36.1249 46.5114 37.0562V54.1036C46.5114 55.0349 45.7538 55.7926 44.8224 55.7926Z" fill="#E6E6E6"/>
<path d="M30.7787 35.8711C28.3487 35.8711 26.3789 37.8409 26.3789 40.2708V53.7426C26.3789 54.5972 27.0717 55.29 27.9264 55.29H38.4791C42.6374 55.29 46.0084 51.9191 46.0084 47.7608V37.4186C46.0084 36.5639 45.3155 35.8711 44.4609 35.8711L30.7787 35.8711Z" fill="white"/>
<path d="M40.816 40.8219H31.5355C31.3584 40.8219 31.2144 40.6778 31.2144 40.5007C31.2144 40.3237 31.3584 40.1797 31.5355 40.1797H40.816C40.993 40.1797 41.137 40.3237 41.137 40.5007C41.137 40.6778 40.993 40.8219 40.816 40.8219Z" fill="#E6E6E6"/>
<path d="M40.816 47.6695H31.5355C31.3584 47.6695 31.2144 47.5254 31.2144 47.3484C31.2144 47.1713 31.3584 47.0273 31.5355 47.0273H40.816C40.993 47.0273 41.137 47.1713 41.137 47.3484C41.137 47.5254 40.993 47.6695 40.816 47.6695Z" fill="#E6E6E6"/>
<path d="M44.5847 44.2476H27.7669C27.5899 44.2476 27.4458 44.1036 27.4458 43.9265C27.4458 43.7495 27.5899 43.6055 27.7669 43.6055H44.5847C44.7617 43.6055 44.9057 43.7495 44.9057 43.9265C44.9057 44.1036 44.7617 44.2476 44.5847 44.2476Z" fill="#E6E6E6"/>
<path d="M44.756 50.4547H38.6805C38.5034 50.4547 38.3594 50.3106 38.3594 50.1336C38.3594 49.9565 38.5034 49.8125 38.6805 49.8125H44.756C44.933 49.8125 45.077 49.9565 45.077 50.1336C45.077 50.3106 44.933 50.4547 44.756 50.4547Z" fill="#E6E6E6"/>
<path d="M95.1871 77.2824C97.1172 77.2824 98.6818 75.7178 98.6818 73.7877C98.6818 71.8576 97.1172 70.293 95.1871 70.293C93.257 70.293 91.6924 71.8576 91.6924 73.7877C91.6924 75.7178 93.257 77.2824 95.1871 77.2824Z" fill="#396AE6"/>
<path d="M45.9117 59.4621C47.8418 59.4621 49.4065 57.8975 49.4065 55.9674C49.4065 54.0373 47.8418 52.4727 45.9117 52.4727C43.9816 52.4727 42.417 54.0373 42.417 55.9674C42.417 57.8975 43.9816 59.4621 45.9117 59.4621Z" fill="#396AE6"/>
<path d="M62.6861 107.341C64.6162 107.341 66.1809 105.776 66.1809 103.846C66.1809 101.916 64.6162 100.352 62.6861 100.352C60.756 100.352 59.1914 101.916 59.1914 103.846C59.1914 105.776 60.756 107.341 62.6861 107.341Z" fill="#396AE6"/>
<path d="M70.235 10.4378C72.6405 10.4378 74.5905 8.52881 74.5905 6.17397C74.5905 3.81913 72.6405 1.91016 70.235 1.91016C67.8295 1.91016 65.8794 3.81913 65.8794 6.17397C65.8794 8.52881 67.8295 10.4378 70.235 10.4378Z" fill="#E5E5E5"/>
<path d="M105.152 4.71422H81.7077C80.9034 4.71422 80.249 4.05984 80.249 3.25555C80.249 2.45126 80.9034 1.79688 81.7077 1.79688H105.152C105.956 1.79688 106.611 2.45126 106.611 3.25555C106.611 4.05984 105.956 4.71422 105.152 4.71422Z" fill="#E5E5E5"/>
<path d="M123.448 9.65172H81.7077C80.9034 9.65172 80.249 8.99733 80.249 8.19305C80.249 7.38876 80.9034 6.73438 81.7077 6.73438H123.448C124.252 6.73438 124.907 7.38876 124.907 8.19305C124.907 8.99733 124.252 9.65172 123.448 9.65172Z" fill="#E5E5E5"/>
<path d="M7.19143 28.3588C8.74824 28.3588 10.0103 27.1233 10.0103 25.5993C10.0103 24.0753 8.74824 22.8398 7.19143 22.8398C5.63461 22.8398 4.37256 24.0753 4.37256 25.5993C4.37256 27.1233 5.63461 28.3588 7.19143 28.3588Z" fill="#E5E5E5"/>
<path d="M29.7888 24.6537H14.6159C14.0954 24.6537 13.6719 24.2302 13.6719 23.7097C13.6719 23.1891 14.0954 22.7656 14.6159 22.7656H29.7888C30.3093 22.7656 30.7328 23.1891 30.7328 23.7097C30.7328 24.2302 30.3093 24.6537 29.7888 24.6537Z" fill="#E5E5E5"/>
<path d="M41.6297 27.849H14.6159C14.0954 27.849 13.6719 27.4255 13.6719 26.905C13.6719 26.3844 14.0954 25.9609 14.6159 25.9609H41.6297C42.1502 25.9609 42.5737 26.3844 42.5737 26.905C42.5737 27.4255 42.1502 27.849 41.6297 27.849Z" fill="#E5E5E5"/>
<path d="M91.7632 126.562C93.32 126.562 94.5821 125.326 94.5821 123.802C94.5821 122.278 93.32 121.043 91.7632 121.043C90.2064 121.043 88.9443 122.278 88.9443 123.802C88.9443 125.326 90.2064 126.562 91.7632 126.562Z" fill="#E5E5E5"/>
<path d="M114.361 122.857H99.1882C98.6677 122.857 98.2441 122.433 98.2441 121.913C98.2441 121.392 98.6677 120.969 99.1882 120.969H114.361C114.882 120.969 115.305 121.392 115.305 121.913C115.305 122.433 114.882 122.857 114.361 122.857Z" fill="#E5E5E5"/>
<path d="M126.202 126.052H99.1882C98.6677 126.052 98.2441 125.629 98.2441 125.108C98.2441 124.588 98.6677 124.164 99.1882 124.164H126.202C126.722 124.164 127.146 124.588 127.146 125.108C127.146 125.629 126.722 126.052 126.202 126.052Z" fill="#E5E5E5"/>
<path d="M57.8811 37.3906H57.1821V70.5906H57.8811V37.3906Z" fill="#E6E6E6"/>
<path d="M57.5314 72.6859C58.6895 72.6859 59.6283 71.7471 59.6283 70.589C59.6283 69.431 58.6895 68.4922 57.5314 68.4922C56.3734 68.4922 55.4346 69.431 55.4346 70.589C55.4346 71.7471 56.3734 72.6859 57.5314 72.6859Z" fill="#E6E6E6"/>
<path d="M89.3337 96.7991C90.4917 96.7991 91.4305 95.8604 91.4305 94.7023C91.4305 93.5443 90.4917 92.6055 89.3337 92.6055C88.1756 92.6055 87.2368 93.5443 87.2368 94.7023C87.2368 95.8604 88.1756 96.7991 89.3337 96.7991Z" fill="#E6E6E6"/>
<path d="M32.7189 68.1429C33.877 68.1429 34.8157 67.2041 34.8157 66.0461C34.8157 64.888 33.877 63.9492 32.7189 63.9492C31.5609 63.9492 30.6221 64.888 30.6221 66.0461C30.6221 67.2041 31.5609 68.1429 32.7189 68.1429Z" fill="#E6E6E6"/>
<path d="M31.9438 66.0513L20.4766 66.3984L20.4977 67.0974L31.9649 66.7502L31.9438 66.0513Z" fill="#E6E6E6"/>
<path d="M106.392 102.73H89.0034V94.7031H89.7024V102.031H106.392V102.73Z" fill="#E6E6E6"/>
<path d="M31.8581 91.0413C33.0161 91.0413 33.9549 90.1025 33.9549 88.9445C33.9549 87.7864 33.0161 86.8477 31.8581 86.8477C30.7 86.8477 29.7612 87.7864 29.7612 88.9445C29.7612 90.1025 30.7 91.0413 31.8581 91.0413Z" fill="#E6E6E6"/>
<path d="M48.7091 96.9687H31.3208V88.9414H32.0197V96.2698H48.7091V96.9687Z" fill="#E6E6E6"/>
<path d="M101.914 80.9648H18.3965V81.6638H101.914V80.9648Z" fill="#E6E6E6"/>
</svg>

After

Width:  |  Height:  |  Size: 10 KiB

View File

@ -69,7 +69,7 @@ export class ProjectService extends APIService {
}
async joinProject(workspaceSlug: string, project_ids: string[]): Promise<any> {
return this.post(`/api/workspaces/${workspaceSlug}/projects/join/`, { project_ids })
return this.post(`/api/users/me/workspaces/${workspaceSlug}/projects/invitations/`, { project_ids })
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;

View File

@ -86,39 +86,67 @@ export class PageStore implements IPageStore {
}
get projectPages() {
if (!this.rootStore.project.projectId) return;
return this.pages?.[this.rootStore.project.projectId] || [];
const projectId = this.rootStore.project.projectId;
if (!projectId || !this.pages[projectId]) return undefined;
return this.pages?.[projectId] || [];
}
get recentProjectPages() {
if (!this.rootStore.project.projectId) return;
const data: IRecentPages = { today: [], yesterday: [], this_week: [] };
data["today"] = this.pages[this.rootStore.project.projectId]?.filter((p) => isToday(new Date(p.created_at))) || [];
data["yesterday"] =
this.pages[this.rootStore.project.projectId]?.filter((p) => isYesterday(new Date(p.created_at))) || [];
const projectId = this.rootStore.project.projectId;
if (!projectId) return undefined;
if (!this.pages[projectId]) return undefined;
const data: IRecentPages = { today: [], yesterday: [], this_week: [], older: [] };
data["today"] = this.pages[projectId]?.filter((p) => isToday(new Date(p.created_at))) || [];
data["yesterday"] = this.pages[projectId]?.filter((p) => isYesterday(new Date(p.created_at))) || [];
data["this_week"] =
this.pages[this.rootStore.project.projectId]?.filter((p) => isThisWeek(new Date(p.created_at))) || [];
this.pages[projectId]?.filter(
(p) =>
isThisWeek(new Date(p.created_at)) && !isToday(new Date(p.created_at)) && !isYesterday(new Date(p.created_at))
) || [];
data["older"] =
this.pages[projectId]?.filter(
(p) => !isThisWeek(new Date(p.created_at)) && !isYesterday(new Date(p.created_at))
) || [];
return data;
}
get favoriteProjectPages() {
if (!this.rootStore.project.projectId) return;
return this.pages[this.rootStore.project.projectId]?.filter((p) => p.is_favorite);
const projectId = this.rootStore.project.projectId;
if (!projectId || !this.pages[projectId]) return undefined;
return this.pages[projectId]?.filter((p) => p.is_favorite);
}
get privateProjectPages() {
if (!this.rootStore.project.projectId) return;
return this.pages[this.rootStore.project.projectId]?.filter((p) => p.access === 1);
const projectId = this.rootStore.project.projectId;
if (!projectId || !this.pages[projectId]) return undefined;
return this.pages[projectId]?.filter((p) => p.access === 1);
}
get sharedProjectPages() {
if (!this.rootStore.project.projectId) return;
return this.pages[this.rootStore.project.projectId]?.filter((p) => p.access === 0);
const projectId = this.rootStore.project.projectId;
if (!projectId || !this.pages[projectId]) return undefined;
return this.pages[projectId]?.filter((p) => p.access === 0);
}
get archivedProjectPages() {
if (!this.rootStore.project.projectId) return;
return this.archivedPages[this.rootStore.project.projectId];
const projectId = this.rootStore.project.projectId;
if (!projectId || !this.archivedPages[projectId]) return undefined;
return this.archivedPages[projectId];
}
addToFavorites = async (workspaceSlug: string, projectId: string, pageId: string) => {

2
web/types/app.d.ts vendored
View File

@ -11,4 +11,6 @@ export interface IAppConfig {
slack_client_id: string | null;
posthog_api_key: string | null;
posthog_host: string | null;
has_openai_configured: boolean;
has_unsplash_configured: boolean;
}

View File

@ -30,6 +30,7 @@ export interface IRecentPages {
today: IPage[];
yesterday: IPage[];
this_week: IPage[];
older: IPage[];
[key: string]: IPage[];
}