Compare commits
31
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d35b28b218 | ||
|
|
6d0a318785 | ||
|
|
e7c332b0bd | ||
|
|
675d3edba8 | ||
|
|
3d06189723 | ||
|
|
6d3d9e6df7 | ||
|
|
d521eab22f | ||
|
|
00e070b509 | ||
|
|
4d17637edf | ||
|
|
bf45635a7b | ||
|
|
56d3a9e049 | ||
|
|
1f7eef5f81 | ||
|
|
bd2272a7da | ||
|
|
b9c6bb07bf | ||
|
|
345dfce25d | ||
|
|
116c8118ab | ||
|
|
d6fd5d12f9 | ||
|
|
c3e7cfd16b | ||
|
|
9ffc30f7b1 | ||
|
|
b60f12a88e | ||
|
|
76a0b38dd1 | ||
|
|
8ee665f491 | ||
|
|
85f23b450d | ||
|
|
8bf059535a | ||
|
|
4cfea87108 | ||
|
|
4fe2ef706b | ||
|
|
8d354b3eb2 | ||
|
|
ec541c2557 | ||
|
|
11cd8d11e4 | ||
|
|
0f7bfdde91 | ||
|
|
ac835bf287 |
@@ -3,11 +3,9 @@ name: "CodeQL"
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: ["preview", "master"]
|
||||
branches: ["preview", "canary", "master"]
|
||||
pull_request:
|
||||
branches: ["develop", "preview", "master"]
|
||||
schedule:
|
||||
- cron: "53 19 * * 5"
|
||||
branches: ["preview", "canary", "master"]
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
|
||||
@@ -39,13 +39,31 @@ def allow_permission(allowed_roles, level="PROJECT", creator=False, model=None):
|
||||
).exists():
|
||||
return view_func(instance, request, *args, **kwargs)
|
||||
else:
|
||||
if ProjectMember.objects.filter(
|
||||
is_user_has_allowed_role = ProjectMember.objects.filter(
|
||||
member=request.user,
|
||||
workspace__slug=kwargs["slug"],
|
||||
project_id=kwargs["project_id"],
|
||||
role__in=allowed_role_values,
|
||||
is_active=True,
|
||||
).exists():
|
||||
).exists()
|
||||
|
||||
# Return if the user has the allowed role else if they are workspace admin and part of the project regardless of the role
|
||||
if is_user_has_allowed_role:
|
||||
return view_func(instance, request, *args, **kwargs)
|
||||
elif (
|
||||
ProjectMember.objects.filter(
|
||||
member=request.user,
|
||||
workspace__slug=kwargs["slug"],
|
||||
project_id=kwargs["project_id"],
|
||||
is_active=True,
|
||||
).exists()
|
||||
and WorkspaceMember.objects.filter(
|
||||
member=request.user,
|
||||
workspace__slug=kwargs["slug"],
|
||||
role=ROLE.ADMIN.value,
|
||||
is_active=True,
|
||||
).exists()
|
||||
):
|
||||
return view_func(instance, request, *args, **kwargs)
|
||||
|
||||
# Return permission denied if no conditions are met
|
||||
|
||||
@@ -3,11 +3,7 @@ from rest_framework.permissions import SAFE_METHODS, BasePermission
|
||||
|
||||
# Module import
|
||||
from plane.db.models import ProjectMember, WorkspaceMember
|
||||
|
||||
# Permission Mappings
|
||||
Admin = 20
|
||||
Member = 15
|
||||
Guest = 5
|
||||
from plane.db.models.project import ROLE
|
||||
|
||||
|
||||
class ProjectBasePermission(BasePermission):
|
||||
@@ -26,18 +22,31 @@ class ProjectBasePermission(BasePermission):
|
||||
return WorkspaceMember.objects.filter(
|
||||
workspace__slug=view.workspace_slug,
|
||||
member=request.user,
|
||||
role__in=[Admin, Member],
|
||||
role__in=[ROLE.ADMIN.value, ROLE.MEMBER.value],
|
||||
is_active=True,
|
||||
).exists()
|
||||
|
||||
## Only Project Admins can update project attributes
|
||||
return ProjectMember.objects.filter(
|
||||
project_member_qs = ProjectMember.objects.filter(
|
||||
workspace__slug=view.workspace_slug,
|
||||
member=request.user,
|
||||
role=Admin,
|
||||
project_id=view.project_id,
|
||||
is_active=True,
|
||||
).exists()
|
||||
)
|
||||
|
||||
## Only project admins or workspace admin who is part of the project can access
|
||||
|
||||
if project_member_qs.filter(role=ROLE.ADMIN.value).exists():
|
||||
return True
|
||||
else:
|
||||
return (
|
||||
project_member_qs.exists()
|
||||
and WorkspaceMember.objects.filter(
|
||||
member=request.user,
|
||||
workspace__slug=view.workspace_slug,
|
||||
role=ROLE.ADMIN.value,
|
||||
is_active=True,
|
||||
).exists()
|
||||
)
|
||||
|
||||
|
||||
class ProjectMemberPermission(BasePermission):
|
||||
@@ -55,7 +64,7 @@ class ProjectMemberPermission(BasePermission):
|
||||
return WorkspaceMember.objects.filter(
|
||||
workspace__slug=view.workspace_slug,
|
||||
member=request.user,
|
||||
role__in=[Admin, Member],
|
||||
role__in=[ROLE.ADMIN.value, ROLE.MEMBER.value],
|
||||
is_active=True,
|
||||
).exists()
|
||||
|
||||
@@ -63,7 +72,7 @@ class ProjectMemberPermission(BasePermission):
|
||||
return ProjectMember.objects.filter(
|
||||
workspace__slug=view.workspace_slug,
|
||||
member=request.user,
|
||||
role__in=[Admin, Member],
|
||||
role__in=[ROLE.ADMIN.value, ROLE.MEMBER.value],
|
||||
project_id=view.project_id,
|
||||
is_active=True,
|
||||
).exists()
|
||||
@@ -97,7 +106,7 @@ class ProjectEntityPermission(BasePermission):
|
||||
return ProjectMember.objects.filter(
|
||||
workspace__slug=view.workspace_slug,
|
||||
member=request.user,
|
||||
role__in=[Admin, Member],
|
||||
role__in=[ROLE.ADMIN.value, ROLE.MEMBER.value],
|
||||
project_id=view.project_id,
|
||||
is_active=True,
|
||||
).exists()
|
||||
|
||||
@@ -15,7 +15,6 @@ from plane.db.models import (
|
||||
)
|
||||
from plane.utils.content_validator import (
|
||||
validate_html_content,
|
||||
validate_binary_data,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -5,13 +5,12 @@ from django.utils import timezone
|
||||
import json
|
||||
|
||||
# Django imports
|
||||
from django.db import IntegrityError
|
||||
from django.db.models import Exists, F, OuterRef, Prefetch, Q, Subquery
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
|
||||
# Third Party imports
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import serializers, status
|
||||
from rest_framework import status
|
||||
from rest_framework.permissions import AllowAny
|
||||
|
||||
# Module imports
|
||||
@@ -106,7 +105,10 @@ class ProjectViewSet(BaseViewSet):
|
||||
fields = [field for field in request.GET.get("fields", "").split(",") if field]
|
||||
projects = self.get_queryset().order_by("sort_order", "name")
|
||||
if WorkspaceMember.objects.filter(
|
||||
member=request.user, workspace__slug=slug, is_active=True, role=5
|
||||
member=request.user,
|
||||
workspace__slug=slug,
|
||||
is_active=True,
|
||||
role=ROLE.GUEST.value,
|
||||
).exists():
|
||||
projects = projects.filter(
|
||||
project_projectmember__member=self.request.user,
|
||||
@@ -114,7 +116,10 @@ class ProjectViewSet(BaseViewSet):
|
||||
)
|
||||
|
||||
if WorkspaceMember.objects.filter(
|
||||
member=request.user, workspace__slug=slug, is_active=True, role=15
|
||||
member=request.user,
|
||||
workspace__slug=slug,
|
||||
is_active=True,
|
||||
role=ROLE.MEMBER.value,
|
||||
).exists():
|
||||
projects = projects.filter(
|
||||
Q(
|
||||
@@ -189,7 +194,10 @@ class ProjectViewSet(BaseViewSet):
|
||||
)
|
||||
|
||||
if WorkspaceMember.objects.filter(
|
||||
member=request.user, workspace__slug=slug, is_active=True, role=5
|
||||
member=request.user,
|
||||
workspace__slug=slug,
|
||||
is_active=True,
|
||||
role=ROLE.GUEST.value,
|
||||
).exists():
|
||||
projects = projects.filter(
|
||||
project_projectmember__member=self.request.user,
|
||||
@@ -197,7 +205,10 @@ class ProjectViewSet(BaseViewSet):
|
||||
)
|
||||
|
||||
if WorkspaceMember.objects.filter(
|
||||
member=request.user, workspace__slug=slug, is_active=True, role=15
|
||||
member=request.user,
|
||||
workspace__slug=slug,
|
||||
is_active=True,
|
||||
role=ROLE.MEMBER.value,
|
||||
).exists():
|
||||
projects = projects.filter(
|
||||
Q(
|
||||
@@ -250,7 +261,9 @@ class ProjectViewSet(BaseViewSet):
|
||||
|
||||
# Add the user as Administrator to the project
|
||||
_ = ProjectMember.objects.create(
|
||||
project_id=serializer.data["id"], member=request.user, role=20
|
||||
project_id=serializer.data["id"],
|
||||
member=request.user,
|
||||
role=ROLE.ADMIN.value,
|
||||
)
|
||||
# Also create the issue property for the user
|
||||
_ = IssueUserProperty.objects.create(
|
||||
@@ -263,7 +276,7 @@ class ProjectViewSet(BaseViewSet):
|
||||
ProjectMember.objects.create(
|
||||
project_id=serializer.data["id"],
|
||||
member_id=serializer.data["project_lead"],
|
||||
role=20,
|
||||
role=ROLE.ADMIN.value,
|
||||
)
|
||||
# Also create the issue property for the user
|
||||
IssueUserProperty.objects.create(
|
||||
@@ -341,13 +354,23 @@ class ProjectViewSet(BaseViewSet):
|
||||
|
||||
def partial_update(self, request, slug, pk=None):
|
||||
# try:
|
||||
if not ProjectMember.objects.filter(
|
||||
is_workspace_admin = WorkspaceMember.objects.filter(
|
||||
member=request.user,
|
||||
workspace__slug=slug,
|
||||
is_active=True,
|
||||
role=ROLE.ADMIN.value,
|
||||
).exists()
|
||||
|
||||
is_project_admin = ProjectMember.objects.filter(
|
||||
member=request.user,
|
||||
workspace__slug=slug,
|
||||
project_id=pk,
|
||||
role=20,
|
||||
role=ROLE.ADMIN.value,
|
||||
is_active=True,
|
||||
).exists():
|
||||
).exists()
|
||||
|
||||
# Return error for if the user is neither workspace admin nor project admin
|
||||
if not is_project_admin and not is_workspace_admin:
|
||||
return Response(
|
||||
{"error": "You don't have the required permissions."},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
@@ -402,13 +425,16 @@ class ProjectViewSet(BaseViewSet):
|
||||
def destroy(self, request, slug, pk):
|
||||
if (
|
||||
WorkspaceMember.objects.filter(
|
||||
member=request.user, workspace__slug=slug, is_active=True, role=20
|
||||
member=request.user,
|
||||
workspace__slug=slug,
|
||||
is_active=True,
|
||||
role=ROLE.ADMIN.value,
|
||||
).exists()
|
||||
or ProjectMember.objects.filter(
|
||||
member=request.user,
|
||||
workspace__slug=slug,
|
||||
project_id=pk,
|
||||
role=20,
|
||||
role=ROLE.ADMIN.value,
|
||||
is_active=True,
|
||||
).exists()
|
||||
):
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
# Python imports
|
||||
from urllib.parse import urlencode, urljoin
|
||||
|
||||
# Django imports
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import validate_email
|
||||
@@ -19,7 +16,7 @@ from plane.authentication.adapter.error import (
|
||||
AuthenticationException,
|
||||
AUTHENTICATION_ERROR_CODES,
|
||||
)
|
||||
from plane.utils.path_validator import validate_next_path
|
||||
from plane.utils.path_validator import get_safe_redirect_url
|
||||
|
||||
|
||||
class SignInAuthEndpoint(View):
|
||||
@@ -34,11 +31,11 @@ class SignInAuthEndpoint(View):
|
||||
error_message="INSTANCE_NOT_CONFIGURED",
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
# Base URL join
|
||||
url = urljoin(
|
||||
base_host(request=request, is_app=True), "sign-in?" + urlencode(params)
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_app=True),
|
||||
next_path=next_path,
|
||||
params=params,
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
@@ -58,10 +55,10 @@ class SignInAuthEndpoint(View):
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
# Next path
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
url = urljoin(
|
||||
base_host(request=request, is_app=True), "sign-in?" + urlencode(params)
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_app=True),
|
||||
next_path=next_path,
|
||||
params=params,
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
@@ -76,10 +73,10 @@ class SignInAuthEndpoint(View):
|
||||
payload={"email": str(email)},
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
url = urljoin(
|
||||
base_host(request=request, is_app=True), "sign-in?" + urlencode(params)
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_app=True),
|
||||
next_path=next_path,
|
||||
params=params,
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
@@ -92,10 +89,10 @@ class SignInAuthEndpoint(View):
|
||||
payload={"email": str(email)},
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
url = urljoin(
|
||||
base_host(request=request, is_app=True), "sign-in?" + urlencode(params)
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_app=True),
|
||||
next_path=next_path,
|
||||
params=params,
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
@@ -112,19 +109,23 @@ class SignInAuthEndpoint(View):
|
||||
user_login(request=request, user=user, is_app=True)
|
||||
# Get the redirection path
|
||||
if next_path:
|
||||
path = str(validate_next_path(next_path))
|
||||
path = next_path
|
||||
else:
|
||||
path = get_redirection_path(user=user)
|
||||
|
||||
# redirect to referer path
|
||||
url = urljoin(base_host(request=request, is_app=True), path)
|
||||
# Get the safe redirect URL
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_app=True),
|
||||
next_path=path,
|
||||
params={},
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
except AuthenticationException as e:
|
||||
params = e.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
url = urljoin(
|
||||
base_host(request=request, is_app=True), "sign-in?" + urlencode(params)
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_app=True),
|
||||
next_path=next_path,
|
||||
params=params,
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
@@ -141,10 +142,10 @@ class SignUpAuthEndpoint(View):
|
||||
error_message="INSTANCE_NOT_CONFIGURED",
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
url = urljoin(
|
||||
base_host(request=request, is_app=True), "?" + urlencode(params)
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_app=True),
|
||||
next_path=next_path,
|
||||
params=params,
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
@@ -161,10 +162,10 @@ class SignUpAuthEndpoint(View):
|
||||
payload={"email": str(email)},
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
url = urljoin(
|
||||
base_host(request=request, is_app=True), "?" + urlencode(params)
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_app=True),
|
||||
next_path=next_path,
|
||||
params=params,
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
# Validate the email
|
||||
@@ -179,10 +180,10 @@ class SignUpAuthEndpoint(View):
|
||||
payload={"email": str(email)},
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
url = urljoin(
|
||||
base_host(request=request, is_app=True), "?" + urlencode(params)
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_app=True),
|
||||
next_path=next_path,
|
||||
params=params,
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
@@ -197,10 +198,10 @@ class SignUpAuthEndpoint(View):
|
||||
payload={"email": str(email)},
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
url = urljoin(
|
||||
base_host(request=request, is_app=True), "?" + urlencode(params)
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_app=True),
|
||||
next_path=next_path,
|
||||
params=params,
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
@@ -217,17 +218,21 @@ class SignUpAuthEndpoint(View):
|
||||
user_login(request=request, user=user, is_app=True)
|
||||
# Get the redirection path
|
||||
if next_path:
|
||||
path = str(validate_next_path(next_path))
|
||||
path = next_path
|
||||
else:
|
||||
path = get_redirection_path(user=user)
|
||||
# redirect to referer path
|
||||
url = urljoin(base_host(request=request, is_app=True), path)
|
||||
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_app=True),
|
||||
next_path=path,
|
||||
params={},
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
except AuthenticationException as e:
|
||||
params = e.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
url = urljoin(
|
||||
base_host(request=request, is_app=True), "?" + urlencode(params)
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_app=True),
|
||||
next_path=next_path,
|
||||
params=params,
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Python imports
|
||||
import uuid
|
||||
from urllib.parse import urlencode, urljoin
|
||||
|
||||
# Django import
|
||||
from django.http import HttpResponseRedirect
|
||||
@@ -16,8 +16,7 @@ from plane.authentication.adapter.error import (
|
||||
AuthenticationException,
|
||||
AUTHENTICATION_ERROR_CODES,
|
||||
)
|
||||
from plane.utils.path_validator import validate_next_path
|
||||
|
||||
from plane.utils.path_validator import get_safe_redirect_url
|
||||
|
||||
class GitHubOauthInitiateEndpoint(View):
|
||||
def get(self, request):
|
||||
@@ -35,10 +34,10 @@ class GitHubOauthInitiateEndpoint(View):
|
||||
error_message="INSTANCE_NOT_CONFIGURED",
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
url = urljoin(
|
||||
base_host(request=request, is_app=True), "?" + urlencode(params)
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_app=True),
|
||||
next_path=next_path,
|
||||
params=params
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
try:
|
||||
@@ -49,10 +48,10 @@ class GitHubOauthInitiateEndpoint(View):
|
||||
return HttpResponseRedirect(auth_url)
|
||||
except AuthenticationException as e:
|
||||
params = e.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
url = urljoin(
|
||||
base_host(request=request, is_app=True), "?" + urlencode(params)
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_app=True),
|
||||
next_path=next_path,
|
||||
params=params
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
@@ -61,7 +60,6 @@ class GitHubCallbackEndpoint(View):
|
||||
def get(self, request):
|
||||
code = request.GET.get("code")
|
||||
state = request.GET.get("state")
|
||||
base_host = request.session.get("host")
|
||||
next_path = request.session.get("next_path")
|
||||
|
||||
if state != request.session.get("state", ""):
|
||||
@@ -70,9 +68,11 @@ class GitHubCallbackEndpoint(View):
|
||||
error_message="GITHUB_OAUTH_PROVIDER_ERROR",
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
url = urljoin(base_host, "?" + urlencode(params))
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_app=True),
|
||||
next_path=next_path,
|
||||
params=params
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
if not code:
|
||||
@@ -81,9 +81,11 @@ class GitHubCallbackEndpoint(View):
|
||||
error_message="GITHUB_OAUTH_PROVIDER_ERROR",
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
url = urljoin(base_host, "?" + urlencode(params))
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_app=True),
|
||||
next_path=next_path,
|
||||
params=params
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
try:
|
||||
@@ -93,17 +95,23 @@ class GitHubCallbackEndpoint(View):
|
||||
user = provider.authenticate()
|
||||
# Login the user and record his device info
|
||||
user_login(request=request, user=user, is_app=True)
|
||||
# Get the redirection path
|
||||
if next_path:
|
||||
path = str(validate_next_path(next_path))
|
||||
path = next_path
|
||||
else:
|
||||
path = get_redirection_path(user=user)
|
||||
# redirect to referer path
|
||||
url = urljoin(base_host, path)
|
||||
|
||||
# Get the safe redirect URL
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_app=True),
|
||||
next_path=path,
|
||||
params={}
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
except AuthenticationException as e:
|
||||
params = e.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
url = urljoin(base_host, "?" + urlencode(params))
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_app=True),
|
||||
next_path=next_path,
|
||||
params=params
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Python imports
|
||||
import uuid
|
||||
from urllib.parse import urlencode, urljoin
|
||||
|
||||
# Django import
|
||||
from django.http import HttpResponseRedirect
|
||||
@@ -16,7 +16,7 @@ from plane.authentication.adapter.error import (
|
||||
AuthenticationException,
|
||||
AUTHENTICATION_ERROR_CODES,
|
||||
)
|
||||
from plane.utils.path_validator import validate_next_path
|
||||
from plane.utils.path_validator import get_safe_redirect_url
|
||||
|
||||
|
||||
class GitLabOauthInitiateEndpoint(View):
|
||||
@@ -25,7 +25,7 @@ class GitLabOauthInitiateEndpoint(View):
|
||||
request.session["host"] = base_host(request=request, is_app=True)
|
||||
next_path = request.GET.get("next_path")
|
||||
if next_path:
|
||||
request.session["next_path"] = str(validate_next_path(next_path))
|
||||
request.session["next_path"] = str(next_path)
|
||||
|
||||
# Check instance configuration
|
||||
instance = Instance.objects.first()
|
||||
@@ -35,10 +35,10 @@ class GitLabOauthInitiateEndpoint(View):
|
||||
error_message="INSTANCE_NOT_CONFIGURED",
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
url = urljoin(
|
||||
base_host(request=request, is_app=True), "?" + urlencode(params)
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_app=True),
|
||||
next_path=next_path,
|
||||
params=params
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
try:
|
||||
@@ -49,10 +49,10 @@ class GitLabOauthInitiateEndpoint(View):
|
||||
return HttpResponseRedirect(auth_url)
|
||||
except AuthenticationException as e:
|
||||
params = e.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
url = urljoin(
|
||||
base_host(request=request, is_app=True), "?" + urlencode(params)
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_app=True),
|
||||
next_path=next_path,
|
||||
params=params
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
@@ -61,7 +61,6 @@ class GitLabCallbackEndpoint(View):
|
||||
def get(self, request):
|
||||
code = request.GET.get("code")
|
||||
state = request.GET.get("state")
|
||||
base_host = request.session.get("host")
|
||||
next_path = request.session.get("next_path")
|
||||
|
||||
if state != request.session.get("state", ""):
|
||||
@@ -70,9 +69,11 @@ class GitLabCallbackEndpoint(View):
|
||||
error_message="GITLAB_OAUTH_PROVIDER_ERROR",
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(next_path)
|
||||
url = urljoin(base_host, "?" + urlencode(params))
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_app=True),
|
||||
next_path=next_path,
|
||||
params=params
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
if not code:
|
||||
@@ -81,9 +82,11 @@ class GitLabCallbackEndpoint(View):
|
||||
error_message="GITLAB_OAUTH_PROVIDER_ERROR",
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
url = urljoin(base_host, "?" + urlencode(params))
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_app=True),
|
||||
next_path=next_path,
|
||||
params=params
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
try:
|
||||
@@ -94,16 +97,23 @@ class GitLabCallbackEndpoint(View):
|
||||
# Login the user and record his device info
|
||||
user_login(request=request, user=user, is_app=True)
|
||||
# Get the redirection path
|
||||
|
||||
if next_path:
|
||||
path = str(validate_next_path(next_path))
|
||||
path = next_path
|
||||
else:
|
||||
path = get_redirection_path(user=user)
|
||||
# redirect to referer path
|
||||
url = urljoin(base_host, path)
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_app=True),
|
||||
next_path=path,
|
||||
params={}
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
except AuthenticationException as e:
|
||||
params = e.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
url = urljoin(base_host, "?" + urlencode(params))
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_app=True),
|
||||
next_path=next_path,
|
||||
params=params
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
# Python imports
|
||||
import uuid
|
||||
from urllib.parse import urlencode, urljoin
|
||||
|
||||
# Django import
|
||||
from django.http import HttpResponseRedirect
|
||||
@@ -18,7 +17,7 @@ from plane.authentication.adapter.error import (
|
||||
AuthenticationException,
|
||||
AUTHENTICATION_ERROR_CODES,
|
||||
)
|
||||
from plane.utils.path_validator import validate_next_path
|
||||
from plane.utils.path_validator import get_safe_redirect_url
|
||||
|
||||
|
||||
class GoogleOauthInitiateEndpoint(View):
|
||||
@@ -36,10 +35,10 @@ class GoogleOauthInitiateEndpoint(View):
|
||||
error_message="INSTANCE_NOT_CONFIGURED",
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
url = urljoin(
|
||||
base_host(request=request, is_app=True), "?" + urlencode(params)
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_app=True),
|
||||
next_path=next_path,
|
||||
params=params
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
@@ -51,10 +50,10 @@ class GoogleOauthInitiateEndpoint(View):
|
||||
return HttpResponseRedirect(auth_url)
|
||||
except AuthenticationException as e:
|
||||
params = e.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
url = urljoin(
|
||||
base_host(request=request, is_app=True), "?" + urlencode(params)
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_app=True),
|
||||
next_path=next_path,
|
||||
params=params
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
@@ -63,7 +62,6 @@ class GoogleCallbackEndpoint(View):
|
||||
def get(self, request):
|
||||
code = request.GET.get("code")
|
||||
state = request.GET.get("state")
|
||||
base_host = request.session.get("host")
|
||||
next_path = request.session.get("next_path")
|
||||
|
||||
if state != request.session.get("state", ""):
|
||||
@@ -72,9 +70,11 @@ class GoogleCallbackEndpoint(View):
|
||||
error_message="GOOGLE_OAUTH_PROVIDER_ERROR",
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
url = urljoin(base_host, "?" + urlencode(params))
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_app=True),
|
||||
next_path=next_path,
|
||||
params=params
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
if not code:
|
||||
exc = AuthenticationException(
|
||||
@@ -82,9 +82,11 @@ class GoogleCallbackEndpoint(View):
|
||||
error_message="GOOGLE_OAUTH_PROVIDER_ERROR",
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
url = urljoin(base_host, "?" + urlencode(params))
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_app=True),
|
||||
next_path=next_path,
|
||||
params=params
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
try:
|
||||
provider = GoogleOAuthProvider(
|
||||
@@ -94,15 +96,21 @@ class GoogleCallbackEndpoint(View):
|
||||
# Login the user and record his device info
|
||||
user_login(request=request, user=user, is_app=True)
|
||||
# Get the redirection path
|
||||
path = get_redirection_path(user=user)
|
||||
# redirect to referer path
|
||||
url = urljoin(
|
||||
base_host, str(validate_next_path(next_path)) if next_path else path
|
||||
if next_path:
|
||||
path = next_path
|
||||
else:
|
||||
path = get_redirection_path(user=user)
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_app=True),
|
||||
next_path=path,
|
||||
params={}
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
except AuthenticationException as e:
|
||||
params = e.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
url = urljoin(base_host, "?" + urlencode(params))
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_app=True),
|
||||
next_path=next_path,
|
||||
params=params
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
# Python imports
|
||||
from urllib.parse import urlencode, urljoin
|
||||
|
||||
# Django imports
|
||||
from django.core.validators import validate_email
|
||||
from django.http import HttpResponseRedirect
|
||||
@@ -26,7 +23,7 @@ from plane.authentication.adapter.error import (
|
||||
AUTHENTICATION_ERROR_CODES,
|
||||
)
|
||||
from plane.authentication.rate_limit import AuthenticationThrottle
|
||||
from plane.utils.path_validator import validate_next_path
|
||||
from plane.utils.path_validator import get_safe_redirect_url
|
||||
|
||||
|
||||
class MagicGenerateEndpoint(APIView):
|
||||
@@ -72,10 +69,10 @@ class MagicSignInEndpoint(View):
|
||||
error_message="MAGIC_SIGN_IN_EMAIL_CODE_REQUIRED",
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
url = urljoin(
|
||||
base_host(request=request, is_app=True), "sign-in?" + urlencode(params)
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_app=True),
|
||||
next_path=next_path,
|
||||
params=params,
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
@@ -88,10 +85,10 @@ class MagicSignInEndpoint(View):
|
||||
error_message="USER_DOES_NOT_EXIST",
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
url = urljoin(
|
||||
base_host(request=request, is_app=True), "sign-in?" + urlencode(params)
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_app=True),
|
||||
next_path=next_path,
|
||||
params=params,
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
@@ -107,7 +104,8 @@ class MagicSignInEndpoint(View):
|
||||
# Login the user and record his device info
|
||||
user_login(request=request, user=user, is_app=True)
|
||||
if user.is_password_autoset and profile.is_onboarded:
|
||||
path = "accounts/set-password"
|
||||
# Redirect to the home page
|
||||
path = "/"
|
||||
else:
|
||||
# Get the redirection path
|
||||
path = (
|
||||
@@ -116,15 +114,19 @@ class MagicSignInEndpoint(View):
|
||||
else str(get_redirection_path(user=user))
|
||||
)
|
||||
# redirect to referer path
|
||||
url = urljoin(base_host(request=request, is_app=True), path)
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_app=True),
|
||||
next_path=path,
|
||||
params={},
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
except AuthenticationException as e:
|
||||
params = e.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
url = urljoin(
|
||||
base_host(request=request, is_app=True), "sign-in?" + urlencode(params)
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_app=True),
|
||||
next_path=next_path,
|
||||
params=params,
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
@@ -144,10 +146,10 @@ class MagicSignUpEndpoint(View):
|
||||
error_message="MAGIC_SIGN_UP_EMAIL_CODE_REQUIRED",
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
url = urljoin(
|
||||
base_host(request=request, is_app=True), "?" + urlencode(params)
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_app=True),
|
||||
next_path=next_path,
|
||||
params=params,
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
# Existing user
|
||||
@@ -158,10 +160,10 @@ class MagicSignUpEndpoint(View):
|
||||
error_message="USER_ALREADY_EXIST",
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
url = urljoin(
|
||||
base_host(request=request, is_app=True), "?" + urlencode(params)
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_app=True),
|
||||
next_path=next_path,
|
||||
params=params,
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
@@ -177,18 +179,22 @@ class MagicSignUpEndpoint(View):
|
||||
user_login(request=request, user=user, is_app=True)
|
||||
# Get the redirection path
|
||||
if next_path:
|
||||
path = str(validate_next_path(next_path))
|
||||
path = next_path
|
||||
else:
|
||||
path = get_redirection_path(user=user)
|
||||
# redirect to referer path
|
||||
url = urljoin(base_host(request=request, is_app=True), path)
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_app=True),
|
||||
next_path=path,
|
||||
params={},
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
except AuthenticationException as e:
|
||||
params = e.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
url = urljoin(
|
||||
base_host(request=request, is_app=True), "?" + urlencode(params)
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_app=True),
|
||||
next_path=next_path,
|
||||
params=params,
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
# Python imports
|
||||
from urllib.parse import urlencode
|
||||
|
||||
# Django imports
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import validate_email
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.views import View
|
||||
from django.utils.http import url_has_allowed_host_and_scheme
|
||||
|
||||
# Module imports
|
||||
from plane.authentication.provider.credentials.email import EmailProvider
|
||||
@@ -17,8 +15,7 @@ from plane.authentication.adapter.error import (
|
||||
AUTHENTICATION_ERROR_CODES,
|
||||
AuthenticationException,
|
||||
)
|
||||
from plane.utils.path_validator import validate_next_path
|
||||
|
||||
from plane.utils.path_validator import get_safe_redirect_url, validate_next_path, get_allowed_hosts
|
||||
|
||||
class SignInAuthSpaceEndpoint(View):
|
||||
def post(self, request):
|
||||
@@ -32,9 +29,11 @@ class SignInAuthSpaceEndpoint(View):
|
||||
error_message="INSTANCE_NOT_CONFIGURED",
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_space=True),
|
||||
next_path=next_path,
|
||||
params=params
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
# set the referer as session to redirect after login
|
||||
@@ -51,9 +50,11 @@ class SignInAuthSpaceEndpoint(View):
|
||||
payload={"email": str(email)},
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_space=True),
|
||||
next_path=next_path,
|
||||
params=params
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
# Validate email
|
||||
@@ -67,9 +68,11 @@ class SignInAuthSpaceEndpoint(View):
|
||||
payload={"email": str(email)},
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_space=True),
|
||||
next_path=next_path,
|
||||
params=params
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
# Existing User
|
||||
@@ -82,9 +85,11 @@ class SignInAuthSpaceEndpoint(View):
|
||||
payload={"email": str(email)},
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_space=True),
|
||||
next_path=next_path,
|
||||
params=params
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
try:
|
||||
@@ -95,13 +100,19 @@ class SignInAuthSpaceEndpoint(View):
|
||||
# Login the user and record his device info
|
||||
user_login(request=request, user=user, is_space=True)
|
||||
# redirect to next path
|
||||
url = f"{base_host(request=request, is_space=True)}{str(next_path) if next_path else ''}"
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_space=True),
|
||||
next_path=next_path,
|
||||
params={}
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
except AuthenticationException as e:
|
||||
params = e.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_space=True),
|
||||
next_path=next_path,
|
||||
params=params
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
|
||||
@@ -117,9 +128,11 @@ class SignUpAuthSpaceEndpoint(View):
|
||||
error_message="INSTANCE_NOT_CONFIGURED",
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_space=True),
|
||||
next_path=next_path,
|
||||
params=params
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
email = request.POST.get("email", False)
|
||||
@@ -135,9 +148,11 @@ class SignUpAuthSpaceEndpoint(View):
|
||||
payload={"email": str(email)},
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_space=True),
|
||||
next_path=next_path,
|
||||
params=params
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
# Validate the email
|
||||
email = email.strip().lower()
|
||||
@@ -151,9 +166,11 @@ class SignUpAuthSpaceEndpoint(View):
|
||||
payload={"email": str(email)},
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_space=True),
|
||||
next_path=next_path,
|
||||
params=params
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
# Existing User
|
||||
@@ -166,9 +183,11 @@ class SignUpAuthSpaceEndpoint(View):
|
||||
payload={"email": str(email)},
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_space=True),
|
||||
next_path=next_path,
|
||||
params=params
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
try:
|
||||
@@ -179,11 +198,17 @@ class SignUpAuthSpaceEndpoint(View):
|
||||
# Login the user and record his device info
|
||||
user_login(request=request, user=user, is_space=True)
|
||||
# redirect to referer path
|
||||
url = f"{base_host(request=request, is_space=True)}{str(next_path) if next_path else ''}"
|
||||
return HttpResponseRedirect(url)
|
||||
next_path = validate_next_path(next_path=next_path)
|
||||
url = f"{base_host(request=request, is_space=True).rstrip('/')}{next_path}"
|
||||
if url_has_allowed_host_and_scheme(url, allowed_hosts=get_allowed_hosts()):
|
||||
return HttpResponseRedirect(url)
|
||||
else:
|
||||
return HttpResponseRedirect(base_host(request=request, is_space=True))
|
||||
except AuthenticationException as e:
|
||||
params = e.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_space=True),
|
||||
next_path=next_path,
|
||||
params=params
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# Python imports
|
||||
import uuid
|
||||
from urllib.parse import urlencode
|
||||
|
||||
# Django import
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.views import View
|
||||
from django.utils.http import url_has_allowed_host_and_scheme
|
||||
|
||||
# Module imports
|
||||
from plane.authentication.provider.oauth.github import GitHubOAuthProvider
|
||||
@@ -15,7 +15,7 @@ from plane.authentication.adapter.error import (
|
||||
AUTHENTICATION_ERROR_CODES,
|
||||
AuthenticationException,
|
||||
)
|
||||
from plane.utils.path_validator import validate_next_path
|
||||
from plane.utils.path_validator import get_safe_redirect_url, validate_next_path, get_allowed_hosts
|
||||
|
||||
|
||||
class GitHubOauthInitiateSpaceEndpoint(View):
|
||||
@@ -23,9 +23,6 @@ class GitHubOauthInitiateSpaceEndpoint(View):
|
||||
# Get host and next path
|
||||
request.session["host"] = base_host(request=request, is_space=True)
|
||||
next_path = request.GET.get("next_path")
|
||||
if next_path:
|
||||
request.session["next_path"] = str(next_path)
|
||||
|
||||
# Check instance configuration
|
||||
instance = Instance.objects.first()
|
||||
if instance is None or not instance.is_setup_done:
|
||||
@@ -34,9 +31,11 @@ class GitHubOauthInitiateSpaceEndpoint(View):
|
||||
error_message="INSTANCE_NOT_CONFIGURED",
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_space=True),
|
||||
next_path=next_path,
|
||||
params=params
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
try:
|
||||
@@ -47,9 +46,11 @@ class GitHubOauthInitiateSpaceEndpoint(View):
|
||||
return HttpResponseRedirect(auth_url)
|
||||
except AuthenticationException as e:
|
||||
params = e.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(next_path)
|
||||
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_space=True),
|
||||
next_path=next_path,
|
||||
params=params
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
|
||||
@@ -66,9 +67,11 @@ class GitHubCallbackSpaceEndpoint(View):
|
||||
error_message="GITHUB_OAUTH_PROVIDER_ERROR",
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_space=True),
|
||||
next_path=next_path,
|
||||
params=params
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
if not code:
|
||||
@@ -77,9 +80,11 @@ class GitHubCallbackSpaceEndpoint(View):
|
||||
error_message="GITHUB_OAUTH_PROVIDER_ERROR",
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_space=True),
|
||||
next_path=next_path,
|
||||
params=params
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
try:
|
||||
@@ -89,11 +94,17 @@ class GitHubCallbackSpaceEndpoint(View):
|
||||
user_login(request=request, user=user, is_space=True)
|
||||
# Process workspace and project invitations
|
||||
# redirect to referer path
|
||||
url = f"{base_host(request=request, is_space=True)}{str(next_path) if next_path else ''}"
|
||||
return HttpResponseRedirect(url)
|
||||
next_path = validate_next_path(next_path=next_path)
|
||||
url = f"{base_host(request=request, is_space=True).rstrip('/')}{next_path}"
|
||||
if url_has_allowed_host_and_scheme(url, allowed_hosts=get_allowed_hosts()):
|
||||
return HttpResponseRedirect(url)
|
||||
else:
|
||||
return HttpResponseRedirect(base_host(request=request, is_space=True))
|
||||
except AuthenticationException as e:
|
||||
params = e.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_space=True),
|
||||
next_path=next_path,
|
||||
params=params
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# Python imports
|
||||
import uuid
|
||||
from urllib.parse import urlencode
|
||||
|
||||
# Django import
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.views import View
|
||||
from django.utils.http import url_has_allowed_host_and_scheme
|
||||
|
||||
# Module imports
|
||||
from plane.authentication.provider.oauth.gitlab import GitLabOAuthProvider
|
||||
@@ -15,7 +15,7 @@ from plane.authentication.adapter.error import (
|
||||
AUTHENTICATION_ERROR_CODES,
|
||||
AuthenticationException,
|
||||
)
|
||||
from plane.utils.path_validator import validate_next_path
|
||||
from plane.utils.path_validator import get_safe_redirect_url, get_allowed_hosts, validate_next_path
|
||||
|
||||
|
||||
class GitLabOauthInitiateSpaceEndpoint(View):
|
||||
@@ -23,8 +23,6 @@ class GitLabOauthInitiateSpaceEndpoint(View):
|
||||
# Get host and next path
|
||||
request.session["host"] = base_host(request=request, is_space=True)
|
||||
next_path = request.GET.get("next_path")
|
||||
if next_path:
|
||||
request.session["next_path"] = str(next_path)
|
||||
|
||||
# Check instance configuration
|
||||
instance = Instance.objects.first()
|
||||
@@ -34,9 +32,11 @@ class GitLabOauthInitiateSpaceEndpoint(View):
|
||||
error_message="INSTANCE_NOT_CONFIGURED",
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_space=True),
|
||||
next_path=next_path,
|
||||
params=params
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
try:
|
||||
@@ -47,9 +47,11 @@ class GitLabOauthInitiateSpaceEndpoint(View):
|
||||
return HttpResponseRedirect(auth_url)
|
||||
except AuthenticationException as e:
|
||||
params = e.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(next_path)
|
||||
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_space=True),
|
||||
next_path=next_path,
|
||||
params=params
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
|
||||
@@ -66,9 +68,11 @@ class GitLabCallbackSpaceEndpoint(View):
|
||||
error_message="GITLAB_OAUTH_PROVIDER_ERROR",
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_space=True),
|
||||
next_path=next_path,
|
||||
params=params
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
if not code:
|
||||
@@ -77,9 +81,11 @@ class GitLabCallbackSpaceEndpoint(View):
|
||||
error_message="GITLAB_OAUTH_PROVIDER_ERROR",
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_space=True),
|
||||
next_path=next_path,
|
||||
params=params
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
try:
|
||||
@@ -89,11 +95,17 @@ class GitLabCallbackSpaceEndpoint(View):
|
||||
user_login(request=request, user=user, is_space=True)
|
||||
# Process workspace and project invitations
|
||||
# redirect to referer path
|
||||
url = f"{base_host(request=request, is_space=True)}{str(next_path) if next_path else ''}"
|
||||
return HttpResponseRedirect(url)
|
||||
next_path = validate_next_path(next_path=next_path)
|
||||
url = f"{base_host(request=request, is_space=True).rstrip('/')}{next_path}"
|
||||
if url_has_allowed_host_and_scheme(url, allowed_hosts=get_allowed_hosts()):
|
||||
return HttpResponseRedirect(url)
|
||||
else:
|
||||
return HttpResponseRedirect(base_host(request=request, is_space=True))
|
||||
except AuthenticationException as e:
|
||||
params = e.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_space=True),
|
||||
next_path=next_path,
|
||||
params=params
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# Python imports
|
||||
import uuid
|
||||
from urllib.parse import urlencode
|
||||
|
||||
# Django import
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.views import View
|
||||
from django.utils.http import url_has_allowed_host_and_scheme
|
||||
|
||||
# Module imports
|
||||
from plane.authentication.provider.oauth.google import GoogleOAuthProvider
|
||||
@@ -15,15 +15,13 @@ from plane.authentication.adapter.error import (
|
||||
AuthenticationException,
|
||||
AUTHENTICATION_ERROR_CODES,
|
||||
)
|
||||
from plane.utils.path_validator import validate_next_path
|
||||
from plane.utils.path_validator import get_safe_redirect_url, validate_next_path, get_allowed_hosts
|
||||
|
||||
|
||||
class GoogleOauthInitiateSpaceEndpoint(View):
|
||||
def get(self, request):
|
||||
request.session["host"] = base_host(request=request, is_space=True)
|
||||
next_path = request.GET.get("next_path")
|
||||
if next_path:
|
||||
request.session["next_path"] = str(next_path)
|
||||
|
||||
# Check instance configuration
|
||||
instance = Instance.objects.first()
|
||||
@@ -33,9 +31,11 @@ class GoogleOauthInitiateSpaceEndpoint(View):
|
||||
error_message="INSTANCE_NOT_CONFIGURED",
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_space=True),
|
||||
next_path=next_path,
|
||||
params=params
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
try:
|
||||
@@ -46,9 +46,11 @@ class GoogleOauthInitiateSpaceEndpoint(View):
|
||||
return HttpResponseRedirect(auth_url)
|
||||
except AuthenticationException as e:
|
||||
params = e.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_space=True),
|
||||
next_path=next_path,
|
||||
params=params
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
|
||||
@@ -65,9 +67,11 @@ class GoogleCallbackSpaceEndpoint(View):
|
||||
error_message="GOOGLE_OAUTH_PROVIDER_ERROR",
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_space=True),
|
||||
next_path=next_path,
|
||||
params=params
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
if not code:
|
||||
exc = AuthenticationException(
|
||||
@@ -75,9 +79,11 @@ class GoogleCallbackSpaceEndpoint(View):
|
||||
error_message="GOOGLE_OAUTH_PROVIDER_ERROR",
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_space=True),
|
||||
next_path=next_path,
|
||||
params=params
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
try:
|
||||
provider = GoogleOAuthProvider(request=request, code=code)
|
||||
@@ -85,11 +91,17 @@ class GoogleCallbackSpaceEndpoint(View):
|
||||
# Login the user and record his device info
|
||||
user_login(request=request, user=user, is_space=True)
|
||||
# redirect to referer path
|
||||
url = f"{base_host(request=request, is_space=True)}{str(next_path) if next_path else ''}"
|
||||
return HttpResponseRedirect(url)
|
||||
next_path = validate_next_path(next_path=next_path)
|
||||
url = f"{base_host(request=request, is_space=True).rstrip('/')}{next_path}"
|
||||
if url_has_allowed_host_and_scheme(url, allowed_hosts=get_allowed_hosts()):
|
||||
return HttpResponseRedirect(url)
|
||||
else:
|
||||
return HttpResponseRedirect(base_host(request=request, is_space=True))
|
||||
except AuthenticationException as e:
|
||||
params = e.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_space=True),
|
||||
next_path=next_path,
|
||||
params=params
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
# Python imports
|
||||
from urllib.parse import urlencode
|
||||
|
||||
# Django imports
|
||||
from django.core.validators import validate_email
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.views import View
|
||||
from django.utils.http import url_has_allowed_host_and_scheme
|
||||
|
||||
# Third party imports
|
||||
from rest_framework import status
|
||||
@@ -23,7 +21,7 @@ from plane.authentication.adapter.error import (
|
||||
AuthenticationException,
|
||||
AUTHENTICATION_ERROR_CODES,
|
||||
)
|
||||
from plane.utils.path_validator import validate_next_path
|
||||
from plane.utils.path_validator import get_safe_redirect_url, validate_next_path, get_allowed_hosts
|
||||
|
||||
|
||||
class MagicGenerateSpaceEndpoint(APIView):
|
||||
@@ -66,9 +64,11 @@ class MagicSignInSpaceEndpoint(View):
|
||||
error_message="MAGIC_SIGN_IN_EMAIL_CODE_REQUIRED",
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_space=True),
|
||||
next_path=next_path,
|
||||
params=params,
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
existing_user = User.objects.filter(email=email).first()
|
||||
@@ -79,9 +79,11 @@ class MagicSignInSpaceEndpoint(View):
|
||||
error_message="USER_DOES_NOT_EXIST",
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_space=True),
|
||||
next_path=next_path,
|
||||
params=params,
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
# Active User
|
||||
@@ -93,15 +95,20 @@ class MagicSignInSpaceEndpoint(View):
|
||||
# Login the user and record his device info
|
||||
user_login(request=request, user=user, is_space=True)
|
||||
# redirect to referer path
|
||||
path = str(next_path) if next_path else ""
|
||||
url = f"{base_host(request=request, is_space=True)}{path}"
|
||||
return HttpResponseRedirect(url)
|
||||
next_path = validate_next_path(next_path=next_path)
|
||||
url = f"{base_host(request=request, is_space=True).rstrip('/')}{next_path}"
|
||||
if url_has_allowed_host_and_scheme(url, allowed_hosts=get_allowed_hosts()):
|
||||
return HttpResponseRedirect(url)
|
||||
else:
|
||||
return HttpResponseRedirect(base_host(request=request, is_space=True))
|
||||
|
||||
except AuthenticationException as e:
|
||||
params = e.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(next_path)
|
||||
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_space=True),
|
||||
next_path=next_path,
|
||||
params=params,
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
|
||||
@@ -120,9 +127,11 @@ class MagicSignUpSpaceEndpoint(View):
|
||||
error_message="MAGIC_SIGN_UP_EMAIL_CODE_REQUIRED",
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_space=True),
|
||||
next_path=next_path,
|
||||
params=params,
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
# Existing User
|
||||
existing_user = User.objects.filter(email=email).first()
|
||||
@@ -133,9 +142,11 @@ class MagicSignUpSpaceEndpoint(View):
|
||||
error_message="USER_ALREADY_EXIST",
|
||||
)
|
||||
params = exc.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_space=True),
|
||||
next_path=next_path,
|
||||
params=params,
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
try:
|
||||
@@ -146,12 +157,18 @@ class MagicSignUpSpaceEndpoint(View):
|
||||
# Login the user and record his device info
|
||||
user_login(request=request, user=user, is_space=True)
|
||||
# redirect to referer path
|
||||
url = f"{base_host(request=request, is_space=True)}{str(next_path) if next_path else ''}"
|
||||
return HttpResponseRedirect(url)
|
||||
next_path = validate_next_path(next_path=next_path)
|
||||
url = f"{base_host(request=request, is_space=True).rstrip('/')}{next_path}"
|
||||
if url_has_allowed_host_and_scheme(url, allowed_hosts=get_allowed_hosts()):
|
||||
return HttpResponseRedirect(url)
|
||||
else:
|
||||
return HttpResponseRedirect(base_host(request=request, is_space=True))
|
||||
|
||||
except AuthenticationException as e:
|
||||
params = e.get_error_dict()
|
||||
if next_path:
|
||||
params["next_path"] = str(validate_next_path(next_path))
|
||||
url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_space=True),
|
||||
next_path=next_path,
|
||||
params=params,
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
@@ -7,7 +7,7 @@ from django.utils import timezone
|
||||
# Module imports
|
||||
from plane.authentication.utils.host import base_host, user_ip
|
||||
from plane.db.models import User
|
||||
from plane.utils.path_validator import validate_next_path
|
||||
from plane.utils.path_validator import get_safe_redirect_url
|
||||
|
||||
|
||||
class SignOutAuthSpaceEndpoint(View):
|
||||
@@ -22,8 +22,14 @@ class SignOutAuthSpaceEndpoint(View):
|
||||
user.save()
|
||||
# Log the user out
|
||||
logout(request)
|
||||
url = f"{base_host(request=request, is_space=True)}{str(validate_next_path(next_path)) if next_path else ''}"
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_space=True),
|
||||
next_path=next_path
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
except Exception:
|
||||
url = f"{base_host(request=request, is_space=True)}{str(validate_next_path(next_path)) if next_path else ''}"
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_space=True),
|
||||
next_path=next_path
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
@@ -92,6 +92,10 @@ def create_project_and_member(workspace: Workspace) -> Dict[int, uuid.UUID]:
|
||||
name=workspace.name, # Use workspace name
|
||||
identifier=project_identifier,
|
||||
created_by_id=workspace.created_by_id,
|
||||
# Enable all views in seed data
|
||||
cycle_view=True,
|
||||
module_view=True,
|
||||
issue_views_view=True,
|
||||
)
|
||||
|
||||
# Create project members
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# Generated by Django 4.2.22 on 2025-09-10 09:54
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("db", "0104_cycleuserproperties_rich_filters_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="project",
|
||||
name="cycle_view",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="project",
|
||||
name="issue_views_view",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="project",
|
||||
name="module_view",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="session",
|
||||
name="user_id",
|
||||
field=models.CharField(db_index=True, max_length=50, null=True),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,152 @@
|
||||
# Generated by Django 4.2.22 on 2025-09-12 08:45
|
||||
import uuid
|
||||
import django
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def set_page_sort_order(apps, schema_editor):
|
||||
Page = apps.get_model("db", "Page")
|
||||
|
||||
batch_size = 3000
|
||||
sort_order = 100
|
||||
|
||||
# Get page IDs ordered by name using the historical model
|
||||
# This should include all pages regardless of soft-delete status
|
||||
page_ids = list(Page.objects.all().order_by("name").values_list("id", flat=True))
|
||||
|
||||
updated_pages = []
|
||||
for page_id in page_ids:
|
||||
# Create page instance with minimal data
|
||||
updated_pages.append(Page(id=page_id, sort_order=sort_order))
|
||||
sort_order += 100
|
||||
|
||||
# Bulk update when batch is full
|
||||
if len(updated_pages) >= batch_size:
|
||||
Page.objects.bulk_update(
|
||||
updated_pages, ["sort_order"], batch_size=batch_size
|
||||
)
|
||||
updated_pages = []
|
||||
|
||||
# Update remaining pages
|
||||
if updated_pages:
|
||||
Page.objects.bulk_update(updated_pages, ["sort_order"], batch_size=batch_size)
|
||||
|
||||
|
||||
def reverse_set_page_sort_order(apps, schema_editor):
|
||||
Page = apps.get_model("db", "Page")
|
||||
Page.objects.update(sort_order=Page.DEFAULT_SORT_ORDER)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("db", "0105_alter_project_cycle_view_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="ProjectWebhook",
|
||||
fields=[
|
||||
(
|
||||
"created_at",
|
||||
models.DateTimeField(auto_now_add=True, verbose_name="Created At"),
|
||||
),
|
||||
(
|
||||
"updated_at",
|
||||
models.DateTimeField(
|
||||
auto_now=True, verbose_name="Last Modified At"
|
||||
),
|
||||
),
|
||||
(
|
||||
"deleted_at",
|
||||
models.DateTimeField(
|
||||
blank=True, null=True, verbose_name="Deleted At"
|
||||
),
|
||||
),
|
||||
(
|
||||
"id",
|
||||
models.UUIDField(
|
||||
db_index=True,
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
unique=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"created_by",
|
||||
models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="%(class)s_created_by",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="Created By",
|
||||
),
|
||||
),
|
||||
(
|
||||
"project",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="project_%(class)s",
|
||||
to="db.project",
|
||||
),
|
||||
),
|
||||
(
|
||||
"updated_by",
|
||||
models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="%(class)s_updated_by",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="Last Modified By",
|
||||
),
|
||||
),
|
||||
(
|
||||
"webhook",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="project_webhooks",
|
||||
to="db.webhook",
|
||||
),
|
||||
),
|
||||
(
|
||||
"workspace",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="workspace_%(class)s",
|
||||
to="db.workspace",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Project Webhook",
|
||||
"verbose_name_plural": "Project Webhooks",
|
||||
"db_table": "project_webhooks",
|
||||
"ordering": ("-created_at",),
|
||||
},
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="projectwebhook",
|
||||
constraint=models.UniqueConstraint(
|
||||
condition=models.Q(("deleted_at__isnull", True)),
|
||||
fields=("project", "webhook"),
|
||||
name="project_webhook_unique_project_webhook_when_deleted_at_null",
|
||||
),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name="projectwebhook",
|
||||
unique_together={("project", "webhook", "deleted_at")},
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="issuerelation",
|
||||
name="relation_type",
|
||||
field=models.CharField(
|
||||
default="blocked_by", max_length=20, verbose_name="Issue Relation Type"
|
||||
),
|
||||
),
|
||||
migrations.RunPython(
|
||||
set_page_sort_order, reverse_code=reverse_set_page_sort_order
|
||||
),
|
||||
]
|
||||
@@ -284,6 +284,7 @@ class IssueRelationChoices(models.TextChoices):
|
||||
BLOCKED_BY = "blocked_by", "Blocked By"
|
||||
START_BEFORE = "start_before", "Start Before"
|
||||
FINISH_BEFORE = "finish_before", "Finish Before"
|
||||
IMPLEMENTED_BY = "implemented_by", "Implemented By"
|
||||
|
||||
|
||||
class IssueRelation(ProjectBaseModel):
|
||||
@@ -295,7 +296,6 @@ class IssueRelation(ProjectBaseModel):
|
||||
)
|
||||
relation_type = models.CharField(
|
||||
max_length=20,
|
||||
choices=IssueRelationChoices.choices,
|
||||
verbose_name="Issue Relation Type",
|
||||
default=IssueRelationChoices.BLOCKED_BY,
|
||||
)
|
||||
|
||||
@@ -19,6 +19,7 @@ def get_view_props():
|
||||
class Page(BaseModel):
|
||||
PRIVATE_ACCESS = 1
|
||||
PUBLIC_ACCESS = 0
|
||||
DEFAULT_SORT_ORDER = 65535
|
||||
|
||||
ACCESS_CHOICES = ((PRIVATE_ACCESS, "Private"), (PUBLIC_ACCESS, "Public"))
|
||||
|
||||
@@ -57,7 +58,7 @@ class Page(BaseModel):
|
||||
)
|
||||
moved_to_page = models.UUIDField(null=True, blank=True)
|
||||
moved_to_project = models.UUIDField(null=True, blank=True)
|
||||
sort_order = models.FloatField(default=65535)
|
||||
sort_order = models.FloatField(default=DEFAULT_SORT_ORDER)
|
||||
|
||||
external_id = models.CharField(max_length=255, null=True, blank=True)
|
||||
external_source = models.CharField(max_length=255, null=True, blank=True)
|
||||
|
||||
@@ -18,6 +18,12 @@ from .base import BaseModel
|
||||
ROLE_CHOICES = ((20, "Admin"), (15, "Member"), (5, "Guest"))
|
||||
|
||||
|
||||
class ROLE(Enum):
|
||||
ADMIN = 20
|
||||
MEMBER = 15
|
||||
GUEST = 5
|
||||
|
||||
|
||||
class ProjectNetwork(Enum):
|
||||
SECRET = 0
|
||||
PUBLIC = 2
|
||||
@@ -89,9 +95,9 @@ class Project(BaseModel):
|
||||
)
|
||||
emoji = models.CharField(max_length=255, null=True, blank=True)
|
||||
icon_prop = models.JSONField(null=True)
|
||||
module_view = models.BooleanField(default=True)
|
||||
cycle_view = models.BooleanField(default=True)
|
||||
issue_views_view = models.BooleanField(default=True)
|
||||
module_view = models.BooleanField(default=False)
|
||||
cycle_view = models.BooleanField(default=False)
|
||||
issue_views_view = models.BooleanField(default=False)
|
||||
page_view = models.BooleanField(default=True)
|
||||
intake_view = models.BooleanField(default=False)
|
||||
is_time_tracking_enabled = models.BooleanField(default=False)
|
||||
|
||||
@@ -13,7 +13,7 @@ VALID_KEY_CHARS = string.ascii_lowercase + string.digits
|
||||
class Session(AbstractBaseSession):
|
||||
device_info = models.JSONField(null=True, blank=True, default=None)
|
||||
session_key = models.CharField(max_length=128, primary_key=True)
|
||||
user_id = models.CharField(null=True, max_length=50)
|
||||
user_id = models.CharField(null=True, max_length=50, db_index=True)
|
||||
|
||||
@classmethod
|
||||
def get_session_store_class(cls):
|
||||
|
||||
@@ -7,7 +7,7 @@ from django.db import models
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import BaseModel
|
||||
from plane.db.models import BaseModel, ProjectBaseModel
|
||||
|
||||
|
||||
def generate_token():
|
||||
@@ -90,3 +90,24 @@ class WebhookLog(BaseModel):
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.event_type} {str(self.webhook)}"
|
||||
|
||||
|
||||
|
||||
class ProjectWebhook(ProjectBaseModel):
|
||||
webhook = models.ForeignKey(
|
||||
"db.Webhook", on_delete=models.CASCADE, related_name="project_webhooks"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
unique_together = ["project", "webhook", "deleted_at"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["project", "webhook"],
|
||||
condition=models.Q(deleted_at__isnull=True),
|
||||
name="project_webhook_unique_project_webhook_when_deleted_at_null",
|
||||
)
|
||||
]
|
||||
verbose_name = "Project Webhook"
|
||||
verbose_name_plural = "Project Webhooks"
|
||||
db_table = "project_webhooks"
|
||||
ordering = ("-created_at",)
|
||||
@@ -34,6 +34,7 @@ from plane.authentication.adapter.error import (
|
||||
AuthenticationException,
|
||||
)
|
||||
from plane.utils.ip_address import get_client_ip
|
||||
from plane.utils.path_validator import get_safe_redirect_url
|
||||
|
||||
|
||||
class InstanceAdminEndpoint(BaseAPIView):
|
||||
@@ -392,7 +393,14 @@ class InstanceAdminSignOutEndpoint(View):
|
||||
user.save()
|
||||
# Log the user out
|
||||
logout(request)
|
||||
url = urljoin(base_host(request=request, is_admin=True))
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_admin=True),
|
||||
next_path=""
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
except Exception:
|
||||
return HttpResponseRedirect(base_host(request=request, is_admin=True))
|
||||
url = get_safe_redirect_url(
|
||||
base_url=base_host(request=request, is_admin=True),
|
||||
next_path=""
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.2.22 on 2025-09-11 08:40
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("license", "0005_rename_product_instance_edition_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="instance",
|
||||
name="is_current_version_deprecated",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
@@ -38,6 +38,8 @@ class Instance(BaseModel):
|
||||
is_signup_screen_visited = models.BooleanField(default=False)
|
||||
is_verified = models.BooleanField(default=False)
|
||||
is_test = models.BooleanField(default=False)
|
||||
# field for validating if the current version is deprecated
|
||||
is_current_version_deprecated = models.BooleanField(default=False)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Instance"
|
||||
|
||||
@@ -476,6 +476,8 @@ def filter_subscribed_issues(params, issue_filter, method, prefix=""):
|
||||
issue_filter[f"{prefix}issue_subscribers__subscriber_id__in"] = params.get(
|
||||
"subscriber"
|
||||
)
|
||||
issue_filter[f"{prefix}issue_subscribers__deleted_at__isnull"] = True
|
||||
|
||||
return issue_filter
|
||||
|
||||
|
||||
|
||||
@@ -6,12 +6,14 @@ def get_inverse_relation(relation_type):
|
||||
"blocking": "blocked_by",
|
||||
"start_before": "start_after",
|
||||
"finish_before": "finish_after",
|
||||
"implemented_by": "implements",
|
||||
"implements": "implemented_by",
|
||||
}
|
||||
return relation_mapping.get(relation_type, relation_type)
|
||||
|
||||
|
||||
def get_actual_relation(relation_type):
|
||||
# This function is used to get the actual relation type which is store in database
|
||||
# This function is used to get the actual relation type which is stored in database
|
||||
actual_relation = {
|
||||
"start_after": "start_before",
|
||||
"finish_after": "finish_before",
|
||||
@@ -19,6 +21,8 @@ def get_actual_relation(relation_type):
|
||||
"blocked_by": "blocked_by",
|
||||
"start_before": "start_before",
|
||||
"finish_before": "finish_before",
|
||||
"implemented_by": "implemented_by",
|
||||
"implements": "implemented_by",
|
||||
}
|
||||
|
||||
return actual_relation.get(relation_type, relation_type)
|
||||
|
||||
@@ -1,21 +1,143 @@
|
||||
# Django imports
|
||||
from django.utils.http import url_has_allowed_host_and_scheme
|
||||
from django.conf import settings
|
||||
|
||||
# Python imports
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
||||
def _contains_suspicious_patterns(path: str) -> bool:
|
||||
"""
|
||||
Check for suspicious patterns that might indicate malicious intent.
|
||||
|
||||
Args:
|
||||
path (str): The path to check
|
||||
|
||||
Returns:
|
||||
bool: True if suspicious patterns found, False otherwise
|
||||
"""
|
||||
suspicious_patterns = [
|
||||
r'javascript:', # JavaScript injection
|
||||
r'data:', # Data URLs
|
||||
r'vbscript:', # VBScript injection
|
||||
r'file:', # File protocol
|
||||
r'ftp:', # FTP protocol
|
||||
r'%2e%2e', # URL encoded path traversal
|
||||
r'%2f%2f', # URL encoded double slash
|
||||
r'%5c%5c', # URL encoded backslashes
|
||||
r'<script', # Script tags
|
||||
r'<iframe', # Iframe tags
|
||||
r'<object', # Object tags
|
||||
r'<embed', # Embed tags
|
||||
r'<form', # Form tags
|
||||
r'onload=', # Event handlers
|
||||
r'onerror=', # Event handlers
|
||||
r'onclick=', # Event handlers
|
||||
]
|
||||
|
||||
path_lower = path.lower()
|
||||
for pattern in suspicious_patterns:
|
||||
if pattern in path_lower:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def get_allowed_hosts() -> list[str]:
|
||||
"""Get the allowed hosts from the settings."""
|
||||
base_origin = settings.WEB_URL or settings.APP_BASE_URL
|
||||
|
||||
allowed_hosts = []
|
||||
if base_origin:
|
||||
host = urlparse(base_origin).netloc
|
||||
allowed_hosts.append(host)
|
||||
if settings.ADMIN_BASE_URL:
|
||||
# Get only the host
|
||||
host = urlparse(settings.ADMIN_BASE_URL).netloc
|
||||
allowed_hosts.append(host)
|
||||
if settings.SPACE_BASE_URL:
|
||||
# Get only the host
|
||||
host = urlparse(settings.SPACE_BASE_URL).netloc
|
||||
allowed_hosts.append(host)
|
||||
return allowed_hosts
|
||||
|
||||
|
||||
def validate_next_path(next_path: str) -> str:
|
||||
"""Validates that next_path is a valid path and extracts only the path component."""
|
||||
"""Validates that next_path is a safe relative path for redirection."""
|
||||
# Browsers interpret backslashes as forward slashes. Remove all backslashes.
|
||||
if not next_path or not isinstance(next_path, str):
|
||||
return ""
|
||||
|
||||
|
||||
# Limit input length to prevent DoS attacks
|
||||
if len(next_path) > 500:
|
||||
return ""
|
||||
|
||||
|
||||
next_path = next_path.replace("\\", "")
|
||||
parsed_url = urlparse(next_path)
|
||||
|
||||
# Ensure next_path is not an absolute URL
|
||||
# Block absolute URLs or anything with scheme/netloc
|
||||
if parsed_url.scheme or parsed_url.netloc:
|
||||
next_path = parsed_url.path # Extract only the path component
|
||||
|
||||
# Ensure it starts with a forward slash (indicating a valid relative path)
|
||||
if not next_path.startswith("/"):
|
||||
# Must start with a forward slash and not be empty
|
||||
if not next_path or not next_path.startswith("/"):
|
||||
return ""
|
||||
|
||||
# Ensure it does not contain dangerous path traversal sequences
|
||||
# Prevent path traversal
|
||||
if ".." in next_path:
|
||||
return ""
|
||||
|
||||
# Additional security checks
|
||||
if _contains_suspicious_patterns(next_path):
|
||||
return ""
|
||||
|
||||
return next_path
|
||||
|
||||
|
||||
def get_safe_redirect_url(base_url: str, next_path: str = "", params: dict = {}) -> str:
|
||||
"""
|
||||
Safely construct a redirect URL with validated next_path.
|
||||
|
||||
Args:
|
||||
base_url (str): The base URL to redirect to
|
||||
next_path (str): The next path to append
|
||||
params (dict): The parameters to append
|
||||
Returns:
|
||||
str: The safe redirect URL
|
||||
"""
|
||||
from urllib.parse import urlencode, quote
|
||||
|
||||
# Validate the next path
|
||||
validated_path = validate_next_path(next_path)
|
||||
|
||||
# Add the next path to the parameters
|
||||
base_url = base_url.rstrip('/')
|
||||
|
||||
# Prepare the query parameters
|
||||
query_parts = []
|
||||
encoded_params = ""
|
||||
|
||||
# Add the next path to the parameters
|
||||
if validated_path:
|
||||
query_parts.append(f"next_path={validated_path}")
|
||||
|
||||
# Add additional parameters
|
||||
if params:
|
||||
encoded_params = urlencode(params)
|
||||
query_parts.append(encoded_params)
|
||||
|
||||
# Construct the url query string
|
||||
if query_parts:
|
||||
query_string = "&".join(query_parts)
|
||||
url = f"{base_url}/?{query_string}"
|
||||
else:
|
||||
url = base_url
|
||||
|
||||
# Check if the URL is allowed
|
||||
if url_has_allowed_host_and_scheme(url, allowed_hosts=get_allowed_hosts()):
|
||||
return url
|
||||
|
||||
# Return the base URL if the URL is not allowed
|
||||
return base_url + (f"?{encoded_params}" if encoded_params else "")
|
||||
@@ -4,4 +4,7 @@ export default defineConfig({
|
||||
entry: ["src/server.ts"],
|
||||
outDir: "dist",
|
||||
format: ["esm", "cjs"],
|
||||
dts: true,
|
||||
clean: true,
|
||||
sourcemap: true,
|
||||
});
|
||||
|
||||
@@ -191,7 +191,7 @@ export const GlobalIssuesHeader = observer(() => {
|
||||
</Breadcrumbs>
|
||||
</Header.LeftItem>
|
||||
|
||||
<Header.RightItem>
|
||||
<Header.RightItem className="items-center">
|
||||
{!isLocked ? (
|
||||
<>
|
||||
<GlobalViewLayoutSelection
|
||||
|
||||
+39
-44
@@ -2,40 +2,36 @@
|
||||
|
||||
import Image from "next/image";
|
||||
import { useTheme } from "next-themes";
|
||||
// plane imports
|
||||
import { API_BASE_URL } from "@plane/constants";
|
||||
import { Button, TOAST_TYPE, setToast } from "@plane/ui";
|
||||
// hooks
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
// layouts
|
||||
import { Button } from "@plane/ui";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
import DefaultLayout from "@/layouts/default-layout";
|
||||
// images
|
||||
import maintenanceModeDarkModeImage from "@/public/instance/maintenance-mode-dark.svg";
|
||||
import maintenanceModeLightModeImage from "@/public/instance/maintenance-mode-light.svg";
|
||||
// services
|
||||
import { AuthService } from "@/services/auth.service";
|
||||
|
||||
// services
|
||||
const authService = new AuthService();
|
||||
const linkMap = [
|
||||
{
|
||||
key: "mail_to",
|
||||
label: "Contact Support",
|
||||
value: "mailto:[email protected]",
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
label: "Status Page",
|
||||
value: "https://status.plane.so/",
|
||||
},
|
||||
{
|
||||
key: "twitter_handle",
|
||||
label: "@planepowers",
|
||||
value: "https://x.com/planepowers",
|
||||
},
|
||||
];
|
||||
|
||||
export default function CustomErrorComponent() {
|
||||
// routers
|
||||
const router = useAppRouter();
|
||||
// hooks
|
||||
const { resolvedTheme } = useTheme();
|
||||
|
||||
const handleSignOut = async () => {
|
||||
await authService
|
||||
.signOut(API_BASE_URL)
|
||||
.catch(() =>
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: "Failed to sign out. Please try again.",
|
||||
})
|
||||
)
|
||||
.finally(() => router.push("/"));
|
||||
};
|
||||
const router = useAppRouter();
|
||||
|
||||
// derived values
|
||||
const maintenanceModeImage = resolvedTheme === "dark" ? maintenanceModeDarkModeImage : maintenanceModeLightModeImage;
|
||||
@@ -55,34 +51,33 @@ export default function CustomErrorComponent() {
|
||||
<div className="w-full relative flex flex-col gap-4 mt-4">
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<h1 className="text-xl font-semibold text-custom-text-100 text-left">
|
||||
🚧 Yikes! That doesn't look good.
|
||||
🚧 Looks like something went wrong!
|
||||
</h1>
|
||||
<span className="text-base font-medium text-custom-text-200 text-left">
|
||||
That crashed Plane, pun intended. No worries, though. Our engineers have been notified. If you have more
|
||||
details, please write to{" "}
|
||||
<a href="mailto:[email protected]" className="text-custom-primary">
|
||||
support@plane.so
|
||||
</a>{" "}
|
||||
or on our{" "}
|
||||
<a
|
||||
href="https://discord.com/invite/A92xrEGCge"
|
||||
target="_blank"
|
||||
className="text-custom-primary"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Discord
|
||||
</a>
|
||||
.
|
||||
We track these errors automatically and working on getting things back up and running. If the problem
|
||||
persists feel free to contact us. In the meantime, try refreshing.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-start gap-2">
|
||||
<div className="flex items-center justify-start gap-6 mt-1">
|
||||
{linkMap.map((link) => (
|
||||
<div key={link.key}>
|
||||
<a
|
||||
href={link.value}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-custom-primary-100 hover:underline text-sm"
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-start gap-6">
|
||||
<Button variant="primary" size="md" onClick={() => router.push("/")}>
|
||||
Go to home
|
||||
</Button>
|
||||
<Button variant="neutral-primary" size="md" onClick={handleSignOut}>
|
||||
Sign out
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { EIssueLayoutTypes } from "@plane/types";
|
||||
import { ExternalLink, Link, Pencil, Trash2 } from "lucide-react";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { EIssueLayoutTypes, IProjectView } from "@plane/types";
|
||||
import { TContextMenuItem } from "@plane/ui";
|
||||
import { TWorkspaceLayoutProps } from "@/components/views/helper";
|
||||
|
||||
export type TLayoutSelectionProps = {
|
||||
@@ -10,3 +13,68 @@ export type TLayoutSelectionProps = {
|
||||
export const GlobalViewLayoutSelection = (props: TLayoutSelectionProps) => <></>;
|
||||
|
||||
export const WorkspaceAdditionalLayouts = (props: TWorkspaceLayoutProps) => <></>;
|
||||
|
||||
export type TMenuItemsFactoryProps = {
|
||||
isOwner: boolean;
|
||||
isAdmin: boolean;
|
||||
setDeleteViewModal: (open: boolean) => void;
|
||||
setCreateUpdateViewModal: (open: boolean) => void;
|
||||
handleOpenInNewTab: () => void;
|
||||
handleCopyText: () => void;
|
||||
isLocked: boolean;
|
||||
workspaceSlug: string;
|
||||
projectId?: string;
|
||||
viewId: string;
|
||||
};
|
||||
|
||||
export const useMenuItemsFactory = (props: TMenuItemsFactoryProps) => {
|
||||
const { isOwner, isAdmin, setDeleteViewModal, setCreateUpdateViewModal, handleOpenInNewTab, handleCopyText } = props;
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const editMenuItem = () => ({
|
||||
key: "edit",
|
||||
action: () => setCreateUpdateViewModal(true),
|
||||
title: t("edit"),
|
||||
icon: Pencil,
|
||||
shouldRender: isOwner,
|
||||
});
|
||||
|
||||
const openInNewTabMenuItem = () => ({
|
||||
key: "open-new-tab",
|
||||
action: handleOpenInNewTab,
|
||||
title: t("open_in_new_tab"),
|
||||
icon: ExternalLink,
|
||||
});
|
||||
|
||||
const copyLinkMenuItem = () => ({
|
||||
key: "copy-link",
|
||||
action: handleCopyText,
|
||||
title: t("copy_link"),
|
||||
icon: Link,
|
||||
});
|
||||
|
||||
const deleteMenuItem = () => ({
|
||||
key: "delete",
|
||||
action: () => setDeleteViewModal(true),
|
||||
title: t("delete"),
|
||||
icon: Trash2,
|
||||
shouldRender: isOwner || isAdmin,
|
||||
});
|
||||
|
||||
return {
|
||||
editMenuItem,
|
||||
openInNewTabMenuItem,
|
||||
copyLinkMenuItem,
|
||||
deleteMenuItem,
|
||||
};
|
||||
};
|
||||
|
||||
export const useViewMenuItems = (props: TMenuItemsFactoryProps): TContextMenuItem[] => {
|
||||
const factory = useMenuItemsFactory(props);
|
||||
|
||||
return [factory.editMenuItem(), factory.openInNewTabMenuItem(), factory.copyLinkMenuItem(), factory.deleteMenuItem()];
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
export const AdditionalHeaderItems = (view: IProjectView) => <></>;
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export * from "./project";
|
||||
export * from "./workspace.service";
|
||||
export * from "@/services/workspace.service";
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export * from "./estimate.service";
|
||||
export * from "./view.service";
|
||||
export * from "@/services/view.service";
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
import { API_BASE_URL } from "@plane/constants";
|
||||
import { EViewAccess, TPublishViewSettings } from "@plane/types";
|
||||
import { ViewService as CoreViewService } from "@/services/view.service";
|
||||
|
||||
export class ViewService extends CoreViewService {
|
||||
constructor() {
|
||||
super(API_BASE_URL);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
async updateViewAccess(workspaceSlug: string, projectId: string, viewId: string, access: EViewAccess) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
async lockView(workspaceSlug: string, projectId: string, viewId: string) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
async unLockView(workspaceSlug: string, projectId: string, viewId: string) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
async getPublishDetails(workspaceSlug: string, projectId: string, viewId: string): Promise<any> {
|
||||
return Promise.resolve({});
|
||||
}
|
||||
|
||||
async publishView(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
workspaceSlug: string,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
projectId: string,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
viewId: string,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
data: TPublishViewSettings
|
||||
): Promise<any> {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
async updatePublishedView(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
workspaceSlug: string,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
projectId: string,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
viewId: string,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
data: Partial<TPublishViewSettings>
|
||||
): Promise<void> {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
async unPublishView(workspaceSlug: string, projectId: string, viewId: string): Promise<void> {
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import { API_BASE_URL } from "@plane/constants";
|
||||
import { EViewAccess } from "@plane/types";
|
||||
import { WorkspaceService as CoreWorkspaceService } from "@/services/workspace.service";
|
||||
|
||||
export class WorkspaceService extends CoreWorkspaceService {
|
||||
constructor() {
|
||||
super(API_BASE_URL);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
async updateViewAccess(workspaceSlug: string, viewId: string, access: EViewAccess) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
async lockView(workspaceSlug: string, viewId: string) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
async unLockView(workspaceSlug: string, viewId: string) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "@/store/global-view.store";
|
||||
@@ -0,0 +1 @@
|
||||
export * from "@/store/project-view.store";
|
||||
@@ -19,7 +19,7 @@ const getInsightLabel = (
|
||||
analyticsType: TAnalyticsTabsBase,
|
||||
item: IInsightField,
|
||||
isEpic: boolean | undefined,
|
||||
t: (key: string, options?: any) => string
|
||||
t: (key: string, params?: Record<string, unknown>) => string
|
||||
) => {
|
||||
if (analyticsType === "work-items") {
|
||||
return isEpic
|
||||
@@ -50,15 +50,7 @@ const TotalInsights: React.FC<{
|
||||
const params = useParams();
|
||||
const workspaceSlug = params.workspaceSlug.toString();
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
selectedDuration,
|
||||
selectedProjects,
|
||||
selectedDurationLabel,
|
||||
selectedCycle,
|
||||
selectedModule,
|
||||
isPeekView,
|
||||
isEpic,
|
||||
} = useAnalytics();
|
||||
const { selectedDuration, selectedProjects, selectedCycle, selectedModule, isPeekView, isEpic } = useAnalytics();
|
||||
const { data: totalInsightsData, isLoading } = useSWR(
|
||||
`total-insights-${analyticsType}-${selectedDuration}-${selectedProjects}-${selectedCycle}-${selectedModule}-${isEpic}`,
|
||||
() =>
|
||||
|
||||
@@ -38,7 +38,7 @@ export const CommandPalette: FC = observer(() => {
|
||||
const { workspaceSlug, projectId: paramsProjectId, workItem } = useParams();
|
||||
// store hooks
|
||||
const { fetchIssueWithIdentifier } = useIssueDetail();
|
||||
const { toggleSidebar } = useAppTheme();
|
||||
const { toggleSidebar, toggleExtendedSidebar } = useAppTheme();
|
||||
const { platform } = usePlatformOS();
|
||||
const { data: currentUser, canPerformAnyCreateAction } = useUser();
|
||||
const { toggleCommandPaletteModal, isShortcutModalOpen, toggleShortcutModal, isAnyModalOpen } = useCommandPalette();
|
||||
@@ -197,6 +197,7 @@ export const CommandPalette: FC = observer(() => {
|
||||
} else if (keyPressed === "b") {
|
||||
e.preventDefault();
|
||||
toggleSidebar();
|
||||
toggleExtendedSidebar(false);
|
||||
}
|
||||
} else if (!isAnyModalOpen) {
|
||||
captureClick({ elementName: COMMAND_PALETTE_TRACKER_ELEMENTS.COMMAND_PALETTE_SHORTCUT_KEY });
|
||||
@@ -242,6 +243,7 @@ export const CommandPalette: FC = observer(() => {
|
||||
toggleCommandPaletteModal,
|
||||
toggleShortcutModal,
|
||||
toggleSidebar,
|
||||
toggleExtendedSidebar,
|
||||
workspaceSlug,
|
||||
]
|
||||
);
|
||||
|
||||
@@ -95,13 +95,11 @@ export const DateFilterModal: React.FC<Props> = ({ title, handleClose, isOpen, o
|
||||
const date2Value = getDate(watch("date2"));
|
||||
return (
|
||||
<Calendar
|
||||
classNames={{
|
||||
root: ` border border-custom-border-200 p-3 rounded-md`,
|
||||
}}
|
||||
className="rounded-md border border-custom-border-200 p-3"
|
||||
captionLayout="dropdown"
|
||||
selected={dateValue}
|
||||
defaultMonth={dateValue}
|
||||
onSelect={(date) => {
|
||||
onSelect={(date: Date | undefined) => {
|
||||
if (!date) return;
|
||||
onChange(date);
|
||||
}}
|
||||
@@ -120,13 +118,11 @@ export const DateFilterModal: React.FC<Props> = ({ title, handleClose, isOpen, o
|
||||
const date1Value = getDate(watch("date1"));
|
||||
return (
|
||||
<Calendar
|
||||
classNames={{
|
||||
root: ` border border-custom-border-200 p-3 rounded-md`,
|
||||
}}
|
||||
className="rounded-md border border-custom-border-200 p-3"
|
||||
captionLayout="dropdown"
|
||||
selected={dateValue}
|
||||
defaultMonth={dateValue}
|
||||
onSelect={(date) => {
|
||||
onSelect={(date: Date | undefined) => {
|
||||
if (!date) return;
|
||||
onChange(date);
|
||||
}}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { Placement } from "@popperjs/core";
|
||||
import { observer } from "mobx-react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { usePopper } from "react-popper";
|
||||
import { ArrowRight, CalendarCheck2, CalendarDays, X } from "lucide-react";
|
||||
import { Combobox } from "@headlessui/react";
|
||||
@@ -59,6 +60,8 @@ type Props = {
|
||||
renderPlaceholder?: boolean;
|
||||
customTooltipContent?: React.ReactNode;
|
||||
customTooltipHeading?: string;
|
||||
defaultOpen?: boolean;
|
||||
renderInPortal?: boolean;
|
||||
};
|
||||
|
||||
export const DateRangeDropdown: React.FC<Props> = observer((props) => {
|
||||
@@ -93,9 +96,11 @@ export const DateRangeDropdown: React.FC<Props> = observer((props) => {
|
||||
renderPlaceholder = true,
|
||||
customTooltipContent,
|
||||
customTooltipHeading,
|
||||
defaultOpen = false,
|
||||
renderInPortal = false,
|
||||
} = props;
|
||||
// states
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isOpen, setIsOpen] = useState(defaultOpen);
|
||||
const [dateRange, setDateRange] = useState<DateRange>(value);
|
||||
// hooks
|
||||
const { data } = useUserProfile();
|
||||
@@ -193,7 +198,9 @@ export const DateRangeDropdown: React.FC<Props> = observer((props) => {
|
||||
renderPlaceholder && (
|
||||
<>
|
||||
<span className="text-custom-text-400">{placeholder.from}</span>
|
||||
<ArrowRight className="h-3 w-3 flex-shrink-0 text-custom-text-400" />
|
||||
{placeholder.from && placeholder.to && (
|
||||
<ArrowRight className="h-3 w-3 flex-shrink-0 text-custom-text-400" />
|
||||
)}
|
||||
<span className="text-custom-text-400">{placeholder.to}</span>
|
||||
</>
|
||||
)
|
||||
@@ -247,6 +254,34 @@ export const DateRangeDropdown: React.FC<Props> = observer((props) => {
|
||||
</button>
|
||||
);
|
||||
|
||||
const comboOptions = (
|
||||
<Combobox.Options data-prevent-outside-click static>
|
||||
<div
|
||||
className="my-1 bg-custom-background-100 shadow-custom-shadow-rg border-[0.5px] border-custom-border-300 rounded-md overflow-hidden z-30"
|
||||
ref={setPopperElement}
|
||||
style={styles.popper}
|
||||
{...attributes.popper}
|
||||
>
|
||||
<Calendar
|
||||
className="rounded-md border border-custom-border-200 p-3"
|
||||
captionLayout="dropdown"
|
||||
selected={dateRange}
|
||||
onSelect={(val: DateRange | undefined) => {
|
||||
onSelect?.(val);
|
||||
}}
|
||||
mode="range"
|
||||
disabled={disabledDays}
|
||||
showOutsideDays
|
||||
fixedWeeks
|
||||
weekStartsOn={startOfWeek}
|
||||
initialFocus
|
||||
/>
|
||||
</div>
|
||||
</Combobox.Options>
|
||||
);
|
||||
|
||||
const Options = renderInPortal ? createPortal(comboOptions, document.body) : comboOptions;
|
||||
|
||||
return (
|
||||
<ComboDropDown
|
||||
as="div"
|
||||
@@ -262,31 +297,7 @@ export const DateRangeDropdown: React.FC<Props> = observer((props) => {
|
||||
disabled={disabled}
|
||||
renderByDefault={renderByDefault}
|
||||
>
|
||||
{isOpen && (
|
||||
<Combobox.Options className="fixed z-10" static>
|
||||
<div
|
||||
className="my-1 bg-custom-background-100 shadow-custom-shadow-rg border-[0.5px] border-custom-border-300 rounded-md overflow-hidden"
|
||||
ref={setPopperElement}
|
||||
style={styles.popper}
|
||||
{...attributes.popper}
|
||||
>
|
||||
<Calendar
|
||||
captionLayout="dropdown"
|
||||
classNames={{ root: `p-3 rounded-md` }}
|
||||
selected={dateRange}
|
||||
onSelect={(val) => {
|
||||
onSelect?.(val);
|
||||
}}
|
||||
mode="range"
|
||||
disabled={disabledDays}
|
||||
showOutsideDays
|
||||
fixedWeeks
|
||||
weekStartsOn={startOfWeek}
|
||||
initialFocus
|
||||
/>
|
||||
</div>
|
||||
</Combobox.Options>
|
||||
)}
|
||||
{isOpen && Options}
|
||||
</ComboDropDown>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import React, { useRef, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { createPortal } from "react-dom";
|
||||
@@ -21,6 +23,7 @@ import { TDropdownProps } from "./types";
|
||||
|
||||
type Props = TDropdownProps & {
|
||||
clearIconClassName?: string;
|
||||
defaultOpen?: boolean;
|
||||
optionsClassName?: string;
|
||||
icon?: React.ReactNode;
|
||||
isClearable?: boolean;
|
||||
@@ -41,6 +44,7 @@ export const DateDropdown: React.FC<Props> = observer((props) => {
|
||||
buttonVariant,
|
||||
className = "",
|
||||
clearIconClassName = "",
|
||||
defaultOpen = false,
|
||||
optionsClassName = "",
|
||||
closeOnSelect = true,
|
||||
disabled = false,
|
||||
@@ -60,7 +64,7 @@ export const DateDropdown: React.FC<Props> = observer((props) => {
|
||||
renderByDefault = true,
|
||||
} = props;
|
||||
// states
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isOpen, setIsOpen] = useState(defaultOpen);
|
||||
// refs
|
||||
const dropdownRef = useRef<HTMLDivElement | null>(null);
|
||||
// hooks
|
||||
@@ -178,11 +182,11 @@ export const DateDropdown: React.FC<Props> = observer((props) => {
|
||||
{...attributes.popper}
|
||||
>
|
||||
<Calendar
|
||||
className="rounded-md border border-custom-border-200 p-3"
|
||||
captionLayout="dropdown"
|
||||
classNames={{ root: `p-3 rounded-md` }}
|
||||
selected={getDate(value)}
|
||||
defaultMonth={getDate(value)}
|
||||
onSelect={(date) => {
|
||||
onSelect={(date: Date | undefined) => {
|
||||
dropdownOnChange(date ?? null);
|
||||
}}
|
||||
showOutsideDays
|
||||
|
||||
@@ -4,7 +4,7 @@ import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Image from "next/image";
|
||||
// ui
|
||||
import { Button } from "@plane/ui/src/button";
|
||||
import { Button } from "@plane/ui";
|
||||
// utils
|
||||
import { cn } from "@plane/utils";
|
||||
|
||||
|
||||
@@ -49,11 +49,11 @@ export const InboxIssueSnoozeModal: FC<InboxIssueSnoozeModalProps> = (props) =>
|
||||
<Dialog.Panel className="relative flex transform rounded-lg bg-custom-background-100 px-5 py-8 text-left shadow-custom-shadow-md transition-all sm:my-8 sm:w-full sm:max-w-2xl sm:p-6">
|
||||
<div className="flex h-full w-full flex-col gap-y-1">
|
||||
<Calendar
|
||||
className="rounded-md border border-custom-border-200 p-3"
|
||||
captionLayout="dropdown"
|
||||
classNames={{ root: `rounded-md border border-custom-border-200 p-3` }}
|
||||
selected={date ? new Date(date) : undefined}
|
||||
defaultMonth={date ? new Date(date) : undefined}
|
||||
onSelect={(date) => {
|
||||
onSelect={(date: Date | undefined) => {
|
||||
if (!date) return;
|
||||
setDate(date);
|
||||
}}
|
||||
|
||||
@@ -92,7 +92,6 @@ export const SingleUserSelect: React.FC<Props> = ({ collaborator, index, users,
|
||||
newUsers[index].email = "";
|
||||
setUsers(newUsers);
|
||||
}}
|
||||
optionsClassName="w-full"
|
||||
noChevron
|
||||
>
|
||||
{importOptions.map((option) => (
|
||||
|
||||
@@ -181,7 +181,6 @@ export const JiraGetImportDetail: React.FC = observer(() => {
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
optionsClassName="w-full"
|
||||
>
|
||||
{workspaceProjectIds && workspaceProjectIds.length > 0 ? (
|
||||
workspaceProjectIds.map((projectId) => {
|
||||
|
||||
@@ -96,7 +96,6 @@ export const JiraImportUsers: FC = () => {
|
||||
input
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
optionsClassName="w-full"
|
||||
label={<span className="capitalize">{Boolean(value) ? value : ("Ignore" as any)}</span>}
|
||||
>
|
||||
<CustomSelect.Option value="invite">Invite by email</CustomSelect.Option>
|
||||
|
||||
+1
-1
@@ -90,7 +90,7 @@ export const ProjectAppliedFiltersRoot: React.FC<TProjectAppliedFiltersRootProps
|
||||
/>
|
||||
</Header.LeftItem>
|
||||
<Header.RightItem>
|
||||
{isEditingAllowed && (
|
||||
{isEditingAllowed && storeType === EIssuesStoreType.PROJECT && (
|
||||
<SaveFilterView
|
||||
workspaceSlug={workspaceSlug?.toString()}
|
||||
projectId={projectId?.toString()}
|
||||
|
||||
@@ -268,7 +268,6 @@ export const CreateWorkspace: React.FC<Props> = observer((props) => {
|
||||
}
|
||||
buttonClassName="!border-[0.5px] !border-custom-border-300 !shadow-none !rounded-md"
|
||||
input
|
||||
optionsClassName="w-full"
|
||||
>
|
||||
{ORGANIZATION_SIZE.map((item) => (
|
||||
<CustomSelect.Option key={item} value={item}>
|
||||
|
||||
@@ -126,7 +126,6 @@ export const LanguageTimezone = observer(() => {
|
||||
onChange={handleLanguageChange}
|
||||
buttonClassName={"border-none"}
|
||||
className="rounded-md border !border-custom-border-200"
|
||||
optionsClassName="w-full"
|
||||
input
|
||||
>
|
||||
{SUPPORTED_LANGUAGES.map((item) => (
|
||||
|
||||
@@ -293,7 +293,6 @@ export const SendProjectInvitationModal: React.FC<Props> = observer((props) => {
|
||||
</div>
|
||||
}
|
||||
input
|
||||
optionsClassName="w-full"
|
||||
>
|
||||
{Object.entries(
|
||||
checkCurrentOptionWorkspaceRole(watch(`members.${index}.member_id`))
|
||||
|
||||
@@ -168,7 +168,6 @@ export const AccountTypeColumn: React.FC<AccountTypeProps> = observer((props) =>
|
||||
}
|
||||
buttonClassName={`!px-0 !justify-start hover:bg-custom-background-100 ${errors.role ? "border-red-500" : "border-none"}`}
|
||||
className="rounded-md p-0 w-32"
|
||||
optionsClassName="w-full"
|
||||
input
|
||||
>
|
||||
{Object.entries(checkCurrentOptionWorkspaceRole(rowData.member.id)).map(([key, label]) => (
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { ListFilter } from "lucide-react";
|
||||
// plane imports
|
||||
import { IFilterInstance } from "@plane/shared-state";
|
||||
import { LOGICAL_OPERATOR, TExternalFilter, TFilterProperty } from "@plane/types";
|
||||
import { CustomSearchSelect, getButtonStyling, TButtonVariant } from "@plane/ui";
|
||||
import { cn, getOperatorForPayload } from "@plane/utils";
|
||||
|
||||
export type TAddFilterButtonProps<P extends TFilterProperty, E extends TExternalFilter> = {
|
||||
buttonConfig?: {
|
||||
label?: string;
|
||||
variant?: TButtonVariant;
|
||||
className?: string;
|
||||
defaultOpen?: boolean;
|
||||
iconConfig?: {
|
||||
shouldShowIcon: boolean;
|
||||
iconComponent?: React.ReactNode;
|
||||
};
|
||||
isDisabled?: boolean;
|
||||
};
|
||||
filter: IFilterInstance<P, E>;
|
||||
onFilterSelect?: (id: string) => void;
|
||||
};
|
||||
|
||||
export const AddFilterButton = observer(
|
||||
<P extends TFilterProperty, E extends TExternalFilter>(props: TAddFilterButtonProps<P, E>) => {
|
||||
const { filter, buttonConfig, onFilterSelect } = props;
|
||||
const {
|
||||
label = "Filters",
|
||||
variant = "link-neutral",
|
||||
className,
|
||||
defaultOpen = false,
|
||||
iconConfig = { shouldShowIcon: true },
|
||||
isDisabled = false,
|
||||
} = buttonConfig || {};
|
||||
|
||||
// Transform available filter configs to CustomSearchSelect options format
|
||||
const filterOptions = filter.configManager.allAvailableConfigs.map((config) => ({
|
||||
value: config.id,
|
||||
content: (
|
||||
<div className="flex items-center gap-2 text-custom-text-200 transition-all duration-200 ease-in-out">
|
||||
{config.icon && (
|
||||
<config.icon className="size-4 text-custom-text-300 transition-transform duration-200 ease-in-out" />
|
||||
)}
|
||||
<span>{config.label}</span>
|
||||
</div>
|
||||
),
|
||||
query: config.label.toLowerCase(),
|
||||
}));
|
||||
|
||||
// If all filters are applied, show disabled options
|
||||
const allFiltersApplied = filterOptions.length === 0;
|
||||
const displayOptions = allFiltersApplied
|
||||
? [
|
||||
{
|
||||
value: "all_filters_applied",
|
||||
content: <div className="text-custom-text-400 italic">All filters applied</div>,
|
||||
query: "all filters applied",
|
||||
disabled: true,
|
||||
},
|
||||
]
|
||||
: filterOptions;
|
||||
|
||||
const handleFilterSelect = (property: P) => {
|
||||
const config = filter.configManager.getConfigByProperty(property);
|
||||
if (config && config.firstOperator) {
|
||||
const { operator, isNegation } = getOperatorForPayload(config.firstOperator);
|
||||
filter.addCondition(
|
||||
LOGICAL_OPERATOR.AND,
|
||||
{
|
||||
property: config.id,
|
||||
operator,
|
||||
value: undefined,
|
||||
},
|
||||
isNegation
|
||||
);
|
||||
onFilterSelect?.(property);
|
||||
}
|
||||
};
|
||||
|
||||
if (isDisabled) return null;
|
||||
return (
|
||||
<div className="relative transition-all duration-200 ease-in-out">
|
||||
<CustomSearchSelect
|
||||
defaultOpen={defaultOpen}
|
||||
value={""}
|
||||
onChange={handleFilterSelect}
|
||||
options={displayOptions}
|
||||
optionsClassName="w-56"
|
||||
maxHeight="full"
|
||||
placement="bottom-start"
|
||||
disabled={isDisabled}
|
||||
customButtonClassName={cn(getButtonStyling(variant, "sm"), className)}
|
||||
customButton={
|
||||
<div className="flex items-center gap-1">
|
||||
{iconConfig.shouldShowIcon &&
|
||||
(iconConfig.iconComponent || <ListFilter className="size-4 text-custom-text-200" />)}
|
||||
{label}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,161 @@
|
||||
import React, { useRef, useEffect } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { X } from "lucide-react";
|
||||
// plane imports
|
||||
import { IFilterInstance } from "@plane/shared-state";
|
||||
import {
|
||||
SingleOrArray,
|
||||
TExternalFilter,
|
||||
TFilterProperty,
|
||||
TFilterValue,
|
||||
TFilterConditionNodeForDisplay,
|
||||
TAllAvailableOperatorsForDisplay,
|
||||
} from "@plane/types";
|
||||
import { CustomSearchSelect } from "@plane/ui";
|
||||
import { cn, hasValidValue, getOperatorForPayload } from "@plane/utils";
|
||||
// local imports
|
||||
import { FilterValueInput } from "./filter-value-input/root";
|
||||
import { COMMON_FILTER_ITEM_BORDER_CLASSNAME } from "./shared";
|
||||
|
||||
interface FilterItemProps<P extends TFilterProperty, E extends TExternalFilter> {
|
||||
condition: TFilterConditionNodeForDisplay<P, TFilterValue>;
|
||||
filter: IFilterInstance<P, E>;
|
||||
isDisabled?: boolean;
|
||||
showTransition?: boolean;
|
||||
}
|
||||
|
||||
export const FilterItem = observer(
|
||||
<P extends TFilterProperty, E extends TExternalFilter>(props: FilterItemProps<P, E>) => {
|
||||
const { condition, filter, isDisabled = false, showTransition = true } = props;
|
||||
// refs
|
||||
const itemRef = useRef<HTMLDivElement>(null);
|
||||
// derived values
|
||||
const filterConfig = condition?.property ? filter.configManager.getConfigByProperty(condition.property) : undefined;
|
||||
const operatorOptions = filterConfig
|
||||
?.getAllDisplayOperatorOptionsByValue(condition.value as TFilterValue)
|
||||
.map((option) => ({
|
||||
value: option.value,
|
||||
content: option.label,
|
||||
query: option.label.toLowerCase(),
|
||||
}));
|
||||
const selectedOperatorFieldConfig = filterConfig?.getOperatorConfig(condition.operator);
|
||||
const selectedOperatorOption = filterConfig?.getDisplayOperatorByValue(
|
||||
condition.operator,
|
||||
condition.value as TFilterValue
|
||||
);
|
||||
// Disable operator selection when filter is disabled or only one operator option is available and selected
|
||||
const isOperatorSelectionDisabled =
|
||||
isDisabled ||
|
||||
(condition.operator && operatorOptions?.length === 1 && operatorOptions[0]?.value === condition.operator);
|
||||
|
||||
// effects
|
||||
useEffect(() => {
|
||||
if (!showTransition) return;
|
||||
|
||||
const element = itemRef.current;
|
||||
if (!element) return;
|
||||
|
||||
if (hasValidValue(condition.value)) return;
|
||||
|
||||
const applyInitialStyles = () => {
|
||||
element.style.opacity = "0";
|
||||
element.style.transform = "scale(0.95)";
|
||||
};
|
||||
|
||||
const applyFinalStyles = () => {
|
||||
// Force a reflow to ensure the initial state is applied
|
||||
void element.offsetWidth;
|
||||
element.style.opacity = "1";
|
||||
element.style.transform = "scale(1)";
|
||||
};
|
||||
|
||||
applyInitialStyles();
|
||||
applyFinalStyles();
|
||||
|
||||
return () => {
|
||||
applyInitialStyles();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const handleOperatorChange = (operator: TAllAvailableOperatorsForDisplay) => {
|
||||
if (operator) {
|
||||
const { operator: positiveOperator, isNegation } = getOperatorForPayload(operator);
|
||||
filter.updateConditionOperator(condition.id, positiveOperator, isNegation);
|
||||
}
|
||||
};
|
||||
|
||||
const handleValueChange = (values: SingleOrArray<TFilterValue>) => {
|
||||
filter.updateConditionValue(condition.id, values);
|
||||
};
|
||||
|
||||
const handleRemoveFilter = () => {
|
||||
filter.removeCondition(condition.id);
|
||||
};
|
||||
|
||||
if (!filterConfig || !filterConfig.isEnabled) return null;
|
||||
return (
|
||||
<div
|
||||
ref={itemRef}
|
||||
className="flex h-7 items-stretch rounded overflow-hidden border border-custom-border-200 bg-custom-background-100 transition-all duration-200"
|
||||
>
|
||||
{/* Property section */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-1 px-2 py-0.5 text-xs text-custom-text-300 min-w-0",
|
||||
COMMON_FILTER_ITEM_BORDER_CLASSNAME
|
||||
)}
|
||||
>
|
||||
{filterConfig.icon && (
|
||||
<div className="transition-transform duration-200 ease-in-out flex-shrink-0">
|
||||
<filterConfig.icon className="size-3.5" />
|
||||
</div>
|
||||
)}
|
||||
<span className="truncate">{filterConfig.label}</span>
|
||||
</div>
|
||||
|
||||
{/* Operator section */}
|
||||
<CustomSearchSelect
|
||||
value={condition.operator}
|
||||
onChange={handleOperatorChange}
|
||||
options={operatorOptions}
|
||||
className={COMMON_FILTER_ITEM_BORDER_CLASSNAME}
|
||||
customButtonClassName={cn(
|
||||
"h-full px-2 text-sm font-normal",
|
||||
isOperatorSelectionDisabled && "hover:bg-custom-background-100"
|
||||
)}
|
||||
optionsClassName="w-48"
|
||||
maxHeight="full"
|
||||
disabled={isOperatorSelectionDisabled}
|
||||
customButton={
|
||||
<div className="flex items-center h-full" aria-disabled={isOperatorSelectionDisabled}>
|
||||
{filterConfig.getLabelForOperator(selectedOperatorOption)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Value section */}
|
||||
{selectedOperatorFieldConfig && (
|
||||
<FilterValueInput
|
||||
filterFieldConfig={selectedOperatorFieldConfig}
|
||||
condition={condition}
|
||||
onChange={handleValueChange}
|
||||
isDisabled={isDisabled}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Remove button */}
|
||||
{!isDisabled && (
|
||||
<button
|
||||
onClick={handleRemoveFilter}
|
||||
className="px-1.5 text-custom-text-400 hover:text-custom-text-300 focus:outline-none hover:bg-custom-background-90"
|
||||
type="button"
|
||||
aria-label="Remove filter"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,59 @@
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { TDateRangeFilterFieldConfig, TFilterConditionNodeForDisplay, TFilterProperty } from "@plane/types";
|
||||
import { cn, isValidDate, renderFormattedPayloadDate, toFilterArray } from "@plane/utils";
|
||||
// components
|
||||
import { DateRangeDropdown } from "@/components/dropdowns/date-range";
|
||||
// local imports
|
||||
import { COMMON_FILTER_ITEM_BORDER_CLASSNAME } from "../../shared";
|
||||
|
||||
type TDateRangeFilterValueInputProps<P extends TFilterProperty> = {
|
||||
config: TDateRangeFilterFieldConfig<string>;
|
||||
condition: TFilterConditionNodeForDisplay<P, string>;
|
||||
isDisabled?: boolean;
|
||||
onChange: (value: string[]) => void;
|
||||
};
|
||||
|
||||
export const DateRangeFilterValueInput = observer(
|
||||
<P extends TFilterProperty>(props: TDateRangeFilterValueInputProps<P>) => {
|
||||
const { config, condition, isDisabled, onChange } = props;
|
||||
// derived values
|
||||
const [fromRaw, toRaw] = toFilterArray(condition.value) ?? [];
|
||||
const from = isValidDate(fromRaw) ? new Date(fromRaw) : undefined;
|
||||
const to = isValidDate(toRaw) ? new Date(toRaw) : undefined;
|
||||
const isIncomplete = !from || !to;
|
||||
|
||||
// Handler for date range selection
|
||||
const handleSelect = (range: { from?: Date; to?: Date } | undefined) => {
|
||||
const formattedFrom = range?.from ? renderFormattedPayloadDate(range.from) : undefined;
|
||||
const formattedTo = range?.to ? renderFormattedPayloadDate(range.to) : undefined;
|
||||
if (formattedFrom && formattedTo) {
|
||||
onChange([formattedFrom, formattedTo]);
|
||||
} else {
|
||||
onChange([]);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DateRangeDropdown
|
||||
value={{ from, to }}
|
||||
onSelect={handleSelect}
|
||||
minDate={config.min}
|
||||
maxDate={config.max}
|
||||
mergeDates
|
||||
placeholder={{ from: "--" }}
|
||||
buttonVariant="transparent-with-text"
|
||||
buttonClassName={cn("rounded-none", {
|
||||
[COMMON_FILTER_ITEM_BORDER_CLASSNAME]: !isDisabled,
|
||||
"text-red-500": isIncomplete,
|
||||
"hover:bg-custom-background-100": isDisabled,
|
||||
})}
|
||||
renderPlaceholder
|
||||
renderInPortal
|
||||
defaultOpen={isIncomplete}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,46 @@
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { TDateFilterFieldConfig, TFilterConditionNodeForDisplay, TFilterProperty } from "@plane/types";
|
||||
import { cn, renderFormattedPayloadDate } from "@plane/utils";
|
||||
import { DateDropdown } from "@/components/dropdowns/date";
|
||||
import { COMMON_FILTER_ITEM_BORDER_CLASSNAME } from "../../shared";
|
||||
|
||||
type TSingleDateFilterValueInputProps<P extends TFilterProperty> = {
|
||||
config: TDateFilterFieldConfig<string>;
|
||||
condition: TFilterConditionNodeForDisplay<P, string>;
|
||||
isDisabled?: boolean;
|
||||
onChange: (value: string | null | undefined) => void;
|
||||
};
|
||||
|
||||
export const SingleDateFilterValueInput = observer(
|
||||
<P extends TFilterProperty>(props: TSingleDateFilterValueInputProps<P>) => {
|
||||
const { config, condition, isDisabled, onChange } = props;
|
||||
// derived values
|
||||
const conditionValue = typeof condition.value === "string" ? condition.value : null;
|
||||
|
||||
return (
|
||||
<DateDropdown
|
||||
value={conditionValue}
|
||||
onChange={(value: Date | null) => {
|
||||
const formattedDate = value ? renderFormattedPayloadDate(value) : null;
|
||||
onChange(formattedDate);
|
||||
}}
|
||||
buttonClassName={cn("rounded-none", {
|
||||
[COMMON_FILTER_ITEM_BORDER_CLASSNAME]: !isDisabled,
|
||||
"text-custom-text-400": !conditionValue,
|
||||
"hover:bg-custom-background-100": isDisabled,
|
||||
})}
|
||||
minDate={config.min}
|
||||
maxDate={config.max}
|
||||
icon={null}
|
||||
placeholder="--"
|
||||
buttonVariant="transparent-with-text"
|
||||
isClearable={false}
|
||||
closeOnSelect
|
||||
defaultOpen={!conditionValue}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,90 @@
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import {
|
||||
FILTER_FIELD_TYPE,
|
||||
TFilterConditionNode,
|
||||
TFilterValue,
|
||||
TFilterProperty,
|
||||
SingleOrArray,
|
||||
TSingleSelectFilterFieldConfig,
|
||||
TMultiSelectFilterFieldConfig,
|
||||
TDateFilterFieldConfig,
|
||||
TDateRangeFilterFieldConfig,
|
||||
TSupportedFilterFieldConfigs,
|
||||
TFilterConditionNodeForDisplay,
|
||||
} from "@plane/types";
|
||||
// local imports
|
||||
import { DateRangeFilterValueInput } from "./date/range";
|
||||
import { SingleDateFilterValueInput } from "./date/single";
|
||||
import { MultiSelectFilterValueInput } from "./select/multi";
|
||||
import { SingleSelectFilterValueInput } from "./select/single";
|
||||
|
||||
type TFilterValueInputProps<P extends TFilterProperty, V extends TFilterValue> = {
|
||||
condition: TFilterConditionNodeForDisplay<P, V>;
|
||||
filterFieldConfig: TSupportedFilterFieldConfigs<V>;
|
||||
isDisabled?: boolean;
|
||||
onChange: (values: SingleOrArray<V>) => void;
|
||||
};
|
||||
|
||||
// TODO: Prevent type assertion
|
||||
export const FilterValueInput = observer(
|
||||
<P extends TFilterProperty, V extends TFilterValue>(props: TFilterValueInputProps<P, V>) => {
|
||||
const { condition, filterFieldConfig, isDisabled = false, onChange } = props;
|
||||
|
||||
// Single select input
|
||||
if (filterFieldConfig?.type === FILTER_FIELD_TYPE.SINGLE_SELECT) {
|
||||
return (
|
||||
<SingleSelectFilterValueInput<P>
|
||||
config={filterFieldConfig as TSingleSelectFilterFieldConfig<string>}
|
||||
condition={condition as TFilterConditionNodeForDisplay<P, string>}
|
||||
isDisabled={isDisabled}
|
||||
onChange={(value) => onChange(value as SingleOrArray<V>)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Multi select input
|
||||
if (filterFieldConfig?.type === FILTER_FIELD_TYPE.MULTI_SELECT) {
|
||||
return (
|
||||
<MultiSelectFilterValueInput<P>
|
||||
config={filterFieldConfig as TMultiSelectFilterFieldConfig<string>}
|
||||
condition={condition as TFilterConditionNode<P, string>}
|
||||
isDisabled={isDisabled}
|
||||
onChange={(value) => onChange(value as SingleOrArray<V>)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Date filter input
|
||||
if (filterFieldConfig?.type === FILTER_FIELD_TYPE.DATE) {
|
||||
return (
|
||||
<SingleDateFilterValueInput<P>
|
||||
config={filterFieldConfig as TDateFilterFieldConfig<string>}
|
||||
condition={condition as TFilterConditionNodeForDisplay<P, string>}
|
||||
isDisabled={isDisabled}
|
||||
onChange={(value) => onChange(value as SingleOrArray<V>)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Date range filter input
|
||||
if (filterFieldConfig?.type === FILTER_FIELD_TYPE.DATE_RANGE) {
|
||||
return (
|
||||
<DateRangeFilterValueInput<P>
|
||||
config={filterFieldConfig as TDateRangeFilterFieldConfig<string>}
|
||||
condition={condition as TFilterConditionNodeForDisplay<P, string>}
|
||||
isDisabled={isDisabled}
|
||||
onChange={(value) => onChange(value as SingleOrArray<V>)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback
|
||||
return (
|
||||
<div className="h-full flex items-center px-4 text-xs text-custom-text-400 transition-opacity duration-200 cursor-not-allowed">
|
||||
Filter type not supported
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,54 @@
|
||||
import React, { useState, useEffect, useMemo } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import {
|
||||
SingleOrArray,
|
||||
IFilterOption,
|
||||
TFilterProperty,
|
||||
TMultiSelectFilterFieldConfig,
|
||||
TFilterConditionNodeForDisplay,
|
||||
} from "@plane/types";
|
||||
import { CustomSearchSelect } from "@plane/ui";
|
||||
import { toFilterArray, getFilterValueLength } from "@plane/utils";
|
||||
// local imports
|
||||
import { SelectedOptionsDisplay } from "./selected-options-display";
|
||||
import { getCommonCustomSearchSelectProps, getFormattedOptions, loadOptions } from "./shared";
|
||||
|
||||
type TMultiSelectFilterValueInputProps<P extends TFilterProperty> = {
|
||||
config: TMultiSelectFilterFieldConfig<string>;
|
||||
condition: TFilterConditionNodeForDisplay<P, string>;
|
||||
isDisabled?: boolean;
|
||||
onChange: (values: SingleOrArray<string>) => void;
|
||||
};
|
||||
|
||||
export const MultiSelectFilterValueInput = observer(
|
||||
<P extends TFilterProperty>(props: TMultiSelectFilterValueInputProps<P>) => {
|
||||
const { config, condition, isDisabled, onChange } = props;
|
||||
// states
|
||||
const [options, setOptions] = useState<IFilterOption<string>[]>([]);
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
// derived values
|
||||
const formattedOptions = useMemo(() => getFormattedOptions<string>(options), [options]);
|
||||
|
||||
useEffect(() => {
|
||||
loadOptions({ config, setOptions, setLoading });
|
||||
}, [config]);
|
||||
|
||||
const handleSelectChange = (values: string[]) => {
|
||||
onChange(values);
|
||||
};
|
||||
|
||||
return (
|
||||
<CustomSearchSelect
|
||||
{...getCommonCustomSearchSelectProps(isDisabled)}
|
||||
value={toFilterArray(condition.value)}
|
||||
onChange={handleSelectChange}
|
||||
options={formattedOptions}
|
||||
multiple
|
||||
disabled={loading || isDisabled}
|
||||
customButton={<SelectedOptionsDisplay<string> selectedValue={condition.value} options={options} />}
|
||||
defaultOpen={getFilterValueLength(condition.value) === 0}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
import React from "react";
|
||||
import { Transition } from "@headlessui/react";
|
||||
// plane imports
|
||||
import { SingleOrArray, IFilterOption, TFilterValue } from "@plane/types";
|
||||
import { cn, toFilterArray } from "@plane/utils";
|
||||
|
||||
type TSelectedOptionsDisplayProps<V extends TFilterValue> = {
|
||||
selectedValue: SingleOrArray<V>;
|
||||
options: IFilterOption<V>[];
|
||||
displayCount?: number;
|
||||
emptyValue?: string;
|
||||
fallbackText?: string;
|
||||
};
|
||||
|
||||
export const SelectedOptionsDisplay = <V extends TFilterValue>(props: TSelectedOptionsDisplayProps<V>) => {
|
||||
const { selectedValue, options, displayCount = 2, emptyValue = "--", fallbackText } = props;
|
||||
// derived values
|
||||
const selectedArray = toFilterArray(selectedValue);
|
||||
const remainingCount = selectedArray.length - displayCount;
|
||||
const selectedOptions = selectedArray
|
||||
.map((value) => options.find((opt) => opt.value === value))
|
||||
.filter(Boolean) as IFilterOption<V>[];
|
||||
|
||||
// When no value is selected, display the empty value
|
||||
if (selectedArray.length === 0) {
|
||||
return <span className="text-custom-text-400">{emptyValue}</span>;
|
||||
}
|
||||
|
||||
// When no options are found but we have a fallback text
|
||||
if (options.length === 0) {
|
||||
return <span className="text-custom-text-400">{fallbackText ?? `${selectedArray.length} option(s) selected`}</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center h-full overflow-hidden">
|
||||
{selectedOptions.slice(0, displayCount).map((option, index) => (
|
||||
<React.Fragment key={index}>
|
||||
<div className="flex items-center whitespace-nowrap">
|
||||
{option?.icon && <span className={cn("mr-1", option.iconClassName)}>{option.icon}</span>}
|
||||
<span className="truncate max-w-24">{option?.label}</span>
|
||||
</div>
|
||||
{index < Math.min(displayCount, selectedOptions.length) - 1 && (
|
||||
<span className="text-custom-text-300 mx-1">,</span>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
{remainingCount > 0 && (
|
||||
<Transition
|
||||
show
|
||||
appear
|
||||
enter="transition-opacity duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
className="text-custom-text-300 whitespace-nowrap ml-1"
|
||||
>
|
||||
+{remainingCount} more
|
||||
</Transition>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
// plane imports
|
||||
import { TSupportedFilterFieldConfigs, IFilterOption, TFilterValue } from "@plane/types";
|
||||
import { cn } from "@plane/utils";
|
||||
// local imports
|
||||
import { COMMON_FILTER_ITEM_BORDER_CLASSNAME } from "../../shared";
|
||||
|
||||
type TLoadOptionsProps<V extends TFilterValue> = {
|
||||
config: TSupportedFilterFieldConfigs<V>;
|
||||
setOptions: (options: IFilterOption<V>[]) => void;
|
||||
setLoading?: (loading: boolean) => void;
|
||||
};
|
||||
|
||||
export const loadOptions = async <V extends TFilterValue>(props: TLoadOptionsProps<V>) => {
|
||||
const { config, setOptions, setLoading } = props;
|
||||
|
||||
// if the config has a getOptions function, load the options
|
||||
if ("getOptions" in config && typeof config.getOptions === "function") {
|
||||
setLoading?.(true);
|
||||
try {
|
||||
const result = await config.getOptions();
|
||||
setOptions(result);
|
||||
} catch (error) {
|
||||
console.error("Failed to load options:", error);
|
||||
} finally {
|
||||
setLoading?.(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const getFormattedOptions = <V extends TFilterValue>(options: IFilterOption<V>[]) =>
|
||||
options.map((option) => ({
|
||||
value: option.value,
|
||||
content: (
|
||||
<div className="flex items-center gap-2 transition-all duration-200 ease-in-out">
|
||||
{option.icon && (
|
||||
<span className={cn("transition-transform duration-200", option.iconClassName)}>{option.icon}</span>
|
||||
)}
|
||||
<span>{option.label}</span>
|
||||
</div>
|
||||
),
|
||||
query: option.label.toString().toLowerCase(),
|
||||
disabled: option.disabled,
|
||||
tooltip: option.description,
|
||||
}));
|
||||
|
||||
export const getCommonCustomSearchSelectProps = (isDisabled?: boolean) => ({
|
||||
customButtonClassName: cn(
|
||||
"h-full w-full px-2 text-sm font-normal transition-all duration-300 ease-in-out",
|
||||
!isDisabled && COMMON_FILTER_ITEM_BORDER_CLASSNAME,
|
||||
isDisabled && "hover:bg-custom-background-100"
|
||||
),
|
||||
optionsClassName: "w-56",
|
||||
maxHeight: "md" as const,
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import React, { useState, useEffect, useMemo } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import {
|
||||
IFilterOption,
|
||||
TFilterProperty,
|
||||
TSingleSelectFilterFieldConfig,
|
||||
TFilterConditionNodeForDisplay,
|
||||
} from "@plane/types";
|
||||
import { CustomSearchSelect } from "@plane/ui";
|
||||
// local imports
|
||||
import { SelectedOptionsDisplay } from "./selected-options-display";
|
||||
import { getCommonCustomSearchSelectProps, getFormattedOptions, loadOptions } from "./shared";
|
||||
|
||||
type TSingleSelectFilterValueInputProps<P extends TFilterProperty> = {
|
||||
config: TSingleSelectFilterFieldConfig<string>;
|
||||
condition: TFilterConditionNodeForDisplay<P, string>;
|
||||
isDisabled?: boolean;
|
||||
onChange: (value: string | null) => void;
|
||||
};
|
||||
|
||||
export const SingleSelectFilterValueInput = observer(
|
||||
<P extends TFilterProperty>(props: TSingleSelectFilterValueInputProps<P>) => {
|
||||
const { config, condition, onChange, isDisabled } = props;
|
||||
// states
|
||||
const [options, setOptions] = useState<IFilterOption<string>[]>([]);
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
// derived values
|
||||
const formattedOptions = useMemo(() => getFormattedOptions<string>(options), [options]);
|
||||
|
||||
useEffect(() => {
|
||||
loadOptions({ config, setOptions, setLoading });
|
||||
}, [config]);
|
||||
|
||||
const handleSelectChange = (value: string) => {
|
||||
if (value === condition.value) {
|
||||
onChange(null);
|
||||
} else {
|
||||
onChange(value);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<CustomSearchSelect
|
||||
{...getCommonCustomSearchSelectProps(isDisabled)}
|
||||
value={condition.value}
|
||||
onChange={handleSelectChange}
|
||||
options={formattedOptions}
|
||||
multiple={false}
|
||||
disabled={loading || isDisabled}
|
||||
customButton={
|
||||
<SelectedOptionsDisplay<string> selectedValue={condition.value} options={options} displayCount={1} />
|
||||
}
|
||||
defaultOpen={!condition.value}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,185 @@
|
||||
import React, { useCallback, useMemo, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Transition } from "@headlessui/react";
|
||||
// plane imports
|
||||
import { IFilterInstance } from "@plane/shared-state";
|
||||
import { TExternalFilter, TFilterProperty } from "@plane/types";
|
||||
import { Button, EHeaderVariant, Header } from "@plane/ui";
|
||||
// local imports
|
||||
import { AddFilterButton, TAddFilterButtonProps } from "./add-filters-button";
|
||||
import { FilterItem } from "./filter-item";
|
||||
|
||||
export type TFiltersRowProps<K extends TFilterProperty, E extends TExternalFilter> = {
|
||||
buttonConfig?: TAddFilterButtonProps<K, E>["buttonConfig"];
|
||||
disabledAllOperations?: boolean;
|
||||
filter: IFilterInstance<K, E>;
|
||||
variant?: "default" | "header";
|
||||
visible?: boolean;
|
||||
maxVisibleConditions?: number;
|
||||
trackerElements?: {
|
||||
clearFilter?: string;
|
||||
saveView?: string;
|
||||
updateView?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export const FiltersRow = observer(
|
||||
<K extends TFilterProperty, E extends TExternalFilter>(props: TFiltersRowProps<K, E>) => {
|
||||
const {
|
||||
buttonConfig,
|
||||
disabledAllOperations = false,
|
||||
filter,
|
||||
variant = "header",
|
||||
visible = true,
|
||||
maxVisibleConditions = 3,
|
||||
trackerElements,
|
||||
} = props;
|
||||
// states
|
||||
const [showAllConditions, setShowAllConditions] = useState(false);
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
// derived values
|
||||
const visibleConditions = useMemo(() => {
|
||||
if (variant === "default" || !maxVisibleConditions || showAllConditions) {
|
||||
return filter.allConditionsForDisplay;
|
||||
}
|
||||
return filter.allConditionsForDisplay.slice(0, maxVisibleConditions);
|
||||
}, [filter.allConditionsForDisplay, maxVisibleConditions, showAllConditions, variant]);
|
||||
const hiddenConditionsCount = useMemo(() => {
|
||||
if (variant === "default" || !maxVisibleConditions || showAllConditions) {
|
||||
return 0;
|
||||
}
|
||||
return Math.max(0, filter.allConditionsForDisplay.length - maxVisibleConditions);
|
||||
}, [filter.allConditionsForDisplay.length, maxVisibleConditions, showAllConditions, variant]);
|
||||
|
||||
const handleUpdate = useCallback(async () => {
|
||||
setIsUpdating(true);
|
||||
await filter.updateView();
|
||||
setTimeout(() => setIsUpdating(false), 240); // To avoid flickering
|
||||
}, [filter]);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
const leftContent = (
|
||||
<>
|
||||
<AddFilterButton
|
||||
filter={filter}
|
||||
buttonConfig={{
|
||||
...buttonConfig,
|
||||
isDisabled: disabledAllOperations,
|
||||
}}
|
||||
onFilterSelect={() => {
|
||||
if (variant === "header") {
|
||||
setShowAllConditions(true);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{visibleConditions.map((condition) => (
|
||||
<FilterItem key={condition.id} filter={filter} condition={condition} isDisabled={disabledAllOperations} />
|
||||
))}
|
||||
{variant === "header" && hiddenConditionsCount > 0 && (
|
||||
<Button
|
||||
variant="neutral-primary"
|
||||
size="sm"
|
||||
className={COMMON_VISIBILITY_BUTTON_CLASSNAME}
|
||||
onClick={() => setShowAllConditions(true)}
|
||||
>
|
||||
+{hiddenConditionsCount} more
|
||||
</Button>
|
||||
)}
|
||||
{variant === "header" &&
|
||||
showAllConditions &&
|
||||
maxVisibleConditions &&
|
||||
filter.allConditionsForDisplay.length > maxVisibleConditions && (
|
||||
<Button
|
||||
variant="neutral-primary"
|
||||
size="sm"
|
||||
className={COMMON_VISIBILITY_BUTTON_CLASSNAME}
|
||||
onClick={() => setShowAllConditions(false)}
|
||||
>
|
||||
Show less
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
const rightContent = !disabledAllOperations && (
|
||||
<>
|
||||
<ElementTransition show={filter.canClearFilters}>
|
||||
<Button
|
||||
variant="neutral-primary"
|
||||
size="sm"
|
||||
className={COMMON_OPERATION_BUTTON_CLASSNAME}
|
||||
onClick={filter.clearFilters}
|
||||
data-ph-element={trackerElements?.clearFilter}
|
||||
>
|
||||
{filter.clearFilterOptions?.label ?? "Clear all"}
|
||||
</Button>
|
||||
</ElementTransition>
|
||||
<ElementTransition show={filter.canSaveView}>
|
||||
<Button
|
||||
variant="accent-primary"
|
||||
size="sm"
|
||||
className={COMMON_OPERATION_BUTTON_CLASSNAME}
|
||||
onClick={filter.saveView}
|
||||
data-ph-element={trackerElements?.saveView}
|
||||
>
|
||||
{filter.saveViewOptions?.label ?? "Save view"}
|
||||
</Button>
|
||||
</ElementTransition>
|
||||
<ElementTransition show={filter.canUpdateView}>
|
||||
<Button
|
||||
variant="accent-primary"
|
||||
size="sm"
|
||||
className={COMMON_OPERATION_BUTTON_CLASSNAME}
|
||||
onClick={handleUpdate}
|
||||
loading={isUpdating}
|
||||
disabled={isUpdating}
|
||||
data-ph-element={trackerElements?.updateView}
|
||||
>
|
||||
{isUpdating ? "Confirming" : (filter.updateViewOptions?.label ?? "Update view")}
|
||||
</Button>
|
||||
</ElementTransition>
|
||||
</>
|
||||
);
|
||||
|
||||
if (variant === "default") {
|
||||
return (
|
||||
<div className="w-full flex flex-wrap items-center gap-2">
|
||||
{leftContent}
|
||||
{rightContent}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Header variant={EHeaderVariant.TERNARY}>
|
||||
<div className="w-full flex items-start gap-2">
|
||||
<div className="w-full flex flex-wrap items-center gap-2">{leftContent}</div>
|
||||
<div className="flex items-center gap-2">{rightContent}</div>
|
||||
</div>
|
||||
</Header>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
const COMMON_VISIBILITY_BUTTON_CLASSNAME = "py-0.5 px-2 text-custom-text-300 hover:text-custom-text-100 rounded-full";
|
||||
const COMMON_OPERATION_BUTTON_CLASSNAME = "py-1";
|
||||
|
||||
type TElementTransitionProps = {
|
||||
children: React.ReactNode;
|
||||
show: boolean;
|
||||
};
|
||||
|
||||
const ElementTransition = observer((props: TElementTransitionProps) => (
|
||||
<Transition
|
||||
show={props.show}
|
||||
enter="transition ease-out duration-200"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="transition ease-in duration-150"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
{props.children}
|
||||
</Transition>
|
||||
));
|
||||
@@ -0,0 +1 @@
|
||||
export const COMMON_FILTER_ITEM_BORDER_CLASSNAME = "border-r border-custom-border-200";
|
||||
@@ -6,7 +6,7 @@ import { useTheme } from "next-themes";
|
||||
import { ChevronLeftIcon } from "lucide-react";
|
||||
// plane imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { getButtonStyling } from "@plane/ui/src/button";
|
||||
import { getButtonStyling } from "@plane/ui";
|
||||
import { cn } from "@plane/utils";
|
||||
// hooks
|
||||
import { useWorkspace } from "@/hooks/store/use-workspace";
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { ExternalLink, Link, Pencil, Trash2 } from "lucide-react";
|
||||
// types
|
||||
import { EUserPermissions, EUserPermissionsLevel, PROJECT_VIEW_TRACKER_ELEMENTS } from "@plane/constants";
|
||||
import { IProjectView } from "@plane/types";
|
||||
@@ -13,6 +12,7 @@ import { copyUrlToClipboard, cn } from "@plane/utils";
|
||||
import { captureClick } from "@/helpers/event-tracker.helper";
|
||||
// hooks
|
||||
import { useUser, useUserPermissions } from "@/hooks/store/user";
|
||||
import { useViewMenuItems } from "@/plane-web/components/views/helper";
|
||||
import { PublishViewModal, useViewPublish } from "@/plane-web/components/views/publish";
|
||||
// local imports
|
||||
import { DeleteProjectViewModal } from "./delete-view-modal";
|
||||
@@ -54,34 +54,18 @@ export const ViewQuickActions: React.FC<Props> = observer((props) => {
|
||||
});
|
||||
const handleOpenInNewTab = () => window.open(`/${viewLink}`, "_blank");
|
||||
|
||||
const MENU_ITEMS: TContextMenuItem[] = [
|
||||
{
|
||||
key: "edit",
|
||||
action: () => setCreateUpdateViewModal(true),
|
||||
title: "Edit",
|
||||
icon: Pencil,
|
||||
shouldRender: isOwner,
|
||||
},
|
||||
{
|
||||
key: "open-new-tab",
|
||||
action: handleOpenInNewTab,
|
||||
title: "Open in new tab",
|
||||
icon: ExternalLink,
|
||||
},
|
||||
{
|
||||
key: "copy-link",
|
||||
action: handleCopyText,
|
||||
title: "Copy link",
|
||||
icon: Link,
|
||||
},
|
||||
{
|
||||
key: "delete",
|
||||
action: () => setDeleteViewModal(true),
|
||||
title: "Delete",
|
||||
icon: Trash2,
|
||||
shouldRender: isOwner || isAdmin,
|
||||
},
|
||||
];
|
||||
const MENU_ITEMS: TContextMenuItem[] = useViewMenuItems({
|
||||
isOwner,
|
||||
isAdmin,
|
||||
setDeleteViewModal,
|
||||
setCreateUpdateViewModal,
|
||||
handleOpenInNewTab,
|
||||
handleCopyText,
|
||||
isLocked: view.is_locked,
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
viewId: view.id,
|
||||
});
|
||||
|
||||
if (publishContextMenu) MENU_ITEMS.splice(2, 0, publishContextMenu);
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { SetStateAction, useEffect, useState } from "react";
|
||||
import { Button } from "@plane/ui";
|
||||
import { LockedComponent } from "../icons/locked-component";
|
||||
|
||||
type Props = {
|
||||
isLocked: boolean;
|
||||
@@ -14,16 +13,8 @@ type Props = {
|
||||
};
|
||||
|
||||
export const UpdateViewComponent = (props: Props) => {
|
||||
const {
|
||||
isLocked,
|
||||
areFiltersEqual,
|
||||
isOwner,
|
||||
isAuthorizedUser,
|
||||
setIsModalOpen,
|
||||
handleUpdateView,
|
||||
lockedTooltipContent,
|
||||
trackerElement,
|
||||
} = props;
|
||||
const { isLocked, areFiltersEqual, isOwner, isAuthorizedUser, setIsModalOpen, handleUpdateView, trackerElement } =
|
||||
props;
|
||||
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
|
||||
@@ -54,24 +45,19 @@ export const UpdateViewComponent = (props: Props) => {
|
||||
|
||||
return (
|
||||
<div className="flex gap-2 h-fit">
|
||||
{isLocked ? (
|
||||
<LockedComponent toolTipContent={lockedTooltipContent} />
|
||||
) : (
|
||||
!areFiltersEqual &&
|
||||
isAuthorizedUser && (
|
||||
<>
|
||||
<Button
|
||||
variant="outline-primary"
|
||||
size="md"
|
||||
className="flex-shrink-0"
|
||||
data-ph-element={trackerElement}
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
>
|
||||
Save as
|
||||
</Button>
|
||||
{isOwner && <>{updateButton}</>}
|
||||
</>
|
||||
)
|
||||
{!isLocked && !areFiltersEqual && isAuthorizedUser && (
|
||||
<>
|
||||
<Button
|
||||
variant="outline-primary"
|
||||
size="md"
|
||||
className="flex-shrink-0"
|
||||
data-ph-element={trackerElement}
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
>
|
||||
Save as
|
||||
</Button>
|
||||
{isOwner && <>{updateButton}</>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
-1
@@ -195,7 +195,6 @@ export const NotificationSnoozeModal: FC<TNotificationSnoozeModal> = (props) =>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
optionsClassName="w-full"
|
||||
input
|
||||
>
|
||||
<div className="mb-2 flex h-9 w-full overflow-hidden rounded">
|
||||
|
||||
@@ -228,7 +228,6 @@ export const CreateWorkspaceForm: FC<Props> = observer((props) => {
|
||||
}
|
||||
buttonClassName="!border-[0.5px] !border-custom-border-200 !shadow-none"
|
||||
input
|
||||
optionsClassName="w-full"
|
||||
>
|
||||
{ORGANIZATION_SIZE.map((item) => (
|
||||
<CustomSelect.Option key={item} value={item}>
|
||||
|
||||
@@ -83,7 +83,6 @@ export const InvitationFields = observer((props: TInvitationFieldsProps) => {
|
||||
value={value}
|
||||
label={<span className="text-xs sm:text-sm">{ROLE[value]}</span>}
|
||||
onChange={onChange}
|
||||
optionsClassName="w-full"
|
||||
className="flex-grow w-24"
|
||||
input
|
||||
>
|
||||
|
||||
@@ -146,7 +146,6 @@ export const AccountTypeColumn: React.FC<AccountTypeProps> = observer((props) =>
|
||||
}
|
||||
buttonClassName={`!px-0 !justify-start hover:bg-custom-background-100 ${errors.role ? "border-red-500" : "border-none"}`}
|
||||
className="rounded-md p-0 w-32"
|
||||
optionsClassName="w-full"
|
||||
input
|
||||
>
|
||||
{Object.keys(ROLE).map((item) => (
|
||||
|
||||
@@ -241,7 +241,6 @@ export const WorkspaceDetails: FC = observer(() => {
|
||||
ORGANIZATION_SIZE.find((c) => c === value) ??
|
||||
t("workspace_settings.settings.general.errors.company_size.select_a_range")
|
||||
}
|
||||
optionsClassName="w-full"
|
||||
buttonClassName="!border-[0.5px] !border-custom-border-200 !shadow-none"
|
||||
input
|
||||
disabled={!isAdmin}
|
||||
|
||||
@@ -2,10 +2,8 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { ExternalLink, LinkIcon, Pencil, Trash2 } from "lucide-react";
|
||||
// plane imports
|
||||
import { EUserPermissions, EUserPermissionsLevel, GLOBAL_VIEW_TRACKER_ELEMENTS } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { IWorkspaceView } from "@plane/types";
|
||||
import { CustomMenu, TContextMenuItem, TOAST_TYPE, setToast } from "@plane/ui";
|
||||
import { copyUrlToClipboard, cn } from "@plane/utils";
|
||||
@@ -14,6 +12,7 @@ import { captureClick } from "@/helpers/event-tracker.helper";
|
||||
// hooks
|
||||
import { useUser, useUserPermissions } from "@/hooks/store/user";
|
||||
// local imports
|
||||
import { useViewMenuItems } from "@/plane-web/components/views/helper";
|
||||
import { DeleteGlobalViewModal } from "./delete-view-modal";
|
||||
import { CreateUpdateWorkspaceViewModal } from "./modal";
|
||||
|
||||
@@ -30,7 +29,6 @@ export const WorkspaceViewQuickActions: React.FC<Props> = observer((props) => {
|
||||
// store hooks
|
||||
const { data } = useUser();
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
const { t } = useTranslation();
|
||||
// auth
|
||||
const isOwner = view?.owned_by === data?.id;
|
||||
const isAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE);
|
||||
@@ -46,34 +44,17 @@ export const WorkspaceViewQuickActions: React.FC<Props> = observer((props) => {
|
||||
});
|
||||
const handleOpenInNewTab = () => window.open(`/${viewLink}`, "_blank");
|
||||
|
||||
const MENU_ITEMS: TContextMenuItem[] = [
|
||||
{
|
||||
key: "edit",
|
||||
action: () => setUpdateViewModal(true),
|
||||
title: t("edit"),
|
||||
icon: Pencil,
|
||||
shouldRender: isOwner,
|
||||
},
|
||||
{
|
||||
key: "open-new-tab",
|
||||
action: handleOpenInNewTab,
|
||||
title: t("open_in_new_tab"),
|
||||
icon: ExternalLink,
|
||||
},
|
||||
{
|
||||
key: "copy-link",
|
||||
action: handleCopyText,
|
||||
title: t("copy_link"),
|
||||
icon: LinkIcon,
|
||||
},
|
||||
{
|
||||
key: "delete",
|
||||
action: () => setDeleteViewModal(true),
|
||||
title: t("delete"),
|
||||
icon: Trash2,
|
||||
shouldRender: isOwner || isAdmin,
|
||||
},
|
||||
];
|
||||
const MENU_ITEMS: TContextMenuItem[] = useViewMenuItems({
|
||||
isOwner,
|
||||
isAdmin,
|
||||
setDeleteViewModal,
|
||||
setCreateUpdateViewModal: setUpdateViewModal,
|
||||
handleOpenInNewTab,
|
||||
handleCopyText,
|
||||
isLocked: view.is_locked,
|
||||
workspaceSlug,
|
||||
viewId: view.id,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useContext } from "react";
|
||||
// mobx store
|
||||
import { StoreContext } from "@/lib/store-context";
|
||||
// types
|
||||
import type { IGlobalViewStore } from "@/store/global-view.store";
|
||||
import type { IGlobalViewStore } from "@/plane-web/store/global-view.store";
|
||||
|
||||
export const useGlobalView = (): IGlobalViewStore => {
|
||||
const context = useContext(StoreContext);
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useContext } from "react";
|
||||
// mobx store
|
||||
import { StoreContext } from "@/lib/store-context";
|
||||
// types
|
||||
import type { IProjectViewStore } from "@/store/project-view.store";
|
||||
import type { IProjectViewStore } from "@/plane-web/store/project-view.store";
|
||||
|
||||
export const useProjectView = (): IProjectViewStore => {
|
||||
const context = useContext(StoreContext);
|
||||
|
||||
@@ -2,8 +2,9 @@ import { ReactNode, useEffect, FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useTheme } from "next-themes";
|
||||
import { useTranslation, TLanguage } from "@plane/i18n";
|
||||
// helpers
|
||||
// plane imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { TLanguage } from "@plane/types";
|
||||
import { applyTheme, unsetCustomCssVariables } from "@plane/utils";
|
||||
// hooks
|
||||
import { useAppTheme } from "@/hooks/store/use-app-theme";
|
||||
|
||||
@@ -99,7 +99,7 @@ export const getEstimatePoints = async (workspaceSlug: string) => {
|
||||
};
|
||||
|
||||
export const getMembers = async (workspaceSlug: string) => {
|
||||
const workspaceService = new WorkspaceService(API_BASE_URL);
|
||||
const workspaceService = new WorkspaceService();
|
||||
const members = await workspaceService.fetchWorkspaceMembers(workspaceSlug);
|
||||
const objects = members.map((member: IWorkspaceMember) => member.member);
|
||||
return objects;
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { API_BASE_URL } from "@plane/constants";
|
||||
import { IProjectView } from "@plane/types";
|
||||
import { APIService } from "@/services/api.service";
|
||||
// types
|
||||
// helpers
|
||||
|
||||
export class ViewService extends APIService {
|
||||
constructor(baseUrl: string) {
|
||||
super(baseUrl);
|
||||
constructor() {
|
||||
super(API_BASE_URL);
|
||||
}
|
||||
|
||||
async createView(workspaceSlug: string, projectId: string, data: Partial<IProjectView>): Promise<any> {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { API_BASE_URL } from "@plane/constants";
|
||||
import {
|
||||
IWorkspace,
|
||||
IWorkspaceMemberMe,
|
||||
@@ -23,8 +24,8 @@ import {
|
||||
import { APIService } from "@/services/api.service";
|
||||
|
||||
export class WorkspaceService extends APIService {
|
||||
constructor(baseUrl: string) {
|
||||
super(baseUrl);
|
||||
constructor() {
|
||||
super(API_BASE_URL);
|
||||
}
|
||||
|
||||
async userWorkspaces(): Promise<IWorkspace[]> {
|
||||
|
||||
@@ -5,7 +5,7 @@ import set from "lodash/set";
|
||||
import { observable, action, makeObservable, runInAction, computed } from "mobx";
|
||||
import { computedFn } from "mobx-utils";
|
||||
import { EIssueFilterType } from "@plane/constants";
|
||||
import { EViewAccess, IIssueFilterOptions, IWorkspaceView } from "@plane/types";
|
||||
import { IIssueFilterOptions, IWorkspaceView } from "@plane/types";
|
||||
// constants
|
||||
// services
|
||||
import { WorkspaceService } from "@/plane-web/services";
|
||||
@@ -50,15 +50,18 @@ export class GlobalViewStore implements IGlobalViewStore {
|
||||
// actions
|
||||
fetchAllGlobalViews: action,
|
||||
fetchGlobalViewDetails: action,
|
||||
createGlobalView: action,
|
||||
updateGlobalView: action,
|
||||
deleteGlobalView: action,
|
||||
updateGlobalView: action,
|
||||
createGlobalView: action,
|
||||
});
|
||||
|
||||
// root store
|
||||
this.rootStore = _rootStore;
|
||||
// services
|
||||
this.workspaceService = new WorkspaceService();
|
||||
|
||||
this.createGlobalView = this.createGlobalView.bind(this);
|
||||
this.updateGlobalView = this.updateGlobalView.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -130,18 +133,19 @@ export class GlobalViewStore implements IGlobalViewStore {
|
||||
* @param workspaceSlug
|
||||
* @param data
|
||||
*/
|
||||
createGlobalView = async (workspaceSlug: string, data: Partial<IWorkspaceView>): Promise<IWorkspaceView> => {
|
||||
const response = await this.workspaceService.createView(workspaceSlug, data);
|
||||
runInAction(() => {
|
||||
set(this.globalViewMap, response.id, response);
|
||||
});
|
||||
async createGlobalView(workspaceSlug: string, data: Partial<IWorkspaceView>) {
|
||||
try {
|
||||
const response = await this.workspaceService.createView(workspaceSlug, data);
|
||||
runInAction(() => {
|
||||
set(this.globalViewMap, response.id, response);
|
||||
});
|
||||
|
||||
if (data.access === EViewAccess.PRIVATE) {
|
||||
await this.updateViewAccess(workspaceSlug, response.id, EViewAccess.PRIVATE);
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
return response;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @description update global view
|
||||
@@ -149,11 +153,11 @@ export class GlobalViewStore implements IGlobalViewStore {
|
||||
* @param viewId
|
||||
* @param data
|
||||
*/
|
||||
updateGlobalView = async (
|
||||
async updateGlobalView(
|
||||
workspaceSlug: string,
|
||||
viewId: string,
|
||||
data: Partial<IWorkspaceView>
|
||||
): Promise<IWorkspaceView | undefined> => {
|
||||
): Promise<IWorkspaceView | undefined> {
|
||||
const currentViewData = this.getViewDetailsById(viewId) ? cloneDeep(this.getViewDetailsById(viewId)) : undefined;
|
||||
try {
|
||||
Object.keys(data).forEach((key) => {
|
||||
@@ -161,14 +165,7 @@ export class GlobalViewStore implements IGlobalViewStore {
|
||||
set(this.globalViewMap, [viewId, currentKey], data[currentKey]);
|
||||
});
|
||||
|
||||
const promiseRequests = [];
|
||||
promiseRequests.push(this.workspaceService.updateView(workspaceSlug, viewId, data));
|
||||
|
||||
if (data.access !== undefined && data.access !== currentViewData?.access) {
|
||||
promiseRequests.push(this.updateViewAccess(workspaceSlug, viewId, data.access));
|
||||
}
|
||||
|
||||
const [currentView] = await Promise.all(promiseRequests);
|
||||
const currentView = await this.workspaceService.updateView(workspaceSlug, viewId, data);
|
||||
|
||||
// applying the filters in the global view
|
||||
if (!isEqual(currentViewData?.filters || {}, currentView?.filters || {})) {
|
||||
@@ -205,7 +202,7 @@ export class GlobalViewStore implements IGlobalViewStore {
|
||||
if (currentViewData) set(this.globalViewMap, [viewId, currentKey], currentViewData[currentKey]);
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @description delete global view
|
||||
@@ -218,73 +215,4 @@ export class GlobalViewStore implements IGlobalViewStore {
|
||||
delete this.globalViewMap[viewId];
|
||||
});
|
||||
});
|
||||
|
||||
/** Locks view
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
* @param viewId
|
||||
* @returns
|
||||
*/
|
||||
lockView = async (workspaceSlug: string, viewId: string) => {
|
||||
try {
|
||||
const currentView = this.getViewDetailsById(viewId);
|
||||
if (currentView?.is_locked) return;
|
||||
runInAction(() => {
|
||||
set(this.globalViewMap, [viewId, "is_locked"], true);
|
||||
});
|
||||
await this.workspaceService.lockView(workspaceSlug, viewId);
|
||||
} catch (error) {
|
||||
console.error("Failed to lock the view in view store", error);
|
||||
runInAction(() => {
|
||||
set(this.globalViewMap, [viewId, "is_locked"], false);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* unlocks View
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
* @param viewId
|
||||
* @returns
|
||||
*/
|
||||
unLockView = async (workspaceSlug: string, viewId: string) => {
|
||||
try {
|
||||
const currentView = this.getViewDetailsById(viewId);
|
||||
if (!currentView?.is_locked) return;
|
||||
runInAction(() => {
|
||||
set(this.globalViewMap, [viewId, "is_locked"], false);
|
||||
});
|
||||
await this.workspaceService.unLockView(workspaceSlug, viewId);
|
||||
} catch (error) {
|
||||
console.error("Failed to unlock view in view store", error);
|
||||
runInAction(() => {
|
||||
set(this.globalViewMap, [viewId, "is_locked"], true);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates View access
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
* @param viewId
|
||||
* @param access
|
||||
* @returns
|
||||
*/
|
||||
updateViewAccess = async (workspaceSlug: string, viewId: string, access: EViewAccess) => {
|
||||
const currentView = this.getViewDetailsById(viewId);
|
||||
const currentAccess = currentView?.access;
|
||||
try {
|
||||
runInAction(() => {
|
||||
set(this.globalViewMap, [viewId, "access"], access);
|
||||
});
|
||||
await this.workspaceService.updateViewAccess(workspaceSlug, viewId, access);
|
||||
} catch (error) {
|
||||
console.error("Failed to update Access for view", error);
|
||||
runInAction(() => {
|
||||
set(this.globalViewMap, [viewId, "access"], currentAccess);
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { set } from "lodash";
|
||||
import { observable, action, makeObservable, runInAction, computed } from "mobx";
|
||||
import { computedFn } from "mobx-utils";
|
||||
// types
|
||||
import { EViewAccess, IProjectView, TPublishViewDetails, TPublishViewSettings, TViewFilters } from "@plane/types";
|
||||
import { IProjectView, TViewFilters } from "@plane/types";
|
||||
// constants
|
||||
// helpers
|
||||
import { getValidatedViewFilters, getViewName, orderViews, shouldFilterView } from "@plane/utils";
|
||||
@@ -41,25 +41,6 @@ export interface IProjectViewStore {
|
||||
// favorites actions
|
||||
addViewToFavorites: (workspaceSlug: string, projectId: string, viewId: string) => Promise<any>;
|
||||
removeViewFromFavorites: (workspaceSlug: string, projectId: string, viewId: string) => Promise<any>;
|
||||
// publish
|
||||
publishView: (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
viewId: string,
|
||||
data: TPublishViewSettings
|
||||
) => Promise<TPublishViewDetails | undefined>;
|
||||
fetchPublishDetails: (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
viewId: string
|
||||
) => Promise<TPublishViewDetails | undefined>;
|
||||
updatePublishedView: (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
viewId: string,
|
||||
data: Partial<TPublishViewSettings>
|
||||
) => Promise<void>;
|
||||
unPublishView: (workspaceSlug: string, projectId: string, viewId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export class ProjectViewStore implements IProjectViewStore {
|
||||
@@ -101,6 +82,9 @@ export class ProjectViewStore implements IProjectViewStore {
|
||||
this.rootStore = _rootStore;
|
||||
// services
|
||||
this.viewService = new ViewService();
|
||||
|
||||
this.createView = this.createView.bind(this);
|
||||
this.updateView = this.updateView.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -213,19 +197,15 @@ export class ProjectViewStore implements IProjectViewStore {
|
||||
* @param data
|
||||
* @returns Promise<IProjectView>
|
||||
*/
|
||||
createView = async (workspaceSlug: string, projectId: string, data: Partial<IProjectView>): Promise<IProjectView> => {
|
||||
async createView(workspaceSlug: string, projectId: string, data: Partial<IProjectView>): Promise<IProjectView> {
|
||||
const response = await this.viewService.createView(workspaceSlug, projectId, getValidatedViewFilters(data));
|
||||
|
||||
runInAction(() => {
|
||||
set(this.viewMap, [response.id], response);
|
||||
});
|
||||
|
||||
if (data.access === EViewAccess.PRIVATE) {
|
||||
await this.updateViewAccess(workspaceSlug, projectId, response.id, EViewAccess.PRIVATE);
|
||||
}
|
||||
|
||||
return response;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a view details of specific view and updates it in the store
|
||||
@@ -235,29 +215,22 @@ export class ProjectViewStore implements IProjectViewStore {
|
||||
* @param data
|
||||
* @returns Promise<IProjectView>
|
||||
*/
|
||||
updateView = async (
|
||||
async updateView(
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
viewId: string,
|
||||
data: Partial<IProjectView>
|
||||
): Promise<IProjectView> => {
|
||||
): Promise<IProjectView> {
|
||||
const currentView = this.getViewById(viewId);
|
||||
|
||||
const promiseRequests = [];
|
||||
promiseRequests.push(this.viewService.patchView(workspaceSlug, projectId, viewId, data));
|
||||
|
||||
runInAction(() => {
|
||||
set(this.viewMap, [viewId], { ...currentView, ...data });
|
||||
});
|
||||
|
||||
if (data.access !== undefined && data.access !== currentView.access) {
|
||||
promiseRequests.push(this.updateViewAccess(workspaceSlug, projectId, viewId, data.access));
|
||||
}
|
||||
|
||||
const [response] = await Promise.all(promiseRequests);
|
||||
const response = await this.viewService.patchView(workspaceSlug, projectId, viewId, data);
|
||||
|
||||
return response;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a view and removes it from the viewMap object
|
||||
@@ -275,75 +248,6 @@ export class ProjectViewStore implements IProjectViewStore {
|
||||
});
|
||||
};
|
||||
|
||||
/** Locks view
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
* @param viewId
|
||||
* @returns
|
||||
*/
|
||||
lockView = async (workspaceSlug: string, projectId: string, viewId: string) => {
|
||||
try {
|
||||
const currentView = this.getViewById(viewId);
|
||||
if (currentView?.is_locked) return;
|
||||
runInAction(() => {
|
||||
set(this.viewMap, [viewId, "is_locked"], true);
|
||||
});
|
||||
await this.viewService.lockView(workspaceSlug, projectId, viewId);
|
||||
} catch (error) {
|
||||
console.error("Failed to lock the view in view store", error);
|
||||
runInAction(() => {
|
||||
set(this.viewMap, [viewId, "is_locked"], false);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* unlocks View
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
* @param viewId
|
||||
* @returns
|
||||
*/
|
||||
unLockView = async (workspaceSlug: string, projectId: string, viewId: string) => {
|
||||
try {
|
||||
const currentView = this.getViewById(viewId);
|
||||
if (!currentView?.is_locked) return;
|
||||
runInAction(() => {
|
||||
set(this.viewMap, [viewId, "is_locked"], false);
|
||||
});
|
||||
await this.viewService.unLockView(workspaceSlug, projectId, viewId);
|
||||
} catch (error) {
|
||||
console.error("Failed to unlock view in view store", error);
|
||||
runInAction(() => {
|
||||
set(this.viewMap, [viewId, "is_locked"], true);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates View access
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
* @param viewId
|
||||
* @param access
|
||||
* @returns
|
||||
*/
|
||||
updateViewAccess = async (workspaceSlug: string, projectId: string, viewId: string, access: EViewAccess) => {
|
||||
const currentView = this.getViewById(viewId);
|
||||
const currentAccess = currentView?.access;
|
||||
try {
|
||||
runInAction(() => {
|
||||
set(this.viewMap, [viewId, "access"], access);
|
||||
});
|
||||
await this.viewService.updateViewAccess(workspaceSlug, projectId, viewId, access);
|
||||
} catch (error) {
|
||||
console.error("Failed to update Access for view", error);
|
||||
runInAction(() => {
|
||||
set(this.viewMap, [viewId, "access"], currentAccess);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Adds a view to favorites
|
||||
* @param workspaceSlug
|
||||
@@ -394,91 +298,4 @@ export class ProjectViewStore implements IProjectViewStore {
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Publishes View to the Public
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
* @param viewId
|
||||
* @returns
|
||||
*/
|
||||
publishView = async (workspaceSlug: string, projectId: string, viewId: string, data: TPublishViewSettings) => {
|
||||
try {
|
||||
const response = (await this.viewService.publishView(
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
viewId,
|
||||
data
|
||||
)) as TPublishViewDetails;
|
||||
runInAction(() => {
|
||||
set(this.viewMap, [viewId, "anchor"], response?.anchor);
|
||||
});
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error("Failed to publish view", error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* fetches Published Details
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
* @param viewId
|
||||
* @returns
|
||||
*/
|
||||
fetchPublishDetails = async (workspaceSlug: string, projectId: string, viewId: string) => {
|
||||
try {
|
||||
const response = (await this.viewService.getPublishDetails(
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
viewId
|
||||
)) as TPublishViewDetails;
|
||||
runInAction(() => {
|
||||
set(this.viewMap, [viewId, "anchor"], response?.anchor);
|
||||
});
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch published view details", error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* updates already published view
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
* @param viewId
|
||||
* @returns
|
||||
*/
|
||||
updatePublishedView = async (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
viewId: string,
|
||||
data: Partial<TPublishViewSettings>
|
||||
) => {
|
||||
try {
|
||||
return await this.viewService.updatePublishedView(workspaceSlug, projectId, viewId, data);
|
||||
} catch (error) {
|
||||
console.error("Failed to update published view details", error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* un publishes the view
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
* @param viewId
|
||||
* @returns
|
||||
*/
|
||||
unPublishView = async (workspaceSlug: string, projectId: string, viewId: string) => {
|
||||
try {
|
||||
const response = await this.viewService.unPublishView(workspaceSlug, projectId, viewId);
|
||||
runInAction(() => {
|
||||
set(this.viewMap, [viewId, "anchor"], null);
|
||||
});
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error("Failed to unPublish view", error);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
TProjectMembership,
|
||||
} from "@plane/types";
|
||||
// plane web imports
|
||||
import { WorkspaceService } from "@/plane-web/services/workspace.service";
|
||||
import { WorkspaceService } from "@/plane-web/services";
|
||||
import type { RootStore } from "@/plane-web/store/root.store";
|
||||
// services
|
||||
import projectMemberService from "@/services/project/project-member.service";
|
||||
@@ -118,7 +118,11 @@ export abstract class BaseUserPermissionStore implements IBaseUserPermissionStor
|
||||
*/
|
||||
protected getProjectRole = computedFn((workspaceSlug: string, projectId: string): EUserPermissions | undefined => {
|
||||
if (!workspaceSlug || !projectId) return undefined;
|
||||
return this.workspaceProjectsPermissions?.[workspaceSlug]?.[projectId] || undefined;
|
||||
const projectRole = this.workspaceProjectsPermissions?.[workspaceSlug]?.[projectId];
|
||||
if (!projectRole) return undefined;
|
||||
const workspaceRole = this.workspaceUserInfo?.[workspaceSlug]?.role;
|
||||
if (workspaceRole === EUserWorkspaceRoles.ADMIN) return EUserPermissions.ADMIN;
|
||||
else return projectRole;
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export * from "./project";
|
||||
export * from "ce/services/workspace.service";
|
||||
export * from "@/services/workspace.service";
|
||||
|
||||
@@ -1,2 +1 @@
|
||||
export * from "./estimate.service";
|
||||
export * from "ce/services/project/view.service";
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
"@plane/i18n": "workspace:*",
|
||||
"@plane/propel": "workspace:*",
|
||||
"@plane/services": "workspace:*",
|
||||
"@plane/shared-state": "workspace:*",
|
||||
"@plane/types": "workspace:*",
|
||||
"@plane/ui": "workspace:*",
|
||||
"@plane/utils": "workspace:*",
|
||||
|
||||
@@ -3,17 +3,11 @@
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0",
|
||||
"files": [
|
||||
"dist/**/*"
|
||||
],
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.mjs",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"types": "./dist/index.d.mts",
|
||||
"require": "./dist/index.js",
|
||||
"import": "./dist/index.js"
|
||||
"import": "./dist/index.mjs"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -1,38 +1,39 @@
|
||||
export * from "./ai";
|
||||
export * from "./analytics";
|
||||
export * from "./auth";
|
||||
export * from "./chart";
|
||||
export * from "./cycle";
|
||||
export * from "./dashboard";
|
||||
export * from "./emoji";
|
||||
export * from "./endpoints";
|
||||
export * from "./estimates";
|
||||
export * from "./event-tracker";
|
||||
export * from "./file";
|
||||
export * from "./filter";
|
||||
export * from "./graph";
|
||||
export * from "./icon";
|
||||
export * from "./instance";
|
||||
export * from "./intake";
|
||||
export * from "./issue";
|
||||
export * from "./label";
|
||||
export * from "./metadata";
|
||||
export * from "./module";
|
||||
export * from "./notification";
|
||||
export * from "./page";
|
||||
export * from "./payment";
|
||||
export * from "./profile";
|
||||
export * from "./project";
|
||||
export * from "./rich-filters";
|
||||
export * from "./settings";
|
||||
export * from "./sidebar";
|
||||
export * from "./spreadsheet";
|
||||
export * from "./state";
|
||||
export * from "./stickies";
|
||||
export * from "./subscription";
|
||||
export * from "./swr";
|
||||
export * from "./tab-indices";
|
||||
export * from "./user";
|
||||
export * from "./payment";
|
||||
export * from "./workspace";
|
||||
export * from "./stickies";
|
||||
export * from "./cycle";
|
||||
export * from "./module";
|
||||
export * from "./project";
|
||||
export * from "./views";
|
||||
export * from "./themes";
|
||||
export * from "./intake";
|
||||
export * from "./profile";
|
||||
export * from "./user";
|
||||
export * from "./views";
|
||||
export * from "./workspace-drafts";
|
||||
export * from "./label";
|
||||
export * from "./event-tracker";
|
||||
export * from "./spreadsheet";
|
||||
export * from "./dashboard";
|
||||
export * from "./page";
|
||||
export * from "./emoji";
|
||||
export * from "./subscription";
|
||||
export * from "./settings";
|
||||
export * from "./icon";
|
||||
export * from "./estimates";
|
||||
export * from "./analytics";
|
||||
export * from "./sidebar";
|
||||
export * from "./workspace";
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./operator-labels";
|
||||
export * from "./option";
|
||||
@@ -0,0 +1,24 @@
|
||||
import {
|
||||
CORE_EQUALITY_OPERATOR,
|
||||
CORE_COLLECTION_OPERATOR,
|
||||
CORE_COMPARISON_OPERATOR,
|
||||
TCoreSupportedOperators,
|
||||
TCoreSupportedDateFilterOperators,
|
||||
} from "@plane/types";
|
||||
|
||||
/**
|
||||
* Core operator labels
|
||||
*/
|
||||
export const CORE_OPERATOR_LABELS_MAP: Record<TCoreSupportedOperators, string> = {
|
||||
[CORE_EQUALITY_OPERATOR.EXACT]: "is",
|
||||
[CORE_COLLECTION_OPERATOR.IN]: "is any of",
|
||||
[CORE_COMPARISON_OPERATOR.RANGE]: "between",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Core date-specific operator labels
|
||||
*/
|
||||
export const CORE_DATE_OPERATOR_LABELS_MAP: Record<TCoreSupportedDateFilterOperators, string> = {
|
||||
[CORE_EQUALITY_OPERATOR.EXACT]: "is",
|
||||
[CORE_COMPARISON_OPERATOR.RANGE]: "between",
|
||||
} as const;
|
||||
@@ -0,0 +1,21 @@
|
||||
import { TExtendedSupportedOperators } from "@plane/types";
|
||||
|
||||
/**
|
||||
* Extended operator labels
|
||||
*/
|
||||
export const EXTENDED_OPERATOR_LABELS_MAP: Record<TExtendedSupportedOperators, string> = {} as const;
|
||||
|
||||
/**
|
||||
* Extended date-specific operator labels
|
||||
*/
|
||||
export const EXTENDED_DATE_OPERATOR_LABELS_MAP: Record<TExtendedSupportedOperators, string> = {} as const;
|
||||
|
||||
/**
|
||||
* Negated operator labels for all operators
|
||||
*/
|
||||
export const NEGATED_OPERATOR_LABELS_MAP: Record<never, string> = {} as const;
|
||||
|
||||
/**
|
||||
* Negated date operator labels for all date operators
|
||||
*/
|
||||
export const NEGATED_DATE_OPERATOR_LABELS_MAP: Record<never, string> = {} as const;
|
||||
@@ -0,0 +1,36 @@
|
||||
import { TAllAvailableOperatorsForDisplay, TAllAvailableDateFilterOperatorsForDisplay } from "@plane/types";
|
||||
import { CORE_OPERATOR_LABELS_MAP, CORE_DATE_OPERATOR_LABELS_MAP } from "./core";
|
||||
import {
|
||||
EXTENDED_OPERATOR_LABELS_MAP,
|
||||
EXTENDED_DATE_OPERATOR_LABELS_MAP,
|
||||
NEGATED_OPERATOR_LABELS_MAP,
|
||||
NEGATED_DATE_OPERATOR_LABELS_MAP,
|
||||
} from "./extended";
|
||||
|
||||
/**
|
||||
* Empty operator label for unselected state
|
||||
*/
|
||||
export const EMPTY_OPERATOR_LABEL = "--";
|
||||
|
||||
/**
|
||||
* Complete operator labels mapping - combines core, extended, and negated labels
|
||||
*/
|
||||
export const OPERATOR_LABELS_MAP: Record<TAllAvailableOperatorsForDisplay, string> = {
|
||||
...CORE_OPERATOR_LABELS_MAP,
|
||||
...EXTENDED_OPERATOR_LABELS_MAP,
|
||||
...NEGATED_OPERATOR_LABELS_MAP,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Complete date operator labels mapping - combines core, extended, and negated labels
|
||||
*/
|
||||
export const DATE_OPERATOR_LABELS_MAP: Record<TAllAvailableDateFilterOperatorsForDisplay, string> = {
|
||||
...CORE_DATE_OPERATOR_LABELS_MAP,
|
||||
...EXTENDED_DATE_OPERATOR_LABELS_MAP,
|
||||
...NEGATED_DATE_OPERATOR_LABELS_MAP,
|
||||
} as const;
|
||||
|
||||
// -------- RE-EXPORTS --------
|
||||
|
||||
export * from "./core";
|
||||
export * from "./extended";
|
||||
@@ -0,0 +1,63 @@
|
||||
import { TExternalFilter } from "@plane/types";
|
||||
|
||||
/**
|
||||
* Filter config options.
|
||||
*/
|
||||
export type TConfigOptions = Record<string, unknown>;
|
||||
|
||||
/**
|
||||
* Default filter config options.
|
||||
*/
|
||||
export const DEFAULT_FILTER_CONFIG_OPTIONS: TConfigOptions = {};
|
||||
|
||||
/**
|
||||
* Clear filter config.
|
||||
*/
|
||||
export type TClearFilterOptions = {
|
||||
label?: string;
|
||||
onFilterClear: () => void | Promise<void>;
|
||||
isDisabled?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Save view config.
|
||||
*/
|
||||
export type TSaveViewOptions<E extends TExternalFilter> = {
|
||||
label?: string;
|
||||
onViewSave: (expression: E) => void | Promise<void>;
|
||||
isDisabled?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Update view config.
|
||||
*/
|
||||
export type TUpdateViewOptions<E extends TExternalFilter> = {
|
||||
label?: string;
|
||||
hasAdditionalChanges?: boolean;
|
||||
onViewUpdate: (expression: E) => void | Promise<void>;
|
||||
isDisabled?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Filter expression options.
|
||||
*/
|
||||
export type TExpressionOptions<E extends TExternalFilter> = {
|
||||
clearFilterOptions?: TClearFilterOptions;
|
||||
saveViewOptions?: TSaveViewOptions<E>;
|
||||
updateViewOptions?: TUpdateViewOptions<E>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Default filter expression options.
|
||||
*/
|
||||
export const DEFAULT_FILTER_EXPRESSION_OPTIONS: TExpressionOptions<TExternalFilter> = {};
|
||||
|
||||
/**
|
||||
* Filter options.
|
||||
* - expression: Filter expression options.
|
||||
* - config: Filter config options.
|
||||
*/
|
||||
export type TFilterOptions<E extends TExternalFilter> = {
|
||||
expression: Partial<TExpressionOptions<E>>;
|
||||
config: Partial<TConfigOptions>;
|
||||
};
|
||||
@@ -3,5 +3,8 @@ import { defineConfig } from "tsdown";
|
||||
export default defineConfig({
|
||||
entry: ["src/index.ts"],
|
||||
outDir: "dist",
|
||||
format: ["esm", "cjs"],
|
||||
format: ["esm"],
|
||||
dts: true,
|
||||
clean: true,
|
||||
sourcemap: false,
|
||||
});
|
||||
|
||||
@@ -4,12 +4,13 @@
|
||||
"description": "Controller and route decorators for Express.js applications",
|
||||
"license": "AGPL-3.0",
|
||||
"private": true,
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.mjs",
|
||||
"types": "./dist/index.d.ts",
|
||||
"files": [
|
||||
"dist/**"
|
||||
],
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.mts",
|
||||
"require": "./dist/index.js",
|
||||
"import": "./dist/index.mjs"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsdown",
|
||||
"dev": "tsdown --watch",
|
||||
|
||||
@@ -4,4 +4,7 @@ export default defineConfig({
|
||||
entry: ["src/index.ts"],
|
||||
outDir: "dist",
|
||||
format: ["esm", "cjs"],
|
||||
dts: true,
|
||||
clean: true,
|
||||
sourcemap: false,
|
||||
});
|
||||
|
||||
@@ -4,20 +4,15 @@
|
||||
"description": "Core Editor that powers Plane",
|
||||
"license": "AGPL-3.0",
|
||||
"private": true,
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"main": "./dist/index.mjs",
|
||||
"module": "./dist/index.mjs",
|
||||
"types": "./dist/index.d.mts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.mts",
|
||||
"require": "./dist/index.js",
|
||||
"import": "./dist/index.mjs"
|
||||
},
|
||||
"./lib": {
|
||||
"require": "./dist/lib.js",
|
||||
"types": "./dist/lib.d.mts",
|
||||
"require": "./dist/lib.js",
|
||||
"import": "./dist/lib.mjs"
|
||||
},
|
||||
"./styles": "./dist/styles/index.css"
|
||||
|
||||
@@ -6,7 +6,7 @@ import { IEditorProps, TExtensions } from "@/types";
|
||||
|
||||
export type TRichTextEditorAdditionalExtensionsProps = Pick<
|
||||
IEditorProps,
|
||||
"disabledExtensions" | "flaggedExtensions" | "fileHandler"
|
||||
"disabledExtensions" | "flaggedExtensions" | "fileHandler" | "extendedEditorProps"
|
||||
>;
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,6 +2,8 @@ export type IEditorExtensionOptions = unknown;
|
||||
|
||||
export type IEditorPropsExtended = unknown;
|
||||
|
||||
export type ICollaborativeDocumentEditorPropsExtended = unknown;
|
||||
|
||||
export type TExtendedEditorCommands = never;
|
||||
|
||||
export type TExtendedCommandExtraProps = unknown;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user