Compare commits

...
Author SHA1 Message Date
Akshat JainandGitHub f278a284c4 chore: comment out APP_RELEASE variable update in build-branch workflow (#6989) 2025-04-28 17:45:44 +05:30
sriram veeraghanta 2bcf6c76cd chore: remove dockerhub user varible from compose 2025-04-28 16:28:48 +05:30
Akshat JainandGitHub fb3e022042 [INFRA-134] fix: Setup and Swarm scripts for DOCKERHUB_USERNAME #6988 2025-04-28 14:37:51 +05:30
Akshat JainandGitHub e3fbb7b073 [INFRA-134]: Update Docker images to use new artifact repository path #6978 2025-04-25 18:09:43 +05:30
Anmol Singh BhatiaandGitHub cce6dd581c [WEB-3948] chore: recent work item improvement (#6976)
* chore: issue entity data type updated

* chore: HomePeekOverviewsRoot component added

* chore: recent work item improvement and code refactor
2025-04-25 15:08:10 +05:30
Akshita GoyalandGitHub d86ac368a4 [WEB-3863] fix: handled error handling for link editing #6968 2025-04-25 14:31:35 +05:30
101994840a [WEB-3944] fix: Error Toast message content update while uploading images (#6969)
* fix: handled svg uploads

* chore: proper error message with all allowed types

---------

Co-authored-by: sangeethailango <sangeethailango21@gmail.com>
2025-04-25 14:30:12 +05:30
Anmol Singh BhatiaandGitHub f60f57ef11 [WEB-3494] chore: platform ux copy changes (#6970)
* chore: project quick action option ux copy updated

* chore: project tour copy updated
2025-04-25 14:29:09 +05:30
Prateek ShouryaandGitHub 546217f09b [WEB-3953] fix: issue description assets upload when project id is switched (#6975) 2025-04-25 14:27:40 +05:30
sriram veeraghanta 6df8323665 fix: add gzip upload support 2025-04-24 17:50:37 +05:30
SangeethaandGitHub 77d022df71 [WEB-3919] chore: support .sql file attachment #6966 2025-04-24 17:39:16 +05:30
M. PalanikannanandGitHub 797f150ec4 [WIKI-331] fix: editor ref issues while locking/unlocking page #6965 2025-04-24 17:38:41 +05:30
sriram veeraghanta b54f54999e chore: bump up the package version 2025-04-24 17:37:50 +05:30
SangeethaandGitHub dff176be8f [WEB-3930] chore: set IN_APP as default source value for intake issue (#6963)
* chore: chore: only allow intake issues with source IN_APP to be created

* chore: set IN_APP as default intake issue
2025-04-24 16:25:15 +05:30
2bbaaed3ea [WEB-3918] fix: api tokens is_active (#6941)
* fix: is_active always returning true
chore: formate expired_at to iso date

* Display exact expiration timestamp for API tokens

* chore: remove conversion to iso

* chore: remove unwanted imports

* fix: added timestamp for api token expiry

* fix: handle none value in expired_at

* fix: fix: handle none value in expired_at

* chore: add type hints

* fix: refactor

---------

Co-authored-by: Alaaeddine bousselmi <alaaeddine.bousselmi@medtech.tn>
Co-authored-by: gakshita <akshitagoyal1516@gmail.com>
Co-authored-by: Akshita Goyal <36129505+gakshita@users.noreply.github.com>
2025-04-24 01:28:29 +05:30
Prateek ShouryaandGitHub b5ceb94fb2 [WEB-3930] fix: application crash on accessing intake work items (#6958) 2025-04-23 15:12:54 +05:30
alaabousselmiandGitHub feb6243065 docs: document minimum RAM requirement and issue naming conventions (#6954) 2025-04-22 18:00:19 +05:30
Anmol Singh BhatiaandGitHub 5dacba74c9 [WEB-3923] fix: applied filters list #6957 2025-04-22 17:58:16 +05:30
bIaOandGitHub 0efb0c239c feat: improve setup.sh script with better error handling and user feedback (#6758) 2025-04-22 17:56:34 +05:30
Vamsi KrishnaandGitHub c8be836d6c [WEB-3920]fix: estimate activity #6950 2025-04-22 17:45:15 +05:30
NikhilandGitHub 833b82e247 [WEB-3927] chore: add logging to support json logging (#6955)
* chore: update logging to json based logging

* chore: add logging to file
2025-04-22 17:41:58 +05:30
Akshita GoyalandGitHub 280aa7f671 [WEB-3399] fix: progress data for cycle list item #6956 2025-04-22 17:41:06 +05:30
Aaryan KhandelwalandGitHub eac1115566 [WIKI-320] refactor: page header actions (#6946)
* refactor: page header actions

* chore: update toolbar component

* chore: update archived and lock badge colors

* chore: added observer to favorite control
2025-04-17 20:52:33 +05:30
sriram veeraghanta 8166a757a7 fix: removed @plane alias from ui package 2025-04-17 20:51:52 +05:30
Anmol Singh BhatiaandGitHub be5d77d978 [WEB-3892] chore: link item improvements (#6944)
* chore: code refactor

* chore: global link block component added

* chore: link item improvement and code refactor
2025-04-17 20:08:53 +05:30
Anmol Singh BhatiaandGitHub 18fb3b8450 [WEB-3904] fix: sub work item fetching #6945 2025-04-17 20:07:13 +05:30
sriram veeraghanta ef5616905e chore: upgrade turbo repo version 2025-04-17 17:51:59 +05:30
SangeethaandGitHub aeb41e603c [WEB-3826] feat: estimate activitites #6937 2025-04-17 17:16:57 +05:30
Vamsi KrishnaandGitHub 55eea1a8b7 [WEB-3872]chore: header switcher enhancements (#6935)
* * chore: alignment and size for header
* fix: switcher close on click

* chore: moved acces icon component to components
2025-04-17 17:15:53 +05:30
Aaryan KhandelwalandGitHub fa87ff14b7 [WIKI-319] chore: remove bottom border when toolbar is hidden (#6943)
* chore: remove border when toolbar is hidden

* chore: add stricter conditions
2025-04-17 17:13:21 +05:30
khalilzitouni2058andGitHub 7d91b5f8df [WEB-3892] feat: add icon to Quicklinks (#6927)
* [feature]: add icon to Quicklinks

* fix: moving  getIconForLink to utils packages
2025-04-17 17:11:57 +05:30
Anmol Singh BhatiaandGitHub 3ce40dfa2f [WIKI-316] fix: list item overflow #6942 2025-04-17 17:08:13 +05:30
Anmol Singh BhatiaandGitHub f65253c994 [WEB-2561] chore: favicon icon updated #6938 (#6940)
* chore: favicon icon updated

* chore: code refactor
2025-04-17 15:38:42 +05:30
Anmol Singh BhatiaandGitHub 97fcfaa653 [WEB-2561] chore: favicon icon updated #6938 2025-04-16 20:34:12 +05:30
Anmol Singh BhatiaandGitHub 0e1ebff978 [WEB-3871] fix: sidebar label property #6934 2025-04-15 19:42:02 +05:30
Anmol Singh BhatiaandGitHub 642dabfe35 [WEB-3870] fix: sidebar comment scroll #6932 2025-04-15 17:47:22 +05:30
Aaryan KhandelwalandGitHub 48557cb670 [WEB-3868] fix: issue detail widget modals #6933 2025-04-15 17:46:45 +05:30
Bavisetti NarayanandGitHub 608da1465c [WEB-3860] chore: added deleted filter in the grouper (#6931)
* chore: added deleted filter in the grouper

* chore: added type hints for the function
2025-04-15 17:42:45 +05:30
dbcc7bedb4 [WEB-3855] feat: Turkish language support (#6922)
* add Turkish language support (#6874)

* add turkish language support

* fix indentation

* chore: extended core translation added

* chore: code refactor

---------

Co-authored-by: Farahat Abdrabouh <88924701+fasdjkherig@users.noreply.github.com>
2025-04-15 16:36:02 +05:30
Vamsi KrishnaandGitHub c401b26dd4 [WEB-3856]chore: refactor work item activity (#6923)
* chore: refactor work item activity

* chore: added estimate render for notifications
2025-04-15 16:35:28 +05:30
Aaryan KhandelwalandGitHub a4bca0c39c [WEB-3859] fix: work item links #6930 2025-04-15 13:46:29 +05:30
154 changed files with 3954 additions and 773 deletions
+1 -1
View File
@@ -273,7 +273,7 @@ jobs:
run: |
cp ./deploy/selfhost/install.sh deploy/selfhost/setup.sh
sed -i 's/${APP_RELEASE:-stable}/${APP_RELEASE:-'${REL_VERSION}'}/g' deploy/selfhost/docker-compose.yml
sed -i 's/APP_RELEASE=stable/APP_RELEASE='${REL_VERSION}'/g' deploy/selfhost/variables.env
# sed -i 's/APP_RELEASE=stable/APP_RELEASE='${REL_VERSION}'/g' deploy/selfhost/variables.env
- name: Create Release
id: create_release
+18
View File
@@ -15,6 +15,22 @@ Without said minimal reproduction, we won't be able to investigate all [issues](
You can open a new issue with this [issue form](https://github.com/makeplane/plane/issues/new).
### Naming conventions for issues
When opening a new issue, please use a clear and concise title that follows this format:
- For bugs: `🐛 Bug: [short description]`
- For features: `🚀 Feature: [short description]`
- For improvements: `🛠️ Improvement: [short description]`
- For documentation: `📘 Docs: [short description]`
**Examples:**
- `🐛 Bug: API token expiry time not saving correctly`
- `📘 Docs: Clarify RAM requirement for local setup`
- `🚀 Feature: Allow custom time selection for token expiration`
This helps us triage and manage issues more efficiently.
## Projects setup and Architecture
### Requirements
@@ -23,6 +39,8 @@ You can open a new issue with this [issue form](https://github.com/makeplane/pla
- Python version 3.8+
- Postgres version v14
- Redis version v6.2.7
- **Memory**: Minimum **12 GB RAM** recommended
> ⚠️ Running the project on a system with only 8 GB RAM may lead to setup failures or memory crashes (especially during Docker container build/start or dependency install). Use cloud environments like GitHub Codespaces or upgrade local RAM if possible.
### Setup the project
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "admin",
"description": "Admin UI for Plane",
"version": "0.25.3",
"version": "0.26.0",
"license": "AGPL-3.0",
"private": true,
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "plane-api",
"version": "0.25.3",
"version": "0.26.0",
"license": "AGPL-3.0",
"private": true,
"description": "API server powering Plane's backend"
+2 -1
View File
@@ -20,6 +20,7 @@ from plane.bgtasks.issue_activities_task import issue_activity
from plane.db.models import Intake, IntakeIssue, Issue, Project, ProjectMember, State
from plane.utils.host import base_host
from .base import BaseAPIView
from plane.db.models.intake import SourceType
class IntakeIssueAPIEndpoint(BaseAPIView):
@@ -125,7 +126,7 @@ class IntakeIssueAPIEndpoint(BaseAPIView):
intake_id=intake.id,
project_id=project_id,
issue=issue,
source=request.data.get("source", "IN-APP"),
source=SourceType.IN_APP,
)
# Create an Issue Activity
issue_activity.delay(
+9
View File
@@ -1,5 +1,7 @@
from .base import BaseSerializer
from plane.db.models import APIToken, APIActivityLog
from rest_framework import serializers
from django.utils import timezone
class APITokenSerializer(BaseSerializer):
@@ -17,10 +19,17 @@ class APITokenSerializer(BaseSerializer):
class APITokenReadSerializer(BaseSerializer):
is_active = serializers.SerializerMethodField()
class Meta:
model = APIToken
exclude = ("token",)
def get_is_active(self, obj: APIToken) -> bool:
if obj.expired_at is None:
return True
return timezone.now() < obj.expired_at
class APIActivityLogSerializer(BaseSerializer):
class Meta:
+4 -4
View File
@@ -137,7 +137,7 @@ class UserAssetsV2Endpoint(BaseAPIView):
if type not in allowed_types:
return Response(
{
"error": "Invalid file type. Only JPEG and PNG files are allowed.",
"error": "Invalid file type. Only JPEG, PNG, WebP, JPG and GIF files are allowed.",
"status": False,
},
status=status.HTTP_400_BAD_REQUEST,
@@ -351,7 +351,7 @@ class WorkspaceFileAssetEndpoint(BaseAPIView):
if type not in allowed_types:
return Response(
{
"error": "Invalid file type. Only JPEG and PNG files are allowed.",
"error": "Invalid file type. Only JPEG, PNG, WebP, JPG and GIF files are allowed.",
"status": False,
},
status=status.HTTP_400_BAD_REQUEST,
@@ -552,7 +552,7 @@ class ProjectAssetEndpoint(BaseAPIView):
if type not in allowed_types:
return Response(
{
"error": "Invalid file type. Only JPEG and PNG files are allowed.",
"error": "Invalid file type. Only JPEG, PNG, WebP, JPG and GIF files are allowed.",
"status": False,
},
status=status.HTTP_400_BAD_REQUEST,
@@ -683,7 +683,7 @@ class ProjectBulkAssetEndpoint(BaseAPIView):
# For some cases, the bulk api is called after the issue is deleted creating
# an integrity error
try:
assets.update(issue_id=entity_id)
assets.update(issue_id=entity_id, project_id=project_id)
except IntegrityError:
pass
+2 -3
View File
@@ -44,6 +44,7 @@ from plane.app.views.base import BaseAPIView
from plane.utils.timezone_converter import user_timezone_converter
from plane.utils.global_paginator import paginate
from plane.utils.host import base_host
from plane.db.models.intake import SourceType
class IntakeViewSet(BaseViewSet):
@@ -278,7 +279,7 @@ class IntakeIssueViewSet(BaseViewSet):
intake_id=intake_id.id,
project_id=project_id,
issue_id=serializer.data["id"],
source=request.data.get("source", "IN-APP"),
source=SourceType.IN_APP,
)
# Create an Issue Activity
issue_activity.delay(
@@ -408,7 +409,6 @@ class IntakeIssueViewSet(BaseViewSet):
)
if issue_serializer.is_valid():
# Log all the updates
requested_data = json.dumps(issue_data, cls=DjangoJSONEncoder)
if issue is not None:
@@ -607,7 +607,6 @@ class IntakeIssueViewSet(BaseViewSet):
class IntakeWorkItemDescriptionVersionEndpoint(BaseAPIView):
def process_paginated_result(self, fields, results, timezone):
paginated_data = results.values(*fields)
@@ -482,7 +482,7 @@ def track_estimate_points(
),
old_value=old_estimate.value if old_estimate else None,
new_value=new_estimate.value if new_estimate else None,
field="estimate_point",
field="estimate_" + new_estimate.estimate.type,
project_id=project_id,
workspace_id=workspace_id,
comment="updated the estimate point to ",
+31 -1
View File
@@ -1,8 +1,16 @@
# Python imports
import os
import logging
# Third party imports
from celery import Celery
from plane.settings.redis import redis_instance
from pythonjsonlogger.jsonlogger import JsonFormatter
from celery.signals import after_setup_logger, after_setup_task_logger
from celery.schedules import crontab
# Module imports
from plane.settings.redis import redis_instance
# Set the default Django settings module for the 'celery' program.
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "plane.settings.production")
@@ -47,6 +55,28 @@ app.conf.beat_schedule = {
},
}
# Setup logging
@after_setup_logger.connect
def setup_loggers(logger, *args, **kwargs):
formatter = JsonFormatter(
'"%(levelname)s %(asctime)s %(module)s %(name)s %(message)s'
)
handler = logging.StreamHandler()
handler.setFormatter(fmt=formatter)
logger.addHandler(handler)
@after_setup_task_logger.connect
def setup_task_loggers(logger, *args, **kwargs):
formatter = JsonFormatter(
'"%(levelname)s %(asctime)s %(module)s %(name)s %(message)s'
)
handler = logging.StreamHandler()
handler.setFormatter(fmt=formatter)
logger.addHandler(handler)
# Load task modules from all registered Django app configs.
app.autodiscover_tasks()
+4
View File
@@ -31,6 +31,10 @@ class Intake(ProjectBaseModel):
ordering = ("name",)
class SourceType(models.TextChoices):
IN_APP = "IN_APP"
class IntakeIssue(ProjectBaseModel):
intake = models.ForeignKey(
"db.Intake", related_name="issue_intake", on_delete=models.CASCADE
@@ -1,40 +0,0 @@
# Module imports
from plane.db.models import APIActivityLog
from plane.utils.ip_address import get_client_ip
class APITokenLogMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
request_body = request.body
response = self.get_response(request)
self.process_request(request, response, request_body)
return response
def process_request(self, request, response, request_body):
api_key_header = "X-Api-Key"
api_key = request.headers.get(api_key_header)
# If the API key is present, log the request
if api_key:
try:
APIActivityLog.objects.create(
token_identifier=api_key,
path=request.path,
method=request.method,
query_params=request.META.get("QUERY_STRING", ""),
headers=str(request.headers),
body=(request_body.decode("utf-8") if request_body else None),
response_body=(
response.content.decode("utf-8") if response.content else None
),
response_code=response.status_code,
ip_address=get_client_ip(request=request),
user_agent=request.META.get("HTTP_USER_AGENT", None),
)
except Exception as e:
print(e)
# If the token does not exist, you can decide whether to log this as an invalid attempt
return None
+41 -1
View File
@@ -10,8 +10,10 @@ from rest_framework.request import Request
# Module imports
from plane.utils.ip_address import get_client_ip
from plane.db.models import APIActivityLog
api_logger = logging.getLogger("plane.api")
api_logger = logging.getLogger("plane.api.request")
class RequestLoggerMiddleware:
@@ -69,3 +71,41 @@ class RequestLoggerMiddleware:
# return the response
return response
class APITokenLogMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
request_body = request.body
response = self.get_response(request)
self.process_request(request, response, request_body)
return response
def process_request(self, request, response, request_body):
api_key_header = "X-Api-Key"
api_key = request.headers.get(api_key_header)
# If the API key is present, log the request
if api_key:
try:
APIActivityLog.objects.create(
token_identifier=api_key,
path=request.path,
method=request.method,
query_params=request.META.get("QUERY_STRING", ""),
headers=str(request.headers),
body=(request_body.decode("utf-8") if request_body else None),
response_body=(
response.content.decode("utf-8") if response.content else None
),
response_code=response.status_code,
ip_address=get_client_ip(request=request),
user_agent=request.META.get("HTTP_USER_AGENT", None),
)
except Exception as e:
api_logger.exception(e)
# If the token does not exist, you can decide whether to log this as an invalid attempt
return None
+5 -1
View File
@@ -58,7 +58,7 @@ MIDDLEWARE = [
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"crum.CurrentRequestUserMiddleware",
"django.middleware.gzip.GZipMiddleware",
"plane.middleware.api_log_middleware.APITokenLogMiddleware",
"plane.middleware.logger.APITokenLogMiddleware",
"plane.middleware.logger.RequestLoggerMiddleware",
]
@@ -391,4 +391,8 @@ ATTACHMENT_MIME_TYPES = [
"text/xml",
"text/csv",
"application/xml",
# SQL
"application/x-sql",
# Gzip
"application/x-gzip",
]
+10
View File
@@ -56,6 +56,11 @@ LOGGING = {
}
},
"loggers": {
"plane.api.request": {
"level": "INFO",
"handlers": ["console"],
"propagate": False,
},
"plane.api": {"level": "INFO", "handlers": ["console"], "propagate": False},
"plane.worker": {"level": "INFO", "handlers": ["console"], "propagate": False},
"plane.exception": {
@@ -63,5 +68,10 @@ LOGGING = {
"handlers": ["console"],
"propagate": False,
},
"plane.external": {
"level": "INFO",
"handlers": ["console"],
"propagate": False,
},
},
}
+10
View File
@@ -58,6 +58,11 @@ LOGGING = {
},
},
"loggers": {
"plane.api.request": {
"level": "DEBUG" if DEBUG else "INFO",
"handlers": ["console"],
"propagate": False,
},
"plane.api": {
"level": "DEBUG" if DEBUG else "INFO",
"handlers": ["console"],
@@ -70,6 +75,11 @@ LOGGING = {
},
"plane.exception": {
"level": "DEBUG" if DEBUG else "ERROR",
"handlers": ["console", "file"],
"propagate": False,
},
"plane.external": {
"level": "INFO",
"handlers": ["console"],
"propagate": False,
},
+25 -3
View File
@@ -3,6 +3,9 @@ from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
from django.db.models import Q, UUIDField, Value, F, Case, When, JSONField, CharField
from django.db.models.functions import Coalesce, JSONObject, Concat
from django.db.models import QuerySet
from typing import List, Optional, Dict, Any, Union
# Module imports
from plane.db.models import (
@@ -17,13 +20,25 @@ from plane.db.models import (
)
def issue_queryset_grouper(queryset, group_by, sub_group_by):
def issue_queryset_grouper(
queryset: QuerySet[Issue], group_by: Optional[str], sub_group_by: Optional[str]
) -> QuerySet[Issue]:
FIELD_MAPPER = {
"label_ids": "labels__id",
"assignee_ids": "assignees__id",
"module_ids": "issue_module__module_id",
}
GROUP_FILTER_MAPPER = {
"assignees__id": Q(issue_assignee__deleted_at__isnull=True),
"labels__id": Q(label_issue__deleted_at__isnull=True),
"issue_module__module_id": Q(issue_module__deleted_at__isnull=True),
}
for group_key in [group_by, sub_group_by]:
if group_key in GROUP_FILTER_MAPPER:
queryset = queryset.filter(GROUP_FILTER_MAPPER[group_key])
annotations_map = {
"assignee_ids": (
"assignees__id",
@@ -50,7 +65,9 @@ def issue_queryset_grouper(queryset, group_by, sub_group_by):
return queryset.annotate(**default_annotations)
def issue_on_results(issues, group_by, sub_group_by):
def issue_on_results(
issues: QuerySet[Issue], group_by: Optional[str], sub_group_by: Optional[str]
) -> List[Dict[str, Any]]:
FIELD_MAPPER = {
"labels__id": "label_ids",
"assignees__id": "assignee_ids",
@@ -160,7 +177,12 @@ def issue_on_results(issues, group_by, sub_group_by):
return issues
def issue_group_values(field, slug, project_id=None, filters=dict):
def issue_group_values(
field: str,
slug: str,
project_id: Optional[str] = None,
filters: Dict[str, Any] = {},
) -> List[Union[str, Any]]:
if field == "state_id":
queryset = State.objects.filter(
is_triage=False, workspace__slug=slug
+1 -1
View File
@@ -96,7 +96,7 @@ class EntityAssetEndpoint(BaseAPIView):
if type not in allowed_types:
return Response(
{
"error": "Invalid file type. Only JPEG and PNG files are allowed.",
"error": "Invalid file type. Only JPEG, PNG, WebP, JPG and GIF files are allowed.",
"status": False,
},
status=status.HTTP_400_BAD_REQUEST,
+1 -1
View File
@@ -9,7 +9,7 @@ from django.conf import settings
def log_exception(e):
# Log the error
logger = logging.getLogger("plane.exception")
logger.error(str(e))
logger.exception(e)
if settings.DEBUG:
# Print the traceback if in debug mode
+59 -31
View File
@@ -1,7 +1,7 @@
# Django imports
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
from django.db.models import Q, UUIDField, Value
from django.db.models import Q, UUIDField, Value, QuerySet
from django.db.models.functions import Coalesce
# Module imports
@@ -15,16 +15,31 @@ from plane.db.models import (
State,
WorkspaceMember,
)
from typing import Optional, Dict, Tuple, Any, Union, List
def issue_queryset_grouper(queryset, group_by, sub_group_by):
FIELD_MAPPER = {
def issue_queryset_grouper(
queryset: QuerySet[Issue],
group_by: Optional[str],
sub_group_by: Optional[str],
) -> QuerySet[Issue]:
FIELD_MAPPER: Dict[str, str] = {
"label_ids": "labels__id",
"assignee_ids": "assignees__id",
"module_ids": "issue_module__module_id",
}
annotations_map = {
GROUP_FILTER_MAPPER: Dict[str, Q] = {
"assignees__id": Q(issue_assignee__deleted_at__isnull=True),
"labels__id": Q(label_issue__deleted_at__isnull=True),
"issue_module__module_id": Q(issue_module__deleted_at__isnull=True),
}
for group_key in [group_by, sub_group_by]:
if group_key in GROUP_FILTER_MAPPER:
queryset = queryset.filter(GROUP_FILTER_MAPPER[group_key])
annotations_map: Dict[str, Tuple[str, Q]] = {
"assignee_ids": (
"assignees__id",
~Q(assignees__id__isnull=True) & Q(issue_assignee__deleted_at__isnull=True),
@@ -42,7 +57,8 @@ def issue_queryset_grouper(queryset, group_by, sub_group_by):
),
),
}
default_annotations = {
default_annotations: Dict[str, Any] = {
key: Coalesce(
ArrayAgg(field, distinct=True, filter=condition),
Value([], output_field=ArrayField(UUIDField())),
@@ -54,16 +70,20 @@ def issue_queryset_grouper(queryset, group_by, sub_group_by):
return queryset.annotate(**default_annotations)
def issue_on_results(issues, group_by, sub_group_by):
FIELD_MAPPER = {
def issue_on_results(
issues: QuerySet[Issue],
group_by: Optional[str],
sub_group_by: Optional[str],
) -> List[Dict[str, Any]]:
FIELD_MAPPER: Dict[str, str] = {
"labels__id": "label_ids",
"assignees__id": "assignee_ids",
"issue_module__module_id": "module_ids",
}
original_list = ["assignee_ids", "label_ids", "module_ids"]
original_list: List[str] = ["assignee_ids", "label_ids", "module_ids"]
required_fields = [
required_fields: List[str] = [
"id",
"name",
"state_id",
@@ -98,62 +118,72 @@ def issue_on_results(issues, group_by, sub_group_by):
original_list.append(sub_group_by)
required_fields.extend(original_list)
return issues.values(*required_fields)
return list(issues.values(*required_fields))
def issue_group_values(field, slug, project_id=None, filters=dict):
def issue_group_values(
field: str,
slug: str,
project_id: Optional[str] = None,
filters: Dict[str, Any] = {},
) -> List[Union[str, Any]]:
if field == "state_id":
queryset = State.objects.filter(
is_triage=False, workspace__slug=slug
).values_list("id", flat=True)
if project_id:
return list(queryset.filter(project_id=project_id))
else:
return list(queryset)
return list(queryset)
if field == "labels__id":
queryset = Label.objects.filter(workspace__slug=slug).values_list(
"id", flat=True
)
if project_id:
return list(queryset.filter(project_id=project_id)) + ["None"]
else:
return list(queryset) + ["None"]
return list(queryset) + ["None"]
if field == "assignees__id":
if project_id:
return ProjectMember.objects.filter(
workspace__slug=slug, project_id=project_id, is_active=True
).values_list("member_id", flat=True)
else:
return list(
WorkspaceMember.objects.filter(
workspace__slug=slug, is_active=True
ProjectMember.objects.filter(
workspace__slug=slug, project_id=project_id, is_active=True
).values_list("member_id", flat=True)
)
return list(
WorkspaceMember.objects.filter(
workspace__slug=slug, is_active=True
).values_list("member_id", flat=True)
)
if field == "issue_module__module_id":
queryset = Module.objects.filter(workspace__slug=slug).values_list(
"id", flat=True
)
if project_id:
return list(queryset.filter(project_id=project_id)) + ["None"]
else:
return list(queryset) + ["None"]
return list(queryset) + ["None"]
if field == "cycle_id":
queryset = Cycle.objects.filter(workspace__slug=slug).values_list(
"id", flat=True
)
if project_id:
return list(queryset.filter(project_id=project_id)) + ["None"]
else:
return list(queryset) + ["None"]
return list(queryset) + ["None"]
if field == "project_id":
queryset = Project.objects.filter(workspace__slug=slug).values_list(
"id", flat=True
)
return list(queryset)
if field == "priority":
return ["low", "medium", "high", "urgent", "none"]
if field == "state__group":
return ["backlog", "unstarted", "started", "completed", "cancelled"]
if field == "target_date":
queryset = (
Issue.issue_objects.filter(workspace__slug=slug)
@@ -163,8 +193,8 @@ def issue_group_values(field, slug, project_id=None, filters=dict):
)
if project_id:
return list(queryset.filter(project_id=project_id))
else:
return list(queryset)
return list(queryset)
if field == "start_date":
queryset = (
Issue.issue_objects.filter(workspace__slug=slug)
@@ -174,8 +204,7 @@ def issue_group_values(field, slug, project_id=None, filters=dict):
)
if project_id:
return list(queryset.filter(project_id=project_id))
else:
return list(queryset)
return list(queryset)
if field == "created_by":
queryset = (
@@ -186,7 +215,6 @@ def issue_group_values(field, slug, project_id=None, filters=dict):
)
if project_id:
return list(queryset.filter(project_id=project_id))
else:
return list(queryset)
return list(queryset)
return []
+9 -10
View File
@@ -51,10 +51,9 @@ x-app-env: &app-env
API_KEY_RATE_LIMIT: ${API_KEY_RATE_LIMIT:-60/minute}
MINIO_ENDPOINT_SSL: ${MINIO_ENDPOINT_SSL:-0}
services:
web:
image: ${DOCKERHUB_USER:-makeplane}/plane-frontend:${APP_RELEASE:-stable}
image: artifacts.plane.so/makeplane/plane-frontend:${APP_RELEASE:-stable}
command: node web/server.js web
deploy:
replicas: ${WEB_REPLICAS:-1}
@@ -65,7 +64,7 @@ services:
- worker
space:
image: ${DOCKERHUB_USER:-makeplane}/plane-space:${APP_RELEASE:-stable}
image: artifacts.plane.so/makeplane/plane-space:${APP_RELEASE:-stable}
command: node space/server.js space
deploy:
replicas: ${SPACE_REPLICAS:-1}
@@ -77,7 +76,7 @@ services:
- web
admin:
image: ${DOCKERHUB_USER:-makeplane}/plane-admin:${APP_RELEASE:-stable}
image: artifacts.plane.so/makeplane/plane-admin:${APP_RELEASE:-stable}
command: node admin/server.js admin
deploy:
replicas: ${ADMIN_REPLICAS:-1}
@@ -88,7 +87,7 @@ services:
- web
live:
image: ${DOCKERHUB_USER:-makeplane}/plane-live:${APP_RELEASE:-stable}
image: artifacts.plane.so/makeplane/plane-live:${APP_RELEASE:-stable}
command: node live/dist/server.js live
environment:
<<: [*live-env]
@@ -101,7 +100,7 @@ services:
- web
api:
image: ${DOCKERHUB_USER:-makeplane}/plane-backend:${APP_RELEASE:-stable}
image: artifacts.plane.so/makeplane/plane-backend:${APP_RELEASE:-stable}
command: ./bin/docker-entrypoint-api.sh
deploy:
replicas: ${API_REPLICAS:-1}
@@ -117,7 +116,7 @@ services:
- plane-mq
worker:
image: ${DOCKERHUB_USER:-makeplane}/plane-backend:${APP_RELEASE:-stable}
image: artifacts.plane.so/makeplane/plane-backend:${APP_RELEASE:-stable}
command: ./bin/docker-entrypoint-worker.sh
deploy:
replicas: ${WORKER_REPLICAS:-1}
@@ -134,7 +133,7 @@ services:
- plane-mq
beat-worker:
image: ${DOCKERHUB_USER:-makeplane}/plane-backend:${APP_RELEASE:-stable}
image: artifacts.plane.so/makeplane/plane-backend:${APP_RELEASE:-stable}
command: ./bin/docker-entrypoint-beat.sh
deploy:
replicas: ${BEAT_WORKER_REPLICAS:-1}
@@ -151,7 +150,7 @@ services:
- plane-mq
migrator:
image: ${DOCKERHUB_USER:-makeplane}/plane-backend:${APP_RELEASE:-stable}
image: artifacts.plane.so/makeplane/plane-backend:${APP_RELEASE:-stable}
command: ./bin/docker-entrypoint-migrator.sh
deploy:
replicas: 1
@@ -213,7 +212,7 @@ services:
# Comment this if you already have a reverse proxy running
proxy:
image: ${DOCKERHUB_USER:-makeplane}/plane-proxy:${APP_RELEASE:-stable}
image: artifacts.plane.so/makeplane/plane-proxy:${APP_RELEASE:-stable}
ports:
- target: 80
published: ${NGINX_PORT:-80}
+2 -2
View File
@@ -5,7 +5,7 @@ SCRIPT_DIR=$PWD
SERVICE_FOLDER=plane-app
PLANE_INSTALL_DIR=$PWD/$SERVICE_FOLDER
export APP_RELEASE=stable
export DOCKERHUB_USER=makeplane
export DOCKERHUB_USER=artifacts.plane.so/makeplane
export PULL_POLICY=${PULL_POLICY:-if_not_present}
export GH_REPO=makeplane/plane
export RELEASE_DOWNLOAD_URL="https://github.com/$GH_REPO/releases/download"
@@ -631,7 +631,7 @@ if [ -f "$DOCKER_ENV_PATH" ]; then
CUSTOM_BUILD=$(getEnvValue "CUSTOM_BUILD" "$DOCKER_ENV_PATH")
if [ -z "$DOCKERHUB_USER" ]; then
DOCKERHUB_USER=makeplane
DOCKERHUB_USER=artifacts.plane.so/makeplane
updateEnvFile "DOCKERHUB_USER" "$DOCKERHUB_USER" "$DOCKER_ENV_PATH"
fi
+2 -2
View File
@@ -5,7 +5,7 @@ SERVICE_FOLDER=plane-app
SCRIPT_DIR=$PWD
PLANE_INSTALL_DIR=$PWD/$SERVICE_FOLDER
export APP_RELEASE="stable"
export DOCKERHUB_USER=makeplane
export DOCKERHUB_USER=artifacts.plane.so/makeplane
export GH_REPO=makeplane/plane
export RELEASE_DOWNLOAD_URL="https://github.com/$GH_REPO/releases/download"
@@ -596,7 +596,7 @@ if [ -f "$DOCKER_ENV_PATH" ]; then
APP_RELEASE=$(getEnvValue "APP_RELEASE" "$DOCKER_ENV_PATH")
if [ -z "$DOCKERHUB_USER" ]; then
DOCKERHUB_USER=makeplane
DOCKERHUB_USER=artifacts.plane.so/makeplane
updateEnvFile "DOCKERHUB_USER" "$DOCKERHUB_USER" "$DOCKER_ENV_PATH"
fi
+1 -1
View File
@@ -60,4 +60,4 @@ GUNICORN_WORKERS=1
MINIO_ENDPOINT_SSL=0
# API key rate limit
API_KEY_RATE_LIMIT="60/minute"
API_KEY_RATE_LIMIT=60/minute
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "live",
"version": "0.25.3",
"version": "0.26.0",
"license": "AGPL-3.0",
"description": "A realtime collaborative server powers Plane's rich text editor",
"main": "./src/server.ts",
+2 -2
View File
@@ -2,7 +2,7 @@
"name": "plane",
"description": "Open-source project management that unlocks customer value",
"repository": "https://github.com/makeplane/plane.git",
"version": "0.25.3",
"version": "0.26.0",
"license": "AGPL-3.0",
"private": true,
"workspaces": [
@@ -24,7 +24,7 @@
"devDependencies": {
"prettier": "latest",
"prettier-plugin-tailwindcss": "^0.5.4",
"turbo": "^2.4.2"
"turbo": "^2.5.0"
},
"resolutions": {
"nanoid": "3.3.8",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/constants",
"version": "0.25.3",
"version": "0.26.0",
"private": true,
"main": "./src/index.ts",
"license": "AGPL-3.0"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/editor",
"version": "0.25.3",
"version": "0.26.0",
"description": "Core Editor that powers Plane",
"license": "AGPL-3.0",
"private": true,
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@plane/eslint-config",
"private": true,
"version": "0.25.3",
"version": "0.26.0",
"license": "AGPL-3.0",
"files": [
"library.js",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/hooks",
"version": "0.25.3",
"version": "0.26.0",
"license": "AGPL-3.0",
"description": "React hooks that are shared across multiple apps internally",
"private": true,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/i18n",
"version": "0.25.3",
"version": "0.26.0",
"license": "AGPL-3.0",
"description": "I18n shared across multiple apps internally",
"private": true,
+1
View File
@@ -21,6 +21,7 @@ export const SUPPORTED_LANGUAGES: ILanguageOption[] = [
{ label: "Indonesian", value: "id" },
{ label: "Română", value: "ro" },
{ label: "Tiếng việt", value: "vi-VN" },
{ label: "Türkçe", value: "tr-TR" },
];
export const LANGUAGE_STORAGE_KEY = "userLanguage";
@@ -348,7 +348,7 @@
"couldnt_remove_the_project_from_favorites": "Nepodařilo se odstranit projekt z oblíbených. Zkuste to prosím znovu.",
"add_to_favorites": "Přidat do oblíbených",
"remove_from_favorites": "Odebrat z oblíbených",
"publish_settings": "Nastavení publikování",
"publish_project": "Publikovat projekt",
"publish": "Publikovat",
"copy_link": "Kopírovat odkaz",
"leave_project": "Opustit projekt",
@@ -348,7 +348,7 @@
"couldnt_remove_the_project_from_favorites": "Projekt konnte nicht aus den Favoriten entfernt werden. Bitte versuchen Sie es erneut.",
"add_to_favorites": "Zu Favoriten hinzufügen",
"remove_from_favorites": "Aus Favoriten entfernen",
"publish_settings": "Veröffentlichungseinstellungen",
"publish_project": "Projekt veröffentlichen",
"publish": "Veröffentlichen",
"copy_link": "Link kopieren",
"leave_project": "Projekt verlassen",
@@ -180,7 +180,7 @@
"couldnt_remove_the_project_from_favorites": "Couldn't remove the project from favorites. Please try again.",
"add_to_favorites": "Add to favorites",
"remove_from_favorites": "Remove from favorites",
"publish_settings": "Publish settings",
"publish_project": "Publish project",
"publish": "Publish",
"copy_link": "Copy link",
"leave_project": "Leave project",
@@ -352,7 +352,7 @@
"couldnt_remove_the_project_from_favorites": "No se pudo eliminar el proyecto de favoritos. Por favor, inténtalo de nuevo.",
"add_to_favorites": "Agregar a favoritos",
"remove_from_favorites": "Eliminar de favoritos",
"publish_settings": "Configuración de publicación",
"publish_project": "Publicar proyecto",
"publish": "Publicar",
"copy_link": "Copiar enlace",
"leave_project": "Abandonar proyecto",
@@ -350,7 +350,7 @@
"couldnt_remove_the_project_from_favorites": "Impossible de supprimer le projet des favoris. Veuillez réessayer.",
"add_to_favorites": "Ajouter aux favoris",
"remove_from_favorites": "Supprimer des favoris",
"publish_settings": "Paramètres de publication",
"publish_project": "Publier le projet",
"publish": "Publier",
"copy_link": "Copier le lien",
"leave_project": "Quitter le projet",
@@ -350,7 +350,7 @@
"couldnt_remove_the_project_from_favorites": "Tidak dapat menghapus proyek dari favorit. Silakan coba lagi.",
"add_to_favorites": "Tambah ke favorit",
"remove_from_favorites": "Hapus dari favorit",
"publish_settings": "Pengaturan publikasi",
"publish_project": "Publikasikan proyek",
"publish": "Publikasikan",
"copy_link": "Salin tautan",
"leave_project": "Tinggalkan proyek",
@@ -349,7 +349,7 @@
"couldnt_remove_the_project_from_favorites": "Impossibile rimuovere il progetto dai preferiti. Per favore, riprova.",
"add_to_favorites": "Aggiungi ai preferiti",
"remove_from_favorites": "Rimuovi dai preferiti",
"publish_settings": "Impostazioni di pubblicazione",
"publish_project": "Pubblica progetto",
"publish": "Pubblica",
"copy_link": "Copia link",
"leave_project": "Lascia progetto",
@@ -350,7 +350,7 @@
"couldnt_remove_the_project_from_favorites": "プロジェクトをお気に入りから削除できませんでした。もう一度お試しください。",
"add_to_favorites": "お気に入りに追加",
"remove_from_favorites": "お気に入りから削除",
"publish_settings": "公開設定",
"publish_project": "プロジェクトを公開",
"publish": "公開",
"copy_link": "リンクをコピー",
"leave_project": "プロジェクトを退出",
@@ -350,7 +350,7 @@
"couldnt_remove_the_project_from_favorites": "프로젝트를 즐겨찾기에서 제거하지 못했습니다. 다시 시도해주세요.",
"add_to_favorites": "즐겨찾기에 추가",
"remove_from_favorites": "즐겨찾기에서 제거",
"publish_settings": "설정 게시",
"publish_project": "프로젝트 게시",
"publish": "게시",
"copy_link": "링크 복사",
"leave_project": "프로젝트 떠나기",
@@ -348,7 +348,7 @@
"couldnt_remove_the_project_from_favorites": "Nie udało się usunąć projektu z ulubionych. Spróbuj ponownie.",
"add_to_favorites": "Dodaj do ulubionych",
"remove_from_favorites": "Usuń z ulubionych",
"publish_settings": "Ustawienia publikowania",
"publish_project": "Opublikuj projekt",
"publish": "Opublikuj",
"copy_link": "Kopiuj link",
"leave_project": "Opuść projekt",
@@ -350,7 +350,7 @@
"couldnt_remove_the_project_from_favorites": "Não foi possível remover o projeto dos favoritos. Por favor, tente novamente.",
"add_to_favorites": "Adicionar aos favoritos",
"remove_from_favorites": "Remover dos favoritos",
"publish_settings": "Configurações de publicação",
"publish_project": "Publicar projeto",
"publish": "Publicar",
"copy_link": "Copiar link",
"leave_project": "Sair do projeto",
@@ -350,7 +350,7 @@
"couldnt_remove_the_project_from_favorites": "Nu s-a putut elimina proiectul din favorite. Încearcă din nou.",
"add_to_favorites": "Adaugă la favorite",
"remove_from_favorites": "Elimină din favorite",
"publish_settings": "Setări de publicare",
"publish_project": "Publică proiectul",
"publish": "Publică",
"copy_link": "Copiază link-ul",
"leave_project": "Părăsește proiectul",
@@ -348,7 +348,7 @@
"couldnt_remove_the_project_from_favorites": "Не удалось удалить проект из избранного. Попробуйте снова.",
"add_to_favorites": "Добавить в избранное",
"remove_from_favorites": "Удалить из избранного",
"publish_settings": "Настройки публикации",
"publish_project": "Опубликовать проект",
"publish": "Опубликовать",
"copy_link": "Копировать ссылку",
"leave_project": "Покинуть проект",
@@ -348,7 +348,7 @@
"couldnt_remove_the_project_from_favorites": "Nepodarilo sa odstrániť projekt z obľúbených. Skúste to prosím znova.",
"add_to_favorites": "Pridať do obľúbených",
"remove_from_favorites": "Odstrániť z obľúbených",
"publish_settings": "Nastavenia publikovania",
"publish_project": "Publikovať projekt",
"publish": "Publikovať",
"copy_link": "Kopírovať odkaz",
"leave_project": "Opustiť projekt",
File diff suppressed because it is too large Load Diff
@@ -348,7 +348,7 @@
"couldnt_remove_the_project_from_favorites": "Не вдалося вилучити проєкт із вибраного. Спробуйте ще раз.",
"add_to_favorites": "Додати у вибране",
"remove_from_favorites": "Вилучити з вибраного",
"publish_settings": "Налаштування публікації",
"publish_project": "Опублікувати проєкт",
"publish": "Опублікувати",
"copy_link": "Скопіювати посилання",
"leave_project": "Вийти з проєкту",
@@ -348,7 +348,7 @@
"couldnt_remove_the_project_from_favorites": "Không thể xóa dự án khỏi mục yêu thích. Vui lòng thử lại.",
"add_to_favorites": "Thêm vào mục yêu thích",
"remove_from_favorites": "Xóa khỏi mục yêu thích",
"publish_settings": "Cài đặt xuất bản",
"publish_project": "Xuất bản dự án",
"publish": "Xuất bản",
"copy_link": "Sao chép liên kết",
"leave_project": "Rời dự án",
@@ -350,7 +350,7 @@
"couldnt_remove_the_project_from_favorites": "无法从收藏中移除项目。请重试。",
"add_to_favorites": "添加到收藏",
"remove_from_favorites": "从收藏中移除",
"publish_settings": "发布设置",
"publish_project": "发布项目",
"publish": "发布",
"copy_link": "复制链接",
"leave_project": "离开项目",
@@ -350,7 +350,7 @@
"couldnt_remove_the_project_from_favorites": "無法從我的最愛移除專案。請再試一次。",
"add_to_favorites": "加入我的最愛",
"remove_from_favorites": "從我的最愛移除",
"publish_settings": "發布設定",
"publish_project": "發佈專案",
"publish": "發布",
"copy_link": "複製連結",
"leave_project": "離開專案",
+2
View File
@@ -173,6 +173,8 @@ export class TranslationStore {
return import("../locales/ro/translations.json");
case "vi-VN":
return import("../locales/vi-VN/translations.json");
case "tr-TR":
return import("../locales/tr-TR/translations.json");
default:
throw new Error(`Unsupported language: ${language}`);
}
+2 -1
View File
@@ -16,7 +16,8 @@ export type TLanguage =
| "pt-BR"
| "id"
| "ro"
| "vi-VN";
| "vi-VN"
| "tr-TR";
export interface ILanguageOption {
label: string;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/logger",
"version": "0.25.3",
"version": "0.26.0",
"license": "AGPL-3.0",
"description": "Logger shared across multiple apps internally",
"private": true,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/propel",
"version": "0.25.3",
"version": "0.26.0",
"private": true,
"license": "AGPL-3.0",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/services",
"version": "0.25.3",
"version": "0.26.0",
"license": "AGPL-3.0",
"private": true,
"main": "./src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/shared-state",
"version": "0.25.3",
"version": "0.26.0",
"license": "AGPL-3.0",
"description": "Shared state shared across multiple apps internally",
"private": true,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/tailwind-config",
"version": "0.25.3",
"version": "0.26.0",
"license": "AGPL-3.0",
"description": "common tailwind configuration across monorepo",
"main": "tailwind.config.js",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/types",
"version": "0.25.3",
"version": "0.26.0",
"license": "AGPL-3.0",
"private": true,
"types": "./src/index.d.ts",
+1
View File
@@ -35,6 +35,7 @@ export type TIssueEntityData = {
sequence_id: number;
project_id: string;
project_identifier: string;
is_epic: boolean;
};
export type TActivityEntityData = {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/typescript-config",
"version": "0.25.3",
"version": "0.26.0",
"license": "AGPL-3.0",
"private": true,
"files": [
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "@plane/ui",
"description": "UI components shared across multiple apps internally",
"private": true,
"version": "0.25.3",
"version": "0.26.0",
"main": "./dist/index.js",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
@@ -2,12 +2,12 @@ import React, { useEffect, useRef, useState } from "react";
import ReactDOM from "react-dom";
// plane helpers
import { useOutsideClickDetector } from "@plane/hooks";
// components
import { ContextMenuItem } from "./item";
// helpers
import { cn } from "../../../helpers";
// hooks
import { usePlatformOS } from "../../hooks/use-platform-os";
// components
import { ContextMenuItem } from "./item";
export type TContextMenuItem = {
key: string;
+4 -4
View File
@@ -1,14 +1,14 @@
import { Menu } from "@headlessui/react";
import { ChevronDown, MoreHorizontal } from "lucide-react";
import * as React from "react";
import ReactDOM from "react-dom";
import { Menu } from "@headlessui/react";
import { usePopper } from "react-popper";
import { ChevronDown, MoreHorizontal } from "lucide-react";
// plane helpers
import { useOutsideClickDetector } from "@plane/hooks";
// hooks
import { useDropdownKeyDown } from "../hooks/use-dropdown-key-down";
// helpers
import { cn } from "../../helpers";
// hooks
import { useDropdownKeyDown } from "../hooks/use-dropdown-key-down";
// types
import { ICustomMenuDropdownProps, ICustomMenuItemProps } from "./helper";
+1
View File
@@ -32,3 +32,4 @@ export * from "./tag";
export * from "./tabs";
export * from "./calendar";
export * from "./color-picker";
export * from "./link";
+69
View File
@@ -0,0 +1,69 @@
import React, { FC } from "react";
// plane utils
import { calculateTimeAgo, cn, getIconForLink } from "@plane/utils";
// plane ui
import { TContextMenuItem } from "../dropdowns/context-menu/root";
import { CustomMenu } from "../dropdowns/custom-menu";
export type TLinkItemBlockProps = {
title: string;
url: string;
createdAt?: Date | string;
menuItems?: TContextMenuItem[];
onClick?: () => void;
};
export const LinkItemBlock: FC<TLinkItemBlockProps> = (props) => {
// props
const { title, url, createdAt, menuItems, onClick } = props;
// icons
const Icon = getIconForLink(url);
return (
<div
onClick={onClick}
className="cursor-pointer group flex items-center bg-custom-background-100 px-4 w-[230px] h-[56px] border-[0.5px] border-custom-border-200 rounded-md gap-4"
>
<div className="flex-shrink-0 size-8 rounded p-2 bg-custom-background-90 grid place-items-center">
<Icon className="size-4 stroke-2 text-custom-text-350 group-hover:text-custom-text-100" />
</div>
<div className="flex-1 truncate">
<div className="text-sm font-medium truncate">{title}</div>
{createdAt && <div className="text-xs font-medium text-custom-text-400">{calculateTimeAgo(createdAt)}</div>}
</div>
{menuItems && (
<div className="hidden group-hover:block">
<CustomMenu placement="bottom-end" menuItemsClassName="z-20" closeOnSelect verticalEllipsis>
{menuItems.map((item) => (
<CustomMenu.MenuItem
key={item.key}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
item.action();
}}
className={cn("flex items-center gap-2 w-full ", {
"text-custom-text-400": item.disabled,
})}
disabled={item.disabled}
>
{item.icon && <item.icon className={cn("h-3 w-3", item.iconClassName)} />}
<div>
<h5>{item.title}</h5>
{item.description && (
<p
className={cn("text-custom-text-300 whitespace-pre-line", {
"text-custom-text-400": item.disabled,
})}
>
{item.description}
</p>
)}
</div>
</CustomMenu.MenuItem>
))}
</CustomMenu>
</div>
)}
</div>
);
};
+1
View File
@@ -0,0 +1 @@
export * from "./block";
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/utils",
"version": "0.25.3",
"version": "0.26.0",
"description": "Helper functions shared across multiple apps internally",
"license": "AGPL-3.0",
"private": true,
+64
View File
@@ -0,0 +1,64 @@
import {
Github,
Linkedin,
Twitter,
Facebook,
Instagram,
Youtube,
Dribbble,
Figma,
FileText,
FileImage,
FileVideo,
FileAudio,
FileArchive,
FileSpreadsheet,
FileCode,
Mail,
Chrome,
Link2,
} from "lucide-react";
type IconMatcher = {
pattern: RegExp;
icon: typeof Github;
};
const SOCIAL_MEDIA_MATCHERS: IconMatcher[] = [
{ pattern: /github\.com/, icon: Github },
{ pattern: /linkedin\.com/, icon: Linkedin },
{ pattern: /(twitter\.com|x\.com)/, icon: Twitter },
{ pattern: /facebook\.com/, icon: Facebook },
{ pattern: /instagram\.com/, icon: Instagram },
{ pattern: /youtube\.com/, icon: Youtube },
{ pattern: /dribbble\.com/, icon: Dribbble },
];
const PRODUCTIVITY_MATCHERS: IconMatcher[] = [
{ pattern: /figma\.com/, icon: Figma },
{ pattern: /(google\.com|docs\.|doc\.)/, icon: FileText },
];
const FILE_TYPE_MATCHERS: IconMatcher[] = [
{ pattern: /\.(jpg|jpeg|png|gif|bmp|svg|webp)$/, icon: FileImage },
{ pattern: /\.(mp4|mov|avi|wmv|flv|mkv)$/, icon: FileVideo },
{ pattern: /\.(mp3|wav|ogg)$/, icon: FileAudio },
{ pattern: /\.(zip|rar|7z|tar|gz)$/, icon: FileArchive },
{ pattern: /\.(xls|xlsx|csv)$/, icon: FileSpreadsheet },
{ pattern: /\.(pdf|doc|docx|txt)$/, icon: FileText },
{ pattern: /\.(html|js|ts|jsx|tsx|css|scss)$/, icon: FileCode },
];
const OTHER_MATCHERS: IconMatcher[] = [
{ pattern: /^mailto:/, icon: Mail },
{ pattern: /^http/, icon: Chrome },
];
export const getIconForLink = (url: string) => {
const lowerUrl = url.toLowerCase();
const allMatchers = [...SOCIAL_MEDIA_MATCHERS, ...PRODUCTIVITY_MATCHERS, ...FILE_TYPE_MATCHERS, ...OTHER_MATCHERS];
const matchedIcon = allMatchers.find(({ pattern }) => pattern.test(lowerUrl));
return matchedIcon?.icon ?? Link2;
};
+4
View File
@@ -12,4 +12,8 @@ export * from "./string";
export * from "./theme";
export * from "./workspace";
export * from "./work-item";
export * from "./get-icon-for-link";
export * from "./subscription";
+83 -9
View File
@@ -1,15 +1,89 @@
#!/bin/bash
cp ./.env.example ./.env
# Export for tr error in mac
# Plane Project Setup Script
# This script prepares the local development environment by setting up all necessary .env files
# https://github.com/makeplane/plane
# Set colors for output messages
GREEN='\033[0;32m'
BLUE='\033[0;34m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
BOLD='\033[1m'
NC='\033[0m' # No Color
# Print header
echo -e "${BOLD}${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo -e "${BOLD}${BLUE} Plane - Project Management Tool ${NC}"
echo -e "${BOLD}${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo -e "${BOLD}Setting up your development environment...${NC}\n"
# Function to handle file copying with error checking
copy_env_file() {
local source=$1
local destination=$2
if [ ! -f "$source" ]; then
echo -e "${RED}Error: Source file $source does not exist.${NC}"
return 1
fi
cp "$source" "$destination"
if [ $? -eq 0 ]; then
echo -e "${GREEN}${NC} Copied $destination"
else
echo -e "${RED}${NC} Failed to copy $destination"
return 1
fi
}
# Export character encoding settings for macOS compatibility
export LC_ALL=C
export LC_CTYPE=C
echo -e "${YELLOW}Setting up environment files...${NC}"
cp ./web/.env.example ./web/.env
cp ./apiserver/.env.example ./apiserver/.env
cp ./space/.env.example ./space/.env
cp ./admin/.env.example ./admin/.env
cp ./live/.env.example ./live/.env
# Copy all environment example files
services=("" "web" "apiserver" "space" "admin" "live")
success=true
# Generate the SECRET_KEY that will be used by django
echo -e "\nSECRET_KEY=\"$(tr -dc 'a-z0-9' < /dev/urandom | head -c50)\"" >> ./apiserver/.env
for service in "${services[@]}"; do
prefix="./"
if [ "$service" != "" ]; then
prefix="./$service/"
fi
copy_env_file "${prefix}.env.example" "${prefix}.env" || success=false
done
# Generate SECRET_KEY for Django
if [ -f "./apiserver/.env" ]; then
echo -e "\n${YELLOW}Generating Django SECRET_KEY...${NC}"
SECRET_KEY=$(tr -dc 'a-z0-9' < /dev/urandom | head -c50)
if [ -z "$SECRET_KEY" ]; then
echo -e "${RED}Error: Failed to generate SECRET_KEY.${NC}"
echo -e "${RED}Ensure 'tr' and 'head' commands are available on your system.${NC}"
success=false
else
echo -e "SECRET_KEY=\"$SECRET_KEY\"" >> ./apiserver/.env
echo -e "${GREEN}${NC} Added SECRET_KEY to apiserver/.env"
fi
else
echo -e "${RED}${NC} apiserver/.env not found. SECRET_KEY not added."
success=false
fi
# Summary
echo -e "\n${YELLOW}Setup status:${NC}"
if [ "$success" = true ]; then
echo -e "${GREEN}${NC} Environment setup completed successfully!\n"
echo -e "${BOLD}Next steps:${NC}"
echo -e "1. Review the .env files in each folder if needed"
echo -e "2. Start the services with: ${BOLD}docker compose -f docker-compose-local.yml up -d${NC}"
echo -e "\n${GREEN}Happy coding! 🚀${NC}"
else
echo -e "${RED}${NC} Some issues occurred during setup. Please check the errors above.\n"
echo -e "For help, visit: ${BLUE}https://github.com/makeplane/plane${NC}"
exit 1
fi
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "space",
"version": "0.25.3",
"version": "0.26.0",
"private": true,
"license": "AGPL-3.0",
"scripts": {
@@ -5,7 +5,7 @@ import { observer } from "mobx-react";
import Link from "next/link";
import { useParams } from "next/navigation";
// icons
import { ArrowRight, PanelRight } from "lucide-react";
import { PanelRight } from "lucide-react";
// plane constants
import {
EIssueLayoutTypes,
@@ -25,7 +25,7 @@ import {
IIssueFilterOptions,
} from "@plane/types";
// ui
import { Breadcrumbs, Button, ContrastIcon, CustomMenu, Tooltip, Header, CustomSearchSelect } from "@plane/ui";
import { Breadcrumbs, Button, ContrastIcon, Tooltip, Header, CustomSearchSelect } from "@plane/ui";
// components
import { ProjectAnalyticsModal } from "@/components/analytics";
import { BreadcrumbLink, SwitcherLabel } from "@/components/common";
@@ -34,7 +34,6 @@ import { DisplayFiltersSelection, FiltersDropdown, FilterSelection, LayoutSelect
// helpers
import { cn } from "@/helpers/common.helper";
import { isIssueFilterActive } from "@/helpers/filter.helper";
import { truncateText } from "@/helpers/string.helper";
// hooks
import {
useEventTracker,
@@ -53,27 +52,6 @@ import { usePlatformOS } from "@/hooks/use-platform-os";
// plane web
import { ProjectBreadcrumb } from "@/plane-web/components/breadcrumbs";
const CycleDropdownOption: React.FC<{ cycleId: string }> = ({ cycleId }) => {
// router
const { workspaceSlug, projectId } = useParams();
// store hooks
const { getCycleById } = useCycle();
// derived values
const cycle = getCycleById(cycleId);
//
if (!cycle) return null;
return (
<CustomMenu.MenuItem key={cycle.id}>
<Link href={`/${workspaceSlug}/projects/${projectId}/cycles/${cycle.id}`} className="flex items-center gap-1.5">
<ContrastIcon className="h-3 w-3" />
{truncateText(cycle.name, 40)}
</Link>
</CustomMenu.MenuItem>
);
};
export const CycleIssuesHeader: React.FC = observer(() => {
// refs
const parentRef = useRef<HTMLDivElement>(null);
@@ -171,23 +149,16 @@ export const CycleIssuesHeader: React.FC = observer(() => {
?.map((id) => {
const _cycle = id === cycleId ? cycleDetails : getCycleById(id);
if (!_cycle) return;
const cycleLink = `/${workspaceSlug}/projects/${projectId}/cycles/${_cycle.id}`;
return {
value: _cycle.id,
query: _cycle.name,
content: (
<Link href={cycleLink}>
<SwitcherLabel name={_cycle.name} LabelIcon={ContrastIcon} />
</Link>
),
content: <SwitcherLabel name={_cycle.name} LabelIcon={ContrastIcon} />,
};
})
.filter((option) => option !== undefined) as ICustomSearchSelectOption[];
const workItemsCount = getGroupIssueCount(undefined, undefined, false);
const issuesCount = getGroupIssueCount(undefined, undefined, false);
return (
<>
<ProjectAnalyticsModal
@@ -231,7 +202,9 @@ export const CycleIssuesHeader: React.FC = observer(() => {
<CustomSearchSelect
options={switcherOptions}
value={cycleId}
onChange={() => {}}
onChange={(value: string) => {
router.push(`/${workspaceSlug}/projects/${projectId}/cycles/${value}`);
}}
label={
<div className="flex items-center gap-1">
<SwitcherLabel name={cycleDetails?.name} LabelIcon={ContrastIcon} />
@@ -256,7 +229,7 @@ export const CycleIssuesHeader: React.FC = observer(() => {
</Breadcrumbs>
</div>
</Header.LeftItem>
<Header.RightItem>
<Header.RightItem className="items-center">
<div className="hidden items-center gap-2 md:flex ">
<LayoutSelection
layouts={[
@@ -325,7 +298,7 @@ export const CycleIssuesHeader: React.FC = observer(() => {
)}
<button
type="button"
className="p-1 rounded outline-none hover:bg-custom-sidebar-background-80 bg-custom-background-80/70"
className="p-1.5 rounded outline-none hover:bg-custom-sidebar-background-80 bg-custom-background-80/70"
onClick={toggleSidebar}
>
<PanelRight className={cn("h-4 w-4", !isSidebarCollapsed ? "text-[#3E63DD]" : "text-custom-text-200")} />
@@ -335,7 +308,7 @@ export const CycleIssuesHeader: React.FC = observer(() => {
cycleId={cycleId}
projectId={projectId}
workspaceSlug={workspaceSlug}
customClassName="flex-shrink-0 flex items-center justify-center size-6 bg-custom-background-80/70 rounded"
customClassName="flex-shrink-0 flex items-center justify-center size-[26px] bg-custom-background-80/70 rounded"
/>
</div>
</Header.RightItem>
@@ -145,15 +145,10 @@ export const ModuleIssuesHeader: React.FC = observer(() => {
?.map((id) => {
const _module = id === moduleId ? moduleDetails : getModuleById(id);
if (!_module) return;
const moduleLink = `/${workspaceSlug}/projects/${projectId}/modules/${_module.id}`;
return {
value: _module.id,
query: _module.name,
content: (
<Link href={moduleLink}>
<SwitcherLabel name={_module.name} LabelIcon={DiceIcon} />
</Link>
),
content: <SwitcherLabel name={_module.name} LabelIcon={DiceIcon} />,
};
})
.filter((option) => option !== undefined) as ICustomSearchSelectOption[];
@@ -218,13 +213,15 @@ export const ModuleIssuesHeader: React.FC = observer(() => {
</div>
}
value={moduleId}
onChange={() => {}}
onChange={(value: string) => {
router.push(`/${workspaceSlug}/projects/${projectId}/modules/${value}`);
}}
/>
}
/>
</Breadcrumbs>
</Header.LeftItem>
<Header.RightItem>
<Header.RightItem className="items-center">
<div className="hidden gap-2 md:flex">
<LayoutSelection
layouts={[
@@ -299,7 +296,7 @@ export const ModuleIssuesHeader: React.FC = observer(() => {
)}
<button
type="button"
className="p-1 rounded outline-none hover:bg-custom-sidebar-background-80 bg-custom-background-80/70"
className="p-1.5 rounded outline-none hover:bg-custom-sidebar-background-80 bg-custom-background-80/70"
onClick={toggleSidebar}
>
<PanelRight className={cn("h-4 w-4", !isSidebarCollapsed ? "text-[#3E63DD]" : "text-custom-text-200")} />
@@ -309,7 +306,7 @@ export const ModuleIssuesHeader: React.FC = observer(() => {
moduleId={moduleId?.toString()}
projectId={projectId.toString()}
workspaceSlug={workspaceSlug.toString()}
customClassName="flex-shrink-0 flex items-center justify-center size-6 bg-custom-background-80/70 rounded"
customClassName="flex-shrink-0 flex items-center justify-center bg-custom-background-80/70 rounded size-[26px]"
/>
</Header.RightItem>
</Header>
@@ -161,7 +161,6 @@ const PageDetailsPage = observer(() => {
config={pageRootConfig}
handlers={pageRootHandlers}
page={page}
storeType={EPageStoreType.PROJECT}
webhookConnectionParams={webhookConnectionParams}
workspaceSlug={workspaceSlug?.toString() ?? ""}
/>
@@ -1,69 +1,55 @@
"use client";
import { observer } from "mobx-react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { ArchiveIcon, Earth, FileText, Lock } from "lucide-react";
import { FileText } from "lucide-react";
// types
import { EPageAccess } from "@plane/constants";
import { ICustomSearchSelectOption, TPage } from "@plane/types";
import { ICustomSearchSelectOption } from "@plane/types";
// ui
import { Breadcrumbs, Header, CustomSearchSelect } from "@plane/ui";
// components
import { BreadcrumbLink, SwitcherLabel } from "@/components/common";
import { PageEditInformationPopover } from "@/components/pages";
import { BreadcrumbLink, PageAccessIcon, SwitcherLabel } from "@/components/common";
import { PageHeaderActions } from "@/components/pages/header/actions";
// helpers
// hooks
import { getPageName } from "@/helpers/page.helper";
// hooks
import { useProject } from "@/hooks/store";
// plane web components
import { useAppRouter } from "@/hooks/use-app-router";
import { ProjectBreadcrumb } from "@/plane-web/components/breadcrumbs";
import { PageDetailsHeaderExtraActions } from "@/plane-web/components/pages";
// plane web hooks
import { EPageStoreType, usePage, usePageStore } from "@/plane-web/hooks/store";
const PageAccessIcon = (page: TPage) => (
<div>
{page.archived_at ? (
<ArchiveIcon className="h-2.5 w-2.5 text-custom-text-300" />
) : page.access === EPageAccess.PUBLIC ? (
<Earth className="h-2.5 w-2.5 text-custom-text-300" />
) : (
<Lock className="h-2.5 w-2.5 text-custom-text-300" />
)}
</div>
);
export interface IPagesHeaderProps {
showButton?: boolean;
}
const storeType = EPageStoreType.PROJECT;
export const PageDetailsHeader = observer(() => {
// router
const router = useAppRouter();
const { workspaceSlug, pageId, projectId } = useParams();
// store hooks
const { currentProjectDetails, loader } = useProject();
const { getPageById, getCurrentProjectPageIds } = usePageStore(storeType);
const page = usePage({
pageId: pageId?.toString() ?? "",
storeType: EPageStoreType.PROJECT,
storeType,
});
const { getPageById, getCurrentProjectPageIds } = usePageStore(EPageStoreType.PROJECT);
// derived values
const projectPageIds = getCurrentProjectPageIds(projectId?.toString());
if (!page) return null;
const switcherOptions = projectPageIds
.map((id) => {
const _page = id === pageId ? page : getPageById(id);
if (!_page) return;
const pageLink = `/${workspaceSlug}/projects/${projectId}/pages/${_page.id}`;
return {
value: _page.id,
query: _page.name,
content: (
<div className="flex gap-2 items-center justify-between">
<Link href={pageLink} className="flex gap-2 items-center justify-between w-full">
<SwitcherLabel logo_props={_page.logo_props} name={getPageName(_page.name)} LabelIcon={FileText} />
</Link>
<SwitcherLabel logo_props={_page.logo_props} name={getPageName(_page.name)} LabelIcon={FileText} />
<PageAccessIcon {..._page} />
</div>
),
@@ -114,7 +100,9 @@ export const PageDetailsHeader = observer(() => {
label={
<SwitcherLabel logo_props={page.logo_props} name={getPageName(page.name)} LabelIcon={FileText} />
}
onChange={() => {}}
onChange={(value: string) => {
router.push(`/${workspaceSlug}/projects/${projectId}/pages/${value}`);
}}
/>
}
/>
@@ -122,8 +110,8 @@ export const PageDetailsHeader = observer(() => {
</div>
</Header.LeftItem>
<Header.RightItem>
<PageEditInformationPopover page={page} />
<PageDetailsHeaderExtraActions page={page} />
<PageHeaderActions page={page} storeType={storeType} />
</Header.RightItem>
</Header>
);
@@ -2,7 +2,6 @@
import { useCallback, useRef } from "react";
import { observer } from "mobx-react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { Layers, Lock } from "lucide-react";
// plane constants
@@ -44,6 +43,7 @@ import {
useUserPermissions,
} from "@/hooks/store";
// plane web
import { useAppRouter } from "@/hooks/use-app-router";
import { ProjectBreadcrumb } from "@/plane-web/components/breadcrumbs";
export const ProjectViewIssuesHeader: React.FC = observer(() => {
@@ -51,6 +51,7 @@ export const ProjectViewIssuesHeader: React.FC = observer(() => {
const parentRef = useRef(null);
// router
const { workspaceSlug, projectId, viewId } = useParams();
const router = useAppRouter();
// store hooks
const {
issuesFilter: { issueFilters, updateFilters },
@@ -151,15 +152,10 @@ export const ProjectViewIssuesHeader: React.FC = observer(() => {
?.map((id) => {
const _view = id === viewId ? viewDetails : getViewById(id);
if (!_view) return;
const viewLink = `/${workspaceSlug}/projects/${projectId}/views/${_view.id}`;
return {
value: _view.id,
query: _view.name,
content: (
<Link href={viewLink}>
<SwitcherLabel logo_props={_view.logo_props} name={_view.name} LabelIcon={Layers} />
</Link>
),
content: <SwitcherLabel logo_props={_view.logo_props} name={_view.name} LabelIcon={Layers} />,
};
})
.filter((option) => option !== undefined) as ICustomSearchSelectOption[];
@@ -186,7 +182,9 @@ export const ProjectViewIssuesHeader: React.FC = observer(() => {
options={switcherOptions}
value={viewId}
label={<SwitcherLabel logo_props={viewDetails.logo_props} name={viewDetails.name} LabelIcon={Layers} />}
onChange={() => {}}
onChange={(value: string) => {
router.push(`/${workspaceSlug}/projects/${projectId}/views/${value}`);
}}
/>
}
/>
@@ -272,7 +270,7 @@ export const ProjectViewIssuesHeader: React.FC = observer(() => {
<div className="hidden md:block">
<ViewQuickActions
parentRef={parentRef}
customClassName="flex-shrink-0 flex items-center justify-center size-6 bg-custom-background-80/70 rounded"
customClassName="flex-shrink-0 flex items-center justify-center size-[26px] bg-custom-background-80/70 rounded"
projectId={projectId.toString()}
view={viewDetails}
workspaceSlug={workspaceSlug.toString()}
+1
View File
@@ -0,0 +1 @@
export * from "./peek-overviews";
@@ -0,0 +1,9 @@
"use client";
import { IssuePeekOverview } from "@/components/issues";
export const HomePeekOverviewsRoot = () => (
<>
<IssuePeekOverview />
</>
);
@@ -0,0 +1,13 @@
"use client";
import { FC } from "react";
import { observer } from "mobx-react";
export type TAdditionalActivityRoot = {
activityId: string;
showIssue?: boolean;
ends: "top" | "bottom" | undefined;
field: string | undefined;
};
export const AdditionalActivityRoot: FC<TAdditionalActivityRoot> = observer(() => <></>);
@@ -4,3 +4,4 @@ export * from "./issue-type-switcher";
export * from "./issue-type-activity";
export * from "./parent-select-root";
export * from "./issue-creator";
export * from "./additional-activity-root";
@@ -1,3 +0,0 @@
import { TIssueActivity } from "@plane/types";
export const renderEstimate = (activity: TIssueActivity, value: string) => value;
@@ -1,2 +1 @@
export * from "./root";
export * from "./helper";
+6 -6
View File
@@ -1,6 +1,6 @@
"use client";
import React, { RefObject, useEffect, useRef, useState } from "react";
import React, { useEffect, useRef, useState } from "react";
import { ChevronRight, CornerDownRight, LucideIcon, RefreshCcw, Sparkles, TriangleAlert } from "lucide-react";
// plane editor
import { EditorRefApi } from "@plane/editor";
@@ -18,7 +18,7 @@ import { AskPiMenu } from "./ask-pi-menu";
const aiService = new AIService();
type Props = {
editorRef: RefObject<EditorRefApi>;
editorRef: EditorRefApi | null;
isOpen: boolean;
onClose: () => void;
workspaceId: string;
@@ -73,7 +73,7 @@ export const EditorAIMenu: React.FC<Props> = (props) => {
};
// handle task click
const handleClick = async (key: AI_EDITOR_TASKS) => {
const selection = editorRef.current?.getSelectedText();
const selection = editorRef?.getSelectedText();
if (!selection || activeTask === key) return;
setActiveTask(key);
if (key === AI_EDITOR_TASKS.ASK_ANYTHING) return;
@@ -86,7 +86,7 @@ export const EditorAIMenu: React.FC<Props> = (props) => {
};
// handle re-generate response
const handleRegenerate = async () => {
const selection = editorRef.current?.getSelectedText();
const selection = editorRef?.getSelectedText();
if (!selection || !activeTask) return;
setIsRegenerating(true);
await handleGenerateResponse({
@@ -104,7 +104,7 @@ export const EditorAIMenu: React.FC<Props> = (props) => {
// handle re-generate response
const handleToneChange = async (key: string) => {
const selectedTone = TONES_LIST.find((t) => t.key === key);
const selection = editorRef.current?.getSelectedText();
const selection = editorRef?.getSelectedText();
if (!selectedTone || !selection || !activeTask) return;
setResponse(undefined);
setIsRegenerating(false);
@@ -123,7 +123,7 @@ export const EditorAIMenu: React.FC<Props> = (props) => {
// handle replace selected text with the response
const handleInsertText = (insertOnNextLine: boolean) => {
if (!response) return;
editorRef.current?.insertText(response, insertOnNextLine);
editorRef?.insertText(response, insertOnNextLine);
onClose();
};
@@ -0,0 +1,10 @@
"use client";
// store
import { TPageInstance } from "@/store/pages/base-page";
export type TPageCollaboratorsListProps = {
page: TPageInstance;
};
export const PageCollaboratorsList = ({}: TPageCollaboratorsListProps) => null;
@@ -0,0 +1,116 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { observer } from "mobx-react";
import { LockKeyhole, LockKeyholeOpen } from "lucide-react";
// plane imports
import { Tooltip } from "@plane/ui";
// hooks
import { usePageOperations } from "@/hooks/use-page-operations";
// store
import { TPageInstance } from "@/store/pages/base-page";
// Define our lock display states, renaming "icon-only" to "neutral"
type LockDisplayState = "neutral" | "locked" | "unlocked";
type Props = {
page: TPageInstance;
};
export const PageLockControl = observer(({ page }: Props) => {
// Initial state: if locked, then "locked", otherwise default to "neutral"
const [displayState, setDisplayState] = useState<LockDisplayState>(page.is_locked ? "locked" : "neutral");
// derived values
const { canCurrentUserLockPage, is_locked } = page;
// Ref for the transition timer
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Ref to store the previous value of isLocked for detecting transitions
const prevLockedRef = useRef(is_locked);
// page operations
const {
pageOperations: { toggleLock },
} = usePageOperations({
page,
});
// Cleanup any running timer on unmount
useEffect(
() => () => {
if (timerRef.current) clearTimeout(timerRef.current);
},
[]
);
// Update display state when isLocked changes
useEffect(() => {
// Clear any previous timer to avoid overlapping transitions
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
// Transition logic:
// If locked, ensure the display state is "locked"
// If unlocked after being locked, show "unlocked" briefly then revert to "neutral"
if (is_locked) {
setDisplayState("locked");
} else if (prevLockedRef.current === true) {
setDisplayState("unlocked");
timerRef.current = setTimeout(() => {
setDisplayState("neutral");
timerRef.current = null;
}, 600);
} else {
setDisplayState("neutral");
}
// Update the previous locked state
prevLockedRef.current = is_locked;
}, [is_locked]);
if (!canCurrentUserLockPage) return null;
// Render different UI based on the current display state
return (
<>
{displayState === "neutral" && (
<Tooltip tooltipContent="Lock" position="bottom">
<button
type="button"
onClick={toggleLock}
className="flex-shrink-0 size-6 grid place-items-center rounded text-custom-text-200 hover:text-custom-text-100 hover:bg-custom-background-80 transition-colors"
aria-label="Lock"
>
<LockKeyhole className="size-3.5" />
</button>
</Tooltip>
)}
{displayState === "locked" && (
<button
type="button"
onClick={toggleLock}
className="h-6 flex items-center gap-1 px-2 rounded text-custom-primary-100 bg-custom-primary-100/20 hover:bg-custom-primary-100/30 transition-colors"
aria-label="Locked"
>
<LockKeyhole className="flex-shrink-0 size-3.5 animate-lock-icon" />
<span className="text-xs font-medium whitespace-nowrap overflow-hidden transition-all duration-500 ease-out animate-text-slide-in">
Locked
</span>
</button>
)}
{displayState === "unlocked" && (
<div
className="h-6 flex items-center gap-1 px-2 rounded text-custom-text-200 animate-fade-out"
aria-label="Unlocked"
>
<LockKeyholeOpen className="flex-shrink-0 size-3.5 animate-unlock-icon" />
<span className="text-xs font-medium whitespace-nowrap overflow-hidden transition-all duration-500 ease-out animate-text-slide-in animate-text-fade-out">
Unlocked
</span>
</div>
)}
</>
);
});
@@ -0,0 +1,10 @@
"use client";
// store
import { TPageInstance } from "@/store/pages/base-page";
export type TPageMoveControlProps = {
page: TPageInstance;
};
export const PageMoveControl = ({}: TPageMoveControlProps) => null;
@@ -46,7 +46,7 @@ export const CreateApiTokenModal: React.FC<Props> = (props) => {
const csvData = {
Title: data.label,
Description: data.description,
Expiry: data.expired_at ? renderFormattedDate(data.expired_at)?.replace(",", " ") ?? "" : "Never expires",
Expiry: data.expired_at ? (renderFormattedDate(data.expired_at)?.replace(",", " ") ?? "") : "Never expires",
"Secret key": data.token ?? "",
};
+20 -16
View File
@@ -13,7 +13,7 @@ import { Button, CustomSelect, Input, TextArea, ToggleSwitch, TOAST_TYPE, setToa
import { DateDropdown } from "@/components/dropdowns";
// helpers
import { cn } from "@/helpers/common.helper";
import { renderFormattedDate, renderFormattedPayloadDate } from "@/helpers/date-time.helper";
import { renderFormattedDate, renderFormattedTime } from "@/helpers/date-time.helper";
type Props = {
handleClose: () => void;
@@ -51,20 +51,21 @@ const defaultValues: Partial<IApiToken> = {
expired_at: null,
};
const getExpiryDate = (val: string): string | null | undefined => {
const getExpiryDate = (val: string): Date | null | undefined => {
const today = new Date();
const dateToAdd = EXPIRY_DATE_OPTIONS.find((option) => option.key === val)?.value;
if (dateToAdd) {
const expiryDate = add(today, dateToAdd);
return renderFormattedDate(expiryDate);
}
if (dateToAdd) return add(today, dateToAdd);
return null;
};
const getFormattedDate = (date: Date): Date => {
const now = new Date();
const hours = now.getHours();
const minutes = now.getMinutes();
const seconds = now.getSeconds();
return add(date, { hours, minutes, seconds });
};
export const CreateApiTokenForm: React.FC<Props> = (props) => {
const { handleClose, neverExpires, toggleNeverExpires, onSubmit } = props;
// states
@@ -97,12 +98,13 @@ export const CreateApiTokenForm: React.FC<Props> = (props) => {
// if never expires is toggled on, set expired_at to null
if (neverExpires) payload.expired_at = null;
// if never expires is toggled off, and the user has selected a custom date, set expired_at to the custom date
else if (data.expired_at === "custom") payload.expired_at = renderFormattedPayloadDate(customDate);
else if (data.expired_at === "custom") {
payload.expired_at = customDate && getFormattedDate(customDate).toISOString();
}
// if never expires is toggled off, and the user has selected a predefined date, set expired_at to the predefined date
else {
const expiryDate = getExpiryDate(data.expired_at ?? "");
if (expiryDate) payload.expired_at = renderFormattedPayloadDate(new Date(expiryDate));
if (expiryDate) payload.expired_at = expiryDate.toISOString();
}
await onSubmit(payload).then(() => {
@@ -114,6 +116,8 @@ export const CreateApiTokenForm: React.FC<Props> = (props) => {
const today = new Date();
const tomorrow = add(today, { days: 1 });
const expiredAt = watch("expired_at");
const expiryDate = getExpiryDate(expiredAt ?? "");
const customDateFormatted = customDate && getFormattedDate(customDate);
return (
<form onSubmit={handleSubmit(handleFormSubmit)}>
@@ -219,10 +223,10 @@ export const CreateApiTokenForm: React.FC<Props> = (props) => {
<span className="text-xs text-custom-text-400">
{expiredAt === "custom"
? customDate
? `Expires ${renderFormattedDate(customDate)}`
? `Expires ${renderFormattedDate(customDateFormatted ?? "")} at ${renderFormattedTime(customDateFormatted ?? "")}`
: null
: expiredAt
? `Expires ${getExpiryDate(expiredAt ?? "")}`
? `Expires ${renderFormattedDate(expiryDate ?? "")} at ${renderFormattedTime(expiryDate ?? "")}`
: null}
</span>
)}
@@ -249,4 +253,4 @@ export const CreateApiTokenForm: React.FC<Props> = (props) => {
</div>
</form>
);
};
};
@@ -6,7 +6,7 @@ import { IApiToken } from "@plane/types";
// ui
import { Button, Tooltip, TOAST_TYPE, setToast } from "@plane/ui";
// helpers
import { renderFormattedDate } from "@/helpers/date-time.helper";
import { renderFormattedDate, renderFormattedTime } from "@/helpers/date-time.helper";
import { copyTextToClipboard } from "@/helpers/string.helper";
// types
import { usePlatformOS } from "@/hooks/use-platform-os";
@@ -49,7 +49,9 @@ export const GeneratedTokenDetails: React.FC<Props> = (props) => {
</button>
<div className="mt-6 flex items-center justify-between">
<p className="text-xs text-custom-text-400">
{tokenDetails.expired_at ? `Expires ${renderFormattedDate(tokenDetails.expired_at)}` : "Never expires"}
{tokenDetails.expired_at
? `Expires ${renderFormattedDate(tokenDetails.expired_at!)} at ${renderFormattedTime(tokenDetails.expired_at!)}`
: "Never expires"}
</p>
<Button variant="neutral-primary" size="sm" onClick={handleClose}>
{t("close")}
@@ -6,7 +6,7 @@ import { IApiToken } from "@plane/types";
// components
import { Tooltip } from "@plane/ui";
import { DeleteApiTokenModal } from "@/components/api-token";
import { renderFormattedDate, calculateTimeAgo } from "@/helpers/date-time.helper";
import { renderFormattedDate, calculateTimeAgo, renderFormattedTime } from "@/helpers/date-time.helper";
import { usePlatformOS } from "@/hooks/use-platform-os";
// ui
// helpers
@@ -52,7 +52,7 @@ export const ApiTokenListItem: React.FC<Props> = (props) => {
<p className="mb-1 text-xs leading-6 text-custom-text-400">
{token.is_active
? token.expired_at
? `Expires ${renderFormattedDate(token.expired_at!)}`
? `Expires ${renderFormattedDate(token.expired_at!)} at ${renderFormattedTime(token.expired_at!)}`
: "Never expires"
: `Expired ${calculateTimeAgo(token.expired_at)}`}
</p>
@@ -60,4 +60,4 @@ export const ApiTokenListItem: React.FC<Props> = (props) => {
</div>
</>
);
};
};
@@ -34,12 +34,6 @@ export const CommentsWrapper: FC<TCommentsWrapper> = observer((props) => {
entityId={entityId}
activityOperations={activityOperations}
projectId={projectId}
onSubmitCallback={async (elementId: string) => {
const sourceElementId = elementId ?? "";
const sourceElement = document.getElementById(sourceElementId);
if (sourceElement)
await smoothScrollIntoView(sourceElement, { behavior: "smooth", block: "center", duration: 1500 });
}}
/>
),
[isEditingAllowed, workspaceSlug, entityId, activityOperations, projectId]
+1
View File
@@ -7,3 +7,4 @@ export * from "./pro-icon";
export * from "./count-chip";
export * from "./activity";
export * from "./switcher-label";
export * from "./page-access-icon";
@@ -0,0 +1,15 @@
import { ArchiveIcon, Earth, Lock } from "lucide-react";
import { EPageAccess } from "@plane/constants";
import { TPage } from "@plane/types";
export const PageAccessIcon = (page: TPage) => (
<div>
{page.archived_at ? (
<ArchiveIcon className="h-2.5 w-2.5 text-custom-text-300" />
) : page.access === EPageAccess.PUBLIC ? (
<Earth className="h-2.5 w-2.5 text-custom-text-300" />
) : (
<Lock className="h-2.5 w-2.5 text-custom-text-300" />
)}
</div>
);
@@ -14,7 +14,7 @@ import { useOutsideClickDetector } from "@plane/hooks";
// plane types
import { EFileAssetType } from "@plane/types/src/enums";
// ui
import { Button, Input, Loader } from "@plane/ui";
import { Button, Input, Loader, TOAST_TYPE, setToast } from "@plane/ui";
// helpers
import { getFileURL } from "@/helpers/file.helper";
// hooks
@@ -114,7 +114,16 @@ export const ImagePickerPopover: React.FC<Props> = observer((props) => {
},
image
)
.then((res) => uploadCallback(res.asset_url));
.then((res) => uploadCallback(res.asset_url))
.catch((error) => {
console.error("Error uploading user cover image:", error);
setIsImageUploading(false);
setToast({
message: error?.error ?? "The image could not be uploaded",
type: TOAST_TYPE.ERROR,
title: "Image not uploaded",
});
});
} else {
if (!workspaceSlug) return;
await fileService
@@ -126,7 +135,16 @@ export const ImagePickerPopover: React.FC<Props> = observer((props) => {
},
image
)
.then((res) => uploadCallback(res.asset_url));
.then((res) => uploadCallback(res.asset_url))
.catch((error) => {
console.error("Error uploading project cover image:", error);
setIsImageUploading(false);
setToast({
message: error?.error ?? "The image could not be uploaded",
type: TOAST_TYPE.ERROR,
title: "Image not uploaded",
});
});
}
};
+10 -4
View File
@@ -24,6 +24,8 @@ interface IListItemProps {
isSidebarOpen?: boolean;
quickActionElement?: JSX.Element;
preventDefaultNProgress?: boolean;
leftElementClassName?: string;
rightElementClassName?: string;
}
export const ListItem: FC<IListItemProps> = (props) => {
@@ -44,6 +46,8 @@ export const ListItem: FC<IListItemProps> = (props) => {
quickActionElement,
itemClassName = "",
preventDefaultNProgress = false,
leftElementClassName = "",
rightElementClassName = "",
} = props;
// router
@@ -64,7 +68,7 @@ export const ListItem: FC<IListItemProps> = (props) => {
className
)}
>
<div className={cn("relative flex w-full items-center justify-between gap-3 overflow-hidden", itemClassName)}>
<div className={cn("relative flex w-full items-center justify-between gap-3 truncate ", itemClassName)}>
<ControlLink
id={id}
className="relative flex w-full items-center gap-3 overflow-hidden"
@@ -74,20 +78,22 @@ export const ListItem: FC<IListItemProps> = (props) => {
disabled={disableLink}
data-prevent-nprogress={preventDefaultNProgress}
>
<div className="flex items-center gap-4 truncate">
<div className={cn("flex items-center gap-4 truncate", leftElementClassName)}>
{prependTitleElement && <span className="flex items-center flex-shrink-0">{prependTitleElement}</span>}
<Tooltip tooltipContent={title} position="top" isMobile={isMobile}>
<span className="truncate text-sm">{title}</span>
</Tooltip>
</div>
{appendTitleElement && <span className="flex items-center flex-shrink-0">{appendTitleElement}</span>}
{appendTitleElement && (
<span className={cn("flex items-center flex-shrink-0", rightElementClassName)}>{appendTitleElement}</span>
)}
</ControlLink>
{quickActionElement && quickActionElement}
</div>
{actionableItems && (
<div
className={cn(
"relative flex items-center justify-start gap-4 flex-wrap w-full",
"relative flex items-center justify-start gap-4 flex-wrap w-full flex-shrink-0",
{
"xl:flex-nowrap xl:w-auto xl:flex-shrink-0": isSidebarOpen,
"lg:flex-nowrap lg:w-auto lg:flex-shrink-0": !isSidebarOpen,
@@ -55,14 +55,8 @@ export const CyclesListItem: FC<TCyclesListItem> = observer((props) => {
const isCompleted = cycleStatus === "completed";
const isActive = cycleStatus === "current";
const cycleTotalIssues =
cycleDetails.backlog_issues +
cycleDetails.unstarted_issues +
cycleDetails.started_issues +
cycleDetails.completed_issues +
cycleDetails.cancelled_issues;
const completionPercentage = (cycleDetails.completed_issues / cycleTotalIssues) * 100;
const completionPercentage =
((cycleDetails.completed_issues + cycleDetails.cancelled_issues) / cycleDetails.total_issues) * 100;
const progress = isNaN(completionPercentage) ? 0 : Math.floor(completionPercentage);
+2 -2
View File
@@ -12,7 +12,7 @@ import { cn } from "@/helpers/common.helper";
import { useUserProfile, useEventTracker, useUser } from "@/hooks/store";
import { useHome } from "@/hooks/store/use-home";
import useSize from "@/hooks/use-window-size";
import { IssuePeekOverview } from "../issues";
import { HomePeekOverviewsRoot } from "@/plane-web/components/home";
import { DashboardWidgets } from "./home-dashboard-widgets";
import { UserGreetingsView } from "./user-greetings";
@@ -57,7 +57,7 @@ export const WorkspaceHomeView = observer(() => {
</div>
)}
<>
<IssuePeekOverview />
<HomePeekOverviewsRoot />
<ContentWrapper
className={cn("gap-6 bg-custom-background-90/20", {
"vertical-scrollbar scrollbar-lg": windowWidth >= 768,
@@ -1,14 +1,12 @@
"use client";
import { FC } from "react";
import { FC, useCallback, useMemo } from "react";
import { observer } from "mobx-react";
import { Pencil, Trash2, ExternalLink, EllipsisVertical, Link2, Link } from "lucide-react";
import { Pencil, ExternalLink, Link, Trash2 } from "lucide-react";
import { useTranslation } from "@plane/i18n";
import { TOAST_TYPE, setToast, CustomMenu, TContextMenuItem } from "@plane/ui";
import { TOAST_TYPE, setToast, TContextMenuItem, LinkItemBlock } from "@plane/ui";
// plane utils
import { cn, copyTextToClipboard } from "@plane/utils";
// helpers
import { calculateTimeAgo } from "@/helpers/date-time.helper";
import { copyTextToClipboard } from "@plane/utils";
// hooks
import { useHome } from "@/hooks/store/use-home";
// types
@@ -27,98 +25,76 @@ export const ProjectLinkDetail: FC<TProjectLinkDetail> = observer((props) => {
quickLinks: { getLinkById, toggleLinkModal, setLinkData },
} = useHome();
const { t } = useTranslation();
// derived values
const linkDetail = getLinkById(linkId);
if (!linkDetail) return <></>;
const viewLink = linkDetail.url;
if (!linkDetail) return null;
const handleEdit = (modalToggle: boolean) => {
toggleLinkModal(modalToggle);
setLinkData(linkDetail);
};
// handlers
const handleEdit = useCallback(
(modalToggle: boolean) => {
toggleLinkModal(modalToggle);
setLinkData(linkDetail);
},
[linkDetail, setLinkData, toggleLinkModal]
);
const handleCopyText = () =>
copyTextToClipboard(viewLink).then(() => {
const handleCopyText = useCallback(() => {
copyTextToClipboard(linkDetail.url).then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: t("link_copied"),
message: t("view_link_copied_to_clipboard"),
});
});
const handleOpenInNewTab = () => window.open(`${viewLink}`, "_blank");
}, [linkDetail.url, t]);
const MENU_ITEMS: TContextMenuItem[] = [
{
key: "edit",
action: () => handleEdit(true),
title: t("edit"),
icon: Pencil,
},
{
key: "open-new-tab",
action: handleOpenInNewTab,
title: t("open_in_new_tab"),
icon: ExternalLink,
},
{
key: "copy-link",
action: handleCopyText,
title: t("copy_link"),
icon: Link,
},
{
key: "delete",
action: () => linkOperations.remove(linkId),
title: t("delete"),
icon: Trash2,
},
];
const handleOpenInNewTab = useCallback(() => {
window.open(linkDetail.url, "_blank", "noopener,noreferrer");
}, [linkDetail.url]);
const handleDelete = useCallback(() => {
linkOperations.remove(linkId);
}, [linkId, linkOperations]);
// derived values
const menuItems = useMemo<TContextMenuItem[]>(
() => [
{
key: "edit",
action: () => handleEdit(true),
title: t("edit"),
icon: Pencil,
},
{
key: "open-new-tab",
action: handleOpenInNewTab,
title: t("open_in_new_tab"),
icon: ExternalLink,
},
{
key: "copy-link",
action: handleCopyText,
title: t("copy_link"),
icon: Link,
},
{
key: "delete",
action: handleDelete,
title: t("delete"),
icon: Trash2,
},
],
[handleEdit, handleOpenInNewTab, handleCopyText, handleDelete, t]
);
return (
<div
<LinkItemBlock
title={linkDetail.title || linkDetail.url}
url={linkDetail.url}
createdAt={linkDetail.created_at}
menuItems={menuItems}
onClick={handleOpenInNewTab}
className="cursor-pointer group flex items-center bg-custom-background-100 px-4 w-[230px] h-[56px] border-[0.5px] border-custom-border-200 rounded-md gap-4 hover:shadow-md transition-shadow"
>
<div className="flex-shrink-0 size-8 rounded p-2 bg-custom-background-80 grid place-items-center">
<Link2 className="size-4 stroke-2 text-custom-text-350 -rotate-45" />
</div>
<div className="flex-1 truncate">
<div className="text-sm font-medium truncate">{linkDetail.title || linkDetail.url}</div>
<div className="text-xs font-medium text-custom-text-400">{calculateTimeAgo(linkDetail.created_at)}</div>
</div>
<div className="hidden group-hover:block">
<CustomMenu placement="bottom-end" menuItemsClassName="z-20" closeOnSelect verticalEllipsis>
{MENU_ITEMS.map((item) => (
<CustomMenu.MenuItem
key={item.key}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
item.action();
}}
className={cn("flex items-center gap-2 w-full ", {
"text-custom-text-400": item.disabled,
})}
disabled={item.disabled}
>
{item.icon && <item.icon className={cn("h-3 w-3", item.iconClassName)} />}
<div>
<h5>{item.title}</h5>
{item.description && (
<p
className={cn("text-custom-text-300 whitespace-pre-line", {
"text-custom-text-400": item.disabled,
})}
>
{item.description}
</p>
)}
</div>
</CustomMenu.MenuItem>
))}
</CustomMenu>
</div>
</div>
/>
);
});
@@ -1,3 +1,6 @@
import { observer } from "mobx-react";
// plane constants
import { EIssueServiceType } from "@plane/constants";
// plane types
import { TActivityEntityData, TIssueEntityData } from "@plane/types";
// plane ui
@@ -18,11 +21,12 @@ type BlockProps = {
ref: React.RefObject<HTMLDivElement>;
workspaceSlug: string;
};
export const RecentIssue = (props: BlockProps) => {
export const RecentIssue = observer((props: BlockProps) => {
const { activity, ref, workspaceSlug } = props;
// hooks
const { getStateById } = useProjectState();
const { setPeekIssue } = useIssueDetail();
const { setPeekIssue: setPeekEpic } = useIssueDetail(EIssueServiceType.EPICS);
const { getProjectIdentifierById } = useProject();
// derived values
const issueDetails: TIssueEntityData = activity.entity_data as TIssueEntityData;
@@ -38,8 +42,21 @@ export const RecentIssue = (props: BlockProps) => {
issueId: issueDetails?.id,
projectIdentifier,
sequenceId: issueDetails?.sequence_id,
isEpic: issueDetails?.is_epic,
});
const handlePeekOverview = (e: React.MouseEvent<HTMLAnchorElement>) => {
e.preventDefault();
e.stopPropagation();
const peekDetails = {
workspaceSlug,
projectId: issueDetails?.project_id,
issueId: activity.entity_data.id,
};
if (issueDetails?.is_epic) setPeekEpic(peekDetails);
else setPeekIssue(peekDetails);
};
return (
<ListItem
key={activity.id}
@@ -109,12 +126,8 @@ export const RecentIssue = (props: BlockProps) => {
disableLink={false}
className="bg-transparent my-auto !px-2 border-none py-3"
itemClassName="my-auto"
onItemClick={(e) => {
e.preventDefault();
e.stopPropagation();
setPeekIssue({ workspaceSlug, projectId: issueDetails?.project_id, issueId: activity.entity_data.id });
}}
onItemClick={handlePeekOverview}
preventDefaultNProgress
/>
);
};
});

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