Compare commits

..

29 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 Tripathy 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 Krishna 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
Aaryan Khandelwal 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 Khandelwal 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 Goyal 25a6cd49fc fix: added @plane/services to the web dependencies (#7271) 2025-06-26 14:33:13 +05:30
JayashTripathy 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
Nikhil 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 Khandelwal 0e91feacc3 [WIKI-74] fix: peek overview closing on escape key #7259 2025-06-25 19:09:54 +05:30
Prateek Shourya 40c0922726 [WEB-4387] fix: global layout when filter is set to list (#7264) 2025-06-24 20:25:26 +05:30
Aaryan Khandelwal dee8f00a71 [WEB-4384] fix: power k page search redirection #7263 2025-06-24 20:24:35 +05:30
Aaryan Khandelwal 072f2e2cac [WEB-4363] regression: analytics tabs improvements #7260 2025-06-24 16:08:30 +05:30
Prateek Shourya 79c2dfd293 [WEB-4375] fix: minor issues in timeline layout (#7257) 2025-06-24 14:19:00 +05:30
JayashTripathy 22a9d48ca3 [WEB-4363]: Analytics tab improvements #7248
fix: padding in tabs
2025-06-24 14:17:40 +05:30
Vipin Chaudhary 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 Khandelwal 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
142 changed files with 2576 additions and 1694 deletions
+2 -40
View File
@@ -117,44 +117,6 @@ jobs:
name: Checkout Files
uses: actions/checkout@v4
- name: Get changed files
id: changed_files
uses: tj-actions/changed-files@v42
with:
files_yaml: |
apiserver:
- apiserver/**
proxy:
- caddy/**
admin:
- admin/**
- packages/**
- "package.json"
- "yarn.lock"
- "tsconfig.json"
- "turbo.json"
space:
- space/**
- packages/**
- "package.json"
- "yarn.lock"
- "tsconfig.json"
- "turbo.json"
web:
- web/**
- packages/**
- "package.json"
- "yarn.lock"
- "tsconfig.json"
- "turbo.json"
live:
- live/**
- packages/**
- 'package.json'
- 'yarn.lock'
- 'tsconfig.json'
- 'turbo.json'
branch_build_push_admin:
name: Build-Push Admin Docker Image
runs-on: ubuntu-22.04
@@ -280,8 +242,8 @@ jobs:
dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }}
docker-image-owner: makeplane
docker-image-name: ${{ needs.branch_build_setup.outputs.dh_img_proxy }}
build-context: ./caddy
dockerfile-path: ./caddy/Dockerfile
build-context: ./nginx
dockerfile-path: ./nginx/Dockerfile
buildx-driver: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }}
buildx-version: ${{ needs.branch_build_setup.outputs.gh_buildx_version }}
buildx-platforms: ${{ needs.branch_build_setup.outputs.gh_buildx_platforms }}
+2 -8
View File
@@ -11,7 +11,7 @@ WORKDIR /app
RUN yarn global add turbo
COPY . .
RUN turbo prune --scope=web --scope=space --scope=admin --scope=live --docker
RUN turbo prune --scope=web --scope=space --scope=admin --docker
# *****************************************************************************
# STAGE 2: Install dependencies & build the project
@@ -53,7 +53,7 @@ ENV NEXT_PUBLIC_WEB_BASE_URL=$NEXT_PUBLIC_WEB_BASE_URL
ENV NEXT_TELEMETRY_DISABLED=1
ENV TURBO_TELEMETRY_DISABLED=1
RUN yarn turbo run build --filter=web --filter=space --filter=admin --filter=live
RUN yarn turbo run build --filter=web --filter=space --filter=admin
# *****************************************************************************
# STAGE 3: Copy the project and start it
@@ -87,8 +87,6 @@ RUN chmod +x ./api/bin/*
RUN chmod -R 777 ./api/
# NEXTJS BUILDS
COPY --from=installer /app/node_modules ./node_modules/
COPY --from=installer /app/web/next.config.js ./web/
COPY --from=installer /app/web/package.json ./web/
COPY --from=installer /app/web/.next/standalone ./web
@@ -107,10 +105,6 @@ COPY --from=installer /app/admin/.next/standalone ./admin
COPY --from=installer /app/admin/.next/static ./admin/admin/.next/static
COPY --from=installer /app/admin/public ./admin/admin/public
COPY --from=installer /app/live/package.json ./live/
COPY --from=installer /app/live/dist ./live/dist
# COPY --from=installer /app/live/node_modules ./live/node_modules
ARG NEXT_PUBLIC_API_BASE_URL=""
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
-8
View File
@@ -45,14 +45,6 @@ http {
proxy_pass http://localhost:3003/god-mode/;
}
location /live/ {
proxy_http_version 1.1;
proxy_set_header Upgrade ${dollar}http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host ${dollar}http_host;
proxy_pass http://localhost:3004/;
}
location /api/ {
proxy_http_version 1.1;
proxy_set_header Upgrade ${dollar}http_upgrade;
-10
View File
@@ -29,16 +29,6 @@ stderr_logfile=/dev/stdout
stderr_logfile_maxbytes=0
environment=PORT=3003,HOSTNAME=0.0.0.0
[program:live]
command=node /app/live/dist/server.js
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stdout
stderr_logfile_maxbytes=0
environment=PORT=3004,HOSTNAME=0.0.0.0,API_BASE_URL="http://localhost:8000"
[program:migrator]
directory=/app/api
command=sh -c "./bin/docker-entrypoint-migrator.sh"
+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)
+60 -53
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,
@@ -947,22 +951,22 @@ class IssueDetailEndpoint(BaseAPIView):
# 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
project_member_subquery = ProjectMember.objects.filter(
project_id=OuterRef("project_id"),
member=self.request.user,
is_active=True,
).filter(
Q(role__gt=ROLE.GUEST.value)
| Q(
role=ROLE.GUEST.value, project__guest_view_all_features=True
permission_subquery = (
Issue.issue_objects.filter(
workspace__slug=slug, project_id=project_id, id=OuterRef("id")
)
)
# Main issue query
issue = (
Issue.issue_objects.filter(workspace__slug=slug, project_id=project_id)
.filter(
Q(Exists(project_member_subquery))
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,
@@ -971,7 +975,30 @@ class IssueDetailEndpoint(BaseAPIView):
created_by=self.request.user,
)
)
.prefetch_related("assignees", "labels", "issue_module__module")
.values("id")
)
# Main issue query
issue = (
Issue.issue_objects.filter(workspace__slug=slug, project_id=project_id)
.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(
@@ -979,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()
@@ -1039,6 +1029,23 @@ class IssueDetailEndpoint(BaseAPIView):
)
)
# 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
@@ -1049,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
+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:
-34
View File
@@ -1,34 +0,0 @@
(plane_proxy) {
request_body {
max_size {$FILE_SIZE_LIMIT}
}
reverse_proxy /spaces/* space:3000
reverse_proxy /god-mode/* admin:3000
reverse_proxy /live/* live:3000
reverse_proxy /api/* api:8000
reverse_proxy /auth/* api:8000
reverse_proxy /{$BUCKET_NAME}/* plane-minio:9000
reverse_proxy /* web:3000
}
{
email {$CERT_EMAIL:admin@example.com}
acme_ca {$CERT_ACME_CA}
{$CERT_ACME_DNS}
servers {
max_header_size 5MB
client_ip_headers X-Forwarded-For X-Real-IP
trusted_proxies static {$TRUSTED_PROXIES:0.0.0.0/0}
}
}
{$SITE_ADDRESS} {
import plane_proxy
}
-9
View File
@@ -1,9 +0,0 @@
FROM makeplane/caddy:latest
COPY ./Caddyfile.template /etc/caddy/Caddyfile
COPY ./caddy.sh /docker-entrypoint.sh
RUN chmod +x /docker-entrypoint.sh
CMD ["/docker-entrypoint.sh"]
-11
View File
@@ -1,11 +0,0 @@
#!/bin/sh
if [ "$APP_DOMAIN" == "localhost" ]; then
export SITE_ADDRESS=":${LISTEN_HTTP_PORT}"
elif [ "$SSL" == "true" ]; then
export SITE_ADDRESS="${APP_DOMAIN}:${LISTEN_HTTPS_PORT}"
else
export SITE_ADDRESS="http://${APP_DOMAIN}:${LISTEN_HTTP_PORT}"
fi
exec caddy run --config /etc/caddy/Caddyfile
+6 -20
View File
@@ -58,7 +58,7 @@ Installing plane is a very easy and minimal step process.
### Downloading Latest Release
```
mkdir -p plane-selfhost && cd plane-selfhost
mkdir plane-selfhost
cd plane-selfhost
```
@@ -144,15 +144,11 @@ Again the `options [1-7]` will be popped up, and this time hit `7` to exit.
Before proceeding, we suggest used to review `.env` file and set the values.
Below are the most import keys you must refer to. _<span style="color: #fcba03">You can use any text editor to edit this file</span>_.
> `APP_DOMAIN` - Set the Fully Qualified Domain Name here. (eg. `plane.example.com`)
>
> `LISTEN_PORT` - This is default set to `80`. Make sure the port you choose to use is not preoccupied. (e.g `LISTEN_PORT=8080`)
>
> `LISTEN_SSL_PORT` - This is default set to `443`. Make sure the port you choose to use is not preoccupied. (e.g `LISTEN_SSL_PORT=8443`)
>
> `WEB_URL` - This is default set to `http://localhost`. Change this to the FQDN you plan to use along with LISTEN_PORT/LISTEN_SSL_PORT (eg. `https://plane.example.com:8443` or `http://[IP-ADDRESS]:8080`)
>
> `CORS_ALLOWED_ORIGINS` - This is default set to `http://${APP_DOMAIN},https://${APP_DOMAIN}`. Change this to the FQDN you plan to use along with LISTEN_PORT and LISTEN_SSL_PORT (eg. `http://plane.example.com:8080,https://plane.example.com:8443`)
> `NGINX_PORT` - This is default set to `80`. Make sure the port you choose to use is not preoccupied. (e.g `NGINX_PORT=8080`)
> `WEB_URL` - This is default set to `http://localhost`. Change this to the FQDN you plan to use along with NGINX_PORT (eg. `https://plane.example.com:8080` or `http://[IP-ADDRESS]:8080`)
> `CORS_ALLOWED_ORIGINS` - This is default set to `http://localhost`. Change this to the FQDN you plan to use along with NGINX_PORT (eg. `https://plane.example.com:8080` or `http://[IP-ADDRESS]:8080`)
There are many other settings you can play with, but we suggest you configure `EMAIL SETTINGS` as it will enable you to invite your teammates onto the platform.
@@ -176,8 +172,6 @@ Select a Action you want to perform:
Action [2]: 2
```
> You can also choose to run `./setup.sh start` as direct command.
Expect something like this.
![Downloading docker images](images/download.png)
@@ -213,8 +207,6 @@ Select a Action you want to perform:
Action [2]: 3
```
> You can also choose to run `./setup.sh stop` as direct command.
If all goes well, you must see something like this
![Stop Services](images/stopped.png)
@@ -261,8 +253,6 @@ Select a Action you want to perform:
Action [2]: 4
```
> You can also choose to run `./setup.sh restart` as direct command.
If all goes well, you must see something like this
![Restart Services](images/restart.png)
@@ -307,8 +297,6 @@ Select a Action you want to perform:
Action [2]: 5
```
> You can also choose to run `./setup.sh upgrade` as direct command.
By choosing this, it will stop the services and then will download the latest `docker-compose.yaml` and `plane.env`.
You must expect the below message
@@ -477,8 +465,6 @@ Select a Action you want to perform:
Action [2]: 7
```
> You can also choose to run `./setup.sh backup` as direct command.
In response, you can find the backup folder
```bash
+1 -7
View File
@@ -17,12 +17,6 @@ services:
context: ./
dockerfile: ./admin/Dockerfile.admin
live:
image: ${DOCKERHUB_USER:-local}/plane-live:${APP_RELEASE:-latest}
build:
context: .
dockerfile: ./live/Dockerfile.live
api:
image: ${DOCKERHUB_USER:-local}/plane-backend:${APP_RELEASE:-latest}
build:
@@ -32,5 +26,5 @@ services:
proxy:
image: ${DOCKERHUB_USER:-local}/plane-proxy:${APP_RELEASE:-latest}
build:
context: ./caddy
context: ./nginx
dockerfile: ./Dockerfile
+13 -29
View File
@@ -24,14 +24,9 @@ x-aws-s3-env: &aws-s3-env
AWS_S3_BUCKET_NAME: ${AWS_S3_BUCKET_NAME:-uploads}
x-proxy-env: &proxy-env
SSL: ${SSL:-false}
APP_DOMAIN: ${APP_DOMAIN:-localhost}
FILE_SIZE_LIMIT: ${FILE_SIZE_LIMIT:-5242880}
CERT_EMAIL: ${CERT_EMAIL:-admin@example.com}
CERT_ACME_CA: ${CERT_ACME_CA:-}
LISTEN_HTTP_PORT: ${LISTEN_PORT:-80}
LISTEN_HTTPS_PORT: ${LISTEN_SSL_PORT:-443}
NGINX_PORT: ${NGINX_PORT:-80}
BUCKET_NAME: ${AWS_S3_BUCKET_NAME:-uploads}
FILE_SIZE_LIMIT: ${FILE_SIZE_LIMIT:-5242880}
x-mq-env: &mq-env # RabbitMQ Settings
RABBITMQ_HOST: ${RABBITMQ_HOST:-plane-mq}
@@ -217,31 +212,22 @@ services:
# Comment this if you already have a reverse proxy running
proxy:
image: artifacts.plane.so/makeplane/plane-proxy:${APP_RELEASE_VERSION}
image: artifacts.plane.so/makeplane/plane-proxy:${APP_RELEASE:-stable}
ports:
- target: 80
published: ${NGINX_PORT:-80}
protocol: tcp
mode: host
environment:
<<: *proxy-env
deploy:
replicas: 1
restart_policy:
condition: on-failure
environment:
<<: *proxy-env
ports:
- target: 80
published: ${LISTEN_HTTP_PORT:-80}
protocol: tcp
mode: host
- target: 443
published: ${LISTEN_HTTPS_PORT:-443}
protocol: tcp
mode: host
volumes:
- proxy_config:/config
- proxy_data:/data
depends_on:
- web
- api
- space
- admin
- live
- web
- api
- space
volumes:
pgdata:
@@ -251,6 +237,4 @@ volumes:
logs_worker:
logs_beat-worker:
logs_migrator:
caddy_config:
caddy_data:
rabbitmq_data:
+2 -5
View File
@@ -1,7 +1,6 @@
#!/bin/bash
BRANCH=${BRANCH:-master}
RELEASE_TAG=${RELEASE_TAG:-v0.22-dev}
SCRIPT_DIR=$PWD
SERVICE_FOLDER=plane-app
PLANE_INSTALL_DIR=$PWD/$SERVICE_FOLDER
@@ -178,13 +177,11 @@ function syncEnvFile(){
updateEnvFile "$key" "$value" "$DOCKER_ENV_PATH"
fi
done < "$DOCKER_ENV_PATH"
# Replace APP_RELEASE with the latest value
updateEnvFile "APP_RELEASE" "$APP_RELEASE" "$DOCKER_ENV_PATH"
fi
echo "Environment variables synced successfully" >&2
}
function buildYourOwnImage() {
function buildYourOwnImage(){
echo "Building images locally..."
export DOCKERHUB_USER="myplane"
@@ -426,7 +423,7 @@ function upgrade() {
stopServices
echo
echo "***** DOWNLOADING $APP_RELEASE VERSION ****"
echo "***** DOWNLOADING STABLE VERSION ****"
install
echo "***** PLEASE VALIDATE AND START SERVICES ****"
+2 -9
View File
@@ -1,6 +1,5 @@
APP_DOMAIN=localhost
APP_RELEASE=stable
SSL=false
WEB_REPLICAS=1
SPACE_REPLICAS=1
@@ -10,11 +9,10 @@ WORKER_REPLICAS=1
BEAT_WORKER_REPLICAS=1
LIVE_REPLICAS=1
LISTEN_PORT=80
LISTEN_SSL_PORT=443
NGINX_PORT=80
WEB_URL=http://${APP_DOMAIN}
DEBUG=0
CORS_ALLOWED_ORIGINS=http://${APP_DOMAIN},https://${APP_DOMAIN}
CORS_ALLOWED_ORIGINS=http://${APP_DOMAIN}
API_BASE_URL=http://api:8000
#DB SETTINGS
@@ -32,11 +30,6 @@ REDIS_HOST=plane-redis
REDIS_PORT=6379
REDIS_URL=
# If SSL Cert to be generated, set CERT_EMAIL and APP_PROTOCOL to https
CERT_EMAIL=
CERT_ACME_CA=https://acme-v02.api.letsencrypt.org/directory
TRUSTED_PROXIES=0.0.0.0/0
# RabbitMQ Settings
RABBITMQ_HOST=plane-mq
RABBITMQ_PORT=5672
-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";
+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";
@@ -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,
@@ -38,6 +37,8 @@ import { getExtensionStorage } from "@/helpers/get-extension-storage";
import { CoreEditorAdditionalExtensions } from "@/plane-editor/extensions";
// types
import type { IEditorProps } from "@/types";
// local imports
import { CustomImageExtension } from "./custom-image/extension";
type TArguments = Pick<
IEditorProps,
@@ -191,12 +192,13 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => {
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,11 +19,11 @@ import {
TableRow,
Table,
CustomMentionExtension,
CustomReadOnlyImageExtension,
CustomTextAlignExtension,
CustomCalloutReadOnlyExtension,
CustomColorExtension,
UtilityExtension,
ImageExtension,
} from "@/extensions";
// helpers
import { isValidHttpUrl } from "@/helpers/common";
@@ -32,6 +31,8 @@ import { isValidHttpUrl } from "@/helpers/common";
import { CoreReadOnlyEditorAdditionalExtensions } from "@/plane-editor/extensions";
// types
import type { IReadOnlyEditorProps } from "@/types";
// local imports
import { CustomImageExtension } from "./custom-image/extension";
type Props = Pick<IReadOnlyEditorProps, "disabledExtensions" | "flaggedExtensions" | "fileHandler" | "mentionHandler">;
@@ -135,12 +136,13 @@ export const CoreReadOnlyEditorExtensions = (props: Props): Extensions => {
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;
}
@@ -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);
}
};
@@ -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,
});
@@ -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;
}
+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}
+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> & {
Binary file not shown.
@@ -1,10 +1,9 @@
"use client";
// components
import { AppHeader, ContentWrapper } from "@/components/core";
// plane web components
import { WorkspaceAnalyticsHeader } from "./header";
export default function WorkspaceAnalyticsLayout({ children }: { children: React.ReactNode }) {
export default function WorkspaceAnalyticsTabLayout({ children }: { children: React.ReactNode }) {
return (
<>
<AppHeader header={<WorkspaceAnalyticsHeader />} />
@@ -2,11 +2,11 @@
import { useMemo } from "react";
import { observer } from "mobx-react";
import { useRouter, useSearchParams } from "next/navigation";
import { useRouter } from "next/navigation";
// plane package imports
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { Tabs } from "@plane/ui";
import { type TabItem, Tabs } from "@plane/ui";
// components
import AnalyticsFilterActions from "@/components/analytics/analytics-filter-actions";
import { PageHead } from "@/components/core";
@@ -16,23 +16,33 @@ import { useCommandPalette, useEventTracker, useProject, useUserPermissions, use
import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path";
import { getAnalyticsTabs } from "@/plane-web/components/analytics/tabs";
const AnalyticsPage = observer(() => {
type Props = {
params: {
tabId: string;
workspaceSlug: string;
};
};
const AnalyticsPage = observer((props: Props) => {
// props
const { params } = props;
const { tabId } = params;
// hooks
const router = useRouter();
const searchParams = useSearchParams();
// plane imports
const { t } = useTranslation();
// store hooks
const { toggleCreateProjectModal } = useCommandPalette();
const { setTrackElement } = useEventTracker();
const { workspaceProjectIds, loader } = useProject();
const { currentWorkspace } = useWorkspace();
const { allowPermissions } = useUserPermissions();
// helper hooks
const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/onboarding/analytics" });
// derived values
const pageTitle = currentWorkspace?.name
? t(`workspace_analytics.page_label`, { workspace: currentWorkspace?.name })
: undefined;
// permissions
const canPerformEmptyStateActions = allowPermissions(
@@ -40,22 +50,25 @@ const AnalyticsPage = observer(() => {
EUserPermissionsLevel.WORKSPACE
);
// derived values
const pageTitle = currentWorkspace?.name
? t(`workspace_analytics.page_label`, { workspace: currentWorkspace?.name })
: undefined;
const ANALYTICS_TABS = useMemo(() => getAnalyticsTabs(t), [t]);
const tabs = useMemo(
const tabs: TabItem[] = useMemo(
() =>
ANALYTICS_TABS.map((tab) => ({
key: tab.key,
label: tab.label,
content: <tab.content />,
onClick: () => {
router.push(`?tab=${tab.key}`);
router.push(`/${currentWorkspace?.slug}/analytics/${tab.key}`);
},
isDisabled: tab.isDisabled,
disabled: tab.isDisabled,
})),
[ANALYTICS_TABS, router]
[ANALYTICS_TABS, router, currentWorkspace?.slug]
);
const defaultTab = searchParams.get("tab") || ANALYTICS_TABS[0].key;
const defaultTab = tabId || ANALYTICS_TABS[0].key;
return (
<>
@@ -70,8 +83,9 @@ const AnalyticsPage = observer(() => {
defaultTab={defaultTab}
size="md"
tabListContainerClassName="px-6 py-2 border-b border-custom-border-200 flex items-center justify-between"
tabListClassName="my-2 max-w-36"
tabPanelClassName="h-full w-full overflow-hidden overflow-y-auto"
tabListClassName="my-2 w-auto"
tabClassName="px-3"
tabPanelClassName="h-full overflow-hidden overflow-y-auto px-2"
storeInLocalStorage={false}
actions={<AnalyticsFilterActions />}
/>
@@ -7,7 +7,7 @@ import { Home } from "lucide-react";
import githubBlackImage from "/public/logos/github-black.png";
import githubWhiteImage from "/public/logos/github-white.png";
// ui
import { GITHUB_REDIRECTED } from "@plane/constants";
import { GITHUB_REDIRECTED_TRACKER_EVENT } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { Breadcrumbs, Header } from "@plane/ui";
// components
@@ -39,7 +39,7 @@ export const WorkspaceDashboardHeader = () => {
<Header.RightItem>
<a
onClick={() =>
captureEvent(GITHUB_REDIRECTED, {
captureEvent(GITHUB_REDIRECTED_TRACKER_EVENT, {
element: "navbar",
})
}
@@ -5,7 +5,7 @@ import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { Search } from "lucide-react";
// types
import { MEMBER_INVITED, EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { EUserPermissions, EUserPermissionsLevel, MEMBER_TRACKER_EVENTS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { IWorkspaceBulkInviteFormData } from "@plane/types";
// ui
@@ -52,7 +52,7 @@ const WorkspaceMembersSettingsPage = observer(() => {
return inviteMembersToWorkspace(workspaceSlug.toString(), data)
.then(() => {
setInviteModal(false);
captureEvent(MEMBER_INVITED, {
captureEvent(MEMBER_TRACKER_EVENTS.invite, {
emails: [
...data.emails.map((email) => ({
email: email.email,
@@ -70,7 +70,7 @@ const WorkspaceMembersSettingsPage = observer(() => {
});
})
.catch((err) => {
captureEvent(MEMBER_INVITED, {
captureEvent(MEMBER_TRACKER_EVENTS.invite, {
emails: [
...data.emails.map((email) => ({
email: email.email,
@@ -9,7 +9,7 @@ import { Controller, useForm } from "react-hook-form";
// icons
import { CircleCheck } from "lucide-react";
// plane imports
import { FORGOT_PASS_LINK, NAVIGATE_TO_SIGNUP } from "@plane/constants";
import { AUTH_TRACKER_EVENTS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { Button, Input, TOAST_TYPE, getButtonStyling, setToast } from "@plane/ui";
import { cn, checkEmailValidity } from "@plane/utils";
@@ -71,7 +71,7 @@ const ForgotPasswordPage = observer(() => {
email: formData.email,
})
.then(() => {
captureEvent(FORGOT_PASS_LINK, {
captureEvent(AUTH_TRACKER_EVENTS.forgot_password, {
state: "SUCCESS",
});
setToast({
@@ -82,7 +82,7 @@ const ForgotPasswordPage = observer(() => {
setResendCodeTimer(30);
})
.catch((err) => {
captureEvent(FORGOT_PASS_LINK, {
captureEvent(AUTH_TRACKER_EVENTS.forgot_password, {
state: "FAILED",
});
setToast({
@@ -120,7 +120,7 @@ const ForgotPasswordPage = observer(() => {
{t("auth.common.new_to_plane")}
<Link
href="/"
onClick={() => captureEvent(NAVIGATE_TO_SIGNUP, {})}
onClick={() => captureEvent(AUTH_TRACKER_EVENTS.navigate.sign_up, {})}
className="font-semibold text-custom-primary-100 hover:underline"
>
{t("auth.common.create_account")}
+3 -3
View File
@@ -9,7 +9,7 @@ import { useTheme } from "next-themes";
import useSWR, { mutate } from "swr";
import { CheckCircle2 } from "lucide-react";
// plane imports
import { ROLE, MEMBER_ACCEPTED, EUserPermissions } from "@plane/constants";
import { ROLE, EUserPermissions, MEMBER_TRACKER_EVENTS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
// types
import type { IWorkspaceMemberInvitation } from "@plane/types";
@@ -86,7 +86,7 @@ const UserInvitationsPage = observer(() => {
const invitation = invitations?.find((i) => i.id === firstInviteId);
const redirectWorkspace = invitations?.find((i) => i.id === firstInviteId)?.workspace;
joinWorkspaceMetricGroup(redirectWorkspace?.id);
captureEvent(MEMBER_ACCEPTED, {
captureEvent(MEMBER_TRACKER_EVENTS.accept, {
member_id: invitation?.id,
// eslint-disable-next-line @typescript-eslint/no-non-null-asserted-optional-chain
role: getUserRole((invitation?.role as unknown as EUserPermissions)!),
@@ -112,7 +112,7 @@ const UserInvitationsPage = observer(() => {
});
})
.catch(() => {
captureEvent(MEMBER_ACCEPTED, {
captureEvent(MEMBER_TRACKER_EVENTS.accept, {
project_id: undefined,
accepted_from: "App",
state: "FAILED",
+2 -2
View File
@@ -4,7 +4,7 @@ import { useEffect, useState } from "react";
import { observer } from "mobx-react";
import useSWR from "swr";
// types
import { USER_ONBOARDING_COMPLETED } from "@plane/constants";
import { USER_TRACKER_EVENTS } from "@plane/constants";
import { TOnboardingSteps, TUserProfile } from "@plane/types";
// ui
import { TOAST_TYPE, setToast } from "@plane/ui";
@@ -73,7 +73,7 @@ const OnboardingPage = observer(() => {
await finishUserOnboarding()
.then(() => {
captureEvent(USER_ONBOARDING_COMPLETED, {
captureEvent(USER_TRACKER_EVENTS.onboarding_complete, {
email: user.email,
user_id: user.id,
status: "SUCCESS",
+2 -2
View File
@@ -6,7 +6,7 @@ import Link from "next/link";
// ui
import { useTheme } from "next-themes";
// components
import { NAVIGATE_TO_SIGNIN } from "@plane/constants";
import { AUTH_TRACKER_EVENTS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { AuthRoot } from "@/components/account";
// constants
@@ -54,7 +54,7 @@ const SignInPage = observer(() => {
{t("auth.common.already_have_an_account")}
<Link
href="/"
onClick={() => captureEvent(NAVIGATE_TO_SIGNIN, {})}
onClick={() => captureEvent(AUTH_TRACKER_EVENTS.navigate.sign_in, {})}
className="font-semibold text-custom-primary-100 hover:underline"
>
{t("auth.common.login")}
+2 -2
View File
@@ -6,7 +6,7 @@ import Link from "next/link";
// ui
import { useTheme } from "next-themes";
// components
import { NAVIGATE_TO_SIGNUP } from "@plane/constants";
import { AUTH_TRACKER_EVENTS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { AuthRoot } from "@/components/account";
import { PageHead } from "@/components/core";
@@ -63,7 +63,7 @@ const HomePage = observer(() => {
{t("auth.common.new_to_plane")}
<Link
href="/sign-up"
onClick={() => captureEvent(NAVIGATE_TO_SIGNUP, {})}
onClick={() => captureEvent(AUTH_TRACKER_EVENTS.navigate.sign_up, {})}
className="font-semibold text-custom-primary-100 hover:underline"
>
{t("auth.common.create_account")}
@@ -93,7 +93,9 @@ export const commandGroups: TCommandGroups = {
path: (page: IWorkspacePageSearchResult, projectId: string | undefined) => {
let redirectProjectId = page?.project_ids?.[0];
if (!!projectId && page?.project_ids?.includes(projectId)) redirectProjectId = projectId;
return `/${page?.workspace__slug}/projects/${redirectProjectId}/pages/${page?.id}`;
return redirectProjectId
? `/${page?.workspace__slug}/projects/${redirectProjectId}/pages/${page?.id}`
: `/${page?.workspace__slug}/pages/${page?.id}`;
},
title: "Pages",
},
+2 -2
View File
@@ -3,7 +3,7 @@
import { useState, FC } from "react";
import { observer } from "mobx-react";
import { FormProvider, useForm } from "react-hook-form";
import { PROJECT_CREATED, DEFAULT_PROJECT_FORM_VALUES } from "@plane/constants";
import { DEFAULT_PROJECT_FORM_VALUES, PROJECT_TRACKER_EVENTS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
// ui
import { setToast, TOAST_TYPE } from "@plane/ui";
@@ -75,7 +75,7 @@ export const CreateProjectForm: FC<TCreateProjectFormProps> = observer((props) =
state: "SUCCESS",
};
captureProjectEvent({
eventName: PROJECT_CREATED,
eventName: PROJECT_TRACKER_EVENTS.create,
payload: newPayload,
});
setToast({
+11
View File
@@ -0,0 +1,11 @@
import { RootStore } from "@/plane-web/store/root.store";
import { CoreEventTrackerStore, ICoreEventTrackerStore } from "@/store/event-tracker.store";
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export interface IEventTrackerStore extends ICoreEventTrackerStore {}
export class EventTrackerStore extends CoreEventTrackerStore implements IEventTrackerStore {
constructor(_rootStore: RootStore) {
super(_rootStore);
}
}
@@ -6,7 +6,7 @@ import Link from "next/link";
// icons
import { Eye, EyeOff, Info, X, XCircle } from "lucide-react";
// plane imports
import { FORGOT_PASSWORD, SIGN_IN_WITH_CODE, SIGN_IN_WITH_PASSWORD, SIGN_UP_WITH_PASSWORD, API_BASE_URL, E_PASSWORD_STRENGTH } from "@plane/constants";
import { API_BASE_URL, E_PASSWORD_STRENGTH, AUTH_TRACKER_EVENTS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { Button, Input, Spinner } from "@plane/ui";
import { getPasswordStrength } from "@plane/utils";
@@ -77,7 +77,7 @@ export const AuthPasswordForm: React.FC<Props> = observer((props: Props) => {
const redirectToUniqueCodeSignIn = async () => {
handleAuthStep(EAuthSteps.UNIQUE_CODE);
captureEvent(SIGN_IN_WITH_CODE);
captureEvent(AUTH_TRACKER_EVENTS.sign_in_with_code);
};
const passwordSupport =
@@ -85,7 +85,7 @@ export const AuthPasswordForm: React.FC<Props> = observer((props: Props) => {
<div className="w-full">
{isSMTPConfigured ? (
<Link
onClick={() => captureEvent(FORGOT_PASSWORD)}
onClick={() => captureEvent(AUTH_TRACKER_EVENTS.forgot_password)}
href={`/accounts/forgot-password?email=${encodeURIComponent(email)}`}
className="text-xs font-medium text-custom-primary-100"
>
@@ -154,7 +154,11 @@ export const AuthPasswordForm: React.FC<Props> = observer((props: Props) => {
: true;
if (isPasswordValid) {
setIsSubmitting(true);
captureEvent(mode === EAuthModes.SIGN_IN ? SIGN_IN_WITH_PASSWORD : SIGN_UP_WITH_PASSWORD);
captureEvent(
mode === EAuthModes.SIGN_IN
? AUTH_TRACKER_EVENTS.sign_in_with_password
: AUTH_TRACKER_EVENTS.sign_up_with_password
);
if (formRef.current) formRef.current.submit(); // Manually submit the form if the condition is met
} else {
setBannerMessage(true);
@@ -2,7 +2,7 @@
import React, { useEffect, useState } from "react";
import { CircleCheck, XCircle } from "lucide-react";
import { CODE_VERIFIED, API_BASE_URL } from "@plane/constants";
import { API_BASE_URL, AUTH_TRACKER_EVENTS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { Button, Input, Spinner } from "@plane/ui";
// constants
@@ -84,7 +84,7 @@ export const AuthUniqueCodeForm: React.FC<TAuthUniqueCodeForm> = (props) => {
action={`${API_BASE_URL}/auth/${mode === EAuthModes.SIGN_IN ? "magic-sign-in" : "magic-sign-up"}/`}
onSubmit={() => {
setIsSubmitting(true);
captureEvent(CODE_VERIFIED, {
captureEvent(AUTH_TRACKER_EVENTS.code_verify, {
state: "SUCCESS",
first_time: !isExistingEmail,
});
@@ -5,7 +5,7 @@ import { observer } from "mobx-react";
import { Controller, useForm } from "react-hook-form";
import { ArrowRight, ChevronRight } from "lucide-react";
// Plane Imports
import { CYCLE_STATUS, CYCLE_UPDATED, EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { CYCLE_TRACKER_EVENTS, CYCLE_STATUS, EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { ICycle } from "@plane/types";
import { setToast, TOAST_TYPE } from "@plane/ui";
@@ -67,7 +67,7 @@ export const CycleSidebarHeader: FC<Props> = observer((props) => {
await updateCycleDetails(workspaceSlug.toString(), projectId.toString(), cycleDetails.id.toString(), data)
.then((res) => {
captureCycleEvent({
eventName: CYCLE_UPDATED,
eventName: CYCLE_TRACKER_EVENTS.update,
payload: {
...res,
changed_properties: [changedProperty],
@@ -79,7 +79,7 @@ export const CycleSidebarHeader: FC<Props> = observer((props) => {
.catch(() => {
captureCycleEvent({
eventName: CYCLE_UPDATED,
eventName: CYCLE_TRACKER_EVENTS.update,
payload: {
...data,
element: "Right side-peek",
+3 -3
View File
@@ -4,7 +4,7 @@ import { useState } from "react";
import { observer } from "mobx-react";
import { useParams, useSearchParams } from "next/navigation";
// types
import { PROJECT_ERROR_MESSAGES, CYCLE_DELETED } from "@plane/constants";
import { PROJECT_ERROR_MESSAGES, CYCLE_TRACKER_EVENTS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { ICycle } from "@plane/types";
// ui
@@ -50,7 +50,7 @@ export const CycleDeleteModal: React.FC<ICycleDelete> = observer((props) => {
message: "Cycle deleted successfully.",
});
captureCycleEvent({
eventName: CYCLE_DELETED,
eventName: CYCLE_TRACKER_EVENTS.delete,
payload: { ...cycle, state: "SUCCESS" },
});
})
@@ -65,7 +65,7 @@ export const CycleDeleteModal: React.FC<ICycleDelete> = observer((props) => {
message: currentError.i18n_message && t(currentError.i18n_message),
});
captureCycleEvent({
eventName: CYCLE_DELETED,
eventName: CYCLE_TRACKER_EVENTS.delete,
payload: { ...cycle, state: "FAILED" },
});
})
@@ -6,13 +6,7 @@ import { useParams, usePathname, useSearchParams } from "next/navigation";
import { useForm } from "react-hook-form";
import { Eye, Users, ArrowRight, CalendarDays } from "lucide-react";
// types
import {
CYCLE_FAVORITED,
CYCLE_UNFAVORITED,
EUserPermissions,
EUserPermissionsLevel,
IS_FAVORITE_MENU_OPEN,
} from "@plane/constants";
import { CYCLE_TRACKER_EVENTS, EUserPermissions, EUserPermissionsLevel, IS_FAVORITE_MENU_OPEN } from "@plane/constants";
import { useLocalStorage } from "@plane/hooks";
import { useTranslation } from "@plane/i18n";
import { ICycle, TCycleGroups } from "@plane/types";
@@ -62,7 +56,7 @@ export const CycleListItemAction: FC<Props> = observer((props) => {
const searchParams = useSearchParams();
const pathname = usePathname();
// store hooks
const { addCycleToFavorites, removeCycleFromFavorites, updateCycleDetails } = useCycle();
const { addCycleToFavorites, removeCycleFromFavorites } = useCycle();
const { captureEvent } = useEventTracker();
const { allowPermissions } = useUserPermissions();
@@ -75,7 +69,7 @@ export const CycleListItemAction: FC<Props> = observer((props) => {
const { getUserDetails } = useMember();
// form
const { control, reset, getValues } = useForm({
const { reset } = useForm({
defaultValues,
});
@@ -107,7 +101,7 @@ export const CycleListItemAction: FC<Props> = observer((props) => {
const addToFavoritePromise = addCycleToFavorites(workspaceSlug?.toString(), projectId.toString(), cycleId).then(
() => {
if (!isFavoriteMenuOpen) toggleFavoriteMenu(true);
captureEvent(CYCLE_FAVORITED, {
captureEvent(CYCLE_TRACKER_EVENTS.favorite, {
cycle_id: cycleId,
element: "List layout",
state: "SUCCESS",
@@ -137,7 +131,7 @@ export const CycleListItemAction: FC<Props> = observer((props) => {
projectId.toString(),
cycleId
).then(() => {
captureEvent(CYCLE_UNFAVORITED, {
captureEvent(CYCLE_TRACKER_EVENTS.unfavorite, {
cycle_id: cycleId,
element: "List layout",
state: "SUCCESS",
+5 -5
View File
@@ -4,7 +4,7 @@ import React, { useEffect, useState } from "react";
import { format } from "date-fns";
import { mutate } from "swr";
// types
import { CYCLE_CREATED, CYCLE_UPDATED } from "@plane/constants";
import { CYCLE_TRACKER_EVENTS } from "@plane/constants";
import type { CycleDateCheckData, ICycle, TCycleTabOptions } from "@plane/types";
// ui
import { EModalPosition, EModalWidth, ModalCore, TOAST_TYPE, setToast } from "@plane/ui";
@@ -64,7 +64,7 @@ export const CycleCreateUpdateModal: React.FC<CycleModalProps> = (props) => {
message: "Cycle created successfully.",
});
captureCycleEvent({
eventName: CYCLE_CREATED,
eventName: CYCLE_TRACKER_EVENTS.create,
payload: { ...res, state: "SUCCESS" },
});
})
@@ -75,7 +75,7 @@ export const CycleCreateUpdateModal: React.FC<CycleModalProps> = (props) => {
message: err?.detail ?? "Error in creating cycle. Please try again.",
});
captureCycleEvent({
eventName: CYCLE_CREATED,
eventName: CYCLE_TRACKER_EVENTS.create,
payload: { ...payload, state: "FAILED" },
});
});
@@ -89,7 +89,7 @@ export const CycleCreateUpdateModal: React.FC<CycleModalProps> = (props) => {
.then((res) => {
const changed_properties = Object.keys(dirtyFields);
captureCycleEvent({
eventName: CYCLE_UPDATED,
eventName: CYCLE_TRACKER_EVENTS.update,
payload: { ...res, changed_properties: changed_properties, state: "SUCCESS" },
});
setToast({
@@ -100,7 +100,7 @@ export const CycleCreateUpdateModal: React.FC<CycleModalProps> = (props) => {
})
.catch((err) => {
captureCycleEvent({
eventName: CYCLE_UPDATED,
eventName: CYCLE_TRACKER_EVENTS.update,
payload: { ...payload, state: "FAILED" },
});
setToast({
+2 -2
View File
@@ -2,7 +2,7 @@ import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// components
import useSWR from "swr";
import { PRODUCT_TOUR_COMPLETED } from "@plane/constants";
import { PRODUCT_TOUR_TRACKER_EVENTS } from "@plane/constants";
import { ContentWrapper } from "@plane/ui";
import { cn } from "@plane/utils";
import { TourRoot } from "@/components/onboarding";
@@ -38,7 +38,7 @@ export const WorkspaceHomeView = observer(() => {
const handleTourCompleted = () => {
updateTourCompleted()
.then(() => {
captureEvent(PRODUCT_TOUR_COMPLETED, {
captureEvent(PRODUCT_TOUR_TRACKER_EVENTS.complete, {
user_id: currentUser?.id,
state: "SUCCESS",
});
@@ -4,7 +4,7 @@ import { Dispatch, SetStateAction, useEffect, useMemo, useRef } from "react";
import { observer } from "mobx-react";
import { usePathname } from "next/navigation";
// plane imports
import { EInboxIssueSource, ISSUE_ARCHIVED, ISSUE_DELETED } from "@plane/constants";
import { EInboxIssueSource, WORK_ITEM_TRACKER_EVENTS } from "@plane/constants";
import { EditorRefApi } from "@plane/editor";
import { TIssue, TNameDescriptionLoader } from "@plane/types";
import { Loader, TOAST_TYPE, setToast } from "@plane/ui";
@@ -105,7 +105,7 @@ export const InboxIssueMainContent: React.FC<Props> = observer((props) => {
message: "Work item deleted successfully",
});
captureIssueEvent({
eventName: ISSUE_DELETED,
eventName: WORK_ITEM_TRACKER_EVENTS.delete,
payload: { id: _issueId, state: "SUCCESS", element: "Work item detail page" },
path: pathname,
});
@@ -117,7 +117,7 @@ export const InboxIssueMainContent: React.FC<Props> = observer((props) => {
message: "Work item delete failed",
});
captureIssueEvent({
eventName: ISSUE_DELETED,
eventName: WORK_ITEM_TRACKER_EVENTS.delete,
payload: { id: _issueId, state: "FAILED", element: "Work item detail page" },
path: pathname,
});
@@ -156,14 +156,14 @@ export const InboxIssueMainContent: React.FC<Props> = observer((props) => {
try {
await archiveIssue(workspaceSlug, projectId, issueId);
captureIssueEvent({
eventName: ISSUE_ARCHIVED,
eventName: WORK_ITEM_TRACKER_EVENTS.archive,
payload: { id: issueId, state: "SUCCESS", element: "Work item details page" },
path: pathname,
});
} catch (error) {
console.log("Error in archiving issue:", error);
captureIssueEvent({
eventName: ISSUE_ARCHIVED,
eventName: WORK_ITEM_TRACKER_EVENTS.archive,
payload: { id: issueId, state: "FAILED", element: "Work item details page" },
path: pathname,
});
@@ -4,7 +4,7 @@ import { FC, FormEvent, useCallback, useEffect, useRef, useState } from "react";
import { observer } from "mobx-react";
import { usePathname } from "next/navigation";
// plane imports
import { ETabIndices, ISSUE_CREATED } from "@plane/constants";
import { ETabIndices, WORK_ITEM_TRACKER_EVENTS } from "@plane/constants";
import { EditorRefApi } from "@plane/editor";
// types
import { useTranslation } from "@plane/i18n";
@@ -168,7 +168,7 @@ export const InboxIssueCreateRoot: FC<TInboxIssueCreateRoot> = observer((props)
setFormData(defaultIssueData);
}
captureIssueEvent({
eventName: ISSUE_CREATED,
eventName: WORK_ITEM_TRACKER_EVENTS.create,
payload: {
...formData,
state: "SUCCESS",
@@ -185,7 +185,7 @@ export const InboxIssueCreateRoot: FC<TInboxIssueCreateRoot> = observer((props)
.catch((error) => {
console.error(error);
captureIssueEvent({
eventName: ISSUE_CREATED,
eventName: WORK_ITEM_TRACKER_EVENTS.create,
payload: {
...formData,
state: "FAILED",
@@ -2,7 +2,7 @@
import { useMemo } from "react";
import { usePathname } from "next/navigation";
// plane imports
import { EIssueServiceType, ISSUE_DELETED, ISSUE_UPDATED } from "@plane/constants";
import { EIssueServiceType, WORK_ITEM_TRACKER_EVENTS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { TIssue, TIssueServiceType } from "@plane/types";
import { TOAST_TYPE, setToast } from "@plane/ui";
@@ -41,7 +41,7 @@ export const useRelationOperations = (
try {
await updateIssue(workspaceSlug, projectId, issueId, data);
captureIssueEvent({
eventName: ISSUE_UPDATED,
eventName: WORK_ITEM_TRACKER_EVENTS.update,
payload: { ...data, issueId, state: "SUCCESS", element: "Issue detail page" },
updates: {
changed_property: Object.keys(data).join(","),
@@ -56,7 +56,7 @@ export const useRelationOperations = (
});
} catch {
captureIssueEvent({
eventName: ISSUE_UPDATED,
eventName: WORK_ITEM_TRACKER_EVENTS.update,
payload: { state: "FAILED", element: "Issue detail page" },
updates: {
changed_property: Object.keys(data).join(","),
@@ -75,14 +75,14 @@ export const useRelationOperations = (
try {
return removeIssue(workspaceSlug, projectId, issueId).then(() => {
captureIssueEvent({
eventName: ISSUE_DELETED,
eventName: WORK_ITEM_TRACKER_EVENTS.delete,
payload: { id: issueId, state: "SUCCESS", element: "Issue detail page" },
path: pathname,
});
});
} catch {
captureIssueEvent({
eventName: ISSUE_DELETED,
eventName: WORK_ITEM_TRACKER_EVENTS.delete,
payload: { id: issueId, state: "FAILED", element: "Issue detail page" },
path: pathname,
});
@@ -1,12 +1,21 @@
// plane imports
import { SyntheticEvent } from "react";
import { SyntheticEvent, useMemo } from "react";
import { observer } from "mobx-react";
import { CalendarCheck2, CalendarClock } from "lucide-react";
import { useTranslation } from "@plane/i18n";
import { IIssueDisplayProperties, TIssue } from "@plane/types";
import { getDate, renderFormattedPayloadDate } from "@plane/utils";
import { getDate, renderFormattedPayloadDate, shouldHighlightIssueDueDate } from "@plane/utils";
// components
import { PriorityDropdown, MemberDropdown, StateDropdown, DateRangeDropdown } from "@/components/dropdowns";
import {
PriorityDropdown,
MemberDropdown,
StateDropdown,
DateRangeDropdown,
DateDropdown,
} from "@/components/dropdowns";
// hooks
import { WithDisplayPropertiesHOC } from "@/components/issues/issue-layouts/properties/with-display-properties-HOC";
import { useProjectState } from "@/hooks/store/use-project-state";
type Props = {
workspaceSlug: string;
@@ -27,6 +36,8 @@ type Props = {
export const SubIssuesListItemProperties: React.FC<Props> = observer((props) => {
const { workspaceSlug, parentIssueId, issueId, disabled, updateSubIssue, displayProperties, issue } = props;
const { t } = useTranslation();
const { getStateById } = useProjectState();
const handleEventPropagation = (e: SyntheticEvent<HTMLDivElement>) => {
e.stopPropagation();
@@ -49,10 +60,22 @@ export const SubIssuesListItemProperties: React.FC<Props> = observer((props) =>
}
};
//derived values
const stateDetails = useMemo(() => getStateById(issue.state_id), [getStateById, issue.state_id]);
const shouldHighlight = useMemo(
() => shouldHighlightIssueDueDate(issue.target_date, stateDetails?.group),
[issue.target_date, stateDetails?.group]
);
// date range is enabled only when both dates are available and both dates are enabled
const isDateRangeEnabled: boolean = Boolean(
issue.start_date && issue.target_date && displayProperties?.start_date && displayProperties?.due_date
);
if (!displayProperties) return <></>;
const maxDate = getDate(issue.target_date);
maxDate?.setDate(maxDate.getDate());
const minDate = getDate(issue.start_date);
return (
<div className="relative flex items-center gap-2">
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="state">
@@ -104,7 +127,7 @@ export const SubIssuesListItemProperties: React.FC<Props> = observer((props) =>
<WithDisplayPropertiesHOC
displayProperties={displayProperties}
displayPropertyKey={["start_date", "due_date"]}
shouldRenderProperty={(properties) => !!(properties.start_date || properties.due_date)}
shouldRenderProperty={() => isDateRangeEnabled}
>
<div className="h-5" onFocus={handleEventPropagation} onClick={handleEventPropagation}>
<DateRangeDropdown
@@ -122,6 +145,7 @@ export const SubIssuesListItemProperties: React.FC<Props> = observer((props) =>
isClearable
mergeDates
buttonVariant={issue.start_date || issue.target_date ? "border-with-text" : "border-without-text"}
buttonClassName={shouldHighlight ? "text-red-500" : ""}
disabled={!disabled}
showTooltip
customTooltipHeading="Date Range"
@@ -130,6 +154,50 @@ export const SubIssuesListItemProperties: React.FC<Props> = observer((props) =>
</div>
</WithDisplayPropertiesHOC>
{/* start date */}
<WithDisplayPropertiesHOC
displayProperties={displayProperties}
displayPropertyKey="start_date"
shouldRenderProperty={() => !isDateRangeEnabled}
>
<div className="h-5">
<DateDropdown
value={issue.start_date ?? null}
onChange={handleStartDate}
maxDate={maxDate}
placeholder={t("common.order_by.start_date")}
icon={<CalendarClock className="h-3 w-3 flex-shrink-0" />}
buttonVariant={issue.start_date ? "border-with-text" : "border-without-text"}
optionsClassName="z-30"
disabled={!disabled}
showTooltip
/>
</div>
</WithDisplayPropertiesHOC>
{/* target/due date */}
<WithDisplayPropertiesHOC
displayProperties={displayProperties}
displayPropertyKey="due_date"
shouldRenderProperty={() => !isDateRangeEnabled}
>
<div className="h-5">
<DateDropdown
value={issue?.target_date ?? null}
onChange={handleTargetDate}
minDate={minDate}
placeholder={t("common.order_by.due_date")}
icon={<CalendarCheck2 className="h-3 w-3 flex-shrink-0" />}
buttonVariant={issue.target_date ? "border-with-text" : "border-without-text"}
buttonClassName={shouldHighlight ? "text-red-500" : ""}
clearIconClassName="text-custom-text-100"
optionsClassName="z-30"
disabled={!disabled}
showTooltip
/>
</div>
</WithDisplayPropertiesHOC>
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="assignee">
<div className="h-5 flex-shrink-0">
<MemberDropdown
@@ -5,12 +5,11 @@ import { observer } from "mobx-react";
import { usePathname } from "next/navigation";
import { ArchiveIcon, ArchiveRestoreIcon, LinkIcon, Trash2 } from "lucide-react";
import {
ISSUE_ARCHIVED,
ISSUE_DELETED,
ARCHIVABLE_STATE_GROUPS,
EIssuesStoreType,
EUserPermissions,
EUserPermissionsLevel,
WORK_ITEM_TRACKER_EVENTS,
} from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { TOAST_TYPE, Tooltip, setToast } from "@plane/ui";
@@ -105,19 +104,19 @@ export const IssueDetailQuickActions: FC<Props> = observer((props) => {
return deleteIssue(workspaceSlug, projectId, issueId).then(() => {
router.push(redirectionPath);
captureIssueEvent({
eventName: ISSUE_DELETED,
eventName: WORK_ITEM_TRACKER_EVENTS.delete,
payload: { id: issueId, state: "SUCCESS", element: "Work item detail page" },
path: pathname,
});
});
} catch (error) {
} catch {
setToast({
title: t("toast.error "),
type: TOAST_TYPE.ERROR,
message: t("entity.delete.failed", { entity: t("issue.label", { count: 1 }) }),
});
captureIssueEvent({
eventName: ISSUE_DELETED,
eventName: WORK_ITEM_TRACKER_EVENTS.delete,
payload: { id: issueId, state: "FAILED", element: "Work item detail page" },
path: pathname,
});
@@ -130,13 +129,13 @@ export const IssueDetailQuickActions: FC<Props> = observer((props) => {
router.push(`/${workspaceSlug}/projects/${projectId}/archives/issues/${issue.id}`);
});
captureIssueEvent({
eventName: ISSUE_ARCHIVED,
eventName: WORK_ITEM_TRACKER_EVENTS.archive,
payload: { id: issueId, state: "SUCCESS", element: "Issue details page" },
path: pathname,
});
} catch (error) {
} catch {
captureIssueEvent({
eventName: ISSUE_ARCHIVED,
eventName: WORK_ITEM_TRACKER_EVENTS.archive,
payload: { id: issueId, state: "FAILED", element: "Issue details page" },
path: pathname,
});
@@ -4,14 +4,7 @@ import { FC, useMemo } from "react";
import { observer } from "mobx-react";
import { usePathname } from "next/navigation";
// types
import {
EIssuesStoreType,
ISSUE_UPDATED,
ISSUE_DELETED,
ISSUE_ARCHIVED,
EUserPermissions,
EUserPermissionsLevel,
} from "@plane/constants";
import { EIssuesStoreType, EUserPermissions, EUserPermissionsLevel, WORK_ITEM_TRACKER_EVENTS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { TIssue } from "@plane/types";
// ui
@@ -98,7 +91,7 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = observer((props) => {
try {
await updateIssue(workspaceSlug, projectId, issueId, data);
captureIssueEvent({
eventName: ISSUE_UPDATED,
eventName: WORK_ITEM_TRACKER_EVENTS.update,
payload: { ...data, issueId, state: "SUCCESS", element: "Issue detail page" },
updates: {
changed_property: Object.keys(data).join(","),
@@ -109,7 +102,7 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = observer((props) => {
} catch (error) {
console.log("Error in updating issue:", error);
captureIssueEvent({
eventName: ISSUE_UPDATED,
eventName: WORK_ITEM_TRACKER_EVENTS.update,
payload: { state: "FAILED", element: "Issue detail page" },
updates: {
changed_property: Object.keys(data).join(","),
@@ -134,7 +127,7 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = observer((props) => {
message: t("entity.delete.success", { entity: t("issue.label") }),
});
captureIssueEvent({
eventName: ISSUE_DELETED,
eventName: WORK_ITEM_TRACKER_EVENTS.delete,
payload: { id: issueId, state: "SUCCESS", element: "Issue detail page" },
path: pathname,
});
@@ -146,7 +139,7 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = observer((props) => {
message: t("entity.delete.failed", { entity: t("issue.label") }),
});
captureIssueEvent({
eventName: ISSUE_DELETED,
eventName: WORK_ITEM_TRACKER_EVENTS.delete,
payload: { id: issueId, state: "FAILED", element: "Issue detail page" },
path: pathname,
});
@@ -156,14 +149,14 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = observer((props) => {
try {
await archiveIssue(workspaceSlug, projectId, issueId);
captureIssueEvent({
eventName: ISSUE_ARCHIVED,
eventName: WORK_ITEM_TRACKER_EVENTS.archive,
payload: { id: issueId, state: "SUCCESS", element: "Issue details page" },
path: pathname,
});
} catch (error) {
console.log("Error in archiving issue:", error);
captureIssueEvent({
eventName: ISSUE_ARCHIVED,
eventName: WORK_ITEM_TRACKER_EVENTS.archive,
payload: { id: issueId, state: "FAILED", element: "Issue details page" },
path: pathname,
});
@@ -173,7 +166,7 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = observer((props) => {
try {
await addCycleToIssue(workspaceSlug, projectId, cycleId, issueId);
captureIssueEvent({
eventName: ISSUE_UPDATED,
eventName: WORK_ITEM_TRACKER_EVENTS.update,
payload: { issueId, state: "SUCCESS", element: "Issue detail page" },
updates: {
changed_property: "cycle_id",
@@ -181,14 +174,14 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = observer((props) => {
},
path: pathname,
});
} catch (error) {
} catch {
setToast({
type: TOAST_TYPE.ERROR,
title: t("common.error.label"),
message: t("issue.add.cycle.failed"),
});
captureIssueEvent({
eventName: ISSUE_UPDATED,
eventName: WORK_ITEM_TRACKER_EVENTS.update,
payload: { state: "FAILED", element: "Issue detail page" },
updates: {
changed_property: "cycle_id",
@@ -202,7 +195,7 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = observer((props) => {
try {
await addIssueToCycle(workspaceSlug, projectId, cycleId, issueIds);
captureIssueEvent({
eventName: ISSUE_UPDATED,
eventName: WORK_ITEM_TRACKER_EVENTS.update,
payload: { ...issueIds, state: "SUCCESS", element: "Issue detail page" },
updates: {
changed_property: "cycle_id",
@@ -210,14 +203,14 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = observer((props) => {
},
path: pathname,
});
} catch (error) {
} catch {
setToast({
type: TOAST_TYPE.ERROR,
title: t("common.error.label"),
message: t("issue.add.cycle.failed"),
});
captureIssueEvent({
eventName: ISSUE_UPDATED,
eventName: WORK_ITEM_TRACKER_EVENTS.update,
payload: { state: "FAILED", element: "Issue detail page" },
updates: {
changed_property: "cycle_id",
@@ -243,7 +236,7 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = observer((props) => {
});
await removeFromCyclePromise;
captureIssueEvent({
eventName: ISSUE_UPDATED,
eventName: WORK_ITEM_TRACKER_EVENTS.update,
payload: { issueId, state: "SUCCESS", element: "Issue detail page" },
updates: {
changed_property: "cycle_id",
@@ -251,9 +244,9 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = observer((props) => {
},
path: pathname,
});
} catch (error) {
} catch {
captureIssueEvent({
eventName: ISSUE_UPDATED,
eventName: WORK_ITEM_TRACKER_EVENTS.update,
payload: { state: "FAILED", element: "Issue detail page" },
updates: {
changed_property: "cycle_id",
@@ -279,7 +272,7 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = observer((props) => {
});
await removeFromModulePromise;
captureIssueEvent({
eventName: ISSUE_UPDATED,
eventName: WORK_ITEM_TRACKER_EVENTS.update,
payload: { id: issueId, state: "SUCCESS", element: "Issue detail page" },
updates: {
changed_property: "module_id",
@@ -287,9 +280,9 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = observer((props) => {
},
path: pathname,
});
} catch (error) {
} catch {
captureIssueEvent({
eventName: ISSUE_UPDATED,
eventName: WORK_ITEM_TRACKER_EVENTS.update,
payload: { id: issueId, state: "FAILED", element: "Issue detail page" },
updates: {
changed_property: "module_id",
@@ -308,7 +301,7 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = observer((props) => {
) => {
const promise = await changeModulesInIssue(workspaceSlug, projectId, issueId, addModuleIds, removeModuleIds);
captureIssueEvent({
eventName: ISSUE_UPDATED,
eventName: WORK_ITEM_TRACKER_EVENTS.update,
payload: { id: issueId, state: "SUCCESS", element: "Issue detail page" },
updates: {
changed_property: "module_id",
@@ -333,6 +326,7 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = observer((props) => {
removeIssueFromModule,
captureIssueEvent,
pathname,
t,
]
);
@@ -11,8 +11,10 @@ import {
EIssueFilterType,
EIssuesStoreType,
EViewAccess,
GLOBAL_VIEW_UPDATED,
EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
EUserPermissions,
EUserPermissionsLevel,
GLOBAL_VIEW_TOUR_TRACKER_EVENTS,
} from "@plane/constants";
import { IIssueFilterOptions, TStaticViewTypes } from "@plane/types";
//ui
// components
@@ -112,7 +114,7 @@ export const GlobalViewsAppliedFiltersRoot = observer((props: Props) => {
updateGlobalView(workspaceSlug.toString(), globalViewId.toString(), viewFilters).then((res) => {
if (res)
captureEvent(GLOBAL_VIEW_UPDATED, {
captureEvent(GLOBAL_VIEW_TOUR_TRACKER_EVENTS.update, {
view_id: res.id,
applied_filters: res.filters,
state: "SUCCESS",
@@ -146,6 +146,7 @@ export const BaseGanttRoot: React.FC<IBaseGanttRoot> = observer((props: IBaseGan
canLoadMoreBlocks={nextPageResults}
updateBlockDates={updateBlockDates}
showAllBlocks
enableDependency
isEpic={isEpic}
/>
</div>
@@ -11,9 +11,9 @@ import {
EIssueServiceType,
EIssueFilterType,
EIssuesStoreType,
ISSUE_DELETED,
EUserPermissions,
EUserPermissionsLevel,
WORK_ITEM_TRACKER_EVENTS,
} from "@plane/constants";
import { DeleteIssueModal } from "@/components/issues";
//constants
@@ -152,7 +152,7 @@ export const BaseKanBanRoot: React.FC<IBaseKanBanLayout> = observer((props: IBas
element,
})
);
}, [scrollableContainerRef?.current]);
}, []);
// Make the Issue Delete Box a Drop Target
useEffect(() => {
@@ -181,7 +181,7 @@ export const BaseKanBanRoot: React.FC<IBaseKanBanLayout> = observer((props: IBas
},
})
);
}, [deleteAreaRef?.current, setIsDragOverDelete, setDraggedIssueId, setDeleteIssueModal]);
}, [setIsDragOverDelete, setDraggedIssueId, setDeleteIssueModal]);
const renderQuickActions: TRenderQuickActions = useCallback(
({ issue, parentRef, customActionButton }) => (
@@ -210,7 +210,7 @@ export const BaseKanBanRoot: React.FC<IBaseKanBanLayout> = observer((props: IBas
setDeleteIssueModal(false);
setDraggedIssueId(undefined);
captureIssueEvent({
eventName: ISSUE_DELETED,
eventName: WORK_ITEM_TRACKER_EVENTS.delete,
payload: { id: draggedIssueId, state: "FAILED", element: "Kanban layout drag & drop" },
path: pathname,
});
@@ -5,15 +5,21 @@ import xor from "lodash/xor";
import { observer } from "mobx-react";
import { useParams, usePathname } from "next/navigation";
// icons
import { Layers, Link, Paperclip } from "lucide-react";
import { CalendarCheck2, CalendarClock, Layers, Link, Paperclip } from "lucide-react";
// types
import { ISSUE_UPDATED } from "@plane/constants";
import { WORK_ITEM_TRACKER_EVENTS } from "@plane/constants";
// i18n
import { useTranslation } from "@plane/i18n";
import { TIssue, IIssueDisplayProperties, TIssuePriorities } from "@plane/types";
// ui
import { Tooltip } from "@plane/ui";
import { cn, getDate, renderFormattedPayloadDate, generateWorkItemLink, shouldHighlightIssueDueDate } from "@plane/utils";
import {
cn,
getDate,
renderFormattedPayloadDate,
generateWorkItemLink,
shouldHighlightIssueDueDate,
} from "@plane/utils";
// components
import {
EstimateDropdown,
@@ -23,6 +29,7 @@ import {
CycleDropdown,
StateDropdown,
DateRangeDropdown,
DateDropdown,
} from "@/components/dropdowns";
// constants
// helpers
@@ -103,7 +110,7 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
if (updateIssue)
updateIssue(issue.project_id, issue.id, { state_id: stateId }).then(() => {
captureIssueEvent({
eventName: ISSUE_UPDATED,
eventName: WORK_ITEM_TRACKER_EVENTS.update,
payload: { ...issue, state: "SUCCESS", element: currentLayout },
path: pathname,
updates: {
@@ -118,7 +125,7 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
if (updateIssue)
updateIssue(issue.project_id, issue.id, { priority: value }).then(() => {
captureIssueEvent({
eventName: ISSUE_UPDATED,
eventName: WORK_ITEM_TRACKER_EVENTS.update,
payload: { ...issue, state: "SUCCESS", element: currentLayout },
path: pathname,
updates: {
@@ -133,7 +140,7 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
if (updateIssue)
updateIssue(issue.project_id, issue.id, { label_ids: ids }).then(() => {
captureIssueEvent({
eventName: ISSUE_UPDATED,
eventName: WORK_ITEM_TRACKER_EVENTS.update,
payload: { ...issue, state: "SUCCESS", element: currentLayout },
path: pathname,
updates: {
@@ -148,7 +155,7 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
if (updateIssue)
updateIssue(issue.project_id, issue.id, { assignee_ids: ids }).then(() => {
captureIssueEvent({
eventName: ISSUE_UPDATED,
eventName: WORK_ITEM_TRACKER_EVENTS.update,
payload: { ...issue, state: "SUCCESS", element: currentLayout },
path: pathname,
updates: {
@@ -173,7 +180,7 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
if (modulesToRemove.length > 0) issueOperations.removeModulesFromIssue(modulesToRemove);
captureIssueEvent({
eventName: ISSUE_UPDATED,
eventName: WORK_ITEM_TRACKER_EVENTS.update,
payload: { ...issue, state: "SUCCESS", element: currentLayout },
path: pathname,
updates: { changed_property: "module_ids", change_details: { module_ids: moduleIds } },
@@ -189,7 +196,7 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
else issueOperations.removeIssueFromCycle?.();
captureIssueEvent({
eventName: ISSUE_UPDATED,
eventName: WORK_ITEM_TRACKER_EVENTS.update,
payload: { ...issue, state: "SUCCESS", element: currentLayout },
path: pathname,
updates: { changed_property: "cycle", change_details: { cycle_id: cycleId } },
@@ -203,7 +210,7 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
updateIssue(issue.project_id, issue.id, { start_date: date ? renderFormattedPayloadDate(date) : null }).then(
() => {
captureIssueEvent({
eventName: ISSUE_UPDATED,
eventName: WORK_ITEM_TRACKER_EVENTS.update,
payload: { ...issue, state: "SUCCESS", element: currentLayout },
path: pathname,
updates: {
@@ -220,7 +227,7 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
updateIssue(issue.project_id, issue.id, { target_date: date ? renderFormattedPayloadDate(date) : null }).then(
() => {
captureIssueEvent({
eventName: ISSUE_UPDATED,
eventName: WORK_ITEM_TRACKER_EVENTS.update,
payload: { ...issue, state: "SUCCESS", element: currentLayout },
path: pathname,
updates: {
@@ -236,7 +243,7 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
if (updateIssue)
updateIssue(issue.project_id, issue.id, { estimate_point: value }).then(() => {
captureIssueEvent({
eventName: ISSUE_UPDATED,
eventName: WORK_ITEM_TRACKER_EVENTS.update,
payload: { ...issue, state: "SUCCESS", element: currentLayout },
path: pathname,
updates: {
@@ -261,8 +268,16 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
if (!displayProperties || !issue.project_id) return null;
// date range is enabled only when both dates are available and both dates are enabled
const isDateRangeEnabled: boolean = Boolean(
issue.start_date && issue.target_date && displayProperties.start_date && displayProperties.due_date
);
const defaultLabelOptions = issue?.label_ids?.map((id) => labelMap[id]) || [];
const minDate = getDate(issue.start_date);
const maxDate = getDate(issue.target_date);
const handleEventPropagation = (e: SyntheticEvent<HTMLDivElement>) => {
e.stopPropagation();
e.preventDefault();
@@ -306,7 +321,7 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
<WithDisplayPropertiesHOC
displayProperties={displayProperties}
displayPropertyKey={["start_date", "due_date"]}
shouldRenderProperty={(properties) => !!(properties.start_date || properties.due_date)}
shouldRenderProperty={() => isDateRangeEnabled}
>
<div className="h-5" onFocus={handleEventPropagation} onClick={handleEventPropagation}>
<DateRangeDropdown
@@ -325,6 +340,7 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
mergeDates
buttonVariant={issue.start_date || issue.target_date ? "border-with-text" : "border-without-text"}
buttonClassName={shouldHighlightIssueDueDate(issue.target_date, stateDetails?.group) ? "text-red-500" : ""}
clearIconClassName="!text-custom-text-100"
disabled={isReadOnly}
renderByDefault={isMobile}
showTooltip
@@ -334,6 +350,52 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
</div>
</WithDisplayPropertiesHOC>
{/* start date */}
<WithDisplayPropertiesHOC
displayProperties={displayProperties}
displayPropertyKey="start_date"
shouldRenderProperty={() => !isDateRangeEnabled}
>
<div className="h-5" onFocus={handleEventPropagation} onClick={handleEventPropagation}>
<DateDropdown
value={issue.start_date ?? null}
onChange={handleStartDate}
maxDate={maxDate}
placeholder={t("common.order_by.start_date")}
icon={<CalendarClock className="h-3 w-3 flex-shrink-0" />}
buttonVariant={issue.start_date ? "border-with-text" : "border-without-text"}
optionsClassName="z-10"
disabled={isReadOnly}
renderByDefault={isMobile}
showTooltip
/>
</div>
</WithDisplayPropertiesHOC>
{/* target/due date */}
<WithDisplayPropertiesHOC
displayProperties={displayProperties}
displayPropertyKey="due_date"
shouldRenderProperty={() => !isDateRangeEnabled}
>
<div className="h-5" onFocus={handleEventPropagation} onClick={handleEventPropagation}>
<DateDropdown
value={issue?.target_date ?? null}
onChange={handleTargetDate}
minDate={minDate}
placeholder={t("common.order_by.due_date")}
icon={<CalendarCheck2 className="h-3 w-3 flex-shrink-0" />}
buttonVariant={issue.target_date ? "border-with-text" : "border-without-text"}
buttonClassName={shouldHighlightIssueDueDate(issue.target_date, stateDetails?.group) ? "text-red-500" : ""}
clearIconClassName="!text-custom-text-100"
optionsClassName="z-10"
disabled={isReadOnly}
renderByDefault={isMobile}
showTooltip
/>
</div>
</WithDisplayPropertiesHOC>
{/* assignee */}
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="assignee">
<div className="h-5" onFocus={handleEventPropagation} onClick={handleEventPropagation}>
@@ -6,7 +6,7 @@ import { useParams, usePathname } from "next/navigation";
import { useForm, UseFormRegister } from "react-hook-form";
import { PlusIcon } from "lucide-react";
// plane constants
import { EIssueLayoutTypes, EIssueServiceType, ISSUE_CREATED } from "@plane/constants";
import { EIssueLayoutTypes, WORK_ITEM_TRACKER_EVENTS } from "@plane/constants";
// i18n
import { useTranslation } from "@plane/i18n";
import { IProject, TIssue } from "@plane/types";
@@ -137,14 +137,14 @@ export const QuickAddIssueRoot: FC<TQuickAddIssueRoot> = observer((props) => {
await quickAddPromise
.then((res) => {
captureIssueEvent({
eventName: ISSUE_CREATED,
eventName: WORK_ITEM_TRACKER_EVENTS.create,
payload: { ...res, state: "SUCCESS", element: ` ${layout} quick add` },
path: pathname,
});
})
.catch(() => {
captureIssueEvent({
eventName: ISSUE_CREATED,
eventName: WORK_ITEM_TRACKER_EVENTS.create,
payload: { ...payload, state: "FAILED", element: `${layout} quick ad` },
path: pathname,
});
@@ -32,7 +32,7 @@ export const AllIssueLayoutRoot: React.FC<Props> = observer((props: Props) => {
// Store hooks
const {
issuesFilter: { filters, fetchFilters, updateFilters },
issuesFilter: { fetchFilters, updateFilters },
issues: { clear, groupedIssueIds, fetchIssues, fetchNextIssues },
} = useIssues(EIssuesStoreType.GLOBAL);
const { fetchAllGlobalViews, getViewDetailsById } = useGlobalView();
@@ -42,8 +42,7 @@ export const AllIssueLayoutRoot: React.FC<Props> = observer((props: Props) => {
// Derived values
const viewDetails = getViewDetailsById(globalViewId?.toString());
const issueFilters = globalViewId ? filters?.[globalViewId.toString()] : undefined;
const activeLayout: EIssueLayoutTypes | undefined = issueFilters?.displayFilters?.layout;
const activeLayout: EIssueLayoutTypes | undefined = EIssueLayoutTypes.SPREADSHEET;
// Route filters
const routeFilters: { [key: string]: string } = {};

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