Compare commits
41
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f278a284c4 | ||
|
|
2bcf6c76cd | ||
|
|
fb3e022042 | ||
|
|
e3fbb7b073 | ||
|
|
cce6dd581c | ||
|
|
d86ac368a4 | ||
|
|
101994840a | ||
|
|
f60f57ef11 | ||
|
|
546217f09b | ||
|
|
6df8323665 | ||
|
|
77d022df71 | ||
|
|
797f150ec4 | ||
|
|
b54f54999e | ||
|
|
dff176be8f | ||
|
|
2bbaaed3ea | ||
|
|
b5ceb94fb2 | ||
|
|
feb6243065 | ||
|
|
5dacba74c9 | ||
|
|
0efb0c239c | ||
|
|
c8be836d6c | ||
|
|
833b82e247 | ||
|
|
280aa7f671 | ||
|
|
eac1115566 | ||
|
|
8166a757a7 | ||
|
|
be5d77d978 | ||
|
|
18fb3b8450 | ||
|
|
ef5616905e | ||
|
|
aeb41e603c | ||
|
|
55eea1a8b7 | ||
|
|
fa87ff14b7 | ||
|
|
7d91b5f8df | ||
|
|
3ce40dfa2f | ||
|
|
f65253c994 | ||
|
|
97fcfaa653 | ||
|
|
0e1ebff978 | ||
|
|
642dabfe35 | ||
|
|
48557cb670 | ||
|
|
608da1465c | ||
|
|
dbcc7bedb4 | ||
|
|
c401b26dd4 | ||
|
|
a4bca0c39c |
@@ -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
|
||||
|
||||
@@ -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
@@ -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,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"
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 ",
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 []
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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,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,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,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,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,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,
|
||||
|
||||
@@ -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": "離開專案",
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -16,7 +16,8 @@ export type TLanguage =
|
||||
| "pt-BR"
|
||||
| "id"
|
||||
| "ro"
|
||||
| "vi-VN";
|
||||
| "vi-VN"
|
||||
| "tr-TR";
|
||||
|
||||
export interface ILanguageOption {
|
||||
label: string;
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@plane/propel",
|
||||
"version": "0.25.3",
|
||||
"version": "0.26.0",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0",
|
||||
"scripts": {
|
||||
|
||||
@@ -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,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,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,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",
|
||||
|
||||
Vendored
+1
@@ -35,6 +35,7 @@ export type TIssueEntityData = {
|
||||
sequence_id: number;
|
||||
project_id: string;
|
||||
project_identifier: string;
|
||||
is_epic: boolean;
|
||||
};
|
||||
|
||||
export type TActivityEntityData = {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@plane/typescript-config",
|
||||
"version": "0.25.3",
|
||||
"version": "0.26.0",
|
||||
"license": "AGPL-3.0",
|
||||
"private": true,
|
||||
"files": [
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -32,3 +32,4 @@ export * from "./tag";
|
||||
export * from "./tabs";
|
||||
export * from "./calendar";
|
||||
export * from "./color-picker";
|
||||
export * from "./link";
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./block";
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "space",
|
||||
"version": "0.25.3",
|
||||
"version": "0.26.0",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0",
|
||||
"scripts": {
|
||||
|
||||
+9
-36
@@ -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>
|
||||
|
||||
+7
-10
@@ -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>
|
||||
|
||||
-1
@@ -161,7 +161,6 @@ const PageDetailsPage = observer(() => {
|
||||
config={pageRootConfig}
|
||||
handlers={pageRootHandlers}
|
||||
page={page}
|
||||
storeType={EPageStoreType.PROJECT}
|
||||
webhookConnectionParams={webhookConnectionParams}
|
||||
workspaceSlug={workspaceSlug?.toString() ?? ""}
|
||||
/>
|
||||
|
||||
+16
-28
@@ -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>
|
||||
);
|
||||
|
||||
+7
-9
@@ -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()}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 ?? "",
|
||||
};
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user