Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 397cecc3a1 | |||
| e70105235b | |||
| 7766e8b5cf | |||
| 16d63abcdc | |||
| 0568b8d583 | |||
| 64da29b0d9 | |||
| 7c336a65c4 | |||
| 2242a85e5c | |||
| 323920a358 | |||
| 151fc8389e | |||
| 0f828fd5e0 | |||
| 67cbe94d4a | |||
| 322af8c436 | |||
| 41c2aefad4 | |||
| 445c819fbd | |||
| 046a8a1bcf | |||
| 099a1cc12b | |||
| a0a697401b | |||
| cb92108bf4 | |||
| 01b685ea57 | |||
| b16a585102 | |||
| 4a97d7c28c | |||
| 141cb17e8a | |||
| 26b62c4a70 | |||
| e388a9a279 | |||
| a3a580923c | |||
| b4bc49971c |
+2
-1
@@ -2,6 +2,7 @@
|
||||
*.pyc
|
||||
.env
|
||||
venv
|
||||
.venv
|
||||
node_modules/
|
||||
**/node_modules/
|
||||
npm-debug.log
|
||||
@@ -14,4 +15,4 @@ build/
|
||||
out/
|
||||
**/out/
|
||||
dist/
|
||||
**/dist/
|
||||
**/dist/
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
name: Test Pull Request
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
types: ["opened", "synchronize", "ready_for_review"]
|
||||
paths:
|
||||
- 'apiserver/**'
|
||||
- '.github/workflows/test-pull-request.yml'
|
||||
|
||||
jobs:
|
||||
test-apiserver:
|
||||
if: github.event.pull_request.draft == false
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:14
|
||||
env:
|
||||
POSTGRES_PASSWORD: plane
|
||||
POSTGRES_USER: plane
|
||||
POSTGRES_DB: plane
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
ports:
|
||||
- 5432:5432
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
options: >-
|
||||
--health-cmd "redis-cli ping"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
ports:
|
||||
- 6379:6379
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Cache Python dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements/test.txt') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pip-
|
||||
|
||||
- name: Install Python dependencies
|
||||
run: |
|
||||
cd apiserver
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements/test.txt
|
||||
|
||||
- name: Set up test environment
|
||||
run: |
|
||||
cd apiserver
|
||||
cat > .env << EOF
|
||||
# Basic Django settings
|
||||
DEBUG=1
|
||||
SECRET_KEY=test-secret-key-for-ci-only-do-not-use-in-production
|
||||
|
||||
# Database Configuration
|
||||
DATABASE_URL=postgres://plane:plane@localhost:5432/plane
|
||||
|
||||
# Redis Configuration
|
||||
REDIS_URL=redis://localhost:6379
|
||||
|
||||
# Email Backend for Testing
|
||||
EMAIL_BACKEND=django.core.mail.backends.locmem.EmailBackend
|
||||
|
||||
# CORS Settings for Testing
|
||||
CORS_ALLOW_ALL_ORIGINS=True
|
||||
CORS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:3001,http://localhost:3002
|
||||
|
||||
# Disable SSL and security features for testing
|
||||
SECURE_SSL_REDIRECT=False
|
||||
SECURE_HSTS_SECONDS=0
|
||||
SESSION_COOKIE_SECURE=False
|
||||
CSRF_COOKIE_SECURE=False
|
||||
|
||||
# Instance settings
|
||||
INSTANCE_KEY=test-instance-key-for-ci
|
||||
SKIP_ENV_VAR=1
|
||||
|
||||
# File upload settings for testing
|
||||
FILE_SIZE_LIMIT=5242880
|
||||
USE_MINIO=0
|
||||
|
||||
# Base URLs for testing
|
||||
WEB_URL=http://localhost:8000
|
||||
APP_BASE_URL=http://localhost:3000
|
||||
ADMIN_BASE_URL=http://localhost:3001
|
||||
SPACE_BASE_URL=http://localhost:3002
|
||||
LIVE_BASE_URL=http://localhost:3100
|
||||
|
||||
# Session settings
|
||||
SESSION_COOKIE_AGE=604800
|
||||
SESSION_COOKIE_NAME=session-id
|
||||
ADMIN_SESSION_COOKIE_AGE=3600
|
||||
|
||||
# API settings
|
||||
API_KEY_RATE_LIMIT=60/minute
|
||||
|
||||
# Disable external services for testing
|
||||
ENABLE_SIGNUP=1
|
||||
POSTHOG_API_KEY=
|
||||
ANALYTICS_SECRET_KEY=
|
||||
GITHUB_ACCESS_TOKEN=
|
||||
UNSPLASH_ACCESS_KEY=
|
||||
|
||||
# RabbitMQ/Celery settings (will be mocked in tests)
|
||||
RABBITMQ_HOST=localhost
|
||||
RABBITMQ_PORT=5672
|
||||
RABBITMQ_USER=guest
|
||||
RABBITMQ_PASSWORD=guest
|
||||
RABBITMQ_VHOST=/
|
||||
|
||||
# AWS/Storage settings (will be mocked in tests)
|
||||
AWS_ACCESS_KEY_ID=test-access-key
|
||||
AWS_SECRET_ACCESS_KEY=test-secret-key
|
||||
AWS_S3_BUCKET_NAME=test-uploads
|
||||
AWS_REGION=us-east-1
|
||||
EOF
|
||||
|
||||
- name: Run unit tests
|
||||
run: |
|
||||
cd apiserver
|
||||
python run_tests.py -u -v --coverage
|
||||
|
||||
- name: Run contract tests
|
||||
run: |
|
||||
cd apiserver
|
||||
python run_tests.py -c -v
|
||||
|
||||
- name: Show coverage summary
|
||||
if: always()
|
||||
run: |
|
||||
cd apiserver
|
||||
python -m coverage report --show-missing
|
||||
|
||||
- name: Upload coverage reports
|
||||
uses: codecov/codecov-action@v4
|
||||
if: always() && env.CODECOV_TOKEN != ''
|
||||
with:
|
||||
file: ./apiserver/coverage.xml
|
||||
flags: apiserver
|
||||
name: apiserver-coverage
|
||||
env:
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
- name: Generate coverage badge
|
||||
if: always()
|
||||
run: |
|
||||
cd apiserver
|
||||
coverage-badge -o coverage.svg
|
||||
continue-on-error: true
|
||||
|
||||
test-summary:
|
||||
if: always() && github.event.pull_request.draft == false
|
||||
needs: [test-apiserver]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Test Results Summary
|
||||
run: |
|
||||
echo "# Test Results Summary" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
if [[ "${{ needs.test-apiserver.result }}" == "success" ]]; then
|
||||
echo "✅ **API Server Tests**: PASSED" >> $GITHUB_STEP_SUMMARY
|
||||
echo "All tests completed successfully with coverage reporting." >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "❌ **API Server Tests**: FAILED" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Tests failed. Please check the logs for details." >> $GITHUB_STEP_SUMMARY
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "## Test Categories Executed" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **Unit Tests**: Fast, isolated component tests" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **Contract Tests**: API endpoint verification" >> $GITHUB_STEP_SUMMARY
|
||||
+1
-1
@@ -31,7 +31,7 @@
|
||||
"lucide-react": "^0.469.0",
|
||||
"mobx": "^6.12.0",
|
||||
"mobx-react": "^9.1.1",
|
||||
"next": "^14.2.28",
|
||||
"next": "^14.2.29",
|
||||
"next-themes": "^0.2.1",
|
||||
"postcss": "^8.4.38",
|
||||
"react": "^18.3.1",
|
||||
|
||||
@@ -148,10 +148,13 @@ class ProjectMemberAdminSerializer(BaseSerializer):
|
||||
fields = "__all__"
|
||||
|
||||
|
||||
class ProjectMemberRoleSerializer(DynamicBaseSerializer):
|
||||
class ProjectMemberRoleSerializer(DynamicBaseSerializer):
|
||||
original_role = serializers.IntegerField(source='role', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = ProjectMember
|
||||
fields = ("id", "role", "member", "project")
|
||||
fields = ("id", "role", "member", "project", "original_role", "created_at")
|
||||
read_only_fields = ["original_role", "created_at"]
|
||||
|
||||
|
||||
class ProjectMemberInviteSerializer(BaseSerializer):
|
||||
|
||||
@@ -3,11 +3,22 @@ from rest_framework import serializers
|
||||
|
||||
# Module import
|
||||
from plane.db.models import Account, Profile, User, Workspace, WorkspaceMemberInvite
|
||||
from plane.utils.url import contains_url
|
||||
|
||||
from .base import BaseSerializer
|
||||
|
||||
|
||||
class UserSerializer(BaseSerializer):
|
||||
def validate_first_name(self, value):
|
||||
if contains_url(value):
|
||||
raise serializers.ValidationError("First name cannot contain a URL.")
|
||||
return value
|
||||
|
||||
def validate_last_name(self, value):
|
||||
if contains_url(value):
|
||||
raise serializers.ValidationError("Last name cannot contain a URL.")
|
||||
return value
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
# Exclude password field from the serializer
|
||||
@@ -99,11 +110,16 @@ class UserMeSettingsSerializer(BaseSerializer):
|
||||
workspace_member__member=obj.id,
|
||||
workspace_member__is_active=True,
|
||||
).first()
|
||||
logo_asset_url = workspace.logo_asset.asset_url if workspace.logo_asset is not None else ""
|
||||
return {
|
||||
"last_workspace_id": profile.last_workspace_id,
|
||||
"last_workspace_slug": (
|
||||
workspace.slug if workspace is not None else ""
|
||||
),
|
||||
"last_workspace_name": (
|
||||
workspace.name if workspace is not None else ""
|
||||
),
|
||||
"last_workspace_logo": (logo_asset_url),
|
||||
"fallback_workspace_id": profile.last_workspace_id,
|
||||
"fallback_workspace_slug": (
|
||||
workspace.slug if workspace is not None else ""
|
||||
|
||||
@@ -25,10 +25,12 @@ from plane.db.models import (
|
||||
WorkspaceUserPreference,
|
||||
)
|
||||
from plane.utils.constants import RESTRICTED_WORKSPACE_SLUGS
|
||||
from plane.utils.url import contains_url
|
||||
|
||||
# Django imports
|
||||
from django.core.validators import URLValidator
|
||||
from django.core.exceptions import ValidationError
|
||||
import re
|
||||
|
||||
|
||||
class WorkSpaceSerializer(DynamicBaseSerializer):
|
||||
@@ -36,10 +38,21 @@ class WorkSpaceSerializer(DynamicBaseSerializer):
|
||||
logo_url = serializers.CharField(read_only=True)
|
||||
role = serializers.IntegerField(read_only=True)
|
||||
|
||||
def validate_name(self, value):
|
||||
# Check if the name contains a URL
|
||||
if contains_url(value):
|
||||
raise serializers.ValidationError("Name must not contain URLs")
|
||||
return value
|
||||
|
||||
def validate_slug(self, value):
|
||||
# Check if the slug is restricted
|
||||
if value in RESTRICTED_WORKSPACE_SLUGS:
|
||||
raise serializers.ValidationError("Slug is not valid")
|
||||
# Slug should only contain alphanumeric characters, hyphens, and underscores
|
||||
if not re.match(r"^[a-zA-Z0-9_-]+$", value):
|
||||
raise serializers.ValidationError(
|
||||
"Slug can only contain letters, numbers, hyphens (-), and underscores (_)"
|
||||
)
|
||||
return value
|
||||
|
||||
class Meta:
|
||||
|
||||
@@ -12,6 +12,7 @@ from plane.app.views import (
|
||||
AssetRestoreEndpoint,
|
||||
ProjectAssetEndpoint,
|
||||
ProjectBulkAssetEndpoint,
|
||||
AssetCheckEndpoint,
|
||||
)
|
||||
|
||||
|
||||
@@ -81,5 +82,11 @@ urlpatterns = [
|
||||
path(
|
||||
"assets/v2/workspaces/<str:slug>/projects/<uuid:project_id>/<uuid:entity_id>/bulk/",
|
||||
ProjectBulkAssetEndpoint.as_view(),
|
||||
name="bulk-asset-update",
|
||||
),
|
||||
path(
|
||||
"assets/v2/workspaces/<str:slug>/check/<uuid:asset_id>/",
|
||||
AssetCheckEndpoint.as_view(),
|
||||
name="asset-check",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -106,6 +106,7 @@ from .asset.v2 import (
|
||||
AssetRestoreEndpoint,
|
||||
ProjectAssetEndpoint,
|
||||
ProjectBulkAssetEndpoint,
|
||||
AssetCheckEndpoint,
|
||||
)
|
||||
from .issue.base import (
|
||||
IssueListEndpoint,
|
||||
|
||||
@@ -707,3 +707,14 @@ class ProjectBulkAssetEndpoint(BaseAPIView):
|
||||
pass
|
||||
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
class AssetCheckEndpoint(BaseAPIView):
|
||||
"""Endpoint to check if an asset exists."""
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")
|
||||
def get(self, request, slug, asset_id):
|
||||
asset = FileAsset.all_objects.filter(
|
||||
id=asset_id, workspace__slug=slug, deleted_at__isnull=True
|
||||
).exists()
|
||||
return Response({"exists": asset}, status=status.HTTP_200_OK)
|
||||
|
||||
@@ -15,6 +15,7 @@ from plane.app.serializers import IssueLinkSerializer
|
||||
from plane.app.permissions import ProjectEntityPermission
|
||||
from plane.db.models import IssueLink
|
||||
from plane.bgtasks.issue_activities_task import issue_activity
|
||||
from plane.bgtasks.work_item_link_task import crawl_work_item_link_title
|
||||
from plane.utils.host import base_host
|
||||
|
||||
|
||||
@@ -44,6 +45,9 @@ class IssueLinkViewSet(BaseViewSet):
|
||||
serializer = IssueLinkSerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
serializer.save(project_id=project_id, issue_id=issue_id)
|
||||
crawl_work_item_link_title(
|
||||
serializer.data.get("id"), serializer.data.get("url")
|
||||
)
|
||||
issue_activity.delay(
|
||||
type="link.activity.created",
|
||||
requested_data=json.dumps(serializer.data, cls=DjangoJSONEncoder),
|
||||
@@ -55,6 +59,10 @@ class IssueLinkViewSet(BaseViewSet):
|
||||
notification=True,
|
||||
origin=base_host(request=request, is_app=True),
|
||||
)
|
||||
|
||||
issue_link = self.get_queryset().get(id=serializer.data.get("id"))
|
||||
serializer = IssueLinkSerializer(issue_link)
|
||||
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@@ -66,9 +74,14 @@ class IssueLinkViewSet(BaseViewSet):
|
||||
current_instance = json.dumps(
|
||||
IssueLinkSerializer(issue_link).data, cls=DjangoJSONEncoder
|
||||
)
|
||||
|
||||
serializer = IssueLinkSerializer(issue_link, data=request.data, partial=True)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
crawl_work_item_link_title(
|
||||
serializer.data.get("id"), serializer.data.get("url")
|
||||
)
|
||||
|
||||
issue_activity.delay(
|
||||
type="link.activity.updated",
|
||||
requested_data=requested_data,
|
||||
@@ -80,6 +93,9 @@ class IssueLinkViewSet(BaseViewSet):
|
||||
notification=True,
|
||||
origin=base_host(request=request, is_app=True),
|
||||
)
|
||||
issue_link = self.get_queryset().get(id=serializer.data.get("id"))
|
||||
serializer = IssueLinkSerializer(issue_link)
|
||||
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import csv
|
||||
import io
|
||||
import os
|
||||
from datetime import date
|
||||
import uuid
|
||||
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from django.db import IntegrityError
|
||||
@@ -35,6 +36,7 @@ from plane.db.models import (
|
||||
Workspace,
|
||||
WorkspaceMember,
|
||||
WorkspaceTheme,
|
||||
Profile,
|
||||
)
|
||||
from plane.app.permissions import ROLE, allow_permission
|
||||
from django.utils.decorators import method_decorator
|
||||
@@ -43,6 +45,7 @@ from django.views.decorators.vary import vary_on_cookie
|
||||
from plane.utils.constants import RESTRICTED_WORKSPACE_SLUGS
|
||||
from plane.license.utils.instance_value import get_configuration_value
|
||||
from plane.bgtasks.workspace_seed_task import workspace_seed
|
||||
from plane.utils.url import contains_url
|
||||
|
||||
|
||||
class WorkSpaceViewSet(BaseViewSet):
|
||||
@@ -109,6 +112,12 @@ class WorkSpaceViewSet(BaseViewSet):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
if contains_url(name):
|
||||
return Response(
|
||||
{"error": "Name cannot contain a URL"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
if serializer.is_valid(raise_exception=True):
|
||||
serializer.save(owner=request.user)
|
||||
# Create Workspace member
|
||||
@@ -150,8 +159,18 @@ class WorkSpaceViewSet(BaseViewSet):
|
||||
def partial_update(self, request, *args, **kwargs):
|
||||
return super().partial_update(request, *args, **kwargs)
|
||||
|
||||
def remove_last_workspace_ids_from_user_settings(self, id: uuid.UUID) -> None:
|
||||
"""
|
||||
Remove the last workspace id from the user settings
|
||||
"""
|
||||
Profile.objects.filter(last_workspace_id=id).update(last_workspace_id=None)
|
||||
return
|
||||
|
||||
@allow_permission([ROLE.ADMIN], level="WORKSPACE")
|
||||
def destroy(self, request, *args, **kwargs):
|
||||
# Get the workspace
|
||||
workspace = self.get_object()
|
||||
self.remove_last_workspace_ids_from_user_settings(workspace.id)
|
||||
return super().destroy(request, *args, **kwargs)
|
||||
|
||||
|
||||
@@ -159,8 +178,6 @@ class UserWorkSpacesEndpoint(BaseAPIView):
|
||||
search_fields = ["name"]
|
||||
filterset_fields = ["owner"]
|
||||
|
||||
@method_decorator(cache_control(private=True, max_age=12))
|
||||
@method_decorator(vary_on_cookie)
|
||||
def get(self, request):
|
||||
fields = [field for field in request.GET.get("fields", "").split(",") if field]
|
||||
member_count = (
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
# Python imports
|
||||
import logging
|
||||
|
||||
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
import requests
|
||||
from bs4 import BeautifulSoup
|
||||
from urllib.parse import urlparse, urljoin
|
||||
import base64
|
||||
import ipaddress
|
||||
from typing import Dict, Any
|
||||
from typing import Optional
|
||||
from plane.db.models import IssueLink
|
||||
from plane.utils.exception_logger import log_exception
|
||||
|
||||
logger = logging.getLogger("plane.worker")
|
||||
|
||||
|
||||
DEFAULT_FAVICON = "PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGNsYXNzPSJsdWNpZGUgbHVjaWRlLWxpbmstaWNvbiBsdWNpZGUtbGluayI+PHBhdGggZD0iTTEwIDEzYTUgNSAwIDAgMCA3LjU0LjU0bDMtM2E1IDUgMCAwIDAtNy4wNy03LjA3bC0xLjcyIDEuNzEiLz48cGF0aCBkPSJNMTQgMTFhNSA1IDAgMCAwLTcuNTQtLjU0bC0zIDNhNSA1IDAgMCAwIDcuMDcgNy4wN2wxLjcxLTEuNzEiLz48L3N2Zz4=" # noqa: E501
|
||||
|
||||
|
||||
@shared_task
|
||||
def crawl_work_item_link_title(id: str, url: str) -> None:
|
||||
meta_data = crawl_work_item_link_title_and_favicon(url)
|
||||
issue_link = IssueLink.objects.get(id=id)
|
||||
|
||||
issue_link.metadata = meta_data
|
||||
|
||||
issue_link.save()
|
||||
|
||||
|
||||
def crawl_work_item_link_title_and_favicon(url: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Crawls a URL to extract the title and favicon.
|
||||
|
||||
Args:
|
||||
url (str): The URL to crawl
|
||||
|
||||
Returns:
|
||||
str: JSON string containing title and base64-encoded favicon
|
||||
"""
|
||||
try:
|
||||
# Prevent access to private IP ranges
|
||||
parsed = urlparse(url)
|
||||
|
||||
try:
|
||||
ip = ipaddress.ip_address(parsed.hostname)
|
||||
if ip.is_private or ip.is_loopback or ip.is_reserved:
|
||||
raise ValueError("Access to private/internal networks is not allowed")
|
||||
except ValueError:
|
||||
# Not an IP address, continue with domain validation
|
||||
pass
|
||||
|
||||
# Set up headers to mimic a real browser
|
||||
headers = {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36" # noqa: E501
|
||||
}
|
||||
|
||||
# Fetch the main page
|
||||
response = requests.get(url, headers=headers, timeout=2)
|
||||
|
||||
response.raise_for_status()
|
||||
|
||||
# Parse HTML
|
||||
soup = BeautifulSoup(response.content, "html.parser")
|
||||
|
||||
# Extract title
|
||||
title_tag = soup.find("title")
|
||||
title = title_tag.get_text().strip() if title_tag else None
|
||||
|
||||
# Fetch and encode favicon
|
||||
favicon_base64 = fetch_and_encode_favicon(headers, soup, url)
|
||||
|
||||
# Prepare result
|
||||
result = {
|
||||
"title": title,
|
||||
"favicon": favicon_base64["favicon_base64"],
|
||||
"url": url,
|
||||
"favicon_url": favicon_base64["favicon_url"],
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
except requests.RequestException as e:
|
||||
log_exception(e)
|
||||
return {
|
||||
"error": f"Request failed: {str(e)}",
|
||||
"title": None,
|
||||
"favicon": None,
|
||||
"url": url,
|
||||
}
|
||||
except Exception as e:
|
||||
log_exception(e)
|
||||
return {
|
||||
"error": f"Unexpected error: {str(e)}",
|
||||
"title": None,
|
||||
"favicon": None,
|
||||
"url": url,
|
||||
}
|
||||
|
||||
|
||||
def find_favicon_url(soup: BeautifulSoup, base_url: str) -> Optional[str]:
|
||||
"""
|
||||
Find the favicon URL from HTML soup.
|
||||
|
||||
Args:
|
||||
soup: BeautifulSoup object
|
||||
base_url: Base URL for resolving relative paths
|
||||
|
||||
Returns:
|
||||
str: Absolute URL to favicon or None
|
||||
"""
|
||||
# Look for various favicon link tags
|
||||
favicon_selectors = [
|
||||
'link[rel="icon"]',
|
||||
'link[rel="shortcut icon"]',
|
||||
'link[rel="apple-touch-icon"]',
|
||||
'link[rel="apple-touch-icon-precomposed"]',
|
||||
]
|
||||
|
||||
for selector in favicon_selectors:
|
||||
favicon_tag = soup.select_one(selector)
|
||||
if favicon_tag and favicon_tag.get("href"):
|
||||
return urljoin(base_url, favicon_tag["href"])
|
||||
|
||||
# Fallback to /favicon.ico
|
||||
parsed_url = urlparse(base_url)
|
||||
fallback_url = f"{parsed_url.scheme}://{parsed_url.netloc}/favicon.ico"
|
||||
|
||||
# Check if fallback exists
|
||||
try:
|
||||
response = requests.head(fallback_url, timeout=2)
|
||||
response.raise_for_status()
|
||||
if response.status_code == 200:
|
||||
return fallback_url
|
||||
except requests.RequestException as e:
|
||||
log_exception(e)
|
||||
return None
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def fetch_and_encode_favicon(
|
||||
headers: Dict[str, str], soup: BeautifulSoup, url: str
|
||||
) -> Optional[Dict[str, str]]:
|
||||
"""
|
||||
Fetch favicon and encode it as base64.
|
||||
|
||||
Args:
|
||||
favicon_url: URL to the favicon
|
||||
headers: Request headers
|
||||
|
||||
Returns:
|
||||
str: Base64 encoded favicon with data URI prefix or None
|
||||
"""
|
||||
try:
|
||||
favicon_url = find_favicon_url(soup, url)
|
||||
if favicon_url is None:
|
||||
return {
|
||||
"favicon_url": None,
|
||||
"favicon_base64": f"data:image/svg+xml;base64,{DEFAULT_FAVICON}",
|
||||
}
|
||||
|
||||
response = requests.get(favicon_url, headers=headers, timeout=2)
|
||||
response.raise_for_status()
|
||||
|
||||
# Get content type
|
||||
content_type = response.headers.get("content-type", "image/x-icon")
|
||||
|
||||
# Convert to base64
|
||||
favicon_base64 = base64.b64encode(response.content).decode("utf-8")
|
||||
|
||||
# Return as data URI
|
||||
return {
|
||||
"favicon_url": favicon_url,
|
||||
"favicon_base64": f"data:{content_type};base64,{favicon_base64}",
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to fetch favicon: {e}")
|
||||
return {
|
||||
"favicon_url": None,
|
||||
"favicon_base64": f"data:image/svg+xml;base64,{DEFAULT_FAVICON}",
|
||||
}
|
||||
@@ -12,6 +12,29 @@ Tests are organized into the following categories:
|
||||
- **App tests**: Test the web application API endpoints (under `/api/`).
|
||||
- **Smoke tests**: Basic tests to verify that the application runs correctly.
|
||||
|
||||
## Continuous Integration (CI)
|
||||
|
||||
Tests run automatically on pull requests via GitHub Actions:
|
||||
|
||||
### Automated Testing Workflow
|
||||
|
||||
When a pull request is created or updated with changes to `apiserver/**` files, the `test-pull-request.yml` workflow automatically:
|
||||
|
||||
1. **Sets up test environment**: PostgreSQL 14, Redis 7, Python 3.11
|
||||
2. **Runs unit tests**: Fast, isolated component tests with coverage
|
||||
3. **Runs contract tests**: API endpoint verification
|
||||
4. **Generates coverage reports**: Enforces 90% threshold with HTML, terminal, and XML formats
|
||||
5. **Uploads to Codecov**: If token is configured
|
||||
|
||||
### CI Environment Variables
|
||||
|
||||
The CI automatically configures comprehensive environment variables including:
|
||||
- Database and Redis connections
|
||||
- Security settings (disabled for testing)
|
||||
- Base URLs for all components
|
||||
- File upload and storage settings
|
||||
- External service configurations (mocked)
|
||||
|
||||
## API vs App Endpoints
|
||||
|
||||
Plane has two types of API endpoints:
|
||||
@@ -32,6 +55,8 @@ Plane has two types of API endpoints:
|
||||
|
||||
## Running Tests
|
||||
|
||||
### Local Testing
|
||||
|
||||
To run all tests:
|
||||
|
||||
```bash
|
||||
@@ -54,20 +79,19 @@ python -m pytest plane/tests/contract/app/
|
||||
python -m pytest plane/tests/smoke/
|
||||
```
|
||||
|
||||
For convenience, we also provide a helper script:
|
||||
### Using the Test Runner
|
||||
|
||||
For convenience, we provide helper scripts:
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
./run_tests.py
|
||||
# Using Python script directly
|
||||
python run_tests.py --coverage --verbose # Full test suite with coverage
|
||||
python run_tests.py -u -v # Unit tests only
|
||||
python run_tests.py -c -v # Contract tests only
|
||||
python run_tests.py -p -v # Parallel execution
|
||||
|
||||
# Run only unit tests
|
||||
./run_tests.py -u
|
||||
|
||||
# Run contract tests with coverage report
|
||||
./run_tests.py -c -o
|
||||
|
||||
# Run tests in parallel
|
||||
./run_tests.py -p
|
||||
# Using shell wrapper
|
||||
./run_tests.sh --coverage --verbose # Full test suite with coverage
|
||||
```
|
||||
|
||||
## Fixtures
|
||||
@@ -134,9 +158,30 @@ Generate a coverage report with:
|
||||
|
||||
```bash
|
||||
python -m pytest --cov=plane --cov-report=term --cov-report=html
|
||||
# Or using the test runner
|
||||
python run_tests.py --coverage
|
||||
```
|
||||
|
||||
This creates an HTML report in the `htmlcov/` directory.
|
||||
This creates an HTML report in the `htmlcov/` directory and enforces the 90% coverage threshold.
|
||||
|
||||
## CI Troubleshooting
|
||||
|
||||
### Common CI Issues
|
||||
|
||||
1. **Test failures**: Check the GitHub Actions logs for specific error messages
|
||||
2. **Coverage below threshold**: Add tests for uncovered code
|
||||
3. **Database connection issues**: Ensure PostgreSQL service is healthy in CI
|
||||
4. **Redis connection issues**: Ensure Redis service is healthy in CI
|
||||
|
||||
### Local Setup for CI Testing
|
||||
|
||||
Make sure you have the test dependencies installed:
|
||||
|
||||
```bash
|
||||
pip install -r requirements/test.txt
|
||||
```
|
||||
|
||||
Set up your local environment with PostgreSQL and Redis, or use the provided Docker setup.
|
||||
|
||||
## Migration from Old Tests
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -75,4 +75,4 @@ 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
|
||||
|
||||
@@ -4,6 +4,14 @@ from typing import Optional
|
||||
from urllib.parse import urlparse, urlunparse
|
||||
|
||||
|
||||
def contains_url(value: str) -> bool:
|
||||
"""
|
||||
Check if the value contains a URL.
|
||||
"""
|
||||
url_pattern = re.compile(r"https?://|www\\.")
|
||||
return bool(url_pattern.search(value))
|
||||
|
||||
|
||||
def is_valid_url(url: str) -> bool:
|
||||
"""
|
||||
Validates whether the given string is a well-formed URL.
|
||||
|
||||
+18
-27
@@ -6,36 +6,20 @@ import sys
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Run Plane tests")
|
||||
parser.add_argument("-u", "--unit", action="store_true", help="Run unit tests only")
|
||||
parser.add_argument(
|
||||
"-u", "--unit",
|
||||
action="store_true",
|
||||
help="Run unit tests only"
|
||||
"-c", "--contract", action="store_true", help="Run contract tests only"
|
||||
)
|
||||
parser.add_argument(
|
||||
"-c", "--contract",
|
||||
action="store_true",
|
||||
help="Run contract tests only"
|
||||
"-s", "--smoke", action="store_true", help="Run smoke tests only"
|
||||
)
|
||||
parser.add_argument(
|
||||
"-s", "--smoke",
|
||||
action="store_true",
|
||||
help="Run smoke tests only"
|
||||
"-o", "--coverage", action="store_true", help="Generate coverage report"
|
||||
)
|
||||
parser.add_argument(
|
||||
"-o", "--coverage",
|
||||
action="store_true",
|
||||
help="Generate coverage report"
|
||||
)
|
||||
parser.add_argument(
|
||||
"-p", "--parallel",
|
||||
action="store_true",
|
||||
help="Run tests in parallel"
|
||||
)
|
||||
parser.add_argument(
|
||||
"-v", "--verbose",
|
||||
action="store_true",
|
||||
help="Verbose output"
|
||||
"-p", "--parallel", action="store_true", help="Run tests in parallel"
|
||||
)
|
||||
parser.add_argument("-v", "--verbose", action="store_true", help="Verbose output")
|
||||
args = parser.parse_args()
|
||||
|
||||
# Build command
|
||||
@@ -56,7 +40,14 @@ def main():
|
||||
|
||||
# Add coverage
|
||||
if args.coverage:
|
||||
cmd.extend(["--cov=plane", "--cov-report=term", "--cov-report=html"])
|
||||
cmd.extend(
|
||||
[
|
||||
"--cov=plane",
|
||||
"--cov-report=term",
|
||||
"--cov-report=html",
|
||||
"--cov-report=xml",
|
||||
]
|
||||
)
|
||||
|
||||
# Add parallel
|
||||
if args.parallel:
|
||||
@@ -71,10 +62,10 @@ def main():
|
||||
|
||||
# Print command
|
||||
print(f"Running: {' '.join(cmd)}")
|
||||
|
||||
|
||||
# Execute command
|
||||
result = subprocess.run(cmd)
|
||||
|
||||
|
||||
# Check coverage thresholds if coverage is enabled
|
||||
if args.coverage:
|
||||
print("Checking coverage thresholds...")
|
||||
@@ -83,9 +74,9 @@ def main():
|
||||
if coverage_result.returncode != 0:
|
||||
print("Coverage below threshold (90%)")
|
||||
sys.exit(coverage_result.returncode)
|
||||
|
||||
|
||||
sys.exit(result.returncode)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
main()
|
||||
|
||||
+1
-1
@@ -57,7 +57,7 @@
|
||||
"concurrently": "^9.0.1",
|
||||
"nodemon": "^3.1.7",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsup": "^8.5.0",
|
||||
"tsup": "8.4.0",
|
||||
"typescript": "5.3.3"
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -30,7 +30,8 @@
|
||||
"nanoid": "3.3.8",
|
||||
"esbuild": "0.25.0",
|
||||
"@babel/helpers": "7.26.10",
|
||||
"@babel/runtime": "7.26.10"
|
||||
"@babel/runtime": "7.26.10",
|
||||
"chokidar": "3.6.0"
|
||||
},
|
||||
"packageManager": "yarn@1.22.22"
|
||||
}
|
||||
|
||||
@@ -32,5 +32,6 @@ export * from "./dashboard";
|
||||
export * from "./page";
|
||||
export * from "./emoji";
|
||||
export * from "./subscription";
|
||||
export * from "./settings";
|
||||
export * from "./icon";
|
||||
export * from "./analytics-v2";
|
||||
|
||||
@@ -1,39 +1,53 @@
|
||||
export const PROFILE_SETTINGS = {
|
||||
profile: {
|
||||
key: "profile",
|
||||
i18n_label: "profile.actions.profile",
|
||||
href: `/settings/account`,
|
||||
highlight: (pathname: string) => pathname === "/settings/account/",
|
||||
},
|
||||
security: {
|
||||
key: "security",
|
||||
i18n_label: "profile.actions.security",
|
||||
href: `/settings/account/security`,
|
||||
highlight: (pathname: string) => pathname === "/settings/account/security/",
|
||||
},
|
||||
activity: {
|
||||
key: "activity",
|
||||
i18n_label: "profile.actions.activity",
|
||||
href: `/settings/account/activity`,
|
||||
highlight: (pathname: string) => pathname === "/settings/account/activity/",
|
||||
},
|
||||
preferences: {
|
||||
key: "preferences",
|
||||
i18n_label: "profile.actions.preferences",
|
||||
href: `/settings/account/preferences`,
|
||||
highlight: (pathname: string) => pathname === "/settings/account/preferences",
|
||||
},
|
||||
notifications: {
|
||||
key: "notifications",
|
||||
i18n_label: "profile.actions.notifications",
|
||||
href: `/settings/account/notifications`,
|
||||
highlight: (pathname: string) => pathname === "/settings/account/notifications/",
|
||||
},
|
||||
"api-tokens": {
|
||||
key: "api-tokens",
|
||||
i18n_label: "profile.actions.api-tokens",
|
||||
href: `/settings/account/api-tokens`,
|
||||
highlight: (pathname: string) => pathname === "/settings/account/api-tokens/",
|
||||
},
|
||||
};
|
||||
export const PROFILE_ACTION_LINKS: {
|
||||
key: string;
|
||||
i18n_label: string;
|
||||
href: string;
|
||||
highlight: (pathname: string) => boolean;
|
||||
}[] = [
|
||||
{
|
||||
key: "profile",
|
||||
i18n_label: "profile.actions.profile",
|
||||
href: `/profile`,
|
||||
highlight: (pathname: string) => pathname === "/profile/",
|
||||
},
|
||||
{
|
||||
key: "security",
|
||||
i18n_label: "profile.actions.security",
|
||||
href: `/profile/security`,
|
||||
highlight: (pathname: string) => pathname === "/profile/security/",
|
||||
},
|
||||
{
|
||||
key: "activity",
|
||||
i18n_label: "profile.actions.activity",
|
||||
href: `/profile/activity`,
|
||||
highlight: (pathname: string) => pathname === "/profile/activity/",
|
||||
},
|
||||
{
|
||||
key: "appearance",
|
||||
i18n_label: "profile.actions.appearance",
|
||||
href: `/profile/appearance`,
|
||||
highlight: (pathname: string) => pathname.includes("/profile/appearance"),
|
||||
},
|
||||
{
|
||||
key: "notifications",
|
||||
i18n_label: "profile.actions.notifications",
|
||||
href: `/profile/notifications`,
|
||||
highlight: (pathname: string) => pathname === "/profile/notifications/",
|
||||
},
|
||||
PROFILE_SETTINGS["profile"],
|
||||
PROFILE_SETTINGS["security"],
|
||||
PROFILE_SETTINGS["activity"],
|
||||
PROFILE_SETTINGS["preferences"],
|
||||
PROFILE_SETTINGS["notifications"],
|
||||
PROFILE_SETTINGS["api-tokens"],
|
||||
];
|
||||
|
||||
export const PROFILE_VIEWER_TAB = [
|
||||
@@ -72,6 +86,23 @@ export const PROFILE_ADMINS_TAB = [
|
||||
},
|
||||
];
|
||||
|
||||
export const PREFERENCE_OPTIONS: {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
}[] = [
|
||||
{
|
||||
id: "theme",
|
||||
title: "theme",
|
||||
description: "select_or_customize_your_interface_color_scheme",
|
||||
},
|
||||
{
|
||||
id: "start_of_week",
|
||||
title: "First day of the week",
|
||||
description: "This will change how all calendars in your app look.",
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* @description The start of the week for the user
|
||||
* @enum {number}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { PROFILE_SETTINGS } from ".";
|
||||
import { WORKSPACE_SETTINGS } from "./workspace";
|
||||
|
||||
export enum WORKSPACE_SETTINGS_CATEGORY {
|
||||
ADMINISTRATION = "administration",
|
||||
FEATURES = "features",
|
||||
DEVELOPER = "developer",
|
||||
}
|
||||
|
||||
export enum PROFILE_SETTINGS_CATEGORY {
|
||||
YOUR_PROFILE = "your profile",
|
||||
DEVELOPER = "developer",
|
||||
}
|
||||
|
||||
export enum PROJECT_SETTINGS_CATEGORY {
|
||||
PROJECTS = "projects",
|
||||
}
|
||||
|
||||
export const WORKSPACE_SETTINGS_CATEGORIES = [
|
||||
WORKSPACE_SETTINGS_CATEGORY.ADMINISTRATION,
|
||||
WORKSPACE_SETTINGS_CATEGORY.FEATURES,
|
||||
WORKSPACE_SETTINGS_CATEGORY.DEVELOPER,
|
||||
];
|
||||
|
||||
export const PROFILE_SETTINGS_CATEGORIES = [
|
||||
PROFILE_SETTINGS_CATEGORY.YOUR_PROFILE,
|
||||
PROFILE_SETTINGS_CATEGORY.DEVELOPER,
|
||||
];
|
||||
|
||||
export const PROJECT_SETTINGS_CATEGORIES = [PROJECT_SETTINGS_CATEGORY.PROJECTS];
|
||||
|
||||
export const GROUPED_WORKSPACE_SETTINGS = {
|
||||
[WORKSPACE_SETTINGS_CATEGORY.ADMINISTRATION]: [
|
||||
WORKSPACE_SETTINGS["general"],
|
||||
WORKSPACE_SETTINGS["members"],
|
||||
WORKSPACE_SETTINGS["billing-and-plans"],
|
||||
WORKSPACE_SETTINGS["export"],
|
||||
],
|
||||
[WORKSPACE_SETTINGS_CATEGORY.FEATURES]: [],
|
||||
[WORKSPACE_SETTINGS_CATEGORY.DEVELOPER]: [WORKSPACE_SETTINGS["webhooks"]],
|
||||
};
|
||||
|
||||
export const GROUPED_PROFILE_SETTINGS = {
|
||||
[PROFILE_SETTINGS_CATEGORY.YOUR_PROFILE]: [
|
||||
PROFILE_SETTINGS["profile"],
|
||||
PROFILE_SETTINGS["preferences"],
|
||||
PROFILE_SETTINGS["notifications"],
|
||||
PROFILE_SETTINGS["security"],
|
||||
PROFILE_SETTINGS["activity"],
|
||||
],
|
||||
[PROFILE_SETTINGS_CATEGORY.DEVELOPER]: [PROFILE_SETTINGS["api-tokens"]],
|
||||
};
|
||||
@@ -114,13 +114,6 @@ export const WORKSPACE_SETTINGS = {
|
||||
access: [EUserWorkspaceRoles.ADMIN],
|
||||
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/webhooks/`,
|
||||
},
|
||||
"api-tokens": {
|
||||
key: "api-tokens",
|
||||
i18n_label: "workspace_settings.settings.api_tokens.title",
|
||||
href: `/settings/api-tokens`,
|
||||
access: [EUserWorkspaceRoles.ADMIN],
|
||||
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/api-tokens/`,
|
||||
},
|
||||
};
|
||||
|
||||
export const WORKSPACE_SETTINGS_ACCESS = Object.fromEntries(
|
||||
@@ -139,7 +132,6 @@ export const WORKSPACE_SETTINGS_LINKS: {
|
||||
WORKSPACE_SETTINGS["billing-and-plans"],
|
||||
WORKSPACE_SETTINGS["export"],
|
||||
WORKSPACE_SETTINGS["webhooks"],
|
||||
WORKSPACE_SETTINGS["api-tokens"],
|
||||
];
|
||||
|
||||
export const ROLE = {
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
"@types/node": "^20.14.9",
|
||||
"@types/reflect-metadata": "^0.1.0",
|
||||
"@types/ws": "^8.5.10",
|
||||
"tsup": "^8.5.0",
|
||||
"tsup": "8.4.0",
|
||||
"typescript": "^5.3.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -81,7 +81,7 @@
|
||||
"@types/react": "^18.3.11",
|
||||
"@types/react-dom": "^18.2.18",
|
||||
"postcss": "^8.4.38",
|
||||
"tsup": "^8.5.0",
|
||||
"tsup": "8.4.0",
|
||||
"typescript": "5.3.3"
|
||||
},
|
||||
"keywords": [
|
||||
|
||||
@@ -3,7 +3,7 @@ import React, { useRef, useState, useCallback, useLayoutEffect, useEffect } from
|
||||
// plane utils
|
||||
import { cn } from "@plane/utils";
|
||||
// extensions
|
||||
import { CustoBaseImageNodeViewProps, ImageToolbarRoot } from "@/extensions/custom-image";
|
||||
import { CustomBaseImageNodeViewProps, ImageToolbarRoot } from "@/extensions/custom-image";
|
||||
import { ImageUploadStatus } from "./upload-status";
|
||||
|
||||
const MIN_SIZE = 100;
|
||||
@@ -38,7 +38,7 @@ const ensurePixelString = <TDefault,>(value: Pixel | TDefault | number | undefin
|
||||
return value;
|
||||
};
|
||||
|
||||
type CustomImageBlockProps = CustoBaseImageNodeViewProps & {
|
||||
type CustomImageBlockProps = CustomBaseImageNodeViewProps & {
|
||||
imageFromFileSystem: string | undefined;
|
||||
setFailedToLoadImage: (isError: boolean) => void;
|
||||
editorContainer: HTMLDivElement | null;
|
||||
|
||||
@@ -7,7 +7,7 @@ import { CustomImageBlock, CustomImageUploader, ImageAttributes } from "@/extens
|
||||
// helpers
|
||||
import { getExtensionStorage } from "@/helpers/get-extension-storage";
|
||||
|
||||
export type CustoBaseImageNodeViewProps = {
|
||||
export type CustomBaseImageNodeViewProps = {
|
||||
getPos: () => number;
|
||||
editor: Editor;
|
||||
node: NodeViewProps["node"] & {
|
||||
@@ -17,7 +17,7 @@ export type CustoBaseImageNodeViewProps = {
|
||||
selected: boolean;
|
||||
};
|
||||
|
||||
export type CustomImageNodeProps = NodeViewProps & CustoBaseImageNodeViewProps;
|
||||
export type CustomImageNodeProps = NodeViewProps & CustomBaseImageNodeViewProps;
|
||||
|
||||
export const CustomImageNode = (props: CustomImageNodeProps) => {
|
||||
const { getPos, editor, node, updateAttributes, selected } = props;
|
||||
|
||||
@@ -6,12 +6,14 @@ import { cn } from "@plane/utils";
|
||||
import { ACCEPTED_IMAGE_MIME_TYPES } from "@/constants/config";
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
// extensions
|
||||
import { CustoBaseImageNodeViewProps, getImageComponentImageFileMap } from "@/extensions/custom-image";
|
||||
import { CustomBaseImageNodeViewProps, getImageComponentImageFileMap } from "@/extensions/custom-image";
|
||||
// helpers
|
||||
import { EFileError } from "@/helpers/file";
|
||||
import { getExtensionStorage } from "@/helpers/get-extension-storage";
|
||||
// hooks
|
||||
import { useUploader, useDropZone, uploadFirstFileAndInsertRemaining } from "@/hooks/use-file-upload";
|
||||
import { getExtensionStorage } from "@/helpers/get-extension-storage";
|
||||
|
||||
type CustomImageUploaderProps = CustoBaseImageNodeViewProps & {
|
||||
type CustomImageUploaderProps = CustomBaseImageNodeViewProps & {
|
||||
maxFileSize: number;
|
||||
loadImageFromFileSystem: (file: string) => void;
|
||||
failedToLoadImage: boolean;
|
||||
@@ -71,23 +73,37 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
|
||||
},
|
||||
[imageComponentImageFileMap, imageEntityId, updateAttributes, getPos]
|
||||
);
|
||||
|
||||
const uploadImageEditorCommand = useCallback(
|
||||
async (file: File) => await editor?.commands.uploadImage(imageEntityId ?? "", file),
|
||||
[editor, imageEntityId]
|
||||
);
|
||||
|
||||
const handleProgressStatus = useCallback(
|
||||
(isUploading: boolean) => {
|
||||
getExtensionStorage(editor, CORE_EXTENSIONS.UTILITY).uploadInProgress = isUploading;
|
||||
},
|
||||
[editor]
|
||||
);
|
||||
|
||||
const handleInvalidFile = useCallback((_error: EFileError, _file: File, message: string) => {
|
||||
alert(message);
|
||||
}, []);
|
||||
|
||||
// hooks
|
||||
const { isUploading: isImageBeingUploaded, uploadFile } = useUploader({
|
||||
acceptedMimeTypes: ACCEPTED_IMAGE_MIME_TYPES,
|
||||
// @ts-expect-error - TODO: fix typings, and don't remove await from here for now
|
||||
editorCommand: async (file) => await editor?.commands.uploadImage(imageEntityId, file),
|
||||
handleProgressStatus: (isUploading) => {
|
||||
getExtensionStorage(editor, CORE_EXTENSIONS.UTILITY).uploadInProgress = isUploading;
|
||||
},
|
||||
editorCommand: uploadImageEditorCommand,
|
||||
handleProgressStatus,
|
||||
loadFileFromFileSystem: loadImageFromFileSystem,
|
||||
maxFileSize,
|
||||
onInvalidFile: handleInvalidFile,
|
||||
onUpload,
|
||||
});
|
||||
|
||||
const { draggedInside, onDrop, onDragEnter, onDragLeave } = useDropZone({
|
||||
acceptedMimeTypes: ACCEPTED_IMAGE_MIME_TYPES,
|
||||
editor,
|
||||
maxFileSize,
|
||||
onInvalidFile: (_error, message) => alert(message),
|
||||
pos: getPos(),
|
||||
type: "image",
|
||||
uploader: uploadFile,
|
||||
@@ -122,11 +138,8 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
|
||||
return;
|
||||
}
|
||||
await uploadFirstFileAndInsertRemaining({
|
||||
acceptedMimeTypes: ACCEPTED_IMAGE_MIME_TYPES,
|
||||
editor,
|
||||
filesList,
|
||||
maxFileSize,
|
||||
onInvalidFile: (_error, message) => alert(message),
|
||||
pos: getPos(),
|
||||
type: "image",
|
||||
uploader: uploadFile,
|
||||
|
||||
@@ -81,6 +81,7 @@ export const useEditor = (props: CustomEditorProps) => {
|
||||
immediatelyRender: false,
|
||||
shouldRerenderOnTransaction: false,
|
||||
autofocus,
|
||||
parseOptions: { preserveWhitespace: true },
|
||||
editorProps: {
|
||||
...CoreEditorProps({
|
||||
editorClassName,
|
||||
@@ -119,7 +120,7 @@ export const useEditor = (props: CustomEditorProps) => {
|
||||
const isUploadInProgress = getExtensionStorage(editor, CORE_EXTENSIONS.UTILITY)?.uploadInProgress;
|
||||
if (!editor.isDestroyed && !isUploadInProgress) {
|
||||
try {
|
||||
editor.commands.setContent(value, false, { preserveWhitespace: "full" });
|
||||
editor.commands.setContent(value, false, { preserveWhitespace: true });
|
||||
if (editor.state.selection) {
|
||||
const docLength = editor.state.doc.content.size;
|
||||
const relativePosition = Math.min(editor.state.selection.from, docLength - 1);
|
||||
@@ -153,7 +154,7 @@ export const useEditor = (props: CustomEditorProps) => {
|
||||
editor?.chain().setMeta(CORE_EDITOR_META.SKIP_FILE_DELETION, true).clearContent(emitUpdate).run();
|
||||
},
|
||||
setEditorValue: (content: string, emitUpdate = false) => {
|
||||
editor?.commands.setContent(content, emitUpdate, { preserveWhitespace: "full" });
|
||||
editor?.commands.setContent(content, emitUpdate, { preserveWhitespace: true });
|
||||
},
|
||||
setEditorValueAtCursorPosition: (content: string) => {
|
||||
if (editor?.state.selection) {
|
||||
|
||||
@@ -9,11 +9,11 @@ import { TEditorCommands } from "@/types";
|
||||
|
||||
type TUploaderArgs = {
|
||||
acceptedMimeTypes: string[];
|
||||
editorCommand: (file: File) => Promise<string>;
|
||||
editorCommand: (file: File) => Promise<string | undefined>;
|
||||
handleProgressStatus?: (isUploading: boolean) => void;
|
||||
loadFileFromFileSystem?: (file: string) => void;
|
||||
maxFileSize: number;
|
||||
onInvalidFile: (error: EFileError, message: string) => void;
|
||||
onInvalidFile: (error: EFileError, file: File, message: string) => void;
|
||||
onUpload: (url: string, file: File) => void;
|
||||
};
|
||||
|
||||
@@ -38,7 +38,7 @@ export const useUploader = (args: TUploaderArgs) => {
|
||||
acceptedMimeTypes,
|
||||
file,
|
||||
maxFileSize,
|
||||
onError: onInvalidFile,
|
||||
onError: (error, message) => onInvalidFile(error, file, message),
|
||||
});
|
||||
if (!isValid) {
|
||||
handleProgressStatus?.(false);
|
||||
@@ -60,7 +60,7 @@ export const useUploader = (args: TUploaderArgs) => {
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
const url: string = await editorCommand(file);
|
||||
const url = await editorCommand(file);
|
||||
|
||||
if (!url) {
|
||||
throw new Error("Something went wrong while uploading the file.");
|
||||
@@ -74,24 +74,29 @@ export const useUploader = (args: TUploaderArgs) => {
|
||||
setIsUploading(false);
|
||||
}
|
||||
},
|
||||
[acceptedMimeTypes, editorCommand, handleProgressStatus, loadFileFromFileSystem, maxFileSize, onUpload]
|
||||
[
|
||||
acceptedMimeTypes,
|
||||
editorCommand,
|
||||
handleProgressStatus,
|
||||
loadFileFromFileSystem,
|
||||
maxFileSize,
|
||||
onInvalidFile,
|
||||
onUpload,
|
||||
]
|
||||
);
|
||||
|
||||
return { isUploading, uploadFile };
|
||||
};
|
||||
|
||||
type TDropzoneArgs = {
|
||||
acceptedMimeTypes: string[];
|
||||
editor: Editor;
|
||||
maxFileSize: number;
|
||||
onInvalidFile: (error: EFileError, message: string) => void;
|
||||
pos: number;
|
||||
type: Extract<TEditorCommands, "attachment" | "image">;
|
||||
uploader: (file: File) => Promise<void>;
|
||||
};
|
||||
|
||||
export const useDropZone = (args: TDropzoneArgs) => {
|
||||
const { acceptedMimeTypes, editor, maxFileSize, onInvalidFile, pos, type, uploader } = args;
|
||||
const { editor, pos, type, uploader } = args;
|
||||
// states
|
||||
const [isDragging, setIsDragging] = useState<boolean>(false);
|
||||
const [draggedInside, setDraggedInside] = useState<boolean>(false);
|
||||
@@ -118,22 +123,21 @@ export const useDropZone = (args: TDropzoneArgs) => {
|
||||
async (e: DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
setDraggedInside(false);
|
||||
if (e.dataTransfer.files.length === 0 || !editor.isEditable) {
|
||||
const filesList = e.dataTransfer.files;
|
||||
|
||||
if (filesList.length === 0 || !editor.isEditable) {
|
||||
return;
|
||||
}
|
||||
const filesList = e.dataTransfer.files;
|
||||
|
||||
await uploadFirstFileAndInsertRemaining({
|
||||
acceptedMimeTypes,
|
||||
editor,
|
||||
filesList,
|
||||
maxFileSize,
|
||||
onInvalidFile,
|
||||
pos,
|
||||
type,
|
||||
uploader,
|
||||
});
|
||||
},
|
||||
[acceptedMimeTypes, editor, maxFileSize, onInvalidFile, pos, type, uploader]
|
||||
[editor, pos, type, uploader]
|
||||
);
|
||||
const onDragEnter = useCallback(() => setDraggedInside(true), []);
|
||||
const onDragLeave = useCallback(() => setDraggedInside(false), []);
|
||||
@@ -148,11 +152,8 @@ export const useDropZone = (args: TDropzoneArgs) => {
|
||||
};
|
||||
|
||||
type TMultipleFileArgs = {
|
||||
acceptedMimeTypes: string[];
|
||||
editor: Editor;
|
||||
filesList: FileList;
|
||||
maxFileSize: number;
|
||||
onInvalidFile: (error: EFileError, message: string) => void;
|
||||
pos: number;
|
||||
type: Extract<TEditorCommands, "attachment" | "image">;
|
||||
uploader: (file: File) => Promise<void>;
|
||||
@@ -160,35 +161,18 @@ type TMultipleFileArgs = {
|
||||
|
||||
// Upload the first file and insert the remaining ones for uploading multiple files
|
||||
export const uploadFirstFileAndInsertRemaining = async (args: TMultipleFileArgs) => {
|
||||
const { acceptedMimeTypes, editor, filesList, maxFileSize, onInvalidFile, pos, type, uploader } = args;
|
||||
const filteredFiles: File[] = [];
|
||||
for (let i = 0; i < filesList.length; i += 1) {
|
||||
const file = filesList.item(i);
|
||||
if (
|
||||
file &&
|
||||
isFileValid({
|
||||
acceptedMimeTypes,
|
||||
file,
|
||||
maxFileSize,
|
||||
onError: onInvalidFile,
|
||||
})
|
||||
) {
|
||||
filteredFiles.push(file);
|
||||
}
|
||||
}
|
||||
if (filteredFiles.length !== filesList.length) {
|
||||
console.warn("Some files were invalid and have been ignored.");
|
||||
}
|
||||
if (filteredFiles.length === 0) {
|
||||
const { editor, filesList, pos, type, uploader } = args;
|
||||
const filesArray = Array.from(filesList);
|
||||
if (filesArray.length === 0) {
|
||||
console.error("No files found to upload.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Upload the first file
|
||||
const firstFile = filteredFiles[0];
|
||||
const firstFile = filesArray[0];
|
||||
uploader(firstFile);
|
||||
// Insert the remaining files
|
||||
const remainingFiles = filteredFiles.slice(1);
|
||||
const remainingFiles = filesArray.slice(1);
|
||||
if (remainingFiles.length > 0) {
|
||||
const docSize = editor.state.doc.content.size;
|
||||
const posOfNextFileToBeInserted = Math.min(pos + 1, docSize);
|
||||
|
||||
@@ -46,6 +46,7 @@ export const useReadOnlyEditor = (props: CustomReadOnlyEditorProps) => {
|
||||
immediatelyRender: true,
|
||||
shouldRerenderOnTransaction: false,
|
||||
content: typeof initialValue === "string" && initialValue.trim() !== "" ? initialValue : "<p></p>",
|
||||
parseOptions: { preserveWhitespace: true },
|
||||
editorProps: {
|
||||
...CoreReadOnlyEditorProps({
|
||||
editorClassName,
|
||||
@@ -71,7 +72,7 @@ export const useReadOnlyEditor = (props: CustomReadOnlyEditorProps) => {
|
||||
// for syncing swr data on tab refocus etc
|
||||
useEffect(() => {
|
||||
if (initialValue === null || initialValue === undefined) return;
|
||||
if (editor && !editor.isDestroyed) editor?.commands.setContent(initialValue, false, { preserveWhitespace: "full" });
|
||||
if (editor && !editor.isDestroyed) editor?.commands.setContent(initialValue, false, { preserveWhitespace: true });
|
||||
}, [editor, initialValue]);
|
||||
|
||||
useImperativeHandle(forwardedRef, () => ({
|
||||
@@ -79,7 +80,7 @@ export const useReadOnlyEditor = (props: CustomReadOnlyEditorProps) => {
|
||||
editor?.chain().setMeta(CORE_EDITOR_META.SKIP_FILE_DELETION, true).clearContent(emitUpdate).run();
|
||||
},
|
||||
setEditorValue: (content: string, emitUpdate = false) => {
|
||||
editor?.commands.setContent(content, emitUpdate, { preserveWhitespace: "full" });
|
||||
editor?.commands.setContent(content, emitUpdate, { preserveWhitespace: true });
|
||||
},
|
||||
getMarkDown: (): string => {
|
||||
const markdownOutput = editor?.storage.markdown.getMarkdown();
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Editor } from "@tiptap/core";
|
||||
import { EditorState, Plugin, PluginKey, Transaction } from "@tiptap/pm/state";
|
||||
// constants
|
||||
import { CORE_EDITOR_META } from "@/constants/meta";
|
||||
// plane editor imports
|
||||
import { NODE_FILE_MAP } from "@/plane-editor/constants/utility";
|
||||
// types
|
||||
@@ -32,7 +34,7 @@ export const TrackFileDeletionPlugin = (editor: Editor, deleteHandler: TFileHand
|
||||
|
||||
transactions.forEach((transaction) => {
|
||||
// if the transaction has meta of skipFileDeletion set to true, then return (like while clearing the editor content programmatically)
|
||||
if (transaction.getMeta("skipFileDeletion")) return;
|
||||
if (transaction.getMeta(CORE_EDITOR_META.SKIP_FILE_DELETION)) return;
|
||||
|
||||
const removedFiles: TFileNode[] = [];
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export type TReadOnlyFileHandler = {
|
||||
checkIfAssetExists: (assetId: string) => Promise<boolean>;
|
||||
getAssetSrc: (path: string) => Promise<string>;
|
||||
restore: (assetSrc: string) => Promise<void>;
|
||||
};
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
"@plane/eslint-config": "*",
|
||||
"@types/node": "^22.5.4",
|
||||
"@types/react": "^18.3.11",
|
||||
"tsup": "^8.5.0",
|
||||
"tsup": "8.4.0",
|
||||
"typescript": "^5.3.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,4 +24,13 @@ export const SUPPORTED_LANGUAGES: ILanguageOption[] = [
|
||||
{ label: "Türkçe", value: "tr-TR" },
|
||||
];
|
||||
|
||||
/**
|
||||
* Enum for translation file names
|
||||
* These are the JSON files that contain translations each category
|
||||
*/
|
||||
export enum ETranslationFiles {
|
||||
TRANSLATIONS = "translations",
|
||||
ACCESSIBILITY = "accessibility",
|
||||
}
|
||||
|
||||
export const LANGUAGE_STORAGE_KEY = "userLanguage";
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"aria_labels": {
|
||||
"projects_sidebar": {
|
||||
"workspace_logo": "Logo pracovního prostoru",
|
||||
"open_workspace_switcher": "Otevřít přepínač pracovního prostoru",
|
||||
"open_user_menu": "Otevřít uživatelské menu",
|
||||
"open_command_palette": "Otevřít paletu příkazů",
|
||||
"open_extended_sidebar": "Otevřít rozšířený postranní panel",
|
||||
"close_extended_sidebar": "Zavřít rozšířený postranní panel",
|
||||
"create_favorites_folder": "Vytvořit složku oblíbených",
|
||||
"open_folder": "Otevřít složku",
|
||||
"close_folder": "Zavřít složku",
|
||||
"open_favorites_menu": "Otevřít menu oblíbených",
|
||||
"close_favorites_menu": "Zavřít menu oblíbených",
|
||||
"enter_folder_name": "Zadejte název složky",
|
||||
"create_new_project": "Vytvořit nový projekt",
|
||||
"open_projects_menu": "Otevřít menu projektů",
|
||||
"close_projects_menu": "Zavřít menu projektů",
|
||||
"toggle_quick_actions_menu": "Přepnout menu rychlých akcí",
|
||||
"open_project_menu": "Otevřít menu projektu",
|
||||
"close_project_menu": "Zavřít menu projektu",
|
||||
"collapse_sidebar": "Sbalit postranní panel",
|
||||
"expand_sidebar": "Rozbalit postranní panel",
|
||||
"edition_badge": "Otevřít modal placených plánů"
|
||||
},
|
||||
"auth_forms": {
|
||||
"clear_email": "Vymazat e-mail",
|
||||
"show_password": "Zobrazit heslo",
|
||||
"hide_password": "Skrýt heslo",
|
||||
"close_alert": "Zavřít upozornění",
|
||||
"close_popover": "Zavřít vyskakovací okno"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -848,6 +848,7 @@
|
||||
"live": "Živě",
|
||||
"change_history": "Historie změn",
|
||||
"coming_soon": "Již brzy",
|
||||
"member": "Člen",
|
||||
"members": "Členové",
|
||||
"you": "Vy",
|
||||
"upgrade_cta": {
|
||||
@@ -1080,7 +1081,9 @@
|
||||
"select": {
|
||||
"error": "Vyberte alespoň jednu pracovní položku",
|
||||
"empty": "Nevybrány žádné pracovní položky",
|
||||
"add_selected": "Přidat vybrané pracovní položky"
|
||||
"add_selected": "Přidat vybrané pracovní položky",
|
||||
"select_all": "Vybrat vše",
|
||||
"deselect_all": "Zrušit výběr všeho"
|
||||
},
|
||||
"open_in_full_screen": "Otevřít pracovní položku na celou obrazovku"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"aria_labels": {
|
||||
"projects_sidebar": {
|
||||
"workspace_logo": "Arbeitsbereich-Logo",
|
||||
"open_workspace_switcher": "Arbeitsbereich-Umschalter öffnen",
|
||||
"open_user_menu": "Benutzermenü öffnen",
|
||||
"open_command_palette": "Befehlspalette öffnen",
|
||||
"open_extended_sidebar": "Erweiterte Seitenleiste öffnen",
|
||||
"close_extended_sidebar": "Erweiterte Seitenleiste schließen",
|
||||
"create_favorites_folder": "Favoriten-Ordner erstellen",
|
||||
"open_folder": "Ordner öffnen",
|
||||
"close_folder": "Ordner schließen",
|
||||
"open_favorites_menu": "Favoriten-Menü öffnen",
|
||||
"close_favorites_menu": "Favoriten-Menü schließen",
|
||||
"enter_folder_name": "Ordnername eingeben",
|
||||
"create_new_project": "Neues Projekt erstellen",
|
||||
"open_projects_menu": "Projekt-Menü öffnen",
|
||||
"close_projects_menu": "Projekt-Menü schließen",
|
||||
"toggle_quick_actions_menu": "Schnellaktionen-Menü umschalten",
|
||||
"open_project_menu": "Projekt-Menü öffnen",
|
||||
"close_project_menu": "Projekt-Menü schließen",
|
||||
"collapse_sidebar": "Seitenleiste einklappen",
|
||||
"expand_sidebar": "Seitenleiste ausklappen",
|
||||
"edition_badge": "Modal für kostenpflichtige Pläne öffnen"
|
||||
},
|
||||
"auth_forms": {
|
||||
"clear_email": "E-Mail löschen",
|
||||
"show_password": "Passwort anzeigen",
|
||||
"hide_password": "Passwort verbergen",
|
||||
"close_alert": "Warnung schließen",
|
||||
"close_popover": "Popover schließen"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -848,6 +848,7 @@
|
||||
"live": "Live",
|
||||
"change_history": "Änderungsverlauf",
|
||||
"coming_soon": "Demnächst verfügbar",
|
||||
"member": "Mitglied",
|
||||
"members": "Mitglieder",
|
||||
"you": "Sie",
|
||||
"upgrade_cta": {
|
||||
@@ -1080,7 +1081,9 @@
|
||||
"select": {
|
||||
"error": "Wählen Sie mindestens ein Arbeitselement aus",
|
||||
"empty": "Keine Arbeitselemente ausgewählt",
|
||||
"add_selected": "Ausgewählte Arbeitselemente hinzufügen"
|
||||
"add_selected": "Ausgewählte Arbeitselemente hinzufügen",
|
||||
"select_all": "Alle auswählen",
|
||||
"deselect_all": "Alle abwählen"
|
||||
},
|
||||
"open_in_full_screen": "Arbeitselement im Vollbild öffnen"
|
||||
},
|
||||
@@ -2459,4 +2462,4 @@
|
||||
"previously_edited_by": "Zuvor bearbeitet von",
|
||||
"edited_by": "Bearbeitet von"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"aria_labels": {
|
||||
"projects_sidebar": {
|
||||
"workspace_logo": "Workspace logo",
|
||||
"open_workspace_switcher": "Open workspace switcher",
|
||||
"open_user_menu": "Open user menu",
|
||||
"open_command_palette": "Open command palette",
|
||||
"open_extended_sidebar": "Open extended sidebar",
|
||||
"close_extended_sidebar": "Close extended sidebar",
|
||||
"create_favorites_folder": "Create favorites folder",
|
||||
"open_folder": "Open folder",
|
||||
"close_folder": "Close folder",
|
||||
"open_favorites_menu": "Open favorites menu",
|
||||
"close_favorites_menu": "Close favorites menu",
|
||||
"enter_folder_name": "Enter folder name",
|
||||
"create_new_project": "Create new project",
|
||||
"open_projects_menu": "Open projects menu",
|
||||
"close_projects_menu": "Close projects menu",
|
||||
"toggle_quick_actions_menu": "Toggle quick actions menu",
|
||||
"open_project_menu": "Open project menu",
|
||||
"close_project_menu": "Close project menu",
|
||||
"collapse_sidebar": "Collapse sidebar",
|
||||
"expand_sidebar": "Expand sidebar",
|
||||
"edition_badge": "Open paid plans' modal"
|
||||
},
|
||||
"auth_forms": {
|
||||
"clear_email": "Clear email",
|
||||
"show_password": "Show password",
|
||||
"hide_password": "Hide password",
|
||||
"close_alert": "Close alert",
|
||||
"close_popover": "Close popover"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -43,7 +43,8 @@
|
||||
"your_account": "Your account",
|
||||
"security": "Security",
|
||||
"activity": "Activity",
|
||||
"appearance": "Appearance",
|
||||
"preferences": "Preferences",
|
||||
"language_and_time": "Language & Time",
|
||||
"notifications": "Notifications",
|
||||
"workspaces": "Workspaces",
|
||||
"create_workspace": "Create workspace",
|
||||
@@ -56,6 +57,10 @@
|
||||
"something_went_wrong_please_try_again": "Something went wrong. Please try again.",
|
||||
"load_more": "Load more",
|
||||
"select_or_customize_your_interface_color_scheme": "Select or customize your interface color scheme.",
|
||||
"timezone_setting": "Current timezone setting.",
|
||||
"language_setting": "Choose the language used in the user interface.",
|
||||
"settings_moved_to_preferences": "Timezone & Language settings have been moved to preferences.",
|
||||
"go_to_preferences": "Go to preferences",
|
||||
"theme": "Theme",
|
||||
"system_preference": "System preference",
|
||||
"light": "Light",
|
||||
@@ -334,6 +339,8 @@
|
||||
"new_password_must_be_different_from_old_password": "New password must be different from old password",
|
||||
"edited": "edited",
|
||||
"bot": "Bot",
|
||||
"settings_description": "Manage your account, workspace, and project preferences all in one place. Switch between tabs to easily configure.",
|
||||
"back_to_workspace": "Back to workspace",
|
||||
"project_view": {
|
||||
"sort_by": {
|
||||
"created_at": "Created at",
|
||||
@@ -683,6 +690,7 @@
|
||||
"live": "Live",
|
||||
"change_history": "Change History",
|
||||
"coming_soon": "Coming soon",
|
||||
"member": "Member",
|
||||
"members": "Members",
|
||||
"you": "You",
|
||||
"upgrade_cta": {
|
||||
@@ -916,7 +924,9 @@
|
||||
"select": {
|
||||
"error": "Please select at least one work item",
|
||||
"empty": "No work items selected",
|
||||
"add_selected": "Add selected work items"
|
||||
"add_selected": "Add selected work items",
|
||||
"select_all": "Select all",
|
||||
"deselect_all": "Deselect all"
|
||||
},
|
||||
"open_in_full_screen": "Open work item in full screen"
|
||||
},
|
||||
@@ -1301,6 +1311,28 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"account_settings": {
|
||||
"profile":{},
|
||||
"preferences":{
|
||||
"heading": "Preferences",
|
||||
"description": "Customize your app experience the way you work"
|
||||
},
|
||||
"notifications":{
|
||||
"heading": "Email notifications",
|
||||
"description": "Stay in the loop on Work items you are subscribed to. Enable this to get notified."
|
||||
},
|
||||
"security":{
|
||||
"heading": "Security"
|
||||
},
|
||||
"api_tokens":{
|
||||
"heading": "Personal Access Tokens",
|
||||
"description": "Generate secure API tokens to integrate your data with external systems and applications."
|
||||
},
|
||||
"activity":{
|
||||
"heading": "Activity",
|
||||
"description": "Track your recent actions and changes across all projects and work items."
|
||||
}
|
||||
},
|
||||
"workspace_settings": {
|
||||
"label": "Workspace settings",
|
||||
"page_label": "{workspace} - General settings",
|
||||
@@ -1367,16 +1399,22 @@
|
||||
}
|
||||
},
|
||||
"billing_and_plans": {
|
||||
"heading": "Billing & Plans",
|
||||
"description":"Choose your plan, manage subscriptions, and easily upgrade as your needs grow.",
|
||||
"title": "Billing & Plans",
|
||||
"current_plan": "Current plan",
|
||||
"free_plan": "You are currently using the free plan",
|
||||
"view_plans": "View plans"
|
||||
},
|
||||
"exports": {
|
||||
"heading": "Exports",
|
||||
"description": "Export your project data in various formats and access your export history with download links.",
|
||||
"title": "Exports",
|
||||
"exporting": "Exporting",
|
||||
"previous_exports": "Previous exports",
|
||||
"export_separate_files": "Export the data into separate files",
|
||||
"exporting_projects": "Exporting project",
|
||||
"format": "Format",
|
||||
"modal": {
|
||||
"title": "Export to",
|
||||
"toasts": {
|
||||
@@ -1392,6 +1430,8 @@
|
||||
}
|
||||
},
|
||||
"webhooks": {
|
||||
"heading": "Webhooks",
|
||||
"description": "Automate notifications to external services when project events occur.",
|
||||
"title": "Webhooks",
|
||||
"add_webhook": "Add webhook",
|
||||
"modal": {
|
||||
@@ -1443,29 +1483,29 @@
|
||||
}
|
||||
},
|
||||
"api_tokens": {
|
||||
"title": "API Tokens",
|
||||
"add_token": "Add API token",
|
||||
"title": "Personal Access Tokens",
|
||||
"add_token": "Add personal access token",
|
||||
"create_token": "Create token",
|
||||
"never_expires": "Never expires",
|
||||
"generate_token": "Generate token",
|
||||
"generating": "Generating",
|
||||
"delete": {
|
||||
"title": "Delete API token",
|
||||
"title": "Delete personal access token",
|
||||
"description": "Any application using this token will no longer have the access to Plane data. This action cannot be undone.",
|
||||
"success": {
|
||||
"title": "Success!",
|
||||
"message": "The API token has been successfully deleted"
|
||||
"message": "The token has been successfully deleted"
|
||||
},
|
||||
"error": {
|
||||
"title": "Error!",
|
||||
"message": "The API token could not be deleted"
|
||||
"message": "The token could not be deleted"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"empty_state": {
|
||||
"api_tokens": {
|
||||
"title": "No API tokens created",
|
||||
"title": "No personal access tokens created",
|
||||
"description": "Plane APIs can be used to integrate your data in Plane with any external system. Create a token to get started."
|
||||
},
|
||||
"webhooks": {
|
||||
@@ -1515,8 +1555,9 @@
|
||||
"profile": "Profile",
|
||||
"security": "Security",
|
||||
"activity": "Activity",
|
||||
"appearance": "Appearance",
|
||||
"notifications": "Notifications"
|
||||
"preferences": "Preferences",
|
||||
"notifications": "Notifications",
|
||||
"api-tokens": "Personal Access Tokens"
|
||||
},
|
||||
"tabs": {
|
||||
"summary": "Summary",
|
||||
@@ -1578,6 +1619,8 @@
|
||||
}
|
||||
},
|
||||
"states": {
|
||||
"heading": "States",
|
||||
"description": "Define and customize workflow states to track the progress of your work items.",
|
||||
"describe_this_state_for_your_members": "Describe this state for your members.",
|
||||
"empty_state": {
|
||||
"title": "No states available for the {groupKey} group",
|
||||
@@ -1585,6 +1628,8 @@
|
||||
}
|
||||
},
|
||||
"labels": {
|
||||
"heading": "Labels",
|
||||
"description": "Create custom labels to categorize and organize your work items",
|
||||
"label_title": "Label title",
|
||||
"label_title_is_required": "Label title is required",
|
||||
"label_max_char": "Label name should not exceed 255 characters",
|
||||
@@ -1593,9 +1638,11 @@
|
||||
}
|
||||
},
|
||||
"estimates": {
|
||||
"heading": "Estimates",
|
||||
"description": "Set up estimation systems to track and communicate the effort required for each work item.",
|
||||
"label": "Estimates",
|
||||
"title": "Enable estimates for my project",
|
||||
"description": "They help you in communicating complexity and workload of the team.",
|
||||
"enable_description": "They help you in communicating complexity and workload of the team.",
|
||||
"no_estimate": "No estimate",
|
||||
"new": "New estimate system",
|
||||
"create": {
|
||||
@@ -1677,6 +1724,8 @@
|
||||
},
|
||||
"automations": {
|
||||
"label": "Automations",
|
||||
"heading": "Automations",
|
||||
"description": "Configure automated actions to streamline your project management workflow and reduce manual tasks.",
|
||||
"auto-archive": {
|
||||
"title": "Auto-archive closed work items",
|
||||
"description": "Plane will auto archive work items that have been completed or canceled.",
|
||||
@@ -2296,4 +2345,4 @@
|
||||
"previously_edited_by": "Previously edited by",
|
||||
"edited_by": "Edited by"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"aria_labels": {
|
||||
"projects_sidebar": {
|
||||
"workspace_logo": "Logo del espacio de trabajo",
|
||||
"open_workspace_switcher": "Abrir cambiador de espacio de trabajo",
|
||||
"open_user_menu": "Abrir menú de usuario",
|
||||
"open_command_palette": "Abrir paleta de comandos",
|
||||
"open_extended_sidebar": "Abrir barra lateral extendida",
|
||||
"close_extended_sidebar": "Cerrar barra lateral extendida",
|
||||
"create_favorites_folder": "Crear carpeta de favoritos",
|
||||
"open_folder": "Abrir carpeta",
|
||||
"close_folder": "Cerrar carpeta",
|
||||
"open_favorites_menu": "Abrir menú de favoritos",
|
||||
"close_favorites_menu": "Cerrar menú de favoritos",
|
||||
"enter_folder_name": "Ingresar nombre de carpeta",
|
||||
"create_new_project": "Crear nuevo proyecto",
|
||||
"open_projects_menu": "Abrir menú de proyectos",
|
||||
"close_projects_menu": "Cerrar menú de proyectos",
|
||||
"toggle_quick_actions_menu": "Alternar menú de acciones rápidas",
|
||||
"open_project_menu": "Abrir menú de proyecto",
|
||||
"close_project_menu": "Cerrar menú de proyecto",
|
||||
"collapse_sidebar": "Colapsar barra lateral",
|
||||
"expand_sidebar": "Expandir barra lateral",
|
||||
"edition_badge": "Abrir modal de planes de pago"
|
||||
},
|
||||
"auth_forms": {
|
||||
"clear_email": "Limpiar correo electrónico",
|
||||
"show_password": "Mostrar contraseña",
|
||||
"hide_password": "Ocultar contraseña",
|
||||
"close_alert": "Cerrar alerta",
|
||||
"close_popover": "Cerrar ventana emergente"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -851,6 +851,7 @@
|
||||
"live": "En vivo",
|
||||
"change_history": "Historial de cambios",
|
||||
"coming_soon": "Próximamente",
|
||||
"member": "Miembro",
|
||||
"members": "Miembros",
|
||||
"you": "Tú",
|
||||
"upgrade_cta": {
|
||||
@@ -1083,7 +1084,9 @@
|
||||
"select": {
|
||||
"error": "Por favor selecciona al menos un elemento de trabajo",
|
||||
"empty": "No hay elementos de trabajo seleccionados",
|
||||
"add_selected": "Agregar elementos seleccionados"
|
||||
"add_selected": "Agregar elementos seleccionados",
|
||||
"select_all": "Seleccionar todo",
|
||||
"deselect_all": "Deseleccionar todo"
|
||||
},
|
||||
"open_in_full_screen": "Abrir elemento de trabajo en pantalla completa"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"aria_labels": {
|
||||
"projects_sidebar": {
|
||||
"workspace_logo": "Logo de l'espace de travail",
|
||||
"open_workspace_switcher": "Ouvrir le sélecteur d'espace de travail",
|
||||
"open_user_menu": "Ouvrir le menu utilisateur",
|
||||
"open_command_palette": "Ouvrir la palette de commandes",
|
||||
"open_extended_sidebar": "Ouvrir la barre latérale étendue",
|
||||
"close_extended_sidebar": "Fermer la barre latérale étendue",
|
||||
"create_favorites_folder": "Créer un dossier de favoris",
|
||||
"open_folder": "Ouvrir le dossier",
|
||||
"close_folder": "Fermer le dossier",
|
||||
"open_favorites_menu": "Ouvrir le menu des favoris",
|
||||
"close_favorites_menu": "Fermer le menu des favoris",
|
||||
"enter_folder_name": "Saisir le nom du dossier",
|
||||
"create_new_project": "Créer un nouveau projet",
|
||||
"open_projects_menu": "Ouvrir le menu des projets",
|
||||
"close_projects_menu": "Fermer le menu des projets",
|
||||
"toggle_quick_actions_menu": "Basculer le menu d'actions rapides",
|
||||
"open_project_menu": "Ouvrir le menu du projet",
|
||||
"close_project_menu": "Fermer le menu du projet",
|
||||
"collapse_sidebar": "Réduire la barre latérale",
|
||||
"expand_sidebar": "Étendre la barre latérale",
|
||||
"edition_badge": "Ouvrir le modal des plans payants"
|
||||
},
|
||||
"auth_forms": {
|
||||
"clear_email": "Effacer l'e-mail",
|
||||
"show_password": "Afficher le mot de passe",
|
||||
"hide_password": "Masquer le mot de passe",
|
||||
"close_alert": "Fermer l'alerte",
|
||||
"close_popover": "Fermer la fenêtre contextuelle"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -849,6 +849,7 @@
|
||||
"live": "En direct",
|
||||
"change_history": "Historique des modifications",
|
||||
"coming_soon": "À venir",
|
||||
"member": "Membre",
|
||||
"members": "Membres",
|
||||
"you": "Vous",
|
||||
"upgrade_cta": {
|
||||
@@ -1081,7 +1082,9 @@
|
||||
"select": {
|
||||
"error": "Veuillez sélectionner au moins un élément de travail",
|
||||
"empty": "Aucun élément de travail sélectionné",
|
||||
"add_selected": "Ajouter les éléments de travail sélectionnés"
|
||||
"add_selected": "Ajouter les éléments de travail sélectionnés",
|
||||
"select_all": "Sélectionner tout",
|
||||
"deselect_all": "Tout désélectionner"
|
||||
},
|
||||
"open_in_full_screen": "Ouvrir l'élément de travail en plein écran"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"aria_labels": {
|
||||
"projects_sidebar": {
|
||||
"workspace_logo": "Logo ruang kerja",
|
||||
"open_workspace_switcher": "Buka penukar ruang kerja",
|
||||
"open_user_menu": "Buka menu pengguna",
|
||||
"open_command_palette": "Buka palet perintah",
|
||||
"open_extended_sidebar": "Buka sidebar diperluas",
|
||||
"close_extended_sidebar": "Tutup sidebar diperluas",
|
||||
"create_favorites_folder": "Buat folder favorit",
|
||||
"open_folder": "Buka folder",
|
||||
"close_folder": "Tutup folder",
|
||||
"open_favorites_menu": "Buka menu favorit",
|
||||
"close_favorites_menu": "Tutup menu favorit",
|
||||
"enter_folder_name": "Masukkan nama folder",
|
||||
"create_new_project": "Buat proyek baru",
|
||||
"open_projects_menu": "Buka menu proyek",
|
||||
"close_projects_menu": "Tutup menu proyek",
|
||||
"toggle_quick_actions_menu": "Alihkan menu tindakan cepat",
|
||||
"open_project_menu": "Buka menu proyek",
|
||||
"close_project_menu": "Tutup menu proyek",
|
||||
"collapse_sidebar": "Tutup sidebar",
|
||||
"expand_sidebar": "Perluas sidebar",
|
||||
"edition_badge": "Buka modal paket berbayar"
|
||||
},
|
||||
"auth_forms": {
|
||||
"clear_email": "Hapus email",
|
||||
"show_password": "Tampilkan kata sandi",
|
||||
"hide_password": "Sembunyikan kata sandi",
|
||||
"close_alert": "Tutup peringatan",
|
||||
"close_popover": "Tutup popover"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -848,6 +848,7 @@
|
||||
"live": "Langsung",
|
||||
"change_history": "Riwayat Perubahan",
|
||||
"coming_soon": "Segera hadir",
|
||||
"member": "Anggota",
|
||||
"members": "Anggota",
|
||||
"you": "Anda",
|
||||
"upgrade_cta": {
|
||||
@@ -1080,7 +1081,9 @@
|
||||
"select": {
|
||||
"error": "Silakan pilih setidaknya satu item kerja",
|
||||
"empty": "Tidak ada item kerja yang dipilih",
|
||||
"add_selected": "Tambah item kerja yang dipilih"
|
||||
"add_selected": "Tambah item kerja yang dipilih",
|
||||
"select_all": "Pilih semua item kerja",
|
||||
"deselect_all": "Batalkan pilihan semua item kerja"
|
||||
},
|
||||
"open_in_full_screen": "Buka item kerja dalam layar penuh"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"aria_labels": {
|
||||
"projects_sidebar": {
|
||||
"workspace_logo": "Logo dell'area di lavoro",
|
||||
"open_workspace_switcher": "Apri selettore area di lavoro",
|
||||
"open_user_menu": "Apri menu utente",
|
||||
"open_command_palette": "Apri tavolozza comandi",
|
||||
"open_extended_sidebar": "Apri barra laterale estesa",
|
||||
"close_extended_sidebar": "Chiudi barra laterale estesa",
|
||||
"create_favorites_folder": "Crea cartella preferiti",
|
||||
"open_folder": "Apri cartella",
|
||||
"close_folder": "Chiudi cartella",
|
||||
"open_favorites_menu": "Apri menu preferiti",
|
||||
"close_favorites_menu": "Chiudi menu preferiti",
|
||||
"enter_folder_name": "Inserisci nome cartella",
|
||||
"create_new_project": "Crea nuovo progetto",
|
||||
"open_projects_menu": "Apri menu progetti",
|
||||
"close_projects_menu": "Chiudi menu progetti",
|
||||
"toggle_quick_actions_menu": "Attiva/disattiva menu azioni rapide",
|
||||
"open_project_menu": "Apri menu progetto",
|
||||
"close_project_menu": "Chiudi menu progetto",
|
||||
"collapse_sidebar": "Comprimi barra laterale",
|
||||
"expand_sidebar": "Espandi barra laterale",
|
||||
"edition_badge": "Apri modal piani a pagamento"
|
||||
},
|
||||
"auth_forms": {
|
||||
"clear_email": "Cancella email",
|
||||
"show_password": "Mostra password",
|
||||
"hide_password": "Nascondi password",
|
||||
"close_alert": "Chiudi avviso",
|
||||
"close_popover": "Chiudi popover"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -847,6 +847,7 @@
|
||||
"live": "Live",
|
||||
"change_history": "Cronologia modifiche",
|
||||
"coming_soon": "Prossimamente",
|
||||
"member": "Membro",
|
||||
"members": "Membri",
|
||||
"you": "Tu",
|
||||
"upgrade_cta": {
|
||||
@@ -1079,7 +1080,9 @@
|
||||
"select": {
|
||||
"error": "Seleziona almeno un elemento di lavoro",
|
||||
"empty": "Nessun elemento di lavoro selezionato",
|
||||
"add_selected": "Aggiungi gli elementi di lavoro selezionati"
|
||||
"add_selected": "Aggiungi gli elementi di lavoro selezionati",
|
||||
"select_all": "Seleziona tutto",
|
||||
"deselect_all": "Deseleziona tutto"
|
||||
},
|
||||
"open_in_full_screen": "Apri l'elemento di lavoro a schermo intero"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"aria_labels": {
|
||||
"projects_sidebar": {
|
||||
"workspace_logo": "ワークスペースロゴ",
|
||||
"open_workspace_switcher": "ワークスペーススイッチャーを開く",
|
||||
"open_user_menu": "ユーザーメニューを開く",
|
||||
"open_command_palette": "コマンドパレットを開く",
|
||||
"open_extended_sidebar": "拡張サイドバーを開く",
|
||||
"close_extended_sidebar": "拡張サイドバーを閉じる",
|
||||
"create_favorites_folder": "お気に入りフォルダを作成",
|
||||
"open_folder": "フォルダを開く",
|
||||
"close_folder": "フォルダを閉じる",
|
||||
"open_favorites_menu": "お気に入りメニューを開く",
|
||||
"close_favorites_menu": "お気に入りメニューを閉じる",
|
||||
"enter_folder_name": "フォルダ名を入力",
|
||||
"create_new_project": "新しいプロジェクトを作成",
|
||||
"open_projects_menu": "プロジェクトメニューを開く",
|
||||
"close_projects_menu": "プロジェクトメニューを閉じる",
|
||||
"toggle_quick_actions_menu": "クイックアクションメニューの切り替え",
|
||||
"open_project_menu": "プロジェクトメニューを開く",
|
||||
"close_project_menu": "プロジェクトメニューを閉じる",
|
||||
"collapse_sidebar": "サイドバーを折りたたむ",
|
||||
"expand_sidebar": "サイドバーを展開",
|
||||
"edition_badge": "有料プランのモーダルを開く"
|
||||
},
|
||||
"auth_forms": {
|
||||
"clear_email": "メールをクリア",
|
||||
"show_password": "パスワードを表示",
|
||||
"hide_password": "パスワードを非表示",
|
||||
"close_alert": "アラートを閉じる",
|
||||
"close_popover": "ポップオーバーを閉じる"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -849,6 +849,7 @@
|
||||
"live": "ライブ",
|
||||
"change_history": "変更履歴",
|
||||
"coming_soon": "近日公開",
|
||||
"member": "メンバー",
|
||||
"members": "メンバー",
|
||||
"you": "あなた",
|
||||
"upgrade_cta": {
|
||||
@@ -1081,7 +1082,9 @@
|
||||
"select": {
|
||||
"error": "少なくとも1つの作業項目を選択してください",
|
||||
"empty": "作業項目が選択されていません",
|
||||
"add_selected": "選択した作業項目を追加"
|
||||
"add_selected": "選択した作業項目を追加",
|
||||
"select_all": "すべて選択",
|
||||
"deselect_all": "すべての選択を解除"
|
||||
},
|
||||
"open_in_full_screen": "作業項目をフルスクリーンで開く"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"aria_labels": {
|
||||
"projects_sidebar": {
|
||||
"workspace_logo": "워크스페이스 로고",
|
||||
"open_workspace_switcher": "워크스페이스 전환기 열기",
|
||||
"open_user_menu": "사용자 메뉴 열기",
|
||||
"open_command_palette": "명령 팔레트 열기",
|
||||
"open_extended_sidebar": "확장된 사이드바 열기",
|
||||
"close_extended_sidebar": "확장된 사이드바 닫기",
|
||||
"create_favorites_folder": "즐겨찾기 폴더 생성",
|
||||
"open_folder": "폴더 열기",
|
||||
"close_folder": "폴더 닫기",
|
||||
"open_favorites_menu": "즐겨찾기 메뉴 열기",
|
||||
"close_favorites_menu": "즐겨찾기 메뉴 닫기",
|
||||
"enter_folder_name": "폴더 이름 입력",
|
||||
"create_new_project": "새 프로젝트 생성",
|
||||
"open_projects_menu": "프로젝트 메뉴 열기",
|
||||
"close_projects_menu": "프로젝트 메뉴 닫기",
|
||||
"toggle_quick_actions_menu": "빠른 작업 메뉴 토글",
|
||||
"open_project_menu": "프로젝트 메뉴 열기",
|
||||
"close_project_menu": "프로젝트 메뉴 닫기",
|
||||
"collapse_sidebar": "사이드바 축소",
|
||||
"expand_sidebar": "사이드바 확장",
|
||||
"edition_badge": "유료 플랜 모달 열기"
|
||||
},
|
||||
"auth_forms": {
|
||||
"clear_email": "이메일 지우기",
|
||||
"show_password": "비밀번호 표시",
|
||||
"hide_password": "비밀번호 숨기기",
|
||||
"close_alert": "알림 닫기",
|
||||
"close_popover": "팝오버 닫기"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -850,6 +850,7 @@
|
||||
"live": "라이브",
|
||||
"change_history": "변경 기록",
|
||||
"coming_soon": "곧 출시",
|
||||
"member": "멤버",
|
||||
"members": "멤버",
|
||||
"you": "나",
|
||||
"upgrade_cta": {
|
||||
@@ -1082,7 +1083,9 @@
|
||||
"select": {
|
||||
"error": "최소 하나의 작업 항목을 선택하세요",
|
||||
"empty": "선택된 작업 항목 없음",
|
||||
"add_selected": "선택된 작업 항목 추가"
|
||||
"add_selected": "선택된 작업 항목 추가",
|
||||
"select_all": "모두 선택",
|
||||
"deselect_all": "모두 선택 해제"
|
||||
},
|
||||
"open_in_full_screen": "작업 항목을 전체 화면으로 열기"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"aria_labels": {
|
||||
"projects_sidebar": {
|
||||
"workspace_logo": "Logo obszaru roboczego",
|
||||
"open_workspace_switcher": "Otwórz przełącznik obszaru roboczego",
|
||||
"open_user_menu": "Otwórz menu użytkownika",
|
||||
"open_command_palette": "Otwórz paletę poleceń",
|
||||
"open_extended_sidebar": "Otwórz rozszerzoną pasek boczny",
|
||||
"close_extended_sidebar": "Zamknij rozszerzoną pasek boczny",
|
||||
"create_favorites_folder": "Utwórz folder ulubionych",
|
||||
"open_folder": "Otwórz folder",
|
||||
"close_folder": "Zamknij folder",
|
||||
"open_favorites_menu": "Otwórz menu ulubionych",
|
||||
"close_favorites_menu": "Zamknij menu ulubionych",
|
||||
"enter_folder_name": "Wprowadź nazwę folderu",
|
||||
"create_new_project": "Utwórz nowy projekt",
|
||||
"open_projects_menu": "Otwórz menu projektów",
|
||||
"close_projects_menu": "Zamknij menu projektów",
|
||||
"toggle_quick_actions_menu": "Przełącz menu szybkich akcji",
|
||||
"open_project_menu": "Otwórz menu projektu",
|
||||
"close_project_menu": "Zamknij menu projektu",
|
||||
"collapse_sidebar": "Zwiń pasek boczny",
|
||||
"expand_sidebar": "Rozwiń pasek boczny",
|
||||
"edition_badge": "Otwórz modal płatnych planów"
|
||||
},
|
||||
"auth_forms": {
|
||||
"clear_email": "Wyczyść e-mail",
|
||||
"show_password": "Pokaż hasło",
|
||||
"hide_password": "Ukryj hasło",
|
||||
"close_alert": "Zamknij alert",
|
||||
"close_popover": "Zamknij popover"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -850,6 +850,7 @@
|
||||
"live": "Na żywo",
|
||||
"change_history": "Historia zmian",
|
||||
"coming_soon": "Wkrótce",
|
||||
"member": "Członek",
|
||||
"members": "Członkowie",
|
||||
"you": "Ty",
|
||||
"upgrade_cta": {
|
||||
@@ -1082,7 +1083,9 @@
|
||||
"select": {
|
||||
"error": "Wybierz co najmniej jeden element pracy",
|
||||
"empty": "Nie wybrano żadnych elementów pracy",
|
||||
"add_selected": "Dodaj wybrane elementy pracy"
|
||||
"add_selected": "Dodaj wybrane elementy pracy",
|
||||
"select_all": "Wybierz wszystko",
|
||||
"deselect_all": "Odznacz wszystko"
|
||||
},
|
||||
"open_in_full_screen": "Otwórz element pracy na pełnym ekranie"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"aria_labels": {
|
||||
"projects_sidebar": {
|
||||
"workspace_logo": "Logo do espaço de trabalho",
|
||||
"open_workspace_switcher": "Abrir seletor de espaço de trabalho",
|
||||
"open_user_menu": "Abrir menu do usuário",
|
||||
"open_command_palette": "Abrir paleta de comandos",
|
||||
"open_extended_sidebar": "Abrir barra lateral estendida",
|
||||
"close_extended_sidebar": "Fechar barra lateral estendida",
|
||||
"create_favorites_folder": "Criar pasta de favoritos",
|
||||
"open_folder": "Abrir pasta",
|
||||
"close_folder": "Fechar pasta",
|
||||
"open_favorites_menu": "Abrir menu de favoritos",
|
||||
"close_favorites_menu": "Fechar menu de favoritos",
|
||||
"enter_folder_name": "Digite o nome da pasta",
|
||||
"create_new_project": "Criar novo projeto",
|
||||
"open_projects_menu": "Abrir menu de projetos",
|
||||
"close_projects_menu": "Fechar menu de projetos",
|
||||
"toggle_quick_actions_menu": "Alternar menu de ações rápidas",
|
||||
"open_project_menu": "Abrir menu do projeto",
|
||||
"close_project_menu": "Fechar menu do projeto",
|
||||
"collapse_sidebar": "Recolher barra lateral",
|
||||
"expand_sidebar": "Expandir barra lateral",
|
||||
"edition_badge": "Abrir modal de planos pagos"
|
||||
},
|
||||
"auth_forms": {
|
||||
"clear_email": "Limpar e-mail",
|
||||
"show_password": "Mostrar senha",
|
||||
"hide_password": "Ocultar senha",
|
||||
"close_alert": "Fechar alerta",
|
||||
"close_popover": "Fechar popover"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -850,6 +850,7 @@
|
||||
"live": "Ao vivo",
|
||||
"change_history": "Histórico de alterações",
|
||||
"coming_soon": "Em breve",
|
||||
"member": "Membro",
|
||||
"members": "Membros",
|
||||
"you": "Você",
|
||||
"upgrade_cta": {
|
||||
@@ -1082,7 +1083,9 @@
|
||||
"select": {
|
||||
"error": "Selecione pelo menos um item de trabalho",
|
||||
"empty": "Nenhum item de trabalho selecionado",
|
||||
"add_selected": "Adicionar itens de trabalho selecionados"
|
||||
"add_selected": "Adicionar itens de trabalho selecionados",
|
||||
"select_all": "Selecionar tudo",
|
||||
"deselect_all": "Desmarcar tudo"
|
||||
},
|
||||
"open_in_full_screen": "Abrir item de trabalho em tela cheia"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"aria_labels": {
|
||||
"projects_sidebar": {
|
||||
"workspace_logo": "Logo spațiu de lucru",
|
||||
"open_workspace_switcher": "Deschide comutator spațiu de lucru",
|
||||
"open_user_menu": "Deschide meniul utilizatorului",
|
||||
"open_command_palette": "Deschide paleta de comenzi",
|
||||
"open_extended_sidebar": "Deschide bara laterală extinsă",
|
||||
"close_extended_sidebar": "Închide bara laterală extinsă",
|
||||
"create_favorites_folder": "Creează folder de favorite",
|
||||
"open_folder": "Deschide folderul",
|
||||
"close_folder": "Închide folderul",
|
||||
"open_favorites_menu": "Deschide meniul de favorite",
|
||||
"close_favorites_menu": "Închide meniul de favorite",
|
||||
"enter_folder_name": "Introduceți numele folderului",
|
||||
"create_new_project": "Creează proiect nou",
|
||||
"open_projects_menu": "Deschide meniul de proiecte",
|
||||
"close_projects_menu": "Închide meniul de proiecte",
|
||||
"toggle_quick_actions_menu": "Comută meniul de acțiuni rapide",
|
||||
"open_project_menu": "Deschide meniul proiectului",
|
||||
"close_project_menu": "Închide meniul proiectului",
|
||||
"collapse_sidebar": "Restrânge bara laterală",
|
||||
"expand_sidebar": "Extinde bara laterală",
|
||||
"edition_badge": "Deschide modalul planurilor plătite"
|
||||
},
|
||||
"auth_forms": {
|
||||
"clear_email": "Șterge e-mailul",
|
||||
"show_password": "Afișează parola",
|
||||
"hide_password": "Ascunde parola",
|
||||
"close_alert": "Închide alerta",
|
||||
"close_popover": "Închide popover-ul"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -848,6 +848,7 @@
|
||||
"live": "În direct",
|
||||
"change_history": "Istoric modificări",
|
||||
"coming_soon": "În curând",
|
||||
"member": "Membru",
|
||||
"members": "Membri",
|
||||
"you": "Tu",
|
||||
"upgrade_cta": {
|
||||
@@ -1080,7 +1081,9 @@
|
||||
"select": {
|
||||
"error": "Selectează cel puțin o activitate",
|
||||
"empty": "Nicio activitate selectată",
|
||||
"add_selected": "Adaugă activitățile selectate"
|
||||
"add_selected": "Adaugă activitățile selectate",
|
||||
"select_all": "Selectează tot",
|
||||
"deselect_all": "Deselează tot"
|
||||
},
|
||||
"open_in_full_screen": "Deschide activitatea pe tot ecranul"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"aria_labels": {
|
||||
"projects_sidebar": {
|
||||
"workspace_logo": "Логотип рабочей области",
|
||||
"open_workspace_switcher": "Открыть переключатель рабочей области",
|
||||
"open_user_menu": "Открыть пользовательское меню",
|
||||
"open_command_palette": "Открыть палитру команд",
|
||||
"open_extended_sidebar": "Открыть расширенную боковую панель",
|
||||
"close_extended_sidebar": "Закрыть расширенную боковую панель",
|
||||
"create_favorites_folder": "Создать папку избранного",
|
||||
"open_folder": "Открыть папку",
|
||||
"close_folder": "Закрыть папку",
|
||||
"open_favorites_menu": "Открыть меню избранного",
|
||||
"close_favorites_menu": "Закрыть меню избранного",
|
||||
"enter_folder_name": "Введите имя папки",
|
||||
"create_new_project": "Создать новый проект",
|
||||
"open_projects_menu": "Открыть меню проектов",
|
||||
"close_projects_menu": "Закрыть меню проектов",
|
||||
"toggle_quick_actions_menu": "Переключить меню быстрых действий",
|
||||
"open_project_menu": "Открыть меню проекта",
|
||||
"close_project_menu": "Закрыть меню проекта",
|
||||
"collapse_sidebar": "Свернуть боковую панель",
|
||||
"expand_sidebar": "Развернуть боковую панель",
|
||||
"edition_badge": "Открыть модал платных планов"
|
||||
},
|
||||
"auth_forms": {
|
||||
"clear_email": "Очистить email",
|
||||
"show_password": "Показать пароль",
|
||||
"hide_password": "Скрыть пароль",
|
||||
"close_alert": "Закрыть уведомление",
|
||||
"close_popover": "Закрыть всплывающее окно"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -850,6 +850,7 @@
|
||||
"live": "В прямом эфире",
|
||||
"change_history": "История изменений",
|
||||
"coming_soon": "Скоро",
|
||||
"member": "Участник",
|
||||
"members": "Участники",
|
||||
"you": "Вы",
|
||||
"upgrade_cta": {
|
||||
@@ -1082,7 +1083,9 @@
|
||||
"select": {
|
||||
"error": "Выберите хотя бы один рабочий элемент",
|
||||
"empty": "Рабочие элементы не выбраны",
|
||||
"add_selected": "Добавить выбранные рабочие элементы"
|
||||
"add_selected": "Добавить выбранные рабочие элементы",
|
||||
"select_all": "Выбрать все",
|
||||
"deselect_all": "Снять выделение со всех"
|
||||
},
|
||||
"open_in_full_screen": "Открыть рабочий элемент в полном экране"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"aria_labels": {
|
||||
"projects_sidebar": {
|
||||
"workspace_logo": "Logo pracovného priestoru",
|
||||
"open_workspace_switcher": "Otvoriť prepínač pracovného priestoru",
|
||||
"open_user_menu": "Otvoriť používateľské menu",
|
||||
"open_command_palette": "Otvoriť paletu príkazov",
|
||||
"open_extended_sidebar": "Otvoriť rozšírený bočný panel",
|
||||
"close_extended_sidebar": "Zavrieť rozšírený bočný panel",
|
||||
"create_favorites_folder": "Vytvoriť priečinok obľúbených",
|
||||
"open_folder": "Otvoriť priečinok",
|
||||
"close_folder": "Zavrieť priečinok",
|
||||
"open_favorites_menu": "Otvoriť menu obľúbených",
|
||||
"close_favorites_menu": "Zavrieť menu obľúbených",
|
||||
"enter_folder_name": "Zadajte názov priečinka",
|
||||
"create_new_project": "Vytvoriť nový projekt",
|
||||
"open_projects_menu": "Otvoriť menu projektov",
|
||||
"close_projects_menu": "Zavrieť menu projektov",
|
||||
"toggle_quick_actions_menu": "Prepnúť menu rýchlych akcií",
|
||||
"open_project_menu": "Otvoriť menu projektu",
|
||||
"close_project_menu": "Zavrieť menu projektu",
|
||||
"collapse_sidebar": "Zbaliť bočný panel",
|
||||
"expand_sidebar": "Rozbaliť bočný panel",
|
||||
"edition_badge": "Otvoriť modal platených plánov"
|
||||
},
|
||||
"auth_forms": {
|
||||
"clear_email": "Vymazať e-mail",
|
||||
"show_password": "Zobraziť heslo",
|
||||
"hide_password": "Skryť heslo",
|
||||
"close_alert": "Zavrieť upozornenie",
|
||||
"close_popover": "Zavrieť vyskakovacie okno"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -850,6 +850,7 @@
|
||||
"live": "Živé",
|
||||
"change_history": "História zmien",
|
||||
"coming_soon": "Už čoskoro",
|
||||
"member": "Člen",
|
||||
"members": "Členovia",
|
||||
"you": "Vy",
|
||||
"upgrade_cta": {
|
||||
@@ -1082,7 +1083,9 @@
|
||||
"select": {
|
||||
"error": "Vyberte aspoň jednu pracovnú položku",
|
||||
"empty": "Nie sú vybrané žiadne pracovné položky",
|
||||
"add_selected": "Pridať vybrané pracovné položky"
|
||||
"add_selected": "Pridať vybrané pracovné položky",
|
||||
"select_all": "Vybrať všetko",
|
||||
"deselect_all": "Zrušiť výber všetkého"
|
||||
},
|
||||
"open_in_full_screen": "Otvoriť pracovnú položku na celú obrazovku"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"aria_labels": {
|
||||
"projects_sidebar": {
|
||||
"workspace_logo": "Çalışma alanı logosu",
|
||||
"open_workspace_switcher": "Çalışma alanı değiştiricisini aç",
|
||||
"open_user_menu": "Kullanıcı menüsünü aç",
|
||||
"open_command_palette": "Komut paletini aç",
|
||||
"open_extended_sidebar": "Genişletilmiş kenar çubuğunu aç",
|
||||
"close_extended_sidebar": "Genişletilmiş kenar çubuğunu kapat",
|
||||
"create_favorites_folder": "Favoriler klasörü oluştur",
|
||||
"open_folder": "Klasörü aç",
|
||||
"close_folder": "Klasörü kapat",
|
||||
"open_favorites_menu": "Favoriler menüsünü aç",
|
||||
"close_favorites_menu": "Favoriler menüsünü kapat",
|
||||
"enter_folder_name": "Klasör adını girin",
|
||||
"create_new_project": "Yeni proje oluştur",
|
||||
"open_projects_menu": "Projeler menüsünü aç",
|
||||
"close_projects_menu": "Projeler menüsünü kapat",
|
||||
"toggle_quick_actions_menu": "Hızlı eylemler menüsünü aç/kapat",
|
||||
"open_project_menu": "Proje menüsünü aç",
|
||||
"close_project_menu": "Proje menüsünü kapat",
|
||||
"collapse_sidebar": "Kenar çubuğunu daralt",
|
||||
"expand_sidebar": "Kenar çubuğunu genişlet",
|
||||
"edition_badge": "Ücretli planlar modalını aç"
|
||||
},
|
||||
"auth_forms": {
|
||||
"clear_email": "E-postayı temizle",
|
||||
"show_password": "Şifreyi göster",
|
||||
"hide_password": "Şifreyi gizle",
|
||||
"close_alert": "Uyarıyı kapat",
|
||||
"close_popover": "Açılır pencereyi kapat"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -851,6 +851,7 @@
|
||||
"live": "Canlı",
|
||||
"change_history": "Değişiklik Geçmişi",
|
||||
"coming_soon": "Çok Yakında",
|
||||
"member": "Üye",
|
||||
"members": "Üyeler",
|
||||
"you": "Siz",
|
||||
"upgrade_cta": {
|
||||
@@ -1083,7 +1084,9 @@
|
||||
"select": {
|
||||
"error": "Lütfen en az bir iş öğesi seçin",
|
||||
"empty": "Hiç iş öğesi seçilmedi",
|
||||
"add_selected": "Seçilen iş öğelerini ekle"
|
||||
"add_selected": "Seçilen iş öğelerini ekle",
|
||||
"select_all": "Tümünü seç",
|
||||
"deselect_all": "Tümünü seçme"
|
||||
},
|
||||
"open_in_full_screen": "İş öğesini tam ekranda aç"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"aria_labels": {
|
||||
"projects_sidebar": {
|
||||
"workspace_logo": "Логотип робочого простору",
|
||||
"open_workspace_switcher": "Відкрити перемикач робочого простору",
|
||||
"open_user_menu": "Відкрити меню користувача",
|
||||
"open_command_palette": "Відкрити палітру команд",
|
||||
"open_extended_sidebar": "Відкрити розширену бічну панель",
|
||||
"close_extended_sidebar": "Закрити розширену бічну панель",
|
||||
"create_favorites_folder": "Створити папку улюблених",
|
||||
"open_folder": "Відкрити папку",
|
||||
"close_folder": "Закрити папку",
|
||||
"open_favorites_menu": "Відкрити меню улюблених",
|
||||
"close_favorites_menu": "Закрити меню улюблених",
|
||||
"enter_folder_name": "Введіть назву папки",
|
||||
"create_new_project": "Створити новий проект",
|
||||
"open_projects_menu": "Відкрити меню проектів",
|
||||
"close_projects_menu": "Закрити меню проектів",
|
||||
"toggle_quick_actions_menu": "Перемкнути меню швидких дій",
|
||||
"open_project_menu": "Відкрити меню проекту",
|
||||
"close_project_menu": "Закрити меню проекту",
|
||||
"collapse_sidebar": "Згорнути бічну панель",
|
||||
"expand_sidebar": "Розгорнути бічну панель",
|
||||
"edition_badge": "Відкрити модал платних планів"
|
||||
},
|
||||
"auth_forms": {
|
||||
"clear_email": "Очистити email",
|
||||
"show_password": "Показати пароль",
|
||||
"hide_password": "Приховати пароль",
|
||||
"close_alert": "Закрити сповіщення",
|
||||
"close_popover": "Закрити спливаюче вікно"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -850,6 +850,7 @@
|
||||
"live": "Наживо",
|
||||
"change_history": "Історія змін",
|
||||
"coming_soon": "Незабаром",
|
||||
"member": "Учасник",
|
||||
"members": "Учасники",
|
||||
"you": "Ви",
|
||||
"upgrade_cta": {
|
||||
@@ -1082,7 +1083,9 @@
|
||||
"select": {
|
||||
"error": "Виберіть принаймні одну робочу одиницю",
|
||||
"empty": "Не вибрано жодної робочої одиниці",
|
||||
"add_selected": "Додати вибрані робочі одиниці"
|
||||
"add_selected": "Додати вибрані робочі одиниці",
|
||||
"select_all": "Вибрати всі",
|
||||
"deselect_all": "Скасувати вибір усіх"
|
||||
},
|
||||
"open_in_full_screen": "Відкрити робочу одиницю на повний екран"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"aria_labels": {
|
||||
"projects_sidebar": {
|
||||
"workspace_logo": "Logo không gian làm việc",
|
||||
"open_workspace_switcher": "Mở trình chuyển đổi không gian làm việc",
|
||||
"open_user_menu": "Mở menu người dùng",
|
||||
"open_command_palette": "Mở bảng lệnh",
|
||||
"open_extended_sidebar": "Mở thanh bên mở rộng",
|
||||
"close_extended_sidebar": "Đóng thanh bên mở rộng",
|
||||
"create_favorites_folder": "Tạo thư mục yêu thích",
|
||||
"open_folder": "Mở thư mục",
|
||||
"close_folder": "Đóng thư mục",
|
||||
"open_favorites_menu": "Mở menu yêu thích",
|
||||
"close_favorites_menu": "Đóng menu yêu thích",
|
||||
"enter_folder_name": "Nhập tên thư mục",
|
||||
"create_new_project": "Tạo dự án mới",
|
||||
"open_projects_menu": "Mở menu dự án",
|
||||
"close_projects_menu": "Đóng menu dự án",
|
||||
"toggle_quick_actions_menu": "Bật/tắt menu hành động nhanh",
|
||||
"open_project_menu": "Mở menu dự án",
|
||||
"close_project_menu": "Đóng menu dự án",
|
||||
"collapse_sidebar": "Thu gọn thanh bên",
|
||||
"expand_sidebar": "Mở rộng thanh bên",
|
||||
"edition_badge": "Mở modal gói trả phí"
|
||||
},
|
||||
"auth_forms": {
|
||||
"clear_email": "Xóa email",
|
||||
"show_password": "Hiển thị mật khẩu",
|
||||
"hide_password": "Ẩn mật khẩu",
|
||||
"close_alert": "Đóng cảnh báo",
|
||||
"close_popover": "Đóng popover"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -849,6 +849,7 @@
|
||||
"live": "Trực tiếp",
|
||||
"change_history": "Lịch sử thay đổi",
|
||||
"coming_soon": "Sắp ra mắt",
|
||||
"member": "Thành viên",
|
||||
"members": "Thành viên",
|
||||
"you": "Bạn",
|
||||
"upgrade_cta": {
|
||||
@@ -1081,7 +1082,9 @@
|
||||
"select": {
|
||||
"error": "Vui lòng chọn ít nhất một mục công việc",
|
||||
"empty": "Chưa chọn mục công việc",
|
||||
"add_selected": "Thêm mục công việc đã chọn"
|
||||
"add_selected": "Thêm mục công việc đã chọn",
|
||||
"select_all": "Chọn tất cả",
|
||||
"deselect_all": "Bỏ chọn tất cả"
|
||||
},
|
||||
"open_in_full_screen": "Mở mục công việc trong chế độ toàn màn hình"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"aria_labels": {
|
||||
"projects_sidebar": {
|
||||
"workspace_logo": "工作空间徽标",
|
||||
"open_workspace_switcher": "打开工作空间切换器",
|
||||
"open_user_menu": "打开用户菜单",
|
||||
"open_command_palette": "打开命令面板",
|
||||
"open_extended_sidebar": "打开扩展侧边栏",
|
||||
"close_extended_sidebar": "关闭扩展侧边栏",
|
||||
"create_favorites_folder": "创建收藏夹文件夹",
|
||||
"open_folder": "打开文件夹",
|
||||
"close_folder": "关闭文件夹",
|
||||
"open_favorites_menu": "打开收藏夹菜单",
|
||||
"close_favorites_menu": "关闭收藏夹菜单",
|
||||
"enter_folder_name": "输入文件夹名称",
|
||||
"create_new_project": "创建新项目",
|
||||
"open_projects_menu": "打开项目菜单",
|
||||
"close_projects_menu": "关闭项目菜单",
|
||||
"toggle_quick_actions_menu": "切换快速操作菜单",
|
||||
"open_project_menu": "打开项目菜单",
|
||||
"close_project_menu": "关闭项目菜单",
|
||||
"collapse_sidebar": "折叠侧边栏",
|
||||
"expand_sidebar": "展开侧边栏",
|
||||
"edition_badge": "打开付费计划模态框"
|
||||
},
|
||||
"auth_forms": {
|
||||
"clear_email": "清除邮箱",
|
||||
"show_password": "显示密码",
|
||||
"hide_password": "隐藏密码",
|
||||
"close_alert": "关闭警告",
|
||||
"close_popover": "关闭弹出框"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -849,6 +849,7 @@
|
||||
"live": "实时",
|
||||
"change_history": "变更历史",
|
||||
"coming_soon": "即将推出",
|
||||
"member": "成员",
|
||||
"members": "成员",
|
||||
"you": "你",
|
||||
"upgrade_cta": {
|
||||
@@ -1081,7 +1082,9 @@
|
||||
"select": {
|
||||
"error": "请至少选择一个工作项",
|
||||
"empty": "未选择工作项",
|
||||
"add_selected": "添加所选工作项"
|
||||
"add_selected": "添加所选工作项",
|
||||
"select_all": "全选",
|
||||
"deselect_all": "取消全选"
|
||||
},
|
||||
"open_in_full_screen": "在全屏中打开工作项"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"aria_labels": {
|
||||
"projects_sidebar": {
|
||||
"workspace_logo": "工作空間標誌",
|
||||
"open_workspace_switcher": "打開工作空間切換器",
|
||||
"open_user_menu": "打開用戶選單",
|
||||
"open_command_palette": "打開命令面板",
|
||||
"open_extended_sidebar": "打開擴展側邊欄",
|
||||
"close_extended_sidebar": "關閉擴展側邊欄",
|
||||
"create_favorites_folder": "創建收藏夾文件夾",
|
||||
"open_folder": "打開文件夾",
|
||||
"close_folder": "關閉文件夾",
|
||||
"open_favorites_menu": "打開收藏夾選單",
|
||||
"close_favorites_menu": "關閉收藏夾選單",
|
||||
"enter_folder_name": "輸入文件夾名稱",
|
||||
"create_new_project": "創建新項目",
|
||||
"open_projects_menu": "打開項目選單",
|
||||
"close_projects_menu": "關閉項目選單",
|
||||
"toggle_quick_actions_menu": "切換快速操作選單",
|
||||
"open_project_menu": "打開項目選單",
|
||||
"close_project_menu": "關閉項目選單",
|
||||
"collapse_sidebar": "摺疊側邊欄",
|
||||
"expand_sidebar": "展開側邊欄",
|
||||
"edition_badge": "打開付費計劃模態框"
|
||||
},
|
||||
"auth_forms": {
|
||||
"clear_email": "清除電子郵件",
|
||||
"show_password": "顯示密碼",
|
||||
"hide_password": "隱藏密碼",
|
||||
"close_alert": "關閉警告",
|
||||
"close_popover": "關閉彈出框"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -850,6 +850,7 @@
|
||||
"live": "即時",
|
||||
"change_history": "變更歷史記錄",
|
||||
"coming_soon": "即將推出",
|
||||
"member": "成員",
|
||||
"members": "成員",
|
||||
"you": "您",
|
||||
"upgrade_cta": {
|
||||
@@ -1082,7 +1083,9 @@
|
||||
"select": {
|
||||
"error": "請至少選擇一個工作事項",
|
||||
"empty": "未選擇工作事項",
|
||||
"add_selected": "新增已選取的工作事項"
|
||||
"add_selected": "新增已選取的工作事項",
|
||||
"select_all": "全選",
|
||||
"deselect_all": "取消全選"
|
||||
},
|
||||
"open_in_full_screen": "以全螢幕開啟工作事項"
|
||||
},
|
||||
|
||||
@@ -3,7 +3,7 @@ import get from "lodash/get";
|
||||
import merge from "lodash/merge";
|
||||
import { makeAutoObservable, runInAction } from "mobx";
|
||||
// constants
|
||||
import { FALLBACK_LANGUAGE, SUPPORTED_LANGUAGES, LANGUAGE_STORAGE_KEY } from "../constants";
|
||||
import { FALLBACK_LANGUAGE, SUPPORTED_LANGUAGES, LANGUAGE_STORAGE_KEY, ETranslationFiles } from "../constants";
|
||||
// core translations imports
|
||||
import coreEn from "../locales/en/core.json";
|
||||
// types
|
||||
@@ -130,54 +130,32 @@ export class TranslationStore {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to import and merge multiple translation files for a language
|
||||
* @param language - The language code
|
||||
* @param files - Array of file names to import (without .json extension)
|
||||
* @returns Promise that resolves to merged translations
|
||||
*/
|
||||
private async importAndMergeFiles(language: TLanguage, files: string[]): Promise<any> {
|
||||
try {
|
||||
const importPromises = files.map((file) => import(`../locales/${language}/${file}.json`));
|
||||
|
||||
const modules = await Promise.all(importPromises);
|
||||
const merged = modules.reduce((acc, module) => merge(acc, module.default), {});
|
||||
return { default: merged };
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to import and merge files for ${language}: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Imports the translations for the given language
|
||||
* @param language - The language to import the translations for
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
private importLanguageFile(language: TLanguage): Promise<any> {
|
||||
switch (language) {
|
||||
case "en":
|
||||
return import("../locales/en/translations.json");
|
||||
case "fr":
|
||||
return import("../locales/fr/translations.json");
|
||||
case "es":
|
||||
return import("../locales/es/translations.json");
|
||||
case "ja":
|
||||
return import("../locales/ja/translations.json");
|
||||
case "zh-CN":
|
||||
return import("../locales/zh-CN/translations.json");
|
||||
case "zh-TW":
|
||||
return import("../locales/zh-TW/translations.json");
|
||||
case "ru":
|
||||
return import("../locales/ru/translations.json");
|
||||
case "it":
|
||||
return import("../locales/it/translations.json");
|
||||
case "cs":
|
||||
return import("../locales/cs/translations.json");
|
||||
case "sk":
|
||||
return import("../locales/sk/translations.json");
|
||||
case "de":
|
||||
return import("../locales/de/translations.json");
|
||||
case "ua":
|
||||
return import("../locales/ua/translations.json");
|
||||
case "pl":
|
||||
return import("../locales/pl/translations.json");
|
||||
case "ko":
|
||||
return import("../locales/ko/translations.json");
|
||||
case "pt-BR":
|
||||
return import("../locales/pt-BR/translations.json");
|
||||
case "id":
|
||||
return import("../locales/id/translations.json");
|
||||
case "ro":
|
||||
return import("../locales/ro/translations.json");
|
||||
case "vi-VN":
|
||||
return import("../locales/vi-VN/translations.json");
|
||||
case "tr-TR":
|
||||
return import("../locales/tr-TR/translations.json");
|
||||
default:
|
||||
throw new Error(`Unsupported language: ${language}`);
|
||||
}
|
||||
private async importLanguageFile(language: TLanguage): Promise<any> {
|
||||
const files = Object.values(ETranslationFiles);
|
||||
return this.importAndMergeFiles(language, files);
|
||||
}
|
||||
|
||||
/** Checks if the language is valid based on the supported languages */
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"main": "tailwind.config.js",
|
||||
"private": true,
|
||||
"devDependencies": {
|
||||
"@tailwindcss/container-queries": "^0.1.1",
|
||||
"@tailwindcss/typography": "^0.5.9",
|
||||
"autoprefixer": "^10.4.14",
|
||||
"postcss": "^8.4.38",
|
||||
|
||||
@@ -469,6 +469,7 @@ module.exports = {
|
||||
plugins: [
|
||||
require("tailwindcss-animate"),
|
||||
require("@tailwindcss/typography"),
|
||||
require("@tailwindcss/container-queries"),
|
||||
function ({ addUtilities }) {
|
||||
const newUtilities = {
|
||||
// Mobile screens
|
||||
|
||||
+14
-33
@@ -1,14 +1,5 @@
|
||||
import { EUserProjectRoles } from "@plane/constants";
|
||||
import type {
|
||||
IProjectViewProps,
|
||||
IUser,
|
||||
IUserLite,
|
||||
IUserMemberLite,
|
||||
IWorkspace,
|
||||
IWorkspaceLite,
|
||||
TLogoProps,
|
||||
TStateGroups,
|
||||
} from "..";
|
||||
import type { IUser, IUserLite, IWorkspace, TLogoProps, TStateGroups } from "..";
|
||||
import { TUserPermissions } from "../enums";
|
||||
|
||||
export interface IPartialProject {
|
||||
@@ -91,30 +82,20 @@ export interface IProjectMemberLite {
|
||||
member_id: string;
|
||||
}
|
||||
|
||||
export interface IProjectMember {
|
||||
id: string;
|
||||
member: IUserMemberLite;
|
||||
project: IProjectLite;
|
||||
workspace: IWorkspaceLite;
|
||||
comment: string;
|
||||
role: TUserPermissions;
|
||||
|
||||
preferences: ProjectPreferences;
|
||||
|
||||
view_props: IProjectViewProps;
|
||||
default_props: IProjectViewProps;
|
||||
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
created_by: string;
|
||||
updated_by: string;
|
||||
}
|
||||
|
||||
export interface IProjectMembership {
|
||||
id: string;
|
||||
export type TProjectMembership = {
|
||||
member: string;
|
||||
role: TUserPermissions;
|
||||
}
|
||||
role: TUserPermissions | EUserProjectRoles;
|
||||
created_at: string;
|
||||
} & (
|
||||
| {
|
||||
id: string;
|
||||
original_role: EUserProjectRoles;
|
||||
}
|
||||
| {
|
||||
id: null;
|
||||
original_role: null;
|
||||
}
|
||||
);
|
||||
|
||||
export interface IProjectBulkAddFormData {
|
||||
members: { role: TUserPermissions | EUserProjectRoles; member_id: string }[];
|
||||
|
||||
Vendored
+3
@@ -12,6 +12,7 @@ export interface IUserLite {
|
||||
id: string;
|
||||
is_bot: boolean;
|
||||
last_name: string;
|
||||
joining_date?: string;
|
||||
}
|
||||
export interface IUser extends IUserLite {
|
||||
// only for uploading the cover image
|
||||
@@ -78,6 +79,8 @@ export interface IUserSettings {
|
||||
workspace: {
|
||||
last_workspace_id: string | undefined;
|
||||
last_workspace_slug: string | undefined;
|
||||
last_workspace_name: string | undefined;
|
||||
last_workspace_logo: string | undefined;
|
||||
fallback_workspace_id: string | undefined;
|
||||
fallback_workspace_slug: string | undefined;
|
||||
invites: number | undefined;
|
||||
|
||||
Vendored
+2
-2
@@ -1,4 +1,4 @@
|
||||
import type { ICycle, IProjectMember, IUser, IUserLite, IWorkspaceViewProps, TPaginationInfo } from "@plane/types";
|
||||
import type { ICycle, TProjectMembership, IUser, IUserLite, IWorkspaceViewProps, TPaginationInfo } from "@plane/types";
|
||||
import { EUserWorkspaceRoles } from "@plane/constants"; // TODO: check if importing this over here causes circular dependency
|
||||
import { TUserPermissions } from "./enums";
|
||||
|
||||
@@ -93,7 +93,7 @@ export interface IWorkspaceMemberMe {
|
||||
|
||||
export interface ILastActiveWorkspaceDetails {
|
||||
workspace_details: IWorkspace;
|
||||
project_details?: IProjectMember[];
|
||||
project_details?: TProjectMembership[];
|
||||
}
|
||||
|
||||
export interface IWorkspaceDefaultSearchResult {
|
||||
|
||||
@@ -71,7 +71,7 @@
|
||||
"postcss-cli": "^11.0.0",
|
||||
"postcss-nested": "^6.0.1",
|
||||
"storybook": "^8.1.1",
|
||||
"tsup": "^8.5.0",
|
||||
"tsup": "8.4.0",
|
||||
"typescript": "5.3.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,7 +160,7 @@ export const Avatar: React.FC<Props> = (props) => {
|
||||
color: fallbackTextColor ?? "#ffffff",
|
||||
}}
|
||||
>
|
||||
{name ? name[0].toUpperCase() : fallbackText ?? "?"}
|
||||
{name?.[0]?.toUpperCase() ?? fallbackText ?? "?"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -14,6 +14,7 @@ import { ICustomMenuDropdownProps, ICustomMenuItemProps } from "./helper";
|
||||
|
||||
const CustomMenu = (props: ICustomMenuDropdownProps) => {
|
||||
const {
|
||||
ariaLabel,
|
||||
buttonClassName = "",
|
||||
customButtonClassName = "",
|
||||
customButtonTabIndex = 0,
|
||||
@@ -75,7 +76,7 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
isOpen ? closeDropdown() : openDropdown();
|
||||
if (menuButtonOnClick) menuButtonOnClick();
|
||||
menuButtonOnClick?.();
|
||||
};
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
@@ -147,6 +148,7 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
|
||||
className={customButtonClassName}
|
||||
tabIndex={customButtonTabIndex}
|
||||
disabled={disabled}
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
{customButton}
|
||||
</button>
|
||||
@@ -164,6 +166,7 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
|
||||
disabled ? "cursor-not-allowed" : "cursor-pointer hover:bg-custom-background-80"
|
||||
} ${buttonClassName}`}
|
||||
tabIndex={customButtonTabIndex}
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
<MoreHorizontal className={`h-3.5 w-3.5 ${verticalEllipsis ? "rotate-90" : ""}`} />
|
||||
</button>
|
||||
@@ -183,6 +186,7 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
|
||||
onClick={handleMenuButtonClick}
|
||||
tabIndex={customButtonTabIndex}
|
||||
disabled={disabled}
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
{label}
|
||||
{!noChevron && <ChevronDown className="h-3.5 w-3.5" />}
|
||||
|
||||
@@ -32,6 +32,7 @@ export interface ICustomMenuDropdownProps extends IDropdownProps {
|
||||
closeOnSelect?: boolean;
|
||||
portalElement?: Element | null;
|
||||
openOnHover?: boolean;
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
export interface ICustomSelectProps extends IDropdownProps {
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
"@types/node": "^22.5.4",
|
||||
"@types/react": "^18.3.11",
|
||||
"@types/zxcvbn": "^4.4.5",
|
||||
"tsup": "^8.5.0",
|
||||
"tsup": "8.4.0",
|
||||
"typescript": "^5.3.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,17 @@
|
||||
export * from "./array";
|
||||
export * from "./attachment";
|
||||
export * from "./auth";
|
||||
export * from "./datetime";
|
||||
export * from "./color";
|
||||
export * from "./common";
|
||||
export * from "./datetime";
|
||||
export * from "./emoji";
|
||||
export * from "./file";
|
||||
export * from "./get-icon-for-link";
|
||||
export * from "./issue";
|
||||
export * from "./permission";
|
||||
export * from "./state";
|
||||
export * from "./string";
|
||||
export * from "./theme";
|
||||
export * from "./workspace";
|
||||
export * from "./work-item";
|
||||
|
||||
export * from "./get-icon-for-link";
|
||||
|
||||
export * from "./subscription";
|
||||
export * from "./theme";
|
||||
export * from "./work-item";
|
||||
export * from "./workspace";
|
||||
@@ -0,0 +1,13 @@
|
||||
import { EUserPermissions, EUserProjectRoles, EUserWorkspaceRoles } from "@plane/constants";
|
||||
|
||||
type TSupportedRole = EUserPermissions | EUserProjectRoles | EUserWorkspaceRoles;
|
||||
|
||||
/**
|
||||
* @description Returns the highest role from an array of supported roles
|
||||
* @param { TSupportedRole[] } roles
|
||||
* @returns { TSupportedRole | undefined }
|
||||
*/
|
||||
export const getHighestRole = <T extends TSupportedRole>(roles: T[]): T | undefined => {
|
||||
if (!roles || roles.length === 0) return undefined;
|
||||
return roles.reduce((highest, current) => (current > highest ? current : highest));
|
||||
};
|
||||
@@ -29,6 +29,7 @@ export const getReadOnlyEditorFileHandlers = (args: Pick<TArgs, "anchor" | "work
|
||||
const { anchor, workspaceId } = args;
|
||||
|
||||
return {
|
||||
checkIfAssetExists: async () => true,
|
||||
getAssetSrc: async (path) => {
|
||||
if (!path) return "";
|
||||
if (path?.startsWith("http")) {
|
||||
|
||||
+1
-1
@@ -37,7 +37,7 @@
|
||||
"mobx": "^6.10.0",
|
||||
"mobx-react": "^9.1.1",
|
||||
"mobx-utils": "^6.0.8",
|
||||
"next": "^14.2.28",
|
||||
"next": "^14.2.29",
|
||||
"next-themes": "^0.2.1",
|
||||
"nprogress": "^0.2.0",
|
||||
"react": "^18.3.1",
|
||||
|
||||
@@ -96,7 +96,7 @@ export const ExtendedAppSidebar = observer(() => {
|
||||
|
||||
useExtendedSidebarOutsideClickDetector(
|
||||
extendedSidebarRef,
|
||||
() => toggleExtendedSidebar(false),
|
||||
() => toggleExtendedSidebar(true),
|
||||
"extended-sidebar-toggle"
|
||||
);
|
||||
|
||||
@@ -106,8 +106,8 @@ export const ExtendedAppSidebar = observer(() => {
|
||||
className={cn(
|
||||
"absolute top-0 h-full z-[19] flex flex-col gap-0.5 w-[300px] transform transition-all duration-300 ease-in-out bg-custom-sidebar-background-100 border-r border-custom-sidebar-border-200 p-4 shadow-md pb-6",
|
||||
{
|
||||
"translate-x-0 opacity-100 pointer-events-auto": extendedSidebarCollapsed,
|
||||
"-translate-x-full opacity-0 pointer-events-none": !extendedSidebarCollapsed,
|
||||
"-translate-x-full opacity-0 pointer-events-none": extendedSidebarCollapsed,
|
||||
"translate-x-0 opacity-100 pointer-events-auto": !extendedSidebarCollapsed,
|
||||
"left-[70px]": sidebarCollapsed,
|
||||
"left-[250px]": !sidebarCollapsed,
|
||||
}
|
||||
|
||||
+1
-1
@@ -69,7 +69,7 @@ const ProjectCyclesPage = observer(() => {
|
||||
primaryButton={{
|
||||
text: t("disabled_project.empty_state.cycle.primary_button.text"),
|
||||
onClick: () => {
|
||||
router.push(`/${workspaceSlug}/projects/${projectId}/settings/features`);
|
||||
router.push(`/${workspaceSlug}/settings/projects/${projectId}/features`);
|
||||
},
|
||||
disabled: !hasAdminLevelPermission,
|
||||
}}
|
||||
|
||||
+1
-1
@@ -42,7 +42,7 @@ const ProjectInboxPage = observer(() => {
|
||||
primaryButton={{
|
||||
text: t("disabled_project.empty_state.inbox.primary_button.text"),
|
||||
onClick: () => {
|
||||
router.push(`/${workspaceSlug}/projects/${projectId}/settings/features`);
|
||||
router.push(`/${workspaceSlug}/settings/projects/${projectId}/features`);
|
||||
},
|
||||
disabled: !canPerformEmptyStateActions,
|
||||
}}
|
||||
|
||||
+1
-1
@@ -61,7 +61,7 @@ const ProjectModulesPage = observer(() => {
|
||||
primaryButton={{
|
||||
text: t("disabled_project.empty_state.module.primary_button.text"),
|
||||
onClick: () => {
|
||||
router.push(`/${workspaceSlug}/projects/${projectId}/settings/features`);
|
||||
router.push(`/${workspaceSlug}/settings/projects/${projectId}/features`);
|
||||
},
|
||||
disabled: !canPerformEmptyStateActions,
|
||||
}}
|
||||
|
||||
+1
-1
@@ -54,7 +54,7 @@ const ProjectPagesPage = observer(() => {
|
||||
primaryButton={{
|
||||
text: t("disabled_project.empty_state.page.primary_button.text"),
|
||||
onClick: () => {
|
||||
router.push(`/${workspaceSlug}/projects/${projectId}/settings/features`);
|
||||
router.push(`/${workspaceSlug}/settings/projects/${projectId}/features`);
|
||||
},
|
||||
disabled: !canPerformEmptyStateActions,
|
||||
}}
|
||||
|
||||
-33
@@ -1,33 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { FC, ReactNode } from "react";
|
||||
// components
|
||||
import { AppHeader } from "@/components/core";
|
||||
// local components
|
||||
import { ProjectSettingHeader } from "../header";
|
||||
import { ProjectSettingsSidebar } from "./sidebar";
|
||||
|
||||
export interface IProjectSettingLayout {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const ProjectSettingLayout: FC<IProjectSettingLayout> = (props) => {
|
||||
const { children } = props;
|
||||
return (
|
||||
<>
|
||||
<AppHeader header={<ProjectSettingHeader />} />
|
||||
<div className="inset-y-0 flex flex-row vertical-scrollbar scrollbar-lg h-full w-full overflow-y-auto">
|
||||
<div className="px-page-x !pr-0 py-page-y flex-shrink-0 overflow-y-hidden sm:hidden hidden md:block lg:block">
|
||||
<ProjectSettingsSidebar />
|
||||
</div>
|
||||
<div className="flex flex-col relative w-full overflow-hidden">
|
||||
<div className="h-full w-full overflow-x-hidden overflow-y-scroll vertical-scrollbar scrollbar-md px-page-x md:px-9 py-page-y">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectSettingLayout;
|
||||
-73
@@ -1,73 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import range from "lodash/range";
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import { useParams, usePathname } from "next/navigation";
|
||||
import { EUserPermissionsLevel } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// ui
|
||||
import { Loader } from "@plane/ui";
|
||||
// components
|
||||
import { SidebarNavItem } from "@/components/sidebar";
|
||||
// hooks
|
||||
import { useUserPermissions } from "@/hooks/store";
|
||||
// plane web constants
|
||||
import { PROJECT_SETTINGS_LINKS } from "@/plane-web/constants/project";
|
||||
|
||||
export const ProjectSettingsSidebar = observer(() => {
|
||||
const { workspaceSlug, projectId } = useParams();
|
||||
const pathname = usePathname();
|
||||
// mobx store
|
||||
const { allowPermissions, projectUserInfo } = useUserPermissions();
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
// derived values
|
||||
const currentProjectRole = projectUserInfo?.[workspaceSlug?.toString()]?.[projectId?.toString()]?.role;
|
||||
|
||||
if (!currentProjectRole) {
|
||||
return (
|
||||
<div className="flex w-[280px] flex-col gap-6">
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-xs font-semibold text-custom-sidebar-text-400">SETTINGS</span>
|
||||
<Loader className="flex w-full flex-col gap-2">
|
||||
{range(8).map((index) => (
|
||||
<Loader.Item key={index} height="34px" />
|
||||
))}
|
||||
</Loader>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex w-[280px] flex-col gap-6">
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-xs font-semibold text-custom-sidebar-text-400">SETTINGS</span>
|
||||
<div className="flex w-full flex-col gap-1">
|
||||
{PROJECT_SETTINGS_LINKS.map(
|
||||
(link) =>
|
||||
allowPermissions(
|
||||
link.access,
|
||||
EUserPermissionsLevel.PROJECT,
|
||||
workspaceSlug.toString(),
|
||||
projectId.toString()
|
||||
) && (
|
||||
<Link key={link.key} href={`/${workspaceSlug}/projects/${projectId}${link.href}`}>
|
||||
<SidebarNavItem
|
||||
key={link.key}
|
||||
isActive={link.highlight(pathname, `/${workspaceSlug}/projects/${projectId}`)}
|
||||
className="text-sm font-medium px-4 py-2"
|
||||
>
|
||||
{t(link.i18n_label)}
|
||||
</SidebarNavItem>
|
||||
</Link>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
-79
@@ -1,79 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// ui
|
||||
import { Settings } from "lucide-react";
|
||||
import { EUserPermissionsLevel } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Breadcrumbs, CustomMenu, Header } from "@plane/ui";
|
||||
// components
|
||||
import { BreadcrumbLink } from "@/components/common";
|
||||
// hooks
|
||||
import { useProject, useUserPermissions } from "@/hooks/store";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
// plane web
|
||||
import { ProjectBreadcrumb } from "@/plane-web/components/breadcrumbs";
|
||||
import { PROJECT_SETTINGS_LINKS } from "@/plane-web/constants/project";
|
||||
|
||||
export const ProjectSettingHeader: FC = observer(() => {
|
||||
// router
|
||||
const router = useAppRouter();
|
||||
const { workspaceSlug, projectId } = useParams();
|
||||
// store hooks
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
const { loader } = useProject();
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Header>
|
||||
<Header.LeftItem>
|
||||
<div>
|
||||
<div className="z-50">
|
||||
<Breadcrumbs onBack={router.back} isLoading={loader === "init-loader"}>
|
||||
<ProjectBreadcrumb />
|
||||
<div className="hidden sm:hidden md:block lg:block">
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="text"
|
||||
link={
|
||||
<BreadcrumbLink label="Settings" icon={<Settings className="h-4 w-4 text-custom-text-300" />} />
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</Breadcrumbs>
|
||||
</div>
|
||||
</div>
|
||||
<CustomMenu
|
||||
className="flex-shrink-0 block sm:block md:hidden lg:hidden"
|
||||
maxHeight="lg"
|
||||
customButton={
|
||||
<span className="text-xs px-1.5 py-1 border rounded-md text-custom-text-200 border-custom-border-300">
|
||||
Settings
|
||||
</span>
|
||||
}
|
||||
placement="bottom-start"
|
||||
closeOnSelect
|
||||
>
|
||||
{PROJECT_SETTINGS_LINKS.map(
|
||||
(item) =>
|
||||
allowPermissions(
|
||||
item.access,
|
||||
EUserPermissionsLevel.PROJECT,
|
||||
workspaceSlug.toString(),
|
||||
projectId.toString()
|
||||
) && (
|
||||
<CustomMenu.MenuItem
|
||||
key={item.key}
|
||||
onClick={() => router.push(`/${workspaceSlug}/projects/${projectId}${item.href}`)}
|
||||
>
|
||||
{t(item.i18n_label)}
|
||||
</CustomMenu.MenuItem>
|
||||
)
|
||||
)}
|
||||
</CustomMenu>
|
||||
</Header.LeftItem>
|
||||
</Header>
|
||||
);
|
||||
});
|
||||
+1
-1
@@ -68,7 +68,7 @@ const ProjectViewsPage = observer(() => {
|
||||
primaryButton={{
|
||||
text: t("disabled_project.empty_state.view.primary_button.text"),
|
||||
onClick: () => {
|
||||
router.push(`/${workspaceSlug}/projects/${projectId}/settings/features`);
|
||||
router.push(`/${workspaceSlug}/settings/projects/${projectId}/features`);
|
||||
},
|
||||
disabled: !canPerformEmptyStateActions,
|
||||
}}
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { FC, ReactNode } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// components
|
||||
import { useParams, usePathname } from "next/navigation";
|
||||
import { EUserWorkspaceRoles, WORKSPACE_SETTINGS_ACCESS } from "@plane/constants";
|
||||
import { NotAuthorizedView } from "@/components/auth-screens";
|
||||
import { AppHeader } from "@/components/core";
|
||||
// hooks
|
||||
import { useUserPermissions } from "@/hooks/store";
|
||||
// plane web constants
|
||||
// local components
|
||||
import { WorkspaceSettingHeader } from "../header";
|
||||
import { MobileWorkspaceSettingsTabs } from "./mobile-header-tabs";
|
||||
import { WorkspaceSettingsSidebar } from "./sidebar";
|
||||
|
||||
export interface IWorkspaceSettingLayout {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const WorkspaceSettingLayout: FC<IWorkspaceSettingLayout> = observer((props) => {
|
||||
const { children } = props;
|
||||
|
||||
const { workspaceUserInfo } = useUserPermissions();
|
||||
const pathname = usePathname();
|
||||
const [workspaceSlug, suffix, route] = pathname.replace(/^\/|\/$/g, "").split("/"); // Regex removes leading and trailing slashes
|
||||
|
||||
// derived values
|
||||
const userWorkspaceRole = workspaceUserInfo?.[workspaceSlug.toString()]?.role;
|
||||
const isAuthorized =
|
||||
pathname &&
|
||||
workspaceSlug &&
|
||||
userWorkspaceRole &&
|
||||
WORKSPACE_SETTINGS_ACCESS[route ? `/${suffix}/${route}` : `/${suffix}`]?.includes(
|
||||
userWorkspaceRole as EUserWorkspaceRoles
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AppHeader header={<WorkspaceSettingHeader />} />
|
||||
<MobileWorkspaceSettingsTabs />
|
||||
<div className="inset-y-0 flex flex-row vertical-scrollbar scrollbar-lg h-full w-full overflow-y-auto">
|
||||
{workspaceUserInfo && !isAuthorized ? (
|
||||
<NotAuthorizedView section="settings" />
|
||||
) : (
|
||||
<>
|
||||
<div className="px-page-x !pr-0 py-page-y flex-shrink-0 overflow-y-hidden sm:hidden hidden md:block lg:block">
|
||||
<WorkspaceSettingsSidebar />
|
||||
</div>
|
||||
<div className="flex flex-col relative w-full overflow-hidden">
|
||||
<div className="w-full h-full overflow-x-hidden overflow-y-scroll vertical-scrollbar scrollbar-md px-page-x md:px-9 py-page-y">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default WorkspaceSettingLayout;
|
||||
@@ -1,48 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import { useParams, usePathname } from "next/navigation";
|
||||
import { WORKSPACE_SETTINGS_LINKS, EUserPermissionsLevel } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// components
|
||||
import { SidebarNavItem } from "@/components/sidebar";
|
||||
// hooks
|
||||
import { useUserPermissions } from "@/hooks/store";
|
||||
// plane web helpers
|
||||
import { shouldRenderSettingLink } from "@/plane-web/helpers/workspace.helper";
|
||||
|
||||
export const WorkspaceSettingsSidebar = observer(() => {
|
||||
// router
|
||||
const { workspaceSlug } = useParams();
|
||||
const pathname = usePathname();
|
||||
// mobx store
|
||||
const { t } = useTranslation();
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
|
||||
return (
|
||||
<div className="flex w-[280px] flex-col gap-6">
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-xs font-semibold text-custom-sidebar-text-400 uppercase">{t("settings")}</span>
|
||||
<div className="flex w-full flex-col gap-1">
|
||||
{WORKSPACE_SETTINGS_LINKS.map(
|
||||
(link) =>
|
||||
shouldRenderSettingLink(workspaceSlug.toString(), link.key) &&
|
||||
allowPermissions(link.access, EUserPermissionsLevel.WORKSPACE, workspaceSlug.toString()) && (
|
||||
<Link key={link.key} href={`/${workspaceSlug}${link.href}`}>
|
||||
<SidebarNavItem
|
||||
key={link.key}
|
||||
isActive={link.highlight(pathname, `/${workspaceSlug}`)}
|
||||
className="text-sm font-medium px-4 py-2"
|
||||
>
|
||||
{t(link.i18n_label)}
|
||||
</SidebarNavItem>
|
||||
</Link>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -1,37 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Settings } from "lucide-react";
|
||||
// ui
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Breadcrumbs, Header } from "@plane/ui";
|
||||
// components
|
||||
import { BreadcrumbLink } from "@/components/common";
|
||||
// hooks
|
||||
import { useWorkspace } from "@/hooks/store";
|
||||
|
||||
export const WorkspaceSettingHeader: FC = observer(() => {
|
||||
const { currentWorkspace, loader } = useWorkspace();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Header>
|
||||
<Header.LeftItem>
|
||||
<Breadcrumbs isLoading={loader}>
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="text"
|
||||
link={
|
||||
<BreadcrumbLink
|
||||
href={`/${currentWorkspace?.slug}/settings`}
|
||||
label={currentWorkspace?.name ?? "Workspace"}
|
||||
icon={<Settings className="h-4 w-4 text-custom-text-300" />}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Breadcrumbs.BreadcrumbItem type="text" link={<BreadcrumbLink label={t("settings")} />} />
|
||||
</Breadcrumbs>
|
||||
</Header.LeftItem>
|
||||
</Header>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import { CommandPalette } from "@/components/command-palette";
|
||||
import { ContentWrapper } from "@/components/core";
|
||||
import { SettingsContentLayout, SettingsHeader } from "@/components/settings";
|
||||
import { AuthenticationWrapper } from "@/lib/wrappers";
|
||||
import { WorkspaceAuthWrapper } from "@/plane-web/layouts/workspace-wrapper";
|
||||
|
||||
export default function SettingsLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<AuthenticationWrapper>
|
||||
<WorkspaceAuthWrapper>
|
||||
<CommandPalette />
|
||||
<main className="relative flex h-screen w-full flex-col overflow-hidden bg-custom-background-100">
|
||||
{/* Header */}
|
||||
<SettingsHeader />
|
||||
{/* Content */}
|
||||
<ContentWrapper className="px-4 md:pl-12 md:pt-page-y md:flex w-full">
|
||||
<SettingsContentLayout>{children}</SettingsContentLayout>
|
||||
</ContentWrapper>
|
||||
</main>
|
||||
</WorkspaceAuthWrapper>
|
||||
</AuthenticationWrapper>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user