Compare commits
42
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4abf722ddd | ||
|
|
ed8fef5cd1 | ||
|
|
bf8935bf51 | ||
|
|
c2e6881b4c | ||
|
|
7045a1f2af | ||
|
|
f26b4d3d06 | ||
|
|
c3c1aef7a9 | ||
|
|
24e57009af | ||
|
|
2b7a17b484 | ||
|
|
64fd0b2830 | ||
|
|
8988cf9a85 | ||
|
|
eb5ffebcc6 | ||
|
|
414010688d | ||
|
|
171099667e | ||
|
|
d65f0e264e | ||
|
|
c7d17d00b7 | ||
|
|
9cdfb2224a | ||
|
|
8129f5f969 | ||
|
|
4a7ecfe051 | ||
|
|
c5e5b99ee7 | ||
|
|
5184ce608b | ||
|
|
f0ddcd7f05 | ||
|
|
54a83ef5a1 | ||
|
|
7d4ec00f91 | ||
|
|
085fc16402 | ||
|
|
bae525eb29 | ||
|
|
607ad3d5ba | ||
|
|
ee50529f55 | ||
|
|
7b1df8ffdd | ||
|
|
c8c7d4384d | ||
|
|
e13c5619d5 | ||
|
|
78edbc8dd6 | ||
|
|
1968242c0d | ||
|
|
83a6ba83b7 | ||
|
|
f02e67a200 | ||
|
|
0741a00ed0 | ||
|
|
a6f8d140ee | ||
|
|
3d12305c6e | ||
|
|
da11073894 | ||
|
|
99ab3386b5 | ||
|
|
779a9c0e47 | ||
|
|
de2cb6baab |
@@ -117,6 +117,44 @@ jobs:
|
||||
name: Checkout Files
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Get changed files
|
||||
id: changed_files
|
||||
uses: tj-actions/changed-files@v42
|
||||
with:
|
||||
files_yaml: |
|
||||
apiserver:
|
||||
- apiserver/**
|
||||
proxy:
|
||||
- caddy/**
|
||||
admin:
|
||||
- admin/**
|
||||
- packages/**
|
||||
- "package.json"
|
||||
- "yarn.lock"
|
||||
- "tsconfig.json"
|
||||
- "turbo.json"
|
||||
space:
|
||||
- space/**
|
||||
- packages/**
|
||||
- "package.json"
|
||||
- "yarn.lock"
|
||||
- "tsconfig.json"
|
||||
- "turbo.json"
|
||||
web:
|
||||
- web/**
|
||||
- packages/**
|
||||
- "package.json"
|
||||
- "yarn.lock"
|
||||
- "tsconfig.json"
|
||||
- "turbo.json"
|
||||
live:
|
||||
- live/**
|
||||
- packages/**
|
||||
- 'package.json'
|
||||
- 'yarn.lock'
|
||||
- 'tsconfig.json'
|
||||
- 'turbo.json'
|
||||
|
||||
branch_build_push_admin:
|
||||
name: Build-Push Admin Docker Image
|
||||
runs-on: ubuntu-22.04
|
||||
@@ -242,8 +280,8 @@ jobs:
|
||||
dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
docker-image-owner: makeplane
|
||||
docker-image-name: ${{ needs.branch_build_setup.outputs.dh_img_proxy }}
|
||||
build-context: ./nginx
|
||||
dockerfile-path: ./nginx/Dockerfile
|
||||
build-context: ./caddy
|
||||
dockerfile-path: ./caddy/Dockerfile
|
||||
buildx-driver: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }}
|
||||
buildx-version: ${{ needs.branch_build_setup.outputs.gh_buildx_version }}
|
||||
buildx-platforms: ${{ needs.branch_build_setup.outputs.gh_buildx_platforms }}
|
||||
|
||||
@@ -67,9 +67,8 @@ export const InstanceHeader: FC = observer(() => {
|
||||
{breadcrumbItems.length >= 0 && (
|
||||
<div>
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="text"
|
||||
link={
|
||||
<Breadcrumbs.Item
|
||||
component={
|
||||
<BreadcrumbLink
|
||||
href="/general/"
|
||||
label="Settings"
|
||||
@@ -80,10 +79,9 @@ export const InstanceHeader: FC = observer(() => {
|
||||
{breadcrumbItems.map(
|
||||
(item) =>
|
||||
item.title && (
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
<Breadcrumbs.Item
|
||||
key={item.title}
|
||||
type="text"
|
||||
link={<BreadcrumbLink href={item.href} label={item.title} />}
|
||||
component={<BreadcrumbLink href={item.href} label={item.title} />}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
|
||||
+8
-2
@@ -11,7 +11,7 @@ WORKDIR /app
|
||||
RUN yarn global add turbo
|
||||
COPY . .
|
||||
|
||||
RUN turbo prune --scope=web --scope=space --scope=admin --docker
|
||||
RUN turbo prune --scope=web --scope=space --scope=admin --scope=live --docker
|
||||
|
||||
# *****************************************************************************
|
||||
# STAGE 2: Install dependencies & build the project
|
||||
@@ -53,7 +53,7 @@ ENV NEXT_PUBLIC_WEB_BASE_URL=$NEXT_PUBLIC_WEB_BASE_URL
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ENV TURBO_TELEMETRY_DISABLED=1
|
||||
|
||||
RUN yarn turbo run build --filter=web --filter=space --filter=admin
|
||||
RUN yarn turbo run build --filter=web --filter=space --filter=admin --filter=live
|
||||
|
||||
# *****************************************************************************
|
||||
# STAGE 3: Copy the project and start it
|
||||
@@ -87,6 +87,8 @@ RUN chmod +x ./api/bin/*
|
||||
RUN chmod -R 777 ./api/
|
||||
|
||||
# NEXTJS BUILDS
|
||||
COPY --from=installer /app/node_modules ./node_modules/
|
||||
|
||||
COPY --from=installer /app/web/next.config.js ./web/
|
||||
COPY --from=installer /app/web/package.json ./web/
|
||||
COPY --from=installer /app/web/.next/standalone ./web
|
||||
@@ -105,6 +107,10 @@ COPY --from=installer /app/admin/.next/standalone ./admin
|
||||
COPY --from=installer /app/admin/.next/static ./admin/admin/.next/static
|
||||
COPY --from=installer /app/admin/public ./admin/admin/public
|
||||
|
||||
COPY --from=installer /app/live/package.json ./live/
|
||||
COPY --from=installer /app/live/dist ./live/dist
|
||||
# COPY --from=installer /app/live/node_modules ./live/node_modules
|
||||
|
||||
ARG NEXT_PUBLIC_API_BASE_URL=""
|
||||
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
|
||||
|
||||
|
||||
@@ -45,6 +45,14 @@ http {
|
||||
proxy_pass http://localhost:3003/god-mode/;
|
||||
}
|
||||
|
||||
location /live/ {
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade ${dollar}http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host ${dollar}http_host;
|
||||
proxy_pass http://localhost:3004/;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade ${dollar}http_upgrade;
|
||||
|
||||
@@ -29,6 +29,16 @@ stderr_logfile=/dev/stdout
|
||||
stderr_logfile_maxbytes=0
|
||||
environment=PORT=3003,HOSTNAME=0.0.0.0
|
||||
|
||||
[program:live]
|
||||
command=node /app/live/dist/server.js
|
||||
autostart=true
|
||||
autorestart=true
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stdout
|
||||
stderr_logfile_maxbytes=0
|
||||
environment=PORT=3004,HOSTNAME=0.0.0.0,API_BASE_URL="http://localhost:8000"
|
||||
|
||||
[program:migrator]
|
||||
directory=/app/api
|
||||
command=sh -c "./bin/docker-entrypoint-migrator.sh"
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
# Third party imports
|
||||
from rest_framework import serializers
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
|
||||
# Module imports
|
||||
from .base import BaseSerializer, DynamicBaseSerializer
|
||||
@@ -198,6 +196,7 @@ class WorkspaceUserLinkSerializer(BaseSerializer):
|
||||
|
||||
class IssueRecentVisitSerializer(serializers.ModelSerializer):
|
||||
project_identifier = serializers.SerializerMethodField()
|
||||
assignees = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Issue
|
||||
@@ -215,9 +214,15 @@ class IssueRecentVisitSerializer(serializers.ModelSerializer):
|
||||
|
||||
def get_project_identifier(self, obj):
|
||||
project = obj.project
|
||||
|
||||
return project.identifier if project else None
|
||||
|
||||
def get_assignees(self, obj):
|
||||
return list(
|
||||
obj.assignees.filter(issue_assignee__deleted_at__isnull=True).values_list(
|
||||
"id", flat=True
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class ProjectRecentVisitSerializer(serializers.ModelSerializer):
|
||||
project_members = serializers.SerializerMethodField()
|
||||
|
||||
@@ -4,14 +4,14 @@ from plane.app.views import ApiTokenEndpoint, ServiceApiTokenEndpoint
|
||||
urlpatterns = [
|
||||
# API Tokens
|
||||
path(
|
||||
"workspaces/<str:slug>/api-tokens/",
|
||||
"users/api-tokens/",
|
||||
ApiTokenEndpoint.as_view(),
|
||||
name="api-tokens",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/api-tokens/<uuid:pk>/",
|
||||
"users/api-tokens/<uuid:pk>/",
|
||||
ApiTokenEndpoint.as_view(),
|
||||
name="api-tokens",
|
||||
name="api-tokens-details",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/service-api-tokens/",
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
# Python import
|
||||
from uuid import uuid4
|
||||
from typing import Optional
|
||||
|
||||
# Third party
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.request import Request
|
||||
from rest_framework import status
|
||||
|
||||
# Module import
|
||||
@@ -13,12 +15,9 @@ from plane.app.permissions import WorkspaceEntityPermission
|
||||
|
||||
|
||||
class ApiTokenEndpoint(BaseAPIView):
|
||||
permission_classes = [WorkspaceEntityPermission]
|
||||
|
||||
def post(self, request, slug):
|
||||
def post(self, request: Request) -> Response:
|
||||
label = request.data.get("label", str(uuid4().hex))
|
||||
description = request.data.get("description", "")
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
expired_at = request.data.get("expired_at", None)
|
||||
|
||||
# Check the user type
|
||||
@@ -28,7 +27,6 @@ class ApiTokenEndpoint(BaseAPIView):
|
||||
label=label,
|
||||
description=description,
|
||||
user=request.user,
|
||||
workspace=workspace,
|
||||
user_type=user_type,
|
||||
expired_at=expired_at,
|
||||
)
|
||||
@@ -37,29 +35,23 @@ class ApiTokenEndpoint(BaseAPIView):
|
||||
# Token will be only visible while creating
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
def get(self, request, slug, pk=None):
|
||||
def get(self, request: Request, pk: Optional[str] = None) -> Response:
|
||||
if pk is None:
|
||||
api_tokens = APIToken.objects.filter(
|
||||
user=request.user, workspace__slug=slug, is_service=False
|
||||
)
|
||||
api_tokens = APIToken.objects.filter(user=request.user, is_service=False)
|
||||
serializer = APITokenReadSerializer(api_tokens, many=True)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
else:
|
||||
api_tokens = APIToken.objects.get(
|
||||
user=request.user, workspace__slug=slug, pk=pk
|
||||
)
|
||||
api_tokens = APIToken.objects.get(user=request.user, pk=pk)
|
||||
serializer = APITokenReadSerializer(api_tokens)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
def delete(self, request, slug, pk):
|
||||
api_token = APIToken.objects.get(
|
||||
workspace__slug=slug, user=request.user, pk=pk, is_service=False
|
||||
)
|
||||
def delete(self, request: Request, pk: str) -> Response:
|
||||
api_token = APIToken.objects.get(user=request.user, pk=pk, is_service=False)
|
||||
api_token.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
def patch(self, request, slug, pk):
|
||||
api_token = APIToken.objects.get(workspace__slug=slug, user=request.user, pk=pk)
|
||||
def patch(self, request: Request, pk: str) -> Response:
|
||||
api_token = APIToken.objects.get(user=request.user, pk=pk)
|
||||
serializer = APITokenSerializer(api_token, data=request.data, partial=True)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
@@ -70,7 +62,7 @@ class ApiTokenEndpoint(BaseAPIView):
|
||||
class ServiceApiTokenEndpoint(BaseAPIView):
|
||||
permission_classes = [WorkspaceEntityPermission]
|
||||
|
||||
def post(self, request, slug):
|
||||
def post(self, request: Request, slug: str) -> Response:
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
|
||||
api_token = APIToken.objects.filter(
|
||||
|
||||
@@ -944,9 +944,33 @@ class IssueDetailEndpoint(BaseAPIView):
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
||||
def get(self, request, slug, project_id):
|
||||
filters = issue_filters(request.query_params, "GET")
|
||||
|
||||
# check for the project member role, if the role is 5 then check for the guest_view_all_features
|
||||
# if it is true then show all the issues else show only the issues created by the user
|
||||
project_member_subquery = ProjectMember.objects.filter(
|
||||
project_id=OuterRef("project_id"),
|
||||
member=self.request.user,
|
||||
is_active=True,
|
||||
).filter(
|
||||
Q(role__gt=ROLE.GUEST.value)
|
||||
| Q(
|
||||
role=ROLE.GUEST.value, project__guest_view_all_features=True
|
||||
)
|
||||
)
|
||||
|
||||
# Main issue query
|
||||
issue = (
|
||||
Issue.issue_objects.filter(workspace__slug=slug, project_id=project_id)
|
||||
.select_related("workspace", "project", "state", "parent")
|
||||
.filter(
|
||||
Q(Exists(project_member_subquery))
|
||||
| Q(
|
||||
project__project_projectmember__member=self.request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
project__project_projectmember__role=ROLE.GUEST.value,
|
||||
project__guest_view_all_features=False,
|
||||
created_by=self.request.user,
|
||||
)
|
||||
)
|
||||
.prefetch_related("assignees", "labels", "issue_module__module")
|
||||
.annotate(
|
||||
cycle_id=Subquery(
|
||||
@@ -1014,6 +1038,7 @@ class IssueDetailEndpoint(BaseAPIView):
|
||||
.values("count")
|
||||
)
|
||||
)
|
||||
|
||||
issue = issue.filter(**filters)
|
||||
order_by_param = request.GET.get("order_by", "-created_at")
|
||||
# Issue queryset
|
||||
|
||||
@@ -27,7 +27,7 @@ def user_data():
|
||||
"email": "test@plane.so",
|
||||
"password": "test-password",
|
||||
"first_name": "Test",
|
||||
"last_name": "User"
|
||||
"last_name": "User",
|
||||
}
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ def create_user(db, user_data):
|
||||
user = User.objects.create(
|
||||
email=user_data["email"],
|
||||
first_name=user_data["first_name"],
|
||||
last_name=user_data["last_name"]
|
||||
last_name=user_data["last_name"],
|
||||
)
|
||||
user.set_password(user_data["password"])
|
||||
user.save()
|
||||
@@ -69,10 +69,52 @@ def session_client(api_client, create_user):
|
||||
return api_client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def create_bot_user(db):
|
||||
"""Create and return a bot user instance"""
|
||||
from uuid import uuid4
|
||||
|
||||
unique_id = uuid4().hex[:8]
|
||||
user = User.objects.create(
|
||||
email=f"bot-{unique_id}@plane.so",
|
||||
username=f"bot_user_{unique_id}",
|
||||
first_name="Bot",
|
||||
last_name="User",
|
||||
is_bot=True,
|
||||
)
|
||||
user.set_password("bot@123")
|
||||
user.save()
|
||||
return user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def api_token_data():
|
||||
"""Return sample API token data for testing"""
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
|
||||
return {
|
||||
"label": "Test API Token",
|
||||
"description": "Test description for API token",
|
||||
"expired_at": (timezone.now() + timedelta(days=30)).isoformat(),
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def create_api_token_for_user(db, create_user):
|
||||
"""Create and return an API token for a specific user"""
|
||||
return APIToken.objects.create(
|
||||
label="Test Token",
|
||||
description="Test token description",
|
||||
user=create_user,
|
||||
user_type=0,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def plane_server(live_server):
|
||||
"""
|
||||
Renamed version of live_server fixture to avoid name clashes.
|
||||
Returns a live Django server for testing HTTP requests.
|
||||
"""
|
||||
return live_server
|
||||
return live_server
|
||||
|
||||
@@ -0,0 +1,372 @@
|
||||
import pytest
|
||||
from datetime import timedelta
|
||||
from uuid import uuid4
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from rest_framework import status
|
||||
|
||||
from plane.db.models import APIToken, User
|
||||
|
||||
|
||||
@pytest.mark.contract
|
||||
class TestApiTokenEndpoint:
|
||||
"""Test cases for ApiTokenEndpoint"""
|
||||
|
||||
# POST /user/api-tokens/ tests
|
||||
@pytest.mark.django_db
|
||||
def test_create_api_token_success(
|
||||
self, session_client, create_user, api_token_data
|
||||
):
|
||||
"""Test successful API token creation"""
|
||||
# Arrange
|
||||
session_client.force_authenticate(user=create_user)
|
||||
url = reverse("api-tokens")
|
||||
|
||||
# Act
|
||||
response = session_client.post(url, api_token_data, format="json")
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert "token" in response.data
|
||||
assert response.data["label"] == api_token_data["label"]
|
||||
assert response.data["description"] == api_token_data["description"]
|
||||
assert response.data["user_type"] == 0 # Human user
|
||||
|
||||
# Verify token was created in database
|
||||
token = APIToken.objects.get(pk=response.data["id"])
|
||||
assert token.user == create_user
|
||||
assert token.label == api_token_data["label"]
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_api_token_for_bot_user(
|
||||
self, session_client, create_bot_user, api_token_data
|
||||
):
|
||||
"""Test API token creation for bot user"""
|
||||
# Arrange
|
||||
session_client.force_authenticate(user=create_bot_user)
|
||||
url = reverse("api-tokens")
|
||||
|
||||
# Act
|
||||
response = session_client.post(url, api_token_data, format="json")
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert response.data["user_type"] == 1 # Bot user
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_api_token_minimal_data(self, session_client, create_user):
|
||||
"""Test API token creation with minimal data"""
|
||||
# Arrange
|
||||
session_client.force_authenticate(user=create_user)
|
||||
url = reverse("api-tokens")
|
||||
|
||||
# Act
|
||||
response = session_client.post(url, {}, format="json")
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert "token" in response.data
|
||||
assert len(response.data["label"]) == 32 # UUID hex length
|
||||
assert response.data["description"] == ""
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_api_token_with_expiry(self, session_client, create_user):
|
||||
"""Test API token creation with expiry date"""
|
||||
# Arrange
|
||||
session_client.force_authenticate(user=create_user)
|
||||
url = reverse("api-tokens")
|
||||
future_date = timezone.now() + timedelta(days=30)
|
||||
data = {"label": "Expiring Token", "expired_at": future_date.isoformat()}
|
||||
|
||||
# Act
|
||||
response = session_client.post(url, data, format="json")
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
|
||||
# Verify expiry date was set
|
||||
token = APIToken.objects.get(pk=response.data["id"])
|
||||
assert token.expired_at is not None
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_api_token_unauthenticated(self, api_client, api_token_data):
|
||||
"""Test API token creation without authentication"""
|
||||
# Arrange
|
||||
url = reverse("api-tokens")
|
||||
|
||||
# Act
|
||||
response = api_client.post(url, api_token_data, format="json")
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
# GET /user/api-tokens/ tests
|
||||
@pytest.mark.django_db
|
||||
def test_get_all_api_tokens(self, session_client, create_user):
|
||||
"""Test retrieving all API tokens for user"""
|
||||
# Arrange
|
||||
session_client.force_authenticate(user=create_user)
|
||||
|
||||
# Create multiple tokens
|
||||
APIToken.objects.create(label="Token 1", user=create_user, user_type=0)
|
||||
APIToken.objects.create(label="Token 2", user=create_user, user_type=0)
|
||||
# Create a service token (should be excluded)
|
||||
APIToken.objects.create(
|
||||
label="Service Token", user=create_user, user_type=0, is_service=True
|
||||
)
|
||||
url = reverse("api-tokens")
|
||||
|
||||
# Act
|
||||
response = session_client.get(url)
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert len(response.data) == 2 # Only non-service tokens
|
||||
assert all(token["is_service"] is False for token in response.data)
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_empty_api_tokens_list(self, session_client, create_user):
|
||||
"""Test retrieving API tokens when none exist"""
|
||||
# Arrange
|
||||
session_client.force_authenticate(user=create_user)
|
||||
url = reverse("api-tokens")
|
||||
|
||||
# Act
|
||||
response = session_client.get(url)
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data == []
|
||||
|
||||
# GET /user/api-tokens/<pk>/ tests
|
||||
@pytest.mark.django_db
|
||||
def test_get_specific_api_token(
|
||||
self, session_client, create_user, create_api_token_for_user
|
||||
):
|
||||
"""Test retrieving a specific API token"""
|
||||
# Arrange
|
||||
session_client.force_authenticate(user=create_user)
|
||||
url = reverse("api-tokens", kwargs={"pk": create_api_token_for_user.pk})
|
||||
|
||||
# Act
|
||||
response = session_client.get(url)
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert str(response.data["id"]) == str(create_api_token_for_user.pk)
|
||||
assert response.data["label"] == create_api_token_for_user.label
|
||||
assert (
|
||||
"token" not in response.data
|
||||
) # Token should not be visible in read serializer
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_nonexistent_api_token(self, session_client, create_user):
|
||||
"""Test retrieving a non-existent API token"""
|
||||
# Arrange
|
||||
session_client.force_authenticate(user=create_user)
|
||||
fake_pk = uuid4()
|
||||
url = reverse("api-tokens", kwargs={"pk": fake_pk})
|
||||
|
||||
# Act
|
||||
response = session_client.get(url)
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_other_users_api_token(self, session_client, create_user, db):
|
||||
"""Test retrieving another user's API token (should fail)"""
|
||||
# Arrange
|
||||
# Create another user and their token with unique email and username
|
||||
unique_id = uuid4().hex[:8]
|
||||
unique_email = f"other-{unique_id}@plane.so"
|
||||
unique_username = f"other_user_{unique_id}"
|
||||
other_user = User.objects.create(email=unique_email, username=unique_username)
|
||||
other_token = APIToken.objects.create(
|
||||
label="Other Token", user=other_user, user_type=0
|
||||
)
|
||||
session_client.force_authenticate(user=create_user)
|
||||
url = reverse("api-tokens", kwargs={"pk": other_token.pk})
|
||||
|
||||
# Act
|
||||
response = session_client.get(url)
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
# DELETE /user/api-tokens/<pk>/ tests
|
||||
@pytest.mark.django_db
|
||||
def test_delete_api_token_success(
|
||||
self, session_client, create_user, create_api_token_for_user
|
||||
):
|
||||
"""Test successful API token deletion"""
|
||||
# Arrange
|
||||
session_client.force_authenticate(user=create_user)
|
||||
url = reverse("api-tokens", kwargs={"pk": create_api_token_for_user.pk})
|
||||
|
||||
# Act
|
||||
response = session_client.delete(url)
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_204_NO_CONTENT
|
||||
assert not APIToken.objects.filter(pk=create_api_token_for_user.pk).exists()
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_delete_nonexistent_api_token(self, session_client, create_user):
|
||||
"""Test deleting a non-existent API token"""
|
||||
# Arrange
|
||||
session_client.force_authenticate(user=create_user)
|
||||
fake_pk = uuid4()
|
||||
url = reverse("api-tokens", kwargs={"pk": fake_pk})
|
||||
|
||||
# Act
|
||||
response = session_client.delete(url)
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_delete_other_users_api_token(self, session_client, create_user, db):
|
||||
"""Test deleting another user's API token (should fail)"""
|
||||
# Arrange
|
||||
# Create another user and their token with unique email and username
|
||||
unique_id = uuid4().hex[:8]
|
||||
unique_email = f"delete-other-{unique_id}@plane.so"
|
||||
unique_username = f"delete_other_user_{unique_id}"
|
||||
other_user = User.objects.create(email=unique_email, username=unique_username)
|
||||
other_token = APIToken.objects.create(
|
||||
label="Other Token", user=other_user, user_type=0
|
||||
)
|
||||
session_client.force_authenticate(user=create_user)
|
||||
url = reverse("api-tokens", kwargs={"pk": other_token.pk})
|
||||
|
||||
# Act
|
||||
response = session_client.delete(url)
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
# Verify token still exists
|
||||
assert APIToken.objects.filter(pk=other_token.pk).exists()
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_delete_service_api_token_forbidden(self, session_client, create_user):
|
||||
"""Test deleting a service API token (should fail)"""
|
||||
# Arrange
|
||||
service_token = APIToken.objects.create(
|
||||
label="Service Token", user=create_user, user_type=0, is_service=True
|
||||
)
|
||||
session_client.force_authenticate(user=create_user)
|
||||
url = reverse("api-tokens", kwargs={"pk": service_token.pk})
|
||||
|
||||
# Act
|
||||
response = session_client.delete(url)
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
# Verify token still exists
|
||||
assert APIToken.objects.filter(pk=service_token.pk).exists()
|
||||
|
||||
# PATCH /user/api-tokens/<pk>/ tests
|
||||
@pytest.mark.django_db
|
||||
def test_patch_api_token_success(
|
||||
self, session_client, create_user, create_api_token_for_user
|
||||
):
|
||||
"""Test successful API token update"""
|
||||
# Arrange
|
||||
session_client.force_authenticate(user=create_user)
|
||||
url = reverse("api-tokens", kwargs={"pk": create_api_token_for_user.pk})
|
||||
update_data = {
|
||||
"label": "Updated Token Label",
|
||||
"description": "Updated description",
|
||||
}
|
||||
|
||||
# Act
|
||||
response = session_client.patch(url, update_data, format="json")
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data["label"] == update_data["label"]
|
||||
assert response.data["description"] == update_data["description"]
|
||||
|
||||
# Verify database was updated
|
||||
create_api_token_for_user.refresh_from_db()
|
||||
assert create_api_token_for_user.label == update_data["label"]
|
||||
assert create_api_token_for_user.description == update_data["description"]
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_patch_api_token_partial_update(
|
||||
self, session_client, create_user, create_api_token_for_user
|
||||
):
|
||||
"""Test partial API token update"""
|
||||
# Arrange
|
||||
session_client.force_authenticate(user=create_user)
|
||||
url = reverse("api-tokens", kwargs={"pk": create_api_token_for_user.pk})
|
||||
original_description = create_api_token_for_user.description
|
||||
update_data = {"label": "Only Label Updated"}
|
||||
|
||||
# Act
|
||||
response = session_client.patch(url, update_data, format="json")
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data["label"] == update_data["label"]
|
||||
assert response.data["description"] == original_description
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_patch_nonexistent_api_token(self, session_client, create_user):
|
||||
"""Test updating a non-existent API token"""
|
||||
# Arrange
|
||||
session_client.force_authenticate(user=create_user)
|
||||
fake_pk = uuid4()
|
||||
url = reverse("api-tokens", kwargs={"pk": fake_pk})
|
||||
update_data = {"label": "New Label"}
|
||||
|
||||
# Act
|
||||
response = session_client.patch(url, update_data, format="json")
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_patch_other_users_api_token(self, session_client, create_user, db):
|
||||
"""Test updating another user's API token (should fail)"""
|
||||
# Arrange
|
||||
# Create another user and their token with unique email and username
|
||||
unique_id = uuid4().hex[:8]
|
||||
unique_email = f"patch-other-{unique_id}@plane.so"
|
||||
unique_username = f"patch_other_user_{unique_id}"
|
||||
other_user = User.objects.create(email=unique_email, username=unique_username)
|
||||
other_token = APIToken.objects.create(
|
||||
label="Other Token", user=other_user, user_type=0
|
||||
)
|
||||
session_client.force_authenticate(user=create_user)
|
||||
url = reverse("api-tokens", kwargs={"pk": other_token.pk})
|
||||
update_data = {"label": "Hacked Label"}
|
||||
|
||||
# Act
|
||||
response = session_client.patch(url, update_data, format="json")
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
# Verify token was not updated
|
||||
other_token.refresh_from_db()
|
||||
assert other_token.label == "Other Token"
|
||||
|
||||
# Authentication tests
|
||||
@pytest.mark.django_db
|
||||
def test_all_endpoints_require_authentication(self, api_client):
|
||||
"""Test that all endpoints require authentication"""
|
||||
# Arrange
|
||||
endpoints = [
|
||||
(reverse("api-tokens"), "get"),
|
||||
(reverse("api-tokens"), "post"),
|
||||
(reverse("api-tokens", kwargs={"pk": uuid4()}), "get"),
|
||||
(reverse("api-tokens", kwargs={"pk": uuid4()}), "patch"),
|
||||
(reverse("api-tokens", kwargs={"pk": uuid4()}), "delete"),
|
||||
]
|
||||
|
||||
# Act & Assert
|
||||
for url, method in endpoints:
|
||||
response = getattr(api_client, method)(url)
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
@@ -0,0 +1,75 @@
|
||||
import pytest
|
||||
|
||||
from plane.db.models import (
|
||||
Workspace,
|
||||
Project,
|
||||
Issue,
|
||||
User,
|
||||
IssueAssignee,
|
||||
WorkspaceMember,
|
||||
ProjectMember,
|
||||
)
|
||||
from plane.app.serializers.workspace import IssueRecentVisitSerializer
|
||||
from django.utils import timezone
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestIssueRecentVisitSerializer:
|
||||
"""Test the IssueRecentVisitSerializer"""
|
||||
|
||||
def test_issue_recent_visit_serializer_fields(self, db):
|
||||
"""Test that the serializer includes the correct fields"""
|
||||
|
||||
test_user_1 = User.objects.create(
|
||||
email="test_user_1@example.com", first_name="Test", last_name="User"
|
||||
)
|
||||
|
||||
# To test for deleted issue assignee
|
||||
test_user_2 = User.objects.create(
|
||||
email="test_user_2@example.com",
|
||||
first_name="Other",
|
||||
last_name="User",
|
||||
username="some user name",
|
||||
)
|
||||
|
||||
workspace = Workspace.objects.create(
|
||||
name="Test Workspace", slug="test-workspace", owner=test_user_1
|
||||
)
|
||||
|
||||
WorkspaceMember.objects.create(member=test_user_2, role=15, workspace=workspace)
|
||||
|
||||
project = Project.objects.create(
|
||||
name="Test Project", identifier="test-project", workspace=workspace
|
||||
)
|
||||
ProjectMember.objects.create(project=project, member=test_user_2)
|
||||
|
||||
issue = Issue.objects.create(
|
||||
name="Test Issue",
|
||||
workspace=workspace,
|
||||
project=project,
|
||||
)
|
||||
|
||||
IssueAssignee.objects.create(issue=issue, assignee=test_user_1, project=project)
|
||||
|
||||
# Deleted issue assignee
|
||||
IssueAssignee.objects.create(
|
||||
issue=issue,
|
||||
assignee=test_user_2,
|
||||
project=project,
|
||||
deleted_at=timezone.now(),
|
||||
)
|
||||
|
||||
serialized_data = IssueRecentVisitSerializer(
|
||||
issue,
|
||||
).data
|
||||
|
||||
# Check fields are present and correct
|
||||
assert "name" in serialized_data
|
||||
assert "assignees" in serialized_data
|
||||
assert "project_identifier" in serialized_data
|
||||
|
||||
assert serialized_data["name"] == "Test Issue"
|
||||
assert serialized_data["project_identifier"] == "TEST-PROJECT"
|
||||
|
||||
# Only including non-deleted issue assignees
|
||||
assert serialized_data["assignees"] == [test_user_1.id]
|
||||
@@ -0,0 +1,34 @@
|
||||
(plane_proxy) {
|
||||
request_body {
|
||||
max_size {$FILE_SIZE_LIMIT}
|
||||
}
|
||||
|
||||
reverse_proxy /spaces/* space:3000
|
||||
|
||||
reverse_proxy /god-mode/* admin:3000
|
||||
|
||||
reverse_proxy /live/* live:3000
|
||||
|
||||
reverse_proxy /api/* api:8000
|
||||
|
||||
reverse_proxy /auth/* api:8000
|
||||
|
||||
reverse_proxy /{$BUCKET_NAME}/* plane-minio:9000
|
||||
|
||||
reverse_proxy /* web:3000
|
||||
}
|
||||
|
||||
{
|
||||
email {$CERT_EMAIL:admin@example.com}
|
||||
acme_ca {$CERT_ACME_CA}
|
||||
{$CERT_ACME_DNS}
|
||||
servers {
|
||||
max_header_size 5MB
|
||||
client_ip_headers X-Forwarded-For X-Real-IP
|
||||
trusted_proxies static {$TRUSTED_PROXIES:0.0.0.0/0}
|
||||
}
|
||||
}
|
||||
|
||||
{$SITE_ADDRESS} {
|
||||
import plane_proxy
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
FROM makeplane/caddy:latest
|
||||
|
||||
COPY ./Caddyfile.template /etc/caddy/Caddyfile
|
||||
|
||||
COPY ./caddy.sh /docker-entrypoint.sh
|
||||
|
||||
RUN chmod +x /docker-entrypoint.sh
|
||||
|
||||
CMD ["/docker-entrypoint.sh"]
|
||||
@@ -0,0 +1,11 @@
|
||||
#!/bin/sh
|
||||
|
||||
if [ "$APP_DOMAIN" == "localhost" ]; then
|
||||
export SITE_ADDRESS=":${LISTEN_HTTP_PORT}"
|
||||
elif [ "$SSL" == "true" ]; then
|
||||
export SITE_ADDRESS="${APP_DOMAIN}:${LISTEN_HTTPS_PORT}"
|
||||
else
|
||||
export SITE_ADDRESS="http://${APP_DOMAIN}:${LISTEN_HTTP_PORT}"
|
||||
fi
|
||||
|
||||
exec caddy run --config /etc/caddy/Caddyfile
|
||||
@@ -58,7 +58,7 @@ Installing plane is a very easy and minimal step process.
|
||||
### Downloading Latest Release
|
||||
|
||||
```
|
||||
mkdir plane-selfhost
|
||||
mkdir -p plane-selfhost && cd plane-selfhost
|
||||
|
||||
cd plane-selfhost
|
||||
```
|
||||
@@ -144,11 +144,15 @@ Again the `options [1-7]` will be popped up, and this time hit `7` to exit.
|
||||
Before proceeding, we suggest used to review `.env` file and set the values.
|
||||
Below are the most import keys you must refer to. _<span style="color: #fcba03">You can use any text editor to edit this file</span>_.
|
||||
|
||||
> `NGINX_PORT` - This is default set to `80`. Make sure the port you choose to use is not preoccupied. (e.g `NGINX_PORT=8080`)
|
||||
|
||||
> `WEB_URL` - This is default set to `http://localhost`. Change this to the FQDN you plan to use along with NGINX_PORT (eg. `https://plane.example.com:8080` or `http://[IP-ADDRESS]:8080`)
|
||||
|
||||
> `CORS_ALLOWED_ORIGINS` - This is default set to `http://localhost`. Change this to the FQDN you plan to use along with NGINX_PORT (eg. `https://plane.example.com:8080` or `http://[IP-ADDRESS]:8080`)
|
||||
> `APP_DOMAIN` - Set the Fully Qualified Domain Name here. (eg. `plane.example.com`)
|
||||
>
|
||||
> `LISTEN_PORT` - This is default set to `80`. Make sure the port you choose to use is not preoccupied. (e.g `LISTEN_PORT=8080`)
|
||||
>
|
||||
> `LISTEN_SSL_PORT` - This is default set to `443`. Make sure the port you choose to use is not preoccupied. (e.g `LISTEN_SSL_PORT=8443`)
|
||||
>
|
||||
> `WEB_URL` - This is default set to `http://localhost`. Change this to the FQDN you plan to use along with LISTEN_PORT/LISTEN_SSL_PORT (eg. `https://plane.example.com:8443` or `http://[IP-ADDRESS]:8080`)
|
||||
>
|
||||
> `CORS_ALLOWED_ORIGINS` - This is default set to `http://${APP_DOMAIN},https://${APP_DOMAIN}`. Change this to the FQDN you plan to use along with LISTEN_PORT and LISTEN_SSL_PORT (eg. `http://plane.example.com:8080,https://plane.example.com:8443`)
|
||||
|
||||
There are many other settings you can play with, but we suggest you configure `EMAIL SETTINGS` as it will enable you to invite your teammates onto the platform.
|
||||
|
||||
@@ -172,6 +176,8 @@ Select a Action you want to perform:
|
||||
Action [2]: 2
|
||||
```
|
||||
|
||||
> You can also choose to run `./setup.sh start` as direct command.
|
||||
|
||||
Expect something like this.
|
||||

|
||||
|
||||
@@ -207,6 +213,8 @@ Select a Action you want to perform:
|
||||
Action [2]: 3
|
||||
```
|
||||
|
||||
> You can also choose to run `./setup.sh stop` as direct command.
|
||||
|
||||
If all goes well, you must see something like this
|
||||
|
||||

|
||||
@@ -253,6 +261,8 @@ Select a Action you want to perform:
|
||||
Action [2]: 4
|
||||
```
|
||||
|
||||
> You can also choose to run `./setup.sh restart` as direct command.
|
||||
|
||||
If all goes well, you must see something like this
|
||||
|
||||

|
||||
@@ -297,6 +307,8 @@ Select a Action you want to perform:
|
||||
Action [2]: 5
|
||||
```
|
||||
|
||||
> You can also choose to run `./setup.sh upgrade` as direct command.
|
||||
|
||||
By choosing this, it will stop the services and then will download the latest `docker-compose.yaml` and `plane.env`.
|
||||
|
||||
You must expect the below message
|
||||
@@ -465,6 +477,8 @@ Select a Action you want to perform:
|
||||
Action [2]: 7
|
||||
```
|
||||
|
||||
> You can also choose to run `./setup.sh backup` as direct command.
|
||||
|
||||
In response, you can find the backup folder
|
||||
|
||||
```bash
|
||||
|
||||
@@ -17,6 +17,12 @@ services:
|
||||
context: ./
|
||||
dockerfile: ./admin/Dockerfile.admin
|
||||
|
||||
live:
|
||||
image: ${DOCKERHUB_USER:-local}/plane-live:${APP_RELEASE:-latest}
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./live/Dockerfile.live
|
||||
|
||||
api:
|
||||
image: ${DOCKERHUB_USER:-local}/plane-backend:${APP_RELEASE:-latest}
|
||||
build:
|
||||
@@ -26,5 +32,5 @@ services:
|
||||
proxy:
|
||||
image: ${DOCKERHUB_USER:-local}/plane-proxy:${APP_RELEASE:-latest}
|
||||
build:
|
||||
context: ./nginx
|
||||
context: ./caddy
|
||||
dockerfile: ./Dockerfile
|
||||
|
||||
@@ -24,9 +24,14 @@ x-aws-s3-env: &aws-s3-env
|
||||
AWS_S3_BUCKET_NAME: ${AWS_S3_BUCKET_NAME:-uploads}
|
||||
|
||||
x-proxy-env: &proxy-env
|
||||
NGINX_PORT: ${NGINX_PORT:-80}
|
||||
BUCKET_NAME: ${AWS_S3_BUCKET_NAME:-uploads}
|
||||
SSL: ${SSL:-false}
|
||||
APP_DOMAIN: ${APP_DOMAIN:-localhost}
|
||||
FILE_SIZE_LIMIT: ${FILE_SIZE_LIMIT:-5242880}
|
||||
CERT_EMAIL: ${CERT_EMAIL:-admin@example.com}
|
||||
CERT_ACME_CA: ${CERT_ACME_CA:-}
|
||||
LISTEN_HTTP_PORT: ${LISTEN_PORT:-80}
|
||||
LISTEN_HTTPS_PORT: ${LISTEN_SSL_PORT:-443}
|
||||
BUCKET_NAME: ${AWS_S3_BUCKET_NAME:-uploads}
|
||||
|
||||
x-mq-env: &mq-env # RabbitMQ Settings
|
||||
RABBITMQ_HOST: ${RABBITMQ_HOST:-plane-mq}
|
||||
@@ -212,22 +217,31 @@ services:
|
||||
|
||||
# Comment this if you already have a reverse proxy running
|
||||
proxy:
|
||||
image: artifacts.plane.so/makeplane/plane-proxy:${APP_RELEASE:-stable}
|
||||
ports:
|
||||
- target: 80
|
||||
published: ${NGINX_PORT:-80}
|
||||
protocol: tcp
|
||||
mode: host
|
||||
environment:
|
||||
<<: *proxy-env
|
||||
image: artifacts.plane.so/makeplane/plane-proxy:${APP_RELEASE_VERSION}
|
||||
deploy:
|
||||
replicas: 1
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
environment:
|
||||
<<: *proxy-env
|
||||
ports:
|
||||
- target: 80
|
||||
published: ${LISTEN_HTTP_PORT:-80}
|
||||
protocol: tcp
|
||||
mode: host
|
||||
- target: 443
|
||||
published: ${LISTEN_HTTPS_PORT:-443}
|
||||
protocol: tcp
|
||||
mode: host
|
||||
volumes:
|
||||
- proxy_config:/config
|
||||
- proxy_data:/data
|
||||
depends_on:
|
||||
- web
|
||||
- api
|
||||
- space
|
||||
- web
|
||||
- api
|
||||
- space
|
||||
- admin
|
||||
- live
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
@@ -237,4 +251,6 @@ volumes:
|
||||
logs_worker:
|
||||
logs_beat-worker:
|
||||
logs_migrator:
|
||||
caddy_config:
|
||||
caddy_data:
|
||||
rabbitmq_data:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#!/bin/bash
|
||||
|
||||
BRANCH=${BRANCH:-master}
|
||||
RELEASE_TAG=${RELEASE_TAG:-v0.22-dev}
|
||||
SCRIPT_DIR=$PWD
|
||||
SERVICE_FOLDER=plane-app
|
||||
PLANE_INSTALL_DIR=$PWD/$SERVICE_FOLDER
|
||||
@@ -177,11 +178,13 @@ function syncEnvFile(){
|
||||
updateEnvFile "$key" "$value" "$DOCKER_ENV_PATH"
|
||||
fi
|
||||
done < "$DOCKER_ENV_PATH"
|
||||
# Replace APP_RELEASE with the latest value
|
||||
updateEnvFile "APP_RELEASE" "$APP_RELEASE" "$DOCKER_ENV_PATH"
|
||||
fi
|
||||
echo "Environment variables synced successfully" >&2
|
||||
}
|
||||
|
||||
function buildYourOwnImage(){
|
||||
function buildYourOwnImage() {
|
||||
echo "Building images locally..."
|
||||
|
||||
export DOCKERHUB_USER="myplane"
|
||||
@@ -423,7 +426,7 @@ function upgrade() {
|
||||
stopServices
|
||||
|
||||
echo
|
||||
echo "***** DOWNLOADING STABLE VERSION ****"
|
||||
echo "***** DOWNLOADING $APP_RELEASE VERSION ****"
|
||||
install
|
||||
|
||||
echo "***** PLEASE VALIDATE AND START SERVICES ****"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
APP_DOMAIN=localhost
|
||||
APP_RELEASE=stable
|
||||
SSL=false
|
||||
|
||||
WEB_REPLICAS=1
|
||||
SPACE_REPLICAS=1
|
||||
@@ -9,10 +10,11 @@ WORKER_REPLICAS=1
|
||||
BEAT_WORKER_REPLICAS=1
|
||||
LIVE_REPLICAS=1
|
||||
|
||||
NGINX_PORT=80
|
||||
LISTEN_PORT=80
|
||||
LISTEN_SSL_PORT=443
|
||||
WEB_URL=http://${APP_DOMAIN}
|
||||
DEBUG=0
|
||||
CORS_ALLOWED_ORIGINS=http://${APP_DOMAIN}
|
||||
CORS_ALLOWED_ORIGINS=http://${APP_DOMAIN},https://${APP_DOMAIN}
|
||||
API_BASE_URL=http://api:8000
|
||||
|
||||
#DB SETTINGS
|
||||
@@ -30,6 +32,11 @@ REDIS_HOST=plane-redis
|
||||
REDIS_PORT=6379
|
||||
REDIS_URL=
|
||||
|
||||
# If SSL Cert to be generated, set CERT_EMAIL and APP_PROTOCOL to https
|
||||
CERT_EMAIL=
|
||||
CERT_ACME_CA=https://acme-v02.api.letsencrypt.org/directory
|
||||
TRUSTED_PROXIES=0.0.0.0/0
|
||||
|
||||
# RabbitMQ Settings
|
||||
RABBITMQ_HOST=plane-mq
|
||||
RABBITMQ_PORT=5672
|
||||
|
||||
@@ -149,3 +149,12 @@ export const DEFAULT_PROJECT_FORM_VALUES: Partial<IProject> = {
|
||||
network: 2,
|
||||
project_lead: null,
|
||||
};
|
||||
|
||||
export enum EProjectFeatureKey {
|
||||
WORK_ITEMS = "work_items",
|
||||
CYCLES = "cycles",
|
||||
MODULES = "modules",
|
||||
VIEWS = "views",
|
||||
PAGES = "pages",
|
||||
INTAKE = "intake",
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { Extensions } from "@tiptap/core";
|
||||
import type { Extensions } from "@tiptap/core";
|
||||
// types
|
||||
import { TExtensions, TFileHandler } from "@/types";
|
||||
import type { IEditorProps } from "@/types";
|
||||
|
||||
type Props = {
|
||||
disabledExtensions: TExtensions[];
|
||||
fileHandler: TFileHandler;
|
||||
};
|
||||
export type TCoreAdditionalExtensionsProps = Pick<
|
||||
IEditorProps,
|
||||
"disabledExtensions" | "flaggedExtensions" | "fileHandler"
|
||||
>;
|
||||
|
||||
export const CoreEditorAdditionalExtensions = (props: Props): Extensions => {
|
||||
export const CoreEditorAdditionalExtensions = (props: TCoreAdditionalExtensionsProps): Extensions => {
|
||||
const {} = props;
|
||||
return [];
|
||||
};
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { Extensions } from "@tiptap/core";
|
||||
import type { Extensions } from "@tiptap/core";
|
||||
// types
|
||||
import { TExtensions } from "@/types";
|
||||
import type { IReadOnlyEditorProps } from "@/types";
|
||||
|
||||
type Props = {
|
||||
disabledExtensions: TExtensions[];
|
||||
};
|
||||
export type TCoreReadOnlyEditorAdditionalExtensionsProps = Pick<
|
||||
IReadOnlyEditorProps,
|
||||
"disabledExtensions" | "flaggedExtensions"
|
||||
>;
|
||||
|
||||
export const CoreReadOnlyEditorAdditionalExtensions = (props: Props): Extensions => {
|
||||
export const CoreReadOnlyEditorAdditionalExtensions = (
|
||||
props: TCoreReadOnlyEditorAdditionalExtensionsProps
|
||||
): Extensions => {
|
||||
const {} = props;
|
||||
return [];
|
||||
};
|
||||
|
||||
@@ -1,37 +1,39 @@
|
||||
import { HocuspocusProvider } from "@hocuspocus/provider";
|
||||
import { AnyExtension } from "@tiptap/core";
|
||||
import type { HocuspocusProvider } from "@hocuspocus/provider";
|
||||
import type { AnyExtension } from "@tiptap/core";
|
||||
import { SlashCommands } from "@/extensions";
|
||||
// plane editor types
|
||||
import { TEmbedConfig } from "@/plane-editor/types";
|
||||
import type { TEmbedConfig } from "@/plane-editor/types";
|
||||
// types
|
||||
import { TExtensions, TFileHandler, TUserDetails } from "@/types";
|
||||
import type { IEditorProps, TExtensions, TUserDetails } from "@/types";
|
||||
|
||||
export type TDocumentEditorAdditionalExtensionsProps = {
|
||||
disabledExtensions: TExtensions[];
|
||||
export type TDocumentEditorAdditionalExtensionsProps = Pick<
|
||||
IEditorProps,
|
||||
"disabledExtensions" | "flaggedExtensions" | "fileHandler"
|
||||
> & {
|
||||
embedConfig: TEmbedConfig | undefined;
|
||||
fileHandler: TFileHandler;
|
||||
provider?: HocuspocusProvider;
|
||||
userDetails: TUserDetails;
|
||||
};
|
||||
|
||||
export type TDocumentEditorAdditionalExtensionsRegistry = {
|
||||
isEnabled: (disabledExtensions: TExtensions[]) => boolean;
|
||||
isEnabled: (disabledExtensions: TExtensions[], flaggedExtensions: TExtensions[]) => boolean;
|
||||
getExtension: (props: TDocumentEditorAdditionalExtensionsProps) => AnyExtension;
|
||||
};
|
||||
|
||||
const extensionRegistry: TDocumentEditorAdditionalExtensionsRegistry[] = [
|
||||
{
|
||||
isEnabled: (disabledExtensions) => !disabledExtensions.includes("slash-commands"),
|
||||
getExtension: ({ disabledExtensions }) => SlashCommands({ disabledExtensions }),
|
||||
getExtension: ({ disabledExtensions, flaggedExtensions }) =>
|
||||
SlashCommands({ disabledExtensions, flaggedExtensions }),
|
||||
},
|
||||
];
|
||||
|
||||
export const DocumentEditorAdditionalExtensions = (_props: TDocumentEditorAdditionalExtensionsProps) => {
|
||||
const { disabledExtensions = [] } = _props;
|
||||
export const DocumentEditorAdditionalExtensions = (props: TDocumentEditorAdditionalExtensionsProps) => {
|
||||
const { disabledExtensions, flaggedExtensions } = props;
|
||||
|
||||
const documentExtensions = extensionRegistry
|
||||
.filter((config) => config.isEnabled(disabledExtensions))
|
||||
.map((config) => config.getExtension(_props));
|
||||
.filter((config) => config.isEnabled(disabledExtensions, flaggedExtensions))
|
||||
.map((config) => config.getExtension(props));
|
||||
|
||||
return documentExtensions;
|
||||
};
|
||||
|
||||
@@ -2,19 +2,19 @@ import { AnyExtension, Extensions } from "@tiptap/core";
|
||||
// extensions
|
||||
import { SlashCommands } from "@/extensions/slash-commands/root";
|
||||
// types
|
||||
import { TExtensions, TFileHandler } from "@/types";
|
||||
import { IEditorProps, TExtensions } from "@/types";
|
||||
|
||||
export type TRichTextEditorAdditionalExtensionsProps = {
|
||||
disabledExtensions: TExtensions[];
|
||||
fileHandler: TFileHandler;
|
||||
};
|
||||
export type TRichTextEditorAdditionalExtensionsProps = Pick<
|
||||
IEditorProps,
|
||||
"disabledExtensions" | "flaggedExtensions" | "fileHandler"
|
||||
>;
|
||||
|
||||
/**
|
||||
* Registry entry configuration for extensions
|
||||
*/
|
||||
export type TRichTextEditorAdditionalExtensionsRegistry = {
|
||||
/** Determines if the extension should be enabled based on disabled extensions */
|
||||
isEnabled: (disabledExtensions: TExtensions[]) => boolean;
|
||||
isEnabled: (disabledExtensions: TExtensions[], flaggedExtensions: TExtensions[]) => boolean;
|
||||
/** Returns the extension instance(s) when enabled */
|
||||
getExtension: (props: TRichTextEditorAdditionalExtensionsProps) => AnyExtension | undefined;
|
||||
};
|
||||
@@ -22,18 +22,19 @@ export type TRichTextEditorAdditionalExtensionsRegistry = {
|
||||
const extensionRegistry: TRichTextEditorAdditionalExtensionsRegistry[] = [
|
||||
{
|
||||
isEnabled: (disabledExtensions) => !disabledExtensions.includes("slash-commands"),
|
||||
getExtension: ({ disabledExtensions }) =>
|
||||
getExtension: ({ disabledExtensions, flaggedExtensions }) =>
|
||||
SlashCommands({
|
||||
disabledExtensions,
|
||||
flaggedExtensions,
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
export const RichTextEditorAdditionalExtensions = (props: TRichTextEditorAdditionalExtensionsProps) => {
|
||||
const { disabledExtensions } = props;
|
||||
const { disabledExtensions, flaggedExtensions } = props;
|
||||
|
||||
const extensions: Extensions = extensionRegistry
|
||||
.filter((config) => config.isEnabled(disabledExtensions))
|
||||
.filter((config) => config.isEnabled(disabledExtensions, flaggedExtensions))
|
||||
.map((config) => config.getExtension(props))
|
||||
.filter((extension): extension is AnyExtension => extension !== undefined);
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { AnyExtension, Extensions } from "@tiptap/core";
|
||||
// types
|
||||
import { TExtensions, TReadOnlyFileHandler } from "@/types";
|
||||
import { IReadOnlyEditorProps, TExtensions } from "@/types";
|
||||
|
||||
export type TRichTextReadOnlyEditorAdditionalExtensionsProps = {
|
||||
disabledExtensions: TExtensions[];
|
||||
fileHandler: TReadOnlyFileHandler;
|
||||
};
|
||||
export type TRichTextReadOnlyEditorAdditionalExtensionsProps = Pick<
|
||||
IReadOnlyEditorProps,
|
||||
"disabledExtensions" | "flaggedExtensions" | "fileHandler"
|
||||
>;
|
||||
|
||||
/**
|
||||
* Registry entry configuration for extensions
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
// extensions
|
||||
import { TSlashCommandAdditionalOption } from "@/extensions";
|
||||
import type { TSlashCommandAdditionalOption } from "@/extensions";
|
||||
// types
|
||||
import { TExtensions } from "@/types";
|
||||
import type { IEditorProps } from "@/types";
|
||||
|
||||
type Props = {
|
||||
disabledExtensions?: TExtensions[];
|
||||
};
|
||||
type Props = Pick<IEditorProps, "disabledExtensions" | "flaggedExtensions">;
|
||||
|
||||
export const coreEditorAdditionalSlashCommandOptions = (props: Props): TSlashCommandAdditionalOption[] => {
|
||||
const {} = props;
|
||||
|
||||
@@ -13,10 +13,11 @@ import { getEditorClassNames } from "@/helpers/common";
|
||||
// hooks
|
||||
import { useCollaborativeEditor } from "@/hooks/use-collaborative-editor";
|
||||
// types
|
||||
import { EditorRefApi, ICollaborativeDocumentEditor } from "@/types";
|
||||
import { EditorRefApi, ICollaborativeDocumentEditorProps } from "@/types";
|
||||
|
||||
const CollaborativeDocumentEditor = (props: ICollaborativeDocumentEditor) => {
|
||||
const CollaborativeDocumentEditor: React.FC<ICollaborativeDocumentEditorProps> = (props) => {
|
||||
const {
|
||||
onChange,
|
||||
onTransaction,
|
||||
aiHandler,
|
||||
bubbleMenuEnabled = true,
|
||||
@@ -27,6 +28,7 @@ const CollaborativeDocumentEditor = (props: ICollaborativeDocumentEditor) => {
|
||||
editorClassName = "",
|
||||
embedHandler,
|
||||
fileHandler,
|
||||
flaggedExtensions,
|
||||
forwardedRef,
|
||||
handleEditorReady,
|
||||
id,
|
||||
@@ -56,10 +58,12 @@ const CollaborativeDocumentEditor = (props: ICollaborativeDocumentEditor) => {
|
||||
embedHandler,
|
||||
extensions,
|
||||
fileHandler,
|
||||
flaggedExtensions,
|
||||
forwardedRef,
|
||||
handleEditorReady,
|
||||
id,
|
||||
mentionHandler,
|
||||
onChange,
|
||||
onTransaction,
|
||||
placeholder,
|
||||
realtimeConfig,
|
||||
@@ -95,7 +99,7 @@ const CollaborativeDocumentEditor = (props: ICollaborativeDocumentEditor) => {
|
||||
);
|
||||
};
|
||||
|
||||
const CollaborativeDocumentEditorWithRef = React.forwardRef<EditorRefApi, ICollaborativeDocumentEditor>(
|
||||
const CollaborativeDocumentEditorWithRef = React.forwardRef<EditorRefApi, ICollaborativeDocumentEditorProps>(
|
||||
(props, ref) => (
|
||||
<CollaborativeDocumentEditor {...props} forwardedRef={ref as React.MutableRefObject<EditorRefApi | null>} />
|
||||
)
|
||||
|
||||
@@ -5,7 +5,7 @@ import { AIFeaturesMenu, BlockMenu, EditorBubbleMenu } from "@/components/menus"
|
||||
// types
|
||||
import { TAIHandler, TDisplayConfig } from "@/types";
|
||||
|
||||
type IPageRenderer = {
|
||||
type Props = {
|
||||
aiHandler?: TAIHandler;
|
||||
bubbleMenuEnabled: boolean;
|
||||
displayConfig: TDisplayConfig;
|
||||
@@ -15,7 +15,7 @@ type IPageRenderer = {
|
||||
tabIndex?: number;
|
||||
};
|
||||
|
||||
export const PageRenderer = (props: IPageRenderer) => {
|
||||
export const PageRenderer = (props: Props) => {
|
||||
const { aiHandler, bubbleMenuEnabled, displayConfig, editor, editorContainerClassName, id, tabIndex } = props;
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Extensions } from "@tiptap/core";
|
||||
import { forwardRef, MutableRefObject } from "react";
|
||||
import React, { forwardRef, MutableRefObject } from "react";
|
||||
// plane imports
|
||||
import { cn } from "@plane/utils";
|
||||
// components
|
||||
@@ -13,30 +13,9 @@ import { getEditorClassNames } from "@/helpers/common";
|
||||
// hooks
|
||||
import { useReadOnlyEditor } from "@/hooks/use-read-only-editor";
|
||||
// types
|
||||
import {
|
||||
EditorReadOnlyRefApi,
|
||||
TDisplayConfig,
|
||||
TExtensions,
|
||||
TReadOnlyFileHandler,
|
||||
TReadOnlyMentionHandler,
|
||||
} from "@/types";
|
||||
import { EditorReadOnlyRefApi, IDocumentReadOnlyEditorProps } from "@/types";
|
||||
|
||||
interface IDocumentReadOnlyEditor {
|
||||
disabledExtensions: TExtensions[];
|
||||
id: string;
|
||||
initialValue: string;
|
||||
containerClassName: string;
|
||||
displayConfig?: TDisplayConfig;
|
||||
editorClassName?: string;
|
||||
embedHandler: any;
|
||||
fileHandler: TReadOnlyFileHandler;
|
||||
tabIndex?: number;
|
||||
handleEditorReady?: (value: boolean) => void;
|
||||
mentionHandler: TReadOnlyMentionHandler;
|
||||
forwardedRef?: React.MutableRefObject<EditorReadOnlyRefApi | null>;
|
||||
}
|
||||
|
||||
const DocumentReadOnlyEditor = (props: IDocumentReadOnlyEditor) => {
|
||||
const DocumentReadOnlyEditor: React.FC<IDocumentReadOnlyEditorProps> = (props) => {
|
||||
const {
|
||||
containerClassName,
|
||||
disabledExtensions,
|
||||
@@ -44,6 +23,7 @@ const DocumentReadOnlyEditor = (props: IDocumentReadOnlyEditor) => {
|
||||
editorClassName = "",
|
||||
embedHandler,
|
||||
fileHandler,
|
||||
flaggedExtensions,
|
||||
id,
|
||||
forwardedRef,
|
||||
handleEditorReady,
|
||||
@@ -64,6 +44,7 @@ const DocumentReadOnlyEditor = (props: IDocumentReadOnlyEditor) => {
|
||||
editorClassName,
|
||||
extensions,
|
||||
fileHandler,
|
||||
flaggedExtensions,
|
||||
forwardedRef,
|
||||
handleEditorReady,
|
||||
initialValue,
|
||||
@@ -87,7 +68,7 @@ const DocumentReadOnlyEditor = (props: IDocumentReadOnlyEditor) => {
|
||||
);
|
||||
};
|
||||
|
||||
const DocumentReadOnlyEditorWithRef = forwardRef<EditorReadOnlyRefApi, IDocumentReadOnlyEditor>((props, ref) => (
|
||||
const DocumentReadOnlyEditorWithRef = forwardRef<EditorReadOnlyRefApi, IDocumentReadOnlyEditorProps>((props, ref) => (
|
||||
<DocumentReadOnlyEditor {...props} forwardedRef={ref as MutableRefObject<EditorReadOnlyRefApi | null>} />
|
||||
));
|
||||
|
||||
|
||||
@@ -53,17 +53,14 @@ export const EditorContainer: FC<EditorContainerProps> = (props) => {
|
||||
const lastNodePos = editor.state.doc.resolve(Math.max(0, docSize - 2));
|
||||
const lastNode = lastNodePos.node();
|
||||
|
||||
// Check if the last node is a not paragraph
|
||||
if (lastNode && lastNode.type.name !== CORE_EXTENSIONS.PARAGRAPH) {
|
||||
// If last node is not a paragraph, insert a new paragraph at the end
|
||||
const endPosition = editor?.state.doc.content.size;
|
||||
editor?.chain().insertContentAt(endPosition, { type: CORE_EXTENSIONS.PARAGRAPH }).run();
|
||||
|
||||
// Focus the newly added paragraph for immediate editing
|
||||
editor
|
||||
.chain()
|
||||
.setTextSelection(endPosition + 1)
|
||||
.run();
|
||||
// Check if its last node and add new node
|
||||
if (lastNode) {
|
||||
const isLastNodeEmptyParagraph = lastNode.type.name === CORE_EXTENSIONS.PARAGRAPH && lastNode.content.size === 0;
|
||||
// Only insert a new paragraph if the last node is not an empty paragraph and not a doc node
|
||||
if (!isLastNodeEmptyParagraph && lastNode.type.name !== "doc") {
|
||||
const endPosition = editor?.state.doc.content.size;
|
||||
editor?.chain().insertContentAt(endPosition, { type: "paragraph" }).focus("end").run();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("An error occurred while handling container click to insert new empty node at bottom:", error);
|
||||
|
||||
@@ -26,6 +26,7 @@ export const EditorWrapper: React.FC<Props> = (props) => {
|
||||
id,
|
||||
initialValue,
|
||||
fileHandler,
|
||||
flaggedExtensions,
|
||||
forwardedRef,
|
||||
mentionHandler,
|
||||
onChange,
|
||||
@@ -44,6 +45,7 @@ export const EditorWrapper: React.FC<Props> = (props) => {
|
||||
enableHistory: true,
|
||||
extensions,
|
||||
fileHandler,
|
||||
flaggedExtensions,
|
||||
forwardedRef,
|
||||
id,
|
||||
initialValue,
|
||||
|
||||
@@ -4,23 +4,25 @@ import { EditorWrapper } from "@/components/editors/editor-wrapper";
|
||||
// extensions
|
||||
import { EnterKeyExtension } from "@/extensions";
|
||||
// types
|
||||
import { EditorRefApi, ILiteTextEditor } from "@/types";
|
||||
import { EditorRefApi, ILiteTextEditorProps } from "@/types";
|
||||
|
||||
const LiteTextEditor = (props: ILiteTextEditor) => {
|
||||
const LiteTextEditor: React.FC<ILiteTextEditorProps> = (props) => {
|
||||
const { onEnterKeyPress, disabledExtensions, extensions: externalExtensions = [] } = props;
|
||||
|
||||
const extensions = useMemo(
|
||||
() => [
|
||||
...externalExtensions,
|
||||
...(disabledExtensions?.includes("enter-key") ? [] : [EnterKeyExtension(onEnterKeyPress)]),
|
||||
],
|
||||
[externalExtensions, disabledExtensions, onEnterKeyPress]
|
||||
);
|
||||
const extensions = useMemo(() => {
|
||||
const resolvedExtensions = [...externalExtensions];
|
||||
|
||||
if (!disabledExtensions?.includes("enter-key")) {
|
||||
resolvedExtensions.push(EnterKeyExtension(onEnterKeyPress));
|
||||
}
|
||||
|
||||
return resolvedExtensions;
|
||||
}, [externalExtensions, disabledExtensions, onEnterKeyPress]);
|
||||
|
||||
return <EditorWrapper {...props} extensions={extensions} />;
|
||||
};
|
||||
|
||||
const LiteTextEditorWithRef = forwardRef<EditorRefApi, ILiteTextEditor>((props, ref) => (
|
||||
const LiteTextEditorWithRef = forwardRef<EditorRefApi, ILiteTextEditorProps>((props, ref) => (
|
||||
<LiteTextEditor {...props} forwardedRef={ref as React.MutableRefObject<EditorRefApi | null>} />
|
||||
));
|
||||
|
||||
|
||||
@@ -2,9 +2,9 @@ import { forwardRef } from "react";
|
||||
// components
|
||||
import { ReadOnlyEditorWrapper } from "@/components/editors";
|
||||
// types
|
||||
import { EditorReadOnlyRefApi, ILiteTextReadOnlyEditor } from "@/types";
|
||||
import { EditorReadOnlyRefApi, ILiteTextReadOnlyEditorProps } from "@/types";
|
||||
|
||||
const LiteTextReadOnlyEditorWithRef = forwardRef<EditorReadOnlyRefApi, ILiteTextReadOnlyEditor>((props, ref) => (
|
||||
const LiteTextReadOnlyEditorWithRef = forwardRef<EditorReadOnlyRefApi, ILiteTextReadOnlyEditorProps>((props, ref) => (
|
||||
<ReadOnlyEditorWrapper {...props} forwardedRef={ref as React.MutableRefObject<EditorReadOnlyRefApi | null>} />
|
||||
));
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ export const ReadOnlyEditorWrapper = (props: IReadOnlyEditorProps) => {
|
||||
editorClassName = "",
|
||||
extensions,
|
||||
fileHandler,
|
||||
flaggedExtensions,
|
||||
forwardedRef,
|
||||
id,
|
||||
initialValue,
|
||||
@@ -28,6 +29,7 @@ export const ReadOnlyEditorWrapper = (props: IReadOnlyEditorProps) => {
|
||||
editorClassName,
|
||||
extensions,
|
||||
fileHandler,
|
||||
flaggedExtensions,
|
||||
forwardedRef,
|
||||
initialValue,
|
||||
mentionHandler,
|
||||
|
||||
@@ -7,15 +7,16 @@ import { SideMenuExtension } from "@/extensions";
|
||||
// plane editor imports
|
||||
import { RichTextEditorAdditionalExtensions } from "@/plane-editor/extensions/rich-text/extensions";
|
||||
// types
|
||||
import { EditorRefApi, IRichTextEditor } from "@/types";
|
||||
import { EditorRefApi, IRichTextEditorProps } from "@/types";
|
||||
|
||||
const RichTextEditor = (props: IRichTextEditor) => {
|
||||
const RichTextEditor: React.FC<IRichTextEditorProps> = (props) => {
|
||||
const {
|
||||
bubbleMenuEnabled = true,
|
||||
disabledExtensions,
|
||||
dragDropEnabled,
|
||||
fileHandler,
|
||||
bubbleMenuEnabled = true,
|
||||
extensions: externalExtensions = [],
|
||||
fileHandler,
|
||||
flaggedExtensions,
|
||||
} = props;
|
||||
|
||||
const getExtensions = useCallback(() => {
|
||||
@@ -28,11 +29,12 @@ const RichTextEditor = (props: IRichTextEditor) => {
|
||||
...RichTextEditorAdditionalExtensions({
|
||||
disabledExtensions,
|
||||
fileHandler,
|
||||
flaggedExtensions,
|
||||
}),
|
||||
];
|
||||
|
||||
return extensions;
|
||||
}, [dragDropEnabled, disabledExtensions, externalExtensions, fileHandler]);
|
||||
}, [dragDropEnabled, disabledExtensions, externalExtensions, fileHandler, flaggedExtensions]);
|
||||
|
||||
return (
|
||||
<EditorWrapper {...props} extensions={getExtensions()}>
|
||||
@@ -41,7 +43,7 @@ const RichTextEditor = (props: IRichTextEditor) => {
|
||||
);
|
||||
};
|
||||
|
||||
const RichTextEditorWithRef = forwardRef<EditorRefApi, IRichTextEditor>((props, ref) => (
|
||||
const RichTextEditorWithRef = forwardRef<EditorRefApi, IRichTextEditorProps>((props, ref) => (
|
||||
<RichTextEditor {...props} forwardedRef={ref as React.MutableRefObject<EditorRefApi | null>} />
|
||||
));
|
||||
|
||||
|
||||
@@ -2,23 +2,22 @@ import { forwardRef, useCallback } from "react";
|
||||
// plane editor extensions
|
||||
import { RichTextReadOnlyEditorAdditionalExtensions } from "@/plane-editor/extensions/rich-text/read-only-extensions";
|
||||
// types
|
||||
import { EditorReadOnlyRefApi, IRichTextReadOnlyEditor } from "@/types";
|
||||
import { EditorReadOnlyRefApi, IRichTextReadOnlyEditorProps } from "@/types";
|
||||
// local imports
|
||||
import { ReadOnlyEditorWrapper } from "../read-only-editor-wrapper";
|
||||
|
||||
const RichTextReadOnlyEditorWithRef = forwardRef<EditorReadOnlyRefApi, IRichTextReadOnlyEditor>((props, ref) => {
|
||||
const { disabledExtensions, fileHandler } = props;
|
||||
const RichTextReadOnlyEditorWithRef = forwardRef<EditorReadOnlyRefApi, IRichTextReadOnlyEditorProps>((props, ref) => {
|
||||
const { disabledExtensions, fileHandler, flaggedExtensions } = props;
|
||||
|
||||
const getExtensions = useCallback(() => {
|
||||
const extensions = [
|
||||
...RichTextReadOnlyEditorAdditionalExtensions({
|
||||
disabledExtensions,
|
||||
fileHandler,
|
||||
}),
|
||||
];
|
||||
const extensions = RichTextReadOnlyEditorAdditionalExtensions({
|
||||
disabledExtensions,
|
||||
fileHandler,
|
||||
flaggedExtensions,
|
||||
});
|
||||
|
||||
return extensions;
|
||||
}, [disabledExtensions, fileHandler]);
|
||||
}, [disabledExtensions, fileHandler, flaggedExtensions]);
|
||||
|
||||
return (
|
||||
<ReadOnlyEditorWrapper
|
||||
|
||||
@@ -37,20 +37,27 @@ import { getExtensionStorage } from "@/helpers/get-extension-storage";
|
||||
// plane editor extensions
|
||||
import { CoreEditorAdditionalExtensions } from "@/plane-editor/extensions";
|
||||
// types
|
||||
import { TExtensions, TFileHandler, TMentionHandler } from "@/types";
|
||||
import type { IEditorProps } from "@/types";
|
||||
|
||||
type TArguments = {
|
||||
disabledExtensions: TExtensions[];
|
||||
type TArguments = Pick<
|
||||
IEditorProps,
|
||||
"disabledExtensions" | "flaggedExtensions" | "fileHandler" | "mentionHandler" | "placeholder" | "tabIndex"
|
||||
> & {
|
||||
enableHistory: boolean;
|
||||
fileHandler: TFileHandler;
|
||||
mentionHandler: TMentionHandler;
|
||||
placeholder?: string | ((isFocused: boolean, value: string) => string);
|
||||
tabIndex?: number;
|
||||
editable: boolean;
|
||||
};
|
||||
|
||||
export const CoreEditorExtensions = (args: TArguments): Extensions => {
|
||||
const { disabledExtensions, enableHistory, fileHandler, mentionHandler, placeholder, tabIndex, editable } = args;
|
||||
const {
|
||||
disabledExtensions,
|
||||
enableHistory,
|
||||
fileHandler,
|
||||
flaggedExtensions,
|
||||
mentionHandler,
|
||||
placeholder,
|
||||
tabIndex,
|
||||
editable,
|
||||
} = args;
|
||||
|
||||
const extensions = [
|
||||
StarterKit.configure({
|
||||
@@ -177,6 +184,7 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => {
|
||||
CustomColorExtension,
|
||||
...CoreEditorAdditionalExtensions({
|
||||
disabledExtensions,
|
||||
flaggedExtensions,
|
||||
fileHandler,
|
||||
}),
|
||||
];
|
||||
|
||||
@@ -31,16 +31,12 @@ import { isValidHttpUrl } from "@/helpers/common";
|
||||
// plane editor extensions
|
||||
import { CoreReadOnlyEditorAdditionalExtensions } from "@/plane-editor/extensions";
|
||||
// types
|
||||
import { TExtensions, TReadOnlyFileHandler, TReadOnlyMentionHandler } from "@/types";
|
||||
import type { IReadOnlyEditorProps } from "@/types";
|
||||
|
||||
type Props = {
|
||||
disabledExtensions: TExtensions[];
|
||||
fileHandler: TReadOnlyFileHandler;
|
||||
mentionHandler: TReadOnlyMentionHandler;
|
||||
};
|
||||
type Props = Pick<IReadOnlyEditorProps, "disabledExtensions" | "flaggedExtensions" | "fileHandler" | "mentionHandler">;
|
||||
|
||||
export const CoreReadOnlyEditorExtensions = (props: Props): Extensions => {
|
||||
const { disabledExtensions, fileHandler, mentionHandler } = props;
|
||||
const { disabledExtensions, fileHandler, flaggedExtensions, mentionHandler } = props;
|
||||
|
||||
const extensions = [
|
||||
StarterKit.configure({
|
||||
@@ -133,6 +129,7 @@ export const CoreReadOnlyEditorExtensions = (props: Props): Extensions => {
|
||||
}),
|
||||
...CoreReadOnlyEditorAdditionalExtensions({
|
||||
disabledExtensions,
|
||||
flaggedExtensions,
|
||||
}),
|
||||
];
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ export type TSlashCommandSection = {
|
||||
export const getSlashCommandFilteredSections =
|
||||
(args: TExtensionProps) =>
|
||||
({ query }: { query: string }): TSlashCommandSection[] => {
|
||||
const { additionalOptions: externalAdditionalOptions, disabledExtensions } = args;
|
||||
const { additionalOptions: externalAdditionalOptions, disabledExtensions, flaggedExtensions } = args;
|
||||
const SLASH_COMMAND_SECTIONS: TSlashCommandSection[] = [
|
||||
{
|
||||
key: "general",
|
||||
@@ -290,6 +290,7 @@ export const getSlashCommandFilteredSections =
|
||||
...(externalAdditionalOptions ?? []),
|
||||
...coreEditorAdditionalSlashCommandOptions({
|
||||
disabledExtensions,
|
||||
flaggedExtensions,
|
||||
}),
|
||||
]?.forEach((item) => {
|
||||
const sectionToPushTo = SLASH_COMMAND_SECTIONS.find((s) => s.key === item.section) ?? SLASH_COMMAND_SECTIONS[0];
|
||||
|
||||
@@ -7,7 +7,7 @@ import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
// helpers
|
||||
import { CommandListInstance } from "@/helpers/tippy";
|
||||
// types
|
||||
import { ISlashCommandItem, TEditorCommands, TExtensions, TSlashCommandSectionKeys } from "@/types";
|
||||
import { IEditorProps, ISlashCommandItem, TEditorCommands, TSlashCommandSectionKeys } from "@/types";
|
||||
// components
|
||||
import { getSlashCommandFilteredSections } from "./command-items-list";
|
||||
import { SlashCommandsMenu, SlashCommandsMenuProps } from "./command-menu";
|
||||
@@ -106,9 +106,8 @@ const renderItems = () => {
|
||||
};
|
||||
};
|
||||
|
||||
export type TExtensionProps = {
|
||||
export type TExtensionProps = Pick<IEditorProps, "disabledExtensions" | "flaggedExtensions"> & {
|
||||
additionalOptions?: TSlashCommandAdditionalOption[];
|
||||
disabledExtensions?: TExtensions[];
|
||||
};
|
||||
|
||||
export const SlashCommands = (props: TExtensionProps) =>
|
||||
|
||||
@@ -8,7 +8,7 @@ import { DropHandlerPlugin } from "@/plugins/drop";
|
||||
import { FilePlugins } from "@/plugins/file/root";
|
||||
import { MarkdownClipboardPlugin } from "@/plugins/markdown-clipboard";
|
||||
// types
|
||||
import { TExtensions, TFileHandler, TReadOnlyFileHandler } from "@/types";
|
||||
import type { IEditorProps, TFileHandler, TReadOnlyFileHandler } from "@/types";
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands {
|
||||
@@ -23,8 +23,7 @@ export interface UtilityExtensionStorage {
|
||||
uploadInProgress: boolean;
|
||||
}
|
||||
|
||||
type Props = {
|
||||
disabledExtensions: TExtensions[];
|
||||
type Props = Pick<IEditorProps, "disabledExtensions"> & {
|
||||
fileHandler: TFileHandler | TReadOnlyFileHandler;
|
||||
isEditable: boolean;
|
||||
};
|
||||
|
||||
@@ -9,18 +9,20 @@ import { useEditor } from "@/hooks/use-editor";
|
||||
// plane editor extensions
|
||||
import { DocumentEditorAdditionalExtensions } from "@/plane-editor/extensions";
|
||||
// types
|
||||
import { TCollaborativeEditorProps } from "@/types";
|
||||
import { TCollaborativeEditorHookProps } from "@/types";
|
||||
|
||||
export const useCollaborativeEditor = (props: TCollaborativeEditorProps) => {
|
||||
export const useCollaborativeEditor = (props: TCollaborativeEditorHookProps) => {
|
||||
const {
|
||||
onChange,
|
||||
onTransaction,
|
||||
disabledExtensions,
|
||||
editable,
|
||||
editorClassName,
|
||||
editorClassName = "",
|
||||
editorProps = {},
|
||||
embedHandler,
|
||||
extensions,
|
||||
extensions = [],
|
||||
fileHandler,
|
||||
flaggedExtensions,
|
||||
forwardedRef,
|
||||
handleEditorReady,
|
||||
id,
|
||||
@@ -89,19 +91,22 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorProps) => {
|
||||
Collaboration.configure({
|
||||
document: provider.document,
|
||||
}),
|
||||
...(extensions ?? []),
|
||||
...extensions,
|
||||
...DocumentEditorAdditionalExtensions({
|
||||
disabledExtensions,
|
||||
embedConfig: embedHandler,
|
||||
fileHandler,
|
||||
flaggedExtensions,
|
||||
provider,
|
||||
userDetails: user,
|
||||
}),
|
||||
],
|
||||
fileHandler,
|
||||
flaggedExtensions,
|
||||
forwardedRef,
|
||||
handleEditorReady,
|
||||
mentionHandler,
|
||||
onChange,
|
||||
onTransaction,
|
||||
placeholder,
|
||||
provider,
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { HocuspocusProvider } from "@hocuspocus/provider";
|
||||
import { DOMSerializer } from "@tiptap/pm/model";
|
||||
import { EditorProps } from "@tiptap/pm/view";
|
||||
import { useEditor as useTiptapEditor, Extensions } from "@tiptap/react";
|
||||
import { useImperativeHandle, MutableRefObject, useEffect } from "react";
|
||||
import { useEditor as useTiptapEditor } from "@tiptap/react";
|
||||
import { useImperativeHandle, useEffect } from "react";
|
||||
import * as Y from "yjs";
|
||||
// components
|
||||
import { getEditorMenuItems } from "@/components/menus";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
import { CORE_EDITOR_META } from "@/constants/meta";
|
||||
// extensions
|
||||
import { CoreEditorExtensions } from "@/extensions";
|
||||
// helpers
|
||||
@@ -18,49 +17,19 @@ import { IMarking, scrollSummary, scrollToNodeViaDOMCoordinates } from "@/helper
|
||||
// props
|
||||
import { CoreEditorProps } from "@/props";
|
||||
// types
|
||||
import type {
|
||||
TDocumentEventsServer,
|
||||
EditorRefApi,
|
||||
TEditorCommands,
|
||||
TFileHandler,
|
||||
TExtensions,
|
||||
TMentionHandler,
|
||||
} from "@/types";
|
||||
import { CORE_EDITOR_META } from "@/constants/meta";
|
||||
import type { TDocumentEventsServer, TEditorCommands, TEditorHookProps } from "@/types";
|
||||
|
||||
export interface CustomEditorProps {
|
||||
editable: boolean;
|
||||
editorClassName: string;
|
||||
editorProps?: EditorProps;
|
||||
enableHistory: boolean;
|
||||
disabledExtensions: TExtensions[];
|
||||
extensions?: Extensions;
|
||||
fileHandler: TFileHandler;
|
||||
forwardedRef?: MutableRefObject<EditorRefApi | null>;
|
||||
handleEditorReady?: (value: boolean) => void;
|
||||
id?: string;
|
||||
initialValue?: string;
|
||||
mentionHandler: TMentionHandler;
|
||||
onChange?: (json: object, html: string) => void;
|
||||
onTransaction?: () => void;
|
||||
autofocus?: boolean;
|
||||
placeholder?: string | ((isFocused: boolean, value: string) => string);
|
||||
provider?: HocuspocusProvider;
|
||||
tabIndex?: number;
|
||||
// undefined when prop is not passed, null if intentionally passed to stop
|
||||
// swr syncing
|
||||
value?: string | null | undefined;
|
||||
}
|
||||
|
||||
export const useEditor = (props: CustomEditorProps) => {
|
||||
export const useEditor = (props: TEditorHookProps) => {
|
||||
const {
|
||||
autofocus = false,
|
||||
disabledExtensions,
|
||||
editable = true,
|
||||
editorClassName,
|
||||
editorClassName = "",
|
||||
editorProps = {},
|
||||
enableHistory,
|
||||
extensions = [],
|
||||
fileHandler,
|
||||
flaggedExtensions,
|
||||
forwardedRef,
|
||||
handleEditorReady,
|
||||
id = "",
|
||||
@@ -69,10 +38,9 @@ export const useEditor = (props: CustomEditorProps) => {
|
||||
onChange,
|
||||
onTransaction,
|
||||
placeholder,
|
||||
provider,
|
||||
tabIndex,
|
||||
value,
|
||||
provider,
|
||||
autofocus = false,
|
||||
} = props;
|
||||
|
||||
const editor = useTiptapEditor(
|
||||
@@ -94,6 +62,7 @@ export const useEditor = (props: CustomEditorProps) => {
|
||||
disabledExtensions,
|
||||
enableHistory,
|
||||
fileHandler,
|
||||
flaggedExtensions,
|
||||
mentionHandler,
|
||||
placeholder,
|
||||
tabIndex,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { HocuspocusProvider } from "@hocuspocus/provider";
|
||||
import { EditorProps } from "@tiptap/pm/view";
|
||||
import { useEditor as useTiptapEditor, Extensions } from "@tiptap/react";
|
||||
import { useImperativeHandle, MutableRefObject, useEffect } from "react";
|
||||
import { useEditor as useTiptapEditor } from "@tiptap/react";
|
||||
import { useImperativeHandle, useEffect } from "react";
|
||||
import * as Y from "yjs";
|
||||
// constants
|
||||
import { CORE_EDITOR_META } from "@/constants/meta";
|
||||
// extensions
|
||||
import { CoreReadOnlyEditorExtensions } from "@/extensions";
|
||||
// helpers
|
||||
@@ -11,32 +11,19 @@ import { IMarking, scrollSummary } from "@/helpers/scroll-to-node";
|
||||
// props
|
||||
import { CoreReadOnlyEditorProps } from "@/props";
|
||||
// types
|
||||
import type { EditorReadOnlyRefApi, TExtensions, TReadOnlyFileHandler, TReadOnlyMentionHandler } from "@/types";
|
||||
import { CORE_EDITOR_META } from "@/constants/meta";
|
||||
import type { TReadOnlyEditorHookProps } from "@/types";
|
||||
|
||||
interface CustomReadOnlyEditorProps {
|
||||
disabledExtensions: TExtensions[];
|
||||
editorClassName: string;
|
||||
editorProps?: EditorProps;
|
||||
extensions?: Extensions;
|
||||
forwardedRef?: MutableRefObject<EditorReadOnlyRefApi | null>;
|
||||
initialValue?: string;
|
||||
fileHandler: TReadOnlyFileHandler;
|
||||
handleEditorReady?: (value: boolean) => void;
|
||||
mentionHandler: TReadOnlyMentionHandler;
|
||||
provider?: HocuspocusProvider;
|
||||
}
|
||||
|
||||
export const useReadOnlyEditor = (props: CustomReadOnlyEditorProps) => {
|
||||
export const useReadOnlyEditor = (props: TReadOnlyEditorHookProps) => {
|
||||
const {
|
||||
disabledExtensions,
|
||||
initialValue,
|
||||
editorClassName,
|
||||
forwardedRef,
|
||||
extensions = [],
|
||||
editorClassName = "",
|
||||
editorProps = {},
|
||||
extensions = [],
|
||||
fileHandler,
|
||||
flaggedExtensions,
|
||||
forwardedRef,
|
||||
handleEditorReady,
|
||||
initialValue,
|
||||
mentionHandler,
|
||||
provider,
|
||||
} = props;
|
||||
@@ -59,8 +46,9 @@ export const useReadOnlyEditor = (props: CustomReadOnlyEditorProps) => {
|
||||
extensions: [
|
||||
...CoreReadOnlyEditorExtensions({
|
||||
disabledExtensions,
|
||||
mentionHandler,
|
||||
fileHandler,
|
||||
flaggedExtensions,
|
||||
mentionHandler,
|
||||
}),
|
||||
...extensions,
|
||||
],
|
||||
|
||||
@@ -1,50 +1,4 @@
|
||||
import { Extensions } from "@tiptap/core";
|
||||
import { EditorProps } from "@tiptap/pm/view";
|
||||
// plane editor types
|
||||
import { TEmbedConfig } from "@/plane-editor/types";
|
||||
// types
|
||||
import {
|
||||
EditorReadOnlyRefApi,
|
||||
EditorRefApi,
|
||||
TExtensions,
|
||||
TFileHandler,
|
||||
TMentionHandler,
|
||||
TReadOnlyFileHandler,
|
||||
TReadOnlyMentionHandler,
|
||||
TRealtimeConfig,
|
||||
TUserDetails,
|
||||
} from "@/types";
|
||||
|
||||
export type TServerHandler = {
|
||||
onConnect?: () => void;
|
||||
onServerError?: () => void;
|
||||
};
|
||||
|
||||
type TCollaborativeEditorHookProps = {
|
||||
disabledExtensions: TExtensions[];
|
||||
editable: boolean;
|
||||
editorClassName: string;
|
||||
editorProps?: EditorProps;
|
||||
extensions?: Extensions;
|
||||
handleEditorReady?: (value: boolean) => void;
|
||||
id: string;
|
||||
realtimeConfig: TRealtimeConfig;
|
||||
serverHandler?: TServerHandler;
|
||||
user: TUserDetails;
|
||||
};
|
||||
|
||||
export type TCollaborativeEditorProps = TCollaborativeEditorHookProps & {
|
||||
onTransaction?: () => void;
|
||||
embedHandler?: TEmbedConfig;
|
||||
fileHandler: TFileHandler;
|
||||
forwardedRef?: React.MutableRefObject<EditorRefApi | null>;
|
||||
mentionHandler: TMentionHandler;
|
||||
placeholder?: string | ((isFocused: boolean, value: string) => string);
|
||||
tabIndex?: number;
|
||||
};
|
||||
|
||||
export type TReadOnlyCollaborativeEditorProps = TCollaborativeEditorHookProps & {
|
||||
fileHandler: TReadOnlyFileHandler;
|
||||
forwardedRef?: React.MutableRefObject<EditorReadOnlyRefApi | null>;
|
||||
mentionHandler: TReadOnlyMentionHandler;
|
||||
};
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// plane imports
|
||||
import { TWebhookConnectionQueryParams } from "@plane/types";
|
||||
|
||||
export type TReadOnlyFileHandler = {
|
||||
checkIfAssetExists: (assetId: string) => Promise<boolean>;
|
||||
getAssetSrc: (path: string) => Promise<string>;
|
||||
@@ -30,3 +33,15 @@ export type TDisplayConfig = {
|
||||
lineSpacing?: TEditorLineSpacing;
|
||||
wideLayout?: boolean;
|
||||
};
|
||||
|
||||
export type TUserDetails = {
|
||||
color: string;
|
||||
id: string;
|
||||
name: string;
|
||||
cookie?: string;
|
||||
};
|
||||
|
||||
export type TRealtimeConfig = {
|
||||
url: string;
|
||||
queryParams: TWebhookConnectionQueryParams;
|
||||
};
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { Extensions, JSONContent } from "@tiptap/core";
|
||||
import { Selection } from "@tiptap/pm/state";
|
||||
// plane types
|
||||
import { TWebhookConnectionQueryParams } from "@plane/types";
|
||||
import type { Extensions, JSONContent } from "@tiptap/core";
|
||||
import type { Selection } from "@tiptap/pm/state";
|
||||
// extension types
|
||||
import { TTextAlign } from "@/extensions";
|
||||
import type { TTextAlign } from "@/extensions";
|
||||
// helpers
|
||||
import { IMarking } from "@/helpers/scroll-to-node";
|
||||
import type { IMarking } from "@/helpers/scroll-to-node";
|
||||
// types
|
||||
import {
|
||||
import type {
|
||||
TAIHandler,
|
||||
TDisplayConfig,
|
||||
TDocumentEventEmitter,
|
||||
@@ -18,7 +16,9 @@ import {
|
||||
TMentionHandler,
|
||||
TReadOnlyFileHandler,
|
||||
TReadOnlyMentionHandler,
|
||||
TRealtimeConfig,
|
||||
TServerHandler,
|
||||
TUserDetails,
|
||||
} from "@/types";
|
||||
|
||||
export type TEditorCommands =
|
||||
@@ -114,90 +114,70 @@ export interface EditorRefApi extends EditorReadOnlyRefApi {
|
||||
|
||||
// editor props
|
||||
export interface IEditorProps {
|
||||
autofocus?: boolean;
|
||||
bubbleMenuEnabled?: boolean;
|
||||
containerClassName?: string;
|
||||
displayConfig?: TDisplayConfig;
|
||||
disabledExtensions: TExtensions[];
|
||||
editorClassName?: string;
|
||||
extensions?: Extensions;
|
||||
flaggedExtensions: TExtensions[];
|
||||
fileHandler: TFileHandler;
|
||||
forwardedRef?: React.MutableRefObject<EditorRefApi | null>;
|
||||
handleEditorReady?: (value: boolean) => void;
|
||||
id: string;
|
||||
initialValue: string;
|
||||
mentionHandler: TMentionHandler;
|
||||
onChange?: (json: object, html: string) => void;
|
||||
onTransaction?: () => void;
|
||||
handleEditorReady?: (value: boolean) => void;
|
||||
autofocus?: boolean;
|
||||
onEnterKeyPress?: (e?: any) => void;
|
||||
onTransaction?: () => void;
|
||||
placeholder?: string | ((isFocused: boolean, value: string) => string);
|
||||
tabIndex?: number;
|
||||
value?: string | null;
|
||||
bubbleMenuEnabled?: boolean;
|
||||
}
|
||||
export interface ILiteTextEditor extends IEditorProps {
|
||||
extensions?: Extensions;
|
||||
}
|
||||
export interface IRichTextEditor extends IEditorProps {
|
||||
extensions?: Extensions;
|
||||
|
||||
export type ILiteTextEditorProps = IEditorProps;
|
||||
export interface IRichTextEditorProps extends IEditorProps {
|
||||
dragDropEnabled?: boolean;
|
||||
}
|
||||
|
||||
export interface ICollaborativeDocumentEditor
|
||||
extends Omit<IEditorProps, "initialValue" | "onChange" | "onEnterKeyPress" | "value"> {
|
||||
export interface ICollaborativeDocumentEditorProps
|
||||
extends Omit<IEditorProps, "extensions" | "initialValue" | "onEnterKeyPress" | "value"> {
|
||||
aiHandler?: TAIHandler;
|
||||
bubbleMenuEnabled?: boolean;
|
||||
editable: boolean;
|
||||
embedHandler: TEmbedConfig;
|
||||
handleEditorReady?: (value: boolean) => void;
|
||||
id: string;
|
||||
realtimeConfig: TRealtimeConfig;
|
||||
serverHandler?: TServerHandler;
|
||||
user: TUserDetails;
|
||||
}
|
||||
|
||||
// read only editor props
|
||||
export interface IReadOnlyEditorProps {
|
||||
containerClassName?: string;
|
||||
disabledExtensions: TExtensions[];
|
||||
displayConfig?: TDisplayConfig;
|
||||
editorClassName?: string;
|
||||
extensions?: Extensions;
|
||||
export interface IReadOnlyEditorProps
|
||||
extends Pick<
|
||||
IEditorProps,
|
||||
| "containerClassName"
|
||||
| "disabledExtensions"
|
||||
| "flaggedExtensions"
|
||||
| "displayConfig"
|
||||
| "editorClassName"
|
||||
| "extensions"
|
||||
| "handleEditorReady"
|
||||
| "id"
|
||||
| "initialValue"
|
||||
> {
|
||||
fileHandler: TReadOnlyFileHandler;
|
||||
forwardedRef?: React.MutableRefObject<EditorReadOnlyRefApi | null>;
|
||||
id: string;
|
||||
initialValue: string;
|
||||
mentionHandler: TReadOnlyMentionHandler;
|
||||
}
|
||||
|
||||
export type ILiteTextReadOnlyEditor = IReadOnlyEditorProps;
|
||||
export type ILiteTextReadOnlyEditorProps = IReadOnlyEditorProps;
|
||||
|
||||
export type IRichTextReadOnlyEditor = IReadOnlyEditorProps;
|
||||
export type IRichTextReadOnlyEditorProps = IReadOnlyEditorProps;
|
||||
|
||||
export interface ICollaborativeDocumentReadOnlyEditor extends Omit<IReadOnlyEditorProps, "initialValue"> {
|
||||
export interface IDocumentReadOnlyEditorProps extends IReadOnlyEditorProps {
|
||||
embedHandler: TEmbedConfig;
|
||||
handleEditorReady?: (value: boolean) => void;
|
||||
id: string;
|
||||
realtimeConfig: TRealtimeConfig;
|
||||
serverHandler?: TServerHandler;
|
||||
user: TUserDetails;
|
||||
}
|
||||
|
||||
export interface IDocumentReadOnlyEditor extends IReadOnlyEditorProps {
|
||||
embedHandler: TEmbedConfig;
|
||||
handleEditorReady?: (value: boolean) => void;
|
||||
}
|
||||
|
||||
export type TUserDetails = {
|
||||
color: string;
|
||||
id: string;
|
||||
name: string;
|
||||
cookie?: string;
|
||||
};
|
||||
|
||||
export type TRealtimeConfig = {
|
||||
url: string;
|
||||
queryParams: TWebhookConnectionQueryParams;
|
||||
};
|
||||
|
||||
export interface EditorEvents {
|
||||
beforeCreate: never;
|
||||
create: never;
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { HocuspocusProvider } from "@hocuspocus/provider";
|
||||
import type { EditorProps } from "@tiptap/pm/view";
|
||||
// local imports
|
||||
import type { ICollaborativeDocumentEditorProps, IEditorProps, IReadOnlyEditorProps } from "./editor";
|
||||
|
||||
type TCoreHookProps = Pick<
|
||||
IEditorProps,
|
||||
"disabledExtensions" | "editorClassName" | "extensions" | "flaggedExtensions" | "handleEditorReady"
|
||||
> & {
|
||||
editorProps?: EditorProps;
|
||||
};
|
||||
|
||||
export type TEditorHookProps = TCoreHookProps &
|
||||
Pick<
|
||||
IEditorProps,
|
||||
| "autofocus"
|
||||
| "fileHandler"
|
||||
| "forwardedRef"
|
||||
| "id"
|
||||
| "mentionHandler"
|
||||
| "onChange"
|
||||
| "onTransaction"
|
||||
| "placeholder"
|
||||
| "tabIndex"
|
||||
| "value"
|
||||
> & {
|
||||
editable: boolean;
|
||||
enableHistory: boolean;
|
||||
initialValue?: string;
|
||||
provider?: HocuspocusProvider;
|
||||
};
|
||||
|
||||
export type TCollaborativeEditorHookProps = TCoreHookProps &
|
||||
Pick<
|
||||
TEditorHookProps,
|
||||
| "editable"
|
||||
| "fileHandler"
|
||||
| "forwardedRef"
|
||||
| "id"
|
||||
| "mentionHandler"
|
||||
| "onChange"
|
||||
| "onTransaction"
|
||||
| "placeholder"
|
||||
| "tabIndex"
|
||||
> &
|
||||
Pick<ICollaborativeDocumentEditorProps, "embedHandler" | "realtimeConfig" | "serverHandler" | "user">;
|
||||
|
||||
export type TReadOnlyEditorHookProps = TCoreHookProps &
|
||||
Pick<TEditorHookProps, "initialValue" | "provider"> &
|
||||
Pick<IReadOnlyEditorProps, "fileHandler" | "forwardedRef" | "mentionHandler">;
|
||||
@@ -4,6 +4,7 @@ export * from "./config";
|
||||
export * from "./editor";
|
||||
export * from "./embed";
|
||||
export * from "./extensions";
|
||||
export * from "./hook";
|
||||
export * from "./mention";
|
||||
export * from "./slash-commands-suggestion";
|
||||
export * from "@/plane-editor/types";
|
||||
|
||||
@@ -36,5 +36,4 @@ export { type IMarking, useEditorMarkings } from "@/hooks/use-editor-markings";
|
||||
export { useReadOnlyEditor } from "@/hooks/use-read-only-editor";
|
||||
|
||||
// types
|
||||
export type { CustomEditorProps } from "@/hooks/use-editor";
|
||||
export * from "@/types";
|
||||
|
||||
@@ -9,12 +9,11 @@ export class APITokenService extends APIService {
|
||||
|
||||
/**
|
||||
* Retrieves all API tokens for a specific workspace
|
||||
* @param {string} workspaceSlug - The unique identifier for the workspace
|
||||
* @returns {Promise<IApiToken[]>} Array of API tokens associated with the workspace
|
||||
* @throws {Error} Throws response data if the request fails
|
||||
*/
|
||||
async list(workspaceSlug: string): Promise<IApiToken[]> {
|
||||
return this.get(`/api/workspaces/${workspaceSlug}/api-tokens/`)
|
||||
async list(): Promise<IApiToken[]> {
|
||||
return this.get(`/api/users/api-tokens/`)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
@@ -23,13 +22,12 @@ export class APITokenService extends APIService {
|
||||
|
||||
/**
|
||||
* Retrieves a specific API token by its ID
|
||||
* @param {string} workspaceSlug - The unique identifier for the workspace
|
||||
* @param {string} tokenId - The unique identifier of the API token
|
||||
* @returns {Promise<IApiToken>} The requested API token's details
|
||||
* @throws {Error} Throws response data if the request fails
|
||||
*/
|
||||
async retrieve(workspaceSlug: string, tokenId: string): Promise<IApiToken> {
|
||||
return this.get(`/api/workspaces/${workspaceSlug}/api-tokens/${tokenId}`)
|
||||
async retrieve(tokenId: string): Promise<IApiToken> {
|
||||
return this.get(`/api/users/api-tokens/${tokenId}`)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
@@ -38,13 +36,12 @@ export class APITokenService extends APIService {
|
||||
|
||||
/**
|
||||
* Creates a new API token for a workspace
|
||||
* @param {string} workspaceSlug - The unique identifier for the workspace
|
||||
* @param {Partial<IApiToken>} data - The data for creating the new API token
|
||||
* @returns {Promise<IApiToken>} The newly created API token
|
||||
* @throws {Error} Throws response data if the request fails
|
||||
*/
|
||||
async create(workspaceSlug: string, data: Partial<IApiToken>): Promise<IApiToken> {
|
||||
return this.post(`/api/workspaces/${workspaceSlug}/api-tokens/`, data)
|
||||
async create(data: Partial<IApiToken>): Promise<IApiToken> {
|
||||
return this.post(`/api/users/api-tokens/`, data)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
@@ -53,13 +50,12 @@ export class APITokenService extends APIService {
|
||||
|
||||
/**
|
||||
* Deletes a specific API token from the workspace
|
||||
* @param {string} workspaceSlug - The unique identifier for the workspace
|
||||
* @param {string} tokenId - The unique identifier of the API token to delete
|
||||
* @returns {Promise<IApiToken>} The deleted API token's details
|
||||
* @throws {Error} Throws response data if the request fails
|
||||
*/
|
||||
async destroy(workspaceSlug: string, tokenId: string): Promise<IApiToken> {
|
||||
return this.delete(`/api/workspaces/${workspaceSlug}/api-tokens/${tokenId}`)
|
||||
async destroy(tokenId: string): Promise<IApiToken> {
|
||||
return this.delete(`/api/users/api-tokens/${tokenId}`)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
|
||||
Vendored
+1
-1
@@ -10,7 +10,7 @@ export * from "./issues";
|
||||
export * from "./module";
|
||||
export * from "./views";
|
||||
export * from "./integration";
|
||||
export * from "./pages";
|
||||
export * from "./page";
|
||||
export * from "./ai";
|
||||
export * from "./estimate";
|
||||
export * from "./importer";
|
||||
|
||||
Vendored
+2
@@ -9,6 +9,7 @@ export interface IGanttBlock {
|
||||
sort_order: number | undefined;
|
||||
start_date: string | undefined;
|
||||
target_date: string | undefined;
|
||||
project_id: string | undefined;
|
||||
}
|
||||
|
||||
export interface IBlockUpdateData {
|
||||
@@ -25,6 +26,7 @@ export interface IBlockUpdateDependencyData {
|
||||
id: string;
|
||||
start_date?: string;
|
||||
target_date?: string;
|
||||
project_id?: string;
|
||||
}
|
||||
|
||||
export type TGanttViews = "week" | "month" | "quarter";
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { TLogoProps } from "./common";
|
||||
import { EPageAccess } from "./enums";
|
||||
import { TLogoProps } from "../common";
|
||||
import { EPageAccess } from "../enums";
|
||||
import { TPageExtended } from "./extended";
|
||||
|
||||
export type TPage = {
|
||||
export type TPage = TPageExtended & {
|
||||
access: EPageAccess | undefined;
|
||||
anchor?: string | null | undefined;
|
||||
archived_at: string | null | undefined;
|
||||
color: string | undefined;
|
||||
created_at: Date | undefined;
|
||||
@@ -16,7 +16,6 @@ export type TPage = {
|
||||
name: string | undefined;
|
||||
owned_by: string | undefined;
|
||||
project_ids?: string[] | undefined;
|
||||
team: string | null | undefined;
|
||||
updated_at: Date | undefined;
|
||||
updated_by: string | undefined;
|
||||
workspace: string | undefined;
|
||||
+1
@@ -0,0 +1 @@
|
||||
export type TPageExtended = {};
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
export * from "./core";
|
||||
export * from "./extended";
|
||||
Vendored
+1
-1
@@ -1,7 +1,7 @@
|
||||
import { ICycle } from "./cycle";
|
||||
import { TIssue } from "./issues/issue";
|
||||
import { IModule } from "./module";
|
||||
import { TPage } from "./pages";
|
||||
import { TPage } from "./page";
|
||||
import { IProject } from "./project";
|
||||
import { IUser } from "./users";
|
||||
import { IWorkspace } from "./workspace";
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { Home, Settings, Briefcase, GridIcon, Layers2, FileIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { ContrastIcon, EpicIcon, LayersIcon } from "../icons";
|
||||
import { Breadcrumbs } from "./breadcrumbs";
|
||||
import { BreadcrumbNavigationDropdown } from "./navigation-dropdown";
|
||||
|
||||
const meta: Meta<typeof Breadcrumbs> = {
|
||||
title: "UI/Breadcrumbs",
|
||||
component: Breadcrumbs,
|
||||
tags: ["autodocs"],
|
||||
argTypes: {
|
||||
isLoading: {
|
||||
control: "boolean",
|
||||
description: "Shows loading state of breadcrumbs",
|
||||
},
|
||||
onBack: {
|
||||
action: "onBack",
|
||||
description: "Callback function when back button is clicked",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
type TBreadcrumbBlockProps = {
|
||||
href?: string;
|
||||
label?: string;
|
||||
icon?: React.ReactNode;
|
||||
disableTooltip?: boolean;
|
||||
};
|
||||
|
||||
// TODO: remove this component and use web Link component
|
||||
const BreadcrumbBlock: React.FC<TBreadcrumbBlockProps> = (props) => {
|
||||
const { label, icon, disableTooltip = false } = props;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Breadcrumbs.ItemWrapper label={label} disableTooltip={disableTooltip}>
|
||||
{icon && <div className="flex size-4 items-center justify-center overflow-hidden !text-[1rem]">{icon}</div>}
|
||||
{label && <div className="relative line-clamp-1 block max-w-[150px] overflow-hidden truncate">{label}</div>}
|
||||
</Breadcrumbs.ItemWrapper>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof Breadcrumbs>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
children: [
|
||||
<Breadcrumbs.Item key="home" component={<BreadcrumbBlock href="/" label="Home" />} />,
|
||||
<Breadcrumbs.Item key="projects" component={<BreadcrumbBlock href="/projects" label="Projects" />} />,
|
||||
<Breadcrumbs.Item
|
||||
key="current"
|
||||
component={<BreadcrumbBlock href="/projects/current" label="Current Project" />}
|
||||
/>,
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const WithLoading: Story = {
|
||||
args: {
|
||||
isLoading: true,
|
||||
children: [
|
||||
<Breadcrumbs.Item key="home" component={<BreadcrumbBlock href="/" label="Home" />} />,
|
||||
<Breadcrumbs.Item key="projects" component={<BreadcrumbBlock href="/projects" label="Projects" />} />,
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const WithCustomComponent: Story = {
|
||||
args: {
|
||||
children: [
|
||||
<Breadcrumbs.Item key="home" component={<BreadcrumbBlock href="/" label="Home" />} />,
|
||||
<Breadcrumbs.Item
|
||||
key="custom"
|
||||
component={
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="size-4 rounded-full bg-blue-500" />
|
||||
<span>Custom Component</span>
|
||||
</div>
|
||||
}
|
||||
/>,
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const SingleItem: Story = {
|
||||
args: {
|
||||
children: [<Breadcrumbs.Item key="home" component={<BreadcrumbBlock href="/" label="Home" />} />],
|
||||
},
|
||||
};
|
||||
|
||||
export const WithNavigationDropdown: Story = {
|
||||
args: {
|
||||
children: [
|
||||
<Breadcrumbs.Item key="home" component={<BreadcrumbBlock href="/" label="Home" />} />,
|
||||
<Breadcrumbs.Item
|
||||
key="projects"
|
||||
component={
|
||||
<BreadcrumbNavigationDropdown
|
||||
selectedItemKey="project-1"
|
||||
navigationItems={[
|
||||
{
|
||||
key: "project-1",
|
||||
title: "Project Alpha",
|
||||
|
||||
action: () => console.log("Project Alpha selected"),
|
||||
},
|
||||
{
|
||||
key: "project-2",
|
||||
title: "Project Beta",
|
||||
|
||||
action: () => console.log("Project Beta selected"),
|
||||
},
|
||||
{
|
||||
key: "project-3",
|
||||
title: "Project Gamma",
|
||||
|
||||
action: () => console.log("Project Gamma selected"),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
}
|
||||
showSeparator={false}
|
||||
/>,
|
||||
<Breadcrumbs.Item key="settings" component={<BreadcrumbBlock href="/settings" label="Settings" />} />,
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const WithNavigationDropdownAndIcons: Story = {
|
||||
args: {
|
||||
children: [
|
||||
<Breadcrumbs.Item
|
||||
key="home"
|
||||
component={<BreadcrumbBlock href="/" label="Home" icon={<Home className="size-3.5" />} />}
|
||||
/>,
|
||||
<Breadcrumbs.Item
|
||||
key="projects"
|
||||
component={
|
||||
<BreadcrumbNavigationDropdown
|
||||
selectedItemKey="project-1"
|
||||
navigationItems={[
|
||||
{
|
||||
key: "project-1",
|
||||
title: "Project Alpha",
|
||||
icon: Briefcase,
|
||||
|
||||
action: () => console.log("Project Alpha selected"),
|
||||
},
|
||||
{
|
||||
key: "project-2",
|
||||
title: "Project Beta",
|
||||
icon: Briefcase,
|
||||
|
||||
// disabled: true,
|
||||
action: () => console.log("Project Beta selected"),
|
||||
},
|
||||
{
|
||||
key: "project-3",
|
||||
title: "Project Gamma",
|
||||
icon: Briefcase,
|
||||
|
||||
action: () => console.log("Project Gamma selected"),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
}
|
||||
showSeparator={false}
|
||||
/>,
|
||||
<Breadcrumbs.Item
|
||||
key="features"
|
||||
component={
|
||||
<BreadcrumbNavigationDropdown
|
||||
selectedItemKey="feature-1"
|
||||
navigationItems={[
|
||||
{
|
||||
key: "feature-1",
|
||||
title: "Epics",
|
||||
icon: EpicIcon,
|
||||
|
||||
action: () => console.log("Feature Alpha selected"),
|
||||
},
|
||||
{
|
||||
key: "feature-2",
|
||||
title: "Work items",
|
||||
icon: LayersIcon,
|
||||
|
||||
// disabled: true,
|
||||
action: () => console.log("Feature Beta selected"),
|
||||
},
|
||||
{
|
||||
key: "feature-3",
|
||||
title: "Cycles",
|
||||
icon: ContrastIcon,
|
||||
|
||||
action: () => console.log("Feature Gamma selected"),
|
||||
},
|
||||
{
|
||||
key: "feature-3",
|
||||
title: "Modules",
|
||||
icon: GridIcon,
|
||||
|
||||
action: () => console.log("Feature Gamma selected"),
|
||||
},
|
||||
{
|
||||
key: "feature-3",
|
||||
title: "Views",
|
||||
icon: Layers2,
|
||||
|
||||
action: () => console.log("Feature Gamma selected"),
|
||||
},
|
||||
{
|
||||
key: "feature-3",
|
||||
title: "Pages",
|
||||
icon: FileIcon,
|
||||
|
||||
action: () => console.log("Feature Gamma selected"),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
}
|
||||
showSeparator={false}
|
||||
/>,
|
||||
<Breadcrumbs.Item
|
||||
key="settings"
|
||||
component={<BreadcrumbBlock href="/settings" label="Settings" icon={<Settings className="size-3.5" />} />}
|
||||
isLast
|
||||
/>,
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -1,13 +1,25 @@
|
||||
import * as React from "react";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { cn } from "../../helpers";
|
||||
import { Tooltip } from "../tooltip";
|
||||
|
||||
type BreadcrumbsProps = {
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
onBack?: () => void;
|
||||
isLoading?: boolean;
|
||||
};
|
||||
|
||||
const Breadcrumbs = ({ children, onBack, isLoading = false }: BreadcrumbsProps) => {
|
||||
export const BreadcrumbItemLoader = () => (
|
||||
<div className="flex items-center gap-2 h-7 animate-pulse">
|
||||
<div className="group h-full flex items-center gap-2 rounded px-2 py-1 text-sm font-medium">
|
||||
<span className="h-full w-5 bg-custom-background-80 rounded" />
|
||||
<span className="h-full w-16 bg-custom-background-80 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const Breadcrumbs = ({ className, children, onBack, isLoading = false }: BreadcrumbsProps) => {
|
||||
const [isSmallScreen, setIsSmallScreen] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -22,35 +34,31 @@ const Breadcrumbs = ({ children, onBack, isLoading = false }: BreadcrumbsProps)
|
||||
|
||||
const childrenArray = React.Children.toArray(children);
|
||||
|
||||
const BreadcrumbItemLoader = (
|
||||
<div className="flex items-center gap-1 animate-pulse">
|
||||
<span className="h-5 w-5 bg-custom-background-80 rounded" />
|
||||
<span className="h-5 w-16 bg-custom-background-80 rounded" />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex items-center space-x-2 overflow-hidden">
|
||||
<div className={cn("flex items-center overflow-hidden gap-0.5 flex-grow", className)}>
|
||||
{!isSmallScreen && (
|
||||
<>
|
||||
{childrenArray.map((child, index) => (
|
||||
<React.Fragment key={index}>
|
||||
{index > 0 && !isSmallScreen && (
|
||||
<div className="flex items-center gap-2.5">
|
||||
<ChevronRight className="h-3.5 w-3.5 flex-shrink-0 text-custom-text-400" aria-hidden="true" />
|
||||
</div>
|
||||
)}
|
||||
<div className={`flex items-center gap-2.5 ${isSmallScreen && index > 0 ? "hidden sm:flex" : "flex"}`}>
|
||||
{isLoading ? BreadcrumbItemLoader : child}
|
||||
</div>
|
||||
</React.Fragment>
|
||||
))}
|
||||
{childrenArray.map((child, index) => {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<>
|
||||
<BreadcrumbItemLoader />
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (React.isValidElement<BreadcrumbItemProps>(child)) {
|
||||
return React.cloneElement(child, {
|
||||
isLast: index === childrenArray.length - 1,
|
||||
});
|
||||
}
|
||||
return child;
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
|
||||
{isSmallScreen && childrenArray.length > 1 && (
|
||||
<>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex items-center gap-2.5 p-1">
|
||||
{onBack && (
|
||||
<span onClick={onBack} className="text-custom-text-200">
|
||||
...
|
||||
@@ -58,8 +66,16 @@ const Breadcrumbs = ({ children, onBack, isLoading = false }: BreadcrumbsProps)
|
||||
)}
|
||||
<ChevronRight className="h-3.5 w-3.5 flex-shrink-0 text-custom-text-400" aria-hidden="true" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2.5">
|
||||
{isLoading ? BreadcrumbItemLoader : childrenArray[childrenArray.length - 1]}
|
||||
<div className="flex items-center gap-2.5 p-1">
|
||||
{isLoading ? (
|
||||
<BreadcrumbItemLoader />
|
||||
) : React.isValidElement(childrenArray[childrenArray.length - 1]) ? (
|
||||
React.cloneElement(childrenArray[childrenArray.length - 1] as React.ReactElement, {
|
||||
isLast: true,
|
||||
})
|
||||
) : (
|
||||
childrenArray[childrenArray.length - 1]
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -68,17 +84,107 @@ const Breadcrumbs = ({ children, onBack, isLoading = false }: BreadcrumbsProps)
|
||||
);
|
||||
};
|
||||
|
||||
type Props = {
|
||||
type?: "text" | "component";
|
||||
// breadcrumb item
|
||||
type BreadcrumbItemProps = {
|
||||
component?: React.ReactNode;
|
||||
link?: JSX.Element;
|
||||
showSeparator?: boolean;
|
||||
isLast?: boolean;
|
||||
};
|
||||
|
||||
const BreadcrumbItem: React.FC<Props> = (props) => {
|
||||
const { type = "text", component, link } = props;
|
||||
return <>{type !== "text" ? <div className="flex items-center space-x-2">{component}</div> : link}</>;
|
||||
const BreadcrumbItem: React.FC<BreadcrumbItemProps> = (props) => {
|
||||
const { component, showSeparator = true, isLast = false } = props;
|
||||
return (
|
||||
<div className="flex items-center gap-0.5 h-6">
|
||||
{component}
|
||||
{showSeparator && !isLast && <BreadcrumbSeparator />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Breadcrumbs.BreadcrumbItem = BreadcrumbItem;
|
||||
// breadcrumb icon
|
||||
type BreadcrumbIconProps = {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export { Breadcrumbs, BreadcrumbItem };
|
||||
const BreadcrumbIcon: React.FC<BreadcrumbIconProps> = (props) => {
|
||||
const { children, className } = props;
|
||||
return <div className={cn("flex size-4 items-center justify-start overflow-hidden", className)}>{children}</div>;
|
||||
};
|
||||
|
||||
// breadcrumb label
|
||||
type BreadcrumbLabelProps = {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const BreadcrumbLabel: React.FC<BreadcrumbLabelProps> = (props) => {
|
||||
const { children, className } = props;
|
||||
return (
|
||||
<div className={cn("relative line-clamp-1 block max-w-[150px] overflow-hidden truncate", className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// breadcrumb separator
|
||||
type BreadcrumbSeparatorProps = {
|
||||
className?: string;
|
||||
containerClassName?: string;
|
||||
iconClassName?: string;
|
||||
showDivider?: boolean;
|
||||
};
|
||||
|
||||
const BreadcrumbSeparator: React.FC<BreadcrumbSeparatorProps> = (props) => {
|
||||
const { className, containerClassName, iconClassName, showDivider = false } = props;
|
||||
return (
|
||||
<div className={cn("relative flex items-center justify-center h-full px-1.5 py-1", className)}>
|
||||
{showDivider && <span className="absolute -left-0.5 top-0 h-full w-[1.8px] bg-custom-background-100" />}
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center flex-shrink-0 rounded text-custom-text-400 transition-all",
|
||||
containerClassName
|
||||
)}
|
||||
>
|
||||
<ChevronRight className={cn("h-3.5 w-3.5 flex-shrink-0", iconClassName)} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// breadcrumb wrapper
|
||||
type BreadcrumbItemWrapperProps = {
|
||||
label?: string;
|
||||
disableTooltip?: boolean;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
type?: "link" | "text";
|
||||
isLast?: boolean;
|
||||
};
|
||||
|
||||
const BreadcrumbItemWrapper: React.FC<BreadcrumbItemWrapperProps> = (props) => {
|
||||
const { label, disableTooltip = false, children, className, type = "link", isLast = false } = props;
|
||||
return (
|
||||
<Tooltip tooltipContent={label} position="bottom" disabled={!label || label === "" || disableTooltip}>
|
||||
<div
|
||||
className={cn(
|
||||
"group h-full flex items-center gap-2 rounded px-1.5 py-1 text-sm font-medium text-custom-text-300 cursor-default",
|
||||
{
|
||||
"hover:text-custom-text-100 hover:bg-custom-background-90 cursor-pointer": type === "link" && !isLast,
|
||||
},
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
Breadcrumbs.Item = BreadcrumbItem;
|
||||
Breadcrumbs.Icon = BreadcrumbIcon;
|
||||
Breadcrumbs.Label = BreadcrumbLabel;
|
||||
Breadcrumbs.Separator = BreadcrumbSeparator;
|
||||
Breadcrumbs.ItemWrapper = BreadcrumbItemWrapper;
|
||||
|
||||
export { Breadcrumbs, BreadcrumbItem, BreadcrumbIcon, BreadcrumbLabel, BreadcrumbSeparator, BreadcrumbItemWrapper };
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from "./breadcrumbs";
|
||||
export * from "./navigation-dropdown";
|
||||
export * from "./navigation-search-dropdown";
|
||||
|
||||
@@ -1,42 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import { CheckIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { CheckIcon, ChevronDownIcon } from "lucide-react";
|
||||
import { cn } from "../../helpers";
|
||||
// ui
|
||||
import { CustomMenu, TContextMenuItem } from "../dropdowns";
|
||||
// helpers
|
||||
import { cn } from "../../helpers";
|
||||
import { Tooltip } from "../tooltip";
|
||||
import { Breadcrumbs } from "./breadcrumbs";
|
||||
|
||||
type TBreadcrumbNavigationDropdownProps = {
|
||||
selectedItemKey: string;
|
||||
navigationItems: TContextMenuItem[];
|
||||
navigationDisabled?: boolean;
|
||||
handleOnClick?: () => void;
|
||||
isLast?: boolean;
|
||||
};
|
||||
|
||||
export const BreadcrumbNavigationDropdown = (props: TBreadcrumbNavigationDropdownProps) => {
|
||||
const { selectedItemKey, navigationItems, navigationDisabled = false } = props;
|
||||
const { selectedItemKey, navigationItems, navigationDisabled = false, handleOnClick, isLast = false } = props;
|
||||
const [isOpen, setIsOpen] = React.useState(false);
|
||||
// derived values
|
||||
const selectedItem = navigationItems.find((item) => item.key === selectedItemKey);
|
||||
const selectedItemIcon = selectedItem?.icon ? (
|
||||
<selectedItem.icon className={cn("size-3.5", selectedItem.iconClassName)} />
|
||||
<selectedItem.icon className={cn("size-4", selectedItem.iconClassName)} />
|
||||
) : undefined;
|
||||
|
||||
// if no selected item, return null
|
||||
if (!selectedItem) return null;
|
||||
|
||||
const NavigationButton = ({ className }: { className?: string }) => (
|
||||
<li
|
||||
className={cn(
|
||||
"flex items-center justify-center cursor-default text-sm font-medium text-custom-text-200 group-hover:text-custom-text-100 outline-none",
|
||||
className
|
||||
)}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{selectedItemIcon && (
|
||||
<div className="flex h-5 w-5 items-center justify-start overflow-hidden">{selectedItemIcon}</div>
|
||||
)}
|
||||
<div className="relative line-clamp-1 block max-w-[150px] overflow-hidden truncate">{selectedItem.title}</div>
|
||||
</li>
|
||||
const NavigationButton = () => (
|
||||
<Tooltip tooltipContent={selectedItem.title} position="bottom" disabled={isOpen}>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
if (!isLast) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleOnClick?.();
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"group h-full flex items-center gap-2 px-1.5 py-1 text-sm font-medium text-custom-text-300 cursor-pointer rounded rounded-r-none",
|
||||
{
|
||||
"hover:bg-custom-background-80 hover:text-custom-text-100": !isLast,
|
||||
}
|
||||
)}
|
||||
>
|
||||
{selectedItemIcon && <Breadcrumbs.Icon>{selectedItemIcon}</Breadcrumbs.Icon>}
|
||||
<Breadcrumbs.Label>{selectedItem.title}</Breadcrumbs.Label>
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
if (navigationDisabled) {
|
||||
@@ -46,13 +58,37 @@ export const BreadcrumbNavigationDropdown = (props: TBreadcrumbNavigationDropdow
|
||||
return (
|
||||
<CustomMenu
|
||||
customButton={
|
||||
<div className="group flex items-center gap-1.5">
|
||||
<NavigationButton className="cursor-pointer" />
|
||||
<ChevronDownIcon className="size-4 text-custom-text-200 group-hover:text-custom-text-100" />
|
||||
</div>
|
||||
<>
|
||||
<NavigationButton />
|
||||
<Breadcrumbs.Separator
|
||||
className={cn("rounded-r", {
|
||||
"bg-custom-background-80": isOpen && !isLast,
|
||||
"hover:bg-custom-background-80": !isLast,
|
||||
})}
|
||||
containerClassName="p-0"
|
||||
iconClassName={cn("group-hover:rotate-90 hover:text-custom-text-100", {
|
||||
"text-custom-text-100": isOpen,
|
||||
"rotate-90": isOpen || isLast,
|
||||
})}
|
||||
showDivider={!isLast}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
placement="bottom-start"
|
||||
className="h-full rounded"
|
||||
customButtonClassName={cn(
|
||||
"group flex items-center gap-0.5 rounded hover:bg-custom-background-90 outline-none cursor-pointer h-full rounded",
|
||||
{
|
||||
"bg-custom-background-90": isOpen,
|
||||
}
|
||||
)}
|
||||
closeOnSelect
|
||||
menuButtonOnClick={() => {
|
||||
setIsOpen(!isOpen);
|
||||
}}
|
||||
onMenuClose={() => {
|
||||
setIsOpen(false);
|
||||
}}
|
||||
>
|
||||
{navigationItems.map((item) => {
|
||||
if (item.shouldRender === false) return null;
|
||||
@@ -74,7 +110,7 @@ export const BreadcrumbNavigationDropdown = (props: TBreadcrumbNavigationDropdow
|
||||
)}
|
||||
disabled={item.disabled}
|
||||
>
|
||||
{item.icon && <item.icon className={cn("size-3.5", item.iconClassName)} />}
|
||||
{item.icon && <item.icon className={cn("size-4 flex-shrink-0", item.iconClassName)} />}
|
||||
<div className="w-full">
|
||||
<h5>{item.title}</h5>
|
||||
{item.description && (
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import * as React from "react";
|
||||
import { useState } from "react";
|
||||
import { ICustomSearchSelectOption } from "@plane/types";
|
||||
import { cn } from "../../helpers";
|
||||
import { CustomSearchSelect } from "../dropdowns";
|
||||
import { Tooltip } from "../tooltip";
|
||||
import { Breadcrumbs } from "./breadcrumbs";
|
||||
|
||||
type TBreadcrumbNavigationSearchDropdownProps = {
|
||||
icon?: React.JSX.Element;
|
||||
title?: string;
|
||||
selectedItem: string;
|
||||
navigationItems: ICustomSearchSelectOption[];
|
||||
onChange?: (value: string) => void;
|
||||
navigationDisabled?: boolean;
|
||||
isLast?: boolean;
|
||||
handleOnClick?: () => void;
|
||||
disableRootHover?: boolean;
|
||||
};
|
||||
|
||||
export const BreadcrumbNavigationSearchDropdown: React.FC<TBreadcrumbNavigationSearchDropdownProps> = (props) => {
|
||||
const {
|
||||
icon,
|
||||
title,
|
||||
selectedItem,
|
||||
navigationItems,
|
||||
onChange,
|
||||
navigationDisabled = false,
|
||||
isLast = false,
|
||||
handleOnClick,
|
||||
} = props;
|
||||
// state
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<CustomSearchSelect
|
||||
onOpen={() => {
|
||||
setIsDropdownOpen(true);
|
||||
}}
|
||||
onClose={() => {
|
||||
setIsDropdownOpen(false);
|
||||
}}
|
||||
options={navigationItems}
|
||||
value={selectedItem}
|
||||
onChange={(value: string) => {
|
||||
if (value !== selectedItem) {
|
||||
onChange?.(value);
|
||||
}
|
||||
}}
|
||||
customButton={
|
||||
<>
|
||||
<Tooltip tooltipContent={title} position="bottom">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
if (!isLast) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleOnClick?.();
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"group h-full flex items-center gap-2 px-1.5 py-1 text-sm font-medium text-custom-text-300 cursor-pointer rounded rounded-r-none",
|
||||
{
|
||||
"hover:bg-custom-background-80 hover:text-custom-text-100": !isLast,
|
||||
}
|
||||
)}
|
||||
>
|
||||
{icon && <Breadcrumbs.Icon>{icon}</Breadcrumbs.Icon>}
|
||||
<Breadcrumbs.Label>{title}</Breadcrumbs.Label>
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Breadcrumbs.Separator
|
||||
className={cn("rounded-r", {
|
||||
"bg-custom-background-80": isDropdownOpen && !isLast,
|
||||
"hover:bg-custom-background-80": !isLast,
|
||||
})}
|
||||
containerClassName="p-0"
|
||||
iconClassName={cn("group-hover:rotate-90 hover:text-custom-text-100", {
|
||||
"text-custom-text-100": isDropdownOpen,
|
||||
"rotate-90": isDropdownOpen || isLast,
|
||||
})}
|
||||
showDivider={!isLast}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
disabled={navigationDisabled}
|
||||
className="h-full rounded"
|
||||
customButtonClassName={cn(
|
||||
"group flex items-center gap-0.5 rounded hover:bg-custom-background-90 outline-none cursor-pointer h-full rounded",
|
||||
{
|
||||
"bg-custom-background-90": isDropdownOpen,
|
||||
}
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -61,6 +61,7 @@ export const CustomSearchSelect = (props: ICustomSearchSelectProps) => {
|
||||
const openDropdown = () => {
|
||||
setIsOpen(true);
|
||||
if (referenceElement) referenceElement.focus();
|
||||
if (onOpen) onOpen();
|
||||
};
|
||||
|
||||
const closeDropdown = () => {
|
||||
@@ -95,11 +96,14 @@ export const CustomSearchSelect = (props: ICustomSearchSelectProps) => {
|
||||
<button
|
||||
ref={setReferenceElement}
|
||||
type="button"
|
||||
className={`flex w-full items-center justify-between gap-1 text-xs ${
|
||||
disabled
|
||||
? "cursor-not-allowed text-custom-text-200"
|
||||
: "cursor-pointer hover:bg-custom-background-80"
|
||||
} ${customButtonClassName}`}
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between gap-1 text-xs",
|
||||
{
|
||||
"cursor-not-allowed text-custom-text-200": disabled,
|
||||
"cursor-pointer hover:bg-custom-background-80": !disabled,
|
||||
},
|
||||
customButtonClassName
|
||||
)}
|
||||
onClick={toggleDropdown}
|
||||
>
|
||||
{customButton}
|
||||
|
||||
@@ -38,7 +38,10 @@ const Header = (props: HeaderProps) => {
|
||||
|
||||
const LeftItem = (props: HeaderProps) => (
|
||||
<div
|
||||
className={cn("flex flex-wrap items-center gap-2 overflow-ellipsis whitespace-nowrap max-w-[80%]", props.className)}
|
||||
className={cn(
|
||||
"flex flex-wrap items-center gap-2 overflow-ellipsis whitespace-nowrap max-w-[80%] flex-grow",
|
||||
props.className
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
|
||||
@@ -44,6 +44,8 @@ export const getFileMetaDataForUpload = (file: File): TFileMetaDataLite => ({
|
||||
* @returns {string} assetId
|
||||
*/
|
||||
export const getAssetIdFromUrl = (src: string): string => {
|
||||
// remove the last char if it is a slash
|
||||
if (src.charAt(src.length - 1) === "/") src = src.slice(0, -1);
|
||||
const sourcePaths = src.split("/");
|
||||
const assetUrl = sourcePaths[sourcePaths.length - 1];
|
||||
return assetUrl;
|
||||
|
||||
@@ -146,6 +146,11 @@ export const createIssuePayload: (projectId: string, formData: Partial<TIssue>)
|
||||
id: uuidv4(),
|
||||
project_id: projectId,
|
||||
priority: "none",
|
||||
label_ids: [],
|
||||
assignee_ids: [],
|
||||
sub_issues_count: 0,
|
||||
attachment_count: 0,
|
||||
link_count: 0,
|
||||
// tempId is used for optimistic updates. It is not a part of the API response.
|
||||
tempId: uuidv4(),
|
||||
// to be overridden by the form data
|
||||
@@ -185,6 +190,7 @@ export const getIssueBlocksStructure = (block: TIssue): IGanttBlock => ({
|
||||
sort_order: block?.sort_order,
|
||||
start_date: block?.start_date ?? undefined,
|
||||
target_date: block?.target_date ?? undefined,
|
||||
project_id: block?.project_id ?? undefined,
|
||||
});
|
||||
|
||||
export const formatTextList = (TextArray: string[]): string => {
|
||||
@@ -260,7 +266,7 @@ export const getComputedDisplayFilters = (
|
||||
displayFilters: IIssueDisplayFilterOptions = {},
|
||||
defaultValues?: IIssueDisplayFilterOptions
|
||||
): IIssueDisplayFilterOptions => {
|
||||
const filters = displayFilters || defaultValues;
|
||||
const filters = !isEmpty(displayFilters) ? displayFilters : defaultValues;
|
||||
|
||||
return {
|
||||
calendar: {
|
||||
|
||||
Binary file not shown.
@@ -1,6 +1,6 @@
|
||||
import React from "react";
|
||||
// plane imports
|
||||
import { EditorRefApi, ILiteTextEditor, LiteTextEditorWithRef, TFileHandler } from "@plane/editor";
|
||||
import { EditorRefApi, ILiteTextEditorProps, LiteTextEditorWithRef, TFileHandler } from "@plane/editor";
|
||||
import { MakeOptional } from "@plane/types";
|
||||
import { cn } from "@plane/utils";
|
||||
// components
|
||||
@@ -10,7 +10,10 @@ import { getEditorFileHandlers } from "@/helpers/editor.helper";
|
||||
import { isCommentEmpty } from "@/helpers/string.helper";
|
||||
|
||||
interface LiteTextEditorWrapperProps
|
||||
extends MakeOptional<Omit<ILiteTextEditor, "fileHandler" | "mentionHandler">, "disabledExtensions"> {
|
||||
extends MakeOptional<
|
||||
Omit<ILiteTextEditorProps, "fileHandler" | "mentionHandler">,
|
||||
"disabledExtensions" | "flaggedExtensions"
|
||||
> {
|
||||
anchor: string;
|
||||
workspaceId: string;
|
||||
isSubmitting?: boolean;
|
||||
@@ -27,6 +30,7 @@ export const LiteTextEditor = React.forwardRef<EditorRefApi, LiteTextEditorWrapp
|
||||
showSubmitButton = true,
|
||||
uploadFile,
|
||||
disabledExtensions,
|
||||
flaggedExtensions,
|
||||
...rest
|
||||
} = props;
|
||||
function isMutableRefObject<T>(ref: React.ForwardedRef<T>): ref is React.MutableRefObject<T | null> {
|
||||
@@ -41,6 +45,7 @@ export const LiteTextEditor = React.forwardRef<EditorRefApi, LiteTextEditorWrapp
|
||||
<LiteTextEditorWithRef
|
||||
ref={ref}
|
||||
disabledExtensions={disabledExtensions ?? []}
|
||||
flaggedExtensions={flaggedExtensions ?? []}
|
||||
fileHandler={getEditorFileHandlers({
|
||||
anchor,
|
||||
uploadFile,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from "react";
|
||||
// plane imports
|
||||
import { EditorReadOnlyRefApi, ILiteTextReadOnlyEditor, LiteTextReadOnlyEditorWithRef } from "@plane/editor";
|
||||
import { EditorReadOnlyRefApi, ILiteTextReadOnlyEditorProps, LiteTextReadOnlyEditorWithRef } from "@plane/editor";
|
||||
import { MakeOptional } from "@plane/types";
|
||||
import { cn } from "@plane/utils";
|
||||
// components
|
||||
@@ -11,21 +11,22 @@ import { getReadOnlyEditorFileHandlers } from "@/helpers/editor.helper";
|
||||
import { useMember } from "@/hooks/store";
|
||||
|
||||
type LiteTextReadOnlyEditorWrapperProps = MakeOptional<
|
||||
Omit<ILiteTextReadOnlyEditor, "fileHandler" | "mentionHandler">,
|
||||
"disabledExtensions"
|
||||
Omit<ILiteTextReadOnlyEditorProps, "fileHandler" | "mentionHandler">,
|
||||
"disabledExtensions" | "flaggedExtensions"
|
||||
> & {
|
||||
anchor: string;
|
||||
workspaceId: string;
|
||||
};
|
||||
|
||||
export const LiteTextReadOnlyEditor = React.forwardRef<EditorReadOnlyRefApi, LiteTextReadOnlyEditorWrapperProps>(
|
||||
({ anchor, workspaceId, disabledExtensions, ...props }, ref) => {
|
||||
({ anchor, workspaceId, disabledExtensions, flaggedExtensions, ...props }, ref) => {
|
||||
const { getMemberById } = useMember();
|
||||
|
||||
return (
|
||||
<LiteTextReadOnlyEditorWithRef
|
||||
ref={ref}
|
||||
disabledExtensions={disabledExtensions ?? []}
|
||||
flaggedExtensions={flaggedExtensions ?? []}
|
||||
fileHandler={getReadOnlyEditorFileHandlers({
|
||||
anchor,
|
||||
workspaceId,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { forwardRef } from "react";
|
||||
// plane imports
|
||||
import { EditorRefApi, IRichTextEditor, RichTextEditorWithRef, TFileHandler } from "@plane/editor";
|
||||
import { EditorRefApi, IRichTextEditorProps, RichTextEditorWithRef, TFileHandler } from "@plane/editor";
|
||||
import { MakeOptional } from "@plane/types";
|
||||
// components
|
||||
import { EditorMentionsRoot } from "@/components/editor";
|
||||
@@ -10,14 +10,17 @@ import { getEditorFileHandlers } from "@/helpers/editor.helper";
|
||||
import { useMember } from "@/hooks/store";
|
||||
|
||||
interface RichTextEditorWrapperProps
|
||||
extends MakeOptional<Omit<IRichTextEditor, "fileHandler" | "mentionHandler">, "disabledExtensions"> {
|
||||
extends MakeOptional<
|
||||
Omit<IRichTextEditorProps, "fileHandler" | "mentionHandler">,
|
||||
"disabledExtensions" | "flaggedExtensions"
|
||||
> {
|
||||
anchor: string;
|
||||
uploadFile: TFileHandler["upload"];
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
export const RichTextEditor = forwardRef<EditorRefApi, RichTextEditorWrapperProps>((props, ref) => {
|
||||
const { anchor, containerClassName, uploadFile, workspaceId, disabledExtensions, ...rest } = props;
|
||||
const { anchor, containerClassName, uploadFile, workspaceId, disabledExtensions, flaggedExtensions, ...rest } = props;
|
||||
const { getMemberById } = useMember();
|
||||
return (
|
||||
<RichTextEditorWithRef
|
||||
@@ -34,6 +37,7 @@ export const RichTextEditor = forwardRef<EditorRefApi, RichTextEditorWrapperProp
|
||||
uploadFile,
|
||||
workspaceId,
|
||||
})}
|
||||
flaggedExtensions={flaggedExtensions ?? []}
|
||||
{...rest}
|
||||
containerClassName={containerClassName}
|
||||
editorClassName="min-h-[100px] max-h-[200px] border-[0.5px] border-custom-border-300 rounded-md pl-3 py-2 overflow-hidden"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from "react";
|
||||
// plane imports
|
||||
import { EditorReadOnlyRefApi, IRichTextReadOnlyEditor, RichTextReadOnlyEditorWithRef } from "@plane/editor";
|
||||
import { EditorReadOnlyRefApi, IRichTextReadOnlyEditorProps, RichTextReadOnlyEditorWithRef } from "@plane/editor";
|
||||
import { MakeOptional } from "@plane/types";
|
||||
import { cn } from "@plane/utils";
|
||||
// components
|
||||
@@ -11,21 +11,22 @@ import { getReadOnlyEditorFileHandlers } from "@/helpers/editor.helper";
|
||||
import { useMember } from "@/hooks/store";
|
||||
|
||||
type RichTextReadOnlyEditorWrapperProps = MakeOptional<
|
||||
Omit<IRichTextReadOnlyEditor, "fileHandler" | "mentionHandler">,
|
||||
"disabledExtensions"
|
||||
Omit<IRichTextReadOnlyEditorProps, "fileHandler" | "mentionHandler">,
|
||||
"disabledExtensions" | "flaggedExtensions"
|
||||
> & {
|
||||
anchor: string;
|
||||
workspaceId: string;
|
||||
};
|
||||
|
||||
export const RichTextReadOnlyEditor = React.forwardRef<EditorReadOnlyRefApi, RichTextReadOnlyEditorWrapperProps>(
|
||||
({ anchor, workspaceId, disabledExtensions, ...props }, ref) => {
|
||||
({ anchor, workspaceId, disabledExtensions, flaggedExtensions, ...props }, ref) => {
|
||||
const { getMemberById } = useMember();
|
||||
|
||||
return (
|
||||
<RichTextReadOnlyEditorWithRef
|
||||
ref={ref}
|
||||
disabledExtensions={disabledExtensions ?? []}
|
||||
flaggedExtensions={flaggedExtensions ?? []}
|
||||
fileHandler={getReadOnlyEditorFileHandlers({
|
||||
anchor,
|
||||
workspaceId,
|
||||
|
||||
@@ -14,18 +14,17 @@ export const WorkspaceActiveCycleHeader = observer(() => {
|
||||
<Header>
|
||||
<Header.LeftItem>
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="text"
|
||||
link={
|
||||
<BreadcrumbLink
|
||||
label={t("active_cycles")}
|
||||
icon={<ContrastIcon className="h-4 w-4 text-custom-text-300 rotate-180" />}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
<UpgradeBadge size="md" />
|
||||
</Header.LeftItem>
|
||||
</Header>
|
||||
<Breadcrumbs.Item
|
||||
component={
|
||||
<BreadcrumbLink
|
||||
label={t("active_cycles")}
|
||||
icon={<ContrastIcon className="h-4 w-4 text-custom-text-300 rotate-180" />}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
<UpgradeBadge size="md" />
|
||||
</Header.LeftItem>
|
||||
</Header>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,47 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { BarChart2, PanelRight } from "lucide-react";
|
||||
import { BarChart2 } from "lucide-react";
|
||||
// plane imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Breadcrumbs, Header } from "@plane/ui";
|
||||
import { cn } from "@plane/utils";
|
||||
// components
|
||||
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
|
||||
// hooks
|
||||
import { useAppTheme } from "@/hooks/store";
|
||||
|
||||
export const WorkspaceAnalyticsHeader = observer(() => {
|
||||
const { t } = useTranslation();
|
||||
const searchParams = useSearchParams();
|
||||
const analytics_tab = searchParams.get("analytics_tab");
|
||||
// store hooks
|
||||
const { workspaceAnalyticsSidebarCollapsed, toggleWorkspaceAnalyticsSidebar } = useAppTheme();
|
||||
|
||||
useEffect(() => {
|
||||
const handleToggleWorkspaceAnalyticsSidebar = () => {
|
||||
if (window && window.innerWidth < 768) {
|
||||
toggleWorkspaceAnalyticsSidebar(true);
|
||||
}
|
||||
if (window && workspaceAnalyticsSidebarCollapsed && window.innerWidth >= 768) {
|
||||
toggleWorkspaceAnalyticsSidebar(false);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("resize", handleToggleWorkspaceAnalyticsSidebar);
|
||||
handleToggleWorkspaceAnalyticsSidebar();
|
||||
return () => window.removeEventListener("resize", handleToggleWorkspaceAnalyticsSidebar);
|
||||
}, [toggleWorkspaceAnalyticsSidebar, workspaceAnalyticsSidebarCollapsed]);
|
||||
|
||||
return (
|
||||
<Header>
|
||||
<Header.LeftItem>
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="text"
|
||||
link={
|
||||
<Breadcrumbs.Item
|
||||
component={
|
||||
<BreadcrumbLink
|
||||
label={t("workspace_analytics.label")}
|
||||
icon={<BarChart2 className="h-4 w-4 text-custom-text-300" />}
|
||||
@@ -49,23 +23,6 @@ export const WorkspaceAnalyticsHeader = observer(() => {
|
||||
}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
{analytics_tab === "custom" ? (
|
||||
<button
|
||||
className="block md:hidden"
|
||||
onClick={() => {
|
||||
toggleWorkspaceAnalyticsSidebar();
|
||||
}}
|
||||
>
|
||||
<PanelRight
|
||||
className={cn(
|
||||
"block h-4 w-4 md:hidden",
|
||||
!workspaceAnalyticsSidebarCollapsed ? "text-custom-primary-100" : "text-custom-text-200"
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</Header.LeftItem>
|
||||
</Header>
|
||||
);
|
||||
|
||||
@@ -2,23 +2,22 @@
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { Briefcase } from "lucide-react";
|
||||
// plane imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Breadcrumbs, LayersIcon, Header, Logo } from "@plane/ui";
|
||||
import { EProjectFeatureKey } from "@plane/constants";
|
||||
import { Breadcrumbs, Header } from "@plane/ui";
|
||||
// components
|
||||
import { BreadcrumbLink } from "@/components/common";
|
||||
import { IssueDetailQuickActions } from "@/components/issues";
|
||||
// hooks
|
||||
import { useIssueDetail, useProject } from "@/hooks/store";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common";
|
||||
|
||||
export const ProjectIssueDetailsHeader = observer(() => {
|
||||
// router
|
||||
const router = useAppRouter();
|
||||
const { workspaceSlug, workItem } = useParams();
|
||||
// store hooks
|
||||
const { t } = useTranslation();
|
||||
const { getProjectById, loader } = useProject();
|
||||
const {
|
||||
issue: { getIssueById, getIssueIdByIdentifier },
|
||||
@@ -34,53 +33,20 @@ export const ProjectIssueDetailsHeader = observer(() => {
|
||||
return (
|
||||
<Header>
|
||||
<Header.LeftItem>
|
||||
<div>
|
||||
<Breadcrumbs onBack={router.back} isLoading={loader === "init-loader"}>
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="text"
|
||||
link={
|
||||
<BreadcrumbLink
|
||||
label={projectDetails?.name ?? "Project"}
|
||||
icon={
|
||||
projectDetails ? (
|
||||
projectDetails && (
|
||||
<span className="grid place-items-center flex-shrink-0 h-4 w-4">
|
||||
<Logo logo={projectDetails?.logo_props} size={16} />
|
||||
</span>
|
||||
)
|
||||
) : (
|
||||
<span className="grid place-items-center flex-shrink-0 h-4 w-4">
|
||||
<Briefcase className="h-4 w-4" />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="text"
|
||||
link={
|
||||
<BreadcrumbLink
|
||||
href={`/${workspaceSlug}/projects/${projectId}/issues`}
|
||||
label={t("common.work_items")}
|
||||
icon={<LayersIcon className="h-4 w-4 text-custom-text-300" />}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="text"
|
||||
link={
|
||||
<BreadcrumbLink
|
||||
label={
|
||||
projectDetails && issueDetails ? `${projectDetails.identifier}-${issueDetails.sequence_id}` : ""
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
</div>
|
||||
<Breadcrumbs onBack={router.back} isLoading={loader === "init-loader"}>
|
||||
<CommonProjectBreadcrumbs
|
||||
workspaceSlug={workspaceSlug?.toString()}
|
||||
projectId={projectId?.toString()}
|
||||
featureKey={EProjectFeatureKey.WORK_ITEMS}
|
||||
/>
|
||||
<Breadcrumbs.Item
|
||||
component={
|
||||
<BreadcrumbLink
|
||||
label={projectDetails && issueDetails ? `${projectDetails.identifier}-${issueDetails.sequence_id}` : ""}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
</Header.LeftItem>
|
||||
<Header.RightItem>
|
||||
{projectId && issueId && (
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useParams } from "next/navigation";
|
||||
import { useTheme } from "next-themes";
|
||||
import useSWR from "swr";
|
||||
// plane imports
|
||||
import { EIssueServiceType } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Loader } from "@plane/ui";
|
||||
// components
|
||||
@@ -16,6 +17,7 @@ import { IssueDetailRoot } from "@/components/issues";
|
||||
import { useAppTheme, useIssueDetail, useProject } from "@/hooks/store";
|
||||
// assets
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
import { useWorkItemProperties } from "@/plane-web/hooks/use-issue-properties";
|
||||
import { ProjectAuthWrapper } from "@/plane-web/layouts/project-wrapper";
|
||||
import emptyIssueDark from "@/public/empty-state/search/issues-dark.webp";
|
||||
import emptyIssueLight from "@/public/empty-state/search/issues-light.webp";
|
||||
@@ -53,6 +55,13 @@ const IssueDetailsPage = observer(() => {
|
||||
const issueLoader = !issue || isLoading;
|
||||
const pageTitle = project && issue ? `${project?.identifier}-${issue?.sequence_id} ${issue?.name}` : undefined;
|
||||
|
||||
useWorkItemProperties(
|
||||
projectId,
|
||||
workspaceSlug.toString(),
|
||||
issueId,
|
||||
issue?.is_epic ? EIssueServiceType.EPICS : EIssueServiceType.ISSUES
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleToggleIssueDetailSidebar = () => {
|
||||
if (window && window.innerWidth < 768) {
|
||||
|
||||
@@ -41,9 +41,8 @@ export const WorkspaceDraftHeader = observer(() => {
|
||||
<Header.LeftItem>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="text"
|
||||
link={
|
||||
<Breadcrumbs.Item
|
||||
component={
|
||||
<BreadcrumbLink label={t("drafts")} icon={<PenSquare className="h-4 w-4 text-custom-text-300" />} />
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -28,9 +28,8 @@ export const WorkspaceDashboardHeader = () => {
|
||||
<Header.LeftItem>
|
||||
<div>
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="text"
|
||||
link={
|
||||
<Breadcrumbs.Item
|
||||
component={
|
||||
<BreadcrumbLink label={t("home.title")} icon={<Home className="h-4 w-4 text-custom-text-300" />} />
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -52,9 +52,8 @@ export const UserProfileHeader: FC<TUserProfileHeader> = observer((props) => {
|
||||
<Header>
|
||||
<Header.LeftItem>
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="text"
|
||||
link={
|
||||
<Breadcrumbs.Item
|
||||
component={
|
||||
<BreadcrumbLink
|
||||
label={breadcrumbLabel}
|
||||
disableTooltip
|
||||
|
||||
+5
-7
@@ -66,10 +66,9 @@ export const ProjectArchivesHeader: FC<TProps> = observer((props: TProps) => {
|
||||
<Header.LeftItem>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Breadcrumbs onBack={router.back} isLoading={loader === "init-loader"}>
|
||||
<ProjectBreadcrumb />
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="text"
|
||||
link={
|
||||
<ProjectBreadcrumb workspaceSlug={workspaceSlug?.toString()} projectId={projectId?.toString()} />
|
||||
<Breadcrumbs.Item
|
||||
component={
|
||||
<BreadcrumbLink
|
||||
href={`/${workspaceSlug}/projects/${projectId}/archives/issues`}
|
||||
label="Archives"
|
||||
@@ -78,9 +77,8 @@ export const ProjectArchivesHeader: FC<TProps> = observer((props: TProps) => {
|
||||
}
|
||||
/>
|
||||
{activeTabBreadcrumbDetail && (
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="text"
|
||||
link={
|
||||
<Breadcrumbs.Item
|
||||
component={
|
||||
<BreadcrumbLink
|
||||
label={activeTabBreadcrumbDetail.label}
|
||||
icon={<activeTabBreadcrumbDetail.icon className="h-4 w-4 text-custom-text-300" />}
|
||||
|
||||
+7
-10
@@ -36,10 +36,9 @@ export const ProjectArchivedIssueDetailsHeader = observer(() => {
|
||||
<Header>
|
||||
<Header.LeftItem>
|
||||
<Breadcrumbs isLoading={loader === "init-loader"}>
|
||||
<ProjectBreadcrumb />
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="text"
|
||||
link={
|
||||
<ProjectBreadcrumb workspaceSlug={workspaceSlug?.toString()} projectId={projectId?.toString()} />
|
||||
<Breadcrumbs.Item
|
||||
component={
|
||||
<BreadcrumbLink
|
||||
href={`/${workspaceSlug}/projects/${projectId}/archives/issues`}
|
||||
label="Archives"
|
||||
@@ -47,9 +46,8 @@ export const ProjectArchivedIssueDetailsHeader = observer(() => {
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="text"
|
||||
link={
|
||||
<Breadcrumbs.Item
|
||||
component={
|
||||
<BreadcrumbLink
|
||||
href={`/${workspaceSlug}/projects/${projectId}/archives/issues`}
|
||||
label="Work items"
|
||||
@@ -57,9 +55,8 @@ export const ProjectArchivedIssueDetailsHeader = observer(() => {
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="text"
|
||||
link={
|
||||
<Breadcrumbs.Item
|
||||
component={
|
||||
<BreadcrumbLink
|
||||
label={
|
||||
currentProjectDetails && issueDetails
|
||||
|
||||
+33
-52
@@ -2,7 +2,6 @@
|
||||
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
// icons
|
||||
import { PanelRight } from "lucide-react";
|
||||
@@ -13,8 +12,10 @@ import {
|
||||
EIssuesStoreType,
|
||||
EUserPermissions,
|
||||
EUserPermissionsLevel,
|
||||
EProjectFeatureKey,
|
||||
ISSUE_DISPLAY_FILTERS_BY_PAGE,
|
||||
} from "@plane/constants";
|
||||
import { usePlatformOS } from "@plane/hooks";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import {
|
||||
ICustomSearchSelectOption,
|
||||
@@ -22,11 +23,11 @@ import {
|
||||
IIssueDisplayProperties,
|
||||
IIssueFilterOptions,
|
||||
} from "@plane/types";
|
||||
import { Breadcrumbs, Button, ContrastIcon, CustomSearchSelect, Header, Tooltip } from "@plane/ui";
|
||||
import { Breadcrumbs, Button, ContrastIcon, BreadcrumbNavigationSearchDropdown, Header, Tooltip } from "@plane/ui";
|
||||
import { cn, isIssueFilterActive } from "@plane/utils";
|
||||
// components
|
||||
import { WorkItemsModal } from "@/components/analytics/work-items/modal";
|
||||
import { BreadcrumbLink, SwitcherLabel } from "@/components/common";
|
||||
import { SwitcherLabel } from "@/components/common";
|
||||
import { CycleQuickActions } from "@/components/cycles";
|
||||
import { DisplayFiltersSelection, FiltersDropdown, FilterSelection, LayoutSelection } from "@/components/issues";
|
||||
// hooks
|
||||
@@ -43,9 +44,8 @@ import {
|
||||
} from "@/hooks/store";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
import useLocalStorage from "@/hooks/use-local-storage";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
// plane web imports
|
||||
import { ProjectBreadcrumb } from "@/plane-web/components/breadcrumbs";
|
||||
import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common";
|
||||
|
||||
export const CycleIssuesHeader: React.FC = observer(() => {
|
||||
// refs
|
||||
@@ -166,63 +166,44 @@ export const CycleIssuesHeader: React.FC = observer(() => {
|
||||
<Header.LeftItem>
|
||||
<div className="flex items-center gap-2">
|
||||
<Breadcrumbs onBack={router.back} isLoading={loader === "init-loader"}>
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="text"
|
||||
link={
|
||||
<span>
|
||||
<span className="hidden md:block">
|
||||
<ProjectBreadcrumb />
|
||||
</span>
|
||||
<Link
|
||||
href={`/${workspaceSlug}/projects/${currentProjectDetails?.id}/issues`}
|
||||
className="block pl-2 text-custom-text-300 md:hidden"
|
||||
>
|
||||
...
|
||||
</Link>
|
||||
</span>
|
||||
}
|
||||
<CommonProjectBreadcrumbs
|
||||
workspaceSlug={workspaceSlug?.toString()}
|
||||
projectId={projectId?.toString()}
|
||||
featureKey={EProjectFeatureKey.CYCLES}
|
||||
/>
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="text"
|
||||
link={
|
||||
<BreadcrumbLink
|
||||
label={t("common.cycles")}
|
||||
href={`/${workspaceSlug}/projects/${projectId}/cycles`}
|
||||
icon={<ContrastIcon className="h-4 w-4 text-custom-text-300" />}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="component"
|
||||
<Breadcrumbs.Item
|
||||
component={
|
||||
<CustomSearchSelect
|
||||
options={switcherOptions}
|
||||
value={cycleId}
|
||||
<BreadcrumbNavigationSearchDropdown
|
||||
selectedItem={cycleId}
|
||||
navigationItems={switcherOptions}
|
||||
onChange={(value: string) => {
|
||||
router.push(`/${workspaceSlug}/projects/${projectId}/cycles/${value}`);
|
||||
}}
|
||||
label={
|
||||
<div className="flex items-center gap-1">
|
||||
<SwitcherLabel name={cycleDetails?.name} LabelIcon={ContrastIcon} />
|
||||
{workItemsCount && workItemsCount > 0 ? (
|
||||
<Tooltip
|
||||
isMobile={isMobile}
|
||||
tooltipContent={`There are ${workItemsCount} ${
|
||||
workItemsCount > 1 ? "work items" : "work item"
|
||||
} in this cycle`}
|
||||
position="bottom"
|
||||
>
|
||||
<span className="flex flex-shrink-0 cursor-default items-center justify-center rounded-xl bg-custom-primary-100/20 px-2 text-center text-xs font-semibold text-custom-primary-100">
|
||||
{workItemsCount}
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</div>
|
||||
title={cycleDetails?.name}
|
||||
icon={
|
||||
<Breadcrumbs.Icon>
|
||||
<ContrastIcon className="size-4 flex-shrink-0 text-custom-text-300" />
|
||||
</Breadcrumbs.Icon>
|
||||
}
|
||||
isLast
|
||||
/>
|
||||
}
|
||||
isLast
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
{workItemsCount && workItemsCount > 0 ? (
|
||||
<Tooltip
|
||||
isMobile={isMobile}
|
||||
tooltipContent={`There are ${workItemsCount} ${
|
||||
workItemsCount > 1 ? "work items" : "work item"
|
||||
} in this cycle`}
|
||||
position="bottom"
|
||||
>
|
||||
<span className="flex flex-shrink-0 cursor-default items-center justify-center rounded-xl bg-custom-primary-100/20 px-2 text-center text-xs font-semibold text-custom-primary-100">
|
||||
{workItemsCount}
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</div>
|
||||
</Header.LeftItem>
|
||||
<Header.RightItem className="items-center">
|
||||
|
||||
+11
-13
@@ -2,23 +2,25 @@
|
||||
|
||||
import { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// ui
|
||||
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
||||
import { EProjectFeatureKey, EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Breadcrumbs, Button, ContrastIcon, Header } from "@plane/ui";
|
||||
import { Breadcrumbs, Button, Header } from "@plane/ui";
|
||||
// components
|
||||
import { BreadcrumbLink } from "@/components/common";
|
||||
import { CyclesViewHeader } from "@/components/cycles";
|
||||
// hooks
|
||||
import { useCommandPalette, useEventTracker, useProject, useUserPermissions } from "@/hooks/store";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
// plane web
|
||||
import { ProjectBreadcrumb } from "@/plane-web/components/breadcrumbs";
|
||||
import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common";
|
||||
// constants
|
||||
|
||||
export const CyclesListHeader: FC = observer(() => {
|
||||
// router
|
||||
const router = useAppRouter();
|
||||
const { workspaceSlug } = useParams();
|
||||
|
||||
// store hooks
|
||||
const { toggleCreateCycleModal } = useCommandPalette();
|
||||
const { setTrackElement } = useEventTracker();
|
||||
@@ -35,15 +37,11 @@ export const CyclesListHeader: FC = observer(() => {
|
||||
<Header>
|
||||
<Header.LeftItem>
|
||||
<Breadcrumbs onBack={router.back} isLoading={loader === "init-loader"}>
|
||||
<ProjectBreadcrumb />
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="text"
|
||||
link={
|
||||
<BreadcrumbLink
|
||||
label={t("cycle.label", { count: 2 })}
|
||||
icon={<ContrastIcon className="h-4 w-4 text-custom-text-300" />}
|
||||
/>
|
||||
}
|
||||
<CommonProjectBreadcrumbs
|
||||
workspaceSlug={workspaceSlug?.toString()}
|
||||
projectId={currentProjectDetails?.id ?? ""}
|
||||
featureKey={EProjectFeatureKey.CYCLES}
|
||||
isLast
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
</Header.LeftItem>
|
||||
|
||||
+3
-4
@@ -93,11 +93,10 @@ export const ProjectDraftIssueHeader: FC = observer(() => {
|
||||
<div className="flex w-full flex-grow items-center gap-2 overflow-ellipsis whitespace-nowrap">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Breadcrumbs isLoading={loader === "init-loader"}>
|
||||
<ProjectBreadcrumb />
|
||||
<ProjectBreadcrumb workspaceSlug={workspaceSlug} projectId={projectId} />
|
||||
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="text"
|
||||
link={
|
||||
<Breadcrumbs.Item
|
||||
component={
|
||||
<BreadcrumbLink
|
||||
label="Draft work items"
|
||||
icon={<LayersIcon className="h-4 w-4 text-custom-text-300" />}
|
||||
|
||||
+39
-61
@@ -2,7 +2,6 @@
|
||||
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
// icons
|
||||
import { PanelRight } from "lucide-react";
|
||||
@@ -14,6 +13,7 @@ import {
|
||||
ISSUE_DISPLAY_FILTERS_BY_PAGE,
|
||||
EUserPermissions,
|
||||
EUserPermissionsLevel,
|
||||
EProjectFeatureKey,
|
||||
} from "@plane/constants";
|
||||
import {
|
||||
ICustomSearchSelectOption,
|
||||
@@ -21,11 +21,11 @@ import {
|
||||
IIssueDisplayProperties,
|
||||
IIssueFilterOptions,
|
||||
} from "@plane/types";
|
||||
import { Breadcrumbs, Button, DiceIcon, Tooltip, Header, CustomSearchSelect } from "@plane/ui";
|
||||
import { Breadcrumbs, Button, DiceIcon, Header, BreadcrumbNavigationSearchDropdown, Tooltip } from "@plane/ui";
|
||||
import { cn, isIssueFilterActive } from "@plane/utils";
|
||||
// components
|
||||
import { WorkItemsModal } from "@/components/analytics/work-items/modal";
|
||||
import { BreadcrumbLink, SwitcherLabel } from "@/components/common";
|
||||
import { SwitcherLabel } from "@/components/common";
|
||||
import { DisplayFiltersSelection, FiltersDropdown, FilterSelection, LayoutSelection } from "@/components/issues";
|
||||
// helpers
|
||||
import { ModuleQuickActions } from "@/components/modules";
|
||||
@@ -46,7 +46,7 @@ import { useIssuesActions } from "@/hooks/use-issues-actions";
|
||||
import useLocalStorage from "@/hooks/use-local-storage";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
// plane web
|
||||
import { ProjectBreadcrumb } from "@/plane-web/components/breadcrumbs";
|
||||
import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs";
|
||||
|
||||
export const ModuleIssuesHeader: React.FC = observer(() => {
|
||||
// refs
|
||||
@@ -160,64 +160,42 @@ export const ModuleIssuesHeader: React.FC = observer(() => {
|
||||
/>
|
||||
<Header>
|
||||
<Header.LeftItem>
|
||||
<Breadcrumbs onBack={router.back} isLoading={loader === "init-loader"}>
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="text"
|
||||
link={
|
||||
<span>
|
||||
<span className="hidden md:block">
|
||||
<ProjectBreadcrumb />
|
||||
</span>
|
||||
<Link
|
||||
href={`/${workspaceSlug}/projects/${currentProjectDetails?.id}/issues`}
|
||||
className="block pl-2 text-custom-text-300 md:hidden"
|
||||
>
|
||||
...
|
||||
</Link>
|
||||
<div className="flex items-center gap-2 flex-grow">
|
||||
<Breadcrumbs onBack={router.back} isLoading={loader === "init-loader"}>
|
||||
<CommonProjectBreadcrumbs
|
||||
workspaceSlug={workspaceSlug?.toString() ?? ""}
|
||||
projectId={projectId?.toString() ?? ""}
|
||||
featureKey={EProjectFeatureKey.MODULES}
|
||||
/>
|
||||
<Breadcrumbs.Item
|
||||
component={
|
||||
<BreadcrumbNavigationSearchDropdown
|
||||
selectedItem={moduleId?.toString() ?? ""}
|
||||
navigationItems={switcherOptions}
|
||||
onChange={(value: string) => {
|
||||
router.push(`/${workspaceSlug}/projects/${projectId}/modules/${value}`);
|
||||
}}
|
||||
title={moduleDetails?.name}
|
||||
icon={<DiceIcon className="size-3.5 flex-shrink-0 text-custom-text-300" />}
|
||||
isLast
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
{workItemsCount && workItemsCount > 0 ? (
|
||||
<Tooltip
|
||||
isMobile={isMobile}
|
||||
tooltipContent={`There are ${workItemsCount} ${
|
||||
workItemsCount > 1 ? "work items" : "work item"
|
||||
} in this module`}
|
||||
position="bottom"
|
||||
>
|
||||
<span className="flex flex-shrink-0 cursor-default items-center justify-center rounded-xl bg-custom-primary-100/20 px-2 text-center text-xs font-semibold text-custom-primary-100">
|
||||
{workItemsCount}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="text"
|
||||
link={
|
||||
<BreadcrumbLink
|
||||
href={`/${workspaceSlug}/projects/${projectId}/modules`}
|
||||
label="Modules"
|
||||
icon={<DiceIcon className="h-4 w-4 text-custom-text-300" />}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="component"
|
||||
component={
|
||||
<CustomSearchSelect
|
||||
options={switcherOptions}
|
||||
label={
|
||||
<div className="flex items-center gap-1">
|
||||
<SwitcherLabel name={moduleDetails?.name} LabelIcon={DiceIcon} />
|
||||
{workItemsCount && workItemsCount > 0 ? (
|
||||
<Tooltip
|
||||
isMobile={isMobile}
|
||||
tooltipContent={`There are ${workItemsCount} ${
|
||||
workItemsCount > 1 ? "work items" : "work item"
|
||||
} in this module`}
|
||||
position="bottom"
|
||||
>
|
||||
<span className="flex flex-shrink-0 cursor-default items-center justify-center rounded-xl bg-custom-primary-100/20 px-2 text-center text-xs font-semibold text-custom-primary-100">
|
||||
{workItemsCount}
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</div>
|
||||
}
|
||||
value={moduleId}
|
||||
onChange={(value: string) => {
|
||||
router.push(`/${workspaceSlug}/projects/${projectId}/modules/${value}`);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</div>
|
||||
</Header.LeftItem>
|
||||
<Header.RightItem className="items-center">
|
||||
<div className="hidden gap-2 md:flex">
|
||||
|
||||
+10
-10
@@ -1,24 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// plane imports
|
||||
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
||||
import { EProjectFeatureKey, EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// ui
|
||||
import { Breadcrumbs, Button, DiceIcon, Header } from "@plane/ui";
|
||||
import { Breadcrumbs, Button, Header } from "@plane/ui";
|
||||
// components
|
||||
import { BreadcrumbLink } from "@/components/common";
|
||||
import { ModuleViewHeader } from "@/components/modules";
|
||||
// hooks
|
||||
import { useCommandPalette, useEventTracker, useProject, useUserPermissions } from "@/hooks/store";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
// plane web
|
||||
import { ProjectBreadcrumb } from "@/plane-web/components/breadcrumbs";
|
||||
import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs";
|
||||
// constants
|
||||
|
||||
export const ModulesListHeader: React.FC = observer(() => {
|
||||
// router
|
||||
const router = useAppRouter();
|
||||
const { workspaceSlug, projectId } = useParams() as { workspaceSlug: string; projectId: string };
|
||||
// store hooks
|
||||
const { toggleCreateModuleModal } = useCommandPalette();
|
||||
const { setTrackElement } = useEventTracker();
|
||||
@@ -39,12 +40,11 @@ export const ModulesListHeader: React.FC = observer(() => {
|
||||
<Header.LeftItem>
|
||||
<div>
|
||||
<Breadcrumbs onBack={router.back} isLoading={loader === "init-loader"}>
|
||||
<ProjectBreadcrumb />
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="text"
|
||||
link={
|
||||
<BreadcrumbLink label={t("modules")} icon={<DiceIcon className="h-4 w-4 text-custom-text-300" />} />
|
||||
}
|
||||
<CommonProjectBreadcrumbs
|
||||
workspaceSlug={workspaceSlug?.toString() ?? ""}
|
||||
projectId={projectId?.toString() ?? ""}
|
||||
featureKey={EProjectFeatureKey.MODULES}
|
||||
isLast
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
</div>
|
||||
|
||||
+21
-38
@@ -2,20 +2,21 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { FileText } from "lucide-react";
|
||||
import { EProjectFeatureKey } from "@plane/constants";
|
||||
// types
|
||||
import { ICustomSearchSelectOption } from "@plane/types";
|
||||
// ui
|
||||
import { Breadcrumbs, Header, CustomSearchSelect } from "@plane/ui";
|
||||
import { Breadcrumbs, Header, BreadcrumbNavigationSearchDropdown } from "@plane/ui";
|
||||
// components
|
||||
import { getPageName } from "@plane/utils";
|
||||
import { BreadcrumbLink, PageAccessIcon, SwitcherLabel } from "@/components/common";
|
||||
import { PageAccessIcon, SwitcherIcon, SwitcherLabel } from "@/components/common";
|
||||
import { PageHeaderActions } from "@/components/pages/header/actions";
|
||||
// helpers
|
||||
// hooks
|
||||
import { useProject } from "@/hooks/store";
|
||||
// plane web components
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
import { ProjectBreadcrumb } from "@/plane-web/components/breadcrumbs";
|
||||
import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common";
|
||||
import { PageDetailsHeaderExtraActions } from "@/plane-web/components/pages";
|
||||
// plane web hooks
|
||||
import { EPageStoreType, usePage, usePageStore } from "@/plane-web/hooks/store";
|
||||
@@ -31,7 +32,7 @@ export const PageDetailsHeader = observer(() => {
|
||||
const router = useAppRouter();
|
||||
const { workspaceSlug, pageId, projectId } = useParams();
|
||||
// store hooks
|
||||
const { currentProjectDetails, loader } = useProject();
|
||||
const { loader } = useProject();
|
||||
const { getPageById, getCurrentProjectPageIds } = usePageStore(storeType);
|
||||
const page = usePage({
|
||||
pageId: pageId?.toString() ?? "",
|
||||
@@ -64,45 +65,27 @@ export const PageDetailsHeader = observer(() => {
|
||||
<Header.LeftItem>
|
||||
<div>
|
||||
<Breadcrumbs isLoading={loader === "init-loader"}>
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="text"
|
||||
link={
|
||||
<span>
|
||||
<span className="hidden md:block">
|
||||
<ProjectBreadcrumb />
|
||||
</span>
|
||||
<span className="md:hidden">
|
||||
<BreadcrumbLink
|
||||
href={`/${workspaceSlug}/projects/${currentProjectDetails?.id}/issues`}
|
||||
label={"..."}
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
<CommonProjectBreadcrumbs
|
||||
workspaceSlug={workspaceSlug?.toString()}
|
||||
projectId={projectId?.toString()}
|
||||
featureKey={EProjectFeatureKey.PAGES}
|
||||
/>
|
||||
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="text"
|
||||
link={
|
||||
<BreadcrumbLink
|
||||
href={`/${workspaceSlug}/projects/${currentProjectDetails?.id}/pages`}
|
||||
label="Pages"
|
||||
icon={<FileText className="h-4 w-4 text-custom-text-300" />}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="component"
|
||||
<Breadcrumbs.Item
|
||||
component={
|
||||
<CustomSearchSelect
|
||||
value={pageId}
|
||||
options={switcherOptions}
|
||||
label={
|
||||
<SwitcherLabel logo_props={page.logo_props} name={getPageName(page.name)} LabelIcon={FileText} />
|
||||
}
|
||||
<BreadcrumbNavigationSearchDropdown
|
||||
selectedItem={pageId?.toString() ?? ""}
|
||||
navigationItems={switcherOptions}
|
||||
onChange={(value: string) => {
|
||||
router.push(`/${workspaceSlug}/projects/${projectId}/pages/${value}`);
|
||||
}}
|
||||
title={page?.name}
|
||||
icon={
|
||||
<Breadcrumbs.Icon>
|
||||
<SwitcherIcon logo_props={page.logo_props} LabelIcon={FileText} size={16} />
|
||||
</Breadcrumbs.Icon>
|
||||
}
|
||||
isLast
|
||||
/>
|
||||
}
|
||||
/>
|
||||
@@ -110,7 +93,7 @@ export const PageDetailsHeader = observer(() => {
|
||||
</div>
|
||||
</Header.LeftItem>
|
||||
<Header.RightItem>
|
||||
<PageDetailsHeaderExtraActions page={page} />
|
||||
<PageDetailsHeaderExtraActions page={page} storeType={storeType} />
|
||||
<PageHeaderActions page={page} storeType={storeType} />
|
||||
</Header.RightItem>
|
||||
</Header>
|
||||
|
||||
+10
-14
@@ -3,19 +3,16 @@
|
||||
import { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||
import { FileText } from "lucide-react";
|
||||
// constants
|
||||
import { EPageAccess } from "@plane/constants";
|
||||
import { EPageAccess, EProjectFeatureKey } from "@plane/constants";
|
||||
// plane types
|
||||
import { TPage } from "@plane/types";
|
||||
// plane ui
|
||||
import { Breadcrumbs, Button, Header, setToast, TOAST_TYPE } from "@plane/ui";
|
||||
// helpers
|
||||
import { BreadcrumbLink } from "@/components/common";
|
||||
// hooks
|
||||
import { useEventTracker, useProject } from "@/hooks/store";
|
||||
// plane web
|
||||
import { ProjectBreadcrumb } from "@/plane-web/components/breadcrumbs";
|
||||
import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs";
|
||||
// plane web hooks
|
||||
import { EPageStoreType, usePageStore } from "@/plane-web/hooks/store";
|
||||
|
||||
@@ -58,15 +55,14 @@ export const PagesListHeader = observer(() => {
|
||||
return (
|
||||
<Header>
|
||||
<Header.LeftItem>
|
||||
<div>
|
||||
<Breadcrumbs isLoading={loader === "init-loader"}>
|
||||
<ProjectBreadcrumb />
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="text"
|
||||
link={<BreadcrumbLink label="Pages" icon={<FileText className="h-4 w-4 text-custom-text-300" />} />}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
</div>
|
||||
<Breadcrumbs isLoading={loader === "init-loader"}>
|
||||
<CommonProjectBreadcrumbs
|
||||
workspaceSlug={workspaceSlug?.toString() ?? ""}
|
||||
projectId={currentProjectDetails?.id?.toString() ?? ""}
|
||||
featureKey={EProjectFeatureKey.PAGES}
|
||||
isLast
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
</Header.LeftItem>
|
||||
{canCurrentUserCreatePage ? (
|
||||
<Header.RightItem>
|
||||
|
||||
+20
-19
@@ -13,6 +13,7 @@ import {
|
||||
EViewAccess,
|
||||
EUserPermissions,
|
||||
EUserPermissionsLevel,
|
||||
EProjectFeatureKey,
|
||||
} from "@plane/constants";
|
||||
// types
|
||||
import {
|
||||
@@ -22,10 +23,10 @@ import {
|
||||
IIssueFilterOptions,
|
||||
} from "@plane/types";
|
||||
// ui
|
||||
import { Breadcrumbs, Button, Tooltip, Header, CustomSearchSelect } from "@plane/ui";
|
||||
import { Breadcrumbs, Button, Tooltip, Header, BreadcrumbNavigationSearchDropdown } from "@plane/ui";
|
||||
// components
|
||||
import { isIssueFilterActive } from "@plane/utils";
|
||||
import { BreadcrumbLink, SwitcherLabel } from "@/components/common";
|
||||
import { SwitcherIcon, SwitcherLabel } from "@/components/common";
|
||||
import { DisplayFiltersSelection, FiltersDropdown, FilterSelection, LayoutSelection } from "@/components/issues";
|
||||
// constants
|
||||
import { ViewQuickActions } from "@/components/views";
|
||||
@@ -44,7 +45,7 @@ import {
|
||||
} from "@/hooks/store";
|
||||
// plane web
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
import { ProjectBreadcrumb } from "@/plane-web/components/breadcrumbs";
|
||||
import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs";
|
||||
|
||||
export const ProjectViewIssuesHeader: React.FC = observer(() => {
|
||||
// refs
|
||||
@@ -164,27 +165,27 @@ export const ProjectViewIssuesHeader: React.FC = observer(() => {
|
||||
<Header>
|
||||
<Header.LeftItem>
|
||||
<Breadcrumbs isLoading={loader === "init-loader"}>
|
||||
<ProjectBreadcrumb />
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="text"
|
||||
link={
|
||||
<BreadcrumbLink
|
||||
href={`/${workspaceSlug}/projects/${currentProjectDetails?.id}/views`}
|
||||
label="Views"
|
||||
icon={<Layers className="h-4 w-4 text-custom-text-300" />}
|
||||
/>
|
||||
}
|
||||
<CommonProjectBreadcrumbs
|
||||
workspaceSlug={workspaceSlug?.toString() ?? ""}
|
||||
projectId={projectId?.toString() ?? ""}
|
||||
featureKey={EProjectFeatureKey.VIEWS}
|
||||
/>
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="component"
|
||||
|
||||
<Breadcrumbs.Item
|
||||
component={
|
||||
<CustomSearchSelect
|
||||
options={switcherOptions}
|
||||
value={viewId}
|
||||
label={<SwitcherLabel logo_props={viewDetails.logo_props} name={viewDetails.name} LabelIcon={Layers} />}
|
||||
<BreadcrumbNavigationSearchDropdown
|
||||
selectedItem={viewId?.toString() ?? ""}
|
||||
navigationItems={switcherOptions}
|
||||
onChange={(value: string) => {
|
||||
router.push(`/${workspaceSlug}/projects/${projectId}/views/${value}`);
|
||||
}}
|
||||
title={viewDetails?.name}
|
||||
icon={
|
||||
<Breadcrumbs.Icon>
|
||||
<SwitcherIcon logo_props={viewDetails.logo_props} LabelIcon={Layers} size={16} />
|
||||
</Breadcrumbs.Icon>
|
||||
}
|
||||
isLast
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
+9
-7
@@ -1,18 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import { Layers } from "lucide-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// ui
|
||||
import { EProjectFeatureKey } from "@plane/constants";
|
||||
import { Breadcrumbs, Button, Header } from "@plane/ui";
|
||||
// components
|
||||
import { BreadcrumbLink } from "@/components/common";
|
||||
import { ViewListHeader } from "@/components/views";
|
||||
// hooks
|
||||
import { useCommandPalette, useProject } from "@/hooks/store";
|
||||
// plane web
|
||||
import { ProjectBreadcrumb } from "@/plane-web/components/breadcrumbs";
|
||||
import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs";
|
||||
|
||||
export const ProjectViewsHeader = observer(() => {
|
||||
const { workspaceSlug, projectId } = useParams() as { workspaceSlug: string; projectId: string };
|
||||
// store hooks
|
||||
const { toggleCreateViewModal } = useCommandPalette();
|
||||
const { loader } = useProject();
|
||||
@@ -22,10 +23,11 @@ export const ProjectViewsHeader = observer(() => {
|
||||
<Header>
|
||||
<Header.LeftItem>
|
||||
<Breadcrumbs isLoading={loader === "init-loader"}>
|
||||
<ProjectBreadcrumb />
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="text"
|
||||
link={<BreadcrumbLink label="Views" icon={<Layers className="h-4 w-4 text-custom-text-300" />} />}
|
||||
<CommonProjectBreadcrumbs
|
||||
workspaceSlug={workspaceSlug?.toString() ?? ""}
|
||||
projectId={projectId?.toString() ?? ""}
|
||||
featureKey={EProjectFeatureKey.VIEWS}
|
||||
isLast
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
</Header.LeftItem>
|
||||
|
||||
@@ -25,9 +25,8 @@ export const WorkspaceStickyHeader = observer(() => {
|
||||
<Header.LeftItem>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="text"
|
||||
link={
|
||||
<Breadcrumbs.Item
|
||||
component={
|
||||
<BreadcrumbLink
|
||||
label={`Stickies`}
|
||||
icon={<RecentStickyIcon className="size-5 rotate-90 text-custom-text-200" />}
|
||||
|
||||
@@ -8,7 +8,6 @@ import { DEFAULT_GLOBAL_VIEWS_LIST } from "@plane/constants";
|
||||
// components
|
||||
import { PageHead } from "@/components/core";
|
||||
import { AllIssueLayoutRoot, GlobalViewsAppliedFiltersRoot } from "@/components/issues";
|
||||
import { GlobalViewsHeader } from "@/components/workspace";
|
||||
// constants
|
||||
// hooks
|
||||
import { useWorkspace } from "@/hooks/store";
|
||||
@@ -32,7 +31,6 @@ const GlobalViewIssuesPage = observer(() => {
|
||||
<PageHead title={pageTitle} />
|
||||
<div className="h-full overflow-hidden bg-custom-background-100">
|
||||
<div className="flex h-full w-full flex-col border-b border-custom-border-300">
|
||||
<GlobalViewsHeader />
|
||||
{globalViewId && (
|
||||
<GlobalViewsAppliedFiltersRoot globalViewId={globalViewId.toString()} isLoading={isLoading} />
|
||||
)}
|
||||
|
||||
@@ -1,35 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { Layers } from "lucide-react";
|
||||
// plane constants
|
||||
import { EIssueFilterType, EIssuesStoreType, ISSUE_DISPLAY_FILTERS_BY_PAGE } from "@plane/constants";
|
||||
import {
|
||||
DEFAULT_GLOBAL_VIEWS_LIST,
|
||||
EIssueFilterType,
|
||||
EIssuesStoreType,
|
||||
ISSUE_DISPLAY_FILTERS_BY_PAGE,
|
||||
EIssueLayoutTypes
|
||||
} from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// types
|
||||
import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions } from "@plane/types";
|
||||
import {
|
||||
ICustomSearchSelectOption,
|
||||
IIssueDisplayFilterOptions,
|
||||
IIssueDisplayProperties,
|
||||
IIssueFilterOptions,
|
||||
} from "@plane/types";
|
||||
// ui
|
||||
import { Breadcrumbs, Button, Header } from "@plane/ui";
|
||||
import { Breadcrumbs, Button, Header, BreadcrumbNavigationSearchDropdown } from "@plane/ui";
|
||||
// components
|
||||
import { isIssueFilterActive } from "@plane/utils";
|
||||
import { BreadcrumbLink } from "@/components/common";
|
||||
import { BreadcrumbLink, SwitcherLabel } from "@/components/common";
|
||||
import { DisplayFiltersSelection, FiltersDropdown, FilterSelection } from "@/components/issues";
|
||||
import { CreateUpdateWorkspaceViewModal } from "@/components/workspace";
|
||||
import {
|
||||
CreateUpdateWorkspaceViewModal,
|
||||
WorkspaceViewQuickActions,
|
||||
DefaultWorkspaceViewQuickActions,
|
||||
} from "@/components/workspace";
|
||||
// helpers
|
||||
// hooks
|
||||
import { useLabel, useMember, useIssues, useGlobalView } from "@/hooks/store";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
import { GlobalViewLayoutSelection } from "@/plane-web/components/views/helper";
|
||||
|
||||
export const GlobalIssuesHeader = observer(() => {
|
||||
// states
|
||||
const [createViewModal, setCreateViewModal] = useState(false);
|
||||
// router
|
||||
const router = useAppRouter();
|
||||
const { workspaceSlug, globalViewId } = useParams();
|
||||
// store hooks
|
||||
const {
|
||||
issuesFilter: { filters, updateFilters },
|
||||
} = useIssues(EIssuesStoreType.GLOBAL);
|
||||
const { getViewDetailsById } = useGlobalView();
|
||||
const { getViewDetailsById, currentWorkspaceViews } = useGlobalView();
|
||||
const { workspaceLabels } = useLabel();
|
||||
const {
|
||||
workspace: { workspaceMemberIds },
|
||||
@@ -38,6 +56,7 @@ export const GlobalIssuesHeader = observer(() => {
|
||||
|
||||
const issueFilters = globalViewId ? filters[globalViewId.toString()] : undefined;
|
||||
|
||||
const activeLayout = issueFilters?.displayFilters?.layout;
|
||||
const viewDetails = getViewDetailsById(globalViewId.toString());
|
||||
|
||||
const handleFiltersUpdate = useCallback(
|
||||
@@ -95,17 +114,79 @@ export const GlobalIssuesHeader = observer(() => {
|
||||
[workspaceSlug, updateFilters, globalViewId]
|
||||
);
|
||||
|
||||
const handleLayoutChange = useCallback(
|
||||
(layout: EIssueLayoutTypes) => {
|
||||
if (!workspaceSlug || !globalViewId) return;
|
||||
updateFilters(
|
||||
workspaceSlug.toString(),
|
||||
undefined,
|
||||
EIssueFilterType.DISPLAY_FILTERS,
|
||||
{ layout: layout },
|
||||
globalViewId.toString()
|
||||
);
|
||||
},
|
||||
[workspaceSlug, updateFilters, globalViewId]
|
||||
);
|
||||
|
||||
const isLocked = viewDetails?.is_locked;
|
||||
|
||||
const isDefaultView = DEFAULT_GLOBAL_VIEWS_LIST.find((view) => view.key === globalViewId);
|
||||
|
||||
const defaultViewDetails = DEFAULT_GLOBAL_VIEWS_LIST.find((view) => view.key === globalViewId);
|
||||
|
||||
const defaultOptions = DEFAULT_GLOBAL_VIEWS_LIST.map((view) => ({
|
||||
value: view.key,
|
||||
query: view.key,
|
||||
content: <SwitcherLabel name={t(view.i18n_label)} LabelIcon={Layers} />,
|
||||
}));
|
||||
|
||||
const workspaceOptions = (currentWorkspaceViews || []).map((view) => {
|
||||
const _view = getViewDetailsById(view);
|
||||
if (!_view) return;
|
||||
return {
|
||||
value: _view.id,
|
||||
query: _view.name,
|
||||
content: <SwitcherLabel name={_view.name} LabelIcon={Layers} />,
|
||||
};
|
||||
});
|
||||
|
||||
const switcherOptions = [...defaultOptions, ...workspaceOptions].filter(
|
||||
(option) => option !== undefined
|
||||
) as ICustomSearchSelectOption[];
|
||||
const currentLayoutFilters = useMemo(() => {
|
||||
const layout = activeLayout ?? EIssueLayoutTypes.SPREADSHEET;
|
||||
return ISSUE_DISPLAY_FILTERS_BY_PAGE.my_issues[layout];
|
||||
}, [activeLayout]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<CreateUpdateWorkspaceViewModal isOpen={createViewModal} onClose={() => setCreateViewModal(false)} />
|
||||
<Header>
|
||||
<Header.LeftItem>
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="text"
|
||||
link={<BreadcrumbLink label={t("views")} icon={<Layers className="h-4 w-4 text-custom-text-300" />} />}
|
||||
<Breadcrumbs.Item
|
||||
component={
|
||||
<BreadcrumbLink label={t("views")} icon={<Layers className="h-4 w-4 text-custom-text-300" />} />
|
||||
}
|
||||
/>
|
||||
<Breadcrumbs.Item
|
||||
component={
|
||||
<BreadcrumbNavigationSearchDropdown
|
||||
selectedItem={globalViewId?.toString() || ""}
|
||||
navigationItems={switcherOptions}
|
||||
onChange={(value: string) => {
|
||||
router.push(`/${workspaceSlug}/workspace-views/${value}`);
|
||||
}}
|
||||
title={viewDetails?.name ?? t(defaultViewDetails?.i18n_label ?? "")}
|
||||
icon={
|
||||
<Breadcrumbs.Icon>
|
||||
<Layers className="size-4 flex-shrink-0 text-custom-text-300" />
|
||||
</Breadcrumbs.Icon>
|
||||
}
|
||||
isLast
|
||||
/>
|
||||
}
|
||||
isLast
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
</Header.LeftItem>
|
||||
@@ -113,13 +194,18 @@ export const GlobalIssuesHeader = observer(() => {
|
||||
<Header.RightItem>
|
||||
{!isLocked ? (
|
||||
<>
|
||||
<GlobalViewLayoutSelection
|
||||
onChange={handleLayoutChange}
|
||||
selectedLayout={activeLayout ?? EIssueLayoutTypes.SPREADSHEET}
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
/>
|
||||
<FiltersDropdown
|
||||
title={t("common.filters")}
|
||||
placement="bottom-end"
|
||||
isFiltersApplied={isIssueFilterActive(issueFilters)}
|
||||
>
|
||||
<FilterSelection
|
||||
layoutDisplayFiltersOptions={ISSUE_DISPLAY_FILTERS_BY_PAGE.my_issues.spreadsheet}
|
||||
layoutDisplayFiltersOptions={currentLayoutFilters}
|
||||
filters={issueFilters?.filters ?? {}}
|
||||
handleFiltersUpdate={handleFiltersUpdate}
|
||||
displayFilters={issueFilters?.displayFilters ?? {}}
|
||||
@@ -130,7 +216,7 @@ export const GlobalIssuesHeader = observer(() => {
|
||||
</FiltersDropdown>
|
||||
<FiltersDropdown title={t("common.display")} placement="bottom-end">
|
||||
<DisplayFiltersSelection
|
||||
layoutDisplayFiltersOptions={ISSUE_DISPLAY_FILTERS_BY_PAGE.my_issues.spreadsheet}
|
||||
layoutDisplayFiltersOptions={currentLayoutFilters}
|
||||
displayFilters={issueFilters?.displayFilters ?? {}}
|
||||
handleDisplayFiltersUpdate={handleDisplayFilters}
|
||||
displayProperties={issueFilters?.displayProperties ?? {}}
|
||||
@@ -145,6 +231,12 @@ export const GlobalIssuesHeader = observer(() => {
|
||||
<Button variant="primary" size="sm" onClick={() => setCreateViewModal(true)}>
|
||||
{t("workspace_views.add_view")}
|
||||
</Button>
|
||||
<div className="hidden md:block">
|
||||
{viewDetails && <WorkspaceViewQuickActions workspaceSlug={workspaceSlug?.toString()} view={viewDetails} />}
|
||||
{isDefaultView && defaultViewDetails && (
|
||||
<DefaultWorkspaceViewQuickActions workspaceSlug={workspaceSlug?.toString()} view={defaultViewDetails} />
|
||||
)}
|
||||
</div>
|
||||
</Header.RightItem>
|
||||
</Header>
|
||||
</>
|
||||
|
||||
@@ -2,24 +2,21 @@
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import useSWR from "swr";
|
||||
// plane imports
|
||||
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// component
|
||||
import { APITokenService } from "@plane/services";
|
||||
import { ApiTokenListItem, CreateApiTokenModal } from "@/components/api-token";
|
||||
import { NotAuthorizedView } from "@/components/auth-screens";
|
||||
import { PageHead } from "@/components/core";
|
||||
import { DetailedEmptyState } from "@/components/empty-state";
|
||||
import { SettingsHeading } from "@/components/settings";
|
||||
import { APITokenSettingsLoader } from "@/components/ui";
|
||||
import { API_TOKENS_LIST } from "@/constants/fetch-keys";
|
||||
// store hooks
|
||||
import { useUserPermissions, useWorkspace } from "@/hooks/store";
|
||||
import { useWorkspace } from "@/hooks/store";
|
||||
import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path";
|
||||
// services
|
||||
import { APITokenService } from "@/services/api_token.service";
|
||||
|
||||
const apiTokenService = new APITokenService();
|
||||
|
||||
@@ -27,30 +24,19 @@ const ApiTokensPage = observer(() => {
|
||||
// states
|
||||
const [isCreateTokenModalOpen, setIsCreateTokenModalOpen] = useState(false);
|
||||
// router
|
||||
const { workspaceSlug } = useParams();
|
||||
// plane hooks
|
||||
const { t } = useTranslation();
|
||||
// store hooks
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const { workspaceUserInfo, allowPermissions } = useUserPermissions();
|
||||
// derived values
|
||||
const canPerformWorkspaceAdminActions = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE);
|
||||
const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/workspace-settings/api-tokens" });
|
||||
|
||||
const { data: tokens } = useSWR(
|
||||
workspaceSlug && canPerformWorkspaceAdminActions ? API_TOKENS_LIST(workspaceSlug.toString()) : null,
|
||||
() =>
|
||||
workspaceSlug && canPerformWorkspaceAdminActions ? apiTokenService.getApiTokens(workspaceSlug.toString()) : null
|
||||
);
|
||||
const { data: tokens } = useSWR(API_TOKENS_LIST, () => apiTokenService.list());
|
||||
|
||||
const pageTitle = currentWorkspace?.name
|
||||
? `${currentWorkspace.name} - ${t("workspace_settings.settings.api_tokens.title")}`
|
||||
: undefined;
|
||||
|
||||
if (workspaceUserInfo && !canPerformWorkspaceAdminActions) {
|
||||
return <NotAuthorizedView section="settings" className="h-auto" />;
|
||||
}
|
||||
|
||||
if (!tokens) {
|
||||
return <APITokenSettingsLoader />;
|
||||
}
|
||||
|
||||
@@ -2,18 +2,12 @@ import { observer } from "mobx-react";
|
||||
import { useParams, usePathname } from "next/navigation";
|
||||
import { CircleUser, Activity, Bell, CircleUserRound, KeyRound, Settings2, Blocks, Lock } from "lucide-react";
|
||||
// plane imports
|
||||
import {
|
||||
EUserPermissions,
|
||||
EUserPermissionsLevel,
|
||||
GROUPED_PROFILE_SETTINGS,
|
||||
PROFILE_SETTINGS_CATEGORIES,
|
||||
PROFILE_SETTINGS_CATEGORY,
|
||||
} from "@plane/constants";
|
||||
import { GROUPED_PROFILE_SETTINGS, PROFILE_SETTINGS_CATEGORIES } from "@plane/constants";
|
||||
import { getFileURL } from "@plane/utils";
|
||||
// components
|
||||
import { SettingsSidebar } from "@/components/settings";
|
||||
// hooks
|
||||
import { useUser, useUserPermissions } from "@/hooks/store/user";
|
||||
import { useUser } from "@/hooks/store/user";
|
||||
|
||||
const ICONS = {
|
||||
profile: CircleUser,
|
||||
@@ -44,14 +38,10 @@ export const ProfileSidebar = observer((props: TProfileSidebarProps) => {
|
||||
// store hooks
|
||||
const { data: currentUser } = useUser();
|
||||
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
const isAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE);
|
||||
return (
|
||||
<SettingsSidebar
|
||||
isMobile={isMobile}
|
||||
categories={PROFILE_SETTINGS_CATEGORIES.filter(
|
||||
(category) => isAdmin || category !== PROFILE_SETTINGS_CATEGORY.DEVELOPER
|
||||
)}
|
||||
categories={PROFILE_SETTINGS_CATEGORIES}
|
||||
groupedSettings={GROUPED_PROFILE_SETTINGS}
|
||||
workspaceSlug={workspaceSlug?.toString() ?? ""}
|
||||
isActive={(data: { href: string }) => pathname === `/${workspaceSlug}${data.href}/`}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import { FC } from "react";
|
||||
// plane imports
|
||||
import { EProjectFeatureKey } from "@plane/constants";
|
||||
// local components
|
||||
import { ProjectFeatureBreadcrumb } from "./project-feature";
|
||||
import { ProjectBreadcrumb } from "./project";
|
||||
|
||||
type TCommonProjectBreadcrumbProps = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
featureKey?: EProjectFeatureKey;
|
||||
isLast?: boolean;
|
||||
};
|
||||
|
||||
export const CommonProjectBreadcrumbs: FC<TCommonProjectBreadcrumbProps> = (props) => {
|
||||
const { workspaceSlug, projectId, featureKey, isLast = false } = props;
|
||||
return (
|
||||
<>
|
||||
<ProjectBreadcrumb workspaceSlug={workspaceSlug} projectId={projectId} />
|
||||
{featureKey && (
|
||||
<ProjectFeatureBreadcrumb
|
||||
workspaceSlug={workspaceSlug?.toString()}
|
||||
projectId={projectId?.toString()}
|
||||
featureKey={featureKey}
|
||||
isLast={isLast}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1 +1,3 @@
|
||||
export * from "./common";
|
||||
export * from "./project-feature";
|
||||
export * from "./project";
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// ui
|
||||
import { EProjectFeatureKey } from "@plane/constants";
|
||||
import { BreadcrumbNavigationDropdown, Breadcrumbs, ISvgIcons } from "@plane/ui";
|
||||
// components
|
||||
import { SwitcherLabel } from "@/components/common";
|
||||
import { TNavigationItem } from "@/components/workspace";
|
||||
// hooks
|
||||
import { useProject } from "@/hooks/store";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
// local components
|
||||
import { getProjectFeatureNavigation } from "../projects/navigation";
|
||||
|
||||
type TProjectFeatureBreadcrumbProps = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
featureKey: EProjectFeatureKey;
|
||||
isLast?: boolean;
|
||||
additionalNavigationItems?: TNavigationItem[];
|
||||
};
|
||||
|
||||
export const ProjectFeatureBreadcrumb = observer((props: TProjectFeatureBreadcrumbProps) => {
|
||||
const { workspaceSlug, projectId, featureKey, isLast = false, additionalNavigationItems } = props;
|
||||
// router
|
||||
const router = useAppRouter();
|
||||
// store hooks
|
||||
const { getPartialProjectById } = useProject();
|
||||
// derived values
|
||||
const project = getPartialProjectById(projectId);
|
||||
|
||||
if (!project) return null;
|
||||
|
||||
const navigationItems = getProjectFeatureNavigation(workspaceSlug, projectId, project);
|
||||
|
||||
// if additional navigation items are provided, add them to the navigation items
|
||||
const allNavigationItems = [...(additionalNavigationItems || []), ...navigationItems];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Breadcrumbs.Item
|
||||
component={
|
||||
<BreadcrumbNavigationDropdown
|
||||
selectedItemKey={featureKey}
|
||||
navigationItems={allNavigationItems
|
||||
.filter((item) => item.shouldRender)
|
||||
.map((item) => ({
|
||||
key: item.key,
|
||||
title: item.name,
|
||||
customContent: <SwitcherLabel name={item.name} LabelIcon={item.icon as FC<ISvgIcons>} />,
|
||||
action: () => router.push(item.href),
|
||||
icon: item.icon as FC<ISvgIcons>,
|
||||
}))}
|
||||
handleOnClick={() => {
|
||||
router.push(
|
||||
`/${workspaceSlug}/projects/${projectId}/${featureKey === EProjectFeatureKey.WORK_ITEMS ? "issues" : featureKey}/`
|
||||
);
|
||||
}}
|
||||
isLast={isLast}
|
||||
/>
|
||||
}
|
||||
showSeparator={false}
|
||||
isLast={isLast}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -2,38 +2,73 @@
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import { Briefcase } from "lucide-react";
|
||||
// ui
|
||||
import { Breadcrumbs, Logo } from "@plane/ui";
|
||||
// plane imports
|
||||
import { ICustomSearchSelectOption } from "@plane/types";
|
||||
import { BreadcrumbNavigationSearchDropdown, Breadcrumbs, Logo } from "@plane/ui";
|
||||
// components
|
||||
import { BreadcrumbLink } from "@/components/common";
|
||||
import { SwitcherLabel } from "@/components/common";
|
||||
// hooks
|
||||
import { useProject } from "@/hooks/store";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
import { TProject } from "@/plane-web/types";
|
||||
|
||||
export const ProjectBreadcrumb = observer(() => {
|
||||
type TProjectBreadcrumbProps = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
handleOnClick?: () => void;
|
||||
};
|
||||
|
||||
export const ProjectBreadcrumb = observer((props: TProjectBreadcrumbProps) => {
|
||||
const { workspaceSlug, projectId, handleOnClick } = props;
|
||||
// router
|
||||
const router = useAppRouter();
|
||||
// store hooks
|
||||
const { currentProjectDetails } = useProject();
|
||||
const { joinedProjectIds, getPartialProjectById } = useProject();
|
||||
const currentProjectDetails = getPartialProjectById(projectId);
|
||||
|
||||
// store hooks
|
||||
|
||||
if (!currentProjectDetails) return null;
|
||||
|
||||
// derived values
|
||||
const switcherOptions = joinedProjectIds
|
||||
.map((projectId) => {
|
||||
const project = getPartialProjectById(projectId);
|
||||
return {
|
||||
value: projectId,
|
||||
query: project?.name,
|
||||
content: <SwitcherLabel name={project?.name} logo_props={project?.logo_props} LabelIcon={Briefcase} />,
|
||||
};
|
||||
})
|
||||
.filter((option) => option !== undefined) as ICustomSearchSelectOption[];
|
||||
|
||||
// helpers
|
||||
const renderIcon = (projectDetails: TProject) => (
|
||||
<span className="grid place-items-center flex-shrink-0 h-4 w-4">
|
||||
<Logo logo={projectDetails.logo_props} size={14} />
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="text"
|
||||
link={
|
||||
<BreadcrumbLink
|
||||
label={currentProjectDetails?.name ?? "Project"}
|
||||
icon={
|
||||
currentProjectDetails ? (
|
||||
currentProjectDetails && (
|
||||
<span className="grid place-items-center flex-shrink-0 h-4 w-4">
|
||||
<Logo logo={currentProjectDetails?.logo_props} size={16} />
|
||||
</span>
|
||||
)
|
||||
) : (
|
||||
<span className="grid h-7 w-7 flex-shrink-0 place-items-center rounded uppercase">
|
||||
<Briefcase className="h-4 w-4" />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<>
|
||||
<Breadcrumbs.Item
|
||||
component={
|
||||
<BreadcrumbNavigationSearchDropdown
|
||||
selectedItem={currentProjectDetails.id}
|
||||
navigationItems={switcherOptions}
|
||||
onChange={(value: string) => {
|
||||
router.push(`/${workspaceSlug}/projects/${value}/issues`);
|
||||
}}
|
||||
title={currentProjectDetails?.name}
|
||||
icon={renderIcon(currentProjectDetails)}
|
||||
handleOnClick={() => {
|
||||
if (handleOnClick) handleOnClick();
|
||||
else router.push(`/${workspaceSlug}/projects/${currentProjectDetails.id}/issues/`);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
showSeparator={false}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -4,13 +4,20 @@ import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// icons
|
||||
import { Circle, ExternalLink } from "lucide-react";
|
||||
import { EIssuesStoreType, EUserPermissions, EUserPermissionsLevel, SPACE_BASE_PATH, SPACE_BASE_URL } from "@plane/constants";
|
||||
import {
|
||||
EIssuesStoreType,
|
||||
EProjectFeatureKey,
|
||||
EUserPermissions,
|
||||
EUserPermissionsLevel,
|
||||
SPACE_BASE_PATH,
|
||||
SPACE_BASE_URL,
|
||||
} from "@plane/constants";
|
||||
// plane constants
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// ui
|
||||
import { Breadcrumbs, Button, LayersIcon, Tooltip, Header } from "@plane/ui";
|
||||
import { Breadcrumbs, Button, Tooltip, Header } from "@plane/ui";
|
||||
// components
|
||||
import { BreadcrumbLink, CountChip } from "@/components/common";
|
||||
import { CountChip } from "@/components/common";
|
||||
// constants
|
||||
import HeaderFilters from "@/components/issues/filters";
|
||||
// helpers
|
||||
@@ -20,7 +27,7 @@ import { useIssues } from "@/hooks/store/use-issues";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
// plane web
|
||||
import { ProjectBreadcrumb } from "@/plane-web/components/breadcrumbs";
|
||||
import { CommonProjectBreadcrumbs } from "../breadcrumbs/common";
|
||||
|
||||
export const IssuesHeader = observer(() => {
|
||||
// router
|
||||
@@ -52,18 +59,13 @@ export const IssuesHeader = observer(() => {
|
||||
return (
|
||||
<Header>
|
||||
<Header.LeftItem>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Breadcrumbs onBack={() => router.back()} isLoading={loader === "init-loader"}>
|
||||
<ProjectBreadcrumb />
|
||||
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="text"
|
||||
link={
|
||||
<BreadcrumbLink
|
||||
label={t("issue.label", { count: 2 })} // count is for pluralization
|
||||
icon={<LayersIcon className="h-4 w-4 text-custom-text-300" />}
|
||||
/>
|
||||
}
|
||||
<div className="flex items-center gap-2.5 flex-grow">
|
||||
<Breadcrumbs onBack={() => router.back()} isLoading={loader === "init-loader"} className="flex-grow-0">
|
||||
<CommonProjectBreadcrumbs
|
||||
workspaceSlug={workspaceSlug?.toString()}
|
||||
projectId={projectId?.toString()}
|
||||
featureKey={EProjectFeatureKey.WORK_ITEMS}
|
||||
isLast
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
{issuesCount && issuesCount > 0 ? (
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user