Compare commits

...
Author SHA1 Message Date
Anmol Singh Bhatia 73d5a80c38 fix: sub work item 2025-03-28 16:53:00 +05:30
Anmol Singh Bhatia 92cdc4ecbb fix: create sub work item operation 2025-02-13 18:26:36 +05:30
Vamsi KrishnaandGitHub b88ae112f9 fix: updated lang key for defaults (#6599) 2025-02-12 19:59:05 +05:30
sriram veeraghanta 2d20278c9b fix: esbuild deps resolution 2025-02-12 18:44:31 +05:30
Prateek ShouryaandGitHub 8cff059868 improvement: add timeout before resetting data in workspace delete form (#6598) 2025-02-12 17:39:12 +05:30
Akshita GoyalandGitHub 6a3ccafe35 fix: code splitting for workspace delete modal (#6581)
* fix: code splitting for delete modal

* fix: redirected to profile post deletion

* fix: translations
2025-02-12 17:15:40 +05:30
Prateek ShouryaandGitHub cc9b448a9b improvement: enhance workspace invitation modularity (#6594) 2025-02-12 17:05:09 +05:30
Prateek ShouryaandGitHub e071bf4861 fix: minor ux copy update for recents empty state (#6597) 2025-02-12 16:57:33 +05:30
Prateek ShouryaandGitHub b9da7df6b7 fix: minor grammar fix (#6595) 2025-02-12 16:10:01 +05:30
sriram veeraghanta 03cc819601 fix: esbuild vulnerbility fix 2025-02-12 14:17:16 +05:30
sriram veeraghanta e1943ee11e fix: lock file regerated 2025-02-12 14:13:18 +05:30
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
b47d2b8825 chore(deps): bump @sentry/node (#6593)
Bumps the npm_and_yarn group with 1 update in the / directory: [@sentry/node](https://github.com/getsentry/sentry-javascript).


Updates `@sentry/node` from 9.0.0 to 9.0.1
- [Release notes](https://github.com/getsentry/sentry-javascript/releases)
- [Changelog](https://github.com/getsentry/sentry-javascript/blob/develop/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-javascript/compare/9.0.0...9.0.1)

---
updated-dependencies:
- dependency-name: "@sentry/node"
  dependency-type: direct:production
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-02-12 13:58:25 +05:30
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
300b47f9a1 chore(deps): bump cryptography (#6592)
Bumps the pip group with 1 update in the /apiserver/requirements directory: [cryptography](https://github.com/pyca/cryptography).


Updates `cryptography` from 43.0.1 to 44.0.1
- [Changelog](https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pyca/cryptography/compare/43.0.1...44.0.1)

---
updated-dependencies:
- dependency-name: cryptography
  dependency-type: direct:production
  dependency-group: pip
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-02-12 13:52:44 +05:30
sriram veeraghanta 03a4a97375 fix: workflow changes 2025-02-12 13:46:01 +05:30
SangeethaandGitHub 6157d5771d fix: delete_at not updating for epics (#6577) 2025-02-12 12:18:08 +05:30
Bavisetti NarayanandGitHub eee43be99a chore: removed the project filter (#6590) 2025-02-11 20:43:23 +05:30
Bavisetti NarayanandGitHub 4db95cc941 chore: duplicate the uploaded assets for duplicated page (#6311)
* chore: duplicate the uploaded assets in the entity

* chore: changed the filtering logic

* chore: captured exception
2025-02-11 20:42:06 +05:30
6aa139a851 fix: workspace level toggle position, paddings, and tab navigation (#6580)
* fix: workspace level toggle position, paddings, and tab navigation

* chore: platform-specific command icons

---------

Co-authored-by: Anmol Singh Bhatia <anmolsinghbhatia@plane.so>
2025-02-11 17:40:31 +05:30
Anmol Singh BhatiaandGitHub ac74cd9e92 [WEB-3352] fix: home recent item redirection (#6586)
* fix: dashboard recent item redirection

* chore: code refactor
2025-02-11 17:37:47 +05:30
Anmol Singh BhatiaandGitHub 7ae841d525 fix: work item form tab index (#6588) 2025-02-11 17:36:54 +05:30
Anmol Singh BhatiaandGitHub 7aa5b6aa91 fix: board layout redirection (#6584) 2025-02-11 16:20:22 +05:30
Akshita GoyalandGitHub 28c3f9d0cc fix: Handled workspace switcher closing on click (#6585) 2025-02-11 16:17:48 +05:30
NikhilandGitHub 9d01a6d5d7 fix: workspace label cache (#6587) 2025-02-11 16:17:04 +05:30
Anmol Singh BhatiaandGitHub 4fd8b4a3a9 [WEB-3347] fix: list layout stats indicator (#6582)
* fix: list layout stats indicator

* chore: code refactor
2025-02-11 14:51:02 +05:30
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
49cc73b6ed chore(deps): bump @sentry/node (#6579)
Bumps the npm_and_yarn group with 1 update in the / directory: [@sentry/node](https://github.com/getsentry/sentry-javascript).


Updates `@sentry/node` from 8.54.0 to 9.0.0
- [Release notes](https://github.com/getsentry/sentry-javascript/releases)
- [Changelog](https://github.com/getsentry/sentry-javascript/blob/develop/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-javascript/compare/8.54.0...9.0.0)

---
updated-dependencies:
- dependency-name: "@sentry/node"
  dependency-type: direct:production
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-02-11 14:09:43 +05:30
sriram veeraghanta 363507f987 chore: turbo upgrade 2025-02-10 23:52:06 +05:30
sriram veeraghanta 30453d1c79 fix: build errors 2025-02-10 20:08:54 +05:30
dff12729c0 [WEB-3287]fix: label creation in global views (#6541)
* fix: label creation on enter

* fix: update label creation permissions

---------

Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
2025-02-10 19:35:04 +05:30
Vamsi KrishnaandGitHub 8efe692c80 [WEB-3235]fix: transfer issues button mutation (#6549)
* fix: pending issues mutation

* fix: transfer issues count
2025-02-10 19:33:43 +05:30
Aaryan KhandelwalandGitHub ce57c1423c [WEB-3329] dev: new chart components (#6565)
* dev: new chart components

* chore: separate out pie chart tooltip

* chore: remove unused any types

* chore: move chart components to propel package
2025-02-10 16:01:06 +05:30
Akshita GoyalandGitHub 1eb1e82fe4 fix: issue icons in published project link (#6576) 2025-02-10 15:07:30 +05:30
a2328d0cbe fix: workspace roles for settings and members button (#6574)
* fix: workspace roles for settings and members button

* fix: user role and member count for new workspace

* chore: set role to 20 while workspace creation

---------

Co-authored-by: Prateek Shourya <prateekshourya29@gmail.com>
2025-02-10 15:03:50 +05:30
Dheeraj Kumar KetireddyandGitHub 5096a15051 Merge pull request #6575 from makeplane/modify-workspace-serializer
modify: workspace user link serializer
2025-02-10 14:51:32 +05:30
sangeethailango 55c2511ab5 modify: workspace serializer 2025-02-10 14:27:21 +05:30
Vipin ChaudharyandGitHub 16bc64e2fa fix: Remove AI Assistant Button from Tab Order to Prevent Focus (#6569)
* make ai button unfocused

* fix the format
2025-02-10 12:55:21 +05:30
Vipin ChaudharyandGitHub 14083ea7da fix: handle shift tab tab-index focus (#6554)
* handle shift tab tab-index focus

* add comment

* fix double tap

* make label focus

* fix title focus

* focus discard and save

* remove comment
2025-02-08 20:54:23 +05:30
Anmol Singh BhatiaandGitHub feb88e64a4 [WEB-3292] fix: workspace switcher validation and ui improvements (#6570)
* fix: workspace action item validation and ui improvements

* chore: code refactor
2025-02-07 20:58:56 +05:30
SangeethaandGitHub a00bb35e54 [WEB-3285] fix: creating and updating duplicate quick links (#6557)
* fix: creating and updating duplicate quick links

* fix: improve code readibiltiy
2025-02-07 20:06:47 +05:30
20ba91b98c [WEB-3292] feat: workspace switcher redesign (#6543)
* feat: ui changes for workspace switcher

* fix: hover

* fix: added current plan

* feat: Return user role

* chore: remove unused imports

* fix: css

* fix: added user role in workspace switcher

* fix: return role as integer

* fix: role casing

* fix: refactor

* fix: plan pill fix

* fix: design updates

* fix: refactor

* fix: member translation

* fix: css improvements

* fix: truncate issue

* fix: workspace switcher dropdown email truncate

* fix: workspace switcher dropdown email truncate

* fix: role

---------

Co-authored-by: sangeethailango <sangeethailango21@gmail.com>
Co-authored-by: Anmol Singh Bhatia <anmolsinghbhatia@plane.so>
2025-02-07 20:05:57 +05:30
Akshita GoyalandGitHub 456c7f55a9 [WEB-2917] Fix home widget (#6560)
* fix: home loading state

* fix: quickstart guide

* fix: link handling

* fix: home completed state

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

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