Compare commits

..

8 Commits

Author SHA1 Message Date
NarayanBavisetti def49be2d3 chore: added description sync for work item and pages 2025-12-02 14:15:01 +05:30
Sangeetha 8b0a797906 fix: import error (#8213) 2025-12-02 13:55:54 +05:30
Saurabh Kumar cea6f7530b [SILO-671] feat: add sticky external apis (#8139)
* add sticky external apis

* add created_at sort by to list

* remove select related method from query set
2025-12-01 18:57:54 +05:30
Nikhil a7e2e596bf [WEB-5518]: update magic code token to send code as 6 digit numbers (#8188)
* refactor: simplify token generation in MagicCodeProvider by using a numeric approach

* fix: update placeholder text for unique code input across multiple languages

* refactor: replace token generation with a numeric approach for user email updates

* fix: update placeholder text for unique code input in multiple languages to a numeric format

* refactor: replace random token generation with secrets for enhanced security in user email updates and magic code provider
2025-12-01 18:56:14 +05:30
Anmol Singh Bhatia 980428b204 [WEB-5536] feat: prevent search panels from reopening on programmatic focus restoration (#8207) 2025-12-01 18:37:06 +05:30
Anmol Singh Bhatia f428c3bdaf [WEB-5542] chore: scrollIntoView implementation for sidebar active project (#8208) 2025-12-01 17:43:05 +05:30
Prateek Shourya a05cd88a53 [WEB-5491] refactor: onboarding tour components (#8167) 2025-12-01 17:14:28 +05:30
Sangeetha 60220801ac [WEB-4338] fix: incorrect error code in project retrieve API (#7234)
* fix: project error message and status code

* fix: incorrect member role check

* fix: project error message and status code

* fix: improve project permission checks and error handling in ProjectViewSet

* feat: enhance project settings layout with better loading strategy and fix all flicker

* fix: prevent rendering during project loading in ProjectAuthWrapper

* refactor: adjust layout structure in ProjectDetailSettingsLayout and enhance access restriction logic in ProjectAccessRestriction

* refactor: replace ProjectAccessRestriction component with updated version and enhance error handling

- Deleted the old ProjectAccessRestriction component.
- Introduced a new ProjectAccessRestriction component with improved error handling and user prompts for joining projects.
- Updated translations for new error states in multiple languages.

* fix: enhance error handling in IssueDetailsPage and remove JoinProject component

---------

Co-authored-by: Dheeraj Kumar Ketireddy <dheeru0198@gmail.com>
Co-authored-by: Prateek Shourya <prateekshourya29@gmail.com>
2025-12-01 17:14:01 +05:30
105 changed files with 1290 additions and 711 deletions
+2 -1
View File
@@ -54,4 +54,5 @@ from .asset import (
FileAssetSerializer,
)
from .invite import WorkspaceInviteSerializer
from .member import ProjectMemberSerializer
from .member import ProjectMemberSerializer
from .sticky import StickySerializer
+30
View File
@@ -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
+2
View File
@@ -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,
]
+12
View File
@@ -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)),
]
+3 -1
View File
@@ -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
+109
View File
@@ -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)
+29 -22
View File
@@ -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,
+1 -9
View File
@@ -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'),
),
]
+1
View File
@@ -52,6 +52,7 @@ from .project import (
ProjectIdentifier,
ProjectMember,
ProjectMemberInvite,
ProjectNetwork,
ProjectPublicMember,
)
from .session import Session
+72 -15
View File
@@ -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"""
+55 -3
View File
@@ -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):
+2
View File
@@ -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))
+17
View File
@@ -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")}
@@ -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")}`} />
@@ -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>
</>
);
}
@@ -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);
@@ -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
View File
@@ -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"
),
]),
]),
]),
]),
@@ -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={() => {
@@ -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 -2
View File
@@ -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>
);
});
+3 -4
View File
@@ -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;
};
+1 -1
View File
@@ -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:
+2 -2
View File
@@ -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:
+2 -2
View File
@@ -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",
+1 -1
View File
@@ -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 dont 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:
+1 -1
View File
@@ -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:
+1 -1
View File
@@ -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 nayez pas accès à ce projet",
restricted_description: "Contactez ladministrateur pour demander laccè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 nexiste pas.",
},
work_items: {
title: "Commencez avec votre premier élément de travail.",
description:
+2 -2
View File
@@ -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 dun 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:
+2 -2
View File
@@ -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:
+2 -2
View File
@@ -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:
+2 -2
View File
@@ -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:
+2 -2
View File
@@ -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:
+2 -2
View File
@@ -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:
+1 -1
View File
@@ -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:
+2 -2
View File
@@ -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:
+2 -2
View File
@@ -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:
+2 -2
View File
@@ -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