Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| def49be2d3 | |||
| 8b0a797906 | |||
| cea6f7530b | |||
| a7e2e596bf | |||
| 980428b204 | |||
| f428c3bdaf | |||
| a05cd88a53 | |||
| 60220801ac |
@@ -54,4 +54,5 @@ from .asset import (
|
||||
FileAssetSerializer,
|
||||
)
|
||||
from .invite import WorkspaceInviteSerializer
|
||||
from .member import ProjectMemberSerializer
|
||||
from .member import ProjectMemberSerializer
|
||||
from .sticky import StickySerializer
|
||||
@@ -0,0 +1,30 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
from .base import BaseSerializer
|
||||
from plane.db.models import Sticky
|
||||
from plane.utils.content_validator import validate_html_content, validate_binary_data
|
||||
|
||||
|
||||
class StickySerializer(BaseSerializer):
|
||||
class Meta:
|
||||
model = Sticky
|
||||
fields = "__all__"
|
||||
read_only_fields = ["workspace", "owner"]
|
||||
extra_kwargs = {"name": {"required": False}}
|
||||
|
||||
def validate(self, data):
|
||||
# Validate description content for security
|
||||
if "description_html" in data and data["description_html"]:
|
||||
is_valid, error_msg, sanitized_html = validate_html_content(data["description_html"])
|
||||
if not is_valid:
|
||||
raise serializers.ValidationError({"error": "html content is not valid"})
|
||||
# Update the data with sanitized HTML if available
|
||||
if sanitized_html is not None:
|
||||
data["description_html"] = sanitized_html
|
||||
|
||||
if "description_binary" in data and data["description_binary"]:
|
||||
is_valid, error_msg = validate_binary_data(data["description_binary"])
|
||||
if not is_valid:
|
||||
raise serializers.ValidationError({"description_binary": "Invalid binary data"})
|
||||
|
||||
return data
|
||||
@@ -9,6 +9,7 @@ from .state import urlpatterns as state_patterns
|
||||
from .user import urlpatterns as user_patterns
|
||||
from .work_item import urlpatterns as work_item_patterns
|
||||
from .invite import urlpatterns as invite_patterns
|
||||
from .sticky import urlpatterns as sticky_patterns
|
||||
|
||||
urlpatterns = [
|
||||
*asset_patterns,
|
||||
@@ -22,4 +23,5 @@ urlpatterns = [
|
||||
*user_patterns,
|
||||
*work_item_patterns,
|
||||
*invite_patterns,
|
||||
*sticky_patterns,
|
||||
]
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
from django.urls import path, include
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from plane.api.views import StickyViewSet
|
||||
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register(r"stickies", StickyViewSet, basename="workspace-stickies")
|
||||
|
||||
urlpatterns = [
|
||||
path("workspaces/<str:slug>/", include(router.urls)),
|
||||
]
|
||||
@@ -54,4 +54,6 @@ from .asset import UserAssetEndpoint, UserServerAssetEndpoint, GenericAssetEndpo
|
||||
|
||||
from .user import UserEndpoint
|
||||
|
||||
from .invite import WorkspaceInvitationsViewset
|
||||
from .invite import WorkspaceInvitationsViewset
|
||||
|
||||
from .sticky import StickyViewSet
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
|
||||
from plane.api.views.base import BaseViewSet
|
||||
from plane.app.permissions import WorkspaceUserPermission
|
||||
from plane.db.models import Sticky, Workspace
|
||||
from plane.api.serializers import StickySerializer
|
||||
|
||||
# OpenAPI imports
|
||||
from plane.utils.openapi.decorators import sticky_docs
|
||||
|
||||
from drf_spectacular.utils import OpenApiRequest, OpenApiResponse
|
||||
from plane.utils.openapi import (
|
||||
STICKY_EXAMPLE,
|
||||
create_paginated_response,
|
||||
DELETED_RESPONSE,
|
||||
)
|
||||
|
||||
|
||||
class StickyViewSet(BaseViewSet):
|
||||
serializer_class = StickySerializer
|
||||
model = Sticky
|
||||
use_read_replica = True
|
||||
permission_classes = [WorkspaceUserPermission]
|
||||
|
||||
def get_queryset(self):
|
||||
return self.filter_queryset(
|
||||
super()
|
||||
.get_queryset()
|
||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.filter(owner_id=self.request.user.id)
|
||||
.distinct()
|
||||
)
|
||||
|
||||
@sticky_docs(
|
||||
operation_id="create_sticky",
|
||||
summary="Create a new sticky",
|
||||
description="Create a new sticky in the workspace",
|
||||
request=OpenApiRequest(request=StickySerializer),
|
||||
responses={
|
||||
201: OpenApiResponse(description="Sticky created", response=StickySerializer, examples=[STICKY_EXAMPLE])
|
||||
},
|
||||
)
|
||||
def create(self, request, slug):
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
serializer = StickySerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
serializer.save(workspace_id=workspace.id, owner_id=request.user.id)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@sticky_docs(
|
||||
operation_id="list_stickies",
|
||||
summary="List stickies",
|
||||
description="List all stickies in the workspace",
|
||||
responses={
|
||||
200: create_paginated_response(
|
||||
StickySerializer, "Sticky", "List of stickies", example_name="List of stickies"
|
||||
)
|
||||
},
|
||||
)
|
||||
def list(self, request, slug):
|
||||
query = request.query_params.get("query", False)
|
||||
stickies = self.get_queryset().order_by("-created_at")
|
||||
if query:
|
||||
stickies = stickies.filter(description_stripped__icontains=query)
|
||||
|
||||
return self.paginate(
|
||||
request=request,
|
||||
queryset=(stickies),
|
||||
on_results=lambda stickies: StickySerializer(stickies, many=True).data,
|
||||
default_per_page=20,
|
||||
)
|
||||
|
||||
@sticky_docs(
|
||||
operation_id="retrieve_sticky",
|
||||
summary="Retrieve a sticky",
|
||||
description="Retrieve a sticky by its ID",
|
||||
responses={200: OpenApiResponse(description="Sticky", response=StickySerializer, examples=[STICKY_EXAMPLE])},
|
||||
)
|
||||
def retrieve(self, request, slug, pk):
|
||||
sticky = self.get_object()
|
||||
return Response(StickySerializer(sticky).data)
|
||||
|
||||
@sticky_docs(
|
||||
operation_id="update_sticky",
|
||||
summary="Update a sticky",
|
||||
description="Update a sticky by its ID",
|
||||
request=OpenApiRequest(request=StickySerializer),
|
||||
responses={200: OpenApiResponse(description="Sticky", response=StickySerializer, examples=[STICKY_EXAMPLE])},
|
||||
)
|
||||
def partial_update(self, request, slug, pk):
|
||||
sticky = self.get_object()
|
||||
serializer = StickySerializer(sticky, data=request.data, partial=True)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@sticky_docs(
|
||||
operation_id="delete_sticky",
|
||||
summary="Delete a sticky",
|
||||
description="Delete a sticky by its ID",
|
||||
responses={204: DELETED_RESPONSE},
|
||||
)
|
||||
def destroy(self, request, slug, pk):
|
||||
sticky = self.get_object()
|
||||
sticky.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
@@ -1,43 +1,44 @@
|
||||
# Python imports
|
||||
import boto3
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
import json
|
||||
|
||||
import boto3
|
||||
|
||||
# Django imports
|
||||
from django.db.models import Exists, F, OuterRef, Prefetch, Q, Subquery
|
||||
from django.conf import settings
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
from django.db.models import Exists, F, OuterRef, Prefetch, Q, Subquery
|
||||
from django.utils import timezone
|
||||
|
||||
# Third Party imports
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
from rest_framework.permissions import AllowAny
|
||||
from rest_framework.response import Response
|
||||
|
||||
# Module imports
|
||||
from plane.app.views.base import BaseViewSet, BaseAPIView
|
||||
from plane.app.permissions import ROLE, ProjectMemberPermission, allow_permission
|
||||
from plane.app.serializers import (
|
||||
ProjectSerializer,
|
||||
ProjectListSerializer,
|
||||
DeployBoardSerializer,
|
||||
ProjectListSerializer,
|
||||
ProjectSerializer,
|
||||
)
|
||||
|
||||
from plane.app.permissions import ProjectMemberPermission, allow_permission, ROLE
|
||||
from plane.app.views.base import BaseAPIView, BaseViewSet
|
||||
from plane.bgtasks.recent_visited_task import recent_visited_task
|
||||
from plane.bgtasks.webhook_task import model_activity, webhook_activity
|
||||
from plane.db.models import (
|
||||
UserFavorite,
|
||||
Intake,
|
||||
DeployBoard,
|
||||
Intake,
|
||||
IssueUserProperty,
|
||||
Project,
|
||||
ProjectIdentifier,
|
||||
ProjectMember,
|
||||
ProjectNetwork,
|
||||
State,
|
||||
DEFAULT_STATES,
|
||||
Workspace,
|
||||
WorkspaceMember,
|
||||
)
|
||||
from plane.utils.cache import cache_response
|
||||
from plane.bgtasks.webhook_task import model_activity, webhook_activity
|
||||
from plane.bgtasks.recent_visited_task import recent_visited_task
|
||||
from plane.utils.exception_logger import log_exception
|
||||
from plane.utils.host import base_host
|
||||
|
||||
@@ -210,19 +211,25 @@ class ProjectViewSet(BaseViewSet):
|
||||
|
||||
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")
|
||||
def retrieve(self, request, slug, pk):
|
||||
project = (
|
||||
self.get_queryset()
|
||||
.filter(
|
||||
project_projectmember__member=self.request.user,
|
||||
project_projectmember__is_active=True,
|
||||
)
|
||||
.filter(archived_at__isnull=True)
|
||||
.filter(pk=pk)
|
||||
).first()
|
||||
project = self.get_queryset().filter(archived_at__isnull=True).filter(pk=pk).first()
|
||||
|
||||
if project is None:
|
||||
return Response({"error": "Project does not exist"}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
member_ids = [str(project_member.member_id) for project_member in project.members_list]
|
||||
|
||||
if str(request.user.id) not in member_ids:
|
||||
if project.network == ProjectNetwork.SECRET.value:
|
||||
return Response(
|
||||
{"error": "You do not have permission"},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
else:
|
||||
return Response(
|
||||
{"error": "You are not a member of this project"},
|
||||
status=status.HTTP_409_CONFLICT,
|
||||
)
|
||||
|
||||
recent_visited_task.delay(
|
||||
slug=slug,
|
||||
project_id=pk,
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
import uuid
|
||||
import json
|
||||
import logging
|
||||
import random
|
||||
import string
|
||||
import secrets
|
||||
|
||||
# Django imports
|
||||
@@ -151,13 +149,7 @@ class UserEndpoint(BaseViewSet):
|
||||
# Include user ID to bind the code to the specific user
|
||||
cache_key = f"magic_email_update_{user.id}_{new_email}"
|
||||
## Generate a random token
|
||||
token = (
|
||||
"".join(secrets.choice(string.ascii_lowercase) for _ in range(4))
|
||||
+ "-"
|
||||
+ "".join(secrets.choice(string.ascii_lowercase) for _ in range(4))
|
||||
+ "-"
|
||||
+ "".join(secrets.choice(string.ascii_lowercase) for _ in range(4))
|
||||
)
|
||||
token = str(secrets.randbelow(900000) + 100000)
|
||||
# Store in cache with 10 minute expiration
|
||||
cache_data = json.dumps({"token": token})
|
||||
cache.set(cache_key, cache_data, timeout=600)
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
# Python imports
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import string
|
||||
import secrets
|
||||
|
||||
|
||||
# Module imports
|
||||
@@ -50,13 +49,7 @@ class MagicCodeProvider(CredentialAdapter):
|
||||
|
||||
def initiate(self):
|
||||
## Generate a random token
|
||||
token = (
|
||||
"".join(random.choices(string.ascii_lowercase, k=4))
|
||||
+ "-"
|
||||
+ "".join(random.choices(string.ascii_lowercase, k=4))
|
||||
+ "-"
|
||||
+ "".join(random.choices(string.ascii_lowercase, k=4))
|
||||
)
|
||||
token = str(secrets.randbelow(900000) + 100000)
|
||||
|
||||
ri = redis_instance()
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
# Django imports
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import transaction
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import Description
|
||||
from plane.db.models import Issue
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Create Description records for existing Issue"
|
||||
|
||||
def handle(self, *args, **kwargs):
|
||||
batch_size = 3000
|
||||
total_processed = 0
|
||||
|
||||
self.stdout.write(self.style.NOTICE("Starting Issue to Description migration..."))
|
||||
|
||||
while True:
|
||||
issues = list(Issue.objects.filter(description_obj_id__isnull=True).order_by("created_at")[:batch_size])
|
||||
|
||||
if not issues:
|
||||
break
|
||||
|
||||
with transaction.atomic():
|
||||
descriptions = [
|
||||
Description(
|
||||
created_at=issue.created_at,
|
||||
updated_at=issue.updated_at,
|
||||
description_json=issue.description,
|
||||
description_html=issue.description_html,
|
||||
description_stripped=issue.description_stripped,
|
||||
project_id=issue.project_id,
|
||||
created_by_id=issue.created_by_id,
|
||||
updated_by_id=issue.updated_by_id,
|
||||
workspace_id=issue.workspace_id,
|
||||
)
|
||||
for issue in issues
|
||||
]
|
||||
|
||||
created_descriptions = Description.objects.bulk_create(descriptions)
|
||||
|
||||
issues_to_update = []
|
||||
for issue, description in zip(issues, created_descriptions):
|
||||
issue.description_obj_id = description.id
|
||||
issues_to_update.append(issue)
|
||||
|
||||
Issue.objects.bulk_update(issues_to_update, ["description_obj_id"])
|
||||
|
||||
total_processed += len(issues)
|
||||
self.stdout.write(self.style.SUCCESS(f"Processed {total_processed} issues..."))
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f"Successfully copied {total_processed} Issue records to Description table")
|
||||
)
|
||||
@@ -0,0 +1,55 @@
|
||||
# Django imports
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import transaction
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import Description
|
||||
from plane.db.models import Page
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Create Description records for existing Page"
|
||||
|
||||
def handle(self, *args, **kwargs):
|
||||
batch_size = 2000
|
||||
total_processed = 0
|
||||
|
||||
self.stdout.write(self.style.NOTICE("Starting Page to Description migration..."))
|
||||
|
||||
while True:
|
||||
pages = list(Page.objects.filter(description_obj_id__isnull=True).order_by("created_at")[:batch_size])
|
||||
|
||||
if not pages:
|
||||
break
|
||||
|
||||
with transaction.atomic():
|
||||
descriptions = [
|
||||
Description(
|
||||
created_at=page.created_at,
|
||||
updated_at=page.updated_at,
|
||||
description_json=page.description,
|
||||
description_html=page.description_html,
|
||||
description_stripped=page.description_stripped,
|
||||
project_id=None, # Pages are workspace-level, not project-level
|
||||
created_by_id=page.created_by_id,
|
||||
updated_by_id=page.updated_by_id,
|
||||
workspace_id=page.workspace_id,
|
||||
)
|
||||
for page in pages
|
||||
]
|
||||
|
||||
created_descriptions = Description.objects.bulk_create(descriptions)
|
||||
|
||||
pages_to_update = []
|
||||
for page, description in zip(pages, created_descriptions):
|
||||
page.description_obj_id = description.id
|
||||
pages_to_update.append(page)
|
||||
|
||||
Page.objects.bulk_update(pages_to_update, ["description_obj_id"])
|
||||
|
||||
total_processed += len(pages)
|
||||
self.stdout.write(self.style.SUCCESS(f"Processed {total_processed} pages..."))
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f"Successfully copied {total_processed} Page records to Description table")
|
||||
)
|
||||
@@ -0,0 +1,24 @@
|
||||
# Generated by Django 4.2.25 on 2025-12-01 10:51
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('db', '0112_auto_20251124_0603'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='issue',
|
||||
name='description_obj',
|
||||
field=models.OneToOneField(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='issue_description', to='db.description'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='page',
|
||||
name='description_obj',
|
||||
field=models.OneToOneField(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='page_description', to='db.description'),
|
||||
),
|
||||
]
|
||||
@@ -52,6 +52,7 @@ from .project import (
|
||||
ProjectIdentifier,
|
||||
ProjectMember,
|
||||
ProjectMemberInvite,
|
||||
ProjectNetwork,
|
||||
ProjectPublicMember,
|
||||
)
|
||||
from .session import Session
|
||||
|
||||
@@ -105,7 +105,7 @@ class IssueManager(SoftDeletionManager):
|
||||
)
|
||||
|
||||
|
||||
class Issue(ProjectBaseModel):
|
||||
class Issue(ChangeTrackerMixin, ProjectBaseModel):
|
||||
PRIORITY_CHOICES = (
|
||||
("urgent", "Urgent"),
|
||||
("high", "High"),
|
||||
@@ -140,6 +140,9 @@ class Issue(ProjectBaseModel):
|
||||
description_html = models.TextField(blank=True, default="<p></p>")
|
||||
description_stripped = models.TextField(blank=True, null=True)
|
||||
description_binary = models.BinaryField(null=True)
|
||||
description_obj = models.OneToOneField(
|
||||
"db.Description", on_delete=models.CASCADE, related_name="issue_description", null=True
|
||||
)
|
||||
priority = models.CharField(
|
||||
max_length=30,
|
||||
choices=PRIORITY_CHOICES,
|
||||
@@ -173,6 +176,8 @@ class Issue(ProjectBaseModel):
|
||||
|
||||
issue_objects = IssueManager()
|
||||
|
||||
TRACKED_FIELDS = ["description_stripped", "description", "description_html"]
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Issue"
|
||||
verbose_name_plural = "Issues"
|
||||
@@ -180,6 +185,12 @@ class Issue(ProjectBaseModel):
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""
|
||||
Custom save method for Issue that manages the associated Description model.
|
||||
|
||||
This method handles creation and updates of both the issue and its description in a
|
||||
single atomic transaction to ensure data consistency.
|
||||
"""
|
||||
if self.state is None:
|
||||
try:
|
||||
from plane.db.models import State
|
||||
@@ -205,7 +216,16 @@ class Issue(ProjectBaseModel):
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
if self._state.adding:
|
||||
# Strip the html tags using html parser
|
||||
self.description_stripped = (
|
||||
None
|
||||
if (self.description_html == "" or self.description_html is None)
|
||||
else strip_tags(self.description_html)
|
||||
)
|
||||
|
||||
is_creating = self._state.adding
|
||||
|
||||
if is_creating:
|
||||
with transaction.atomic():
|
||||
# Create a lock for this specific project using an advisory lock
|
||||
# This ensures only one transaction per project can execute this code at a time
|
||||
@@ -221,12 +241,7 @@ class Issue(ProjectBaseModel):
|
||||
largest=models.Max("sequence")
|
||||
)["largest"]
|
||||
self.sequence_id = last_sequence + 1 if last_sequence else 1
|
||||
# Strip the html tags using html parser
|
||||
self.description_stripped = (
|
||||
None
|
||||
if (self.description_html == "" or self.description_html is None)
|
||||
else strip_tags(self.description_html)
|
||||
)
|
||||
|
||||
largest_sort_order = Issue.objects.filter(project=self.project, state=self.state).aggregate(
|
||||
largest=models.Max("sort_order")
|
||||
)["largest"]
|
||||
@@ -236,18 +251,60 @@ class Issue(ProjectBaseModel):
|
||||
super(Issue, self).save(*args, **kwargs)
|
||||
|
||||
IssueSequence.objects.create(issue=self, sequence=self.sequence_id, project=self.project)
|
||||
|
||||
# Create new description for new issue
|
||||
description_defaults = {
|
||||
"workspace_id": self.workspace_id,
|
||||
"project_id": self.project_id,
|
||||
"created_by_id": self.created_by_id,
|
||||
"updated_by_id": self.updated_by_id,
|
||||
"description_stripped": self.description_stripped,
|
||||
"description_json": self.description,
|
||||
"description_html": self.description_html,
|
||||
}
|
||||
description = Description.objects.create(**description_defaults)
|
||||
self.description_obj_id = description.id
|
||||
super(Issue, self).save(update_fields=["description_obj_id"])
|
||||
finally:
|
||||
# Release the lock
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute("SELECT pg_advisory_unlock(%s)", [lock_key])
|
||||
else:
|
||||
# Strip the html tags using html parser
|
||||
self.description_stripped = (
|
||||
None
|
||||
if (self.description_html == "" or self.description_html is None)
|
||||
else strip_tags(self.description_html)
|
||||
)
|
||||
super(Issue, self).save(*args, **kwargs)
|
||||
with transaction.atomic():
|
||||
super(Issue, self).save(*args, **kwargs)
|
||||
|
||||
if not self.description_obj_id:
|
||||
# Create description if it doesn't exist (for existing issues)
|
||||
description_defaults = {
|
||||
"workspace_id": self.workspace_id,
|
||||
"project_id": self.project_id,
|
||||
"created_by_id": self.created_by_id,
|
||||
"updated_by_id": self.updated_by_id,
|
||||
"description_stripped": self.description_stripped,
|
||||
"description_json": self.description,
|
||||
"description_html": self.description_html,
|
||||
}
|
||||
description = Description.objects.create(**description_defaults)
|
||||
self.description_obj_id = description.id
|
||||
super(Issue, self).save(update_fields=["description_obj_id"])
|
||||
else:
|
||||
# Update description only if fields changed
|
||||
field_mapping = {
|
||||
"description_html": "description_html",
|
||||
"description_stripped": "description_stripped",
|
||||
"description": "description_json",
|
||||
}
|
||||
|
||||
changed_fields = {
|
||||
desc_field: getattr(self, issue_field)
|
||||
for issue_field, desc_field in field_mapping.items()
|
||||
if self.has_changed(issue_field)
|
||||
}
|
||||
|
||||
if changed_fields and self.description_obj_id:
|
||||
Description.objects.filter(pk=self.description_obj_id).update(
|
||||
**changed_fields, updated_by_id=self.updated_by_id, updated_at=self.updated_at
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
"""Return name of the issue"""
|
||||
|
||||
@@ -4,19 +4,21 @@ from django.conf import settings
|
||||
from django.utils import timezone
|
||||
|
||||
# Django imports
|
||||
from django.db import models
|
||||
from django.db import models, transaction
|
||||
|
||||
# Module imports
|
||||
from plane.utils.html_processor import strip_tags
|
||||
from plane.db.mixins import ChangeTrackerMixin
|
||||
|
||||
from .base import BaseModel
|
||||
from .description import Description
|
||||
|
||||
|
||||
def get_view_props():
|
||||
return {"full_width": False}
|
||||
|
||||
|
||||
class Page(BaseModel):
|
||||
class Page(ChangeTrackerMixin, BaseModel):
|
||||
PRIVATE_ACCESS = 1
|
||||
PUBLIC_ACCESS = 0
|
||||
DEFAULT_SORT_ORDER = 65535
|
||||
@@ -29,6 +31,9 @@ class Page(BaseModel):
|
||||
description_binary = models.BinaryField(null=True)
|
||||
description_html = models.TextField(blank=True, default="<p></p>")
|
||||
description_stripped = models.TextField(blank=True, null=True)
|
||||
description_obj = models.OneToOneField(
|
||||
"db.Description", on_delete=models.CASCADE, related_name="page_description", null=True
|
||||
)
|
||||
owned_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="pages")
|
||||
access = models.PositiveSmallIntegerField(choices=((0, "Public"), (1, "Private")), default=0)
|
||||
color = models.CharField(max_length=255, blank=True)
|
||||
@@ -53,6 +58,8 @@ class Page(BaseModel):
|
||||
external_id = models.CharField(max_length=255, null=True, blank=True)
|
||||
external_source = models.CharField(max_length=255, null=True, blank=True)
|
||||
|
||||
TRACKED_FIELDS = ["description_stripped", "description", "description_html"]
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Page"
|
||||
verbose_name_plural = "Pages"
|
||||
@@ -64,13 +71,58 @@ class Page(BaseModel):
|
||||
return f"{self.owned_by.email} <{self.name}>"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""
|
||||
Custom save method for Page that manages the associated Description model.
|
||||
|
||||
This method handles creation and updates of both the page and its description in a
|
||||
single atomic transaction to ensure data consistency.
|
||||
"""
|
||||
# Strip the html tags using html parser
|
||||
self.description_stripped = (
|
||||
None
|
||||
if (self.description_html == "" or self.description_html is None)
|
||||
else strip_tags(self.description_html)
|
||||
)
|
||||
super(Page, self).save(*args, **kwargs)
|
||||
|
||||
is_creating = self._state.adding
|
||||
|
||||
# Prepare description defaults
|
||||
description_defaults = {
|
||||
"workspace_id": self.workspace_id,
|
||||
"project_id": None,
|
||||
"created_by_id": self.created_by_id,
|
||||
"updated_by_id": self.updated_by_id,
|
||||
"description_stripped": self.description_stripped,
|
||||
"description_json": self.description,
|
||||
"description_html": self.description_html,
|
||||
}
|
||||
|
||||
with transaction.atomic():
|
||||
super(Page, self).save(*args, **kwargs)
|
||||
|
||||
if is_creating or not self.description_obj_id:
|
||||
# Create new description for new page
|
||||
description = Description.objects.create(**description_defaults)
|
||||
self.description_obj_id = description.id
|
||||
super(Page, self).save(update_fields=["description_obj_id"])
|
||||
else:
|
||||
# Update description only if fields changed
|
||||
field_mapping = {
|
||||
"description_html": "description_html",
|
||||
"description_stripped": "description_stripped",
|
||||
"description": "description_json",
|
||||
}
|
||||
|
||||
changed_fields = {
|
||||
desc_field: getattr(self, page_field)
|
||||
for page_field, desc_field in field_mapping.items()
|
||||
if self.has_changed(page_field)
|
||||
}
|
||||
|
||||
if changed_fields and self.description_obj_id:
|
||||
Description.objects.filter(pk=self.description_obj_id).update(
|
||||
**changed_fields, updated_by_id=self.updated_by_id, updated_at=self.updated_at
|
||||
)
|
||||
|
||||
|
||||
class PageLog(BaseModel):
|
||||
|
||||
@@ -140,6 +140,7 @@ from .examples import (
|
||||
WORKSPACE_MEMBER_EXAMPLE,
|
||||
PROJECT_MEMBER_EXAMPLE,
|
||||
CYCLE_ISSUE_EXAMPLE,
|
||||
STICKY_EXAMPLE,
|
||||
)
|
||||
|
||||
# Helper decorators
|
||||
@@ -292,6 +293,7 @@ __all__ = [
|
||||
"WORKSPACE_MEMBER_EXAMPLE",
|
||||
"PROJECT_MEMBER_EXAMPLE",
|
||||
"CYCLE_ISSUE_EXAMPLE",
|
||||
"STICKY_EXAMPLE",
|
||||
# Decorators
|
||||
"workspace_docs",
|
||||
"project_docs",
|
||||
|
||||
@@ -262,3 +262,18 @@ def state_docs(**kwargs):
|
||||
}
|
||||
|
||||
return extend_schema(**_merge_schema_options(defaults, kwargs))
|
||||
|
||||
def sticky_docs(**kwargs):
|
||||
"""Decorator for sticky management endpoints"""
|
||||
defaults = {
|
||||
"tags": ["Stickies"],
|
||||
"summary": "Endpoints for sticky create/update/delete and fetch sticky details",
|
||||
"parameters": [WORKSPACE_SLUG_PARAMETER],
|
||||
"responses": {
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
403: FORBIDDEN_RESPONSE,
|
||||
404: NOT_FOUND_RESPONSE,
|
||||
},
|
||||
}
|
||||
|
||||
return extend_schema(**_merge_schema_options(defaults, kwargs))
|
||||
@@ -672,6 +672,15 @@ CYCLE_ISSUE_EXAMPLE = OpenApiExample(
|
||||
},
|
||||
)
|
||||
|
||||
STICKY_EXAMPLE = OpenApiExample(
|
||||
name="Sticky",
|
||||
value={
|
||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"name": "Sticky 1",
|
||||
"description_html": "<p>Sticky 1 description</p>",
|
||||
"created_at": "2024-01-01T10:30:00Z",
|
||||
},
|
||||
)
|
||||
|
||||
# Sample data for different entity types
|
||||
SAMPLE_ISSUE = {
|
||||
@@ -781,6 +790,13 @@ SAMPLE_CYCLE_ISSUE = {
|
||||
"created_at": "2024-01-01T10:30:00Z",
|
||||
}
|
||||
|
||||
SAMPLE_STICKY = {
|
||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"name": "Sticky 1",
|
||||
"description_html": "<p>Sticky 1 description</p>",
|
||||
"created_at": "2024-01-01T10:30:00Z",
|
||||
}
|
||||
|
||||
# Mapping of schema types to sample data
|
||||
SCHEMA_EXAMPLES = {
|
||||
"Issue": SAMPLE_ISSUE,
|
||||
@@ -795,6 +811,7 @@ SCHEMA_EXAMPLES = {
|
||||
"Activity": SAMPLE_ACTIVITY,
|
||||
"Intake": SAMPLE_INTAKE,
|
||||
"CycleIssue": SAMPLE_CYCLE_ISSUE,
|
||||
"Sticky": SAMPLE_STICKY,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -113,7 +113,7 @@ export function AuthUniqueCodeForm(props: TAuthUniqueCodeForm) {
|
||||
name="code"
|
||||
value={uniqueCodeFormData.code}
|
||||
onChange={(e) => handleFormChange("code", e.target.value)}
|
||||
placeholder="gets-sets-flys"
|
||||
placeholder="123456"
|
||||
className="disable-autofill-style h-10 w-full border border-custom-border-100 !bg-custom-background-100 pr-12 placeholder:text-custom-text-400"
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
@@ -82,7 +82,7 @@ function IssueDetailsPage({ params }: Route.ComponentProps) {
|
||||
return (
|
||||
<>
|
||||
<PageHead title={pageTitle} />
|
||||
{error ? (
|
||||
{error && !issueLoader ? (
|
||||
<EmptyState
|
||||
image={resolvedTheme === "dark" ? emptyIssueDark : emptyIssueLight}
|
||||
title={t("issue.empty_state.issue_detail.title")}
|
||||
|
||||
+4
-1
@@ -11,6 +11,7 @@ import { AppSidebarToggleButton } from "@/components/sidebar/sidebar-toggle-butt
|
||||
// hooks
|
||||
import { useAppTheme } from "@/hooks/store/use-app-theme";
|
||||
import { useProjectNavigationPreferences } from "@/hooks/use-navigation-preferences";
|
||||
import { ProjectAuthWrapper } from "@/plane-web/layouts/project-wrapper";
|
||||
// local imports
|
||||
import type { Route } from "./+types/layout";
|
||||
|
||||
@@ -44,7 +45,9 @@ function ProjectLayout({ params }: Route.ComponentProps) {
|
||||
</Row>
|
||||
</div>
|
||||
)}
|
||||
<Outlet />
|
||||
<ProjectAuthWrapper workspaceSlug={workspaceSlug} projectId={projectId}>
|
||||
<Outlet />
|
||||
</ProjectAuthWrapper>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import { Outlet } from "react-router";
|
||||
// plane web layouts
|
||||
import { ProjectAuthWrapper } from "@/plane-web/layouts/project-wrapper";
|
||||
import type { Route } from "./+types/layout";
|
||||
|
||||
export default function ProjectDetailLayout({ params }: Route.ComponentProps) {
|
||||
// router
|
||||
const { workspaceSlug, projectId } = params;
|
||||
return (
|
||||
<ProjectAuthWrapper workspaceSlug={workspaceSlug} projectId={projectId}>
|
||||
<Outlet />
|
||||
</ProjectAuthWrapper>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// components
|
||||
import { LogoSpinner } from "@/components/common/logo-spinner";
|
||||
import { PageHead } from "@/components/core/page-title";
|
||||
import { ProfileForm } from "@/components/profile/form";
|
||||
// hooks
|
||||
@@ -12,13 +11,7 @@ function ProfileSettingsPage() {
|
||||
// store hooks
|
||||
const { data: currentUser, userProfile } = useUser();
|
||||
|
||||
if (!currentUser)
|
||||
return (
|
||||
<div className="grid h-full w-full place-items-center px-4 sm:px-0">
|
||||
<LogoSpinner />
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!currentUser) return <></>;
|
||||
return (
|
||||
<>
|
||||
<PageHead title={`${t("profile.label")} - ${t("general_settings")}`} />
|
||||
|
||||
+13
-21
@@ -2,7 +2,6 @@ import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// components
|
||||
import { LogoSpinner } from "@/components/common/logo-spinner";
|
||||
import { PageHead } from "@/components/core/page-title";
|
||||
import { PreferencesList } from "@/components/preferences/list";
|
||||
import { LanguageTimezone } from "@/components/profile/preferences/language-timezone";
|
||||
@@ -16,30 +15,23 @@ function ProfileAppearancePage() {
|
||||
// hooks
|
||||
const { data: userProfile } = useUserProfile();
|
||||
|
||||
if (!userProfile) return <></>;
|
||||
return (
|
||||
<>
|
||||
<PageHead title={`${t("profile.label")} - ${t("preferences")}`} />
|
||||
{userProfile ? (
|
||||
<>
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<div>
|
||||
<SettingsHeading
|
||||
title={t("account_settings.preferences.heading")}
|
||||
description={t("account_settings.preferences.description")}
|
||||
/>
|
||||
<PreferencesList />
|
||||
</div>
|
||||
<div>
|
||||
<ProfileSettingContentHeader title={t("language_and_time")} />
|
||||
<LanguageTimezone />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="grid h-full w-full place-items-center px-4 sm:px-0">
|
||||
<LogoSpinner />
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<div>
|
||||
<SettingsHeading
|
||||
title={t("account_settings.preferences.heading")}
|
||||
description={t("account_settings.preferences.description")}
|
||||
/>
|
||||
<PreferencesList />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<ProfileSettingContentHeader title={t("language_and_time")} />
|
||||
<LanguageTimezone />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { Outlet } from "react-router";
|
||||
// components
|
||||
import { getProjectActivePath } from "@/components/settings/helper";
|
||||
import { SettingsMobileNav } from "@/components/settings/mobile";
|
||||
import { ProjectSettingsSidebar } from "@/components/settings/project/sidebar";
|
||||
// plane web imports
|
||||
import { ProjectAuthWrapper } from "@/plane-web/layouts/project-wrapper";
|
||||
// types
|
||||
import type { Route } from "./+types/layout";
|
||||
|
||||
function ProjectDetailSettingsLayout({ params }: Route.ComponentProps) {
|
||||
const { workspaceSlug, projectId } = params;
|
||||
// router
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsMobileNav hamburgerContent={ProjectSettingsSidebar} activePath={getProjectActivePath(pathname) || ""} />
|
||||
<div className="relative flex h-full w-full">
|
||||
<div className="hidden md:block">{projectId && <ProjectSettingsSidebar />}</div>
|
||||
<ProjectAuthWrapper workspaceSlug={workspaceSlug} projectId={projectId}>
|
||||
<div className="w-full h-full overflow-y-scroll md:pt-page-y">
|
||||
<Outlet />
|
||||
</div>
|
||||
</ProjectAuthWrapper>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(ProjectDetailSettingsLayout);
|
||||
+2
-7
@@ -1,6 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import useSWR from "swr";
|
||||
// plane imports
|
||||
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
||||
// components
|
||||
@@ -24,12 +23,8 @@ function ProjectSettingsPage({ params }: Route.ComponentProps) {
|
||||
// router
|
||||
const { workspaceSlug, projectId } = params;
|
||||
// store hooks
|
||||
const { currentProjectDetails, fetchProjectDetails } = useProject();
|
||||
const { currentProjectDetails } = useProject();
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
|
||||
// api call to fetch project details
|
||||
// TODO: removed this API if not necessary
|
||||
const { isLoading } = useSWR(`PROJECT_DETAILS_${projectId}`, () => fetchProjectDetails(workspaceSlug, projectId));
|
||||
// derived values
|
||||
const isAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT, workspaceSlug, projectId);
|
||||
|
||||
@@ -56,7 +51,7 @@ function ProjectSettingsPage({ params }: Route.ComponentProps) {
|
||||
)}
|
||||
|
||||
<div className={`w-full ${isAdmin ? "" : "opacity-60"}`}>
|
||||
{currentProjectDetails && !isLoading ? (
|
||||
{currentProjectDetails ? (
|
||||
<ProjectDetailsForm
|
||||
project={currentProjectDetails}
|
||||
workspaceSlug={workspaceSlug}
|
||||
|
||||
@@ -1,21 +1,17 @@
|
||||
import { useEffect } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { Outlet } from "react-router";
|
||||
// components
|
||||
import { getProjectActivePath } from "@/components/settings/helper";
|
||||
import { SettingsMobileNav } from "@/components/settings/mobile";
|
||||
import { ProjectSettingsSidebar } from "@/components/settings/project/sidebar";
|
||||
// hooks
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
import { ProjectAuthWrapper } from "@/plane-web/layouts/project-wrapper";
|
||||
// types
|
||||
import type { Route } from "./+types/layout";
|
||||
|
||||
function ProjectSettingsLayout({ params }: Route.ComponentProps) {
|
||||
const { workspaceSlug, projectId } = params;
|
||||
// router
|
||||
const router = useAppRouter();
|
||||
const pathname = usePathname();
|
||||
const { workspaceSlug, projectId } = params;
|
||||
// store hooks
|
||||
const { joinedProjectIds } = useProject();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -25,19 +21,7 @@ function ProjectSettingsLayout({ params }: Route.ComponentProps) {
|
||||
}
|
||||
}, [joinedProjectIds, router, workspaceSlug, projectId]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsMobileNav hamburgerContent={ProjectSettingsSidebar} activePath={getProjectActivePath(pathname) || ""} />
|
||||
<ProjectAuthWrapper workspaceSlug={workspaceSlug} projectId={projectId}>
|
||||
<div className="relative flex h-full w-full">
|
||||
<div className="hidden md:block">{projectId && <ProjectSettingsSidebar />}</div>
|
||||
<div className="w-full h-full overflow-y-scroll md:pt-page-y">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
</ProjectAuthWrapper>
|
||||
</>
|
||||
);
|
||||
return <Outlet />;
|
||||
}
|
||||
|
||||
export default observer(ProjectSettingsLayout);
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import { useTheme } from "next-themes";
|
||||
// plane imports
|
||||
import { PROJECT_TRACKER_ELEMENTS } from "@plane/constants";
|
||||
import { Button, getButtonStyling } from "@plane/propel/button";
|
||||
import { cn } from "@plane/utils";
|
||||
// assets
|
||||
import ProjectDarkEmptyState from "@/app/assets/empty-state/project-settings/no-projects-dark.png?url";
|
||||
import ProjectLightEmptyState from "@/app/assets/empty-state/project-settings/no-projects-light.png?url";
|
||||
// hooks
|
||||
import { useCommandPalette } from "@/hooks/store/use-command-palette";
|
||||
|
||||
function ProjectSettingsPage() {
|
||||
@@ -10,13 +16,10 @@ function ProjectSettingsPage() {
|
||||
const { resolvedTheme } = useTheme();
|
||||
const { toggleCreateProjectModal } = useCommandPalette();
|
||||
// derived values
|
||||
const resolvedPath =
|
||||
resolvedTheme === "dark"
|
||||
? "/empty-state/project-settings/no-projects-dark.png"
|
||||
: "/empty-state/project-settings/no-projects-light.png";
|
||||
const resolvedPath = resolvedTheme === "dark" ? ProjectDarkEmptyState : ProjectLightEmptyState;
|
||||
return (
|
||||
<div className="flex flex-col gap-4 items-center justify-center h-full max-w-[480px] mx-auto">
|
||||
<img src={resolvedPath} className="w-full h-full object-contain" alt="No projects yet" />
|
||||
<img src={resolvedPath} alt="No projects yet" />
|
||||
<div className="text-lg font-semibold text-custom-text-350">No projects yet</div>
|
||||
<div className="text-sm text-custom-text-350 text-center">
|
||||
Projects act as the foundation for goal-driven work. They let you manage your teams, tasks, and everything you
|
||||
@@ -38,4 +41,4 @@ function ProjectSettingsPage() {
|
||||
);
|
||||
}
|
||||
|
||||
export default ProjectSettingsPage;
|
||||
export default observer(ProjectSettingsPage);
|
||||
|
||||
+149
-156
@@ -108,9 +108,13 @@ export const coreRoutes: RouteConfigEntry[] = [
|
||||
),
|
||||
]),
|
||||
|
||||
// ====================================================================
|
||||
// PROJECT LEVEL ROUTES
|
||||
// ====================================================================
|
||||
// Archived Projects
|
||||
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/archives/layout.tsx", [
|
||||
route(
|
||||
":workspaceSlug/projects/archives",
|
||||
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/archives/page.tsx"
|
||||
),
|
||||
]),
|
||||
|
||||
// --------------------------------------------------------------------
|
||||
// PROJECT LEVEL ROUTES
|
||||
@@ -122,136 +126,123 @@ export const coreRoutes: RouteConfigEntry[] = [
|
||||
]),
|
||||
|
||||
// Project Detail
|
||||
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/layout.tsx", [
|
||||
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/layout.tsx", [
|
||||
// Project Issues List
|
||||
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/layout.tsx", [
|
||||
route(
|
||||
":workspaceSlug/projects/:projectId/issues",
|
||||
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/page.tsx"
|
||||
),
|
||||
]),
|
||||
// Issue Detail
|
||||
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/layout.tsx", [
|
||||
// Project Issues List
|
||||
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/layout.tsx", [
|
||||
route(
|
||||
":workspaceSlug/projects/:projectId/issues/:issueId",
|
||||
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(detail)/[issueId]/page.tsx"
|
||||
),
|
||||
|
||||
// Cycle Detail
|
||||
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/layout.tsx", [
|
||||
route(
|
||||
":workspaceSlug/projects/:projectId/cycles/:cycleId",
|
||||
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/[cycleId]/page.tsx"
|
||||
),
|
||||
]),
|
||||
|
||||
// Cycles List
|
||||
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/layout.tsx", [
|
||||
route(
|
||||
":workspaceSlug/projects/:projectId/cycles",
|
||||
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/page.tsx"
|
||||
),
|
||||
]),
|
||||
|
||||
// Module Detail
|
||||
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/layout.tsx", [
|
||||
route(
|
||||
":workspaceSlug/projects/:projectId/modules/:moduleId",
|
||||
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/[moduleId]/page.tsx"
|
||||
),
|
||||
]),
|
||||
|
||||
// Modules List
|
||||
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/layout.tsx", [
|
||||
route(
|
||||
":workspaceSlug/projects/:projectId/modules",
|
||||
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/page.tsx"
|
||||
),
|
||||
]),
|
||||
|
||||
// View Detail
|
||||
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/layout.tsx", [
|
||||
route(
|
||||
":workspaceSlug/projects/:projectId/views/:viewId",
|
||||
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/[viewId]/page.tsx"
|
||||
),
|
||||
]),
|
||||
|
||||
// Views List
|
||||
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/layout.tsx", [
|
||||
route(
|
||||
":workspaceSlug/projects/:projectId/views",
|
||||
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/page.tsx"
|
||||
),
|
||||
]),
|
||||
|
||||
// Page Detail
|
||||
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/layout.tsx", [
|
||||
route(
|
||||
":workspaceSlug/projects/:projectId/pages/:pageId",
|
||||
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/[pageId]/page.tsx"
|
||||
),
|
||||
]),
|
||||
|
||||
// Pages List
|
||||
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/layout.tsx", [
|
||||
route(
|
||||
":workspaceSlug/projects/:projectId/pages",
|
||||
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/page.tsx"
|
||||
),
|
||||
]),
|
||||
// Intake list
|
||||
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/intake/layout.tsx", [
|
||||
route(
|
||||
":workspaceSlug/projects/:projectId/intake",
|
||||
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/intake/page.tsx"
|
||||
),
|
||||
]),
|
||||
]),
|
||||
|
||||
// Archived Projects
|
||||
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/archives/layout.tsx", [
|
||||
route(
|
||||
":workspaceSlug/projects/archives",
|
||||
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/archives/page.tsx"
|
||||
":workspaceSlug/projects/:projectId/issues",
|
||||
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/page.tsx"
|
||||
),
|
||||
]),
|
||||
|
||||
// Project Archives - Issues, Cycles, Modules
|
||||
// Project Archives - Issues - List
|
||||
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(list)/layout.tsx", [
|
||||
route(
|
||||
":workspaceSlug/projects/:projectId/archives/issues",
|
||||
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(list)/page.tsx"
|
||||
),
|
||||
]),
|
||||
|
||||
// Project Archives - Issues - Detail
|
||||
layout(
|
||||
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/layout.tsx",
|
||||
[
|
||||
route(
|
||||
":workspaceSlug/projects/:projectId/archives/issues/:archivedIssueId",
|
||||
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/[archivedIssueId]/page.tsx"
|
||||
),
|
||||
]
|
||||
// Issue Detail
|
||||
route(
|
||||
":workspaceSlug/projects/:projectId/issues/:issueId",
|
||||
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(detail)/[issueId]/page.tsx"
|
||||
),
|
||||
|
||||
// Project Archives - Cycles
|
||||
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/cycles/layout.tsx", [
|
||||
// Cycle Detail
|
||||
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/layout.tsx", [
|
||||
route(
|
||||
":workspaceSlug/projects/:projectId/archives/cycles",
|
||||
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/cycles/page.tsx"
|
||||
":workspaceSlug/projects/:projectId/cycles/:cycleId",
|
||||
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/[cycleId]/page.tsx"
|
||||
),
|
||||
]),
|
||||
|
||||
// Project Archives - Modules
|
||||
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/modules/layout.tsx", [
|
||||
// Cycles List
|
||||
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/layout.tsx", [
|
||||
route(
|
||||
":workspaceSlug/projects/:projectId/archives/modules",
|
||||
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/modules/page.tsx"
|
||||
":workspaceSlug/projects/:projectId/cycles",
|
||||
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/page.tsx"
|
||||
),
|
||||
]),
|
||||
|
||||
// Module Detail
|
||||
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/layout.tsx", [
|
||||
route(
|
||||
":workspaceSlug/projects/:projectId/modules/:moduleId",
|
||||
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/[moduleId]/page.tsx"
|
||||
),
|
||||
]),
|
||||
|
||||
// Modules List
|
||||
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/layout.tsx", [
|
||||
route(
|
||||
":workspaceSlug/projects/:projectId/modules",
|
||||
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/page.tsx"
|
||||
),
|
||||
]),
|
||||
|
||||
// View Detail
|
||||
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/layout.tsx", [
|
||||
route(
|
||||
":workspaceSlug/projects/:projectId/views/:viewId",
|
||||
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/[viewId]/page.tsx"
|
||||
),
|
||||
]),
|
||||
|
||||
// Views List
|
||||
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/layout.tsx", [
|
||||
route(
|
||||
":workspaceSlug/projects/:projectId/views",
|
||||
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/page.tsx"
|
||||
),
|
||||
]),
|
||||
|
||||
// Page Detail
|
||||
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/layout.tsx", [
|
||||
route(
|
||||
":workspaceSlug/projects/:projectId/pages/:pageId",
|
||||
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/[pageId]/page.tsx"
|
||||
),
|
||||
]),
|
||||
|
||||
// Pages List
|
||||
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/layout.tsx", [
|
||||
route(
|
||||
":workspaceSlug/projects/:projectId/pages",
|
||||
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/page.tsx"
|
||||
),
|
||||
]),
|
||||
// Intake list
|
||||
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/intake/layout.tsx", [
|
||||
route(
|
||||
":workspaceSlug/projects/:projectId/intake",
|
||||
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/intake/page.tsx"
|
||||
),
|
||||
]),
|
||||
]),
|
||||
|
||||
// Project Archives - Issues, Cycles, Modules
|
||||
// Project Archives - Issues - List
|
||||
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(list)/layout.tsx", [
|
||||
route(
|
||||
":workspaceSlug/projects/:projectId/archives/issues",
|
||||
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(list)/page.tsx"
|
||||
),
|
||||
]),
|
||||
|
||||
// Project Archives - Issues - Detail
|
||||
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/layout.tsx", [
|
||||
route(
|
||||
":workspaceSlug/projects/:projectId/archives/issues/:archivedIssueId",
|
||||
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/[archivedIssueId]/page.tsx"
|
||||
),
|
||||
]),
|
||||
|
||||
// Project Archives - Cycles
|
||||
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/cycles/layout.tsx", [
|
||||
route(
|
||||
":workspaceSlug/projects/:projectId/archives/cycles",
|
||||
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/cycles/page.tsx"
|
||||
),
|
||||
]),
|
||||
|
||||
// Project Archives - Modules
|
||||
layout("./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/modules/layout.tsx", [
|
||||
route(
|
||||
":workspaceSlug/projects/:projectId/archives/modules",
|
||||
"./(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/modules/page.tsx"
|
||||
),
|
||||
]),
|
||||
]),
|
||||
|
||||
@@ -320,44 +311,46 @@ export const coreRoutes: RouteConfigEntry[] = [
|
||||
// --------------------------------------------------------------------
|
||||
|
||||
layout("./(all)/[workspaceSlug]/(settings)/settings/projects/layout.tsx", [
|
||||
// CORE Routes
|
||||
// Project Settings
|
||||
// No Projects available page
|
||||
route(":workspaceSlug/settings/projects", "./(all)/[workspaceSlug]/(settings)/settings/projects/page.tsx"),
|
||||
route(
|
||||
":workspaceSlug/settings/projects/:projectId",
|
||||
"./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/page.tsx"
|
||||
),
|
||||
// Project Members
|
||||
route(
|
||||
":workspaceSlug/settings/projects/:projectId/members",
|
||||
"./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/members/page.tsx"
|
||||
),
|
||||
// Project Features
|
||||
route(
|
||||
":workspaceSlug/settings/projects/:projectId/features",
|
||||
"./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/page.tsx"
|
||||
),
|
||||
// Project States
|
||||
route(
|
||||
":workspaceSlug/settings/projects/:projectId/states",
|
||||
"./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/states/page.tsx"
|
||||
),
|
||||
// Project Labels
|
||||
route(
|
||||
":workspaceSlug/settings/projects/:projectId/labels",
|
||||
"./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/labels/page.tsx"
|
||||
),
|
||||
// Project Estimates
|
||||
route(
|
||||
":workspaceSlug/settings/projects/:projectId/estimates",
|
||||
"./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/estimates/page.tsx"
|
||||
),
|
||||
// Project Automations
|
||||
layout("./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/automations/layout.tsx", [
|
||||
layout("./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/layout.tsx", [
|
||||
// Project Settings
|
||||
route(
|
||||
":workspaceSlug/settings/projects/:projectId/automations",
|
||||
"./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/automations/page.tsx"
|
||||
":workspaceSlug/settings/projects/:projectId",
|
||||
"./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/page.tsx"
|
||||
),
|
||||
// Project Members
|
||||
route(
|
||||
":workspaceSlug/settings/projects/:projectId/members",
|
||||
"./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/members/page.tsx"
|
||||
),
|
||||
// Project Features
|
||||
route(
|
||||
":workspaceSlug/settings/projects/:projectId/features",
|
||||
"./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/page.tsx"
|
||||
),
|
||||
// Project States
|
||||
route(
|
||||
":workspaceSlug/settings/projects/:projectId/states",
|
||||
"./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/states/page.tsx"
|
||||
),
|
||||
// Project Labels
|
||||
route(
|
||||
":workspaceSlug/settings/projects/:projectId/labels",
|
||||
"./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/labels/page.tsx"
|
||||
),
|
||||
// Project Estimates
|
||||
route(
|
||||
":workspaceSlug/settings/projects/:projectId/estimates",
|
||||
"./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/estimates/page.tsx"
|
||||
),
|
||||
// Project Automations
|
||||
layout("./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/automations/layout.tsx", [
|
||||
route(
|
||||
":workspaceSlug/settings/projects/:projectId/automations",
|
||||
"./(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/automations/page.tsx"
|
||||
),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
|
||||
+8
-8
@@ -18,7 +18,7 @@ import { useUser } from "@/hooks/store/user";
|
||||
// local imports
|
||||
import { TourSidebar } from "./sidebar";
|
||||
|
||||
type Props = {
|
||||
export type TOnboardingTourProps = {
|
||||
onComplete: () => void;
|
||||
};
|
||||
|
||||
@@ -28,7 +28,7 @@ const TOUR_STEPS: {
|
||||
key: TTourSteps;
|
||||
title: string;
|
||||
description: string;
|
||||
image: any;
|
||||
image: string;
|
||||
prevStep?: TTourSteps;
|
||||
nextStep?: TTourSteps;
|
||||
}[] = [
|
||||
@@ -75,7 +75,7 @@ const TOUR_STEPS: {
|
||||
},
|
||||
];
|
||||
|
||||
export const TourRoot = observer(function TourRoot(props: Props) {
|
||||
export const TourRoot = observer(function TourRoot(props: TOnboardingTourProps) {
|
||||
const { onComplete } = props;
|
||||
// states
|
||||
const [step, setStep] = useState<TTourSteps>("welcome");
|
||||
@@ -89,12 +89,12 @@ export const TourRoot = observer(function TourRoot(props: Props) {
|
||||
return (
|
||||
<>
|
||||
{step === "welcome" ? (
|
||||
<div className="h-3/4 w-4/5 overflow-hidden rounded-[10px] bg-custom-background-100 md:w-1/2 lg:w-2/5">
|
||||
<div className="w-4/5 overflow-hidden rounded-[10px] bg-custom-background-100 md:w-1/2 lg:w-2/5">
|
||||
<div className="h-full overflow-hidden">
|
||||
<div className="grid h-3/5 place-items-center bg-custom-primary-100">
|
||||
<PlaneLockup className="h-10 w-auto text-custom-text-100" />
|
||||
<div className="grid h-64 place-items-center bg-custom-primary-100">
|
||||
<PlaneLockup className="h-10 w-auto text-white" />
|
||||
</div>
|
||||
<div className="flex h-2/5 flex-col overflow-y-auto p-6">
|
||||
<div className="flex flex-col overflow-y-auto p-6">
|
||||
<h3 className="font-semibold sm:text-xl">
|
||||
Welcome to Plane, {currentUser?.first_name} {currentUser?.last_name}
|
||||
</h3>
|
||||
@@ -103,7 +103,7 @@ export const TourRoot = observer(function TourRoot(props: Props) {
|
||||
started by creating a project.
|
||||
</p>
|
||||
<div className="flex h-full items-end">
|
||||
<div className="mt-8 flex items-center gap-6">
|
||||
<div className="mt-12 flex items-center gap-6">
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
+3
-2
@@ -1,12 +1,13 @@
|
||||
// icons
|
||||
// plane imports
|
||||
import { CycleIcon, ModuleIcon, PageIcon, ViewsIcon, WorkItemsIcon } from "@plane/propel/icons";
|
||||
import type { ISvgIcons } from "@plane/propel/icons";
|
||||
// types
|
||||
import type { TTourSteps } from "./root";
|
||||
|
||||
const sidebarOptions: {
|
||||
key: TTourSteps;
|
||||
label: string;
|
||||
Icon: any;
|
||||
Icon: React.FC<ISvgIcons>;
|
||||
}[] = [
|
||||
{
|
||||
key: "work-items",
|
||||
@@ -1,11 +1,10 @@
|
||||
import type { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// layouts
|
||||
import { ProjectAuthWrapper as CoreProjectAuthWrapper } from "@/layouts/auth-layout/project-wrapper";
|
||||
|
||||
export type IProjectAuthWrapper = {
|
||||
workspaceSlug: string;
|
||||
projectId?: string;
|
||||
projectId: string;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { ClipboardList } from "lucide-react";
|
||||
// plane imports
|
||||
import { Button } from "@plane/propel/button";
|
||||
// assets
|
||||
import Unauthorized from "@/app/assets/auth/unauthorized.svg?url";
|
||||
// hooks
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
|
||||
type Props = {
|
||||
projectId?: string;
|
||||
isPrivateProject?: boolean;
|
||||
};
|
||||
|
||||
export function JoinProject(props: Props) {
|
||||
const { projectId, isPrivateProject = false } = props;
|
||||
// states
|
||||
const [isJoiningProject, setIsJoiningProject] = useState(false);
|
||||
// store hooks
|
||||
const { joinProject } = useUserPermissions();
|
||||
const { fetchProjectDetails } = useProject();
|
||||
|
||||
const { workspaceSlug } = useParams();
|
||||
|
||||
const handleJoin = () => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
||||
setIsJoiningProject(true);
|
||||
|
||||
joinProject(workspaceSlug.toString(), projectId.toString())
|
||||
.then(() => fetchProjectDetails(workspaceSlug.toString(), projectId.toString()))
|
||||
.finally(() => setIsJoiningProject(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-y-5 bg-custom-background-100 text-center">
|
||||
<div className="h-44 w-72">
|
||||
<img src={Unauthorized} className="h-[176px] w-[288px] object-contain" alt="JoinProject" />
|
||||
</div>
|
||||
<h1 className="text-xl font-medium text-custom-text-100">
|
||||
{!isPrivateProject ? `You are not a member of this project yet.` : `You are not a member of this project.`}
|
||||
</h1>
|
||||
|
||||
<div className="w-full max-w-md text-base text-custom-text-200">
|
||||
<p className="mx-auto w-full text-sm md:w-3/4">
|
||||
{!isPrivateProject
|
||||
? `Click the button below to join it.`
|
||||
: `This is a private project. \n We can't tell you more about this project to protect confidentiality.`}
|
||||
</p>
|
||||
</div>
|
||||
{!isPrivateProject && (
|
||||
<div>
|
||||
<Button
|
||||
variant="primary"
|
||||
prependIcon={<ClipboardList color="white" />}
|
||||
loading={isJoiningProject}
|
||||
onClick={handleJoin}
|
||||
>
|
||||
{isJoiningProject ? "Taking you in" : "Click to join"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { EmptyStateDetailed } from "@plane/propel/empty-state";
|
||||
|
||||
type TProps = {
|
||||
isWorkspaceAdmin: boolean;
|
||||
handleJoinProject: () => void;
|
||||
isJoinButtonDisabled: boolean;
|
||||
errorStatusCode: number | undefined;
|
||||
};
|
||||
|
||||
export const ProjectAccessRestriction = observer(function ProjectAccessRestriction(props: TProps) {
|
||||
const { isWorkspaceAdmin, handleJoinProject, isJoinButtonDisabled, errorStatusCode } = props;
|
||||
// plane hooks
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Show join project screen if:
|
||||
// - User lacks project membership (409 Conflict)
|
||||
// - User lacks permission to access the private project (403 Forbidden) but is a workspace admin (can join any project)
|
||||
if (errorStatusCode === 409 || (errorStatusCode === 403 && isWorkspaceAdmin))
|
||||
return (
|
||||
<div className="grid h-full w-full place-items-center bg-custom-background-100">
|
||||
<EmptyStateDetailed
|
||||
title={t("project_empty_state.no_access.title")}
|
||||
description={t("project_empty_state.no_access.join_description")}
|
||||
assetKey="no-access"
|
||||
assetClassName="size-40"
|
||||
actions={[
|
||||
{
|
||||
label: isJoinButtonDisabled
|
||||
? t("project_empty_state.no_access.cta_loading")
|
||||
: t("project_empty_state.no_access.cta_primary"),
|
||||
onClick: handleJoinProject,
|
||||
disabled: isJoinButtonDisabled,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Show no access screen if:
|
||||
// - User lacks permission to access the private project (403 Forbidden)
|
||||
if (errorStatusCode === 403) {
|
||||
return (
|
||||
<div className="grid h-full w-full place-items-center bg-custom-background-100">
|
||||
<EmptyStateDetailed
|
||||
title={t("project_empty_state.no_access.title")}
|
||||
description={t("project_empty_state.no_access.restricted_description")}
|
||||
assetKey="no-access"
|
||||
assetClassName="size-40"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Show empty state screen if:
|
||||
// - Project not found (404 Not Found)
|
||||
// - Any other error status code
|
||||
return (
|
||||
<div className="grid h-full w-full place-items-center bg-custom-background-100">
|
||||
<EmptyStateDetailed
|
||||
title={t("project_empty_state.invalid_project.title")}
|
||||
description={t("project_empty_state.invalid_project.description")}
|
||||
assetKey="project"
|
||||
assetClassName="size-40"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -4,15 +4,14 @@ import useSWR from "swr";
|
||||
// plane imports
|
||||
import { PRODUCT_TOUR_TRACKER_EVENTS } from "@plane/constants";
|
||||
import { ContentWrapper } from "@plane/ui";
|
||||
// components
|
||||
import { TourRoot } from "@/components/onboarding/tour";
|
||||
// helpers
|
||||
import { captureSuccess } from "@/helpers/event-tracker.helper";
|
||||
// hooks
|
||||
import { useHome } from "@/hooks/store/use-home";
|
||||
import { useUserProfile, useUser } from "@/hooks/store/user";
|
||||
// plane web components
|
||||
// plane web imports
|
||||
import { HomePeekOverviewsRoot } from "@/plane-web/components/home";
|
||||
import { TourRoot } from "@/plane-web/components/onboarding/tour/root";
|
||||
// local imports
|
||||
import { DashboardWidgets } from "./home-dashboard-widgets";
|
||||
import { UserGreetingsView } from "./user-greetings";
|
||||
@@ -53,7 +52,7 @@ export const WorkspaceHomeView = observer(function WorkspaceHomeView() {
|
||||
return (
|
||||
<>
|
||||
{currentUserProfile && !currentUserProfile.is_tour_completed && (
|
||||
<div className="fixed left-0 top-0 z-20 grid h-full w-full place-items-center bg-custom-backdrop bg-opacity-50 transition-opacity">
|
||||
<div className="fixed left-0 top-0 z-20 grid h-full w-full place-items-center bg-custom-backdrop bg-opacity-50 transition-opacity overflow-y-auto">
|
||||
<TourRoot onComplete={handleTourCompleted} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -6,7 +6,6 @@ import useSWR from "swr";
|
||||
import { ISSUE_DISPLAY_FILTERS_BY_PAGE } from "@plane/constants";
|
||||
import { EIssuesStoreType } from "@plane/types";
|
||||
// components
|
||||
import { LogoSpinner } from "@/components/common/logo-spinner";
|
||||
import { ProjectLevelWorkItemFiltersHOC } from "@/components/work-item-filters/filters-hoc/project-level";
|
||||
// hooks
|
||||
import { WorkItemFiltersRow } from "@/components/work-item-filters/filters-row";
|
||||
@@ -26,7 +25,7 @@ export const ArchivedIssueLayoutRoot = observer(function ArchivedIssueLayoutRoot
|
||||
// derived values
|
||||
const workItemFilters = projectId ? issuesFilter?.getIssueFilters(projectId) : undefined;
|
||||
|
||||
const { isLoading } = useSWR(
|
||||
useSWR(
|
||||
workspaceSlug && projectId ? `ARCHIVED_ISSUES_${workspaceSlug.toString()}_${projectId.toString()}` : null,
|
||||
async () => {
|
||||
if (workspaceSlug && projectId) {
|
||||
@@ -36,15 +35,7 @@ export const ArchivedIssueLayoutRoot = observer(function ArchivedIssueLayoutRoot
|
||||
{ revalidateIfStale: false, revalidateOnFocus: false }
|
||||
);
|
||||
|
||||
if (!workspaceSlug || !projectId) return <></>;
|
||||
|
||||
if (isLoading && !workItemFilters)
|
||||
return (
|
||||
<div className="h-full w-full flex items-center justify-center">
|
||||
<LogoSpinner />
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!workspaceSlug || !projectId || !workItemFilters) return <></>;
|
||||
return (
|
||||
<IssuesStoreContext.Provider value={EIssuesStoreType.ARCHIVED}>
|
||||
<ProjectLevelWorkItemFiltersHOC
|
||||
|
||||
@@ -7,7 +7,6 @@ import useSWR from "swr";
|
||||
import { ISSUE_DISPLAY_FILTERS_BY_PAGE, PROJECT_VIEW_TRACKER_ELEMENTS } from "@plane/constants";
|
||||
import { EIssuesStoreType, EIssueLayoutTypes } from "@plane/types";
|
||||
// components
|
||||
import { LogoSpinner } from "@/components/common/logo-spinner";
|
||||
import { TransferIssues } from "@/components/cycles/transfer-issues";
|
||||
import { TransferIssuesModal } from "@/components/cycles/transfer-issues-modal";
|
||||
// hooks
|
||||
@@ -59,7 +58,7 @@ export const CycleLayoutRoot = observer(function CycleLayoutRoot() {
|
||||
const workItemFilters = cycleId ? issuesFilter?.getIssueFilters(cycleId) : undefined;
|
||||
const activeLayout = workItemFilters?.displayFilters?.layout;
|
||||
|
||||
const { isLoading } = useSWR(
|
||||
useSWR(
|
||||
workspaceSlug && projectId && cycleId ? `CYCLE_ISSUES_${workspaceSlug}_${projectId}_${cycleId}` : null,
|
||||
async () => {
|
||||
if (workspaceSlug && projectId && cycleId) {
|
||||
@@ -78,15 +77,7 @@ export const CycleLayoutRoot = observer(function CycleLayoutRoot() {
|
||||
: 0;
|
||||
const canTransferIssues = isProgressSnapshotEmpty && transferableIssuesCount > 0;
|
||||
|
||||
if (!workspaceSlug || !projectId || !cycleId) return <></>;
|
||||
|
||||
if (isLoading && !workItemFilters)
|
||||
return (
|
||||
<div className="h-full w-full flex items-center justify-center">
|
||||
<LogoSpinner />
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!workspaceSlug || !projectId || !cycleId || !workItemFilters) return <></>;
|
||||
return (
|
||||
<IssuesStoreContext.Provider value={EIssuesStoreType.CYCLE}>
|
||||
<ProjectLevelWorkItemFiltersHOC
|
||||
|
||||
@@ -6,8 +6,6 @@ import useSWR from "swr";
|
||||
import { ISSUE_DISPLAY_FILTERS_BY_PAGE, PROJECT_VIEW_TRACKER_ELEMENTS } from "@plane/constants";
|
||||
import { EIssuesStoreType, EIssueLayoutTypes } from "@plane/types";
|
||||
import { Row, ERowVariant } from "@plane/ui";
|
||||
// components
|
||||
import { LogoSpinner } from "@/components/common/logo-spinner";
|
||||
// hooks
|
||||
import { ProjectLevelWorkItemFiltersHOC } from "@/components/work-item-filters/filters-hoc/project-level";
|
||||
import { WorkItemFiltersRow } from "@/components/work-item-filters/filters-row";
|
||||
@@ -50,7 +48,7 @@ export const ModuleLayoutRoot = observer(function ModuleLayoutRoot() {
|
||||
const workItemFilters = moduleId ? issuesFilter?.getIssueFilters(moduleId) : undefined;
|
||||
const activeLayout = workItemFilters?.displayFilters?.layout || undefined;
|
||||
|
||||
const { isLoading } = useSWR(
|
||||
useSWR(
|
||||
workspaceSlug && projectId && moduleId
|
||||
? `MODULE_ISSUES_${workspaceSlug.toString()}_${projectId.toString()}_${moduleId.toString()}`
|
||||
: null,
|
||||
@@ -62,15 +60,7 @@ export const ModuleLayoutRoot = observer(function ModuleLayoutRoot() {
|
||||
{ revalidateIfStale: false, revalidateOnFocus: false }
|
||||
);
|
||||
|
||||
if (!workspaceSlug || !projectId || !moduleId) return <></>;
|
||||
|
||||
if (isLoading && !workItemFilters)
|
||||
return (
|
||||
<div className="h-full w-full flex items-center justify-center">
|
||||
<LogoSpinner />
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!workspaceSlug || !projectId || !moduleId || !workItemFilters) return <></>;
|
||||
return (
|
||||
<IssuesStoreContext.Provider value={EIssuesStoreType.MODULE}>
|
||||
<ProjectLevelWorkItemFiltersHOC
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import useSWR from "swr";
|
||||
@@ -7,7 +6,6 @@ import { ISSUE_DISPLAY_FILTERS_BY_PAGE, PROJECT_VIEW_TRACKER_ELEMENTS } from "@p
|
||||
import { EIssueLayoutTypes, EIssuesStoreType } from "@plane/types";
|
||||
import { Spinner } from "@plane/ui";
|
||||
// components
|
||||
import { LogoSpinner } from "@/components/common/logo-spinner";
|
||||
import { ProjectLevelWorkItemFiltersHOC } from "@/components/work-item-filters/filters-hoc/project-level";
|
||||
import { WorkItemFiltersRow } from "@/components/work-item-filters/filters-row";
|
||||
// hooks
|
||||
@@ -49,7 +47,7 @@ export const ProjectLayoutRoot = observer(function ProjectLayoutRoot() {
|
||||
const workItemFilters = projectId ? issuesFilter?.getIssueFilters(projectId) : undefined;
|
||||
const activeLayout = workItemFilters?.displayFilters?.layout;
|
||||
|
||||
const { isLoading } = useSWR(
|
||||
useSWR(
|
||||
workspaceSlug && projectId ? `PROJECT_ISSUES_${workspaceSlug}_${projectId}` : null,
|
||||
async () => {
|
||||
if (workspaceSlug && projectId) {
|
||||
@@ -59,15 +57,7 @@ export const ProjectLayoutRoot = observer(function ProjectLayoutRoot() {
|
||||
{ revalidateIfStale: false, revalidateOnFocus: false }
|
||||
);
|
||||
|
||||
if (!workspaceSlug || !projectId) return <></>;
|
||||
|
||||
if (isLoading && !workItemFilters)
|
||||
return (
|
||||
<div className="h-full w-full flex items-center justify-center">
|
||||
<LogoSpinner />
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!workspaceSlug || !projectId || !workItemFilters) return <></>;
|
||||
return (
|
||||
<IssuesStoreContext.Provider value={EIssuesStoreType.PROJECT}>
|
||||
<ProjectLevelWorkItemFiltersHOC
|
||||
|
||||
@@ -5,8 +5,6 @@ import useSWR from "swr";
|
||||
// plane constants
|
||||
import { ISSUE_DISPLAY_FILTERS_BY_PAGE, PROJECT_VIEW_TRACKER_ELEMENTS } from "@plane/constants";
|
||||
import { EIssuesStoreType, EIssueLayoutTypes } from "@plane/types";
|
||||
// components
|
||||
import { LogoSpinner } from "@/components/common/logo-spinner";
|
||||
// hooks
|
||||
import { ProjectLevelWorkItemFiltersHOC } from "@/components/work-item-filters/filters-hoc/project-level";
|
||||
import { WorkItemFiltersRow } from "@/components/work-item-filters/filters-row";
|
||||
@@ -60,7 +58,7 @@ export const ProjectViewLayoutRoot = observer(function ProjectViewLayoutRoot() {
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const { isLoading } = useSWR(
|
||||
useSWR(
|
||||
workspaceSlug && projectId && viewId ? `PROJECT_VIEW_ISSUES_${workspaceSlug}_${projectId}_${viewId}` : null,
|
||||
async () => {
|
||||
if (workspaceSlug && projectId && viewId) {
|
||||
@@ -78,16 +76,7 @@ export const ProjectViewLayoutRoot = observer(function ProjectViewLayoutRoot() {
|
||||
[issuesFilter, workspaceSlug, viewId]
|
||||
);
|
||||
|
||||
if (!workspaceSlug || !projectId || !viewId) return <></>;
|
||||
|
||||
if (isLoading && !workItemFilters) {
|
||||
return (
|
||||
<div className="relative flex h-screen w-full items-center justify-center">
|
||||
<LogoSpinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!workspaceSlug || !projectId || !viewId || !workItemFilters) return <></>;
|
||||
return (
|
||||
<IssuesStoreContext.Provider value={EIssuesStoreType.PROJECT_VIEW}>
|
||||
<ProjectLevelWorkItemFiltersHOC
|
||||
|
||||
@@ -6,7 +6,6 @@ import { SPREADSHEET_SELECT_GROUP, SPREADSHEET_PROPERTY_LIST } from "@plane/cons
|
||||
import type { TIssue, IIssueDisplayFilterOptions, IIssueDisplayProperties } from "@plane/types";
|
||||
import { EIssueLayoutTypes } from "@plane/types";
|
||||
// components
|
||||
import { LogoSpinner } from "@/components/common/logo-spinner";
|
||||
import { MultipleSelectGroup } from "@/components/core/multiple-select";
|
||||
// hooks
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
@@ -72,13 +71,7 @@ export const SpreadsheetView = observer(function SpreadsheetView(props: Props) {
|
||||
return true;
|
||||
});
|
||||
|
||||
if (!issueIds || issueIds.length === 0)
|
||||
return (
|
||||
<div className="grid h-full w-full place-items-center">
|
||||
<LogoSpinner />
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!issueIds || issueIds.length === 0) return <></>;
|
||||
return (
|
||||
<div className="relative flex h-full w-full flex-col overflow-x-hidden whitespace-nowrap rounded-lg bg-custom-background-200 text-custom-text-200">
|
||||
<div ref={portalRef} className="spreadsheet-menu-portal" />
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { useState, useRef, useMemo, useCallback, useEffect } from "react";
|
||||
import { useState, useMemo, useCallback, useEffect } from "react";
|
||||
import { Command } from "cmdk";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// hooks
|
||||
import { useOutsideClickDetector } from "@plane/hooks";
|
||||
import { CloseIcon, SearchIcon } from "@plane/propel/icons";
|
||||
import { cn } from "@plane/utils";
|
||||
// power-k
|
||||
@@ -14,6 +13,7 @@ import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
import { usePowerK } from "@/hooks/store/use-power-k";
|
||||
import { useUser } from "@/hooks/store/user";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
import { useExpandableSearch } from "@/hooks/use-expandable-search";
|
||||
|
||||
export const TopNavPowerK = observer(() => {
|
||||
// router
|
||||
@@ -22,7 +22,6 @@ export const TopNavPowerK = observer(() => {
|
||||
const { projectId: routerProjectId, workItem: workItemIdentifier } = params;
|
||||
|
||||
// states
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [activeCommand, setActiveCommand] = useState<TPowerKCommandConfig | null>(null);
|
||||
const [shouldShowContextBasedActions, setShouldShowContextBasedActions] = useState(true);
|
||||
@@ -32,6 +31,25 @@ export const TopNavPowerK = observer(() => {
|
||||
const { activeContext, setActivePage, activePage, setTopNavInputRef } = usePowerK();
|
||||
const { data: currentUser } = useUser();
|
||||
|
||||
const handleOnClose = useCallback(() => {
|
||||
setSearchTerm("");
|
||||
setActivePage(null);
|
||||
setActiveCommand(null);
|
||||
}, [setSearchTerm, setActivePage, setActiveCommand]);
|
||||
|
||||
// expandable search hook
|
||||
const {
|
||||
isOpen,
|
||||
containerRef,
|
||||
inputRef,
|
||||
handleClose: closePanel,
|
||||
handleMouseDown,
|
||||
handleFocus,
|
||||
openPanel,
|
||||
} = useExpandableSearch({
|
||||
onClose: handleOnClose,
|
||||
});
|
||||
|
||||
// derived values
|
||||
const {
|
||||
issue: { getIssueById, getIssueIdByIdentifier },
|
||||
@@ -54,12 +72,7 @@ export const TopNavPowerK = observer(() => {
|
||||
projectId,
|
||||
},
|
||||
router,
|
||||
closePalette: () => {
|
||||
setIsOpen(false);
|
||||
setSearchTerm("");
|
||||
setActivePage(null);
|
||||
setActiveCommand(null);
|
||||
},
|
||||
closePalette: closePanel,
|
||||
setActiveCommand,
|
||||
setActivePage,
|
||||
}),
|
||||
@@ -72,12 +85,10 @@ export const TopNavPowerK = observer(() => {
|
||||
projectId,
|
||||
router,
|
||||
setActivePage,
|
||||
closePanel,
|
||||
]
|
||||
);
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Register input ref with PowerK store for keyboard shortcut access
|
||||
useEffect(() => {
|
||||
setTopNavInputRef(inputRef);
|
||||
@@ -86,18 +97,6 @@ export const TopNavPowerK = observer(() => {
|
||||
};
|
||||
}, [setTopNavInputRef]);
|
||||
|
||||
useOutsideClickDetector(containerRef, () => {
|
||||
if (isOpen) {
|
||||
setIsOpen(false);
|
||||
setActivePage(null);
|
||||
setActiveCommand(null);
|
||||
}
|
||||
});
|
||||
|
||||
const handleFocus = () => {
|
||||
setIsOpen(true);
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
setSearchTerm("");
|
||||
inputRef.current?.focus();
|
||||
@@ -136,10 +135,7 @@ export const TopNavPowerK = observer(() => {
|
||||
// Cmd/Ctrl+K closes the search dropdown
|
||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
|
||||
e.preventDefault();
|
||||
setIsOpen(false);
|
||||
setSearchTerm("");
|
||||
setActivePage(null);
|
||||
context.setActiveCommand(null);
|
||||
closePanel();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -148,9 +144,7 @@ export const TopNavPowerK = observer(() => {
|
||||
if (searchTerm) {
|
||||
setSearchTerm("");
|
||||
}
|
||||
setIsOpen(false);
|
||||
inputRef.current?.blur();
|
||||
|
||||
closePanel();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -203,7 +197,7 @@ export const TopNavPowerK = observer(() => {
|
||||
return;
|
||||
}
|
||||
},
|
||||
[searchTerm, activePage, context, shouldShowContextBasedActions, setActivePage, isOpen]
|
||||
[searchTerm, activePage, context, shouldShowContextBasedActions, setActivePage, closePanel]
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -228,7 +222,11 @@ export const TopNavPowerK = observer(() => {
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setSearchTerm(e.target.value);
|
||||
if (!isOpen) openPanel();
|
||||
}}
|
||||
onMouseDown={handleMouseDown}
|
||||
onFocus={handleFocus}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Search commands..."
|
||||
|
||||
@@ -42,9 +42,10 @@ export const useResponsiveTabLayout = ({
|
||||
const gap = 4; // gap-1 = 4px
|
||||
const overflowButtonWidth = 40;
|
||||
|
||||
const container = containerRef?.current;
|
||||
|
||||
// ResizeObserver to measure container width
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const resizeObserver = new ResizeObserver((entries) => {
|
||||
@@ -58,7 +59,7 @@ export const useResponsiveTabLayout = ({
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, []);
|
||||
}, [container]);
|
||||
|
||||
// Calculate how many items can fit
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./root";
|
||||
@@ -49,16 +49,18 @@ export const SidebarWrapper = observer(function SidebarWrapper(props: TSidebarWr
|
||||
|
||||
<div className="flex items-center justify-between gap-2 px-2">
|
||||
<span className="text-md text-custom-text-200 font-medium pt-1">{title}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center justify-center size-6 rounded-md text-custom-text-400 hover:text-custom-primary-100 hover:bg-custom-background-90"
|
||||
onClick={() => setIsCustomizeNavDialogOpen(true)}
|
||||
>
|
||||
<PreferencesIcon className="size-4" />
|
||||
</button>
|
||||
<AppSidebarToggleButton />
|
||||
</div>
|
||||
{title === "Projects" && (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center justify-center size-6 rounded-md text-custom-text-400 hover:text-custom-primary-100 hover:bg-custom-background-90"
|
||||
onClick={() => setIsCustomizeNavDialogOpen(true)}
|
||||
>
|
||||
<PreferencesIcon className="size-4" />
|
||||
</button>
|
||||
<AppSidebarToggleButton />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Quick actions */}
|
||||
{quickActions}
|
||||
|
||||
@@ -17,7 +17,6 @@ import type { IWorkspace } from "@plane/types";
|
||||
import { CustomSelect, Input } from "@plane/ui";
|
||||
import { copyUrlToClipboard, getFileURL } from "@plane/utils";
|
||||
// components
|
||||
import { LogoSpinner } from "@/components/common/logo-spinner";
|
||||
import { WorkspaceImageUploadModal } from "@/components/core/modals/workspace-image-upload-modal";
|
||||
// helpers
|
||||
import { captureError, captureSuccess } from "@/helpers/event-tracker.helper";
|
||||
@@ -129,13 +128,7 @@ export const WorkspaceDetails = observer(function WorkspaceDetails() {
|
||||
|
||||
const isAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE);
|
||||
|
||||
if (!currentWorkspace)
|
||||
return (
|
||||
<div className="grid h-full w-full place-items-center px-4 sm:px-0">
|
||||
<LogoSpinner />
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!currentWorkspace) return <></>;
|
||||
return (
|
||||
<>
|
||||
<Controller
|
||||
|
||||
@@ -7,6 +7,7 @@ import { attachInstruction, extractInstruction } from "@atlaskit/pragmatic-drag-
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import scrollIntoView from "smooth-scroll-into-view-if-needed";
|
||||
import { LinkIcon, Settings, Share2, LogOut, MoreHorizontal } from "lucide-react";
|
||||
import { Disclosure, Transition } from "@headlessui/react";
|
||||
// plane imports
|
||||
@@ -225,7 +226,29 @@ export const SidebarProjectsListItem = observer(function SidebarProjectsListItem
|
||||
useOutsideClickDetector(projectRef, () => projectRef?.current?.classList?.remove(HIGHLIGHT_CLASS));
|
||||
|
||||
useEffect(() => {
|
||||
if (URLProjectId === project?.id) setIsProjectListOpen(true);
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
if (URLProjectId === project?.id) {
|
||||
setIsProjectListOpen(true);
|
||||
// Scroll to active project
|
||||
if (projectRef.current) {
|
||||
timeoutId = setTimeout(() => {
|
||||
if (projectRef.current) {
|
||||
scrollIntoView(projectRef.current, {
|
||||
behavior: "smooth",
|
||||
block: "center",
|
||||
scrollMode: "if-needed",
|
||||
});
|
||||
}
|
||||
}, 200);
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
};
|
||||
}, [URLProjectId, project?.id, setIsProjectListOpen]);
|
||||
|
||||
if (!project) return null;
|
||||
|
||||
@@ -46,6 +46,7 @@ export const useEditorMention = (args: TArgs) => {
|
||||
name={user.member__display_name}
|
||||
/>
|
||||
),
|
||||
id: user.member__id,
|
||||
entity_identifier: user.member__id,
|
||||
entity_name: "user_mention",
|
||||
title: user.member__display_name,
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { useOutsideClickDetector } from "@plane/hooks";
|
||||
|
||||
type UseExpandableSearchOptions = {
|
||||
onClose?: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Custom hook for expandable search input behavior
|
||||
* Handles focus management to prevent unwanted opening on programmatic focus restoration
|
||||
*/
|
||||
export const useExpandableSearch = (options?: UseExpandableSearchOptions) => {
|
||||
const { onClose } = options || {};
|
||||
|
||||
// states
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
// refs
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const wasClickedRef = useRef<boolean>(false);
|
||||
|
||||
// Handle close
|
||||
const handleClose = useCallback(() => {
|
||||
setIsOpen(false);
|
||||
inputRef.current?.blur();
|
||||
onClose?.();
|
||||
}, [onClose]);
|
||||
|
||||
// Outside click handler - memoized to prevent unnecessary re-registrations
|
||||
const handleOutsideClick = useCallback(() => {
|
||||
if (isOpen) {
|
||||
handleClose();
|
||||
}
|
||||
}, [isOpen, handleClose]);
|
||||
|
||||
// Outside click detection
|
||||
useOutsideClickDetector(containerRef, handleOutsideClick);
|
||||
|
||||
// Track explicit clicks
|
||||
const handleMouseDown = useCallback(() => {
|
||||
wasClickedRef.current = true;
|
||||
}, []);
|
||||
|
||||
// Only open on explicit clicks, not programmatic focus
|
||||
const handleFocus = useCallback(() => {
|
||||
if (wasClickedRef.current) {
|
||||
setIsOpen(true);
|
||||
wasClickedRef.current = false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Helper to open panel (for typing/onChange)
|
||||
const openPanel = useCallback(() => {
|
||||
if (!isOpen) {
|
||||
setIsOpen(true);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
return {
|
||||
// State
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
|
||||
// Refs
|
||||
containerRef,
|
||||
inputRef,
|
||||
|
||||
// Handlers
|
||||
handleClose,
|
||||
handleMouseDown,
|
||||
handleFocus,
|
||||
openPanel,
|
||||
};
|
||||
};
|
||||
@@ -1,15 +1,12 @@
|
||||
import type { FC, ReactNode } from "react";
|
||||
import { useEffect } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import useSWR from "swr";
|
||||
// plane imports
|
||||
import { EUserPermissions, EUserPermissionsLevel, PROJECT_TRACKER_ELEMENTS } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { EmptyStateDetailed } from "@plane/propel/empty-state";
|
||||
import { EProjectNetwork, GANTT_TIMELINE_TYPE } from "@plane/types";
|
||||
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
||||
import { GANTT_TIMELINE_TYPE } from "@plane/types";
|
||||
// components
|
||||
import { JoinProject } from "@/components/auth-screens/project/join-project";
|
||||
import { LogoSpinner } from "@/components/common/logo-spinner";
|
||||
import { ProjectAccessRestriction } from "@/components/auth-screens/project/project-access-restriction";
|
||||
import {
|
||||
PROJECT_DETAILS,
|
||||
PROJECT_ME_INFORMATION,
|
||||
@@ -23,10 +20,8 @@ import {
|
||||
PROJECT_VIEWS,
|
||||
PROJECT_INTAKE_STATE,
|
||||
} from "@/constants/fetch-keys";
|
||||
import { captureClick } from "@/helpers/event-tracker.helper";
|
||||
// hooks
|
||||
import { useProjectEstimates } from "@/hooks/store/estimates";
|
||||
import { useCommandPalette } from "@/hooks/store/use-command-palette";
|
||||
import { useCycle } from "@/hooks/store/use-cycle";
|
||||
import { useLabel } from "@/hooks/store/use-label";
|
||||
import { useMember } from "@/hooks/store/use-member";
|
||||
@@ -39,19 +34,19 @@ import { useTimeLineChart } from "@/hooks/use-timeline-chart";
|
||||
|
||||
interface IProjectAuthWrapper {
|
||||
workspaceSlug: string;
|
||||
projectId?: string;
|
||||
projectId: string;
|
||||
children: ReactNode;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export const ProjectAuthWrapper = observer(function ProjectAuthWrapper(props: IProjectAuthWrapper) {
|
||||
const { workspaceSlug, projectId, children, isLoading: isParentLoading = false } = props;
|
||||
// plane hooks
|
||||
const { t } = useTranslation();
|
||||
// states
|
||||
const [isJoiningProject, setIsJoiningProject] = useState(false);
|
||||
// store hooks
|
||||
const { toggleCreateProjectModal } = useCommandPalette();
|
||||
const { fetchUserProjectInfo, allowPermissions, getProjectRoleByWorkspaceSlugAndProjectId } = useUserPermissions();
|
||||
const { loader, getProjectById, fetchProjectDetails } = useProject();
|
||||
const { fetchUserProjectInfo, allowPermissions } = useUserPermissions();
|
||||
const { fetchProjectDetails } = useProject();
|
||||
const { joinProject } = useUserPermissions();
|
||||
const { fetchAllCycles } = useCycle();
|
||||
const { fetchModulesSlim, fetchModules } = useModule();
|
||||
const { initGantt } = useTimeLineChart(GANTT_TIMELINE_TYPE.MODULE);
|
||||
@@ -63,10 +58,7 @@ export const ProjectAuthWrapper = observer(function ProjectAuthWrapper(props: IP
|
||||
const { data: currentUserData } = useUser();
|
||||
const { fetchProjectLabels } = useLabel();
|
||||
const { getProjectEstimates } = useProjectEstimates();
|
||||
|
||||
// derived values
|
||||
const projectExists = projectId ? getProjectById(projectId) : null;
|
||||
const projectMemberInfo = getProjectRoleByWorkspaceSlugAndProjectId(workspaceSlug, projectId);
|
||||
const hasPermissionToCurrentProject = allowPermissions(
|
||||
[EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST],
|
||||
EUserPermissionsLevel.PROJECT,
|
||||
@@ -82,120 +74,84 @@ export const ProjectAuthWrapper = observer(function ProjectAuthWrapper(props: IP
|
||||
}, []);
|
||||
|
||||
// fetching project details
|
||||
useSWR(
|
||||
workspaceSlug && projectId ? PROJECT_DETAILS(workspaceSlug, projectId) : null,
|
||||
workspaceSlug && projectId ? () => fetchProjectDetails(workspaceSlug, projectId) : null
|
||||
const { isLoading: isProjectDetailsLoading, error: projectDetailsError } = useSWR(
|
||||
PROJECT_DETAILS(workspaceSlug, projectId),
|
||||
() => fetchProjectDetails(workspaceSlug, projectId)
|
||||
);
|
||||
|
||||
// fetching user project member information
|
||||
useSWR(PROJECT_ME_INFORMATION(workspaceSlug, projectId), () => fetchUserProjectInfo(workspaceSlug, projectId));
|
||||
// fetching project member preferences
|
||||
useSWR(
|
||||
workspaceSlug && projectId && currentUserData?.id ? PROJECT_MEMBER_PREFERENCES(workspaceSlug, projectId) : null,
|
||||
workspaceSlug && projectId && currentUserData?.id
|
||||
? () => fetchProjectMemberPreferences(workspaceSlug, projectId, currentUserData.id)
|
||||
: null,
|
||||
currentUserData?.id ? PROJECT_MEMBER_PREFERENCES(workspaceSlug, projectId) : null,
|
||||
currentUserData?.id ? () => fetchProjectMemberPreferences(workspaceSlug, projectId, currentUserData.id) : null,
|
||||
{ revalidateIfStale: false, revalidateOnFocus: false }
|
||||
);
|
||||
// fetching project labels
|
||||
useSWR(
|
||||
workspaceSlug && projectId ? PROJECT_LABELS(workspaceSlug, projectId) : null,
|
||||
workspaceSlug && projectId ? () => fetchProjectLabels(workspaceSlug, projectId) : null,
|
||||
{ revalidateIfStale: false, revalidateOnFocus: false }
|
||||
);
|
||||
useSWR(PROJECT_LABELS(workspaceSlug, projectId), () => fetchProjectLabels(workspaceSlug, projectId), {
|
||||
revalidateIfStale: false,
|
||||
revalidateOnFocus: false,
|
||||
});
|
||||
// fetching project members
|
||||
useSWR(
|
||||
workspaceSlug && projectId ? PROJECT_MEMBERS(workspaceSlug, projectId) : null,
|
||||
workspaceSlug && projectId ? () => fetchProjectMembers(workspaceSlug, projectId) : null,
|
||||
{ revalidateIfStale: false, revalidateOnFocus: false }
|
||||
);
|
||||
useSWR(PROJECT_MEMBERS(workspaceSlug, projectId), () => fetchProjectMembers(workspaceSlug, projectId), {
|
||||
revalidateIfStale: false,
|
||||
revalidateOnFocus: false,
|
||||
});
|
||||
// fetching project states
|
||||
useSWR(
|
||||
workspaceSlug && projectId ? PROJECT_STATES(workspaceSlug, projectId) : null,
|
||||
workspaceSlug && projectId ? () => fetchProjectStates(workspaceSlug, projectId) : null,
|
||||
{ revalidateIfStale: false, revalidateOnFocus: false }
|
||||
);
|
||||
useSWR(PROJECT_STATES(workspaceSlug, projectId), () => fetchProjectStates(workspaceSlug, projectId), {
|
||||
revalidateIfStale: false,
|
||||
revalidateOnFocus: false,
|
||||
});
|
||||
// fetching project intake state
|
||||
useSWR(
|
||||
workspaceSlug && projectId ? PROJECT_INTAKE_STATE(workspaceSlug, projectId) : null,
|
||||
workspaceSlug && projectId ? () => fetchProjectIntakeState(workspaceSlug, projectId) : null,
|
||||
{ revalidateIfStale: false, revalidateOnFocus: false }
|
||||
);
|
||||
useSWR(PROJECT_INTAKE_STATE(workspaceSlug, projectId), () => fetchProjectIntakeState(workspaceSlug, projectId), {
|
||||
revalidateIfStale: false,
|
||||
revalidateOnFocus: false,
|
||||
});
|
||||
// fetching project estimates
|
||||
useSWR(
|
||||
workspaceSlug && projectId ? PROJECT_ESTIMATES(workspaceSlug, projectId) : null,
|
||||
workspaceSlug && projectId ? () => getProjectEstimates(workspaceSlug, projectId) : null,
|
||||
{ revalidateIfStale: false, revalidateOnFocus: false }
|
||||
);
|
||||
useSWR(PROJECT_ESTIMATES(workspaceSlug, projectId), () => getProjectEstimates(workspaceSlug, projectId), {
|
||||
revalidateIfStale: false,
|
||||
revalidateOnFocus: false,
|
||||
});
|
||||
// fetching project cycles
|
||||
useSWR(
|
||||
workspaceSlug && projectId ? PROJECT_ALL_CYCLES(workspaceSlug, projectId) : null,
|
||||
workspaceSlug && projectId ? () => fetchAllCycles(workspaceSlug, projectId) : null,
|
||||
{ revalidateIfStale: false, revalidateOnFocus: false }
|
||||
);
|
||||
useSWR(PROJECT_ALL_CYCLES(workspaceSlug, projectId), () => fetchAllCycles(workspaceSlug, projectId), {
|
||||
revalidateIfStale: false,
|
||||
revalidateOnFocus: false,
|
||||
});
|
||||
// fetching project modules
|
||||
useSWR(
|
||||
workspaceSlug && projectId ? PROJECT_MODULES(workspaceSlug, projectId) : null,
|
||||
workspaceSlug && projectId
|
||||
? async () => {
|
||||
await fetchModulesSlim(workspaceSlug, projectId);
|
||||
await fetchModules(workspaceSlug, projectId);
|
||||
}
|
||||
: null,
|
||||
PROJECT_MODULES(workspaceSlug, projectId),
|
||||
async () => {
|
||||
await Promise.all([fetchModulesSlim(workspaceSlug, projectId), fetchModules(workspaceSlug, projectId)]);
|
||||
},
|
||||
{ revalidateIfStale: false, revalidateOnFocus: false }
|
||||
);
|
||||
// fetching project views
|
||||
useSWR(
|
||||
workspaceSlug && projectId ? PROJECT_VIEWS(workspaceSlug, projectId) : null,
|
||||
workspaceSlug && projectId ? () => fetchViews(workspaceSlug, projectId) : null,
|
||||
{ revalidateIfStale: false, revalidateOnFocus: false }
|
||||
);
|
||||
useSWR(PROJECT_VIEWS(workspaceSlug, projectId), () => fetchViews(workspaceSlug, projectId), {
|
||||
revalidateIfStale: false,
|
||||
revalidateOnFocus: false,
|
||||
});
|
||||
|
||||
// permissions
|
||||
const canPerformEmptyStateActions = allowPermissions(
|
||||
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
|
||||
EUserPermissionsLevel.WORKSPACE
|
||||
);
|
||||
// handle join project
|
||||
const handleJoinProject = () => {
|
||||
setIsJoiningProject(true);
|
||||
joinProject(workspaceSlug, projectId)
|
||||
.then(() => fetchProjectDetails(workspaceSlug, projectId))
|
||||
.finally(() => setIsJoiningProject(false));
|
||||
};
|
||||
|
||||
// check if the project member apis is loading
|
||||
if (isParentLoading || (!projectMemberInfo && projectId && hasPermissionToCurrentProject === null))
|
||||
const isProjectLoading = (isParentLoading || isProjectDetailsLoading) && !projectDetailsError;
|
||||
|
||||
if (isProjectLoading) return null;
|
||||
|
||||
if (!isProjectLoading && hasPermissionToCurrentProject === false) {
|
||||
return (
|
||||
<div className="grid h-full place-items-center bg-custom-background-100 p-4 rounded-lg border border-custom-border-200">
|
||||
<div className="flex flex-col items-center gap-3 text-center">
|
||||
<LogoSpinner />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// check if the user don't have permission to access the project
|
||||
if (
|
||||
((projectExists?.network && projectExists?.network !== EProjectNetwork.PRIVATE) || isWorkspaceAdmin) &&
|
||||
projectId &&
|
||||
hasPermissionToCurrentProject === false
|
||||
)
|
||||
return <JoinProject projectId={projectId} />;
|
||||
|
||||
// check if the project info is not found.
|
||||
if (loader === "loaded" && projectId && !!hasPermissionToCurrentProject === false)
|
||||
return (
|
||||
<div className="grid h-full place-items-center bg-custom-background-100">
|
||||
<EmptyStateDetailed
|
||||
title={t("workspace_projects.empty_state.general.title")}
|
||||
description={t("workspace_projects.empty_state.general.description")}
|
||||
assetKey="project"
|
||||
assetClassName="size-40"
|
||||
actions={[
|
||||
{
|
||||
label: t("workspace_projects.empty_state.general.primary_button.text"),
|
||||
onClick: () => {
|
||||
toggleCreateProjectModal(true);
|
||||
captureClick({ elementName: PROJECT_TRACKER_ELEMENTS.EMPTY_STATE_CREATE_PROJECT_BUTTON });
|
||||
},
|
||||
disabled: !canPerformEmptyStateActions,
|
||||
variant: "primary",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<ProjectAccessRestriction
|
||||
errorStatusCode={projectDetailsError?.status}
|
||||
isWorkspaceAdmin={isWorkspaceAdmin}
|
||||
handleJoinProject={handleJoinProject}
|
||||
isJoinButtonDisabled={isJoiningProject}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
});
|
||||
|
||||
@@ -57,7 +57,7 @@ export class ProjectService extends APIService {
|
||||
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/`)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
throw error?.response;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -69,5 +69,3 @@ export const BLOCK_NODE_TYPES = [
|
||||
CORE_EXTENSIONS.CALLOUT,
|
||||
CORE_EXTENSIONS.WORK_ITEM_EMBED,
|
||||
];
|
||||
|
||||
export const INLINE_NODE_TYPES = [CORE_EXTENSIONS.MENTION];
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { NodeViewProps } from "@tiptap/react";
|
||||
import { NodeViewContent, NodeViewWrapper } from "@tiptap/react";
|
||||
import { useState } from "react";
|
||||
import React, { useState } from "react";
|
||||
// constants
|
||||
import { COLORS_LIST } from "@/constants/common";
|
||||
// local components
|
||||
@@ -33,7 +33,6 @@ export function CustomCalloutBlock(props: CustomCalloutNodeViewProps) {
|
||||
style={{
|
||||
backgroundColor: activeBackgroundColor,
|
||||
}}
|
||||
key={`callout-block-${node.attrs.id}`}
|
||||
>
|
||||
<CalloutBlockLogoSelector
|
||||
blockAttributes={node.attrs}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { Node as ProseMirrorNode } from "@tiptap/core";
|
||||
import type { TBlockNodeBaseAttributes } from "../unique-id/types";
|
||||
|
||||
export enum ECalloutAttributeNames {
|
||||
ICON_COLOR = "data-icon-color",
|
||||
@@ -21,7 +20,7 @@ export type TCalloutBlockEmojiAttributes = {
|
||||
[ECalloutAttributeNames.EMOJI_URL]: string | undefined;
|
||||
};
|
||||
|
||||
export type TCalloutBlockAttributes = TBlockNodeBaseAttributes & {
|
||||
export type TCalloutBlockAttributes = {
|
||||
[ECalloutAttributeNames.LOGO_IN_USE]: "emoji" | "icon";
|
||||
[ECalloutAttributeNames.BACKGROUND]: string | undefined;
|
||||
[ECalloutAttributeNames.BLOCK_TYPE]: "callout-component";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { NodeViewProps } from "@tiptap/react";
|
||||
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
|
||||
import { NodeViewWrapper, NodeViewContent } from "@tiptap/react";
|
||||
import ts from "highlight.js/lib/languages/typescript";
|
||||
import { common, createLowlight } from "lowlight";
|
||||
@@ -8,20 +8,16 @@ import { useState } from "react";
|
||||
import { Tooltip } from "@plane/ui";
|
||||
// plane utils
|
||||
import { cn } from "@plane/utils";
|
||||
// types
|
||||
import type { TCodeBlockAttributes } from "./types";
|
||||
|
||||
// we just have ts support for now
|
||||
const lowlight = createLowlight(common);
|
||||
lowlight.register("ts", ts);
|
||||
|
||||
export type CodeBlockNodeViewProps = NodeViewProps & {
|
||||
node: NodeViewProps["node"] & {
|
||||
attrs: TCodeBlockAttributes;
|
||||
};
|
||||
type Props = {
|
||||
node: ProseMirrorNode;
|
||||
};
|
||||
|
||||
export function CodeBlockComponent({ node }: CodeBlockNodeViewProps) {
|
||||
export function CodeBlockComponent({ node }: Props) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const copyToClipboard = async (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
|
||||
@@ -37,7 +33,7 @@ export function CodeBlockComponent({ node }: CodeBlockNodeViewProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<NodeViewWrapper className="code-block relative group/code" key={`code-block-${node.attrs.id}`}>
|
||||
<NodeViewWrapper className="code-block relative group/code">
|
||||
<Tooltip tooltipContent="Copy code">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -5,16 +5,13 @@ import { common, createLowlight } from "lowlight";
|
||||
// components
|
||||
import { CodeBlockLowlight } from "./code-block-lowlight";
|
||||
import { CodeBlockComponent } from "./code-block-node-view";
|
||||
import type { CodeBlockNodeViewProps } from "./code-block-node-view";
|
||||
|
||||
const lowlight = createLowlight(common);
|
||||
lowlight.register("ts", ts);
|
||||
|
||||
export const CustomCodeBlockExtension = CodeBlockLowlight.extend({
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer((props) => (
|
||||
<CodeBlockComponent {...props} node={props.node as CodeBlockNodeViewProps["node"]} />
|
||||
));
|
||||
return ReactNodeViewRenderer(CodeBlockComponent);
|
||||
},
|
||||
|
||||
addKeyboardShortcuts() {
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
import type { TBlockNodeBaseAttributes } from "../unique-id/types";
|
||||
|
||||
export type TCodeBlockAttributes = TBlockNodeBaseAttributes & {
|
||||
language: string | null;
|
||||
};
|
||||
@@ -122,7 +122,7 @@ export function CustomImageNodeView(props: CustomImageNodeViewProps) {
|
||||
const shouldShowBlock = (isUploaded || imageFromFileSystem) && !failedToLoadImage;
|
||||
|
||||
return (
|
||||
<NodeViewWrapper key={`image-block-${node.attrs.id}`}>
|
||||
<NodeViewWrapper>
|
||||
<div className="p-0 mx-0 my-2" data-drag-handle ref={imageComponentRef}>
|
||||
{shouldShowBlock && !hasDuplicationFailed ? (
|
||||
<CustomImageBlock
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { Node } from "@tiptap/core";
|
||||
// types
|
||||
import type { TFileHandler } from "@/types";
|
||||
import type { TBlockNodeBaseAttributes } from "../unique-id/types";
|
||||
|
||||
export enum ECustomImageAttributeNames {
|
||||
ID = "id",
|
||||
@@ -33,7 +32,8 @@ export enum ECustomImageStatus {
|
||||
DUPLICATION_FAILED = "duplication-failed",
|
||||
}
|
||||
|
||||
export type TCustomImageAttributes = TBlockNodeBaseAttributes & {
|
||||
export type TCustomImageAttributes = {
|
||||
[ECustomImageAttributeNames.ID]: string | null;
|
||||
[ECustomImageAttributeNames.WIDTH]: PixelAttribute<"35%" | number> | null;
|
||||
[ECustomImageAttributeNames.HEIGHT]: PixelAttribute<"auto" | number> | null;
|
||||
[ECustomImageAttributeNames.ASPECT_RATIO]: number | null;
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import type { NodeViewProps } from "@tiptap/react";
|
||||
import { NodeViewWrapper } from "@tiptap/react";
|
||||
import { useMemo } from "react";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
// extension config
|
||||
import type { TMentionExtensionOptions } from "./extension-config";
|
||||
// extension types
|
||||
@@ -21,7 +19,7 @@ export function MentionNodeView(props: MentionNodeViewProps) {
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<NodeViewWrapper className="mention-component inline w-fit" key={`mention-${attrs.id}`}>
|
||||
<NodeViewWrapper className="mention-component inline w-fit">
|
||||
{(extension.options as TMentionExtensionOptions).renderComponent({
|
||||
entity_identifier: attrs[EMentionComponentAttributeNames.ENTITY_IDENTIFIER] ?? "",
|
||||
entity_name: attrs[EMentionComponentAttributeNames.ENTITY_NAME] ?? "user_mention",
|
||||
|
||||
@@ -31,9 +31,11 @@ export const MentionsListDropdown = forwardRef(function MentionsListDropdown(pro
|
||||
(sectionIndex: number, itemIndex: number) => {
|
||||
try {
|
||||
const item = sections?.[sectionIndex]?.items?.[itemIndex];
|
||||
const transactionId = uuidv4();
|
||||
if (item) {
|
||||
command({
|
||||
...item,
|
||||
id: transactionId,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -4,12 +4,13 @@ import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
|
||||
import type { Transaction } from "@tiptap/pm/state";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS, BLOCK_NODE_TYPES, INLINE_NODE_TYPES } from "@/constants/extension";
|
||||
import { CORE_EXTENSIONS, BLOCK_NODE_TYPES } from "@/constants/extension";
|
||||
import { ADDITIONAL_BLOCK_NODE_TYPES } from "@/plane-editor/constants/extensions";
|
||||
import { createUniqueIDPlugin } from "./plugin";
|
||||
import { createIdsForView } from "./utils";
|
||||
// plane imports
|
||||
|
||||
const COMBINED_BLOCK_NODE_TYPES = [...INLINE_NODE_TYPES, ...BLOCK_NODE_TYPES, ...ADDITIONAL_BLOCK_NODE_TYPES];
|
||||
const COMBINED_BLOCK_NODE_TYPES = [...BLOCK_NODE_TYPES, ...ADDITIONAL_BLOCK_NODE_TYPES];
|
||||
export type UniqueIDGenerationContext = {
|
||||
node: ProseMirrorNode;
|
||||
pos: number;
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
/**
|
||||
* Base attributes for all block nodes that have the unique-id extension.
|
||||
* All block node attribute types should extend this.
|
||||
*/
|
||||
export interface TBlockNodeBaseAttributes {
|
||||
id?: string | null;
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import { ReactNodeViewRenderer, NodeViewWrapper } from "@tiptap/react";
|
||||
import type { NodeViewProps } from "@tiptap/react";
|
||||
// local imports
|
||||
import { WorkItemEmbedExtensionConfig } from "./extension-config";
|
||||
import type { TWorkItemEmbedAttributes } from "./types";
|
||||
|
||||
type Props = {
|
||||
widgetCallback: ({
|
||||
@@ -19,18 +18,15 @@ type Props = {
|
||||
export function WorkItemEmbedExtension(props: Props) {
|
||||
return WorkItemEmbedExtensionConfig.extend({
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer((issueProps: NodeViewProps) => {
|
||||
const attrs = issueProps.node.attrs as TWorkItemEmbedAttributes;
|
||||
return (
|
||||
<NodeViewWrapper key={`work-item-embed-${attrs.id}`}>
|
||||
{props.widgetCallback({
|
||||
issueId: attrs.entity_identifier!,
|
||||
projectId: attrs.project_identifier,
|
||||
workspaceSlug: attrs.workspace_identifier,
|
||||
})}
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
});
|
||||
return ReactNodeViewRenderer((issueProps: NodeViewProps) => (
|
||||
<NodeViewWrapper>
|
||||
{props.widgetCallback({
|
||||
issueId: issueProps.node.attrs.entity_identifier,
|
||||
projectId: issueProps.node.attrs.project_identifier,
|
||||
workspaceSlug: issueProps.node.attrs.workspace_identifier,
|
||||
})}
|
||||
</NodeViewWrapper>
|
||||
));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
import type { TBlockNodeBaseAttributes } from "../unique-id/types";
|
||||
|
||||
export type TWorkItemEmbedAttributes = TBlockNodeBaseAttributes & {
|
||||
entity_identifier: string | undefined;
|
||||
project_identifier: string | undefined;
|
||||
workspace_identifier: string | undefined;
|
||||
entity_name: string | undefined;
|
||||
};
|
||||
@@ -5,7 +5,7 @@ export type TMentionSuggestion = {
|
||||
entity_identifier: string;
|
||||
entity_name: TSearchEntities;
|
||||
icon: React.ReactNode;
|
||||
id?: string | null;
|
||||
id: string;
|
||||
subTitle?: string;
|
||||
title: string;
|
||||
};
|
||||
|
||||
@@ -24,6 +24,17 @@ export default {
|
||||
},
|
||||
},
|
||||
project_empty_state: {
|
||||
no_access: {
|
||||
title: "Vypadá to, že nemáte přístup k tomuto projektu",
|
||||
restricted_description: "Kontaktujte administrátora a požádejte o přístup, abyste zde mohli pokračovat.",
|
||||
join_description: "Klikněte na tlačítko níže pro připojení k projektu.",
|
||||
cta_primary: "Připojit se k projektu",
|
||||
cta_loading: "Připojování k projektu",
|
||||
},
|
||||
invalid_project: {
|
||||
title: "Projekt nebyl nalezen",
|
||||
description: "Projekt, který hledáte, neexistuje.",
|
||||
},
|
||||
work_items: {
|
||||
title: "Začněte s vaší první pracovní položkou.",
|
||||
description:
|
||||
|
||||
@@ -74,7 +74,7 @@ export default {
|
||||
},
|
||||
unique_code: {
|
||||
label: "Jedinečný kód",
|
||||
placeholder: "gets-sets-flys",
|
||||
placeholder: "123456",
|
||||
paste_code: "Vložte kód zaslaný na váš e-mail",
|
||||
requesting_new_code: "Žádám o nový kód",
|
||||
sending_code: "Odesílám kód",
|
||||
@@ -1544,7 +1544,7 @@ export default {
|
||||
},
|
||||
code: {
|
||||
label: "Jedinečný kód",
|
||||
placeholder: "gets-sets-flys",
|
||||
placeholder: "123456",
|
||||
helper_text: "Ověřovací kód byl odeslán na váš nový e-mail.",
|
||||
errors: {
|
||||
required: "Jedinečný kód je povinný",
|
||||
|
||||
@@ -26,6 +26,18 @@ export default {
|
||||
},
|
||||
},
|
||||
project_empty_state: {
|
||||
no_access: {
|
||||
title: "Es scheint, als hätten Sie keinen Zugriff auf dieses Projekt",
|
||||
restricted_description:
|
||||
"Kontaktieren Sie den Administrator, um Zugriff anzufordern, damit Sie hier fortfahren können.",
|
||||
join_description: "Klicken Sie unten auf die Schaltfläche, um beizutreten.",
|
||||
cta_primary: "Projekt beitreten",
|
||||
cta_loading: "Projekt wird beigetreten",
|
||||
},
|
||||
invalid_project: {
|
||||
title: "Projekt nicht gefunden",
|
||||
description: "Das gesuchte Projekt existiert nicht.",
|
||||
},
|
||||
work_items: {
|
||||
title: "Beginnen Sie mit Ihrem ersten Arbeitselement.",
|
||||
description:
|
||||
|
||||
@@ -74,7 +74,7 @@ export default {
|
||||
},
|
||||
unique_code: {
|
||||
label: "Einmaliger Code",
|
||||
placeholder: "gets-sets-flys",
|
||||
placeholder: "123456",
|
||||
paste_code: "Fügen Sie den an Ihre E-Mail gesendeten Code ein",
|
||||
requesting_new_code: "Neuen Code anfordern",
|
||||
sending_code: "Code wird gesendet",
|
||||
@@ -1562,7 +1562,7 @@ export default {
|
||||
},
|
||||
code: {
|
||||
label: "Einmaliger Code",
|
||||
placeholder: "gets-sets-flys",
|
||||
placeholder: "123456",
|
||||
helper_text: "Verifizierungscode wurde an deine neue E-Mail gesendet.",
|
||||
errors: {
|
||||
required: "Einmaliger Code ist erforderlich",
|
||||
|
||||
@@ -75,7 +75,7 @@ export default {
|
||||
},
|
||||
unique_code: {
|
||||
label: "Unique code",
|
||||
placeholder: "gets-sets-flys",
|
||||
placeholder: "123456",
|
||||
paste_code: "Paste the code sent to your email",
|
||||
requesting_new_code: "Requesting new code",
|
||||
sending_code: "Sending code",
|
||||
|
||||
@@ -24,6 +24,17 @@ export default {
|
||||
},
|
||||
},
|
||||
project_empty_state: {
|
||||
no_access: {
|
||||
title: "Seems like you don’t have access to this Project",
|
||||
restricted_description: "Contact admin to request for access and you can continue here.",
|
||||
join_description: "Click the button below to join it.",
|
||||
cta_primary: "Join project",
|
||||
cta_loading: "Joining project",
|
||||
},
|
||||
invalid_project: {
|
||||
title: "Project not found",
|
||||
description: "The project you are looking for does not exist.",
|
||||
},
|
||||
work_items: {
|
||||
title: "Start with your first work item.",
|
||||
description:
|
||||
|
||||
@@ -1378,7 +1378,7 @@ export default {
|
||||
},
|
||||
code: {
|
||||
label: "Unique code",
|
||||
placeholder: "gets-sets-flys",
|
||||
placeholder: "123456",
|
||||
helper_text: "Verification code sent to your new email.",
|
||||
errors: {
|
||||
required: "Unique code is required",
|
||||
|
||||
@@ -26,6 +26,17 @@ export default {
|
||||
},
|
||||
},
|
||||
project_empty_state: {
|
||||
no_access: {
|
||||
title: "Parece que no tienes acceso a este proyecto",
|
||||
restricted_description: "Contacta con el administrador para solicitar acceso y podrás continuar aquí.",
|
||||
join_description: "Haz clic en el botón de abajo para unirte.",
|
||||
cta_primary: "Unirse al proyecto",
|
||||
cta_loading: "Uniéndose al proyecto",
|
||||
},
|
||||
invalid_project: {
|
||||
title: "Proyecto no encontrado",
|
||||
description: "El proyecto que buscas no existe.",
|
||||
},
|
||||
work_items: {
|
||||
title: "Comienza con tu primer elemento de trabajo.",
|
||||
description:
|
||||
|
||||
@@ -1566,7 +1566,7 @@ export default {
|
||||
},
|
||||
code: {
|
||||
label: "Código único",
|
||||
placeholder: "gets-sets-flys",
|
||||
placeholder: "123456",
|
||||
helper_text: "Código de verificación enviado a tu nuevo correo electrónico.",
|
||||
errors: {
|
||||
required: "El código único es obligatorio",
|
||||
|
||||
@@ -27,6 +27,17 @@ export default {
|
||||
},
|
||||
},
|
||||
project_empty_state: {
|
||||
no_access: {
|
||||
title: "Il semble que vous n’ayez pas accès à ce projet",
|
||||
restricted_description: "Contactez l’administrateur pour demander l’accès afin de pouvoir continuer ici.",
|
||||
join_description: "Cliquez sur le bouton ci-dessous pour rejoindre le projet.",
|
||||
cta_primary: "Rejoindre le projet",
|
||||
cta_loading: "Rejoindre le projet…",
|
||||
},
|
||||
invalid_project: {
|
||||
title: "Projet non trouvé",
|
||||
description: "Le projet que vous recherchez n’existe pas.",
|
||||
},
|
||||
work_items: {
|
||||
title: "Commencez avec votre premier élément de travail.",
|
||||
description:
|
||||
|
||||
@@ -74,7 +74,7 @@ export default {
|
||||
},
|
||||
unique_code: {
|
||||
label: "Code unique",
|
||||
placeholder: "gets-sets-flys",
|
||||
placeholder: "123456",
|
||||
paste_code: "Collez le code envoyé à votre e-mail",
|
||||
requesting_new_code: "Demande d’un nouveau code",
|
||||
sending_code: "Envoi du code",
|
||||
@@ -1564,7 +1564,7 @@ export default {
|
||||
},
|
||||
code: {
|
||||
label: "Code unique",
|
||||
placeholder: "gets-sets-flys",
|
||||
placeholder: "123456",
|
||||
helper_text: "Code de vérification envoyé à votre nouvel e-mail.",
|
||||
errors: {
|
||||
required: "Le code unique est requis",
|
||||
|
||||
@@ -25,6 +25,17 @@ export default {
|
||||
},
|
||||
},
|
||||
project_empty_state: {
|
||||
no_access: {
|
||||
title: "Sepertinya Anda tidak memiliki akses ke Proyek ini",
|
||||
restricted_description: "Hubungi admin untuk meminta akses agar Anda dapat melanjutkan di sini.",
|
||||
join_description: "Klik tombol di bawah ini untuk bergabung.",
|
||||
cta_primary: "Bergabung dengan proyek",
|
||||
cta_loading: "Sedang bergabung dengan proyek",
|
||||
},
|
||||
invalid_project: {
|
||||
title: "Proyek tidak ditemukan",
|
||||
description: "Proyek yang Anda cari tidak ada.",
|
||||
},
|
||||
work_items: {
|
||||
title: "Mulai dengan item kerja pertama Anda.",
|
||||
description:
|
||||
|
||||
@@ -74,7 +74,7 @@ export default {
|
||||
},
|
||||
unique_code: {
|
||||
label: "Kode unik",
|
||||
placeholder: "gets-sets-flys",
|
||||
placeholder: "123456",
|
||||
paste_code: "Tempelkan kode yang dikirim ke email anda",
|
||||
requesting_new_code: "Meminta kode baru",
|
||||
sending_code: "Mengirim kode",
|
||||
@@ -1552,7 +1552,7 @@ export default {
|
||||
},
|
||||
code: {
|
||||
label: "Kode unik",
|
||||
placeholder: "gets-sets-flys",
|
||||
placeholder: "123456",
|
||||
helper_text: "Kode verifikasi dikirim ke email baru Anda.",
|
||||
errors: {
|
||||
required: "Kode unik wajib diisi",
|
||||
|
||||
@@ -27,6 +27,17 @@ export default {
|
||||
},
|
||||
},
|
||||
project_empty_state: {
|
||||
no_access: {
|
||||
title: "Sembra che tu non abbia accesso a questo progetto",
|
||||
restricted_description: "Contatta l'amministratore per richiedere l'accesso e potrai continuare qui.",
|
||||
join_description: "Clicca sul pulsante qui sotto per unirti.",
|
||||
cta_primary: "Unisciti al progetto",
|
||||
cta_loading: "Unione al progetto in corso",
|
||||
},
|
||||
invalid_project: {
|
||||
title: "Progetto non trovato",
|
||||
description: "Il progetto che stai cercando non esiste.",
|
||||
},
|
||||
work_items: {
|
||||
title: "Inizia con il tuo primo elemento di lavoro.",
|
||||
description:
|
||||
|
||||
@@ -74,7 +74,7 @@ export default {
|
||||
},
|
||||
unique_code: {
|
||||
label: "Codice unico",
|
||||
placeholder: "gets-sets-flys",
|
||||
placeholder: "123456",
|
||||
paste_code: "Incolla il codice inviato alla tua email",
|
||||
requesting_new_code: "Richiesta di nuovo codice",
|
||||
sending_code: "Invio codice",
|
||||
@@ -1556,7 +1556,7 @@ export default {
|
||||
},
|
||||
code: {
|
||||
label: "Codice univoco",
|
||||
placeholder: "gets-sets-flys",
|
||||
placeholder: "123456",
|
||||
helper_text: "Codice di verifica inviato alla tua nuova email.",
|
||||
errors: {
|
||||
required: "Il codice univoco è obbligatorio",
|
||||
|
||||
@@ -24,6 +24,17 @@ export default {
|
||||
},
|
||||
},
|
||||
project_empty_state: {
|
||||
no_access: {
|
||||
title: "このプロジェクトへのアクセス権がないようです",
|
||||
restricted_description: "管理者に連絡してアクセス権をリクエストすると、ここで作業を続けられます。",
|
||||
join_description: "下のボタンをクリックして参加してください。",
|
||||
cta_primary: "プロジェクトに参加",
|
||||
cta_loading: "プロジェクトに参加中",
|
||||
},
|
||||
invalid_project: {
|
||||
title: "プロジェクトが見つかりません",
|
||||
description: "お探しのプロジェクトは存在しません。",
|
||||
},
|
||||
work_items: {
|
||||
title: "最初の作業項目から始めましょう。",
|
||||
description:
|
||||
|
||||
@@ -74,7 +74,7 @@ export default {
|
||||
},
|
||||
unique_code: {
|
||||
label: "ユニークコード",
|
||||
placeholder: "gets-sets-flys",
|
||||
placeholder: "123456",
|
||||
paste_code: "メールで送信されたコードを貼り付けてください",
|
||||
requesting_new_code: "新しいコードをリクエスト中",
|
||||
sending_code: "コードを送信中",
|
||||
@@ -1543,7 +1543,7 @@ export default {
|
||||
},
|
||||
code: {
|
||||
label: "認証コード",
|
||||
placeholder: "gets-sets-flys",
|
||||
placeholder: "123456",
|
||||
helper_text: "認証コードを新しいメールに送信しました。",
|
||||
errors: {
|
||||
required: "認証コードは必須です",
|
||||
|
||||
@@ -24,6 +24,17 @@ export default {
|
||||
},
|
||||
},
|
||||
project_empty_state: {
|
||||
no_access: {
|
||||
title: "이 프로젝트에 접근할 수 없는 것 같습니다",
|
||||
restricted_description: "관리자에게 접근 권한을 요청하시면 여기서 계속 진행하실 수 있습니다.",
|
||||
join_description: "아래 버튼을 클릭하여 프로젝트에 참여하세요.",
|
||||
cta_primary: "프로젝트 참여",
|
||||
cta_loading: "프로젝트 참여 중",
|
||||
},
|
||||
invalid_project: {
|
||||
title: "프로젝트를 찾을 수 없습니다",
|
||||
description: "찾으시는 프로젝트가 존재하지 않습니다.",
|
||||
},
|
||||
work_items: {
|
||||
title: "첫 번째 작업 항목으로 시작하세요.",
|
||||
description:
|
||||
|
||||
@@ -74,7 +74,7 @@ export default {
|
||||
},
|
||||
unique_code: {
|
||||
label: "고유 코드",
|
||||
placeholder: "gets-sets-flys",
|
||||
placeholder: "123456",
|
||||
paste_code: "이메일로 전송된 코드를 붙여넣기",
|
||||
requesting_new_code: "새 코드 요청 중",
|
||||
sending_code: "코드 전송 중",
|
||||
@@ -1536,7 +1536,7 @@ export default {
|
||||
},
|
||||
code: {
|
||||
label: "고유 코드",
|
||||
placeholder: "gets-sets-flys",
|
||||
placeholder: "123456",
|
||||
helper_text: "인증 코드가 새 이메일로 전송되었습니다.",
|
||||
errors: {
|
||||
required: "고유 코드는 필수입니다",
|
||||
|
||||
@@ -24,6 +24,17 @@ export default {
|
||||
},
|
||||
},
|
||||
project_empty_state: {
|
||||
no_access: {
|
||||
title: "Wygląda na to, że nie masz dostępu do tego projektu",
|
||||
restricted_description: "Skontaktuj się z administratorem, aby poprosić o dostęp i móc kontynuować tutaj.",
|
||||
join_description: "Kliknij przycisk poniżej, aby dołączyć.",
|
||||
cta_primary: "Dołącz do projektu",
|
||||
cta_loading: "Dołączanie do projektu",
|
||||
},
|
||||
invalid_project: {
|
||||
title: "Projekt nie został znaleziony",
|
||||
description: "Projekt, którego szukasz, nie istnieje.",
|
||||
},
|
||||
work_items: {
|
||||
title: "Zacznij od swojego pierwszego elementu roboczego.",
|
||||
description:
|
||||
|
||||
@@ -74,7 +74,7 @@ export default {
|
||||
},
|
||||
unique_code: {
|
||||
label: "Unikalny kod",
|
||||
placeholder: "gets-sets-flys",
|
||||
placeholder: "123456",
|
||||
paste_code: "Wklej kod wysłany na Twój e-mail",
|
||||
requesting_new_code: "Żądanie nowego kodu",
|
||||
sending_code: "Wysyłanie kodu",
|
||||
@@ -1547,7 +1547,7 @@ export default {
|
||||
},
|
||||
code: {
|
||||
label: "Unikalny kod",
|
||||
placeholder: "gets-sets-flys",
|
||||
placeholder: "123456",
|
||||
helper_text: "Kod weryfikacyjny wysłano na nowy e-mail.",
|
||||
errors: {
|
||||
required: "Unikalny kod jest wymagany",
|
||||
|
||||
@@ -26,6 +26,18 @@ export default {
|
||||
},
|
||||
},
|
||||
project_empty_state: {
|
||||
no_access: {
|
||||
title: "Parece que você não tem acesso a este projeto",
|
||||
restricted_description:
|
||||
"Entre em contato com o administrador para solicitar acesso e você poderá continuar aqui.",
|
||||
join_description: "Clique no botão abaixo para participar.",
|
||||
cta_primary: "Participar do projeto",
|
||||
cta_loading: "Participando do projeto",
|
||||
},
|
||||
invalid_project: {
|
||||
title: "Projeto não encontrado",
|
||||
description: "O projeto que você está procurando não existe.",
|
||||
},
|
||||
work_items: {
|
||||
title: "Comece com seu primeiro item de trabalho.",
|
||||
description:
|
||||
|
||||
@@ -74,7 +74,7 @@ export default {
|
||||
},
|
||||
unique_code: {
|
||||
label: "Código único",
|
||||
placeholder: "gets-sets-flys",
|
||||
placeholder: "123456",
|
||||
paste_code: "Cole o código enviado para seu email",
|
||||
requesting_new_code: "Solicitando novo código",
|
||||
sending_code: "Enviando código",
|
||||
@@ -1564,7 +1564,7 @@ export default {
|
||||
},
|
||||
code: {
|
||||
label: "Código único",
|
||||
placeholder: "gets-sets-flys",
|
||||
placeholder: "123456",
|
||||
helper_text: "Código de verificação enviado para o novo e-mail.",
|
||||
errors: {
|
||||
required: "O código único é obrigatório",
|
||||
|
||||
@@ -25,6 +25,17 @@ export default {
|
||||
},
|
||||
},
|
||||
project_empty_state: {
|
||||
no_access: {
|
||||
title: "Se pare că nu aveți acces la acest proiect",
|
||||
restricted_description: "Contactați administratorul pentru a solicita accesul și veți putea continua aici.",
|
||||
join_description: "Faceți clic pe butonul de mai jos pentru a vă alătura.",
|
||||
cta_primary: "Alăturați-vă proiectului",
|
||||
cta_loading: "Se alătură proiectului",
|
||||
},
|
||||
invalid_project: {
|
||||
title: "Proiect negăsit",
|
||||
description: "Proiectul pe care îl căutați nu există.",
|
||||
},
|
||||
work_items: {
|
||||
title: "Începeți cu primul dvs. element de lucru.",
|
||||
description:
|
||||
|
||||
@@ -1556,7 +1556,7 @@ export default {
|
||||
},
|
||||
code: {
|
||||
label: "Cod unic",
|
||||
placeholder: "gets-sets-flys",
|
||||
placeholder: "123456",
|
||||
helper_text: "Codul de verificare a fost trimis la noul e-mail.",
|
||||
errors: {
|
||||
required: "Codul unic este obligatoriu",
|
||||
|
||||
@@ -25,6 +25,17 @@ export default {
|
||||
},
|
||||
},
|
||||
project_empty_state: {
|
||||
no_access: {
|
||||
title: "Похоже, у вас нет доступа к этому проекту",
|
||||
restricted_description: "Свяжитесь с администратором, чтобы запросить доступ, и вы сможете продолжить здесь.",
|
||||
join_description: "Нажмите кнопку ниже, чтобы присоединиться.",
|
||||
cta_primary: "Присоединиться к проекту",
|
||||
cta_loading: "Присоединение к проекту",
|
||||
},
|
||||
invalid_project: {
|
||||
title: "Проект не найден",
|
||||
description: "Проект, который вы ищете, не существует.",
|
||||
},
|
||||
work_items: {
|
||||
title: "Начните с вашего первого рабочего элемента.",
|
||||
description:
|
||||
|
||||
@@ -74,7 +74,7 @@ export default {
|
||||
},
|
||||
unique_code: {
|
||||
label: "Уникальный код",
|
||||
placeholder: "gets-sets-flys",
|
||||
placeholder: "123456",
|
||||
paste_code: "Вставьте код, отправленный на ваш email",
|
||||
requesting_new_code: "Запрос нового кода",
|
||||
sending_code: "Отправка кода",
|
||||
@@ -1549,7 +1549,7 @@ export default {
|
||||
},
|
||||
code: {
|
||||
label: "Уникальный код",
|
||||
placeholder: "gets-sets-flys",
|
||||
placeholder: "123456",
|
||||
helper_text: "Код подтверждения отправлен на ваш новый email.",
|
||||
errors: {
|
||||
required: "Уникальный код обязателен",
|
||||
|
||||
@@ -24,6 +24,17 @@ export default {
|
||||
},
|
||||
},
|
||||
project_empty_state: {
|
||||
no_access: {
|
||||
title: "Zdá sa, že nemáte prístup k tomuto projektu",
|
||||
restricted_description: "Kontaktujte administrátora, aby ste požiadali o prístup, a potom tu môžete pokračovať.",
|
||||
join_description: "Kliknite na tlačidlo nižšie, aby ste sa pripojili.",
|
||||
cta_primary: "Pripojiť sa k projektu",
|
||||
cta_loading: "Pripájanie k projektu",
|
||||
},
|
||||
invalid_project: {
|
||||
title: "Projekt nebol nájdený",
|
||||
description: "Projekt, ktorý hľadáte, neexistuje.",
|
||||
},
|
||||
work_items: {
|
||||
title: "Začnite s vašou prvou pracovnou položkou.",
|
||||
description:
|
||||
|
||||
@@ -74,7 +74,7 @@ export default {
|
||||
},
|
||||
unique_code: {
|
||||
label: "Jedinečný kód",
|
||||
placeholder: "gets-sets-flys",
|
||||
placeholder: "123456",
|
||||
paste_code: "Vložte kód zaslaný na váš e-mail",
|
||||
requesting_new_code: "Žiadam o nový kód",
|
||||
sending_code: "Odosielam kód",
|
||||
@@ -1547,7 +1547,7 @@ export default {
|
||||
},
|
||||
code: {
|
||||
label: "Jedinečný kód",
|
||||
placeholder: "gets-sets-flys",
|
||||
placeholder: "123456",
|
||||
helper_text: "Overovací kód bol odoslaný na váš nový e-mail.",
|
||||
errors: {
|
||||
required: "Jedinečný kód je povinný",
|
||||
|
||||
@@ -24,6 +24,17 @@ export default {
|
||||
},
|
||||
},
|
||||
project_empty_state: {
|
||||
no_access: {
|
||||
title: "Görünüşe göre bu projeye erişiminiz yok",
|
||||
restricted_description: "Erişim talep etmek için yöneticiyle iletişime geçin, sonra burada devam edebilirsiniz.",
|
||||
join_description: "Katılmak için aşağıdaki butona tıklayın.",
|
||||
cta_primary: "Projeye katıl",
|
||||
cta_loading: "Projeye katılınıyor",
|
||||
},
|
||||
invalid_project: {
|
||||
title: "Proje bulunamadı",
|
||||
description: "Aradığınız proje mevcut değil.",
|
||||
},
|
||||
work_items: {
|
||||
title: "İlk iş öğenizle başlayın.",
|
||||
description:
|
||||
|
||||
@@ -1551,7 +1551,7 @@ export default {
|
||||
},
|
||||
code: {
|
||||
label: "Benzersiz kod",
|
||||
placeholder: "gets-sets-flys",
|
||||
placeholder: "123456",
|
||||
helper_text: "Doğrulama kodu yeni e-postanıza gönderildi.",
|
||||
errors: {
|
||||
required: "Benzersiz kod zorunludur",
|
||||
|
||||
@@ -25,6 +25,17 @@ export default {
|
||||
},
|
||||
},
|
||||
project_empty_state: {
|
||||
no_access: {
|
||||
title: "Схоже, у вас немає доступу до цього проєкту",
|
||||
restricted_description: "Зверніться до адміністратора, щоб запросити доступ, і ви зможете продовжити тут.",
|
||||
join_description: "Натисніть кнопку нижче, щоб приєднатися.",
|
||||
cta_primary: "Приєднатися до проєкту",
|
||||
cta_loading: "Приєднання до проєкту",
|
||||
},
|
||||
invalid_project: {
|
||||
title: "Проєкт не знайдено",
|
||||
description: "Проєкт, який ви шукаєте, не існує.",
|
||||
},
|
||||
work_items: {
|
||||
title: "Почніть з вашого першого робочого елемента.",
|
||||
description:
|
||||
|
||||
@@ -74,7 +74,7 @@ export default {
|
||||
},
|
||||
unique_code: {
|
||||
label: "Унікальний код",
|
||||
placeholder: "gets-sets-flys",
|
||||
placeholder: "123456",
|
||||
paste_code: "Вставте код, надісланий на вашу електронну пошту",
|
||||
requesting_new_code: "Запитую новий код",
|
||||
sending_code: "Надсилаю код",
|
||||
@@ -1551,7 +1551,7 @@ export default {
|
||||
},
|
||||
code: {
|
||||
label: "Унікальний код",
|
||||
placeholder: "gets-sets-flys",
|
||||
placeholder: "123456",
|
||||
helper_text: "Код підтвердження надіслано на ваш новий email.",
|
||||
errors: {
|
||||
required: "Унікальний код є обов’язковим",
|
||||
|
||||
@@ -25,6 +25,17 @@ export default {
|
||||
},
|
||||
},
|
||||
project_empty_state: {
|
||||
no_access: {
|
||||
title: "Có vẻ như bạn không có quyền truy cập vào Dự án này",
|
||||
restricted_description: "Liên hệ với quản trị viên để yêu cầu quyền truy cập và bạn có thể tiếp tục tại đây.",
|
||||
join_description: "Nhấn nút bên dưới để tham gia.",
|
||||
cta_primary: "Tham gia dự án",
|
||||
cta_loading: "Đang tham gia dự án",
|
||||
},
|
||||
invalid_project: {
|
||||
title: "Không tìm thấy dự án",
|
||||
description: "Dự án bạn đang tìm kiếm không tồn tại.",
|
||||
},
|
||||
work_items: {
|
||||
title: "Bắt đầu với mục công việc đầu tiên của bạn.",
|
||||
description:
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user