Compare commits

...
Author SHA1 Message Date
Aaryan Khandelwal 45ddfc4559 fix: table header selected status 2025-07-01 20:54:06 +05:30
Aaryan Khandelwal ace33f6093 chore: add aria labels 2025-06-30 19:59:37 +05:30
Aaryan Khandelwal 9d1d3755d0 refactor: plugins folder structure 2025-06-30 19:39:48 +05:30
Aaryan Khandelwal 50e342638f refactor: css rules 2025-06-30 19:37:19 +05:30
Aaryan Khandelwal 756cba5096 Merge branch 'preview' of https://github.com/makeplane/plane into feat/table-insert-handlers 2025-06-30 19:25:00 +05:30
Aaryan Khandelwal 369be93a77 feat: table insert handlers 2025-06-30 17:41:12 +05:30
Jayash TripathyandGitHub 2f6923fca0 fix: work item peek infinite loop (#7284)
* fix: removed t function from dependency array which was causing infinite loop

* fix: add eslint disable comment for exhaustive-deps warning in IssuePeekOverview
2025-06-30 16:10:50 +05:30
Vamsi KrishnaandGitHub 912246c592 [WEB-4365] fix: dates display properties toggle #7262 2025-06-30 16:10:06 +05:30
Aaryan Khandelwal a8487c2ee3 Merge branch 'preview' of https://github.com/makeplane/plane into refactor/tables 2025-06-27 19:40:18 +05:30
Aaryan Khandelwal e32807d908 refactor: plugin location 2025-06-27 19:29:57 +05:30
4a065e14d0 [WEB-4409] refactor: event constants (#7276)
* refactor: event constants

* fix: cycle event keys

* chore: store extension

* chore: update events naming convention

---------

Co-authored-by: sriramveeraghanta <veeraghanta.sriram@gmail.com>
2025-06-27 19:01:00 +05:30
Aaryan Khandelwal 13c7ac8f9c chore: default column width for new columns 2025-06-27 18:42:25 +05:30
Aaryan KhandelwalandGitHub e09aeab5b8 [WIKI-481] refactor: editor parser #7261 2025-06-27 16:05:38 +05:30
Aaryan Khandelwal 167e53f74c refactor: folder structure 2025-06-27 14:27:52 +05:30
Aaryan Khandelwal ec7cbd474d refactor: adjacent cells logic 2025-06-27 14:18:52 +05:30
Aaryan Khandelwal a0d6fa0f1e refactor: selection decorator logic 2025-06-27 00:55:43 +05:30
Aaryan Khandelwal fdd4f80576 fix: drag handle position 2025-06-26 18:00:11 +05:30
Aaryan Khandelwal a414ae23f5 refactor: tables width and selection UI 2025-06-26 17:31:29 +05:30
Akshita GoyalandGitHub 25a6cd49fc fix: added @plane/services to the web dependencies (#7271) 2025-06-26 14:33:13 +05:30
JayashTripathyGitHubcoderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
b5538565c7 [WEB-4371] feat: bar chart component with lollipop shape variant (#7268)
* feat: enhance bar chart component with shape variants and custom tooltip

* Update packages/propel/src/charts/bar-chart/bar.tsx

removed the unknown props

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* Update packages/propel/src/charts/bar-chart/bar.tsx

removed console log

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* refactor: replace inline percentage text with PercentageText component in bar chart

* Added new variant - lollipop-dotted

* added some comments

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-06-25 23:43:10 +05:30
b8043f92b1 [WEB-4373]: optimize backend query for workspace views and Project gantt view (#7267)
* feat: add IssueListDetailSerializer for detailed issue representation

- Introduced IssueListDetailSerializer to enhance issue data representation with expanded fields.
- Updated issue detail endpoint to utilize the new serializer for improved data handling.
- Added methods for retrieving related module, label, and assignee IDs, along with support for expanded relations.

* feat: add ViewIssueListSerializer and enhance issue ordering

- Introduced ViewIssueListSerializer for improved issue representation, including assignee, label, and module IDs.
- Updated WorkspaceViewIssuesViewSet to utilize the new serializer and optimized queryset with prefetching.
- Enhanced order_issue_queryset to maintain consistent ordering by created_at alongside other fields.
- Modified pagination logic to support total count retrieval for better performance.

* fix: optimize issue filtering and pagination logic

- Updated WorkspaceViewIssuesViewSet to apply filters more efficiently in the issue query.
- Refined pagination logic in OffsetPaginator to ensure consistent behavior using limit instead of cursor.value, improving overall pagination accuracy.

* fix: improve pagination logic in OffsetPaginator

- Updated the next_cursor calculation to use the length of results instead of cursor.value, ensuring accurate pagination behavior.
- Added a comment to clarify the purpose of checking for additional results after the current page.

* Move the common permission filters into a separate method

* fix: handle deleted related issues in serializers

- Updated IssueListDetailSerializer to skip null related issues when building relations.
- Enhanced ViewIssueListSerializer to safely access state.group, returning None if state is not present.
- Removed unused User import in base.py for cleaner code.

---------

Co-authored-by: Dheeraj Kumar Ketireddy <dheeru0198@gmail.com>
2025-06-25 19:10:24 +05:30
Aaryan KhandelwalandGitHub 0e91feacc3 [WIKI-74] fix: peek overview closing on escape key #7259 2025-06-25 19:09:54 +05:30
Prateek ShouryaandGitHub 40c0922726 [WEB-4387] fix: global layout when filter is set to list (#7264) 2025-06-24 20:25:26 +05:30
Aaryan KhandelwalandGitHub dee8f00a71 [WEB-4384] fix: power k page search redirection #7263 2025-06-24 20:24:35 +05:30
Aaryan KhandelwalandGitHub 072f2e2cac [WEB-4363] regression: analytics tabs improvements #7260 2025-06-24 16:08:30 +05:30
Prateek ShouryaandGitHub 79c2dfd293 [WEB-4375] fix: minor issues in timeline layout (#7257) 2025-06-24 14:19:00 +05:30
JayashTripathyandGitHub 22a9d48ca3 [WEB-4363]: Analytics tab improvements #7248
fix: padding in tabs
2025-06-24 14:17:40 +05:30
fbcc8fc8a0 [WIKI-421] fix: Toolbar not reflecting strikethrough state (#7255)
* fix: strick through

* fix: bubble menu options types

---------

Co-authored-by: vipin chaudhary <vipinchaudhary@vipins-MacBook-Pro.local>
2025-06-24 14:16:07 +05:30
Aaryan KhandelwalandGitHub c1fa372c84 [WIKI-471] refactor: custom image extension (#7247)
* refactor: custom image extension

* refactor: extension config

* revert: image full screen component

* fix: undo operation
2025-06-24 14:05:11 +05:30
Prateek ShouryaandGitHub 7045a1f2af [WEB-4361] fix: add onChange to collaborative editor #7246 2025-06-20 17:24:49 +05:30
Prateek ShouryaandGitHub f26b4d3d06 [WEB-4359] fix: application crash when creating work item via quick add (#7245) 2025-06-20 15:16:16 +05:30
Prateek ShouryaandGitHub c3c1aef7a9 [WEB-4357] fix: remove trailing slash from asset url #7240 2025-06-19 19:09:59 +05:30
24e57009af [WIKI-465] fix : Add new node on click of doc end (#7063)
* fix : handle last node

* fix : handle unexpected node

* remove logs

* feat: handle focus

---------

Co-authored-by: M. Palanikannan <73993394+Palanikannan1437@users.noreply.github.com>
2025-06-19 17:17:56 +05:30
2b7a17b484 [WEB-4050] feat: breadcrumbs revamp (#7188)
* chore: project feature enum added

* feat: revamp breadcrumb and add navigation dropdown component

* chore: custom search select component refactoring

* chore: breadcrumb stories added

* chore: switch label and breadcrumb link component refactor

* chore: project navigation helper function added

* chore: common breadcrumb component added

* chore: breadcrumb refactoring

* chore: code refactor

* chore: code refactor

* fix: build error

* fix: nprogress and button tooltip

* chore: code refactor

* chore: workspace view breadcrumb improvements

* chore: code refactor

* chore: code refactor

* chore: code refactor

* chore: code refactor

---------

Co-authored-by: vamsikrishnamathala <matalav55@gmail.com>
2025-06-19 17:17:14 +05:30
64fd0b2830 [WEB-4321]chore: workspace views refactor (#7214)
* chore: workspace views reafactor

* chore: resolved coderabbit suggestions

* chore: added project level workspace filter

* chore: added enum for roles

* chore: removed redundant type definition

* chore: optimised the query

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
2025-06-19 16:26:32 +05:30
Aaryan KhandelwalandGitHub 8988cf9a85 [WEB-462] refactor: editor props structure (#7233)
* refactor: editor props structure

* chore: add missing prop

* fix: space app build

* chore: export ce types
2025-06-19 16:25:52 +05:30
eb5ffebcc6 [WIKI-458] refactor: base page instance for additional properties (#7228)
* refactor: create a super class for base page

* fix: path

---------

Co-authored-by: Palanikannan M <akashmalinimurugu@gmail.com>
2025-06-19 16:00:18 +05:30
M. PalanikannanandGitHub 414010688d [WIKI-384] chore: editor core refactor (#7235)
* fix: extra actions

* chore: page flags
2025-06-19 15:59:38 +05:30
JayashTripathyandGitHub 171099667e [WEB-4339] fix: projects dropdown shows all projects (#7238)
* fix: projects drop only shows joined project

* refactor: removed unused things from header
2025-06-19 15:57:19 +05:30
d65f0e264e [WEB-4327] Chore PAT permissions (#7224)
* chore: improved pat permissions

* fix: err message

* fix: removed permission from backend

* [WEB-4330] refactor: update API token endpoints to use user context instead of workspace slug

- Changed URL patterns for API token endpoints to use "users/api-tokens/" instead of "workspaces/<str:slug>/api-tokens/".
- Refactored ApiTokenEndpoint methods to remove workspace slug parameter and adjust database queries accordingly.
- Added new test cases for API token creation, retrieval, deletion, and updates, including support for bot users and minimal data submissions.

* fix: removed workspace slug from api-tokens

* fix: refactor

* chore: url.py code rabbit suggestion

* fix: APITokenService moved to package

---------

Co-authored-by: Dheeraj Kumar Ketireddy <dheeru0198@gmail.com>
Co-authored-by: sriramveeraghanta <veeraghanta.sriram@gmail.com>
2025-06-18 16:08:11 +05:30
Akshita GoyalandGitHub c7d17d00b7 [WEB-4017] fix: hooks and store refactoring for issue-details (#7107)
* fix: hooks and store splitting for issue-details

* fix: refactoring

* fix: refactoring

* fix: refactor

* fix: css
2025-06-18 15:59:26 +05:30
JayashTripathyandGitHub 9cdfb2224a [WEB-4160]: Context menu close after clicking on menu item of project #7231 2025-06-18 15:33:06 +05:30
SangeethaandGitHub 8129f5f969 [WEB-4340] fix: duplicate assignees in user recents (#7216)
* fix: duplicate assignees in user recents

* chore: optimize filtering logic

* chore: filter with deleted_at field

* chore: tests for IssueRecentSerializer
2025-06-18 15:14:21 +05:30
266 changed files with 5206 additions and 2853 deletions
+4 -6
View File
@@ -67,9 +67,8 @@ export const InstanceHeader: FC = observer(() => {
{breadcrumbItems.length >= 0 && (
<div>
<Breadcrumbs>
<Breadcrumbs.BreadcrumbItem
type="text"
link={
<Breadcrumbs.Item
component={
<BreadcrumbLink
href="/general/"
label="Settings"
@@ -80,10 +79,9 @@ export const InstanceHeader: FC = observer(() => {
{breadcrumbItems.map(
(item) =>
item.title && (
<Breadcrumbs.BreadcrumbItem
<Breadcrumbs.Item
key={item.title}
type="text"
link={<BreadcrumbLink href={item.href} label={item.title} />}
component={<BreadcrumbLink href={item.href} label={item.title} />}
/>
)
)}
+2 -1
View File
@@ -39,7 +39,7 @@ from .project import (
ProjectMemberRoleSerializer,
)
from .state import StateSerializer, StateLiteSerializer
from .view import IssueViewSerializer
from .view import IssueViewSerializer, ViewIssueListSerializer
from .cycle import (
CycleSerializer,
CycleIssueSerializer,
@@ -74,6 +74,7 @@ from .issue import (
IssueLinkLiteSerializer,
IssueVersionDetailSerializer,
IssueDescriptionVersionDetailSerializer,
IssueListDetailSerializer,
)
from .module import (
+104
View File
@@ -725,6 +725,110 @@ class IssueSerializer(DynamicBaseSerializer):
read_only_fields = fields
class IssueListDetailSerializer(serializers.Serializer):
def __init__(self, *args, **kwargs):
# Extract expand parameter and store it as instance variable
self.expand = kwargs.pop("expand", []) or []
# Extract fields parameter and store it as instance variable
self.fields = kwargs.pop("fields", []) or []
super().__init__(*args, **kwargs)
def get_module_ids(self, obj):
return [module.module_id for module in obj.issue_module.all()]
def get_label_ids(self, obj):
return [label.label_id for label in obj.label_issue.all()]
def get_assignee_ids(self, obj):
return [assignee.assignee_id for assignee in obj.issue_assignee.all()]
def to_representation(self, instance):
data = {
# Basic fields
"id": instance.id,
"name": instance.name,
"state_id": instance.state_id,
"sort_order": instance.sort_order,
"completed_at": instance.completed_at,
"estimate_point": instance.estimate_point_id,
"priority": instance.priority,
"start_date": instance.start_date,
"target_date": instance.target_date,
"sequence_id": instance.sequence_id,
"project_id": instance.project_id,
"parent_id": instance.parent_id,
"created_at": instance.created_at,
"updated_at": instance.updated_at,
"created_by": instance.created_by_id,
"updated_by": instance.updated_by_id,
"is_draft": instance.is_draft,
"archived_at": instance.archived_at,
# Computed fields
"cycle_id": instance.cycle_id,
"module_ids": self.get_module_ids(instance),
"label_ids": self.get_label_ids(instance),
"assignee_ids": self.get_assignee_ids(instance),
"sub_issues_count": instance.sub_issues_count,
"attachment_count": instance.attachment_count,
"link_count": instance.link_count,
}
# Handle expanded fields only when requested - using direct field access
if self.expand:
if "issue_relation" in self.expand:
relations = []
for relation in instance.issue_relation.all():
related_issue = relation.related_issue
# If the related issue is deleted, skip it
if not related_issue:
continue
# Add the related issue to the relations list
relations.append(
{
"id": related_issue.id,
"project_id": related_issue.project_id,
"sequence_id": related_issue.sequence_id,
"name": related_issue.name,
"relation_type": relation.relation_type,
"state_id": related_issue.state_id,
"priority": related_issue.priority,
"created_by": related_issue.created_by_id,
"created_at": related_issue.created_at,
"updated_at": related_issue.updated_at,
"updated_by": related_issue.updated_by_id,
}
)
data["issue_relation"] = relations
if "issue_related" in self.expand:
related = []
for relation in instance.issue_related.all():
issue = relation.issue
# If the related issue is deleted, skip it
if not issue:
continue
# Add the related issue to the related list
related.append(
{
"id": issue.id,
"project_id": issue.project_id,
"sequence_id": issue.sequence_id,
"name": issue.name,
"relation_type": relation.relation_type,
"state_id": issue.state_id,
"priority": issue.priority,
"created_by": issue.created_by_id,
"created_at": issue.created_at,
"updated_at": issue.updated_at,
"updated_by": issue.updated_by_id,
}
)
data["issue_related"] = related
return data
class IssueLiteSerializer(DynamicBaseSerializer):
class Meta:
model = Issue
+43
View File
@@ -7,6 +7,49 @@ from plane.db.models import IssueView
from plane.utils.issue_filters import issue_filters
class ViewIssueListSerializer(serializers.Serializer):
def get_assignee_ids(self, instance):
return [assignee.assignee_id for assignee in instance.issue_assignee.all()]
def get_label_ids(self, instance):
return [label.label_id for label in instance.label_issue.all()]
def get_module_ids(self, instance):
return [module.module_id for module in instance.issue_module.all()]
def to_representation(self, instance):
data = {
"id": instance.id,
"name": instance.name,
"state_id": instance.state_id,
"sort_order": instance.sort_order,
"completed_at": instance.completed_at,
"estimate_point": instance.estimate_point_id,
"priority": instance.priority,
"start_date": instance.start_date,
"target_date": instance.target_date,
"sequence_id": instance.sequence_id,
"project_id": instance.project_id,
"parent_id": instance.parent_id,
"cycle_id": instance.cycle_id,
"sub_issues_count": instance.sub_issues_count,
"created_at": instance.created_at,
"updated_at": instance.updated_at,
"created_by": instance.created_by_id,
"updated_by": instance.updated_by_id,
"attachment_count": instance.attachment_count,
"link_count": instance.link_count,
"is_draft": instance.is_draft,
"archived_at": instance.archived_at,
"state__group": instance.state.group if instance.state else None,
"assignee_ids": self.get_assignee_ids(instance),
"label_ids": self.get_label_ids(instance),
"module_ids": self.get_module_ids(instance),
}
return data
class IssueViewSerializer(DynamicBaseSerializer):
is_favorite = serializers.BooleanField(read_only=True)
+8 -3
View File
@@ -1,7 +1,5 @@
# Third party imports
from rest_framework import serializers
from rest_framework import status
from rest_framework.response import Response
# Module imports
from .base import BaseSerializer, DynamicBaseSerializer
@@ -198,6 +196,7 @@ class WorkspaceUserLinkSerializer(BaseSerializer):
class IssueRecentVisitSerializer(serializers.ModelSerializer):
project_identifier = serializers.SerializerMethodField()
assignees = serializers.SerializerMethodField()
class Meta:
model = Issue
@@ -215,9 +214,15 @@ class IssueRecentVisitSerializer(serializers.ModelSerializer):
def get_project_identifier(self, obj):
project = obj.project
return project.identifier if project else None
def get_assignees(self, obj):
return list(
obj.assignees.filter(issue_assignee__deleted_at__isnull=True).values_list(
"id", flat=True
)
)
class ProjectRecentVisitSerializer(serializers.ModelSerializer):
project_members = serializers.SerializerMethodField()
+3 -3
View File
@@ -4,14 +4,14 @@ from plane.app.views import ApiTokenEndpoint, ServiceApiTokenEndpoint
urlpatterns = [
# API Tokens
path(
"workspaces/<str:slug>/api-tokens/",
"users/api-tokens/",
ApiTokenEndpoint.as_view(),
name="api-tokens",
),
path(
"workspaces/<str:slug>/api-tokens/<uuid:pk>/",
"users/api-tokens/<uuid:pk>/",
ApiTokenEndpoint.as_view(),
name="api-tokens",
name="api-tokens-details",
),
path(
"workspaces/<str:slug>/service-api-tokens/",
+11 -19
View File
@@ -1,8 +1,10 @@
# Python import
from uuid import uuid4
from typing import Optional
# Third party
from rest_framework.response import Response
from rest_framework.request import Request
from rest_framework import status
# Module import
@@ -13,12 +15,9 @@ from plane.app.permissions import WorkspaceEntityPermission
class ApiTokenEndpoint(BaseAPIView):
permission_classes = [WorkspaceEntityPermission]
def post(self, request, slug):
def post(self, request: Request) -> Response:
label = request.data.get("label", str(uuid4().hex))
description = request.data.get("description", "")
workspace = Workspace.objects.get(slug=slug)
expired_at = request.data.get("expired_at", None)
# Check the user type
@@ -28,7 +27,6 @@ class ApiTokenEndpoint(BaseAPIView):
label=label,
description=description,
user=request.user,
workspace=workspace,
user_type=user_type,
expired_at=expired_at,
)
@@ -37,29 +35,23 @@ class ApiTokenEndpoint(BaseAPIView):
# Token will be only visible while creating
return Response(serializer.data, status=status.HTTP_201_CREATED)
def get(self, request, slug, pk=None):
def get(self, request: Request, pk: Optional[str] = None) -> Response:
if pk is None:
api_tokens = APIToken.objects.filter(
user=request.user, workspace__slug=slug, is_service=False
)
api_tokens = APIToken.objects.filter(user=request.user, is_service=False)
serializer = APITokenReadSerializer(api_tokens, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
else:
api_tokens = APIToken.objects.get(
user=request.user, workspace__slug=slug, pk=pk
)
api_tokens = APIToken.objects.get(user=request.user, pk=pk)
serializer = APITokenReadSerializer(api_tokens)
return Response(serializer.data, status=status.HTTP_200_OK)
def delete(self, request, slug, pk):
api_token = APIToken.objects.get(
workspace__slug=slug, user=request.user, pk=pk, is_service=False
)
def delete(self, request: Request, pk: str) -> Response:
api_token = APIToken.objects.get(user=request.user, pk=pk, is_service=False)
api_token.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
def patch(self, request, slug, pk):
api_token = APIToken.objects.get(workspace__slug=slug, user=request.user, pk=pk)
def patch(self, request: Request, pk: str) -> Response:
api_token = APIToken.objects.get(user=request.user, pk=pk)
serializer = APITokenSerializer(api_token, data=request.data, partial=True)
if serializer.is_valid():
serializer.save()
@@ -70,7 +62,7 @@ class ApiTokenEndpoint(BaseAPIView):
class ServiceApiTokenEndpoint(BaseAPIView):
permission_classes = [WorkspaceEntityPermission]
def post(self, request, slug):
def post(self, request: Request, slug: str) -> Response:
workspace = Workspace.objects.get(slug=slug)
api_token = APIToken.objects.filter(
+72 -40
View File
@@ -32,6 +32,7 @@ from plane.app.serializers import (
IssueDetailSerializer,
IssueUserPropertySerializer,
IssueSerializer,
IssueListDetailSerializer,
)
from plane.bgtasks.issue_activities_task import issue_activity
from plane.db.models import (
@@ -46,6 +47,9 @@ from plane.db.models import (
CycleIssue,
UserRecentVisit,
ModuleIssue,
IssueRelation,
IssueAssignee,
IssueLabel,
)
from plane.utils.grouper import (
issue_group_values,
@@ -944,10 +948,57 @@ class IssueDetailEndpoint(BaseAPIView):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def get(self, request, slug, project_id):
filters = issue_filters(request.query_params, "GET")
# check for the project member role, if the role is 5 then check for the guest_view_all_features
# if it is true then show all the issues else show only the issues created by the user
permission_subquery = (
Issue.issue_objects.filter(
workspace__slug=slug, project_id=project_id, id=OuterRef("id")
)
.filter(
Q(
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
project__project_projectmember__role__gt=ROLE.GUEST.value,
)
| Q(
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
project__project_projectmember__role=ROLE.GUEST.value,
project__guest_view_all_features=True,
)
| Q(
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
project__project_projectmember__role=ROLE.GUEST.value,
project__guest_view_all_features=False,
created_by=self.request.user,
)
)
.values("id")
)
# Main issue query
issue = (
Issue.issue_objects.filter(workspace__slug=slug, project_id=project_id)
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module")
.filter(Exists(permission_subquery))
.prefetch_related(
Prefetch(
"issue_assignee",
queryset=IssueAssignee.objects.all(),
)
)
.prefetch_related(
Prefetch(
"label_issue",
queryset=IssueLabel.objects.all(),
)
)
.prefetch_related(
Prefetch(
"issue_module",
queryset=ModuleIssue.objects.all(),
)
)
.annotate(
cycle_id=Subquery(
CycleIssue.objects.filter(
@@ -955,43 +1006,6 @@ class IssueDetailEndpoint(BaseAPIView):
).values("cycle_id")[:1]
)
)
.annotate(
label_ids=Coalesce(
ArrayAgg(
"labels__id",
distinct=True,
filter=Q(
~Q(labels__id__isnull=True)
& Q(label_issue__deleted_at__isnull=True)
),
),
Value([], output_field=ArrayField(UUIDField())),
),
assignee_ids=Coalesce(
ArrayAgg(
"assignees__id",
distinct=True,
filter=Q(
~Q(assignees__id__isnull=True)
& Q(assignees__member_project__is_active=True)
& Q(issue_assignee__deleted_at__isnull=True)
),
),
Value([], output_field=ArrayField(UUIDField())),
),
module_ids=Coalesce(
ArrayAgg(
"issue_module__module_id",
distinct=True,
filter=Q(
~Q(issue_module__module_id__isnull=True)
& Q(issue_module__module__archived_at__isnull=True)
& Q(issue_module__deleted_at__isnull=True)
),
),
Value([], output_field=ArrayField(UUIDField())),
),
)
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
@@ -1014,6 +1028,24 @@ class IssueDetailEndpoint(BaseAPIView):
.values("count")
)
)
# Add additional prefetch based on expand parameter
if self.expand:
if "issue_relation" in self.expand:
issue = issue.prefetch_related(
Prefetch(
"issue_relation",
queryset=IssueRelation.objects.select_related("related_issue"),
)
)
if "issue_related" in self.expand:
issue = issue.prefetch_related(
Prefetch(
"issue_related",
queryset=IssueRelation.objects.select_related("issue"),
)
)
issue = issue.filter(**filters)
order_by_param = request.GET.get("order_by", "-created_at")
# Issue queryset
@@ -1024,7 +1056,7 @@ class IssueDetailEndpoint(BaseAPIView):
request=request,
order_by=order_by_param,
queryset=(issue),
on_results=lambda issue: IssueSerializer(
on_results=lambda issue: IssueListDetailSerializer(
issue, many=True, fields=self.fields, expand=self.expand
).data,
)
+73 -163
View File
@@ -1,8 +1,13 @@
# Django imports
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
from django.db.models import Exists, F, Func, OuterRef, Q, UUIDField, Value, Subquery
from django.db.models.functions import Coalesce
from django.db.models import (
Exists,
F,
Func,
OuterRef,
Q,
Subquery,
Prefetch,
)
from django.utils.decorators import method_decorator
from django.views.decorators.gzip import gzip_page
from django.db import transaction
@@ -13,7 +18,7 @@ from rest_framework.response import Response
# Module imports
from plane.app.permissions import allow_permission, ROLE
from plane.app.serializers import IssueViewSerializer
from plane.app.serializers import IssueViewSerializer, ViewIssueListSerializer
from plane.db.models import (
Issue,
FileAsset,
@@ -25,15 +30,12 @@ from plane.db.models import (
Project,
CycleIssue,
UserRecentVisit,
)
from plane.utils.grouper import (
issue_group_values,
issue_on_results,
issue_queryset_grouper,
IssueAssignee,
IssueLabel,
ModuleIssue,
)
from plane.utils.issue_filters import issue_filters
from plane.utils.order_queryset import order_issue_queryset
from plane.utils.paginator import GroupedOffsetPaginator, SubGroupedOffsetPaginator
from plane.bgtasks.recent_visited_task import recent_visited_task
from .. import BaseViewSet
from plane.db.models import UserFavorite
@@ -143,6 +145,28 @@ class WorkspaceViewViewSet(BaseViewSet):
class WorkspaceViewIssuesViewSet(BaseViewSet):
def _get_project_permission_filters(self):
"""
Get common project permission filters for guest users and role-based access control.
Returns Q object for filtering issues based on user role and project settings.
"""
return Q(
Q(
project__project_projectmember__role=5,
project__guest_view_all_features=True,
)
| Q(
project__project_projectmember__role=5,
project__guest_view_all_features=False,
created_by=self.request.user,
)
|
# For other roles (role > 5), show all issues
Q(project__project_projectmember__role__gt=5),
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
)
def get_queryset(self):
return (
Issue.issue_objects.annotate(
@@ -152,12 +176,25 @@ class WorkspaceViewIssuesViewSet(BaseViewSet):
.values("count")
)
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
.select_related("state")
.prefetch_related(
Prefetch(
"issue_assignee",
queryset=IssueAssignee.objects.all(),
)
)
.prefetch_related(
Prefetch(
"label_issue",
queryset=IssueLabel.objects.all(),
)
)
.prefetch_related(
Prefetch(
"issue_module",
queryset=ModuleIssue.objects.all(),
)
)
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module")
.annotate(
cycle_id=Subquery(
CycleIssue.objects.filter(
@@ -186,43 +223,6 @@ class WorkspaceViewIssuesViewSet(BaseViewSet):
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
label_ids=Coalesce(
ArrayAgg(
"labels__id",
distinct=True,
filter=Q(
~Q(labels__id__isnull=True)
& Q(label_issue__deleted_at__isnull=True)
),
),
Value([], output_field=ArrayField(UUIDField())),
),
assignee_ids=Coalesce(
ArrayAgg(
"assignees__id",
distinct=True,
filter=Q(
~Q(assignees__id__isnull=True)
& Q(assignees__member_project__is_active=True)
& Q(issue_assignee__deleted_at__isnull=True)
),
),
Value([], output_field=ArrayField(UUIDField())),
),
module_ids=Coalesce(
ArrayAgg(
"issue_module__module_id",
distinct=True,
filter=Q(
~Q(issue_module__module_id__isnull=True)
& Q(issue_module__module__archived_at__isnull=True)
& Q(issue_module__deleted_at__isnull=True)
),
),
Value([], output_field=ArrayField(UUIDField())),
),
)
)
@method_decorator(gzip_page)
@@ -233,126 +233,36 @@ class WorkspaceViewIssuesViewSet(BaseViewSet):
filters = issue_filters(request.query_params, "GET")
order_by_param = request.GET.get("order_by", "-created_at")
issue_queryset = (
self.get_queryset()
.filter(**filters)
.annotate(
cycle_id=Subquery(
CycleIssue.objects.filter(
issue=OuterRef("id"), deleted_at__isnull=True
).values("cycle_id")[:1]
)
)
issue_queryset = self.get_queryset().filter(**filters)
# Get common project permission filters
permission_filters = self._get_project_permission_filters()
# Base query for the counts
total_issue_count = (
Issue.issue_objects.filter(**filters)
.filter(workspace__slug=slug)
.filter(permission_filters)
.only("id")
)
# check for the project member role, if the role is 5 then check for the guest_view_all_features if it is true then show all the issues else show only the issues created by the user
issue_queryset = issue_queryset.filter(
Q(
project__project_projectmember__role=5,
project__guest_view_all_features=True,
)
| Q(
project__project_projectmember__role=5,
project__guest_view_all_features=False,
created_by=self.request.user,
)
|
# For other roles (role < 5), show all issues
Q(project__project_projectmember__role__gt=5),
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
)
# Apply project permission filters to the issue queryset
issue_queryset = issue_queryset.filter(permission_filters)
# Issue queryset
issue_queryset, order_by_param = order_issue_queryset(
issue_queryset=issue_queryset, order_by_param=order_by_param
)
# Group by
group_by = request.GET.get("group_by", False)
sub_group_by = request.GET.get("sub_group_by", False)
# issue queryset
issue_queryset = issue_queryset_grouper(
queryset=issue_queryset, group_by=group_by, sub_group_by=sub_group_by
# List Paginate
return self.paginate(
order_by=order_by_param,
request=request,
queryset=issue_queryset,
on_results=lambda issues: ViewIssueListSerializer(issues, many=True).data,
total_count_queryset=total_issue_count,
)
if group_by:
# Check group and sub group value paginate
if sub_group_by:
if group_by == sub_group_by:
return Response(
{
"error": "Group by and sub group by cannot have same parameters"
},
status=status.HTTP_400_BAD_REQUEST,
)
else:
# group and sub group pagination
return self.paginate(
request=request,
order_by=order_by_param,
queryset=issue_queryset,
on_results=lambda issues: issue_on_results(
group_by=group_by, issues=issues, sub_group_by=sub_group_by
),
paginator_cls=SubGroupedOffsetPaginator,
group_by_fields=issue_group_values(
field=group_by, slug=slug, project_id=None, filters=filters
),
sub_group_by_fields=issue_group_values(
field=sub_group_by,
slug=slug,
project_id=None,
filters=filters,
),
group_by_field_name=group_by,
sub_group_by_field_name=sub_group_by,
count_filter=Q(
Q(issue_intake__status=1)
| Q(issue_intake__status=-1)
| Q(issue_intake__status=2)
| Q(issue_intake__isnull=True),
archived_at__isnull=True,
is_draft=False,
),
)
# Group Paginate
else:
# Group paginate
return self.paginate(
request=request,
order_by=order_by_param,
queryset=issue_queryset,
on_results=lambda issues: issue_on_results(
group_by=group_by, issues=issues, sub_group_by=sub_group_by
),
paginator_cls=GroupedOffsetPaginator,
group_by_fields=issue_group_values(
field=group_by, slug=slug, project_id=None, filters=filters
),
group_by_field_name=group_by,
count_filter=Q(
Q(issue_intake__status=1)
| Q(issue_intake__status=-1)
| Q(issue_intake__status=2)
| Q(issue_intake__isnull=True),
archived_at__isnull=True,
is_draft=False,
),
)
else:
# List Paginate
return self.paginate(
order_by=order_by_param,
request=request,
queryset=issue_queryset,
on_results=lambda issues: issue_on_results(
group_by=group_by, issues=issues, sub_group_by=sub_group_by
),
)
class IssueViewViewSet(BaseViewSet):
serializer_class = IssueViewSerializer
+45 -3
View File
@@ -27,7 +27,7 @@ def user_data():
"email": "test@plane.so",
"password": "test-password",
"first_name": "Test",
"last_name": "User"
"last_name": "User",
}
@@ -37,7 +37,7 @@ def create_user(db, user_data):
user = User.objects.create(
email=user_data["email"],
first_name=user_data["first_name"],
last_name=user_data["last_name"]
last_name=user_data["last_name"],
)
user.set_password(user_data["password"])
user.save()
@@ -69,10 +69,52 @@ def session_client(api_client, create_user):
return api_client
@pytest.fixture
def create_bot_user(db):
"""Create and return a bot user instance"""
from uuid import uuid4
unique_id = uuid4().hex[:8]
user = User.objects.create(
email=f"bot-{unique_id}@plane.so",
username=f"bot_user_{unique_id}",
first_name="Bot",
last_name="User",
is_bot=True,
)
user.set_password("bot@123")
user.save()
return user
@pytest.fixture
def api_token_data():
"""Return sample API token data for testing"""
from django.utils import timezone
from datetime import timedelta
return {
"label": "Test API Token",
"description": "Test description for API token",
"expired_at": (timezone.now() + timedelta(days=30)).isoformat(),
}
@pytest.fixture
def create_api_token_for_user(db, create_user):
"""Create and return an API token for a specific user"""
return APIToken.objects.create(
label="Test Token",
description="Test token description",
user=create_user,
user_type=0,
)
@pytest.fixture
def plane_server(live_server):
"""
Renamed version of live_server fixture to avoid name clashes.
Returns a live Django server for testing HTTP requests.
"""
return live_server
return live_server
@@ -0,0 +1,372 @@
import pytest
from datetime import timedelta
from uuid import uuid4
from django.urls import reverse
from django.utils import timezone
from rest_framework import status
from plane.db.models import APIToken, User
@pytest.mark.contract
class TestApiTokenEndpoint:
"""Test cases for ApiTokenEndpoint"""
# POST /user/api-tokens/ tests
@pytest.mark.django_db
def test_create_api_token_success(
self, session_client, create_user, api_token_data
):
"""Test successful API token creation"""
# Arrange
session_client.force_authenticate(user=create_user)
url = reverse("api-tokens")
# Act
response = session_client.post(url, api_token_data, format="json")
# Assert
assert response.status_code == status.HTTP_201_CREATED
assert "token" in response.data
assert response.data["label"] == api_token_data["label"]
assert response.data["description"] == api_token_data["description"]
assert response.data["user_type"] == 0 # Human user
# Verify token was created in database
token = APIToken.objects.get(pk=response.data["id"])
assert token.user == create_user
assert token.label == api_token_data["label"]
@pytest.mark.django_db
def test_create_api_token_for_bot_user(
self, session_client, create_bot_user, api_token_data
):
"""Test API token creation for bot user"""
# Arrange
session_client.force_authenticate(user=create_bot_user)
url = reverse("api-tokens")
# Act
response = session_client.post(url, api_token_data, format="json")
# Assert
assert response.status_code == status.HTTP_201_CREATED
assert response.data["user_type"] == 1 # Bot user
@pytest.mark.django_db
def test_create_api_token_minimal_data(self, session_client, create_user):
"""Test API token creation with minimal data"""
# Arrange
session_client.force_authenticate(user=create_user)
url = reverse("api-tokens")
# Act
response = session_client.post(url, {}, format="json")
# Assert
assert response.status_code == status.HTTP_201_CREATED
assert "token" in response.data
assert len(response.data["label"]) == 32 # UUID hex length
assert response.data["description"] == ""
@pytest.mark.django_db
def test_create_api_token_with_expiry(self, session_client, create_user):
"""Test API token creation with expiry date"""
# Arrange
session_client.force_authenticate(user=create_user)
url = reverse("api-tokens")
future_date = timezone.now() + timedelta(days=30)
data = {"label": "Expiring Token", "expired_at": future_date.isoformat()}
# Act
response = session_client.post(url, data, format="json")
# Assert
assert response.status_code == status.HTTP_201_CREATED
# Verify expiry date was set
token = APIToken.objects.get(pk=response.data["id"])
assert token.expired_at is not None
@pytest.mark.django_db
def test_create_api_token_unauthenticated(self, api_client, api_token_data):
"""Test API token creation without authentication"""
# Arrange
url = reverse("api-tokens")
# Act
response = api_client.post(url, api_token_data, format="json")
# Assert
assert response.status_code == status.HTTP_401_UNAUTHORIZED
# GET /user/api-tokens/ tests
@pytest.mark.django_db
def test_get_all_api_tokens(self, session_client, create_user):
"""Test retrieving all API tokens for user"""
# Arrange
session_client.force_authenticate(user=create_user)
# Create multiple tokens
APIToken.objects.create(label="Token 1", user=create_user, user_type=0)
APIToken.objects.create(label="Token 2", user=create_user, user_type=0)
# Create a service token (should be excluded)
APIToken.objects.create(
label="Service Token", user=create_user, user_type=0, is_service=True
)
url = reverse("api-tokens")
# Act
response = session_client.get(url)
# Assert
assert response.status_code == status.HTTP_200_OK
assert len(response.data) == 2 # Only non-service tokens
assert all(token["is_service"] is False for token in response.data)
@pytest.mark.django_db
def test_get_empty_api_tokens_list(self, session_client, create_user):
"""Test retrieving API tokens when none exist"""
# Arrange
session_client.force_authenticate(user=create_user)
url = reverse("api-tokens")
# Act
response = session_client.get(url)
# Assert
assert response.status_code == status.HTTP_200_OK
assert response.data == []
# GET /user/api-tokens/<pk>/ tests
@pytest.mark.django_db
def test_get_specific_api_token(
self, session_client, create_user, create_api_token_for_user
):
"""Test retrieving a specific API token"""
# Arrange
session_client.force_authenticate(user=create_user)
url = reverse("api-tokens", kwargs={"pk": create_api_token_for_user.pk})
# Act
response = session_client.get(url)
# Assert
assert response.status_code == status.HTTP_200_OK
assert str(response.data["id"]) == str(create_api_token_for_user.pk)
assert response.data["label"] == create_api_token_for_user.label
assert (
"token" not in response.data
) # Token should not be visible in read serializer
@pytest.mark.django_db
def test_get_nonexistent_api_token(self, session_client, create_user):
"""Test retrieving a non-existent API token"""
# Arrange
session_client.force_authenticate(user=create_user)
fake_pk = uuid4()
url = reverse("api-tokens", kwargs={"pk": fake_pk})
# Act
response = session_client.get(url)
# Assert
assert response.status_code == status.HTTP_404_NOT_FOUND
@pytest.mark.django_db
def test_get_other_users_api_token(self, session_client, create_user, db):
"""Test retrieving another user's API token (should fail)"""
# Arrange
# Create another user and their token with unique email and username
unique_id = uuid4().hex[:8]
unique_email = f"other-{unique_id}@plane.so"
unique_username = f"other_user_{unique_id}"
other_user = User.objects.create(email=unique_email, username=unique_username)
other_token = APIToken.objects.create(
label="Other Token", user=other_user, user_type=0
)
session_client.force_authenticate(user=create_user)
url = reverse("api-tokens", kwargs={"pk": other_token.pk})
# Act
response = session_client.get(url)
# Assert
assert response.status_code == status.HTTP_404_NOT_FOUND
# DELETE /user/api-tokens/<pk>/ tests
@pytest.mark.django_db
def test_delete_api_token_success(
self, session_client, create_user, create_api_token_for_user
):
"""Test successful API token deletion"""
# Arrange
session_client.force_authenticate(user=create_user)
url = reverse("api-tokens", kwargs={"pk": create_api_token_for_user.pk})
# Act
response = session_client.delete(url)
# Assert
assert response.status_code == status.HTTP_204_NO_CONTENT
assert not APIToken.objects.filter(pk=create_api_token_for_user.pk).exists()
@pytest.mark.django_db
def test_delete_nonexistent_api_token(self, session_client, create_user):
"""Test deleting a non-existent API token"""
# Arrange
session_client.force_authenticate(user=create_user)
fake_pk = uuid4()
url = reverse("api-tokens", kwargs={"pk": fake_pk})
# Act
response = session_client.delete(url)
# Assert
assert response.status_code == status.HTTP_404_NOT_FOUND
@pytest.mark.django_db
def test_delete_other_users_api_token(self, session_client, create_user, db):
"""Test deleting another user's API token (should fail)"""
# Arrange
# Create another user and their token with unique email and username
unique_id = uuid4().hex[:8]
unique_email = f"delete-other-{unique_id}@plane.so"
unique_username = f"delete_other_user_{unique_id}"
other_user = User.objects.create(email=unique_email, username=unique_username)
other_token = APIToken.objects.create(
label="Other Token", user=other_user, user_type=0
)
session_client.force_authenticate(user=create_user)
url = reverse("api-tokens", kwargs={"pk": other_token.pk})
# Act
response = session_client.delete(url)
# Assert
assert response.status_code == status.HTTP_404_NOT_FOUND
# Verify token still exists
assert APIToken.objects.filter(pk=other_token.pk).exists()
@pytest.mark.django_db
def test_delete_service_api_token_forbidden(self, session_client, create_user):
"""Test deleting a service API token (should fail)"""
# Arrange
service_token = APIToken.objects.create(
label="Service Token", user=create_user, user_type=0, is_service=True
)
session_client.force_authenticate(user=create_user)
url = reverse("api-tokens", kwargs={"pk": service_token.pk})
# Act
response = session_client.delete(url)
# Assert
assert response.status_code == status.HTTP_404_NOT_FOUND
# Verify token still exists
assert APIToken.objects.filter(pk=service_token.pk).exists()
# PATCH /user/api-tokens/<pk>/ tests
@pytest.mark.django_db
def test_patch_api_token_success(
self, session_client, create_user, create_api_token_for_user
):
"""Test successful API token update"""
# Arrange
session_client.force_authenticate(user=create_user)
url = reverse("api-tokens", kwargs={"pk": create_api_token_for_user.pk})
update_data = {
"label": "Updated Token Label",
"description": "Updated description",
}
# Act
response = session_client.patch(url, update_data, format="json")
# Assert
assert response.status_code == status.HTTP_200_OK
assert response.data["label"] == update_data["label"]
assert response.data["description"] == update_data["description"]
# Verify database was updated
create_api_token_for_user.refresh_from_db()
assert create_api_token_for_user.label == update_data["label"]
assert create_api_token_for_user.description == update_data["description"]
@pytest.mark.django_db
def test_patch_api_token_partial_update(
self, session_client, create_user, create_api_token_for_user
):
"""Test partial API token update"""
# Arrange
session_client.force_authenticate(user=create_user)
url = reverse("api-tokens", kwargs={"pk": create_api_token_for_user.pk})
original_description = create_api_token_for_user.description
update_data = {"label": "Only Label Updated"}
# Act
response = session_client.patch(url, update_data, format="json")
# Assert
assert response.status_code == status.HTTP_200_OK
assert response.data["label"] == update_data["label"]
assert response.data["description"] == original_description
@pytest.mark.django_db
def test_patch_nonexistent_api_token(self, session_client, create_user):
"""Test updating a non-existent API token"""
# Arrange
session_client.force_authenticate(user=create_user)
fake_pk = uuid4()
url = reverse("api-tokens", kwargs={"pk": fake_pk})
update_data = {"label": "New Label"}
# Act
response = session_client.patch(url, update_data, format="json")
# Assert
assert response.status_code == status.HTTP_404_NOT_FOUND
@pytest.mark.django_db
def test_patch_other_users_api_token(self, session_client, create_user, db):
"""Test updating another user's API token (should fail)"""
# Arrange
# Create another user and their token with unique email and username
unique_id = uuid4().hex[:8]
unique_email = f"patch-other-{unique_id}@plane.so"
unique_username = f"patch_other_user_{unique_id}"
other_user = User.objects.create(email=unique_email, username=unique_username)
other_token = APIToken.objects.create(
label="Other Token", user=other_user, user_type=0
)
session_client.force_authenticate(user=create_user)
url = reverse("api-tokens", kwargs={"pk": other_token.pk})
update_data = {"label": "Hacked Label"}
# Act
response = session_client.patch(url, update_data, format="json")
# Assert
assert response.status_code == status.HTTP_404_NOT_FOUND
# Verify token was not updated
other_token.refresh_from_db()
assert other_token.label == "Other Token"
# Authentication tests
@pytest.mark.django_db
def test_all_endpoints_require_authentication(self, api_client):
"""Test that all endpoints require authentication"""
# Arrange
endpoints = [
(reverse("api-tokens"), "get"),
(reverse("api-tokens"), "post"),
(reverse("api-tokens", kwargs={"pk": uuid4()}), "get"),
(reverse("api-tokens", kwargs={"pk": uuid4()}), "patch"),
(reverse("api-tokens", kwargs={"pk": uuid4()}), "delete"),
]
# Act & Assert
for url, method in endpoints:
response = getattr(api_client, method)(url)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
@@ -0,0 +1,75 @@
import pytest
from plane.db.models import (
Workspace,
Project,
Issue,
User,
IssueAssignee,
WorkspaceMember,
ProjectMember,
)
from plane.app.serializers.workspace import IssueRecentVisitSerializer
from django.utils import timezone
@pytest.mark.unit
class TestIssueRecentVisitSerializer:
"""Test the IssueRecentVisitSerializer"""
def test_issue_recent_visit_serializer_fields(self, db):
"""Test that the serializer includes the correct fields"""
test_user_1 = User.objects.create(
email="test_user_1@example.com", first_name="Test", last_name="User"
)
# To test for deleted issue assignee
test_user_2 = User.objects.create(
email="test_user_2@example.com",
first_name="Other",
last_name="User",
username="some user name",
)
workspace = Workspace.objects.create(
name="Test Workspace", slug="test-workspace", owner=test_user_1
)
WorkspaceMember.objects.create(member=test_user_2, role=15, workspace=workspace)
project = Project.objects.create(
name="Test Project", identifier="test-project", workspace=workspace
)
ProjectMember.objects.create(project=project, member=test_user_2)
issue = Issue.objects.create(
name="Test Issue",
workspace=workspace,
project=project,
)
IssueAssignee.objects.create(issue=issue, assignee=test_user_1, project=project)
# Deleted issue assignee
IssueAssignee.objects.create(
issue=issue,
assignee=test_user_2,
project=project,
deleted_at=timezone.now(),
)
serialized_data = IssueRecentVisitSerializer(
issue,
).data
# Check fields are present and correct
assert "name" in serialized_data
assert "assignees" in serialized_data
assert "project_identifier" in serialized_data
assert serialized_data["name"] == "Test Issue"
assert serialized_data["project_identifier"] == "TEST-PROJECT"
# Only including non-deleted issue assignees
assert serialized_data["assignees"] == [test_user_1.id]
+11 -4
View File
@@ -16,7 +16,7 @@ def order_issue_queryset(issue_queryset, order_by_param="-created_at"):
],
output_field=CharField(),
)
).order_by("priority_order")
).order_by("priority_order", "-created_at")
order_by_param = (
"priority_order" if order_by_param.startswith("-") else "-priority_order"
)
@@ -36,7 +36,7 @@ def order_issue_queryset(issue_queryset, order_by_param="-created_at"):
default=Value(len(state_order)),
output_field=CharField(),
)
).order_by("state_order")
).order_by("state_order", "-created_at")
order_by_param = (
"-state_order" if order_by_param.startswith("-") else "state_order"
)
@@ -55,11 +55,18 @@ def order_issue_queryset(issue_queryset, order_by_param="-created_at"):
if order_by_param.startswith("-")
else order_by_param
)
).order_by("-min_values" if order_by_param.startswith("-") else "min_values")
).order_by(
"-min_values" if order_by_param.startswith("-") else "min_values",
"-created_at",
)
order_by_param = (
"-min_values" if order_by_param.startswith("-") else "min_values"
)
else:
issue_queryset = issue_queryset.order_by(order_by_param)
# If the order_by_param is created_at, then don't add the -created_at
if "created_at" in order_by_param:
issue_queryset = issue_queryset.order_by(order_by_param)
else:
issue_queryset = issue_queryset.order_by(order_by_param, "-created_at")
order_by_param = order_by_param
return issue_queryset, order_by_param
+23 -6
View File
@@ -102,6 +102,7 @@ class OffsetPaginator:
max_limit=MAX_LIMIT,
max_offset=None,
on_results=None,
total_count_queryset=None,
):
# Key tuple and remove `-` if descending order by
self.key = (
@@ -115,6 +116,7 @@ class OffsetPaginator:
self.max_limit = max_limit
self.max_offset = max_offset
self.on_results = on_results
self.total_count_queryset = total_count_queryset
def get_result(self, limit=1000, cursor=None):
# offset is page #
@@ -138,9 +140,9 @@ class OffsetPaginator:
)
# The current page
page = cursor.offset
# The offset
offset = cursor.offset * cursor.value
stop = offset + (cursor.value or limit) + 1
# The offset - use limit instead of cursor.value for consistent pagination
offset = cursor.offset * limit
stop = offset + limit + 1
if self.max_offset is not None and offset >= self.max_offset:
raise BadPaginationError("Pagination offset too large")
@@ -148,11 +150,21 @@ class OffsetPaginator:
raise BadPaginationError("Pagination offset cannot be negative")
results = queryset[offset:stop]
if cursor.value != limit:
# Only slice from the end if we're going backwards (previous page)
if cursor.value != limit and cursor.is_prev:
results = results[-(limit + 1) :]
total_count = (
self.total_count_queryset.count()
if self.total_count_queryset
else results.count()
)
# Check if there are more results available after the current page
# Adjust cursors based on the results for pagination
next_cursor = Cursor(limit, page + 1, False, results.count() > limit)
next_cursor = Cursor(limit, page + 1, False, len(results) > limit)
# If the page is greater than 0, then set the previous cursor
prev_cursor = Cursor(limit, page - 1, True, page > 0)
@@ -164,7 +176,7 @@ class OffsetPaginator:
results = self.on_results(results)
# Count the queryset
count = queryset.count()
count = total_count
# Optionally, calculate the total count and max_hits if needed
max_hits = math.ceil(count / limit)
@@ -196,6 +208,7 @@ class GroupedOffsetPaginator(OffsetPaginator):
group_by_field_name,
group_by_fields,
count_filter,
total_count_queryset=None,
*args,
**kwargs,
):
@@ -404,6 +417,7 @@ class SubGroupedOffsetPaginator(OffsetPaginator):
group_by_fields,
sub_group_by_fields,
count_filter,
total_count_queryset=None,
*args,
**kwargs,
):
@@ -694,6 +708,7 @@ class BasePaginator:
sub_group_by_field_name=None,
sub_group_by_fields=None,
count_filter=None,
total_count_queryset=None,
**paginator_kwargs,
):
"""Paginate the request"""
@@ -719,6 +734,8 @@ class BasePaginator:
)
paginator_kwargs["sub_group_by_fields"] = sub_group_by_fields
paginator_kwargs["total_count_queryset"] = total_count_queryset
paginator = paginator_cls(**paginator_kwargs)
try:
-238
View File
@@ -1,238 +0,0 @@
export type IssueEventProps = {
eventName: string;
payload: any;
updates?: any;
path?: string;
};
export type EventProps = {
eventName: string;
payload: any;
updates?: any;
path?: string;
};
export const getWorkspaceEventPayload = (payload: any) => ({
workspace_id: payload.id,
created_at: payload.created_at,
updated_at: payload.updated_at,
organization_size: payload.organization_size,
first_time: payload.first_time,
state: payload.state,
element: payload.element,
});
export const getProjectEventPayload = (payload: any) => ({
workspace_id: payload.workspace_id,
project_id: payload.id,
identifier: payload.identifier,
project_visibility: payload.network == 2 ? "Public" : "Private",
changed_properties: payload.changed_properties,
lead_id: payload.project_lead,
created_at: payload.created_at,
updated_at: payload.updated_at,
state: payload.state,
element: payload.element,
});
export const getCycleEventPayload = (payload: any) => ({
workspace_id: payload.workspace_id,
project_id: payload.project,
cycle_id: payload.id,
created_at: payload.created_at,
updated_at: payload.updated_at,
start_date: payload.start_date,
target_date: payload.target_date,
cycle_status: payload.status,
changed_properties: payload.changed_properties,
state: payload.state,
element: payload.element,
});
export const getModuleEventPayload = (payload: any) => ({
workspace_id: payload.workspace_id,
project_id: payload.project,
module_id: payload.id,
created_at: payload.created_at,
updated_at: payload.updated_at,
start_date: payload.start_date,
target_date: payload.target_date,
module_status: payload.status,
lead_id: payload.lead,
changed_properties: payload.changed_properties,
member_ids: payload.members,
state: payload.state,
element: payload.element,
});
export const getPageEventPayload = (payload: any) => ({
workspace_id: payload.workspace_id,
project_id: payload.project,
created_at: payload.created_at,
updated_at: payload.updated_at,
access: payload.access === 0 ? "Public" : "Private",
is_locked: payload.is_locked,
archived_at: payload.archived_at,
created_by: payload.created_by,
state: payload.state,
element: payload.element,
});
export const getIssueEventPayload = (props: IssueEventProps) => {
const { eventName, payload, updates, path } = props;
let eventPayload: any = {
issue_id: payload.id,
estimate_point: payload.estimate_point,
link_count: payload.link_count,
target_date: payload.target_date,
is_draft: payload.is_draft,
label_ids: payload.label_ids,
assignee_ids: payload.assignee_ids,
created_at: payload.created_at,
updated_at: payload.updated_at,
sequence_id: payload.sequence_id,
module_ids: payload.module_ids,
sub_issues_count: payload.sub_issues_count,
parent_id: payload.parent_id,
project_id: payload.project_id,
workspace_id: payload.workspace_id,
priority: payload.priority,
state_id: payload.state_id,
start_date: payload.start_date,
attachment_count: payload.attachment_count,
cycle_id: payload.cycle_id,
module_id: payload.module_id,
archived_at: payload.archived_at,
state: payload.state,
view_id:
path?.includes("workspace-views") || path?.includes("views")
? path.split("/").pop()
: "",
};
if (eventName === ISSUE_UPDATED) {
eventPayload = {
...eventPayload,
...updates,
updated_from: props.path?.includes("workspace-views")
? "All views"
: props.path?.includes("cycles")
? "Cycle"
: props.path?.includes("modules")
? "Module"
: props.path?.includes("views")
? "Project view"
: props.path?.includes("inbox")
? "Inbox"
: props.path?.includes("draft")
? "Draft"
: "Project",
};
}
return eventPayload;
};
export const getProjectStateEventPayload = (payload: any) => ({
workspace_id: payload.workspace_id,
project_id: payload.id,
state_id: payload.id,
created_at: payload.created_at,
updated_at: payload.updated_at,
group: payload.group,
color: payload.color,
default: payload.default,
state: payload.state,
element: payload.element,
});
// Workspace crud Events
export const WORKSPACE_CREATED = "Workspace created";
export const WORKSPACE_UPDATED = "Workspace updated";
export const WORKSPACE_DELETED = "Workspace deleted";
// Project Events
export const PROJECT_CREATED = "Project created";
export const PROJECT_UPDATED = "Project updated";
export const PROJECT_DELETED = "Project deleted";
// Cycle Events
export const CYCLE_CREATED = "Cycle created";
export const CYCLE_UPDATED = "Cycle updated";
export const CYCLE_DELETED = "Cycle deleted";
export const CYCLE_FAVORITED = "Cycle favorited";
export const CYCLE_UNFAVORITED = "Cycle unfavorited";
// Module Events
export const MODULE_CREATED = "Module created";
export const MODULE_UPDATED = "Module updated";
export const MODULE_DELETED = "Module deleted";
export const MODULE_FAVORITED = "Module favorited";
export const MODULE_UNFAVORITED = "Module unfavorited";
export const MODULE_LINK_CREATED = "Module link created";
export const MODULE_LINK_UPDATED = "Module link updated";
export const MODULE_LINK_DELETED = "Module link deleted";
// Issue Events
export const ISSUE_CREATED = "Work item created";
export const ISSUE_UPDATED = "Work item updated";
export const ISSUE_DELETED = "Work item deleted";
export const ISSUE_ARCHIVED = "Work item archived";
export const ISSUE_RESTORED = "Work item restored";
export const ISSUE_OPENED = "Work item opened";
// Project State Events
export const STATE_CREATED = "State created";
export const STATE_UPDATED = "State updated";
export const STATE_DELETED = "State deleted";
// Project Page Events
export const PAGE_CREATED = "Page created";
export const PAGE_UPDATED = "Page updated";
export const PAGE_DELETED = "Page deleted";
// Member Events
export const MEMBER_INVITED = "Member invited";
export const MEMBER_ACCEPTED = "Member accepted";
export const PROJECT_MEMBER_ADDED = "Project member added";
export const PROJECT_MEMBER_LEAVE = "Project member leave";
export const WORKSPACE_MEMBER_LEAVE = "Workspace member leave";
// Sign-in & Sign-up Events
export const NAVIGATE_TO_SIGNUP = "Navigate to sign-up page";
export const NAVIGATE_TO_SIGNIN = "Navigate to sign-in page";
export const CODE_VERIFIED = "Code verified";
export const SETUP_PASSWORD = "Password setup";
export const PASSWORD_CREATE_SELECTED = "Password created";
export const PASSWORD_CREATE_SKIPPED = "Skipped to setup";
export const SIGN_IN_WITH_PASSWORD = "Sign in with password";
export const SIGN_UP_WITH_PASSWORD = "Sign up with password";
export const SIGN_IN_WITH_CODE = "Sign in with magic link";
export const FORGOT_PASSWORD = "Forgot password clicked";
export const FORGOT_PASS_LINK = "Forgot password link generated";
export const NEW_PASS_CREATED = "New password created";
// Onboarding Events
export const USER_DETAILS = "User details added";
export const USER_ONBOARDING_COMPLETED = "User onboarding completed";
// Product Tour Events
export const PRODUCT_TOUR_STARTED = "Product tour started";
export const PRODUCT_TOUR_COMPLETED = "Product tour completed";
export const PRODUCT_TOUR_SKIPPED = "Product tour skipped";
// Dashboard Events
export const CHANGELOG_REDIRECTED = "Changelog redirected";
export const GITHUB_REDIRECTED = "GitHub redirected";
// Sidebar Events
export const SIDEBAR_CLICKED = "Sidenav clicked";
// Global View Events
export const GLOBAL_VIEW_CREATED = "Global view created";
export const GLOBAL_VIEW_UPDATED = "Global view updated";
export const GLOBAL_VIEW_DELETED = "Global view deleted";
export const GLOBAL_VIEW_OPENED = "Global view opened";
// Notification Events
export const NOTIFICATION_ARCHIVED = "Notification archived";
export const NOTIFICATION_SNOOZED = "Notification snoozed";
export const NOTIFICATION_READ = "Notification marked read";
export const UNREAD_NOTIFICATIONS = "Unread notifications viewed";
export const NOTIFICATIONS_READ = "All notifications marked read";
export const SNOOZED_NOTIFICATIONS = "Snoozed notifications viewed";
export const ARCHIVED_NOTIFICATIONS = "Archived notifications viewed";
// Groups
export const GROUP_WORKSPACE = "Workspace_metrics";
//Elements
export const E_ONBOARDING = "Onboarding";
export const E_ONBOARDING_STEP_1 = "Onboarding step 1";
export const E_ONBOARDING_STEP_2 = "Onboarding step 2";
// Favorites
export const FAVORITE_ADDED = "Favorite added";
@@ -0,0 +1,258 @@
export type IssueEventProps = {
eventName: string;
payload: any;
updates?: any;
path?: string;
};
export type EventProps = {
eventName: string;
payload: any;
updates?: any;
path?: string;
};
export const getWorkspaceEventPayload = (payload: any) => ({
workspace_id: payload.id,
created_at: payload.created_at,
updated_at: payload.updated_at,
organization_size: payload.organization_size,
first_time: payload.first_time,
state: payload.state,
element: payload.element,
});
export const getProjectEventPayload = (payload: any) => ({
workspace_id: payload.workspace_id,
project_id: payload.id,
identifier: payload.identifier,
project_visibility: payload.network == 2 ? "Public" : "Private",
changed_properties: payload.changed_properties,
lead_id: payload.project_lead,
created_at: payload.created_at,
updated_at: payload.updated_at,
state: payload.state,
element: payload.element,
});
export const getCycleEventPayload = (payload: any) => ({
workspace_id: payload.workspace_id,
project_id: payload.project,
cycle_id: payload.id,
created_at: payload.created_at,
updated_at: payload.updated_at,
start_date: payload.start_date,
target_date: payload.target_date,
cycle_status: payload.status,
changed_properties: payload.changed_properties,
state: payload.state,
element: payload.element,
});
export const getModuleEventPayload = (payload: any) => ({
workspace_id: payload.workspace_id,
project_id: payload.project,
module_id: payload.id,
created_at: payload.created_at,
updated_at: payload.updated_at,
start_date: payload.start_date,
target_date: payload.target_date,
module_status: payload.status,
lead_id: payload.lead,
changed_properties: payload.changed_properties,
member_ids: payload.members,
state: payload.state,
element: payload.element,
});
export const getPageEventPayload = (payload: any) => ({
workspace_id: payload.workspace_id,
project_id: payload.project,
created_at: payload.created_at,
updated_at: payload.updated_at,
access: payload.access === 0 ? "Public" : "Private",
is_locked: payload.is_locked,
archived_at: payload.archived_at,
created_by: payload.created_by,
state: payload.state,
element: payload.element,
});
export const getIssueEventPayload = (props: IssueEventProps) => {
const { eventName, payload, updates, path } = props;
let eventPayload: any = {
issue_id: payload.id,
estimate_point: payload.estimate_point,
link_count: payload.link_count,
target_date: payload.target_date,
is_draft: payload.is_draft,
label_ids: payload.label_ids,
assignee_ids: payload.assignee_ids,
created_at: payload.created_at,
updated_at: payload.updated_at,
sequence_id: payload.sequence_id,
module_ids: payload.module_ids,
sub_issues_count: payload.sub_issues_count,
parent_id: payload.parent_id,
project_id: payload.project_id,
workspace_id: payload.workspace_id,
priority: payload.priority,
state_id: payload.state_id,
start_date: payload.start_date,
attachment_count: payload.attachment_count,
cycle_id: payload.cycle_id,
module_id: payload.module_id,
archived_at: payload.archived_at,
state: payload.state,
view_id: path?.includes("workspace-views") || path?.includes("views") ? path.split("/").pop() : "",
};
if (eventName === WORK_ITEM_TRACKER_EVENTS.update) {
eventPayload = {
...eventPayload,
...updates,
updated_from: props.path?.includes("workspace-views")
? "All views"
: props.path?.includes("cycles")
? "Cycle"
: props.path?.includes("modules")
? "Module"
: props.path?.includes("views")
? "Project view"
: props.path?.includes("inbox")
? "Inbox"
: props.path?.includes("draft")
? "Draft"
: "Project",
};
}
return eventPayload;
};
export const getProjectStateEventPayload = (payload: any) => ({
workspace_id: payload.workspace_id,
project_id: payload.id,
state_id: payload.id,
created_at: payload.created_at,
updated_at: payload.updated_at,
group: payload.group,
color: payload.color,
default: payload.default,
state: payload.state,
element: payload.element,
});
// Dashboard Events
export const GITHUB_REDIRECTED_TRACKER_EVENT = "github_redirected";
// Groups
export const GROUP_WORKSPACE_TRACKER_EVENT = "workspace_metrics";
export const WORKSPACE_TRACKER_EVENTS = {
create: "workspace_created",
update: "workspace_updated",
delete: "workspace_deleted",
};
export const PROJECT_TRACKER_EVENTS = {
create: "project_created",
update: "project_updated",
delete: "project_deleted",
};
export const CYCLE_TRACKER_EVENTS = {
create: "cycle_created",
update: "cycle_updated",
delete: "cycle_deleted",
favorite: "cycle_favorited",
unfavorite: "cycle_unfavorited",
};
export const MODULE_TRACKER_EVENTS = {
create: "module_created",
update: "module_updated",
delete: "module_deleted",
favorite: "module_favorited",
unfavorite: "module_unfavorited",
link: {
create: "module_link_created",
update: "module_link_updated",
delete: "module_link_deleted",
},
};
export const WORK_ITEM_TRACKER_EVENTS = {
create: "work_item_created",
update: "work_item_updated",
delete: "work_item_deleted",
archive: "work_item_archived",
restore: "work_item_restored",
};
export const STATE_TRACKER_EVENTS = {
create: "state_created",
update: "state_updated",
delete: "state_deleted",
};
export const PROJECT_PAGE_TRACKER_EVENTS = {
create: "project_page_created",
update: "project_page_updated",
delete: "project_page_deleted",
};
export const MEMBER_TRACKER_EVENTS = {
invite: "member_invited",
accept: "member_accepted",
project: {
add: "project_member_added",
leave: "project_member_left",
},
workspace: {
leave: "workspace_member_left",
},
};
export const AUTH_TRACKER_EVENTS = {
navigate: {
sign_up: "navigate_to_sign_up_page",
sign_in: "navigate_to_sign_in_page",
},
code_verify: "code_verified",
sign_up_with_password: "sign_up_with_password",
sign_in_with_password: "sign_in_with_password",
sign_in_with_code: "sign_in_with_magic_link",
forgot_password: "forgot_password_clicked",
};
export const PRODUCT_TOUR_TRACKER_EVENTS = {
start: "product_tour_started",
complete: "product_tour_completed",
skip: "product_tour_skipped",
};
export const GLOBAL_VIEW_TOUR_TRACKER_EVENTS = {
create: "global_view_created",
update: "global_view_updated",
delete: "global_view_deleted",
open: "global_view_opened",
};
export const NOTIFICATION_TRACKER_EVENTS = {
archive: "notification_archived",
all_marked_read: "all_notifications_marked_read",
};
export const USER_TRACKER_EVENTS = {
add_details: "user_details_added",
onboarding_complete: "user_onboarding_completed",
};
export const ONBOARDING_TRACKER_EVENTS = {
root: "onboarding",
step_1: "onboarding_step_1",
step_2: "onboarding_step_2",
};
export const SIDEBAR_TRACKER_EVENTS = {
click: "sidenav_clicked",
};
@@ -0,0 +1 @@
export * from "./core";
+9
View File
@@ -149,3 +149,12 @@ export const DEFAULT_PROJECT_FORM_VALUES: Partial<IProject> = {
network: 2,
project_lead: null,
};
export enum EProjectFeatureKey {
WORK_ITEMS = "work_items",
CYCLES = "cycles",
MODULES = "modules",
VIEWS = "views",
PAGES = "pages",
INTAKE = "intake",
}
@@ -1,13 +1,13 @@
import { Extensions } from "@tiptap/core";
import type { Extensions } from "@tiptap/core";
// types
import { TExtensions, TFileHandler } from "@/types";
import type { IEditorProps } from "@/types";
type Props = {
disabledExtensions: TExtensions[];
fileHandler: TFileHandler;
};
export type TCoreAdditionalExtensionsProps = Pick<
IEditorProps,
"disabledExtensions" | "flaggedExtensions" | "fileHandler"
>;
export const CoreEditorAdditionalExtensions = (props: Props): Extensions => {
export const CoreEditorAdditionalExtensions = (props: TCoreAdditionalExtensionsProps): Extensions => {
const {} = props;
return [];
};
@@ -1,12 +1,15 @@
import { Extensions } from "@tiptap/core";
import type { Extensions } from "@tiptap/core";
// types
import { TExtensions } from "@/types";
import type { IReadOnlyEditorProps } from "@/types";
type Props = {
disabledExtensions: TExtensions[];
};
export type TCoreReadOnlyEditorAdditionalExtensionsProps = Pick<
IReadOnlyEditorProps,
"disabledExtensions" | "flaggedExtensions"
>;
export const CoreReadOnlyEditorAdditionalExtensions = (props: Props): Extensions => {
export const CoreReadOnlyEditorAdditionalExtensions = (
props: TCoreReadOnlyEditorAdditionalExtensionsProps
): Extensions => {
const {} = props;
return [];
};
@@ -1,37 +1,39 @@
import { HocuspocusProvider } from "@hocuspocus/provider";
import { AnyExtension } from "@tiptap/core";
import type { HocuspocusProvider } from "@hocuspocus/provider";
import type { AnyExtension } from "@tiptap/core";
import { SlashCommands } from "@/extensions";
// plane editor types
import { TEmbedConfig } from "@/plane-editor/types";
import type { TEmbedConfig } from "@/plane-editor/types";
// types
import { TExtensions, TFileHandler, TUserDetails } from "@/types";
import type { IEditorProps, TExtensions, TUserDetails } from "@/types";
export type TDocumentEditorAdditionalExtensionsProps = {
disabledExtensions: TExtensions[];
export type TDocumentEditorAdditionalExtensionsProps = Pick<
IEditorProps,
"disabledExtensions" | "flaggedExtensions" | "fileHandler"
> & {
embedConfig: TEmbedConfig | undefined;
fileHandler: TFileHandler;
provider?: HocuspocusProvider;
userDetails: TUserDetails;
};
export type TDocumentEditorAdditionalExtensionsRegistry = {
isEnabled: (disabledExtensions: TExtensions[]) => boolean;
isEnabled: (disabledExtensions: TExtensions[], flaggedExtensions: TExtensions[]) => boolean;
getExtension: (props: TDocumentEditorAdditionalExtensionsProps) => AnyExtension;
};
const extensionRegistry: TDocumentEditorAdditionalExtensionsRegistry[] = [
{
isEnabled: (disabledExtensions) => !disabledExtensions.includes("slash-commands"),
getExtension: ({ disabledExtensions }) => SlashCommands({ disabledExtensions }),
getExtension: ({ disabledExtensions, flaggedExtensions }) =>
SlashCommands({ disabledExtensions, flaggedExtensions }),
},
];
export const DocumentEditorAdditionalExtensions = (_props: TDocumentEditorAdditionalExtensionsProps) => {
const { disabledExtensions = [] } = _props;
export const DocumentEditorAdditionalExtensions = (props: TDocumentEditorAdditionalExtensionsProps) => {
const { disabledExtensions, flaggedExtensions } = props;
const documentExtensions = extensionRegistry
.filter((config) => config.isEnabled(disabledExtensions))
.map((config) => config.getExtension(_props));
.filter((config) => config.isEnabled(disabledExtensions, flaggedExtensions))
.map((config) => config.getExtension(props));
return documentExtensions;
};
@@ -2,19 +2,19 @@ import { AnyExtension, Extensions } from "@tiptap/core";
// extensions
import { SlashCommands } from "@/extensions/slash-commands/root";
// types
import { TExtensions, TFileHandler } from "@/types";
import { IEditorProps, TExtensions } from "@/types";
export type TRichTextEditorAdditionalExtensionsProps = {
disabledExtensions: TExtensions[];
fileHandler: TFileHandler;
};
export type TRichTextEditorAdditionalExtensionsProps = Pick<
IEditorProps,
"disabledExtensions" | "flaggedExtensions" | "fileHandler"
>;
/**
* Registry entry configuration for extensions
*/
export type TRichTextEditorAdditionalExtensionsRegistry = {
/** Determines if the extension should be enabled based on disabled extensions */
isEnabled: (disabledExtensions: TExtensions[]) => boolean;
isEnabled: (disabledExtensions: TExtensions[], flaggedExtensions: TExtensions[]) => boolean;
/** Returns the extension instance(s) when enabled */
getExtension: (props: TRichTextEditorAdditionalExtensionsProps) => AnyExtension | undefined;
};
@@ -22,18 +22,19 @@ export type TRichTextEditorAdditionalExtensionsRegistry = {
const extensionRegistry: TRichTextEditorAdditionalExtensionsRegistry[] = [
{
isEnabled: (disabledExtensions) => !disabledExtensions.includes("slash-commands"),
getExtension: ({ disabledExtensions }) =>
getExtension: ({ disabledExtensions, flaggedExtensions }) =>
SlashCommands({
disabledExtensions,
flaggedExtensions,
}),
},
];
export const RichTextEditorAdditionalExtensions = (props: TRichTextEditorAdditionalExtensionsProps) => {
const { disabledExtensions } = props;
const { disabledExtensions, flaggedExtensions } = props;
const extensions: Extensions = extensionRegistry
.filter((config) => config.isEnabled(disabledExtensions))
.filter((config) => config.isEnabled(disabledExtensions, flaggedExtensions))
.map((config) => config.getExtension(props))
.filter((extension): extension is AnyExtension => extension !== undefined);
@@ -1,11 +1,11 @@
import { AnyExtension, Extensions } from "@tiptap/core";
// types
import { TExtensions, TReadOnlyFileHandler } from "@/types";
import { IReadOnlyEditorProps, TExtensions } from "@/types";
export type TRichTextReadOnlyEditorAdditionalExtensionsProps = {
disabledExtensions: TExtensions[];
fileHandler: TReadOnlyFileHandler;
};
export type TRichTextReadOnlyEditorAdditionalExtensionsProps = Pick<
IReadOnlyEditorProps,
"disabledExtensions" | "flaggedExtensions" | "fileHandler"
>;
/**
* Registry entry configuration for extensions
@@ -1,11 +1,9 @@
// extensions
import { TSlashCommandAdditionalOption } from "@/extensions";
import type { TSlashCommandAdditionalOption } from "@/extensions";
// types
import { TExtensions } from "@/types";
import type { IEditorProps } from "@/types";
type Props = {
disabledExtensions?: TExtensions[];
};
type Props = Pick<IEditorProps, "disabledExtensions" | "flaggedExtensions">;
export const coreEditorAdditionalSlashCommandOptions = (props: Props): TSlashCommandAdditionalOption[] => {
const {} = props;
+19
View File
@@ -0,0 +1,19 @@
/**
* @description function to extract all additional assets from HTML content
* @param htmlContent
* @returns {string[]} array of additional asset sources
*/
export const extractAdditionalAssetsFromHTMLContent = (_htmlContent: string): string[] => [];
/**
* @description function to replace additional assets in HTML content with new IDs
* @param props
* @returns {string} HTML content with replaced additional assets
*/
export const replaceAdditionalAssetsInHTMLContent = (props: {
htmlContent: string;
assetMap: Record<string, string>;
}): string => {
const { htmlContent } = props;
return htmlContent;
};
+1 -1
View File
@@ -2,7 +2,7 @@
import { CORE_EXTENSIONS } from "@/constants/extension";
// extensions
import { type HeadingExtensionStorage } from "@/extensions";
import { type CustomImageExtensionStorage } from "@/extensions/custom-image";
import { type CustomImageExtensionStorage } from "@/extensions/custom-image/types";
import { type CustomLinkStorage } from "@/extensions/custom-link";
import { type ImageExtensionStorage } from "@/extensions/image";
import { type MentionExtensionStorage } from "@/extensions/mentions";
@@ -13,10 +13,11 @@ import { getEditorClassNames } from "@/helpers/common";
// hooks
import { useCollaborativeEditor } from "@/hooks/use-collaborative-editor";
// types
import { EditorRefApi, ICollaborativeDocumentEditor } from "@/types";
import { EditorRefApi, ICollaborativeDocumentEditorProps } from "@/types";
const CollaborativeDocumentEditor = (props: ICollaborativeDocumentEditor) => {
const CollaborativeDocumentEditor: React.FC<ICollaborativeDocumentEditorProps> = (props) => {
const {
onChange,
onTransaction,
aiHandler,
bubbleMenuEnabled = true,
@@ -27,6 +28,7 @@ const CollaborativeDocumentEditor = (props: ICollaborativeDocumentEditor) => {
editorClassName = "",
embedHandler,
fileHandler,
flaggedExtensions,
forwardedRef,
handleEditorReady,
id,
@@ -56,10 +58,12 @@ const CollaborativeDocumentEditor = (props: ICollaborativeDocumentEditor) => {
embedHandler,
extensions,
fileHandler,
flaggedExtensions,
forwardedRef,
handleEditorReady,
id,
mentionHandler,
onChange,
onTransaction,
placeholder,
realtimeConfig,
@@ -95,7 +99,7 @@ const CollaborativeDocumentEditor = (props: ICollaborativeDocumentEditor) => {
);
};
const CollaborativeDocumentEditorWithRef = React.forwardRef<EditorRefApi, ICollaborativeDocumentEditor>(
const CollaborativeDocumentEditorWithRef = React.forwardRef<EditorRefApi, ICollaborativeDocumentEditorProps>(
(props, ref) => (
<CollaborativeDocumentEditor {...props} forwardedRef={ref as React.MutableRefObject<EditorRefApi | null>} />
)
@@ -5,7 +5,7 @@ import { AIFeaturesMenu, BlockMenu, EditorBubbleMenu } from "@/components/menus"
// types
import { TAIHandler, TDisplayConfig } from "@/types";
type IPageRenderer = {
type Props = {
aiHandler?: TAIHandler;
bubbleMenuEnabled: boolean;
displayConfig: TDisplayConfig;
@@ -15,7 +15,7 @@ type IPageRenderer = {
tabIndex?: number;
};
export const PageRenderer = (props: IPageRenderer) => {
export const PageRenderer = (props: Props) => {
const { aiHandler, bubbleMenuEnabled, displayConfig, editor, editorContainerClassName, id, tabIndex } = props;
return (
@@ -1,5 +1,5 @@
import { Extensions } from "@tiptap/core";
import { forwardRef, MutableRefObject } from "react";
import React, { forwardRef, MutableRefObject } from "react";
// plane imports
import { cn } from "@plane/utils";
// components
@@ -13,30 +13,9 @@ import { getEditorClassNames } from "@/helpers/common";
// hooks
import { useReadOnlyEditor } from "@/hooks/use-read-only-editor";
// types
import {
EditorReadOnlyRefApi,
TDisplayConfig,
TExtensions,
TReadOnlyFileHandler,
TReadOnlyMentionHandler,
} from "@/types";
import { EditorReadOnlyRefApi, IDocumentReadOnlyEditorProps } from "@/types";
interface IDocumentReadOnlyEditor {
disabledExtensions: TExtensions[];
id: string;
initialValue: string;
containerClassName: string;
displayConfig?: TDisplayConfig;
editorClassName?: string;
embedHandler: any;
fileHandler: TReadOnlyFileHandler;
tabIndex?: number;
handleEditorReady?: (value: boolean) => void;
mentionHandler: TReadOnlyMentionHandler;
forwardedRef?: React.MutableRefObject<EditorReadOnlyRefApi | null>;
}
const DocumentReadOnlyEditor = (props: IDocumentReadOnlyEditor) => {
const DocumentReadOnlyEditor: React.FC<IDocumentReadOnlyEditorProps> = (props) => {
const {
containerClassName,
disabledExtensions,
@@ -44,6 +23,7 @@ const DocumentReadOnlyEditor = (props: IDocumentReadOnlyEditor) => {
editorClassName = "",
embedHandler,
fileHandler,
flaggedExtensions,
id,
forwardedRef,
handleEditorReady,
@@ -64,6 +44,7 @@ const DocumentReadOnlyEditor = (props: IDocumentReadOnlyEditor) => {
editorClassName,
extensions,
fileHandler,
flaggedExtensions,
forwardedRef,
handleEditorReady,
initialValue,
@@ -87,7 +68,7 @@ const DocumentReadOnlyEditor = (props: IDocumentReadOnlyEditor) => {
);
};
const DocumentReadOnlyEditorWithRef = forwardRef<EditorReadOnlyRefApi, IDocumentReadOnlyEditor>((props, ref) => (
const DocumentReadOnlyEditorWithRef = forwardRef<EditorReadOnlyRefApi, IDocumentReadOnlyEditorProps>((props, ref) => (
<DocumentReadOnlyEditor {...props} forwardedRef={ref as MutableRefObject<EditorReadOnlyRefApi | null>} />
));
@@ -53,17 +53,14 @@ export const EditorContainer: FC<EditorContainerProps> = (props) => {
const lastNodePos = editor.state.doc.resolve(Math.max(0, docSize - 2));
const lastNode = lastNodePos.node();
// Check if the last node is a not paragraph
if (lastNode && lastNode.type.name !== CORE_EXTENSIONS.PARAGRAPH) {
// If last node is not a paragraph, insert a new paragraph at the end
const endPosition = editor?.state.doc.content.size;
editor?.chain().insertContentAt(endPosition, { type: CORE_EXTENSIONS.PARAGRAPH }).run();
// Focus the newly added paragraph for immediate editing
editor
.chain()
.setTextSelection(endPosition + 1)
.run();
// Check if its last node and add new node
if (lastNode) {
const isLastNodeEmptyParagraph = lastNode.type.name === CORE_EXTENSIONS.PARAGRAPH && lastNode.content.size === 0;
// Only insert a new paragraph if the last node is not an empty paragraph and not a doc node
if (!isLastNodeEmptyParagraph && lastNode.type.name !== "doc") {
const endPosition = editor?.state.doc.content.size;
editor?.chain().insertContentAt(endPosition, { type: "paragraph" }).focus("end").run();
}
}
} catch (error) {
console.error("An error occurred while handling container click to insert new empty node at bottom:", error);
@@ -26,6 +26,7 @@ export const EditorWrapper: React.FC<Props> = (props) => {
id,
initialValue,
fileHandler,
flaggedExtensions,
forwardedRef,
mentionHandler,
onChange,
@@ -44,6 +45,7 @@ export const EditorWrapper: React.FC<Props> = (props) => {
enableHistory: true,
extensions,
fileHandler,
flaggedExtensions,
forwardedRef,
id,
initialValue,
@@ -4,23 +4,25 @@ import { EditorWrapper } from "@/components/editors/editor-wrapper";
// extensions
import { EnterKeyExtension } from "@/extensions";
// types
import { EditorRefApi, ILiteTextEditor } from "@/types";
import { EditorRefApi, ILiteTextEditorProps } from "@/types";
const LiteTextEditor = (props: ILiteTextEditor) => {
const LiteTextEditor: React.FC<ILiteTextEditorProps> = (props) => {
const { onEnterKeyPress, disabledExtensions, extensions: externalExtensions = [] } = props;
const extensions = useMemo(
() => [
...externalExtensions,
...(disabledExtensions?.includes("enter-key") ? [] : [EnterKeyExtension(onEnterKeyPress)]),
],
[externalExtensions, disabledExtensions, onEnterKeyPress]
);
const extensions = useMemo(() => {
const resolvedExtensions = [...externalExtensions];
if (!disabledExtensions?.includes("enter-key")) {
resolvedExtensions.push(EnterKeyExtension(onEnterKeyPress));
}
return resolvedExtensions;
}, [externalExtensions, disabledExtensions, onEnterKeyPress]);
return <EditorWrapper {...props} extensions={extensions} />;
};
const LiteTextEditorWithRef = forwardRef<EditorRefApi, ILiteTextEditor>((props, ref) => (
const LiteTextEditorWithRef = forwardRef<EditorRefApi, ILiteTextEditorProps>((props, ref) => (
<LiteTextEditor {...props} forwardedRef={ref as React.MutableRefObject<EditorRefApi | null>} />
));
@@ -2,9 +2,9 @@ import { forwardRef } from "react";
// components
import { ReadOnlyEditorWrapper } from "@/components/editors";
// types
import { EditorReadOnlyRefApi, ILiteTextReadOnlyEditor } from "@/types";
import { EditorReadOnlyRefApi, ILiteTextReadOnlyEditorProps } from "@/types";
const LiteTextReadOnlyEditorWithRef = forwardRef<EditorReadOnlyRefApi, ILiteTextReadOnlyEditor>((props, ref) => (
const LiteTextReadOnlyEditorWithRef = forwardRef<EditorReadOnlyRefApi, ILiteTextReadOnlyEditorProps>((props, ref) => (
<ReadOnlyEditorWrapper {...props} forwardedRef={ref as React.MutableRefObject<EditorReadOnlyRefApi | null>} />
));
@@ -17,6 +17,7 @@ export const ReadOnlyEditorWrapper = (props: IReadOnlyEditorProps) => {
editorClassName = "",
extensions,
fileHandler,
flaggedExtensions,
forwardedRef,
id,
initialValue,
@@ -28,6 +29,7 @@ export const ReadOnlyEditorWrapper = (props: IReadOnlyEditorProps) => {
editorClassName,
extensions,
fileHandler,
flaggedExtensions,
forwardedRef,
initialValue,
mentionHandler,
@@ -7,15 +7,16 @@ import { SideMenuExtension } from "@/extensions";
// plane editor imports
import { RichTextEditorAdditionalExtensions } from "@/plane-editor/extensions/rich-text/extensions";
// types
import { EditorRefApi, IRichTextEditor } from "@/types";
import { EditorRefApi, IRichTextEditorProps } from "@/types";
const RichTextEditor = (props: IRichTextEditor) => {
const RichTextEditor: React.FC<IRichTextEditorProps> = (props) => {
const {
bubbleMenuEnabled = true,
disabledExtensions,
dragDropEnabled,
fileHandler,
bubbleMenuEnabled = true,
extensions: externalExtensions = [],
fileHandler,
flaggedExtensions,
} = props;
const getExtensions = useCallback(() => {
@@ -28,11 +29,12 @@ const RichTextEditor = (props: IRichTextEditor) => {
...RichTextEditorAdditionalExtensions({
disabledExtensions,
fileHandler,
flaggedExtensions,
}),
];
return extensions;
}, [dragDropEnabled, disabledExtensions, externalExtensions, fileHandler]);
}, [dragDropEnabled, disabledExtensions, externalExtensions, fileHandler, flaggedExtensions]);
return (
<EditorWrapper {...props} extensions={getExtensions()}>
@@ -41,7 +43,7 @@ const RichTextEditor = (props: IRichTextEditor) => {
);
};
const RichTextEditorWithRef = forwardRef<EditorRefApi, IRichTextEditor>((props, ref) => (
const RichTextEditorWithRef = forwardRef<EditorRefApi, IRichTextEditorProps>((props, ref) => (
<RichTextEditor {...props} forwardedRef={ref as React.MutableRefObject<EditorRefApi | null>} />
));
@@ -2,23 +2,22 @@ import { forwardRef, useCallback } from "react";
// plane editor extensions
import { RichTextReadOnlyEditorAdditionalExtensions } from "@/plane-editor/extensions/rich-text/read-only-extensions";
// types
import { EditorReadOnlyRefApi, IRichTextReadOnlyEditor } from "@/types";
import { EditorReadOnlyRefApi, IRichTextReadOnlyEditorProps } from "@/types";
// local imports
import { ReadOnlyEditorWrapper } from "../read-only-editor-wrapper";
const RichTextReadOnlyEditorWithRef = forwardRef<EditorReadOnlyRefApi, IRichTextReadOnlyEditor>((props, ref) => {
const { disabledExtensions, fileHandler } = props;
const RichTextReadOnlyEditorWithRef = forwardRef<EditorReadOnlyRefApi, IRichTextReadOnlyEditorProps>((props, ref) => {
const { disabledExtensions, fileHandler, flaggedExtensions } = props;
const getExtensions = useCallback(() => {
const extensions = [
...RichTextReadOnlyEditorAdditionalExtensions({
disabledExtensions,
fileHandler,
}),
];
const extensions = RichTextReadOnlyEditorAdditionalExtensions({
disabledExtensions,
fileHandler,
flaggedExtensions,
});
return extensions;
}, [disabledExtensions, fileHandler]);
}, [disabledExtensions, fileHandler, flaggedExtensions]);
return (
<ReadOnlyEditorWrapper
@@ -10,6 +10,7 @@ import {
BubbleMenuLinkSelector,
BubbleMenuNodeSelector,
CodeItem,
EditorMenuItem,
ItalicItem,
StrikeThroughItem,
TextAlignItem,
@@ -23,6 +24,7 @@ import { CORE_EXTENSIONS } from "@/constants/extension";
import { isCellSelection } from "@/extensions/table/table/utilities/is-cell-selection";
// local components
import { TextAlignmentSelector } from "./alignment-selector";
import { TEditorCommands } from "@/types";
type EditorBubbleMenuProps = Omit<BubbleMenuProps, "children">;
@@ -31,19 +33,19 @@ export interface EditorStateType {
bold: boolean;
italic: boolean;
underline: boolean;
strike: boolean;
strikethrough: boolean;
left: boolean;
right: boolean;
center: boolean;
color: { key: string; label: string; textColor: string; backgroundColor: string } | undefined;
backgroundColor:
| {
key: string;
label: string;
textColor: string;
backgroundColor: string;
}
| undefined;
| {
key: string;
label: string;
textColor: string;
backgroundColor: string;
}
| undefined;
}
export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: { editor: Editor }) => {
@@ -58,8 +60,10 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: { editor: Edi
bold: BoldItem(props.editor),
italic: ItalicItem(props.editor),
underline: UnderLineItem(props.editor),
strike: StrikeThroughItem(props.editor),
textAlign: TextAlignItem(props.editor),
strikethrough: StrikeThroughItem(props.editor),
"text-align": TextAlignItem(props.editor),
} satisfies {
[K in TEditorCommands]?: EditorMenuItem<K>;
};
const editorState: EditorStateType = useEditorState({
@@ -69,10 +73,10 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: { editor: Edi
bold: formattingItems.bold.isActive(),
italic: formattingItems.italic.isActive(),
underline: formattingItems.underline.isActive(),
strike: formattingItems.strike.isActive(),
left: formattingItems.textAlign.isActive({ alignment: "left" }),
right: formattingItems.textAlign.isActive({ alignment: "right" }),
center: formattingItems.textAlign.isActive({ alignment: "center" }),
strikethrough: formattingItems.strikethrough.isActive(),
left: formattingItems["text-align"].isActive({ alignment: "left" }),
right: formattingItems["text-align"].isActive({ alignment: "right" }),
center: formattingItems["text-align"].isActive({ alignment: "center" }),
color: COLORS_LIST.find((c) => TextColorItem(editor).isActive({ color: c.key })),
backgroundColor: COLORS_LIST.find((c) => BackgroundColorItem(editor).isActive({ color: c.key })),
}),
@@ -80,7 +84,7 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: { editor: Edi
const basicFormattingOptions = editorState.code
? [formattingItems.code]
: [formattingItems.bold, formattingItems.italic, formattingItems.underline, formattingItems.strike];
: [formattingItems.bold, formattingItems.italic, formattingItems.underline, formattingItems.strikethrough];
const bubbleMenuProps: EditorBubbleMenuProps = {
...props,
@@ -12,10 +12,10 @@ import { CustomCalloutExtensionConfig } from "./callout/extension-config";
import { CustomCodeBlockExtensionWithoutProps } from "./code/without-props";
import { CustomCodeInlineExtension } from "./code-inline";
import { CustomColorExtension } from "./custom-color";
import { CustomImageExtensionConfig } from "./custom-image/extension-config";
import { CustomLinkExtension } from "./custom-link";
import { CustomHorizontalRule } from "./horizontal-rule";
import { ImageExtensionWithoutProps } from "./image";
import { CustomImageComponentWithoutProps } from "./image/image-component-without-props";
import { ImageExtensionConfig } from "./image";
import { CustomMentionExtensionConfig } from "./mentions/extension-config";
import { CustomQuoteExtension } from "./quote";
import { TableHeader, TableCell, TableRow, Table } from "./table";
@@ -72,12 +72,8 @@ export const CoreEditorExtensionsWithoutProps = [
"text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer",
},
}),
ImageExtensionWithoutProps.configure({
HTMLAttributes: {
class: "rounded-md",
},
}),
CustomImageComponentWithoutProps,
ImageExtensionConfig,
CustomImageExtensionConfig,
TiptapUnderline,
TextStyle,
TaskList.configure({
@@ -1,68 +1,42 @@
import { NodeSelection } from "@tiptap/pm/state";
import React, { useRef, useState, useCallback, useLayoutEffect, useEffect } from "react";
// plane utils
// plane imports
import { cn } from "@plane/utils";
// extensions
import { CustomBaseImageNodeViewProps, ImageToolbarRoot } from "@/extensions/custom-image";
// local imports
import { Pixel, TCustomImageAttributes, TCustomImageSize } from "../types";
import { ensurePixelString } from "../utils";
import type { CustomImageNodeViewProps } from "./node-view";
import { ImageToolbarRoot } from "./toolbar";
import { ImageUploadStatus } from "./upload-status";
const MIN_SIZE = 100;
type Pixel = `${number}px`;
type PixelAttribute<TDefault> = Pixel | TDefault;
export type ImageAttributes = {
src: string | null;
width: PixelAttribute<"35%" | number>;
height: PixelAttribute<"auto" | number>;
aspectRatio: number | null;
id: string | null;
};
type Size = {
width: PixelAttribute<"35%">;
height: PixelAttribute<"auto">;
aspectRatio: number | null;
};
const ensurePixelString = <TDefault,>(value: Pixel | TDefault | number | undefined | null, defaultValue?: TDefault) => {
if (!value || value === defaultValue) {
return defaultValue;
}
if (typeof value === "number") {
return `${value}px` satisfies Pixel;
}
return value;
};
type CustomImageBlockProps = CustomBaseImageNodeViewProps & {
imageFromFileSystem: string | undefined;
setFailedToLoadImage: (isError: boolean) => void;
type CustomImageBlockProps = CustomImageNodeViewProps & {
editorContainer: HTMLDivElement | null;
imageFromFileSystem: string | undefined;
setEditorContainer: (editorContainer: HTMLDivElement | null) => void;
setFailedToLoadImage: (isError: boolean) => void;
src: string | undefined;
};
export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
// props
const {
node,
updateAttributes,
setFailedToLoadImage,
imageFromFileSystem,
selected,
getPos,
editor,
editorContainer,
src: resolvedImageSrc,
extension,
getPos,
imageFromFileSystem,
node,
selected,
setEditorContainer,
setFailedToLoadImage,
src: resolvedImageSrc,
updateAttributes,
} = props;
const { width: nodeWidth, height: nodeHeight, aspectRatio: nodeAspectRatio, src: imgNodeSrc } = node.attrs;
// states
const [size, setSize] = useState<Size>({
const [size, setSize] = useState<TCustomImageSize>({
width: ensurePixelString(nodeWidth, "35%") ?? "35%",
height: ensurePixelString(nodeHeight, "auto") ?? "auto",
aspectRatio: nodeAspectRatio || null,
@@ -77,7 +51,7 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
const [hasTriedRestoringImageOnce, setHasTriedRestoringImageOnce] = useState(false);
const updateAttributesSafely = useCallback(
(attributes: Partial<ImageAttributes>, errorMessage: string) => {
(attributes: Partial<TCustomImageAttributes>, errorMessage: string) => {
try {
updateAttributes(attributes);
} catch (error) {
@@ -114,7 +88,7 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
const initialWidth = Math.max(editorWidth * 0.35, MIN_SIZE);
const initialHeight = initialWidth / aspectRatioCalculated;
const initialComputedSize = {
const initialComputedSize: TCustomImageSize = {
width: `${Math.round(initialWidth)}px` satisfies Pixel,
height: `${Math.round(initialHeight)}px` satisfies Pixel,
aspectRatio: aspectRatioCalculated,
@@ -139,7 +113,7 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
}
}
setInitialResizeComplete(true);
}, [nodeWidth, updateAttributes, editorContainer, nodeAspectRatio]);
}, [nodeWidth, updateAttributesSafely, editorContainer, nodeAspectRatio, setEditorContainer]);
// for real time resizing
useLayoutEffect(() => {
@@ -168,7 +142,7 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
const handleResizeEnd = useCallback(() => {
setIsResizing(false);
updateAttributesSafely(size, "Failed to update attributes at the end of resizing:");
}, [size, updateAttributes]);
}, [size, updateAttributesSafely]);
const handleResizeStart = useCallback((e: React.MouseEvent | React.TouchEvent) => {
e.preventDefault();
@@ -242,7 +216,7 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
onLoad={handleImageLoad}
onError={async (e) => {
// for old image extension this command doesn't exist or if the image failed to load for the first time
if (!editor?.commands.restoreImage || hasTriedRestoringImageOnce) {
if (!extension.options.restoreImage || hasTriedRestoringImageOnce) {
setFailedToLoadImage(true);
return;
}
@@ -253,7 +227,7 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
if (!imgNodeSrc) {
throw new Error("No source image to restore from");
}
await editor?.commands.restoreImage?.(imgNodeSrc);
await extension.options.restoreImage?.(imgNodeSrc);
if (!imageRef.current) {
throw new Error("Image reference not found");
}
@@ -289,10 +263,10 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
"absolute top-1 right-1 z-20 bg-black/40 rounded opacity-0 pointer-events-none group-hover/image-component:opacity-100 group-hover/image-component:pointer-events-auto transition-opacity"
}
image={{
src: resolvedImageSrc,
aspectRatio: size.aspectRatio === null ? 1 : size.aspectRatio,
height: size.height,
width: size.width,
height: size.height,
aspectRatio: size.aspectRatio === null ? 1 : size.aspectRatio,
src: resolvedImageSrc,
}}
/>
)}
@@ -1,4 +0,0 @@
export * from "./toolbar";
export * from "./image-block";
export * from "./image-node";
export * from "./image-uploader";
@@ -2,25 +2,26 @@ import { Editor, NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import { useEffect, useRef, useState } from "react";
// constants
import { CORE_EXTENSIONS } from "@/constants/extension";
// extensions
import { CustomImageBlock, CustomImageUploader, ImageAttributes } from "@/extensions/custom-image";
// helpers
import { getExtensionStorage } from "@/helpers/get-extension-storage";
// local imports
import type { CustomImageExtension, TCustomImageAttributes } from "../types";
import { CustomImageBlock } from "./block";
import { CustomImageUploader } from "./uploader";
export type CustomBaseImageNodeViewProps = {
export type CustomImageNodeViewProps = Omit<NodeViewProps, "extension" | "updateAttributes"> & {
extension: CustomImageExtension;
getPos: () => number;
editor: Editor;
node: NodeViewProps["node"] & {
attrs: ImageAttributes;
attrs: TCustomImageAttributes;
};
updateAttributes: (attrs: Partial<ImageAttributes>) => void;
updateAttributes: (attrs: Partial<TCustomImageAttributes>) => void;
selected: boolean;
};
export type CustomImageNodeProps = NodeViewProps & CustomBaseImageNodeViewProps;
export const CustomImageNode = (props: CustomImageNodeProps) => {
const { getPos, editor, node, updateAttributes, selected } = props;
export const CustomImageNodeView: React.FC<CustomImageNodeViewProps> = (props) => {
const { editor, extension, node } = props;
const { src: imgNodeSrc } = node.attrs;
const [isUploaded, setIsUploaded] = useState(false);
@@ -50,41 +51,37 @@ export const CustomImageNode = (props: CustomImageNodeProps) => {
}, [resolvedSrc]);
useEffect(() => {
if (!imgNodeSrc) {
setResolvedSrc(undefined);
return;
}
const getImageSource = async () => {
// @ts-expect-error function not expected here, but will still work and don't remove await
const url: string = await editor?.commands?.getImageSource?.(imgNodeSrc);
setResolvedSrc(url as string);
const url = await extension.options.getImageSource?.(imgNodeSrc);
setResolvedSrc(url);
};
getImageSource();
}, [imgNodeSrc]);
}, [imgNodeSrc, extension.options]);
return (
<NodeViewWrapper>
<div className="p-0 mx-0 my-2" data-drag-handle ref={imageComponentRef}>
{(isUploaded || imageFromFileSystem) && !failedToLoadImage ? (
<CustomImageBlock
imageFromFileSystem={imageFromFileSystem}
editorContainer={editorContainer}
editor={editor}
src={resolvedSrc}
getPos={getPos}
node={node}
imageFromFileSystem={imageFromFileSystem}
setEditorContainer={setEditorContainer}
setFailedToLoadImage={setFailedToLoadImage}
selected={selected}
updateAttributes={updateAttributes}
src={resolvedSrc}
{...props}
/>
) : (
<CustomImageUploader
editor={editor}
failedToLoadImage={failedToLoadImage}
getPos={getPos}
loadImageFromFileSystem={setImageFromFileSystem}
maxFileSize={getExtensionStorage(editor, CORE_EXTENSIONS.CUSTOM_IMAGE).maxFileSize}
node={node}
setIsUploaded={setIsUploaded}
selected={selected}
updateAttributes={updateAttributes}
{...props}
/>
)}
</div>
@@ -1,14 +1,14 @@
import { ExternalLink, Maximize, Minus, Plus, X } from "lucide-react";
import { useCallback, useEffect, useMemo, useState, useRef } from "react";
// plane utils
// plane imports
import { cn } from "@plane/utils";
type Props = {
image: {
src: string;
height: string;
width: string;
height: string;
aspectRatio: number;
src: string;
};
isOpen: boolean;
toggleFullScreenMode: (val: boolean) => void;
@@ -189,7 +189,7 @@ export const ImageFullScreenAction: React.FC<Props> = (props) => {
<>
<div
className={cn("fixed inset-0 size-full z-20 bg-black/90 opacity-0 pointer-events-none transition-opacity", {
"opacity-100 pointer-events-auto": isFullScreenEnabled,
"opacity-100 pointer-events-auto editor-image-full-screen-modal": isFullScreenEnabled,
"cursor-default": !isDragging,
"cursor-grabbing": isDragging,
})}
@@ -1,16 +1,16 @@
import { useState } from "react";
// plane utils
// plane imports
import { cn } from "@plane/utils";
// components
// local imports
import { ImageFullScreenAction } from "./full-screen";
type Props = {
containerClassName?: string;
image: {
src: string;
height: string;
width: string;
height: string;
aspectRatio: number;
src: string;
};
};
@@ -1,28 +1,30 @@
import { ImageIcon } from "lucide-react";
import { ChangeEvent, useCallback, useEffect, useMemo, useRef } from "react";
// plane utils
// plane imports
import { cn } from "@plane/utils";
// constants
import { ACCEPTED_IMAGE_MIME_TYPES } from "@/constants/config";
import { CORE_EXTENSIONS } from "@/constants/extension";
// extensions
import { CustomBaseImageNodeViewProps, getImageComponentImageFileMap } from "@/extensions/custom-image";
// helpers
import { EFileError } from "@/helpers/file";
import { getExtensionStorage } from "@/helpers/get-extension-storage";
// hooks
import { useUploader, useDropZone, uploadFirstFileAndInsertRemaining } from "@/hooks/use-file-upload";
// local imports
import { getImageComponentImageFileMap } from "../utils";
import type { CustomImageNodeViewProps } from "./node-view";
type CustomImageUploaderProps = CustomBaseImageNodeViewProps & {
maxFileSize: number;
loadImageFromFileSystem: (file: string) => void;
type CustomImageUploaderProps = CustomImageNodeViewProps & {
failedToLoadImage: boolean;
loadImageFromFileSystem: (file: string) => void;
maxFileSize: number;
setIsUploaded: (isUploaded: boolean) => void;
};
export const CustomImageUploader = (props: CustomImageUploaderProps) => {
const {
editor,
extension,
failedToLoadImage,
getPos,
loadImageFromFileSystem,
@@ -71,12 +73,13 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
}
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[imageComponentImageFileMap, imageEntityId, updateAttributes, getPos]
);
const uploadImageEditorCommand = useCallback(
async (file: File) => await editor?.commands.uploadImage(imageEntityId ?? "", file),
[editor, imageEntityId]
async (file: File) => await extension.options.uploadImage?.(imageEntityId ?? "", file),
[extension.options, imageEntityId]
);
const handleProgressStatus = useCallback(
@@ -93,7 +96,6 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
// hooks
const { isUploading: isImageBeingUploaded, uploadFile } = useUploader({
acceptedMimeTypes: ACCEPTED_IMAGE_MIME_TYPES,
// @ts-expect-error - TODO: fix typings, and don't remove await from here for now
editorCommand: uploadImageEditorCommand,
handleProgressStatus,
loadFileFromFileSystem: loadImageFromFileSystem,
@@ -128,7 +130,7 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
imageComponentImageFileMap?.set(imageEntityId ?? "", { ...meta, hasOpenedFileInputOnce: true });
}
}
}, [meta, uploadFile, imageComponentImageFileMap]);
}, [meta, uploadFile, imageComponentImageFileMap, imageEntityId]);
const onFileChange = useCallback(
async (e: ChangeEvent<HTMLInputElement>) => {
@@ -163,7 +165,7 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
}
return "Add an image";
}, [draggedInside, failedToLoadImage, isImageBeingUploaded]);
}, [draggedInside, failedToLoadImage, isImageBeingUploaded, editor.isEditable]);
return (
<div
@@ -1,180 +0,0 @@
import { Editor, mergeAttributes } from "@tiptap/core";
import { Image as BaseImageExtension } from "@tiptap/extension-image";
import { ReactNodeViewRenderer } from "@tiptap/react";
import { v4 as uuidv4 } from "uuid";
// constants
import { ACCEPTED_IMAGE_MIME_TYPES } from "@/constants/config";
import { CORE_EXTENSIONS } from "@/constants/extension";
// extensions
import { CustomImageNode } from "@/extensions/custom-image";
// helpers
import { isFileValid } from "@/helpers/file";
import { getExtensionStorage } from "@/helpers/get-extension-storage";
import { insertEmptyParagraphAtNodeBoundaries } from "@/helpers/insert-empty-paragraph-at-node-boundary";
// types
import { TFileHandler } from "@/types";
export type InsertImageComponentProps = {
file?: File;
pos?: number;
event: "insert" | "drop";
};
declare module "@tiptap/core" {
interface Commands<ReturnType> {
[CORE_EXTENSIONS.CUSTOM_IMAGE]: {
insertImageComponent: ({ file, pos, event }: InsertImageComponentProps) => ReturnType;
uploadImage: (blockId: string, file: File) => () => Promise<string> | undefined;
getImageSource?: (path: string) => () => Promise<string>;
restoreImage: (src: string) => () => Promise<void>;
};
}
}
export const getImageComponentImageFileMap = (editor: Editor) =>
getExtensionStorage(editor, CORE_EXTENSIONS.CUSTOM_IMAGE)?.fileMap;
export interface CustomImageExtensionStorage {
fileMap: Map<string, UploadEntity>;
deletedImageSet: Map<string, boolean>;
maxFileSize: number;
}
export type UploadEntity = ({ event: "insert" } | { event: "drop"; file: File }) & { hasOpenedFileInputOnce?: boolean };
export const CustomImageExtension = (props: TFileHandler) => {
const {
getAssetSrc,
upload,
restore: restoreImageFn,
validation: { maxFileSize },
} = props;
return BaseImageExtension.extend<Record<string, unknown>, CustomImageExtensionStorage>({
name: CORE_EXTENSIONS.CUSTOM_IMAGE,
selectable: true,
group: "block",
atom: true,
draggable: true,
addAttributes() {
return {
...this.parent?.(),
width: {
default: "35%",
},
src: {
default: null,
},
height: {
default: "auto",
},
["id"]: {
default: null,
},
aspectRatio: {
default: null,
},
};
},
parseHTML() {
return [
{
tag: "image-component",
},
];
},
renderHTML({ HTMLAttributes }) {
return ["image-component", mergeAttributes(HTMLAttributes)];
},
addKeyboardShortcuts() {
return {
ArrowDown: insertEmptyParagraphAtNodeBoundaries("down", this.name),
ArrowUp: insertEmptyParagraphAtNodeBoundaries("up", this.name),
};
},
addStorage() {
return {
fileMap: new Map(),
deletedImageSet: new Map<string, boolean>(),
maxFileSize,
// escape markdown for images
markdown: {
serialize() {},
},
};
},
addCommands() {
return {
insertImageComponent:
(props) =>
({ commands }) => {
// Early return if there's an invalid file being dropped
if (
props?.file &&
!isFileValid({
acceptedMimeTypes: ACCEPTED_IMAGE_MIME_TYPES,
file: props.file,
maxFileSize,
onError: (_error, message) => alert(message),
})
) {
return false;
}
// generate a unique id for the image to keep track of dropped
// files' file data
const fileId = uuidv4();
const imageComponentImageFileMap = getImageComponentImageFileMap(this.editor);
if (imageComponentImageFileMap) {
if (props?.event === "drop" && props.file) {
imageComponentImageFileMap.set(fileId, {
file: props.file,
event: props.event,
});
} else if (props.event === "insert") {
imageComponentImageFileMap.set(fileId, {
event: props.event,
hasOpenedFileInputOnce: false,
});
}
}
const attributes = {
id: fileId,
};
if (props.pos) {
return commands.insertContentAt(props.pos, {
type: this.name,
attrs: attributes,
});
}
return commands.insertContent({
type: this.name,
attrs: attributes,
});
},
uploadImage: (blockId, file) => async () => {
const fileUrl = await upload(blockId, file);
return fileUrl;
},
getImageSource: (path) => async () => await getAssetSrc(path),
restoreImage: (src) => async () => {
await restoreImageFn(src);
},
};
},
addNodeView() {
return ReactNodeViewRenderer(CustomImageNode);
},
});
};
@@ -0,0 +1,47 @@
import { mergeAttributes } from "@tiptap/core";
import { Image as BaseImageExtension } from "@tiptap/extension-image";
// constants
import { CORE_EXTENSIONS } from "@/constants/extension";
// local imports
import { type CustomImageExtension, ECustomImageAttributeNames, type InsertImageComponentProps } from "./types";
import { DEFAULT_CUSTOM_IMAGE_ATTRIBUTES } from "./utils";
declare module "@tiptap/core" {
interface Commands<ReturnType> {
[CORE_EXTENSIONS.CUSTOM_IMAGE]: {
insertImageComponent: ({ file, pos, event }: InsertImageComponentProps) => ReturnType;
};
}
}
export const CustomImageExtensionConfig: CustomImageExtension = BaseImageExtension.extend({
name: CORE_EXTENSIONS.CUSTOM_IMAGE,
group: "block",
atom: true,
addAttributes() {
const attributes = {
...this.parent?.(),
...Object.values(ECustomImageAttributeNames).reduce((acc, value) => {
acc[value] = {
default: DEFAULT_CUSTOM_IMAGE_ATTRIBUTES[value],
};
return acc;
}, {}),
};
return attributes;
},
parseHTML() {
return [
{
tag: "image-component",
},
];
},
renderHTML({ HTMLAttributes }) {
return ["image-component", mergeAttributes(HTMLAttributes)];
},
});
@@ -0,0 +1,121 @@
import { ReactNodeViewRenderer } from "@tiptap/react";
import { v4 as uuidv4 } from "uuid";
// constants
import { ACCEPTED_IMAGE_MIME_TYPES } from "@/constants/config";
// helpers
import { isFileValid } from "@/helpers/file";
import { insertEmptyParagraphAtNodeBoundaries } from "@/helpers/insert-empty-paragraph-at-node-boundary";
// types
import type { TFileHandler, TReadOnlyFileHandler } from "@/types";
// local imports
import { CustomImageNodeView } from "./components/node-view";
import { CustomImageExtensionConfig } from "./extension-config";
import { getImageComponentImageFileMap } from "./utils";
type Props = {
fileHandler: TFileHandler | TReadOnlyFileHandler;
isEditable: boolean;
};
export const CustomImageExtension = (props: Props) => {
const { fileHandler, isEditable } = props;
// derived values
const { getAssetSrc, restore: restoreImageFn } = fileHandler;
return CustomImageExtensionConfig.extend({
selectable: isEditable,
draggable: isEditable,
addOptions() {
const upload = "upload" in fileHandler ? fileHandler.upload : undefined;
return {
...this.parent?.(),
getImageSource: getAssetSrc,
restoreImage: restoreImageFn,
uploadImage: upload,
};
},
addStorage() {
const maxFileSize = "validation" in fileHandler ? fileHandler.validation?.maxFileSize : 0;
return {
fileMap: new Map(),
deletedImageSet: new Map<string, boolean>(),
maxFileSize,
// escape markdown for images
markdown: {
serialize() {},
},
};
},
addCommands() {
return {
insertImageComponent:
(props) =>
({ commands }) => {
// Early return if there's an invalid file being dropped
if (
props?.file &&
!isFileValid({
acceptedMimeTypes: ACCEPTED_IMAGE_MIME_TYPES,
file: props.file,
maxFileSize: this.storage.maxFileSize,
onError: (_error, message) => alert(message),
})
) {
return false;
}
// generate a unique id for the image to keep track of dropped
// files' file data
const fileId = uuidv4();
const imageComponentImageFileMap = getImageComponentImageFileMap(this.editor);
if (imageComponentImageFileMap) {
if (props?.event === "drop" && props.file) {
imageComponentImageFileMap.set(fileId, {
file: props.file,
event: props.event,
});
} else if (props.event === "insert") {
imageComponentImageFileMap.set(fileId, {
event: props.event,
hasOpenedFileInputOnce: false,
});
}
}
const attributes = {
id: fileId,
};
if (props.pos) {
return commands.insertContentAt(props.pos, {
type: this.name,
attrs: attributes,
});
}
return commands.insertContent({
type: this.name,
attrs: attributes,
});
},
};
},
addKeyboardShortcuts() {
return {
ArrowDown: insertEmptyParagraphAtNodeBoundaries("down", this.name),
ArrowUp: insertEmptyParagraphAtNodeBoundaries("up", this.name),
};
},
addNodeView() {
return ReactNodeViewRenderer(CustomImageNodeView);
},
});
};
@@ -1,3 +0,0 @@
export * from "./components";
export * from "./custom-image";
export * from "./read-only-custom-image";
@@ -1,79 +0,0 @@
import { mergeAttributes } from "@tiptap/core";
import { Image as BaseImageExtension } from "@tiptap/extension-image";
import { ReactNodeViewRenderer } from "@tiptap/react";
// constants
import { CORE_EXTENSIONS } from "@/constants/extension";
// components
import { CustomImageNode, CustomImageExtensionStorage } from "@/extensions/custom-image";
// types
import { TReadOnlyFileHandler } from "@/types";
export const CustomReadOnlyImageExtension = (props: TReadOnlyFileHandler) => {
const { getAssetSrc, restore: restoreImageFn } = props;
return BaseImageExtension.extend<Record<string, unknown>, CustomImageExtensionStorage>({
name: CORE_EXTENSIONS.CUSTOM_IMAGE,
selectable: false,
group: "block",
atom: true,
draggable: false,
addAttributes() {
return {
...this.parent?.(),
width: {
default: "35%",
},
src: {
default: null,
},
height: {
default: "auto",
},
["id"]: {
default: null,
},
aspectRatio: {
default: null,
},
};
},
parseHTML() {
return [
{
tag: "image-component",
},
];
},
renderHTML({ HTMLAttributes }) {
return ["image-component", mergeAttributes(HTMLAttributes)];
},
addStorage() {
return {
fileMap: new Map(),
deletedImageSet: new Map<string, boolean>(),
maxFileSize: 0,
// escape markdown for images
markdown: {
serialize() {},
},
};
},
addCommands() {
return {
getImageSource: (path: string) => async () => await getAssetSrc(path),
restoreImage: (src) => async () => {
await restoreImageFn(src);
},
};
},
addNodeView() {
return ReactNodeViewRenderer(CustomImageNode);
},
});
};
@@ -0,0 +1,51 @@
import type { Node } from "@tiptap/core";
// types
import type { TFileHandler } from "@/types";
export enum ECustomImageAttributeNames {
ID = "id",
WIDTH = "width",
HEIGHT = "height",
ASPECT_RATIO = "aspectRatio",
SOURCE = "src",
}
export type Pixel = `${number}px`;
export type PixelAttribute<TDefault> = Pixel | TDefault;
export type TCustomImageSize = {
width: PixelAttribute<"35%">;
height: PixelAttribute<"auto">;
aspectRatio: number | null;
};
export type TCustomImageAttributes = {
[ECustomImageAttributeNames.ID]: string | null;
[ECustomImageAttributeNames.WIDTH]: PixelAttribute<"35%" | number> | null;
[ECustomImageAttributeNames.HEIGHT]: PixelAttribute<"auto" | number> | null;
[ECustomImageAttributeNames.ASPECT_RATIO]: number | null;
[ECustomImageAttributeNames.SOURCE]: string | null;
};
export type UploadEntity = ({ event: "insert" } | { event: "drop"; file: File }) & { hasOpenedFileInputOnce?: boolean };
export type InsertImageComponentProps = {
file?: File;
pos?: number;
event: "insert" | "drop";
};
export type CustomImageExtensionOptions = {
getImageSource: TFileHandler["getAssetSrc"];
restoreImage: TFileHandler["restore"];
uploadImage?: TFileHandler["upload"];
};
export type CustomImageExtensionStorage = {
fileMap: Map<string, UploadEntity>;
deletedImageSet: Map<string, boolean>;
maxFileSize: number;
};
export type CustomImageExtension = Node<CustomImageExtensionOptions, CustomImageExtensionStorage>;
@@ -0,0 +1,33 @@
import type { Editor } from "@tiptap/core";
// constants
import { CORE_EXTENSIONS } from "@/constants/extension";
// helpers
import { getExtensionStorage } from "@/helpers/get-extension-storage";
// local imports
import { ECustomImageAttributeNames, type Pixel, type TCustomImageAttributes } from "./types";
export const DEFAULT_CUSTOM_IMAGE_ATTRIBUTES: TCustomImageAttributes = {
[ECustomImageAttributeNames.SOURCE]: null,
[ECustomImageAttributeNames.ID]: null,
[ECustomImageAttributeNames.WIDTH]: "35%",
[ECustomImageAttributeNames.HEIGHT]: "auto",
[ECustomImageAttributeNames.ASPECT_RATIO]: null,
};
export const getImageComponentImageFileMap = (editor: Editor) =>
getExtensionStorage(editor, CORE_EXTENSIONS.CUSTOM_IMAGE)?.fileMap;
export const ensurePixelString = <TDefault>(
value: Pixel | TDefault | number | undefined | null,
defaultValue?: TDefault
) => {
if (!value || value === defaultValue) {
return defaultValue;
}
if (typeof value === "number") {
return `${value}px` satisfies Pixel;
}
return value;
};
@@ -16,7 +16,6 @@ import {
CustomCodeInlineExtension,
CustomColorExtension,
CustomHorizontalRule,
CustomImageExtension,
CustomKeymap,
CustomLinkExtension,
CustomMentionExtension,
@@ -37,20 +36,29 @@ import { getExtensionStorage } from "@/helpers/get-extension-storage";
// plane editor extensions
import { CoreEditorAdditionalExtensions } from "@/plane-editor/extensions";
// types
import { TExtensions, TFileHandler, TMentionHandler } from "@/types";
import type { IEditorProps } from "@/types";
// local imports
import { CustomImageExtension } from "./custom-image/extension";
type TArguments = {
disabledExtensions: TExtensions[];
type TArguments = Pick<
IEditorProps,
"disabledExtensions" | "flaggedExtensions" | "fileHandler" | "mentionHandler" | "placeholder" | "tabIndex"
> & {
enableHistory: boolean;
fileHandler: TFileHandler;
mentionHandler: TMentionHandler;
placeholder?: string | ((isFocused: boolean, value: string) => string);
tabIndex?: number;
editable: boolean;
};
export const CoreEditorExtensions = (args: TArguments): Extensions => {
const { disabledExtensions, enableHistory, fileHandler, mentionHandler, placeholder, tabIndex, editable } = args;
const {
disabledExtensions,
enableHistory,
fileHandler,
flaggedExtensions,
mentionHandler,
placeholder,
tabIndex,
editable,
} = args;
const extensions = [
StarterKit.configure({
@@ -177,18 +185,20 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => {
CustomColorExtension,
...CoreEditorAdditionalExtensions({
disabledExtensions,
flaggedExtensions,
fileHandler,
}),
];
if (!disabledExtensions.includes("image")) {
extensions.push(
ImageExtension(fileHandler).configure({
HTMLAttributes: {
class: "rounded-md",
},
ImageExtension({
fileHandler,
}),
CustomImageExtension(fileHandler)
CustomImageExtension({
fileHandler,
isEditable: editable,
})
);
}
@@ -1,6 +1,12 @@
import { Image as BaseImageExtension } from "@tiptap/extension-image";
// local imports
import { CustomImageExtensionOptions } from "../custom-image/types";
import { ImageExtensionStorage } from "./extension";
export const ImageExtensionWithoutProps = BaseImageExtension.extend({
export const ImageExtensionConfig = BaseImageExtension.extend<
Pick<CustomImageExtensionOptions, "getImageSource">,
ImageExtensionStorage
>({
addAttributes() {
return {
...this.parent?.(),
@@ -1,23 +1,33 @@
import { Image as BaseImageExtension } from "@tiptap/extension-image";
import { ReactNodeViewRenderer } from "@tiptap/react";
// extensions
import { CustomImageNode } from "@/extensions";
// helpers
import { insertEmptyParagraphAtNodeBoundaries } from "@/helpers/insert-empty-paragraph-at-node-boundary";
// types
import { TFileHandler } from "@/types";
import type { TFileHandler, TReadOnlyFileHandler } from "@/types";
// local imports
import { CustomImageNodeView } from "../custom-image/components/node-view";
import { ImageExtensionConfig } from "./extension-config";
export type ImageExtensionStorage = {
deletedImageSet: Map<string, boolean>;
};
export const ImageExtension = (fileHandler: TFileHandler) => {
const {
getAssetSrc,
validation: { maxFileSize },
} = fileHandler;
type Props = {
fileHandler: TFileHandler | TReadOnlyFileHandler;
};
export const ImageExtension = (props: Props) => {
const { fileHandler } = props;
// derived values
const { getAssetSrc } = fileHandler;
return ImageExtensionConfig.extend({
addOptions() {
return {
...this.parent?.(),
getImageSource: getAssetSrc,
};
},
return BaseImageExtension.extend<unknown, ImageExtensionStorage>({
addKeyboardShortcuts() {
return {
ArrowDown: insertEmptyParagraphAtNodeBoundaries("down", this.name),
@@ -27,36 +37,17 @@ export const ImageExtension = (fileHandler: TFileHandler) => {
// storage to keep track of image states Map<src, isDeleted>
addStorage() {
const maxFileSize = "validation" in fileHandler ? fileHandler.validation?.maxFileSize : 0;
return {
deletedImageSet: new Map<string, boolean>(),
maxFileSize,
};
},
addAttributes() {
return {
...this.parent?.(),
width: {
default: "35%",
},
height: {
default: null,
},
aspectRatio: {
default: null,
},
};
},
addCommands() {
return {
getImageSource: (path: string) => async () => await getAssetSrc(path),
};
},
// render custom image node
addNodeView() {
return ReactNodeViewRenderer(CustomImageNode);
return ReactNodeViewRenderer(CustomImageNodeView);
},
});
};
@@ -1,56 +0,0 @@
import { mergeAttributes } from "@tiptap/core";
import { Image as BaseImageExtension } from "@tiptap/extension-image";
// local imports
import { ImageExtensionStorage } from "./extension";
export const CustomImageComponentWithoutProps = BaseImageExtension.extend<
Record<string, unknown>,
ImageExtensionStorage
>({
name: "imageComponent",
selectable: true,
group: "block",
atom: true,
draggable: true,
addAttributes() {
return {
...this.parent?.(),
width: {
default: "35%",
},
src: {
default: null,
},
height: {
default: "auto",
},
["id"]: {
default: null,
},
aspectRatio: {
default: null,
},
};
},
parseHTML() {
return [
{
tag: "image-component",
},
];
},
renderHTML({ HTMLAttributes }) {
return ["image-component", mergeAttributes(HTMLAttributes)];
},
addStorage() {
return {
fileMap: new Map(),
deletedImageSet: new Map<string, boolean>(),
maxFileSize: 0,
};
},
});
@@ -1,3 +1,2 @@
export * from "./extension";
export * from "./image-extension-without-props";
export * from "./read-only-image";
export * from "./extension-config";
@@ -1,37 +0,0 @@
import { Image as BaseImageExtension } from "@tiptap/extension-image";
import { ReactNodeViewRenderer } from "@tiptap/react";
// extensions
import { CustomImageNode } from "@/extensions";
// types
import { TReadOnlyFileHandler } from "@/types";
export const ReadOnlyImageExtension = (props: TReadOnlyFileHandler) => {
const { getAssetSrc } = props;
return BaseImageExtension.extend({
addAttributes() {
return {
...this.parent?.(),
width: {
default: "35%",
},
height: {
default: null,
},
aspectRatio: {
default: null,
},
};
},
addCommands() {
return {
getImageSource: (path: string) => async () => await getAssetSrc(path),
};
},
addNodeView() {
return ReactNodeViewRenderer(CustomImageNode);
},
});
};
@@ -1,7 +1,6 @@
export * from "./callout";
export * from "./code";
export * from "./code-inline";
export * from "./custom-image";
export * from "./custom-link";
export * from "./custom-list-keymap";
export * from "./image";
@@ -12,7 +12,6 @@ import {
CustomHorizontalRule,
CustomLinkExtension,
CustomTypographyExtension,
ReadOnlyImageExtension,
CustomCodeBlockExtension,
CustomCodeInlineExtension,
TableHeader,
@@ -20,27 +19,25 @@ import {
TableRow,
Table,
CustomMentionExtension,
CustomReadOnlyImageExtension,
CustomTextAlignExtension,
CustomCalloutReadOnlyExtension,
CustomColorExtension,
UtilityExtension,
ImageExtension,
} from "@/extensions";
// helpers
import { isValidHttpUrl } from "@/helpers/common";
// plane editor extensions
import { CoreReadOnlyEditorAdditionalExtensions } from "@/plane-editor/extensions";
// types
import { TExtensions, TReadOnlyFileHandler, TReadOnlyMentionHandler } from "@/types";
import type { IReadOnlyEditorProps } from "@/types";
// local imports
import { CustomImageExtension } from "./custom-image/extension";
type Props = {
disabledExtensions: TExtensions[];
fileHandler: TReadOnlyFileHandler;
mentionHandler: TReadOnlyMentionHandler;
};
type Props = Pick<IReadOnlyEditorProps, "disabledExtensions" | "flaggedExtensions" | "fileHandler" | "mentionHandler">;
export const CoreReadOnlyEditorExtensions = (props: Props): Extensions => {
const { disabledExtensions, fileHandler, mentionHandler } = props;
const { disabledExtensions, fileHandler, flaggedExtensions, mentionHandler } = props;
const extensions = [
StarterKit.configure({
@@ -133,17 +130,19 @@ export const CoreReadOnlyEditorExtensions = (props: Props): Extensions => {
}),
...CoreReadOnlyEditorAdditionalExtensions({
disabledExtensions,
flaggedExtensions,
}),
];
if (!disabledExtensions.includes("image")) {
extensions.push(
ReadOnlyImageExtension(fileHandler).configure({
HTMLAttributes: {
class: "rounded-md",
},
ImageExtension({
fileHandler,
}),
CustomReadOnlyImageExtension(fileHandler)
CustomImageExtension({
fileHandler,
isEditable: false,
})
);
}
@@ -131,7 +131,7 @@ const SideMenu = (options: SideMenuPluginProps) => {
}
}
if (node.matches(".table-wrapper")) {
if (node.matches("table")) {
rect.top += 8;
rect.left -= 8;
}
@@ -49,7 +49,7 @@ export type TSlashCommandSection = {
export const getSlashCommandFilteredSections =
(args: TExtensionProps) =>
({ query }: { query: string }): TSlashCommandSection[] => {
const { additionalOptions: externalAdditionalOptions, disabledExtensions } = args;
const { additionalOptions: externalAdditionalOptions, disabledExtensions, flaggedExtensions } = args;
const SLASH_COMMAND_SECTIONS: TSlashCommandSection[] = [
{
key: "general",
@@ -290,6 +290,7 @@ export const getSlashCommandFilteredSections =
...(externalAdditionalOptions ?? []),
...coreEditorAdditionalSlashCommandOptions({
disabledExtensions,
flaggedExtensions,
}),
]?.forEach((item) => {
const sectionToPushTo = SLASH_COMMAND_SECTIONS.find((s) => s.key === item.section) ?? SLASH_COMMAND_SECTIONS[0];
@@ -7,7 +7,7 @@ import { CORE_EXTENSIONS } from "@/constants/extension";
// helpers
import { CommandListInstance } from "@/helpers/tippy";
// types
import { ISlashCommandItem, TEditorCommands, TExtensions, TSlashCommandSectionKeys } from "@/types";
import { IEditorProps, ISlashCommandItem, TEditorCommands, TSlashCommandSectionKeys } from "@/types";
// components
import { getSlashCommandFilteredSections } from "./command-items-list";
import { SlashCommandsMenu, SlashCommandsMenuProps } from "./command-menu";
@@ -106,9 +106,8 @@ const renderItems = () => {
};
};
export type TExtensionProps = {
export type TExtensionProps = Pick<IEditorProps, "disabledExtensions" | "flaggedExtensions"> & {
additionalOptions?: TSlashCommandAdditionalOption[];
disabledExtensions?: TExtensions[];
};
export const SlashCommands = (props: TExtensionProps) =>
@@ -0,0 +1,87 @@
import { type Editor } from "@tiptap/core";
import { Plugin, PluginKey } from "@tiptap/pm/state";
// local imports
import { createColumnInsertButton, createRowInsertButton, findAllTables, TableInfo } from "./utils";
const TABLE_INSERT_PLUGIN_KEY = new PluginKey("table-insert");
export const TableInsertPlugin = (editor: Editor): Plugin => {
const tableMap = new Map<HTMLElement, TableInfo>();
const setupTable = (tableInfo: TableInfo) => {
const { tableElement } = tableInfo;
// Create and add column button if it doesn't exist
if (!tableInfo.columnButtonElement) {
const columnButton = createColumnInsertButton(editor, tableInfo);
tableElement.appendChild(columnButton);
tableInfo.columnButtonElement = columnButton;
}
// Create and add row button if it doesn't exist
if (!tableInfo.rowButtonElement) {
const rowButton = createRowInsertButton(editor, tableInfo);
tableElement.appendChild(rowButton);
tableInfo.rowButtonElement = rowButton;
}
tableMap.set(tableElement, tableInfo);
};
const cleanupTable = (tableElement: HTMLElement) => {
const tableInfo = tableMap.get(tableElement);
tableInfo?.columnButtonElement?.remove();
tableInfo?.rowButtonElement?.remove();
tableMap.delete(tableElement);
};
const updateAllTables = () => {
if (!editor.isEditable) {
// Clean up all tables if editor is not editable
tableMap.forEach((_, tableElement) => {
cleanupTable(tableElement);
});
return;
}
const currentTables = findAllTables(editor);
const currentTableElements = new Set(currentTables.map((t) => t.tableElement));
// Remove buttons from tables that no longer exist
tableMap.forEach((_, tableElement) => {
if (!currentTableElements.has(tableElement)) {
cleanupTable(tableElement);
}
});
// Add buttons to new tables
currentTables.forEach((tableInfo) => {
if (!tableMap.has(tableInfo.tableElement)) {
setupTable(tableInfo);
}
});
};
return new Plugin({
key: TABLE_INSERT_PLUGIN_KEY,
view() {
setTimeout(updateAllTables, 0);
return {
update(view, prevState) {
// Update when document changes
if (!prevState.doc.eq(view.state.doc)) {
updateAllTables();
}
},
destroy() {
// Clean up all tables
tableMap.forEach((_, tableElement) => {
cleanupTable(tableElement);
});
tableMap.clear();
},
};
},
});
};
@@ -0,0 +1,430 @@
import type { Editor } from "@tiptap/core";
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
import { addColumn, removeColumn, addRow, removeRow, TableMap } from "@tiptap/pm/tables";
const addSvg = `<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M8.5 7.49988V3.49988C8.5 3.22374 8.27614 2.99988 8 2.99988C7.72386 2.99988 7.5 3.22374 7.5 3.49988L7.5 7.49988L3.5 7.49988C3.22386 7.49988 3 7.72374 3 7.99988C3 8.27602 3.22386 8.49988 3.5 8.49988H7.5L7.5 12.4999C7.5 12.776 7.72386 12.9999 8 12.9999C8.27614 12.9999 8.5 12.776 8.5 12.4999L8.5 8.49988L12.5 8.49988C12.7761 8.49988 13 8.27602 13 7.99988C13 7.72374 12.7761 7.49988 12.5 7.49988L8.5 7.49988Z"
fill="currentColor"
/>
</svg>`;
export type TableInfo = {
tableElement: HTMLElement;
tableNode: ProseMirrorNode;
tablePos: number;
columnButtonElement?: HTMLElement;
rowButtonElement?: HTMLElement;
};
export const createColumnInsertButton = (editor: Editor, tableInfo: TableInfo): HTMLElement => {
const button = document.createElement("button");
button.type = "button";
button.className = "table-column-insert-button";
button.title = "Insert columns";
button.ariaLabel = "Insert columns";
const icon = document.createElement("span");
icon.innerHTML = addSvg;
button.appendChild(icon);
let mouseDownX = 0;
let isDragging = false;
let dragStarted = false;
let lastActionX = 0;
const DRAG_THRESHOLD = 5; // pixels to start drag
const ACTION_THRESHOLD = 150; // pixels total distance to trigger action
const onMouseDown = (e: MouseEvent) => {
if (e.button !== 0) return; // Only left mouse button
e.preventDefault();
e.stopPropagation();
mouseDownX = e.clientX;
lastActionX = e.clientX;
isDragging = false;
dragStarted = false;
document.addEventListener("mousemove", onMouseMove);
document.addEventListener("mouseup", onMouseUp);
};
const onMouseMove = (e: MouseEvent) => {
const deltaX = e.clientX - mouseDownX;
const distance = Math.abs(deltaX);
// Start dragging if moved more than threshold
if (!isDragging && distance > DRAG_THRESHOLD) {
isDragging = true;
dragStarted = true;
// Visual feedback
button.classList.add("dragging");
document.body.style.userSelect = "none";
}
if (isDragging) {
const totalDistance = Math.abs(e.clientX - lastActionX);
// Only trigger action when total distance reaches threshold
if (totalDistance >= ACTION_THRESHOLD) {
// Determine direction based on current movement relative to last action point
const directionFromLastAction = e.clientX - lastActionX;
// Right direction - add columns
if (directionFromLastAction > 0) {
insertColumnAfterLast(editor, tableInfo);
lastActionX = e.clientX; // Reset action point
}
// Left direction - delete empty columns
else if (directionFromLastAction < 0) {
const deleted = removeLastColumn(editor, tableInfo);
if (deleted) {
lastActionX = e.clientX; // Reset action point
}
}
}
}
};
const onMouseUp = () => {
document.removeEventListener("mousemove", onMouseMove);
document.removeEventListener("mouseup", onMouseUp);
if (isDragging) {
// Clean up drag state
button.classList.remove("dragging");
document.body.style.cursor = "";
document.body.style.userSelect = "";
} else if (!dragStarted) {
// Handle as click if no dragging occurred
insertColumnAfterLast(editor, tableInfo);
}
isDragging = false;
dragStarted = false;
};
button.addEventListener("mousedown", onMouseDown);
// Prevent context menu and text selection
button.addEventListener("contextmenu", (e) => e.preventDefault());
button.addEventListener("selectstart", (e) => e.preventDefault());
return button;
};
export const createRowInsertButton = (editor: Editor, tableInfo: TableInfo): HTMLElement => {
const button = document.createElement("button");
button.type = "button";
button.className = "table-row-insert-button";
button.title = "Insert rows";
button.ariaLabel = "Insert rows";
const icon = document.createElement("span");
icon.innerHTML = addSvg;
button.appendChild(icon);
let mouseDownY = 0;
let isDragging = false;
let dragStarted = false;
let lastActionY = 0;
const DRAG_THRESHOLD = 5; // pixels to start drag
const ACTION_THRESHOLD = 40; // pixels total distance to trigger action
const onMouseDown = (e: MouseEvent) => {
if (e.button !== 0) return; // Only left mouse button
e.preventDefault();
e.stopPropagation();
mouseDownY = e.clientY;
lastActionY = e.clientY;
isDragging = false;
dragStarted = false;
document.addEventListener("mousemove", onMouseMove);
document.addEventListener("mouseup", onMouseUp);
};
const onMouseMove = (e: MouseEvent) => {
const deltaY = e.clientY - mouseDownY;
const distance = Math.abs(deltaY);
// Start dragging if moved more than threshold
if (!isDragging && distance > DRAG_THRESHOLD) {
isDragging = true;
dragStarted = true;
// Visual feedback
button.classList.add("dragging");
document.body.style.userSelect = "none";
}
if (isDragging) {
const totalDistance = Math.abs(e.clientY - lastActionY);
// Only trigger action when total distance reaches threshold
if (totalDistance >= ACTION_THRESHOLD) {
// Determine direction based on current movement relative to last action point
const directionFromLastAction = e.clientY - lastActionY;
// Down direction - add rows
if (directionFromLastAction > 0) {
insertRowAfterLast(editor, tableInfo);
lastActionY = e.clientY; // Reset action point
}
// Up direction - delete empty rows
else if (directionFromLastAction < 0) {
const deleted = removeLastRow(editor, tableInfo);
if (deleted) {
lastActionY = e.clientY; // Reset action point
}
}
}
}
};
const onMouseUp = () => {
document.removeEventListener("mousemove", onMouseMove);
document.removeEventListener("mouseup", onMouseUp);
if (isDragging) {
// Clean up drag state
button.classList.remove("dragging");
document.body.style.cursor = "";
document.body.style.userSelect = "";
} else if (!dragStarted) {
// Handle as click if no dragging occurred
insertRowAfterLast(editor, tableInfo);
}
isDragging = false;
dragStarted = false;
};
button.addEventListener("mousedown", onMouseDown);
// Prevent context menu and text selection
button.addEventListener("contextmenu", (e) => e.preventDefault());
button.addEventListener("selectstart", (e) => e.preventDefault());
return button;
};
export const findAllTables = (editor: Editor): TableInfo[] => {
const tables: TableInfo[] = [];
const tableElements = editor.view.dom.querySelectorAll("table");
tableElements.forEach((tableElement) => {
// Find the table's ProseMirror position
let tablePos = -1;
let tableNode: ProseMirrorNode | null = null;
// Walk through the document to find matching table nodes
editor.state.doc.descendants((node, pos) => {
if (node.type.spec.tableRole === "table") {
const domAtPos = editor.view.domAtPos(pos + 1);
let domTable = domAtPos.node;
// Navigate to find the table element
while (domTable && domTable.parentNode && domTable.nodeType !== Node.ELEMENT_NODE) {
domTable = domTable.parentNode;
}
while (domTable && domTable.parentNode && (domTable as HTMLElement).tagName !== "TABLE") {
domTable = domTable.parentNode;
}
if (domTable === tableElement) {
tablePos = pos;
tableNode = node;
return false; // Stop iteration
}
}
});
if (tablePos !== -1 && tableNode) {
tables.push({
tableElement,
tableNode,
tablePos,
});
}
});
return tables;
};
const getCurrentTableInfo = (editor: Editor, tableInfo: TableInfo): TableInfo => {
// Refresh table info to get latest state
const tables = findAllTables(editor);
const updated = tables.find((t) => t.tableElement === tableInfo.tableElement);
return updated || tableInfo;
};
// Column functions
const insertColumnAfterLast = (editor: Editor, tableInfo: TableInfo) => {
const currentTableInfo = getCurrentTableInfo(editor, tableInfo);
const { tableNode, tablePos } = currentTableInfo;
const tableMapData = TableMap.get(tableNode);
const lastColumnIndex = tableMapData.width;
const tr = editor.state.tr;
const rect = {
map: tableMapData,
tableStart: tablePos,
table: tableNode,
top: 0,
left: 0,
bottom: tableMapData.height - 1,
right: tableMapData.width - 1,
};
const newTr = addColumn(tr, rect, lastColumnIndex);
editor.view.dispatch(newTr);
};
const removeLastColumn = (editor: Editor, tableInfo: TableInfo): boolean => {
const currentTableInfo = getCurrentTableInfo(editor, tableInfo);
const { tableNode, tablePos } = currentTableInfo;
const tableMapData = TableMap.get(tableNode);
// Don't delete if only one column left
if (tableMapData.width <= 1) {
return false;
}
const lastColumnIndex = tableMapData.width - 1;
// Check if last column is empty
if (!isColumnEmpty(currentTableInfo, lastColumnIndex)) {
return false;
}
const tr = editor.state.tr;
const rect = {
map: tableMapData,
tableStart: tablePos,
table: tableNode,
top: 0,
left: 0,
bottom: tableMapData.height - 1,
right: tableMapData.width - 1,
};
removeColumn(tr, rect, lastColumnIndex);
editor.view.dispatch(tr);
return true;
};
// Helper function to check if a single cell is empty
const isCellEmpty = (cell: ProseMirrorNode | null | undefined): boolean => {
if (!cell || cell.content.size === 0) {
return true;
}
// Check if cell has any non-empty content
let hasContent = false;
cell.content.forEach((node) => {
if (node.type.name === "paragraph") {
if (node.content.size > 0) {
hasContent = true;
}
} else if (node.content.size > 0 || node.isText) {
hasContent = true;
}
});
return !hasContent;
};
const isColumnEmpty = (tableInfo: TableInfo, columnIndex: number): boolean => {
const { tableNode } = tableInfo;
const tableMapData = TableMap.get(tableNode);
// Check each cell in the column
for (let row = 0; row < tableMapData.height; row++) {
const cellIndex = row * tableMapData.width + columnIndex;
const cellPos = tableMapData.map[cellIndex];
const cell = tableNode.nodeAt(cellPos);
if (!isCellEmpty(cell)) {
return false;
}
}
return true;
};
// Row functions
const insertRowAfterLast = (editor: Editor, tableInfo: TableInfo) => {
const currentTableInfo = getCurrentTableInfo(editor, tableInfo);
const { tableNode, tablePos } = currentTableInfo;
const tableMapData = TableMap.get(tableNode);
const lastRowIndex = tableMapData.height;
const tr = editor.state.tr;
const rect = {
map: tableMapData,
tableStart: tablePos,
table: tableNode,
top: 0,
left: 0,
bottom: tableMapData.height - 1,
right: tableMapData.width - 1,
};
const newTr = addRow(tr, rect, lastRowIndex);
editor.view.dispatch(newTr);
};
const removeLastRow = (editor: Editor, tableInfo: TableInfo): boolean => {
const currentTableInfo = getCurrentTableInfo(editor, tableInfo);
const { tableNode, tablePos } = currentTableInfo;
const tableMapData = TableMap.get(tableNode);
// Don't delete if only one row left
if (tableMapData.height <= 1) {
return false;
}
const lastRowIndex = tableMapData.height - 1;
// Check if last row is empty
if (!isRowEmpty(currentTableInfo, lastRowIndex)) {
return false;
}
const tr = editor.state.tr;
const rect = {
map: tableMapData,
tableStart: tablePos,
table: tableNode,
top: 0,
left: 0,
bottom: tableMapData.height - 1,
right: tableMapData.width - 1,
};
removeRow(tr, rect, lastRowIndex);
editor.view.dispatch(tr);
return true;
};
const isRowEmpty = (tableInfo: TableInfo, rowIndex: number): boolean => {
const { tableNode } = tableInfo;
const tableMapData = TableMap.get(tableNode);
// Check each cell in the row
for (let col = 0; col < tableMapData.width; col++) {
const cellIndex = rowIndex * tableMapData.width + col;
const cellPos = tableMapData.map[cellIndex];
const cell = tableNode.nodeAt(cellPos);
if (!isCellEmpty(cell)) {
return false;
}
}
return true;
};
@@ -0,0 +1,58 @@
import { findParentNode, type Editor } from "@tiptap/core";
import { Plugin, PluginKey } from "@tiptap/pm/state";
import { CellSelection, TableMap } from "@tiptap/pm/tables";
import { Decoration, DecorationSet } from "@tiptap/pm/view";
// local imports
import { getCellBorderClasses } from "./utils";
type TableCellSelectionOutlinePluginState = {
decorations?: DecorationSet;
};
const TABLE_SELECTION_OUTLINE_PLUGIN_KEY = new PluginKey("table-cell-selection-outline");
export const TableCellSelectionOutlinePlugin = (editor: Editor): Plugin<TableCellSelectionOutlinePluginState> =>
new Plugin<TableCellSelectionOutlinePluginState>({
key: TABLE_SELECTION_OUTLINE_PLUGIN_KEY,
state: {
init: () => ({}),
apply(tr, prev, oldState, newState) {
if (!editor.isEditable) return {};
const table = findParentNode((node) => node.type.spec.tableRole === "table")(newState.selection);
const hasDocChanged = tr.docChanged || !newState.selection.eq(oldState.selection);
if (!table || !hasDocChanged) {
return table === undefined ? {} : prev;
}
const { selection } = newState;
if (!(selection instanceof CellSelection)) return {};
const decorations: Decoration[] = [];
const tableMap = TableMap.get(table.node);
const selectedCells: number[] = [];
// First, collect all selected cell positions
selection.forEachCell((_node, pos) => {
const start = pos - table.pos - 1;
selectedCells.push(start);
});
// Then, add decorations with appropriate border classes
selection.forEachCell((node, pos) => {
const start = pos - table.pos - 1;
const classes = getCellBorderClasses(start, selectedCells, tableMap);
decorations.push(Decoration.node(pos, pos + node.nodeSize, { class: classes.join(" ") }));
});
return {
decorations: DecorationSet.create(newState.doc, decorations),
};
},
},
props: {
decorations(state) {
return TABLE_SELECTION_OUTLINE_PLUGIN_KEY.getState(state).decorations;
},
},
});
@@ -0,0 +1,75 @@
import type { TableMap } from "@tiptap/pm/tables";
/**
* Calculates the positions of cells adjacent to a given cell in a table
* @param cellStart - The start position of the current cell in the document
* @param tableMap - ProseMirror's table mapping structure containing cell positions and dimensions
* @returns Object with positions of adjacent cells (undefined if cell doesn't exist at table edge)
*/
const getAdjacentCellPositions = (
cellStart: number,
tableMap: TableMap
): { top?: number; bottom?: number; left?: number; right?: number } => {
// Extract table dimensions
// width -> number of columns in the table
// height -> number of rows in the table
const { width, height } = tableMap;
// Find the index of our cell in the flat tableMap.map array
// tableMap.map contains start positions of all cells in row-by-row order
const cellIndex = tableMap.map.indexOf(cellStart);
// Safety check: if cell position not found in table map, return empty object
if (cellIndex === -1) return {};
// Convert flat array index to 2D grid coordinates
// row = which row the cell is in (0-based from top)
// col = which column the cell is in (0-based from left)
const row = Math.floor(cellIndex / width); // Integer division gives row number
const col = cellIndex % width; // Remainder gives column number
return {
// Top cell: same column, one row up
// Check if we're not in the first row (row > 0) before calculating
top: row > 0 ? tableMap.map[(row - 1) * width + col] : undefined,
// Bottom cell: same column, one row down
// Check if we're not in the last row (row < height - 1) before calculating
bottom: row < height - 1 ? tableMap.map[(row + 1) * width + col] : undefined,
// Left cell: same row, one column left
// Check if we're not in the first column (col > 0) before calculating
left: col > 0 ? tableMap.map[row * width + (col - 1)] : undefined,
// Right cell: same row, one column right
// Check if we're not in the last column (col < width - 1) before calculating
right: col < width - 1 ? tableMap.map[row * width + (col + 1)] : undefined,
};
};
export const getCellBorderClasses = (cellStart: number, selectedCells: number[], tableMap: TableMap): string[] => {
const adjacent = getAdjacentCellPositions(cellStart, tableMap);
const classes: string[] = [];
// Add border-right if right cell is not selected or doesn't exist
if (adjacent.right === undefined || !selectedCells.includes(adjacent.right)) {
classes.push("selectedCell-border-right");
}
// Add border-left if left cell is not selected or doesn't exist
if (adjacent.left === undefined || !selectedCells.includes(adjacent.left)) {
classes.push("selectedCell-border-left");
}
// Add border-top if top cell is not selected or doesn't exist
if (adjacent.top === undefined || !selectedCells.includes(adjacent.top)) {
classes.push("selectedCell-border-top");
}
// Add border-bottom if bottom cell is not selected or doesn't exist
if (adjacent.bottom === undefined || !selectedCells.includes(adjacent.bottom)) {
classes.push("selectedCell-border-bottom");
}
return classes;
};
@@ -1,6 +1,10 @@
import { mergeAttributes, Node } from "@tiptap/core";
// constants
import { CORE_EXTENSIONS } from "@/constants/extension";
// local imports
import { TableCellSelectionOutlinePlugin } from "./plugins/selection-outline/plugin";
import { DEFAULT_COLUMN_WIDTH } from "./table";
export interface TableCellOptions {
HTMLAttributes: Record<string, any>;
}
@@ -25,7 +29,7 @@ export const TableCell = Node.create<TableCellOptions>({
default: 1,
},
colwidth: {
default: null,
default: [DEFAULT_COLUMN_WIDTH],
parseHTML: (element) => {
const colwidth = element.getAttribute("colwidth");
const value = colwidth ? [parseInt(colwidth, 10)] : null;
@@ -46,6 +50,10 @@ export const TableCell = Node.create<TableCellOptions>({
isolating: true,
addProseMirrorPlugins() {
return [TableCellSelectionOutlinePlugin(this.editor)];
},
parseHTML() {
return [{ tag: "td" }];
},
@@ -1,6 +1,9 @@
import { mergeAttributes, Node } from "@tiptap/core";
// constants
import { CORE_EXTENSIONS } from "@/constants/extension";
// local imports
import { DEFAULT_COLUMN_WIDTH } from "./table";
export interface TableHeaderOptions {
HTMLAttributes: Record<string, any>;
}
@@ -25,7 +28,7 @@ export const TableHeader = Node.create<TableHeaderOptions>({
default: 1,
},
colwidth: {
default: null,
default: [DEFAULT_COLUMN_WIDTH],
parseHTML: (element) => {
const colwidth = element.getAttribute("colwidth");
const value = colwidth ? [parseInt(colwidth, 10)] : null;
@@ -1 +1,3 @@
export { Table } from "./table";
export const DEFAULT_COLUMN_WIDTH = 150;
@@ -387,7 +387,7 @@ export class TableView implements NodeView {
this.root = h(
"div",
{
className: "table-wrapper horizontal-scrollbar scrollbar-md controls--disabled",
className: "table-wrapper editor-full-width-block horizontal-scrollbar scrollbar-sm controls--disabled",
},
this.controls,
this.table
@@ -23,12 +23,14 @@ import { Decoration } from "@tiptap/pm/view";
// constants
import { CORE_EXTENSIONS } from "@/constants/extension";
// local imports
import { TableInsertPlugin } from "../plugins/insert-handlers/plugin";
import { tableControls } from "./table-controls";
import { TableView } from "./table-view";
import { createTable } from "./utilities/create-table";
import { deleteTableWhenAllCellsSelected } from "./utilities/delete-table-when-all-cells-selected";
import { insertLineAboveTableAction } from "./utilities/insert-line-above-table-action";
import { insertLineBelowTableAction } from "./utilities/insert-line-below-table-action";
import { DEFAULT_COLUMN_WIDTH } from ".";
export interface TableOptions {
HTMLAttributes: Record<string, any>;
@@ -42,12 +44,7 @@ export interface TableOptions {
declare module "@tiptap/core" {
interface Commands<ReturnType> {
[CORE_EXTENSIONS.TABLE]: {
insertTable: (options?: {
rows?: number;
cols?: number;
withHeaderRow?: boolean;
columnWidth?: number;
}) => ReturnType;
insertTable: (options?: { rows?: number; cols?: number; withHeaderRow?: boolean }) => ReturnType;
addColumnBefore: () => ReturnType;
addColumnAfter: () => ReturnType;
deleteColumn: () => ReturnType;
@@ -81,7 +78,7 @@ declare module "@tiptap/core" {
}
}
export const Table = Node.create({
export const Table = Node.create<TableOptions>({
name: CORE_EXTENSIONS.TABLE,
addOptions() {
@@ -116,9 +113,15 @@ export const Table = Node.create({
addCommands() {
return {
insertTable:
({ rows = 3, cols = 3, withHeaderRow = false, columnWidth = 150 } = {}) =>
({ rows = 3, cols = 3, withHeaderRow = false } = {}) =>
({ tr, dispatch, editor }) => {
const node = createTable(editor.schema, rows, cols, withHeaderRow, undefined, columnWidth);
const node = createTable({
schema: editor.schema,
rowsCount: rows,
colsCount: cols,
withHeaderRow,
columnWidth: DEFAULT_COLUMN_WIDTH,
});
if (dispatch) {
const offset = tr.selection.anchor + 1;
@@ -264,6 +267,7 @@ export const Table = Node.create({
allowTableNodeSelection: this.options.allowTableNodeSelection,
}),
tableControls(),
TableInsertPlugin(this.editor),
];
if (isResizable) {
@@ -3,14 +3,18 @@ import { Fragment, Node as ProsemirrorNode, Schema } from "@tiptap/pm/model";
import { createCell } from "@/extensions/table/table/utilities/create-cell";
import { getTableNodeTypes } from "@/extensions/table/table/utilities/get-table-node-types";
export function createTable(
schema: Schema,
rowsCount: number,
colsCount: number,
withHeaderRow: boolean,
cellContent?: Fragment | ProsemirrorNode | Array<ProsemirrorNode>,
columnWidth: number = 100
): ProsemirrorNode {
type Props = {
schema: Schema;
rowsCount: number;
colsCount: number;
withHeaderRow: boolean;
cellContent?: Fragment | ProsemirrorNode | Array<ProsemirrorNode>;
columnWidth: number;
};
export const createTable = (props: Props): ProsemirrorNode => {
const { schema, rowsCount, colsCount, withHeaderRow, cellContent, columnWidth } = props;
const types = getTableNodeTypes(schema);
const headerCells: ProsemirrorNode[] = [];
const cells: ProsemirrorNode[] = [];
@@ -38,4 +42,4 @@ export function createTable(
}
return types.table.createChecked(null, rows);
}
};
@@ -8,7 +8,7 @@ import { DropHandlerPlugin } from "@/plugins/drop";
import { FilePlugins } from "@/plugins/file/root";
import { MarkdownClipboardPlugin } from "@/plugins/markdown-clipboard";
// types
import { TExtensions, TFileHandler, TReadOnlyFileHandler } from "@/types";
import type { IEditorProps, TFileHandler, TReadOnlyFileHandler } from "@/types";
declare module "@tiptap/core" {
interface Commands {
@@ -23,8 +23,7 @@ export interface UtilityExtensionStorage {
uploadInProgress: boolean;
}
type Props = {
disabledExtensions: TExtensions[];
type Props = Pick<IEditorProps, "disabledExtensions"> & {
fileHandler: TFileHandler | TReadOnlyFileHandler;
isEditable: boolean;
};
@@ -2,8 +2,8 @@ import { Editor, Range } from "@tiptap/core";
// constants
import { CORE_EXTENSIONS } from "@/constants/extension";
// extensions
import { InsertImageComponentProps } from "@/extensions";
import { replaceCodeWithText } from "@/extensions/code/utils/replace-code-block-with-text";
import type { InsertImageComponentProps } from "@/extensions/custom-image/types";
// helpers
import { findTableAncestor } from "@/helpers/common";
@@ -109,9 +109,8 @@ export const insertTableCommand = (editor: Editor, range?: Range) => {
}
}
}
if (range)
editor.chain().focus().deleteRange(range).clearNodes().insertTable({ rows: 3, cols: 3, columnWidth: 150 }).run();
else editor.chain().focus().clearNodes().insertTable({ rows: 3, cols: 3, columnWidth: 150 }).run();
if (range) editor.chain().focus().deleteRange(range).clearNodes().insertTable({ rows: 3, cols: 3 }).run();
else editor.chain().focus().clearNodes().insertTable({ rows: 3, cols: 3 }).run();
};
export const insertImage = ({
+28 -22
View File
@@ -1,40 +1,42 @@
// plane imports
import { TDocumentPayload, TDuplicateAssetData, TDuplicateAssetResponse } from "@plane/types";
import { TEditorAssetType } from "@plane/types/src/enums";
// plane web imports
import {
extractAdditionalAssetsFromHTMLContent,
replaceAdditionalAssetsInHTMLContent,
} from "@/plane-editor/helpers/parser";
// local imports
import { convertHTMLDocumentToAllFormats } from "./yjs-utils";
/**
* @description function to extract all image assets from HTML content
* @description function to extract all assets from HTML content
* @param htmlContent
* @returns {string[]} array of image asset sources
* @returns {string[]} array of asset sources
*/
export const extractImageAssetsFromHTMLContent = (htmlContent: string): string[] => {
const extractAssetsFromHTMLContent = (htmlContent: string): string[] => {
// create a DOM parser
const parser = new DOMParser();
// parse the HTML string into a DOM document
const doc = parser.parseFromString(htmlContent, "text/html");
// get all image components
const imageComponents = doc.querySelectorAll("image-component");
// collect all unique image sources
const imageSources = new Set<string>();
// collect all unique asset sources
const assetSources = new Set<string>();
// extract sources from image components
const imageComponents = doc.querySelectorAll("image-component");
imageComponents.forEach((component) => {
const src = component.getAttribute("src");
if (src) imageSources.add(src);
if (src) assetSources.add(src);
});
return Array.from(imageSources);
const additionalAssetIds = extractAdditionalAssetsFromHTMLContent(htmlContent);
return [...Array.from(assetSources), ...additionalAssetIds];
};
/**
* @description function to replace image assets in HTML content with new IDs
* @description function to replace assets in HTML content with new IDs
* @param props
* @returns {string} HTML content with replaced image assets
* @returns {string} HTML content with replaced assets
*/
export const replaceImageAssetsInHTMLContent = (props: {
htmlContent: string;
assetMap: Record<string, string>;
}): string => {
const replaceAssetsInHTMLContent = (props: { htmlContent: string; assetMap: Record<string, string> }): string => {
const { htmlContent, assetMap } = props;
// create a DOM parser
const parser = new DOMParser();
@@ -48,11 +50,15 @@ export const replaceImageAssetsInHTMLContent = (props: {
component.setAttribute("src", assetMap[oldSrc]);
}
});
// serialize the document back into a string
return doc.body.innerHTML;
// replace additional sources
const replacedHTMLContent = replaceAdditionalAssetsInHTMLContent({
htmlContent: doc.body.innerHTML,
assetMap,
});
return replacedHTMLContent;
};
export const getEditorContentWithReplacedImageAssets = async (props: {
export const getEditorContentWithReplacedAssets = async (props: {
descriptionHTML: string;
entityId: string;
entityType: TEditorAssetType;
@@ -63,18 +69,18 @@ export const getEditorContentWithReplacedImageAssets = async (props: {
const { descriptionHTML, entityId, entityType, projectId, variant, duplicateAssetService } = props;
let replacedDescription = descriptionHTML;
// step 1: extract image assets from the description
const imageAssets = extractImageAssetsFromHTMLContent(descriptionHTML);
if (imageAssets.length !== 0) {
const assetIds = extractAssetsFromHTMLContent(descriptionHTML);
if (assetIds.length !== 0) {
// step 2: duplicate the image assets
const duplicateAssetsResponse = await duplicateAssetService({
entity_id: entityId,
entity_type: entityType,
project_id: projectId,
asset_ids: imageAssets,
asset_ids: assetIds,
});
if (Object.keys(duplicateAssetsResponse ?? {}).length > 0) {
// step 3: replace the image assets in the description
replacedDescription = replaceImageAssetsInHTMLContent({
replacedDescription = replaceAssetsInHTMLContent({
htmlContent: descriptionHTML,
assetMap: duplicateAssetsResponse,
});
@@ -9,18 +9,20 @@ import { useEditor } from "@/hooks/use-editor";
// plane editor extensions
import { DocumentEditorAdditionalExtensions } from "@/plane-editor/extensions";
// types
import { TCollaborativeEditorProps } from "@/types";
import { TCollaborativeEditorHookProps } from "@/types";
export const useCollaborativeEditor = (props: TCollaborativeEditorProps) => {
export const useCollaborativeEditor = (props: TCollaborativeEditorHookProps) => {
const {
onChange,
onTransaction,
disabledExtensions,
editable,
editorClassName,
editorClassName = "",
editorProps = {},
embedHandler,
extensions,
extensions = [],
fileHandler,
flaggedExtensions,
forwardedRef,
handleEditorReady,
id,
@@ -89,19 +91,22 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorProps) => {
Collaboration.configure({
document: provider.document,
}),
...(extensions ?? []),
...extensions,
...DocumentEditorAdditionalExtensions({
disabledExtensions,
embedConfig: embedHandler,
fileHandler,
flaggedExtensions,
provider,
userDetails: user,
}),
],
fileHandler,
flaggedExtensions,
forwardedRef,
handleEditorReady,
mentionHandler,
onChange,
onTransaction,
placeholder,
provider,
+10 -41
View File
@@ -1,13 +1,12 @@
import { HocuspocusProvider } from "@hocuspocus/provider";
import { DOMSerializer } from "@tiptap/pm/model";
import { EditorProps } from "@tiptap/pm/view";
import { useEditor as useTiptapEditor, Extensions } from "@tiptap/react";
import { useImperativeHandle, MutableRefObject, useEffect } from "react";
import { useEditor as useTiptapEditor } from "@tiptap/react";
import { useImperativeHandle, useEffect } from "react";
import * as Y from "yjs";
// components
import { getEditorMenuItems } from "@/components/menus";
// constants
import { CORE_EXTENSIONS } from "@/constants/extension";
import { CORE_EDITOR_META } from "@/constants/meta";
// extensions
import { CoreEditorExtensions } from "@/extensions";
// helpers
@@ -18,49 +17,19 @@ import { IMarking, scrollSummary, scrollToNodeViaDOMCoordinates } from "@/helper
// props
import { CoreEditorProps } from "@/props";
// types
import type {
TDocumentEventsServer,
EditorRefApi,
TEditorCommands,
TFileHandler,
TExtensions,
TMentionHandler,
} from "@/types";
import { CORE_EDITOR_META } from "@/constants/meta";
import type { TDocumentEventsServer, TEditorCommands, TEditorHookProps } from "@/types";
export interface CustomEditorProps {
editable: boolean;
editorClassName: string;
editorProps?: EditorProps;
enableHistory: boolean;
disabledExtensions: TExtensions[];
extensions?: Extensions;
fileHandler: TFileHandler;
forwardedRef?: MutableRefObject<EditorRefApi | null>;
handleEditorReady?: (value: boolean) => void;
id?: string;
initialValue?: string;
mentionHandler: TMentionHandler;
onChange?: (json: object, html: string) => void;
onTransaction?: () => void;
autofocus?: boolean;
placeholder?: string | ((isFocused: boolean, value: string) => string);
provider?: HocuspocusProvider;
tabIndex?: number;
// undefined when prop is not passed, null if intentionally passed to stop
// swr syncing
value?: string | null | undefined;
}
export const useEditor = (props: CustomEditorProps) => {
export const useEditor = (props: TEditorHookProps) => {
const {
autofocus = false,
disabledExtensions,
editable = true,
editorClassName,
editorClassName = "",
editorProps = {},
enableHistory,
extensions = [],
fileHandler,
flaggedExtensions,
forwardedRef,
handleEditorReady,
id = "",
@@ -69,10 +38,9 @@ export const useEditor = (props: CustomEditorProps) => {
onChange,
onTransaction,
placeholder,
provider,
tabIndex,
value,
provider,
autofocus = false,
} = props;
const editor = useTiptapEditor(
@@ -94,6 +62,7 @@ export const useEditor = (props: CustomEditorProps) => {
disabledExtensions,
enableHistory,
fileHandler,
flaggedExtensions,
mentionHandler,
placeholder,
tabIndex,
@@ -1,8 +1,8 @@
import { HocuspocusProvider } from "@hocuspocus/provider";
import { EditorProps } from "@tiptap/pm/view";
import { useEditor as useTiptapEditor, Extensions } from "@tiptap/react";
import { useImperativeHandle, MutableRefObject, useEffect } from "react";
import { useEditor as useTiptapEditor } from "@tiptap/react";
import { useImperativeHandle, useEffect } from "react";
import * as Y from "yjs";
// constants
import { CORE_EDITOR_META } from "@/constants/meta";
// extensions
import { CoreReadOnlyEditorExtensions } from "@/extensions";
// helpers
@@ -11,32 +11,19 @@ import { IMarking, scrollSummary } from "@/helpers/scroll-to-node";
// props
import { CoreReadOnlyEditorProps } from "@/props";
// types
import type { EditorReadOnlyRefApi, TExtensions, TReadOnlyFileHandler, TReadOnlyMentionHandler } from "@/types";
import { CORE_EDITOR_META } from "@/constants/meta";
import type { TReadOnlyEditorHookProps } from "@/types";
interface CustomReadOnlyEditorProps {
disabledExtensions: TExtensions[];
editorClassName: string;
editorProps?: EditorProps;
extensions?: Extensions;
forwardedRef?: MutableRefObject<EditorReadOnlyRefApi | null>;
initialValue?: string;
fileHandler: TReadOnlyFileHandler;
handleEditorReady?: (value: boolean) => void;
mentionHandler: TReadOnlyMentionHandler;
provider?: HocuspocusProvider;
}
export const useReadOnlyEditor = (props: CustomReadOnlyEditorProps) => {
export const useReadOnlyEditor = (props: TReadOnlyEditorHookProps) => {
const {
disabledExtensions,
initialValue,
editorClassName,
forwardedRef,
extensions = [],
editorClassName = "",
editorProps = {},
extensions = [],
fileHandler,
flaggedExtensions,
forwardedRef,
handleEditorReady,
initialValue,
mentionHandler,
provider,
} = props;
@@ -59,8 +46,9 @@ export const useReadOnlyEditor = (props: CustomReadOnlyEditorProps) => {
extensions: [
...CoreReadOnlyEditorExtensions({
disabledExtensions,
mentionHandler,
fileHandler,
flaggedExtensions,
mentionHandler,
}),
...extensions,
],
@@ -16,7 +16,7 @@ const generalSelectors = [
"blockquote",
"h1.editor-heading-block, h2.editor-heading-block, h3.editor-heading-block, h4.editor-heading-block, h5.editor-heading-block, h6.editor-heading-block",
"[data-type=horizontalRule]",
".table-wrapper",
"table",
".issue-embed",
".image-component",
".image-upload-component",
@@ -90,7 +90,7 @@ export const nodeDOMAtCoords = (coords: { x: number; y: number }) => {
for (const elem of elements) {
// Check for table wrapper first
if (elem.matches(".table-wrapper")) {
if (elem.matches("table")) {
return elem;
}
@@ -99,7 +99,7 @@ export const nodeDOMAtCoords = (coords: { x: number; y: number }) => {
}
// Skip table cells
if (elem.closest(".table-wrapper")) {
if (elem.closest("table")) {
continue;
}
@@ -1,50 +1,4 @@
import { Extensions } from "@tiptap/core";
import { EditorProps } from "@tiptap/pm/view";
// plane editor types
import { TEmbedConfig } from "@/plane-editor/types";
// types
import {
EditorReadOnlyRefApi,
EditorRefApi,
TExtensions,
TFileHandler,
TMentionHandler,
TReadOnlyFileHandler,
TReadOnlyMentionHandler,
TRealtimeConfig,
TUserDetails,
} from "@/types";
export type TServerHandler = {
onConnect?: () => void;
onServerError?: () => void;
};
type TCollaborativeEditorHookProps = {
disabledExtensions: TExtensions[];
editable: boolean;
editorClassName: string;
editorProps?: EditorProps;
extensions?: Extensions;
handleEditorReady?: (value: boolean) => void;
id: string;
realtimeConfig: TRealtimeConfig;
serverHandler?: TServerHandler;
user: TUserDetails;
};
export type TCollaborativeEditorProps = TCollaborativeEditorHookProps & {
onTransaction?: () => void;
embedHandler?: TEmbedConfig;
fileHandler: TFileHandler;
forwardedRef?: React.MutableRefObject<EditorRefApi | null>;
mentionHandler: TMentionHandler;
placeholder?: string | ((isFocused: boolean, value: string) => string);
tabIndex?: number;
};
export type TReadOnlyCollaborativeEditorProps = TCollaborativeEditorHookProps & {
fileHandler: TReadOnlyFileHandler;
forwardedRef?: React.MutableRefObject<EditorReadOnlyRefApi | null>;
mentionHandler: TReadOnlyMentionHandler;
};
+15
View File
@@ -1,3 +1,6 @@
// plane imports
import { TWebhookConnectionQueryParams } from "@plane/types";
export type TReadOnlyFileHandler = {
checkIfAssetExists: (assetId: string) => Promise<boolean>;
getAssetSrc: (path: string) => Promise<string>;
@@ -30,3 +33,15 @@ export type TDisplayConfig = {
lineSpacing?: TEditorLineSpacing;
wideLayout?: boolean;
};
export type TUserDetails = {
color: string;
id: string;
name: string;
cookie?: string;
};
export type TRealtimeConfig = {
url: string;
queryParams: TWebhookConnectionQueryParams;
};
+34 -54
View File
@@ -1,13 +1,11 @@
import { Extensions, JSONContent } from "@tiptap/core";
import { Selection } from "@tiptap/pm/state";
// plane types
import { TWebhookConnectionQueryParams } from "@plane/types";
import type { Extensions, JSONContent } from "@tiptap/core";
import type { Selection } from "@tiptap/pm/state";
// extension types
import { TTextAlign } from "@/extensions";
import type { TTextAlign } from "@/extensions";
// helpers
import { IMarking } from "@/helpers/scroll-to-node";
import type { IMarking } from "@/helpers/scroll-to-node";
// types
import {
import type {
TAIHandler,
TDisplayConfig,
TDocumentEventEmitter,
@@ -18,7 +16,9 @@ import {
TMentionHandler,
TReadOnlyFileHandler,
TReadOnlyMentionHandler,
TRealtimeConfig,
TServerHandler,
TUserDetails,
} from "@/types";
export type TEditorCommands =
@@ -114,90 +114,70 @@ export interface EditorRefApi extends EditorReadOnlyRefApi {
// editor props
export interface IEditorProps {
autofocus?: boolean;
bubbleMenuEnabled?: boolean;
containerClassName?: string;
displayConfig?: TDisplayConfig;
disabledExtensions: TExtensions[];
editorClassName?: string;
extensions?: Extensions;
flaggedExtensions: TExtensions[];
fileHandler: TFileHandler;
forwardedRef?: React.MutableRefObject<EditorRefApi | null>;
handleEditorReady?: (value: boolean) => void;
id: string;
initialValue: string;
mentionHandler: TMentionHandler;
onChange?: (json: object, html: string) => void;
onTransaction?: () => void;
handleEditorReady?: (value: boolean) => void;
autofocus?: boolean;
onEnterKeyPress?: (e?: any) => void;
onTransaction?: () => void;
placeholder?: string | ((isFocused: boolean, value: string) => string);
tabIndex?: number;
value?: string | null;
bubbleMenuEnabled?: boolean;
}
export interface ILiteTextEditor extends IEditorProps {
extensions?: Extensions;
}
export interface IRichTextEditor extends IEditorProps {
extensions?: Extensions;
export type ILiteTextEditorProps = IEditorProps;
export interface IRichTextEditorProps extends IEditorProps {
dragDropEnabled?: boolean;
}
export interface ICollaborativeDocumentEditor
extends Omit<IEditorProps, "initialValue" | "onChange" | "onEnterKeyPress" | "value"> {
export interface ICollaborativeDocumentEditorProps
extends Omit<IEditorProps, "extensions" | "initialValue" | "onEnterKeyPress" | "value"> {
aiHandler?: TAIHandler;
bubbleMenuEnabled?: boolean;
editable: boolean;
embedHandler: TEmbedConfig;
handleEditorReady?: (value: boolean) => void;
id: string;
realtimeConfig: TRealtimeConfig;
serverHandler?: TServerHandler;
user: TUserDetails;
}
// read only editor props
export interface IReadOnlyEditorProps {
containerClassName?: string;
disabledExtensions: TExtensions[];
displayConfig?: TDisplayConfig;
editorClassName?: string;
extensions?: Extensions;
export interface IReadOnlyEditorProps
extends Pick<
IEditorProps,
| "containerClassName"
| "disabledExtensions"
| "flaggedExtensions"
| "displayConfig"
| "editorClassName"
| "extensions"
| "handleEditorReady"
| "id"
| "initialValue"
> {
fileHandler: TReadOnlyFileHandler;
forwardedRef?: React.MutableRefObject<EditorReadOnlyRefApi | null>;
id: string;
initialValue: string;
mentionHandler: TReadOnlyMentionHandler;
}
export type ILiteTextReadOnlyEditor = IReadOnlyEditorProps;
export type ILiteTextReadOnlyEditorProps = IReadOnlyEditorProps;
export type IRichTextReadOnlyEditor = IReadOnlyEditorProps;
export type IRichTextReadOnlyEditorProps = IReadOnlyEditorProps;
export interface ICollaborativeDocumentReadOnlyEditor extends Omit<IReadOnlyEditorProps, "initialValue"> {
export interface IDocumentReadOnlyEditorProps extends IReadOnlyEditorProps {
embedHandler: TEmbedConfig;
handleEditorReady?: (value: boolean) => void;
id: string;
realtimeConfig: TRealtimeConfig;
serverHandler?: TServerHandler;
user: TUserDetails;
}
export interface IDocumentReadOnlyEditor extends IReadOnlyEditorProps {
embedHandler: TEmbedConfig;
handleEditorReady?: (value: boolean) => void;
}
export type TUserDetails = {
color: string;
id: string;
name: string;
cookie?: string;
};
export type TRealtimeConfig = {
url: string;
queryParams: TWebhookConnectionQueryParams;
};
export interface EditorEvents {
beforeCreate: never;
create: never;
+50
View File
@@ -0,0 +1,50 @@
import type { HocuspocusProvider } from "@hocuspocus/provider";
import type { EditorProps } from "@tiptap/pm/view";
// local imports
import type { ICollaborativeDocumentEditorProps, IEditorProps, IReadOnlyEditorProps } from "./editor";
type TCoreHookProps = Pick<
IEditorProps,
"disabledExtensions" | "editorClassName" | "extensions" | "flaggedExtensions" | "handleEditorReady"
> & {
editorProps?: EditorProps;
};
export type TEditorHookProps = TCoreHookProps &
Pick<
IEditorProps,
| "autofocus"
| "fileHandler"
| "forwardedRef"
| "id"
| "mentionHandler"
| "onChange"
| "onTransaction"
| "placeholder"
| "tabIndex"
| "value"
> & {
editable: boolean;
enableHistory: boolean;
initialValue?: string;
provider?: HocuspocusProvider;
};
export type TCollaborativeEditorHookProps = TCoreHookProps &
Pick<
TEditorHookProps,
| "editable"
| "fileHandler"
| "forwardedRef"
| "id"
| "mentionHandler"
| "onChange"
| "onTransaction"
| "placeholder"
| "tabIndex"
> &
Pick<ICollaborativeDocumentEditorProps, "embedHandler" | "realtimeConfig" | "serverHandler" | "user">;
export type TReadOnlyEditorHookProps = TCoreHookProps &
Pick<TEditorHookProps, "initialValue" | "provider"> &
Pick<IReadOnlyEditorProps, "fileHandler" | "forwardedRef" | "mentionHandler">;
+1
View File
@@ -4,6 +4,7 @@ export * from "./config";
export * from "./editor";
export * from "./embed";
export * from "./extensions";
export * from "./hook";
export * from "./mention";
export * from "./slash-commands-suggestion";
export * from "@/plane-editor/types";
-1
View File
@@ -36,5 +36,4 @@ export { type IMarking, useEditorMarkings } from "@/hooks/use-editor-markings";
export { useReadOnlyEditor } from "@/hooks/use-read-only-editor";
// types
export type { CustomEditorProps } from "@/hooks/use-editor";
export * from "@/types";
+3 -2
View File
@@ -35,7 +35,7 @@
}
/* end ai handle */
.ProseMirror:not(.dragging) .ProseMirror-selectednode:not(.node-imageComponent):not(.node-image) {
.ProseMirror:not(.dragging) .ProseMirror-selectednode:not(.node-imageComponent):not(.node-image):not(.table-wrapper) {
position: relative;
cursor: grab;
outline: none !important;
@@ -61,7 +61,8 @@
}
&.node-imageComponent,
&.node-image {
&.node-image,
&.table-wrapper {
--horizontal-offset: 0px;
&::after {
+189 -87
View File
@@ -1,115 +1,155 @@
.table-wrapper {
overflow-x: auto;
width: fit-content;
max-width: 100%;
}
padding-bottom: 30px;
.table-wrapper table {
border-collapse: collapse;
table-layout: fixed;
margin: 0.5rem 0 1rem 0;
border: 1px solid rgba(var(--color-border-200));
width: 100%;
}
table {
position: relative;
border-collapse: collapse;
table-layout: fixed;
margin: 0.5rem 0 0 0;
border: 1px solid rgba(var(--color-border-200));
width: 100%;
transition: background-color 0.3s ease;
.table-wrapper table td,
.table-wrapper table th {
min-width: 1em;
border: 1px solid rgba(var(--color-border-200));
padding: 7px 10px;
vertical-align: top;
box-sizing: border-box;
position: relative;
transition: background-color 0.3s ease;
> * {
margin-bottom: 0;
}
}
.table-wrapper table {
th {
font-weight: 500;
text-align: left;
}
tr[background="none"],
tr:not([background]) {
td,
th {
background-color: rgba(var(--color-background-90));
min-width: 1em;
border: 1px solid rgba(var(--color-border-300));
padding: 7px 10px;
vertical-align: top;
box-sizing: border-box;
position: relative;
transition: background-color 0.3s ease;
> * {
margin-bottom: 0;
}
/* Selected cell outline */
&.selectedCell {
user-select: none;
&::after {
position: absolute;
content: "";
top: -1px;
left: -1px;
height: calc(100% + 2px);
width: calc(100% + 2px);
border: 2px solid transparent;
}
&.selectedCell-border-top::after {
border-top-color: rgba(var(--color-primary-100));
}
&.selectedCell-border-left::after {
border-left-color: rgba(var(--color-primary-100));
}
&.selectedCell-border-bottom::after {
border-bottom-color: rgba(var(--color-primary-100));
}
&.selectedCell-border-right::after {
border-right-color: rgba(var(--color-primary-100));
}
}
/* End selected cell outline */
}
th {
font-weight: 500;
text-align: left;
}
}
&:not(.ProseMirror-selectednode) {
table {
tr[background="none"],
tr:not([background]) {
th {
background-color: rgba(var(--color-background-90));
}
}
}
}
/* Selected status */
&.ProseMirror-selectednode {
table {
background-color: rgba(var(--color-primary-100), 0.2);
}
}
/* End selected status */
}
.table-wrapper table .selectedCell {
outline: 0.5px solid rgba(var(--color-primary-100));
}
/* table dropdown */
/* Column resizer */
.table-wrapper table .column-resize-handle {
position: absolute;
right: 0;
top: 0;
right: -1px;
top: -1px;
width: 2px;
height: 100%;
height: calc(100% + 2px);
z-index: 5;
background-color: rgba(var(--color-primary-100));
pointer-events: none;
}
/* End column resizer */
.table-wrapper .table-controls {
position: absolute;
}
.table-wrapper .table-controls .columns-control,
.table-wrapper .table-controls .rows-control {
transition: opacity ease-in 100ms;
position: absolute;
z-index: 5;
display: flex;
justify-content: center;
align-items: center;
}
.columns-control,
.rows-control {
transition: opacity ease-in 100ms;
position: absolute;
z-index: 5;
display: flex;
justify-content: center;
align-items: center;
}
.table-wrapper .table-controls .columns-control {
height: 20px;
transform: translateY(-50%);
}
.columns-control {
height: 20px;
transform: translateY(-50%);
.table-wrapper .table-controls .columns-control .columns-control-div {
color: white;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='24' height='24'%3E%3Cpath fill='none' d='M0 0h24v24H0z'/%3E%3Cpath fill='%238F95B2' d='M4.5 10.5c-.825 0-1.5.675-1.5 1.5s.675 1.5 1.5 1.5S6 12.825 6 12s-.675-1.5-1.5-1.5zm15 0c-.825 0-1.5.675-1.5 1.5s.675 1.5 1.5 1.5S21 12.825 21 12s-.675-1.5-1.5-1.5zm-7.5 0c-.825 0-1.5.675-1.5 1.5s.675 1.5 1.5 1.5 1.5-.675 1.5-1.5-.675-1.5-1.5-1.5z'/%3E%3C/svg%3E");
width: 30px;
height: 15px;
}
.columns-control-div {
color: white;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='24' height='24'%3E%3Cpath fill='none' d='M0 0h24v24H0z'/%3E%3Cpath fill='%238F95B2' d='M4.5 10.5c-.825 0-1.5.675-1.5 1.5s.675 1.5 1.5 1.5S6 12.825 6 12s-.675-1.5-1.5-1.5zm15 0c-.825 0-1.5.675-1.5 1.5s.675 1.5 1.5 1.5S21 12.825 21 12s-.675-1.5-1.5-1.5zm-7.5 0c-.825 0-1.5.675-1.5 1.5s.675 1.5 1.5 1.5 1.5-.675 1.5-1.5-.675-1.5-1.5-1.5z'/%3E%3C/svg%3E");
width: 30px;
height: 15px;
}
}
.table-wrapper .table-controls .rows-control {
width: 20px;
transform: translateX(-50%);
left: -8px;
}
.rows-control {
width: 20px;
transform: translateX(-50%);
left: -8px;
.table-wrapper .table-controls .rows-control .rows-control-div {
color: white;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='24' height='24'%3E%3Cpath fill='none' d='M0 0h24v24H0z'/%3E%3Cpath fill='%238F95B2' d='M12 3c-.825 0-1.5.675-1.5 1.5S11.175 6 12 6s1.5-.675 1.5-1.5S12.825 3 12 3zm0 15c-.825 0-1.5.675-1.5 1.5S11.175 21 12 21s1.5-.675 1.5-1.5S12.825 18 12 18zm0-7.5c-.825 0-1.5.675-1.5 1.5s.675 1.5 1.5 1.5 1.5-.675 1.5-1.5-.675-1.5-1.5-1.5z'/%3E%3C/svg%3E");
height: 30px;
width: 15px;
}
.rows-control-div {
color: white;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='24' height='24'%3E%3Cpath fill='none' d='M0 0h24v24H0z'/%3E%3Cpath fill='%238F95B2' d='M12 3c-.825 0-1.5.675-1.5 1.5S11.175 6 12 6s1.5-.675 1.5-1.5S12.825 3 12 3zm0 15c-.825 0-1.5.675-1.5 1.5S11.175 21 12 21s1.5-.675 1.5-1.5S12.825 18 12 18zm0-7.5c-.825 0-1.5.675-1.5 1.5s.675 1.5 1.5 1.5 1.5-.675 1.5-1.5-.675-1.5-1.5-1.5z'/%3E%3C/svg%3E");
height: 30px;
width: 15px;
}
}
.table-wrapper .table-controls .rows-control-div,
.table-wrapper .table-controls .columns-control-div {
background-color: rgba(var(--color-background-80));
border: 0.5px solid rgba(var(--color-border-200));
border-radius: 4px;
background-size: 1.25rem;
background-repeat: no-repeat;
background-position: center;
transition:
transform ease-out 100ms,
background-color ease-out 100ms;
outline: none;
box-shadow: rgba(var(--color-shadow-2xs));
cursor: pointer;
.columns-control-div,
.rows-control-div {
background-color: rgba(var(--color-background-80));
border: 0.5px solid rgba(var(--color-border-200));
border-radius: 4px;
background-size: 1.25rem;
background-repeat: no-repeat;
background-position: center;
transition:
transform ease-out 100ms,
background-color ease-out 100ms;
outline: none;
box-shadow: rgba(var(--color-shadow-2xs));
cursor: pointer;
}
}
.resize-cursor .table-wrapper .table-controls .rows-control,
@@ -119,3 +159,65 @@
opacity: 0;
pointer-events: none;
}
/* Insert buttons */
.table-wrapper {
.table-column-insert-button,
.table-row-insert-button {
position: absolute;
background-color: rgba(var(--color-background-90));
color: rgba(var(--color-text-300));
border: 1px solid rgba(var(--color-border-200));
border-radius: 4px;
display: grid;
place-items: center;
opacity: 0;
pointer-events: none;
outline: none;
z-index: 1000;
transition: all 0.2s ease;
&:hover {
background-color: rgba(var(--color-background-80));
color: rgba(var(--color-text-100));
}
&.dragging {
opacity: 1;
pointer-events: auto;
background-color: rgba(var(--color-primary-100), 0.2);
color: rgba(var(--color-text-100));
}
svg {
width: 12px;
height: 12px;
}
}
.table-column-insert-button {
top: 0;
right: -20px;
width: 20px;
height: 100%;
transform: translateX(50%);
}
.table-row-insert-button {
bottom: -20px;
left: 0;
width: 100%;
height: 20px;
transform: translateY(50%);
}
/* Show buttons on table hover */
&:hover {
.table-column-insert-button,
.table-row-insert-button {
opacity: 1;
pointer-events: auto;
}
}
}
/* End insert buttons */
+12 -3
View File
@@ -179,9 +179,18 @@
}
.ProseMirror {
max-width: var(--editor-content-width);
margin: 0 auto;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
& > *:not(.editor-full-width-block) {
max-width: var(--editor-content-width);
margin-left: auto !important;
margin-right: auto !important;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
& > .editor-full-width-block {
max-width: 100%;
padding-inline-start: calc((100% - var(--editor-content-width)) / 2);
padding-inline-end: var(--wide-content-margin);
}
}
}
+138 -37
View File
@@ -1,10 +1,38 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import React from "react";
// plane imports
import { TChartData } from "@plane/types";
import { TBarChartShapeVariant, TBarItem, TChartData } from "@plane/types";
import { cn } from "@plane/utils";
// Helper to calculate percentage
// Constants
const MIN_BAR_HEIGHT_FOR_INTERNAL_TEXT = 14; // Minimum height required to show text inside bar
const BAR_TOP_BORDER_RADIUS = 4; // Border radius for the top of bars
const BAR_BOTTOM_BORDER_RADIUS = 4; // Border radius for the bottom of bars
const DEFAULT_LOLLIPOP_LINE_WIDTH = 2; // Width of lollipop stick
const DEFAULT_LOLLIPOP_CIRCLE_RADIUS = 8; // Radius of lollipop circle
// Types
interface TShapeProps {
x: number;
y: number;
width: number;
height: number;
dataKey: string;
payload: any;
opacity?: number;
}
interface TBarProps extends TShapeProps {
fill: string | ((payload: any) => string);
stackKeys: string[];
textClassName?: string;
showPercentage?: boolean;
showTopBorderRadius?: boolean;
showBottomBorderRadius?: boolean;
dotted?: boolean;
}
// Helper Functions
const calculatePercentage = <K extends string, T extends string>(
data: TChartData<K, T>,
stackKeys: T[],
@@ -14,11 +42,36 @@ const calculatePercentage = <K extends string, T extends string>(
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_TOP_BORDER_RADIUS = 4; // Border radius for each bar
const BAR_BOTTOM_BORDER_RADIUS = 4; // Border radius for each bar
const getBarPath = (x: number, y: number, width: number, height: number, topRadius: number, bottomRadius: number) => `
M${x},${y + topRadius}
Q${x},${y} ${x + topRadius},${y}
L${x + width - topRadius},${y}
Q${x + width},${y} ${x + width},${y + topRadius}
L${x + width},${y + height - bottomRadius}
Q${x + width},${y + height} ${x + width - bottomRadius},${y + height}
L${x + bottomRadius},${y + height}
Q${x},${y + height} ${x},${y + height - bottomRadius}
Z
`;
export const CustomBar = React.memo((props: any) => {
const PercentageText = ({
x,
y,
percentage,
className,
}: {
x: number;
y: number;
percentage: number;
className?: string;
}) => (
<text x={x} y={y} textAnchor="middle" className={cn("text-xs font-medium", className)} fill="currentColor">
{percentage}%
</text>
);
// Base Components
const CustomBar = React.memo((props: TBarProps) => {
const {
opacity,
fill,
@@ -34,56 +87,104 @@ export const CustomBar = React.memo((props: any) => {
showTopBorderRadius,
showBottomBorderRadius,
} = 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
if (!height) return null;
const currentBarPercentage = calculatePercentage(payload, stackKeys, dataKey);
const TEXT_PADDING_Y = Math.min(6, Math.abs(MIN_BAR_HEIGHT_FOR_INTERNAL_TEXT - height / 2));
const textY = y + height - TEXT_PADDING_Y;
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);
const topBorderRadius = showTopBorderRadius ? BAR_TOP_BORDER_RADIUS : 0;
const bottomBorderRadius = showBottomBorderRadius ? BAR_BOTTOM_BORDER_RADIUS : 0;
if (!height) return null;
return (
<g>
<path
d={`
M${x},${y + topBorderRadius}
Q${x},${y} ${x + topBorderRadius},${y}
L${x + width - topBorderRadius},${y}
Q${x + width},${y} ${x + width},${y + topBorderRadius}
L${x + width},${y + height - bottomBorderRadius}
Q${x + width},${y + height} ${x + width - bottomBorderRadius},${y + height}
L${x + bottomBorderRadius},${y + height}
Q${x},${y + height} ${x},${y + height - bottomBorderRadius}
Z
`}
d={getBarPath(x, y, width, height, topBorderRadius, bottomBorderRadius)}
className="transition-opacity duration-200"
fill={fill}
fill={typeof fill === "function" ? fill(payload) : fill}
opacity={opacity}
/>
{showText && (
<text
x={x + width / 2}
y={textY}
textAnchor="middle"
className={cn("text-xs font-medium", textClassName)}
fill="currentColor"
>
{currentBarPercentage}%
</text>
<PercentageText x={x + width / 2} y={textY} percentage={currentBarPercentage} className={textClassName} />
)}
</g>
);
});
const CustomBarLollipop = React.memo((props: TBarProps) => {
const { fill, x, y, width, height, dataKey, stackKeys, payload, textClassName, showPercentage, dotted } = props;
const currentBarPercentage = calculatePercentage(payload, stackKeys, dataKey);
return (
<g>
<line
x1={x + width / 2}
y1={y + height}
x2={x + width / 2}
y2={y}
stroke={typeof fill === "function" ? fill(payload) : fill}
strokeWidth={DEFAULT_LOLLIPOP_LINE_WIDTH}
strokeLinecap="round"
strokeDasharray={dotted ? "4 4" : "0"}
/>
<circle
cx={x + width / 2}
cy={y}
r={DEFAULT_LOLLIPOP_CIRCLE_RADIUS}
fill={typeof fill === "function" ? fill(payload) : fill}
stroke="none"
/>
{showPercentage && (
<PercentageText x={x + width / 2} y={y} percentage={currentBarPercentage} className={textClassName} />
)}
</g>
);
});
// Shape Variants
/**
* Factory function to create shape variants with consistent props
* @param Component - The base component to render
* @param factoryProps - Additional props to pass to the component
* @returns A function that creates the shape with proper props
*/
const createShapeVariant =
(Component: React.ComponentType<TBarProps>, factoryProps?: Partial<TBarProps>) =>
(shapeProps: TShapeProps, bar: TBarItem<string>, stackKeys: string[]): JSX.Element => {
const showTopBorderRadius = bar.showTopBorderRadius?.(shapeProps.dataKey, shapeProps.payload);
const showBottomBorderRadius = bar.showBottomBorderRadius?.(shapeProps.dataKey, shapeProps.payload);
return (
<Component
{...shapeProps}
fill={typeof bar.fill === "function" ? bar.fill(shapeProps.payload) : bar.fill}
stackKeys={stackKeys}
textClassName={bar.textClassName}
showPercentage={bar.showPercentage}
showTopBorderRadius={!!showTopBorderRadius}
showBottomBorderRadius={!!showBottomBorderRadius}
{...factoryProps}
/>
);
};
export const barShapeVariants: Record<
TBarChartShapeVariant,
(props: TShapeProps, bar: TBarItem<string>, stackKeys: string[]) => JSX.Element
> = {
bar: createShapeVariant(CustomBar), // Standard bar with rounded corners
lollipop: createShapeVariant(CustomBarLollipop), // Line with circle at top
"lollipop-dotted": createShapeVariant(CustomBarLollipop, { dotted: true }), // Dotted line lollipop variant
};
// Display names
CustomBar.displayName = "CustomBar";
CustomBarLollipop.displayName = "CustomBarLollipop";
+18 -26
View File
@@ -19,7 +19,7 @@ import { TBarChartProps } from "@plane/types";
import { getLegendProps } from "../components/legend";
import { CustomXAxisTick, CustomYAxisTick } from "../components/tick";
import { CustomTooltip } from "../components/tooltip";
import { CustomBar } from "./bar";
import { barShapeVariants } from "./bar";
export const BarChart = React.memo(<K extends string, T extends string>(props: TBarChartProps<K, T>) => {
const {
@@ -36,6 +36,7 @@ export const BarChart = React.memo(<K extends string, T extends string>(props: T
y: 10,
},
showTooltip = true,
customTooltipContent,
} = props;
// states
const [activeBar, setActiveBar] = useState<string | null>(null);
@@ -66,20 +67,8 @@ export const BarChart = React.memo(<K extends string, T extends string>(props: T
stackId={bar.stackId}
opacity={!!activeLegend && activeLegend !== bar.key ? 0.1 : 1}
shape={(shapeProps: any) => {
const showTopBorderRadius = bar.showTopBorderRadius?.(shapeProps.dataKey, shapeProps.payload);
const showBottomBorderRadius = bar.showBottomBorderRadius?.(shapeProps.dataKey, shapeProps.payload);
return (
<CustomBar
{...shapeProps}
fill={typeof bar.fill === "function" ? bar.fill(shapeProps.payload) : bar.fill}
stackKeys={stackKeys}
textClassName={bar.textClassName}
showPercentage={bar.showPercentage}
showTopBorderRadius={!!showTopBorderRadius}
showBottomBorderRadius={!!showBottomBorderRadius}
/>
);
const shapeVariant = barShapeVariants[bar.shapeVariant ?? "bar"];
return shapeVariant(shapeProps, bar, stackKeys);
}}
className="[&_path]:transition-opacity [&_path]:duration-200"
onMouseEnter={() => setActiveBar(bar.key)}
@@ -150,17 +139,20 @@ export const BarChart = React.memo(<K extends string, T extends string>(props: T
wrapperStyle={{
pointerEvents: "auto",
}}
content={({ active, label, payload }) => (
<CustomTooltip
active={active}
label={label}
payload={payload}
activeKey={activeBar}
itemKeys={stackKeys}
itemLabels={stackLabels}
itemDotColors={stackDotColors}
/>
)}
content={({ active, label, payload }) => {
if (customTooltipContent) return customTooltipContent({ active, label, payload });
return (
<CustomTooltip
active={active}
label={label}
payload={payload}
activeKey={activeBar}
itemKeys={stackKeys}
itemLabels={stackLabels}
itemDotColors={stackDotColors}
/>
);
}}
/>
)}
{renderBars}
@@ -9,12 +9,11 @@ export class APITokenService extends APIService {
/**
* Retrieves all API tokens for a specific workspace
* @param {string} workspaceSlug - The unique identifier for the workspace
* @returns {Promise<IApiToken[]>} Array of API tokens associated with the workspace
* @throws {Error} Throws response data if the request fails
*/
async list(workspaceSlug: string): Promise<IApiToken[]> {
return this.get(`/api/workspaces/${workspaceSlug}/api-tokens/`)
async list(): Promise<IApiToken[]> {
return this.get(`/api/users/api-tokens/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
@@ -23,13 +22,12 @@ export class APITokenService extends APIService {
/**
* Retrieves a specific API token by its ID
* @param {string} workspaceSlug - The unique identifier for the workspace
* @param {string} tokenId - The unique identifier of the API token
* @returns {Promise<IApiToken>} The requested API token's details
* @throws {Error} Throws response data if the request fails
*/
async retrieve(workspaceSlug: string, tokenId: string): Promise<IApiToken> {
return this.get(`/api/workspaces/${workspaceSlug}/api-tokens/${tokenId}`)
async retrieve(tokenId: string): Promise<IApiToken> {
return this.get(`/api/users/api-tokens/${tokenId}`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
@@ -38,13 +36,12 @@ export class APITokenService extends APIService {
/**
* Creates a new API token for a workspace
* @param {string} workspaceSlug - The unique identifier for the workspace
* @param {Partial<IApiToken>} data - The data for creating the new API token
* @returns {Promise<IApiToken>} The newly created API token
* @throws {Error} Throws response data if the request fails
*/
async create(workspaceSlug: string, data: Partial<IApiToken>): Promise<IApiToken> {
return this.post(`/api/workspaces/${workspaceSlug}/api-tokens/`, data)
async create(data: Partial<IApiToken>): Promise<IApiToken> {
return this.post(`/api/users/api-tokens/`, data)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
@@ -53,13 +50,12 @@ export class APITokenService extends APIService {
/**
* Deletes a specific API token from the workspace
* @param {string} workspaceSlug - The unique identifier for the workspace
* @param {string} tokenId - The unique identifier of the API token to delete
* @returns {Promise<IApiToken>} The deleted API token's details
* @throws {Error} Throws response data if the request fails
*/
async destroy(workspaceSlug: string, tokenId: string): Promise<IApiToken> {
return this.delete(`/api/workspaces/${workspaceSlug}/api-tokens/${tokenId}`)
async destroy(tokenId: string): Promise<IApiToken> {
return this.delete(`/api/users/api-tokens/${tokenId}`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
+3
View File
@@ -53,6 +53,8 @@ type TChartProps<K extends string, T extends string> = {
// Bar Chart
// ============================================================
export type TBarChartShapeVariant = "bar" | "lollipop" | "lollipop-dotted";
export type TBarItem<T extends string> = {
key: T;
label: string;
@@ -62,6 +64,7 @@ export type TBarItem<T extends string> = {
stackId: string;
showTopBorderRadius?: (barKey: string, payload: any) => boolean;
showBottomBorderRadius?: (barKey: string, payload: any) => boolean;
shapeVariant?: TBarChartShapeVariant;
};
export type TBarChartProps<K extends string, T extends string> = TChartProps<K, T> & {
+1 -1
View File
@@ -10,7 +10,7 @@ export * from "./issues";
export * from "./module";
export * from "./views";
export * from "./integration";
export * from "./pages";
export * from "./page";
export * from "./ai";
export * from "./estimate";
export * from "./importer";
+2
View File
@@ -9,6 +9,7 @@ export interface IGanttBlock {
sort_order: number | undefined;
start_date: string | undefined;
target_date: string | undefined;
project_id: string | undefined;
}
export interface IBlockUpdateData {
@@ -25,6 +26,7 @@ export interface IBlockUpdateDependencyData {
id: string;
start_date?: string;
target_date?: string;
project_id?: string;
}
export type TGanttViews = "week" | "month" | "quarter";
@@ -1,9 +1,9 @@
import { TLogoProps } from "./common";
import { EPageAccess } from "./enums";
import { TLogoProps } from "../common";
import { EPageAccess } from "../enums";
import { TPageExtended } from "./extended";
export type TPage = {
export type TPage = TPageExtended & {
access: EPageAccess | undefined;
anchor?: string | null | undefined;
archived_at: string | null | undefined;
color: string | undefined;
created_at: Date | undefined;
@@ -16,7 +16,6 @@ export type TPage = {
name: string | undefined;
owned_by: string | undefined;
project_ids?: string[] | undefined;
team: string | null | undefined;
updated_at: Date | undefined;
updated_by: string | undefined;
workspace: string | undefined;
+1
View File
@@ -0,0 +1 @@
export type TPageExtended = {};
+2
View File
@@ -0,0 +1,2 @@
export * from "./core";
export * from "./extended";
+1 -1
View File
@@ -1,7 +1,7 @@
import { ICycle } from "./cycle";
import { TIssue } from "./issues/issue";
import { IModule } from "./module";
import { TPage } from "./pages";
import { TPage } from "./page";
import { IProject } from "./project";
import { IUser } from "./users";
import { IWorkspace } from "./workspace";
@@ -0,0 +1,233 @@
import type { Meta, StoryObj } from "@storybook/react";
import { Home, Settings, Briefcase, GridIcon, Layers2, FileIcon } from "lucide-react";
import * as React from "react";
import { ContrastIcon, EpicIcon, LayersIcon } from "../icons";
import { Breadcrumbs } from "./breadcrumbs";
import { BreadcrumbNavigationDropdown } from "./navigation-dropdown";
const meta: Meta<typeof Breadcrumbs> = {
title: "UI/Breadcrumbs",
component: Breadcrumbs,
tags: ["autodocs"],
argTypes: {
isLoading: {
control: "boolean",
description: "Shows loading state of breadcrumbs",
},
onBack: {
action: "onBack",
description: "Callback function when back button is clicked",
},
},
};
type TBreadcrumbBlockProps = {
href?: string;
label?: string;
icon?: React.ReactNode;
disableTooltip?: boolean;
};
// TODO: remove this component and use web Link component
const BreadcrumbBlock: React.FC<TBreadcrumbBlockProps> = (props) => {
const { label, icon, disableTooltip = false } = props;
return (
<>
<Breadcrumbs.ItemWrapper label={label} disableTooltip={disableTooltip}>
{icon && <div className="flex size-4 items-center justify-center overflow-hidden !text-[1rem]">{icon}</div>}
{label && <div className="relative line-clamp-1 block max-w-[150px] overflow-hidden truncate">{label}</div>}
</Breadcrumbs.ItemWrapper>
</>
);
};
export default meta;
type Story = StoryObj<typeof Breadcrumbs>;
export const Default: Story = {
args: {
children: [
<Breadcrumbs.Item key="home" component={<BreadcrumbBlock href="/" label="Home" />} />,
<Breadcrumbs.Item key="projects" component={<BreadcrumbBlock href="/projects" label="Projects" />} />,
<Breadcrumbs.Item
key="current"
component={<BreadcrumbBlock href="/projects/current" label="Current Project" />}
/>,
],
},
};
export const WithLoading: Story = {
args: {
isLoading: true,
children: [
<Breadcrumbs.Item key="home" component={<BreadcrumbBlock href="/" label="Home" />} />,
<Breadcrumbs.Item key="projects" component={<BreadcrumbBlock href="/projects" label="Projects" />} />,
],
},
};
export const WithCustomComponent: Story = {
args: {
children: [
<Breadcrumbs.Item key="home" component={<BreadcrumbBlock href="/" label="Home" />} />,
<Breadcrumbs.Item
key="custom"
component={
<div className="flex items-center gap-2">
<span className="size-4 rounded-full bg-blue-500" />
<span>Custom Component</span>
</div>
}
/>,
],
},
};
export const SingleItem: Story = {
args: {
children: [<Breadcrumbs.Item key="home" component={<BreadcrumbBlock href="/" label="Home" />} />],
},
};
export const WithNavigationDropdown: Story = {
args: {
children: [
<Breadcrumbs.Item key="home" component={<BreadcrumbBlock href="/" label="Home" />} />,
<Breadcrumbs.Item
key="projects"
component={
<BreadcrumbNavigationDropdown
selectedItemKey="project-1"
navigationItems={[
{
key: "project-1",
title: "Project Alpha",
action: () => console.log("Project Alpha selected"),
},
{
key: "project-2",
title: "Project Beta",
action: () => console.log("Project Beta selected"),
},
{
key: "project-3",
title: "Project Gamma",
action: () => console.log("Project Gamma selected"),
},
]}
/>
}
showSeparator={false}
/>,
<Breadcrumbs.Item key="settings" component={<BreadcrumbBlock href="/settings" label="Settings" />} />,
],
},
};
export const WithNavigationDropdownAndIcons: Story = {
args: {
children: [
<Breadcrumbs.Item
key="home"
component={<BreadcrumbBlock href="/" label="Home" icon={<Home className="size-3.5" />} />}
/>,
<Breadcrumbs.Item
key="projects"
component={
<BreadcrumbNavigationDropdown
selectedItemKey="project-1"
navigationItems={[
{
key: "project-1",
title: "Project Alpha",
icon: Briefcase,
action: () => console.log("Project Alpha selected"),
},
{
key: "project-2",
title: "Project Beta",
icon: Briefcase,
// disabled: true,
action: () => console.log("Project Beta selected"),
},
{
key: "project-3",
title: "Project Gamma",
icon: Briefcase,
action: () => console.log("Project Gamma selected"),
},
]}
/>
}
showSeparator={false}
/>,
<Breadcrumbs.Item
key="features"
component={
<BreadcrumbNavigationDropdown
selectedItemKey="feature-1"
navigationItems={[
{
key: "feature-1",
title: "Epics",
icon: EpicIcon,
action: () => console.log("Feature Alpha selected"),
},
{
key: "feature-2",
title: "Work items",
icon: LayersIcon,
// disabled: true,
action: () => console.log("Feature Beta selected"),
},
{
key: "feature-3",
title: "Cycles",
icon: ContrastIcon,
action: () => console.log("Feature Gamma selected"),
},
{
key: "feature-3",
title: "Modules",
icon: GridIcon,
action: () => console.log("Feature Gamma selected"),
},
{
key: "feature-3",
title: "Views",
icon: Layers2,
action: () => console.log("Feature Gamma selected"),
},
{
key: "feature-3",
title: "Pages",
icon: FileIcon,
action: () => console.log("Feature Gamma selected"),
},
]}
/>
}
showSeparator={false}
/>,
<Breadcrumbs.Item
key="settings"
component={<BreadcrumbBlock href="/settings" label="Settings" icon={<Settings className="size-3.5" />} />}
isLast
/>,
],
},
};
+139 -33
View File
@@ -1,13 +1,25 @@
import * as React from "react";
import { ChevronRight } from "lucide-react";
import * as React from "react";
import { cn } from "../../helpers";
import { Tooltip } from "../tooltip";
type BreadcrumbsProps = {
className?: string;
children: React.ReactNode;
onBack?: () => void;
isLoading?: boolean;
};
const Breadcrumbs = ({ children, onBack, isLoading = false }: BreadcrumbsProps) => {
export const BreadcrumbItemLoader = () => (
<div className="flex items-center gap-2 h-7 animate-pulse">
<div className="group h-full flex items-center gap-2 rounded px-2 py-1 text-sm font-medium">
<span className="h-full w-5 bg-custom-background-80 rounded" />
<span className="h-full w-16 bg-custom-background-80 rounded" />
</div>
</div>
);
const Breadcrumbs = ({ className, children, onBack, isLoading = false }: BreadcrumbsProps) => {
const [isSmallScreen, setIsSmallScreen] = React.useState(false);
React.useEffect(() => {
@@ -22,35 +34,31 @@ const Breadcrumbs = ({ children, onBack, isLoading = false }: BreadcrumbsProps)
const childrenArray = React.Children.toArray(children);
const BreadcrumbItemLoader = (
<div className="flex items-center gap-1 animate-pulse">
<span className="h-5 w-5 bg-custom-background-80 rounded" />
<span className="h-5 w-16 bg-custom-background-80 rounded" />
</div>
);
return (
<div className="flex items-center space-x-2 overflow-hidden">
<div className={cn("flex items-center overflow-hidden gap-0.5 flex-grow", className)}>
{!isSmallScreen && (
<>
{childrenArray.map((child, index) => (
<React.Fragment key={index}>
{index > 0 && !isSmallScreen && (
<div className="flex items-center gap-2.5">
<ChevronRight className="h-3.5 w-3.5 flex-shrink-0 text-custom-text-400" aria-hidden="true" />
</div>
)}
<div className={`flex items-center gap-2.5 ${isSmallScreen && index > 0 ? "hidden sm:flex" : "flex"}`}>
{isLoading ? BreadcrumbItemLoader : child}
</div>
</React.Fragment>
))}
{childrenArray.map((child, index) => {
if (isLoading) {
return (
<>
<BreadcrumbItemLoader />
</>
);
}
if (React.isValidElement<BreadcrumbItemProps>(child)) {
return React.cloneElement(child, {
isLast: index === childrenArray.length - 1,
});
}
return child;
})}
</>
)}
{isSmallScreen && childrenArray.length > 1 && (
<>
<div className="flex items-center gap-2.5">
<div className="flex items-center gap-2.5 p-1">
{onBack && (
<span onClick={onBack} className="text-custom-text-200">
...
@@ -58,8 +66,16 @@ const Breadcrumbs = ({ children, onBack, isLoading = false }: BreadcrumbsProps)
)}
<ChevronRight className="h-3.5 w-3.5 flex-shrink-0 text-custom-text-400" aria-hidden="true" />
</div>
<div className="flex items-center gap-2.5">
{isLoading ? BreadcrumbItemLoader : childrenArray[childrenArray.length - 1]}
<div className="flex items-center gap-2.5 p-1">
{isLoading ? (
<BreadcrumbItemLoader />
) : React.isValidElement(childrenArray[childrenArray.length - 1]) ? (
React.cloneElement(childrenArray[childrenArray.length - 1] as React.ReactElement, {
isLast: true,
})
) : (
childrenArray[childrenArray.length - 1]
)}
</div>
</>
)}
@@ -68,17 +84,107 @@ const Breadcrumbs = ({ children, onBack, isLoading = false }: BreadcrumbsProps)
);
};
type Props = {
type?: "text" | "component";
// breadcrumb item
type BreadcrumbItemProps = {
component?: React.ReactNode;
link?: JSX.Element;
showSeparator?: boolean;
isLast?: boolean;
};
const BreadcrumbItem: React.FC<Props> = (props) => {
const { type = "text", component, link } = props;
return <>{type !== "text" ? <div className="flex items-center space-x-2">{component}</div> : link}</>;
const BreadcrumbItem: React.FC<BreadcrumbItemProps> = (props) => {
const { component, showSeparator = true, isLast = false } = props;
return (
<div className="flex items-center gap-0.5 h-6">
{component}
{showSeparator && !isLast && <BreadcrumbSeparator />}
</div>
);
};
Breadcrumbs.BreadcrumbItem = BreadcrumbItem;
// breadcrumb icon
type BreadcrumbIconProps = {
children: React.ReactNode;
className?: string;
};
export { Breadcrumbs, BreadcrumbItem };
const BreadcrumbIcon: React.FC<BreadcrumbIconProps> = (props) => {
const { children, className } = props;
return <div className={cn("flex size-4 items-center justify-start overflow-hidden", className)}>{children}</div>;
};
// breadcrumb label
type BreadcrumbLabelProps = {
children: React.ReactNode;
className?: string;
};
const BreadcrumbLabel: React.FC<BreadcrumbLabelProps> = (props) => {
const { children, className } = props;
return (
<div className={cn("relative line-clamp-1 block max-w-[150px] overflow-hidden truncate", className)}>
{children}
</div>
);
};
// breadcrumb separator
type BreadcrumbSeparatorProps = {
className?: string;
containerClassName?: string;
iconClassName?: string;
showDivider?: boolean;
};
const BreadcrumbSeparator: React.FC<BreadcrumbSeparatorProps> = (props) => {
const { className, containerClassName, iconClassName, showDivider = false } = props;
return (
<div className={cn("relative flex items-center justify-center h-full px-1.5 py-1", className)}>
{showDivider && <span className="absolute -left-0.5 top-0 h-full w-[1.8px] bg-custom-background-100" />}
<div
className={cn(
"flex items-center justify-center flex-shrink-0 rounded text-custom-text-400 transition-all",
containerClassName
)}
>
<ChevronRight className={cn("h-3.5 w-3.5 flex-shrink-0", iconClassName)} />
</div>
</div>
);
};
// breadcrumb wrapper
type BreadcrumbItemWrapperProps = {
label?: string;
disableTooltip?: boolean;
children: React.ReactNode;
className?: string;
type?: "link" | "text";
isLast?: boolean;
};
const BreadcrumbItemWrapper: React.FC<BreadcrumbItemWrapperProps> = (props) => {
const { label, disableTooltip = false, children, className, type = "link", isLast = false } = props;
return (
<Tooltip tooltipContent={label} position="bottom" disabled={!label || label === "" || disableTooltip}>
<div
className={cn(
"group h-full flex items-center gap-2 rounded px-1.5 py-1 text-sm font-medium text-custom-text-300 cursor-default",
{
"hover:text-custom-text-100 hover:bg-custom-background-90 cursor-pointer": type === "link" && !isLast,
},
className
)}
>
{children}
</div>
</Tooltip>
);
};
Breadcrumbs.Item = BreadcrumbItem;
Breadcrumbs.Icon = BreadcrumbIcon;
Breadcrumbs.Label = BreadcrumbLabel;
Breadcrumbs.Separator = BreadcrumbSeparator;
Breadcrumbs.ItemWrapper = BreadcrumbItemWrapper;
export { Breadcrumbs, BreadcrumbItem, BreadcrumbIcon, BreadcrumbLabel, BreadcrumbSeparator, BreadcrumbItemWrapper };

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