Compare commits
41
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
73d5a80c38 | ||
|
|
92cdc4ecbb | ||
|
|
b88ae112f9 | ||
|
|
2d20278c9b | ||
|
|
8cff059868 | ||
|
|
6a3ccafe35 | ||
|
|
cc9b448a9b | ||
|
|
e071bf4861 | ||
|
|
b9da7df6b7 | ||
|
|
03cc819601 | ||
|
|
e1943ee11e | ||
|
|
b47d2b8825 | ||
|
|
300b47f9a1 | ||
|
|
03a4a97375 | ||
|
|
6157d5771d | ||
|
|
eee43be99a | ||
|
|
4db95cc941 | ||
|
|
6aa139a851 | ||
|
|
ac74cd9e92 | ||
|
|
7ae841d525 | ||
|
|
7aa5b6aa91 | ||
|
|
28c3f9d0cc | ||
|
|
9d01a6d5d7 | ||
|
|
4fd8b4a3a9 | ||
|
|
49cc73b6ed | ||
|
|
363507f987 | ||
|
|
30453d1c79 | ||
|
|
dff12729c0 | ||
|
|
8efe692c80 | ||
|
|
ce57c1423c | ||
|
|
1eb1e82fe4 | ||
|
|
a2328d0cbe | ||
|
|
5096a15051 | ||
|
|
55c2511ab5 | ||
|
|
16bc64e2fa | ||
|
|
14083ea7da | ||
|
|
feb88e64a4 | ||
|
|
a00bb35e54 | ||
|
|
20ba91b98c | ||
|
|
456c7f55a9 | ||
|
|
c2da3ea4c8 |
@@ -25,6 +25,10 @@ on:
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
push:
|
||||
branches:
|
||||
- preview
|
||||
- canary
|
||||
|
||||
env:
|
||||
TARGET_BRANCH: ${{ github.ref_name }}
|
||||
|
||||
@@ -71,7 +71,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18.x
|
||||
node-version: 20.x
|
||||
- run: yarn install
|
||||
- run: yarn lint --filter=admin
|
||||
|
||||
@@ -84,7 +84,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18.x
|
||||
node-version: 20.x
|
||||
- run: yarn install
|
||||
- run: yarn lint --filter=space
|
||||
|
||||
@@ -97,7 +97,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18.x
|
||||
node-version: 20.x
|
||||
- run: yarn install
|
||||
- run: yarn lint --filter=web
|
||||
|
||||
@@ -109,7 +109,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18.x
|
||||
node-version: 20.x
|
||||
- run: yarn install
|
||||
- run: yarn build --filter=admin
|
||||
|
||||
@@ -121,7 +121,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18.x
|
||||
node-version: 20.x
|
||||
- run: yarn install
|
||||
- run: yarn build --filter=space
|
||||
|
||||
@@ -133,6 +133,6 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18.x
|
||||
node-version: 20.x
|
||||
- run: yarn install
|
||||
- run: yarn build --filter=web
|
||||
|
||||
@@ -32,10 +32,9 @@ from django.core.exceptions import ValidationError
|
||||
|
||||
|
||||
class WorkSpaceSerializer(DynamicBaseSerializer):
|
||||
owner = UserLiteSerializer(read_only=True)
|
||||
total_members = serializers.IntegerField(read_only=True)
|
||||
total_issues = serializers.IntegerField(read_only=True)
|
||||
logo_url = serializers.CharField(read_only=True)
|
||||
role = serializers.IntegerField(read_only=True)
|
||||
|
||||
def validate_slug(self, value):
|
||||
# Check if the slug is restricted
|
||||
@@ -147,6 +146,42 @@ class WorkspaceUserLinkSerializer(BaseSerializer):
|
||||
return value
|
||||
|
||||
|
||||
def create(self, validated_data):
|
||||
# Filtering the WorkspaceUserLink with the given url to check if the link already exists.
|
||||
|
||||
url = validated_data.get("url")
|
||||
|
||||
workspace_user_link = WorkspaceUserLink.objects.filter(
|
||||
url=url,
|
||||
workspace_id=validated_data.get("workspace_id"),
|
||||
owner_id=validated_data.get("owner_id")
|
||||
)
|
||||
|
||||
if workspace_user_link.exists():
|
||||
raise serializers.ValidationError(
|
||||
{"error": "URL already exists for this workspace and owner"}
|
||||
)
|
||||
|
||||
return super().create(validated_data)
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
# Filtering the WorkspaceUserLink with the given url to check if the link already exists.
|
||||
|
||||
url = validated_data.get("url")
|
||||
|
||||
workspace_user_link = WorkspaceUserLink.objects.filter(
|
||||
url=url,
|
||||
workspace_id=instance.workspace_id,
|
||||
owner=instance.owner
|
||||
)
|
||||
|
||||
if workspace_user_link.exclude(pk=instance.id).exists():
|
||||
raise serializers.ValidationError(
|
||||
{"error": "URL already exists for this workspace and owner"}
|
||||
)
|
||||
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
class IssueRecentVisitSerializer(serializers.ModelSerializer):
|
||||
project_identifier = serializers.SerializerMethodField()
|
||||
|
||||
|
||||
@@ -134,11 +134,11 @@ class CycleViewSet(BaseViewSet):
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
pending_issues=Count(
|
||||
cancelled_issues=Count(
|
||||
"issue_cycle__issue__id",
|
||||
distinct=True,
|
||||
filter=Q(
|
||||
issue_cycle__issue__state__group__in=["backlog", "unstarted", "started"],
|
||||
issue_cycle__issue__state__group__in=["cancelled"],
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
issue_cycle__deleted_at__isnull=True,
|
||||
@@ -227,7 +227,7 @@ class CycleViewSet(BaseViewSet):
|
||||
"is_favorite",
|
||||
"total_issues",
|
||||
"completed_issues",
|
||||
"pending_issues",
|
||||
"cancelled_issues",
|
||||
"assignee_ids",
|
||||
"status",
|
||||
"version",
|
||||
@@ -259,7 +259,7 @@ class CycleViewSet(BaseViewSet):
|
||||
# meta fields
|
||||
"is_favorite",
|
||||
"total_issues",
|
||||
"pending_issues",
|
||||
"cancelled_issues",
|
||||
"completed_issues",
|
||||
"assignee_ids",
|
||||
"status",
|
||||
|
||||
@@ -35,7 +35,9 @@ class LabelViewSet(BaseViewSet):
|
||||
.order_by("sort_order")
|
||||
)
|
||||
|
||||
@invalidate_cache(path="/api/workspaces/:slug/labels/", url_params=True, user=False)
|
||||
@invalidate_cache(
|
||||
path="/api/workspaces/:slug/labels/", url_params=True, user=False, multiple=True
|
||||
)
|
||||
@allow_permission([ROLE.ADMIN])
|
||||
def create(self, request, slug, project_id):
|
||||
try:
|
||||
|
||||
@@ -272,10 +272,9 @@ class IssueRelationViewSet(BaseViewSet):
|
||||
|
||||
issue_relations = IssueRelation.objects.filter(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
).filter(
|
||||
Q(issue_id=related_issue, related_issue_id=issue_id) |
|
||||
Q(issue_id=issue_id, related_issue_id=related_issue)
|
||||
Q(issue_id=related_issue, related_issue_id=issue_id)
|
||||
| Q(issue_id=issue_id, related_issue_id=related_issue)
|
||||
)
|
||||
issue_relations = issue_relations.first()
|
||||
current_instance = json.dumps(
|
||||
|
||||
@@ -40,7 +40,7 @@ from ..base import BaseAPIView, BaseViewSet
|
||||
from plane.bgtasks.page_transaction_task import page_transaction
|
||||
from plane.bgtasks.page_version_task import page_version
|
||||
from plane.bgtasks.recent_visited_task import recent_visited_task
|
||||
|
||||
from plane.bgtasks.copy_s3_object import copy_s3_objects
|
||||
|
||||
def unarchive_archive_page_and_descendants(page_id, archived_at):
|
||||
# Your SQL query
|
||||
@@ -597,6 +597,16 @@ class PageDuplicateEndpoint(BaseAPIView):
|
||||
page_transaction.delay(
|
||||
{"description_html": page.description_html}, None, page.id
|
||||
)
|
||||
|
||||
# Copy the s3 objects uploaded in the page
|
||||
copy_s3_objects.delay(
|
||||
entity_name="PAGE",
|
||||
entity_identifier=page.id,
|
||||
project_id=project_id,
|
||||
slug=slug,
|
||||
user_id=request.user.id,
|
||||
)
|
||||
|
||||
page = (
|
||||
Page.objects.filter(pk=page.id)
|
||||
.annotate(
|
||||
|
||||
@@ -7,9 +7,11 @@ from datetime import date
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from django.db import IntegrityError
|
||||
from django.db.models import Count, F, Func, OuterRef, Prefetch, Q
|
||||
|
||||
from django.db.models.fields import DateField
|
||||
from django.db.models.functions import Cast, ExtractDay, ExtractWeek
|
||||
|
||||
|
||||
# Django imports
|
||||
from django.http import HttpResponse
|
||||
from django.utils import timezone
|
||||
@@ -62,12 +64,6 @@ class WorkSpaceViewSet(BaseViewSet):
|
||||
.values("count")
|
||||
)
|
||||
|
||||
issue_count = (
|
||||
Issue.issue_objects.filter(workspace=OuterRef("id"))
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
return (
|
||||
self.filter_queryset(super().get_queryset().select_related("owner"))
|
||||
.order_by("name")
|
||||
@@ -76,8 +72,6 @@ class WorkSpaceViewSet(BaseViewSet):
|
||||
workspace_member__is_active=True,
|
||||
)
|
||||
.annotate(total_members=member_count)
|
||||
.annotate(total_issues=issue_count)
|
||||
.select_related("owner")
|
||||
)
|
||||
|
||||
def create(self, request):
|
||||
@@ -123,7 +117,14 @@ class WorkSpaceViewSet(BaseViewSet):
|
||||
role=20,
|
||||
company_role=request.data.get("company_role", ""),
|
||||
)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
# Get total members and role
|
||||
total_members=WorkspaceMember.objects.filter(workspace_id=serializer.data["id"]).count()
|
||||
data = serializer.data
|
||||
data["total_members"] = total_members
|
||||
data["role"] = 20
|
||||
|
||||
return Response(data, status=status.HTTP_201_CREATED)
|
||||
return Response(
|
||||
[serializer.errors[error][0] for error in serializer.errors],
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
@@ -166,11 +167,9 @@ class UserWorkSpacesEndpoint(BaseAPIView):
|
||||
.values("count")
|
||||
)
|
||||
|
||||
issue_count = (
|
||||
Issue.issue_objects.filter(workspace=OuterRef("id"))
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
role = (
|
||||
WorkspaceMember.objects.filter(workspace=OuterRef("id"), member=request.user, is_active=True)
|
||||
.values("role")
|
||||
)
|
||||
|
||||
workspace = (
|
||||
@@ -182,19 +181,19 @@ class UserWorkSpacesEndpoint(BaseAPIView):
|
||||
),
|
||||
)
|
||||
)
|
||||
.select_related("owner")
|
||||
.annotate(total_members=member_count)
|
||||
.annotate(total_issues=issue_count)
|
||||
.annotate(role=role, total_members=member_count)
|
||||
.filter(
|
||||
workspace_member__member=request.user, workspace_member__is_active=True
|
||||
)
|
||||
.distinct()
|
||||
)
|
||||
|
||||
workspaces = WorkSpaceSerializer(
|
||||
self.filter_queryset(workspace),
|
||||
fields=fields if fields else None,
|
||||
many=True,
|
||||
).data
|
||||
|
||||
return Response(workspaces, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ class QuickLinkViewSet(BaseViewSet):
|
||||
serializer = WorkspaceUserLinkSerializer(data=request.data)
|
||||
|
||||
if serializer.is_valid():
|
||||
serializer.save(workspace_id=workspace.id, owner=request.user)
|
||||
serializer.save(workspace_id=workspace.id, owner_id=request.user.id)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
# Python imports
|
||||
import uuid
|
||||
import base64
|
||||
import requests
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
# Django imports
|
||||
from django.conf import settings
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import FileAsset, Page, Issue
|
||||
from plane.utils.exception_logger import log_exception
|
||||
from plane.settings.storage import S3Storage
|
||||
from celery import shared_task
|
||||
|
||||
|
||||
def get_entity_id_field(entity_type, entity_id):
|
||||
entity_mapping = {
|
||||
FileAsset.EntityTypeContext.WORKSPACE_LOGO: {"workspace_id": entity_id},
|
||||
FileAsset.EntityTypeContext.PROJECT_COVER: {"project_id": entity_id},
|
||||
FileAsset.EntityTypeContext.USER_AVATAR: {"user_id": entity_id},
|
||||
FileAsset.EntityTypeContext.USER_COVER: {"user_id": entity_id},
|
||||
FileAsset.EntityTypeContext.ISSUE_ATTACHMENT: {"issue_id": entity_id},
|
||||
FileAsset.EntityTypeContext.ISSUE_DESCRIPTION: {"issue_id": entity_id},
|
||||
FileAsset.EntityTypeContext.PAGE_DESCRIPTION: {"page_id": entity_id},
|
||||
FileAsset.EntityTypeContext.COMMENT_DESCRIPTION: {"comment_id": entity_id},
|
||||
FileAsset.EntityTypeContext.DRAFT_ISSUE_DESCRIPTION: {
|
||||
"draft_issue_id": entity_id
|
||||
},
|
||||
}
|
||||
return entity_mapping.get(entity_type, {})
|
||||
|
||||
|
||||
def extract_asset_ids(html, tag):
|
||||
try:
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
return [tag.get("src") for tag in soup.find_all(tag) if tag.get("src")]
|
||||
except Exception as e:
|
||||
log_exception(e)
|
||||
return []
|
||||
|
||||
|
||||
def replace_asset_ids(html, tag, duplicated_assets):
|
||||
try:
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
for mention_tag in soup.find_all(tag):
|
||||
for asset in duplicated_assets:
|
||||
if mention_tag.get("src") == asset["old_asset_id"]:
|
||||
mention_tag["src"] = asset["new_asset_id"]
|
||||
return str(soup)
|
||||
except Exception as e:
|
||||
log_exception(e)
|
||||
return html
|
||||
|
||||
|
||||
def update_description(entity, duplicated_assets, tag):
|
||||
updated_html = replace_asset_ids(entity.description_html, tag, duplicated_assets)
|
||||
entity.description_html = updated_html
|
||||
entity.save()
|
||||
return updated_html
|
||||
|
||||
|
||||
# Get the description binary and description from the live server
|
||||
def sync_with_external_service(entity_name, description_html):
|
||||
try:
|
||||
data = {
|
||||
"description_html": description_html,
|
||||
"variant": "rich" if entity_name == "PAGE" else "document",
|
||||
}
|
||||
response = requests.post(
|
||||
f"{settings.LIVE_BASE_URL}/convert-document/",
|
||||
json=data,
|
||||
headers=None,
|
||||
)
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
except requests.RequestException as e:
|
||||
log_exception(e)
|
||||
return {}
|
||||
|
||||
|
||||
@shared_task
|
||||
def copy_s3_objects(entity_name, entity_identifier, project_id, slug, user_id):
|
||||
"""
|
||||
Step 1: Extract asset ids from the description_html of the entity
|
||||
Step 2: Duplicate the assets
|
||||
Step 3: Update the description_html of the entity with the new asset ids (change the src of img tag)
|
||||
Step 4: Request the live server to generate the description_binary and description for the entity
|
||||
|
||||
"""
|
||||
try:
|
||||
model_class = {"PAGE": Page, "ISSUE": Issue}.get(entity_name)
|
||||
if not model_class:
|
||||
raise ValueError(f"Unsupported entity_name: {entity_name}")
|
||||
|
||||
entity = model_class.objects.get(id=entity_identifier)
|
||||
asset_ids = extract_asset_ids(entity.description_html, "image-component")
|
||||
|
||||
duplicated_assets = []
|
||||
workspace = entity.workspace
|
||||
storage = S3Storage()
|
||||
original_assets = FileAsset.objects.filter(
|
||||
workspace=workspace, project_id=project_id, id__in=asset_ids
|
||||
)
|
||||
|
||||
for original_asset in original_assets:
|
||||
destination_key = f"{workspace.id}/{uuid.uuid4().hex}-{original_asset.attributes.get('name')}"
|
||||
duplicated_asset = FileAsset.objects.create(
|
||||
attributes={
|
||||
"name": original_asset.attributes.get("name"),
|
||||
"type": original_asset.attributes.get("type"),
|
||||
"size": original_asset.attributes.get("size"),
|
||||
},
|
||||
asset=destination_key,
|
||||
size=original_asset.size,
|
||||
workspace=workspace,
|
||||
created_by_id=user_id,
|
||||
entity_type=original_asset.entity_type,
|
||||
project_id=project_id,
|
||||
storage_metadata=original_asset.storage_metadata,
|
||||
**get_entity_id_field(original_asset.entity_type, entity_identifier),
|
||||
)
|
||||
storage.copy_object(original_asset.asset, destination_key)
|
||||
duplicated_assets.append(
|
||||
{
|
||||
"new_asset_id": str(duplicated_asset.id),
|
||||
"old_asset_id": str(original_asset.id),
|
||||
}
|
||||
)
|
||||
|
||||
if duplicated_assets:
|
||||
FileAsset.objects.filter(
|
||||
pk__in=[item["new_asset_id"] for item in duplicated_assets]
|
||||
).update(is_uploaded=True)
|
||||
updated_html = update_description(
|
||||
entity, duplicated_assets, "image-component"
|
||||
)
|
||||
external_data = sync_with_external_service(entity_name, updated_html)
|
||||
|
||||
if external_data:
|
||||
entity.description = external_data.get("description")
|
||||
entity.description_binary = base64.b64decode(
|
||||
external_data.get("description_binary")
|
||||
)
|
||||
entity.save()
|
||||
|
||||
return
|
||||
except Exception as e:
|
||||
log_exception(e)
|
||||
return []
|
||||
@@ -82,7 +82,10 @@ def soft_delete_related_objects(app_label, model_name, instance_pk, using=None):
|
||||
)
|
||||
else:
|
||||
# Handle other relationships
|
||||
related_queryset = getattr(instance, related_name).all()
|
||||
related_queryset = getattr(instance, related_name)(
|
||||
manager="objects"
|
||||
).all()
|
||||
|
||||
for related_obj in related_queryset:
|
||||
if hasattr(related_obj, "deleted_at"):
|
||||
if not related_obj.deleted_at:
|
||||
|
||||
@@ -336,6 +336,8 @@ CSRF_FAILURE_VIEW = "plane.authentication.views.common.csrf_failure"
|
||||
ADMIN_BASE_URL = os.environ.get("ADMIN_BASE_URL", None)
|
||||
SPACE_BASE_URL = os.environ.get("SPACE_BASE_URL", None)
|
||||
APP_BASE_URL = os.environ.get("APP_BASE_URL")
|
||||
LIVE_BASE_URL = os.environ.get("LIVE_BASE_URL")
|
||||
|
||||
|
||||
HARD_DELETE_AFTER_DAYS = int(os.environ.get("HARD_DELETE_AFTER_DAYS", 60))
|
||||
|
||||
|
||||
@@ -151,3 +151,17 @@ class S3Storage(S3Boto3Storage):
|
||||
"ETag": response.get("ETag"),
|
||||
"Metadata": response.get("Metadata", {}),
|
||||
}
|
||||
|
||||
def copy_object(self, object_name, new_object_name):
|
||||
"""Copy an S3 object to a new location"""
|
||||
try:
|
||||
response = self.s3_client.copy_object(
|
||||
Bucket=self.aws_storage_bucket_name,
|
||||
CopySource={"Bucket": self.aws_storage_bucket_name, "Key": object_name},
|
||||
Key=new_object_name,
|
||||
)
|
||||
except ClientError as e:
|
||||
log_exception(e)
|
||||
return None
|
||||
|
||||
return response
|
||||
|
||||
@@ -51,7 +51,7 @@ beautifulsoup4==4.12.3
|
||||
# analytics
|
||||
posthog==3.5.0
|
||||
# crypto
|
||||
cryptography==43.0.1
|
||||
cryptography==44.0.1
|
||||
# html validator
|
||||
lxml==5.2.1
|
||||
# s3
|
||||
|
||||
+1
-1
@@ -23,7 +23,7 @@
|
||||
"@plane/constants": "*",
|
||||
"@plane/editor": "*",
|
||||
"@plane/types": "*",
|
||||
"@sentry/node": "^8.28.0",
|
||||
"@sentry/node": "^9.0.1",
|
||||
"@sentry/profiling-node": "^8.28.0",
|
||||
"@tiptap/core": "2.10.4",
|
||||
"@tiptap/html": "2.11.0",
|
||||
|
||||
+5
-1
@@ -22,7 +22,11 @@
|
||||
"devDependencies": {
|
||||
"prettier": "latest",
|
||||
"prettier-plugin-tailwindcss": "^0.5.4",
|
||||
"turbo": "^2.3.3"
|
||||
"turbo": "^2.4.1"
|
||||
},
|
||||
"resolutions": {
|
||||
"nanoid": "3.3.8",
|
||||
"esbuild": "0.25.0"
|
||||
},
|
||||
"packageManager": "yarn@1.22.22",
|
||||
"name": "plane"
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export const LABEL_CLASSNAME = "uppercase text-custom-text-300/60 text-sm tracking-wide";
|
||||
export const AXIS_LINE_CLASSNAME = "text-custom-text-400/70";
|
||||
@@ -1,6 +1,7 @@
|
||||
export * from "./ai";
|
||||
export * from "./analytics";
|
||||
export * from "./auth";
|
||||
export * from "./chart";
|
||||
export * from "./endpoints";
|
||||
export * from "./file";
|
||||
export * from "./filter";
|
||||
|
||||
@@ -2,7 +2,6 @@ export const ISSUE_FORM_TAB_INDICES = [
|
||||
"name",
|
||||
"description_html",
|
||||
"feeling_lucky",
|
||||
"ai_assistant",
|
||||
"state_id",
|
||||
"priority",
|
||||
"assignee_ids",
|
||||
|
||||
@@ -51,6 +51,10 @@ export const ListKeymap = ({ tabIndex }: { tabIndex?: number }) =>
|
||||
} else if (this.editor.commands.liftListItem("taskItem")) {
|
||||
return true;
|
||||
}
|
||||
// if tabIndex is set, we don't want to handle Tab key
|
||||
if (tabIndex !== undefined && tabIndex !== null) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
Delete: ({ editor }) => {
|
||||
|
||||
@@ -376,6 +376,8 @@
|
||||
|
||||
"home": {
|
||||
"empty": {
|
||||
"quickstart_guide": "Your quickstart guide",
|
||||
"not_right_now": "Not right now",
|
||||
"create_project": {
|
||||
"title": "Create a project",
|
||||
"description": "Most things start with a project in Plane.",
|
||||
@@ -416,7 +418,7 @@
|
||||
"project": "Your recent projects will appear here once you visit one.",
|
||||
"page": "Your recent pages will appear here once you visit one.",
|
||||
"issue": "Your recent work items will appear here once you visit one.",
|
||||
"default": "You don't have any recent items yet."
|
||||
"default": "You don't have any recents yet."
|
||||
},
|
||||
"filters": {
|
||||
"all": "All items",
|
||||
@@ -1251,9 +1253,19 @@
|
||||
"company_size": "Company size",
|
||||
"url": "Workspace URL",
|
||||
"update_workspace": "Update workspace",
|
||||
"delete_workspace": "Delete workspace",
|
||||
"delete_workspace": "Delete this workspace",
|
||||
"delete_workspace_description": "When deleting a workspace, all of the data and resources within that workspace will be permanently removed and cannot be recovered.",
|
||||
"delete_btn": "Delete my workspace",
|
||||
"delete_btn": "Delete this workspace",
|
||||
"delete_modal": {
|
||||
"title": "Are you sure you want to delete this workspace?",
|
||||
"description": "You have an active trial to one of our paid plans. Please cancel it first to proceed.",
|
||||
"dismiss": "Dismiss",
|
||||
"cancel": "Cancel trial",
|
||||
"success_title": "Workspace deleted.",
|
||||
"success_message": "You will soon go to your profile page.",
|
||||
"error_title": "That didn't work.",
|
||||
"error_message": "Try again, please."
|
||||
},
|
||||
"errors": {
|
||||
"name": {
|
||||
"required": "Name is required",
|
||||
|
||||
@@ -546,6 +546,8 @@
|
||||
|
||||
"home": {
|
||||
"empty": {
|
||||
"quickstart_guide": "Guía de inicio rápido",
|
||||
"not_right_now": "Ahora no",
|
||||
"create_project": {
|
||||
"title": "Crear un proyecto",
|
||||
"description": "La mayoría de las cosas comienzan con un proyecto en Plane.",
|
||||
@@ -1420,9 +1422,19 @@
|
||||
"company_size": "Tamaño de la empresa",
|
||||
"url": "URL del espacio de trabajo",
|
||||
"update_workspace": "Actualizar espacio de trabajo",
|
||||
"delete_workspace": "Eliminar espacio de trabajo",
|
||||
"delete_workspace_description": "Al eliminar un espacio de trabajo, todos los datos y recursos dentro de ese espacio de trabajo se eliminarán permanentemente y no se podrán recuperar.",
|
||||
"delete_btn": "Eliminar mi espacio de trabajo",
|
||||
"delete_workspace": "Eliminar este espacio de trabajo",
|
||||
"delete_workspace_description": "Al eliminar un espacio de trabajo, todos los datos y recursos dentro de ese espacio se eliminarán permanentemente y no podrán recuperarse.",
|
||||
"delete_btn": "Eliminar este espacio de trabajo",
|
||||
"delete_modal": {
|
||||
"title": "¿Está seguro de que desea eliminar este espacio de trabajo?",
|
||||
"description": "Tiene una prueba activa de uno de nuestros planes de pago. Por favor, cancelela primero para continuar.",
|
||||
"dismiss": "Descartar",
|
||||
"cancel": "Cancelar prueba",
|
||||
"success_title": "Espacio de trabajo eliminado.",
|
||||
"success_message": "Pronto irá a su página de perfil.",
|
||||
"error_title": "Eso no funcionó.",
|
||||
"error_message": "Por favor, inténtelo de nuevo."
|
||||
},
|
||||
"errors": {
|
||||
"name": {
|
||||
"required": "El nombre es obligatorio",
|
||||
|
||||
@@ -546,6 +546,8 @@
|
||||
|
||||
"home": {
|
||||
"empty": {
|
||||
"quickstart_guide": "Guide de démarrage rapide",
|
||||
"not_right_now": "Pas maintenant",
|
||||
"create_project": {
|
||||
"title": "Créer un projet",
|
||||
"description": "La plupart des choses commencent par un projet dans Plane.",
|
||||
@@ -1420,9 +1422,19 @@
|
||||
"company_size": "Taille de l'entreprise",
|
||||
"url": "URL de l'espace de travail",
|
||||
"update_workspace": "Mettre à jour l'espace de travail",
|
||||
"delete_workspace": "Supprimer l'espace de travail",
|
||||
"delete_workspace_description": "Lors de la suppression d'un espace de travail, toutes les données et ressources au sein de cet espace de travail seront définitivement supprimées et ne pourront pas être récupérées.",
|
||||
"delete_btn": "Supprimer mon espace de travail",
|
||||
"delete_workspace": "Supprimer cet espace de travail",
|
||||
"delete_workspace_description": "Lors de la suppression d'un espace de travail, toutes les données et ressources au sein de cet espace seront définitivement supprimées et ne pourront pas être récupérées.",
|
||||
"delete_btn": "Supprimer cet espace de travail",
|
||||
"delete_modal": {
|
||||
"title": "Êtes-vous sûr de vouloir supprimer cet espace de travail ?",
|
||||
"description": "Vous avez un essai actif sur l'un de nos forfaits payants. Veuillez d'abord l'annuler pour continuer.",
|
||||
"dismiss": "Fermer",
|
||||
"cancel": "Annuler l'essai",
|
||||
"success_title": "Espace de travail supprimé.",
|
||||
"success_message": "Vous serez bientôt redirigé vers votre page de profil.",
|
||||
"error_title": "Cela n'a pas fonctionné.",
|
||||
"error_message": "Veuillez réessayer."
|
||||
},
|
||||
"errors": {
|
||||
"name": {
|
||||
"required": "Le nom est requis",
|
||||
|
||||
@@ -546,6 +546,8 @@
|
||||
|
||||
"home": {
|
||||
"empty": {
|
||||
"quickstart_guide": "クイックスタートガイド",
|
||||
"not_right_now": "今はしない",
|
||||
"create_project": {
|
||||
"title": "プロジェクトを作成",
|
||||
"description": "Planeのほとんどはプロジェクトから始まります。",
|
||||
@@ -1420,9 +1422,19 @@
|
||||
"company_size": "会社の規模",
|
||||
"url": "ワークスペースURL",
|
||||
"update_workspace": "ワークスペースを更新",
|
||||
"delete_workspace": "ワークスペースを削除",
|
||||
"delete_workspace_description": "ワークスペースを削除すると、そのワークスペース内のすべてのデータとリソースが永久に削除され、復元できなくなります。",
|
||||
"delete_btn": "ワークスペースを削除",
|
||||
"delete_workspace": "このワークスペースを削除",
|
||||
"delete_workspace_description": "ワークスペースを削除すると、そのワークスペース内のすべてのデータとリソースが完全に削除され、復元することはできません。",
|
||||
"delete_btn": "このワークスペースを削除",
|
||||
"delete_modal": {
|
||||
"title": "このワークスペースを削除してもよろしいですか?",
|
||||
"description": "有料プランの無料トライアルが有効です。続行するには、まずトライアルをキャンセルしてください。",
|
||||
"dismiss": "閉じる",
|
||||
"cancel": "トライアルをキャンセル",
|
||||
"success_title": "ワークスペースが削除されました。",
|
||||
"success_message": "まもなくプロフィールページに移動します。",
|
||||
"error_title": "操作に失敗しました。",
|
||||
"error_message": "もう一度お試しください。"
|
||||
},
|
||||
"errors": {
|
||||
"name": {
|
||||
"required": "名前は必須です",
|
||||
|
||||
@@ -546,6 +546,8 @@
|
||||
|
||||
"home": {
|
||||
"empty": {
|
||||
"quickstart_guide": "快速入门指南",
|
||||
"not_right_now": "暂时不要",
|
||||
"create_project": {
|
||||
"title": "创建项目",
|
||||
"description": "在Plane中,大多数事情都从项目开始。",
|
||||
@@ -1420,9 +1422,19 @@
|
||||
"company_size": "公司规模",
|
||||
"url": "工作区网址",
|
||||
"update_workspace": "更新工作区",
|
||||
"delete_workspace": "删除工作区",
|
||||
"delete_workspace_description": "删除工作区时,该工作区内的所有数据和资源将被永久删除且无法恢复。",
|
||||
"delete_btn": "删除我的工作区",
|
||||
"delete_workspace": "删除此工作区",
|
||||
"delete_workspace_description": "删除工作区时,该工作区内的所有数据和资源将被永久删除,且无法恢复。",
|
||||
"delete_btn": "删除此工作区",
|
||||
"delete_modal": {
|
||||
"title": "确定要删除此工作区吗?",
|
||||
"description": "您目前正在试用我们的付费方案。请先取消试用后再继续。",
|
||||
"dismiss": "关闭",
|
||||
"cancel": "取消试用",
|
||||
"success_title": "工作区已删除。",
|
||||
"success_message": "即将跳转到您的个人资料页面。",
|
||||
"error_title": "操作失败。",
|
||||
"error_message": "请重试。"
|
||||
},
|
||||
"errors": {
|
||||
"name": {
|
||||
"required": "名称为必填项",
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
.next
|
||||
.turbo
|
||||
out/
|
||||
dist/
|
||||
build/
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"printWidth": 120,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "es5"
|
||||
}
|
||||
@@ -2,9 +2,22 @@
|
||||
"name": "@plane/propel",
|
||||
"version": "0.24.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"lint": "eslint src --ext .ts,.tsx",
|
||||
"lint:errors": "eslint src --ext .ts,.tsx --quiet"
|
||||
},
|
||||
"exports": {
|
||||
"./globals.css": "./src/globals.css",
|
||||
"./components/*": "./src/*.tsx"
|
||||
"./ui/*": "./src/ui/*.tsx",
|
||||
"./charts/*": "./src/charts/*/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "^1.1.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"lucide-react": "^0.469.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"recharts": "^2.15.1",
|
||||
"tailwindcss-animate": "^1.0.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@plane/eslint-config": "*",
|
||||
@@ -13,15 +26,5 @@
|
||||
"@types/react": "18.3.1",
|
||||
"@types/react-dom": "18.3.0",
|
||||
"typescript": "^5.3.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "^1.1.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.469.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"tailwindcss-animate": "^1.0.7"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
"use client";
|
||||
|
||||
import React, { useMemo } from "react";
|
||||
import { AreaChart as CoreAreaChart, Area, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
|
||||
// plane imports
|
||||
import { AXIS_LINE_CLASSNAME, LABEL_CLASSNAME } from "@plane/constants";
|
||||
import { TAreaChartProps } from "@plane/types";
|
||||
// local components
|
||||
import { CustomXAxisTick, CustomYAxisTick } from "../tick";
|
||||
import { CustomTooltip } from "../tooltip";
|
||||
|
||||
export const AreaChart = React.memo(<K extends string, T extends string>(props: TAreaChartProps<K, T>) => {
|
||||
const {
|
||||
data,
|
||||
areas,
|
||||
xAxis,
|
||||
yAxis,
|
||||
className = "w-full h-96",
|
||||
tickCount = {
|
||||
x: undefined,
|
||||
y: 10,
|
||||
},
|
||||
showTooltip = true,
|
||||
} = props;
|
||||
// derived values
|
||||
const itemKeys = useMemo(() => areas.map((area) => area.key), [areas]);
|
||||
const itemDotClassNames = useMemo(
|
||||
() => areas.reduce((acc, area) => ({ ...acc, [area.key]: area.dotClassName }), {}),
|
||||
[areas]
|
||||
);
|
||||
|
||||
const renderAreas = useMemo(
|
||||
() =>
|
||||
areas.map((area) => (
|
||||
<Area
|
||||
key={area.key}
|
||||
type="monotone"
|
||||
dataKey={area.key}
|
||||
stackId={area.stackId}
|
||||
className={area.className}
|
||||
stroke="inherit"
|
||||
fill="inherit"
|
||||
/>
|
||||
)),
|
||||
[areas]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<CoreAreaChart
|
||||
width={500}
|
||||
height={300}
|
||||
data={data}
|
||||
margin={{
|
||||
top: 5,
|
||||
right: 30,
|
||||
left: 20,
|
||||
bottom: 5,
|
||||
}}
|
||||
reverseStackOrder
|
||||
>
|
||||
<XAxis
|
||||
dataKey={xAxis.key}
|
||||
tick={(props) => <CustomXAxisTick {...props} />}
|
||||
tickLine={{
|
||||
stroke: "currentColor",
|
||||
className: AXIS_LINE_CLASSNAME,
|
||||
}}
|
||||
axisLine={{
|
||||
stroke: "currentColor",
|
||||
className: AXIS_LINE_CLASSNAME,
|
||||
}}
|
||||
label={{
|
||||
value: xAxis.label,
|
||||
dy: 28,
|
||||
className: LABEL_CLASSNAME,
|
||||
}}
|
||||
tickCount={tickCount.x}
|
||||
/>
|
||||
<YAxis
|
||||
domain={yAxis.domain}
|
||||
tickLine={{
|
||||
stroke: "currentColor",
|
||||
className: AXIS_LINE_CLASSNAME,
|
||||
}}
|
||||
axisLine={{
|
||||
stroke: "currentColor",
|
||||
className: AXIS_LINE_CLASSNAME,
|
||||
}}
|
||||
label={{
|
||||
value: yAxis.label,
|
||||
angle: -90,
|
||||
position: "bottom",
|
||||
offset: -24,
|
||||
dx: -16,
|
||||
className: LABEL_CLASSNAME,
|
||||
}}
|
||||
tick={(props) => <CustomYAxisTick {...props} />}
|
||||
tickCount={tickCount.y}
|
||||
allowDecimals={!!yAxis.allowDecimals}
|
||||
/>
|
||||
{showTooltip && (
|
||||
<Tooltip
|
||||
cursor={{ fill: "currentColor", className: "text-custom-background-90/80 cursor-pointer" }}
|
||||
content={({ active, label, payload }) => (
|
||||
<CustomTooltip
|
||||
active={active}
|
||||
label={label}
|
||||
payload={payload}
|
||||
itemKeys={itemKeys}
|
||||
itemDotClassNames={itemDotClassNames}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{renderAreas}
|
||||
</CoreAreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
AreaChart.displayName = "AreaChart";
|
||||
@@ -0,0 +1,70 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import React from "react";
|
||||
// plane imports
|
||||
import { TChartData } from "@plane/types";
|
||||
import { cn } from "@plane/utils";
|
||||
|
||||
// Helper to calculate percentage
|
||||
const calculatePercentage = <K extends string, T extends string>(
|
||||
data: TChartData<K, T>,
|
||||
stackKeys: T[],
|
||||
currentKey: T
|
||||
): number => {
|
||||
const total = stackKeys.reduce((sum, key) => sum + data[key], 0);
|
||||
return total === 0 ? 0 : Math.round((data[currentKey] / total) * 100);
|
||||
};
|
||||
|
||||
const MIN_BAR_HEIGHT_FOR_INTERNAL_TEXT = 14; // Minimum height needed to show text inside
|
||||
const BAR_BORDER_RADIUS = 2; // Border radius for each bar
|
||||
|
||||
export const CustomBar = React.memo((props: any) => {
|
||||
const { fill, x, y, width, height, dataKey, stackKeys, payload, textClassName, showPercentage } = props;
|
||||
// Calculate text position
|
||||
const TEXT_PADDING_Y = Math.min(6, Math.abs(MIN_BAR_HEIGHT_FOR_INTERNAL_TEXT - height / 2));
|
||||
const textY = y + height - TEXT_PADDING_Y; // Position inside bar if tall enough
|
||||
// derived values
|
||||
const currentBarPercentage = calculatePercentage(payload, stackKeys, dataKey);
|
||||
const showText =
|
||||
// from props
|
||||
showPercentage &&
|
||||
// height of the bar is greater than or equal to the minimum height required to show the text
|
||||
height >= MIN_BAR_HEIGHT_FOR_INTERNAL_TEXT &&
|
||||
// bar percentage text has some value
|
||||
currentBarPercentage !== undefined &&
|
||||
// bar percentage is a number
|
||||
!Number.isNaN(currentBarPercentage);
|
||||
|
||||
if (!height) return null;
|
||||
return (
|
||||
<g>
|
||||
<path
|
||||
d={`
|
||||
M${x + BAR_BORDER_RADIUS},${y + height}
|
||||
L${x + BAR_BORDER_RADIUS},${y}
|
||||
Q${x},${y} ${x},${y + BAR_BORDER_RADIUS}
|
||||
L${x},${y + height - BAR_BORDER_RADIUS}
|
||||
Q${x},${y + height} ${x + BAR_BORDER_RADIUS},${y + height}
|
||||
L${x + width - BAR_BORDER_RADIUS},${y + height}
|
||||
Q${x + width},${y + height} ${x + width},${y + height - BAR_BORDER_RADIUS}
|
||||
L${x + width},${y + BAR_BORDER_RADIUS}
|
||||
Q${x + width},${y} ${x + width - BAR_BORDER_RADIUS},${y}
|
||||
L${x + BAR_BORDER_RADIUS},${y}
|
||||
`}
|
||||
className={cn("transition-colors duration-200", fill)}
|
||||
fill="currentColor"
|
||||
/>
|
||||
{showText && (
|
||||
<text
|
||||
x={x + width / 2}
|
||||
y={textY}
|
||||
textAnchor="middle"
|
||||
className={cn("text-xs font-medium", textClassName)}
|
||||
fill="currentColor"
|
||||
>
|
||||
{currentBarPercentage}%
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
});
|
||||
CustomBar.displayName = "CustomBar";
|
||||
@@ -0,0 +1,125 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
"use client";
|
||||
|
||||
import React, { useMemo } from "react";
|
||||
import { BarChart as CoreBarChart, Bar, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
|
||||
// plane imports
|
||||
import { AXIS_LINE_CLASSNAME, LABEL_CLASSNAME } from "@plane/constants";
|
||||
import { TBarChartProps } from "@plane/types";
|
||||
// local components
|
||||
import { CustomXAxisTick, CustomYAxisTick } from "../tick";
|
||||
import { CustomTooltip } from "../tooltip";
|
||||
import { CustomBar } from "./bar";
|
||||
|
||||
export const BarChart = React.memo(<K extends string, T extends string>(props: TBarChartProps<K, T>) => {
|
||||
const {
|
||||
data,
|
||||
bars,
|
||||
xAxis,
|
||||
yAxis,
|
||||
barSize = 40,
|
||||
className = "w-full h-96",
|
||||
tickCount = {
|
||||
x: undefined,
|
||||
y: 10,
|
||||
},
|
||||
showTooltip = true,
|
||||
} = props;
|
||||
// derived values
|
||||
const stackKeys = useMemo(() => bars.map((bar) => bar.key), [bars]);
|
||||
const stackDotClassNames = useMemo(
|
||||
() => bars.reduce((acc, bar) => ({ ...acc, [bar.key]: bar.dotClassName }), {}),
|
||||
[bars]
|
||||
);
|
||||
|
||||
const renderBars = useMemo(
|
||||
() =>
|
||||
bars.map((bar) => (
|
||||
<Bar
|
||||
key={bar.key}
|
||||
dataKey={bar.key}
|
||||
stackId={bar.stackId}
|
||||
fill={bar.fillClassName}
|
||||
shape={(shapeProps: any) => (
|
||||
<CustomBar
|
||||
{...shapeProps}
|
||||
stackKeys={stackKeys}
|
||||
textClassName={bar.textClassName}
|
||||
showPercentage={bar.showPercentage}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)),
|
||||
[stackKeys, bars]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<CoreBarChart
|
||||
data={data}
|
||||
margin={{ top: 10, right: 10, left: 10, bottom: 40 }}
|
||||
barSize={barSize}
|
||||
className="recharts-wrapper"
|
||||
>
|
||||
<XAxis
|
||||
dataKey={xAxis.key}
|
||||
tick={(props) => <CustomXAxisTick {...props} />}
|
||||
tickLine={{
|
||||
stroke: "currentColor",
|
||||
className: AXIS_LINE_CLASSNAME,
|
||||
}}
|
||||
axisLine={{
|
||||
stroke: "currentColor",
|
||||
className: AXIS_LINE_CLASSNAME,
|
||||
}}
|
||||
label={{
|
||||
value: xAxis.label,
|
||||
dy: 28,
|
||||
className: LABEL_CLASSNAME,
|
||||
}}
|
||||
tickCount={tickCount.x}
|
||||
/>
|
||||
<YAxis
|
||||
domain={yAxis.domain}
|
||||
tickLine={{
|
||||
stroke: "currentColor",
|
||||
className: AXIS_LINE_CLASSNAME,
|
||||
}}
|
||||
axisLine={{
|
||||
stroke: "currentColor",
|
||||
className: AXIS_LINE_CLASSNAME,
|
||||
}}
|
||||
label={{
|
||||
value: yAxis.label,
|
||||
angle: -90,
|
||||
position: "bottom",
|
||||
offset: -24,
|
||||
dx: -16,
|
||||
className: LABEL_CLASSNAME,
|
||||
}}
|
||||
tick={(props) => <CustomYAxisTick {...props} />}
|
||||
tickCount={tickCount.y}
|
||||
allowDecimals={!!yAxis.allowDecimals}
|
||||
/>
|
||||
{showTooltip && (
|
||||
<Tooltip
|
||||
cursor={{ fill: "currentColor", className: "text-custom-background-90/80 cursor-pointer" }}
|
||||
content={({ active, label, payload }) => (
|
||||
<CustomTooltip
|
||||
active={active}
|
||||
label={label}
|
||||
payload={payload}
|
||||
itemKeys={stackKeys}
|
||||
itemDotClassNames={stackDotClassNames}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{renderBars}
|
||||
</CoreBarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
BarChart.displayName = "BarChart";
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./root";
|
||||
@@ -0,0 +1,115 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
"use client";
|
||||
|
||||
import React, { useMemo } from "react";
|
||||
import { LineChart as CoreLineChart, Line, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
|
||||
// plane imports
|
||||
import { AXIS_LINE_CLASSNAME, LABEL_CLASSNAME } from "@plane/constants";
|
||||
import { TLineChartProps } from "@plane/types";
|
||||
// local components
|
||||
import { CustomXAxisTick, CustomYAxisTick } from "../tick";
|
||||
import { CustomTooltip } from "../tooltip";
|
||||
|
||||
export const LineChart = React.memo(<K extends string, T extends string>(props: TLineChartProps<K, T>) => {
|
||||
const {
|
||||
data,
|
||||
lines,
|
||||
xAxis,
|
||||
yAxis,
|
||||
className = "w-full h-96",
|
||||
tickCount = {
|
||||
x: undefined,
|
||||
y: 10,
|
||||
},
|
||||
showTooltip = true,
|
||||
} = props;
|
||||
// derived values
|
||||
const itemKeys = useMemo(() => lines.map((line) => line.key), [lines]);
|
||||
const itemDotClassNames = useMemo(
|
||||
() => lines.reduce((acc, line) => ({ ...acc, [line.key]: line.dotClassName }), {}),
|
||||
[lines]
|
||||
);
|
||||
|
||||
const renderLines = useMemo(
|
||||
() =>
|
||||
lines.map((line) => (
|
||||
<Line key={line.key} dataKey={line.key} type="monotone" className={line.className} stroke="inherit" />
|
||||
)),
|
||||
[lines]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<CoreLineChart
|
||||
width={500}
|
||||
height={300}
|
||||
data={data}
|
||||
margin={{
|
||||
top: 5,
|
||||
right: 30,
|
||||
left: 20,
|
||||
bottom: 5,
|
||||
}}
|
||||
>
|
||||
<XAxis
|
||||
dataKey={xAxis.key}
|
||||
tick={(props) => <CustomXAxisTick {...props} />}
|
||||
tickLine={{
|
||||
stroke: "currentColor",
|
||||
className: AXIS_LINE_CLASSNAME,
|
||||
}}
|
||||
axisLine={{
|
||||
stroke: "currentColor",
|
||||
className: AXIS_LINE_CLASSNAME,
|
||||
}}
|
||||
label={{
|
||||
value: xAxis.label,
|
||||
dy: 28,
|
||||
className: LABEL_CLASSNAME,
|
||||
}}
|
||||
tickCount={tickCount.x}
|
||||
/>
|
||||
<YAxis
|
||||
domain={yAxis.domain}
|
||||
tickLine={{
|
||||
stroke: "currentColor",
|
||||
className: AXIS_LINE_CLASSNAME,
|
||||
}}
|
||||
axisLine={{
|
||||
stroke: "currentColor",
|
||||
className: AXIS_LINE_CLASSNAME,
|
||||
}}
|
||||
label={{
|
||||
value: yAxis.label,
|
||||
angle: -90,
|
||||
position: "bottom",
|
||||
offset: -24,
|
||||
dx: -16,
|
||||
className: LABEL_CLASSNAME,
|
||||
}}
|
||||
tick={(props) => <CustomYAxisTick {...props} />}
|
||||
tickCount={tickCount.y}
|
||||
allowDecimals={!!yAxis.allowDecimals}
|
||||
/>
|
||||
{showTooltip && (
|
||||
<Tooltip
|
||||
cursor={{ fill: "currentColor", className: "text-custom-background-90/80 cursor-pointer" }}
|
||||
content={({ active, label, payload }) => (
|
||||
<CustomTooltip
|
||||
active={active}
|
||||
label={label}
|
||||
payload={payload}
|
||||
itemKeys={itemKeys}
|
||||
itemDotClassNames={itemDotClassNames}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{renderLines}
|
||||
</CoreLineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
LineChart.displayName = "LineChart";
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./root";
|
||||
@@ -0,0 +1,51 @@
|
||||
"use client";
|
||||
|
||||
import React, { useMemo } from "react";
|
||||
import { Cell, PieChart as CorePieChart, Pie, ResponsiveContainer, Tooltip } from "recharts";
|
||||
// plane imports
|
||||
import { TPieChartProps } from "@plane/types";
|
||||
// local components
|
||||
import { CustomPieChartTooltip } from "./tooltip";
|
||||
|
||||
export const PieChart = React.memo(<K extends string, T extends string>(props: TPieChartProps<K, T>) => {
|
||||
const { data, dataKey, cells, className = "w-full h-96", innerRadius, outerRadius, showTooltip = true } = props;
|
||||
|
||||
const renderCells = useMemo(
|
||||
() => cells.map((cell) => <Cell key={cell.key} className={cell.className} style={cell.style} />),
|
||||
[cells]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<CorePieChart
|
||||
width={500}
|
||||
height={300}
|
||||
data={data}
|
||||
margin={{
|
||||
top: 5,
|
||||
right: 30,
|
||||
left: 20,
|
||||
bottom: 5,
|
||||
}}
|
||||
>
|
||||
<Pie data={data} dataKey={dataKey} cx="50%" cy="50%" innerRadius={innerRadius} outerRadius={outerRadius}>
|
||||
{renderCells}
|
||||
</Pie>
|
||||
{showTooltip && (
|
||||
<Tooltip
|
||||
cursor={{ fill: "currentColor", className: "text-custom-background-90/80 cursor-pointer" }}
|
||||
content={({ active, payload }) => {
|
||||
if (!active || !payload || !payload.length) return null;
|
||||
const cellData = cells.find((c) => c.key === payload[0].name);
|
||||
if (!cellData) return null;
|
||||
return <CustomPieChartTooltip dotClassName={cellData.dotClassName} label={dataKey} payload={payload} />;
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</CorePieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
PieChart.displayName = "PieChart";
|
||||
@@ -0,0 +1,31 @@
|
||||
import React from "react";
|
||||
import { NameType, Payload, ValueType } from "recharts/types/component/DefaultTooltipContent";
|
||||
// plane imports
|
||||
import { Card, ECardSpacing } from "@plane/ui";
|
||||
import { cn } from "@plane/utils";
|
||||
|
||||
type Props = {
|
||||
dotClassName?: string;
|
||||
label: string;
|
||||
payload: Payload<ValueType, NameType>[];
|
||||
};
|
||||
|
||||
export const CustomPieChartTooltip = React.memo((props: Props) => {
|
||||
const { dotClassName, label, payload } = props;
|
||||
|
||||
return (
|
||||
<Card className="flex flex-col" spacing={ECardSpacing.SM}>
|
||||
<p className="text-xs text-custom-text-100 font-medium border-b border-custom-border-200 pb-2 capitalize">
|
||||
{label}
|
||||
</p>
|
||||
{payload?.map((item) => (
|
||||
<div key={item?.dataKey} className="flex items-center gap-2 text-xs capitalize">
|
||||
<div className={cn("size-2 rounded-full", dotClassName)} />
|
||||
<span className="text-custom-text-300">{item?.name}:</span>
|
||||
<span className="font-medium text-custom-text-200">{item?.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
);
|
||||
});
|
||||
CustomPieChartTooltip.displayName = "CustomPieChartTooltip";
|
||||
@@ -0,0 +1,41 @@
|
||||
import React from "react";
|
||||
import { NameType, Payload, ValueType } from "recharts/types/component/DefaultTooltipContent";
|
||||
// plane imports
|
||||
import { Card, ECardSpacing } from "@plane/ui";
|
||||
import { cn } from "@plane/utils";
|
||||
|
||||
type Props = {
|
||||
active: boolean | undefined;
|
||||
label: string | undefined;
|
||||
payload: Payload<ValueType, NameType>[] | undefined;
|
||||
itemKeys: string[];
|
||||
itemDotClassNames: Record<string, string>;
|
||||
};
|
||||
|
||||
export const CustomTooltip = React.memo((props: Props) => {
|
||||
const { active, label, payload, itemKeys, itemDotClassNames } = props;
|
||||
// derived values
|
||||
const filteredPayload = payload?.filter((item) => item.dataKey && itemKeys.includes(`${item.dataKey}`));
|
||||
|
||||
if (!active || !filteredPayload || !filteredPayload.length) return null;
|
||||
return (
|
||||
<Card className="flex flex-col" spacing={ECardSpacing.SM}>
|
||||
<p className="text-xs text-custom-text-100 font-medium border-b border-custom-border-200 pb-2 capitalize">
|
||||
{label}
|
||||
</p>
|
||||
{filteredPayload.map((item) => {
|
||||
if (!item.dataKey) return null;
|
||||
return (
|
||||
<div key={item?.dataKey} className="flex items-center gap-2 text-xs capitalize">
|
||||
{itemDotClassNames[item?.dataKey] && (
|
||||
<div className={cn("size-2 rounded-full", itemDotClassNames[item?.dataKey])} />
|
||||
)}
|
||||
<span className="text-custom-text-300">{item?.name}:</span>
|
||||
<span className="font-medium text-custom-text-200">{item?.value}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Card>
|
||||
);
|
||||
});
|
||||
CustomTooltip.displayName = "CustomTooltip";
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./root";
|
||||
@@ -2,53 +2,6 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 222.2 47.4% 11.2%;
|
||||
--muted: 210 40% 96.1%;
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 222.2 47.4% 11.2%;
|
||||
--border: 214.3 31.8% 91.4%;
|
||||
--input: 214.3 31.8% 91.4%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 222.2 47.4% 11.2%;
|
||||
--primary: 222.2 47.4% 11.2%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--secondary: 210 40% 96.1%;
|
||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||
--accent: 210 40% 96.1%;
|
||||
--accent-foreground: 222.2 47.4% 11.2%;
|
||||
--destructive: 0 100% 50%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--ring: 215 20.2% 65.1%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 224 71% 4%;
|
||||
--foreground: 213 31% 91%;
|
||||
--muted: 223 47% 11%;
|
||||
--muted-foreground: 215.4 16.3% 56.9%;
|
||||
--accent: 216 34% 17%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
--popover: 224 71% 4%;
|
||||
--popover-foreground: 215 20.2% 65.1%;
|
||||
--border: 216 34% 17%;
|
||||
--input: 216 34% 17%;
|
||||
--card: 224 71% 4%;
|
||||
--card-foreground: 213 31% 91%;
|
||||
--primary: 210 40% 98%;
|
||||
--primary-foreground: 222.2 47.4% 1.2%;
|
||||
--secondary: 222.2 47.4% 11.2%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
--destructive: 0 63% 31%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--ring: 216 34% 17%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@plane/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2",
|
||||
sm: "h-8 rounded-md px-3 text-xs",
|
||||
lg: "h-10 rounded-md px-8",
|
||||
icon: "h-9 w-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
|
||||
export { Button, buttonVariants };
|
||||
@@ -19,6 +19,7 @@ module.exports = {
|
||||
"./app/**/*.tsx",
|
||||
"./ui/**/*.tsx",
|
||||
"../packages/ui/src/**/*.{js,ts,jsx,tsx}",
|
||||
"../packages/propel/src/**/*.{js,ts,jsx,tsx}",
|
||||
"../packages/editor/src/**/*.{js,ts,jsx,tsx}",
|
||||
"!../packages/ui/**/*.stories{js,ts,jsx,tsx}",
|
||||
],
|
||||
|
||||
Vendored
+61
-16
@@ -1,29 +1,20 @@
|
||||
export type TStackItem<T extends string> = {
|
||||
key: T;
|
||||
fillClassName: string;
|
||||
textClassName: string;
|
||||
dotClassName?: string;
|
||||
showPercentage?: boolean;
|
||||
};
|
||||
|
||||
export type TStackChartData<K extends string, T extends string> = {
|
||||
export type TChartData<K extends string, T extends string> = {
|
||||
// required key
|
||||
[key in K]: string | number;
|
||||
} & Record<T, any>;
|
||||
|
||||
export type TStackedBarChartProps<K extends string, T extends string> = {
|
||||
data: TStackChartData<K, T>[];
|
||||
stacks: TStackItem<T>[];
|
||||
type TChartProps<K extends string, T extends string> = {
|
||||
data: TChartData<K, T>[];
|
||||
xAxis: {
|
||||
key: keyof TStackChartData<K, T>;
|
||||
key: keyof TChartData<K, T>;
|
||||
label: string;
|
||||
};
|
||||
yAxis: {
|
||||
key: keyof TStackChartData<K, T>;
|
||||
key: keyof TChartData<K, T>;
|
||||
label: string;
|
||||
domain?: [number, number];
|
||||
allowDecimals?: boolean;
|
||||
};
|
||||
barSize?: number;
|
||||
className?: string;
|
||||
tickCount?: {
|
||||
x?: number;
|
||||
@@ -32,6 +23,60 @@ export type TStackedBarChartProps<K extends string, T extends string> = {
|
||||
showTooltip?: boolean;
|
||||
};
|
||||
|
||||
export type TBarItem<T extends string> = {
|
||||
key: T;
|
||||
fillClassName: string;
|
||||
textClassName: string;
|
||||
dotClassName?: string;
|
||||
showPercentage?: boolean;
|
||||
stackId: string;
|
||||
};
|
||||
|
||||
export type TBarChartProps<K extends string, T extends string> = TChartProps<K, T> & {
|
||||
bars: TBarItem<T>[];
|
||||
barSize?: number;
|
||||
};
|
||||
|
||||
export type TLineItem<T extends string> = {
|
||||
key: T;
|
||||
className?: string;
|
||||
style?: Record<string, string | number>;
|
||||
dotClassName?: string;
|
||||
};
|
||||
|
||||
export type TLineChartProps<K extends string, T extends string> = TChartProps<K, T> & {
|
||||
lines: TLineItem<T>[];
|
||||
};
|
||||
|
||||
export type TAreaItem<T extends string> = {
|
||||
key: T;
|
||||
stackId: string;
|
||||
className?: string;
|
||||
style?: Record<string, string | number>;
|
||||
dotClassName?: string;
|
||||
};
|
||||
|
||||
export type TAreaChartProps<K extends string, T extends string> = TChartProps<K, T> & {
|
||||
areas: TAreaItem<T>[];
|
||||
};
|
||||
|
||||
export type TCellItem<T extends string> = {
|
||||
key: T;
|
||||
className?: string;
|
||||
style?: Record<string, string | number>;
|
||||
dotClassName?: string;
|
||||
};
|
||||
|
||||
export type TPieChartProps<K extends string, T extends string> = Pick<
|
||||
TChartProps<K, T>,
|
||||
"className" | "data" | "showTooltip"
|
||||
> & {
|
||||
dataKey: T;
|
||||
cells: TCellItem<T>[];
|
||||
innerRadius?: number;
|
||||
outerRadius?: number;
|
||||
};
|
||||
|
||||
export type TreeMapItem = {
|
||||
name: string;
|
||||
value: number;
|
||||
@@ -45,7 +90,7 @@ export type TreeMapItem = {
|
||||
| {
|
||||
fillClassName: string;
|
||||
}
|
||||
);
|
||||
);
|
||||
|
||||
export type TreeMapChartProps = {
|
||||
data: TreeMapItem[];
|
||||
|
||||
Vendored
-1
@@ -104,7 +104,6 @@ export interface ICycle extends TProgressSnapshot {
|
||||
project_detail: IProjectDetails;
|
||||
progress: any[];
|
||||
version: number;
|
||||
pending_issues: number;
|
||||
}
|
||||
|
||||
export interface CycleIssueResponse {
|
||||
|
||||
Vendored
+2
-1
@@ -21,8 +21,9 @@ export interface IWorkspace {
|
||||
readonly created_by: string;
|
||||
readonly updated_by: string;
|
||||
organization_size: string;
|
||||
total_issues: number;
|
||||
total_projects?: number;
|
||||
current_plan?: string;
|
||||
role: number;
|
||||
}
|
||||
|
||||
export interface IWorkspaceLite {
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { List, Kanban, LucideProps } from "lucide-react";
|
||||
import { TIssueLayout } from "@plane/constants";
|
||||
|
||||
export const IssueLayoutIcon = ({ layout, ...props }: { layout: TIssueLayout } & LucideProps) => {
|
||||
switch (layout) {
|
||||
case "list":
|
||||
return <List {...props} />;
|
||||
case "kanban":
|
||||
return <Kanban {...props} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
@@ -14,6 +14,7 @@ import { queryParamGenerator } from "@/helpers/query-param-generator";
|
||||
import { useIssueFilter } from "@/hooks/store";
|
||||
// mobx
|
||||
import { TIssueLayout } from "@/types/issue";
|
||||
import { IssueLayoutIcon } from "./layout-icon";
|
||||
|
||||
type Props = {
|
||||
anchor: string;
|
||||
@@ -57,8 +58,8 @@ export const IssuesLayoutSelection: FC<Props> = observer((props) => {
|
||||
}`}
|
||||
onClick={() => handleCurrentBoardView(layout.key)}
|
||||
>
|
||||
<layout.icon
|
||||
strokeWidth={2}
|
||||
<IssueLayoutIcon
|
||||
layout={layout.key}
|
||||
className={`size-3.5 ${activeLayout == layout.key ? "text-custom-text-100" : "text-custom-text-200"}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
@@ -12,14 +12,17 @@ import { IWorkspaceBulkInviteFormData } from "@plane/types";
|
||||
import { Button, TOAST_TYPE, setToast } from "@plane/ui";
|
||||
// components
|
||||
import { NotAuthorizedView } from "@/components/auth-screens";
|
||||
import { CountChip } from "@/components/common";
|
||||
import { PageHead } from "@/components/core";
|
||||
import { SendWorkspaceInvitationModal, WorkspaceMembersList } from "@/components/workspace";
|
||||
// constants
|
||||
import { WorkspaceMembersList } from "@/components/workspace";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
import { getUserRole } from "@/helpers/user.helper";
|
||||
// hooks
|
||||
import { useEventTracker, useMember, useUserPermissions, useWorkspace } from "@/hooks/store";
|
||||
// plane web components
|
||||
import { BillingActionsButton } from "@/plane-web/components/workspace/billing";
|
||||
import { SendWorkspaceInvitationModal } from "@/plane-web/components/workspace/members";
|
||||
|
||||
const WorkspaceMembersSettingsPage = observer(() => {
|
||||
// states
|
||||
@@ -31,7 +34,7 @@ const WorkspaceMembersSettingsPage = observer(() => {
|
||||
const { workspaceUserInfo, allowPermissions } = useUserPermissions();
|
||||
const { captureEvent } = useEventTracker();
|
||||
const {
|
||||
workspace: { inviteMembersToWorkspace },
|
||||
workspace: { workspaceMemberIds, inviteMembersToWorkspace },
|
||||
} = useMember();
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const { t } = useTranslation();
|
||||
@@ -83,6 +86,7 @@ const WorkspaceMembersSettingsPage = observer(() => {
|
||||
title: "Error!",
|
||||
message: `${err.error ?? t("something_went_wrong_please_try_again")}`,
|
||||
});
|
||||
throw err;
|
||||
});
|
||||
};
|
||||
|
||||
@@ -107,8 +111,13 @@ const WorkspaceMembersSettingsPage = observer(() => {
|
||||
"opacity-60": !canPerformWorkspaceMemberActions,
|
||||
})}
|
||||
>
|
||||
<div className="flex justify-between gap-4 pb-3.5 items-start ">
|
||||
<h4 className="text-xl font-medium">{t("workspace_settings.settings.members.title")}</h4>
|
||||
<div className="flex justify-between gap-4 pb-3.5 items-start">
|
||||
<h4 className="flex items-center gap-2.5 text-xl font-medium">
|
||||
{t("workspace_settings.settings.members.title")}
|
||||
{workspaceMemberIds && workspaceMemberIds.length > 0 && (
|
||||
<CountChip count={workspaceMemberIds.length} className="h-5 m-auto" />
|
||||
)}
|
||||
</h4>
|
||||
<div className="ml-auto flex items-center gap-1.5 rounded-md border border-custom-border-200 bg-custom-background-100 px-2.5 py-1.5">
|
||||
<Search className="h-3.5 w-3.5 text-custom-text-400" />
|
||||
<input
|
||||
@@ -124,6 +133,7 @@ const WorkspaceMembersSettingsPage = observer(() => {
|
||||
{t("workspace_settings.settings.members.add_member")}
|
||||
</Button>
|
||||
)}
|
||||
<BillingActionsButton canPerformWorkspaceAdminActions={canPerformWorkspaceAdminActions} />
|
||||
</div>
|
||||
<WorkspaceMembersList searchQuery={searchQuery} isAdmin={canPerformWorkspaceAdminActions} />
|
||||
</section>
|
||||
|
||||
+1
-1
@@ -67,7 +67,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
||||
<link rel="preload" href={`${API_BASE_URL}/api/users/me/settings/ `} as="fetch" crossOrigin="use-credentials" />
|
||||
<link
|
||||
rel="preload"
|
||||
href={`${API_BASE_URL}/api/users/me/workspaces/`}
|
||||
href={`${API_BASE_URL}/api/users/me/workspaces/?v=${Date.now()}`}
|
||||
as="fetch"
|
||||
crossOrigin="use-credentials"
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { IWorkspace } from "@plane/types";
|
||||
|
||||
type TProps = {
|
||||
workspace: IWorkspace;
|
||||
};
|
||||
|
||||
export const SubscriptionPill = (props: TProps) => <></>;
|
||||
@@ -0,0 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
|
||||
export type TBillingActionsButtonProps = {
|
||||
canPerformWorkspaceAdminActions: boolean;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
export const BillingActionsButton = observer((props: TBillingActionsButtonProps) => <></>);
|
||||
@@ -1 +1,2 @@
|
||||
export * from "./root";
|
||||
export * from "./billing-actions-button";
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import type { IWorkspace } from "@plane/types";
|
||||
// ui
|
||||
import { EModalPosition, EModalWidth, ModalCore } from "@plane/ui";
|
||||
// constants
|
||||
// hooks
|
||||
|
||||
import { DeleteWorkspaceForm } from "@/components/workspace/delete-workspace-form";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
data: IWorkspace | null;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export const DeleteWorkspaceModal: React.FC<Props> = observer((props) => {
|
||||
const { isOpen, data, onClose } = props;
|
||||
|
||||
return (
|
||||
<ModalCore isOpen={isOpen} handleClose={() => onClose()} position={EModalPosition.CENTER} width={EModalWidth.XL}>
|
||||
<DeleteWorkspaceForm data={data} onClose={onClose} />
|
||||
</ModalCore>
|
||||
);
|
||||
});
|
||||
@@ -6,8 +6,8 @@ import { useTranslation } from "@plane/i18n";
|
||||
import { IWorkspace } from "@plane/types";
|
||||
// ui
|
||||
import { Button, Collapsible } from "@plane/ui";
|
||||
import { DeleteWorkspaceModal } from "./delete-workspace-modal";
|
||||
// components
|
||||
import { DeleteWorkspaceModal } from "@/components/workspace";
|
||||
|
||||
type TDeleteWorkspace = {
|
||||
workspace: IWorkspace | null;
|
||||
|
||||
@@ -2,3 +2,4 @@ export * from "./edition-badge";
|
||||
export * from "./upgrade-badge";
|
||||
export * from "./billing";
|
||||
export * from "./delete-workspace-section";
|
||||
export * from "./members";
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./invite-modal";
|
||||
@@ -0,0 +1,60 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// plane imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { IWorkspaceBulkInviteFormData } from "@plane/types";
|
||||
// ui
|
||||
import { EModalWidth, EModalPosition, ModalCore } from "@plane/ui";
|
||||
// components
|
||||
import { InvitationFields, InvitationModalActions } from "@/components/workspace/invite-modal";
|
||||
import { InvitationForm } from "@/components/workspace/invite-modal/form";
|
||||
// hooks
|
||||
import { useWorkspaceInvitationActions } from "@/hooks/use-workspace-invitation";
|
||||
|
||||
export type TSendWorkspaceInvitationModalProps = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (data: IWorkspaceBulkInviteFormData) => Promise<void> | undefined;
|
||||
};
|
||||
|
||||
export const SendWorkspaceInvitationModal: React.FC<TSendWorkspaceInvitationModalProps> = observer((props) => {
|
||||
const { isOpen, onClose, onSubmit } = props;
|
||||
// store hooks
|
||||
const { t } = useTranslation();
|
||||
// router
|
||||
const { workspaceSlug } = useParams();
|
||||
// derived values
|
||||
const { control, fields, formState, remove, onFormSubmit, handleClose, appendField } = useWorkspaceInvitationActions({
|
||||
onSubmit,
|
||||
onClose,
|
||||
});
|
||||
|
||||
return (
|
||||
<ModalCore isOpen={isOpen} position={EModalPosition.TOP} width={EModalWidth.XXL}>
|
||||
<InvitationForm
|
||||
title={t("workspace_settings.settings.members.modal.title")}
|
||||
description={t("workspace_settings.settings.members.modal.description")}
|
||||
onSubmit={onFormSubmit}
|
||||
actions={
|
||||
<InvitationModalActions
|
||||
isSubmitting={formState.isSubmitting}
|
||||
handleClose={handleClose}
|
||||
appendField={appendField}
|
||||
/>
|
||||
}
|
||||
className="p-5"
|
||||
>
|
||||
<InvitationFields
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
fields={fields}
|
||||
control={control}
|
||||
formState={formState}
|
||||
remove={remove}
|
||||
/>
|
||||
</InvitationForm>
|
||||
</ModalCore>
|
||||
);
|
||||
});
|
||||
@@ -5,13 +5,13 @@ import { Command } from "cmdk";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import useSWR from "swr";
|
||||
import { FolderPlus, Search, Settings } from "lucide-react";
|
||||
import { CommandIcon, FolderPlus, Search, Settings } from "lucide-react";
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
// plane imports
|
||||
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { IWorkspaceSearchResults } from "@plane/types";
|
||||
import { LayersIcon, Loader, ToggleSwitch, Tooltip } from "@plane/ui";
|
||||
import { LayersIcon, Loader, ToggleSwitch } from "@plane/ui";
|
||||
// components
|
||||
import {
|
||||
ChangeIssueAssignee,
|
||||
@@ -66,13 +66,13 @@ export const CommandModal: React.FC = observer(() => {
|
||||
page: [],
|
||||
},
|
||||
});
|
||||
const [isWorkspaceLevel, setIsWorkspaceLevel] = useState(false);
|
||||
const [isWorkspaceLevel, setIsWorkspaceLevel] = useState(true);
|
||||
const [pages, setPages] = useState<string[]>([]);
|
||||
// plane hooks
|
||||
const { t } = useTranslation();
|
||||
// hooks
|
||||
const { workspaceProjectIds } = useProject();
|
||||
const { isMobile } = usePlatformOS();
|
||||
const { platform, isMobile } = usePlatformOS();
|
||||
const { canPerformAnyCreateAction } = useUser();
|
||||
const { isCommandPaletteOpen, toggleCommandPaletteModal, toggleCreateIssueModal, toggleCreateProjectModal } =
|
||||
useCommandPalette();
|
||||
@@ -176,22 +176,60 @@ export const CommandModal: React.FC = observer(() => {
|
||||
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
>
|
||||
<Dialog.Panel className="relative flex w-full max-w-2xl transform items-center justify-center divide-y divide-custom-border-200 divide-opacity-10 rounded-lg bg-custom-background-100 shadow-custom-shadow-md transition-all">
|
||||
<Dialog.Panel className="relative flex w-full max-w-2xl transform flex-col items-center justify-center divide-y divide-custom-border-200 divide-opacity-10 rounded-lg bg-custom-background-100 shadow-custom-shadow-md transition-all">
|
||||
<div className="w-full max-w-2xl">
|
||||
<Command
|
||||
filter={(value, search) => {
|
||||
if (value.toLowerCase().includes(search.toLowerCase())) return 1;
|
||||
return 0;
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
// when search term is not empty, esc should clear the search term
|
||||
if (e.key === "Escape" && searchTerm) setSearchTerm("");
|
||||
shouldFilter={searchTerm.length > 0}
|
||||
onKeyDown={(e: any) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
closePalette();
|
||||
return;
|
||||
}
|
||||
|
||||
// when user tries to close the modal with esc
|
||||
if (e.key === "Escape" && !page && !searchTerm) closePalette();
|
||||
if (e.key === "Tab") {
|
||||
e.preventDefault();
|
||||
const commandList = document.querySelector("[cmdk-list]");
|
||||
const items = commandList?.querySelectorAll("[cmdk-item]") || [];
|
||||
const selectedItem = commandList?.querySelector('[aria-selected="true"]');
|
||||
if (items.length === 0) return;
|
||||
|
||||
const currentIndex = Array.from(items).indexOf(selectedItem as Element);
|
||||
let nextIndex;
|
||||
|
||||
if (e.shiftKey) {
|
||||
nextIndex = currentIndex > 0 ? currentIndex - 1 : items.length - 1;
|
||||
} else {
|
||||
nextIndex = currentIndex < items.length - 1 ? currentIndex + 1 : 0;
|
||||
}
|
||||
|
||||
const nextItem = items[nextIndex] as HTMLElement;
|
||||
if (nextItem) {
|
||||
nextItem.setAttribute("aria-selected", "true");
|
||||
selectedItem?.setAttribute("aria-selected", "false");
|
||||
nextItem.focus();
|
||||
nextItem.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "nearest",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (e.key === "Escape" && searchTerm) {
|
||||
e.preventDefault();
|
||||
setSearchTerm("");
|
||||
}
|
||||
|
||||
if (e.key === "Escape" && !page && !searchTerm) {
|
||||
e.preventDefault();
|
||||
closePalette();
|
||||
}
|
||||
|
||||
// Escape goes to previous page
|
||||
// Backspace goes to previous page when search is empty
|
||||
if (e.key === "Escape" || (e.key === "Backspace" && !searchTerm)) {
|
||||
e.preventDefault();
|
||||
setPages((pages) => pages.slice(0, -1));
|
||||
@@ -200,7 +238,7 @@ export const CommandModal: React.FC = observer(() => {
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={`flex gap-4 p-3 pb-0 sm:items-center ${
|
||||
className={`flex gap-4 pb-0 sm:items-center ${
|
||||
issueDetails ? "flex-col justify-between sm:flex-row" : "justify-end"
|
||||
}`}
|
||||
>
|
||||
@@ -216,23 +254,6 @@ export const CommandModal: React.FC = observer(() => {
|
||||
{issueDetails.name}
|
||||
</div>
|
||||
)}
|
||||
{projectId && (
|
||||
<Tooltip tooltipContent="Toggle workspace level search" isMobile={isMobile}>
|
||||
<div className="flex flex-shrink-0 cursor-pointer items-center gap-1 self-end text-xs sm:self-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsWorkspaceLevel((prevData) => !prevData)}
|
||||
className="flex-shrink-0"
|
||||
>
|
||||
Workspace Level
|
||||
</button>
|
||||
<ToggleSwitch
|
||||
value={isWorkspaceLevel}
|
||||
onChange={() => setIsWorkspaceLevel((prevData) => !prevData)}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Search
|
||||
@@ -413,6 +434,28 @@ export const CommandModal: React.FC = observer(() => {
|
||||
</Command.List>
|
||||
</Command>
|
||||
</div>
|
||||
{/* Bottom overlay */}
|
||||
<div className="w-full flex items-center justify-between px-4 py-2 border-t border-custom-border-200 bg-custom-background-90/80 rounded-b-lg">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-custom-text-300">Actions</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="grid h-6 min-w-[1.5rem] place-items-center rounded bg-custom-background-80 border-[0.5px] border-custom-border-200 px-1.5 text-[10px] text-custom-text-200">
|
||||
{platform === "MacOS" ? <CommandIcon className="h-2.5 w-2.5 text-custom-text-200" /> : "Ctrl"}
|
||||
</div>
|
||||
<kbd className="grid h-6 min-w-[1.5rem] place-items-center rounded bg-custom-background-80 border-[0.5px] border-custom-border-200 px-1.5 text-[10px] text-custom-text-200">
|
||||
K
|
||||
</kbd>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-custom-text-300">Workspace Level</span>
|
||||
<ToggleSwitch
|
||||
value={isWorkspaceLevel}
|
||||
onChange={() => setIsWorkspaceLevel((prevData) => !prevData)}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import React from "react";
|
||||
// plane imports
|
||||
import { TStackChartData } from "@plane/types";
|
||||
import { cn } from "@plane/utils";
|
||||
|
||||
// Helper to calculate percentage
|
||||
const calculatePercentage = <K extends string, T extends string>(
|
||||
data: TStackChartData<K, T>,
|
||||
stackKeys: T[],
|
||||
currentKey: T
|
||||
): number => {
|
||||
const total = stackKeys.reduce((sum, key) => sum + data[key], 0);
|
||||
return total === 0 ? 0 : Math.round((data[currentKey] / total) * 100);
|
||||
};
|
||||
|
||||
export const CustomStackBar = React.memo<any>((props: any) => {
|
||||
const { fill, x, y, width, height, dataKey, stackKeys, payload, textClassName, showPercentage } = props;
|
||||
// Calculate text position
|
||||
const MIN_BAR_HEIGHT_FOR_INTERNAL = 14; // Minimum height needed to show text inside
|
||||
const TEXT_PADDING = Math.min(6, Math.abs(MIN_BAR_HEIGHT_FOR_INTERNAL - height / 2));
|
||||
const textY = y + height - TEXT_PADDING; // Position inside bar if tall enough
|
||||
// derived values
|
||||
const RADIUS = 2;
|
||||
const currentBarPercentage = calculatePercentage(payload, stackKeys, dataKey);
|
||||
|
||||
if (!height) return null;
|
||||
return (
|
||||
<g>
|
||||
<path
|
||||
d={`
|
||||
M${x + RADIUS},${y + height}
|
||||
L${x + RADIUS},${y}
|
||||
Q${x},${y} ${x},${y + RADIUS}
|
||||
L${x},${y + height - RADIUS}
|
||||
Q${x},${y + height} ${x + RADIUS},${y + height}
|
||||
L${x + width - RADIUS},${y + height}
|
||||
Q${x + width},${y + height} ${x + width},${y + height - RADIUS}
|
||||
L${x + width},${y + RADIUS}
|
||||
Q${x + width},${y} ${x + width - RADIUS},${y}
|
||||
L${x + RADIUS},${y}
|
||||
`}
|
||||
className={cn("transition-colors duration-200", fill)}
|
||||
fill="currentColor"
|
||||
/>
|
||||
{showPercentage &&
|
||||
height >= MIN_BAR_HEIGHT_FOR_INTERNAL &&
|
||||
currentBarPercentage !== undefined &&
|
||||
!Number.isNaN(currentBarPercentage) && (
|
||||
<text
|
||||
x={x + width / 2}
|
||||
y={textY}
|
||||
textAnchor="middle"
|
||||
className={cn("text-xs font-medium", textClassName)}
|
||||
fill="currentColor"
|
||||
>
|
||||
{currentBarPercentage}%
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
});
|
||||
CustomStackBar.displayName = "CustomStackBar";
|
||||
@@ -1,130 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Tooltip } from "recharts";
|
||||
// plane imports
|
||||
import { TStackedBarChartProps } from "@plane/types";
|
||||
import { cn } from "@plane/utils";
|
||||
// local components
|
||||
import { CustomStackBar } from "./bar";
|
||||
import { CustomXAxisTick, CustomYAxisTick } from "./tick";
|
||||
import { CustomTooltip } from "./tooltip";
|
||||
|
||||
// Common classnames
|
||||
const LABEL_CLASSNAME = "uppercase text-custom-text-300/60 text-sm tracking-wide";
|
||||
const AXIS_LINE_CLASSNAME = "text-custom-text-400/70";
|
||||
|
||||
export const StackedBarChart = React.memo(
|
||||
<K extends string, T extends string>({
|
||||
data,
|
||||
stacks,
|
||||
xAxis,
|
||||
yAxis,
|
||||
barSize = 40,
|
||||
className = "w-full h-96",
|
||||
tickCount = {
|
||||
x: undefined,
|
||||
y: 10,
|
||||
},
|
||||
showTooltip = true,
|
||||
}: TStackedBarChartProps<K, T>) => {
|
||||
// derived values
|
||||
const stackKeys = React.useMemo(() => stacks.map((stack) => stack.key), [stacks]);
|
||||
const stackDotClassNames = React.useMemo(
|
||||
() => stacks.reduce((acc, stack) => ({ ...acc, [stack.key]: stack.dotClassName }), {}),
|
||||
[stacks]
|
||||
);
|
||||
|
||||
const renderBars = React.useMemo(
|
||||
() =>
|
||||
stacks.map((stack) => (
|
||||
<Bar
|
||||
key={stack.key}
|
||||
dataKey={stack.key}
|
||||
stackId="a"
|
||||
fill={stack.fillClassName}
|
||||
shape={(props: any) => (
|
||||
<CustomStackBar
|
||||
{...props}
|
||||
stackKeys={stackKeys}
|
||||
textClassName={stack.textClassName}
|
||||
showPercentage={stack.showPercentage}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)),
|
||||
[stackKeys, stacks]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cn(className)}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
data={data}
|
||||
margin={{ top: 10, right: 10, left: 10, bottom: 40 }}
|
||||
barSize={barSize}
|
||||
className="recharts-wrapper"
|
||||
>
|
||||
<XAxis
|
||||
dataKey={xAxis.key}
|
||||
tick={(props) => <CustomXAxisTick {...props} />}
|
||||
tickLine={{
|
||||
stroke: "currentColor",
|
||||
className: AXIS_LINE_CLASSNAME,
|
||||
}}
|
||||
axisLine={{
|
||||
stroke: "currentColor",
|
||||
className: AXIS_LINE_CLASSNAME,
|
||||
}}
|
||||
label={{
|
||||
value: xAxis.label,
|
||||
dy: 28,
|
||||
className: LABEL_CLASSNAME,
|
||||
}}
|
||||
tickCount={tickCount.x}
|
||||
/>
|
||||
<YAxis
|
||||
domain={yAxis.domain}
|
||||
tickLine={{
|
||||
stroke: "currentColor",
|
||||
className: AXIS_LINE_CLASSNAME,
|
||||
}}
|
||||
axisLine={{
|
||||
stroke: "currentColor",
|
||||
className: AXIS_LINE_CLASSNAME,
|
||||
}}
|
||||
label={{
|
||||
value: yAxis.label,
|
||||
angle: -90,
|
||||
position: "bottom",
|
||||
offset: -24,
|
||||
dx: -16,
|
||||
className: LABEL_CLASSNAME,
|
||||
}}
|
||||
tick={(props) => <CustomYAxisTick {...props} />}
|
||||
tickCount={tickCount.y}
|
||||
allowDecimals={yAxis.allowDecimals ?? false}
|
||||
/>
|
||||
{showTooltip && (
|
||||
<Tooltip
|
||||
cursor={{ fill: "currentColor", className: "text-custom-background-90/80 cursor-pointer" }}
|
||||
content={({ active, label, payload }) => (
|
||||
<CustomTooltip
|
||||
active={active}
|
||||
label={label}
|
||||
payload={payload}
|
||||
stackKeys={stackKeys}
|
||||
stackDotClassNames={stackDotClassNames}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{renderBars}
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
StackedBarChart.displayName = "StackedBarChart";
|
||||
@@ -1,39 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import React from "react";
|
||||
// plane imports
|
||||
import { Card, ECardSpacing } from "@plane/ui";
|
||||
import { cn } from "@plane/utils";
|
||||
|
||||
type TStackedBarChartProps = {
|
||||
active: boolean | undefined;
|
||||
label: string | undefined;
|
||||
payload: any[] | undefined;
|
||||
stackKeys: string[];
|
||||
stackDotClassNames: Record<string, string>;
|
||||
};
|
||||
|
||||
export const CustomTooltip = React.memo(
|
||||
({ active, label, payload, stackKeys, stackDotClassNames }: TStackedBarChartProps) => {
|
||||
// derived values
|
||||
const filteredPayload = payload?.filter((item: any) => item.dataKey && stackKeys.includes(item.dataKey));
|
||||
|
||||
if (!active || !filteredPayload || !filteredPayload.length) return null;
|
||||
return (
|
||||
<Card className="flex flex-col" spacing={ECardSpacing.SM}>
|
||||
<p className="text-xs text-custom-text-100 font-medium border-b border-custom-border-200 pb-2 capitalize">
|
||||
{label}
|
||||
</p>
|
||||
{filteredPayload.map((item: any) => (
|
||||
<div key={item?.dataKey} className="flex items-center gap-2 text-xs capitalize">
|
||||
{stackDotClassNames[item?.dataKey] && (
|
||||
<div className={cn("size-2 rounded-full", stackDotClassNames[item?.dataKey])} />
|
||||
)}
|
||||
<span className="text-custom-text-300">{item?.name}:</span>
|
||||
<span className="font-medium text-custom-text-200">{item?.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
);
|
||||
CustomTooltip.displayName = "CustomTooltip";
|
||||
@@ -188,7 +188,7 @@ export const GptAssistantPopover: React.FC<Props> = (props) => {
|
||||
return (
|
||||
<Popover as="div" className={`relative w-min text-left`}>
|
||||
<Popover.Button as={Fragment}>
|
||||
<button ref={setReferenceElement} className="flex items-center">
|
||||
<button ref={setReferenceElement} className="flex items-center" tabIndex={-1}>
|
||||
{button}
|
||||
</button>
|
||||
</Popover.Button>
|
||||
|
||||
@@ -86,7 +86,11 @@ export const CycleListItemAction: FC<Props> = observer((props) => {
|
||||
|
||||
const showIssueCount = useMemo(() => cycleStatus === "draft" || cycleStatus === "upcoming", [cycleStatus]);
|
||||
|
||||
const showTransferIssues = routerProjectId && cycleDetails.pending_issues > 0 && cycleStatus === "completed";
|
||||
const transferableIssuesCount = cycleDetails
|
||||
? cycleDetails.total_issues - (cycleDetails.cancelled_issues + cycleDetails.completed_issues)
|
||||
: 0;
|
||||
|
||||
const showTransferIssues = routerProjectId && transferableIssuesCount > 0 && cycleStatus === "completed";
|
||||
|
||||
const isEditingAllowed = allowPermissions(
|
||||
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
|
||||
@@ -258,7 +262,7 @@ export const CycleListItemAction: FC<Props> = observer((props) => {
|
||||
}}
|
||||
>
|
||||
<TransferIcon className="fill-custom-primary-200 w-4" />
|
||||
<span>Transfer {cycleDetails.pending_issues} work items</span>
|
||||
<span>Transfer {transferableIssuesCount} work items</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -44,6 +44,9 @@ export const CycleQuickActions: React.FC<Props> = observer((props) => {
|
||||
const isArchived = !!cycleDetails?.archived_at;
|
||||
const isCompleted = cycleDetails?.status?.toLowerCase() === "completed";
|
||||
const isCurrentCycle = cycleDetails?.status?.toLowerCase() === "current";
|
||||
const transferrableIssuesCount = cycleDetails
|
||||
? cycleDetails.total_issues - (cycleDetails.cancelled_issues + cycleDetails.completed_issues)
|
||||
: 0;
|
||||
// auth
|
||||
const isEditingAllowed = allowPermissions(
|
||||
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
|
||||
@@ -178,7 +181,7 @@ export const CycleQuickActions: React.FC<Props> = observer((props) => {
|
||||
cycleId={cycleId}
|
||||
projectId={projectId}
|
||||
workspaceSlug={workspaceSlug}
|
||||
transferrableIssuesCount={cycleDetails.pending_issues}
|
||||
transferrableIssuesCount={transferrableIssuesCount}
|
||||
cycleName={cycleDetails.name}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -90,6 +90,7 @@ export const CycleDropdown: React.FC<Props> = observer((props) => {
|
||||
)}
|
||||
onClick={handleOnClick}
|
||||
disabled={disabled}
|
||||
tabIndex={tabIndex}
|
||||
>
|
||||
{button}
|
||||
</button>
|
||||
@@ -107,6 +108,7 @@ export const CycleDropdown: React.FC<Props> = observer((props) => {
|
||||
)}
|
||||
onClick={handleOnClick}
|
||||
disabled={disabled}
|
||||
tabIndex={tabIndex}
|
||||
>
|
||||
<DropdownButton
|
||||
className={buttonClassName}
|
||||
@@ -134,7 +136,6 @@ export const CycleDropdown: React.FC<Props> = observer((props) => {
|
||||
<ComboDropDown
|
||||
as="div"
|
||||
ref={dropdownRef}
|
||||
tabIndex={tabIndex}
|
||||
className={cn("h-full", className)}
|
||||
value={value}
|
||||
onChange={dropdownOnChange}
|
||||
|
||||
@@ -113,6 +113,7 @@ export const MemberDropdown: React.FC<Props> = observer((props) => {
|
||||
className={cn("clickable block h-full w-full outline-none", buttonContainerClassName)}
|
||||
onClick={handleOnClick}
|
||||
disabled={disabled}
|
||||
tabIndex={tabIndex}
|
||||
>
|
||||
{button}
|
||||
</button>
|
||||
@@ -130,6 +131,7 @@ export const MemberDropdown: React.FC<Props> = observer((props) => {
|
||||
)}
|
||||
onClick={handleOnClick}
|
||||
disabled={disabled}
|
||||
tabIndex={tabIndex}
|
||||
>
|
||||
<DropdownButton
|
||||
className={cn("text-xs", buttonClassName)}
|
||||
@@ -161,7 +163,6 @@ export const MemberDropdown: React.FC<Props> = observer((props) => {
|
||||
<ComboDropDown
|
||||
as="div"
|
||||
ref={dropdownRef}
|
||||
tabIndex={tabIndex}
|
||||
className={cn("h-full", className)}
|
||||
onChange={dropdownOnChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
|
||||
@@ -239,6 +239,7 @@ export const ModuleDropdown: React.FC<Props> = observer((props) => {
|
||||
)}
|
||||
onClick={handleOnClick}
|
||||
disabled={disabled}
|
||||
tabIndex={tabIndex}
|
||||
>
|
||||
{button}
|
||||
</button>
|
||||
@@ -256,6 +257,7 @@ export const ModuleDropdown: React.FC<Props> = observer((props) => {
|
||||
)}
|
||||
onClick={handleOnClick}
|
||||
disabled={disabled}
|
||||
tabIndex={tabIndex}
|
||||
>
|
||||
<DropdownButton
|
||||
className={buttonClassName}
|
||||
@@ -296,7 +298,6 @@ export const ModuleDropdown: React.FC<Props> = observer((props) => {
|
||||
<ComboDropDown
|
||||
as="div"
|
||||
ref={dropdownRef}
|
||||
tabIndex={tabIndex}
|
||||
className={cn("h-full", className)}
|
||||
onKeyDown={handleKeyDown}
|
||||
button={comboButton}
|
||||
|
||||
@@ -394,6 +394,7 @@ export const PriorityDropdown: React.FC<Props> = (props) => {
|
||||
className={cn("clickable block h-full w-full outline-none", buttonContainerClassName)}
|
||||
onClick={handleOnClick}
|
||||
disabled={disabled}
|
||||
tabIndex={tabIndex}
|
||||
>
|
||||
{button}
|
||||
</button>
|
||||
@@ -411,6 +412,7 @@ export const PriorityDropdown: React.FC<Props> = (props) => {
|
||||
)}
|
||||
onClick={handleOnClick}
|
||||
disabled={disabled}
|
||||
tabIndex={tabIndex}
|
||||
>
|
||||
<ButtonToRender
|
||||
priority={value ?? undefined}
|
||||
@@ -435,7 +437,6 @@ export const PriorityDropdown: React.FC<Props> = (props) => {
|
||||
<ComboDropDown
|
||||
as="div"
|
||||
ref={dropdownRef}
|
||||
tabIndex={tabIndex}
|
||||
className={cn(
|
||||
"h-full",
|
||||
{
|
||||
|
||||
@@ -141,11 +141,13 @@ export const StateDropdown: React.FC<Props> = observer((props) => {
|
||||
className={cn("clickable block h-full w-full outline-none", buttonContainerClassName)}
|
||||
onClick={handleOnClick}
|
||||
disabled={disabled}
|
||||
tabIndex={tabIndex}
|
||||
>
|
||||
{button}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
tabIndex={tabIndex}
|
||||
ref={setReferenceElement}
|
||||
type="button"
|
||||
className={cn(
|
||||
@@ -197,7 +199,6 @@ export const StateDropdown: React.FC<Props> = observer((props) => {
|
||||
<ComboDropDown
|
||||
as="div"
|
||||
ref={dropdownRef}
|
||||
tabIndex={tabIndex}
|
||||
className={cn("h-full", className)}
|
||||
value={stateValue}
|
||||
onChange={dropdownOnChange}
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useParams, usePathname } from "next/navigation";
|
||||
// plane imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { THomeWidgetKeys, THomeWidgetProps } from "@plane/types";
|
||||
// components
|
||||
import { SimpleEmptyState } from "@/components/empty-state";
|
||||
// hooks
|
||||
import { useProject } from "@/hooks/store";
|
||||
import { useHome } from "@/hooks/store/use-home";
|
||||
// plane web components
|
||||
import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path";
|
||||
import { HomePageHeader } from "@/plane-web/components/home/header";
|
||||
import { StickiesWidget } from "../stickies";
|
||||
import { RecentActivityWidget } from "./widgets";
|
||||
import { HomeLoader, NoProjectsEmptyState, RecentActivityWidget } from "./widgets";
|
||||
import { DashboardQuickLinks } from "./widgets/links";
|
||||
import { ManageWidgetsModal } from "./widgets/manage";
|
||||
|
||||
@@ -52,14 +53,21 @@ export const HOME_WIDGETS_LIST: {
|
||||
export const DashboardWidgets = observer(() => {
|
||||
// router
|
||||
const { workspaceSlug } = useParams();
|
||||
// navigation
|
||||
const pathname = usePathname();
|
||||
// store hooks
|
||||
const { toggleWidgetSettings, widgetsMap, showWidgetSettings, orderedWidgets, isAnyWidgetEnabled, loading } =
|
||||
useHome();
|
||||
const { loader } = useProject();
|
||||
// plane hooks
|
||||
const { t } = useTranslation();
|
||||
// store hooks
|
||||
const { toggleWidgetSettings, widgetsMap, showWidgetSettings, orderedWidgets, isAnyWidgetEnabled } = useHome();
|
||||
// derived values
|
||||
const noWidgetsResolvedPath = useResolvedAssetPath({ basePath: "/empty-state/dashboard/widgets" });
|
||||
|
||||
// derived values
|
||||
const isWikiApp = pathname.includes(`/${workspaceSlug.toString()}/pages`);
|
||||
if (!workspaceSlug) return null;
|
||||
if (loading || loader !== "loaded") return <HomeLoader />;
|
||||
|
||||
return (
|
||||
<div className="h-full w-full relative flex flex-col gap-7">
|
||||
@@ -69,6 +77,8 @@ export const DashboardWidgets = observer(() => {
|
||||
isModalOpen={showWidgetSettings}
|
||||
handleOnClose={() => toggleWidgetSettings(false)}
|
||||
/>
|
||||
{!isWikiApp && <NoProjectsEmptyState />}
|
||||
|
||||
{isAnyWidgetEnabled ? (
|
||||
<div className="flex flex-col divide-y-[1px] divide-custom-border-100">
|
||||
{orderedWidgets.map((key) => {
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
import React from "react";
|
||||
// mobx
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { Briefcase, Hotel, Users } from "lucide-react";
|
||||
import { Briefcase, Check, Hotel, Users, X } from "lucide-react";
|
||||
// plane ui
|
||||
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
||||
import { useLocalStorage } from "@plane/hooks";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// helpers
|
||||
import { cn } from "@plane/utils";
|
||||
import { getFileURL } from "@/helpers/file.helper";
|
||||
// hooks
|
||||
import { useCommandPalette, useEventTracker, useUser, useUserPermissions } from "@/hooks/store";
|
||||
import { useCommandPalette, useEventTracker, useProject, useUser, useUserPermissions } from "@/hooks/store";
|
||||
// plane web constants
|
||||
|
||||
export const NoProjectsEmptyState = () => {
|
||||
export const NoProjectsEmptyState = observer(() => {
|
||||
// navigation
|
||||
const { workspaceSlug } = useParams();
|
||||
// store hooks
|
||||
@@ -19,6 +23,14 @@ export const NoProjectsEmptyState = () => {
|
||||
const { toggleCreateProjectModal } = useCommandPalette();
|
||||
const { setTrackElement } = useEventTracker();
|
||||
const { data: currentUser } = useUser();
|
||||
const { joinedProjectIds } = useProject();
|
||||
// local storage
|
||||
const { storedValue, setValue } = useLocalStorage(`quickstart-guide-${workspaceSlug}`, {
|
||||
hide: false,
|
||||
visited_members: false,
|
||||
visited_workspace: false,
|
||||
visited_profile: false,
|
||||
});
|
||||
const { t } = useTranslation();
|
||||
// derived values
|
||||
const canCreateProject = allowPermissions(
|
||||
@@ -31,7 +43,8 @@ export const NoProjectsEmptyState = () => {
|
||||
id: "create-project",
|
||||
title: "home.empty.create_project.title",
|
||||
description: "home.empty.create_project.description",
|
||||
icon: <Briefcase className="w-[40px] h-[40px] text-custom-primary-100" />,
|
||||
icon: <Briefcase className="size-10" />,
|
||||
flag: "projects",
|
||||
cta: {
|
||||
text: "home.empty.create_project.cta",
|
||||
onClick: (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
|
||||
@@ -47,7 +60,8 @@ export const NoProjectsEmptyState = () => {
|
||||
id: "invite-team",
|
||||
title: "home.empty.invite_team.title",
|
||||
description: "home.empty.invite_team.description",
|
||||
icon: <Users className="w-[40px] h-[40px] text-custom-primary-100" />,
|
||||
icon: <Users className="size-10" />,
|
||||
flag: "visited_members",
|
||||
cta: {
|
||||
text: "home.empty.invite_team.cta",
|
||||
link: `/${workspaceSlug}/settings/members`,
|
||||
@@ -57,7 +71,8 @@ export const NoProjectsEmptyState = () => {
|
||||
id: "configure-workspace",
|
||||
title: "home.empty.configure_workspace.title",
|
||||
description: "home.empty.configure_workspace.description",
|
||||
icon: <Hotel className="w-[40px] h-[40px] text-custom-primary-100" />,
|
||||
icon: <Hotel className="size-10" />,
|
||||
flag: "visited_workspace",
|
||||
cta: {
|
||||
text: "home.empty.configure_workspace.cta",
|
||||
link: "settings",
|
||||
@@ -85,44 +100,94 @@ export const NoProjectsEmptyState = () => {
|
||||
</span>
|
||||
</Link>
|
||||
),
|
||||
flag: "visited_profile",
|
||||
cta: {
|
||||
text: "home.empty.personalize_account.cta",
|
||||
link: "/profile",
|
||||
},
|
||||
},
|
||||
];
|
||||
const isComplete = (type: string) => {
|
||||
switch (type) {
|
||||
case "projects":
|
||||
return joinedProjectIds?.length > 0;
|
||||
case "visited_members":
|
||||
return storedValue?.visited_members;
|
||||
case "visited_workspace":
|
||||
return storedValue?.visited_workspace;
|
||||
case "visited_profile":
|
||||
return storedValue?.visited_profile;
|
||||
}
|
||||
};
|
||||
|
||||
if (storedValue?.hide) return null;
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4">
|
||||
{EMPTY_STATE_DATA.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex flex-col items-center justify-center p-6 bg-custom-background-100 rounded-lg text-center border border-custom-border-200/40"
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="text-base font-semibold text-custom-text-350">{t("home.empty.quickstart_guide")}</div>
|
||||
<button
|
||||
className="text-custom-text-300 font-medium text-sm flex items-center gap-1"
|
||||
onClick={() => {
|
||||
if (!storedValue) return;
|
||||
setValue({ ...storedValue, hide: true });
|
||||
}}
|
||||
>
|
||||
<div className="grid place-items-center bg-custom-primary-100/10 rounded-full size-24 mb-3">
|
||||
<span className="text-3xl my-auto">{item.icon}</span>
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-custom-text-100 mb-2">{t(item.title)}</h3>
|
||||
<p className="text-sm text-custom-text-200 mb-4 w-[80%] flex-1">{t(item.description)}</p>
|
||||
|
||||
{item.cta.link ? (
|
||||
<Link
|
||||
href={item.cta.link}
|
||||
className="text-custom-primary-100 hover:text-custom-primary-200 text-sm font-medium"
|
||||
<X className="size-4" />
|
||||
{t("home.empty.not_right_now")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4">
|
||||
{EMPTY_STATE_DATA.map((item) => {
|
||||
const isStateComplete = isComplete(item.flag);
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex flex-col items-center justify-center p-6 bg-custom-background-100 rounded-lg text-center border border-custom-border-200/40"
|
||||
>
|
||||
{t(item.cta.text)}
|
||||
</Link>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="text-custom-primary-100 hover:text-custom-primary-200 text-sm font-medium"
|
||||
onClick={item.cta.onClick}
|
||||
>
|
||||
{t(item.cta.text)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<div
|
||||
className={cn(
|
||||
"grid place-items-center bg-custom-background-90 rounded-full size-20 mb-3 text-custom-text-400",
|
||||
{
|
||||
"text-custom-primary-100 bg-custom-primary-100/10": !isStateComplete,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<span className="text-3xl my-auto">{item.icon}</span>
|
||||
</div>
|
||||
<h3 className="text-base font-medium text-custom-text-100 mb-2">{t(item.title)}</h3>
|
||||
<p className="text-sm text-custom-text-300 mb-2">{t(item.description)}</p>
|
||||
{isStateComplete ? (
|
||||
<div className="flex items-center gap-2 bg-[#17a34a] rounded-full p-1">
|
||||
<Check className="size-3 text-custom-primary-100 text-white" />
|
||||
</div>
|
||||
) : item.cta.link ? (
|
||||
<Link
|
||||
href={item.cta.link}
|
||||
onClick={() => {
|
||||
if (!storedValue) return;
|
||||
setValue({
|
||||
...storedValue,
|
||||
[item.flag]: true,
|
||||
});
|
||||
}}
|
||||
className="text-custom-primary-100 hover:text-custom-primary-200 text-sm font-medium"
|
||||
>
|
||||
{t(item.cta.text)}
|
||||
</Link>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="text-custom-primary-100 hover:text-custom-primary-200 text-sm font-medium"
|
||||
onClick={item.cta.onClick}
|
||||
>
|
||||
{t(item.cta.text)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
@@ -42,9 +42,9 @@ export const useLinks = (workspaceSlug: string) => {
|
||||
});
|
||||
toggleLinkModal(false);
|
||||
} catch (error: any) {
|
||||
console.error("error", error);
|
||||
console.error("error", error?.data?.url?.error);
|
||||
setToast({
|
||||
message: error?.data?.error ?? t("links.toasts.not_created.message"),
|
||||
message: error?.data?.url?.error ?? t("links.toasts.not_created.message"),
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: t("links.toasts.not_created.title"),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import range from "lodash/range";
|
||||
// ui
|
||||
import { Loader } from "@plane/ui";
|
||||
|
||||
export const HomeLoader = () => (
|
||||
<>
|
||||
{range(3).map((index) => (
|
||||
<div key={index}>
|
||||
<div className="mb-2">
|
||||
<div className="text-base font-semibold text-custom-text-350 mb-4">
|
||||
<Loader.Item height="20px" width="100px" />
|
||||
</div>
|
||||
<Loader className="h-[110px] w-full flex items-center justify-center gap-2 text-custom-text-400 rounded">
|
||||
<Loader.Item height="100%" width="100%" />
|
||||
</Loader>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
@@ -1 +1,2 @@
|
||||
export * from "./loader";
|
||||
export * from "./home-loader";
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import useSWR from "swr";
|
||||
import { Briefcase, FileText } from "lucide-react";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
@@ -12,11 +11,9 @@ import { TActivityEntityData, THomeWidgetProps, TRecentActivityFilterKeys } from
|
||||
import { LayersIcon } from "@plane/ui";
|
||||
// components
|
||||
import { ContentOverflowWrapper } from "@/components/core/content-overflow-HOC";
|
||||
// hooks
|
||||
import { useProject } from "@/hooks/store";
|
||||
// plane web services
|
||||
import { WorkspaceService } from "@/plane-web/services";
|
||||
import { NoProjectsEmptyState, RecentsEmptyState } from "../empty-states";
|
||||
import { RecentsEmptyState } from "../empty-states";
|
||||
import { EWidgetKeys, WidgetLoader } from "../loaders";
|
||||
import { FiltersDropdown } from "./filters";
|
||||
import { RecentIssue } from "./issue";
|
||||
@@ -41,15 +38,9 @@ export const RecentActivityWidget: React.FC<TRecentWidgetProps> = observer((prop
|
||||
const { presetFilter, showFilterSelect = true, workspaceSlug } = props;
|
||||
// states
|
||||
const [filter, setFilter] = useState<TRecentActivityFilterKeys>(presetFilter ?? filters[0].name);
|
||||
// navigation
|
||||
const pathname = usePathname();
|
||||
const { t } = useTranslation();
|
||||
// ref
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
// store hooks
|
||||
const { joinedProjectIds, loader } = useProject();
|
||||
const { t } = useTranslation();
|
||||
// derived values
|
||||
const isWikiApp = pathname.includes(`/${workspaceSlug.toString()}/pages`);
|
||||
|
||||
const { data: recents, isLoading } = useSWR(
|
||||
workspaceSlug ? `WORKSPACE_RECENT_ACTIVITY_${workspaceSlug}_${filter}` : null,
|
||||
@@ -81,8 +72,6 @@ export const RecentActivityWidget: React.FC<TRecentWidgetProps> = observer((prop
|
||||
}
|
||||
};
|
||||
|
||||
if (loader === "loaded" && !isWikiApp && joinedProjectIds?.length === 0) return <NoProjectsEmptyState />;
|
||||
|
||||
if (!isLoading && recents?.length === 0)
|
||||
return (
|
||||
<div ref={ref} className="max-h-[500px] overflow-y-scroll">
|
||||
|
||||
@@ -24,12 +24,16 @@ export const RecentIssue = (props: BlockProps) => {
|
||||
const { setPeekIssue } = useIssueDetail();
|
||||
// derived values
|
||||
const issueDetails: TIssueEntityData = activity.entity_data as TIssueEntityData;
|
||||
|
||||
if (!issueDetails) return <></>;
|
||||
|
||||
const state = getStateById(issueDetails?.state);
|
||||
const workItemLink = `/${workspaceSlug}/projects/${issueDetails?.project_id}/issues/${issueDetails.id}`;
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
key={activity.id}
|
||||
itemLink=""
|
||||
itemLink={workItemLink}
|
||||
title={issueDetails?.name}
|
||||
prependTitleElement={
|
||||
<div className="flex-shrink-0 flex items-center gap-2">
|
||||
|
||||
@@ -28,6 +28,9 @@ export const RecentPage = (props: BlockProps) => {
|
||||
const { getUserDetails } = useMember();
|
||||
// derived values
|
||||
const pageDetails = activity.entity_data as TPageEntityData;
|
||||
|
||||
if (!pageDetails) return <></>;
|
||||
|
||||
const ownerDetails = getUserDetails(pageDetails?.owned_by);
|
||||
const pageLink = pageDetails.project_id
|
||||
? `/${workspaceSlug}/projects/${pageDetails.project_id}/pages/${pageDetails.id}`
|
||||
@@ -36,7 +39,7 @@ export const RecentPage = (props: BlockProps) => {
|
||||
return (
|
||||
<ListItem
|
||||
key={activity.id}
|
||||
itemLink=""
|
||||
itemLink={pageLink}
|
||||
title={getPageName(pageDetails?.name)}
|
||||
prependTitleElement={
|
||||
<div className="flex-shrink-0 flex items-center gap-2">
|
||||
|
||||
@@ -21,10 +21,14 @@ export const RecentProject = (props: BlockProps) => {
|
||||
// derived values
|
||||
const projectDetails: TProjectEntityData = activity.entity_data as TProjectEntityData;
|
||||
|
||||
if (!projectDetails) return <></>;
|
||||
|
||||
const projectLink = `/${workspaceSlug}/projects/${projectDetails?.id}/issues`;
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
key={activity.id}
|
||||
itemLink=""
|
||||
itemLink={projectLink}
|
||||
title={projectDetails?.name}
|
||||
prependTitleElement={
|
||||
<div className="flex-shrink-0 flex items-center gap-2">
|
||||
@@ -69,7 +73,7 @@ export const RecentProject = (props: BlockProps) => {
|
||||
onItemClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
router.push(`/${workspaceSlug}/projects/${projectDetails?.id}/issues`);
|
||||
router.push(projectLink);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -42,7 +42,7 @@ export const FilterStatus: FC<Props> = observer((props) => {
|
||||
return (
|
||||
<>
|
||||
<FilterHeader
|
||||
title={`Issue Status ${appliedFiltersCount > 0 ? ` (${appliedFiltersCount})` : ""}`}
|
||||
title={`Work item Status ${appliedFiltersCount > 0 ? ` (${appliedFiltersCount})` : ""}`}
|
||||
isPreviewEnabled={previewEnabled}
|
||||
handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)}
|
||||
/>
|
||||
|
||||
@@ -82,7 +82,7 @@ export const IssueDetailWidgetModals: FC<Props> = observer((props) => {
|
||||
|
||||
const handleCreateUpdateModalOnSubmit = async (_issue: TIssue) => {
|
||||
if (_issue.parent_id) {
|
||||
await subIssueOperations.addSubIssue(workspaceSlug, projectId, issueId, [_issue.id]);
|
||||
await subIssueOperations.addSubIssue(workspaceSlug, projectId, _issue.parent_id, [_issue.id]);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ export const IssueDefaultActivity: FC<TIssueDefaultActivity> = observer((props)
|
||||
icon={<LayersIcon width={14} height={14} className="text-custom-text-200" aria-hidden="true" />}
|
||||
ends={ends}
|
||||
>
|
||||
<>{activity.verb === "created" ? " created the work item." : " deleted an work item."}</>
|
||||
<>{activity.verb === "created" ? " created the work item." : " deleted a work item."}</>
|
||||
</IssueActivityBlockComponent>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -36,7 +36,8 @@ export const IssueLabelSelect: React.FC<IIssueLabelSelect> = observer((props) =>
|
||||
const [query, setQuery] = useState("");
|
||||
const [submitting, setSubmitting] = useState<boolean>(false);
|
||||
|
||||
const canCreateLabel = allowPermissions([EUserProjectRoles.ADMIN], EUserPermissionsLevel.PROJECT);
|
||||
const canCreateLabel =
|
||||
projectId && allowPermissions([EUserProjectRoles.ADMIN], EUserPermissionsLevel.PROJECT, workspaceSlug, projectId);
|
||||
|
||||
const projectLabels = getProjectLabels(projectId);
|
||||
|
||||
@@ -98,7 +99,7 @@ export const IssueLabelSelect: React.FC<IIssueLabelSelect> = observer((props) =>
|
||||
setQuery("");
|
||||
}
|
||||
|
||||
if (query !== "" && e.key === "Enter") {
|
||||
if (query !== "" && e.key === "Enter" && canCreateLabel) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
await handleAddLabel(query);
|
||||
|
||||
@@ -215,7 +215,7 @@ export const KanbanIssueBlock: React.FC<IssueBlockProps> = observer((props) => {
|
||||
>
|
||||
<ControlLink
|
||||
id={getIssueBlockId(issueId, groupId, subGroupId)}
|
||||
href={`/${workspaceSlug}/projects/${issue.project_id}/${issue.archived_at ? "archives/" : ""}${isEpic ? "epics" : "work items"}/${
|
||||
href={`/${workspaceSlug}/projects/${issue.project_id}/${issue.archived_at ? "archives/" : ""}${isEpic ? "epics" : "issues"}/${
|
||||
issue.id
|
||||
}`}
|
||||
ref={cardRef}
|
||||
|
||||
@@ -183,7 +183,7 @@ export const IssueBlock = observer((props: IssueBlockProps) => {
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex w-full truncate">
|
||||
<div className="flex gap-2 w-full truncate">
|
||||
<div className="flex flex-grow items-center gap-0.5 truncate">
|
||||
<div className="flex items-center gap-1" style={isSubIssue ? { marginLeft } : {}}>
|
||||
{/* select checkbox */}
|
||||
@@ -257,7 +257,7 @@ export const IssueBlock = observer((props: IssueBlockProps) => {
|
||||
disabled={isCurrentBlockDragging}
|
||||
renderByDefault={false}
|
||||
>
|
||||
<p className="w-full truncate cursor-pointer text-sm text-custom-text-100">{issue.name}</p>
|
||||
<p className="truncate cursor-pointer text-sm text-custom-text-100">{issue.name}</p>
|
||||
</Tooltip>
|
||||
{isEpic && <IssueStats issueId={issue.id} />}
|
||||
</div>
|
||||
|
||||
@@ -81,7 +81,8 @@ export const LabelDropdown = (props: ILabelDropdownProps) => {
|
||||
const storeLabels = getProjectLabels(projectId);
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
|
||||
const canCreateLabel = allowPermissions([EUserProjectRoles.ADMIN], EUserPermissionsLevel.PROJECT);
|
||||
const canCreateLabel =
|
||||
projectId && allowPermissions([EUserProjectRoles.ADMIN], EUserPermissionsLevel.PROJECT, workspaceSlug, projectId);
|
||||
|
||||
let projectLabels: IIssueLabel[] = defaultOptions;
|
||||
if (storeLabels && storeLabels.length > 0) projectLabels = storeLabels;
|
||||
@@ -157,7 +158,7 @@ export const LabelDropdown = (props: ILabelDropdownProps) => {
|
||||
setQuery("");
|
||||
}
|
||||
|
||||
if (query !== "" && e.key === "Enter") {
|
||||
if (query !== "" && e.key === "Enter" && canCreateLabel) {
|
||||
e.preventDefault();
|
||||
await handleAddLabel(query);
|
||||
}
|
||||
|
||||
@@ -262,7 +262,7 @@ export const IssueDescriptionEditor: React.FC<TIssueDescriptionEditorProps> = ob
|
||||
type="button"
|
||||
className="flex items-center gap-1 rounded px-1.5 py-1 text-xs bg-custom-background-90 hover:bg-custom-background-80"
|
||||
onClick={() => setGptAssistantModal((prevData) => !prevData)}
|
||||
tabIndex={getIndex("ai_assistant")}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<Sparkle className="h-4 w-4" />
|
||||
AI
|
||||
|
||||
@@ -37,7 +37,7 @@ export const IssueTitleInput: React.FC<TIssueTitleInputProps> = observer((props)
|
||||
return undefined;
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<Controller
|
||||
control={control}
|
||||
name="name"
|
||||
@@ -63,12 +63,12 @@ export const IssueTitleInput: React.FC<TIssueTitleInputProps> = observer((props)
|
||||
hasError={Boolean(errors.name)}
|
||||
placeholder={t("title")}
|
||||
className="w-full text-base"
|
||||
tabIndex={getIndex("name")}
|
||||
autoFocus
|
||||
tabIndex={getIndex("name")}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<span className="text-xs font-medium text-red-500">{errors?.name?.message}</span>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -485,7 +485,7 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
|
||||
setSelectedParentIssue={setSelectedParentIssue}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-4 py-3">
|
||||
<div className="flex items-center justify-end gap-4 py-3" tabIndex={getIndex("create_more")}>
|
||||
{!data?.id && (
|
||||
<div
|
||||
className="inline-flex items-center gap-1.5 cursor-pointer"
|
||||
@@ -493,7 +493,6 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") onCreateMoreToggleChange(!isCreateMoreToggleEnabled);
|
||||
}}
|
||||
tabIndex={getIndex("create_more")}
|
||||
role="button"
|
||||
>
|
||||
<ToggleSwitch value={isCreateMoreToggleEnabled} onChange={() => {}} size="sm" />
|
||||
@@ -501,34 +500,37 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="neutral-primary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (editorRef.current?.isEditorReadyToDiscard()) {
|
||||
onClose();
|
||||
} else {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: "Editor is still processing changes. Please wait before proceeding.",
|
||||
});
|
||||
}
|
||||
}}
|
||||
tabIndex={getIndex("discard_button")}
|
||||
>
|
||||
{t("discard")}
|
||||
</Button>
|
||||
<Button
|
||||
variant={moveToIssue ? "neutral-primary" : "primary"}
|
||||
type="submit"
|
||||
size="sm"
|
||||
ref={submitBtnRef}
|
||||
loading={isSubmitting}
|
||||
tabIndex={isDraft ? getIndex("submit_button") : getIndex("draft_button")}
|
||||
>
|
||||
{isSubmitting ? primaryButtonText.loading : primaryButtonText.default}
|
||||
</Button>
|
||||
<div tabIndex={getIndex("discard_button")}>
|
||||
<Button
|
||||
variant="neutral-primary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (editorRef.current?.isEditorReadyToDiscard()) {
|
||||
onClose();
|
||||
} else {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: "Editor is still processing changes. Please wait before proceeding.",
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t("discard")}
|
||||
</Button>
|
||||
</div>
|
||||
<div tabIndex={isDraft ? getIndex("submit_button") : getIndex("draft_button")}>
|
||||
<Button
|
||||
variant={moveToIssue ? "neutral-primary" : "primary"}
|
||||
type="submit"
|
||||
size="sm"
|
||||
ref={submitBtnRef}
|
||||
loading={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? primaryButtonText.loading : primaryButtonText.default}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{moveToIssue && (
|
||||
<Button
|
||||
variant="primary"
|
||||
|
||||
@@ -112,31 +112,29 @@ export const IssueLabelSelect: React.FC<Props> = observer((props) => {
|
||||
disabled={disabled}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<Combobox.Button as={Fragment}>
|
||||
<button
|
||||
type="button"
|
||||
ref={setReferenceElement}
|
||||
className={cn("h-full flex cursor-pointer items-center gap-2 text-xs text-custom-text-200", buttonClassName)}
|
||||
onClick={handleOnClick}
|
||||
>
|
||||
{label ? (
|
||||
label
|
||||
) : value && value.length > 0 ? (
|
||||
<span className="flex items-center justify-center gap-2 text-xs h-full">
|
||||
<IssueLabelsList
|
||||
labels={value.map((v) => projectLabels?.find((l) => l.id === v)) ?? []}
|
||||
length={3}
|
||||
showLength
|
||||
/>
|
||||
</span>
|
||||
) : (
|
||||
<div className="h-full flex items-center justify-center gap-1 rounded border-[0.5px] border-custom-border-300 px-2 py-1 text-xs hover:bg-custom-background-80">
|
||||
<Tag className="h-3 w-3 flex-shrink-0" />
|
||||
<span>{t("labels")}</span>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</Combobox.Button>
|
||||
<button
|
||||
type="button"
|
||||
ref={setReferenceElement}
|
||||
className={cn("h-full flex cursor-pointer items-center gap-2 text-xs text-custom-text-200", buttonClassName)}
|
||||
onClick={handleOnClick}
|
||||
>
|
||||
{label ? (
|
||||
label
|
||||
) : value && value.length > 0 ? (
|
||||
<span className="flex items-center justify-center gap-2 text-xs h-full">
|
||||
<IssueLabelsList
|
||||
labels={value.map((v) => projectLabels?.find((l) => l.id === v)) ?? []}
|
||||
length={3}
|
||||
showLength
|
||||
/>
|
||||
</span>
|
||||
) : (
|
||||
<div className="h-full flex items-center justify-center gap-1 rounded border-[0.5px] border-custom-border-300 px-2 py-1 text-xs hover:bg-custom-background-80">
|
||||
<Tag className="h-3 w-3 flex-shrink-0" />
|
||||
<span>{t("labels")}</span>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isDropdownOpen && (
|
||||
<Combobox.Options className="fixed z-10" static>
|
||||
|
||||
@@ -40,7 +40,7 @@ export const IssueList: FC<IIssueList> = observer((props) => {
|
||||
// hooks
|
||||
const {
|
||||
subIssues: { subIssuesByIssueId },
|
||||
} = useIssueDetail(issueServiceType);
|
||||
} = useIssueDetail();
|
||||
|
||||
const subIssueIds = subIssuesByIssueId(parentIssueId);
|
||||
|
||||
|
||||
@@ -465,7 +465,7 @@ export const SubIssuesRoot: FC<ISubIssuesRoot> = observer((props) => {
|
||||
}}
|
||||
onSubmit={async (_issue: TIssue) => {
|
||||
if (_issue.parent_id) {
|
||||
await subIssueOperations.addSubIssue(workspaceSlug, projectId, parentIssueId, [_issue.id]);
|
||||
await subIssueOperations.addSubIssue(workspaceSlug, projectId, _issue.parent_id, [_issue.id]);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -112,7 +112,7 @@ export const ProjectSettingsMemberDefaults: React.FC = observer(() => {
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center border-b border-custom-border-100 pb-3.5">
|
||||
<h3 className="text-xl font-medium">{t("defaults")}</h3>
|
||||
<h3 className="text-xl font-medium">{t("common.defaults")}</h3>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full flex-col gap-2 pb-4">
|
||||
|
||||
@@ -192,7 +192,7 @@ export const StickiesLayout = (props: TStickiesLayout) => {
|
||||
const columnCount = getColumnCount(containerWidth);
|
||||
|
||||
return (
|
||||
<div ref={ref} className="size-full min-h-[500px]">
|
||||
<div ref={ref} className="size-full">
|
||||
<StickiesList {...props} columnCount={columnCount} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
// types
|
||||
import { WORKSPACE_DELETED } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import type { IWorkspace } from "@plane/types";
|
||||
// ui
|
||||
import { Button, Input, TOAST_TYPE, setToast } from "@plane/ui";
|
||||
// constants
|
||||
// hooks
|
||||
import { cn } from "@plane/utils";
|
||||
import { useEventTracker, useWorkspace } from "@/hooks/store";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
|
||||
type Props = {
|
||||
data: IWorkspace | null;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
const defaultValues = {
|
||||
workspaceName: "",
|
||||
confirmDelete: "",
|
||||
};
|
||||
|
||||
export const DeleteWorkspaceForm: React.FC<Props> = observer((props) => {
|
||||
const { data, onClose } = props;
|
||||
// router
|
||||
const router = useAppRouter();
|
||||
// store hooks
|
||||
const { captureWorkspaceEvent } = useEventTracker();
|
||||
const { deleteWorkspace } = useWorkspace();
|
||||
const { t } = useTranslation();
|
||||
// form info
|
||||
const {
|
||||
control,
|
||||
formState: { errors, isSubmitting },
|
||||
handleSubmit,
|
||||
reset,
|
||||
watch,
|
||||
} = useForm({ defaultValues });
|
||||
|
||||
const canDelete = watch("workspaceName") === data?.name && watch("confirmDelete") === "delete my workspace";
|
||||
|
||||
const handleClose = () => {
|
||||
const timer = setTimeout(() => {
|
||||
reset(defaultValues);
|
||||
clearTimeout(timer);
|
||||
}, 350);
|
||||
|
||||
onClose();
|
||||
};
|
||||
|
||||
const onSubmit = async () => {
|
||||
if (!data || !canDelete) return;
|
||||
|
||||
await deleteWorkspace(data.slug)
|
||||
.then(() => {
|
||||
handleClose();
|
||||
router.push("/profile");
|
||||
captureWorkspaceEvent({
|
||||
eventName: WORKSPACE_DELETED,
|
||||
payload: {
|
||||
...data,
|
||||
state: "SUCCESS",
|
||||
element: "Workspace general settings page",
|
||||
},
|
||||
});
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: t("workspace_settings.settings.general.delete_modal.success_title"),
|
||||
message: t("workspace_settings.settings.general.delete_modal.success_message"),
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: t("workspace_settings.settings.general.delete_modal.error_title"),
|
||||
message: t("workspace_settings.settings.general.delete_modal.error_message"),
|
||||
});
|
||||
captureWorkspaceEvent({
|
||||
eventName: WORKSPACE_DELETED,
|
||||
payload: {
|
||||
...data,
|
||||
state: "FAILED",
|
||||
element: "Workspace general settings page",
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-6 p-6">
|
||||
<div className="flex flex-col sm:flex-row items-center sm:items-start gap-4">
|
||||
<span
|
||||
className={cn(
|
||||
"flex-shrink-0 grid place-items-center rounded-full size-12 sm:size-10 bg-red-500/20 text-red-100"
|
||||
)}
|
||||
>
|
||||
<AlertTriangle className="size-5 text-red-600" aria-hidden="true" />
|
||||
</span>
|
||||
<div>
|
||||
<div className="text-center sm:text-left">
|
||||
<h3 className="text-lg font-medium">{t("workspace_settings.settings.general.delete_modal.title")}</h3>
|
||||
<p className="mt-1 text-sm text-custom-text-200">
|
||||
You are about to delete the workspace <span className="break-words font-semibold">{data?.name}</span>. If
|
||||
you confirm, you will lose access to all your work data in this workspace without any way to restore it.
|
||||
Tread very carefully.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="text-custom-text-200 mt-4">
|
||||
<p className="break-words text-sm ">Type in this workspace's name to continue.</p>
|
||||
<Controller
|
||||
control={control}
|
||||
name="workspaceName"
|
||||
render={({ field: { value, onChange, ref } }) => (
|
||||
<Input
|
||||
id="workspaceName"
|
||||
name="workspaceName"
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
ref={ref}
|
||||
hasError={Boolean(errors.workspaceName)}
|
||||
placeholder={data?.name}
|
||||
className="mt-2 w-full"
|
||||
autoComplete="off"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="text-custom-text-200 mt-4">
|
||||
<p className="text-sm">
|
||||
For final confirmation, type{" "}
|
||||
<span className="font-medium text-custom-text-100">delete my workspace </span>
|
||||
below.
|
||||
</p>
|
||||
<Controller
|
||||
control={control}
|
||||
name="confirmDelete"
|
||||
render={({ field: { value, onChange, ref } }) => (
|
||||
<Input
|
||||
id="confirmDelete"
|
||||
name="confirmDelete"
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
ref={ref}
|
||||
hasError={Boolean(errors.confirmDelete)}
|
||||
placeholder=""
|
||||
className="mt-2 w-full"
|
||||
autoComplete="off"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="neutral-primary" size="sm" onClick={handleClose}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button variant="danger" size="sm" type="submit" disabled={!canDelete} loading={isSubmitting}>
|
||||
{isSubmitting ? t("deleting") : t("confirm")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user