Compare commits

...
Author SHA1 Message Date
sriramveeraghanta d35b28b218 fix: code refactor 2025-09-23 14:36:05 +05:30
sriramveeraghanta 6d0a318785 chore: convert all packages to modules 2025-09-23 12:43:01 +05:30
sriramveeraghanta e7c332b0bd fix: code refactor 2025-09-17 20:56:39 +05:30
sriramveeraghanta 675d3edba8 Merge branch 'preview' of github.com:makeplane/plane into feat-package-exports 2025-09-17 16:31:45 +05:30
NikhilandGitHub 3d06189723 [WEB-4943] refactor: enhance URL validation and redirection logic in authentication views (#7815)
* refactor: enhance URL validation and redirection logic in authentication views

* Updated authentication views (SignInAuthSpaceEndpoint, GitHubCallbackSpaceEndpoint, GitLabCallbackSpaceEndpoint, GoogleCallbackSpaceEndpoint, and MagicSignInSpaceEndpoint) to include url_has_allowed_host_and_scheme checks for safer redirection.
* Improved URL construction by ensuring proper formatting and fallback to base host when necessary.
* Added get_allowed_hosts function to path_validator.py for better host validation.

* refactor: improve comments and clean up code in path_validator.py

* Updated comments for clarity in the get_safe_redirect_url function.
* Removed unnecessary blank line to enhance
2025-09-17 16:13:32 +05:30
NikhilandGitHub 6d3d9e6df7 [WEB-4943]: add url has allowed host or scheme for validating valid redirections (#7809)
* feat: enhance path validation and URL safety in path_validator.py

* Added get_allowed_hosts function to retrieve allowed hosts from settings.
* Updated get_safe_redirect_url to validate URLs against allowed hosts.
* Improved URL construction logic for safer redirection handling.

* feat: enhance URL validation in authentication views

* Added url_has_allowed_host_and_scheme checks in SignUpAuthSpaceEndpoint and MagicSignInSpaceEndpoint for safer redirection.
* Updated redirect logic to fallback to base host if the constructed URL is not allowed.
* Improved overall URL safety and handling in authentication flows.

* fix: improve host extraction in get_allowed_hosts function

* Updated get_allowed_hosts to extract only the host from ADMIN_BASE_URL and SPACE_BASE_URL settings for better URL validation.
* Enhanced overall safety and clarity in allowed hosts retrieval.
2025-09-16 21:37:08 +05:30
Prateek ShouryaandGitHub d521eab22f [WEB-4885] feat: new filters architecture and UI components (#7802)
* feat: add rich filters types

* feat: add rich filters constants

* feat: add rich filters utils

* feat: add rich filters store in shared state package

* feat: add rich filters UI components

* fix: make setLoading optional in loadOptions function for improved flexibility

* chore: minor improvements to rich filters

* fix: formatting
2025-09-16 21:15:08 +05:30
sriramveeraghanta 00e070b509 fix: codeql triggers 2025-09-16 20:46:44 +05:30
NikhilandGitHub 4d17637edf [WEB-4943] refactor: streamline URL construction in authentication views (#7806)
* refactor: streamline URL construction in authentication views

* Updated MagicSignInSpaceEndpoint and MagicSignUpSpaceEndpoint to directly construct redirect URLs using formatted strings instead of the get_safe_redirect_url function.
* Enhanced get_safe_redirect_url to use quote for safer URL encoding of parameters.

* refactor: enhance URL validation and redirection in authentication views

* Added validate_next_path function to improve the safety of redirect URLs in MagicSignInSpaceEndpoint and MagicSignUpSpaceEndpoint.
* Updated URL construction to ensure proper handling of next_path and base_url.
* Streamlined the get_safe_redirect_url function for better parameter encoding.

* refactor: unify URL redirection logic across authentication views

* Introduced validate_next_path function to enhance URL safety in SignInAuthSpaceEndpoint, SignUpAuthSpaceEndpoint, GitHubCallbackSpaceEndpoint, GitLabCallbackSpaceEndpoint, and GoogleCallbackSpaceEndpoint.
* Updated URL construction to directly format the redirect URL, improving clarity and consistency across multiple authentication views.
2025-09-16 18:44:26 +05:30
Anmol Singh BhatiaandGitHub bf45635a7b [WEB-4898] fix: extended sidebar toggle #7797 2025-09-16 15:35:31 +05:30
NikhilandGitHub 56d3a9e049 [WEB-4900] refactor: remove base_host retrieval from authentication views (#7804)
* refactor: remove base_host retrieval from authentication views

* Removed unnecessary base_host retrieval from GitHub, GitLab, and Google callback endpoints.
* Updated MagicSignUpEndpoint to use get_safe_redirect_url for URL construction.
* Refactored MagicSignInSpaceEndpoint to streamline URL redirection logic.

* refactor: streamline URL redirection in MagicSignInSpaceEndpoint

* Removed redundant base_url retrieval from the exception handling in MagicSignInSpaceEndpoint.
* Enhanced the clarity of URL construction by directly using get_safe_redirect_url.
2025-09-16 10:57:20 +05:30
sriram veeraghanta 1f7eef5f81 chore: django import error 2025-09-16 01:24:48 +05:30
sriram veeraghanta bd2272a7da chore (deps): pnpm lockfile overrides update 2025-09-16 00:55:35 +05:30
sriram veeraghanta b9c6bb07bf chore(deps): axios version upgrade to 1.12.0 2025-09-16 00:14:18 +05:30
345dfce25d [WEB-4900]: validated authentication redirection paths (#7798)
* refactor: replace validate_next_path with get_safe_redirect_url for safer URL redirection across authentication views

* refactor: use get_safe_redirect_url for improved URL redirection in SignInAuthSpaceEndpoint and SignUpAuthSpaceEndpoint

* fix: redirect paths

---------

Co-authored-by: sriram veeraghanta <[email protected]>
2025-09-16 00:01:06 +05:30
116c8118ab [WIKI-659] chore: added issue relation and page sort order (#7784)
* chore: added issue relation and page sort order

* feat: add ProjectWebhook model to manage webhooks associated with projects

* chore: updated the migration file

* chore: added migration

* chore: reverted the page base code

* chore: added a variable for sort order in pages

---------

Co-authored-by: pablohashescobar <[email protected]>
2025-09-15 18:36:00 +05:30
sriram veeraghanta d6fd5d12f9 chore: implements exports for all packages 2025-09-14 21:19:36 +05:30
137 changed files with 5682 additions and 954 deletions
+2 -4
View File
@@ -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:
@@ -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)
@@ -117,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)
@@ -145,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
@@ -159,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)
@@ -178,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)
@@ -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
),
]
+1 -1
View File
@@ -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,
)
+2 -1
View File
@@ -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)
+22 -1
View File
@@ -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",)
+10 -2
View File
@@ -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)
@@ -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)
+120
View File
@@ -1,10 +1,79 @@
# 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 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)
@@ -20,4 +89,55 @@ def validate_next_path(next_path: str) -> str:
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 "")
+3
View File
@@ -4,4 +4,7 @@ export default defineConfig({
entry: ["src/server.ts"],
outDir: "dist",
format: ["esm", "cjs"],
dts: true,
clean: true,
sourcemap: true,
});
@@ -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,
]
);
@@ -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
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>
)}
{isOpen && Options}
</ComboDropDown>
);
});
+5 -1
View File
@@ -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
@@ -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";
@@ -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}
/>
);
}
);
@@ -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";
+1 -1
View File
@@ -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";
+3 -2
View File
@@ -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";
+1
View File
@@ -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:*",
+2 -8
View File
@@ -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": {
+23 -22
View File
@@ -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>;
};
+4 -1
View File
@@ -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,
});
+7 -6
View File
@@ -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",
+3
View File
@@ -4,4 +4,7 @@ export default defineConfig({
entry: ["src/index.ts"],
outDir: "dist",
format: ["esm", "cjs"],
dts: true,
clean: true,
sourcemap: false,
});
+2 -7
View File
@@ -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"
+1 -2
View File
@@ -3,12 +3,11 @@
"jsx": "react-jsx",
"lib": ["ES2022", "DOM"],
"module": "ESNext",
"target": "ESNext",
"moduleResolution": "bundler",
"noEmit": true,
"skipLibCheck": true,
"sourceMap": true,
"target": "ESNext",
"baseUrl": ".",
"paths": {
"@/*": ["./src/core/*"],
+7 -6
View File
@@ -4,12 +4,13 @@
"license": "AGPL-3.0",
"description": "React hooks that are shared across multiple apps internally",
"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",
+1 -1
View File
@@ -6,6 +6,6 @@ export default defineConfig({
format: ["esm", "cjs"],
external: ["react", "react-dom"],
dts: true,
sourcemap: true,
clean: true,
sourcemap: false,
});
+8 -3
View File
@@ -4,9 +4,13 @@
"license": "AGPL-3.0",
"description": "I18n shared across multiple apps internally",
"private": true,
"main": "./dist/index.js",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.mts",
"import": "./dist/index.mjs",
"require": "./dist/index.js"
}
},
"scripts": {
"dev": "tsdown --watch",
"build": "tsdown",
@@ -18,6 +22,7 @@
"clean": "rm -rf .turbo && rm -rf .next && rm -rf node_modules && rm -rf dist"
},
"dependencies": {
"@plane/types": "workspace:*",
"@plane/utils": "workspace:*",
"intl-messageformat": "^10.7.11",
"mobx": "catalog:",
+1 -1
View File
@@ -1,4 +1,4 @@
import { TLanguage, ILanguageOption } from "../types";
import { TLanguage, ILanguageOption } from "@plane/types";
export const FALLBACK_LANGUAGE: TLanguage = "en";
+2 -2
View File
@@ -1,8 +1,8 @@
import { useContext } from "react";
// types
import { ILanguageOption, TLanguage } from "@plane/types";
// context
import { TranslationContext } from "../context";
// types
import { ILanguageOption, TLanguage } from "../types";
export type TTranslationStore = {
t: (key: string, params?: Record<string, unknown>) => string;
-1
View File
@@ -1,6 +1,5 @@
export * from "./constants";
export * from "./context";
export * from "./hooks";
export * from "./types";
export * from "./store";
export * from "./locales";
+2 -2
View File
@@ -2,12 +2,12 @@ import IntlMessageFormat from "intl-messageformat";
import get from "lodash/get";
import merge from "lodash/merge";
import { makeAutoObservable, runInAction } from "mobx";
// types
import { TLanguage, ILanguageOption, ITranslations } from "@plane/types";
// constants
import { FALLBACK_LANGUAGE, SUPPORTED_LANGUAGES, LANGUAGE_STORAGE_KEY, ETranslationFiles } from "../constants";
// core translations imports
import { enCore, locales } from "../locales";
// types
import { TLanguage, ILanguageOption, ITranslations } from "../types";
/**
* Mobx store class for handling translations and language changes in the application
-2
View File
@@ -1,2 +0,0 @@
export * from "./language";
export * from "./translation";
-7
View File
@@ -1,7 +0,0 @@
export interface ITranslation {
[key: string]: string | ITranslation;
}
export interface ITranslations {
[locale: string]: ITranslation;
}
+3 -3
View File
@@ -4,7 +4,7 @@ export default defineConfig({
entry: ["src/index.ts"],
outDir: "dist",
format: ["esm", "cjs"],
dts: true,
external: ["react", "lodash", "mobx", "mobx-react", "intl-messageformat"],
sourcemap: true,
dts: false,
clean: true,
sourcemap: false,
});
+1 -7
View File
@@ -4,19 +4,13 @@
"license": "AGPL-3.0",
"description": "Logger shared across multiple apps internally",
"private": true,
"main": "./dist/index.js",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"types": "./dist/index.d.mts",
"import": "./dist/index.mjs",
"require": "./dist/index.js"
}
},
"files": [
"dist/**"
],
"scripts": {
"build": "tsdown",
"dev": "tsdown --watch",
-3
View File
@@ -1,9 +1,6 @@
{
"extends": "@plane/typescript-config/base.json",
"compilerOptions": {
"module": "ESNext",
"target": "ESNext",
"moduleResolution": "node",
"esModuleInterop": true,
"outDir": "./dist",
"rootDir": "./src",
+1 -1
View File
@@ -5,6 +5,6 @@ export default defineConfig({
outDir: "dist",
format: ["esm", "cjs"],
dts: true,
sourcemap: true,
clean: true,
sourcemap: false,
});
+140 -28
View File
@@ -16,36 +16,148 @@
"build-storybook": "storybook build"
},
"exports": {
"./accordion": "./dist/accordion/index.js",
"./animated-counter": "./dist/animated-counter/index.js",
"./avatar": "./dist/avatar/index.js",
"./button": "./dist/button/index.js",
"./calendar": "./dist/calendar/index.js",
"./card": "./dist/card/index.js",
"./charts/*": "./dist/charts/*/index.js",
"./collapsible": "./dist/collapsible/index.js",
"./combobox": "./dist/combobox/index.js",
"./command": "./dist/command/index.js",
"./context-menu": "./dist/context-menu/index.js",
"./dialog": "./dist/dialog/index.js",
"./emoji-icon-picker": "./dist/emoji-icon-picker/index.js",
"./emoji-reaction": "./dist/emoji-reaction/index.js",
"./emoji-reaction-picker": "./dist/emoji-reaction-picker/index.js",
"./icons": "./dist/icons/index.js",
"./menu": "./dist/menu/index.js",
"./pill": "./dist/pill/index.js",
"./popover": "./dist/popover/index.js",
"./scrollarea": "./dist/scrollarea/index.js",
"./skeleton": "./dist/skeleton/index.js",
"./accordion": {
"types": "./dist/accordion/index.d.mts",
"import": "./dist/accordion/index.mjs",
"require": "./dist/accordion/index.js"
},
"./animated-counter": {
"types": "./dist/animated-counter/index.d.mts",
"import": "./dist/animated-counter/index.mjs",
"require": "./dist/animated-counter/index.js"
},
"./avatar": {
"types": "./dist/avatar/index.d.mts",
"import": "./dist/avatar/index.mjs",
"require": "./dist/avatar/index.js"
},
"./button": {
"types": "./dist/button/index.d.mts",
"import": "./dist/button/index.mjs",
"require": "./dist/button/index.js"
},
"./calendar": {
"types": "./dist/calendar/index.d.mts",
"import": "./dist/calendar/index.mjs",
"require": "./dist/calendar/index.js"
},
"./card": {
"types": "./dist/card/index.d.mts",
"import": "./dist/card/index.mjs",
"require": "./dist/card/index.js"
},
"./charts/*": {
"types": "./dist/charts/*/index.d.mts",
"import": "./dist/charts/*/index.mjs",
"require": "./dist/charts/*/index.js"
},
"./collapsible": {
"types": "./dist/collapsible/index.d.mts",
"import": "./dist/collapsible/index.mjs",
"require": "./dist/collapsible/index.js"
},
"./combobox": {
"types": "./dist/combobox/index.d.mts",
"import": "./dist/combobox/index.mjs",
"require": "./dist/combobox/index.js"
},
"./command": {
"types": "./dist/command/index.d.mts",
"import": "./dist/command/index.mjs",
"require": "./dist/command/index.js"
},
"./context-menu": {
"types": "./dist/context-menu/index.d.mts",
"import": "./dist/context-menu/index.mjs",
"require": "./dist/context-menu/index.js"
},
"./dialog": {
"types": "./dist/dialog/index.d.mts",
"import": "./dist/dialog/index.mjs",
"require": "./dist/dialog/index.js"
},
"./emoji-icon-picker": {
"types": "./dist/emoji-icon-picker/index.d.mts",
"import": "./dist/emoji-icon-picker/index.mjs",
"require": "./dist/emoji-icon-picker/index.js"
},
"./emoji-reaction": {
"types": "./dist/emoji-reaction/index.d.mts",
"import": "./dist/emoji-reaction/index.mjs",
"require": "./dist/emoji-reaction/index.js"
},
"./emoji-reaction-picker": {
"types": "./dist/emoji-reaction-picker/index.d.mts",
"import": "./dist/emoji-reaction-picker/index.mjs",
"require": "./dist/emoji-reaction-picker/index.js"
},
"./icons": {
"types": "./dist/icons/index.d.mts",
"import": "./dist/icons/index.mjs",
"require": "./dist/icons/index.js"
},
"./menu": {
"types": "./dist/menu/index.d.mts",
"import": "./dist/menu/index.mjs",
"require": "./dist/menu/index.js"
},
"./pill": {
"types": "./dist/pill/index.d.mts",
"import": "./dist/pill/index.mjs",
"require": "./dist/pill/index.js"
},
"./popover": {
"types": "./dist/popover/index.d.mts",
"import": "./dist/popover/index.mjs",
"require": "./dist/popover/index.js"
},
"./scrollarea": {
"types": "./dist/scrollarea/index.d.mts",
"import": "./dist/scrollarea/index.mjs",
"require": "./dist/scrollarea/index.js"
},
"./skeleton": {
"types": "./dist/skeleton/index.d.mts",
"import": "./dist/skeleton/index.mjs",
"require": "./dist/skeleton/index.js"
},
"./styles/fonts": "./dist/styles/fonts/index.css",
"./styles/react-day-picker": "./dist/styles/react-day-picker.css",
"./switch": "./dist/switch/index.js",
"./table": "./dist/table/index.js",
"./tabs": "./dist/tabs/index.js",
"./toast": "./dist/toast/index.js",
"./toolbar": "./dist/toolbar/index.js",
"./tooltip": "./dist/tooltip/index.js",
"./utils": "./dist/utils/index.js"
"./switch": {
"types": "./dist/switch/index.d.mts",
"import": "./dist/switch/index.mjs",
"require": "./dist/switch/index.js"
},
"./table": {
"types": "./dist/table/index.d.mts",
"import": "./dist/table/index.mjs",
"require": "./dist/table/index.js"
},
"./tabs": {
"types": "./dist/tabs/index.d.mts",
"import": "./dist/tabs/index.mjs",
"require": "./dist/tabs/index.js"
},
"./toast": {
"types": "./dist/toast/index.d.mts",
"import": "./dist/toast/index.mjs",
"require": "./dist/toast/index.js"
},
"./toolbar": {
"types": "./dist/toolbar/index.d.mts",
"import": "./dist/toolbar/index.mjs",
"require": "./dist/toolbar/index.js"
},
"./tooltip": {
"types": "./dist/tooltip/index.d.mts",
"import": "./dist/tooltip/index.mjs",
"require": "./dist/tooltip/index.js"
},
"./utils": {
"types": "./dist/utils/index.d.mts",
"import": "./dist/utils/index.mjs",
"require": "./dist/utils/index.js"
}
},
"dependencies": {
"@base-ui-components/react": "^1.0.0-beta.2",
+2
View File
@@ -34,5 +34,7 @@ export default defineConfig({
outDir: "dist",
format: ["esm", "cjs"],
dts: true,
clean: true,
sourcemap: false,
copy: ["src/styles"],
});
+7 -6
View File
@@ -3,12 +3,13 @@
"version": "1.0.0",
"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",
+3
View File
@@ -4,4 +4,7 @@ export default defineConfig({
entry: ["src/index.ts"],
outDir: "dist",
format: ["esm", "cjs"],
dts: true,
clean: true,
sourcemap: false,
});
+8
View File
@@ -15,13 +15,21 @@
"clean": "rm -rf .turbo && rm -rf .next && rm -rf node_modules && rm -rf dist"
},
"dependencies": {
"@plane/constants": "workspace:*",
"@plane/types": "workspace:*",
"@plane/utils": "workspace:*",
"lodash": "catalog:",
"mobx": "catalog:",
"mobx-utils": "catalog:",
"uuid": "catalog:",
"zod": "^3.22.2"
},
"devDependencies": {
"@plane/eslint-config": "workspace:*",
"@plane/typescript-config": "workspace:*",
"@types/node": "^22.5.4",
"@types/lodash": "catalog:",
"@types/uuid": "catalog:",
"typescript": "catalog:"
}
}
+2
View File
@@ -0,0 +1,2 @@
export * from "./store";
export * from "./utils";
+1
View File
@@ -0,0 +1 @@
export * from "./rich-filters";
@@ -0,0 +1,31 @@
// plane imports
import { IFilterAdapter, TExternalFilter, TFilterExpression, TFilterProperty } from "@plane/types";
/**
* Abstract base class for converting between external filter formats and internal filter expressions.
* Provides common utilities for creating and manipulating filter nodes.
*
* @template K - Property key type that extends TFilterProperty
* @template E - External filter type that extends TExternalFilter
*/
export abstract class FilterAdapter<K extends TFilterProperty, E extends TExternalFilter>
implements IFilterAdapter<K, E>
{
/**
* Converts an external filter format to internal filter expression.
* Must be implemented by concrete adapter classes.
*
* @param externalFilter - The external filter to convert
* @returns The internal filter expression or null if conversion fails
*/
abstract toInternal(externalFilter: E): TFilterExpression<K> | null;
/**
* Converts an internal filter expression to external filter format.
* Must be implemented by concrete adapter classes.
*
* @param internalFilter - The internal filter expression to convert
* @returns The external filter format
*/
abstract toExternal(internalFilter: TFilterExpression<K> | null): E;
}
@@ -0,0 +1,173 @@
import { action, computed, makeObservable, observable } from "mobx";
import { computedFn } from "mobx-utils";
// plane imports
import { DEFAULT_FILTER_CONFIG_OPTIONS, TConfigOptions } from "@plane/constants";
import { TExternalFilter, TFilterConfig, TFilterProperty, TFilterValue } from "@plane/types";
// local imports
import { FilterConfig, IFilterConfig } from "./config";
import { IFilterInstance } from "./filter";
/**
* Interface for managing filter configurations.
* Provides methods to register, update, and retrieve filter configurations.
* - filterConfigs: Map storing filter configurations by their ID
* - configOptions: Configuration options controlling filter behavior
* - allConfigs: All registered filter configurations
* - allAvailableConfigs: All available filter configurations based on current state
* - getConfigByProperty: Retrieves a filter configuration by its ID
* - register: Registers a single filter configuration
* - registerAll: Registers multiple filter configurations
* - updateConfigByProperty: Updates an existing filter configuration by ID
* @template P - The filter property type extending TFilterProperty
*/
export interface IFilterConfigManager<P extends TFilterProperty> {
// observables
filterConfigs: Map<P, IFilterConfig<P, TFilterValue>>; // filter property -> config
configOptions: TConfigOptions;
// computed
allAvailableConfigs: IFilterConfig<P, TFilterValue>[];
// computed functions
getConfigByProperty: (property: P) => IFilterConfig<P, TFilterValue> | undefined;
// helpers
register: <C extends TFilterConfig<P, TFilterValue>>(config: C) => void;
registerAll: (configs: TFilterConfig<P, TFilterValue>[]) => void;
updateConfigByProperty: (property: P, configUpdates: Partial<TFilterConfig<P, TFilterValue>>) => void;
}
/**
* Parameters for initializing the FilterConfigManager.
* - options: Optional configuration options to override defaults
*/
export type TConfigManagerParams = {
options?: Partial<TConfigOptions>;
};
/**
* Manages filter configurations for a filter instance.
* Handles registration, updates, and retrieval of filter configurations.
* Provides computed properties for available configurations based on current filter state.
*
* @template P - The filter property type extending TFilterProperty
* @template V - The filter value type extending TFilterValue
* @template E - The external filter type extending TExternalFilter
*/
export class FilterConfigManager<P extends TFilterProperty, E extends TExternalFilter = TExternalFilter>
implements IFilterConfigManager<P>
{
// observables
filterConfigs: IFilterConfigManager<P>["filterConfigs"];
configOptions: IFilterConfigManager<P>["configOptions"];
// parent filter instance
_filterInstance: IFilterInstance<P, E>;
/**
* Creates a new FilterConfigManager instance.
*
* @param filterInstance - The parent filter instance this manager belongs to
* @param params - Configuration parameters for the manager
*/
constructor(filterInstance: IFilterInstance<P, E>, params: TConfigManagerParams) {
this.filterConfigs = new Map<P, IFilterConfig<P>>();
this.configOptions = this._initializeConfigOptions(params.options);
// parent filter instance
this._filterInstance = filterInstance;
makeObservable(this, {
filterConfigs: observable,
configOptions: observable,
// computed
allAvailableConfigs: computed,
// helpers
register: action,
registerAll: action,
updateConfigByProperty: action,
});
}
// ------------ computed ------------
/**
* Returns all available filterConfigs.
* If allowSameFilters is true, all enabled configs are returned.
* Otherwise, only configs that are not already applied to the filter instance are returned.
* @returns All available filterConfigs.
*/
get allAvailableConfigs(): IFilterConfigManager<P>["allAvailableConfigs"] {
const appliedProperties = new Set(this._filterInstance.allConditions.map((condition) => condition.property));
// Return all enabled configs that either allow multiple filters or are not currently applied
return this._allEnabledConfigs.filter((config) => config.allowMultipleFilters || !appliedProperties.has(config.id));
}
// ------------ computed functions ------------
/**
* Returns a config by filter property.
* @param property - The property to get the config for.
* @returns The config for the property, or undefined if not found.
*/
getConfigByProperty: IFilterConfigManager<P>["getConfigByProperty"] = computedFn(
(property) => this.filterConfigs.get(property) as IFilterConfig<P, TFilterValue>
);
// ------------ helpers ------------
/**
* Register a config.
* If a config with the same property already exists, it will be updated with the new values.
* Otherwise, a new config will be created.
* @param configUpdates - The config updates to register.
*/
register: IFilterConfigManager<P>["register"] = action((configUpdates) => {
if (this.filterConfigs.has(configUpdates.id)) {
// Update existing config if it has differences
const existingConfig = this.filterConfigs.get(configUpdates.id)!;
existingConfig.mutate(configUpdates);
} else {
// Create new config if it doesn't exist
this.filterConfigs.set(configUpdates.id, new FilterConfig(configUpdates));
}
});
/**
* Register all configs.
* @param configs - The configs to register.
*/
registerAll: IFilterConfigManager<P>["registerAll"] = action((configs) => {
configs.forEach((config) => this.register(config));
});
/**
* Updates a config by filter property.
* @param property - The property of the config to update.
* @param configUpdates - The updates to apply to the config.
*/
updateConfigByProperty: IFilterConfigManager<P>["updateConfigByProperty"] = action((property, configUpdates) => {
const prevConfig = this.filterConfigs.get(property);
prevConfig?.mutate(configUpdates);
});
// ------------ private computed ------------
private get _allConfigs(): IFilterConfig<P, TFilterValue>[] {
return Array.from(this.filterConfigs.values());
}
/**
* Returns all enabled filterConfigs.
* @returns All enabled filterConfigs.
*/
private get _allEnabledConfigs(): IFilterConfig<P, TFilterValue>[] {
return this._allConfigs.filter((config) => config.isEnabled);
}
// ------------ private helpers ------------
/**
* Initializes the config options.
* @param options - The options to initialize the config options with.
* @returns The initialized config options.
*/
private _initializeConfigOptions(options?: Partial<TConfigOptions>): TConfigOptions {
return DEFAULT_FILTER_CONFIG_OPTIONS ? { ...DEFAULT_FILTER_CONFIG_OPTIONS, ...options } : options || {};
}
}
@@ -0,0 +1,212 @@
import set from "lodash/set";
import { action, computed, makeObservable, observable, runInAction } from "mobx";
import { computedFn } from "mobx-utils";
// plane imports
import { EMPTY_OPERATOR_LABEL } from "@plane/constants";
import {
FILTER_FIELD_TYPE,
TSupportedOperators,
TFilterConfig,
TFilterProperty,
TFilterValue,
TOperatorSpecificConfigs,
TAllAvailableOperatorsForDisplay,
} from "@plane/types";
import {
getOperatorLabel,
isDateFilterType,
getDateOperatorLabel,
isDateFilterOperator,
getOperatorForPayload,
} from "@plane/utils";
type TOperatorOptionForDisplay = {
value: TAllAvailableOperatorsForDisplay;
label: string;
};
export interface IFilterConfig<P extends TFilterProperty, V extends TFilterValue = TFilterValue>
extends TFilterConfig<P, V> {
// computed
allSupportedOperators: TSupportedOperators[];
allSupportedOperatorConfigs: TOperatorSpecificConfigs<V>[keyof TOperatorSpecificConfigs<V>][];
firstOperator: TSupportedOperators | undefined;
// computed functions
getOperatorConfig: (
operator: TAllAvailableOperatorsForDisplay
) => TOperatorSpecificConfigs<V>[keyof TOperatorSpecificConfigs<V>] | undefined;
getLabelForOperator: (operator: TAllAvailableOperatorsForDisplay | undefined) => string;
getDisplayOperatorByValue: <T extends TSupportedOperators | TAllAvailableOperatorsForDisplay>(
operator: T,
value: V
) => T;
getAllDisplayOperatorOptionsByValue: (value: V) => TOperatorOptionForDisplay[];
// actions
mutate: (updates: Partial<TFilterConfig<P, V>>) => void;
}
export class FilterConfig<P extends TFilterProperty, V extends TFilterValue = TFilterValue>
implements IFilterConfig<P, V>
{
// observables
id: IFilterConfig<P, V>["id"];
label: IFilterConfig<P, V>["label"];
icon?: IFilterConfig<P, V>["icon"];
isEnabled: IFilterConfig<P, V>["isEnabled"];
supportedOperatorConfigsMap: IFilterConfig<P, V>["supportedOperatorConfigsMap"];
allowMultipleFilters: IFilterConfig<P, V>["allowMultipleFilters"];
/**
* Creates a new FilterConfig instance.
* @param params - The parameters for the filter config.
*/
constructor(params: TFilterConfig<P, V>) {
this.id = params.id;
this.label = params.label;
this.icon = params.icon;
this.isEnabled = params.isEnabled;
this.supportedOperatorConfigsMap = params.supportedOperatorConfigsMap;
this.allowMultipleFilters = params.allowMultipleFilters;
makeObservable(this, {
id: observable,
label: observable,
icon: observable,
isEnabled: observable,
supportedOperatorConfigsMap: observable,
allowMultipleFilters: observable,
// computed
allSupportedOperators: computed,
allSupportedOperatorConfigs: computed,
firstOperator: computed,
// actions
mutate: action,
});
}
// ------------ computed ------------
/**
* Returns all supported operators.
* @returns All supported operators.
*/
get allSupportedOperators(): IFilterConfig<P, V>["allSupportedOperators"] {
return Array.from(this.supportedOperatorConfigsMap.keys());
}
/**
* Returns all supported operator configs.
* @returns All supported operator configs.
*/
get allSupportedOperatorConfigs(): IFilterConfig<P, V>["allSupportedOperatorConfigs"] {
return Array.from(this.supportedOperatorConfigsMap.values());
}
/**
* Returns the first operator.
* @returns The first operator.
*/
get firstOperator(): IFilterConfig<P, V>["firstOperator"] {
return this.allSupportedOperators[0];
}
// ------------ computed functions ------------
/**
* Returns the operator config.
* @param operator - The operator.
* @returns The operator config.
*/
getOperatorConfig: IFilterConfig<P, V>["getOperatorConfig"] = computedFn((operator) =>
this.supportedOperatorConfigsMap.get(getOperatorForPayload(operator).operator)
);
/**
* Returns the label for an operator.
* @param operator - The operator.
* @returns The label for the operator.
*/
getLabelForOperator: IFilterConfig<P, V>["getLabelForOperator"] = computedFn((operator) => {
if (!operator) return EMPTY_OPERATOR_LABEL;
const operatorConfig = this.getOperatorConfig(operator);
if (operatorConfig?.operatorLabel) {
return operatorConfig.operatorLabel;
}
if (operatorConfig?.type && isDateFilterType(operatorConfig.type) && isDateFilterOperator(operator)) {
return getDateOperatorLabel(operator);
}
return getOperatorLabel(operator);
});
/**
* Returns the operator for a value.
* @param value - The value.
* @returns The operator for the value.
*/
getDisplayOperatorByValue: IFilterConfig<P, V>["getDisplayOperatorByValue"] = computedFn((operator, value) => {
const operatorConfig = this.getOperatorConfig(operator);
if (operatorConfig?.type === FILTER_FIELD_TYPE.MULTI_SELECT && (Array.isArray(value) ? value.length : 0) <= 1) {
return operatorConfig.singleValueOperator as typeof operator;
}
return operator;
});
/**
* Returns all supported operator options for display in the filter UI.
* This method filters out operators that are already applied (unless multiple filters are allowed)
* and includes both positive and negative variants when supported.
*
* @param value - The current filter value used to determine the appropriate operator variant
* @returns Array of operator options with their display labels and values
*/
getAllDisplayOperatorOptionsByValue: IFilterConfig<P, V>["getAllDisplayOperatorOptionsByValue"] = computedFn(
(value) => {
const operatorOptions: TOperatorOptionForDisplay[] = [];
// Process each supported operator to build display options
for (const operator of this.allSupportedOperators) {
const displayOperator = this.getDisplayOperatorByValue(operator, value);
const displayOperatorLabel = this.getLabelForOperator(displayOperator);
operatorOptions.push({
value: operator,
label: displayOperatorLabel,
});
const additionalOperatorOption = this._getAdditionalOperatorOptions(operator, value);
if (additionalOperatorOption) {
operatorOptions.push(additionalOperatorOption);
}
}
return operatorOptions;
}
);
// ------------ actions ------------
/**
* Mutates the config.
* @param updates - The updates to apply to the config.
*/
mutate: IFilterConfig<P, V>["mutate"] = action((updates) => {
runInAction(() => {
for (const key in updates) {
if (updates.hasOwnProperty(key)) {
const configKey = key as keyof TFilterConfig<P, V>;
set(this, configKey, updates[configKey]);
}
}
});
});
// ------------ private helpers ------------
private _getAdditionalOperatorOptions = (
_operator: TSupportedOperators,
_value: V
): TOperatorOptionForDisplay | undefined => undefined;
}
@@ -0,0 +1,172 @@
import cloneDeep from "lodash/cloneDeep";
import { toJS } from "mobx";
// plane imports
import { DEFAULT_FILTER_EXPRESSION_OPTIONS, TExpressionOptions } from "@plane/constants";
import {
IFilterAdapter,
LOGICAL_OPERATOR,
TSupportedOperators,
TFilterExpression,
TFilterValue,
TFilterProperty,
TExternalFilter,
TLogicalOperator,
TFilterConditionPayload,
} from "@plane/types";
import { addAndCondition, createConditionNode, updateNodeInExpression } from "@plane/utils";
/**
* Interface for filter instance helper utilities.
* Provides comprehensive methods for filter expression manipulation, node operations,
* operator utilities, and expression restructuring.
* @template P - The filter property type extending TFilterProperty
* @template E - The external filter type extending TExternalFilter
*/
export interface IFilterInstanceHelper<P extends TFilterProperty, E extends TExternalFilter> {
// initialization
initializeExpression: (initialExpression?: E) => TFilterExpression<P> | null;
initializeExpressionOptions: (expressionOptions?: Partial<TExpressionOptions<E>>) => TExpressionOptions<E>;
// condition operations
addConditionToExpression: <V extends TFilterValue>(
expression: TFilterExpression<P> | null,
groupOperator: TLogicalOperator,
condition: TFilterConditionPayload<P, V>,
isNegation: boolean
) => TFilterExpression<P> | null;
// group operations
restructureExpressionForOperatorChange: (
expression: TFilterExpression<P> | null,
conditionId: string,
newOperator: TSupportedOperators,
isNegation: boolean,
shouldResetValue: boolean
) => TFilterExpression<P> | null;
}
/**
* Comprehensive helper class for filter instance operations.
* Provides utilities for filter expression manipulation, node operations,
* operator transformations, and expression restructuring.
*
* @template K - The filter property type extending TFilterProperty
* @template E - The external filter type extending TExternalFilter
*/
export class FilterInstanceHelper<P extends TFilterProperty, E extends TExternalFilter>
implements IFilterInstanceHelper<P, E>
{
private adapter: IFilterAdapter<P, E>;
/**
* Creates a new FilterInstanceHelper instance.
*
* @param adapter - The filter adapter for converting between internal and external formats
*/
constructor(adapter: IFilterAdapter<P, E>) {
this.adapter = adapter;
}
// ------------ initialization ------------
/**
* Initializes the filter expression from external format.
* @param initialExpression - The initial expression to initialize the filter with
* @returns The initialized filter expression or null if no initial expression provided
*/
initializeExpression: IFilterInstanceHelper<P, E>["initializeExpression"] = (initialExpression) => {
if (!initialExpression) return null;
return this.adapter.toInternal(toJS(cloneDeep(initialExpression)));
};
/**
* Initializes the filter expression options with defaults.
* @param expressionOptions - Optional expression options to override defaults
* @returns The initialized filter expression options
*/
initializeExpressionOptions: IFilterInstanceHelper<P, E>["initializeExpressionOptions"] = (expressionOptions) => ({
...DEFAULT_FILTER_EXPRESSION_OPTIONS,
...expressionOptions,
});
// ------------ condition operations ------------
/**
* Adds a condition to the filter expression based on the logical operator.
* @param expression - The current filter expression
* @param groupOperator - The logical operator to use for the condition
* @param condition - The condition to add
* @param isNegation - Whether the condition should be negated
* @returns The updated filter expression
*/
addConditionToExpression: IFilterInstanceHelper<P, E>["addConditionToExpression"] = (
expression,
groupOperator,
condition,
isNegation
) => this._addConditionByOperator(expression, groupOperator, this._getConditionPayloadToAdd(condition, isNegation));
// ------------ group operations ------------
/**
* Restructures the expression when a condition's operator changes between positive and negative.
* @param expression - The filter expression to operate on
* @param conditionId - The ID of the condition being updated
* @param newOperator - The new operator for the condition
* @param isNegation - Whether the operator is negation
* @param shouldResetValue - Whether to reset the condition value
* @returns The restructured expression
*/
restructureExpressionForOperatorChange: IFilterInstanceHelper<P, E>["restructureExpressionForOperatorChange"] = (
expression,
conditionId,
newOperator,
_isNegation,
shouldResetValue
) => {
if (!expression) return null;
const payload = shouldResetValue ? { operator: newOperator, value: undefined } : { operator: newOperator };
// Update the condition with the new operator
updateNodeInExpression(expression, conditionId, payload);
return expression;
};
// ------------ private helpers ------------
/**
* Gets the condition payload to add to the expression.
* @param conditionNode - The condition node to add
* @param isNegation - Whether the condition should be negated
* @returns The condition payload to add
*/
private _getConditionPayloadToAdd = (
condition: TFilterConditionPayload<P, TFilterValue>,
_isNegation: boolean
): TFilterExpression<P> => {
const conditionNode = createConditionNode(condition);
return conditionNode;
};
/**
* Handles the logical operator switch for adding conditions.
* @param expression - The current expression
* @param groupOperator - The logical operator
* @param conditionToAdd - The condition to add
* @returns The updated expression
*/
private _addConditionByOperator(
expression: TFilterExpression<P> | null,
groupOperator: TLogicalOperator,
conditionToAdd: TFilterExpression<P>
): TFilterExpression<P> | null {
switch (groupOperator) {
case LOGICAL_OPERATOR.AND:
return addAndCondition(expression, conditionToAdd);
default:
console.warn(`Unsupported logical operator: ${groupOperator}`);
return expression;
}
}
}
@@ -0,0 +1,490 @@
import cloneDeep from "lodash/cloneDeep";
import { action, computed, makeObservable, observable, toJS } from "mobx";
import { computedFn } from "mobx-utils";
import { v4 as uuidv4 } from "uuid";
// plane imports
import {
TClearFilterOptions,
TExpressionOptions,
TFilterOptions,
TSaveViewOptions,
TUpdateViewOptions,
} from "@plane/constants";
import {
FILTER_NODE_TYPE,
IFilterAdapter,
SingleOrArray,
TAllAvailableOperatorsForDisplay,
TExternalFilter,
TFilterConditionNode,
TFilterConditionNodeForDisplay,
TFilterConditionPayload,
TFilterExpression,
TFilterProperty,
TFilterValue,
TLogicalOperator,
TSupportedOperators,
} from "@plane/types";
// local imports
import {
deepCompareFilterExpressions,
extractConditions,
extractConditionsWithDisplayOperators,
findConditionsByPropertyAndOperator,
findNodeById,
hasValidValue,
removeNodeFromExpression,
sanitizeAndStabilizeExpression,
shouldNotifyChangeForExpression,
updateNodeInExpression,
} from "@plane/utils";
import { FilterConfigManager, IFilterConfigManager } from "./config-manager";
import { FilterInstanceHelper, IFilterInstanceHelper } from "./filter-helpers";
/**
* Interface for a filter instance.
* Provides methods to manage the filter expression and notify changes.
* - id: The id of the filter instance
* - expression: The filter expression
* - adapter: The filter adapter
* - configManager: The filter config manager
* - onExpressionChange: The callback to notify when the expression changes
* - hasActiveFilters: Whether the filter instance has any active filters
* - allConditions: All conditions in the filter expression
* - allConditionsForDisplay: All conditions in the filter expression
* - addCondition: Adds a condition to the filter expression
* - updateConditionOperator: Updates the operator of a condition in the filter expression
* - updateConditionValue: Updates the value of a condition in the filter expression
* - removeCondition: Removes a condition from the filter expression
* - clearFilters: Clears the filter expression
* @template P - The filter property type extending TFilterProperty
* @template E - The external filter type extending TExternalFilter
*/
export interface IFilterInstance<P extends TFilterProperty, E extends TExternalFilter> {
// observables
id: string;
initialFilterExpression: TFilterExpression<P> | null;
expression: TFilterExpression<P> | null;
adapter: IFilterAdapter<P, E>;
configManager: IFilterConfigManager<P>;
onExpressionChange?: (expression: E) => void;
// computed
hasActiveFilters: boolean;
hasChanges: boolean;
allConditions: TFilterConditionNode<P, TFilterValue>[];
allConditionsForDisplay: TFilterConditionNodeForDisplay<P, TFilterValue>[];
// computed option helpers
clearFilterOptions: TClearFilterOptions | undefined;
saveViewOptions: TSaveViewOptions<E> | undefined;
updateViewOptions: TUpdateViewOptions<E> | undefined;
// computed permissions
canClearFilters: boolean;
canSaveView: boolean;
canUpdateView: boolean;
// filter expression actions
resetExpression: (externalExpression: E, shouldResetInitialExpression?: boolean) => void;
// filter condition
findConditionsByPropertyAndOperator: (
property: P,
operator: TAllAvailableOperatorsForDisplay
) => TFilterConditionNodeForDisplay<P, TFilterValue>[];
findFirstConditionByPropertyAndOperator: (
property: P,
operator: TAllAvailableOperatorsForDisplay
) => TFilterConditionNodeForDisplay<P, TFilterValue> | undefined;
addCondition: <V extends TFilterValue>(
groupOperator: TLogicalOperator,
condition: TFilterConditionPayload<P, V>,
isNegation: boolean
) => void;
updateConditionOperator: (conditionId: string, operator: TSupportedOperators, isNegation: boolean) => void;
updateConditionValue: <V extends TFilterValue>(conditionId: string, value: SingleOrArray<V>) => void;
removeCondition: (conditionId: string) => void;
// config actions
clearFilters: () => Promise<void>;
saveView: () => Promise<void>;
updateView: () => Promise<void>;
// expression options actions
updateExpressionOptions: (newOptions: Partial<TExpressionOptions<E>>) => void;
}
export type TFilterParams<P extends TFilterProperty, E extends TExternalFilter> = {
adapter: IFilterAdapter<P, E>;
options?: Partial<TFilterOptions<E>>;
initialExpression?: E;
onExpressionChange?: (expression: E) => void;
};
export class FilterInstance<P extends TFilterProperty, E extends TExternalFilter> implements IFilterInstance<P, E> {
// observables
id: string;
initialFilterExpression: TFilterExpression<P> | null;
expression: TFilterExpression<P> | null;
expressionOptions: TExpressionOptions<E>;
adapter: IFilterAdapter<P, E>;
configManager: IFilterConfigManager<P>;
onExpressionChange?: (expression: E) => void;
// helper instance
private helper: IFilterInstanceHelper<P, E>;
constructor(params: TFilterParams<P, E>) {
this.id = uuidv4();
this.adapter = params.adapter;
this.helper = new FilterInstanceHelper<P, E>(this.adapter);
this.configManager = new FilterConfigManager<P, E>(this, {
options: params.options?.config,
});
// initialize expression
const initialExpression = this.helper.initializeExpression(params.initialExpression);
this.initialFilterExpression = cloneDeep(initialExpression);
this.expression = cloneDeep(initialExpression);
this.expressionOptions = this.helper.initializeExpressionOptions(params.options?.expression);
this.onExpressionChange = params.onExpressionChange;
makeObservable(this, {
// observables
id: observable,
initialFilterExpression: observable,
expression: observable,
expressionOptions: observable,
adapter: observable,
configManager: observable,
// computed
hasActiveFilters: computed,
hasChanges: computed,
allConditions: computed,
allConditionsForDisplay: computed,
// computed option helpers
clearFilterOptions: computed,
saveViewOptions: computed,
updateViewOptions: computed,
// computed permissions
canClearFilters: computed,
canSaveView: computed,
canUpdateView: computed,
// actions
resetExpression: action,
findConditionsByPropertyAndOperator: action,
findFirstConditionByPropertyAndOperator: action,
addCondition: action,
updateConditionOperator: action,
updateConditionValue: action,
removeCondition: action,
clearFilters: action,
saveView: action,
updateView: action,
updateExpressionOptions: action,
});
}
// ------------ computed ------------
/**
* Checks if the filter instance has any active filters.
* @returns True if the filter instance has any active filters, false otherwise.
*/
get hasActiveFilters(): IFilterInstance<P, E>["hasActiveFilters"] {
// if the expression is null, return false
if (!this.expression) return false;
// if there are no conditions, return false
if (this.allConditionsForDisplay.length === 0) return false;
// if there are conditions, return true if any of them have a valid value
return this.allConditionsForDisplay.some((condition) => hasValidValue(condition.value));
}
/**
* Checks if the filter instance has any changes with respect to the initial expression.
* @returns True if the filter instance has any changes, false otherwise.
*/
get hasChanges(): IFilterInstance<P, E>["hasChanges"] {
return !deepCompareFilterExpressions(this.initialFilterExpression, this.expression);
}
/**
* Returns all conditions from the filter expression.
* @returns An array of filter conditions.
*/
get allConditions(): IFilterInstance<P, E>["allConditions"] {
if (!this.expression) return [];
return extractConditions(this.expression);
}
/**
* Returns all conditions in the filter expression for display purposes.
* @returns An array of filter conditions for display purposes.
*/
get allConditionsForDisplay(): IFilterInstance<P, E>["allConditionsForDisplay"] {
if (!this.expression) return [];
return extractConditionsWithDisplayOperators(this.expression);
}
// ------------ computed option helpers ------------
/**
* Returns the clear filter options.
* @returns The clear filter options.
*/
get clearFilterOptions(): IFilterInstance<P, E>["clearFilterOptions"] {
return this.expressionOptions.clearFilterOptions;
}
/**
* Returns the save view options.
* @returns The save view options.
*/
get saveViewOptions(): IFilterInstance<P, E>["saveViewOptions"] {
return this.expressionOptions.saveViewOptions;
}
/**
* Returns the update view options.
* @returns The update view options.
*/
get updateViewOptions(): IFilterInstance<P, E>["updateViewOptions"] {
return this.expressionOptions.updateViewOptions;
}
// ------------ computed permissions ------------
/**
* Checks if the filter expression can be cleared.
* @returns True if the filter expression can be cleared, false otherwise.
*/
get canClearFilters(): IFilterInstance<P, E>["canClearFilters"] {
if (!this.expression) return false;
if (this.allConditionsForDisplay.length === 0) return false;
return this.clearFilterOptions ? !this.clearFilterOptions.isDisabled : true;
}
/**
* Checks if the filter expression can be saved as a view.
* @returns True if the filter instance can be saved, false otherwise.
*/
get canSaveView(): IFilterInstance<P, E>["canSaveView"] {
return this.hasActiveFilters && !!this.saveViewOptions && !this.saveViewOptions.isDisabled;
}
/**
* Checks if the filter expression can be updated as a view.
* @returns True if the filter expression can be updated, false otherwise.
*/
get canUpdateView(): IFilterInstance<P, E>["canUpdateView"] {
return (
!!this.updateViewOptions &&
(this.hasChanges || !!this.updateViewOptions.hasAdditionalChanges) &&
!this.updateViewOptions.isDisabled
);
}
// ------------ actions ------------
/**
* Resets the filter expression to the initial expression.
* @param externalExpression - The external expression to reset to.
*/
resetExpression: IFilterInstance<P, E>["resetExpression"] = action(
(externalExpression, shouldResetInitialExpression = true) => {
this.expression = this.helper.initializeExpression(externalExpression);
if (shouldResetInitialExpression) {
this._resetInitialFilterExpression();
}
this._notifyExpressionChange();
}
);
/**
* Finds all conditions by property and operator.
* @param property - The property to find the conditions by.
* @param operator - The operator to find the conditions by.
* @returns All the conditions that match the property and operator.
*/
findConditionsByPropertyAndOperator: IFilterInstance<P, E>["findConditionsByPropertyAndOperator"] = action(
(property, operator) => {
if (!this.expression) return [];
return findConditionsByPropertyAndOperator(this.expression, property, operator);
}
);
/**
* Finds the first condition by property and operator.
* @param property - The property to find the condition by.
* @param operator - The operator to find the condition by.
* @returns The first condition that matches the property and operator.
*/
findFirstConditionByPropertyAndOperator: IFilterInstance<P, E>["findFirstConditionByPropertyAndOperator"] = action(
(property, operator) => {
if (!this.expression) return undefined;
const conditions = findConditionsByPropertyAndOperator(this.expression, property, operator);
return conditions[0];
}
);
/**
* Adds a condition to the filter expression.
* @param groupOperator - The logical operator to use for the condition.
* @param condition - The condition to add.
* @param isNegation - Whether the condition should be negated.
*/
addCondition: IFilterInstance<P, E>["addCondition"] = action((groupOperator, condition, isNegation = false) => {
const conditionValue = condition.value;
this.expression = this.helper.addConditionToExpression(this.expression, groupOperator, condition, isNegation);
if (hasValidValue(conditionValue)) {
this._notifyExpressionChange();
}
});
/**
* Updates the operator of a condition in the filter expression.
* @param conditionId - The id of the condition to update.
* @param operator - The new operator for the condition.
*/
updateConditionOperator: IFilterInstance<P, E>["updateConditionOperator"] = action(
(conditionId: string, operator: TSupportedOperators, isNegation: boolean) => {
if (!this.expression) return;
const conditionBeforeUpdate = cloneDeep(findNodeById(this.expression, conditionId));
if (!conditionBeforeUpdate || conditionBeforeUpdate.type !== FILTER_NODE_TYPE.CONDITION) return;
// Get the operator configs for the current and new operators
const currentOperatorConfig = this.configManager
.getConfigByProperty(conditionBeforeUpdate.property)
?.getOperatorConfig(conditionBeforeUpdate.operator);
const newOperatorConfig = this.configManager
.getConfigByProperty(conditionBeforeUpdate.property)
?.getOperatorConfig(operator);
// Reset the value if the operator config types are different
const shouldResetConditionValue = currentOperatorConfig?.type !== newOperatorConfig?.type;
// Use restructuring logic for operator changes
const updatedExpression = this.helper.restructureExpressionForOperatorChange(
this.expression,
conditionId,
operator,
isNegation,
shouldResetConditionValue
);
if (updatedExpression) {
this.expression = updatedExpression;
}
if (hasValidValue(conditionBeforeUpdate.value)) {
this._notifyExpressionChange();
}
}
);
/**
* Updates the value of a condition in the filter expression with automatic optimization.
* @param conditionId - The id of the condition to update.
* @param value - The new value for the condition.
*/
updateConditionValue: IFilterInstance<P, E>["updateConditionValue"] = action(
<V extends TFilterValue>(conditionId: string, value: SingleOrArray<V>) => {
// If the expression is not valid, return
if (!this.expression) return;
// If the value is not valid, remove the condition
if (!hasValidValue(value)) {
this.removeCondition(conditionId);
return;
}
// Update the condition value
updateNodeInExpression(this.expression, conditionId, {
value,
});
// Notify the change
this._notifyExpressionChange();
}
);
/**
* Removes a condition from the filter expression.
* @param conditionId - The id of the condition to remove.
*/
removeCondition: IFilterInstance<P, E>["removeCondition"] = action((conditionId) => {
if (!this.expression) return;
const { expression, shouldNotify } = removeNodeFromExpression(this.expression, conditionId);
this.expression = expression;
if (shouldNotify) {
this._notifyExpressionChange();
}
});
/**
* Clears the filter expression.
*/
clearFilters: IFilterInstance<P, E>["clearFilters"] = action(async () => {
if (this.canClearFilters) {
const shouldNotify = shouldNotifyChangeForExpression(this.expression);
this.expression = null;
await this.clearFilterOptions?.onFilterClear();
if (shouldNotify) {
this._notifyExpressionChange();
}
} else {
console.warn("Cannot clear filters: invalid expression or missing options.");
}
});
/**
* Saves the filter expression.
*/
saveView: IFilterInstance<P, E>["saveView"] = action(async () => {
if (this.canSaveView && this.saveViewOptions) {
await this.saveViewOptions.onViewSave(this._getExternalExpression());
} else {
console.warn("Cannot save view: invalid expression or missing options.");
}
});
/**
* Updates the filter expression.
*/
updateView: IFilterInstance<P, E>["updateView"] = action(async () => {
if (this.canUpdateView && this.updateViewOptions) {
await this.updateViewOptions.onViewUpdate(this._getExternalExpression());
this._resetInitialFilterExpression();
} else {
console.warn("Cannot update view: invalid expression or missing options.");
}
});
/**
* Updates the expression options for the filter instance.
* This allows dynamic updates to options like isDisabled properties.
*/
updateExpressionOptions: IFilterInstance<P, E>["updateExpressionOptions"] = action((newOptions) => {
this.expressionOptions = {
...this.expressionOptions,
...newOptions,
};
});
// ------------ private helpers ------------
/**
* Resets the initial filter expression to the current expression.
*/
private _resetInitialFilterExpression(): void {
this.initialFilterExpression = cloneDeep(this.expression);
}
/**
* Returns the external filter representation of the filter instance.
* @returns The external filter representation of the filter instance.
*/
private _getExternalExpression = computedFn(() =>
this.adapter.toExternal(sanitizeAndStabilizeExpression(toJS(this.expression)))
);
/**
* Notifies the parent component of the expression change.
*/
private _notifyExpressionChange(): void {
this.onExpressionChange?.(this._getExternalExpression());
}
}
@@ -0,0 +1,2 @@
export * from "./adapter";
export * from "./filter";
+1
View File
@@ -0,0 +1 @@
export * from "./rich-filter.helper";
@@ -0,0 +1,47 @@
// plane imports
import {
LOGICAL_OPERATOR,
TBuildFilterExpressionParams,
TExternalFilter,
TFilterProperty,
TFilterValue,
} from "@plane/types";
import { getOperatorForPayload } from "@plane/utils";
// local imports
import { FilterInstance } from "../store/rich-filters/filter";
/**
* Builds a temporary filter expression from conditions.
* @param params.conditions - The conditions for building the filter expression.
* @param params.adapter - The adapter for building the filter expression.
* @returns The temporary filter expression.
*/
export const buildTempFilterExpressionFromConditions = <
P extends TFilterProperty,
V extends TFilterValue,
E extends TExternalFilter,
>(
params: TBuildFilterExpressionParams<P, V, E>
): E | undefined => {
const { conditions, adapter } = params;
let tempExpression: E | undefined = undefined;
const tempFilterInstance = new FilterInstance<P, E>({
adapter,
onExpressionChange: (expression) => {
tempExpression = expression;
},
});
for (const condition of conditions) {
const { operator, isNegation } = getOperatorForPayload(condition.operator);
tempFilterInstance.addCondition(
LOGICAL_OPERATOR.AND,
{
property: condition.property,
operator,
value: condition.value,
},
isNegation
);
}
return tempExpression;
};
+2 -8
View File
@@ -3,17 +3,11 @@
"version": "1.0.0",
"license": "AGPL-3.0",
"private": true,
"files": [
"dist"
],
"types": "./dist/index.d.ts",
"main": "./dist/index.js",
"module": "./dist/index.js",
"exports": {
".": {
"types": "./dist/index.d.ts",
"types": "./dist/index.d.mts",
"require": "./dist/index.js",
"import": "./dist/index.js"
"import": "./dist/index.mjs"
}
},
"scripts": {
+39 -38
View File
@@ -1,49 +1,50 @@
export * from "./users";
export * from "./workspace";
export * from "./cycle";
export * from "./dashboard";
export * from "./de-dupe";
export * from "./description_version";
export * from "./enums";
export * from "./project";
export * from "./state";
export * from "./issues";
export * from "./module";
export * from "./views";
export * from "./integration";
export * from "./page";
export * from "./activity";
export * from "./ai";
export * from "./estimate";
export * from "./importer";
export * from "./inbox";
export * from "./analytics";
export * from "./api_token";
export * from "./auth";
export * from "./calendar";
export * from "./instance";
export * from "./issues/base"; // TODO: Remove this after development and the refactor/mobx-store-issue branch is stable
export * from "./reaction";
export * from "./view-props";
export * from "./waitlist";
export * from "./webhook";
export * from "./workspace-views";
export * from "./charts";
export * from "./command-palette";
export * from "./common";
export * from "./cycle";
export * from "./dashboard";
export * from "./de-dupe";
export * from "./description_version";
export * from "./editor";
export * from "./pragmatic";
export * from "./publish";
export * from "./search";
export * from "./workspace-notifications";
export * from "./enums";
export * from "./epics";
export * from "./estimate";
export * from "./favorite";
export * from "./file";
export * from "./workspace-draft-issues/base";
export * from "./command-palette";
export * from "./timezone";
export * from "./activity";
export * from "./epics";
export * from "./charts";
export * from "./home";
export * from "./stickies";
export * from "./utils";
export * from "./payment";
export * from "./importer";
export * from "./inbox";
export * from "./instance";
export * from "./integration";
export * from "./issues";
export * from "./issues/base"; // TODO: Remove this after development and the refactor/mobx-store-issue branch is stable
export * from "./layout";
export * from "./analytics";
export * from "./module";
export * from "./page";
export * from "./payment";
export * from "./pragmatic";
export * from "./project";
export * from "./publish";
export * from "./reaction";
export * from "./rich-filters";
export * from "./search";
export * from "./state";
export * from "./stickies";
export * from "./timezone";
export * from "./translations";
export * from "./users";
export * from "./utils";
export * from "./view-props";
export * from "./views";
export * from "./waitlist";
export * from "./webhook";
export * from "./workspace";
export * from "./workspace-draft-issues/base";
export * from "./workspace-notifications";
export * from "./workspace-views";
@@ -0,0 +1,23 @@
// local imports
import { TFilterExpression, TFilterProperty } from "./expression";
/**
* External filter format
*/
export type TExternalFilter = Record<string, unknown> | undefined | null;
/**
* Adapter for converting between internal filter trees and external formats.
* @template P - Filter property type (e.g., 'state_id', 'priority', 'assignee')
* @template E - External filter format type (e.g., work item filters, automation filters)
*/
export interface IFilterAdapter<P extends TFilterProperty, E extends TExternalFilter> {
/**
* Converts external format to internal filter tree.
*/
toInternal(externalFilter: E): TFilterExpression<P> | null;
/**
* Converts internal filter tree to external format.
*/
toExternal(internalFilter: TFilterExpression<P> | null): E;
}
@@ -0,0 +1,29 @@
import { SingleOrArray } from "../utils";
import { IFilterAdapter, TExternalFilter } from "./adapter";
import { TFilterProperty, TFilterValue } from "./expression";
import { TAllAvailableOperatorsForDisplay } from "./operators";
/**
* Condition payload for building filter expressions.
* @template P - Property key type
* @template V - Value type
*/
export type TFilterConditionForBuild<P extends TFilterProperty, V extends TFilterValue> = {
property: P;
operator: TAllAvailableOperatorsForDisplay;
value: SingleOrArray<V>;
};
/**
* Parameters for building filter expressions from multiple conditions.
* @template P - Property key type
* @template V - Value type
*/
export type TBuildFilterExpressionParams<
P extends TFilterProperty,
V extends TFilterValue,
E extends TExternalFilter,
> = {
conditions: TFilterConditionForBuild<P, V>[];
adapter: IFilterAdapter<P, E>;
};
@@ -0,0 +1,18 @@
import { TFilterProperty, TFilterValue } from "../expression";
import { TOperatorConfigMap } from "../operator-configs";
/**
* Main filter configuration type for different properties.
* This is the primary configuration type used throughout the application.
*
* @template P - Property key type (e.g., 'state_id', 'priority', 'assignee')
* @template V - Value type for the filter
*/
export type TFilterConfig<P extends TFilterProperty, V extends TFilterValue = TFilterValue> = {
id: P;
label: string;
icon?: React.FC<React.SVGAttributes<SVGElement>>;
isEnabled: boolean;
allowMultipleFilters?: boolean;
supportedOperatorConfigsMap: TOperatorConfigMap<V>;
};
@@ -0,0 +1 @@
export * from "./filter-config";
@@ -0,0 +1,77 @@
import { TFilterValue } from "../expression";
import {
TDateFilterFieldConfig,
TDateRangeFilterFieldConfig,
TSingleSelectFilterFieldConfig,
TMultiSelectFilterFieldConfig,
} from "../field-types";
import { TCoreOperatorSpecificConfigs } from "../operator-configs";
import { TFilterOperatorHelper } from "./shared";
// -------- DATE FILTER OPERATORS --------
/**
* Union type representing all core operators that support single date filter types.
*/
export type TCoreSupportedSingleDateFilterOperators<V extends TFilterValue = TFilterValue> = {
[K in keyof TCoreOperatorSpecificConfigs<V>]: TFilterOperatorHelper<
TCoreOperatorSpecificConfigs<V>,
K,
TDateFilterFieldConfig<V>
>;
}[keyof TCoreOperatorSpecificConfigs<V>];
/**
* Union type representing all core operators that support range date filter types.
*/
export type TCoreSupportedRangeDateFilterOperators<V extends TFilterValue = TFilterValue> = {
[K in keyof TCoreOperatorSpecificConfigs<V>]: TFilterOperatorHelper<
TCoreOperatorSpecificConfigs<V>,
K,
TDateRangeFilterFieldConfig<V>
>;
}[keyof TCoreOperatorSpecificConfigs<V>];
/**
* Union type representing all core operators that support date filter types.
*/
export type TCoreSupportedDateFilterOperators<V extends TFilterValue = TFilterValue> =
| TCoreSupportedSingleDateFilterOperators<V>
| TCoreSupportedRangeDateFilterOperators<V>;
export type TCoreAllAvailableDateFilterOperatorsForDisplay<V extends TFilterValue = TFilterValue> =
TCoreSupportedDateFilterOperators<V>;
// -------- SELECT FILTER OPERATORS --------
/**
* Union type representing all core operators that support single select filter types.
*/
export type TCoreSupportedSingleSelectFilterOperators<V extends TFilterValue = TFilterValue> = {
[K in keyof TCoreOperatorSpecificConfigs<V>]: TFilterOperatorHelper<
TCoreOperatorSpecificConfigs<V>,
K,
TSingleSelectFilterFieldConfig<V>
>;
}[keyof TCoreOperatorSpecificConfigs<V>];
/**
* Union type representing all core operators that support multi select filter types.
*/
export type TCoreSupportedMultiSelectFilterOperators<V extends TFilterValue = TFilterValue> = {
[K in keyof TCoreOperatorSpecificConfigs<V>]: TFilterOperatorHelper<
TCoreOperatorSpecificConfigs<V>,
K,
TMultiSelectFilterFieldConfig<V>
>;
}[keyof TCoreOperatorSpecificConfigs<V>];
/**
* Union type representing all core operators that support any select filter types.
*/
export type TCoreSupportedSelectFilterOperators<V extends TFilterValue = TFilterValue> =
| TCoreSupportedSingleSelectFilterOperators<V>
| TCoreSupportedMultiSelectFilterOperators<V>;
export type TCoreAllAvailableSelectFilterOperatorsForDisplay<V extends TFilterValue = TFilterValue> =
TCoreSupportedSelectFilterOperators<V>;
@@ -0,0 +1,19 @@
import { TFilterValue } from "../expression";
// -------- DATE FILTER OPERATORS --------
/**
* Union type representing all extended operators that support date filter types.
*/
export type TExtendedSupportedDateFilterOperators<_V extends TFilterValue = TFilterValue> = never;
export type TExtendedAllAvailableDateFilterOperatorsForDisplay<_V extends TFilterValue = TFilterValue> = never;
// -------- SELECT FILTER OPERATORS --------
/**
* Union type representing all extended operators that support select filter types.
*/
export type TExtendedSupportedSelectFilterOperators<_V extends TFilterValue = TFilterValue> = never;
export type TExtendedAllAvailableSelectFilterOperatorsForDisplay<_V extends TFilterValue = TFilterValue> = never;
@@ -0,0 +1,43 @@
import { TFilterValue } from "../expression";
import {
TCoreAllAvailableDateFilterOperatorsForDisplay,
TCoreAllAvailableSelectFilterOperatorsForDisplay,
TCoreSupportedDateFilterOperators,
TCoreSupportedSelectFilterOperators,
} from "./core";
import {
TExtendedAllAvailableDateFilterOperatorsForDisplay,
TExtendedAllAvailableSelectFilterOperatorsForDisplay,
TExtendedSupportedDateFilterOperators,
TExtendedSupportedSelectFilterOperators,
} from "./extended";
// -------- COMPOSED SUPPORT TYPES --------
/**
* All supported date filter operators.
*/
export type TSupportedDateFilterOperators<V extends TFilterValue = TFilterValue> =
| TCoreSupportedDateFilterOperators<V>
| TExtendedSupportedDateFilterOperators<V>;
export type TAllAvailableDateFilterOperatorsForDisplay<V extends TFilterValue = TFilterValue> =
| TCoreAllAvailableDateFilterOperatorsForDisplay<V>
| TExtendedAllAvailableDateFilterOperatorsForDisplay<V>;
/**
* All supported select filter operators.
*/
export type TSupportedSelectFilterOperators<V extends TFilterValue = TFilterValue> =
| TCoreSupportedSelectFilterOperators<V>
| TExtendedSupportedSelectFilterOperators<V>;
export type TAllAvailableSelectFilterOperatorsForDisplay<V extends TFilterValue = TFilterValue> =
| TCoreAllAvailableSelectFilterOperatorsForDisplay<V>
| TExtendedAllAvailableSelectFilterOperatorsForDisplay<V>;
// -------- RE-EXPORTS --------
export * from "./shared";
export * from "./core";
export * from "./extended";
@@ -0,0 +1,9 @@
/**
* Generic utility type to check if a configuration type supports specific filter types.
* Returns the operator key if any member of the union includes the target filter types, never otherwise.
*/
export type TFilterOperatorHelper<
TOperatorConfigs,
K extends keyof TOperatorConfigs,
TTargetFilter,
> = TTargetFilter extends TOperatorConfigs[K] ? K : TOperatorConfigs[K] extends TTargetFilter ? K : never;
@@ -0,0 +1,110 @@
// local imports
import { SingleOrArray } from "../utils";
import { TSupportedOperators, LOGICAL_OPERATOR, TAllAvailableOperatorsForDisplay } from "./operators";
/**
* Filter node types for building hierarchical filter trees.
* - CONDITION: Single filter for one field (e.g., "state is backlog")
* - GROUP: Logical container combining multiple filters with AND/OR or single filter/group with NOT
*/
export const FILTER_NODE_TYPE = {
CONDITION: "condition",
GROUP: "group",
} as const;
export type TFilterNodeType = (typeof FILTER_NODE_TYPE)[keyof typeof FILTER_NODE_TYPE];
/**
* Field property key that can be filtered (e.g., "state", "assignee", "created_at").
*/
export type TFilterProperty = string;
/**
* Allowed filter values - primitives plus null/undefined for empty states.
*/
export type TFilterValue = string | number | Date | boolean | null | undefined;
/**
* Base properties shared by all filter nodes.
* - id: Unique identifier for the node
* - type: Node type (condition or group)
*/
type TBaseFilterNode = {
id: string;
type: TFilterNodeType;
};
/**
* Leaf node representing a single filter condition (e.g., "state is backlog").
* - type: Node type (condition)
* - property: Field being filtered
* - operator: Comparison operator (is, is not, between, not between, etc.)
* - value: Filter value(s) - array for operators that support multiple values
* @template P - Property key type
* @template V - Value type
*/
export type TFilterConditionNode<P extends TFilterProperty, V extends TFilterValue> = TBaseFilterNode & {
type: typeof FILTER_NODE_TYPE.CONDITION;
property: P;
operator: TSupportedOperators;
value: SingleOrArray<V>;
};
/**
* Filter condition node for display purposes.
*/
export type TFilterConditionNodeForDisplay<P extends TFilterProperty, V extends TFilterValue> = Omit<
TFilterConditionNode<P, V>,
"operator"
> & {
operator: TAllAvailableOperatorsForDisplay;
};
/**
* Container node that combines multiple conditions with AND logical operator.
* - type: Node type (group)
* - logicalOperator: AND operator for combining child filters
* - children: Child conditions and/or nested groups (minimum 2 for meaningful operations)
* @template P - Property key type
*/
export type TFilterAndGroupNode<P extends TFilterProperty> = TBaseFilterNode & {
type: typeof FILTER_NODE_TYPE.GROUP;
logicalOperator: typeof LOGICAL_OPERATOR.AND;
children: TFilterExpression<P>[];
};
/**
* Union type for all group node types - AND, OR, and NOT groups.
* @template P - Property key type
*/
export type TFilterGroupNode<P extends TFilterProperty> = TFilterAndGroupNode<P>;
/**
* Union type for any filter node - either a single condition or a group container.
* @template P - Property key type
* @template V - Value type
*/
export type TFilterExpression<P extends TFilterProperty, V extends TFilterValue = TFilterValue> =
| TFilterConditionNode<P, V>
| TFilterGroupNode<P>;
/**
* Payload for creating/updating condition nodes - excludes base node properties.
* @template P - Property key type
* @template V - Value type
*/
export type TFilterConditionPayload<P extends TFilterProperty, V extends TFilterValue> = Omit<
TFilterConditionNode<P, V>,
keyof TBaseFilterNode
>;
/**
* Payload for creating/updating AND group nodes - excludes base node properties.
* @template P - Property key type
*/
export type TFilterAndGroupPayload<P extends TFilterProperty> = Omit<TFilterAndGroupNode<P>, keyof TBaseFilterNode>;
/**
* Union payload type for creating/updating any group node - excludes base node properties.
* @template P - Property key type
*/
export type TFilterGroupPayload<P extends TFilterProperty> = TFilterAndGroupPayload<P>;
@@ -0,0 +1,79 @@
import { TFilterValue } from "../expression";
import { TSupportedOperators } from "../operators";
import { TBaseFilterFieldConfig, IFilterOption } from "./shared";
/**
* Core filter types
*/
export const CORE_FILTER_FIELD_TYPE = {
DATE: "date",
DATE_RANGE: "date_range",
SINGLE_SELECT: "single_select",
MULTI_SELECT: "multi_select",
} as const;
// -------- DATE FILTER CONFIGURATIONS --------
type TBaseDateFilterFieldConfig = TBaseFilterFieldConfig & {
min?: Date;
max?: Date;
};
/**
* Date filter configuration - for temporal filtering.
* - defaultValue: Initial date/time value
* - min: Minimum allowed date
* - max: Maximum allowed date
*/
export type TDateFilterFieldConfig<V extends TFilterValue> = TBaseDateFilterFieldConfig & {
type: typeof CORE_FILTER_FIELD_TYPE.DATE;
defaultValue?: V;
};
/**
* Date range filter configuration - for temporal filtering.
* - defaultValue: Initial date/time range values
* - min: Minimum allowed date
* - max: Maximum allowed date
*/
export type TDateRangeFilterFieldConfig<V extends TFilterValue> = TBaseDateFilterFieldConfig & {
type: typeof CORE_FILTER_FIELD_TYPE.DATE_RANGE;
defaultValue?: V[];
};
// -------- SELECT FILTER CONFIGURATIONS --------
/**
* Single-select filter configuration - dropdown with one selectable option.
* - defaultValue: Initial selected value
* - getOptions: Options as static array or async function
*/
export type TSingleSelectFilterFieldConfig<V extends TFilterValue> = TBaseFilterFieldConfig & {
type: typeof CORE_FILTER_FIELD_TYPE.SINGLE_SELECT;
defaultValue?: V;
getOptions: IFilterOption<V>[] | (() => IFilterOption<V>[] | Promise<IFilterOption<V>[]>);
};
/**
* Multi-select filter configuration - allows selecting multiple options.
* - defaultValue: Initial selected values array
* - getOptions: Options as static array or async function
* - singleValueOperator: Operator to show when single value is selected
*/
export type TMultiSelectFilterFieldConfig<V extends TFilterValue> = TBaseFilterFieldConfig & {
type: typeof CORE_FILTER_FIELD_TYPE.MULTI_SELECT;
defaultValue?: V[];
getOptions: IFilterOption<V>[] | (() => IFilterOption<V>[] | Promise<IFilterOption<V>[]>);
singleValueOperator: TSupportedOperators;
};
// -------- UNION TYPES --------
/**
* All core filter configurations
*/
export type TCoreFilterFieldConfigs<V extends TFilterValue = TFilterValue> =
| TDateFilterFieldConfig<V>
| TDateRangeFilterFieldConfig<V>
| TSingleSelectFilterFieldConfig<V>
| TMultiSelectFilterFieldConfig<V>;
@@ -0,0 +1,13 @@
import { TFilterValue } from "../expression";
/**
* Extended filter types
*/
export const EXTENDED_FILTER_FIELD_TYPE = {} as const;
// -------- UNION TYPES --------
/**
* All extended filter configurations
*/
export type TExtendedFilterFieldConfigs<_V extends TFilterValue = TFilterValue> = never;
@@ -0,0 +1,27 @@
import { TFilterValue } from "../expression";
import { CORE_FILTER_FIELD_TYPE, TCoreFilterFieldConfigs } from "./core";
import { EXTENDED_FILTER_FIELD_TYPE, TExtendedFilterFieldConfigs } from "./extended";
// -------- COMPOSED FILTER TYPES --------
export const FILTER_FIELD_TYPE = {
...CORE_FILTER_FIELD_TYPE,
...EXTENDED_FILTER_FIELD_TYPE,
} as const;
export type TFilterFieldType = (typeof FILTER_FIELD_TYPE)[keyof typeof FILTER_FIELD_TYPE];
// -------- COMPOSED CONFIGURATIONS --------
/**
* All supported filter configurations.
*/
export type TSupportedFilterFieldConfigs<V extends TFilterValue = TFilterValue> =
| TCoreFilterFieldConfigs<V>
| TExtendedFilterFieldConfigs<V>;
// -------- RE-EXPORTS --------
export * from "./shared";
export * from "./core";
export * from "./extended";
@@ -0,0 +1,37 @@
import { TFilterValue } from "../expression";
/**
* Negative operator configuration for operators.
* - allowNegative: Whether the operator supports negation
* - negOperatorLabel: Label to use when the operator is negated
*/
export type TNegativeOperatorConfig = { allowNegative: true; negOperatorLabel?: string } | { allowNegative?: false };
/**
* Base filter configuration shared by all filter types.
* - operatorLabel: Label to use for the operator
* - negativeOperatorConfig: Configuration for negative operators
*/
export type TBaseFilterFieldConfig = {
operatorLabel?: string;
} & TNegativeOperatorConfig;
/**
* Individual option for select/multi-select filters.
* - id: Unique identifier for the option
* - label: Display text shown to users
* - value: Actual value used in filtering
* - icon: Optional icon component
* - iconClassName: CSS class for icon styling
* - disabled: Whether option can be selected
* - description: Additional context to be displayed in the filter dropdown
*/
export interface IFilterOption<V extends TFilterValue> {
id: string;
label: string;
value: V;
icon?: React.ReactNode;
iconClassName?: string;
disabled?: boolean;
description?: string;
}
+8
View File
@@ -0,0 +1,8 @@
export * from "./adapter";
export * from "./builder";
export * from "./config";
export * from "./derived";
export * from "./expression";
export * from "./operator-configs";
export * from "./operators";
export * from "./field-types";
@@ -0,0 +1,26 @@
import { TFilterValue } from "../expression";
import {
TDateFilterFieldConfig,
TDateRangeFilterFieldConfig,
TSingleSelectFilterFieldConfig,
TMultiSelectFilterFieldConfig,
} from "../field-types";
import { CORE_COLLECTION_OPERATOR, CORE_COMPARISON_OPERATOR, CORE_EQUALITY_OPERATOR } from "../operators";
// ----------------------------- EXACT Operator -----------------------------
export type TCoreExactOperatorConfigs<V extends TFilterValue> =
| TSingleSelectFilterFieldConfig<V>
| TDateFilterFieldConfig<V>;
// ----------------------------- IN Operator -----------------------------
export type TCoreInOperatorConfigs<V extends TFilterValue> = TMultiSelectFilterFieldConfig<V>;
// ----------------------------- RANGE Operator -----------------------------
export type TCoreRangeOperatorConfigs<V extends TFilterValue> = TDateRangeFilterFieldConfig<V>;
// ----------------------------- Core Operator Specific Configs -----------------------------
export type TCoreOperatorSpecificConfigs<V extends TFilterValue> = {
[CORE_EQUALITY_OPERATOR.EXACT]: TCoreExactOperatorConfigs<V>;
[CORE_COLLECTION_OPERATOR.IN]: TCoreInOperatorConfigs<V>;
[CORE_COMPARISON_OPERATOR.RANGE]: TCoreRangeOperatorConfigs<V>;
};
@@ -0,0 +1,13 @@
import { TFilterValue } from "../expression";
// ----------------------------- EXACT Operator -----------------------------
export type TExtendedExactOperatorConfigs<_V extends TFilterValue> = never;
// ----------------------------- IN Operator -----------------------------
export type TExtendedInOperatorConfigs<_V extends TFilterValue> = never;
// ----------------------------- RANGE Operator -----------------------------
export type TExtendedRangeOperatorConfigs<_V extends TFilterValue> = never;
// ----------------------------- Extended Operator Specific Configs -----------------------------
export type TExtendedOperatorSpecificConfigs<_V extends TFilterValue> = unknown;
@@ -0,0 +1,56 @@
import { TFilterValue } from "../expression";
import { EQUALITY_OPERATOR, COLLECTION_OPERATOR, COMPARISON_OPERATOR } from "../operators";
import { TCoreExactOperatorConfigs, TCoreInOperatorConfigs, TCoreRangeOperatorConfigs } from "./core";
import {
TExtendedExactOperatorConfigs,
TExtendedInOperatorConfigs,
TExtendedOperatorSpecificConfigs,
TExtendedRangeOperatorConfigs,
} from "./extended";
// ----------------------------- Composed Operator Configs -----------------------------
/**
* EXACT operator - combines core and extended configurations
*/
export type TExactOperatorConfigs<V extends TFilterValue> =
| TCoreExactOperatorConfigs<V>
| TExtendedExactOperatorConfigs<V>;
/**
* IN operator - combines core and extended configurations
*/
export type TInOperatorConfigs<V extends TFilterValue> = TCoreInOperatorConfigs<V> | TExtendedInOperatorConfigs<V>;
/**
* RANGE operator - combines core and extended configurations
*/
export type TRangeOperatorConfigs<V extends TFilterValue> =
| TCoreRangeOperatorConfigs<V>
| TExtendedRangeOperatorConfigs<V>;
// ----------------------------- Final Operator Specific Configs -----------------------------
/**
* Type-safe mapping of specific operators to their supported filter type configurations.
* Each operator maps to its composed (core + extended) configurations.
*/
export type TOperatorSpecificConfigs<V extends TFilterValue> = {
[EQUALITY_OPERATOR.EXACT]: TExactOperatorConfigs<V>;
[COLLECTION_OPERATOR.IN]: TInOperatorConfigs<V>;
[COMPARISON_OPERATOR.RANGE]: TRangeOperatorConfigs<V>;
} & TExtendedOperatorSpecificConfigs<V>;
/**
* Operator filter configuration mapping - for different operators.
* Provides type-safe mapping of operators to their specific supported configurations.
*/
export type TOperatorConfigMap<V extends TFilterValue> = Map<
keyof TOperatorSpecificConfigs<V>,
TOperatorSpecificConfigs<V>[keyof TOperatorSpecificConfigs<V>]
>;
// -------- RE-EXPORTS --------
export * from "./core";
export * from "./extended";
@@ -0,0 +1,38 @@
/**
* Core logical operators
*/
export const CORE_LOGICAL_OPERATOR = {
AND: "and",
} as const;
/**
* Core equality operators
*/
export const CORE_EQUALITY_OPERATOR = {
EXACT: "exact",
} as const;
/**
* Core collection operators
*/
export const CORE_COLLECTION_OPERATOR = {
IN: "in",
} as const;
/**
* Core comparison operators
*/
export const CORE_COMPARISON_OPERATOR = {
RANGE: "range",
} as const;
// -------- TYPE EXPORTS --------
type TCoreEqualityOperator = (typeof CORE_EQUALITY_OPERATOR)[keyof typeof CORE_EQUALITY_OPERATOR];
type TCoreCollectionOperator = (typeof CORE_COLLECTION_OPERATOR)[keyof typeof CORE_COLLECTION_OPERATOR];
type TCoreComparisonOperator = (typeof CORE_COMPARISON_OPERATOR)[keyof typeof CORE_COMPARISON_OPERATOR];
/**
* All core operators that can be used in filter conditions
*/
export type TCoreSupportedOperators = TCoreEqualityOperator | TCoreCollectionOperator | TCoreComparisonOperator;
@@ -0,0 +1,33 @@
/**
* Extended logical operators
*/
export const EXTENDED_LOGICAL_OPERATOR = {} as const;
/**
* Extended equality operators
*/
export const EXTENDED_EQUALITY_OPERATOR = {} as const;
/**
* Extended collection operators
*/
export const EXTENDED_COLLECTION_OPERATOR = {} as const;
/**
* Extended comparison operators
*/
export const EXTENDED_COMPARISON_OPERATOR = {} as const;
// -------- TYPE EXPORTS --------
type TExtendedEqualityOperator = (typeof EXTENDED_EQUALITY_OPERATOR)[keyof typeof EXTENDED_EQUALITY_OPERATOR];
type TExtendedCollectionOperator = (typeof EXTENDED_COLLECTION_OPERATOR)[keyof typeof EXTENDED_COLLECTION_OPERATOR];
type TExtendedComparisonOperator = (typeof EXTENDED_COMPARISON_OPERATOR)[keyof typeof EXTENDED_COMPARISON_OPERATOR];
/**
* All extended operators that can be used in filter conditions
*/
export type TExtendedSupportedOperators =
| TExtendedEqualityOperator
| TExtendedCollectionOperator
| TExtendedComparisonOperator;
@@ -0,0 +1,59 @@
import {
CORE_LOGICAL_OPERATOR,
CORE_EQUALITY_OPERATOR,
CORE_COLLECTION_OPERATOR,
CORE_COMPARISON_OPERATOR,
TCoreSupportedOperators,
} from "./core";
import {
EXTENDED_LOGICAL_OPERATOR,
EXTENDED_EQUALITY_OPERATOR,
EXTENDED_COLLECTION_OPERATOR,
EXTENDED_COMPARISON_OPERATOR,
TExtendedSupportedOperators,
} from "./extended";
// -------- COMPOSED OPERATORS --------
export const LOGICAL_OPERATOR = {
...CORE_LOGICAL_OPERATOR,
...EXTENDED_LOGICAL_OPERATOR,
} as const;
export const EQUALITY_OPERATOR = {
...CORE_EQUALITY_OPERATOR,
...EXTENDED_EQUALITY_OPERATOR,
} as const;
export const COLLECTION_OPERATOR = {
...CORE_COLLECTION_OPERATOR,
...EXTENDED_COLLECTION_OPERATOR,
} as const;
export const COMPARISON_OPERATOR = {
...CORE_COMPARISON_OPERATOR,
...EXTENDED_COMPARISON_OPERATOR,
} as const;
// -------- COMPOSED TYPES --------
export type TLogicalOperator = (typeof LOGICAL_OPERATOR)[keyof typeof LOGICAL_OPERATOR];
export type TEqualityOperator = (typeof EQUALITY_OPERATOR)[keyof typeof EQUALITY_OPERATOR];
export type TCollectionOperator = (typeof COLLECTION_OPERATOR)[keyof typeof COLLECTION_OPERATOR];
export type TComparisonOperator = (typeof COMPARISON_OPERATOR)[keyof typeof COMPARISON_OPERATOR];
/**
* Union type representing all operators that can be used in a filter condition.
* Combines core and extended operators.
*/
export type TSupportedOperators = TCoreSupportedOperators | TExtendedSupportedOperators;
/**
* All operators available for use in rich filters UI, including negated versions.
*/
export type TAllAvailableOperatorsForDisplay = TSupportedOperators;
// -------- RE-EXPORTS --------
export * from "./core";
export * from "./extended";

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