Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0106689c89 | ||
|
|
31dc1f193f | ||
|
|
c2070a09ed | ||
|
|
5b7ee22c02 | ||
|
|
35b552d6f8 |
@@ -25,10 +25,6 @@ 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: 20.x
|
||||
node-version: 18.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: 20.x
|
||||
node-version: 18.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: 20.x
|
||||
node-version: 18.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: 20.x
|
||||
node-version: 18.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: 20.x
|
||||
node-version: 18.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: 20.x
|
||||
node-version: 18.x
|
||||
- run: yarn install
|
||||
- run: yarn build --filter=web
|
||||
|
||||
@@ -32,9 +32,10 @@ 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
|
||||
@@ -146,42 +147,6 @@ 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(
|
||||
cancelled_issues=Count(
|
||||
pending_issues=Count(
|
||||
"issue_cycle__issue__id",
|
||||
distinct=True,
|
||||
filter=Q(
|
||||
issue_cycle__issue__state__group__in=["cancelled"],
|
||||
issue_cycle__issue__state__group__in=["backlog", "unstarted", "started"],
|
||||
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",
|
||||
"cancelled_issues",
|
||||
"pending_issues",
|
||||
"assignee_ids",
|
||||
"status",
|
||||
"version",
|
||||
@@ -259,7 +259,7 @@ class CycleViewSet(BaseViewSet):
|
||||
# meta fields
|
||||
"is_favorite",
|
||||
"total_issues",
|
||||
"cancelled_issues",
|
||||
"pending_issues",
|
||||
"completed_issues",
|
||||
"assignee_ids",
|
||||
"status",
|
||||
|
||||
@@ -35,9 +35,7 @@ class LabelViewSet(BaseViewSet):
|
||||
.order_by("sort_order")
|
||||
)
|
||||
|
||||
@invalidate_cache(
|
||||
path="/api/workspaces/:slug/labels/", url_params=True, user=False, multiple=True
|
||||
)
|
||||
@invalidate_cache(path="/api/workspaces/:slug/labels/", url_params=True, user=False)
|
||||
@allow_permission([ROLE.ADMIN])
|
||||
def create(self, request, slug, project_id):
|
||||
try:
|
||||
|
||||
@@ -272,9 +272,10 @@ 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,16 +597,6 @@ 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,11 +7,9 @@ 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
|
||||
@@ -64,6 +62,12 @@ 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")
|
||||
@@ -72,6 +76,8 @@ 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):
|
||||
@@ -117,14 +123,7 @@ class WorkSpaceViewSet(BaseViewSet):
|
||||
role=20,
|
||||
company_role=request.data.get("company_role", ""),
|
||||
)
|
||||
|
||||
# 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.data, status=status.HTTP_201_CREATED)
|
||||
return Response(
|
||||
[serializer.errors[error][0] for error in serializer.errors],
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
@@ -167,9 +166,11 @@ class UserWorkSpacesEndpoint(BaseAPIView):
|
||||
.values("count")
|
||||
)
|
||||
|
||||
role = (
|
||||
WorkspaceMember.objects.filter(workspace=OuterRef("id"), member=request.user, is_active=True)
|
||||
.values("role")
|
||||
issue_count = (
|
||||
Issue.issue_objects.filter(workspace=OuterRef("id"))
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
|
||||
workspace = (
|
||||
@@ -181,19 +182,19 @@ class UserWorkSpacesEndpoint(BaseAPIView):
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(role=role, total_members=member_count)
|
||||
.select_related("owner")
|
||||
.annotate(total_members=member_count)
|
||||
.annotate(total_issues=issue_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_id=request.user.id)
|
||||
serializer.save(workspace_id=workspace.id, owner=request.user)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
# 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,10 +82,7 @@ def soft_delete_related_objects(app_label, model_name, instance_pk, using=None):
|
||||
)
|
||||
else:
|
||||
# Handle other relationships
|
||||
related_queryset = getattr(instance, related_name)(
|
||||
manager="objects"
|
||||
).all()
|
||||
|
||||
related_queryset = getattr(instance, related_name).all()
|
||||
for related_obj in related_queryset:
|
||||
if hasattr(related_obj, "deleted_at"):
|
||||
if not related_obj.deleted_at:
|
||||
|
||||
@@ -336,8 +336,6 @@ 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,17 +151,3 @@ 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==44.0.1
|
||||
cryptography==43.0.1
|
||||
# html validator
|
||||
lxml==5.2.1
|
||||
# s3
|
||||
|
||||
+1
-1
@@ -23,7 +23,7 @@
|
||||
"@plane/constants": "*",
|
||||
"@plane/editor": "*",
|
||||
"@plane/types": "*",
|
||||
"@sentry/node": "^9.0.1",
|
||||
"@sentry/node": "^8.28.0",
|
||||
"@sentry/profiling-node": "^8.28.0",
|
||||
"@tiptap/core": "2.10.4",
|
||||
"@tiptap/html": "2.11.0",
|
||||
|
||||
+1
-5
@@ -22,11 +22,7 @@
|
||||
"devDependencies": {
|
||||
"prettier": "latest",
|
||||
"prettier-plugin-tailwindcss": "^0.5.4",
|
||||
"turbo": "^2.4.1"
|
||||
},
|
||||
"resolutions": {
|
||||
"nanoid": "3.3.8",
|
||||
"esbuild": "0.25.0"
|
||||
"turbo": "^2.3.3"
|
||||
},
|
||||
"packageManager": "yarn@1.22.22",
|
||||
"name": "plane"
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
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,7 +1,6 @@
|
||||
export * from "./ai";
|
||||
export * from "./analytics";
|
||||
export * from "./auth";
|
||||
export * from "./chart";
|
||||
export * from "./endpoints";
|
||||
export * from "./file";
|
||||
export * from "./filter";
|
||||
|
||||
@@ -2,6 +2,7 @@ export const ISSUE_FORM_TAB_INDICES = [
|
||||
"name",
|
||||
"description_html",
|
||||
"feeling_lucky",
|
||||
"ai_assistant",
|
||||
"state_id",
|
||||
"priority",
|
||||
"assignee_ids",
|
||||
|
||||
@@ -51,10 +51,6 @@ 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,8 +376,6 @@
|
||||
|
||||
"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.",
|
||||
@@ -418,7 +416,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 recents yet."
|
||||
"default": "You don't have any recent items yet."
|
||||
},
|
||||
"filters": {
|
||||
"all": "All items",
|
||||
@@ -1253,19 +1251,9 @@
|
||||
"company_size": "Company size",
|
||||
"url": "Workspace URL",
|
||||
"update_workspace": "Update workspace",
|
||||
"delete_workspace": "Delete this workspace",
|
||||
"delete_workspace": "Delete 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 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."
|
||||
},
|
||||
"delete_btn": "Delete my workspace",
|
||||
"errors": {
|
||||
"name": {
|
||||
"required": "Name is required",
|
||||
|
||||
@@ -546,8 +546,6 @@
|
||||
|
||||
"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.",
|
||||
@@ -1422,19 +1420,9 @@
|
||||
"company_size": "Tamaño de la empresa",
|
||||
"url": "URL del espacio de trabajo",
|
||||
"update_workspace": "Actualizar 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."
|
||||
},
|
||||
"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",
|
||||
"errors": {
|
||||
"name": {
|
||||
"required": "El nombre es obligatorio",
|
||||
|
||||
@@ -546,8 +546,6 @@
|
||||
|
||||
"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.",
|
||||
@@ -1422,19 +1420,9 @@
|
||||
"company_size": "Taille de l'entreprise",
|
||||
"url": "URL de l'espace de travail",
|
||||
"update_workspace": "Mettre à jour l'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."
|
||||
},
|
||||
"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",
|
||||
"errors": {
|
||||
"name": {
|
||||
"required": "Le nom est requis",
|
||||
|
||||
@@ -546,8 +546,6 @@
|
||||
|
||||
"home": {
|
||||
"empty": {
|
||||
"quickstart_guide": "クイックスタートガイド",
|
||||
"not_right_now": "今はしない",
|
||||
"create_project": {
|
||||
"title": "プロジェクトを作成",
|
||||
"description": "Planeのほとんどはプロジェクトから始まります。",
|
||||
@@ -1422,19 +1420,9 @@
|
||||
"company_size": "会社の規模",
|
||||
"url": "ワークスペースURL",
|
||||
"update_workspace": "ワークスペースを更新",
|
||||
"delete_workspace": "このワークスペースを削除",
|
||||
"delete_workspace_description": "ワークスペースを削除すると、そのワークスペース内のすべてのデータとリソースが完全に削除され、復元することはできません。",
|
||||
"delete_btn": "このワークスペースを削除",
|
||||
"delete_modal": {
|
||||
"title": "このワークスペースを削除してもよろしいですか?",
|
||||
"description": "有料プランの無料トライアルが有効です。続行するには、まずトライアルをキャンセルしてください。",
|
||||
"dismiss": "閉じる",
|
||||
"cancel": "トライアルをキャンセル",
|
||||
"success_title": "ワークスペースが削除されました。",
|
||||
"success_message": "まもなくプロフィールページに移動します。",
|
||||
"error_title": "操作に失敗しました。",
|
||||
"error_message": "もう一度お試しください。"
|
||||
},
|
||||
"delete_workspace": "ワークスペースを削除",
|
||||
"delete_workspace_description": "ワークスペースを削除すると、そのワークスペース内のすべてのデータとリソースが永久に削除され、復元できなくなります。",
|
||||
"delete_btn": "ワークスペースを削除",
|
||||
"errors": {
|
||||
"name": {
|
||||
"required": "名前は必須です",
|
||||
|
||||
@@ -546,8 +546,6 @@
|
||||
|
||||
"home": {
|
||||
"empty": {
|
||||
"quickstart_guide": "快速入门指南",
|
||||
"not_right_now": "暂时不要",
|
||||
"create_project": {
|
||||
"title": "创建项目",
|
||||
"description": "在Plane中,大多数事情都从项目开始。",
|
||||
@@ -1422,19 +1420,9 @@
|
||||
"company_size": "公司规模",
|
||||
"url": "工作区网址",
|
||||
"update_workspace": "更新工作区",
|
||||
"delete_workspace": "删除此工作区",
|
||||
"delete_workspace_description": "删除工作区时,该工作区内的所有数据和资源将被永久删除,且无法恢复。",
|
||||
"delete_btn": "删除此工作区",
|
||||
"delete_modal": {
|
||||
"title": "确定要删除此工作区吗?",
|
||||
"description": "您目前正在试用我们的付费方案。请先取消试用后再继续。",
|
||||
"dismiss": "关闭",
|
||||
"cancel": "取消试用",
|
||||
"success_title": "工作区已删除。",
|
||||
"success_message": "即将跳转到您的个人资料页面。",
|
||||
"error_title": "操作失败。",
|
||||
"error_message": "请重试。"
|
||||
},
|
||||
"delete_workspace": "删除工作区",
|
||||
"delete_workspace_description": "删除工作区时,该工作区内的所有数据和资源将被永久删除且无法恢复。",
|
||||
"delete_btn": "删除我的工作区",
|
||||
"errors": {
|
||||
"name": {
|
||||
"required": "名称为必填项",
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
.next
|
||||
.turbo
|
||||
out/
|
||||
dist/
|
||||
build/
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"printWidth": 120,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "es5"
|
||||
}
|
||||
@@ -2,22 +2,9 @@
|
||||
"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": {
|
||||
"./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"
|
||||
"./globals.css": "./src/globals.css",
|
||||
"./components/*": "./src/*.tsx"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@plane/eslint-config": "*",
|
||||
@@ -26,5 +13,15 @@
|
||||
"@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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
/* 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";
|
||||
@@ -1,70 +0,0 @@
|
||||
/* 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";
|
||||
@@ -1,125 +0,0 @@
|
||||
/* 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";
|
||||
@@ -1,115 +0,0 @@
|
||||
/* 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";
|
||||
@@ -1,51 +0,0 @@
|
||||
"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";
|
||||
@@ -1,31 +0,0 @@
|
||||
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";
|
||||
@@ -1,41 +0,0 @@
|
||||
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";
|
||||
@@ -2,6 +2,53 @@
|
||||
@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;
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
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,7 +19,6 @@ 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
+16
-61
@@ -1,20 +1,29 @@
|
||||
export type TChartData<K extends string, T extends string> = {
|
||||
// required key
|
||||
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> = {
|
||||
[key in K]: string | number;
|
||||
} & Record<T, any>;
|
||||
|
||||
type TChartProps<K extends string, T extends string> = {
|
||||
data: TChartData<K, T>[];
|
||||
export type TStackedBarChartProps<K extends string, T extends string> = {
|
||||
data: TStackChartData<K, T>[];
|
||||
stacks: TStackItem<T>[];
|
||||
xAxis: {
|
||||
key: keyof TChartData<K, T>;
|
||||
key: keyof TStackChartData<K, T>;
|
||||
label: string;
|
||||
};
|
||||
yAxis: {
|
||||
key: keyof TChartData<K, T>;
|
||||
key: keyof TStackChartData<K, T>;
|
||||
label: string;
|
||||
domain?: [number, number];
|
||||
allowDecimals?: boolean;
|
||||
};
|
||||
barSize?: number;
|
||||
className?: string;
|
||||
tickCount?: {
|
||||
x?: number;
|
||||
@@ -23,60 +32,6 @@ type TChartProps<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;
|
||||
@@ -90,7 +45,7 @@ export type TreeMapItem = {
|
||||
| {
|
||||
fillClassName: string;
|
||||
}
|
||||
);
|
||||
);
|
||||
|
||||
export type TreeMapChartProps = {
|
||||
data: TreeMapItem[];
|
||||
|
||||
Vendored
+1
@@ -104,6 +104,7 @@ export interface ICycle extends TProgressSnapshot {
|
||||
project_detail: IProjectDetails;
|
||||
progress: any[];
|
||||
version: number;
|
||||
pending_issues: number;
|
||||
}
|
||||
|
||||
export interface CycleIssueResponse {
|
||||
|
||||
Vendored
+1
@@ -26,6 +26,7 @@ export * from "./waitlist";
|
||||
export * from "./webhook";
|
||||
export * from "./workspace-views";
|
||||
export * from "./common";
|
||||
export * from "./power-k";
|
||||
export * from "./pragmatic";
|
||||
export * from "./publish";
|
||||
export * from "./search";
|
||||
|
||||
Vendored
+24
@@ -0,0 +1,24 @@
|
||||
export type TPowerKPageKeys =
|
||||
// work-item actions
|
||||
| "change-work-item-assignee"
|
||||
| "change-work-item-priority"
|
||||
| "change-work-item-state"
|
||||
// module actions
|
||||
| "change-module-member"
|
||||
| "change-module-status"
|
||||
// configs
|
||||
| "workspace-settings"
|
||||
| "project-settings"
|
||||
| "profile-settings"
|
||||
// personalization
|
||||
| "change-theme";
|
||||
|
||||
export type TPowerKCreateActionKeys = "cycle" | "issue" | "module" | "page" | "project" | "view" | "workspace";
|
||||
export type TPowerKCreateAction = {
|
||||
key: TPowerKCreateActionKeys;
|
||||
icon: any;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
shortcut?: string;
|
||||
shouldRender?: boolean;
|
||||
};
|
||||
Vendored
+1
-2
@@ -21,9 +21,8 @@ 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 {
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
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,7 +14,6 @@ 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;
|
||||
@@ -58,8 +57,8 @@ export const IssuesLayoutSelection: FC<Props> = observer((props) => {
|
||||
}`}
|
||||
onClick={() => handleCurrentBoardView(layout.key)}
|
||||
>
|
||||
<IssueLayoutIcon
|
||||
layout={layout.key}
|
||||
<layout.icon
|
||||
strokeWidth={2}
|
||||
className={`size-3.5 ${activeLayout == layout.key ? "text-custom-text-100" : "text-custom-text-200"}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
@@ -12,17 +12,14 @@ 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 { WorkspaceMembersList } from "@/components/workspace";
|
||||
import { SendWorkspaceInvitationModal, WorkspaceMembersList } from "@/components/workspace";
|
||||
// constants
|
||||
// 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
|
||||
@@ -34,7 +31,7 @@ const WorkspaceMembersSettingsPage = observer(() => {
|
||||
const { workspaceUserInfo, allowPermissions } = useUserPermissions();
|
||||
const { captureEvent } = useEventTracker();
|
||||
const {
|
||||
workspace: { workspaceMemberIds, inviteMembersToWorkspace },
|
||||
workspace: { inviteMembersToWorkspace },
|
||||
} = useMember();
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const { t } = useTranslation();
|
||||
@@ -86,7 +83,6 @@ const WorkspaceMembersSettingsPage = observer(() => {
|
||||
title: "Error!",
|
||||
message: `${err.error ?? t("something_went_wrong_please_try_again")}`,
|
||||
});
|
||||
throw err;
|
||||
});
|
||||
};
|
||||
|
||||
@@ -111,13 +107,8 @@ const WorkspaceMembersSettingsPage = observer(() => {
|
||||
"opacity-60": !canPerformWorkspaceMemberActions,
|
||||
})}
|
||||
>
|
||||
<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="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="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
|
||||
@@ -133,7 +124,6 @@ const WorkspaceMembersSettingsPage = observer(() => {
|
||||
{t("workspace_settings.settings.members.add_member")}
|
||||
</Button>
|
||||
)}
|
||||
<BillingActionsButton canPerformWorkspaceAdminActions={canPerformWorkspaceAdminActions} />
|
||||
</div>
|
||||
<WorkspaceMembersList searchQuery={searchQuery} isAdmin={canPerformWorkspaceAdminActions} />
|
||||
</section>
|
||||
|
||||
+3
-3
@@ -1,9 +1,9 @@
|
||||
import { Metadata, Viewport } from "next";
|
||||
import Script from "next/script";
|
||||
// styles
|
||||
import "@/styles/globals.css";
|
||||
import "@/styles/command-pallette.css";
|
||||
import "@/styles/emoji.css";
|
||||
import "@/styles/globals.css";
|
||||
import "@/styles/power-k.css";
|
||||
import "@/styles/react-day-picker.css";
|
||||
// meta data info
|
||||
|
||||
@@ -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/?v=${Date.now()}`}
|
||||
href={`${API_BASE_URL}/api/users/me/workspaces/`}
|
||||
as="fetch"
|
||||
crossOrigin="use-credentials"
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useParams } from "next/navigation";
|
||||
// plane types
|
||||
import { TPowerKPageKeys } from "@plane/types";
|
||||
// components
|
||||
import { PowerKIssueActionsMenu } from "@/components/command-palette/power-k/context-based-actions";
|
||||
|
||||
type Props = {
|
||||
activePage: TPowerKPageKeys | undefined;
|
||||
handleClose: () => void;
|
||||
handleUpdateSearchTerm: (searchTerm: string) => void;
|
||||
handleUpdatePage: (page: TPowerKPageKeys) => void;
|
||||
};
|
||||
|
||||
export const PowerKContextBasedActions: React.FC<Props> = (props) => {
|
||||
const { activePage, handleClose, handleUpdateSearchTerm, handleUpdatePage } = props;
|
||||
// navigation
|
||||
const { issueId } = useParams();
|
||||
|
||||
return (
|
||||
<>
|
||||
{issueId && (
|
||||
<PowerKIssueActionsMenu
|
||||
handleClose={handleClose}
|
||||
activePage={activePage}
|
||||
handleUpdatePage={handleUpdatePage}
|
||||
issueId={issueId.toString()}
|
||||
handleUpdateSearchTerm={handleUpdateSearchTerm}
|
||||
/>
|
||||
)}
|
||||
{/* {moduleId && (
|
||||
<PowerKModuleActionsMenu
|
||||
handleClose={handleClose}
|
||||
activePage={activePage}
|
||||
handleUpdatePage={handleUpdatePage}
|
||||
moduleId={moduleId.toString()}
|
||||
handleUpdateSearchTerm={handleUpdateSearchTerm}
|
||||
/>
|
||||
)} */}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,105 @@
|
||||
import { FileText, FolderPlus, Layers, SquarePlus } from "lucide-react";
|
||||
// plane imports
|
||||
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
||||
import { TPowerKCreateAction, TPowerKCreateActionKeys } from "@plane/types";
|
||||
import { ContrastIcon, DiceIcon, LayersIcon } from "@plane/ui";
|
||||
// lib
|
||||
import { TAppRouterInstance } from "@/lib/n-progress/AppProgressBar";
|
||||
import { store } from "@/lib/store-context";
|
||||
|
||||
export const commonCreateActions = (
|
||||
router: TAppRouterInstance
|
||||
): Record<TPowerKCreateActionKeys, TPowerKCreateAction> => {
|
||||
// store
|
||||
const {
|
||||
canPerformAnyCreateAction,
|
||||
permission: { allowPermissions },
|
||||
} = store.user;
|
||||
const { workspaceProjectIds, currentProjectDetails } = store.projectRoot.project;
|
||||
const {
|
||||
toggleCreateCycleModal,
|
||||
toggleCreateIssueModal,
|
||||
toggleCreateModuleModal,
|
||||
toggleCreatePageModal,
|
||||
toggleCreateProjectModal,
|
||||
toggleCreateViewModal,
|
||||
} = store.commandPalette;
|
||||
// derived values
|
||||
const canCreateIssue = workspaceProjectIds && workspaceProjectIds.length > 0;
|
||||
const canCreateProject = allowPermissions(
|
||||
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
|
||||
EUserPermissionsLevel.WORKSPACE
|
||||
);
|
||||
|
||||
const options: Record<TPowerKCreateActionKeys, TPowerKCreateAction> = {
|
||||
issue: {
|
||||
key: "issue",
|
||||
onClick: () => toggleCreateIssueModal(true),
|
||||
label: "New work item",
|
||||
icon: LayersIcon,
|
||||
shortcut: "C",
|
||||
shouldRender: canCreateIssue,
|
||||
},
|
||||
page: {
|
||||
key: "page",
|
||||
onClick: () => toggleCreatePageModal({ isOpen: true }),
|
||||
label: "New page",
|
||||
icon: FileText,
|
||||
shortcut: "D",
|
||||
shouldRender: currentProjectDetails?.page_view && canPerformAnyCreateAction,
|
||||
},
|
||||
view: {
|
||||
key: "view",
|
||||
onClick: () => toggleCreateViewModal(true),
|
||||
label: "New view",
|
||||
icon: Layers,
|
||||
shortcut: "V",
|
||||
shouldRender: currentProjectDetails?.issue_views_view && canPerformAnyCreateAction,
|
||||
},
|
||||
cycle: {
|
||||
key: "cycle",
|
||||
onClick: () => toggleCreateCycleModal(true),
|
||||
label: "New cycle",
|
||||
icon: ContrastIcon,
|
||||
shortcut: "Q",
|
||||
shouldRender: currentProjectDetails?.cycle_view && canPerformAnyCreateAction,
|
||||
},
|
||||
module: {
|
||||
key: "module",
|
||||
onClick: () => toggleCreateModuleModal(true),
|
||||
label: "New module",
|
||||
icon: DiceIcon,
|
||||
shortcut: "M",
|
||||
shouldRender: currentProjectDetails?.module_view && canPerformAnyCreateAction,
|
||||
},
|
||||
project: {
|
||||
key: "project",
|
||||
onClick: () => toggleCreateProjectModal(true),
|
||||
label: "New project",
|
||||
icon: FolderPlus,
|
||||
shortcut: "P",
|
||||
shouldRender: canCreateProject,
|
||||
},
|
||||
workspace: {
|
||||
key: "workspace",
|
||||
onClick: () => router.push("/create-workspace"),
|
||||
label: "New workspace",
|
||||
icon: SquarePlus,
|
||||
},
|
||||
};
|
||||
|
||||
return options;
|
||||
};
|
||||
|
||||
export const getCreateActionsList = (router: TAppRouterInstance): TPowerKCreateAction[] => {
|
||||
const optionsList = commonCreateActions(router);
|
||||
return [
|
||||
optionsList["issue"],
|
||||
optionsList["page"],
|
||||
optionsList["view"],
|
||||
optionsList["cycle"],
|
||||
optionsList["module"],
|
||||
optionsList["project"],
|
||||
optionsList["workspace"],
|
||||
];
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./context-based-actions";
|
||||
export * from "./create-actions";
|
||||
export * from "./placeholder";
|
||||
@@ -0,0 +1,19 @@
|
||||
// plane types
|
||||
import { TPowerKPageKeys } from "@plane/types";
|
||||
|
||||
export const POWER_K_PLACEHOLDER_TEXT: Record<TPowerKPageKeys | "default", string> = {
|
||||
// issue actions
|
||||
"change-issue-assignee": "Assign to",
|
||||
"change-issue-priority": "Change priority",
|
||||
"change-issue-state": "Change state",
|
||||
// module actions
|
||||
"change-module-member": "Add/remove members",
|
||||
"change-module-status": "Change status",
|
||||
// configs
|
||||
"workspace-settings": "Search workspace settings",
|
||||
"project-settings": "Search project settings",
|
||||
"profile-settings": "Search profile settings",
|
||||
// personalization
|
||||
"change-theme": "Change theme",
|
||||
default: "Type a command or search",
|
||||
};
|
||||
@@ -1,7 +0,0 @@
|
||||
import { IWorkspace } from "@plane/types";
|
||||
|
||||
type TProps = {
|
||||
workspace: IWorkspace;
|
||||
};
|
||||
|
||||
export const SubscriptionPill = (props: TProps) => <></>;
|
||||
@@ -1,10 +0,0 @@
|
||||
"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,2 +1 @@
|
||||
export * from "./root";
|
||||
export * from "./billing-actions-button";
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
"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,4 +2,3 @@ export * from "./edition-badge";
|
||||
export * from "./upgrade-badge";
|
||||
export * from "./billing";
|
||||
export * from "./delete-workspace-section";
|
||||
export * from "./members";
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./invite-modal";
|
||||
@@ -1,60 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
});
|
||||
@@ -1,84 +0,0 @@
|
||||
"use client";
|
||||
import { Command } from "cmdk";
|
||||
import { observer } from "mobx-react";
|
||||
import { FileText, GithubIcon, MessageSquare, Rocket } from "lucide-react";
|
||||
// ui
|
||||
import { DiscordIcon } from "@plane/ui";
|
||||
// hooks
|
||||
import { useCommandPalette, useTransient } from "@/hooks/store";
|
||||
|
||||
type Props = {
|
||||
closePalette: () => void;
|
||||
};
|
||||
|
||||
export const CommandPaletteHelpActions: React.FC<Props> = observer((props) => {
|
||||
const { closePalette } = props;
|
||||
// hooks
|
||||
const { toggleShortcutModal } = useCommandPalette();
|
||||
const { toggleIntercom } = useTransient();
|
||||
|
||||
return (
|
||||
<Command.Group heading="Help">
|
||||
<Command.Item
|
||||
onSelect={() => {
|
||||
closePalette();
|
||||
toggleShortcutModal(true);
|
||||
}}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-custom-text-200">
|
||||
<Rocket className="h-3.5 w-3.5" />
|
||||
Open keyboard shortcuts
|
||||
</div>
|
||||
</Command.Item>
|
||||
<Command.Item
|
||||
onSelect={() => {
|
||||
closePalette();
|
||||
window.open("https://docs.plane.so/", "_blank");
|
||||
}}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-custom-text-200">
|
||||
<FileText className="h-3.5 w-3.5" />
|
||||
Open Plane documentation
|
||||
</div>
|
||||
</Command.Item>
|
||||
<Command.Item
|
||||
onSelect={() => {
|
||||
closePalette();
|
||||
window.open("https://discord.com/invite/A92xrEGCge", "_blank");
|
||||
}}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-custom-text-200">
|
||||
<DiscordIcon className="h-4 w-4" color="rgb(var(--color-text-200))" />
|
||||
Join our Discord
|
||||
</div>
|
||||
</Command.Item>
|
||||
<Command.Item
|
||||
onSelect={() => {
|
||||
closePalette();
|
||||
window.open("https://github.com/makeplane/plane/issues/new/choose", "_blank");
|
||||
}}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-custom-text-200">
|
||||
<GithubIcon className="h-4 w-4" color="rgb(var(--color-text-200))" />
|
||||
Report a bug
|
||||
</div>
|
||||
</Command.Item>
|
||||
<Command.Item
|
||||
onSelect={() => {
|
||||
closePalette();
|
||||
toggleIntercom(true);
|
||||
}}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-custom-text-200">
|
||||
<MessageSquare className="h-3.5 w-3.5" />
|
||||
Chat with us
|
||||
</div>
|
||||
</Command.Item>
|
||||
</Command.Group>
|
||||
);
|
||||
});
|
||||
@@ -1,6 +0,0 @@
|
||||
export * from "./issue-actions";
|
||||
export * from "./help-actions";
|
||||
export * from "./project-actions";
|
||||
export * from "./search-results";
|
||||
export * from "./theme-actions";
|
||||
export * from "./workspace-settings-actions";
|
||||
@@ -1,163 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Command } from "cmdk";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { LinkIcon, Signal, Trash2, UserMinus2, UserPlus2, Users } from "lucide-react";
|
||||
import { EIssuesStoreType } from "@plane/constants";
|
||||
import { TIssue } from "@plane/types";
|
||||
// hooks
|
||||
import { DoubleCircleIcon, TOAST_TYPE, setToast } from "@plane/ui";
|
||||
// helpers
|
||||
import { copyTextToClipboard } from "@/helpers/string.helper";
|
||||
// hooks
|
||||
import { useCommandPalette, useIssues, useUser } from "@/hooks/store";
|
||||
|
||||
type Props = {
|
||||
closePalette: () => void;
|
||||
issueDetails: TIssue | undefined;
|
||||
pages: string[];
|
||||
setPages: (pages: string[]) => void;
|
||||
setPlaceholder: (placeholder: string) => void;
|
||||
setSearchTerm: (searchTerm: string) => void;
|
||||
};
|
||||
|
||||
export const CommandPaletteIssueActions: React.FC<Props> = observer((props) => {
|
||||
const { closePalette, issueDetails, pages, setPages, setPlaceholder, setSearchTerm } = props;
|
||||
// router
|
||||
const { workspaceSlug, projectId, issueId } = useParams();
|
||||
// hooks
|
||||
const {
|
||||
issues: { updateIssue },
|
||||
} = useIssues(EIssuesStoreType.PROJECT);
|
||||
const { toggleCommandPaletteModal, toggleDeleteIssueModal } = useCommandPalette();
|
||||
const { data: currentUser } = useUser();
|
||||
|
||||
const handleUpdateIssue = async (formData: Partial<TIssue>) => {
|
||||
if (!workspaceSlug || !projectId || !issueDetails) return;
|
||||
|
||||
const payload = { ...formData };
|
||||
await updateIssue(workspaceSlug.toString(), projectId.toString(), issueDetails.id, payload).catch((e) => {
|
||||
console.error(e);
|
||||
});
|
||||
};
|
||||
|
||||
const handleIssueAssignees = (assignee: string) => {
|
||||
if (!issueDetails || !assignee) return;
|
||||
|
||||
closePalette();
|
||||
const updatedAssignees = issueDetails.assignee_ids ?? [];
|
||||
|
||||
if (updatedAssignees.includes(assignee)) updatedAssignees.splice(updatedAssignees.indexOf(assignee), 1);
|
||||
else updatedAssignees.push(assignee);
|
||||
|
||||
handleUpdateIssue({ assignee_ids: updatedAssignees });
|
||||
};
|
||||
|
||||
const deleteIssue = () => {
|
||||
toggleCommandPaletteModal(false);
|
||||
toggleDeleteIssueModal(true);
|
||||
};
|
||||
|
||||
const copyIssueUrlToClipboard = () => {
|
||||
if (!issueId) return;
|
||||
|
||||
const url = new URL(window.location.href);
|
||||
copyTextToClipboard(url.href)
|
||||
.then(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Copied to clipboard",
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Some error occurred",
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Command.Group heading="Work item actions">
|
||||
<Command.Item
|
||||
onSelect={() => {
|
||||
setPlaceholder("Change state...");
|
||||
setSearchTerm("");
|
||||
setPages([...pages, "change-issue-state"]);
|
||||
}}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-custom-text-200">
|
||||
<DoubleCircleIcon className="h-3.5 w-3.5" />
|
||||
Change state...
|
||||
</div>
|
||||
</Command.Item>
|
||||
<Command.Item
|
||||
onSelect={() => {
|
||||
setPlaceholder("Change priority...");
|
||||
setSearchTerm("");
|
||||
setPages([...pages, "change-issue-priority"]);
|
||||
}}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-custom-text-200">
|
||||
<Signal className="h-3.5 w-3.5" />
|
||||
Change priority...
|
||||
</div>
|
||||
</Command.Item>
|
||||
<Command.Item
|
||||
onSelect={() => {
|
||||
setPlaceholder("Assign to...");
|
||||
setSearchTerm("");
|
||||
setPages([...pages, "change-issue-assignee"]);
|
||||
}}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-custom-text-200">
|
||||
<Users className="h-3.5 w-3.5" />
|
||||
Assign to...
|
||||
</div>
|
||||
</Command.Item>
|
||||
<Command.Item
|
||||
onSelect={() => {
|
||||
handleIssueAssignees(currentUser?.id ?? "");
|
||||
setSearchTerm("");
|
||||
}}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-custom-text-200">
|
||||
{issueDetails?.assignee_ids.includes(currentUser?.id ?? "") ? (
|
||||
<>
|
||||
<UserMinus2 className="h-3.5 w-3.5" />
|
||||
Un-assign from me
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<UserPlus2 className="h-3.5 w-3.5" />
|
||||
Assign to me
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Command.Item>
|
||||
<Command.Item onSelect={deleteIssue} className="focus:outline-none">
|
||||
<div className="flex items-center gap-2 text-custom-text-200">
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Delete work item
|
||||
</div>
|
||||
</Command.Item>
|
||||
<Command.Item
|
||||
onSelect={() => {
|
||||
closePalette();
|
||||
copyIssueUrlToClipboard();
|
||||
}}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-custom-text-200">
|
||||
<LinkIcon className="h-3.5 w-3.5" />
|
||||
Copy work item URL
|
||||
</div>
|
||||
</Command.Item>
|
||||
</Command.Group>
|
||||
);
|
||||
});
|
||||
@@ -1,100 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Command } from "cmdk";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { Check } from "lucide-react";
|
||||
// plane constants
|
||||
import { EIssuesStoreType } from "@plane/constants";
|
||||
// plane types
|
||||
import { TIssue } from "@plane/types";
|
||||
// plane ui
|
||||
import { Avatar } from "@plane/ui";
|
||||
// helpers
|
||||
import { getFileURL } from "@/helpers/file.helper";
|
||||
// hooks
|
||||
import { useIssues, useMember } from "@/hooks/store";
|
||||
|
||||
type Props = {
|
||||
closePalette: () => void;
|
||||
issue: TIssue;
|
||||
};
|
||||
|
||||
export const ChangeIssueAssignee: React.FC<Props> = observer((props) => {
|
||||
const { closePalette, issue } = props;
|
||||
// router params
|
||||
const { workspaceSlug, projectId } = useParams();
|
||||
// store
|
||||
const {
|
||||
issues: { updateIssue },
|
||||
} = useIssues(EIssuesStoreType.PROJECT);
|
||||
const {
|
||||
project: { projectMemberIds, getProjectMemberDetails },
|
||||
} = useMember();
|
||||
|
||||
const options =
|
||||
projectMemberIds
|
||||
?.map((userId) => {
|
||||
if (!projectId) return;
|
||||
const memberDetails = getProjectMemberDetails(userId, projectId.toString());
|
||||
|
||||
return {
|
||||
value: `${memberDetails?.member?.id}`,
|
||||
query: `${memberDetails?.member?.display_name}`,
|
||||
content: (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar
|
||||
name={memberDetails?.member?.display_name}
|
||||
src={getFileURL(memberDetails?.member?.avatar_url ?? "")}
|
||||
showTooltip={false}
|
||||
/>
|
||||
{memberDetails?.member?.display_name}
|
||||
</div>
|
||||
{issue.assignee_ids.includes(memberDetails?.member?.id ?? "") && (
|
||||
<div>
|
||||
<Check className="h-3 w-3" />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
};
|
||||
})
|
||||
.filter((o) => o !== undefined) ?? [];
|
||||
|
||||
const handleUpdateIssue = async (formData: Partial<TIssue>) => {
|
||||
if (!workspaceSlug || !projectId || !issue) return;
|
||||
|
||||
const payload = { ...formData };
|
||||
await updateIssue(workspaceSlug.toString(), projectId.toString(), issue.id, payload).catch((e) => {
|
||||
console.error(e);
|
||||
});
|
||||
};
|
||||
|
||||
const handleIssueAssignees = (assignee: string) => {
|
||||
const updatedAssignees = issue.assignee_ids ?? [];
|
||||
|
||||
if (updatedAssignees.includes(assignee)) updatedAssignees.splice(updatedAssignees.indexOf(assignee), 1);
|
||||
else updatedAssignees.push(assignee);
|
||||
|
||||
handleUpdateIssue({ assignee_ids: updatedAssignees });
|
||||
closePalette();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{options.map(
|
||||
(option) =>
|
||||
option && (
|
||||
<Command.Item
|
||||
key={option.value}
|
||||
onSelect={() => handleIssueAssignees(option.value)}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
{option.content}
|
||||
</Command.Item>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -1,58 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Command } from "cmdk";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { Check } from "lucide-react";
|
||||
// plane constants
|
||||
import { EIssuesStoreType, ISSUE_PRIORITIES } from "@plane/constants";
|
||||
// plane types
|
||||
import { TIssue, TIssuePriorities } from "@plane/types";
|
||||
// mobx store
|
||||
import { PriorityIcon } from "@plane/ui";
|
||||
import { useIssues } from "@/hooks/store";
|
||||
// ui
|
||||
// types
|
||||
// constants
|
||||
|
||||
type Props = {
|
||||
closePalette: () => void;
|
||||
issue: TIssue;
|
||||
};
|
||||
|
||||
export const ChangeIssuePriority: React.FC<Props> = observer((props) => {
|
||||
const { closePalette, issue } = props;
|
||||
// router params
|
||||
const { workspaceSlug, projectId } = useParams();
|
||||
const {
|
||||
issues: { updateIssue },
|
||||
} = useIssues(EIssuesStoreType.PROJECT);
|
||||
|
||||
const submitChanges = async (formData: Partial<TIssue>) => {
|
||||
if (!workspaceSlug || !projectId || !issue) return;
|
||||
|
||||
const payload = { ...formData };
|
||||
await updateIssue(workspaceSlug.toString(), projectId.toString(), issue.id, payload).catch((e) => {
|
||||
console.error(e);
|
||||
});
|
||||
};
|
||||
|
||||
const handleIssueState = (priority: TIssuePriorities) => {
|
||||
submitChanges({ priority });
|
||||
closePalette();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{ISSUE_PRIORITIES.map((priority) => (
|
||||
<Command.Item key={priority.key} onSelect={() => handleIssueState(priority.key)} className="focus:outline-none">
|
||||
<div className="flex items-center space-x-3">
|
||||
<PriorityIcon priority={priority.key} />
|
||||
<span className="capitalize">{priority.title ?? "None"}</span>
|
||||
</div>
|
||||
<div>{priority.key === issue.priority && <Check className="h-3 w-3" />}</div>
|
||||
</Command.Item>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -1,66 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Command } from "cmdk";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// hooks
|
||||
import { Check } from "lucide-react";
|
||||
import { EIssuesStoreType } from "@plane/constants";
|
||||
import { TIssue } from "@plane/types";
|
||||
import { Spinner, StateGroupIcon } from "@plane/ui";
|
||||
import { useProjectState, useIssues } from "@/hooks/store";
|
||||
// ui
|
||||
// icons
|
||||
// types
|
||||
|
||||
type Props = {
|
||||
closePalette: () => void;
|
||||
issue: TIssue;
|
||||
};
|
||||
|
||||
export const ChangeIssueState: React.FC<Props> = observer((props) => {
|
||||
const { closePalette, issue } = props;
|
||||
// router params
|
||||
const { workspaceSlug, projectId } = useParams();
|
||||
// store hooks
|
||||
const {
|
||||
issues: { updateIssue },
|
||||
} = useIssues(EIssuesStoreType.PROJECT);
|
||||
const { projectStates } = useProjectState();
|
||||
|
||||
const submitChanges = async (formData: Partial<TIssue>) => {
|
||||
if (!workspaceSlug || !projectId || !issue) return;
|
||||
|
||||
const payload = { ...formData };
|
||||
await updateIssue(workspaceSlug.toString(), projectId.toString(), issue.id, payload).catch((e) => {
|
||||
console.error(e);
|
||||
});
|
||||
};
|
||||
|
||||
const handleIssueState = (stateId: string) => {
|
||||
submitChanges({ state_id: stateId });
|
||||
closePalette();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{projectStates ? (
|
||||
projectStates.length > 0 ? (
|
||||
projectStates.map((state) => (
|
||||
<Command.Item key={state.id} onSelect={() => handleIssueState(state.id)} className="focus:outline-none">
|
||||
<div className="flex items-center space-x-3">
|
||||
<StateGroupIcon stateGroup={state.group} color={state.color} height="16px" width="16px" />
|
||||
<p>{state.name}</p>
|
||||
</div>
|
||||
<div>{state.id === issue.state_id && <Check className="h-3 w-3" />}</div>
|
||||
</Command.Item>
|
||||
))
|
||||
) : (
|
||||
<div className="text-center">No states found</div>
|
||||
)
|
||||
) : (
|
||||
<Spinner />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -1,4 +0,0 @@
|
||||
export * from "./actions-list";
|
||||
export * from "./change-state";
|
||||
export * from "./change-priority";
|
||||
export * from "./change-assignee";
|
||||
@@ -1,89 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Command } from "cmdk";
|
||||
import { ContrastIcon, FileText, Layers } from "lucide-react";
|
||||
// hooks
|
||||
import { DiceIcon } from "@plane/ui";
|
||||
import { useCommandPalette, useEventTracker } from "@/hooks/store";
|
||||
// ui
|
||||
|
||||
type Props = {
|
||||
closePalette: () => void;
|
||||
};
|
||||
|
||||
export const CommandPaletteProjectActions: React.FC<Props> = (props) => {
|
||||
const { closePalette } = props;
|
||||
// store hooks
|
||||
const { toggleCreateCycleModal, toggleCreateModuleModal, toggleCreatePageModal, toggleCreateViewModal } =
|
||||
useCommandPalette();
|
||||
const { setTrackElement } = useEventTracker();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Command.Group heading="Cycle">
|
||||
<Command.Item
|
||||
onSelect={() => {
|
||||
closePalette();
|
||||
setTrackElement("Command palette");
|
||||
toggleCreateCycleModal(true);
|
||||
}}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-custom-text-200">
|
||||
<ContrastIcon className="h-3.5 w-3.5" />
|
||||
Create new cycle
|
||||
</div>
|
||||
<kbd>Q</kbd>
|
||||
</Command.Item>
|
||||
</Command.Group>
|
||||
<Command.Group heading="Module">
|
||||
<Command.Item
|
||||
onSelect={() => {
|
||||
closePalette();
|
||||
setTrackElement("Command palette");
|
||||
toggleCreateModuleModal(true);
|
||||
}}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-custom-text-200">
|
||||
<DiceIcon className="h-3.5 w-3.5" />
|
||||
Create new module
|
||||
</div>
|
||||
<kbd>M</kbd>
|
||||
</Command.Item>
|
||||
</Command.Group>
|
||||
<Command.Group heading="View">
|
||||
<Command.Item
|
||||
onSelect={() => {
|
||||
closePalette();
|
||||
setTrackElement("Command palette");
|
||||
toggleCreateViewModal(true);
|
||||
}}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-custom-text-200">
|
||||
<Layers className="h-3.5 w-3.5" />
|
||||
Create new view
|
||||
</div>
|
||||
<kbd>V</kbd>
|
||||
</Command.Item>
|
||||
</Command.Group>
|
||||
<Command.Group heading="Page">
|
||||
<Command.Item
|
||||
onSelect={() => {
|
||||
closePalette();
|
||||
setTrackElement("Command palette");
|
||||
toggleCreatePageModal({ isOpen: true });
|
||||
}}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-custom-text-200">
|
||||
<FileText className="h-3.5 w-3.5" />
|
||||
Create new page
|
||||
</div>
|
||||
<kbd>D</kbd>
|
||||
</Command.Item>
|
||||
</Command.Group>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,466 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Command } from "cmdk";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import useSWR from "swr";
|
||||
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 } from "@plane/ui";
|
||||
// components
|
||||
import {
|
||||
ChangeIssueAssignee,
|
||||
ChangeIssuePriority,
|
||||
ChangeIssueState,
|
||||
CommandPaletteHelpActions,
|
||||
CommandPaletteIssueActions,
|
||||
CommandPaletteProjectActions,
|
||||
CommandPaletteSearchResults,
|
||||
CommandPaletteThemeActions,
|
||||
CommandPaletteWorkspaceSettingsActions,
|
||||
} from "@/components/command-palette";
|
||||
import { SimpleEmptyState } from "@/components/empty-state";
|
||||
// fetch-keys
|
||||
import { ISSUE_DETAILS } from "@/constants/fetch-keys";
|
||||
// helpers
|
||||
import { getTabIndex } from "@/helpers/tab-indices.helper";
|
||||
// hooks
|
||||
import { useCommandPalette, useEventTracker, useProject, useUser, useUserPermissions } from "@/hooks/store";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
import useDebounce from "@/hooks/use-debounce";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
// plane web components
|
||||
import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path";
|
||||
import { IssueIdentifier } from "@/plane-web/components/issues";
|
||||
// plane web services
|
||||
import { WorkspaceService } from "@/plane-web/services";
|
||||
// services
|
||||
import { IssueService } from "@/services/issue";
|
||||
|
||||
const workspaceService = new WorkspaceService();
|
||||
const issueService = new IssueService();
|
||||
|
||||
export const CommandModal: React.FC = observer(() => {
|
||||
// router
|
||||
const router = useAppRouter();
|
||||
const { workspaceSlug, projectId, issueId } = useParams();
|
||||
// states
|
||||
const [placeholder, setPlaceholder] = useState("Type a command or search...");
|
||||
const [resultsCount, setResultsCount] = useState(0);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [results, setResults] = useState<IWorkspaceSearchResults>({
|
||||
results: {
|
||||
workspace: [],
|
||||
project: [],
|
||||
issue: [],
|
||||
cycle: [],
|
||||
module: [],
|
||||
issue_view: [],
|
||||
page: [],
|
||||
},
|
||||
});
|
||||
const [isWorkspaceLevel, setIsWorkspaceLevel] = useState(true);
|
||||
const [pages, setPages] = useState<string[]>([]);
|
||||
// plane hooks
|
||||
const { t } = useTranslation();
|
||||
// hooks
|
||||
const { workspaceProjectIds } = useProject();
|
||||
const { platform, isMobile } = usePlatformOS();
|
||||
const { canPerformAnyCreateAction } = useUser();
|
||||
const { isCommandPaletteOpen, toggleCommandPaletteModal, toggleCreateIssueModal, toggleCreateProjectModal } =
|
||||
useCommandPalette();
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
const { setTrackElement } = useEventTracker();
|
||||
// derived values
|
||||
const page = pages[pages.length - 1];
|
||||
const debouncedSearchTerm = useDebounce(searchTerm, 500);
|
||||
const { baseTabIndex } = getTabIndex(undefined, isMobile);
|
||||
const canPerformWorkspaceActions = allowPermissions(
|
||||
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
|
||||
EUserPermissionsLevel.WORKSPACE
|
||||
);
|
||||
const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/search/search" });
|
||||
|
||||
// TODO: update this to mobx store
|
||||
const { data: issueDetails } = useSWR(
|
||||
workspaceSlug && projectId && issueId ? ISSUE_DETAILS(issueId.toString()) : null,
|
||||
workspaceSlug && projectId && issueId
|
||||
? () => issueService.retrieve(workspaceSlug.toString(), projectId.toString(), issueId.toString())
|
||||
: null
|
||||
);
|
||||
|
||||
const closePalette = () => {
|
||||
toggleCommandPaletteModal(false);
|
||||
};
|
||||
|
||||
const createNewWorkspace = () => {
|
||||
closePalette();
|
||||
router.push("/create-workspace");
|
||||
};
|
||||
|
||||
useEffect(
|
||||
() => {
|
||||
if (!workspaceSlug) return;
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
if (debouncedSearchTerm) {
|
||||
setIsSearching(true);
|
||||
workspaceService
|
||||
.searchWorkspace(workspaceSlug.toString(), {
|
||||
...(projectId ? { project_id: projectId.toString() } : {}),
|
||||
search: debouncedSearchTerm,
|
||||
workspace_search: !projectId ? true : isWorkspaceLevel,
|
||||
})
|
||||
.then((results) => {
|
||||
setResults(results);
|
||||
const count = Object.keys(results.results).reduce(
|
||||
(accumulator, key) => (results.results as any)[key].length + accumulator,
|
||||
0
|
||||
);
|
||||
setResultsCount(count);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
setIsSearching(false);
|
||||
});
|
||||
} else {
|
||||
setResults({
|
||||
results: {
|
||||
workspace: [],
|
||||
project: [],
|
||||
issue: [],
|
||||
cycle: [],
|
||||
module: [],
|
||||
issue_view: [],
|
||||
page: [],
|
||||
},
|
||||
});
|
||||
setIsLoading(false);
|
||||
setIsSearching(false);
|
||||
}
|
||||
},
|
||||
[debouncedSearchTerm, isWorkspaceLevel, projectId, workspaceSlug] // Only call effect if debounced search term changes
|
||||
);
|
||||
|
||||
return (
|
||||
<Transition.Root show={isCommandPaletteOpen} afterLeave={() => setSearchTerm("")} as={React.Fragment}>
|
||||
<Dialog as="div" className="relative z-30" onClose={() => closePalette()}>
|
||||
<Transition.Child
|
||||
as={React.Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 bg-custom-backdrop transition-opacity" />
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0 z-30 overflow-y-auto">
|
||||
<div className="flex items-center justify-center p-4 sm:p-6 md:p-20">
|
||||
<Transition.Child
|
||||
as={React.Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
enterTo="opacity-100 translate-y-0 sm:scale-100"
|
||||
leave="ease-in duration-200"
|
||||
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 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;
|
||||
}}
|
||||
shouldFilter={searchTerm.length > 0}
|
||||
onKeyDown={(e: any) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
closePalette();
|
||||
return;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
if (e.key === "Escape" || (e.key === "Backspace" && !searchTerm)) {
|
||||
e.preventDefault();
|
||||
setPages((pages) => pages.slice(0, -1));
|
||||
setPlaceholder("Type a command or search...");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={`flex gap-4 pb-0 sm:items-center ${
|
||||
issueDetails ? "flex-col justify-between sm:flex-row" : "justify-end"
|
||||
}`}
|
||||
>
|
||||
{issueDetails && (
|
||||
<div className="flex gap-2 items-center overflow-hidden truncate rounded-md bg-custom-background-80 p-2 text-xs font-medium text-custom-text-200">
|
||||
{issueDetails.project_id && (
|
||||
<IssueIdentifier
|
||||
issueId={issueDetails.id}
|
||||
projectId={issueDetails.project_id}
|
||||
textContainerClassName="text-xs font-medium text-custom-text-200"
|
||||
/>
|
||||
)}
|
||||
{issueDetails.name}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Search
|
||||
className="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-custom-text-200"
|
||||
aria-hidden="true"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<Command.Input
|
||||
className="w-full border-0 border-b border-custom-border-200 bg-transparent p-4 pl-11 text-sm text-custom-text-100 outline-none placeholder:text-custom-text-400 focus:ring-0"
|
||||
placeholder={placeholder}
|
||||
value={searchTerm}
|
||||
onValueChange={(e) => setSearchTerm(e)}
|
||||
autoFocus
|
||||
tabIndex={baseTabIndex}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Command.List className="vertical-scrollbar scrollbar-sm max-h-96 overflow-scroll p-2">
|
||||
{searchTerm !== "" && (
|
||||
<h5 className="mx-[3px] my-4 text-xs text-custom-text-100">
|
||||
Search results for{" "}
|
||||
<span className="font-medium">
|
||||
{'"'}
|
||||
{searchTerm}
|
||||
{'"'}
|
||||
</span>{" "}
|
||||
in {!projectId || isWorkspaceLevel ? "workspace" : "project"}:
|
||||
</h5>
|
||||
)}
|
||||
|
||||
{!isLoading && resultsCount === 0 && searchTerm !== "" && debouncedSearchTerm !== "" && (
|
||||
<div className="flex flex-col items-center justify-center px-3 py-8 text-center">
|
||||
<SimpleEmptyState title={t("command_k.empty_state.search.title")} assetPath={resolvedPath} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(isLoading || isSearching) && (
|
||||
<Command.Loading>
|
||||
<Loader className="space-y-3">
|
||||
<Loader.Item height="40px" />
|
||||
<Loader.Item height="40px" />
|
||||
<Loader.Item height="40px" />
|
||||
<Loader.Item height="40px" />
|
||||
</Loader>
|
||||
</Command.Loading>
|
||||
)}
|
||||
|
||||
{debouncedSearchTerm !== "" && (
|
||||
<CommandPaletteSearchResults closePalette={closePalette} results={results} />
|
||||
)}
|
||||
|
||||
{!page && (
|
||||
<>
|
||||
{/* issue actions */}
|
||||
{issueId && (
|
||||
<CommandPaletteIssueActions
|
||||
closePalette={closePalette}
|
||||
issueDetails={issueDetails}
|
||||
pages={pages}
|
||||
setPages={(newPages) => setPages(newPages)}
|
||||
setPlaceholder={(newPlaceholder) => setPlaceholder(newPlaceholder)}
|
||||
setSearchTerm={(newSearchTerm) => setSearchTerm(newSearchTerm)}
|
||||
/>
|
||||
)}
|
||||
{workspaceSlug &&
|
||||
workspaceProjectIds &&
|
||||
workspaceProjectIds.length > 0 &&
|
||||
canPerformAnyCreateAction && (
|
||||
<Command.Group heading="Work item">
|
||||
<Command.Item
|
||||
onSelect={() => {
|
||||
closePalette();
|
||||
setTrackElement("Command Palette");
|
||||
toggleCreateIssueModal(true);
|
||||
}}
|
||||
className="focus:bg-custom-background-80"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-custom-text-200">
|
||||
<LayersIcon className="h-3.5 w-3.5" />
|
||||
Create new work item
|
||||
</div>
|
||||
<kbd>C</kbd>
|
||||
</Command.Item>
|
||||
</Command.Group>
|
||||
)}
|
||||
{workspaceSlug && canPerformWorkspaceActions && (
|
||||
<Command.Group heading="Project">
|
||||
<Command.Item
|
||||
onSelect={() => {
|
||||
closePalette();
|
||||
setTrackElement("Command palette");
|
||||
toggleCreateProjectModal(true);
|
||||
}}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-custom-text-200">
|
||||
<FolderPlus className="h-3.5 w-3.5" />
|
||||
Create new project
|
||||
</div>
|
||||
<kbd>P</kbd>
|
||||
</Command.Item>
|
||||
</Command.Group>
|
||||
)}
|
||||
|
||||
{/* project actions */}
|
||||
{projectId && canPerformAnyCreateAction && (
|
||||
<CommandPaletteProjectActions closePalette={closePalette} />
|
||||
)}
|
||||
{canPerformWorkspaceActions && (
|
||||
<Command.Group heading="Workspace Settings">
|
||||
<Command.Item
|
||||
onSelect={() => {
|
||||
setPlaceholder("Search workspace settings...");
|
||||
setSearchTerm("");
|
||||
setPages([...pages, "settings"]);
|
||||
}}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-custom-text-200">
|
||||
<Settings className="h-3.5 w-3.5" />
|
||||
Search settings...
|
||||
</div>
|
||||
</Command.Item>
|
||||
</Command.Group>
|
||||
)}
|
||||
<Command.Group heading="Account">
|
||||
<Command.Item onSelect={createNewWorkspace} className="focus:outline-none">
|
||||
<div className="flex items-center gap-2 text-custom-text-200">
|
||||
<FolderPlus className="h-3.5 w-3.5" />
|
||||
Create new workspace
|
||||
</div>
|
||||
</Command.Item>
|
||||
<Command.Item
|
||||
onSelect={() => {
|
||||
setPlaceholder("Change interface theme...");
|
||||
setSearchTerm("");
|
||||
setPages([...pages, "change-interface-theme"]);
|
||||
}}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-custom-text-200">
|
||||
<Settings className="h-3.5 w-3.5" />
|
||||
Change interface theme...
|
||||
</div>
|
||||
</Command.Item>
|
||||
</Command.Group>
|
||||
|
||||
{/* help options */}
|
||||
<CommandPaletteHelpActions closePalette={closePalette} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* workspace settings actions */}
|
||||
{page === "settings" && workspaceSlug && (
|
||||
<CommandPaletteWorkspaceSettingsActions closePalette={closePalette} />
|
||||
)}
|
||||
|
||||
{/* issue details page actions */}
|
||||
{page === "change-issue-state" && issueDetails && (
|
||||
<ChangeIssueState closePalette={closePalette} issue={issueDetails} />
|
||||
)}
|
||||
{page === "change-issue-priority" && issueDetails && (
|
||||
<ChangeIssuePriority closePalette={closePalette} issue={issueDetails} />
|
||||
)}
|
||||
{page === "change-issue-assignee" && issueDetails && (
|
||||
<ChangeIssueAssignee closePalette={closePalette} issue={issueDetails} />
|
||||
)}
|
||||
|
||||
{/* theme actions */}
|
||||
{page === "change-interface-theme" && (
|
||||
<CommandPaletteThemeActions
|
||||
closePalette={() => {
|
||||
closePalette();
|
||||
setPages((pages) => pages.slice(0, -1));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</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>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition.Root>
|
||||
);
|
||||
});
|
||||
@@ -6,8 +6,6 @@ import { useParams } from "next/navigation";
|
||||
// ui
|
||||
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
||||
import { TOAST_TYPE, setToast } from "@plane/ui";
|
||||
// components
|
||||
import { CommandModal, ShortcutsModal } from "@/components/command-palette";
|
||||
// helpers
|
||||
import { copyTextToClipboard } from "@/helpers/string.helper";
|
||||
// hooks
|
||||
@@ -27,6 +25,8 @@ import {
|
||||
getWorkspaceShortcutsList,
|
||||
handleAdditionalKeyDownEvents,
|
||||
} from "@/plane-web/helpers/command-palette";
|
||||
// local components
|
||||
import { PowerKModal, ShortcutsModal } from "./";
|
||||
|
||||
export const CommandPalette: FC = observer(() => {
|
||||
// router params
|
||||
@@ -237,7 +237,7 @@ export const CommandPalette: FC = observer(() => {
|
||||
<ProjectLevelModals workspaceSlug={workspaceSlug.toString()} projectId={projectId.toString()} />
|
||||
)}
|
||||
<IssueLevelModals />
|
||||
<CommandModal />
|
||||
<PowerKModal />
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
export * from "./actions";
|
||||
export * from "./shortcuts-modal";
|
||||
export * from "./command-modal";
|
||||
export * from "./power-k";
|
||||
export * from "./command-palette";
|
||||
export * from "./helpers";
|
||||
export * from "./shortcuts-modal";
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// hooks
|
||||
import { useIssueDetail } from "@/hooks/store";
|
||||
// plane web components
|
||||
import { IssueIdentifier } from "@/plane-web/components/issues";
|
||||
|
||||
export const PowerKBreadcrumbs = observer(() => {
|
||||
// navigation
|
||||
const { issueId } = useParams();
|
||||
// store hooks
|
||||
const {
|
||||
issue: { getIssueById },
|
||||
} = useIssueDetail();
|
||||
// derived values
|
||||
const issueDetails = issueId ? getIssueById(issueId.toString()) : undefined;
|
||||
|
||||
if (issueId && issueDetails) {
|
||||
return (
|
||||
<div className="flex gap-4 p-3 pb-0 sm:items-center">
|
||||
<div className="flex gap-2 items-center overflow-hidden truncate rounded-md bg-custom-background-80 p-2 text-xs font-medium text-custom-text-200">
|
||||
{issueDetails.project_id && (
|
||||
<IssueIdentifier
|
||||
issueId={issueDetails.id}
|
||||
projectId={issueDetails.project_id}
|
||||
textContainerClassName="text-xs font-medium text-custom-text-200"
|
||||
/>
|
||||
)}
|
||||
{issueDetails.name}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import React from "react";
|
||||
import { Command } from "cmdk";
|
||||
import { LucideIcon } from "lucide-react";
|
||||
import type { ISvgIcons } from "@plane/ui";
|
||||
|
||||
type Props = {
|
||||
icon?: LucideIcon | React.FC<ISvgIcons> | JSX.Element;
|
||||
label: string | React.ReactNode;
|
||||
onSelect: () => void;
|
||||
shortcut?: string;
|
||||
value?: string;
|
||||
};
|
||||
|
||||
export const PowerKCommandItem: React.FC<Props> = (props) => {
|
||||
const { label, onSelect, shortcut, value } = props;
|
||||
|
||||
const renderIcon = () => {
|
||||
if (!props.icon) return null;
|
||||
|
||||
if (React.isValidElement(props.icon)) return props.icon;
|
||||
|
||||
// @ts-expect-error hardcoded types
|
||||
if (typeof props.icon === "function" || (props.icon as LucideIcon).$$typeof) {
|
||||
// @ts-expect-error hardcoded types
|
||||
return <props.icon className="flex-shrink-0 size-3.5" />;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<Command.Item value={value} onSelect={onSelect} className="focus:outline-none">
|
||||
<div className="flex items-center gap-2 text-custom-text-200">
|
||||
{renderIcon()}
|
||||
{label}
|
||||
</div>
|
||||
{shortcut && <kbd>{shortcut}</kbd>}
|
||||
</Command.Item>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./module";
|
||||
export * from "./work-item";
|
||||
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import { Command } from "cmdk";
|
||||
import { observer } from "mobx-react";
|
||||
import { Check } from "lucide-react";
|
||||
// plane ui
|
||||
import { Avatar } from "@plane/ui";
|
||||
// helpers
|
||||
import { getFileURL } from "@/helpers/file.helper";
|
||||
// hooks
|
||||
import { useMember } from "@/hooks/store";
|
||||
|
||||
type Props = {
|
||||
handleUpdateMember: (assigneeId: string) => void;
|
||||
value: string[];
|
||||
};
|
||||
|
||||
export const PowerKMembersMenu: React.FC<Props> = observer((props) => {
|
||||
const { handleUpdateMember, value } = props;
|
||||
// store hooks
|
||||
const {
|
||||
getUserDetails,
|
||||
project: { projectMemberIds },
|
||||
} = useMember();
|
||||
|
||||
return (
|
||||
<>
|
||||
{projectMemberIds?.map((memberId) => {
|
||||
const memberDetails = getUserDetails(memberId);
|
||||
if (!memberDetails) return;
|
||||
|
||||
return (
|
||||
<Command.Item key={memberId} onSelect={() => handleUpdateMember(memberId)} className="focus:outline-none">
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar
|
||||
name={memberDetails?.display_name}
|
||||
src={getFileURL(memberDetails?.avatar_url ?? "")}
|
||||
showTooltip={false}
|
||||
className="flex-shrink-0"
|
||||
/>
|
||||
{memberDetails?.display_name}
|
||||
</div>
|
||||
{value.includes(memberId ?? "") && (
|
||||
<div className="flex-shrink-0">
|
||||
<Check className="size-3" />
|
||||
</div>
|
||||
)}
|
||||
</Command.Item>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
"use client";
|
||||
|
||||
import { Command } from "cmdk";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { LinkIcon, Users } from "lucide-react";
|
||||
// plane types
|
||||
import { IModule, TPowerKPageKeys } from "@plane/types";
|
||||
// hooks
|
||||
import { DoubleCircleIcon, TOAST_TYPE, setToast } from "@plane/ui";
|
||||
// helpers
|
||||
import { copyTextToClipboard } from "@/helpers/string.helper";
|
||||
// hooks
|
||||
import { useModule } from "@/hooks/store";
|
||||
// local components
|
||||
import { PowerKCommandItem } from "../../command-item";
|
||||
import { PowerKMembersMenu } from "../members-menu";
|
||||
import { PowerKModuleStatusMenu } from "./status-menu";
|
||||
|
||||
type Props = {
|
||||
activePage: TPowerKPageKeys | undefined;
|
||||
handleClose: () => void;
|
||||
handleUpdateSearchTerm: (searchTerm: string) => void;
|
||||
handleUpdatePage: (page: TPowerKPageKeys) => void;
|
||||
moduleId: string;
|
||||
};
|
||||
|
||||
export const PowerKModuleActionsMenu: React.FC<Props> = observer((props) => {
|
||||
const { activePage, handleClose, handleUpdateSearchTerm, handleUpdatePage, moduleId } = props;
|
||||
// navigation
|
||||
const { workspaceSlug, projectId } = useParams();
|
||||
// store hooks
|
||||
const { getModuleById, updateModuleDetails } = useModule();
|
||||
// derived values
|
||||
const moduleDetails = getModuleById(moduleId);
|
||||
|
||||
const handleUpdateModule = async (formData: Partial<IModule>) => {
|
||||
if (!workspaceSlug || !projectId || !moduleDetails) return;
|
||||
await updateModuleDetails(workspaceSlug.toString(), projectId.toString(), moduleDetails.id, formData).catch(
|
||||
(error) => {
|
||||
console.error("Error in updating issue from Power K:", error);
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: "Issue could not be updated. Please try again.",
|
||||
});
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const handleUpdateMember = (memberId: string) => {
|
||||
if (!moduleDetails) return;
|
||||
|
||||
const updatedMembers = moduleDetails.member_ids ?? [];
|
||||
if (updatedMembers.includes(memberId)) updatedMembers.splice(updatedMembers.indexOf(memberId), 1);
|
||||
else updatedMembers.push(memberId);
|
||||
|
||||
handleUpdateModule({ member_ids: updatedMembers });
|
||||
};
|
||||
|
||||
const copyModuleUrlToClipboard = () => {
|
||||
const url = new URL(window.location.href);
|
||||
copyTextToClipboard(url.href)
|
||||
.then(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Copied to clipboard",
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Some error occurred",
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{!activePage && (
|
||||
<Command.Group heading="Module actions">
|
||||
<PowerKCommandItem
|
||||
icon={Users}
|
||||
label="Add/remove members"
|
||||
onSelect={() => {
|
||||
handleUpdateSearchTerm("");
|
||||
handleUpdatePage("change-module-member");
|
||||
}}
|
||||
/>
|
||||
<PowerKCommandItem
|
||||
icon={DoubleCircleIcon}
|
||||
label="Change status"
|
||||
onSelect={() => {
|
||||
handleUpdateSearchTerm("");
|
||||
handleUpdatePage("change-module-status");
|
||||
}}
|
||||
/>
|
||||
<PowerKCommandItem
|
||||
icon={LinkIcon}
|
||||
label="Copy module URL"
|
||||
onSelect={() => {
|
||||
handleClose();
|
||||
copyModuleUrlToClipboard();
|
||||
}}
|
||||
/>
|
||||
</Command.Group>
|
||||
)}
|
||||
{/* members menu */}
|
||||
{activePage === "change-module-member" && moduleDetails && (
|
||||
<PowerKMembersMenu handleUpdateMember={handleUpdateMember} value={moduleDetails.member_ids} />
|
||||
)}
|
||||
{/* status menu */}
|
||||
{activePage === "change-module-status" && moduleDetails?.status && (
|
||||
<PowerKModuleStatusMenu
|
||||
handleClose={handleClose}
|
||||
handleUpdateModule={handleUpdateModule}
|
||||
value={moduleDetails.status}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import { Command } from "cmdk";
|
||||
import { observer } from "mobx-react";
|
||||
import { Check } from "lucide-react";
|
||||
// plane imports
|
||||
import { MODULE_STATUS } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { IModule } from "@plane/types";
|
||||
import { ModuleStatusIcon, TModuleStatus } from "@plane/ui";
|
||||
|
||||
type Props = {
|
||||
handleClose: () => void;
|
||||
handleUpdateModule: (data: Partial<IModule>) => void;
|
||||
value: TModuleStatus;
|
||||
};
|
||||
|
||||
export const PowerKModuleStatusMenu: React.FC<Props> = observer((props) => {
|
||||
const { handleClose, handleUpdateModule, value } = props;
|
||||
// translation
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
{MODULE_STATUS.map((status) => (
|
||||
<Command.Item
|
||||
key={status.value}
|
||||
onSelect={() => {
|
||||
handleUpdateModule({
|
||||
status: status.value,
|
||||
});
|
||||
handleClose();
|
||||
}}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
<ModuleStatusIcon status={status.value} height="14px" width="14px" />
|
||||
<p>{t(status.i18n_label)}</p>
|
||||
</div>
|
||||
<div className="flex-shrink-0">{status.value === value && <Check className="size-3" />}</div>
|
||||
</Command.Item>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
});
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import { Command } from "cmdk";
|
||||
import { observer } from "mobx-react";
|
||||
import { Check } from "lucide-react";
|
||||
// plane imports
|
||||
import { ISSUE_PRIORITIES } from "@plane/constants";
|
||||
import { TIssue } from "@plane/types";
|
||||
import { PriorityIcon } from "@plane/ui";
|
||||
|
||||
type Props = {
|
||||
handleClose: () => void;
|
||||
handleUpdateIssue: (data: Partial<TIssue>) => void;
|
||||
issue: TIssue;
|
||||
};
|
||||
|
||||
export const PowerKPrioritiesMenu: React.FC<Props> = observer((props) => {
|
||||
const { handleClose, handleUpdateIssue, issue } = props;
|
||||
|
||||
return (
|
||||
<>
|
||||
{ISSUE_PRIORITIES.map((priority) => (
|
||||
<Command.Item
|
||||
key={priority.key}
|
||||
onSelect={() => {
|
||||
handleUpdateIssue({
|
||||
priority: priority.key,
|
||||
});
|
||||
handleClose();
|
||||
}}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
<PriorityIcon priority={priority.key} />
|
||||
<span className="capitalize">{priority.title ?? "None"}</span>
|
||||
</div>
|
||||
<div className="flex-shrink-0">{priority.key === issue.priority && <Check className="size-3" />}</div>
|
||||
</Command.Item>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
"use client";
|
||||
|
||||
import { Command } from "cmdk";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { LinkIcon, Signal, Trash2, UserMinus2, UserPlus2, Users } from "lucide-react";
|
||||
// plane constants
|
||||
import { EIssuesStoreType } from "@plane/constants";
|
||||
// plane types
|
||||
import { TIssue, TPowerKPageKeys } from "@plane/types";
|
||||
// hooks
|
||||
import { DoubleCircleIcon, TOAST_TYPE, setToast } from "@plane/ui";
|
||||
// helpers
|
||||
import { copyTextToClipboard } from "@/helpers/string.helper";
|
||||
// hooks
|
||||
import { useCommandPalette, useIssueDetail, useIssues, useUser } from "@/hooks/store";
|
||||
// local components
|
||||
import { PowerKCommandItem } from "../../command-item";
|
||||
import { PowerKMembersMenu } from "../members-menu";
|
||||
import { PowerKPrioritiesMenu } from "./priorities-menu";
|
||||
import { PowerKProjectStatesMenu } from "./states-menu";
|
||||
|
||||
type Props = {
|
||||
activePage: TPowerKPageKeys | undefined;
|
||||
handleClose: () => void;
|
||||
handleUpdateSearchTerm: (searchTerm: string) => void;
|
||||
handleUpdatePage: (page: TPowerKPageKeys) => void;
|
||||
issueId: string;
|
||||
};
|
||||
|
||||
export const PowerKIssueActionsMenu: React.FC<Props> = observer((props) => {
|
||||
const { activePage, handleClose, handleUpdateSearchTerm, handleUpdatePage, issueId } = props;
|
||||
// navigation
|
||||
const { workspaceSlug, projectId } = useParams();
|
||||
// store hooks
|
||||
const {
|
||||
issues: { updateIssue },
|
||||
} = useIssues(EIssuesStoreType.PROJECT);
|
||||
const {
|
||||
issue: { getIssueById },
|
||||
} = useIssueDetail();
|
||||
const { toggleDeleteIssueModal } = useCommandPalette();
|
||||
const { data: currentUser } = useUser();
|
||||
// derived values
|
||||
const issueDetails = getIssueById(issueId);
|
||||
const isCurrentUserAssigned = issueDetails?.assignee_ids.includes(currentUser?.id ?? "");
|
||||
|
||||
const handleUpdateIssue = async (formData: Partial<TIssue>) => {
|
||||
if (!workspaceSlug || !projectId || !issueDetails) return;
|
||||
await updateIssue(workspaceSlug.toString(), projectId.toString(), issueDetails.id, formData).catch((error) => {
|
||||
console.error("Error in updating issue from Power K:", error);
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: "Issue could not be updated. Please try again.",
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handleUpdateAssignee = (assigneeId: string) => {
|
||||
if (!issueDetails) return;
|
||||
|
||||
const updatedAssignees = issueDetails.assignee_ids ?? [];
|
||||
if (updatedAssignees.includes(assigneeId)) updatedAssignees.splice(updatedAssignees.indexOf(assigneeId), 1);
|
||||
else updatedAssignees.push(assigneeId);
|
||||
|
||||
handleUpdateIssue({ assignee_ids: updatedAssignees });
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const handleDeleteIssue = () => {
|
||||
toggleDeleteIssueModal(true);
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const copyIssueUrlToClipboard = () => {
|
||||
if (!issueId) return;
|
||||
|
||||
const url = new URL(window.location.href);
|
||||
copyTextToClipboard(url.href)
|
||||
.then(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Copied to clipboard",
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Some error occurred",
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{!activePage && (
|
||||
<Command.Group heading="Work item actions">
|
||||
<PowerKCommandItem
|
||||
icon={DoubleCircleIcon}
|
||||
label="Change state"
|
||||
onSelect={() => {
|
||||
handleUpdateSearchTerm("");
|
||||
handleUpdatePage("change-work-item-state");
|
||||
}}
|
||||
/>
|
||||
<PowerKCommandItem
|
||||
icon={Signal}
|
||||
label="Change priority"
|
||||
onSelect={() => {
|
||||
handleUpdateSearchTerm("");
|
||||
handleUpdatePage("change-work-item-priority");
|
||||
}}
|
||||
/>
|
||||
<PowerKCommandItem
|
||||
icon={Users}
|
||||
label="Assign to"
|
||||
onSelect={() => {
|
||||
handleUpdateSearchTerm("");
|
||||
handleUpdatePage("change-work-item-assignee");
|
||||
}}
|
||||
/>
|
||||
<PowerKCommandItem
|
||||
icon={isCurrentUserAssigned ? UserMinus2 : UserPlus2}
|
||||
label={isCurrentUserAssigned ? "Un-assign from me" : "Assign to me"}
|
||||
onSelect={() => {
|
||||
if (!currentUser) return;
|
||||
handleUpdateAssignee(currentUser?.id);
|
||||
handleClose();
|
||||
}}
|
||||
/>
|
||||
<PowerKCommandItem icon={Trash2} label="Delete" onSelect={handleDeleteIssue} />
|
||||
<PowerKCommandItem
|
||||
icon={LinkIcon}
|
||||
label="Copy URL"
|
||||
onSelect={() => {
|
||||
copyIssueUrlToClipboard();
|
||||
handleClose();
|
||||
}}
|
||||
/>
|
||||
</Command.Group>
|
||||
)}
|
||||
{/* states menu */}
|
||||
{activePage === "change-work-item-state" && issueDetails && (
|
||||
<PowerKProjectStatesMenu handleClose={handleClose} handleUpdateIssue={handleUpdateIssue} issue={issueDetails} />
|
||||
)}
|
||||
{/* priority menu */}
|
||||
{activePage === "change-work-item-priority" && issueDetails && (
|
||||
<PowerKPrioritiesMenu handleClose={handleClose} handleUpdateIssue={handleUpdateIssue} issue={issueDetails} />
|
||||
)}
|
||||
{/* members menu */}
|
||||
{activePage === "change-work-item-assignee" && issueDetails && (
|
||||
<PowerKMembersMenu handleUpdateMember={handleUpdateAssignee} value={issueDetails.assignee_ids} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import { Command } from "cmdk";
|
||||
import { observer } from "mobx-react";
|
||||
import { Check } from "lucide-react";
|
||||
// plane types
|
||||
import { TIssue } from "@plane/types";
|
||||
// plane ui
|
||||
import { Spinner, StateGroupIcon } from "@plane/ui";
|
||||
// hooks
|
||||
import { useProjectState } from "@/hooks/store";
|
||||
|
||||
type Props = {
|
||||
handleClose: () => void;
|
||||
handleUpdateIssue: (data: Partial<TIssue>) => void;
|
||||
issue: TIssue;
|
||||
};
|
||||
|
||||
export const PowerKProjectStatesMenu: React.FC<Props> = observer((props) => {
|
||||
const { handleClose, handleUpdateIssue, issue } = props;
|
||||
// store hooks
|
||||
const { projectStates } = useProjectState();
|
||||
|
||||
return (
|
||||
<>
|
||||
{projectStates ? (
|
||||
projectStates.length > 0 ? (
|
||||
projectStates.map((state) => (
|
||||
<Command.Item
|
||||
key={state.id}
|
||||
onSelect={() => {
|
||||
handleUpdateIssue({
|
||||
state_id: state.id,
|
||||
});
|
||||
handleClose();
|
||||
}}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
<StateGroupIcon stateGroup={state.group} color={state.color} height="14px" width="14px" />
|
||||
<p>{state.name}</p>
|
||||
</div>
|
||||
<div className="flex-shrink-0">{state.id === issue.state_id && <Check className="size-3" />}</div>
|
||||
</Command.Item>
|
||||
))
|
||||
) : (
|
||||
<div className="text-center">No states found</div>
|
||||
)
|
||||
) : (
|
||||
<Spinner />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useMemo } from "react";
|
||||
import { Command } from "cmdk";
|
||||
import { observer } from "mobx-react";
|
||||
// plane ui
|
||||
import { useEventTracker, useUser } from "@/hooks/store";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
// plane web helpers
|
||||
import { getCreateActionsList } from "@/plane-web/components/command-palette/power-k";
|
||||
// local components
|
||||
import { PowerKCommandItem } from "./command-item";
|
||||
|
||||
type Props = {
|
||||
handleClose: () => void;
|
||||
};
|
||||
|
||||
export const PowerKCreateActionsMenu: React.FC<Props> = observer((props) => {
|
||||
const { handleClose } = props;
|
||||
// navigation
|
||||
const router = useAppRouter();
|
||||
// store hooks
|
||||
const { canPerformAnyCreateAction } = useUser();
|
||||
const { setTrackElement } = useEventTracker();
|
||||
// derived values
|
||||
const CREATE_OPTIONS_LIST = useMemo(() => getCreateActionsList(router), [router]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{canPerformAnyCreateAction && (
|
||||
<Command.Group heading="Create">
|
||||
{CREATE_OPTIONS_LIST.map((option) => {
|
||||
if (option.shouldRender !== undefined && option.shouldRender === false) return null;
|
||||
|
||||
return (
|
||||
<PowerKCommandItem
|
||||
key={option.key}
|
||||
icon={option.icon}
|
||||
label={option.label}
|
||||
onSelect={() => {
|
||||
setTrackElement("Power K");
|
||||
option.onClick();
|
||||
handleClose();
|
||||
}}
|
||||
shortcut={option.shortcut}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Command.Group>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { Command } from "cmdk";
|
||||
import { observer } from "mobx-react";
|
||||
import { FileText, GithubIcon, MessageSquare, Rocket } from "lucide-react";
|
||||
// plane ui
|
||||
import { DiscordIcon } from "@plane/ui";
|
||||
// hooks
|
||||
import { useCommandPalette, useTransient } from "@/hooks/store";
|
||||
// local components
|
||||
import { PowerKCommandItem } from "./command-item";
|
||||
|
||||
type Props = {
|
||||
handleClose: () => void;
|
||||
};
|
||||
|
||||
export const PowerKHelpMenu: React.FC<Props> = observer((props) => {
|
||||
const { handleClose } = props;
|
||||
// store hooks
|
||||
const { toggleShortcutModal } = useCommandPalette();
|
||||
const { toggleIntercom } = useTransient();
|
||||
|
||||
const HELP_MENU_OPTIONS = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: "keyboard-shortcuts",
|
||||
label: "Open keyboard shortcuts",
|
||||
icon: Rocket,
|
||||
onClick: () => toggleShortcutModal(true),
|
||||
},
|
||||
{
|
||||
key: "documentation",
|
||||
label: "Open Plane documentation",
|
||||
icon: FileText,
|
||||
onClick: () => window.open("https://docs.plane.so/", "_blank"),
|
||||
},
|
||||
{
|
||||
key: "discord",
|
||||
label: "Join our Discord",
|
||||
icon: DiscordIcon,
|
||||
onClick: () => window.open("https://discord.com/invite/A92xrEGCge", "_blank"),
|
||||
},
|
||||
{
|
||||
key: "report-bug",
|
||||
label: "Report a bug",
|
||||
icon: GithubIcon,
|
||||
onClick: () => window.open("https://github.com/makeplane/plane/issues/new/choose", "_blank"),
|
||||
},
|
||||
{
|
||||
key: "chat",
|
||||
label: "Chat with us",
|
||||
icon: MessageSquare,
|
||||
onClick: () => toggleIntercom(true),
|
||||
},
|
||||
],
|
||||
[toggleIntercom, toggleShortcutModal]
|
||||
);
|
||||
|
||||
return (
|
||||
<Command.Group heading="Help">
|
||||
{HELP_MENU_OPTIONS.map((option) => (
|
||||
<PowerKCommandItem
|
||||
key={option.key}
|
||||
icon={option.icon}
|
||||
label={option.label}
|
||||
onSelect={() => {
|
||||
option.onClick();
|
||||
handleClose();
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Command.Group>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Command } from "cmdk";
|
||||
// plane ui
|
||||
import { Loader } from "@plane/ui";
|
||||
|
||||
export const PowerKLoader = () => (
|
||||
<Command.Loading>
|
||||
<Loader className="space-y-3">
|
||||
<Loader.Item height="40px" />
|
||||
<Loader.Item height="40px" />
|
||||
<Loader.Item height="40px" />
|
||||
<Loader.Item height="40px" />
|
||||
</Loader>
|
||||
</Command.Loading>
|
||||
);
|
||||
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import { Command } from "cmdk";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// plane constants
|
||||
import { EUserPermissionsLevel } from "@plane/constants";
|
||||
// plane i18n
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// components
|
||||
import { SIDEBAR_WORKSPACE_MENU_ITEMS, sidebarUserMenuItems } from "@/components/workspace";
|
||||
// hooks
|
||||
import { useUser, useUserPermissions } from "@/hooks/store";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
// local components
|
||||
import { PowerKCommandItem } from "./command-item";
|
||||
|
||||
type Props = {
|
||||
handleClose: () => void;
|
||||
};
|
||||
|
||||
export const PowerKNavigationMenu: React.FC<Props> = observer((props) => {
|
||||
const { handleClose } = props;
|
||||
// navigation
|
||||
const router = useAppRouter();
|
||||
const { workspaceSlug } = useParams();
|
||||
// store hooks
|
||||
const { data: currentUser } = useUser();
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
// derived values
|
||||
const SIDEBAR_USER_MENU_ITEMS = sidebarUserMenuItems(currentUser?.id ?? "");
|
||||
// translation
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Command.Group heading="Navigation">
|
||||
{[...SIDEBAR_USER_MENU_ITEMS, ...SIDEBAR_WORKSPACE_MENU_ITEMS].map((item) => {
|
||||
if (!allowPermissions(item.access as any, EUserPermissionsLevel.WORKSPACE, workspaceSlug.toString())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<PowerKCommandItem
|
||||
key={item.key}
|
||||
icon={item.Icon}
|
||||
label={t(item.labelTranslationKey)}
|
||||
onSelect={() => {
|
||||
router.push(`/${workspaceSlug.toString()}${item.href}`);
|
||||
handleClose();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Command.Group>
|
||||
);
|
||||
});
|
||||
+24
-6
@@ -4,7 +4,6 @@ import React, { FC, useEffect, useState } from "react";
|
||||
import { Command } from "cmdk";
|
||||
import { observer } from "mobx-react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { Settings } from "lucide-react";
|
||||
// plane imports
|
||||
import { THEME_OPTIONS } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
@@ -13,11 +12,11 @@ import { TOAST_TYPE, setToast } from "@plane/ui";
|
||||
import { useUserProfile } from "@/hooks/store";
|
||||
|
||||
type Props = {
|
||||
closePalette: () => void;
|
||||
handleClose: () => void;
|
||||
};
|
||||
|
||||
export const CommandPaletteThemeActions: FC<Props> = observer((props) => {
|
||||
const { closePalette } = props;
|
||||
export const PowerKChangeThemeMenu: FC<Props> = observer((props) => {
|
||||
const { handleClose } = props;
|
||||
const { setTheme } = useTheme();
|
||||
// hooks
|
||||
const { updateUserTheme } = useUserProfile();
|
||||
@@ -49,12 +48,31 @@ export const CommandPaletteThemeActions: FC<Props> = observer((props) => {
|
||||
key={theme.value}
|
||||
onSelect={() => {
|
||||
updateTheme(theme.value);
|
||||
closePalette();
|
||||
handleClose();
|
||||
}}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-custom-text-200">
|
||||
<Settings className="h-4 w-4 text-custom-text-200" />
|
||||
<div
|
||||
className="border border-1 relative size-4 flex items-center justify-center rotate-45 transform rounded-full"
|
||||
style={{
|
||||
borderColor: theme.icon.border,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="h-full w-1/2 rounded-l-full"
|
||||
style={{
|
||||
background: theme.icon.color1,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="h-full w-1/2 rounded-r-full border-l"
|
||||
style={{
|
||||
borderLeftColor: theme.icon.border,
|
||||
background: theme.icon.color2,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{t(theme.i18n_label)}
|
||||
</div>
|
||||
</Command.Item>
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Command } from "cmdk";
|
||||
import { Settings } from "lucide-react";
|
||||
// plane types
|
||||
import { TPowerKPageKeys } from "@plane/types";
|
||||
// local components
|
||||
import { PowerKCommandItem } from "../command-item";
|
||||
import { PowerKChangeThemeMenu } from "./change-theme";
|
||||
|
||||
type Props = {
|
||||
activePage: TPowerKPageKeys | undefined;
|
||||
handleClose: () => void;
|
||||
handleUpdateSearchTerm: (value: string) => void;
|
||||
handleUpdatePage: (page: TPowerKPageKeys) => void;
|
||||
};
|
||||
|
||||
export const PowerKPersonalizationMenu: React.FC<Props> = (props) => {
|
||||
const { activePage, handleClose, handleUpdateSearchTerm, handleUpdatePage } = props;
|
||||
|
||||
return (
|
||||
<>
|
||||
{!activePage && (
|
||||
<Command.Group heading="Personalization">
|
||||
<PowerKCommandItem
|
||||
icon={Settings}
|
||||
label="Change theme"
|
||||
onSelect={() => {
|
||||
handleUpdateSearchTerm("");
|
||||
handleUpdatePage("change-theme");
|
||||
}}
|
||||
/>
|
||||
</Command.Group>
|
||||
)}
|
||||
{/* theme change menu */}
|
||||
{activePage === "change-theme" && <PowerKChangeThemeMenu handleClose={handleClose} />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,204 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { Command } from "cmdk";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// plane imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { IWorkspaceSearchResults, TPowerKPageKeys } from "@plane/types";
|
||||
import { EModalPosition, EModalWidth, ModalCore } from "@plane/ui";
|
||||
// components
|
||||
import { SimpleEmptyState } from "@/components/empty-state";
|
||||
// hooks
|
||||
import { useCommandPalette } from "@/hooks/store";
|
||||
import useDebounce from "@/hooks/use-debounce";
|
||||
import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path";
|
||||
// plane web constants
|
||||
import { POWER_K_PLACEHOLDER_TEXT, PowerKContextBasedActions } from "@/plane-web/components/command-palette/power-k";
|
||||
// plane web services
|
||||
import { WorkspaceService } from "@/plane-web/services";
|
||||
// local components
|
||||
import { PowerKBreadcrumbs } from "./breadcrumbs";
|
||||
import { PowerKCreateActionsMenu } from "./create-menu";
|
||||
import { PowerKHelpMenu } from "./help-menu";
|
||||
import { PowerKLoader } from "./loader";
|
||||
import { PowerKNavigationMenu } from "./navigation-menu";
|
||||
import { PowerKPersonalizationMenu } from "./personalization";
|
||||
import { PowerKSearchInput } from "./search-input";
|
||||
import { PowerKSearchResults } from "./search-results";
|
||||
import { PowerKSettingsMenu } from "./settings";
|
||||
|
||||
const workspaceService = new WorkspaceService();
|
||||
|
||||
export const PowerKModal: React.FC = observer(() => {
|
||||
// states
|
||||
const [resultsCount, setResultsCount] = useState(0);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [results, setResults] = useState<IWorkspaceSearchResults>({
|
||||
results: {
|
||||
workspace: [],
|
||||
project: [],
|
||||
issue: [],
|
||||
cycle: [],
|
||||
module: [],
|
||||
issue_view: [],
|
||||
page: [],
|
||||
},
|
||||
});
|
||||
const [isWorkspaceLevel, setIsWorkspaceLevel] = useState(false);
|
||||
const [pages, setPages] = useState<TPowerKPageKeys[]>([]);
|
||||
const { isCommandPaletteOpen, toggleCommandPaletteModal } = useCommandPalette();
|
||||
// router params
|
||||
const { workspaceSlug, projectId } = useParams();
|
||||
// debounce
|
||||
const debouncedSearchTerm = useDebounce(searchTerm, 500);
|
||||
// translation
|
||||
const { t } = useTranslation();
|
||||
// derived values
|
||||
const activePage = pages.length > 0 ? pages[pages.length - 1] : undefined;
|
||||
const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/search/search" });
|
||||
|
||||
const handleClose = () => {
|
||||
toggleCommandPaletteModal(false);
|
||||
setTimeout(() => {
|
||||
setSearchTerm("");
|
||||
setPages([]);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
const handleUpdatePage = useCallback((page: TPowerKPageKeys) => setPages((prev) => [...prev, page]), []);
|
||||
const handleUpdateSearchTerm = useCallback((searchTerm: string) => setSearchTerm(searchTerm), []);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
// when search term is not empty, esc should clear the search term
|
||||
if (e.key === "Escape" && searchTerm) setSearchTerm("");
|
||||
|
||||
// when user tries to close the modal with esc
|
||||
if (e.key === "Escape" && !activePage && !searchTerm) handleClose();
|
||||
|
||||
// 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));
|
||||
}
|
||||
};
|
||||
|
||||
// search handler
|
||||
useEffect(
|
||||
() => {
|
||||
if (!workspaceSlug) return;
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
if (debouncedSearchTerm) {
|
||||
setIsSearching(true);
|
||||
workspaceService
|
||||
.searchWorkspace(workspaceSlug.toString(), {
|
||||
...(projectId ? { project_id: projectId.toString() } : {}),
|
||||
search: debouncedSearchTerm,
|
||||
workspace_search: !projectId ? true : isWorkspaceLevel,
|
||||
})
|
||||
.then((results) => {
|
||||
setResults(results);
|
||||
const count = Object.keys(results.results).reduce(
|
||||
(accumulator, key) => results.results[key as keyof typeof results.results].length + accumulator,
|
||||
0
|
||||
);
|
||||
setResultsCount(count);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
setIsSearching(false);
|
||||
});
|
||||
} else {
|
||||
setResults({
|
||||
results: {
|
||||
workspace: [],
|
||||
project: [],
|
||||
issue: [],
|
||||
cycle: [],
|
||||
module: [],
|
||||
issue_view: [],
|
||||
page: [],
|
||||
},
|
||||
});
|
||||
setIsLoading(false);
|
||||
setIsSearching(false);
|
||||
}
|
||||
},
|
||||
[debouncedSearchTerm, isWorkspaceLevel, projectId, workspaceSlug] // Only call effect if debounced search term changes
|
||||
);
|
||||
|
||||
return (
|
||||
<ModalCore
|
||||
isOpen={isCommandPaletteOpen}
|
||||
handleClose={handleClose}
|
||||
position={EModalPosition.TOP}
|
||||
width={EModalWidth.XXL}
|
||||
>
|
||||
<div className="w-full max-w-2xl">
|
||||
<Command
|
||||
filter={(value, search) => {
|
||||
if (value.toLowerCase().includes(search.toLowerCase())) return 1;
|
||||
return 0;
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<PowerKBreadcrumbs />
|
||||
<PowerKSearchInput
|
||||
handleUpdateSearchTerm={setSearchTerm}
|
||||
isWorkspaceLevel={isWorkspaceLevel}
|
||||
placeholder={POWER_K_PLACEHOLDER_TEXT[activePage ?? "default"]}
|
||||
searchTerm={searchTerm}
|
||||
toggleWorkspaceLevel={() => setIsWorkspaceLevel((prev) => !prev)}
|
||||
/>
|
||||
<Command.List className="vertical-scrollbar scrollbar-sm max-h-96 overflow-scroll p-2">
|
||||
{searchTerm !== "" && (
|
||||
<h5 className="mx-[3px] my-4 text-xs text-custom-text-100">
|
||||
Search results for{" "}
|
||||
<span className="font-medium">
|
||||
{'"'}
|
||||
{searchTerm}
|
||||
{'"'}
|
||||
</span>{" "}
|
||||
in {!projectId || isWorkspaceLevel ? "workspace" : "project"}:
|
||||
</h5>
|
||||
)}
|
||||
{!isLoading && resultsCount === 0 && searchTerm !== "" && debouncedSearchTerm !== "" && (
|
||||
<div className="flex flex-col items-center justify-center px-3 py-8 text-center">
|
||||
<SimpleEmptyState title={t("command_k.empty_state.search.title")} assetPath={resolvedPath} />
|
||||
</div>
|
||||
)}
|
||||
{(isLoading || isSearching) && <PowerKLoader />}
|
||||
{debouncedSearchTerm !== "" && <PowerKSearchResults handleClose={handleClose} results={results} />}
|
||||
<PowerKContextBasedActions
|
||||
handleClose={handleClose}
|
||||
activePage={activePage}
|
||||
handleUpdatePage={handleUpdatePage}
|
||||
handleUpdateSearchTerm={handleUpdateSearchTerm}
|
||||
/>
|
||||
{!activePage && <PowerKNavigationMenu handleClose={handleClose} />}
|
||||
{!activePage && <PowerKCreateActionsMenu handleClose={handleClose} />}
|
||||
<PowerKSettingsMenu
|
||||
activePage={activePage}
|
||||
handleClose={handleClose}
|
||||
handleUpdateSearchTerm={handleUpdateSearchTerm}
|
||||
handleUpdatePage={handleUpdatePage}
|
||||
/>
|
||||
<PowerKPersonalizationMenu
|
||||
activePage={activePage}
|
||||
handleClose={handleClose}
|
||||
handleUpdateSearchTerm={handleUpdateSearchTerm}
|
||||
handleUpdatePage={handleUpdatePage}
|
||||
/>
|
||||
{!activePage && <PowerKHelpMenu handleClose={handleClose} />}
|
||||
</Command.List>
|
||||
</Command>
|
||||
</div>
|
||||
</ModalCore>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Command } from "cmdk";
|
||||
import { useParams } from "next/navigation";
|
||||
import { Search } from "lucide-react";
|
||||
// plane hooks
|
||||
import { usePlatformOS } from "@plane/hooks";
|
||||
// plane ui
|
||||
import { ToggleSwitch, Tooltip } from "@plane/ui";
|
||||
// helpers
|
||||
import { getTabIndex } from "@/helpers/tab-indices.helper";
|
||||
|
||||
type Props = {
|
||||
handleUpdateSearchTerm: (value: string) => void;
|
||||
isWorkspaceLevel: boolean;
|
||||
placeholder: string;
|
||||
searchTerm: string;
|
||||
toggleWorkspaceLevel: () => void;
|
||||
};
|
||||
|
||||
export const PowerKSearchInput: React.FC<Props> = (props) => {
|
||||
const { handleUpdateSearchTerm, isWorkspaceLevel, placeholder, searchTerm, toggleWorkspaceLevel } = props;
|
||||
// navigation
|
||||
const { projectId } = useParams();
|
||||
// platform os
|
||||
const { isMobile } = usePlatformOS();
|
||||
// tab index
|
||||
const { baseTabIndex } = getTabIndex(undefined, isMobile);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 p-4 border-b border-custom-border-200">
|
||||
<Search className="flex-shrink-0 size-4 stroke-2 text-custom-text-200" aria-hidden="true" />
|
||||
<Command.Input
|
||||
className="w-full border-0 bg-transparent text-sm text-custom-text-100 outline-none placeholder:text-custom-text-400 focus:ring-0"
|
||||
placeholder={placeholder}
|
||||
value={searchTerm}
|
||||
onValueChange={handleUpdateSearchTerm}
|
||||
autoFocus
|
||||
tabIndex={baseTabIndex}
|
||||
/>
|
||||
{projectId && (
|
||||
<Tooltip tooltipContent="Toggle workspace level search" isMobile={isMobile}>
|
||||
<div className="flex-shrink-0 flex items-center gap-1 text-xs">
|
||||
<button type="button" onClick={toggleWorkspaceLevel} className="flex-shrink-0">
|
||||
Workspace level
|
||||
</button>
|
||||
<ToggleSwitch value={isWorkspaceLevel} onChange={toggleWorkspaceLevel} />
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+14
-16
@@ -2,20 +2,22 @@
|
||||
|
||||
import { Command } from "cmdk";
|
||||
import { useParams } from "next/navigation";
|
||||
// types
|
||||
// plane types
|
||||
import { IWorkspaceSearchResults } from "@plane/types";
|
||||
// helpers
|
||||
import { commandGroups } from "@/components/command-palette";
|
||||
// hooks
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
// local components
|
||||
import { PowerKCommandItem } from "./command-item";
|
||||
|
||||
type Props = {
|
||||
closePalette: () => void;
|
||||
handleClose: () => void;
|
||||
results: IWorkspaceSearchResults;
|
||||
};
|
||||
|
||||
export const CommandPaletteSearchResults: React.FC<Props> = (props) => {
|
||||
const { closePalette, results } = props;
|
||||
export const PowerKSearchResults: React.FC<Props> = (props) => {
|
||||
const { handleClose, results } = props;
|
||||
// router
|
||||
const router = useAppRouter();
|
||||
const { projectId: routerProjectId } = useParams();
|
||||
@@ -25,27 +27,23 @@ export const CommandPaletteSearchResults: React.FC<Props> = (props) => {
|
||||
return (
|
||||
<>
|
||||
{Object.keys(results.results).map((key) => {
|
||||
const section = (results.results as any)[key];
|
||||
const section = results.results[key as keyof typeof results.results];
|
||||
const currentSection = commandGroups[key];
|
||||
|
||||
if (section.length > 0) {
|
||||
return (
|
||||
<Command.Group key={key} heading={`${currentSection.title} search`}>
|
||||
<Command.Group key={key} heading={currentSection.title}>
|
||||
{section.map((item: any) => (
|
||||
<Command.Item
|
||||
<PowerKCommandItem
|
||||
key={item.id}
|
||||
icon={currentSection.icon ?? undefined}
|
||||
value={`${key}-${item?.id}-${item.name}-${item.project__identifier ?? ""}-${item.sequence_id ?? ""}`}
|
||||
label={currentSection.itemName(item)}
|
||||
onSelect={() => {
|
||||
closePalette();
|
||||
handleClose();
|
||||
router.push(currentSection.path(item, projectId));
|
||||
}}
|
||||
value={`${key}-${item?.id}-${item.name}-${item.project__identifier ?? ""}-${item.sequence_id ?? ""}`}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center gap-2 overflow-hidden text-custom-text-200">
|
||||
{currentSection.icon}
|
||||
<p className="block flex-1 truncate">{currentSection.itemName(item)}</p>
|
||||
</div>
|
||||
</Command.Item>
|
||||
/>
|
||||
))}
|
||||
</Command.Group>
|
||||
);
|
||||
@@ -0,0 +1,41 @@
|
||||
import React from "react";
|
||||
import { ProjectActionIcons } from "app/profile/sidebar";
|
||||
import { Command } from "cmdk";
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
// plane imports
|
||||
import { PROFILE_ACTION_LINKS } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
|
||||
type Props = {
|
||||
handleClose: () => void;
|
||||
};
|
||||
|
||||
export const PowerKProfileSettingsMenu: React.FC<Props> = observer((props) => {
|
||||
const { handleClose } = props;
|
||||
// navigation
|
||||
const router = useRouter();
|
||||
// translation
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
{PROFILE_ACTION_LINKS.map((setting) => (
|
||||
<Command.Item
|
||||
key={setting.key}
|
||||
onSelect={() => {
|
||||
handleClose();
|
||||
router.push(`/profile${setting.href}`);
|
||||
}}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<Link href={`/profile${setting.href}`} className="flex items-center gap-2 text-custom-text-200">
|
||||
<ProjectActionIcons type={setting.key} size={16} />
|
||||
{t(setting.i18n_label)}
|
||||
</Link>
|
||||
</Command.Item>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import React from "react";
|
||||
import { Command } from "cmdk";
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
// hooks
|
||||
import { useUserPermissions } from "@/hooks/store";
|
||||
// plane web constants
|
||||
import { EUserPermissionsLevel, PROJECT_SETTINGS_LINKS } from "@/plane-web/constants";
|
||||
|
||||
type Props = {
|
||||
handleClose: () => void;
|
||||
};
|
||||
|
||||
export const PowerKProjectSettingsMenu: React.FC<Props> = observer((props) => {
|
||||
const { handleClose } = props;
|
||||
// navigation
|
||||
const { workspaceSlug, projectId } = useParams();
|
||||
const router = useRouter();
|
||||
// store hooks
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
|
||||
return (
|
||||
<>
|
||||
{PROJECT_SETTINGS_LINKS.map(
|
||||
(setting) =>
|
||||
allowPermissions(
|
||||
setting.access,
|
||||
EUserPermissionsLevel.PROJECT,
|
||||
workspaceSlug?.toString(),
|
||||
projectId?.toString()
|
||||
) && (
|
||||
<Command.Item
|
||||
key={setting.key}
|
||||
onSelect={() => {
|
||||
handleClose();
|
||||
router.push(`/${workspaceSlug}/projects/${projectId}${setting.href}`);
|
||||
}}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<Link
|
||||
href={`/${workspaceSlug}/projects/${projectId}${setting.href}`}
|
||||
className="flex items-center gap-2 text-custom-text-200"
|
||||
>
|
||||
<setting.Icon className="flex-shrink-0 size-4 text-custom-text-200" />
|
||||
{setting.label}
|
||||
</Link>
|
||||
</Command.Item>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import { Command } from "cmdk";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { Settings } from "lucide-react";
|
||||
// plane imports
|
||||
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
||||
import { TPowerKPageKeys } from "@plane/types";
|
||||
import { useUserPermissions } from "@/hooks/store";
|
||||
// local components
|
||||
import { PowerKCommandItem } from "../command-item";
|
||||
import { PowerKProfileSettingsMenu } from "./profile-settings";
|
||||
import { PowerKProjectSettingsMenu } from "./project-settings";
|
||||
import { PowerKWorkspaceSettingsMenu } from "./workspace-settings";
|
||||
|
||||
type Props = {
|
||||
activePage: TPowerKPageKeys | undefined;
|
||||
handleClose: () => void;
|
||||
handleUpdateSearchTerm: (value: string) => void;
|
||||
handleUpdatePage: (page: TPowerKPageKeys) => void;
|
||||
};
|
||||
|
||||
export const PowerKSettingsMenu: React.FC<Props> = observer((props) => {
|
||||
const { activePage, handleClose, handleUpdateSearchTerm, handleUpdatePage } = props;
|
||||
// navigation
|
||||
const { projectId } = useParams();
|
||||
// store hooks
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
// derived values
|
||||
const canAccessWorkspaceSettings = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE);
|
||||
const canAccessProjectSettings = !!projectId;
|
||||
|
||||
return (
|
||||
<>
|
||||
{!activePage && (
|
||||
<Command.Group heading="Configs">
|
||||
{canAccessWorkspaceSettings && (
|
||||
<PowerKCommandItem
|
||||
icon={Settings}
|
||||
label="Workspace settings"
|
||||
onSelect={() => {
|
||||
handleUpdateSearchTerm("");
|
||||
handleUpdatePage("workspace-settings");
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{canAccessProjectSettings && (
|
||||
<PowerKCommandItem
|
||||
icon={Settings}
|
||||
label="Project settings"
|
||||
onSelect={() => {
|
||||
handleUpdateSearchTerm("");
|
||||
handleUpdatePage("project-settings");
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<PowerKCommandItem
|
||||
icon={Settings}
|
||||
label="Profile settings"
|
||||
onSelect={() => {
|
||||
handleUpdateSearchTerm("");
|
||||
handleUpdatePage("profile-settings");
|
||||
}}
|
||||
/>
|
||||
</Command.Group>
|
||||
)}
|
||||
{/* workspace settings menu */}
|
||||
{activePage === "workspace-settings" && canAccessWorkspaceSettings && (
|
||||
<PowerKWorkspaceSettingsMenu handleClose={handleClose} />
|
||||
)}
|
||||
{/* project settings menu */}
|
||||
{activePage === "project-settings" && canAccessProjectSettings && (
|
||||
<PowerKProjectSettingsMenu handleClose={handleClose} />
|
||||
)}
|
||||
{/* profile settings menu */}
|
||||
{activePage === "profile-settings" && <PowerKProfileSettingsMenu handleClose={handleClose} />}
|
||||
</>
|
||||
);
|
||||
});
|
||||
+20
-27
@@ -1,39 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Command } from "cmdk";
|
||||
// hooks
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { WORKSPACE_SETTINGS_LINKS, EUserPermissionsLevel } from "@plane/constants";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
// plane imports
|
||||
import { EUserPermissionsLevel, WORKSPACE_SETTINGS_LINKS } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// components
|
||||
import { SettingIcon } from "@/components/icons";
|
||||
// hooks
|
||||
import { useUserPermissions } from "@/hooks/store";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
// plane wev constants
|
||||
// plane web helpers
|
||||
import { shouldRenderSettingLink } from "@/plane-web/helpers/workspace.helper";
|
||||
|
||||
type Props = {
|
||||
closePalette: () => void;
|
||||
handleClose: () => void;
|
||||
};
|
||||
|
||||
export const CommandPaletteWorkspaceSettingsActions: React.FC<Props> = (props) => {
|
||||
const { closePalette } = props;
|
||||
// router
|
||||
const router = useAppRouter();
|
||||
// router params
|
||||
export const PowerKWorkspaceSettingsMenu: React.FC<Props> = observer((props) => {
|
||||
const { handleClose } = props;
|
||||
// navigation
|
||||
const { workspaceSlug } = useParams();
|
||||
// mobx store
|
||||
const router = useRouter();
|
||||
// translation
|
||||
const { t } = useTranslation();
|
||||
// store hooks
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
// derived values
|
||||
|
||||
const redirect = (path: string) => {
|
||||
closePalette();
|
||||
router.push(path);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -43,18 +35,19 @@ export const CommandPaletteWorkspaceSettingsActions: React.FC<Props> = (props) =
|
||||
shouldRenderSettingLink(setting.key) && (
|
||||
<Command.Item
|
||||
key={setting.key}
|
||||
onSelect={() => redirect(`/${workspaceSlug}${setting.href}`)}
|
||||
onSelect={() => {
|
||||
handleClose();
|
||||
router.push(`/${workspaceSlug}${setting.href}`);
|
||||
}}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<Link href={`/${workspaceSlug}${setting.href}`}>
|
||||
<div className="flex items-center gap-2 text-custom-text-200">
|
||||
<SettingIcon className="h-4 w-4 text-custom-text-200" />
|
||||
{t(setting.i18n_label)}
|
||||
</div>
|
||||
<Link href={`/${workspaceSlug}${setting.href}`} className="flex items-center gap-2 text-custom-text-200">
|
||||
<SettingIcon className="flex-shrink-0 size-4 text-custom-text-200" />
|
||||
{t(setting.i18n_label)}
|
||||
</Link>
|
||||
</Command.Item>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
/* 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";
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./root";
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user