Compare commits

..
167 changed files with 6413 additions and 6188 deletions
-4
View File
@@ -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
+2 -37
View File
@@ -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()
+4 -4
View File
@@ -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",
+1 -3
View File
@@ -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:
+3 -2
View File
@@ -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(
+1 -11
View File
@@ -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(
+17 -16
View File
@@ -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)
-150
View File
@@ -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 []
+1 -4
View File
@@ -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:
-2
View File
@@ -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))
-14
View File
@@ -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
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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"
-2
View File
@@ -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
View File
@@ -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";
+1
View File
@@ -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 }) => {
+3 -15
View File
@@ -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",
+3 -15
View File
@@ -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",
+3 -15
View File
@@ -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",
+3 -15
View File
@@ -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": "名称为必填项",
-5
View File
@@ -1,5 +0,0 @@
.next
.turbo
out/
dist/
build/
-5
View File
@@ -1,5 +0,0 @@
{
"printWidth": 120,
"tabWidth": 2,
"trailingComma": "es5"
}
+12 -15
View File
@@ -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";
-41
View File
@@ -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";
+47
View File
@@ -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;
View File
+57
View File
@@ -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}",
],
+16 -61
View File
@@ -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[];
+1
View File
@@ -104,6 +104,7 @@ export interface ICycle extends TProgressSnapshot {
project_detail: IProjectDetails;
progress: any[];
version: number;
pending_issues: number;
}
export interface CycleIssueResponse {
+1
View File
@@ -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";
+24
View File
@@ -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;
};
+1 -2
View File
@@ -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
View File
@@ -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;
-1
View File
@@ -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 />
</>
);
});
+2 -3
View File
@@ -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}
/>
)}
</>
);
});
@@ -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>
))}
</>
);
});
@@ -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} />
)}
</>
);
});
@@ -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>
);
});
@@ -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>
);
};
@@ -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} />}
</>
);
});
@@ -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