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
236 changed files with 2122 additions and 4353 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/
+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)
@@ -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,19 +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)
@@ -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}",
}
-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.
-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 = {
@@ -1,14 +0,0 @@
import { Editor } from "@tiptap/core";
import { LinkViewContainer } from "@/components/editors/link-view-container";
export const LinkContainer = ({
editor,
containerRef,
}: {
editor: Editor;
containerRef: React.RefObject<HTMLDivElement>;
}) => (
<>
<LinkViewContainer editor={editor} containerRef={containerRef} />
</>
);
@@ -91,7 +91,6 @@ const CollaborativeDocumentEditor = (props: ICollaborativeDocumentEditor) => {
editorContainerClassName={cn(editorContainerClassNames, "document-editor")}
id={id}
tabIndex={tabIndex}
disabledExtensions={disabledExtensions}
/>
);
};
@@ -3,7 +3,7 @@ import { Editor } from "@tiptap/react";
import { EditorContainer, EditorContentWrapper } from "@/components/editors";
import { AIFeaturesMenu, BlockMenu, EditorBubbleMenu } from "@/components/menus";
// types
import { TAIHandler, TDisplayConfig, TExtensions } from "@/types";
import { TAIHandler, TDisplayConfig } from "@/types";
type IPageRenderer = {
aiHandler?: TAIHandler;
@@ -13,20 +13,10 @@ type IPageRenderer = {
editorContainerClassName: string;
id: string;
tabIndex?: number;
disabledExtensions: TExtensions[];
};
export const PageRenderer = (props: IPageRenderer) => {
const {
aiHandler,
bubbleMenuEnabled,
displayConfig,
editor,
editorContainerClassName,
id,
tabIndex,
disabledExtensions,
} = props;
const { aiHandler, bubbleMenuEnabled, displayConfig, editor, editorContainerClassName, id, tabIndex } = props;
return (
<div className="frame-renderer flex-grow w-full">
@@ -40,7 +30,7 @@ export const PageRenderer = (props: IPageRenderer) => {
{editor.isEditable && (
<div>
{bubbleMenuEnabled && <EditorBubbleMenu editor={editor} />}
<BlockMenu editor={editor} disabledExtensions={disabledExtensions} />
<BlockMenu editor={editor} />
<AIFeaturesMenu menu={aiHandler?.menu} />
</div>
)}
@@ -83,7 +83,6 @@ const DocumentReadOnlyEditor = (props: IDocumentReadOnlyEditor) => {
editor={editor}
editorContainerClassName={cn(editorContainerClassName, "document-editor")}
id={id}
disabledExtensions={disabledExtensions}
/>
);
};
@@ -9,7 +9,6 @@ import { CORE_EXTENSIONS } from "@/constants/extension";
import { TDisplayConfig } from "@/types";
// components
import { LinkViewContainer } from "./link-view-container";
import { LinkContainer } from "@/plane-editor/components/link-container";
interface EditorContainerProps {
children: ReactNode;
@@ -97,7 +96,7 @@ export const EditorContainer: FC<EditorContainerProps> = (props) => {
)}
>
{children}
<LinkContainer editor={editor} containerRef={containerRef} />
<LinkViewContainer editor={editor} containerRef={containerRef} />
</div>
</>
);
@@ -3,7 +3,6 @@ import { Editor, useEditorState } from "@tiptap/react";
import { FC, useCallback, useEffect, useState } from "react";
// components
import { LinkView, LinkViewProps } from "@/components/links";
import { getExtensionStorage } from "@/helpers/get-extension-storage";
interface LinkViewContainerProps {
editor: Editor;
@@ -18,7 +17,7 @@ export const LinkViewContainer: FC<LinkViewContainerProps> = ({ editor, containe
const editorState = useEditorState({
editor,
selector: ({ editor }: { editor: Editor }) => ({
linkExtensionStorage: getExtensionStorage(editor, "link"),
linkExtensionStorage: editor.storage.link,
}),
});
@@ -110,7 +109,7 @@ export const LinkViewContainer: FC<LinkViewContainerProps> = ({ editor, containe
// Close link view when bubble menu opens
useEffect(() => {
if (editorState.linkExtensionStorage?.isBubbleMenuOpen && isOpen) {
if (editorState.linkExtensionStorage.isBubbleMenuOpen && isOpen) {
setIsOpen(false);
}
}, [editorState.linkExtensionStorage, isOpen]);
@@ -4,11 +4,9 @@ import { useCallback, useEffect, useRef } from "react";
import tippy, { Instance } from "tippy.js";
// constants
import { CORE_EXTENSIONS } from "@/constants/extension";
import { TExtensions } from "@/types";
interface BlockMenuProps {
editor: Editor;
disabledExtensions?: TExtensions[];
}
export const BlockMenu = (props: BlockMenuProps) => {
@@ -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,
@@ -79,7 +79,6 @@ declare module "@tiptap/core" {
export type CustomLinkStorage = {
isPreviewOpen: boolean;
posToInsert: { from: number; to: number };
isBubbleMenuOpen: boolean;
};
export const CustomLinkExtension = Mark.create<LinkOptions, CustomLinkStorage>({
+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;
@@ -0,0 +1,40 @@
"use client";
import { observer } from "mobx-react";
// 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 { useProject, useUserPermissions } from "@/hooks/store";
const MembersSettingsPage = observer(() => {
// store
const { currentProjectDetails } = useProject();
const { workspaceUserInfo, allowPermissions } = useUserPermissions();
// derived values
const pageTitle = currentProjectDetails?.name ? `${currentProjectDetails?.name} - Members` : undefined;
const isProjectMemberOrAdmin = allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
EUserPermissionsLevel.PROJECT
);
const isWorkspaceAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE);
const canPerformProjectMemberActions = isProjectMemberOrAdmin || isWorkspaceAdmin;
if (workspaceUserInfo && !canPerformProjectMemberActions) {
return <NotAuthorizedView section="settings" isProjectView />;
}
return (
<>
<PageHead title={pageTitle} />
<section className={`w-full overflow-y-auto`}>
<ProjectSettingsMemberDefaults />
<ProjectMemberList />
</section>
</>
);
});
export default MembersSettingsPage;
@@ -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>
</>
);
});

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