Compare commits

..

4 Commits

Author SHA1 Message Date
Aaron Reisman b4eab66e3d refactor: revert unintentional layout changes 2025-05-28 20:21:03 -07:00
Aaron Reisman d9d39199ae refactor: standardize loading spinner implementation in dynamic graph components
- Replaced inline loading divs with a shared LoadingSpinner component across all dynamic graph imports.
- Ensured consistent loading behavior for BarGraph, PieGraph, LineGraph, CalendarGraph, and ScatterPlotGraph components.
2025-05-28 20:14:25 -07:00
Aaron Reisman f30e31e294 refactor: enhance webpack configuration for client-side optimizations
- Updated webpack settings to improve tree shaking and chunk splitting strategies for client-side production builds.
- Increased maximum chunk size to reduce fragmentation and improve loading performance.
- Adjusted cache groups for better management of framework and library chunks.
2025-05-28 20:11:31 -07:00
Aaron Reisman dc57098507 chore: update dependencies and optimize dynamic imports in layout components
- Updated various dependencies in package.json and yarn.lock.
- Refactored layout components to dynamically import heavy components for improved performance.
- Enhanced webpack configuration for better chunk splitting and optimization.
2025-05-28 20:02:40 -07:00
231 changed files with 2109 additions and 4487 deletions
+1 -2
View File
@@ -2,7 +2,6 @@
*.pyc
.env
venv
.venv
node_modules/
**/node_modules/
npm-debug.log
@@ -15,4 +14,4 @@ build/
out/
**/out/
dist/
**/dist/
**/dist/
-190
View File
@@ -1,190 +0,0 @@
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.29",
"next": "^14.2.28",
"next-themes": "^0.2.1",
"postcss": "^8.4.38",
"react": "^18.3.1",
+2 -5
View File
@@ -148,13 +148,10 @@ class ProjectMemberAdminSerializer(BaseSerializer):
fields = "__all__"
class ProjectMemberRoleSerializer(DynamicBaseSerializer):
original_role = serializers.IntegerField(source='role', read_only=True)
class ProjectMemberRoleSerializer(DynamicBaseSerializer):
class Meta:
model = ProjectMember
fields = ("id", "role", "member", "project", "original_role", "created_at")
read_only_fields = ["original_role", "created_at"]
fields = ("id", "role", "member", "project")
class ProjectMemberInviteSerializer(BaseSerializer):
-16
View File
@@ -3,22 +3,11 @@ 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
@@ -110,16 +99,11 @@ 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,12 +25,10 @@ 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):
@@ -38,21 +36,10 @@ 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,7 +12,6 @@ from plane.app.views import (
AssetRestoreEndpoint,
ProjectAssetEndpoint,
ProjectBulkAssetEndpoint,
AssetCheckEndpoint,
)
@@ -82,11 +81,5 @@ 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,7 +106,6 @@ from .asset.v2 import (
AssetRestoreEndpoint,
ProjectAssetEndpoint,
ProjectBulkAssetEndpoint,
AssetCheckEndpoint,
)
from .issue.base import (
IssueListEndpoint,
-11
View File
@@ -707,14 +707,3 @@ 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,7 +15,6 @@ 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
@@ -45,9 +44,6 @@ 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),
@@ -59,10 +55,6 @@ 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)
@@ -74,14 +66,9 @@ 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,
@@ -93,9 +80,6 @@ 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)
+2 -19
View File
@@ -3,7 +3,6 @@ import csv
import io
import os
from datetime import date
import uuid
from dateutil.relativedelta import relativedelta
from django.db import IntegrityError
@@ -36,7 +35,6 @@ from plane.db.models import (
Workspace,
WorkspaceMember,
WorkspaceTheme,
Profile,
)
from plane.app.permissions import ROLE, allow_permission
from django.utils.decorators import method_decorator
@@ -45,7 +43,6 @@ 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):
@@ -112,12 +109,6 @@ 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
@@ -159,18 +150,8 @@ 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)
@@ -178,6 +159,8 @@ 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 = (
@@ -1,185 +0,0 @@
# Python imports
import logging
# Third party imports
from celery import shared_task
import requests
from bs4 import BeautifulSoup
from urllib.parse import urlparse, urljoin
import base64
import ipaddress
from typing import Dict, Any
from typing import Optional
from plane.db.models import IssueLink
from plane.utils.exception_logger import log_exception
logger = logging.getLogger("plane.worker")
DEFAULT_FAVICON = "PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGNsYXNzPSJsdWNpZGUgbHVjaWRlLWxpbmstaWNvbiBsdWNpZGUtbGluayI+PHBhdGggZD0iTTEwIDEzYTUgNSAwIDAgMCA3LjU0LjU0bDMtM2E1IDUgMCAwIDAtNy4wNy03LjA3bC0xLjcyIDEuNzEiLz48cGF0aCBkPSJNMTQgMTFhNSA1IDAgMCAwLTcuNTQtLjU0bC0zIDNhNSA1IDAgMCAwIDcuMDcgNy4wN2wxLjcxLTEuNzEiLz48L3N2Zz4=" # noqa: E501
@shared_task
def crawl_work_item_link_title(id: str, url: str) -> None:
meta_data = crawl_work_item_link_title_and_favicon(url)
issue_link = IssueLink.objects.get(id=id)
issue_link.metadata = meta_data
issue_link.save()
def crawl_work_item_link_title_and_favicon(url: str) -> Dict[str, Any]:
"""
Crawls a URL to extract the title and favicon.
Args:
url (str): The URL to crawl
Returns:
str: JSON string containing title and base64-encoded favicon
"""
try:
# Prevent access to private IP ranges
parsed = urlparse(url)
try:
ip = ipaddress.ip_address(parsed.hostname)
if ip.is_private or ip.is_loopback or ip.is_reserved:
raise ValueError("Access to private/internal networks is not allowed")
except ValueError:
# Not an IP address, continue with domain validation
pass
# Set up headers to mimic a real browser
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36" # noqa: E501
}
# Fetch the main page
response = requests.get(url, headers=headers, timeout=2)
response.raise_for_status()
# Parse HTML
soup = BeautifulSoup(response.content, "html.parser")
# Extract title
title_tag = soup.find("title")
title = title_tag.get_text().strip() if title_tag else None
# Fetch and encode favicon
favicon_base64 = fetch_and_encode_favicon(headers, soup, url)
# Prepare result
result = {
"title": title,
"favicon": favicon_base64["favicon_base64"],
"url": url,
"favicon_url": favicon_base64["favicon_url"],
}
return result
except requests.RequestException as e:
log_exception(e)
return {
"error": f"Request failed: {str(e)}",
"title": None,
"favicon": None,
"url": url,
}
except Exception as e:
log_exception(e)
return {
"error": f"Unexpected error: {str(e)}",
"title": None,
"favicon": None,
"url": url,
}
def find_favicon_url(soup: BeautifulSoup, base_url: str) -> Optional[str]:
"""
Find the favicon URL from HTML soup.
Args:
soup: BeautifulSoup object
base_url: Base URL for resolving relative paths
Returns:
str: Absolute URL to favicon or None
"""
# Look for various favicon link tags
favicon_selectors = [
'link[rel="icon"]',
'link[rel="shortcut icon"]',
'link[rel="apple-touch-icon"]',
'link[rel="apple-touch-icon-precomposed"]',
]
for selector in favicon_selectors:
favicon_tag = soup.select_one(selector)
if favicon_tag and favicon_tag.get("href"):
return urljoin(base_url, favicon_tag["href"])
# Fallback to /favicon.ico
parsed_url = urlparse(base_url)
fallback_url = f"{parsed_url.scheme}://{parsed_url.netloc}/favicon.ico"
# Check if fallback exists
try:
response = requests.head(fallback_url, timeout=2)
response.raise_for_status()
if response.status_code == 200:
return fallback_url
except requests.RequestException as e:
log_exception(e)
return None
return None
def fetch_and_encode_favicon(
headers: Dict[str, str], soup: BeautifulSoup, url: str
) -> Optional[Dict[str, str]]:
"""
Fetch favicon and encode it as base64.
Args:
favicon_url: URL to the favicon
headers: Request headers
Returns:
str: Base64 encoded favicon with data URI prefix or None
"""
try:
favicon_url = find_favicon_url(soup, url)
if favicon_url is None:
return {
"favicon_url": None,
"favicon_base64": f"data:image/svg+xml;base64,{DEFAULT_FAVICON}",
}
response = requests.get(favicon_url, headers=headers, timeout=2)
response.raise_for_status()
# Get content type
content_type = response.headers.get("content-type", "image/x-icon")
# Convert to base64
favicon_base64 = base64.b64encode(response.content).decode("utf-8")
# Return as data URI
return {
"favicon_url": favicon_url,
"favicon_base64": f"data:{content_type};base64,{favicon_base64}",
}
except Exception as e:
logger.warning(f"Failed to fetch favicon: {e}")
return {
"favicon_url": None,
"favicon_base64": f"data:image/svg+xml;base64,{DEFAULT_FAVICON}",
}
+12 -57
View File
@@ -12,29 +12,6 @@ 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:
@@ -55,8 +32,6 @@ Plane has two types of API endpoints:
## Running Tests
### Local Testing
To run all tests:
```bash
@@ -79,19 +54,20 @@ python -m pytest plane/tests/contract/app/
python -m pytest plane/tests/smoke/
```
### Using the Test Runner
For convenience, we provide helper scripts:
For convenience, we also provide a helper script:
```bash
# 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 all tests
./run_tests.py
# Using shell wrapper
./run_tests.sh --coverage --verbose # Full test suite with coverage
# 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
```
## Fixtures
@@ -158,30 +134,9 @@ 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 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.
This creates an HTML report in the `htmlcov/` directory.
## 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,14 +4,6 @@ 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.
+27 -18
View File
@@ -6,20 +6,36 @@ 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(
"-c", "--contract", action="store_true", help="Run contract tests only"
"-u", "--unit",
action="store_true",
help="Run unit tests only"
)
parser.add_argument(
"-s", "--smoke", action="store_true", help="Run smoke tests only"
"-c", "--contract",
action="store_true",
help="Run contract tests only"
)
parser.add_argument(
"-o", "--coverage", action="store_true", help="Generate coverage report"
"-s", "--smoke",
action="store_true",
help="Run smoke tests only"
)
parser.add_argument(
"-p", "--parallel", action="store_true", help="Run tests in parallel"
"-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"
)
parser.add_argument("-v", "--verbose", action="store_true", help="Verbose output")
args = parser.parse_args()
# Build command
@@ -40,14 +56,7 @@ def main():
# Add coverage
if args.coverage:
cmd.extend(
[
"--cov=plane",
"--cov-report=term",
"--cov-report=html",
"--cov-report=xml",
]
)
cmd.extend(["--cov=plane", "--cov-report=term", "--cov-report=html"])
# Add parallel
if args.parallel:
@@ -62,10 +71,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...")
@@ -74,9 +83,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,6 +32,5 @@ export * from "./dashboard";
export * from "./page";
export * from "./emoji";
export * from "./subscription";
export * from "./settings";
export * from "./icon";
export * from "./analytics-v2";
+30 -61
View File
@@ -1,53 +1,39 @@
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;
}[] = [
PROFILE_SETTINGS["profile"],
PROFILE_SETTINGS["security"],
PROFILE_SETTINGS["activity"],
PROFILE_SETTINGS["preferences"],
PROFILE_SETTINGS["notifications"],
PROFILE_SETTINGS["api-tokens"],
{
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/",
},
];
export const PROFILE_VIEWER_TAB = [
@@ -86,23 +72,6 @@ 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
@@ -1,52 +0,0 @@
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,6 +114,13 @@ 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(
@@ -132,6 +139,7 @@ export const WORKSPACE_SETTINGS_LINKS: {
WORKSPACE_SETTINGS["billing-and-plans"],
WORKSPACE_SETTINGS["export"],
WORKSPACE_SETTINGS["webhooks"],
WORKSPACE_SETTINGS["api-tokens"],
];
export const ROLE = {
@@ -86,10 +86,6 @@ 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,
@@ -98,12 +94,18 @@ 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,
@@ -138,8 +140,11 @@ 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,
+2 -3
View File
@@ -81,7 +81,6 @@ export const useEditor = (props: CustomEditorProps) => {
immediatelyRender: false,
shouldRerenderOnTransaction: false,
autofocus,
parseOptions: { preserveWhitespace: true },
editorProps: {
...CoreEditorProps({
editorClassName,
@@ -120,7 +119,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: true });
editor.commands.setContent(value, false, { preserveWhitespace: "full" });
if (editor.state.selection) {
const docLength = editor.state.doc.content.size;
const relativePosition = Math.min(editor.state.selection.from, docLength - 1);
@@ -154,7 +153,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: true });
editor?.commands.setContent(content, emitUpdate, { preserveWhitespace: "full" });
},
setEditorValueAtCursorPosition: (content: string) => {
if (editor?.state.selection) {
@@ -9,11 +9,11 @@ import { TEditorCommands } from "@/types";
type TUploaderArgs = {
acceptedMimeTypes: string[];
editorCommand: (file: File) => Promise<string | undefined>;
editorCommand: (file: File) => Promise<string>;
handleProgressStatus?: (isUploading: boolean) => void;
loadFileFromFileSystem?: (file: string) => void;
maxFileSize: number;
onInvalidFile: (error: EFileError, file: File, message: string) => void;
onInvalidFile: (error: EFileError, message: string) => void;
onUpload: (url: string, file: File) => void;
};
@@ -38,7 +38,7 @@ export const useUploader = (args: TUploaderArgs) => {
acceptedMimeTypes,
file,
maxFileSize,
onError: (error, message) => onInvalidFile(error, file, message),
onError: onInvalidFile,
});
if (!isValid) {
handleProgressStatus?.(false);
@@ -60,7 +60,7 @@ export const useUploader = (args: TUploaderArgs) => {
};
reader.readAsDataURL(file);
}
const url = await editorCommand(file);
const url: string = await editorCommand(file);
if (!url) {
throw new Error("Something went wrong while uploading the file.");
@@ -89,14 +89,17 @@ 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 { editor, pos, type, uploader } = args;
const { acceptedMimeTypes, editor, maxFileSize, onInvalidFile, pos, type, uploader } = args;
// states
const [isDragging, setIsDragging] = useState<boolean>(false);
const [draggedInside, setDraggedInside] = useState<boolean>(false);
@@ -123,21 +126,22 @@ export const useDropZone = (args: TDropzoneArgs) => {
async (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
setDraggedInside(false);
const filesList = e.dataTransfer.files;
if (filesList.length === 0 || !editor.isEditable) {
if (e.dataTransfer.files.length === 0 || !editor.isEditable) {
return;
}
const filesList = e.dataTransfer.files;
await uploadFirstFileAndInsertRemaining({
acceptedMimeTypes,
editor,
filesList,
maxFileSize,
onInvalidFile,
pos,
type,
uploader,
});
},
[editor, pos, type, uploader]
[acceptedMimeTypes, editor, maxFileSize, onInvalidFile, pos, type, uploader]
);
const onDragEnter = useCallback(() => setDraggedInside(true), []);
const onDragLeave = useCallback(() => setDraggedInside(false), []);
@@ -152,8 +156,11 @@ 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>;
@@ -161,18 +168,35 @@ type TMultipleFileArgs = {
// Upload the first file and insert the remaining ones for uploading multiple files
export const uploadFirstFileAndInsertRemaining = async (args: TMultipleFileArgs) => {
const { editor, filesList, pos, type, uploader } = args;
const filesArray = Array.from(filesList);
if (filesArray.length === 0) {
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) {
console.error("No files found to upload.");
return;
}
// Upload the first file
const firstFile = filesArray[0];
const firstFile = filteredFiles[0];
uploader(firstFile);
// Insert the remaining files
const remainingFiles = filesArray.slice(1);
const remainingFiles = filteredFiles.slice(1);
if (remainingFiles.length > 0) {
const docSize = editor.state.doc.content.size;
const posOfNextFileToBeInserted = Math.min(pos + 1, docSize);
@@ -46,7 +46,6 @@ export const useReadOnlyEditor = (props: CustomReadOnlyEditorProps) => {
immediatelyRender: true,
shouldRerenderOnTransaction: false,
content: typeof initialValue === "string" && initialValue.trim() !== "" ? initialValue : "<p></p>",
parseOptions: { preserveWhitespace: true },
editorProps: {
...CoreReadOnlyEditorProps({
editorClassName,
@@ -72,7 +71,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: true });
if (editor && !editor.isDestroyed) editor?.commands.setContent(initialValue, false, { preserveWhitespace: "full" });
}, [editor, initialValue]);
useImperativeHandle(forwardedRef, () => ({
@@ -80,7 +79,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: true });
editor?.commands.setContent(content, emitUpdate, { preserveWhitespace: "full" });
},
getMarkDown: (): string => {
const markdownOutput = editor?.storage.markdown.getMarkdown();
@@ -1,7 +1,5 @@
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
@@ -34,7 +32,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(CORE_EDITOR_META.SKIP_FILE_DELETION)) return;
if (transaction.getMeta("skipFileDeletion")) return;
const removedFiles: TFileNode[] = [];
-1
View File
@@ -1,5 +1,4 @@
export type TReadOnlyFileHandler = {
checkIfAssetExists: (assetId: string) => Promise<boolean>;
getAssetSrc: (path: string) => Promise<string>;
restore: (assetSrc: string) => Promise<void>;
};
@@ -22,13 +22,6 @@
"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,7 +848,6 @@
"live": "Živě",
"change_history": "Historie změn",
"coming_soon": "Již brzy",
"member": "Člen",
"members": "Členové",
"you": "Vy",
"upgrade_cta": {
@@ -1081,9 +1080,7 @@
"select": {
"error": "Vyberte alespoň jednu pracovní položku",
"empty": "Nevybrány žádné 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"
"add_selected": "Přidat vybrané pracovní položky"
},
"open_in_full_screen": "Otevřít pracovní položku na celou obrazovku"
},
@@ -22,13 +22,6 @@
"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,7 +848,6 @@
"live": "Live",
"change_history": "Änderungsverlauf",
"coming_soon": "Demnächst verfügbar",
"member": "Mitglied",
"members": "Mitglieder",
"you": "Sie",
"upgrade_cta": {
@@ -1081,9 +1080,7 @@
"select": {
"error": "Wählen Sie mindestens ein Arbeitselement aus",
"empty": "Keine Arbeitselemente ausgewählt",
"add_selected": "Ausgewählte Arbeitselemente hinzufügen",
"select_all": "Alle auswählen",
"deselect_all": "Alle abwählen"
"add_selected": "Ausgewählte Arbeitselemente hinzufügen"
},
"open_in_full_screen": "Arbeitselement im Vollbild öffnen"
},
@@ -2462,4 +2459,4 @@
"previously_edited_by": "Zuvor bearbeitet von",
"edited_by": "Bearbeitet von"
}
}
}
@@ -22,13 +22,6 @@
"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"
}
}
}
}
+11 -60
View File
@@ -43,8 +43,7 @@
"your_account": "Your account",
"security": "Security",
"activity": "Activity",
"preferences": "Preferences",
"language_and_time": "Language & Time",
"appearance": "Appearance",
"notifications": "Notifications",
"workspaces": "Workspaces",
"create_workspace": "Create workspace",
@@ -57,10 +56,6 @@
"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",
@@ -339,8 +334,6 @@
"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",
@@ -690,7 +683,6 @@
"live": "Live",
"change_history": "Change History",
"coming_soon": "Coming soon",
"member": "Member",
"members": "Members",
"you": "You",
"upgrade_cta": {
@@ -924,9 +916,7 @@
"select": {
"error": "Please select at least one work item",
"empty": "No work items selected",
"add_selected": "Add selected work items",
"select_all": "Select all",
"deselect_all": "Deselect all"
"add_selected": "Add selected work items"
},
"open_in_full_screen": "Open work item in full screen"
},
@@ -1311,28 +1301,6 @@
}
}
},
"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",
@@ -1399,22 +1367,16 @@
}
},
"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": {
@@ -1430,8 +1392,6 @@
}
},
"webhooks": {
"heading": "Webhooks",
"description": "Automate notifications to external services when project events occur.",
"title": "Webhooks",
"add_webhook": "Add webhook",
"modal": {
@@ -1483,29 +1443,29 @@
}
},
"api_tokens": {
"title": "Personal Access Tokens",
"add_token": "Add personal access token",
"title": "API Tokens",
"add_token": "Add API token",
"create_token": "Create token",
"never_expires": "Never expires",
"generate_token": "Generate token",
"generating": "Generating",
"delete": {
"title": "Delete personal access token",
"title": "Delete API 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 token has been successfully deleted"
"message": "The API token has been successfully deleted"
},
"error": {
"title": "Error!",
"message": "The token could not be deleted"
"message": "The API token could not be deleted"
}
}
}
},
"empty_state": {
"api_tokens": {
"title": "No personal access tokens created",
"title": "No API 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": {
@@ -1555,9 +1515,8 @@
"profile": "Profile",
"security": "Security",
"activity": "Activity",
"preferences": "Preferences",
"notifications": "Notifications",
"api-tokens": "Personal Access Tokens"
"appearance": "Appearance",
"notifications": "Notifications"
},
"tabs": {
"summary": "Summary",
@@ -1619,8 +1578,6 @@
}
},
"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",
@@ -1628,8 +1585,6 @@
}
},
"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",
@@ -1638,11 +1593,9 @@
}
},
"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",
"enable_description": "They help you in communicating complexity and workload of the team.",
"description": "They help you in communicating complexity and workload of the team.",
"no_estimate": "No estimate",
"new": "New estimate system",
"create": {
@@ -1724,8 +1677,6 @@
},
"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,13 +22,6 @@
"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,7 +851,6 @@
"live": "En vivo",
"change_history": "Historial de cambios",
"coming_soon": "Próximamente",
"member": "Miembro",
"members": "Miembros",
"you": "Tú",
"upgrade_cta": {
@@ -1084,9 +1083,7 @@
"select": {
"error": "Por favor selecciona al menos un elemento de trabajo",
"empty": "No hay elementos de trabajo seleccionados",
"add_selected": "Agregar elementos seleccionados",
"select_all": "Seleccionar todo",
"deselect_all": "Deseleccionar todo"
"add_selected": "Agregar elementos seleccionados"
},
"open_in_full_screen": "Abrir elemento de trabajo en pantalla completa"
},
@@ -22,13 +22,6 @@
"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,7 +849,6 @@
"live": "En direct",
"change_history": "Historique des modifications",
"coming_soon": "À venir",
"member": "Membre",
"members": "Membres",
"you": "Vous",
"upgrade_cta": {
@@ -1082,9 +1081,7 @@
"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",
"select_all": "Sélectionner tout",
"deselect_all": "Tout désélectionner"
"add_selected": "Ajouter les éléments de travail sélectionnés"
},
"open_in_full_screen": "Ouvrir l'élément de travail en plein écran"
},
@@ -22,13 +22,6 @@
"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,7 +848,6 @@
"live": "Langsung",
"change_history": "Riwayat Perubahan",
"coming_soon": "Segera hadir",
"member": "Anggota",
"members": "Anggota",
"you": "Anda",
"upgrade_cta": {
@@ -1081,9 +1080,7 @@
"select": {
"error": "Silakan pilih setidaknya satu item kerja",
"empty": "Tidak ada item kerja yang dipilih",
"add_selected": "Tambah item kerja yang dipilih",
"select_all": "Pilih semua item kerja",
"deselect_all": "Batalkan pilihan semua item kerja"
"add_selected": "Tambah item kerja yang dipilih"
},
"open_in_full_screen": "Buka item kerja dalam layar penuh"
},
@@ -22,13 +22,6 @@
"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,7 +847,6 @@
"live": "Live",
"change_history": "Cronologia modifiche",
"coming_soon": "Prossimamente",
"member": "Membro",
"members": "Membri",
"you": "Tu",
"upgrade_cta": {
@@ -1080,9 +1079,7 @@
"select": {
"error": "Seleziona almeno un elemento di lavoro",
"empty": "Nessun elemento di lavoro selezionato",
"add_selected": "Aggiungi gli elementi di lavoro selezionati",
"select_all": "Seleziona tutto",
"deselect_all": "Deseleziona tutto"
"add_selected": "Aggiungi gli elementi di lavoro selezionati"
},
"open_in_full_screen": "Apri l'elemento di lavoro a schermo intero"
},
@@ -22,13 +22,6 @@
"collapse_sidebar": "サイドバーを折りたたむ",
"expand_sidebar": "サイドバーを展開",
"edition_badge": "有料プランのモーダルを開く"
},
"auth_forms": {
"clear_email": "メールをクリア",
"show_password": "パスワードを表示",
"hide_password": "パスワードを非表示",
"close_alert": "アラートを閉じる",
"close_popover": "ポップオーバーを閉じる"
}
}
}
}
@@ -849,7 +849,6 @@
"live": "ライブ",
"change_history": "変更履歴",
"coming_soon": "近日公開",
"member": "メンバー",
"members": "メンバー",
"you": "あなた",
"upgrade_cta": {
@@ -1082,9 +1081,7 @@
"select": {
"error": "少なくとも1つの作業項目を選択してください",
"empty": "作業項目が選択されていません",
"add_selected": "選択した作業項目を追加",
"select_all": "すべて選択",
"deselect_all": "すべての選択を解除"
"add_selected": "選択した作業項目を追加"
},
"open_in_full_screen": "作業項目をフルスクリーンで開く"
},
@@ -22,13 +22,6 @@
"collapse_sidebar": "사이드바 축소",
"expand_sidebar": "사이드바 확장",
"edition_badge": "유료 플랜 모달 열기"
},
"auth_forms": {
"clear_email": "이메일 지우기",
"show_password": "비밀번호 표시",
"hide_password": "비밀번호 숨기기",
"close_alert": "알림 닫기",
"close_popover": "팝오버 닫기"
}
}
}
}
@@ -850,7 +850,6 @@
"live": "라이브",
"change_history": "변경 기록",
"coming_soon": "곧 출시",
"member": "멤버",
"members": "멤버",
"you": "나",
"upgrade_cta": {
@@ -1083,9 +1082,7 @@
"select": {
"error": "최소 하나의 작업 항목을 선택하세요",
"empty": "선택된 작업 항목 없음",
"add_selected": "선택된 작업 항목 추가",
"select_all": "모두 선택",
"deselect_all": "모두 선택 해제"
"add_selected": "선택된 작업 항목 추가"
},
"open_in_full_screen": "작업 항목을 전체 화면으로 열기"
},
@@ -22,13 +22,6 @@
"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,7 +850,6 @@
"live": "Na żywo",
"change_history": "Historia zmian",
"coming_soon": "Wkrótce",
"member": "Członek",
"members": "Członkowie",
"you": "Ty",
"upgrade_cta": {
@@ -1083,9 +1082,7 @@
"select": {
"error": "Wybierz co najmniej jeden element pracy",
"empty": "Nie wybrano żadnych elementów pracy",
"add_selected": "Dodaj wybrane elementy pracy",
"select_all": "Wybierz wszystko",
"deselect_all": "Odznacz wszystko"
"add_selected": "Dodaj wybrane elementy pracy"
},
"open_in_full_screen": "Otwórz element pracy na pełnym ekranie"
},
@@ -22,13 +22,6 @@
"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,7 +850,6 @@
"live": "Ao vivo",
"change_history": "Histórico de alterações",
"coming_soon": "Em breve",
"member": "Membro",
"members": "Membros",
"you": "Você",
"upgrade_cta": {
@@ -1083,9 +1082,7 @@
"select": {
"error": "Selecione pelo menos um item de trabalho",
"empty": "Nenhum item de trabalho selecionado",
"add_selected": "Adicionar itens de trabalho selecionados",
"select_all": "Selecionar tudo",
"deselect_all": "Desmarcar tudo"
"add_selected": "Adicionar itens de trabalho selecionados"
},
"open_in_full_screen": "Abrir item de trabalho em tela cheia"
},
@@ -22,13 +22,6 @@
"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,7 +848,6 @@
"live": "În direct",
"change_history": "Istoric modificări",
"coming_soon": "În curând",
"member": "Membru",
"members": "Membri",
"you": "Tu",
"upgrade_cta": {
@@ -1081,9 +1080,7 @@
"select": {
"error": "Selectează cel puțin o activitate",
"empty": "Nicio activitate selectată",
"add_selected": "Adaugă activitățile selectate",
"select_all": "Selectează tot",
"deselect_all": "Deselează tot"
"add_selected": "Adaugă activitățile selectate"
},
"open_in_full_screen": "Deschide activitatea pe tot ecranul"
},
@@ -22,13 +22,6 @@
"collapse_sidebar": "Свернуть боковую панель",
"expand_sidebar": "Развернуть боковую панель",
"edition_badge": "Открыть модал платных планов"
},
"auth_forms": {
"clear_email": "Очистить email",
"show_password": "Показать пароль",
"hide_password": "Скрыть пароль",
"close_alert": "Закрыть уведомление",
"close_popover": "Закрыть всплывающее окно"
}
}
}
}
@@ -850,7 +850,6 @@
"live": "В прямом эфире",
"change_history": "История изменений",
"coming_soon": "Скоро",
"member": "Участник",
"members": "Участники",
"you": "Вы",
"upgrade_cta": {
@@ -1083,9 +1082,7 @@
"select": {
"error": "Выберите хотя бы один рабочий элемент",
"empty": "Рабочие элементы не выбраны",
"add_selected": "Добавить выбранные рабочие элементы",
"select_all": "Выбрать все",
"deselect_all": "Снять выделение со всех"
"add_selected": "Добавить выбранные рабочие элементы"
},
"open_in_full_screen": "Открыть рабочий элемент в полном экране"
},
@@ -22,13 +22,6 @@
"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,7 +850,6 @@
"live": "Živé",
"change_history": "História zmien",
"coming_soon": "Už čoskoro",
"member": "Člen",
"members": "Členovia",
"you": "Vy",
"upgrade_cta": {
@@ -1083,9 +1082,7 @@
"select": {
"error": "Vyberte aspoň jednu pracovnú položku",
"empty": "Nie sú vybrané žiadne pracovné položky",
"add_selected": "Pridať vybrané pracovné položky",
"select_all": "Vybrať všetko",
"deselect_all": "Zrušiť výber všetkého"
"add_selected": "Pridať vybrané pracovné položky"
},
"open_in_full_screen": "Otvoriť pracovnú položku na celú obrazovku"
},
@@ -22,13 +22,6 @@
"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,7 +851,6 @@
"live": "Canlı",
"change_history": "Değişiklik Geçmişi",
"coming_soon": "Çok Yakında",
"member": "Üye",
"members": "Üyeler",
"you": "Siz",
"upgrade_cta": {
@@ -1084,9 +1083,7 @@
"select": {
"error": "Lütfen en az bir iş öğesi seçin",
"empty": "Hiç iş öğesi seçilmedi",
"add_selected": "Seçilen iş öğelerini ekle",
"select_all": "Tümünü seç",
"deselect_all": "Tümünü seçme"
"add_selected": "Seçilen iş öğelerini ekle"
},
"open_in_full_screen": "İş öğesini tam ekranda aç"
},
@@ -22,13 +22,6 @@
"collapse_sidebar": "Згорнути бічну панель",
"expand_sidebar": "Розгорнути бічну панель",
"edition_badge": "Відкрити модал платних планів"
},
"auth_forms": {
"clear_email": "Очистити email",
"show_password": "Показати пароль",
"hide_password": "Приховати пароль",
"close_alert": "Закрити сповіщення",
"close_popover": "Закрити спливаюче вікно"
}
}
}
}
@@ -850,7 +850,6 @@
"live": "Наживо",
"change_history": "Історія змін",
"coming_soon": "Незабаром",
"member": "Учасник",
"members": "Учасники",
"you": "Ви",
"upgrade_cta": {
@@ -1083,9 +1082,7 @@
"select": {
"error": "Виберіть принаймні одну робочу одиницю",
"empty": "Не вибрано жодної робочої одиниці",
"add_selected": "Додати вибрані робочі одиниці",
"select_all": "Вибрати всі",
"deselect_all": "Скасувати вибір усіх"
"add_selected": "Додати вибрані робочі одиниці"
},
"open_in_full_screen": "Відкрити робочу одиницю на повний екран"
},
@@ -22,13 +22,6 @@
"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,7 +849,6 @@
"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": {
@@ -1082,9 +1081,7 @@
"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",
"select_all": "Chọn tất cả",
"deselect_all": "Bỏ chọn tất cả"
"add_selected": "Thêm mục công việc đã chọn"
},
"open_in_full_screen": "Mở mục công việc trong chế độ toàn màn hình"
},
@@ -22,13 +22,6 @@
"collapse_sidebar": "折叠侧边栏",
"expand_sidebar": "展开侧边栏",
"edition_badge": "打开付费计划模态框"
},
"auth_forms": {
"clear_email": "清除邮箱",
"show_password": "显示密码",
"hide_password": "隐藏密码",
"close_alert": "关闭警告",
"close_popover": "关闭弹出框"
}
}
}
}
@@ -849,7 +849,6 @@
"live": "实时",
"change_history": "变更历史",
"coming_soon": "即将推出",
"member": "成员",
"members": "成员",
"you": "你",
"upgrade_cta": {
@@ -1082,9 +1081,7 @@
"select": {
"error": "请至少选择一个工作项",
"empty": "未选择工作项",
"add_selected": "添加所选工作项",
"select_all": "全选",
"deselect_all": "取消全选"
"add_selected": "添加所选工作项"
},
"open_in_full_screen": "在全屏中打开工作项"
},
@@ -22,13 +22,6 @@
"collapse_sidebar": "摺疊側邊欄",
"expand_sidebar": "展開側邊欄",
"edition_badge": "打開付費計劃模態框"
},
"auth_forms": {
"clear_email": "清除電子郵件",
"show_password": "顯示密碼",
"hide_password": "隱藏密碼",
"close_alert": "關閉警告",
"close_popover": "關閉彈出框"
}
}
}
}
@@ -850,7 +850,6 @@
"live": "即時",
"change_history": "變更歷史記錄",
"coming_soon": "即將推出",
"member": "成員",
"members": "成員",
"you": "您",
"upgrade_cta": {
@@ -1083,9 +1082,7 @@
"select": {
"error": "請至少選擇一個工作事項",
"empty": "未選擇工作事項",
"add_selected": "新增已選取的工作事項",
"select_all": "全選",
"deselect_all": "取消全選"
"add_selected": "新增已選取的工作事項"
},
"open_in_full_screen": "以全螢幕開啟工作事項"
},
-1
View File
@@ -6,7 +6,6 @@
"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,7 +469,6 @@ module.exports = {
plugins: [
require("tailwindcss-animate"),
require("@tailwindcss/typography"),
require("@tailwindcss/container-queries"),
function ({ addUtilities }) {
const newUtilities = {
// Mobile screens
+33 -14
View File
@@ -1,5 +1,14 @@
import { EUserProjectRoles } from "@plane/constants";
import type { IUser, IUserLite, IWorkspace, TLogoProps, TStateGroups } from "..";
import type {
IProjectViewProps,
IUser,
IUserLite,
IUserMemberLite,
IWorkspace,
IWorkspaceLite,
TLogoProps,
TStateGroups,
} from "..";
import { TUserPermissions } from "../enums";
export interface IPartialProject {
@@ -82,20 +91,30 @@ export interface IProjectMemberLite {
member_id: string;
}
export type TProjectMembership = {
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;
member: string;
role: TUserPermissions | EUserProjectRoles;
created_at: string;
} & (
| {
id: string;
original_role: EUserProjectRoles;
}
| {
id: null;
original_role: null;
}
);
role: TUserPermissions;
}
export interface IProjectBulkAddFormData {
members: { role: TUserPermissions | EUserProjectRoles; member_id: string }[];
-3
View File
@@ -12,7 +12,6 @@ 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
@@ -79,8 +78,6 @@ 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, TProjectMembership, IUser, IUserLite, IWorkspaceViewProps, TPaginationInfo } from "@plane/types";
import type { ICycle, IProjectMember, 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?: TProjectMembership[];
project_details?: IProjectMember[];
}
export interface IWorkspaceDefaultSearchResult {
+6 -4
View File
@@ -1,17 +1,19 @@
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 "./subscription";
export * from "./theme";
export * from "./workspace";
export * from "./work-item";
export * from "./workspace";
export * from "./get-icon-for-link";
export * from "./subscription";
-13
View File
@@ -1,13 +0,0 @@
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,7 +29,6 @@ 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.29",
"next": "^14.2.28",
"next-themes": "^0.2.1",
"nprogress": "^0.2.0",
"react": "^18.3.1",
@@ -1,11 +1,20 @@
"use client";
import { CommandPalette } from "@/components/command-palette";
import dynamic from "next/dynamic";
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}/settings/projects/${projectId}/features`);
router.push(`/${workspaceSlug}/projects/${projectId}/settings/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}/settings/projects/${projectId}/features`);
router.push(`/${workspaceSlug}/projects/${projectId}/settings/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}/settings/projects/${projectId}/features`);
router.push(`/${workspaceSlug}/projects/${projectId}/settings/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}/settings/projects/${projectId}/features`);
router.push(`/${workspaceSlug}/projects/${projectId}/settings/features`);
},
disabled: !canPerformEmptyStateActions,
}}
@@ -13,7 +13,6 @@ import { NotAuthorizedView } from "@/components/auth-screens";
import { AutoArchiveAutomation, AutoCloseAutomation } from "@/components/automation";
import { PageHead } from "@/components/core";
// hooks
import { SettingsContentWrapper, SettingsHeading } from "@/components/settings";
import { useProject, useUserPermissions } from "@/hooks/store";
const AutomationSettingsPage = observer(() => {
@@ -44,21 +43,20 @@ const AutomationSettingsPage = observer(() => {
const pageTitle = projectDetails?.name ? `${projectDetails?.name} - Automations` : undefined;
if (workspaceUserInfo && !canPerformProjectAdminActions) {
return <NotAuthorizedView section="settings" isProjectView className="h-auto" />;
return <NotAuthorizedView section="settings" isProjectView />;
}
return (
<SettingsContentWrapper>
<>
<PageHead title={pageTitle} />
<section className={`w-full ${canPerformProjectAdminActions ? "" : "opacity-60"}`}>
<SettingsHeading
title={t("project_settings.automations.heading")}
description={t("project_settings.automations.description")}
/>
<section className={`w-full overflow-y-auto ${canPerformProjectAdminActions ? "" : "opacity-60"}`}>
<div className="flex flex-col items-start border-b border-custom-border-100 pb-3.5">
<h3 className="text-xl font-medium leading-normal">{t("project_settings.automations.label")}</h3>
</div>
<AutoArchiveAutomation handleChange={handleChange} />
<AutoCloseAutomation handleChange={handleChange} />
</section>
</SettingsContentWrapper>
</>
);
});
@@ -8,7 +8,6 @@ import { NotAuthorizedView } from "@/components/auth-screens";
import { PageHead } from "@/components/core";
import { EstimateRoot } from "@/components/estimates";
// hooks
import { SettingsContentWrapper } from "@/components/settings";
import { useProject, useUserPermissions } from "@/hooks/store";
const EstimatesSettingsPage = observer(() => {
@@ -24,20 +23,22 @@ const EstimatesSettingsPage = observer(() => {
if (!workspaceSlug || !projectId) return <></>;
if (workspaceUserInfo && !canPerformProjectAdminActions) {
return <NotAuthorizedView section="settings" isProjectView className="h-auto" />;
return <NotAuthorizedView section="settings" isProjectView />;
}
return (
<SettingsContentWrapper>
<>
<PageHead title={pageTitle} />
<div className={`w-full ${canPerformProjectAdminActions ? "" : "pointer-events-none opacity-60"}`}>
<div
className={`w-full overflow-y-auto ${canPerformProjectAdminActions ? "" : "pointer-events-none opacity-60"}`}
>
<EstimateRoot
workspaceSlug={workspaceSlug?.toString()}
projectId={projectId?.toString()}
isAdmin={canPerformProjectAdminActions}
/>
</div>
</SettingsContentWrapper>
</>
);
});
@@ -8,7 +8,6 @@ import { NotAuthorizedView } from "@/components/auth-screens";
import { PageHead } from "@/components/core";
import { ProjectFeaturesList } from "@/components/project";
// hooks
import { SettingsContentWrapper } from "@/components/settings";
import { useProject, useUserPermissions } from "@/hooks/store";
const FeaturesSettingsPage = observer(() => {
@@ -24,20 +23,20 @@ const FeaturesSettingsPage = observer(() => {
if (!workspaceSlug || !projectId) return null;
if (workspaceUserInfo && !canPerformProjectAdminActions) {
return <NotAuthorizedView section="settings" isProjectView className="h-auto" />;
return <NotAuthorizedView section="settings" isProjectView />;
}
return (
<SettingsContentWrapper>
<>
<PageHead title={pageTitle} />
<section className={`w-full ${canPerformProjectAdminActions ? "" : "opacity-60"}`}>
<section className={`w-full overflow-y-auto ${canPerformProjectAdminActions ? "" : "opacity-60"}`}>
<ProjectFeaturesList
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
isAdmin={canPerformProjectAdminActions}
/>
</section>
</SettingsContentWrapper>
</>
);
});
@@ -10,7 +10,6 @@ import { NotAuthorizedView } from "@/components/auth-screens";
import { PageHead } from "@/components/core";
import { ProjectSettingsLabelList } from "@/components/labels";
// hooks
import { SettingsContentWrapper } from "@/components/settings";
import { useProject, useUserPermissions } from "@/hooks/store";
const LabelsSettingsPage = observer(() => {
@@ -39,19 +38,19 @@ const LabelsSettingsPage = observer(() => {
element,
})
);
}, []);
}, [scrollableContainerRef?.current]);
if (workspaceUserInfo && !canPerformProjectMemberActions) {
return <NotAuthorizedView section="settings" isProjectView className="h-auto" />;
return <NotAuthorizedView section="settings" isProjectView />;
}
return (
<SettingsContentWrapper>
<>
<PageHead title={pageTitle} />
<div ref={scrollableContainerRef} className="h-full w-full gap-10">
<div ref={scrollableContainerRef} className="h-full w-full gap-10 overflow-y-auto">
<ProjectSettingsLabelList />
</div>
</SettingsContentWrapper>
</>
);
});
@@ -0,0 +1,33 @@
"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,32 +1,19 @@
"use client";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// plane imports
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
// components
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { NotAuthorizedView } from "@/components/auth-screens";
import { PageHead } from "@/components/core";
import { ProjectMemberList, ProjectSettingsMemberDefaults } from "@/components/project";
// hooks
import { SettingsContentWrapper, SettingsHeading } from "@/components/settings";
import { useProject, useUserPermissions } from "@/hooks/store";
// plane web imports
import { ProjectTeamspaceList } from "@/plane-web/components/projects/teamspaces";
import { getProjectSettingsPageLabelI18nKey } from "@/plane-web/helpers/project-settings";
const MembersSettingsPage = observer(() => {
// router
const { workspaceSlug: routerWorkspaceSlug, projectId: routerProjectId } = useParams();
// plane hooks
const { t } = useTranslation();
// store hooks
// store
const { currentProjectDetails } = useProject();
const { workspaceUserInfo, allowPermissions } = useUserPermissions();
// derived values
const projectId = routerProjectId?.toString();
const workspaceSlug = routerWorkspaceSlug?.toString();
const pageTitle = currentProjectDetails?.name ? `${currentProjectDetails?.name} - Members` : undefined;
const isProjectMemberOrAdmin = allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
@@ -36,17 +23,17 @@ const MembersSettingsPage = observer(() => {
const canPerformProjectMemberActions = isProjectMemberOrAdmin || isWorkspaceAdmin;
if (workspaceUserInfo && !canPerformProjectMemberActions) {
return <NotAuthorizedView section="settings" isProjectView className="h-auto" />;
return <NotAuthorizedView section="settings" isProjectView />;
}
return (
<SettingsContentWrapper size="lg">
<>
<PageHead title={pageTitle} />
<SettingsHeading title={t(getProjectSettingsPageLabelI18nKey("members", "common.members"))} />
<ProjectSettingsMemberDefaults projectId={projectId} workspaceSlug={workspaceSlug} />
<ProjectTeamspaceList projectId={projectId} workspaceSlug={workspaceSlug} />
<ProjectMemberList projectId={projectId} workspaceSlug={workspaceSlug} />
</SettingsContentWrapper>
<section className={`w-full overflow-y-auto`}>
<ProjectSettingsMemberDefaults />
<ProjectMemberList />
</section>
</>
);
});
@@ -16,9 +16,9 @@ import {
ProjectDetailsFormLoader,
} from "@/components/project";
// hooks
import { SettingsContentWrapper } from "@/components/settings";
import { useProject, useUserPermissions } from "@/hooks/store";
const ProjectSettingsPage = observer(() => {
const GeneralSettingsPage = observer(() => {
// states
const [selectProject, setSelectedProject] = useState<string | null>(null);
const [archiveProject, setArchiveProject] = useState<boolean>(false);
@@ -45,7 +45,7 @@ const ProjectSettingsPage = observer(() => {
const pageTitle = currentProjectDetails?.name ? `${currentProjectDetails?.name} - General Settings` : undefined;
return (
<SettingsContentWrapper>
<>
<PageHead title={pageTitle} />
{currentProjectDetails && workspaceSlug && projectId && (
<>
@@ -64,7 +64,7 @@ const ProjectSettingsPage = observer(() => {
</>
)}
<div className={`w-full ${isAdmin ? "" : "opacity-60"}`}>
<div className={`w-full overflow-y-auto ${isAdmin ? "" : "opacity-60"}`}>
{currentProjectDetails && workspaceSlug && projectId && !isLoading ? (
<ProjectDetailsForm
project={currentProjectDetails}
@@ -89,8 +89,8 @@ const ProjectSettingsPage = observer(() => {
</>
)}
</div>
</SettingsContentWrapper>
</>
);
});
export default ProjectSettingsPage;
export default GeneralSettingsPage;
@@ -0,0 +1,73 @@
"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>
);
});
@@ -9,7 +9,6 @@ import { NotAuthorizedView } from "@/components/auth-screens";
import { PageHead } from "@/components/core";
import { ProjectStateRoot } from "@/components/project-states";
// hook
import { SettingsContentWrapper, SettingsHeading } from "@/components/settings";
import { useProject, useUserPermissions } from "@/hooks/store";
const StatesSettingsPage = observer(() => {
@@ -29,22 +28,19 @@ const StatesSettingsPage = observer(() => {
);
if (workspaceUserInfo && !canPerformProjectMemberActions) {
return <NotAuthorizedView section="settings" isProjectView className="h-auto" />;
return <NotAuthorizedView section="settings" isProjectView />;
}
return (
<SettingsContentWrapper>
<>
<PageHead title={pageTitle} />
<div className="w-full">
<SettingsHeading
title={t("project_settings.states.heading")}
description={t("project_settings.states.description")}
/>
{workspaceSlug && projectId && (
<ProjectStateRoot workspaceSlug={workspaceSlug.toString()} projectId={projectId.toString()} />
)}
<div className="flex items-center border-b border-custom-border-100">
<h3 className="text-xl font-medium">{t("common.states")}</h3>
</div>
</SettingsContentWrapper>
{workspaceSlug && projectId && (
<ProjectStateRoot workspaceSlug={workspaceSlug.toString()} projectId={projectId.toString()} />
)}
</>
);
});
@@ -0,0 +1,79 @@
"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}/settings/projects/${projectId}/features`);
router.push(`/${workspaceSlug}/projects/${projectId}/settings/features`);
},
disabled: !canPerformEmptyStateActions,
}}
@@ -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" className="h-auto" />;
return <NotAuthorizedView section="settings" />;
}
if (!tokens) {
@@ -56,20 +56,18 @@ const ApiTokensPage = observer(() => {
}
return (
<div className="w-full">
<>
<PageHead title={pageTitle} />
<CreateApiTokenModal isOpen={isCreateTokenModalOpen} onClose={() => setIsCreateTokenModalOpen(false)} />
<section className="w-full">
<section className="w-full overflow-y-auto">
{tokens.length > 0 ? (
<>
<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="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>
<div>
{tokens.map((token) => (
<ApiTokenListItem key={token.id} token={token} />
@@ -78,31 +76,23 @@ const ApiTokensPage = observer(() => {
</>
) : (
<div className="flex h-full w-full flex-col">
<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="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>
<div className="h-full w-full flex items-center justify-center">
<DetailedEmptyState
title=""
description=""
title={t("workspace_settings.empty_state.api_tokens.title")}
description={t("workspace_settings.empty_state.api_tokens.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>
</>
);
});
@@ -6,7 +6,6 @@ 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";
@@ -20,14 +19,14 @@ const BillingSettingsPage = observer(() => {
const pageTitle = currentWorkspace?.name ? `${currentWorkspace.name} - Billing & Plans` : undefined;
if (workspaceUserInfo && !canPerformWorkspaceAdminActions) {
return <NotAuthorizedView section="settings" className="h-auto" />;
return <NotAuthorizedView section="settings" />;
}
return (
<SettingsContentWrapper size="lg">
<>
<PageHead title={pageTitle} />
<BillingRoot />
</SettingsContentWrapper>
</>
);
});
@@ -8,7 +8,6 @@ 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";
@@ -30,24 +29,23 @@ const ExportsPage = observer(() => {
// if user is not authorized to view this page
if (workspaceUserInfo && !canPerformWorkspaceMemberActions) {
return <NotAuthorizedView section="settings" className="h-auto" />;
return <NotAuthorizedView section="settings" />;
}
return (
<SettingsContentWrapper size="lg">
<>
<PageHead title={pageTitle} />
<div
className={cn("w-full", {
className={cn("w-full overflow-y-auto", {
"opacity-60": !canPerformWorkspaceMemberActions,
})}
>
<SettingsHeading
title={t("workspace_settings.settings.exports.heading")}
description={t("workspace_settings.settings.exports.description")}
/>
<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>
<ExportGuide />
</div>
</SettingsContentWrapper>
</>
);
});
@@ -3,32 +3,40 @@
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 <NotAuthorizedView section="settings" className="h-auto" />;
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>
</>
);
return (
<SettingsContentWrapper size="lg">
<>
<PageHead title={pageTitle} />
<section className="w-full">
<SettingsHeading title="Imports" />
<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>
<IntegrationGuide />
</section>
</SettingsContentWrapper>
</>
);
});
@@ -4,10 +4,8 @@ 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";
@@ -28,14 +26,23 @@ 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" />
@@ -49,7 +56,7 @@ const WorkspaceIntegrationsPage = observer(() => {
)}
</div>
</section>
</SettingsContentWrapper>
</>
);
});
@@ -0,0 +1,63 @@
"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;
@@ -14,7 +14,6 @@ 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";
@@ -96,11 +95,11 @@ const WorkspaceMembersSettingsPage = observer(() => {
// if user is not authorized to view this page
if (workspaceUserInfo && !canPerformWorkspaceMemberActions) {
return <NotAuthorizedView section="settings" className="h-auto" />;
return <NotAuthorizedView section="settings" />;
}
return (
<SettingsContentWrapper size="lg">
<>
<PageHead title={pageTitle} />
<SendWorkspaceInvitationModal
isOpen={inviteModal}
@@ -108,7 +107,7 @@ const WorkspaceMembersSettingsPage = observer(() => {
onSubmit={handleWorkspaceInvite}
/>
<section
className={cn("w-full h-full", {
className={cn("w-full h-full overflow-y-auto", {
"opacity-60": !canPerformWorkspaceMemberActions,
})}
>
@@ -138,7 +137,7 @@ const WorkspaceMembersSettingsPage = observer(() => {
</div>
<WorkspaceMembersList searchQuery={searchQuery} isAdmin={canPerformWorkspaceAdminActions} />
</section>
</SettingsContentWrapper>
</>
);
});
@@ -4,7 +4,6 @@ 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";
@@ -19,10 +18,10 @@ const WorkspaceSettingsPage = observer(() => {
: undefined;
return (
<SettingsContentWrapper>
<>
<PageHead title={pageTitle} />
<WorkspaceDetails />
</SettingsContentWrapper>
</>
);
});
@@ -0,0 +1,48 @@
"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>
);
});
@@ -11,7 +11,6 @@ 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";
@@ -88,7 +87,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">
@@ -97,7 +96,7 @@ const WebhookDetailsPage = observer(() => {
</div>
{currentWebhook && <WebhookDeleteSection openDeleteModal={() => setDeleteWebhookModal(true)} />}
</div>
</SettingsContentWrapper>
</>
);
});

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