Compare commits

..

22 Commits

Author SHA1 Message Date
Dheeraj Kumar Ketireddy 397cecc3a1 Initial CI setup for apiserver unittests 2025-06-03 22:27:41 +05:30
Prateek Shourya e70105235b [WEB-4245] improvement: minor enhancements to project members settings page (#7153) 2025-06-03 15:09:54 +05:30
Nikhil 7766e8b5cf [WEB-3998]: clean up imports and remove cache decorators in workspace views to avoid stale data on browser cache #7150 2025-06-03 13:36:52 +05:30
Akshita Goyal 16d63abcdc [WEB-3998] fix: minor empty states changes + refactoring (#7151) 2025-06-02 15:50:57 +05:30
M. Palanikannan 0568b8d583 regression: building utils back to run live server (#7149) 2025-06-02 13:32:34 +05:30
Quang Hung Pham 64da29b0d9 chore: add select all/deselect all functionality when adding existing work item (#7045)
* chore: add select all/deselect all functionality

* chore: update button display logic by CR
2025-06-02 13:30:31 +05:30
Zero King 7c336a65c4 buid: add .venv to .dockerignore (#7146) 2025-05-31 12:32:25 +05:30
sriram veeraghanta 2242a85e5c chore: nextjs upgrade 2025-05-30 21:12:02 +05:30
Aaryan Khandelwal 323920a358 [WIKI-399] fix: add favorite action to page header #7144 2025-05-30 20:58:46 +05:30
Aaryan Khandelwal 151fc8389e [WIKI-181] chore: asset check endpoint added #7140 2025-05-30 20:58:06 +05:30
sriram veeraghanta 0f828fd5e0 chore: core component fixes 2025-05-30 20:57:35 +05:30
Prateek Shourya 67cbe94d4a [WEB-3964] refactor: permission layer (#7094)
* refactor: permission layer

* refactor: add original_role to project member serializer

* chore: minor fixes related to permission layer

* fix: strict type checking while checking user permissions
2025-05-30 19:57:07 +05:30
sriram veeraghanta 322af8c436 [WEB-4223] fix: remove build process from utils package #7138 2025-05-30 18:48:18 +05:30
Sangeetha 41c2aefad4 [WEB-3998] feat: settings page revamp (#6959)
* chore: return workspace name and logo in profile settings api

* chore: remove unwanted fields

* fix: backend

* feat: workspace settings

* feat: workspce settings + layouting

* feat: profile + workspace settings ui

* chore: project settings + refactoring

* routes

* fix: handled no project

* fix: css + build

* feat: profile settings internal screens upgrade

* fix: workspace settings internal screens

* fix: external scrolling allowed

* fix: css

* fix: css

* fix: css

* fix: preferences settings

* fix: css

* fix: mobile interface

* fix: profile redirections

* fix: dark theme

* fix: css

* fix: css

* feat: scroll

* fix: refactor

* fix: bug fixes

* fix: refactor

* fix: css

* fix: routes

* fix: first day of the week

* fix: scrolling

* fix: refactoring

* fix: project -> projects

* fix: refactoring

* fix: refactor

* fix: no authorized view consistency

* fix: folder structure

* fix: revert

* fix: handled redirections

* fix: scroll

* fix: deleted old routes

* fix: empty states

* fix: headings

* fix: settings description

* fix: build

---------

Co-authored-by: gakshita <akshitagoyal1516@gmail.com>
Co-authored-by: Akshita Goyal <36129505+gakshita@users.noreply.github.com>
2025-05-30 18:47:33 +05:30
sriram veeraghanta 445c819fbd [WEB-4172] feat: Crawl work item links for title and favicon (#7117)
* feat: added a python bg task to crawl work item links for title and description

* fix: return meta_data in the response

* fix: add validation for accessing IP ranges

* fix: remove json.dumps

* fix: handle exception by returning None

* refactor: call find_favicon_url inside fetch_and_encode_favicon function

* chore: type hints

* fix: Handle None

* fix: remove print statementsg

* chore: added favicon and title of links

* fix: return null if no title found

* Update apiserver/plane/bgtasks/work_item_link_task.py

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* fix: remove exception handling

* fix: reduce timeout seconds

* fix: handle timeout exception

* fix: remove request timeout handling

* feat: add Link icon to issue detail links and update rendering logic

* fix: use logger for exception

---------

Co-authored-by: sangeethailango <sangeethailango21@gmail.com>
Co-authored-by: JayashTripathy <jayashtripathy371@gmail.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-05-30 18:44:53 +05:30
Aaryan Khandelwal 046a8a1bcf [WEB-4189] chore: add tailwind container-queries plugin #7125 2025-05-30 18:41:12 +05:30
Akshita Goyal 099a1cc12b [WEB-3863] fix: links error handling #7126 2025-05-30 18:24:01 +05:30
Sangeetha a0a697401b [WEB-3787] fix: project joining date (#7127)
* fix: return project joining date

* fix: added project's joining date

* fix: set created_at as read_only_fields

---------

Co-authored-by: gakshita <akshitagoyal1516@gmail.com>
2025-05-30 18:23:19 +05:30
Aaryan Khandelwal cb92108bf4 [WEB-4197] chore: auth forms semantics and accessibility #7128 2025-05-30 18:22:20 +05:30
Aaryan Khandelwal 01b685ea57 [WIKI-181] refactor: invalid file handling #7139 2025-05-30 18:18:05 +05:30
Vipin Chaudhary b16a585102 [WIKI-343] [WIKI-312] Fix: html characters (#7049)
* fix: handle symbols and space

* chore: refactor
2025-05-30 18:17:03 +05:30
sriram veeraghanta 4a97d7c28c fix: adding url validations for workspace name and user name 2025-05-29 17:53:48 +05:30
231 changed files with 4488 additions and 2110 deletions
+2 -1
View File
@@ -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/
+190
View File
@@ -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
View File
@@ -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",
+5 -2
View File
@@ -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):
+16
View File
@@ -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:
+7
View File
@@ -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",
),
]
+1
View File
@@ -106,6 +106,7 @@ from .asset.v2 import (
AssetRestoreEndpoint,
ProjectAssetEndpoint,
ProjectBulkAssetEndpoint,
AssetCheckEndpoint,
)
from .issue.base import (
IssueListEndpoint,
+11
View File
@@ -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)
+16
View File
@@ -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)
+19 -2
View File
@@ -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}",
}
+57 -12
View File
@@ -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
+3 -3
View File
@@ -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
+8
View File
@@ -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
View File
@@ -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
View File
@@ -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";
+61 -30
View File
@@ -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}
+52
View File
@@ -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"]],
};
-8
View File
@@ -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 = {
@@ -86,6 +86,10 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
[editor]
);
const handleInvalidFile = useCallback((_error: EFileError, _file: File, message: string) => {
alert(message);
}, []);
// hooks
const { isUploading: isImageBeingUploaded, uploadFile } = useUploader({
acceptedMimeTypes: ACCEPTED_IMAGE_MIME_TYPES,
@@ -94,18 +98,12 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
handleProgressStatus,
loadFileFromFileSystem: loadImageFromFileSystem,
maxFileSize,
onInvalidFile: handleInvalidFile,
onUpload,
});
const handleInvalidFile = useCallback((_error: EFileError, message: string) => {
alert(message);
}, []);
const { draggedInside, onDrop, onDragEnter, onDragLeave } = useDropZone({
acceptedMimeTypes: ACCEPTED_IMAGE_MIME_TYPES,
editor,
maxFileSize,
onInvalidFile: handleInvalidFile,
pos: getPos(),
type: "image",
uploader: uploadFile,
@@ -140,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,
+3 -2
View File
@@ -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.");
@@ -89,17 +89,14 @@ export const useUploader = (args: TUploaderArgs) => {
};
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);
@@ -126,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), []);
@@ -156,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>;
@@ -168,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
View File
@@ -1,4 +1,5 @@
export type TReadOnlyFileHandler = {
checkIfAssetExists: (assetId: string) => Promise<boolean>;
getAssetSrc: (path: string) => Promise<string>;
restore: (assetSrc: string) => Promise<void>;
};
@@ -22,6 +22,13 @@
"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"
},
@@ -22,6 +22,13 @@
"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"
}
}
}
@@ -22,6 +22,13 @@
"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"
}
}
}
}
+60 -11
View File
@@ -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.",
@@ -22,6 +22,13 @@
"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"
},
@@ -22,6 +22,13 @@
"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"
},
@@ -22,6 +22,13 @@
"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"
},
@@ -22,6 +22,13 @@
"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"
},
@@ -22,6 +22,13 @@
"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": "作業項目をフルスクリーンで開く"
},
@@ -22,6 +22,13 @@
"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": "작업 항목을 전체 화면으로 열기"
},
@@ -22,6 +22,13 @@
"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"
},
@@ -22,6 +22,13 @@
"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"
},
@@ -22,6 +22,13 @@
"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"
},
@@ -22,6 +22,13 @@
"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": "Открыть рабочий элемент в полном экране"
},
@@ -22,6 +22,13 @@
"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"
},
@@ -22,6 +22,13 @@
"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ç"
},
@@ -22,6 +22,13 @@
"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": "Відкрити робочу одиницю на повний екран"
},
@@ -22,6 +22,13 @@
"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"
},
@@ -22,6 +22,13 @@
"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": "在全屏中打开工作项"
},
@@ -22,6 +22,13 @@
"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": "以全螢幕開啟工作事項"
},
+1
View File
@@ -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
View File
@@ -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 }[];
+3
View File
@@ -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;
+2 -2
View File
@@ -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 {
+5 -7
View File
@@ -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";
+13
View File
@@ -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));
};
+1
View File
@@ -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
View File
@@ -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",
@@ -1,20 +1,11 @@
"use client";
import dynamic from "next/dynamic";
import { CommandPalette } from "@/components/command-palette";
import { AuthenticationWrapper } from "@/lib/wrappers";
// plane web components
import { WorkspaceAuthWrapper } from "@/plane-web/layouts/workspace-wrapper";
import { AppSidebar } from "./sidebar";
// Dynamically import heavy components
const CommandPalette = dynamic(
() => import("@/components/command-palette").then((module) => ({ default: module.CommandPalette })),
{
ssr: false, // Command palette doesn't need SSR
loading: () => null,
}
);
export default function WorkspaceLayout({ children }: { children: React.ReactNode }) {
return (
<AuthenticationWrapper>
@@ -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,
}}
@@ -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,
}}
@@ -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,
}}
@@ -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,
}}
@@ -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;
@@ -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>
);
});
@@ -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>
);
});
@@ -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>
);
}
@@ -6,6 +6,7 @@ import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { NotAuthorizedView } from "@/components/auth-screens";
import { PageHead } from "@/components/core";
// hooks
import { SettingsContentWrapper } from "@/components/settings";
import { useUserPermissions, useWorkspace } from "@/hooks/store";
// plane web components
import { BillingRoot } from "@/plane-web/components/workspace";
@@ -19,14 +20,14 @@ const BillingSettingsPage = observer(() => {
const pageTitle = currentWorkspace?.name ? `${currentWorkspace.name} - Billing & Plans` : undefined;
if (workspaceUserInfo && !canPerformWorkspaceAdminActions) {
return <NotAuthorizedView section="settings" />;
return <NotAuthorizedView section="settings" className="h-auto" />;
}
return (
<>
<SettingsContentWrapper size="lg">
<PageHead title={pageTitle} />
<BillingRoot />
</>
</SettingsContentWrapper>
);
});
@@ -8,6 +8,7 @@ import { NotAuthorizedView } from "@/components/auth-screens";
import { PageHead } from "@/components/core";
import ExportGuide from "@/components/exporter/guide";
// helpers
import { SettingsContentWrapper, SettingsHeading } from "@/components/settings";
import { cn } from "@/helpers/common.helper";
// hooks
import { useUserPermissions, useWorkspace } from "@/hooks/store";
@@ -29,23 +30,24 @@ const ExportsPage = observer(() => {
// if user is not authorized to view this page
if (workspaceUserInfo && !canPerformWorkspaceMemberActions) {
return <NotAuthorizedView section="settings" />;
return <NotAuthorizedView section="settings" className="h-auto" />;
}
return (
<>
<SettingsContentWrapper size="lg">
<PageHead title={pageTitle} />
<div
className={cn("w-full overflow-y-auto", {
className={cn("w-full", {
"opacity-60": !canPerformWorkspaceMemberActions,
})}
>
<div className="flex items-center border-b border-custom-border-100 pb-3.5">
<h3 className="text-xl font-medium">{t("workspace_settings.settings.exports.title")}</h3>
</div>
<SettingsHeading
title={t("workspace_settings.settings.exports.heading")}
description={t("workspace_settings.settings.exports.description")}
/>
<ExportGuide />
</div>
</>
</SettingsContentWrapper>
);
});
@@ -3,40 +3,32 @@
import { observer } from "mobx-react";
// components
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { NotAuthorizedView } from "@/components/auth-screens";
import { PageHead } from "@/components/core";
import IntegrationGuide from "@/components/integration/guide";
// hooks
import { SettingsContentWrapper, SettingsHeading } from "@/components/settings";
import { useUserPermissions, useWorkspace } from "@/hooks/store";
const ImportsPage = observer(() => {
// router
// store hooks
const { currentWorkspace } = useWorkspace();
const { allowPermissions } = useUserPermissions();
// derived values
const isAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE);
const pageTitle = currentWorkspace?.name ? `${currentWorkspace.name} - Imports` : undefined;
if (!isAdmin)
return (
<>
<PageHead title={pageTitle} />
<div className="mt-10 flex h-full w-full justify-center p-4">
<p className="text-sm text-custom-text-300">You are not authorized to access this page.</p>
</div>
</>
);
if (!isAdmin) return <NotAuthorizedView section="settings" className="h-auto" />;
return (
<>
<SettingsContentWrapper size="lg">
<PageHead title={pageTitle} />
<section className="w-full overflow-y-auto">
<div className="flex items-center border-b border-custom-border-100 pb-3.5">
<h3 className="text-xl font-medium">Imports</h3>
</div>
<section className="w-full">
<SettingsHeading title="Imports" />
<IntegrationGuide />
</section>
</>
</SettingsContentWrapper>
);
});
@@ -4,8 +4,10 @@ import { useParams } from "next/navigation";
import useSWR from "swr";
// components
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { NotAuthorizedView } from "@/components/auth-screens";
import { PageHead } from "@/components/core";
import { SingleIntegrationCard } from "@/components/integration";
import { SettingsContentWrapper } from "@/components/settings";
import { IntegrationAndImportExportBanner, IntegrationsSettingsLoader } from "@/components/ui";
// constants
import { APP_INTEGRATIONS } from "@/constants/fetch-keys";
@@ -26,23 +28,14 @@ const WorkspaceIntegrationsPage = observer(() => {
// derived values
const isAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE);
const pageTitle = currentWorkspace?.name ? `${currentWorkspace.name} - Integrations` : undefined;
if (!isAdmin)
return (
<>
<PageHead title={pageTitle} />
<div className="mt-10 flex h-full w-full justify-center">
<p className="text-sm text-custom-text-300">You are not authorized to access this page.</p>
</div>
</>
);
const { data: appIntegrations } = useSWR(workspaceSlug && isAdmin ? APP_INTEGRATIONS : null, () =>
workspaceSlug && isAdmin ? integrationService.getAppIntegrationsList() : null
);
if (!isAdmin) return <NotAuthorizedView section="settings" className="h-auto" />;
return (
<>
<SettingsContentWrapper size="lg">
<PageHead title={pageTitle} />
<section className="w-full overflow-y-auto">
<IntegrationAndImportExportBanner bannerName="Integrations" />
@@ -56,7 +49,7 @@ const WorkspaceIntegrationsPage = observer(() => {
)}
</div>
</section>
</>
</SettingsContentWrapper>
);
});
@@ -0,0 +1,58 @@
"use client";
import { FC, ReactNode } from "react";
import { observer } from "mobx-react";
import { usePathname } from "next/navigation";
// constants
import { EUserWorkspaceRoles, WORKSPACE_SETTINGS_ACCESS } from "@plane/constants";
// components
import { NotAuthorizedView } from "@/components/auth-screens";
import { CommandPalette } from "@/components/command-palette";
import { SettingsMobileNav } from "@/components/settings";
import { getWorkspaceActivePath, pathnameToAccessKey } from "@/components/settings/helper";
// hooks
import { useUserPermissions } from "@/hooks/store";
// local components
import { WorkspaceSettingsSidebar } from "./sidebar";
export interface IWorkspaceSettingLayout {
children: ReactNode;
}
const WorkspaceSettingLayout: FC<IWorkspaceSettingLayout> = observer((props) => {
const { children } = props;
// store hooks
const { workspaceUserInfo, getWorkspaceRoleByWorkspaceSlug } = useUserPermissions();
// next hooks
const pathname = usePathname();
// derived values
const { workspaceSlug, accessKey } = pathnameToAccessKey(pathname);
const userWorkspaceRole = getWorkspaceRoleByWorkspaceSlug(workspaceSlug.toString());
let isAuthorized: boolean | string = false;
if (pathname && workspaceSlug && userWorkspaceRole) {
isAuthorized = WORKSPACE_SETTINGS_ACCESS[accessKey]?.includes(userWorkspaceRole as EUserWorkspaceRoles);
}
return (
<>
<CommandPalette />
<SettingsMobileNav
hamburgerContent={WorkspaceSettingsSidebar}
activePath={getWorkspaceActivePath(pathname) || ""}
/>
<div className="inset-y-0 flex flex-row w-full">
{workspaceUserInfo && !isAuthorized ? (
<NotAuthorizedView section="settings" className="h-auto" />
) : (
<div className="relative flex h-full w-full">
<div className="hidden md:block">{<WorkspaceSettingsSidebar />}</div>
{children}
</div>
)}
</div>
</>
);
});
export default WorkspaceSettingLayout;
@@ -14,6 +14,7 @@ import { Button, TOAST_TYPE, setToast } from "@plane/ui";
import { NotAuthorizedView } from "@/components/auth-screens";
import { CountChip } from "@/components/common";
import { PageHead } from "@/components/core";
import { SettingsContentWrapper } from "@/components/settings";
import { WorkspaceMembersList } from "@/components/workspace";
// helpers
import { cn } from "@/helpers/common.helper";
@@ -95,11 +96,11 @@ const WorkspaceMembersSettingsPage = observer(() => {
// if user is not authorized to view this page
if (workspaceUserInfo && !canPerformWorkspaceMemberActions) {
return <NotAuthorizedView section="settings" />;
return <NotAuthorizedView section="settings" className="h-auto" />;
}
return (
<>
<SettingsContentWrapper size="lg">
<PageHead title={pageTitle} />
<SendWorkspaceInvitationModal
isOpen={inviteModal}
@@ -107,7 +108,7 @@ const WorkspaceMembersSettingsPage = observer(() => {
onSubmit={handleWorkspaceInvite}
/>
<section
className={cn("w-full h-full overflow-y-auto", {
className={cn("w-full h-full", {
"opacity-60": !canPerformWorkspaceMemberActions,
})}
>
@@ -137,7 +138,7 @@ const WorkspaceMembersSettingsPage = observer(() => {
</div>
<WorkspaceMembersList searchQuery={searchQuery} isAdmin={canPerformWorkspaceAdminActions} />
</section>
</>
</SettingsContentWrapper>
);
});
@@ -4,6 +4,7 @@ import { observer } from "mobx-react";
// components
import { useTranslation } from "@plane/i18n";
import { PageHead } from "@/components/core";
import { SettingsContentWrapper } from "@/components/settings";
import { WorkspaceDetails } from "@/components/workspace";
// hooks
import { useWorkspace } from "@/hooks/store";
@@ -18,10 +19,10 @@ const WorkspaceSettingsPage = observer(() => {
: undefined;
return (
<>
<SettingsContentWrapper>
<PageHead title={pageTitle} />
<WorkspaceDetails />
</>
</SettingsContentWrapper>
);
});
@@ -0,0 +1,73 @@
import { useParams, usePathname } from "next/navigation";
import { ArrowUpToLine, Building, CreditCard, Users, Webhook } from "lucide-react";
import {
EUserPermissionsLevel,
GROUPED_WORKSPACE_SETTINGS,
WORKSPACE_SETTINGS_CATEGORIES,
EUserWorkspaceRoles,
EUserPermissions,
WORKSPACE_SETTINGS_CATEGORY,
} from "@plane/constants";
import { SettingsSidebar } from "@/components/settings";
import { useUserPermissions } from "@/hooks/store/user";
import { shouldRenderSettingLink } from "@/plane-web/helpers/workspace.helper";
const ICONS = {
general: Building,
members: Users,
export: ArrowUpToLine,
"billing-and-plans": CreditCard,
webhooks: Webhook,
};
export const WorkspaceActionIcons = ({
type,
size,
className,
}: {
type: string;
size?: number;
className?: string;
}) => {
if (type === undefined) return null;
const Icon = ICONS[type as keyof typeof ICONS];
if (!Icon) return null;
return <Icon size={size} className={className} strokeWidth={2} />;
};
type TWorkspaceSettingsSidebarProps = {
isMobile?: boolean;
};
export const WorkspaceSettingsSidebar = (props: TWorkspaceSettingsSidebarProps) => {
const { isMobile = false } = props;
// router
const pathname = usePathname();
const { workspaceSlug } = useParams(); // store hooks
const { allowPermissions } = useUserPermissions();
const isAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE);
return (
<SettingsSidebar
isMobile={isMobile}
categories={WORKSPACE_SETTINGS_CATEGORIES.filter(
(category) =>
isAdmin || ![WORKSPACE_SETTINGS_CATEGORY.FEATURES, WORKSPACE_SETTINGS_CATEGORY.DEVELOPER].includes(category)
)}
groupedSettings={GROUPED_WORKSPACE_SETTINGS}
workspaceSlug={workspaceSlug.toString()}
isActive={(data: { href: string }) =>
data.href === "/settings"
? pathname === `/${workspaceSlug}${data.href}/`
: new RegExp(`^/${workspaceSlug}${data.href}/`).test(pathname)
}
shouldRender={(data: { key: string; access?: EUserWorkspaceRoles[] | undefined }) =>
data.access
? shouldRenderSettingLink(workspaceSlug.toString(), data.key) &&
allowPermissions(data.access, EUserPermissionsLevel.WORKSPACE, workspaceSlug.toString())
: false
}
actionIcons={WorkspaceActionIcons}
/>
);
};
@@ -11,6 +11,7 @@ import { TOAST_TYPE, setToast } from "@plane/ui";
// components
import { LogoSpinner } from "@/components/common";
import { PageHead } from "@/components/core";
import { SettingsContentWrapper } from "@/components/settings";
import { DeleteWebhookModal, WebhookDeleteSection, WebhookForm } from "@/components/web-hooks";
// hooks
import { useUserPermissions, useWebhook, useWorkspace } from "@/hooks/store";
@@ -87,7 +88,7 @@ const WebhookDetailsPage = observer(() => {
);
return (
<>
<SettingsContentWrapper>
<PageHead title={pageTitle} />
<DeleteWebhookModal isOpen={deleteWebhookModal} onClose={() => setDeleteWebhookModal(false)} />
<div className="w-full space-y-8 overflow-y-auto">
@@ -96,7 +97,7 @@ const WebhookDetailsPage = observer(() => {
</div>
{currentWebhook && <WebhookDeleteSection openDeleteModal={() => setDeleteWebhookModal(true)} />}
</div>
</>
</SettingsContentWrapper>
);
});
@@ -7,11 +7,11 @@ import useSWR from "swr";
// plane imports
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/ui";
// components
import { NotAuthorizedView } from "@/components/auth-screens";
import { PageHead } from "@/components/core";
import { DetailedEmptyState } from "@/components/empty-state";
import { SettingsContentWrapper, SettingsHeading } from "@/components/settings";
import { WebhookSettingsLoader } from "@/components/ui";
import { WebhooksList, CreateWebhookModal } from "@/components/web-hooks";
// hooks
@@ -48,15 +48,15 @@ const WebhooksListPage = observer(() => {
}, [showCreateWebhookModal, webhookSecretKey, clearSecretKey]);
if (workspaceUserInfo && !canPerformWorkspaceAdminActions) {
return <NotAuthorizedView section="settings" />;
return <NotAuthorizedView section="settings" className="h-auto" />;
}
if (!webhooks) return <WebhookSettingsLoader />;
return (
<>
<SettingsContentWrapper>
<PageHead title={pageTitle} />
<div className="w-full overflow-y-auto">
<div className="w-full">
<CreateWebhookModal
createWebhook={createWebhook}
clearSecretKey={clearSecretKey}
@@ -66,35 +66,37 @@ const WebhooksListPage = observer(() => {
setShowCreateWebhookModal(false);
}}
/>
<SettingsHeading
title={t("workspace_settings.settings.webhooks.title")}
description={t("workspace_settings.settings.webhooks.description")}
button={{
label: t("workspace_settings.settings.webhooks.add_webhook"),
onClick: () => setShowCreateWebhookModal(true),
}}
/>
{Object.keys(webhooks).length > 0 ? (
<div className="flex h-full w-full flex-col">
<div className="flex items-center justify-between gap-4 border-b border-custom-border-200 pb-3.5">
<div className="text-xl font-medium">{t("workspace_settings.settings.webhooks.title")}</div>
<Button variant="primary" size="sm" onClick={() => setShowCreateWebhookModal(true)}>
{t("workspace_settings.settings.webhooks.add_webhook")}
</Button>
</div>
<WebhooksList />
</div>
) : (
<div className="flex h-full w-full flex-col">
<div className="flex items-center justify-between gap-4 border-b border-custom-border-200 pb-3.5">
<div className="text-xl font-medium">{t("workspace_settings.settings.webhooks.title")}</div>
<Button variant="primary" size="sm" onClick={() => setShowCreateWebhookModal(true)}>
{t("workspace_settings.settings.webhooks.add_webhook")}
</Button>
</div>
<div className="h-full w-full flex items-center justify-center">
<DetailedEmptyState
title={t("workspace_settings.empty_state.webhooks.title")}
description={t("workspace_settings.empty_state.webhooks.description")}
className="!p-0"
title=""
description=""
assetPath={resolvedPath}
size="md"
primaryButton={{
text: t("workspace_settings.settings.webhooks.add_webhook"),
onClick: () => setShowCreateWebhookModal(true),
}}
/>
</div>
</div>
)}
</div>
</>
</SettingsContentWrapper>
);
});
@@ -0,0 +1,89 @@
"use client";
import { useState } from "react";
import { observer } from "mobx-react";
import { useTranslation } from "@plane/i18n";
// ui
import { Button } from "@plane/ui";
// components
import { PageHead } from "@/components/core";
import { DetailedEmptyState } from "@/components/empty-state";
import { ProfileActivityListPage } from "@/components/profile";
// hooks
import { SettingsHeading } from "@/components/settings";
import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path";
const PER_PAGE = 100;
const ProfileActivityPage = observer(() => {
// states
const [pageCount, setPageCount] = useState(1);
const [totalPages, setTotalPages] = useState(0);
const [resultsCount, setResultsCount] = useState(0);
const [isEmpty, setIsEmpty] = useState(false);
// plane hooks
const { t } = useTranslation();
// derived values
const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/profile/activity" });
const updateTotalPages = (count: number) => setTotalPages(count);
const updateResultsCount = (count: number) => setResultsCount(count);
const updateEmptyState = (isEmpty: boolean) => setIsEmpty(isEmpty);
const handleLoadMore = () => setPageCount((prev) => prev + 1);
const activityPages: JSX.Element[] = [];
for (let i = 0; i < pageCount; i++)
activityPages.push(
<ProfileActivityListPage
key={i}
cursor={`${PER_PAGE}:${i}:0`}
perPage={PER_PAGE}
updateResultsCount={updateResultsCount}
updateTotalPages={updateTotalPages}
updateEmptyState={updateEmptyState}
/>
);
const isLoadMoreVisible = pageCount < totalPages && resultsCount !== 0;
if (isEmpty) {
return (
<div className="flex h-full w-full flex-col">
<SettingsHeading
title={t("account_settings.activity.heading")}
description={t("account_settings.activity.description")}
/>
<DetailedEmptyState
title={""}
description={""}
assetPath={resolvedPath}
className="w-full !p-0 justify-center mx-auto min-h-fit"
size="md"
/>
</div>
);
}
return (
<>
<PageHead title="Profile - Activity" />
<SettingsHeading
title={t("account_settings.activity.heading")}
description={t("account_settings.activity.description")}
/>
<div className="w-full">{activityPages}</div>
{isLoadMoreVisible && (
<div className="flex w-full items-center justify-center text-xs">
<Button variant="accent-primary" size="sm" onClick={handleLoadMore}>
{t("load_more")}
</Button>
</div>
)}
</>
);
});
export default ProfileActivityPage;
@@ -7,12 +7,12 @@ import useSWR from "swr";
// plane imports
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/ui";
// component
import { ApiTokenListItem, CreateApiTokenModal } from "@/components/api-token";
import { NotAuthorizedView } from "@/components/auth-screens";
import { PageHead } from "@/components/core";
import { DetailedEmptyState } from "@/components/empty-state";
import { SettingsHeading } from "@/components/settings";
import { APITokenSettingsLoader } from "@/components/ui";
import { API_TOKENS_LIST } from "@/constants/fetch-keys";
// store hooks
@@ -48,7 +48,7 @@ const ApiTokensPage = observer(() => {
: undefined;
if (workspaceUserInfo && !canPerformWorkspaceAdminActions) {
return <NotAuthorizedView section="settings" />;
return <NotAuthorizedView section="settings" className="h-auto" />;
}
if (!tokens) {
@@ -56,18 +56,20 @@ const ApiTokensPage = observer(() => {
}
return (
<>
<div className="w-full">
<PageHead title={pageTitle} />
<CreateApiTokenModal isOpen={isCreateTokenModalOpen} onClose={() => setIsCreateTokenModalOpen(false)} />
<section className="w-full overflow-y-auto">
<section className="w-full">
{tokens.length > 0 ? (
<>
<div className="flex items-center justify-between border-b border-custom-border-200 pb-3.5">
<h3 className="text-xl font-medium">{t("workspace_settings.settings.api_tokens.title")}</h3>
<Button variant="primary" onClick={() => setIsCreateTokenModalOpen(true)}>
{t("workspace_settings.settings.api_tokens.add_token")}
</Button>
</div>
<SettingsHeading
title={t("account_settings.api_tokens.heading")}
description={t("account_settings.api_tokens.description")}
button={{
label: t("workspace_settings.settings.api_tokens.add_token"),
onClick: () => setIsCreateTokenModalOpen(true),
}}
/>
<div>
{tokens.map((token) => (
<ApiTokenListItem key={token.id} token={token} />
@@ -76,23 +78,31 @@ const ApiTokensPage = observer(() => {
</>
) : (
<div className="flex h-full w-full flex-col">
<div className="flex items-center justify-between gap-4 border-b border-custom-border-200 pb-3.5">
<h3 className="text-xl font-medium">{t("workspace_settings.settings.api_tokens.title")}</h3>
<Button variant="primary" onClick={() => setIsCreateTokenModalOpen(true)}>
{t("workspace_settings.settings.api_tokens.add_token")}
</Button>
</div>
<SettingsHeading
title={t("account_settings.api_tokens.heading")}
description={t("account_settings.api_tokens.description")}
button={{
label: t("workspace_settings.settings.api_tokens.add_token"),
onClick: () => setIsCreateTokenModalOpen(true),
}}
/>
<div className="h-full w-full flex items-center justify-center">
<DetailedEmptyState
title={t("workspace_settings.empty_state.api_tokens.title")}
description={t("workspace_settings.empty_state.api_tokens.description")}
title=""
description=""
assetPath={resolvedPath}
className="w-full !p-0 justify-center mx-auto"
size="md"
primaryButton={{
text: t("workspace_settings.settings.api_tokens.add_token"),
onClick: () => setIsCreateTokenModalOpen(true),
}}
/>
</div>
</div>
)}
</section>
</>
</div>
);
});
@@ -0,0 +1,33 @@
"use client";
import { ReactNode } from "react";
// components
import { observer } from "mobx-react";
import { usePathname } from "next/navigation";
import { SettingsContentWrapper, SettingsMobileNav } from "@/components/settings";
import { getProfileActivePath } from "@/components/settings/helper";
import { ProfileSidebar } from "./sidebar";
type Props = {
children: ReactNode;
};
const ProfileSettingsLayout = observer((props: Props) => {
const { children } = props;
// router
const pathname = usePathname();
return (
<>
<SettingsMobileNav hamburgerContent={ProfileSidebar} activePath={getProfileActivePath(pathname) || ""} />
<div className="relative flex h-full w-full">
<div className="hidden md:block">
<ProfileSidebar />
</div>
<SettingsContentWrapper>{children}</SettingsContentWrapper>
</div>
</>
);
});
export default ProfileSettingsLayout;

Some files were not shown because too many files have changed in this diff Show More