Compare commits

..
Author SHA1 Message Date
akshat5302 4abf722ddd fix: update proxy service configuration in docker-compose for improved deployment 2025-06-20 17:48:17 +05:30
akshat5302 ed8fef5cd1 feat: add AWS S3 bucket name to docker-compose configuration 2025-06-20 17:39:32 +05:30
akshat5302 bf8935bf51 Merge branch 'preview' of https://github.com/makeplane/plane into env-update 2025-06-20 17:36:35 +05:30
akshat5302 c2e6881b4c Merge branch 'preview' of https://github.com/makeplane/plane into env-update 2025-06-20 17:31:04 +05:30
akshat5302 4a7ecfe051 Merge branch 'preview' of https://github.com/makeplane/plane into env-update 2024-12-05 18:24:56 +05:30
Manish Gupta c5e5b99ee7 updated install.sh to not use release assets 2024-09-10 13:45:00 +05:30
Manish Gupta 5184ce608b updated branch-build 2024-09-10 12:44:31 +05:30
Manish Gupta f0ddcd7f05 Merge branch 'preview' of github.com:makeplane/plane into env-update 2024-09-10 12:41:38 +05:30
Akshat Jain 54a83ef5a1 add default value for CERT_EMAIL 2024-09-06 12:27:51 +05:30
Manish Gupta 7d4ec00f91 updated AIP 2024-09-03 15:51:02 +05:30
Manish Gupta 085fc16402 AIO updates for LIVE 2024-09-03 13:04:06 +05:30
Manish Gupta bae525eb29 selfhost fix for live 2024-09-03 12:56:43 +05:30
Manish Gupta 607ad3d5ba Merge branch 'preview' of https://github.com/makeplane/plane into env-update 2024-09-03 12:30:00 +05:30
Manish Gupta ee50529f55 Update selfhost README 2024-09-03 10:50:00 +05:30
Manish Gupta 7b1df8ffdd updated selfhost README 2024-09-03 10:46:24 +05:30
Manish Gupta c8c7d4384d update install.sh 2024-08-29 19:02:07 +05:30
Manish Gupta e13c5619d5 Merge branch 'preview' of https://github.com/makeplane/plane into env-update 2024-08-29 16:00:58 +05:30
Manish Gupta 78edbc8dd6 updated build.yml 2024-08-29 16:00:00 +05:30
Manish Gupta 1968242c0d added release assets 2024-08-29 15:41:31 +05:30
Akshat Jain 83a6ba83b7 fixed typo changes 2024-08-28 16:47:45 +05:30
Akshat Jain f02e67a200 fixed envs 2024-08-28 16:22:14 +05:30
Akshat Jain 0741a00ed0 Merge branch 'env-update' of https://github.com/makeplane/plane into env-update 2024-08-28 15:32:33 +05:30
Akshat Jain a6f8d140ee fix: handling localhost as APP_DOMAIN 2024-08-28 15:31:51 +05:30
Akshat Jain 3d12305c6e Update variables.env 2024-08-28 15:27:20 +05:30
Akshat Jain da11073894 fix: handling localhost as APP_DOMAIN 2024-08-28 13:57:34 +05:30
Akshat Jain 99ab3386b5 added envs in variables file 2024-08-26 18:55:11 +05:30
Akshat Jain 779a9c0e47 added caddy setup for with or without SSL 2024-08-26 18:33:19 +05:30
Akshat Jain de2cb6baab Separated environment variables for specific app containers. 2024-08-20 12:57:24 +05:30
133 changed files with 1893 additions and 2476 deletions
+40 -2
View File
@@ -117,6 +117,44 @@ 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
@@ -242,8 +280,8 @@ jobs:
dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }}
docker-image-owner: makeplane
docker-image-name: ${{ needs.branch_build_setup.outputs.dh_img_proxy }}
build-context: ./nginx
dockerfile-path: ./nginx/Dockerfile
build-context: ./caddy
dockerfile-path: ./caddy/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 }}
+8 -2
View File
@@ -11,7 +11,7 @@ WORKDIR /app
RUN yarn global add turbo
COPY . .
RUN turbo prune --scope=web --scope=space --scope=admin --docker
RUN turbo prune --scope=web --scope=space --scope=admin --scope=live --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
RUN yarn turbo run build --filter=web --filter=space --filter=admin --filter=live
# *****************************************************************************
# STAGE 3: Copy the project and start it
@@ -87,6 +87,8 @@ 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
@@ -105,6 +107,10 @@ 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,6 +45,14 @@ 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,6 +29,16 @@ 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"
+1 -2
View File
@@ -39,7 +39,7 @@ from .project import (
ProjectMemberRoleSerializer,
)
from .state import StateSerializer, StateLiteSerializer
from .view import IssueViewSerializer, ViewIssueListSerializer
from .view import IssueViewSerializer
from .cycle import (
CycleSerializer,
CycleIssueSerializer,
@@ -74,7 +74,6 @@ from .issue import (
IssueLinkLiteSerializer,
IssueVersionDetailSerializer,
IssueDescriptionVersionDetailSerializer,
IssueListDetailSerializer,
)
from .module import (
-104
View File
@@ -725,110 +725,6 @@ 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,49 +7,6 @@ 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)
+53 -60
View File
@@ -32,7 +32,6 @@ from plane.app.serializers import (
IssueDetailSerializer,
IssueUserPropertySerializer,
IssueSerializer,
IssueListDetailSerializer,
)
from plane.bgtasks.issue_activities_task import issue_activity
from plane.db.models import (
@@ -47,9 +46,6 @@ from plane.db.models import (
CycleIssue,
UserRecentVisit,
ModuleIssue,
IssueRelation,
IssueAssignee,
IssueLabel,
)
from plane.utils.grouper import (
issue_group_values,
@@ -951,22 +947,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
permission_subquery = (
Issue.issue_objects.filter(
workspace__slug=slug, project_id=project_id, id=OuterRef("id")
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
)
)
# Main issue query
issue = (
Issue.issue_objects.filter(workspace__slug=slug, project_id=project_id)
.filter(
Q(
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
project__project_projectmember__role__gt=ROLE.GUEST.value,
)
| Q(
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
project__project_projectmember__role=ROLE.GUEST.value,
project__guest_view_all_features=True,
)
Q(Exists(project_member_subquery))
| Q(
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
@@ -975,30 +971,7 @@ class IssueDetailEndpoint(BaseAPIView):
created_by=self.request.user,
)
)
.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(),
)
)
.prefetch_related("assignees", "labels", "issue_module__module")
.annotate(
cycle_id=Subquery(
CycleIssue.objects.filter(
@@ -1006,6 +979,43 @@ 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()
@@ -1029,23 +1039,6 @@ 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
@@ -1056,7 +1049,7 @@ class IssueDetailEndpoint(BaseAPIView):
request=request,
order_by=order_by_param,
queryset=(issue),
on_results=lambda issue: IssueListDetailSerializer(
on_results=lambda issue: IssueSerializer(
issue, many=True, fields=self.fields, expand=self.expand
).data,
)
+163 -73
View File
@@ -1,13 +1,8 @@
# Django imports
from django.db.models import (
Exists,
F,
Func,
OuterRef,
Q,
Subquery,
Prefetch,
)
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.utils.decorators import method_decorator
from django.views.decorators.gzip import gzip_page
from django.db import transaction
@@ -18,7 +13,7 @@ from rest_framework.response import Response
# Module imports
from plane.app.permissions import allow_permission, ROLE
from plane.app.serializers import IssueViewSerializer, ViewIssueListSerializer
from plane.app.serializers import IssueViewSerializer
from plane.db.models import (
Issue,
FileAsset,
@@ -30,12 +25,15 @@ from plane.db.models import (
Project,
CycleIssue,
UserRecentVisit,
IssueAssignee,
IssueLabel,
ModuleIssue,
)
from plane.utils.grouper import (
issue_group_values,
issue_on_results,
issue_queryset_grouper,
)
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
@@ -145,28 +143,6 @@ 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(
@@ -176,25 +152,12 @@ class WorkspaceViewIssuesViewSet(BaseViewSet):
.values("count")
)
.filter(workspace__slug=self.kwargs.get("slug"))
.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(),
)
.filter(
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
)
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module")
.annotate(
cycle_id=Subquery(
CycleIssue.objects.filter(
@@ -223,6 +186,43 @@ 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,36 +233,126 @@ 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)
# 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")
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]
)
)
)
# Apply project permission filters to the issue queryset
issue_queryset = issue_queryset.filter(permission_filters)
# 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,
)
# Issue queryset
issue_queryset, order_by_param = order_issue_queryset(
issue_queryset=issue_queryset, order_by_param=order_by_param
)
# 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,
# 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
)
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
+4 -11
View File
@@ -16,7 +16,7 @@ def order_issue_queryset(issue_queryset, order_by_param="-created_at"):
],
output_field=CharField(),
)
).order_by("priority_order", "-created_at")
).order_by("priority_order")
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", "-created_at")
).order_by("state_order")
order_by_param = (
"-state_order" if order_by_param.startswith("-") else "state_order"
)
@@ -55,18 +55,11 @@ 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",
"-created_at",
)
).order_by("-min_values" if order_by_param.startswith("-") else "min_values")
order_by_param = (
"-min_values" if order_by_param.startswith("-") else "min_values"
)
else:
# 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")
issue_queryset = issue_queryset.order_by(order_by_param)
order_by_param = order_by_param
return issue_queryset, order_by_param
+6 -23
View File
@@ -102,7 +102,6 @@ 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 = (
@@ -116,7 +115,6 @@ 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 #
@@ -140,9 +138,9 @@ class OffsetPaginator:
)
# The current page
page = cursor.offset
# The offset - use limit instead of cursor.value for consistent pagination
offset = cursor.offset * limit
stop = offset + limit + 1
# The offset
offset = cursor.offset * cursor.value
stop = offset + (cursor.value or limit) + 1
if self.max_offset is not None and offset >= self.max_offset:
raise BadPaginationError("Pagination offset too large")
@@ -150,21 +148,11 @@ class OffsetPaginator:
raise BadPaginationError("Pagination offset cannot be negative")
results = queryset[offset:stop]
# Only slice from the end if we're going backwards (previous page)
if cursor.value != limit and cursor.is_prev:
if cursor.value != limit:
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, len(results) > limit)
next_cursor = Cursor(limit, page + 1, False, results.count() > limit)
# If the page is greater than 0, then set the previous cursor
prev_cursor = Cursor(limit, page - 1, True, page > 0)
@@ -176,7 +164,7 @@ class OffsetPaginator:
results = self.on_results(results)
# Count the queryset
count = total_count
count = queryset.count()
# Optionally, calculate the total count and max_hits if needed
max_hits = math.ceil(count / limit)
@@ -208,7 +196,6 @@ class GroupedOffsetPaginator(OffsetPaginator):
group_by_field_name,
group_by_fields,
count_filter,
total_count_queryset=None,
*args,
**kwargs,
):
@@ -417,7 +404,6 @@ class SubGroupedOffsetPaginator(OffsetPaginator):
group_by_fields,
sub_group_by_fields,
count_filter,
total_count_queryset=None,
*args,
**kwargs,
):
@@ -708,7 +694,6 @@ 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"""
@@ -734,8 +719,6 @@ 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
@@ -0,0 +1,34 @@
(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
@@ -0,0 +1,9 @@
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
@@ -0,0 +1,11 @@
#!/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
+20 -6
View File
@@ -58,7 +58,7 @@ Installing plane is a very easy and minimal step process.
### Downloading Latest Release
```
mkdir plane-selfhost
mkdir -p plane-selfhost && cd plane-selfhost
cd plane-selfhost
```
@@ -144,11 +144,15 @@ 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>_.
> `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`)
> `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`)
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.
@@ -172,6 +176,8 @@ 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)
@@ -207,6 +213,8 @@ 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)
@@ -253,6 +261,8 @@ 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)
@@ -297,6 +307,8 @@ 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
@@ -465,6 +477,8 @@ 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
+7 -1
View File
@@ -17,6 +17,12 @@ 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:
@@ -26,5 +32,5 @@ services:
proxy:
image: ${DOCKERHUB_USER:-local}/plane-proxy:${APP_RELEASE:-latest}
build:
context: ./nginx
context: ./caddy
dockerfile: ./Dockerfile
+29 -13
View File
@@ -24,9 +24,14 @@ x-aws-s3-env: &aws-s3-env
AWS_S3_BUCKET_NAME: ${AWS_S3_BUCKET_NAME:-uploads}
x-proxy-env: &proxy-env
NGINX_PORT: ${NGINX_PORT:-80}
BUCKET_NAME: ${AWS_S3_BUCKET_NAME:-uploads}
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}
BUCKET_NAME: ${AWS_S3_BUCKET_NAME:-uploads}
x-mq-env: &mq-env # RabbitMQ Settings
RABBITMQ_HOST: ${RABBITMQ_HOST:-plane-mq}
@@ -212,22 +217,31 @@ services:
# Comment this if you already have a reverse proxy running
proxy:
image: artifacts.plane.so/makeplane/plane-proxy:${APP_RELEASE:-stable}
ports:
- target: 80
published: ${NGINX_PORT:-80}
protocol: tcp
mode: host
environment:
<<: *proxy-env
image: artifacts.plane.so/makeplane/plane-proxy:${APP_RELEASE_VERSION}
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
- web
- api
- space
- admin
- live
volumes:
pgdata:
@@ -237,4 +251,6 @@ volumes:
logs_worker:
logs_beat-worker:
logs_migrator:
caddy_config:
caddy_data:
rabbitmq_data:
+5 -2
View File
@@ -1,6 +1,7 @@
#!/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
@@ -177,11 +178,13 @@ 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"
@@ -423,7 +426,7 @@ function upgrade() {
stopServices
echo
echo "***** DOWNLOADING STABLE VERSION ****"
echo "***** DOWNLOADING $APP_RELEASE VERSION ****"
install
echo "***** PLEASE VALIDATE AND START SERVICES ****"
+9 -2
View File
@@ -1,5 +1,6 @@
APP_DOMAIN=localhost
APP_RELEASE=stable
SSL=false
WEB_REPLICAS=1
SPACE_REPLICAS=1
@@ -9,10 +10,11 @@ WORKER_REPLICAS=1
BEAT_WORKER_REPLICAS=1
LIVE_REPLICAS=1
NGINX_PORT=80
LISTEN_PORT=80
LISTEN_SSL_PORT=443
WEB_URL=http://${APP_DOMAIN}
DEBUG=0
CORS_ALLOWED_ORIGINS=http://${APP_DOMAIN}
CORS_ALLOWED_ORIGINS=http://${APP_DOMAIN},https://${APP_DOMAIN}
API_BASE_URL=http://api:8000
#DB SETTINGS
@@ -30,6 +32,11 @@ 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
@@ -0,0 +1,238 @@
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";
@@ -1,258 +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 === 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",
};
@@ -1 +0,0 @@
export * from "./core";
-19
View File
@@ -1,19 +0,0 @@
/**
* @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/types";
import { type CustomImageExtensionStorage } from "@/extensions/custom-image";
import { type CustomLinkStorage } from "@/extensions/custom-link";
import { type ImageExtensionStorage } from "@/extensions/image";
import { type MentionExtensionStorage } from "@/extensions/mentions";
@@ -57,7 +57,7 @@ export const TextAlignmentSelector: React.FC<Props> = (props) => {
];
return (
<div className="flex items-center gap-0.5 px-1.5 py-1">
<div className="flex gap-0.5 px-2">
{textAlignmentOptions.map((item) => (
<button
key={item.renderKey}
@@ -67,17 +67,13 @@ export const TextAlignmentSelector: React.FC<Props> = (props) => {
item.command();
}}
className={cn(
"size-7 grid place-items-center rounded text-custom-text-300 hover:bg-custom-background-80 active:bg-custom-background-80 transition-all duration-200 ease-in-out",
"size-7 grid place-items-center rounded text-custom-text-300 hover:bg-custom-background-80 active:bg-custom-background-80 transition-colors",
{
"bg-custom-background-80 text-custom-text-100": item.isActive(),
}
)}
>
<item.icon
className={cn("size-4 transition-transform duration-200", {
"text-custom-text-100": item.isActive(),
})}
/>
<item.icon className="size-4" />
</button>
))}
</div>
@@ -1,535 +0,0 @@
import {
type Middleware,
arrow,
autoPlacement,
computePosition,
flip,
hide,
inline,
offset,
shift,
size,
} from "@floating-ui/dom";
import { type Editor, isTextSelection, posToDOMRect } from "@tiptap/core";
import { Plugin, PluginKey, type EditorState, type PluginView } from "@tiptap/pm/state";
import { CellSelection } from "@tiptap/pm/tables";
import type { EditorView } from "@tiptap/pm/view";
function combineDOMRects(rect1: DOMRect, rect2: DOMRect): DOMRect {
const top = Math.min(rect1.top, rect2.top);
const bottom = Math.max(rect1.bottom, rect2.bottom);
const left = Math.min(rect1.left, rect2.left);
const right = Math.max(rect1.right, rect2.right);
const width = right - left;
const height = bottom - top;
const x = left;
const y = top;
return new DOMRect(x, y, width, height);
}
export interface BubbleMenuPluginProps {
/**
* The plugin key.
* @type {PluginKey | string}
* @default 'bubbleMenu'
*/
pluginKey: PluginKey | string;
/**
* The editor instance.
*/
editor: Editor;
/**
* The DOM element that contains your menu.
* @type {HTMLElement}
* @default null
*/
element: HTMLElement;
/**
* The delay in milliseconds before the menu should be updated.
* This can be useful to prevent performance issues.
* @type {number}
* @default 250
*/
updateDelay?: number;
/**
* The delay in milliseconds before the menu position should be updated on window resize.
* This can be useful to prevent performance issues.
* @type {number}
* @default 60
*/
resizeDelay?: number;
/**
* A function that determines whether the menu should be shown or not.
* If this function returns `false`, the menu will be hidden, otherwise it will be shown.
*/
shouldShow:
| ((props: {
editor: Editor;
element: HTMLElement;
view: EditorView;
state: EditorState;
oldState?: EditorState;
from: number;
to: number;
}) => boolean)
| null;
/**
* FloatingUI options.
*/
options?: {
strategy?: "absolute" | "fixed";
placement?:
| "top"
| "right"
| "bottom"
| "left"
| "top-start"
| "top-end"
| "right-start"
| "right-end"
| "bottom-start"
| "bottom-end"
| "left-start"
| "left-end";
offset?: Parameters<typeof offset>[0] | boolean;
flip?: Parameters<typeof flip>[0] | boolean;
shift?: Parameters<typeof shift>[0] | boolean;
arrow?: Parameters<typeof arrow>[0] | false;
size?: Parameters<typeof size>[0] | boolean;
autoPlacement?: Parameters<typeof autoPlacement>[0] | boolean;
hide?: Parameters<typeof hide>[0] | boolean;
inline?: Parameters<typeof inline>[0] | boolean;
onShow?: () => void;
onHide?: () => void;
onUpdate?: () => void;
onDestroy?: () => void;
};
}
export type BubbleMenuViewProps = BubbleMenuPluginProps & {
view: EditorView;
};
export class BubbleMenuView implements PluginView {
public editor: Editor;
public element: HTMLElement;
public view: EditorView;
public preventHide = false;
public updateDelay: number;
public resizeDelay: number;
private updateDebounceTimer: number | undefined;
private resizeDebounceTimer: number | undefined;
private isVisible = false;
private isSelecting = false;
private selectionStarted = false;
private floatingUIOptions: NonNullable<BubbleMenuPluginProps["options"]> = {
strategy: "absolute",
placement: "top",
offset: 8,
flip: {},
shift: {},
arrow: false,
size: false,
autoPlacement: false,
hide: false,
inline: false,
onShow: undefined,
onHide: undefined,
onUpdate: undefined,
onDestroy: undefined,
};
public shouldShow: Exclude<BubbleMenuPluginProps["shouldShow"], null> = ({ view, state, from, to }) => {
const { doc, selection } = state;
const { empty } = selection;
// Sometime check for `empty` is not enough.
// Doubleclick an empty paragraph returns a node size of 2.
// So we check also for an empty text size.
const isEmptyTextBlock = !doc.textBetween(from, to).length && isTextSelection(state.selection);
// When clicking on a element inside the bubble menu the editor "blur" event
// is called and the bubble menu item is focussed. In this case we should
// consider the menu as part of the editor and keep showing the menu
const isChildOfMenu = this.element.contains(document.activeElement);
const hasEditorFocus = view.hasFocus() || isChildOfMenu;
if (!hasEditorFocus || empty || isEmptyTextBlock || !this.editor.isEditable) {
return false;
}
return true;
};
get middlewares() {
const middlewares: Middleware[] = [];
if (this.floatingUIOptions.flip) {
middlewares.push(
flip(typeof this.floatingUIOptions.flip !== "boolean" ? this.floatingUIOptions.flip : undefined)
);
}
if (this.floatingUIOptions.shift) {
middlewares.push(
shift(typeof this.floatingUIOptions.shift !== "boolean" ? this.floatingUIOptions.shift : undefined)
);
}
if (this.floatingUIOptions.offset) {
middlewares.push(
offset(typeof this.floatingUIOptions.offset !== "boolean" ? this.floatingUIOptions.offset : undefined)
);
}
if (this.floatingUIOptions.arrow) {
middlewares.push(arrow(this.floatingUIOptions.arrow));
}
if (this.floatingUIOptions.size) {
middlewares.push(
size(typeof this.floatingUIOptions.size !== "boolean" ? this.floatingUIOptions.size : undefined)
);
}
if (this.floatingUIOptions.autoPlacement) {
middlewares.push(
autoPlacement(
typeof this.floatingUIOptions.autoPlacement !== "boolean" ? this.floatingUIOptions.autoPlacement : undefined
)
);
}
if (this.floatingUIOptions.hide) {
middlewares.push(
hide(typeof this.floatingUIOptions.hide !== "boolean" ? this.floatingUIOptions.hide : undefined)
);
}
if (this.floatingUIOptions.inline) {
middlewares.push(
inline(typeof this.floatingUIOptions.inline !== "boolean" ? this.floatingUIOptions.inline : undefined)
);
}
return middlewares;
}
constructor({
editor,
element,
view,
updateDelay = 250,
resizeDelay = 60,
shouldShow,
options,
}: BubbleMenuViewProps) {
this.editor = editor;
this.element = element;
this.view = view;
this.updateDelay = updateDelay;
this.resizeDelay = resizeDelay;
this.floatingUIOptions = {
...this.floatingUIOptions,
...options,
};
if (shouldShow) {
this.shouldShow = shouldShow;
}
// Event listeners
this.element.addEventListener("mousedown", this.mousedownHandler, { capture: true });
this.view.dom.addEventListener("dragstart", this.dragstartHandler);
this.editor.on("focus", this.focusHandler);
this.editor.on("blur", this.blurHandler);
window.addEventListener("resize", this.resizeHandler);
// Add mousedown/mouseup listeners for selection tracking
this.view.dom.addEventListener("mousedown", this.editorMousedownHandler);
this.view.dom.addEventListener("mouseup", this.editorMouseupHandler);
this.update(view, view.state);
// Don't show initially even if there's a selection
// Wait for user interaction
}
mousedownHandler = () => {
this.preventHide = true;
};
dragstartHandler = () => {
this.hide();
};
editorMousedownHandler = () => {
this.isSelecting = true;
this.selectionStarted = true;
// Hide menu when starting a new selection
this.hide();
};
editorMouseupHandler = () => {
if (!this.isSelecting) {
return;
}
this.isSelecting = false;
// Use setTimeout to ensure selection is finalized
setTimeout(() => {
const shouldShow = this.getShouldShow();
if (shouldShow && this.selectionStarted) {
this.updatePosition();
this.show();
}
this.selectionStarted = false;
}, 0);
};
/**
* Handles the window resize event to update the position of the bubble menu.
* It uses a debounce mechanism to prevent excessive updates.
* The delay is defined by the `resizeDelay` property.
*/
resizeHandler = () => {
if (this.resizeDebounceTimer) {
clearTimeout(this.resizeDebounceTimer);
}
this.resizeDebounceTimer = window.setTimeout(() => {
if (this.isVisible) {
this.updatePosition();
}
}, this.resizeDelay);
};
focusHandler = () => {
// we use `setTimeout` to make sure `selection` is already updated
setTimeout(() => this.update(this.editor.view));
};
blurHandler = ({ event }: { event: FocusEvent }) => {
if (this.preventHide) {
this.preventHide = false;
return;
}
if (event?.relatedTarget && this.element.parentNode?.contains(event.relatedTarget as Node)) {
return;
}
if (event?.relatedTarget === this.editor.view.dom) {
return;
}
this.hide();
};
updatePosition() {
const { selection } = this.editor.state;
let virtualElement = {
getBoundingClientRect: () => posToDOMRect(this.view, selection.from, selection.to),
};
// this is a special case for cell selections
if (selection instanceof CellSelection) {
const { $anchorCell, $headCell } = selection;
const from = $anchorCell ? $anchorCell.pos : $headCell!.pos;
const to = $headCell ? $headCell.pos : $anchorCell!.pos;
const fromDOM = this.view.nodeDOM(from);
const toDOM = this.view.nodeDOM(to);
if (!fromDOM || !toDOM) {
return;
}
const clientRect =
fromDOM === toDOM
? (fromDOM as HTMLElement).getBoundingClientRect()
: combineDOMRects(
(fromDOM as HTMLElement).getBoundingClientRect(),
(toDOM as HTMLElement).getBoundingClientRect()
);
virtualElement = {
getBoundingClientRect: () => clientRect,
};
}
computePosition(virtualElement, this.element, {
placement: this.floatingUIOptions.placement,
strategy: this.floatingUIOptions.strategy,
middleware: this.middlewares,
}).then(({ x, y, strategy }) => {
this.element.style.width = "max-content";
this.element.style.position = strategy;
this.element.style.left = `${x}px`;
this.element.style.top = `${y}px`;
if (this.isVisible && this.floatingUIOptions.onUpdate) {
this.floatingUIOptions.onUpdate();
}
});
}
update(view: EditorView, oldState?: EditorState) {
const { state } = view;
const hasValidSelection = state.selection.from !== state.selection.to;
// Don't update while user is actively selecting
if (this.isSelecting) {
return;
}
if (this.updateDelay > 0 && hasValidSelection) {
this.handleDebouncedUpdate(view, oldState);
return;
}
const selectionChanged = !oldState?.selection.eq(view.state.selection);
const docChanged = !oldState?.doc.eq(view.state.doc);
this.updateHandler(view, selectionChanged, docChanged, oldState);
}
handleDebouncedUpdate = (view: EditorView, oldState?: EditorState) => {
const selectionChanged = !oldState?.selection.eq(view.state.selection);
const docChanged = !oldState?.doc.eq(view.state.doc);
if (!selectionChanged && !docChanged) {
return;
}
if (this.updateDebounceTimer) {
clearTimeout(this.updateDebounceTimer);
}
this.updateDebounceTimer = window.setTimeout(() => {
this.updateHandler(view, selectionChanged, docChanged, oldState);
}, this.updateDelay);
};
getShouldShow(oldState?: EditorState) {
const { state } = this.view;
const { selection } = state;
// support for CellSelections
const { ranges } = selection;
const from = Math.min(...ranges.map((range) => range.$from.pos));
const to = Math.max(...ranges.map((range) => range.$to.pos));
const shouldShow = this.shouldShow?.({
editor: this.editor,
element: this.element,
view: this.view,
state,
oldState,
from,
to,
});
return shouldShow;
}
updateHandler = (view: EditorView, selectionChanged: boolean, docChanged: boolean, oldState?: EditorState) => {
const { composing } = view;
const isSame = !selectionChanged && !docChanged;
if (composing || isSame || this.isSelecting) {
return;
}
const shouldShow = this.getShouldShow(oldState);
if (!shouldShow) {
this.hide();
return;
}
// Only update position for already visible menu
// New selections are handled by mouseup
if (this.isVisible) {
this.updatePosition();
}
};
show() {
if (this.isVisible) {
return;
}
this.element.style.visibility = "visible";
this.element.style.opacity = "1";
// attach to editor's parent element
this.view.dom.parentElement?.appendChild(this.element);
if (this.floatingUIOptions.onShow) {
this.floatingUIOptions.onShow();
}
this.isVisible = true;
}
hide() {
if (!this.isVisible) {
return;
}
this.element.style.visibility = "hidden";
this.element.style.opacity = "0";
// remove from the parent element
this.element.remove();
if (this.floatingUIOptions.onHide) {
this.floatingUIOptions.onHide();
}
this.isVisible = false;
}
destroy() {
this.hide();
this.element.removeEventListener("mousedown", this.mousedownHandler, { capture: true });
this.view.dom.removeEventListener("dragstart", this.dragstartHandler);
this.view.dom.removeEventListener("mousedown", this.editorMousedownHandler);
this.view.dom.removeEventListener("mouseup", this.editorMouseupHandler);
window.removeEventListener("resize", this.resizeHandler);
this.editor.off("focus", this.focusHandler);
this.editor.off("blur", this.blurHandler);
if (this.floatingUIOptions.onDestroy) {
this.floatingUIOptions.onDestroy();
}
}
}
export const BubbleMenuPlugin = (options: BubbleMenuPluginProps) =>
new Plugin({
key: typeof options.pluginKey === "string" ? new PluginKey(options.pluginKey) : options.pluginKey,
view: (view) => new BubbleMenuView({ view, ...options }),
});
@@ -1,67 +0,0 @@
import React, { useEffect, useRef } from "react";
import { createPortal } from "react-dom";
import { type BubbleMenuPluginProps, BubbleMenuPlugin } from "./bubble-menu-plugin";
type Optional<T, K extends keyof T> = Pick<Partial<T>, K> & Omit<T, K>;
export type BubbleMenuProps = Optional<Omit<Optional<BubbleMenuPluginProps, "pluginKey">, "element">, "editor"> &
React.HTMLAttributes<HTMLDivElement>;
export const BubbleMenu = React.forwardRef<HTMLDivElement, BubbleMenuProps>(
(
{ pluginKey = "bubbleMenu", editor, updateDelay, resizeDelay, shouldShow = null, options, children, ...restProps },
ref
) => {
const menuEl = useRef(document.createElement("div"));
if (typeof ref === "function") {
ref(menuEl.current);
} else if (ref) {
ref.current = menuEl.current;
}
useEffect(() => {
const bubbleMenuElement = menuEl.current;
bubbleMenuElement.style.visibility = "hidden";
bubbleMenuElement.style.position = "absolute";
if (editor?.isDestroyed) {
return;
}
const attachToEditor = editor;
if (!attachToEditor) {
console.warn(
"BubbleMenu component is not rendered inside of an editor component or does not have editor prop."
);
return;
}
const plugin = BubbleMenuPlugin({
updateDelay,
resizeDelay,
editor: attachToEditor,
element: bubbleMenuElement,
pluginKey,
shouldShow,
options,
});
attachToEditor.registerPlugin(plugin);
return () => {
attachToEditor.unregisterPlugin(pluginKey);
window.requestAnimationFrame(() => {
if (bubbleMenuElement.parentNode) {
bubbleMenuElement.parentNode.removeChild(bubbleMenuElement);
}
});
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [editor]);
return createPortal(<div {...restProps}>{children}</div>, menuEl.current);
}
);
@@ -1,45 +0,0 @@
import { Extension } from "@tiptap/core";
import { BubbleMenuPlugin, type BubbleMenuPluginProps } from "./bubble-menu-plugin";
export type BubbleMenuOptions = Omit<BubbleMenuPluginProps, "editor" | "element"> & {
/**
* The DOM element that contains your menu.
* @type {HTMLElement}
* @default null
*/
element: HTMLElement | null;
};
/**
* This extension allows you to create a bubble menu.
* @see https://tiptap.dev/api/extensions/bubble-menu
*/
export const BubbleMenu = Extension.create<BubbleMenuOptions>({
name: "bubbleMenu",
addOptions() {
return {
element: null,
pluginKey: "bubbleMenu",
updateDelay: undefined,
shouldShow: null,
};
},
addProseMirrorPlugins() {
if (!this.options.element) {
return [];
}
return [
BubbleMenuPlugin({
pluginKey: this.options.pluginKey,
editor: this.editor,
element: this.options.element,
updateDelay: this.options.updateDelay,
shouldShow: this.options.shouldShow,
}),
];
},
});
@@ -1,4 +1,3 @@
import { Popover } from "@headlessui/react";
import { Editor } from "@tiptap/react";
import { ALargeSmall, Ban } from "lucide-react";
import { Dispatch, FC, SetStateAction } from "react";
@@ -8,7 +7,6 @@ import { cn } from "@plane/utils";
import { COLORS_LIST } from "@/constants/common";
// helpers
import { BackgroundColorItem, TextColorItem } from "../menu-items";
import { EditorStateType } from "./root";
type Props = {
@@ -19,103 +17,93 @@ type Props = {
};
export const BubbleMenuColorSelector: FC<Props> = (props) => {
const { editor, editorState, isOpen, setIsOpen } = props;
const { editor, isOpen, setIsOpen, editorState } = props;
const activeTextColor = editorState.color;
const activeBackgroundColor = editorState.backgroundColor;
return (
<Popover as="div" className="h-7 px-2">
<Popover.Button
as="button"
<div className="relative h-full">
<button
type="button"
className={cn("h-full", {
"outline-none": isOpen,
})}
onClick={() => setIsOpen((prev) => !prev)}
onClick={(e) => {
setIsOpen(!isOpen);
e.stopPropagation();
}}
className="flex items-center gap-1 h-full whitespace-nowrap px-3 text-sm font-medium text-custom-text-300 hover:bg-custom-background-80 active:bg-custom-background-80 rounded transition-colors"
>
<span>Color</span>
<span
className={cn(
"h-full px-2 text-custom-text-300 text-sm flex items-center gap-1.5 rounded hover:bg-custom-background-80",
"flex-shrink-0 size-6 grid place-items-center rounded border-[0.5px] border-custom-border-300",
{
"text-custom-text-100 bg-custom-background-80": isOpen,
"bg-custom-background-100": !activeBackgroundColor,
}
)}
style={{
backgroundColor: activeBackgroundColor ? activeBackgroundColor.backgroundColor : "transparent",
}}
>
Color
<span
className={cn(
"flex-shrink-0 size-6 grid place-items-center rounded border-[0.5px] border-custom-border-300",
{
"bg-custom-background-100": !activeBackgroundColor,
}
)}
<ALargeSmall
className={cn("size-3.5", {
"text-custom-text-100": !activeTextColor,
})}
style={{
backgroundColor: activeBackgroundColor ? activeBackgroundColor.backgroundColor : "transparent",
color: activeTextColor ? activeTextColor.textColor : "inherit",
}}
>
<ALargeSmall
className={cn("size-3.5", {
"text-custom-text-100": !activeTextColor,
})}
style={{
color: activeTextColor ? activeTextColor.textColor : "inherit",
}}
/>
</span>
/>
</span>
</Popover.Button>
<Popover.Panel
as="div"
className="fixed z-20 mt-1 rounded-md border-[0.5px] border-custom-border-300 bg-custom-background-100 shadow-custom-shadow-rg p-2 space-y-2"
>
<div className="space-y-1.5">
<p className="text-xs text-custom-text-300 font-semibold">Text colors</p>
<div className="flex items-center gap-2">
{COLORS_LIST.map((color) => (
</button>
{isOpen && (
<section className="fixed top-full z-[99999] mt-1 rounded-md border-[0.5px] border-custom-border-300 bg-custom-background-100 p-2 space-y-2 shadow-custom-shadow-rg animate-in fade-in slide-in-from-top-1">
<div className="space-y-1.5">
<p className="text-xs text-custom-text-300 font-semibold">Text colors</p>
<div className="flex items-center gap-2">
{COLORS_LIST.map((color) => (
<button
key={color.key}
type="button"
className="flex-shrink-0 size-6 rounded border-[0.5px] border-custom-border-400 hover:opacity-60 transition-opacity"
style={{
backgroundColor: color.textColor,
}}
onClick={() => TextColorItem(editor).command({ color: color.key })}
/>
))}
<button
key={color.key}
type="button"
className="flex-shrink-0 size-6 rounded border-[0.5px] border-custom-border-400 hover:opacity-60 transition-opacity"
style={{
backgroundColor: color.textColor,
}}
onClick={() => TextColorItem(editor).command({ color: color.key })}
/>
))}
<button
type="button"
className="flex-shrink-0 size-6 grid place-items-center rounded text-custom-text-300 border-[0.5px] border-custom-border-400 hover:bg-custom-background-80 transition-colors"
onClick={() => TextColorItem(editor).command({ color: undefined })}
>
<Ban className="size-4" />
</button>
className="flex-shrink-0 size-6 grid place-items-center rounded text-custom-text-300 border-[0.5px] border-custom-border-400 hover:bg-custom-background-80 transition-colors"
onClick={() => TextColorItem(editor).command({ color: undefined })}
>
<Ban className="size-4" />
</button>
</div>
</div>
</div>
<div className="space-y-1.5">
<p className="text-xs text-custom-text-300 font-semibold">Background colors</p>
<div className="flex items-center gap-2">
{COLORS_LIST.map((color) => (
<div className="space-y-1.5">
<p className="text-xs text-custom-text-300 font-semibold">Background colors</p>
<div className="flex items-center gap-2">
{COLORS_LIST.map((color) => (
<button
key={color.key}
type="button"
className="flex-shrink-0 size-6 rounded border-[0.5px] border-custom-border-400 hover:opacity-60 transition-opacity"
style={{
backgroundColor: color.backgroundColor,
}}
onClick={() => BackgroundColorItem(editor).command({ color: color.key })}
/>
))}
<button
key={color.key}
type="button"
className="flex-shrink-0 size-6 rounded border-[0.5px] border-custom-border-400 hover:opacity-60 transition-opacity"
style={{
backgroundColor: color.backgroundColor,
}}
onClick={() => BackgroundColorItem(editor).command({ color: color.key })}
/>
))}
<button
type="button"
className="flex-shrink-0 size-6 grid place-items-center rounded text-custom-text-300 border-[0.5px] border-custom-border-400 hover:bg-custom-background-80 transition-colors"
onClick={() => BackgroundColorItem(editor).command({ color: undefined })}
>
<Ban className="size-4" />
</button>
className="flex-shrink-0 size-6 grid place-items-center rounded text-custom-text-300 border-[0.5px] border-custom-border-400 hover:bg-custom-background-80 transition-colors"
onClick={() => BackgroundColorItem(editor).command({ color: undefined })}
>
<Ban className="size-4" />
</button>
</div>
</div>
</div>
</Popover.Panel>
</Popover>
</section>
)}
</div>
);
};
@@ -1,7 +1,6 @@
import { Popover } from "@headlessui/react";
import { Editor } from "@tiptap/core";
import { Check, Link, Trash2 } from "lucide-react";
import { FC, useCallback, useRef, useState, Dispatch, SetStateAction } from "react";
import { Dispatch, FC, SetStateAction, useCallback, useRef, useState } from "react";
// plane imports
import { cn } from "@plane/utils";
// constants
@@ -39,77 +38,80 @@ export const BubbleMenuLinkSelector: FC<Props> = (props) => {
}, [editor, inputRef, setIsOpen]);
return (
<Popover as="div" className="h-7 px-2">
<Popover.Button
as="button"
<div className="relative h-full">
<button
type="button"
className={cn("h-full", {
"outline-none": isOpen,
})}
onClick={() => setIsOpen((prev) => !prev)}
className={cn(
"h-full flex items-center gap-1 px-3 text-sm font-medium text-custom-text-300 hover:bg-custom-background-80 active:bg-custom-background-80 rounded transition-colors",
{
"bg-custom-background-80": isOpen,
"text-custom-text-100": editor.isActive(CORE_EXTENSIONS.CUSTOM_LINK),
}
)}
onClick={(e) => {
setIsOpen(!isOpen);
e.stopPropagation();
}}
>
<span
className={cn(
"h-full px-2 text-custom-text-300 text-sm flex items-center gap-1.5 rounded hover:bg-custom-background-80",
{
"text-custom-text-100 bg-custom-background-80": isOpen || editor.isActive(CORE_EXTENSIONS.CUSTOM_LINK),
}
)}
>
Link
<Link className="flex-shrink-0 size-3" />
</span>
</Popover.Button>
<Popover.Panel as="div" className="fixed z-20 mt-1 w-60 rounded bg-custom-background-100 shadow-custom-shadow-rg">
<div
className={cn("flex rounded border border-custom-border-300 transition-colors", {
"border-red-500": error,
})}
>
<input
ref={inputRef}
type="url"
placeholder="Enter or paste a link"
onClick={(e) => e.stopPropagation()}
className="flex-1 border-r border-custom-border-300 bg-custom-background-100 py-2 px-1.5 text-xs outline-none placeholder:text-custom-text-400 rounded"
defaultValue={editor.getAttributes("link").href || ""}
onKeyDown={(e) => {
setError(false);
if (e.key === "Enter") {
e.preventDefault();
handleLinkSubmit();
}
}}
onFocus={() => setError(false)}
autoFocus
/>
{editor.getAttributes("link").href ? (
<button
type="button"
className="grid place-items-center rounded-sm p-1 text-red-500 hover:bg-red-500/20 transition-all"
onClick={(e) => {
unsetLinkEditor(editor);
setIsOpen(false);
e.stopPropagation();
Link
<Link className="flex-shrink-0 size-3" />
</button>
{isOpen && (
<div className="fixed top-full z-[99999] mt-1 w-60 animate-in fade-in slide-in-from-top-1 rounded bg-custom-background-100 shadow-custom-shadow-rg">
<div
className={cn("flex rounded border border-custom-border-300 transition-colors", {
"border-red-500": error,
})}
>
<input
ref={inputRef}
type="url"
placeholder="Enter or paste a link"
onClick={(e) => e.stopPropagation()}
className="flex-1 border-r border-custom-border-300 bg-custom-background-100 py-2 px-1.5 text-xs outline-none placeholder:text-custom-text-400 rounded"
defaultValue={editor.getAttributes("link").href || ""}
onKeyDown={(e) => {
setError(false);
if (e.key === "Enter") {
e.preventDefault();
handleLinkSubmit();
}
}}
>
<Trash2 className="size-4" />
</button>
) : (
<button
type="button"
className="h-full aspect-square grid place-items-center p-1 rounded-sm text-custom-text-300 hover:bg-custom-background-80 transition-all"
onClick={(e) => {
e.stopPropagation();
handleLinkSubmit();
}}
>
<Check className="size-4" />
</button>
onFocus={() => setError(false)}
autoFocus
/>
{editor.getAttributes("link").href ? (
<button
type="button"
className="grid place-items-center rounded-sm p-1 text-red-500 hover:bg-red-500/20 transition-all"
onClick={(e) => {
unsetLinkEditor(editor);
setIsOpen(false);
e.stopPropagation();
}}
>
<Trash2 className="size-4" />
</button>
) : (
<button
type="button"
className="h-full aspect-square grid place-items-center p-1 rounded-sm text-custom-text-300 hover:bg-custom-background-80 transition-all"
onClick={(e) => {
e.stopPropagation();
handleLinkSubmit();
}}
>
<Check className="size-4" />
</button>
)}
</div>
{error && (
<p className="text-xs text-red-500 my-1 px-2 pointer-events-none animate-in fade-in slide-in-from-top-0">
Please enter a valid URL
</p>
)}
</div>
{error && <p className="text-xs text-red-500 my-1 px-2 pointer-events-none">Please enter a valid URL</p>}
</Popover.Panel>
</Popover>
)}
</div>
);
};
@@ -1,8 +1,8 @@
import { Editor } from "@tiptap/react";
import { Check, ChevronDown } from "lucide-react";
import { FC, Dispatch, SetStateAction } from "react";
// plane imports
import { CustomMenu } from "@plane/ui";
import { Dispatch, FC, SetStateAction } from "react";
// plane utils
import { cn } from "@plane/utils";
// components
import {
BulletListItem,
@@ -29,7 +29,7 @@ type Props = {
};
export const BubbleMenuNodeSelector: FC<Props> = (props) => {
const { editor, setIsOpen } = props;
const { editor, isOpen, setIsOpen } = props;
const items: EditorMenuItem<TEditorCommands>[] = [
TextItem(editor),
@@ -51,36 +51,45 @@ export const BubbleMenuNodeSelector: FC<Props> = (props) => {
};
return (
<div className="px-1.5 py-1">
<CustomMenu
customButton={
<span className="text-custom-text-300 text-sm border-[0.5px] border-custom-border-300 hover:bg-custom-background-80 h-7 w-24 rounded px-2 flex items-center justify-between gap-2 whitespace-nowrap text-left">
{activeItem?.name || "Text"}
<ChevronDown className="flex-shrink-0 size-3" />
</span>
}
placement="bottom-start"
closeOnSelect
maxHeight="lg"
<div className="relative h-full">
<button
type="button"
onClick={(e) => {
setIsOpen(!isOpen);
e.stopPropagation();
}}
className="flex items-center gap-1 h-full whitespace-nowrap px-3 text-sm font-medium text-custom-text-300 hover:bg-custom-background-80 active:bg-custom-background-80 rounded transition-colors"
>
{items.map((item) => (
<CustomMenu.MenuItem
key={item.name}
className="flex items-center justify-between gap-2"
onClick={(e) => {
item.command();
setIsOpen(false);
e.stopPropagation();
}}
>
<span className="flex items-center gap-2">
<item.icon className="size-3" />
{item.name}
</span>
{activeItem?.name === item.name && <Check className="size-3 text-custom-text-300 flex-shrink-0" />}
</CustomMenu.MenuItem>
))}
</CustomMenu>
<span>{activeItem?.name}</span>
<ChevronDown className="flex-shrink-0 size-3" />
</button>
{isOpen && (
<section className="fixed top-full z-[99999] mt-1 flex w-48 flex-col overflow-hidden rounded-md border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 shadow-custom-shadow-rg animate-in fade-in slide-in-from-top-1">
{items.map((item) => (
<button
key={item.name}
type="button"
onClick={(e) => {
item.command();
setIsOpen(false);
e.stopPropagation();
}}
className={cn(
"flex items-center justify-between rounded px-1 py-1.5 text-sm text-custom-text-200 hover:bg-custom-background-80",
{
"bg-custom-background-80": activeItem.name === item.name,
}
)}
>
<div className="flex items-center space-x-2">
<item.icon className="size-3 flex-shrink-0" />
<span>{item.name}</span>
</div>
{activeItem.name === item.name && <Check className="size-3 text-custom-text-300 flex-shrink-0" />}
</button>
))}
</section>
)}
</div>
);
};
@@ -1,36 +1,37 @@
import { isNodeSelection, type Editor } from "@tiptap/core";
import { useEditorState } from "@tiptap/react";
import { useState } from "react";
import { BubbleMenu, BubbleMenuProps, Editor, isNodeSelection, useEditorState } from "@tiptap/react";
import { FC, useEffect, useState, useRef } from "react";
// plane utils
import { cn } from "@plane/utils";
import { COLORS_LIST } from "@/constants/common";
import { CORE_EXTENSIONS } from "@/constants/extension";
import { isCellSelection } from "@/extensions/table/table/utilities/is-cell-selection";
import { TEditorCommands } from "@/types";
// components
import {
TextColorItem,
BackgroundColorItem,
BoldItem,
BubbleMenuColorSelector,
BubbleMenuLinkSelector,
BubbleMenuNodeSelector,
CodeItem,
EditorMenuItem,
ItalicItem,
StrikeThroughItem,
TextAlignItem,
TextColorItem,
UnderLineItem,
} from "../menu-items";
} from "@/components/menus";
// constants
import { COLORS_LIST } from "@/constants/common";
import { CORE_EXTENSIONS } from "@/constants/extension";
// extensions
import { isCellSelection } from "@/extensions/table/table/utilities/is-cell-selection";
// local components
import { TextAlignmentSelector } from "./alignment-selector";
import { BubbleMenu } from "./bubble-menu-renderer";
import { BubbleMenuColorSelector } from "./color-selector";
import { BubbleMenuLinkSelector } from "./link-selector";
import { BubbleMenuNodeSelector } from "./node-selector";
type EditorBubbleMenuProps = { editor: Editor };
type EditorBubbleMenuProps = Omit<BubbleMenuProps, "children">;
export interface EditorStateType {
code: boolean;
bold: boolean;
italic: boolean;
underline: boolean;
strikethrough: boolean;
strike: boolean;
left: boolean;
right: boolean;
center: boolean;
@@ -45,13 +46,44 @@ export interface EditorStateType {
| undefined;
}
export const EditorBubbleMenu = (bubbleMenuProps: EditorBubbleMenuProps) => {
const { editor } = bubbleMenuProps;
export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: { editor: Editor }) => {
const menuRef = useRef<HTMLDivElement>(null);
const [isNodeSelectorOpen, setIsNodeSelectorOpen] = useState(false);
const [isLinkSelectorOpen, setIsLinkSelectorOpen] = useState(false);
const [isColorSelectorOpen, setIsColorSelectorOpen] = useState(false);
const [isSelecting, setIsSelecting] = useState(false);
const bubbleMenuPropsInternal = {
const formattingItems = {
code: CodeItem(props.editor),
bold: BoldItem(props.editor),
italic: ItalicItem(props.editor),
underline: UnderLineItem(props.editor),
strike: StrikeThroughItem(props.editor),
textAlign: TextAlignItem(props.editor),
};
const editorState: EditorStateType = useEditorState({
editor: props.editor,
selector: ({ editor }: { editor: Editor }) => ({
code: formattingItems.code.isActive(),
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" }),
color: COLORS_LIST.find((c) => TextColorItem(editor).isActive({ color: c.key })),
backgroundColor: COLORS_LIST.find((c) => BackgroundColorItem(editor).isActive({ color: c.key })),
}),
});
const basicFormattingOptions = editorState.code
? [formattingItems.code]
: [formattingItems.bold, formattingItems.italic, formattingItems.underline, formattingItems.strike];
const bubbleMenuProps: EditorBubbleMenuProps = {
...props,
shouldShow: ({ state, editor }) => {
const { selection } = state;
const { empty } = selection;
@@ -62,117 +94,125 @@ export const EditorBubbleMenu = (bubbleMenuProps: EditorBubbleMenuProps) => {
editor.isActive(CORE_EXTENSIONS.IMAGE) ||
editor.isActive(CORE_EXTENSIONS.CUSTOM_IMAGE) ||
isNodeSelection(selection) ||
isCellSelection(selection)
isCellSelection(selection) ||
isSelecting
) {
return false;
}
return true;
},
tippyOptions: {
moveTransition: "transform 0.15s ease-out",
duration: [300, 0],
zIndex: 9,
onShow: () => {
props.editor.storage.link.isBubbleMenuOpen = true;
},
onHidden: () => {
props.editor.storage.link.isBubbleMenuOpen = false;
setIsNodeSelectorOpen(false);
setIsLinkSelectorOpen(false);
setIsColorSelectorOpen(false);
},
},
};
const formattingItems = {
code: CodeItem(editor),
bold: BoldItem(editor),
italic: ItalicItem(editor),
underline: UnderLineItem(editor),
strikethrough: StrikeThroughItem(editor),
"text-align": TextAlignItem(editor),
} satisfies {
[K in TEditorCommands]?: EditorMenuItem<K>;
};
useEffect(() => {
function handleMouseDown(e: MouseEvent) {
if (menuRef.current?.contains(e.target as Node)) return;
const editorState: EditorStateType = useEditorState({
editor: editor,
selector: ({ editor }: { editor: Editor }) => ({
code: formattingItems.code.isActive(),
bold: formattingItems.bold.isActive(),
italic: formattingItems.italic.isActive(),
underline: formattingItems.underline.isActive(),
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 })),
}),
});
function handleMouseMove() {
if (!props.editor.state.selection.empty) {
setIsSelecting(true);
document.removeEventListener("mousemove", handleMouseMove);
}
}
const basicFormattingOptions = editorState.code
? [formattingItems.code]
: [formattingItems.bold, formattingItems.italic, formattingItems.underline, formattingItems.strikethrough];
function handleMouseUp() {
setIsSelecting(false);
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
}
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
}
document.addEventListener("mousedown", handleMouseDown);
return () => {
document.removeEventListener("mousedown", handleMouseDown);
};
}, [props.editor]);
return (
<BubbleMenu
editor={editor}
options={{
placement: "top-start",
offset: 2,
}}
shouldShow={bubbleMenuPropsInternal.shouldShow}
>
<div
className={cn(
"flex items-center divide-x divide-custom-border-200 rounded-md border border-custom-border-300 bg-custom-background-100 shadow-custom-shadow-rg"
)}
>
<BubbleMenuNodeSelector
editor={editor}
isOpen={isNodeSelectorOpen}
setIsOpen={() => {
setIsNodeSelectorOpen((prev) => !prev);
setIsLinkSelectorOpen(false);
setIsColorSelectorOpen(false);
}}
/>
{!editorState.code && (
<BubbleMenuLinkSelector
editor={editor}
isOpen={isLinkSelectorOpen}
setIsOpen={() => {
setIsLinkSelectorOpen((prev) => !prev);
setIsNodeSelectorOpen(false);
setIsColorSelectorOpen(false);
}}
/>
)}
{!editorState.code && (
<BubbleMenuColorSelector
editor={editor}
isOpen={isColorSelectorOpen}
editorState={editorState}
setIsOpen={() => {
setIsColorSelectorOpen((prev) => !prev);
setIsNodeSelectorOpen(false);
setIsLinkSelectorOpen(false);
}}
/>
)}
<div className="flex gap-0.5 px-2">
{basicFormattingOptions.map((item) => (
<button
key={item.key}
type="button"
onClick={(e) => {
item.command();
e.stopPropagation();
<BubbleMenu {...bubbleMenuProps}>
{!isSelecting && (
<div
ref={menuRef}
className="flex py-2 divide-x divide-custom-border-200 rounded-lg border border-custom-border-200 bg-custom-background-100 shadow-custom-shadow-rg"
>
<div className="px-2">
<BubbleMenuNodeSelector
editor={props.editor!}
isOpen={isNodeSelectorOpen}
setIsOpen={() => {
setIsNodeSelectorOpen((prev) => !prev);
setIsLinkSelectorOpen(false);
setIsColorSelectorOpen(false);
}}
className={cn(
"size-7 grid place-items-center rounded text-custom-text-300 hover:bg-custom-background-80 active:bg-custom-background-80 transition-all duration-200 ease-in-out",
{
"bg-custom-background-80 text-custom-text-100": editorState[item.key],
}
)}
>
<item.icon
className={cn("size-4 transition-transform duration-200", {
"text-custom-text-100": editorState[item.key],
})}
/>
</div>
{!editorState.code && (
<div className="px-2">
<BubbleMenuLinkSelector
editor={props.editor}
isOpen={isLinkSelectorOpen}
setIsOpen={() => {
setIsLinkSelectorOpen((prev) => !prev);
setIsNodeSelectorOpen(false);
setIsColorSelectorOpen(false);
}}
/>
</button>
))}
</div>
)}
{!editorState.code && (
<div className="px-2">
<BubbleMenuColorSelector
editor={props.editor}
isOpen={isColorSelectorOpen}
editorState={editorState}
setIsOpen={() => {
setIsColorSelectorOpen((prev) => !prev);
setIsNodeSelectorOpen(false);
setIsLinkSelectorOpen(false);
}}
/>
</div>
)}
<div className="flex gap-0.5 px-2">
{basicFormattingOptions.map((item) => (
<button
key={item.key}
type="button"
onClick={(e) => {
item.command();
e.stopPropagation();
}}
className={cn(
"size-7 grid place-items-center rounded text-custom-text-300 hover:bg-custom-background-80 active:bg-custom-background-80 transition-colors",
{
"bg-custom-background-80 text-custom-text-100": editorState[item.key],
}
)}
>
<item.icon className="size-4" />
</button>
))}
</div>
<TextAlignmentSelector editor={props.editor} editorState={editorState} />
</div>
<TextAlignmentSelector editor={editor} editorState={editorState} />
</div>
)}
</BubbleMenu>
);
};
@@ -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 { ImageExtensionConfig } from "./image";
import { ImageExtensionWithoutProps } from "./image";
import { CustomImageComponentWithoutProps } from "./image/image-component-without-props";
import { CustomMentionExtensionConfig } from "./mentions/extension-config";
import { CustomQuoteExtension } from "./quote";
import { TableHeader, TableCell, TableRow, Table } from "./table";
@@ -72,8 +72,12 @@ export const CoreEditorExtensionsWithoutProps = [
"text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer",
},
}),
ImageExtensionConfig,
CustomImageExtensionConfig,
ImageExtensionWithoutProps.configure({
HTMLAttributes: {
class: "rounded-md",
},
}),
CustomImageComponentWithoutProps,
TiptapUnderline,
TextStyle,
TaskList.configure({
@@ -1,42 +1,68 @@
import { NodeSelection } from "@tiptap/pm/state";
import React, { useRef, useState, useCallback, useLayoutEffect, useEffect } from "react";
// plane imports
// plane utils
import { cn } from "@plane/utils";
// local imports
import { Pixel, TCustomImageAttributes, TCustomImageSize } from "../types";
import { ensurePixelString } from "../utils";
import type { CustomImageNodeViewProps } from "./node-view";
import { ImageToolbarRoot } from "./toolbar";
// extensions
import { CustomBaseImageNodeViewProps, ImageToolbarRoot } from "@/extensions/custom-image";
import { ImageUploadStatus } from "./upload-status";
const MIN_SIZE = 100;
type CustomImageBlockProps = CustomImageNodeViewProps & {
editorContainer: HTMLDivElement | null;
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;
setEditorContainer: (editorContainer: HTMLDivElement | null) => void;
setFailedToLoadImage: (isError: boolean) => void;
editorContainer: HTMLDivElement | null;
setEditorContainer: (editorContainer: HTMLDivElement | null) => void;
src: string | undefined;
};
export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
// props
const {
node,
updateAttributes,
setFailedToLoadImage,
imageFromFileSystem,
selected,
getPos,
editor,
editorContainer,
extension,
getPos,
imageFromFileSystem,
node,
selected,
setEditorContainer,
setFailedToLoadImage,
src: resolvedImageSrc,
updateAttributes,
setEditorContainer,
} = props;
const { width: nodeWidth, height: nodeHeight, aspectRatio: nodeAspectRatio, src: imgNodeSrc } = node.attrs;
// states
const [size, setSize] = useState<TCustomImageSize>({
const [size, setSize] = useState<Size>({
width: ensurePixelString(nodeWidth, "35%") ?? "35%",
height: ensurePixelString(nodeHeight, "auto") ?? "auto",
aspectRatio: nodeAspectRatio || null,
@@ -51,7 +77,7 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
const [hasTriedRestoringImageOnce, setHasTriedRestoringImageOnce] = useState(false);
const updateAttributesSafely = useCallback(
(attributes: Partial<TCustomImageAttributes>, errorMessage: string) => {
(attributes: Partial<ImageAttributes>, errorMessage: string) => {
try {
updateAttributes(attributes);
} catch (error) {
@@ -88,7 +114,7 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
const initialWidth = Math.max(editorWidth * 0.35, MIN_SIZE);
const initialHeight = initialWidth / aspectRatioCalculated;
const initialComputedSize: TCustomImageSize = {
const initialComputedSize = {
width: `${Math.round(initialWidth)}px` satisfies Pixel,
height: `${Math.round(initialHeight)}px` satisfies Pixel,
aspectRatio: aspectRatioCalculated,
@@ -113,7 +139,7 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
}
}
setInitialResizeComplete(true);
}, [nodeWidth, updateAttributesSafely, editorContainer, nodeAspectRatio, setEditorContainer]);
}, [nodeWidth, updateAttributes, editorContainer, nodeAspectRatio]);
// for real time resizing
useLayoutEffect(() => {
@@ -142,7 +168,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, updateAttributesSafely]);
}, [size, updateAttributes]);
const handleResizeStart = useCallback((e: React.MouseEvent | React.TouchEvent) => {
e.preventDefault();
@@ -216,7 +242,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 (!extension.options.restoreImage || hasTriedRestoringImageOnce) {
if (!editor?.commands.restoreImage || hasTriedRestoringImageOnce) {
setFailedToLoadImage(true);
return;
}
@@ -227,7 +253,7 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
if (!imgNodeSrc) {
throw new Error("No source image to restore from");
}
await extension.options.restoreImage?.(imgNodeSrc);
await editor?.commands.restoreImage?.(imgNodeSrc);
if (!imageRef.current) {
throw new Error("Image reference not found");
}
@@ -263,10 +289,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={{
width: size.width,
height: size.height,
aspectRatio: size.aspectRatio === null ? 1 : size.aspectRatio,
src: resolvedImageSrc,
aspectRatio: size.aspectRatio === null ? 1 : size.aspectRatio,
height: size.height,
width: size.width,
}}
/>
)}
@@ -2,26 +2,25 @@ 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 CustomImageNodeViewProps = Omit<NodeViewProps, "extension" | "updateAttributes"> & {
extension: CustomImageExtension;
export type CustomBaseImageNodeViewProps = {
getPos: () => number;
editor: Editor;
node: NodeViewProps["node"] & {
attrs: TCustomImageAttributes;
attrs: ImageAttributes;
};
updateAttributes: (attrs: Partial<TCustomImageAttributes>) => void;
updateAttributes: (attrs: Partial<ImageAttributes>) => void;
selected: boolean;
};
export const CustomImageNodeView: React.FC<CustomImageNodeViewProps> = (props) => {
const { editor, extension, node } = props;
export type CustomImageNodeProps = NodeViewProps & CustomBaseImageNodeViewProps;
export const CustomImageNode = (props: CustomImageNodeProps) => {
const { getPos, editor, node, updateAttributes, selected } = props;
const { src: imgNodeSrc } = node.attrs;
const [isUploaded, setIsUploaded] = useState(false);
@@ -51,37 +50,41 @@ export const CustomImageNodeView: React.FC<CustomImageNodeViewProps> = (props) =
}, [resolvedSrc]);
useEffect(() => {
if (!imgNodeSrc) {
setResolvedSrc(undefined);
return;
}
const getImageSource = async () => {
const url = await extension.options.getImageSource?.(imgNodeSrc);
setResolvedSrc(url);
// @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);
};
getImageSource();
}, [imgNodeSrc, extension.options]);
}, [imgNodeSrc]);
return (
<NodeViewWrapper>
<div className="p-0 mx-0 my-2" data-drag-handle ref={imageComponentRef}>
{(isUploaded || imageFromFileSystem) && !failedToLoadImage ? (
<CustomImageBlock
editorContainer={editorContainer}
imageFromFileSystem={imageFromFileSystem}
editorContainer={editorContainer}
editor={editor}
src={resolvedSrc}
getPos={getPos}
node={node}
setEditorContainer={setEditorContainer}
setFailedToLoadImage={setFailedToLoadImage}
src={resolvedSrc}
{...props}
selected={selected}
updateAttributes={updateAttributes}
/>
) : (
<CustomImageUploader
editor={editor}
failedToLoadImage={failedToLoadImage}
getPos={getPos}
loadImageFromFileSystem={setImageFromFileSystem}
maxFileSize={getExtensionStorage(editor, CORE_EXTENSIONS.CUSTOM_IMAGE).maxFileSize}
node={node}
setIsUploaded={setIsUploaded}
{...props}
selected={selected}
updateAttributes={updateAttributes}
/>
)}
</div>
@@ -1,30 +1,28 @@
import { ImageIcon } from "lucide-react";
import { ChangeEvent, useCallback, useEffect, useMemo, useRef } from "react";
// plane imports
// plane utils
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 = CustomImageNodeViewProps & {
failedToLoadImage: boolean;
loadImageFromFileSystem: (file: string) => void;
type CustomImageUploaderProps = CustomBaseImageNodeViewProps & {
maxFileSize: number;
loadImageFromFileSystem: (file: string) => void;
failedToLoadImage: boolean;
setIsUploaded: (isUploaded: boolean) => void;
};
export const CustomImageUploader = (props: CustomImageUploaderProps) => {
const {
editor,
extension,
failedToLoadImage,
getPos,
loadImageFromFileSystem,
@@ -73,13 +71,12 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
}
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[imageComponentImageFileMap, imageEntityId, updateAttributes, getPos]
);
const uploadImageEditorCommand = useCallback(
async (file: File) => await extension.options.uploadImage?.(imageEntityId ?? "", file),
[extension.options, imageEntityId]
async (file: File) => await editor?.commands.uploadImage(imageEntityId ?? "", file),
[editor, imageEntityId]
);
const handleProgressStatus = useCallback(
@@ -96,6 +93,7 @@ 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,
@@ -130,7 +128,7 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
imageComponentImageFileMap?.set(imageEntityId ?? "", { ...meta, hasOpenedFileInputOnce: true });
}
}
}, [meta, uploadFile, imageComponentImageFileMap, imageEntityId]);
}, [meta, uploadFile, imageComponentImageFileMap]);
const onFileChange = useCallback(
async (e: ChangeEvent<HTMLInputElement>) => {
@@ -165,7 +163,7 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
}
return "Add an image";
}, [draggedInside, failedToLoadImage, isImageBeingUploaded, editor.isEditable]);
}, [draggedInside, failedToLoadImage, isImageBeingUploaded]);
return (
<div
@@ -0,0 +1,4 @@
export * from "./toolbar";
export * from "./image-block";
export * from "./image-node";
export * from "./image-uploader";
@@ -1,14 +1,14 @@
import { ExternalLink, Maximize, Minus, Plus, X } from "lucide-react";
import { useCallback, useEffect, useMemo, useState, useRef } from "react";
// plane imports
// plane utils
import { cn } from "@plane/utils";
type Props = {
image: {
width: string;
height: string;
aspectRatio: number;
src: string;
height: string;
width: string;
aspectRatio: number;
};
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 editor-image-full-screen-modal": isFullScreenEnabled,
"opacity-100 pointer-events-auto": isFullScreenEnabled,
"cursor-default": !isDragging,
"cursor-grabbing": isDragging,
})}
@@ -1,16 +1,16 @@
import { useState } from "react";
// plane imports
// plane utils
import { cn } from "@plane/utils";
// local imports
// components
import { ImageFullScreenAction } from "./full-screen";
type Props = {
containerClassName?: string;
image: {
width: string;
height: string;
aspectRatio: number;
src: string;
height: string;
width: string;
aspectRatio: number;
};
};
@@ -0,0 +1,180 @@
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);
},
});
};
@@ -1,47 +0,0 @@
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)];
},
});
@@ -1,121 +0,0 @@
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);
},
});
};
@@ -0,0 +1,3 @@
export * from "./components";
export * from "./custom-image";
export * from "./read-only-custom-image";
@@ -0,0 +1,79 @@
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);
},
});
};
@@ -1,51 +0,0 @@
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>;
@@ -1,33 +0,0 @@
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,6 +16,7 @@ import {
CustomCodeInlineExtension,
CustomColorExtension,
CustomHorizontalRule,
CustomImageExtension,
CustomKeymap,
CustomLinkExtension,
CustomMentionExtension,
@@ -37,8 +38,6 @@ 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,
@@ -192,13 +191,12 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => {
if (!disabledExtensions.includes("image")) {
extensions.push(
ImageExtension({
fileHandler,
ImageExtension(fileHandler).configure({
HTMLAttributes: {
class: "rounded-md",
},
}),
CustomImageExtension({
fileHandler,
isEditable: editable,
})
CustomImageExtension(fileHandler)
);
}
@@ -1,33 +1,23 @@
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 type { TFileHandler, TReadOnlyFileHandler } from "@/types";
// local imports
import { CustomImageNodeView } from "../custom-image/components/node-view";
import { ImageExtensionConfig } from "./extension-config";
import { TFileHandler } from "@/types";
export type ImageExtensionStorage = {
deletedImageSet: Map<string, boolean>;
};
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,
};
},
export const ImageExtension = (fileHandler: TFileHandler) => {
const {
getAssetSrc,
validation: { maxFileSize },
} = fileHandler;
return BaseImageExtension.extend<unknown, ImageExtensionStorage>({
addKeyboardShortcuts() {
return {
ArrowDown: insertEmptyParagraphAtNodeBoundaries("down", this.name),
@@ -37,17 +27,36 @@ export const ImageExtension = (props: Props) => {
// 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(CustomImageNodeView);
return ReactNodeViewRenderer(CustomImageNode);
},
});
};
@@ -0,0 +1,56 @@
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,12 +1,6 @@
import { Image as BaseImageExtension } from "@tiptap/extension-image";
// local imports
import { CustomImageExtensionOptions } from "../custom-image/types";
import { ImageExtensionStorage } from "./extension";
export const ImageExtensionConfig = BaseImageExtension.extend<
Pick<CustomImageExtensionOptions, "getImageSource">,
ImageExtensionStorage
>({
export const ImageExtensionWithoutProps = BaseImageExtension.extend({
addAttributes() {
return {
...this.parent?.(),
@@ -1,2 +1,3 @@
export * from "./extension";
export * from "./extension-config";
export * from "./image-extension-without-props";
export * from "./read-only-image";
@@ -0,0 +1,37 @@
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,6 +1,7 @@
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,6 +12,7 @@ import {
CustomHorizontalRule,
CustomLinkExtension,
CustomTypographyExtension,
ReadOnlyImageExtension,
CustomCodeBlockExtension,
CustomCodeInlineExtension,
TableHeader,
@@ -19,11 +20,11 @@ import {
TableRow,
Table,
CustomMentionExtension,
CustomReadOnlyImageExtension,
CustomTextAlignExtension,
CustomCalloutReadOnlyExtension,
CustomColorExtension,
UtilityExtension,
ImageExtension,
} from "@/extensions";
// helpers
import { isValidHttpUrl } from "@/helpers/common";
@@ -31,8 +32,6 @@ 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">;
@@ -136,13 +135,12 @@ export const CoreReadOnlyEditorExtensions = (props: Props): Extensions => {
if (!disabledExtensions.includes("image")) {
extensions.push(
ImageExtension({
fileHandler,
ReadOnlyImageExtension(fileHandler).configure({
HTMLAttributes: {
class: "rounded-md",
},
}),
CustomImageExtension({
fileHandler,
isEditable: false,
})
CustomReadOnlyImageExtension(fileHandler)
);
}
@@ -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";
+22 -28
View File
@@ -1,42 +1,40 @@
// 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 assets from HTML content
* @description function to extract all image assets from HTML content
* @param htmlContent
* @returns {string[]} array of asset sources
* @returns {string[]} array of image asset sources
*/
const extractAssetsFromHTMLContent = (htmlContent: string): string[] => {
export const extractImageAssetsFromHTMLContent = (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");
// collect all unique asset sources
const assetSources = new Set<string>();
// extract sources from image components
// get all image components
const imageComponents = doc.querySelectorAll("image-component");
// collect all unique image sources
const imageSources = new Set<string>();
// extract sources from image components
imageComponents.forEach((component) => {
const src = component.getAttribute("src");
if (src) assetSources.add(src);
if (src) imageSources.add(src);
});
const additionalAssetIds = extractAdditionalAssetsFromHTMLContent(htmlContent);
return [...Array.from(assetSources), ...additionalAssetIds];
return Array.from(imageSources);
};
/**
* @description function to replace assets in HTML content with new IDs
* @description function to replace image assets in HTML content with new IDs
* @param props
* @returns {string} HTML content with replaced assets
* @returns {string} HTML content with replaced image assets
*/
const replaceAssetsInHTMLContent = (props: { htmlContent: string; assetMap: Record<string, string> }): string => {
export const replaceImageAssetsInHTMLContent = (props: {
htmlContent: string;
assetMap: Record<string, string>;
}): string => {
const { htmlContent, assetMap } = props;
// create a DOM parser
const parser = new DOMParser();
@@ -50,15 +48,11 @@ const replaceAssetsInHTMLContent = (props: { htmlContent: string; assetMap: Reco
component.setAttribute("src", assetMap[oldSrc]);
}
});
// replace additional sources
const replacedHTMLContent = replaceAdditionalAssetsInHTMLContent({
htmlContent: doc.body.innerHTML,
assetMap,
});
return replacedHTMLContent;
// serialize the document back into a string
return doc.body.innerHTML;
};
export const getEditorContentWithReplacedAssets = async (props: {
export const getEditorContentWithReplacedImageAssets = async (props: {
descriptionHTML: string;
entityId: string;
entityType: TEditorAssetType;
@@ -69,18 +63,18 @@ export const getEditorContentWithReplacedAssets = async (props: {
const { descriptionHTML, entityId, entityType, projectId, variant, duplicateAssetService } = props;
let replacedDescription = descriptionHTML;
// step 1: extract image assets from the description
const assetIds = extractAssetsFromHTMLContent(descriptionHTML);
if (assetIds.length !== 0) {
const imageAssets = extractImageAssetsFromHTMLContent(descriptionHTML);
if (imageAssets.length !== 0) {
// step 2: duplicate the image assets
const duplicateAssetsResponse = await duplicateAssetService({
entity_id: entityId,
entity_type: entityType,
project_id: projectId,
asset_ids: assetIds,
asset_ids: imageAssets,
});
if (Object.keys(duplicateAssetsResponse ?? {}).length > 0) {
// step 3: replace the image assets in the description
replacedDescription = replaceAssetsInHTMLContent({
replacedDescription = replaceImageAssetsInHTMLContent({
htmlContent: descriptionHTML,
assetMap: duplicateAssetsResponse,
});
+37 -138
View File
@@ -1,38 +1,10 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import React from "react";
// plane imports
import { TBarChartShapeVariant, TBarItem, TChartData } from "@plane/types";
import { TChartData } from "@plane/types";
import { cn } from "@plane/utils";
// 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
// Helper to calculate percentage
const calculatePercentage = <K extends string, T extends string>(
data: TChartData<K, T>,
stackKeys: T[],
@@ -42,36 +14,11 @@ const calculatePercentage = <K extends string, T extends string>(
return total === 0 ? 0 : Math.round((data[currentKey] / total) * 100);
};
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
`;
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 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) => {
export const CustomBar = React.memo((props: any) => {
const {
opacity,
fill,
@@ -87,104 +34,56 @@ const CustomBar = React.memo((props: TBarProps) => {
showTopBorderRadius,
showBottomBorderRadius,
} = props;
if (!height) return null;
const currentBarPercentage = calculatePercentage(payload, stackKeys, dataKey);
// 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;
const textY = y + height - TEXT_PADDING_Y; // Position inside bar if tall enough
// derived values
const currentBarPercentage = calculatePercentage(payload, stackKeys, dataKey);
const showText =
// from props
showPercentage &&
// height of the bar is greater than or equal to the minimum height required to show the text
height >= MIN_BAR_HEIGHT_FOR_INTERNAL_TEXT &&
// bar percentage text has some value
currentBarPercentage !== undefined &&
// bar percentage is a number
!Number.isNaN(currentBarPercentage);
const topBorderRadius = showTopBorderRadius ? BAR_TOP_BORDER_RADIUS : 0;
const bottomBorderRadius = showBottomBorderRadius ? BAR_BOTTOM_BORDER_RADIUS : 0;
if (!height) return null;
return (
<g>
<path
d={getBarPath(x, y, width, height, topBorderRadius, bottomBorderRadius)}
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
`}
className="transition-opacity duration-200"
fill={typeof fill === "function" ? fill(payload) : fill}
fill={fill}
opacity={opacity}
/>
{showText && (
<PercentageText x={x + width / 2} y={textY} percentage={currentBarPercentage} className={textClassName} />
<text
x={x + width / 2}
y={textY}
textAnchor="middle"
className={cn("text-xs font-medium", textClassName)}
fill="currentColor"
>
{currentBarPercentage}%
</text>
)}
</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";
+26 -18
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 { barShapeVariants } from "./bar";
import { CustomBar } from "./bar";
export const BarChart = React.memo(<K extends string, T extends string>(props: TBarChartProps<K, T>) => {
const {
@@ -36,7 +36,6 @@ 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);
@@ -67,8 +66,20 @@ 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 shapeVariant = barShapeVariants[bar.shapeVariant ?? "bar"];
return shapeVariant(shapeProps, bar, stackKeys);
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}
/>
);
}}
className="[&_path]:transition-opacity [&_path]:duration-200"
onMouseEnter={() => setActiveBar(bar.key)}
@@ -139,20 +150,17 @@ export const BarChart = React.memo(<K extends string, T extends string>(props: T
wrapperStyle={{
pointerEvents: "auto",
}}
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}
/>
);
}}
content={({ active, label, payload }) => (
<CustomTooltip
active={active}
label={label}
payload={payload}
activeKey={activeBar}
itemKeys={stackKeys}
itemLabels={stackLabels}
itemDotColors={stackDotColors}
/>
)}
/>
)}
{renderBars}
-3
View File
@@ -53,8 +53,6 @@ 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;
@@ -64,7 +62,6 @@ 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,9 +1,10 @@
"use client";
// components
import { AppHeader, ContentWrapper } from "@/components/core";
// plane web components
import { WorkspaceAnalyticsHeader } from "./header";
export default function WorkspaceAnalyticsTabLayout({ children }: { children: React.ReactNode }) {
export default function WorkspaceAnalyticsLayout({ children }: { children: React.ReactNode }) {
return (
<>
<AppHeader header={<WorkspaceAnalyticsHeader />} />
@@ -2,11 +2,11 @@
import { useMemo } from "react";
import { observer } from "mobx-react";
import { useRouter } from "next/navigation";
import { useRouter, useSearchParams } from "next/navigation";
// plane package imports
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { type TabItem, Tabs } from "@plane/ui";
import { Tabs } from "@plane/ui";
// components
import AnalyticsFilterActions from "@/components/analytics/analytics-filter-actions";
import { PageHead } from "@/components/core";
@@ -16,33 +16,23 @@ import { useCommandPalette, useEventTracker, useProject, useUserPermissions, use
import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path";
import { getAnalyticsTabs } from "@/plane-web/components/analytics/tabs";
type Props = {
params: {
tabId: string;
workspaceSlug: string;
};
};
const AnalyticsPage = observer((props: Props) => {
// props
const { params } = props;
const { tabId } = params;
// hooks
const AnalyticsPage = observer(() => {
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(
@@ -50,25 +40,22 @@ const AnalyticsPage = observer((props: Props) => {
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: TabItem[] = useMemo(
const tabs = useMemo(
() =>
ANALYTICS_TABS.map((tab) => ({
key: tab.key,
label: tab.label,
content: <tab.content />,
onClick: () => {
router.push(`/${currentWorkspace?.slug}/analytics/${tab.key}`);
router.push(`?tab=${tab.key}`);
},
disabled: tab.isDisabled,
isDisabled: tab.isDisabled,
})),
[ANALYTICS_TABS, router, currentWorkspace?.slug]
[ANALYTICS_TABS, router]
);
const defaultTab = tabId || ANALYTICS_TABS[0].key;
const defaultTab = searchParams.get("tab") || ANALYTICS_TABS[0].key;
return (
<>
@@ -83,9 +70,8 @@ const AnalyticsPage = observer((props: Props) => {
defaultTab={defaultTab}
size="md"
tabListContainerClassName="px-6 py-2 border-b border-custom-border-200 flex items-center justify-between"
tabListClassName="my-2 w-auto"
tabClassName="px-3"
tabPanelClassName="h-full overflow-hidden overflow-y-auto px-2"
tabListClassName="my-2 max-w-36"
tabPanelClassName="h-full w-full overflow-hidden overflow-y-auto"
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_TRACKER_EVENT } from "@plane/constants";
import { GITHUB_REDIRECTED } 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_TRACKER_EVENT, {
captureEvent(GITHUB_REDIRECTED, {
element: "navbar",
})
}
@@ -5,7 +5,7 @@ import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { Search } from "lucide-react";
// types
import { EUserPermissions, EUserPermissionsLevel, MEMBER_TRACKER_EVENTS } from "@plane/constants";
import { MEMBER_INVITED, EUserPermissions, EUserPermissionsLevel } 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_TRACKER_EVENTS.invite, {
captureEvent(MEMBER_INVITED, {
emails: [
...data.emails.map((email) => ({
email: email.email,
@@ -70,7 +70,7 @@ const WorkspaceMembersSettingsPage = observer(() => {
});
})
.catch((err) => {
captureEvent(MEMBER_TRACKER_EVENTS.invite, {
captureEvent(MEMBER_INVITED, {
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 { AUTH_TRACKER_EVENTS } from "@plane/constants";
import { FORGOT_PASS_LINK, NAVIGATE_TO_SIGNUP } 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(AUTH_TRACKER_EVENTS.forgot_password, {
captureEvent(FORGOT_PASS_LINK, {
state: "SUCCESS",
});
setToast({
@@ -82,7 +82,7 @@ const ForgotPasswordPage = observer(() => {
setResendCodeTimer(30);
})
.catch((err) => {
captureEvent(AUTH_TRACKER_EVENTS.forgot_password, {
captureEvent(FORGOT_PASS_LINK, {
state: "FAILED",
});
setToast({
@@ -120,7 +120,7 @@ const ForgotPasswordPage = observer(() => {
{t("auth.common.new_to_plane")}
<Link
href="/"
onClick={() => captureEvent(AUTH_TRACKER_EVENTS.navigate.sign_up, {})}
onClick={() => captureEvent(NAVIGATE_TO_SIGNUP, {})}
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, EUserPermissions, MEMBER_TRACKER_EVENTS } from "@plane/constants";
import { ROLE, MEMBER_ACCEPTED, EUserPermissions } 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_TRACKER_EVENTS.accept, {
captureEvent(MEMBER_ACCEPTED, {
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_TRACKER_EVENTS.accept, {
captureEvent(MEMBER_ACCEPTED, {
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_TRACKER_EVENTS } from "@plane/constants";
import { USER_ONBOARDING_COMPLETED } 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_TRACKER_EVENTS.onboarding_complete, {
captureEvent(USER_ONBOARDING_COMPLETED, {
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 { AUTH_TRACKER_EVENTS } from "@plane/constants";
import { NAVIGATE_TO_SIGNIN } 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(AUTH_TRACKER_EVENTS.navigate.sign_in, {})}
onClick={() => captureEvent(NAVIGATE_TO_SIGNIN, {})}
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 { AUTH_TRACKER_EVENTS } from "@plane/constants";
import { NAVIGATE_TO_SIGNUP } 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(AUTH_TRACKER_EVENTS.navigate.sign_up, {})}
onClick={() => captureEvent(NAVIGATE_TO_SIGNUP, {})}
className="font-semibold text-custom-primary-100 hover:underline"
>
{t("auth.common.create_account")}
@@ -93,9 +93,7 @@ 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 redirectProjectId
? `/${page?.workspace__slug}/projects/${redirectProjectId}/pages/${page?.id}`
: `/${page?.workspace__slug}/pages/${page?.id}`;
return `/${page?.workspace__slug}/projects/${redirectProjectId}/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 { DEFAULT_PROJECT_FORM_VALUES, PROJECT_TRACKER_EVENTS } from "@plane/constants";
import { PROJECT_CREATED, DEFAULT_PROJECT_FORM_VALUES } 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_TRACKER_EVENTS.create,
eventName: PROJECT_CREATED,
payload: newPayload,
});
setToast({
-11
View File
@@ -1,11 +0,0 @@
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 { API_BASE_URL, E_PASSWORD_STRENGTH, AUTH_TRACKER_EVENTS } from "@plane/constants";
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 { 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(AUTH_TRACKER_EVENTS.sign_in_with_code);
captureEvent(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(AUTH_TRACKER_EVENTS.forgot_password)}
onClick={() => captureEvent(FORGOT_PASSWORD)}
href={`/accounts/forgot-password?email=${encodeURIComponent(email)}`}
className="text-xs font-medium text-custom-primary-100"
>
@@ -154,11 +154,7 @@ export const AuthPasswordForm: React.FC<Props> = observer((props: Props) => {
: true;
if (isPasswordValid) {
setIsSubmitting(true);
captureEvent(
mode === EAuthModes.SIGN_IN
? AUTH_TRACKER_EVENTS.sign_in_with_password
: AUTH_TRACKER_EVENTS.sign_up_with_password
);
captureEvent(mode === EAuthModes.SIGN_IN ? SIGN_IN_WITH_PASSWORD : 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 { API_BASE_URL, AUTH_TRACKER_EVENTS } from "@plane/constants";
import { CODE_VERIFIED, API_BASE_URL } 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(AUTH_TRACKER_EVENTS.code_verify, {
captureEvent(CODE_VERIFIED, {
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_TRACKER_EVENTS, CYCLE_STATUS, EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { CYCLE_STATUS, CYCLE_UPDATED, 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_TRACKER_EVENTS.update,
eventName: CYCLE_UPDATED,
payload: {
...res,
changed_properties: [changedProperty],
@@ -79,7 +79,7 @@ export const CycleSidebarHeader: FC<Props> = observer((props) => {
.catch(() => {
captureCycleEvent({
eventName: CYCLE_TRACKER_EVENTS.update,
eventName: CYCLE_UPDATED,
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_TRACKER_EVENTS } from "@plane/constants";
import { PROJECT_ERROR_MESSAGES, CYCLE_DELETED } 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_TRACKER_EVENTS.delete,
eventName: CYCLE_DELETED,
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_TRACKER_EVENTS.delete,
eventName: CYCLE_DELETED,
payload: { ...cycle, state: "FAILED" },
});
})
@@ -6,7 +6,13 @@ import { useParams, usePathname, useSearchParams } from "next/navigation";
import { useForm } from "react-hook-form";
import { Eye, Users, ArrowRight, CalendarDays } from "lucide-react";
// types
import { CYCLE_TRACKER_EVENTS, EUserPermissions, EUserPermissionsLevel, IS_FAVORITE_MENU_OPEN } from "@plane/constants";
import {
CYCLE_FAVORITED,
CYCLE_UNFAVORITED,
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";
@@ -56,7 +62,7 @@ export const CycleListItemAction: FC<Props> = observer((props) => {
const searchParams = useSearchParams();
const pathname = usePathname();
// store hooks
const { addCycleToFavorites, removeCycleFromFavorites } = useCycle();
const { addCycleToFavorites, removeCycleFromFavorites, updateCycleDetails } = useCycle();
const { captureEvent } = useEventTracker();
const { allowPermissions } = useUserPermissions();
@@ -69,7 +75,7 @@ export const CycleListItemAction: FC<Props> = observer((props) => {
const { getUserDetails } = useMember();
// form
const { reset } = useForm({
const { control, reset, getValues } = useForm({
defaultValues,
});
@@ -101,7 +107,7 @@ export const CycleListItemAction: FC<Props> = observer((props) => {
const addToFavoritePromise = addCycleToFavorites(workspaceSlug?.toString(), projectId.toString(), cycleId).then(
() => {
if (!isFavoriteMenuOpen) toggleFavoriteMenu(true);
captureEvent(CYCLE_TRACKER_EVENTS.favorite, {
captureEvent(CYCLE_FAVORITED, {
cycle_id: cycleId,
element: "List layout",
state: "SUCCESS",
@@ -131,7 +137,7 @@ export const CycleListItemAction: FC<Props> = observer((props) => {
projectId.toString(),
cycleId
).then(() => {
captureEvent(CYCLE_TRACKER_EVENTS.unfavorite, {
captureEvent(CYCLE_UNFAVORITED, {
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_TRACKER_EVENTS } from "@plane/constants";
import { CYCLE_CREATED, CYCLE_UPDATED } 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_TRACKER_EVENTS.create,
eventName: CYCLE_CREATED,
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_TRACKER_EVENTS.create,
eventName: CYCLE_CREATED,
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_TRACKER_EVENTS.update,
eventName: CYCLE_UPDATED,
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_TRACKER_EVENTS.update,
eventName: CYCLE_UPDATED,
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_TRACKER_EVENTS } from "@plane/constants";
import { PRODUCT_TOUR_COMPLETED } 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_TRACKER_EVENTS.complete, {
captureEvent(PRODUCT_TOUR_COMPLETED, {
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, WORK_ITEM_TRACKER_EVENTS } from "@plane/constants";
import { EInboxIssueSource, ISSUE_ARCHIVED, ISSUE_DELETED } 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: WORK_ITEM_TRACKER_EVENTS.delete,
eventName: ISSUE_DELETED,
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: WORK_ITEM_TRACKER_EVENTS.delete,
eventName: ISSUE_DELETED,
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: WORK_ITEM_TRACKER_EVENTS.archive,
eventName: ISSUE_ARCHIVED,
payload: { id: issueId, state: "SUCCESS", element: "Work item details page" },
path: pathname,
});
} catch (error) {
console.log("Error in archiving issue:", error);
captureIssueEvent({
eventName: WORK_ITEM_TRACKER_EVENTS.archive,
eventName: ISSUE_ARCHIVED,
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, WORK_ITEM_TRACKER_EVENTS } from "@plane/constants";
import { ETabIndices, ISSUE_CREATED } 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: WORK_ITEM_TRACKER_EVENTS.create,
eventName: ISSUE_CREATED,
payload: {
...formData,
state: "SUCCESS",
@@ -185,7 +185,7 @@ export const InboxIssueCreateRoot: FC<TInboxIssueCreateRoot> = observer((props)
.catch((error) => {
console.error(error);
captureIssueEvent({
eventName: WORK_ITEM_TRACKER_EVENTS.create,
eventName: ISSUE_CREATED,
payload: {
...formData,
state: "FAILED",
@@ -2,7 +2,7 @@
import { useMemo } from "react";
import { usePathname } from "next/navigation";
// plane imports
import { EIssueServiceType, WORK_ITEM_TRACKER_EVENTS } from "@plane/constants";
import { EIssueServiceType, ISSUE_DELETED, ISSUE_UPDATED } 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: WORK_ITEM_TRACKER_EVENTS.update,
eventName: ISSUE_UPDATED,
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: WORK_ITEM_TRACKER_EVENTS.update,
eventName: ISSUE_UPDATED,
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: WORK_ITEM_TRACKER_EVENTS.delete,
eventName: ISSUE_DELETED,
payload: { id: issueId, state: "SUCCESS", element: "Issue detail page" },
path: pathname,
});
});
} catch {
captureIssueEvent({
eventName: WORK_ITEM_TRACKER_EVENTS.delete,
eventName: ISSUE_DELETED,
payload: { id: issueId, state: "FAILED", element: "Issue detail page" },
path: pathname,
});
@@ -5,11 +5,12 @@ 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";
@@ -104,19 +105,19 @@ export const IssueDetailQuickActions: FC<Props> = observer((props) => {
return deleteIssue(workspaceSlug, projectId, issueId).then(() => {
router.push(redirectionPath);
captureIssueEvent({
eventName: WORK_ITEM_TRACKER_EVENTS.delete,
eventName: ISSUE_DELETED,
payload: { id: issueId, state: "SUCCESS", element: "Work item detail page" },
path: pathname,
});
});
} catch {
} catch (error) {
setToast({
title: t("toast.error "),
type: TOAST_TYPE.ERROR,
message: t("entity.delete.failed", { entity: t("issue.label", { count: 1 }) }),
});
captureIssueEvent({
eventName: WORK_ITEM_TRACKER_EVENTS.delete,
eventName: ISSUE_DELETED,
payload: { id: issueId, state: "FAILED", element: "Work item detail page" },
path: pathname,
});
@@ -129,13 +130,13 @@ export const IssueDetailQuickActions: FC<Props> = observer((props) => {
router.push(`/${workspaceSlug}/projects/${projectId}/archives/issues/${issue.id}`);
});
captureIssueEvent({
eventName: WORK_ITEM_TRACKER_EVENTS.archive,
eventName: ISSUE_ARCHIVED,
payload: { id: issueId, state: "SUCCESS", element: "Issue details page" },
path: pathname,
});
} catch {
} catch (error) {
captureIssueEvent({
eventName: WORK_ITEM_TRACKER_EVENTS.archive,
eventName: ISSUE_ARCHIVED,
payload: { id: issueId, state: "FAILED", element: "Issue details page" },
path: pathname,
});
@@ -4,7 +4,14 @@ import { FC, useMemo } from "react";
import { observer } from "mobx-react";
import { usePathname } from "next/navigation";
// types
import { EIssuesStoreType, EUserPermissions, EUserPermissionsLevel, WORK_ITEM_TRACKER_EVENTS } from "@plane/constants";
import {
EIssuesStoreType,
ISSUE_UPDATED,
ISSUE_DELETED,
ISSUE_ARCHIVED,
EUserPermissions,
EUserPermissionsLevel,
} from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { TIssue } from "@plane/types";
// ui
@@ -91,7 +98,7 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = observer((props) => {
try {
await updateIssue(workspaceSlug, projectId, issueId, data);
captureIssueEvent({
eventName: WORK_ITEM_TRACKER_EVENTS.update,
eventName: ISSUE_UPDATED,
payload: { ...data, issueId, state: "SUCCESS", element: "Issue detail page" },
updates: {
changed_property: Object.keys(data).join(","),
@@ -102,7 +109,7 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = observer((props) => {
} catch (error) {
console.log("Error in updating issue:", error);
captureIssueEvent({
eventName: WORK_ITEM_TRACKER_EVENTS.update,
eventName: ISSUE_UPDATED,
payload: { state: "FAILED", element: "Issue detail page" },
updates: {
changed_property: Object.keys(data).join(","),
@@ -127,7 +134,7 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = observer((props) => {
message: t("entity.delete.success", { entity: t("issue.label") }),
});
captureIssueEvent({
eventName: WORK_ITEM_TRACKER_EVENTS.delete,
eventName: ISSUE_DELETED,
payload: { id: issueId, state: "SUCCESS", element: "Issue detail page" },
path: pathname,
});
@@ -139,7 +146,7 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = observer((props) => {
message: t("entity.delete.failed", { entity: t("issue.label") }),
});
captureIssueEvent({
eventName: WORK_ITEM_TRACKER_EVENTS.delete,
eventName: ISSUE_DELETED,
payload: { id: issueId, state: "FAILED", element: "Issue detail page" },
path: pathname,
});
@@ -149,14 +156,14 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = observer((props) => {
try {
await archiveIssue(workspaceSlug, projectId, issueId);
captureIssueEvent({
eventName: WORK_ITEM_TRACKER_EVENTS.archive,
eventName: ISSUE_ARCHIVED,
payload: { id: issueId, state: "SUCCESS", element: "Issue details page" },
path: pathname,
});
} catch (error) {
console.log("Error in archiving issue:", error);
captureIssueEvent({
eventName: WORK_ITEM_TRACKER_EVENTS.archive,
eventName: ISSUE_ARCHIVED,
payload: { id: issueId, state: "FAILED", element: "Issue details page" },
path: pathname,
});
@@ -166,7 +173,7 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = observer((props) => {
try {
await addCycleToIssue(workspaceSlug, projectId, cycleId, issueId);
captureIssueEvent({
eventName: WORK_ITEM_TRACKER_EVENTS.update,
eventName: ISSUE_UPDATED,
payload: { issueId, state: "SUCCESS", element: "Issue detail page" },
updates: {
changed_property: "cycle_id",
@@ -174,14 +181,14 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = observer((props) => {
},
path: pathname,
});
} catch {
} catch (error) {
setToast({
type: TOAST_TYPE.ERROR,
title: t("common.error.label"),
message: t("issue.add.cycle.failed"),
});
captureIssueEvent({
eventName: WORK_ITEM_TRACKER_EVENTS.update,
eventName: ISSUE_UPDATED,
payload: { state: "FAILED", element: "Issue detail page" },
updates: {
changed_property: "cycle_id",
@@ -195,7 +202,7 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = observer((props) => {
try {
await addIssueToCycle(workspaceSlug, projectId, cycleId, issueIds);
captureIssueEvent({
eventName: WORK_ITEM_TRACKER_EVENTS.update,
eventName: ISSUE_UPDATED,
payload: { ...issueIds, state: "SUCCESS", element: "Issue detail page" },
updates: {
changed_property: "cycle_id",
@@ -203,14 +210,14 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = observer((props) => {
},
path: pathname,
});
} catch {
} catch (error) {
setToast({
type: TOAST_TYPE.ERROR,
title: t("common.error.label"),
message: t("issue.add.cycle.failed"),
});
captureIssueEvent({
eventName: WORK_ITEM_TRACKER_EVENTS.update,
eventName: ISSUE_UPDATED,
payload: { state: "FAILED", element: "Issue detail page" },
updates: {
changed_property: "cycle_id",
@@ -236,7 +243,7 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = observer((props) => {
});
await removeFromCyclePromise;
captureIssueEvent({
eventName: WORK_ITEM_TRACKER_EVENTS.update,
eventName: ISSUE_UPDATED,
payload: { issueId, state: "SUCCESS", element: "Issue detail page" },
updates: {
changed_property: "cycle_id",
@@ -244,9 +251,9 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = observer((props) => {
},
path: pathname,
});
} catch {
} catch (error) {
captureIssueEvent({
eventName: WORK_ITEM_TRACKER_EVENTS.update,
eventName: ISSUE_UPDATED,
payload: { state: "FAILED", element: "Issue detail page" },
updates: {
changed_property: "cycle_id",
@@ -272,7 +279,7 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = observer((props) => {
});
await removeFromModulePromise;
captureIssueEvent({
eventName: WORK_ITEM_TRACKER_EVENTS.update,
eventName: ISSUE_UPDATED,
payload: { id: issueId, state: "SUCCESS", element: "Issue detail page" },
updates: {
changed_property: "module_id",
@@ -280,9 +287,9 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = observer((props) => {
},
path: pathname,
});
} catch {
} catch (error) {
captureIssueEvent({
eventName: WORK_ITEM_TRACKER_EVENTS.update,
eventName: ISSUE_UPDATED,
payload: { id: issueId, state: "FAILED", element: "Issue detail page" },
updates: {
changed_property: "module_id",
@@ -301,7 +308,7 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = observer((props) => {
) => {
const promise = await changeModulesInIssue(workspaceSlug, projectId, issueId, addModuleIds, removeModuleIds);
captureIssueEvent({
eventName: WORK_ITEM_TRACKER_EVENTS.update,
eventName: ISSUE_UPDATED,
payload: { id: issueId, state: "SUCCESS", element: "Issue detail page" },
updates: {
changed_property: "module_id",
@@ -326,7 +333,6 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = observer((props) => {
removeIssueFromModule,
captureIssueEvent,
pathname,
t,
]
);
@@ -11,10 +11,8 @@ import {
EIssueFilterType,
EIssuesStoreType,
EViewAccess,
EUserPermissions,
EUserPermissionsLevel,
GLOBAL_VIEW_TOUR_TRACKER_EVENTS,
} from "@plane/constants";
GLOBAL_VIEW_UPDATED,
EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { IIssueFilterOptions, TStaticViewTypes } from "@plane/types";
//ui
// components
@@ -114,7 +112,7 @@ export const GlobalViewsAppliedFiltersRoot = observer((props: Props) => {
updateGlobalView(workspaceSlug.toString(), globalViewId.toString(), viewFilters).then((res) => {
if (res)
captureEvent(GLOBAL_VIEW_TOUR_TRACKER_EVENTS.update, {
captureEvent(GLOBAL_VIEW_UPDATED, {
view_id: res.id,
applied_filters: res.filters,
state: "SUCCESS",
@@ -146,7 +146,6 @@ 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
},
})
);
}, [setIsDragOverDelete, setDraggedIssueId, setDeleteIssueModal]);
}, [deleteAreaRef?.current, 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: WORK_ITEM_TRACKER_EVENTS.delete,
eventName: ISSUE_DELETED,
payload: { id: draggedIssueId, state: "FAILED", element: "Kanban layout drag & drop" },
path: pathname,
});
@@ -7,19 +7,13 @@ import { useParams, usePathname } from "next/navigation";
// icons
import { Layers, Link, Paperclip } from "lucide-react";
// types
import { WORK_ITEM_TRACKER_EVENTS } from "@plane/constants";
import { ISSUE_UPDATED } 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,
@@ -109,7 +103,7 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
if (updateIssue)
updateIssue(issue.project_id, issue.id, { state_id: stateId }).then(() => {
captureIssueEvent({
eventName: WORK_ITEM_TRACKER_EVENTS.update,
eventName: ISSUE_UPDATED,
payload: { ...issue, state: "SUCCESS", element: currentLayout },
path: pathname,
updates: {
@@ -124,7 +118,7 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
if (updateIssue)
updateIssue(issue.project_id, issue.id, { priority: value }).then(() => {
captureIssueEvent({
eventName: WORK_ITEM_TRACKER_EVENTS.update,
eventName: ISSUE_UPDATED,
payload: { ...issue, state: "SUCCESS", element: currentLayout },
path: pathname,
updates: {
@@ -139,7 +133,7 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
if (updateIssue)
updateIssue(issue.project_id, issue.id, { label_ids: ids }).then(() => {
captureIssueEvent({
eventName: WORK_ITEM_TRACKER_EVENTS.update,
eventName: ISSUE_UPDATED,
payload: { ...issue, state: "SUCCESS", element: currentLayout },
path: pathname,
updates: {
@@ -154,7 +148,7 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
if (updateIssue)
updateIssue(issue.project_id, issue.id, { assignee_ids: ids }).then(() => {
captureIssueEvent({
eventName: WORK_ITEM_TRACKER_EVENTS.update,
eventName: ISSUE_UPDATED,
payload: { ...issue, state: "SUCCESS", element: currentLayout },
path: pathname,
updates: {
@@ -179,7 +173,7 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
if (modulesToRemove.length > 0) issueOperations.removeModulesFromIssue(modulesToRemove);
captureIssueEvent({
eventName: WORK_ITEM_TRACKER_EVENTS.update,
eventName: ISSUE_UPDATED,
payload: { ...issue, state: "SUCCESS", element: currentLayout },
path: pathname,
updates: { changed_property: "module_ids", change_details: { module_ids: moduleIds } },
@@ -195,7 +189,7 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
else issueOperations.removeIssueFromCycle?.();
captureIssueEvent({
eventName: WORK_ITEM_TRACKER_EVENTS.update,
eventName: ISSUE_UPDATED,
payload: { ...issue, state: "SUCCESS", element: currentLayout },
path: pathname,
updates: { changed_property: "cycle", change_details: { cycle_id: cycleId } },
@@ -209,7 +203,7 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
updateIssue(issue.project_id, issue.id, { start_date: date ? renderFormattedPayloadDate(date) : null }).then(
() => {
captureIssueEvent({
eventName: WORK_ITEM_TRACKER_EVENTS.update,
eventName: ISSUE_UPDATED,
payload: { ...issue, state: "SUCCESS", element: currentLayout },
path: pathname,
updates: {
@@ -226,7 +220,7 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
updateIssue(issue.project_id, issue.id, { target_date: date ? renderFormattedPayloadDate(date) : null }).then(
() => {
captureIssueEvent({
eventName: WORK_ITEM_TRACKER_EVENTS.update,
eventName: ISSUE_UPDATED,
payload: { ...issue, state: "SUCCESS", element: currentLayout },
path: pathname,
updates: {
@@ -242,7 +236,7 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
if (updateIssue)
updateIssue(issue.project_id, issue.id, { estimate_point: value }).then(() => {
captureIssueEvent({
eventName: WORK_ITEM_TRACKER_EVENTS.update,
eventName: ISSUE_UPDATED,
payload: { ...issue, state: "SUCCESS", element: currentLayout },
path: pathname,
updates: {
@@ -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, WORK_ITEM_TRACKER_EVENTS } from "@plane/constants";
import { EIssueLayoutTypes, EIssueServiceType, ISSUE_CREATED } 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: WORK_ITEM_TRACKER_EVENTS.create,
eventName: ISSUE_CREATED,
payload: { ...res, state: "SUCCESS", element: ` ${layout} quick add` },
path: pathname,
});
})
.catch(() => {
captureIssueEvent({
eventName: WORK_ITEM_TRACKER_EVENTS.create,
eventName: ISSUE_CREATED,
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: { fetchFilters, updateFilters },
issuesFilter: { filters, fetchFilters, updateFilters },
issues: { clear, groupedIssueIds, fetchIssues, fetchNextIssues },
} = useIssues(EIssuesStoreType.GLOBAL);
const { fetchAllGlobalViews, getViewDetailsById } = useGlobalView();
@@ -42,7 +42,8 @@ export const AllIssueLayoutRoot: React.FC<Props> = observer((props: Props) => {
// Derived values
const viewDetails = getViewDetailsById(globalViewId?.toString());
const activeLayout: EIssueLayoutTypes | undefined = EIssueLayoutTypes.SPREADSHEET;
const issueFilters = globalViewId ? filters?.[globalViewId.toString()] : undefined;
const activeLayout: EIssueLayoutTypes | undefined = issueFilters?.displayFilters?.layout;
// Route filters
const routeFilters: { [key: string]: string } = {};
@@ -3,7 +3,7 @@
import React, { useEffect, useRef, useState } from "react";
import { observer } from "mobx-react";
import { useParams, usePathname } from "next/navigation";
import { EIssuesStoreType, WORK_ITEM_TRACKER_EVENTS } from "@plane/constants";
import { EIssuesStoreType, ISSUE_CREATED, ISSUE_UPDATED } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
// types
import type { TBaseIssue, TIssue } from "@plane/types";
@@ -241,7 +241,7 @@ export const CreateUpdateIssueModalBase: React.FC<IssuesModalProps> = observer((
),
});
captureIssueEvent({
eventName: WORK_ITEM_TRACKER_EVENTS.create,
eventName: ISSUE_CREATED,
payload: { ...response, state: "SUCCESS" },
path: pathname,
});
@@ -257,7 +257,7 @@ export const CreateUpdateIssueModalBase: React.FC<IssuesModalProps> = observer((
message: error?.error ?? t(is_draft_issue ? "draft_creation_failed" : "issue_creation_failed"),
});
captureIssueEvent({
eventName: WORK_ITEM_TRACKER_EVENTS.create,
eventName: ISSUE_CREATED,
payload: { ...payload, state: "FAILED" },
path: pathname,
});
@@ -303,7 +303,7 @@ export const CreateUpdateIssueModalBase: React.FC<IssuesModalProps> = observer((
message: t("issue_updated_successfully"),
});
captureIssueEvent({
eventName: WORK_ITEM_TRACKER_EVENTS.update,
eventName: ISSUE_UPDATED,
payload: { ...payload, issueId: data.id, state: "SUCCESS" },
path: pathname,
});
@@ -316,7 +316,7 @@ export const CreateUpdateIssueModalBase: React.FC<IssuesModalProps> = observer((
message: error?.error ?? t("issue_could_not_be_updated"),
});
captureIssueEvent({
eventName: WORK_ITEM_TRACKER_EVENTS.update,
eventName: ISSUE_UPDATED,
payload: { ...payload, state: "FAILED" },
path: pathname,
});
@@ -334,6 +334,8 @@ export const CreateUpdateIssueModalBase: React.FC<IssuesModalProps> = observer((
if (beforeFormSubmit) await beforeFormSubmit();
if (!data?.id) response = await handleCreateIssue(payload, is_draft_issue);
else response = await handleUpdateIssue(payload);
} catch (error) {
throw error;
} finally {
if (response != undefined && onSubmit) await onSubmit(response);
}
@@ -6,10 +6,13 @@ import { usePathname } from "next/navigation";
// plane types
import {
EIssuesStoreType,
ISSUE_UPDATED,
ISSUE_DELETED,
ISSUE_ARCHIVED,
ISSUE_RESTORED,
EUserPermissions,
EUserPermissionsLevel,
EIssueServiceType,
WORK_ITEM_TRACKER_EVENTS,
} from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { TIssue, IWorkItemPeekOverview } from "@plane/types";
@@ -23,6 +26,7 @@ import { useEventTracker, useIssueDetail, useIssues, useUserPermissions } from "
import { useIssueStoreType } from "@/hooks/use-issue-layout-store";
import { useWorkItemProperties } from "@/plane-web/hooks/use-issue-properties";
export const IssuePeekOverview: FC<IWorkItemPeekOverview> = observer((props) => {
const {
embedIssue = false,
@@ -82,7 +86,7 @@ export const IssuePeekOverview: FC<IWorkItemPeekOverview> = observer((props) =>
.then(async () => {
fetchActivities(workspaceSlug, projectId, issueId);
captureIssueEvent({
eventName: WORK_ITEM_TRACKER_EVENTS.update,
eventName: ISSUE_UPDATED,
payload: { ...data, issueId, state: "SUCCESS", element: "Issue peek-overview" },
updates: {
changed_property: Object.keys(data).join(","),
@@ -93,7 +97,7 @@ export const IssuePeekOverview: FC<IWorkItemPeekOverview> = observer((props) =>
})
.catch(() => {
captureIssueEvent({
eventName: WORK_ITEM_TRACKER_EVENTS.update,
eventName: ISSUE_UPDATED,
payload: { state: "FAILED", element: "Issue peek-overview" },
path: pathname,
});
@@ -109,7 +113,7 @@ export const IssuePeekOverview: FC<IWorkItemPeekOverview> = observer((props) =>
try {
return issues?.removeIssue(workspaceSlug, projectId, issueId).then(() => {
captureIssueEvent({
eventName: WORK_ITEM_TRACKER_EVENTS.delete,
eventName: ISSUE_DELETED,
payload: { id: issueId, state: "SUCCESS", element: "Issue peek-overview" },
path: pathname,
});
@@ -122,7 +126,7 @@ export const IssuePeekOverview: FC<IWorkItemPeekOverview> = observer((props) =>
message: t("entity.delete.failed", { entity: t("issue.label", { count: 1 }) }),
});
captureIssueEvent({
eventName: WORK_ITEM_TRACKER_EVENTS.delete,
eventName: ISSUE_DELETED,
payload: { id: issueId, state: "FAILED", element: "Issue peek-overview" },
path: pathname,
});
@@ -133,13 +137,13 @@ export const IssuePeekOverview: FC<IWorkItemPeekOverview> = observer((props) =>
if (!issues?.archiveIssue) return;
await issues.archiveIssue(workspaceSlug, projectId, issueId);
captureIssueEvent({
eventName: WORK_ITEM_TRACKER_EVENTS.archive,
eventName: ISSUE_ARCHIVED,
payload: { id: issueId, state: "SUCCESS", element: "Issue peek-overview" },
path: pathname,
});
} catch {
captureIssueEvent({
eventName: WORK_ITEM_TRACKER_EVENTS.archive,
eventName: ISSUE_ARCHIVED,
payload: { id: issueId, state: "FAILED", element: "Issue peek-overview" },
path: pathname,
});
@@ -154,7 +158,7 @@ export const IssuePeekOverview: FC<IWorkItemPeekOverview> = observer((props) =>
message: t("issue.restore.success.message"),
});
captureIssueEvent({
eventName: WORK_ITEM_TRACKER_EVENTS.restore,
eventName: ISSUE_RESTORED,
payload: { id: issueId, state: "SUCCESS", element: "Issue peek-overview" },
path: pathname,
});
@@ -165,7 +169,7 @@ export const IssuePeekOverview: FC<IWorkItemPeekOverview> = observer((props) =>
message: t("issue.restore.failed.message"),
});
captureIssueEvent({
eventName: WORK_ITEM_TRACKER_EVENTS.restore,
eventName: ISSUE_RESTORED,
payload: { id: issueId, state: "FAILED", element: "Issue peek-overview" },
path: pathname,
});
@@ -176,7 +180,7 @@ export const IssuePeekOverview: FC<IWorkItemPeekOverview> = observer((props) =>
await issues.addCycleToIssue(workspaceSlug, projectId, cycleId, issueId);
fetchActivities(workspaceSlug, projectId, issueId);
captureIssueEvent({
eventName: WORK_ITEM_TRACKER_EVENTS.update,
eventName: ISSUE_UPDATED,
payload: { issueId, state: "SUCCESS", element: "Issue peek-overview" },
updates: {
changed_property: "cycle_id",
@@ -191,7 +195,7 @@ export const IssuePeekOverview: FC<IWorkItemPeekOverview> = observer((props) =>
message: t("issue.add.cycle.failed"),
});
captureIssueEvent({
eventName: WORK_ITEM_TRACKER_EVENTS.update,
eventName: ISSUE_UPDATED,
payload: { state: "FAILED", element: "Issue peek-overview" },
updates: {
changed_property: "cycle_id",
@@ -205,7 +209,7 @@ export const IssuePeekOverview: FC<IWorkItemPeekOverview> = observer((props) =>
try {
await issues.addIssueToCycle(workspaceSlug, projectId, cycleId, issueIds);
captureIssueEvent({
eventName: WORK_ITEM_TRACKER_EVENTS.update,
eventName: ISSUE_UPDATED,
payload: { ...issueIds, state: "SUCCESS", element: "Issue peek-overview" },
updates: {
changed_property: "cycle_id",
@@ -220,7 +224,7 @@ export const IssuePeekOverview: FC<IWorkItemPeekOverview> = observer((props) =>
message: t("issue.add.cycle.failed"),
});
captureIssueEvent({
eventName: WORK_ITEM_TRACKER_EVENTS.update,
eventName: ISSUE_UPDATED,
payload: { state: "FAILED", element: "Issue peek-overview" },
updates: {
changed_property: "cycle_id",
@@ -247,7 +251,7 @@ export const IssuePeekOverview: FC<IWorkItemPeekOverview> = observer((props) =>
await removeFromCyclePromise;
fetchActivities(workspaceSlug, projectId, issueId);
captureIssueEvent({
eventName: WORK_ITEM_TRACKER_EVENTS.update,
eventName: ISSUE_UPDATED,
payload: { issueId, state: "SUCCESS", element: "Issue peek-overview" },
updates: {
changed_property: "cycle_id",
@@ -257,7 +261,7 @@ export const IssuePeekOverview: FC<IWorkItemPeekOverview> = observer((props) =>
});
} catch {
captureIssueEvent({
eventName: WORK_ITEM_TRACKER_EVENTS.update,
eventName: ISSUE_UPDATED,
payload: { state: "FAILED", element: "Issue peek-overview" },
updates: {
changed_property: "cycle_id",
@@ -283,7 +287,7 @@ export const IssuePeekOverview: FC<IWorkItemPeekOverview> = observer((props) =>
);
fetchActivities(workspaceSlug, projectId, issueId);
captureIssueEvent({
eventName: WORK_ITEM_TRACKER_EVENTS.update,
eventName: ISSUE_UPDATED,
payload: { id: issueId, state: "SUCCESS", element: "Issue detail page" },
updates: {
changed_property: "module_id",
@@ -310,7 +314,7 @@ export const IssuePeekOverview: FC<IWorkItemPeekOverview> = observer((props) =>
await removeFromModulePromise;
fetchActivities(workspaceSlug, projectId, issueId);
captureIssueEvent({
eventName: WORK_ITEM_TRACKER_EVENTS.update,
eventName: ISSUE_UPDATED,
payload: { id: issueId, state: "SUCCESS", element: "Issue peek-overview" },
updates: {
changed_property: "module_id",
@@ -320,7 +324,7 @@ export const IssuePeekOverview: FC<IWorkItemPeekOverview> = observer((props) =>
});
} catch {
captureIssueEvent({
eventName: WORK_ITEM_TRACKER_EVENTS.update,
eventName: ISSUE_UPDATED,
payload: { id: issueId, state: "FAILED", element: "Issue peek-overview" },
updates: {
changed_property: "module_id",
@@ -331,7 +335,7 @@ export const IssuePeekOverview: FC<IWorkItemPeekOverview> = observer((props) =>
}
},
}),
[fetchIssue, is_draft, issues, fetchActivities, captureIssueEvent, pathname, removeRoutePeekId, restoreIssue, t]
[fetchIssue, is_draft, issues, fetchActivities, captureIssueEvent, pathname, removeRoutePeekId, restoreIssue]
);
useEffect(() => {
@@ -91,9 +91,8 @@ export const IssueView: FC<IIssueView> = observer((props) => {
const handleKeyDown = () => {
const slashCommandDropdownElement = document.querySelector("#slash-command");
const editorImageFullScreenModalElement = document.querySelector(".editor-image-full-screen-modal");
const dropdownElement = document.activeElement?.tagName === "INPUT";
if (!isAnyModalOpen && !slashCommandDropdownElement && !dropdownElement && !editorImageFullScreenModalElement) {
if (!isAnyModalOpen && !slashCommandDropdownElement && !dropdownElement) {
removeRoutePeekId();
const issueElement = document.getElementById(`issue-${issueId}`);
if (issueElement) issueElement?.focus();
@@ -6,13 +6,7 @@ import { useParams } from "next/navigation";
import { Controller, useForm } from "react-hook-form";
import { CalendarClock, ChevronDown, ChevronRight, Info, Plus, SquareUser, Users } from "lucide-react";
import { Disclosure, Transition } from "@headlessui/react";
import {
MODULE_STATUS,
EUserPermissions,
EUserPermissionsLevel,
EEstimateSystem,
MODULE_TRACKER_EVENTS,
} from "@plane/constants";
import { MODULE_STATUS, MODULE_LINK_CREATED, MODULE_LINK_DELETED, MODULE_LINK_UPDATED, MODULE_UPDATED, EUserPermissions, EUserPermissionsLevel, EEstimateSystem } from "@plane/constants";
// plane types
import { useTranslation } from "@plane/i18n";
import { ILinkDetails, IModule, ModuleLink } from "@plane/types";
@@ -80,13 +74,13 @@ export const ModuleAnalyticsSidebar: React.FC<Props> = observer((props) => {
updateModuleDetails(workspaceSlug.toString(), projectId.toString(), moduleId.toString(), data)
.then((res) => {
captureModuleEvent({
eventName: MODULE_TRACKER_EVENTS.update,
eventName: MODULE_UPDATED,
payload: { ...res, changed_properties: Object.keys(data)[0], element: "Right side-peek", state: "SUCCESS" },
});
})
.catch(() => {
captureModuleEvent({
eventName: MODULE_TRACKER_EVENTS.update,
eventName: MODULE_UPDATED,
payload: { ...data, state: "FAILED" },
});
});
@@ -98,7 +92,7 @@ export const ModuleAnalyticsSidebar: React.FC<Props> = observer((props) => {
const payload = { metadata: {}, ...formData };
await createModuleLink(workspaceSlug.toString(), projectId.toString(), moduleId.toString(), payload).then(() =>
captureEvent(MODULE_TRACKER_EVENTS.link.create, {
captureEvent(MODULE_LINK_CREATED, {
module_id: moduleId,
state: "SUCCESS",
})
@@ -112,7 +106,7 @@ export const ModuleAnalyticsSidebar: React.FC<Props> = observer((props) => {
await updateModuleLink(workspaceSlug.toString(), projectId.toString(), moduleId.toString(), linkId, payload).then(
() =>
captureEvent(MODULE_TRACKER_EVENTS.link.update, {
captureEvent(MODULE_LINK_UPDATED, {
module_id: moduleId,
state: "SUCCESS",
})
@@ -124,7 +118,7 @@ export const ModuleAnalyticsSidebar: React.FC<Props> = observer((props) => {
deleteModuleLink(workspaceSlug.toString(), projectId.toString(), moduleId.toString(), linkId)
.then(() => {
captureEvent(MODULE_TRACKER_EVENTS.link.delete, {
captureEvent(MODULE_LINK_DELETED, {
module_id: moduleId,
state: "SUCCESS",
});
@@ -4,7 +4,7 @@ import React, { useState } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// types
import { MODULE_TRACKER_EVENTS, PROJECT_ERROR_MESSAGES } from "@plane/constants";
import { PROJECT_ERROR_MESSAGES, MODULE_DELETED } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import type { IModule } from "@plane/types";
// ui
@@ -52,7 +52,7 @@ export const DeleteModuleModal: React.FC<Props> = observer((props) => {
message: "Module deleted successfully.",
});
captureModuleEvent({
eventName: MODULE_TRACKER_EVENTS.delete,
eventName: MODULE_DELETED,
payload: { ...data, state: "SUCCESS" },
});
})
@@ -67,7 +67,7 @@ export const DeleteModuleModal: React.FC<Props> = observer((props) => {
message: currentError.i18n_message && t(currentError.i18n_message),
});
captureModuleEvent({
eventName: MODULE_TRACKER_EVENTS.delete,
eventName: MODULE_DELETED,
payload: { ...data, state: "FAILED" },
});
})
+5 -5
View File
@@ -4,7 +4,7 @@ import React, { useEffect, useState } from "react";
import { observer } from "mobx-react";
import { useForm } from "react-hook-form";
// types
import { MODULE_TRACKER_EVENTS } from "@plane/constants";
import { MODULE_CREATED, MODULE_UPDATED } from "@plane/constants";
import type { IModule } from "@plane/types";
// ui
import { EModalPosition, EModalWidth, ModalCore, TOAST_TYPE, setToast } from "@plane/ui";
@@ -64,7 +64,7 @@ export const CreateUpdateModuleModal: React.FC<Props> = observer((props) => {
message: "Module created successfully.",
});
captureModuleEvent({
eventName: MODULE_TRACKER_EVENTS.create,
eventName: MODULE_CREATED,
payload: { ...res, state: "SUCCESS" },
});
})
@@ -75,7 +75,7 @@ export const CreateUpdateModuleModal: React.FC<Props> = observer((props) => {
message: err?.detail ?? err?.error ?? "Module could not be created. Please try again.",
});
captureModuleEvent({
eventName: MODULE_TRACKER_EVENTS.create,
eventName: MODULE_CREATED,
payload: { ...data, state: "FAILED" },
});
});
@@ -95,7 +95,7 @@ export const CreateUpdateModuleModal: React.FC<Props> = observer((props) => {
message: "Module updated successfully.",
});
captureModuleEvent({
eventName: MODULE_TRACKER_EVENTS.update,
eventName: MODULE_UPDATED,
payload: { ...res, changed_properties: Object.keys(dirtyFields || {}), state: "SUCCESS" },
});
})
@@ -106,7 +106,7 @@ export const CreateUpdateModuleModal: React.FC<Props> = observer((props) => {
message: err?.detail ?? err?.error ?? "Module could not be updated. Please try again.",
});
captureModuleEvent({
eventName: MODULE_TRACKER_EVENTS.update,
eventName: MODULE_UPDATED,
payload: { ...data, state: "FAILED" },
});
});
@@ -9,10 +9,11 @@ import { Info, SquareUser } from "lucide-react";
import {
MODULE_STATUS,
PROGRESS_STATE_GROUPS_DETAILS,
MODULE_FAVORITED,
MODULE_UNFAVORITED,
EUserPermissions,
EUserPermissionsLevel,
IS_FAVORITE_MENU_OPEN,
MODULE_TRACKER_EVENTS,
} from "@plane/constants";
import { useLocalStorage } from "@plane/hooks";
import { IModule } from "@plane/types";
@@ -79,7 +80,7 @@ export const ModuleCardItem: React.FC<Props> = observer((props) => {
const addToFavoritePromise = addModuleToFavorites(workspaceSlug.toString(), projectId.toString(), moduleId).then(
() => {
if (!storedValue) toggleFavoriteMenu(true);
captureEvent(MODULE_TRACKER_EVENTS.favorite, {
captureEvent(MODULE_FAVORITED, {
module_id: moduleId,
element: "Grid layout",
state: "SUCCESS",
@@ -110,7 +111,7 @@ export const ModuleCardItem: React.FC<Props> = observer((props) => {
projectId.toString(),
moduleId
).then(() => {
captureEvent(MODULE_TRACKER_EVENTS.unfavorite, {
captureEvent(MODULE_UNFAVORITED, {
module_id: moduleId,
element: "Grid layout",
state: "SUCCESS",
@@ -8,10 +8,11 @@ import { SquareUser } from "lucide-react";
// types
import {
MODULE_STATUS,
MODULE_FAVORITED,
MODULE_UNFAVORITED,
EUserPermissions,
EUserPermissionsLevel,
IS_FAVORITE_MENU_OPEN,
MODULE_TRACKER_EVENTS,
} from "@plane/constants";
import { useLocalStorage } from "@plane/hooks";
import { useTranslation } from "@plane/i18n";
@@ -68,7 +69,7 @@ export const ModuleListItemAction: FC<Props> = observer((props) => {
() => {
// open favorites menu if closed
if (!storedValue) toggleFavoriteMenu(true);
captureEvent(MODULE_TRACKER_EVENTS.favorite, {
captureEvent(MODULE_FAVORITED, {
module_id: moduleId,
element: "Grid layout",
state: "SUCCESS",
@@ -99,7 +100,7 @@ export const ModuleListItemAction: FC<Props> = observer((props) => {
projectId.toString(),
moduleId
).then(() => {
captureEvent(MODULE_TRACKER_EVENTS.unfavorite, {
captureEvent(MODULE_UNFAVORITED, {
module_id: moduleId,
element: "Grid layout",
state: "SUCCESS",
@@ -4,12 +4,7 @@ import { useState } from "react";
import { observer } from "mobx-react";
import { Controller, useForm } from "react-hook-form";
// constants
import {
ONBOARDING_TRACKER_EVENTS,
ORGANIZATION_SIZE,
RESTRICTED_URLS,
WORKSPACE_TRACKER_EVENTS,
} from "@plane/constants";
import { ORGANIZATION_SIZE, RESTRICTED_URLS, WORKSPACE_CREATED, E_ONBOARDING } from "@plane/constants";
// types
import { useTranslation } from "@plane/i18n";
import { IUser, IWorkspace, TOnboardingSteps } from "@plane/types";
@@ -74,12 +69,12 @@ export const CreateWorkspace: React.FC<Props> = observer((props) => {
message: t("workspace_creation.toast.success.message"),
});
captureWorkspaceEvent({
eventName: WORKSPACE_TRACKER_EVENTS.create,
eventName: WORKSPACE_CREATED,
payload: {
...workspaceResponse,
state: "SUCCESS",
first_time: true,
element: ONBOARDING_TRACKER_EVENTS.root,
element: E_ONBOARDING,
},
});
await fetchWorkspaces();
@@ -87,11 +82,11 @@ export const CreateWorkspace: React.FC<Props> = observer((props) => {
})
.catch(() => {
captureWorkspaceEvent({
eventName: WORKSPACE_TRACKER_EVENTS.create,
eventName: WORKSPACE_CREATED,
payload: {
state: "FAILED",
first_time: true,
element: ONBOARDING_TRACKER_EVENTS.root,
element: E_ONBOARDING,
},
});
setToast({
@@ -268,9 +263,7 @@ export const CreateWorkspace: React.FC<Props> = observer((props) => {
onChange={onChange}
label={
ORGANIZATION_SIZE.find((c) => c === value) ?? (
<span className="text-custom-text-400">
{t("workspace_creation.form.organization_size.placeholder")}
</span>
<span className="text-custom-text-400">{t("workspace_creation.form.organization_size.placeholder")}</span>
)
}
buttonClassName="!border-[0.5px] !border-onboarding-border-100 !shadow-none !rounded-md"

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