Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 45ddfc4559 | |||
| ace33f6093 | |||
| 9d1d3755d0 | |||
| 50e342638f | |||
| 756cba5096 | |||
| 369be93a77 | |||
| 2f6923fca0 | |||
| 912246c592 | |||
| a8487c2ee3 | |||
| e32807d908 | |||
| 4a065e14d0 | |||
| 13c7ac8f9c | |||
| e09aeab5b8 | |||
| 167e53f74c | |||
| ec7cbd474d | |||
| a0d6fa0f1e | |||
| fdd4f80576 | |||
| a414ae23f5 | |||
| 25a6cd49fc | |||
| b5538565c7 | |||
| b8043f92b1 | |||
| 0e91feacc3 | |||
| 40c0922726 | |||
| dee8f00a71 | |||
| 072f2e2cac | |||
| 79c2dfd293 | |||
| 22a9d48ca3 | |||
| fbcc8fc8a0 | |||
| c1fa372c84 |
@@ -117,44 +117,6 @@ jobs:
|
||||
name: Checkout Files
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Get changed files
|
||||
id: changed_files
|
||||
uses: tj-actions/changed-files@v42
|
||||
with:
|
||||
files_yaml: |
|
||||
apiserver:
|
||||
- apiserver/**
|
||||
proxy:
|
||||
- caddy/**
|
||||
admin:
|
||||
- admin/**
|
||||
- packages/**
|
||||
- "package.json"
|
||||
- "yarn.lock"
|
||||
- "tsconfig.json"
|
||||
- "turbo.json"
|
||||
space:
|
||||
- space/**
|
||||
- packages/**
|
||||
- "package.json"
|
||||
- "yarn.lock"
|
||||
- "tsconfig.json"
|
||||
- "turbo.json"
|
||||
web:
|
||||
- web/**
|
||||
- packages/**
|
||||
- "package.json"
|
||||
- "yarn.lock"
|
||||
- "tsconfig.json"
|
||||
- "turbo.json"
|
||||
live:
|
||||
- live/**
|
||||
- packages/**
|
||||
- 'package.json'
|
||||
- 'yarn.lock'
|
||||
- 'tsconfig.json'
|
||||
- 'turbo.json'
|
||||
|
||||
branch_build_push_admin:
|
||||
name: Build-Push Admin Docker Image
|
||||
runs-on: ubuntu-22.04
|
||||
@@ -280,8 +242,8 @@ jobs:
|
||||
dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
docker-image-owner: makeplane
|
||||
docker-image-name: ${{ needs.branch_build_setup.outputs.dh_img_proxy }}
|
||||
build-context: ./caddy
|
||||
dockerfile-path: ./caddy/Dockerfile
|
||||
build-context: ./nginx
|
||||
dockerfile-path: ./nginx/Dockerfile
|
||||
buildx-driver: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }}
|
||||
buildx-version: ${{ needs.branch_build_setup.outputs.gh_buildx_version }}
|
||||
buildx-platforms: ${{ needs.branch_build_setup.outputs.gh_buildx_platforms }}
|
||||
|
||||
+2
-8
@@ -11,7 +11,7 @@ WORKDIR /app
|
||||
RUN yarn global add turbo
|
||||
COPY . .
|
||||
|
||||
RUN turbo prune --scope=web --scope=space --scope=admin --scope=live --docker
|
||||
RUN turbo prune --scope=web --scope=space --scope=admin --docker
|
||||
|
||||
# *****************************************************************************
|
||||
# STAGE 2: Install dependencies & build the project
|
||||
@@ -53,7 +53,7 @@ ENV NEXT_PUBLIC_WEB_BASE_URL=$NEXT_PUBLIC_WEB_BASE_URL
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ENV TURBO_TELEMETRY_DISABLED=1
|
||||
|
||||
RUN yarn turbo run build --filter=web --filter=space --filter=admin --filter=live
|
||||
RUN yarn turbo run build --filter=web --filter=space --filter=admin
|
||||
|
||||
# *****************************************************************************
|
||||
# STAGE 3: Copy the project and start it
|
||||
@@ -87,8 +87,6 @@ RUN chmod +x ./api/bin/*
|
||||
RUN chmod -R 777 ./api/
|
||||
|
||||
# NEXTJS BUILDS
|
||||
COPY --from=installer /app/node_modules ./node_modules/
|
||||
|
||||
COPY --from=installer /app/web/next.config.js ./web/
|
||||
COPY --from=installer /app/web/package.json ./web/
|
||||
COPY --from=installer /app/web/.next/standalone ./web
|
||||
@@ -107,10 +105,6 @@ COPY --from=installer /app/admin/.next/standalone ./admin
|
||||
COPY --from=installer /app/admin/.next/static ./admin/admin/.next/static
|
||||
COPY --from=installer /app/admin/public ./admin/admin/public
|
||||
|
||||
COPY --from=installer /app/live/package.json ./live/
|
||||
COPY --from=installer /app/live/dist ./live/dist
|
||||
# COPY --from=installer /app/live/node_modules ./live/node_modules
|
||||
|
||||
ARG NEXT_PUBLIC_API_BASE_URL=""
|
||||
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
|
||||
|
||||
|
||||
@@ -45,14 +45,6 @@ http {
|
||||
proxy_pass http://localhost:3003/god-mode/;
|
||||
}
|
||||
|
||||
location /live/ {
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade ${dollar}http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host ${dollar}http_host;
|
||||
proxy_pass http://localhost:3004/;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade ${dollar}http_upgrade;
|
||||
|
||||
@@ -29,16 +29,6 @@ stderr_logfile=/dev/stdout
|
||||
stderr_logfile_maxbytes=0
|
||||
environment=PORT=3003,HOSTNAME=0.0.0.0
|
||||
|
||||
[program:live]
|
||||
command=node /app/live/dist/server.js
|
||||
autostart=true
|
||||
autorestart=true
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stdout
|
||||
stderr_logfile_maxbytes=0
|
||||
environment=PORT=3004,HOSTNAME=0.0.0.0,API_BASE_URL="http://localhost:8000"
|
||||
|
||||
[program:migrator]
|
||||
directory=/app/api
|
||||
command=sh -c "./bin/docker-entrypoint-migrator.sh"
|
||||
|
||||
@@ -39,7 +39,7 @@ from .project import (
|
||||
ProjectMemberRoleSerializer,
|
||||
)
|
||||
from .state import StateSerializer, StateLiteSerializer
|
||||
from .view import IssueViewSerializer
|
||||
from .view import IssueViewSerializer, ViewIssueListSerializer
|
||||
from .cycle import (
|
||||
CycleSerializer,
|
||||
CycleIssueSerializer,
|
||||
@@ -74,6 +74,7 @@ from .issue import (
|
||||
IssueLinkLiteSerializer,
|
||||
IssueVersionDetailSerializer,
|
||||
IssueDescriptionVersionDetailSerializer,
|
||||
IssueListDetailSerializer,
|
||||
)
|
||||
|
||||
from .module import (
|
||||
|
||||
@@ -725,6 +725,110 @@ class IssueSerializer(DynamicBaseSerializer):
|
||||
read_only_fields = fields
|
||||
|
||||
|
||||
class IssueListDetailSerializer(serializers.Serializer):
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
# Extract expand parameter and store it as instance variable
|
||||
self.expand = kwargs.pop("expand", []) or []
|
||||
# Extract fields parameter and store it as instance variable
|
||||
self.fields = kwargs.pop("fields", []) or []
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def get_module_ids(self, obj):
|
||||
return [module.module_id for module in obj.issue_module.all()]
|
||||
|
||||
def get_label_ids(self, obj):
|
||||
return [label.label_id for label in obj.label_issue.all()]
|
||||
|
||||
def get_assignee_ids(self, obj):
|
||||
return [assignee.assignee_id for assignee in obj.issue_assignee.all()]
|
||||
|
||||
def to_representation(self, instance):
|
||||
data = {
|
||||
# Basic fields
|
||||
"id": instance.id,
|
||||
"name": instance.name,
|
||||
"state_id": instance.state_id,
|
||||
"sort_order": instance.sort_order,
|
||||
"completed_at": instance.completed_at,
|
||||
"estimate_point": instance.estimate_point_id,
|
||||
"priority": instance.priority,
|
||||
"start_date": instance.start_date,
|
||||
"target_date": instance.target_date,
|
||||
"sequence_id": instance.sequence_id,
|
||||
"project_id": instance.project_id,
|
||||
"parent_id": instance.parent_id,
|
||||
"created_at": instance.created_at,
|
||||
"updated_at": instance.updated_at,
|
||||
"created_by": instance.created_by_id,
|
||||
"updated_by": instance.updated_by_id,
|
||||
"is_draft": instance.is_draft,
|
||||
"archived_at": instance.archived_at,
|
||||
# Computed fields
|
||||
"cycle_id": instance.cycle_id,
|
||||
"module_ids": self.get_module_ids(instance),
|
||||
"label_ids": self.get_label_ids(instance),
|
||||
"assignee_ids": self.get_assignee_ids(instance),
|
||||
"sub_issues_count": instance.sub_issues_count,
|
||||
"attachment_count": instance.attachment_count,
|
||||
"link_count": instance.link_count,
|
||||
}
|
||||
|
||||
# Handle expanded fields only when requested - using direct field access
|
||||
if self.expand:
|
||||
if "issue_relation" in self.expand:
|
||||
relations = []
|
||||
for relation in instance.issue_relation.all():
|
||||
related_issue = relation.related_issue
|
||||
# If the related issue is deleted, skip it
|
||||
if not related_issue:
|
||||
continue
|
||||
# Add the related issue to the relations list
|
||||
relations.append(
|
||||
{
|
||||
"id": related_issue.id,
|
||||
"project_id": related_issue.project_id,
|
||||
"sequence_id": related_issue.sequence_id,
|
||||
"name": related_issue.name,
|
||||
"relation_type": relation.relation_type,
|
||||
"state_id": related_issue.state_id,
|
||||
"priority": related_issue.priority,
|
||||
"created_by": related_issue.created_by_id,
|
||||
"created_at": related_issue.created_at,
|
||||
"updated_at": related_issue.updated_at,
|
||||
"updated_by": related_issue.updated_by_id,
|
||||
}
|
||||
)
|
||||
data["issue_relation"] = relations
|
||||
|
||||
if "issue_related" in self.expand:
|
||||
related = []
|
||||
for relation in instance.issue_related.all():
|
||||
issue = relation.issue
|
||||
# If the related issue is deleted, skip it
|
||||
if not issue:
|
||||
continue
|
||||
# Add the related issue to the related list
|
||||
related.append(
|
||||
{
|
||||
"id": issue.id,
|
||||
"project_id": issue.project_id,
|
||||
"sequence_id": issue.sequence_id,
|
||||
"name": issue.name,
|
||||
"relation_type": relation.relation_type,
|
||||
"state_id": issue.state_id,
|
||||
"priority": issue.priority,
|
||||
"created_by": issue.created_by_id,
|
||||
"created_at": issue.created_at,
|
||||
"updated_at": issue.updated_at,
|
||||
"updated_by": issue.updated_by_id,
|
||||
}
|
||||
)
|
||||
data["issue_related"] = related
|
||||
|
||||
return data
|
||||
|
||||
|
||||
class IssueLiteSerializer(DynamicBaseSerializer):
|
||||
class Meta:
|
||||
model = Issue
|
||||
|
||||
@@ -7,6 +7,49 @@ from plane.db.models import IssueView
|
||||
from plane.utils.issue_filters import issue_filters
|
||||
|
||||
|
||||
class ViewIssueListSerializer(serializers.Serializer):
|
||||
|
||||
def get_assignee_ids(self, instance):
|
||||
return [assignee.assignee_id for assignee in instance.issue_assignee.all()]
|
||||
|
||||
def get_label_ids(self, instance):
|
||||
return [label.label_id for label in instance.label_issue.all()]
|
||||
|
||||
def get_module_ids(self, instance):
|
||||
return [module.module_id for module in instance.issue_module.all()]
|
||||
|
||||
def to_representation(self, instance):
|
||||
data = {
|
||||
"id": instance.id,
|
||||
"name": instance.name,
|
||||
"state_id": instance.state_id,
|
||||
"sort_order": instance.sort_order,
|
||||
"completed_at": instance.completed_at,
|
||||
"estimate_point": instance.estimate_point_id,
|
||||
"priority": instance.priority,
|
||||
"start_date": instance.start_date,
|
||||
"target_date": instance.target_date,
|
||||
"sequence_id": instance.sequence_id,
|
||||
"project_id": instance.project_id,
|
||||
"parent_id": instance.parent_id,
|
||||
"cycle_id": instance.cycle_id,
|
||||
"sub_issues_count": instance.sub_issues_count,
|
||||
"created_at": instance.created_at,
|
||||
"updated_at": instance.updated_at,
|
||||
"created_by": instance.created_by_id,
|
||||
"updated_by": instance.updated_by_id,
|
||||
"attachment_count": instance.attachment_count,
|
||||
"link_count": instance.link_count,
|
||||
"is_draft": instance.is_draft,
|
||||
"archived_at": instance.archived_at,
|
||||
"state__group": instance.state.group if instance.state else None,
|
||||
"assignee_ids": self.get_assignee_ids(instance),
|
||||
"label_ids": self.get_label_ids(instance),
|
||||
"module_ids": self.get_module_ids(instance),
|
||||
}
|
||||
return data
|
||||
|
||||
|
||||
class IssueViewSerializer(DynamicBaseSerializer):
|
||||
is_favorite = serializers.BooleanField(read_only=True)
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ from plane.app.serializers import (
|
||||
IssueDetailSerializer,
|
||||
IssueUserPropertySerializer,
|
||||
IssueSerializer,
|
||||
IssueListDetailSerializer,
|
||||
)
|
||||
from plane.bgtasks.issue_activities_task import issue_activity
|
||||
from plane.db.models import (
|
||||
@@ -46,6 +47,9 @@ from plane.db.models import (
|
||||
CycleIssue,
|
||||
UserRecentVisit,
|
||||
ModuleIssue,
|
||||
IssueRelation,
|
||||
IssueAssignee,
|
||||
IssueLabel,
|
||||
)
|
||||
from plane.utils.grouper import (
|
||||
issue_group_values,
|
||||
@@ -947,22 +951,22 @@ class IssueDetailEndpoint(BaseAPIView):
|
||||
|
||||
# check for the project member role, if the role is 5 then check for the guest_view_all_features
|
||||
# if it is true then show all the issues else show only the issues created by the user
|
||||
project_member_subquery = ProjectMember.objects.filter(
|
||||
project_id=OuterRef("project_id"),
|
||||
member=self.request.user,
|
||||
is_active=True,
|
||||
).filter(
|
||||
Q(role__gt=ROLE.GUEST.value)
|
||||
| Q(
|
||||
role=ROLE.GUEST.value, project__guest_view_all_features=True
|
||||
permission_subquery = (
|
||||
Issue.issue_objects.filter(
|
||||
workspace__slug=slug, project_id=project_id, id=OuterRef("id")
|
||||
)
|
||||
)
|
||||
|
||||
# Main issue query
|
||||
issue = (
|
||||
Issue.issue_objects.filter(workspace__slug=slug, project_id=project_id)
|
||||
.filter(
|
||||
Q(Exists(project_member_subquery))
|
||||
Q(
|
||||
project__project_projectmember__member=self.request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
project__project_projectmember__role__gt=ROLE.GUEST.value,
|
||||
)
|
||||
| Q(
|
||||
project__project_projectmember__member=self.request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
project__project_projectmember__role=ROLE.GUEST.value,
|
||||
project__guest_view_all_features=True,
|
||||
)
|
||||
| Q(
|
||||
project__project_projectmember__member=self.request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
@@ -971,7 +975,30 @@ class IssueDetailEndpoint(BaseAPIView):
|
||||
created_by=self.request.user,
|
||||
)
|
||||
)
|
||||
.prefetch_related("assignees", "labels", "issue_module__module")
|
||||
.values("id")
|
||||
)
|
||||
# Main issue query
|
||||
issue = (
|
||||
Issue.issue_objects.filter(workspace__slug=slug, project_id=project_id)
|
||||
.filter(Exists(permission_subquery))
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_assignee",
|
||||
queryset=IssueAssignee.objects.all(),
|
||||
)
|
||||
)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"label_issue",
|
||||
queryset=IssueLabel.objects.all(),
|
||||
)
|
||||
)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_module",
|
||||
queryset=ModuleIssue.objects.all(),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
cycle_id=Subquery(
|
||||
CycleIssue.objects.filter(
|
||||
@@ -979,43 +1006,6 @@ class IssueDetailEndpoint(BaseAPIView):
|
||||
).values("cycle_id")[:1]
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
label_ids=Coalesce(
|
||||
ArrayAgg(
|
||||
"labels__id",
|
||||
distinct=True,
|
||||
filter=Q(
|
||||
~Q(labels__id__isnull=True)
|
||||
& Q(label_issue__deleted_at__isnull=True)
|
||||
),
|
||||
),
|
||||
Value([], output_field=ArrayField(UUIDField())),
|
||||
),
|
||||
assignee_ids=Coalesce(
|
||||
ArrayAgg(
|
||||
"assignees__id",
|
||||
distinct=True,
|
||||
filter=Q(
|
||||
~Q(assignees__id__isnull=True)
|
||||
& Q(assignees__member_project__is_active=True)
|
||||
& Q(issue_assignee__deleted_at__isnull=True)
|
||||
),
|
||||
),
|
||||
Value([], output_field=ArrayField(UUIDField())),
|
||||
),
|
||||
module_ids=Coalesce(
|
||||
ArrayAgg(
|
||||
"issue_module__module_id",
|
||||
distinct=True,
|
||||
filter=Q(
|
||||
~Q(issue_module__module_id__isnull=True)
|
||||
& Q(issue_module__module__archived_at__isnull=True)
|
||||
& Q(issue_module__deleted_at__isnull=True)
|
||||
),
|
||||
),
|
||||
Value([], output_field=ArrayField(UUIDField())),
|
||||
),
|
||||
)
|
||||
.annotate(
|
||||
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
|
||||
.order_by()
|
||||
@@ -1039,6 +1029,23 @@ class IssueDetailEndpoint(BaseAPIView):
|
||||
)
|
||||
)
|
||||
|
||||
# Add additional prefetch based on expand parameter
|
||||
if self.expand:
|
||||
if "issue_relation" in self.expand:
|
||||
issue = issue.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_relation",
|
||||
queryset=IssueRelation.objects.select_related("related_issue"),
|
||||
)
|
||||
)
|
||||
if "issue_related" in self.expand:
|
||||
issue = issue.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_related",
|
||||
queryset=IssueRelation.objects.select_related("issue"),
|
||||
)
|
||||
)
|
||||
|
||||
issue = issue.filter(**filters)
|
||||
order_by_param = request.GET.get("order_by", "-created_at")
|
||||
# Issue queryset
|
||||
@@ -1049,7 +1056,7 @@ class IssueDetailEndpoint(BaseAPIView):
|
||||
request=request,
|
||||
order_by=order_by_param,
|
||||
queryset=(issue),
|
||||
on_results=lambda issue: IssueSerializer(
|
||||
on_results=lambda issue: IssueListDetailSerializer(
|
||||
issue, many=True, fields=self.fields, expand=self.expand
|
||||
).data,
|
||||
)
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
# Django imports
|
||||
from django.contrib.postgres.aggregates import ArrayAgg
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
from django.db.models import Exists, F, Func, OuterRef, Q, UUIDField, Value, Subquery
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.db.models import (
|
||||
Exists,
|
||||
F,
|
||||
Func,
|
||||
OuterRef,
|
||||
Q,
|
||||
Subquery,
|
||||
Prefetch,
|
||||
)
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.decorators.gzip import gzip_page
|
||||
from django.db import transaction
|
||||
@@ -13,7 +18,7 @@ from rest_framework.response import Response
|
||||
|
||||
# Module imports
|
||||
from plane.app.permissions import allow_permission, ROLE
|
||||
from plane.app.serializers import IssueViewSerializer
|
||||
from plane.app.serializers import IssueViewSerializer, ViewIssueListSerializer
|
||||
from plane.db.models import (
|
||||
Issue,
|
||||
FileAsset,
|
||||
@@ -25,15 +30,12 @@ from plane.db.models import (
|
||||
Project,
|
||||
CycleIssue,
|
||||
UserRecentVisit,
|
||||
)
|
||||
from plane.utils.grouper import (
|
||||
issue_group_values,
|
||||
issue_on_results,
|
||||
issue_queryset_grouper,
|
||||
IssueAssignee,
|
||||
IssueLabel,
|
||||
ModuleIssue,
|
||||
)
|
||||
from plane.utils.issue_filters import issue_filters
|
||||
from plane.utils.order_queryset import order_issue_queryset
|
||||
from plane.utils.paginator import GroupedOffsetPaginator, SubGroupedOffsetPaginator
|
||||
from plane.bgtasks.recent_visited_task import recent_visited_task
|
||||
from .. import BaseViewSet
|
||||
from plane.db.models import UserFavorite
|
||||
@@ -143,6 +145,28 @@ class WorkspaceViewViewSet(BaseViewSet):
|
||||
|
||||
|
||||
class WorkspaceViewIssuesViewSet(BaseViewSet):
|
||||
def _get_project_permission_filters(self):
|
||||
"""
|
||||
Get common project permission filters for guest users and role-based access control.
|
||||
Returns Q object for filtering issues based on user role and project settings.
|
||||
"""
|
||||
return Q(
|
||||
Q(
|
||||
project__project_projectmember__role=5,
|
||||
project__guest_view_all_features=True,
|
||||
)
|
||||
| Q(
|
||||
project__project_projectmember__role=5,
|
||||
project__guest_view_all_features=False,
|
||||
created_by=self.request.user,
|
||||
)
|
||||
|
|
||||
# For other roles (role > 5), show all issues
|
||||
Q(project__project_projectmember__role__gt=5),
|
||||
project__project_projectmember__member=self.request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
)
|
||||
|
||||
def get_queryset(self):
|
||||
return (
|
||||
Issue.issue_objects.annotate(
|
||||
@@ -152,12 +176,25 @@ class WorkspaceViewIssuesViewSet(BaseViewSet):
|
||||
.values("count")
|
||||
)
|
||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.filter(
|
||||
project__project_projectmember__member=self.request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
.select_related("state")
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_assignee",
|
||||
queryset=IssueAssignee.objects.all(),
|
||||
)
|
||||
)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"label_issue",
|
||||
queryset=IssueLabel.objects.all(),
|
||||
)
|
||||
)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_module",
|
||||
queryset=ModuleIssue.objects.all(),
|
||||
)
|
||||
)
|
||||
.select_related("workspace", "project", "state", "parent")
|
||||
.prefetch_related("assignees", "labels", "issue_module__module")
|
||||
.annotate(
|
||||
cycle_id=Subquery(
|
||||
CycleIssue.objects.filter(
|
||||
@@ -186,43 +223,6 @@ class WorkspaceViewIssuesViewSet(BaseViewSet):
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
label_ids=Coalesce(
|
||||
ArrayAgg(
|
||||
"labels__id",
|
||||
distinct=True,
|
||||
filter=Q(
|
||||
~Q(labels__id__isnull=True)
|
||||
& Q(label_issue__deleted_at__isnull=True)
|
||||
),
|
||||
),
|
||||
Value([], output_field=ArrayField(UUIDField())),
|
||||
),
|
||||
assignee_ids=Coalesce(
|
||||
ArrayAgg(
|
||||
"assignees__id",
|
||||
distinct=True,
|
||||
filter=Q(
|
||||
~Q(assignees__id__isnull=True)
|
||||
& Q(assignees__member_project__is_active=True)
|
||||
& Q(issue_assignee__deleted_at__isnull=True)
|
||||
),
|
||||
),
|
||||
Value([], output_field=ArrayField(UUIDField())),
|
||||
),
|
||||
module_ids=Coalesce(
|
||||
ArrayAgg(
|
||||
"issue_module__module_id",
|
||||
distinct=True,
|
||||
filter=Q(
|
||||
~Q(issue_module__module_id__isnull=True)
|
||||
& Q(issue_module__module__archived_at__isnull=True)
|
||||
& Q(issue_module__deleted_at__isnull=True)
|
||||
),
|
||||
),
|
||||
Value([], output_field=ArrayField(UUIDField())),
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
@method_decorator(gzip_page)
|
||||
@@ -233,126 +233,36 @@ class WorkspaceViewIssuesViewSet(BaseViewSet):
|
||||
filters = issue_filters(request.query_params, "GET")
|
||||
order_by_param = request.GET.get("order_by", "-created_at")
|
||||
|
||||
issue_queryset = (
|
||||
self.get_queryset()
|
||||
.filter(**filters)
|
||||
.annotate(
|
||||
cycle_id=Subquery(
|
||||
CycleIssue.objects.filter(
|
||||
issue=OuterRef("id"), deleted_at__isnull=True
|
||||
).values("cycle_id")[:1]
|
||||
)
|
||||
)
|
||||
issue_queryset = self.get_queryset().filter(**filters)
|
||||
|
||||
# Get common project permission filters
|
||||
permission_filters = self._get_project_permission_filters()
|
||||
|
||||
# Base query for the counts
|
||||
total_issue_count = (
|
||||
Issue.issue_objects.filter(**filters)
|
||||
.filter(workspace__slug=slug)
|
||||
.filter(permission_filters)
|
||||
.only("id")
|
||||
)
|
||||
|
||||
# check for the project member role, if the role is 5 then check for the guest_view_all_features if it is true then show all the issues else show only the issues created by the user
|
||||
|
||||
issue_queryset = issue_queryset.filter(
|
||||
Q(
|
||||
project__project_projectmember__role=5,
|
||||
project__guest_view_all_features=True,
|
||||
)
|
||||
| Q(
|
||||
project__project_projectmember__role=5,
|
||||
project__guest_view_all_features=False,
|
||||
created_by=self.request.user,
|
||||
)
|
||||
|
|
||||
# For other roles (role < 5), show all issues
|
||||
Q(project__project_projectmember__role__gt=5),
|
||||
project__project_projectmember__member=self.request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
)
|
||||
# Apply project permission filters to the issue queryset
|
||||
issue_queryset = issue_queryset.filter(permission_filters)
|
||||
|
||||
# Issue queryset
|
||||
issue_queryset, order_by_param = order_issue_queryset(
|
||||
issue_queryset=issue_queryset, order_by_param=order_by_param
|
||||
)
|
||||
|
||||
# Group by
|
||||
group_by = request.GET.get("group_by", False)
|
||||
sub_group_by = request.GET.get("sub_group_by", False)
|
||||
|
||||
# issue queryset
|
||||
issue_queryset = issue_queryset_grouper(
|
||||
queryset=issue_queryset, group_by=group_by, sub_group_by=sub_group_by
|
||||
# List Paginate
|
||||
return self.paginate(
|
||||
order_by=order_by_param,
|
||||
request=request,
|
||||
queryset=issue_queryset,
|
||||
on_results=lambda issues: ViewIssueListSerializer(issues, many=True).data,
|
||||
total_count_queryset=total_issue_count,
|
||||
)
|
||||
|
||||
if group_by:
|
||||
# Check group and sub group value paginate
|
||||
if sub_group_by:
|
||||
if group_by == sub_group_by:
|
||||
return Response(
|
||||
{
|
||||
"error": "Group by and sub group by cannot have same parameters"
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
else:
|
||||
# group and sub group pagination
|
||||
return self.paginate(
|
||||
request=request,
|
||||
order_by=order_by_param,
|
||||
queryset=issue_queryset,
|
||||
on_results=lambda issues: issue_on_results(
|
||||
group_by=group_by, issues=issues, sub_group_by=sub_group_by
|
||||
),
|
||||
paginator_cls=SubGroupedOffsetPaginator,
|
||||
group_by_fields=issue_group_values(
|
||||
field=group_by, slug=slug, project_id=None, filters=filters
|
||||
),
|
||||
sub_group_by_fields=issue_group_values(
|
||||
field=sub_group_by,
|
||||
slug=slug,
|
||||
project_id=None,
|
||||
filters=filters,
|
||||
),
|
||||
group_by_field_name=group_by,
|
||||
sub_group_by_field_name=sub_group_by,
|
||||
count_filter=Q(
|
||||
Q(issue_intake__status=1)
|
||||
| Q(issue_intake__status=-1)
|
||||
| Q(issue_intake__status=2)
|
||||
| Q(issue_intake__isnull=True),
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
# Group Paginate
|
||||
else:
|
||||
# Group paginate
|
||||
return self.paginate(
|
||||
request=request,
|
||||
order_by=order_by_param,
|
||||
queryset=issue_queryset,
|
||||
on_results=lambda issues: issue_on_results(
|
||||
group_by=group_by, issues=issues, sub_group_by=sub_group_by
|
||||
),
|
||||
paginator_cls=GroupedOffsetPaginator,
|
||||
group_by_fields=issue_group_values(
|
||||
field=group_by, slug=slug, project_id=None, filters=filters
|
||||
),
|
||||
group_by_field_name=group_by,
|
||||
count_filter=Q(
|
||||
Q(issue_intake__status=1)
|
||||
| Q(issue_intake__status=-1)
|
||||
| Q(issue_intake__status=2)
|
||||
| Q(issue_intake__isnull=True),
|
||||
archived_at__isnull=True,
|
||||
is_draft=False,
|
||||
),
|
||||
)
|
||||
else:
|
||||
# List Paginate
|
||||
return self.paginate(
|
||||
order_by=order_by_param,
|
||||
request=request,
|
||||
queryset=issue_queryset,
|
||||
on_results=lambda issues: issue_on_results(
|
||||
group_by=group_by, issues=issues, sub_group_by=sub_group_by
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class IssueViewViewSet(BaseViewSet):
|
||||
serializer_class = IssueViewSerializer
|
||||
|
||||
@@ -16,7 +16,7 @@ def order_issue_queryset(issue_queryset, order_by_param="-created_at"):
|
||||
],
|
||||
output_field=CharField(),
|
||||
)
|
||||
).order_by("priority_order")
|
||||
).order_by("priority_order", "-created_at")
|
||||
order_by_param = (
|
||||
"priority_order" if order_by_param.startswith("-") else "-priority_order"
|
||||
)
|
||||
@@ -36,7 +36,7 @@ def order_issue_queryset(issue_queryset, order_by_param="-created_at"):
|
||||
default=Value(len(state_order)),
|
||||
output_field=CharField(),
|
||||
)
|
||||
).order_by("state_order")
|
||||
).order_by("state_order", "-created_at")
|
||||
order_by_param = (
|
||||
"-state_order" if order_by_param.startswith("-") else "state_order"
|
||||
)
|
||||
@@ -55,11 +55,18 @@ def order_issue_queryset(issue_queryset, order_by_param="-created_at"):
|
||||
if order_by_param.startswith("-")
|
||||
else order_by_param
|
||||
)
|
||||
).order_by("-min_values" if order_by_param.startswith("-") else "min_values")
|
||||
).order_by(
|
||||
"-min_values" if order_by_param.startswith("-") else "min_values",
|
||||
"-created_at",
|
||||
)
|
||||
order_by_param = (
|
||||
"-min_values" if order_by_param.startswith("-") else "min_values"
|
||||
)
|
||||
else:
|
||||
issue_queryset = issue_queryset.order_by(order_by_param)
|
||||
# If the order_by_param is created_at, then don't add the -created_at
|
||||
if "created_at" in order_by_param:
|
||||
issue_queryset = issue_queryset.order_by(order_by_param)
|
||||
else:
|
||||
issue_queryset = issue_queryset.order_by(order_by_param, "-created_at")
|
||||
order_by_param = order_by_param
|
||||
return issue_queryset, order_by_param
|
||||
|
||||
@@ -102,6 +102,7 @@ class OffsetPaginator:
|
||||
max_limit=MAX_LIMIT,
|
||||
max_offset=None,
|
||||
on_results=None,
|
||||
total_count_queryset=None,
|
||||
):
|
||||
# Key tuple and remove `-` if descending order by
|
||||
self.key = (
|
||||
@@ -115,6 +116,7 @@ class OffsetPaginator:
|
||||
self.max_limit = max_limit
|
||||
self.max_offset = max_offset
|
||||
self.on_results = on_results
|
||||
self.total_count_queryset = total_count_queryset
|
||||
|
||||
def get_result(self, limit=1000, cursor=None):
|
||||
# offset is page #
|
||||
@@ -138,9 +140,9 @@ class OffsetPaginator:
|
||||
)
|
||||
# The current page
|
||||
page = cursor.offset
|
||||
# The offset
|
||||
offset = cursor.offset * cursor.value
|
||||
stop = offset + (cursor.value or limit) + 1
|
||||
# The offset - use limit instead of cursor.value for consistent pagination
|
||||
offset = cursor.offset * limit
|
||||
stop = offset + limit + 1
|
||||
|
||||
if self.max_offset is not None and offset >= self.max_offset:
|
||||
raise BadPaginationError("Pagination offset too large")
|
||||
@@ -148,11 +150,21 @@ class OffsetPaginator:
|
||||
raise BadPaginationError("Pagination offset cannot be negative")
|
||||
|
||||
results = queryset[offset:stop]
|
||||
if cursor.value != limit:
|
||||
|
||||
# Only slice from the end if we're going backwards (previous page)
|
||||
if cursor.value != limit and cursor.is_prev:
|
||||
results = results[-(limit + 1) :]
|
||||
|
||||
total_count = (
|
||||
self.total_count_queryset.count()
|
||||
if self.total_count_queryset
|
||||
else results.count()
|
||||
)
|
||||
|
||||
# Check if there are more results available after the current page
|
||||
|
||||
# Adjust cursors based on the results for pagination
|
||||
next_cursor = Cursor(limit, page + 1, False, results.count() > limit)
|
||||
next_cursor = Cursor(limit, page + 1, False, len(results) > limit)
|
||||
# If the page is greater than 0, then set the previous cursor
|
||||
prev_cursor = Cursor(limit, page - 1, True, page > 0)
|
||||
|
||||
@@ -164,7 +176,7 @@ class OffsetPaginator:
|
||||
results = self.on_results(results)
|
||||
|
||||
# Count the queryset
|
||||
count = queryset.count()
|
||||
count = total_count
|
||||
|
||||
# Optionally, calculate the total count and max_hits if needed
|
||||
max_hits = math.ceil(count / limit)
|
||||
@@ -196,6 +208,7 @@ class GroupedOffsetPaginator(OffsetPaginator):
|
||||
group_by_field_name,
|
||||
group_by_fields,
|
||||
count_filter,
|
||||
total_count_queryset=None,
|
||||
*args,
|
||||
**kwargs,
|
||||
):
|
||||
@@ -404,6 +417,7 @@ class SubGroupedOffsetPaginator(OffsetPaginator):
|
||||
group_by_fields,
|
||||
sub_group_by_fields,
|
||||
count_filter,
|
||||
total_count_queryset=None,
|
||||
*args,
|
||||
**kwargs,
|
||||
):
|
||||
@@ -694,6 +708,7 @@ class BasePaginator:
|
||||
sub_group_by_field_name=None,
|
||||
sub_group_by_fields=None,
|
||||
count_filter=None,
|
||||
total_count_queryset=None,
|
||||
**paginator_kwargs,
|
||||
):
|
||||
"""Paginate the request"""
|
||||
@@ -719,6 +734,8 @@ class BasePaginator:
|
||||
)
|
||||
paginator_kwargs["sub_group_by_fields"] = sub_group_by_fields
|
||||
|
||||
paginator_kwargs["total_count_queryset"] = total_count_queryset
|
||||
|
||||
paginator = paginator_cls(**paginator_kwargs)
|
||||
|
||||
try:
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
(plane_proxy) {
|
||||
request_body {
|
||||
max_size {$FILE_SIZE_LIMIT}
|
||||
}
|
||||
|
||||
reverse_proxy /spaces/* space:3000
|
||||
|
||||
reverse_proxy /god-mode/* admin:3000
|
||||
|
||||
reverse_proxy /live/* live:3000
|
||||
|
||||
reverse_proxy /api/* api:8000
|
||||
|
||||
reverse_proxy /auth/* api:8000
|
||||
|
||||
reverse_proxy /{$BUCKET_NAME}/* plane-minio:9000
|
||||
|
||||
reverse_proxy /* web:3000
|
||||
}
|
||||
|
||||
{
|
||||
email {$CERT_EMAIL:admin@example.com}
|
||||
acme_ca {$CERT_ACME_CA}
|
||||
{$CERT_ACME_DNS}
|
||||
servers {
|
||||
max_header_size 5MB
|
||||
client_ip_headers X-Forwarded-For X-Real-IP
|
||||
trusted_proxies static {$TRUSTED_PROXIES:0.0.0.0/0}
|
||||
}
|
||||
}
|
||||
|
||||
{$SITE_ADDRESS} {
|
||||
import plane_proxy
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
FROM makeplane/caddy:latest
|
||||
|
||||
COPY ./Caddyfile.template /etc/caddy/Caddyfile
|
||||
|
||||
COPY ./caddy.sh /docker-entrypoint.sh
|
||||
|
||||
RUN chmod +x /docker-entrypoint.sh
|
||||
|
||||
CMD ["/docker-entrypoint.sh"]
|
||||
@@ -1,11 +0,0 @@
|
||||
#!/bin/sh
|
||||
|
||||
if [ "$APP_DOMAIN" == "localhost" ]; then
|
||||
export SITE_ADDRESS=":${LISTEN_HTTP_PORT}"
|
||||
elif [ "$SSL" == "true" ]; then
|
||||
export SITE_ADDRESS="${APP_DOMAIN}:${LISTEN_HTTPS_PORT}"
|
||||
else
|
||||
export SITE_ADDRESS="http://${APP_DOMAIN}:${LISTEN_HTTP_PORT}"
|
||||
fi
|
||||
|
||||
exec caddy run --config /etc/caddy/Caddyfile
|
||||
@@ -58,7 +58,7 @@ Installing plane is a very easy and minimal step process.
|
||||
### Downloading Latest Release
|
||||
|
||||
```
|
||||
mkdir -p plane-selfhost && cd plane-selfhost
|
||||
mkdir plane-selfhost
|
||||
|
||||
cd plane-selfhost
|
||||
```
|
||||
@@ -144,15 +144,11 @@ Again the `options [1-7]` will be popped up, and this time hit `7` to exit.
|
||||
Before proceeding, we suggest used to review `.env` file and set the values.
|
||||
Below are the most import keys you must refer to. _<span style="color: #fcba03">You can use any text editor to edit this file</span>_.
|
||||
|
||||
> `APP_DOMAIN` - Set the Fully Qualified Domain Name here. (eg. `plane.example.com`)
|
||||
>
|
||||
> `LISTEN_PORT` - This is default set to `80`. Make sure the port you choose to use is not preoccupied. (e.g `LISTEN_PORT=8080`)
|
||||
>
|
||||
> `LISTEN_SSL_PORT` - This is default set to `443`. Make sure the port you choose to use is not preoccupied. (e.g `LISTEN_SSL_PORT=8443`)
|
||||
>
|
||||
> `WEB_URL` - This is default set to `http://localhost`. Change this to the FQDN you plan to use along with LISTEN_PORT/LISTEN_SSL_PORT (eg. `https://plane.example.com:8443` or `http://[IP-ADDRESS]:8080`)
|
||||
>
|
||||
> `CORS_ALLOWED_ORIGINS` - This is default set to `http://${APP_DOMAIN},https://${APP_DOMAIN}`. Change this to the FQDN you plan to use along with LISTEN_PORT and LISTEN_SSL_PORT (eg. `http://plane.example.com:8080,https://plane.example.com:8443`)
|
||||
> `NGINX_PORT` - This is default set to `80`. Make sure the port you choose to use is not preoccupied. (e.g `NGINX_PORT=8080`)
|
||||
|
||||
> `WEB_URL` - This is default set to `http://localhost`. Change this to the FQDN you plan to use along with NGINX_PORT (eg. `https://plane.example.com:8080` or `http://[IP-ADDRESS]:8080`)
|
||||
|
||||
> `CORS_ALLOWED_ORIGINS` - This is default set to `http://localhost`. Change this to the FQDN you plan to use along with NGINX_PORT (eg. `https://plane.example.com:8080` or `http://[IP-ADDRESS]:8080`)
|
||||
|
||||
There are many other settings you can play with, but we suggest you configure `EMAIL SETTINGS` as it will enable you to invite your teammates onto the platform.
|
||||
|
||||
@@ -176,8 +172,6 @@ Select a Action you want to perform:
|
||||
Action [2]: 2
|
||||
```
|
||||
|
||||
> You can also choose to run `./setup.sh start` as direct command.
|
||||
|
||||
Expect something like this.
|
||||

|
||||
|
||||
@@ -213,8 +207,6 @@ Select a Action you want to perform:
|
||||
Action [2]: 3
|
||||
```
|
||||
|
||||
> You can also choose to run `./setup.sh stop` as direct command.
|
||||
|
||||
If all goes well, you must see something like this
|
||||
|
||||

|
||||
@@ -261,8 +253,6 @@ Select a Action you want to perform:
|
||||
Action [2]: 4
|
||||
```
|
||||
|
||||
> You can also choose to run `./setup.sh restart` as direct command.
|
||||
|
||||
If all goes well, you must see something like this
|
||||
|
||||

|
||||
@@ -307,8 +297,6 @@ Select a Action you want to perform:
|
||||
Action [2]: 5
|
||||
```
|
||||
|
||||
> You can also choose to run `./setup.sh upgrade` as direct command.
|
||||
|
||||
By choosing this, it will stop the services and then will download the latest `docker-compose.yaml` and `plane.env`.
|
||||
|
||||
You must expect the below message
|
||||
@@ -477,8 +465,6 @@ Select a Action you want to perform:
|
||||
Action [2]: 7
|
||||
```
|
||||
|
||||
> You can also choose to run `./setup.sh backup` as direct command.
|
||||
|
||||
In response, you can find the backup folder
|
||||
|
||||
```bash
|
||||
|
||||
@@ -17,12 +17,6 @@ services:
|
||||
context: ./
|
||||
dockerfile: ./admin/Dockerfile.admin
|
||||
|
||||
live:
|
||||
image: ${DOCKERHUB_USER:-local}/plane-live:${APP_RELEASE:-latest}
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./live/Dockerfile.live
|
||||
|
||||
api:
|
||||
image: ${DOCKERHUB_USER:-local}/plane-backend:${APP_RELEASE:-latest}
|
||||
build:
|
||||
@@ -32,5 +26,5 @@ services:
|
||||
proxy:
|
||||
image: ${DOCKERHUB_USER:-local}/plane-proxy:${APP_RELEASE:-latest}
|
||||
build:
|
||||
context: ./caddy
|
||||
context: ./nginx
|
||||
dockerfile: ./Dockerfile
|
||||
|
||||
@@ -24,14 +24,9 @@ x-aws-s3-env: &aws-s3-env
|
||||
AWS_S3_BUCKET_NAME: ${AWS_S3_BUCKET_NAME:-uploads}
|
||||
|
||||
x-proxy-env: &proxy-env
|
||||
SSL: ${SSL:-false}
|
||||
APP_DOMAIN: ${APP_DOMAIN:-localhost}
|
||||
FILE_SIZE_LIMIT: ${FILE_SIZE_LIMIT:-5242880}
|
||||
CERT_EMAIL: ${CERT_EMAIL:-admin@example.com}
|
||||
CERT_ACME_CA: ${CERT_ACME_CA:-}
|
||||
LISTEN_HTTP_PORT: ${LISTEN_PORT:-80}
|
||||
LISTEN_HTTPS_PORT: ${LISTEN_SSL_PORT:-443}
|
||||
NGINX_PORT: ${NGINX_PORT:-80}
|
||||
BUCKET_NAME: ${AWS_S3_BUCKET_NAME:-uploads}
|
||||
FILE_SIZE_LIMIT: ${FILE_SIZE_LIMIT:-5242880}
|
||||
|
||||
x-mq-env: &mq-env # RabbitMQ Settings
|
||||
RABBITMQ_HOST: ${RABBITMQ_HOST:-plane-mq}
|
||||
@@ -217,31 +212,22 @@ services:
|
||||
|
||||
# Comment this if you already have a reverse proxy running
|
||||
proxy:
|
||||
image: artifacts.plane.so/makeplane/plane-proxy:${APP_RELEASE_VERSION}
|
||||
image: artifacts.plane.so/makeplane/plane-proxy:${APP_RELEASE:-stable}
|
||||
ports:
|
||||
- target: 80
|
||||
published: ${NGINX_PORT:-80}
|
||||
protocol: tcp
|
||||
mode: host
|
||||
environment:
|
||||
<<: *proxy-env
|
||||
deploy:
|
||||
replicas: 1
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
environment:
|
||||
<<: *proxy-env
|
||||
ports:
|
||||
- target: 80
|
||||
published: ${LISTEN_HTTP_PORT:-80}
|
||||
protocol: tcp
|
||||
mode: host
|
||||
- target: 443
|
||||
published: ${LISTEN_HTTPS_PORT:-443}
|
||||
protocol: tcp
|
||||
mode: host
|
||||
volumes:
|
||||
- proxy_config:/config
|
||||
- proxy_data:/data
|
||||
depends_on:
|
||||
- web
|
||||
- api
|
||||
- space
|
||||
- admin
|
||||
- live
|
||||
- web
|
||||
- api
|
||||
- space
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
@@ -251,6 +237,4 @@ volumes:
|
||||
logs_worker:
|
||||
logs_beat-worker:
|
||||
logs_migrator:
|
||||
caddy_config:
|
||||
caddy_data:
|
||||
rabbitmq_data:
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
#!/bin/bash
|
||||
|
||||
BRANCH=${BRANCH:-master}
|
||||
RELEASE_TAG=${RELEASE_TAG:-v0.22-dev}
|
||||
SCRIPT_DIR=$PWD
|
||||
SERVICE_FOLDER=plane-app
|
||||
PLANE_INSTALL_DIR=$PWD/$SERVICE_FOLDER
|
||||
@@ -178,13 +177,11 @@ function syncEnvFile(){
|
||||
updateEnvFile "$key" "$value" "$DOCKER_ENV_PATH"
|
||||
fi
|
||||
done < "$DOCKER_ENV_PATH"
|
||||
# Replace APP_RELEASE with the latest value
|
||||
updateEnvFile "APP_RELEASE" "$APP_RELEASE" "$DOCKER_ENV_PATH"
|
||||
fi
|
||||
echo "Environment variables synced successfully" >&2
|
||||
}
|
||||
|
||||
function buildYourOwnImage() {
|
||||
function buildYourOwnImage(){
|
||||
echo "Building images locally..."
|
||||
|
||||
export DOCKERHUB_USER="myplane"
|
||||
@@ -426,7 +423,7 @@ function upgrade() {
|
||||
stopServices
|
||||
|
||||
echo
|
||||
echo "***** DOWNLOADING $APP_RELEASE VERSION ****"
|
||||
echo "***** DOWNLOADING STABLE VERSION ****"
|
||||
install
|
||||
|
||||
echo "***** PLEASE VALIDATE AND START SERVICES ****"
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
APP_DOMAIN=localhost
|
||||
APP_RELEASE=stable
|
||||
SSL=false
|
||||
|
||||
WEB_REPLICAS=1
|
||||
SPACE_REPLICAS=1
|
||||
@@ -10,11 +9,10 @@ WORKER_REPLICAS=1
|
||||
BEAT_WORKER_REPLICAS=1
|
||||
LIVE_REPLICAS=1
|
||||
|
||||
LISTEN_PORT=80
|
||||
LISTEN_SSL_PORT=443
|
||||
NGINX_PORT=80
|
||||
WEB_URL=http://${APP_DOMAIN}
|
||||
DEBUG=0
|
||||
CORS_ALLOWED_ORIGINS=http://${APP_DOMAIN},https://${APP_DOMAIN}
|
||||
CORS_ALLOWED_ORIGINS=http://${APP_DOMAIN}
|
||||
API_BASE_URL=http://api:8000
|
||||
|
||||
#DB SETTINGS
|
||||
@@ -32,11 +30,6 @@ REDIS_HOST=plane-redis
|
||||
REDIS_PORT=6379
|
||||
REDIS_URL=
|
||||
|
||||
# If SSL Cert to be generated, set CERT_EMAIL and APP_PROTOCOL to https
|
||||
CERT_EMAIL=
|
||||
CERT_ACME_CA=https://acme-v02.api.letsencrypt.org/directory
|
||||
TRUSTED_PROXIES=0.0.0.0/0
|
||||
|
||||
# RabbitMQ Settings
|
||||
RABBITMQ_HOST=plane-mq
|
||||
RABBITMQ_PORT=5672
|
||||
|
||||
@@ -1,238 +0,0 @@
|
||||
export type IssueEventProps = {
|
||||
eventName: string;
|
||||
payload: any;
|
||||
updates?: any;
|
||||
path?: string;
|
||||
};
|
||||
|
||||
export type EventProps = {
|
||||
eventName: string;
|
||||
payload: any;
|
||||
updates?: any;
|
||||
path?: string;
|
||||
};
|
||||
|
||||
export const getWorkspaceEventPayload = (payload: any) => ({
|
||||
workspace_id: payload.id,
|
||||
created_at: payload.created_at,
|
||||
updated_at: payload.updated_at,
|
||||
organization_size: payload.organization_size,
|
||||
first_time: payload.first_time,
|
||||
state: payload.state,
|
||||
element: payload.element,
|
||||
});
|
||||
|
||||
export const getProjectEventPayload = (payload: any) => ({
|
||||
workspace_id: payload.workspace_id,
|
||||
project_id: payload.id,
|
||||
identifier: payload.identifier,
|
||||
project_visibility: payload.network == 2 ? "Public" : "Private",
|
||||
changed_properties: payload.changed_properties,
|
||||
lead_id: payload.project_lead,
|
||||
created_at: payload.created_at,
|
||||
updated_at: payload.updated_at,
|
||||
state: payload.state,
|
||||
element: payload.element,
|
||||
});
|
||||
|
||||
export const getCycleEventPayload = (payload: any) => ({
|
||||
workspace_id: payload.workspace_id,
|
||||
project_id: payload.project,
|
||||
cycle_id: payload.id,
|
||||
created_at: payload.created_at,
|
||||
updated_at: payload.updated_at,
|
||||
start_date: payload.start_date,
|
||||
target_date: payload.target_date,
|
||||
cycle_status: payload.status,
|
||||
changed_properties: payload.changed_properties,
|
||||
state: payload.state,
|
||||
element: payload.element,
|
||||
});
|
||||
|
||||
export const getModuleEventPayload = (payload: any) => ({
|
||||
workspace_id: payload.workspace_id,
|
||||
project_id: payload.project,
|
||||
module_id: payload.id,
|
||||
created_at: payload.created_at,
|
||||
updated_at: payload.updated_at,
|
||||
start_date: payload.start_date,
|
||||
target_date: payload.target_date,
|
||||
module_status: payload.status,
|
||||
lead_id: payload.lead,
|
||||
changed_properties: payload.changed_properties,
|
||||
member_ids: payload.members,
|
||||
state: payload.state,
|
||||
element: payload.element,
|
||||
});
|
||||
|
||||
export const getPageEventPayload = (payload: any) => ({
|
||||
workspace_id: payload.workspace_id,
|
||||
project_id: payload.project,
|
||||
created_at: payload.created_at,
|
||||
updated_at: payload.updated_at,
|
||||
access: payload.access === 0 ? "Public" : "Private",
|
||||
is_locked: payload.is_locked,
|
||||
archived_at: payload.archived_at,
|
||||
created_by: payload.created_by,
|
||||
state: payload.state,
|
||||
element: payload.element,
|
||||
});
|
||||
|
||||
export const getIssueEventPayload = (props: IssueEventProps) => {
|
||||
const { eventName, payload, updates, path } = props;
|
||||
let eventPayload: any = {
|
||||
issue_id: payload.id,
|
||||
estimate_point: payload.estimate_point,
|
||||
link_count: payload.link_count,
|
||||
target_date: payload.target_date,
|
||||
is_draft: payload.is_draft,
|
||||
label_ids: payload.label_ids,
|
||||
assignee_ids: payload.assignee_ids,
|
||||
created_at: payload.created_at,
|
||||
updated_at: payload.updated_at,
|
||||
sequence_id: payload.sequence_id,
|
||||
module_ids: payload.module_ids,
|
||||
sub_issues_count: payload.sub_issues_count,
|
||||
parent_id: payload.parent_id,
|
||||
project_id: payload.project_id,
|
||||
workspace_id: payload.workspace_id,
|
||||
priority: payload.priority,
|
||||
state_id: payload.state_id,
|
||||
start_date: payload.start_date,
|
||||
attachment_count: payload.attachment_count,
|
||||
cycle_id: payload.cycle_id,
|
||||
module_id: payload.module_id,
|
||||
archived_at: payload.archived_at,
|
||||
state: payload.state,
|
||||
view_id:
|
||||
path?.includes("workspace-views") || path?.includes("views")
|
||||
? path.split("/").pop()
|
||||
: "",
|
||||
};
|
||||
|
||||
if (eventName === ISSUE_UPDATED) {
|
||||
eventPayload = {
|
||||
...eventPayload,
|
||||
...updates,
|
||||
updated_from: props.path?.includes("workspace-views")
|
||||
? "All views"
|
||||
: props.path?.includes("cycles")
|
||||
? "Cycle"
|
||||
: props.path?.includes("modules")
|
||||
? "Module"
|
||||
: props.path?.includes("views")
|
||||
? "Project view"
|
||||
: props.path?.includes("inbox")
|
||||
? "Inbox"
|
||||
: props.path?.includes("draft")
|
||||
? "Draft"
|
||||
: "Project",
|
||||
};
|
||||
}
|
||||
return eventPayload;
|
||||
};
|
||||
|
||||
export const getProjectStateEventPayload = (payload: any) => ({
|
||||
workspace_id: payload.workspace_id,
|
||||
project_id: payload.id,
|
||||
state_id: payload.id,
|
||||
created_at: payload.created_at,
|
||||
updated_at: payload.updated_at,
|
||||
group: payload.group,
|
||||
color: payload.color,
|
||||
default: payload.default,
|
||||
state: payload.state,
|
||||
element: payload.element,
|
||||
});
|
||||
|
||||
// Workspace crud Events
|
||||
export const WORKSPACE_CREATED = "Workspace created";
|
||||
export const WORKSPACE_UPDATED = "Workspace updated";
|
||||
export const WORKSPACE_DELETED = "Workspace deleted";
|
||||
// Project Events
|
||||
export const PROJECT_CREATED = "Project created";
|
||||
export const PROJECT_UPDATED = "Project updated";
|
||||
export const PROJECT_DELETED = "Project deleted";
|
||||
// Cycle Events
|
||||
export const CYCLE_CREATED = "Cycle created";
|
||||
export const CYCLE_UPDATED = "Cycle updated";
|
||||
export const CYCLE_DELETED = "Cycle deleted";
|
||||
export const CYCLE_FAVORITED = "Cycle favorited";
|
||||
export const CYCLE_UNFAVORITED = "Cycle unfavorited";
|
||||
// Module Events
|
||||
export const MODULE_CREATED = "Module created";
|
||||
export const MODULE_UPDATED = "Module updated";
|
||||
export const MODULE_DELETED = "Module deleted";
|
||||
export const MODULE_FAVORITED = "Module favorited";
|
||||
export const MODULE_UNFAVORITED = "Module unfavorited";
|
||||
export const MODULE_LINK_CREATED = "Module link created";
|
||||
export const MODULE_LINK_UPDATED = "Module link updated";
|
||||
export const MODULE_LINK_DELETED = "Module link deleted";
|
||||
// Issue Events
|
||||
export const ISSUE_CREATED = "Work item created";
|
||||
export const ISSUE_UPDATED = "Work item updated";
|
||||
export const ISSUE_DELETED = "Work item deleted";
|
||||
export const ISSUE_ARCHIVED = "Work item archived";
|
||||
export const ISSUE_RESTORED = "Work item restored";
|
||||
export const ISSUE_OPENED = "Work item opened";
|
||||
// Project State Events
|
||||
export const STATE_CREATED = "State created";
|
||||
export const STATE_UPDATED = "State updated";
|
||||
export const STATE_DELETED = "State deleted";
|
||||
// Project Page Events
|
||||
export const PAGE_CREATED = "Page created";
|
||||
export const PAGE_UPDATED = "Page updated";
|
||||
export const PAGE_DELETED = "Page deleted";
|
||||
// Member Events
|
||||
export const MEMBER_INVITED = "Member invited";
|
||||
export const MEMBER_ACCEPTED = "Member accepted";
|
||||
export const PROJECT_MEMBER_ADDED = "Project member added";
|
||||
export const PROJECT_MEMBER_LEAVE = "Project member leave";
|
||||
export const WORKSPACE_MEMBER_LEAVE = "Workspace member leave";
|
||||
// Sign-in & Sign-up Events
|
||||
export const NAVIGATE_TO_SIGNUP = "Navigate to sign-up page";
|
||||
export const NAVIGATE_TO_SIGNIN = "Navigate to sign-in page";
|
||||
export const CODE_VERIFIED = "Code verified";
|
||||
export const SETUP_PASSWORD = "Password setup";
|
||||
export const PASSWORD_CREATE_SELECTED = "Password created";
|
||||
export const PASSWORD_CREATE_SKIPPED = "Skipped to setup";
|
||||
export const SIGN_IN_WITH_PASSWORD = "Sign in with password";
|
||||
export const SIGN_UP_WITH_PASSWORD = "Sign up with password";
|
||||
export const SIGN_IN_WITH_CODE = "Sign in with magic link";
|
||||
export const FORGOT_PASSWORD = "Forgot password clicked";
|
||||
export const FORGOT_PASS_LINK = "Forgot password link generated";
|
||||
export const NEW_PASS_CREATED = "New password created";
|
||||
// Onboarding Events
|
||||
export const USER_DETAILS = "User details added";
|
||||
export const USER_ONBOARDING_COMPLETED = "User onboarding completed";
|
||||
// Product Tour Events
|
||||
export const PRODUCT_TOUR_STARTED = "Product tour started";
|
||||
export const PRODUCT_TOUR_COMPLETED = "Product tour completed";
|
||||
export const PRODUCT_TOUR_SKIPPED = "Product tour skipped";
|
||||
// Dashboard Events
|
||||
export const CHANGELOG_REDIRECTED = "Changelog redirected";
|
||||
export const GITHUB_REDIRECTED = "GitHub redirected";
|
||||
// Sidebar Events
|
||||
export const SIDEBAR_CLICKED = "Sidenav clicked";
|
||||
// Global View Events
|
||||
export const GLOBAL_VIEW_CREATED = "Global view created";
|
||||
export const GLOBAL_VIEW_UPDATED = "Global view updated";
|
||||
export const GLOBAL_VIEW_DELETED = "Global view deleted";
|
||||
export const GLOBAL_VIEW_OPENED = "Global view opened";
|
||||
// Notification Events
|
||||
export const NOTIFICATION_ARCHIVED = "Notification archived";
|
||||
export const NOTIFICATION_SNOOZED = "Notification snoozed";
|
||||
export const NOTIFICATION_READ = "Notification marked read";
|
||||
export const UNREAD_NOTIFICATIONS = "Unread notifications viewed";
|
||||
export const NOTIFICATIONS_READ = "All notifications marked read";
|
||||
export const SNOOZED_NOTIFICATIONS = "Snoozed notifications viewed";
|
||||
export const ARCHIVED_NOTIFICATIONS = "Archived notifications viewed";
|
||||
// Groups
|
||||
export const GROUP_WORKSPACE = "Workspace_metrics";
|
||||
|
||||
//Elements
|
||||
export const E_ONBOARDING = "Onboarding";
|
||||
export const E_ONBOARDING_STEP_1 = "Onboarding step 1";
|
||||
export const E_ONBOARDING_STEP_2 = "Onboarding step 2";
|
||||
// Favorites
|
||||
export const FAVORITE_ADDED = "Favorite added";
|
||||
@@ -0,0 +1,258 @@
|
||||
export type IssueEventProps = {
|
||||
eventName: string;
|
||||
payload: any;
|
||||
updates?: any;
|
||||
path?: string;
|
||||
};
|
||||
|
||||
export type EventProps = {
|
||||
eventName: string;
|
||||
payload: any;
|
||||
updates?: any;
|
||||
path?: string;
|
||||
};
|
||||
|
||||
export const getWorkspaceEventPayload = (payload: any) => ({
|
||||
workspace_id: payload.id,
|
||||
created_at: payload.created_at,
|
||||
updated_at: payload.updated_at,
|
||||
organization_size: payload.organization_size,
|
||||
first_time: payload.first_time,
|
||||
state: payload.state,
|
||||
element: payload.element,
|
||||
});
|
||||
|
||||
export const getProjectEventPayload = (payload: any) => ({
|
||||
workspace_id: payload.workspace_id,
|
||||
project_id: payload.id,
|
||||
identifier: payload.identifier,
|
||||
project_visibility: payload.network == 2 ? "Public" : "Private",
|
||||
changed_properties: payload.changed_properties,
|
||||
lead_id: payload.project_lead,
|
||||
created_at: payload.created_at,
|
||||
updated_at: payload.updated_at,
|
||||
state: payload.state,
|
||||
element: payload.element,
|
||||
});
|
||||
|
||||
export const getCycleEventPayload = (payload: any) => ({
|
||||
workspace_id: payload.workspace_id,
|
||||
project_id: payload.project,
|
||||
cycle_id: payload.id,
|
||||
created_at: payload.created_at,
|
||||
updated_at: payload.updated_at,
|
||||
start_date: payload.start_date,
|
||||
target_date: payload.target_date,
|
||||
cycle_status: payload.status,
|
||||
changed_properties: payload.changed_properties,
|
||||
state: payload.state,
|
||||
element: payload.element,
|
||||
});
|
||||
|
||||
export const getModuleEventPayload = (payload: any) => ({
|
||||
workspace_id: payload.workspace_id,
|
||||
project_id: payload.project,
|
||||
module_id: payload.id,
|
||||
created_at: payload.created_at,
|
||||
updated_at: payload.updated_at,
|
||||
start_date: payload.start_date,
|
||||
target_date: payload.target_date,
|
||||
module_status: payload.status,
|
||||
lead_id: payload.lead,
|
||||
changed_properties: payload.changed_properties,
|
||||
member_ids: payload.members,
|
||||
state: payload.state,
|
||||
element: payload.element,
|
||||
});
|
||||
|
||||
export const getPageEventPayload = (payload: any) => ({
|
||||
workspace_id: payload.workspace_id,
|
||||
project_id: payload.project,
|
||||
created_at: payload.created_at,
|
||||
updated_at: payload.updated_at,
|
||||
access: payload.access === 0 ? "Public" : "Private",
|
||||
is_locked: payload.is_locked,
|
||||
archived_at: payload.archived_at,
|
||||
created_by: payload.created_by,
|
||||
state: payload.state,
|
||||
element: payload.element,
|
||||
});
|
||||
|
||||
export const getIssueEventPayload = (props: IssueEventProps) => {
|
||||
const { eventName, payload, updates, path } = props;
|
||||
let eventPayload: any = {
|
||||
issue_id: payload.id,
|
||||
estimate_point: payload.estimate_point,
|
||||
link_count: payload.link_count,
|
||||
target_date: payload.target_date,
|
||||
is_draft: payload.is_draft,
|
||||
label_ids: payload.label_ids,
|
||||
assignee_ids: payload.assignee_ids,
|
||||
created_at: payload.created_at,
|
||||
updated_at: payload.updated_at,
|
||||
sequence_id: payload.sequence_id,
|
||||
module_ids: payload.module_ids,
|
||||
sub_issues_count: payload.sub_issues_count,
|
||||
parent_id: payload.parent_id,
|
||||
project_id: payload.project_id,
|
||||
workspace_id: payload.workspace_id,
|
||||
priority: payload.priority,
|
||||
state_id: payload.state_id,
|
||||
start_date: payload.start_date,
|
||||
attachment_count: payload.attachment_count,
|
||||
cycle_id: payload.cycle_id,
|
||||
module_id: payload.module_id,
|
||||
archived_at: payload.archived_at,
|
||||
state: payload.state,
|
||||
view_id: path?.includes("workspace-views") || path?.includes("views") ? path.split("/").pop() : "",
|
||||
};
|
||||
|
||||
if (eventName === WORK_ITEM_TRACKER_EVENTS.update) {
|
||||
eventPayload = {
|
||||
...eventPayload,
|
||||
...updates,
|
||||
updated_from: props.path?.includes("workspace-views")
|
||||
? "All views"
|
||||
: props.path?.includes("cycles")
|
||||
? "Cycle"
|
||||
: props.path?.includes("modules")
|
||||
? "Module"
|
||||
: props.path?.includes("views")
|
||||
? "Project view"
|
||||
: props.path?.includes("inbox")
|
||||
? "Inbox"
|
||||
: props.path?.includes("draft")
|
||||
? "Draft"
|
||||
: "Project",
|
||||
};
|
||||
}
|
||||
return eventPayload;
|
||||
};
|
||||
|
||||
export const getProjectStateEventPayload = (payload: any) => ({
|
||||
workspace_id: payload.workspace_id,
|
||||
project_id: payload.id,
|
||||
state_id: payload.id,
|
||||
created_at: payload.created_at,
|
||||
updated_at: payload.updated_at,
|
||||
group: payload.group,
|
||||
color: payload.color,
|
||||
default: payload.default,
|
||||
state: payload.state,
|
||||
element: payload.element,
|
||||
});
|
||||
|
||||
// Dashboard Events
|
||||
export const GITHUB_REDIRECTED_TRACKER_EVENT = "github_redirected";
|
||||
// Groups
|
||||
export const GROUP_WORKSPACE_TRACKER_EVENT = "workspace_metrics";
|
||||
|
||||
export const WORKSPACE_TRACKER_EVENTS = {
|
||||
create: "workspace_created",
|
||||
update: "workspace_updated",
|
||||
delete: "workspace_deleted",
|
||||
};
|
||||
|
||||
export const PROJECT_TRACKER_EVENTS = {
|
||||
create: "project_created",
|
||||
update: "project_updated",
|
||||
delete: "project_deleted",
|
||||
};
|
||||
|
||||
export const CYCLE_TRACKER_EVENTS = {
|
||||
create: "cycle_created",
|
||||
update: "cycle_updated",
|
||||
delete: "cycle_deleted",
|
||||
favorite: "cycle_favorited",
|
||||
unfavorite: "cycle_unfavorited",
|
||||
};
|
||||
|
||||
export const MODULE_TRACKER_EVENTS = {
|
||||
create: "module_created",
|
||||
update: "module_updated",
|
||||
delete: "module_deleted",
|
||||
favorite: "module_favorited",
|
||||
unfavorite: "module_unfavorited",
|
||||
link: {
|
||||
create: "module_link_created",
|
||||
update: "module_link_updated",
|
||||
delete: "module_link_deleted",
|
||||
},
|
||||
};
|
||||
|
||||
export const WORK_ITEM_TRACKER_EVENTS = {
|
||||
create: "work_item_created",
|
||||
update: "work_item_updated",
|
||||
delete: "work_item_deleted",
|
||||
archive: "work_item_archived",
|
||||
restore: "work_item_restored",
|
||||
};
|
||||
|
||||
export const STATE_TRACKER_EVENTS = {
|
||||
create: "state_created",
|
||||
update: "state_updated",
|
||||
delete: "state_deleted",
|
||||
};
|
||||
|
||||
export const PROJECT_PAGE_TRACKER_EVENTS = {
|
||||
create: "project_page_created",
|
||||
update: "project_page_updated",
|
||||
delete: "project_page_deleted",
|
||||
};
|
||||
|
||||
export const MEMBER_TRACKER_EVENTS = {
|
||||
invite: "member_invited",
|
||||
accept: "member_accepted",
|
||||
project: {
|
||||
add: "project_member_added",
|
||||
leave: "project_member_left",
|
||||
},
|
||||
workspace: {
|
||||
leave: "workspace_member_left",
|
||||
},
|
||||
};
|
||||
|
||||
export const AUTH_TRACKER_EVENTS = {
|
||||
navigate: {
|
||||
sign_up: "navigate_to_sign_up_page",
|
||||
sign_in: "navigate_to_sign_in_page",
|
||||
},
|
||||
code_verify: "code_verified",
|
||||
sign_up_with_password: "sign_up_with_password",
|
||||
sign_in_with_password: "sign_in_with_password",
|
||||
sign_in_with_code: "sign_in_with_magic_link",
|
||||
forgot_password: "forgot_password_clicked",
|
||||
};
|
||||
|
||||
export const PRODUCT_TOUR_TRACKER_EVENTS = {
|
||||
start: "product_tour_started",
|
||||
complete: "product_tour_completed",
|
||||
skip: "product_tour_skipped",
|
||||
};
|
||||
|
||||
export const GLOBAL_VIEW_TOUR_TRACKER_EVENTS = {
|
||||
create: "global_view_created",
|
||||
update: "global_view_updated",
|
||||
delete: "global_view_deleted",
|
||||
open: "global_view_opened",
|
||||
};
|
||||
|
||||
export const NOTIFICATION_TRACKER_EVENTS = {
|
||||
archive: "notification_archived",
|
||||
all_marked_read: "all_notifications_marked_read",
|
||||
};
|
||||
|
||||
export const USER_TRACKER_EVENTS = {
|
||||
add_details: "user_details_added",
|
||||
onboarding_complete: "user_onboarding_completed",
|
||||
};
|
||||
|
||||
export const ONBOARDING_TRACKER_EVENTS = {
|
||||
root: "onboarding",
|
||||
step_1: "onboarding_step_1",
|
||||
step_2: "onboarding_step_2",
|
||||
};
|
||||
|
||||
export const SIDEBAR_TRACKER_EVENTS = {
|
||||
click: "sidenav_clicked",
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./core";
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* @description function to extract all additional assets from HTML content
|
||||
* @param htmlContent
|
||||
* @returns {string[]} array of additional asset sources
|
||||
*/
|
||||
export const extractAdditionalAssetsFromHTMLContent = (_htmlContent: string): string[] => [];
|
||||
|
||||
/**
|
||||
* @description function to replace additional assets in HTML content with new IDs
|
||||
* @param props
|
||||
* @returns {string} HTML content with replaced additional assets
|
||||
*/
|
||||
export const replaceAdditionalAssetsInHTMLContent = (props: {
|
||||
htmlContent: string;
|
||||
assetMap: Record<string, string>;
|
||||
}): string => {
|
||||
const { htmlContent } = props;
|
||||
return htmlContent;
|
||||
};
|
||||
@@ -2,7 +2,7 @@
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
// extensions
|
||||
import { type HeadingExtensionStorage } from "@/extensions";
|
||||
import { type CustomImageExtensionStorage } from "@/extensions/custom-image";
|
||||
import { type CustomImageExtensionStorage } from "@/extensions/custom-image/types";
|
||||
import { type CustomLinkStorage } from "@/extensions/custom-link";
|
||||
import { type ImageExtensionStorage } from "@/extensions/image";
|
||||
import { type MentionExtensionStorage } from "@/extensions/mentions";
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
BubbleMenuLinkSelector,
|
||||
BubbleMenuNodeSelector,
|
||||
CodeItem,
|
||||
EditorMenuItem,
|
||||
ItalicItem,
|
||||
StrikeThroughItem,
|
||||
TextAlignItem,
|
||||
@@ -23,6 +24,7 @@ import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
import { isCellSelection } from "@/extensions/table/table/utilities/is-cell-selection";
|
||||
// local components
|
||||
import { TextAlignmentSelector } from "./alignment-selector";
|
||||
import { TEditorCommands } from "@/types";
|
||||
|
||||
type EditorBubbleMenuProps = Omit<BubbleMenuProps, "children">;
|
||||
|
||||
@@ -31,19 +33,19 @@ export interface EditorStateType {
|
||||
bold: boolean;
|
||||
italic: boolean;
|
||||
underline: boolean;
|
||||
strike: boolean;
|
||||
strikethrough: boolean;
|
||||
left: boolean;
|
||||
right: boolean;
|
||||
center: boolean;
|
||||
color: { key: string; label: string; textColor: string; backgroundColor: string } | undefined;
|
||||
backgroundColor:
|
||||
| {
|
||||
key: string;
|
||||
label: string;
|
||||
textColor: string;
|
||||
backgroundColor: string;
|
||||
}
|
||||
| undefined;
|
||||
| {
|
||||
key: string;
|
||||
label: string;
|
||||
textColor: string;
|
||||
backgroundColor: string;
|
||||
}
|
||||
| undefined;
|
||||
}
|
||||
|
||||
export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: { editor: Editor }) => {
|
||||
@@ -58,8 +60,10 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: { editor: Edi
|
||||
bold: BoldItem(props.editor),
|
||||
italic: ItalicItem(props.editor),
|
||||
underline: UnderLineItem(props.editor),
|
||||
strike: StrikeThroughItem(props.editor),
|
||||
textAlign: TextAlignItem(props.editor),
|
||||
strikethrough: StrikeThroughItem(props.editor),
|
||||
"text-align": TextAlignItem(props.editor),
|
||||
} satisfies {
|
||||
[K in TEditorCommands]?: EditorMenuItem<K>;
|
||||
};
|
||||
|
||||
const editorState: EditorStateType = useEditorState({
|
||||
@@ -69,10 +73,10 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: { editor: Edi
|
||||
bold: formattingItems.bold.isActive(),
|
||||
italic: formattingItems.italic.isActive(),
|
||||
underline: formattingItems.underline.isActive(),
|
||||
strike: formattingItems.strike.isActive(),
|
||||
left: formattingItems.textAlign.isActive({ alignment: "left" }),
|
||||
right: formattingItems.textAlign.isActive({ alignment: "right" }),
|
||||
center: formattingItems.textAlign.isActive({ alignment: "center" }),
|
||||
strikethrough: formattingItems.strikethrough.isActive(),
|
||||
left: formattingItems["text-align"].isActive({ alignment: "left" }),
|
||||
right: formattingItems["text-align"].isActive({ alignment: "right" }),
|
||||
center: formattingItems["text-align"].isActive({ alignment: "center" }),
|
||||
color: COLORS_LIST.find((c) => TextColorItem(editor).isActive({ color: c.key })),
|
||||
backgroundColor: COLORS_LIST.find((c) => BackgroundColorItem(editor).isActive({ color: c.key })),
|
||||
}),
|
||||
@@ -80,7 +84,7 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: { editor: Edi
|
||||
|
||||
const basicFormattingOptions = editorState.code
|
||||
? [formattingItems.code]
|
||||
: [formattingItems.bold, formattingItems.italic, formattingItems.underline, formattingItems.strike];
|
||||
: [formattingItems.bold, formattingItems.italic, formattingItems.underline, formattingItems.strikethrough];
|
||||
|
||||
const bubbleMenuProps: EditorBubbleMenuProps = {
|
||||
...props,
|
||||
|
||||
@@ -12,10 +12,10 @@ import { CustomCalloutExtensionConfig } from "./callout/extension-config";
|
||||
import { CustomCodeBlockExtensionWithoutProps } from "./code/without-props";
|
||||
import { CustomCodeInlineExtension } from "./code-inline";
|
||||
import { CustomColorExtension } from "./custom-color";
|
||||
import { CustomImageExtensionConfig } from "./custom-image/extension-config";
|
||||
import { CustomLinkExtension } from "./custom-link";
|
||||
import { CustomHorizontalRule } from "./horizontal-rule";
|
||||
import { ImageExtensionWithoutProps } from "./image";
|
||||
import { CustomImageComponentWithoutProps } from "./image/image-component-without-props";
|
||||
import { ImageExtensionConfig } from "./image";
|
||||
import { CustomMentionExtensionConfig } from "./mentions/extension-config";
|
||||
import { CustomQuoteExtension } from "./quote";
|
||||
import { TableHeader, TableCell, TableRow, Table } from "./table";
|
||||
@@ -72,12 +72,8 @@ export const CoreEditorExtensionsWithoutProps = [
|
||||
"text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer",
|
||||
},
|
||||
}),
|
||||
ImageExtensionWithoutProps.configure({
|
||||
HTMLAttributes: {
|
||||
class: "rounded-md",
|
||||
},
|
||||
}),
|
||||
CustomImageComponentWithoutProps,
|
||||
ImageExtensionConfig,
|
||||
CustomImageExtensionConfig,
|
||||
TiptapUnderline,
|
||||
TextStyle,
|
||||
TaskList.configure({
|
||||
|
||||
+27
-53
@@ -1,68 +1,42 @@
|
||||
import { NodeSelection } from "@tiptap/pm/state";
|
||||
import React, { useRef, useState, useCallback, useLayoutEffect, useEffect } from "react";
|
||||
// plane utils
|
||||
// plane imports
|
||||
import { cn } from "@plane/utils";
|
||||
// extensions
|
||||
import { CustomBaseImageNodeViewProps, ImageToolbarRoot } from "@/extensions/custom-image";
|
||||
// local imports
|
||||
import { Pixel, TCustomImageAttributes, TCustomImageSize } from "../types";
|
||||
import { ensurePixelString } from "../utils";
|
||||
import type { CustomImageNodeViewProps } from "./node-view";
|
||||
import { ImageToolbarRoot } from "./toolbar";
|
||||
import { ImageUploadStatus } from "./upload-status";
|
||||
|
||||
const MIN_SIZE = 100;
|
||||
|
||||
type Pixel = `${number}px`;
|
||||
|
||||
type PixelAttribute<TDefault> = Pixel | TDefault;
|
||||
|
||||
export type ImageAttributes = {
|
||||
src: string | null;
|
||||
width: PixelAttribute<"35%" | number>;
|
||||
height: PixelAttribute<"auto" | number>;
|
||||
aspectRatio: number | null;
|
||||
id: string | null;
|
||||
};
|
||||
|
||||
type Size = {
|
||||
width: PixelAttribute<"35%">;
|
||||
height: PixelAttribute<"auto">;
|
||||
aspectRatio: number | null;
|
||||
};
|
||||
|
||||
const ensurePixelString = <TDefault,>(value: Pixel | TDefault | number | undefined | null, defaultValue?: TDefault) => {
|
||||
if (!value || value === defaultValue) {
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
if (typeof value === "number") {
|
||||
return `${value}px` satisfies Pixel;
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
||||
|
||||
type CustomImageBlockProps = CustomBaseImageNodeViewProps & {
|
||||
imageFromFileSystem: string | undefined;
|
||||
setFailedToLoadImage: (isError: boolean) => void;
|
||||
type CustomImageBlockProps = CustomImageNodeViewProps & {
|
||||
editorContainer: HTMLDivElement | null;
|
||||
imageFromFileSystem: string | undefined;
|
||||
setEditorContainer: (editorContainer: HTMLDivElement | null) => void;
|
||||
setFailedToLoadImage: (isError: boolean) => void;
|
||||
src: string | undefined;
|
||||
};
|
||||
|
||||
export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
|
||||
// props
|
||||
const {
|
||||
node,
|
||||
updateAttributes,
|
||||
setFailedToLoadImage,
|
||||
imageFromFileSystem,
|
||||
selected,
|
||||
getPos,
|
||||
editor,
|
||||
editorContainer,
|
||||
src: resolvedImageSrc,
|
||||
extension,
|
||||
getPos,
|
||||
imageFromFileSystem,
|
||||
node,
|
||||
selected,
|
||||
setEditorContainer,
|
||||
setFailedToLoadImage,
|
||||
src: resolvedImageSrc,
|
||||
updateAttributes,
|
||||
} = props;
|
||||
const { width: nodeWidth, height: nodeHeight, aspectRatio: nodeAspectRatio, src: imgNodeSrc } = node.attrs;
|
||||
// states
|
||||
const [size, setSize] = useState<Size>({
|
||||
const [size, setSize] = useState<TCustomImageSize>({
|
||||
width: ensurePixelString(nodeWidth, "35%") ?? "35%",
|
||||
height: ensurePixelString(nodeHeight, "auto") ?? "auto",
|
||||
aspectRatio: nodeAspectRatio || null,
|
||||
@@ -77,7 +51,7 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
|
||||
const [hasTriedRestoringImageOnce, setHasTriedRestoringImageOnce] = useState(false);
|
||||
|
||||
const updateAttributesSafely = useCallback(
|
||||
(attributes: Partial<ImageAttributes>, errorMessage: string) => {
|
||||
(attributes: Partial<TCustomImageAttributes>, errorMessage: string) => {
|
||||
try {
|
||||
updateAttributes(attributes);
|
||||
} catch (error) {
|
||||
@@ -114,7 +88,7 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
|
||||
const initialWidth = Math.max(editorWidth * 0.35, MIN_SIZE);
|
||||
const initialHeight = initialWidth / aspectRatioCalculated;
|
||||
|
||||
const initialComputedSize = {
|
||||
const initialComputedSize: TCustomImageSize = {
|
||||
width: `${Math.round(initialWidth)}px` satisfies Pixel,
|
||||
height: `${Math.round(initialHeight)}px` satisfies Pixel,
|
||||
aspectRatio: aspectRatioCalculated,
|
||||
@@ -139,7 +113,7 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
|
||||
}
|
||||
}
|
||||
setInitialResizeComplete(true);
|
||||
}, [nodeWidth, updateAttributes, editorContainer, nodeAspectRatio]);
|
||||
}, [nodeWidth, updateAttributesSafely, editorContainer, nodeAspectRatio, setEditorContainer]);
|
||||
|
||||
// for real time resizing
|
||||
useLayoutEffect(() => {
|
||||
@@ -168,7 +142,7 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
|
||||
const handleResizeEnd = useCallback(() => {
|
||||
setIsResizing(false);
|
||||
updateAttributesSafely(size, "Failed to update attributes at the end of resizing:");
|
||||
}, [size, updateAttributes]);
|
||||
}, [size, updateAttributesSafely]);
|
||||
|
||||
const handleResizeStart = useCallback((e: React.MouseEvent | React.TouchEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -242,7 +216,7 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
|
||||
onLoad={handleImageLoad}
|
||||
onError={async (e) => {
|
||||
// for old image extension this command doesn't exist or if the image failed to load for the first time
|
||||
if (!editor?.commands.restoreImage || hasTriedRestoringImageOnce) {
|
||||
if (!extension.options.restoreImage || hasTriedRestoringImageOnce) {
|
||||
setFailedToLoadImage(true);
|
||||
return;
|
||||
}
|
||||
@@ -253,7 +227,7 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
|
||||
if (!imgNodeSrc) {
|
||||
throw new Error("No source image to restore from");
|
||||
}
|
||||
await editor?.commands.restoreImage?.(imgNodeSrc);
|
||||
await extension.options.restoreImage?.(imgNodeSrc);
|
||||
if (!imageRef.current) {
|
||||
throw new Error("Image reference not found");
|
||||
}
|
||||
@@ -289,10 +263,10 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
|
||||
"absolute top-1 right-1 z-20 bg-black/40 rounded opacity-0 pointer-events-none group-hover/image-component:opacity-100 group-hover/image-component:pointer-events-auto transition-opacity"
|
||||
}
|
||||
image={{
|
||||
src: resolvedImageSrc,
|
||||
aspectRatio: size.aspectRatio === null ? 1 : size.aspectRatio,
|
||||
height: size.height,
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
aspectRatio: size.aspectRatio === null ? 1 : size.aspectRatio,
|
||||
src: resolvedImageSrc,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -1,4 +0,0 @@
|
||||
export * from "./toolbar";
|
||||
export * from "./image-block";
|
||||
export * from "./image-node";
|
||||
export * from "./image-uploader";
|
||||
+22
-25
@@ -2,25 +2,26 @@ import { Editor, NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
// extensions
|
||||
import { CustomImageBlock, CustomImageUploader, ImageAttributes } from "@/extensions/custom-image";
|
||||
// helpers
|
||||
import { getExtensionStorage } from "@/helpers/get-extension-storage";
|
||||
// local imports
|
||||
import type { CustomImageExtension, TCustomImageAttributes } from "../types";
|
||||
import { CustomImageBlock } from "./block";
|
||||
import { CustomImageUploader } from "./uploader";
|
||||
|
||||
export type CustomBaseImageNodeViewProps = {
|
||||
export type CustomImageNodeViewProps = Omit<NodeViewProps, "extension" | "updateAttributes"> & {
|
||||
extension: CustomImageExtension;
|
||||
getPos: () => number;
|
||||
editor: Editor;
|
||||
node: NodeViewProps["node"] & {
|
||||
attrs: ImageAttributes;
|
||||
attrs: TCustomImageAttributes;
|
||||
};
|
||||
updateAttributes: (attrs: Partial<ImageAttributes>) => void;
|
||||
updateAttributes: (attrs: Partial<TCustomImageAttributes>) => void;
|
||||
selected: boolean;
|
||||
};
|
||||
|
||||
export type CustomImageNodeProps = NodeViewProps & CustomBaseImageNodeViewProps;
|
||||
|
||||
export const CustomImageNode = (props: CustomImageNodeProps) => {
|
||||
const { getPos, editor, node, updateAttributes, selected } = props;
|
||||
export const CustomImageNodeView: React.FC<CustomImageNodeViewProps> = (props) => {
|
||||
const { editor, extension, node } = props;
|
||||
const { src: imgNodeSrc } = node.attrs;
|
||||
|
||||
const [isUploaded, setIsUploaded] = useState(false);
|
||||
@@ -50,41 +51,37 @@ export const CustomImageNode = (props: CustomImageNodeProps) => {
|
||||
}, [resolvedSrc]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!imgNodeSrc) {
|
||||
setResolvedSrc(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
const getImageSource = async () => {
|
||||
// @ts-expect-error function not expected here, but will still work and don't remove await
|
||||
const url: string = await editor?.commands?.getImageSource?.(imgNodeSrc);
|
||||
setResolvedSrc(url as string);
|
||||
const url = await extension.options.getImageSource?.(imgNodeSrc);
|
||||
setResolvedSrc(url);
|
||||
};
|
||||
getImageSource();
|
||||
}, [imgNodeSrc]);
|
||||
}, [imgNodeSrc, extension.options]);
|
||||
|
||||
return (
|
||||
<NodeViewWrapper>
|
||||
<div className="p-0 mx-0 my-2" data-drag-handle ref={imageComponentRef}>
|
||||
{(isUploaded || imageFromFileSystem) && !failedToLoadImage ? (
|
||||
<CustomImageBlock
|
||||
imageFromFileSystem={imageFromFileSystem}
|
||||
editorContainer={editorContainer}
|
||||
editor={editor}
|
||||
src={resolvedSrc}
|
||||
getPos={getPos}
|
||||
node={node}
|
||||
imageFromFileSystem={imageFromFileSystem}
|
||||
setEditorContainer={setEditorContainer}
|
||||
setFailedToLoadImage={setFailedToLoadImage}
|
||||
selected={selected}
|
||||
updateAttributes={updateAttributes}
|
||||
src={resolvedSrc}
|
||||
{...props}
|
||||
/>
|
||||
) : (
|
||||
<CustomImageUploader
|
||||
editor={editor}
|
||||
failedToLoadImage={failedToLoadImage}
|
||||
getPos={getPos}
|
||||
loadImageFromFileSystem={setImageFromFileSystem}
|
||||
maxFileSize={getExtensionStorage(editor, CORE_EXTENSIONS.CUSTOM_IMAGE).maxFileSize}
|
||||
node={node}
|
||||
setIsUploaded={setIsUploaded}
|
||||
selected={selected}
|
||||
updateAttributes={updateAttributes}
|
||||
{...props}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -1,14 +1,14 @@
|
||||
import { ExternalLink, Maximize, Minus, Plus, X } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState, useRef } from "react";
|
||||
// plane utils
|
||||
// plane imports
|
||||
import { cn } from "@plane/utils";
|
||||
|
||||
type Props = {
|
||||
image: {
|
||||
src: string;
|
||||
height: string;
|
||||
width: string;
|
||||
height: string;
|
||||
aspectRatio: number;
|
||||
src: string;
|
||||
};
|
||||
isOpen: boolean;
|
||||
toggleFullScreenMode: (val: boolean) => void;
|
||||
@@ -189,7 +189,7 @@ export const ImageFullScreenAction: React.FC<Props> = (props) => {
|
||||
<>
|
||||
<div
|
||||
className={cn("fixed inset-0 size-full z-20 bg-black/90 opacity-0 pointer-events-none transition-opacity", {
|
||||
"opacity-100 pointer-events-auto": isFullScreenEnabled,
|
||||
"opacity-100 pointer-events-auto editor-image-full-screen-modal": isFullScreenEnabled,
|
||||
"cursor-default": !isDragging,
|
||||
"cursor-grabbing": isDragging,
|
||||
})}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { useState } from "react";
|
||||
// plane utils
|
||||
// plane imports
|
||||
import { cn } from "@plane/utils";
|
||||
// components
|
||||
// local imports
|
||||
import { ImageFullScreenAction } from "./full-screen";
|
||||
|
||||
type Props = {
|
||||
containerClassName?: string;
|
||||
image: {
|
||||
src: string;
|
||||
height: string;
|
||||
width: string;
|
||||
height: string;
|
||||
aspectRatio: number;
|
||||
src: string;
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
+13
-11
@@ -1,28 +1,30 @@
|
||||
import { ImageIcon } from "lucide-react";
|
||||
import { ChangeEvent, useCallback, useEffect, useMemo, useRef } from "react";
|
||||
// plane utils
|
||||
// plane imports
|
||||
import { cn } from "@plane/utils";
|
||||
// constants
|
||||
import { ACCEPTED_IMAGE_MIME_TYPES } from "@/constants/config";
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
// extensions
|
||||
import { CustomBaseImageNodeViewProps, getImageComponentImageFileMap } from "@/extensions/custom-image";
|
||||
// helpers
|
||||
import { EFileError } from "@/helpers/file";
|
||||
import { getExtensionStorage } from "@/helpers/get-extension-storage";
|
||||
// hooks
|
||||
import { useUploader, useDropZone, uploadFirstFileAndInsertRemaining } from "@/hooks/use-file-upload";
|
||||
// local imports
|
||||
import { getImageComponentImageFileMap } from "../utils";
|
||||
import type { CustomImageNodeViewProps } from "./node-view";
|
||||
|
||||
type CustomImageUploaderProps = CustomBaseImageNodeViewProps & {
|
||||
maxFileSize: number;
|
||||
loadImageFromFileSystem: (file: string) => void;
|
||||
type CustomImageUploaderProps = CustomImageNodeViewProps & {
|
||||
failedToLoadImage: boolean;
|
||||
loadImageFromFileSystem: (file: string) => void;
|
||||
maxFileSize: number;
|
||||
setIsUploaded: (isUploaded: boolean) => void;
|
||||
};
|
||||
|
||||
export const CustomImageUploader = (props: CustomImageUploaderProps) => {
|
||||
const {
|
||||
editor,
|
||||
extension,
|
||||
failedToLoadImage,
|
||||
getPos,
|
||||
loadImageFromFileSystem,
|
||||
@@ -71,12 +73,13 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
|
||||
}
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[imageComponentImageFileMap, imageEntityId, updateAttributes, getPos]
|
||||
);
|
||||
|
||||
const uploadImageEditorCommand = useCallback(
|
||||
async (file: File) => await editor?.commands.uploadImage(imageEntityId ?? "", file),
|
||||
[editor, imageEntityId]
|
||||
async (file: File) => await extension.options.uploadImage?.(imageEntityId ?? "", file),
|
||||
[extension.options, imageEntityId]
|
||||
);
|
||||
|
||||
const handleProgressStatus = useCallback(
|
||||
@@ -93,7 +96,6 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
|
||||
// hooks
|
||||
const { isUploading: isImageBeingUploaded, uploadFile } = useUploader({
|
||||
acceptedMimeTypes: ACCEPTED_IMAGE_MIME_TYPES,
|
||||
// @ts-expect-error - TODO: fix typings, and don't remove await from here for now
|
||||
editorCommand: uploadImageEditorCommand,
|
||||
handleProgressStatus,
|
||||
loadFileFromFileSystem: loadImageFromFileSystem,
|
||||
@@ -128,7 +130,7 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
|
||||
imageComponentImageFileMap?.set(imageEntityId ?? "", { ...meta, hasOpenedFileInputOnce: true });
|
||||
}
|
||||
}
|
||||
}, [meta, uploadFile, imageComponentImageFileMap]);
|
||||
}, [meta, uploadFile, imageComponentImageFileMap, imageEntityId]);
|
||||
|
||||
const onFileChange = useCallback(
|
||||
async (e: ChangeEvent<HTMLInputElement>) => {
|
||||
@@ -163,7 +165,7 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
|
||||
}
|
||||
|
||||
return "Add an image";
|
||||
}, [draggedInside, failedToLoadImage, isImageBeingUploaded]);
|
||||
}, [draggedInside, failedToLoadImage, isImageBeingUploaded, editor.isEditable]);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -1,180 +0,0 @@
|
||||
import { Editor, mergeAttributes } from "@tiptap/core";
|
||||
import { Image as BaseImageExtension } from "@tiptap/extension-image";
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
// constants
|
||||
import { ACCEPTED_IMAGE_MIME_TYPES } from "@/constants/config";
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
// extensions
|
||||
import { CustomImageNode } from "@/extensions/custom-image";
|
||||
// helpers
|
||||
import { isFileValid } from "@/helpers/file";
|
||||
import { getExtensionStorage } from "@/helpers/get-extension-storage";
|
||||
import { insertEmptyParagraphAtNodeBoundaries } from "@/helpers/insert-empty-paragraph-at-node-boundary";
|
||||
// types
|
||||
import { TFileHandler } from "@/types";
|
||||
|
||||
export type InsertImageComponentProps = {
|
||||
file?: File;
|
||||
pos?: number;
|
||||
event: "insert" | "drop";
|
||||
};
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
[CORE_EXTENSIONS.CUSTOM_IMAGE]: {
|
||||
insertImageComponent: ({ file, pos, event }: InsertImageComponentProps) => ReturnType;
|
||||
uploadImage: (blockId: string, file: File) => () => Promise<string> | undefined;
|
||||
getImageSource?: (path: string) => () => Promise<string>;
|
||||
restoreImage: (src: string) => () => Promise<void>;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const getImageComponentImageFileMap = (editor: Editor) =>
|
||||
getExtensionStorage(editor, CORE_EXTENSIONS.CUSTOM_IMAGE)?.fileMap;
|
||||
|
||||
export interface CustomImageExtensionStorage {
|
||||
fileMap: Map<string, UploadEntity>;
|
||||
deletedImageSet: Map<string, boolean>;
|
||||
maxFileSize: number;
|
||||
}
|
||||
|
||||
export type UploadEntity = ({ event: "insert" } | { event: "drop"; file: File }) & { hasOpenedFileInputOnce?: boolean };
|
||||
|
||||
export const CustomImageExtension = (props: TFileHandler) => {
|
||||
const {
|
||||
getAssetSrc,
|
||||
upload,
|
||||
restore: restoreImageFn,
|
||||
validation: { maxFileSize },
|
||||
} = props;
|
||||
|
||||
return BaseImageExtension.extend<Record<string, unknown>, CustomImageExtensionStorage>({
|
||||
name: CORE_EXTENSIONS.CUSTOM_IMAGE,
|
||||
selectable: true,
|
||||
group: "block",
|
||||
atom: true,
|
||||
draggable: true,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
width: {
|
||||
default: "35%",
|
||||
},
|
||||
src: {
|
||||
default: null,
|
||||
},
|
||||
height: {
|
||||
default: "auto",
|
||||
},
|
||||
["id"]: {
|
||||
default: null,
|
||||
},
|
||||
aspectRatio: {
|
||||
default: null,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: "image-component",
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ["image-component", mergeAttributes(HTMLAttributes)];
|
||||
},
|
||||
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
ArrowDown: insertEmptyParagraphAtNodeBoundaries("down", this.name),
|
||||
ArrowUp: insertEmptyParagraphAtNodeBoundaries("up", this.name),
|
||||
};
|
||||
},
|
||||
|
||||
addStorage() {
|
||||
return {
|
||||
fileMap: new Map(),
|
||||
deletedImageSet: new Map<string, boolean>(),
|
||||
maxFileSize,
|
||||
// escape markdown for images
|
||||
markdown: {
|
||||
serialize() {},
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
insertImageComponent:
|
||||
(props) =>
|
||||
({ commands }) => {
|
||||
// Early return if there's an invalid file being dropped
|
||||
if (
|
||||
props?.file &&
|
||||
!isFileValid({
|
||||
acceptedMimeTypes: ACCEPTED_IMAGE_MIME_TYPES,
|
||||
file: props.file,
|
||||
maxFileSize,
|
||||
onError: (_error, message) => alert(message),
|
||||
})
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// generate a unique id for the image to keep track of dropped
|
||||
// files' file data
|
||||
const fileId = uuidv4();
|
||||
|
||||
const imageComponentImageFileMap = getImageComponentImageFileMap(this.editor);
|
||||
|
||||
if (imageComponentImageFileMap) {
|
||||
if (props?.event === "drop" && props.file) {
|
||||
imageComponentImageFileMap.set(fileId, {
|
||||
file: props.file,
|
||||
event: props.event,
|
||||
});
|
||||
} else if (props.event === "insert") {
|
||||
imageComponentImageFileMap.set(fileId, {
|
||||
event: props.event,
|
||||
hasOpenedFileInputOnce: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const attributes = {
|
||||
id: fileId,
|
||||
};
|
||||
|
||||
if (props.pos) {
|
||||
return commands.insertContentAt(props.pos, {
|
||||
type: this.name,
|
||||
attrs: attributes,
|
||||
});
|
||||
}
|
||||
return commands.insertContent({
|
||||
type: this.name,
|
||||
attrs: attributes,
|
||||
});
|
||||
},
|
||||
uploadImage: (blockId, file) => async () => {
|
||||
const fileUrl = await upload(blockId, file);
|
||||
return fileUrl;
|
||||
},
|
||||
getImageSource: (path) => async () => await getAssetSrc(path),
|
||||
restoreImage: (src) => async () => {
|
||||
await restoreImageFn(src);
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(CustomImageNode);
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
import { mergeAttributes } from "@tiptap/core";
|
||||
import { Image as BaseImageExtension } from "@tiptap/extension-image";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
// local imports
|
||||
import { type CustomImageExtension, ECustomImageAttributeNames, type InsertImageComponentProps } from "./types";
|
||||
import { DEFAULT_CUSTOM_IMAGE_ATTRIBUTES } from "./utils";
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
[CORE_EXTENSIONS.CUSTOM_IMAGE]: {
|
||||
insertImageComponent: ({ file, pos, event }: InsertImageComponentProps) => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const CustomImageExtensionConfig: CustomImageExtension = BaseImageExtension.extend({
|
||||
name: CORE_EXTENSIONS.CUSTOM_IMAGE,
|
||||
group: "block",
|
||||
atom: true,
|
||||
|
||||
addAttributes() {
|
||||
const attributes = {
|
||||
...this.parent?.(),
|
||||
...Object.values(ECustomImageAttributeNames).reduce((acc, value) => {
|
||||
acc[value] = {
|
||||
default: DEFAULT_CUSTOM_IMAGE_ATTRIBUTES[value],
|
||||
};
|
||||
return acc;
|
||||
}, {}),
|
||||
};
|
||||
|
||||
return attributes;
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: "image-component",
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ["image-component", mergeAttributes(HTMLAttributes)];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
// constants
|
||||
import { ACCEPTED_IMAGE_MIME_TYPES } from "@/constants/config";
|
||||
// helpers
|
||||
import { isFileValid } from "@/helpers/file";
|
||||
import { insertEmptyParagraphAtNodeBoundaries } from "@/helpers/insert-empty-paragraph-at-node-boundary";
|
||||
// types
|
||||
import type { TFileHandler, TReadOnlyFileHandler } from "@/types";
|
||||
// local imports
|
||||
import { CustomImageNodeView } from "./components/node-view";
|
||||
import { CustomImageExtensionConfig } from "./extension-config";
|
||||
import { getImageComponentImageFileMap } from "./utils";
|
||||
|
||||
type Props = {
|
||||
fileHandler: TFileHandler | TReadOnlyFileHandler;
|
||||
isEditable: boolean;
|
||||
};
|
||||
|
||||
export const CustomImageExtension = (props: Props) => {
|
||||
const { fileHandler, isEditable } = props;
|
||||
// derived values
|
||||
const { getAssetSrc, restore: restoreImageFn } = fileHandler;
|
||||
|
||||
return CustomImageExtensionConfig.extend({
|
||||
selectable: isEditable,
|
||||
draggable: isEditable,
|
||||
|
||||
addOptions() {
|
||||
const upload = "upload" in fileHandler ? fileHandler.upload : undefined;
|
||||
|
||||
return {
|
||||
...this.parent?.(),
|
||||
getImageSource: getAssetSrc,
|
||||
restoreImage: restoreImageFn,
|
||||
uploadImage: upload,
|
||||
};
|
||||
},
|
||||
|
||||
addStorage() {
|
||||
const maxFileSize = "validation" in fileHandler ? fileHandler.validation?.maxFileSize : 0;
|
||||
|
||||
return {
|
||||
fileMap: new Map(),
|
||||
deletedImageSet: new Map<string, boolean>(),
|
||||
maxFileSize,
|
||||
// escape markdown for images
|
||||
markdown: {
|
||||
serialize() {},
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
insertImageComponent:
|
||||
(props) =>
|
||||
({ commands }) => {
|
||||
// Early return if there's an invalid file being dropped
|
||||
if (
|
||||
props?.file &&
|
||||
!isFileValid({
|
||||
acceptedMimeTypes: ACCEPTED_IMAGE_MIME_TYPES,
|
||||
file: props.file,
|
||||
maxFileSize: this.storage.maxFileSize,
|
||||
onError: (_error, message) => alert(message),
|
||||
})
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// generate a unique id for the image to keep track of dropped
|
||||
// files' file data
|
||||
const fileId = uuidv4();
|
||||
|
||||
const imageComponentImageFileMap = getImageComponentImageFileMap(this.editor);
|
||||
|
||||
if (imageComponentImageFileMap) {
|
||||
if (props?.event === "drop" && props.file) {
|
||||
imageComponentImageFileMap.set(fileId, {
|
||||
file: props.file,
|
||||
event: props.event,
|
||||
});
|
||||
} else if (props.event === "insert") {
|
||||
imageComponentImageFileMap.set(fileId, {
|
||||
event: props.event,
|
||||
hasOpenedFileInputOnce: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const attributes = {
|
||||
id: fileId,
|
||||
};
|
||||
|
||||
if (props.pos) {
|
||||
return commands.insertContentAt(props.pos, {
|
||||
type: this.name,
|
||||
attrs: attributes,
|
||||
});
|
||||
}
|
||||
return commands.insertContent({
|
||||
type: this.name,
|
||||
attrs: attributes,
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
ArrowDown: insertEmptyParagraphAtNodeBoundaries("down", this.name),
|
||||
ArrowUp: insertEmptyParagraphAtNodeBoundaries("up", this.name),
|
||||
};
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(CustomImageNodeView);
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -1,3 +0,0 @@
|
||||
export * from "./components";
|
||||
export * from "./custom-image";
|
||||
export * from "./read-only-custom-image";
|
||||
@@ -1,79 +0,0 @@
|
||||
import { mergeAttributes } from "@tiptap/core";
|
||||
import { Image as BaseImageExtension } from "@tiptap/extension-image";
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
// components
|
||||
import { CustomImageNode, CustomImageExtensionStorage } from "@/extensions/custom-image";
|
||||
// types
|
||||
import { TReadOnlyFileHandler } from "@/types";
|
||||
|
||||
export const CustomReadOnlyImageExtension = (props: TReadOnlyFileHandler) => {
|
||||
const { getAssetSrc, restore: restoreImageFn } = props;
|
||||
|
||||
return BaseImageExtension.extend<Record<string, unknown>, CustomImageExtensionStorage>({
|
||||
name: CORE_EXTENSIONS.CUSTOM_IMAGE,
|
||||
selectable: false,
|
||||
group: "block",
|
||||
atom: true,
|
||||
draggable: false,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
width: {
|
||||
default: "35%",
|
||||
},
|
||||
src: {
|
||||
default: null,
|
||||
},
|
||||
height: {
|
||||
default: "auto",
|
||||
},
|
||||
["id"]: {
|
||||
default: null,
|
||||
},
|
||||
aspectRatio: {
|
||||
default: null,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: "image-component",
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ["image-component", mergeAttributes(HTMLAttributes)];
|
||||
},
|
||||
|
||||
addStorage() {
|
||||
return {
|
||||
fileMap: new Map(),
|
||||
deletedImageSet: new Map<string, boolean>(),
|
||||
maxFileSize: 0,
|
||||
// escape markdown for images
|
||||
markdown: {
|
||||
serialize() {},
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
getImageSource: (path: string) => async () => await getAssetSrc(path),
|
||||
restoreImage: (src) => async () => {
|
||||
await restoreImageFn(src);
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(CustomImageNode);
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { Node } from "@tiptap/core";
|
||||
// types
|
||||
import type { TFileHandler } from "@/types";
|
||||
|
||||
export enum ECustomImageAttributeNames {
|
||||
ID = "id",
|
||||
WIDTH = "width",
|
||||
HEIGHT = "height",
|
||||
ASPECT_RATIO = "aspectRatio",
|
||||
SOURCE = "src",
|
||||
}
|
||||
|
||||
export type Pixel = `${number}px`;
|
||||
|
||||
export type PixelAttribute<TDefault> = Pixel | TDefault;
|
||||
|
||||
export type TCustomImageSize = {
|
||||
width: PixelAttribute<"35%">;
|
||||
height: PixelAttribute<"auto">;
|
||||
aspectRatio: number | null;
|
||||
};
|
||||
|
||||
export type TCustomImageAttributes = {
|
||||
[ECustomImageAttributeNames.ID]: string | null;
|
||||
[ECustomImageAttributeNames.WIDTH]: PixelAttribute<"35%" | number> | null;
|
||||
[ECustomImageAttributeNames.HEIGHT]: PixelAttribute<"auto" | number> | null;
|
||||
[ECustomImageAttributeNames.ASPECT_RATIO]: number | null;
|
||||
[ECustomImageAttributeNames.SOURCE]: string | null;
|
||||
};
|
||||
|
||||
export type UploadEntity = ({ event: "insert" } | { event: "drop"; file: File }) & { hasOpenedFileInputOnce?: boolean };
|
||||
|
||||
export type InsertImageComponentProps = {
|
||||
file?: File;
|
||||
pos?: number;
|
||||
event: "insert" | "drop";
|
||||
};
|
||||
|
||||
export type CustomImageExtensionOptions = {
|
||||
getImageSource: TFileHandler["getAssetSrc"];
|
||||
restoreImage: TFileHandler["restore"];
|
||||
uploadImage?: TFileHandler["upload"];
|
||||
};
|
||||
|
||||
export type CustomImageExtensionStorage = {
|
||||
fileMap: Map<string, UploadEntity>;
|
||||
deletedImageSet: Map<string, boolean>;
|
||||
maxFileSize: number;
|
||||
};
|
||||
|
||||
export type CustomImageExtension = Node<CustomImageExtensionOptions, CustomImageExtensionStorage>;
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { Editor } from "@tiptap/core";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
// helpers
|
||||
import { getExtensionStorage } from "@/helpers/get-extension-storage";
|
||||
// local imports
|
||||
import { ECustomImageAttributeNames, type Pixel, type TCustomImageAttributes } from "./types";
|
||||
|
||||
export const DEFAULT_CUSTOM_IMAGE_ATTRIBUTES: TCustomImageAttributes = {
|
||||
[ECustomImageAttributeNames.SOURCE]: null,
|
||||
[ECustomImageAttributeNames.ID]: null,
|
||||
[ECustomImageAttributeNames.WIDTH]: "35%",
|
||||
[ECustomImageAttributeNames.HEIGHT]: "auto",
|
||||
[ECustomImageAttributeNames.ASPECT_RATIO]: null,
|
||||
};
|
||||
|
||||
export const getImageComponentImageFileMap = (editor: Editor) =>
|
||||
getExtensionStorage(editor, CORE_EXTENSIONS.CUSTOM_IMAGE)?.fileMap;
|
||||
|
||||
export const ensurePixelString = <TDefault>(
|
||||
value: Pixel | TDefault | number | undefined | null,
|
||||
defaultValue?: TDefault
|
||||
) => {
|
||||
if (!value || value === defaultValue) {
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
if (typeof value === "number") {
|
||||
return `${value}px` satisfies Pixel;
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
CustomCodeInlineExtension,
|
||||
CustomColorExtension,
|
||||
CustomHorizontalRule,
|
||||
CustomImageExtension,
|
||||
CustomKeymap,
|
||||
CustomLinkExtension,
|
||||
CustomMentionExtension,
|
||||
@@ -38,6 +37,8 @@ import { getExtensionStorage } from "@/helpers/get-extension-storage";
|
||||
import { CoreEditorAdditionalExtensions } from "@/plane-editor/extensions";
|
||||
// types
|
||||
import type { IEditorProps } from "@/types";
|
||||
// local imports
|
||||
import { CustomImageExtension } from "./custom-image/extension";
|
||||
|
||||
type TArguments = Pick<
|
||||
IEditorProps,
|
||||
@@ -191,12 +192,13 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => {
|
||||
|
||||
if (!disabledExtensions.includes("image")) {
|
||||
extensions.push(
|
||||
ImageExtension(fileHandler).configure({
|
||||
HTMLAttributes: {
|
||||
class: "rounded-md",
|
||||
},
|
||||
ImageExtension({
|
||||
fileHandler,
|
||||
}),
|
||||
CustomImageExtension(fileHandler)
|
||||
CustomImageExtension({
|
||||
fileHandler,
|
||||
isEditable: editable,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+7
-1
@@ -1,6 +1,12 @@
|
||||
import { Image as BaseImageExtension } from "@tiptap/extension-image";
|
||||
// local imports
|
||||
import { CustomImageExtensionOptions } from "../custom-image/types";
|
||||
import { ImageExtensionStorage } from "./extension";
|
||||
|
||||
export const ImageExtensionWithoutProps = BaseImageExtension.extend({
|
||||
export const ImageExtensionConfig = BaseImageExtension.extend<
|
||||
Pick<CustomImageExtensionOptions, "getImageSource">,
|
||||
ImageExtensionStorage
|
||||
>({
|
||||
addAttributes() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
@@ -1,23 +1,33 @@
|
||||
import { Image as BaseImageExtension } from "@tiptap/extension-image";
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
// extensions
|
||||
import { CustomImageNode } from "@/extensions";
|
||||
// helpers
|
||||
import { insertEmptyParagraphAtNodeBoundaries } from "@/helpers/insert-empty-paragraph-at-node-boundary";
|
||||
// types
|
||||
import { TFileHandler } from "@/types";
|
||||
import type { TFileHandler, TReadOnlyFileHandler } from "@/types";
|
||||
// local imports
|
||||
import { CustomImageNodeView } from "../custom-image/components/node-view";
|
||||
import { ImageExtensionConfig } from "./extension-config";
|
||||
|
||||
export type ImageExtensionStorage = {
|
||||
deletedImageSet: Map<string, boolean>;
|
||||
};
|
||||
|
||||
export const ImageExtension = (fileHandler: TFileHandler) => {
|
||||
const {
|
||||
getAssetSrc,
|
||||
validation: { maxFileSize },
|
||||
} = fileHandler;
|
||||
type Props = {
|
||||
fileHandler: TFileHandler | TReadOnlyFileHandler;
|
||||
};
|
||||
|
||||
export const ImageExtension = (props: Props) => {
|
||||
const { fileHandler } = props;
|
||||
// derived values
|
||||
const { getAssetSrc } = fileHandler;
|
||||
|
||||
return ImageExtensionConfig.extend({
|
||||
addOptions() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
getImageSource: getAssetSrc,
|
||||
};
|
||||
},
|
||||
|
||||
return BaseImageExtension.extend<unknown, ImageExtensionStorage>({
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
ArrowDown: insertEmptyParagraphAtNodeBoundaries("down", this.name),
|
||||
@@ -27,36 +37,17 @@ export const ImageExtension = (fileHandler: TFileHandler) => {
|
||||
|
||||
// storage to keep track of image states Map<src, isDeleted>
|
||||
addStorage() {
|
||||
const maxFileSize = "validation" in fileHandler ? fileHandler.validation?.maxFileSize : 0;
|
||||
|
||||
return {
|
||||
deletedImageSet: new Map<string, boolean>(),
|
||||
maxFileSize,
|
||||
};
|
||||
},
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
width: {
|
||||
default: "35%",
|
||||
},
|
||||
height: {
|
||||
default: null,
|
||||
},
|
||||
aspectRatio: {
|
||||
default: null,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
getImageSource: (path: string) => async () => await getAssetSrc(path),
|
||||
};
|
||||
},
|
||||
|
||||
// render custom image node
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(CustomImageNode);
|
||||
return ReactNodeViewRenderer(CustomImageNodeView);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
import { mergeAttributes } from "@tiptap/core";
|
||||
import { Image as BaseImageExtension } from "@tiptap/extension-image";
|
||||
// local imports
|
||||
import { ImageExtensionStorage } from "./extension";
|
||||
|
||||
export const CustomImageComponentWithoutProps = BaseImageExtension.extend<
|
||||
Record<string, unknown>,
|
||||
ImageExtensionStorage
|
||||
>({
|
||||
name: "imageComponent",
|
||||
selectable: true,
|
||||
group: "block",
|
||||
atom: true,
|
||||
draggable: true,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
width: {
|
||||
default: "35%",
|
||||
},
|
||||
src: {
|
||||
default: null,
|
||||
},
|
||||
height: {
|
||||
default: "auto",
|
||||
},
|
||||
["id"]: {
|
||||
default: null,
|
||||
},
|
||||
aspectRatio: {
|
||||
default: null,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: "image-component",
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ["image-component", mergeAttributes(HTMLAttributes)];
|
||||
},
|
||||
|
||||
addStorage() {
|
||||
return {
|
||||
fileMap: new Map(),
|
||||
deletedImageSet: new Map<string, boolean>(),
|
||||
maxFileSize: 0,
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -1,3 +1,2 @@
|
||||
export * from "./extension";
|
||||
export * from "./image-extension-without-props";
|
||||
export * from "./read-only-image";
|
||||
export * from "./extension-config";
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import { Image as BaseImageExtension } from "@tiptap/extension-image";
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
// extensions
|
||||
import { CustomImageNode } from "@/extensions";
|
||||
// types
|
||||
import { TReadOnlyFileHandler } from "@/types";
|
||||
|
||||
export const ReadOnlyImageExtension = (props: TReadOnlyFileHandler) => {
|
||||
const { getAssetSrc } = props;
|
||||
|
||||
return BaseImageExtension.extend({
|
||||
addAttributes() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
width: {
|
||||
default: "35%",
|
||||
},
|
||||
height: {
|
||||
default: null,
|
||||
},
|
||||
aspectRatio: {
|
||||
default: null,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
getImageSource: (path: string) => async () => await getAssetSrc(path),
|
||||
};
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(CustomImageNode);
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -1,7 +1,6 @@
|
||||
export * from "./callout";
|
||||
export * from "./code";
|
||||
export * from "./code-inline";
|
||||
export * from "./custom-image";
|
||||
export * from "./custom-link";
|
||||
export * from "./custom-list-keymap";
|
||||
export * from "./image";
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
CustomHorizontalRule,
|
||||
CustomLinkExtension,
|
||||
CustomTypographyExtension,
|
||||
ReadOnlyImageExtension,
|
||||
CustomCodeBlockExtension,
|
||||
CustomCodeInlineExtension,
|
||||
TableHeader,
|
||||
@@ -20,11 +19,11 @@ import {
|
||||
TableRow,
|
||||
Table,
|
||||
CustomMentionExtension,
|
||||
CustomReadOnlyImageExtension,
|
||||
CustomTextAlignExtension,
|
||||
CustomCalloutReadOnlyExtension,
|
||||
CustomColorExtension,
|
||||
UtilityExtension,
|
||||
ImageExtension,
|
||||
} from "@/extensions";
|
||||
// helpers
|
||||
import { isValidHttpUrl } from "@/helpers/common";
|
||||
@@ -32,6 +31,8 @@ import { isValidHttpUrl } from "@/helpers/common";
|
||||
import { CoreReadOnlyEditorAdditionalExtensions } from "@/plane-editor/extensions";
|
||||
// types
|
||||
import type { IReadOnlyEditorProps } from "@/types";
|
||||
// local imports
|
||||
import { CustomImageExtension } from "./custom-image/extension";
|
||||
|
||||
type Props = Pick<IReadOnlyEditorProps, "disabledExtensions" | "flaggedExtensions" | "fileHandler" | "mentionHandler">;
|
||||
|
||||
@@ -135,12 +136,13 @@ export const CoreReadOnlyEditorExtensions = (props: Props): Extensions => {
|
||||
|
||||
if (!disabledExtensions.includes("image")) {
|
||||
extensions.push(
|
||||
ReadOnlyImageExtension(fileHandler).configure({
|
||||
HTMLAttributes: {
|
||||
class: "rounded-md",
|
||||
},
|
||||
ImageExtension({
|
||||
fileHandler,
|
||||
}),
|
||||
CustomReadOnlyImageExtension(fileHandler)
|
||||
CustomImageExtension({
|
||||
fileHandler,
|
||||
isEditable: false,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -131,7 +131,7 @@ const SideMenu = (options: SideMenuPluginProps) => {
|
||||
}
|
||||
}
|
||||
|
||||
if (node.matches(".table-wrapper")) {
|
||||
if (node.matches("table")) {
|
||||
rect.top += 8;
|
||||
rect.left -= 8;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { type Editor } from "@tiptap/core";
|
||||
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
||||
// local imports
|
||||
import { createColumnInsertButton, createRowInsertButton, findAllTables, TableInfo } from "./utils";
|
||||
|
||||
const TABLE_INSERT_PLUGIN_KEY = new PluginKey("table-insert");
|
||||
|
||||
export const TableInsertPlugin = (editor: Editor): Plugin => {
|
||||
const tableMap = new Map<HTMLElement, TableInfo>();
|
||||
|
||||
const setupTable = (tableInfo: TableInfo) => {
|
||||
const { tableElement } = tableInfo;
|
||||
|
||||
// Create and add column button if it doesn't exist
|
||||
if (!tableInfo.columnButtonElement) {
|
||||
const columnButton = createColumnInsertButton(editor, tableInfo);
|
||||
tableElement.appendChild(columnButton);
|
||||
tableInfo.columnButtonElement = columnButton;
|
||||
}
|
||||
|
||||
// Create and add row button if it doesn't exist
|
||||
if (!tableInfo.rowButtonElement) {
|
||||
const rowButton = createRowInsertButton(editor, tableInfo);
|
||||
tableElement.appendChild(rowButton);
|
||||
tableInfo.rowButtonElement = rowButton;
|
||||
}
|
||||
|
||||
tableMap.set(tableElement, tableInfo);
|
||||
};
|
||||
|
||||
const cleanupTable = (tableElement: HTMLElement) => {
|
||||
const tableInfo = tableMap.get(tableElement);
|
||||
tableInfo?.columnButtonElement?.remove();
|
||||
tableInfo?.rowButtonElement?.remove();
|
||||
tableMap.delete(tableElement);
|
||||
};
|
||||
|
||||
const updateAllTables = () => {
|
||||
if (!editor.isEditable) {
|
||||
// Clean up all tables if editor is not editable
|
||||
tableMap.forEach((_, tableElement) => {
|
||||
cleanupTable(tableElement);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const currentTables = findAllTables(editor);
|
||||
const currentTableElements = new Set(currentTables.map((t) => t.tableElement));
|
||||
|
||||
// Remove buttons from tables that no longer exist
|
||||
tableMap.forEach((_, tableElement) => {
|
||||
if (!currentTableElements.has(tableElement)) {
|
||||
cleanupTable(tableElement);
|
||||
}
|
||||
});
|
||||
|
||||
// Add buttons to new tables
|
||||
currentTables.forEach((tableInfo) => {
|
||||
if (!tableMap.has(tableInfo.tableElement)) {
|
||||
setupTable(tableInfo);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return new Plugin({
|
||||
key: TABLE_INSERT_PLUGIN_KEY,
|
||||
view() {
|
||||
setTimeout(updateAllTables, 0);
|
||||
|
||||
return {
|
||||
update(view, prevState) {
|
||||
// Update when document changes
|
||||
if (!prevState.doc.eq(view.state.doc)) {
|
||||
updateAllTables();
|
||||
}
|
||||
},
|
||||
destroy() {
|
||||
// Clean up all tables
|
||||
tableMap.forEach((_, tableElement) => {
|
||||
cleanupTable(tableElement);
|
||||
});
|
||||
tableMap.clear();
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,430 @@
|
||||
import type { Editor } from "@tiptap/core";
|
||||
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
|
||||
import { addColumn, removeColumn, addRow, removeRow, TableMap } from "@tiptap/pm/tables";
|
||||
|
||||
const addSvg = `<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M8.5 7.49988V3.49988C8.5 3.22374 8.27614 2.99988 8 2.99988C7.72386 2.99988 7.5 3.22374 7.5 3.49988L7.5 7.49988L3.5 7.49988C3.22386 7.49988 3 7.72374 3 7.99988C3 8.27602 3.22386 8.49988 3.5 8.49988H7.5L7.5 12.4999C7.5 12.776 7.72386 12.9999 8 12.9999C8.27614 12.9999 8.5 12.776 8.5 12.4999L8.5 8.49988L12.5 8.49988C12.7761 8.49988 13 8.27602 13 7.99988C13 7.72374 12.7761 7.49988 12.5 7.49988L8.5 7.49988Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>`;
|
||||
|
||||
export type TableInfo = {
|
||||
tableElement: HTMLElement;
|
||||
tableNode: ProseMirrorNode;
|
||||
tablePos: number;
|
||||
columnButtonElement?: HTMLElement;
|
||||
rowButtonElement?: HTMLElement;
|
||||
};
|
||||
|
||||
export const createColumnInsertButton = (editor: Editor, tableInfo: TableInfo): HTMLElement => {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "table-column-insert-button";
|
||||
button.title = "Insert columns";
|
||||
button.ariaLabel = "Insert columns";
|
||||
|
||||
const icon = document.createElement("span");
|
||||
icon.innerHTML = addSvg;
|
||||
button.appendChild(icon);
|
||||
|
||||
let mouseDownX = 0;
|
||||
let isDragging = false;
|
||||
let dragStarted = false;
|
||||
let lastActionX = 0;
|
||||
const DRAG_THRESHOLD = 5; // pixels to start drag
|
||||
const ACTION_THRESHOLD = 150; // pixels total distance to trigger action
|
||||
|
||||
const onMouseDown = (e: MouseEvent) => {
|
||||
if (e.button !== 0) return; // Only left mouse button
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
mouseDownX = e.clientX;
|
||||
lastActionX = e.clientX;
|
||||
isDragging = false;
|
||||
dragStarted = false;
|
||||
|
||||
document.addEventListener("mousemove", onMouseMove);
|
||||
document.addEventListener("mouseup", onMouseUp);
|
||||
};
|
||||
|
||||
const onMouseMove = (e: MouseEvent) => {
|
||||
const deltaX = e.clientX - mouseDownX;
|
||||
const distance = Math.abs(deltaX);
|
||||
|
||||
// Start dragging if moved more than threshold
|
||||
if (!isDragging && distance > DRAG_THRESHOLD) {
|
||||
isDragging = true;
|
||||
dragStarted = true;
|
||||
|
||||
// Visual feedback
|
||||
button.classList.add("dragging");
|
||||
document.body.style.userSelect = "none";
|
||||
}
|
||||
|
||||
if (isDragging) {
|
||||
const totalDistance = Math.abs(e.clientX - lastActionX);
|
||||
|
||||
// Only trigger action when total distance reaches threshold
|
||||
if (totalDistance >= ACTION_THRESHOLD) {
|
||||
// Determine direction based on current movement relative to last action point
|
||||
const directionFromLastAction = e.clientX - lastActionX;
|
||||
|
||||
// Right direction - add columns
|
||||
if (directionFromLastAction > 0) {
|
||||
insertColumnAfterLast(editor, tableInfo);
|
||||
lastActionX = e.clientX; // Reset action point
|
||||
}
|
||||
// Left direction - delete empty columns
|
||||
else if (directionFromLastAction < 0) {
|
||||
const deleted = removeLastColumn(editor, tableInfo);
|
||||
if (deleted) {
|
||||
lastActionX = e.clientX; // Reset action point
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onMouseUp = () => {
|
||||
document.removeEventListener("mousemove", onMouseMove);
|
||||
document.removeEventListener("mouseup", onMouseUp);
|
||||
|
||||
if (isDragging) {
|
||||
// Clean up drag state
|
||||
button.classList.remove("dragging");
|
||||
document.body.style.cursor = "";
|
||||
document.body.style.userSelect = "";
|
||||
} else if (!dragStarted) {
|
||||
// Handle as click if no dragging occurred
|
||||
insertColumnAfterLast(editor, tableInfo);
|
||||
}
|
||||
|
||||
isDragging = false;
|
||||
dragStarted = false;
|
||||
};
|
||||
|
||||
button.addEventListener("mousedown", onMouseDown);
|
||||
|
||||
// Prevent context menu and text selection
|
||||
button.addEventListener("contextmenu", (e) => e.preventDefault());
|
||||
button.addEventListener("selectstart", (e) => e.preventDefault());
|
||||
|
||||
return button;
|
||||
};
|
||||
|
||||
export const createRowInsertButton = (editor: Editor, tableInfo: TableInfo): HTMLElement => {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "table-row-insert-button";
|
||||
button.title = "Insert rows";
|
||||
button.ariaLabel = "Insert rows";
|
||||
|
||||
const icon = document.createElement("span");
|
||||
icon.innerHTML = addSvg;
|
||||
button.appendChild(icon);
|
||||
|
||||
let mouseDownY = 0;
|
||||
let isDragging = false;
|
||||
let dragStarted = false;
|
||||
let lastActionY = 0;
|
||||
const DRAG_THRESHOLD = 5; // pixels to start drag
|
||||
const ACTION_THRESHOLD = 40; // pixels total distance to trigger action
|
||||
|
||||
const onMouseDown = (e: MouseEvent) => {
|
||||
if (e.button !== 0) return; // Only left mouse button
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
mouseDownY = e.clientY;
|
||||
lastActionY = e.clientY;
|
||||
isDragging = false;
|
||||
dragStarted = false;
|
||||
|
||||
document.addEventListener("mousemove", onMouseMove);
|
||||
document.addEventListener("mouseup", onMouseUp);
|
||||
};
|
||||
|
||||
const onMouseMove = (e: MouseEvent) => {
|
||||
const deltaY = e.clientY - mouseDownY;
|
||||
const distance = Math.abs(deltaY);
|
||||
|
||||
// Start dragging if moved more than threshold
|
||||
if (!isDragging && distance > DRAG_THRESHOLD) {
|
||||
isDragging = true;
|
||||
dragStarted = true;
|
||||
|
||||
// Visual feedback
|
||||
button.classList.add("dragging");
|
||||
document.body.style.userSelect = "none";
|
||||
}
|
||||
|
||||
if (isDragging) {
|
||||
const totalDistance = Math.abs(e.clientY - lastActionY);
|
||||
|
||||
// Only trigger action when total distance reaches threshold
|
||||
if (totalDistance >= ACTION_THRESHOLD) {
|
||||
// Determine direction based on current movement relative to last action point
|
||||
const directionFromLastAction = e.clientY - lastActionY;
|
||||
|
||||
// Down direction - add rows
|
||||
if (directionFromLastAction > 0) {
|
||||
insertRowAfterLast(editor, tableInfo);
|
||||
lastActionY = e.clientY; // Reset action point
|
||||
}
|
||||
// Up direction - delete empty rows
|
||||
else if (directionFromLastAction < 0) {
|
||||
const deleted = removeLastRow(editor, tableInfo);
|
||||
if (deleted) {
|
||||
lastActionY = e.clientY; // Reset action point
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onMouseUp = () => {
|
||||
document.removeEventListener("mousemove", onMouseMove);
|
||||
document.removeEventListener("mouseup", onMouseUp);
|
||||
|
||||
if (isDragging) {
|
||||
// Clean up drag state
|
||||
button.classList.remove("dragging");
|
||||
document.body.style.cursor = "";
|
||||
document.body.style.userSelect = "";
|
||||
} else if (!dragStarted) {
|
||||
// Handle as click if no dragging occurred
|
||||
insertRowAfterLast(editor, tableInfo);
|
||||
}
|
||||
|
||||
isDragging = false;
|
||||
dragStarted = false;
|
||||
};
|
||||
|
||||
button.addEventListener("mousedown", onMouseDown);
|
||||
|
||||
// Prevent context menu and text selection
|
||||
button.addEventListener("contextmenu", (e) => e.preventDefault());
|
||||
button.addEventListener("selectstart", (e) => e.preventDefault());
|
||||
|
||||
return button;
|
||||
};
|
||||
|
||||
export const findAllTables = (editor: Editor): TableInfo[] => {
|
||||
const tables: TableInfo[] = [];
|
||||
const tableElements = editor.view.dom.querySelectorAll("table");
|
||||
|
||||
tableElements.forEach((tableElement) => {
|
||||
// Find the table's ProseMirror position
|
||||
let tablePos = -1;
|
||||
let tableNode: ProseMirrorNode | null = null;
|
||||
|
||||
// Walk through the document to find matching table nodes
|
||||
editor.state.doc.descendants((node, pos) => {
|
||||
if (node.type.spec.tableRole === "table") {
|
||||
const domAtPos = editor.view.domAtPos(pos + 1);
|
||||
let domTable = domAtPos.node;
|
||||
|
||||
// Navigate to find the table element
|
||||
while (domTable && domTable.parentNode && domTable.nodeType !== Node.ELEMENT_NODE) {
|
||||
domTable = domTable.parentNode;
|
||||
}
|
||||
|
||||
while (domTable && domTable.parentNode && (domTable as HTMLElement).tagName !== "TABLE") {
|
||||
domTable = domTable.parentNode;
|
||||
}
|
||||
|
||||
if (domTable === tableElement) {
|
||||
tablePos = pos;
|
||||
tableNode = node;
|
||||
return false; // Stop iteration
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (tablePos !== -1 && tableNode) {
|
||||
tables.push({
|
||||
tableElement,
|
||||
tableNode,
|
||||
tablePos,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return tables;
|
||||
};
|
||||
|
||||
const getCurrentTableInfo = (editor: Editor, tableInfo: TableInfo): TableInfo => {
|
||||
// Refresh table info to get latest state
|
||||
const tables = findAllTables(editor);
|
||||
const updated = tables.find((t) => t.tableElement === tableInfo.tableElement);
|
||||
return updated || tableInfo;
|
||||
};
|
||||
|
||||
// Column functions
|
||||
const insertColumnAfterLast = (editor: Editor, tableInfo: TableInfo) => {
|
||||
const currentTableInfo = getCurrentTableInfo(editor, tableInfo);
|
||||
const { tableNode, tablePos } = currentTableInfo;
|
||||
const tableMapData = TableMap.get(tableNode);
|
||||
const lastColumnIndex = tableMapData.width;
|
||||
|
||||
const tr = editor.state.tr;
|
||||
const rect = {
|
||||
map: tableMapData,
|
||||
tableStart: tablePos,
|
||||
table: tableNode,
|
||||
top: 0,
|
||||
left: 0,
|
||||
bottom: tableMapData.height - 1,
|
||||
right: tableMapData.width - 1,
|
||||
};
|
||||
|
||||
const newTr = addColumn(tr, rect, lastColumnIndex);
|
||||
editor.view.dispatch(newTr);
|
||||
};
|
||||
|
||||
const removeLastColumn = (editor: Editor, tableInfo: TableInfo): boolean => {
|
||||
const currentTableInfo = getCurrentTableInfo(editor, tableInfo);
|
||||
const { tableNode, tablePos } = currentTableInfo;
|
||||
const tableMapData = TableMap.get(tableNode);
|
||||
|
||||
// Don't delete if only one column left
|
||||
if (tableMapData.width <= 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const lastColumnIndex = tableMapData.width - 1;
|
||||
|
||||
// Check if last column is empty
|
||||
if (!isColumnEmpty(currentTableInfo, lastColumnIndex)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const tr = editor.state.tr;
|
||||
const rect = {
|
||||
map: tableMapData,
|
||||
tableStart: tablePos,
|
||||
table: tableNode,
|
||||
top: 0,
|
||||
left: 0,
|
||||
bottom: tableMapData.height - 1,
|
||||
right: tableMapData.width - 1,
|
||||
};
|
||||
|
||||
removeColumn(tr, rect, lastColumnIndex);
|
||||
editor.view.dispatch(tr);
|
||||
return true;
|
||||
};
|
||||
|
||||
// Helper function to check if a single cell is empty
|
||||
const isCellEmpty = (cell: ProseMirrorNode | null | undefined): boolean => {
|
||||
if (!cell || cell.content.size === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if cell has any non-empty content
|
||||
let hasContent = false;
|
||||
cell.content.forEach((node) => {
|
||||
if (node.type.name === "paragraph") {
|
||||
if (node.content.size > 0) {
|
||||
hasContent = true;
|
||||
}
|
||||
} else if (node.content.size > 0 || node.isText) {
|
||||
hasContent = true;
|
||||
}
|
||||
});
|
||||
|
||||
return !hasContent;
|
||||
};
|
||||
|
||||
const isColumnEmpty = (tableInfo: TableInfo, columnIndex: number): boolean => {
|
||||
const { tableNode } = tableInfo;
|
||||
const tableMapData = TableMap.get(tableNode);
|
||||
|
||||
// Check each cell in the column
|
||||
for (let row = 0; row < tableMapData.height; row++) {
|
||||
const cellIndex = row * tableMapData.width + columnIndex;
|
||||
const cellPos = tableMapData.map[cellIndex];
|
||||
const cell = tableNode.nodeAt(cellPos);
|
||||
|
||||
if (!isCellEmpty(cell)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
// Row functions
|
||||
const insertRowAfterLast = (editor: Editor, tableInfo: TableInfo) => {
|
||||
const currentTableInfo = getCurrentTableInfo(editor, tableInfo);
|
||||
const { tableNode, tablePos } = currentTableInfo;
|
||||
const tableMapData = TableMap.get(tableNode);
|
||||
const lastRowIndex = tableMapData.height;
|
||||
|
||||
const tr = editor.state.tr;
|
||||
const rect = {
|
||||
map: tableMapData,
|
||||
tableStart: tablePos,
|
||||
table: tableNode,
|
||||
top: 0,
|
||||
left: 0,
|
||||
bottom: tableMapData.height - 1,
|
||||
right: tableMapData.width - 1,
|
||||
};
|
||||
|
||||
const newTr = addRow(tr, rect, lastRowIndex);
|
||||
editor.view.dispatch(newTr);
|
||||
};
|
||||
|
||||
const removeLastRow = (editor: Editor, tableInfo: TableInfo): boolean => {
|
||||
const currentTableInfo = getCurrentTableInfo(editor, tableInfo);
|
||||
const { tableNode, tablePos } = currentTableInfo;
|
||||
const tableMapData = TableMap.get(tableNode);
|
||||
|
||||
// Don't delete if only one row left
|
||||
if (tableMapData.height <= 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const lastRowIndex = tableMapData.height - 1;
|
||||
|
||||
// Check if last row is empty
|
||||
if (!isRowEmpty(currentTableInfo, lastRowIndex)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const tr = editor.state.tr;
|
||||
const rect = {
|
||||
map: tableMapData,
|
||||
tableStart: tablePos,
|
||||
table: tableNode,
|
||||
top: 0,
|
||||
left: 0,
|
||||
bottom: tableMapData.height - 1,
|
||||
right: tableMapData.width - 1,
|
||||
};
|
||||
|
||||
removeRow(tr, rect, lastRowIndex);
|
||||
editor.view.dispatch(tr);
|
||||
return true;
|
||||
};
|
||||
|
||||
const isRowEmpty = (tableInfo: TableInfo, rowIndex: number): boolean => {
|
||||
const { tableNode } = tableInfo;
|
||||
const tableMapData = TableMap.get(tableNode);
|
||||
|
||||
// Check each cell in the row
|
||||
for (let col = 0; col < tableMapData.width; col++) {
|
||||
const cellIndex = rowIndex * tableMapData.width + col;
|
||||
const cellPos = tableMapData.map[cellIndex];
|
||||
const cell = tableNode.nodeAt(cellPos);
|
||||
|
||||
if (!isCellEmpty(cell)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
import { findParentNode, type Editor } from "@tiptap/core";
|
||||
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
||||
import { CellSelection, TableMap } from "@tiptap/pm/tables";
|
||||
import { Decoration, DecorationSet } from "@tiptap/pm/view";
|
||||
// local imports
|
||||
import { getCellBorderClasses } from "./utils";
|
||||
|
||||
type TableCellSelectionOutlinePluginState = {
|
||||
decorations?: DecorationSet;
|
||||
};
|
||||
|
||||
const TABLE_SELECTION_OUTLINE_PLUGIN_KEY = new PluginKey("table-cell-selection-outline");
|
||||
|
||||
export const TableCellSelectionOutlinePlugin = (editor: Editor): Plugin<TableCellSelectionOutlinePluginState> =>
|
||||
new Plugin<TableCellSelectionOutlinePluginState>({
|
||||
key: TABLE_SELECTION_OUTLINE_PLUGIN_KEY,
|
||||
state: {
|
||||
init: () => ({}),
|
||||
apply(tr, prev, oldState, newState) {
|
||||
if (!editor.isEditable) return {};
|
||||
const table = findParentNode((node) => node.type.spec.tableRole === "table")(newState.selection);
|
||||
const hasDocChanged = tr.docChanged || !newState.selection.eq(oldState.selection);
|
||||
if (!table || !hasDocChanged) {
|
||||
return table === undefined ? {} : prev;
|
||||
}
|
||||
|
||||
const { selection } = newState;
|
||||
if (!(selection instanceof CellSelection)) return {};
|
||||
|
||||
const decorations: Decoration[] = [];
|
||||
const tableMap = TableMap.get(table.node);
|
||||
const selectedCells: number[] = [];
|
||||
|
||||
// First, collect all selected cell positions
|
||||
selection.forEachCell((_node, pos) => {
|
||||
const start = pos - table.pos - 1;
|
||||
selectedCells.push(start);
|
||||
});
|
||||
|
||||
// Then, add decorations with appropriate border classes
|
||||
selection.forEachCell((node, pos) => {
|
||||
const start = pos - table.pos - 1;
|
||||
const classes = getCellBorderClasses(start, selectedCells, tableMap);
|
||||
|
||||
decorations.push(Decoration.node(pos, pos + node.nodeSize, { class: classes.join(" ") }));
|
||||
});
|
||||
|
||||
return {
|
||||
decorations: DecorationSet.create(newState.doc, decorations),
|
||||
};
|
||||
},
|
||||
},
|
||||
props: {
|
||||
decorations(state) {
|
||||
return TABLE_SELECTION_OUTLINE_PLUGIN_KEY.getState(state).decorations;
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { TableMap } from "@tiptap/pm/tables";
|
||||
|
||||
/**
|
||||
* Calculates the positions of cells adjacent to a given cell in a table
|
||||
* @param cellStart - The start position of the current cell in the document
|
||||
* @param tableMap - ProseMirror's table mapping structure containing cell positions and dimensions
|
||||
* @returns Object with positions of adjacent cells (undefined if cell doesn't exist at table edge)
|
||||
*/
|
||||
const getAdjacentCellPositions = (
|
||||
cellStart: number,
|
||||
tableMap: TableMap
|
||||
): { top?: number; bottom?: number; left?: number; right?: number } => {
|
||||
// Extract table dimensions
|
||||
// width -> number of columns in the table
|
||||
// height -> number of rows in the table
|
||||
const { width, height } = tableMap;
|
||||
|
||||
// Find the index of our cell in the flat tableMap.map array
|
||||
// tableMap.map contains start positions of all cells in row-by-row order
|
||||
const cellIndex = tableMap.map.indexOf(cellStart);
|
||||
|
||||
// Safety check: if cell position not found in table map, return empty object
|
||||
if (cellIndex === -1) return {};
|
||||
|
||||
// Convert flat array index to 2D grid coordinates
|
||||
// row = which row the cell is in (0-based from top)
|
||||
// col = which column the cell is in (0-based from left)
|
||||
const row = Math.floor(cellIndex / width); // Integer division gives row number
|
||||
const col = cellIndex % width; // Remainder gives column number
|
||||
|
||||
return {
|
||||
// Top cell: same column, one row up
|
||||
// Check if we're not in the first row (row > 0) before calculating
|
||||
top: row > 0 ? tableMap.map[(row - 1) * width + col] : undefined,
|
||||
|
||||
// Bottom cell: same column, one row down
|
||||
// Check if we're not in the last row (row < height - 1) before calculating
|
||||
bottom: row < height - 1 ? tableMap.map[(row + 1) * width + col] : undefined,
|
||||
|
||||
// Left cell: same row, one column left
|
||||
// Check if we're not in the first column (col > 0) before calculating
|
||||
left: col > 0 ? tableMap.map[row * width + (col - 1)] : undefined,
|
||||
|
||||
// Right cell: same row, one column right
|
||||
// Check if we're not in the last column (col < width - 1) before calculating
|
||||
right: col < width - 1 ? tableMap.map[row * width + (col + 1)] : undefined,
|
||||
};
|
||||
};
|
||||
|
||||
export const getCellBorderClasses = (cellStart: number, selectedCells: number[], tableMap: TableMap): string[] => {
|
||||
const adjacent = getAdjacentCellPositions(cellStart, tableMap);
|
||||
const classes: string[] = [];
|
||||
|
||||
// Add border-right if right cell is not selected or doesn't exist
|
||||
if (adjacent.right === undefined || !selectedCells.includes(adjacent.right)) {
|
||||
classes.push("selectedCell-border-right");
|
||||
}
|
||||
|
||||
// Add border-left if left cell is not selected or doesn't exist
|
||||
if (adjacent.left === undefined || !selectedCells.includes(adjacent.left)) {
|
||||
classes.push("selectedCell-border-left");
|
||||
}
|
||||
|
||||
// Add border-top if top cell is not selected or doesn't exist
|
||||
if (adjacent.top === undefined || !selectedCells.includes(adjacent.top)) {
|
||||
classes.push("selectedCell-border-top");
|
||||
}
|
||||
|
||||
// Add border-bottom if bottom cell is not selected or doesn't exist
|
||||
if (adjacent.bottom === undefined || !selectedCells.includes(adjacent.bottom)) {
|
||||
classes.push("selectedCell-border-bottom");
|
||||
}
|
||||
|
||||
return classes;
|
||||
};
|
||||
@@ -1,6 +1,10 @@
|
||||
import { mergeAttributes, Node } from "@tiptap/core";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
// local imports
|
||||
import { TableCellSelectionOutlinePlugin } from "./plugins/selection-outline/plugin";
|
||||
import { DEFAULT_COLUMN_WIDTH } from "./table";
|
||||
|
||||
export interface TableCellOptions {
|
||||
HTMLAttributes: Record<string, any>;
|
||||
}
|
||||
@@ -25,7 +29,7 @@ export const TableCell = Node.create<TableCellOptions>({
|
||||
default: 1,
|
||||
},
|
||||
colwidth: {
|
||||
default: null,
|
||||
default: [DEFAULT_COLUMN_WIDTH],
|
||||
parseHTML: (element) => {
|
||||
const colwidth = element.getAttribute("colwidth");
|
||||
const value = colwidth ? [parseInt(colwidth, 10)] : null;
|
||||
@@ -46,6 +50,10 @@ export const TableCell = Node.create<TableCellOptions>({
|
||||
|
||||
isolating: true,
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [TableCellSelectionOutlinePlugin(this.editor)];
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [{ tag: "td" }];
|
||||
},
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { mergeAttributes, Node } from "@tiptap/core";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
// local imports
|
||||
import { DEFAULT_COLUMN_WIDTH } from "./table";
|
||||
|
||||
export interface TableHeaderOptions {
|
||||
HTMLAttributes: Record<string, any>;
|
||||
}
|
||||
@@ -25,7 +28,7 @@ export const TableHeader = Node.create<TableHeaderOptions>({
|
||||
default: 1,
|
||||
},
|
||||
colwidth: {
|
||||
default: null,
|
||||
default: [DEFAULT_COLUMN_WIDTH],
|
||||
parseHTML: (element) => {
|
||||
const colwidth = element.getAttribute("colwidth");
|
||||
const value = colwidth ? [parseInt(colwidth, 10)] : null;
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
export { Table } from "./table";
|
||||
|
||||
export const DEFAULT_COLUMN_WIDTH = 150;
|
||||
|
||||
@@ -387,7 +387,7 @@ export class TableView implements NodeView {
|
||||
this.root = h(
|
||||
"div",
|
||||
{
|
||||
className: "table-wrapper horizontal-scrollbar scrollbar-md controls--disabled",
|
||||
className: "table-wrapper editor-full-width-block horizontal-scrollbar scrollbar-sm controls--disabled",
|
||||
},
|
||||
this.controls,
|
||||
this.table
|
||||
|
||||
@@ -23,12 +23,14 @@ import { Decoration } from "@tiptap/pm/view";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
// local imports
|
||||
import { TableInsertPlugin } from "../plugins/insert-handlers/plugin";
|
||||
import { tableControls } from "./table-controls";
|
||||
import { TableView } from "./table-view";
|
||||
import { createTable } from "./utilities/create-table";
|
||||
import { deleteTableWhenAllCellsSelected } from "./utilities/delete-table-when-all-cells-selected";
|
||||
import { insertLineAboveTableAction } from "./utilities/insert-line-above-table-action";
|
||||
import { insertLineBelowTableAction } from "./utilities/insert-line-below-table-action";
|
||||
import { DEFAULT_COLUMN_WIDTH } from ".";
|
||||
|
||||
export interface TableOptions {
|
||||
HTMLAttributes: Record<string, any>;
|
||||
@@ -42,12 +44,7 @@ export interface TableOptions {
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
[CORE_EXTENSIONS.TABLE]: {
|
||||
insertTable: (options?: {
|
||||
rows?: number;
|
||||
cols?: number;
|
||||
withHeaderRow?: boolean;
|
||||
columnWidth?: number;
|
||||
}) => ReturnType;
|
||||
insertTable: (options?: { rows?: number; cols?: number; withHeaderRow?: boolean }) => ReturnType;
|
||||
addColumnBefore: () => ReturnType;
|
||||
addColumnAfter: () => ReturnType;
|
||||
deleteColumn: () => ReturnType;
|
||||
@@ -81,7 +78,7 @@ declare module "@tiptap/core" {
|
||||
}
|
||||
}
|
||||
|
||||
export const Table = Node.create({
|
||||
export const Table = Node.create<TableOptions>({
|
||||
name: CORE_EXTENSIONS.TABLE,
|
||||
|
||||
addOptions() {
|
||||
@@ -116,9 +113,15 @@ export const Table = Node.create({
|
||||
addCommands() {
|
||||
return {
|
||||
insertTable:
|
||||
({ rows = 3, cols = 3, withHeaderRow = false, columnWidth = 150 } = {}) =>
|
||||
({ rows = 3, cols = 3, withHeaderRow = false } = {}) =>
|
||||
({ tr, dispatch, editor }) => {
|
||||
const node = createTable(editor.schema, rows, cols, withHeaderRow, undefined, columnWidth);
|
||||
const node = createTable({
|
||||
schema: editor.schema,
|
||||
rowsCount: rows,
|
||||
colsCount: cols,
|
||||
withHeaderRow,
|
||||
columnWidth: DEFAULT_COLUMN_WIDTH,
|
||||
});
|
||||
if (dispatch) {
|
||||
const offset = tr.selection.anchor + 1;
|
||||
|
||||
@@ -264,6 +267,7 @@ export const Table = Node.create({
|
||||
allowTableNodeSelection: this.options.allowTableNodeSelection,
|
||||
}),
|
||||
tableControls(),
|
||||
TableInsertPlugin(this.editor),
|
||||
];
|
||||
|
||||
if (isResizable) {
|
||||
|
||||
@@ -3,14 +3,18 @@ import { Fragment, Node as ProsemirrorNode, Schema } from "@tiptap/pm/model";
|
||||
import { createCell } from "@/extensions/table/table/utilities/create-cell";
|
||||
import { getTableNodeTypes } from "@/extensions/table/table/utilities/get-table-node-types";
|
||||
|
||||
export function createTable(
|
||||
schema: Schema,
|
||||
rowsCount: number,
|
||||
colsCount: number,
|
||||
withHeaderRow: boolean,
|
||||
cellContent?: Fragment | ProsemirrorNode | Array<ProsemirrorNode>,
|
||||
columnWidth: number = 100
|
||||
): ProsemirrorNode {
|
||||
type Props = {
|
||||
schema: Schema;
|
||||
rowsCount: number;
|
||||
colsCount: number;
|
||||
withHeaderRow: boolean;
|
||||
cellContent?: Fragment | ProsemirrorNode | Array<ProsemirrorNode>;
|
||||
columnWidth: number;
|
||||
};
|
||||
|
||||
export const createTable = (props: Props): ProsemirrorNode => {
|
||||
const { schema, rowsCount, colsCount, withHeaderRow, cellContent, columnWidth } = props;
|
||||
|
||||
const types = getTableNodeTypes(schema);
|
||||
const headerCells: ProsemirrorNode[] = [];
|
||||
const cells: ProsemirrorNode[] = [];
|
||||
@@ -38,4 +42,4 @@ export function createTable(
|
||||
}
|
||||
|
||||
return types.table.createChecked(null, rows);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2,8 +2,8 @@ import { Editor, Range } from "@tiptap/core";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
// extensions
|
||||
import { InsertImageComponentProps } from "@/extensions";
|
||||
import { replaceCodeWithText } from "@/extensions/code/utils/replace-code-block-with-text";
|
||||
import type { InsertImageComponentProps } from "@/extensions/custom-image/types";
|
||||
// helpers
|
||||
import { findTableAncestor } from "@/helpers/common";
|
||||
|
||||
@@ -109,9 +109,8 @@ export const insertTableCommand = (editor: Editor, range?: Range) => {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (range)
|
||||
editor.chain().focus().deleteRange(range).clearNodes().insertTable({ rows: 3, cols: 3, columnWidth: 150 }).run();
|
||||
else editor.chain().focus().clearNodes().insertTable({ rows: 3, cols: 3, columnWidth: 150 }).run();
|
||||
if (range) editor.chain().focus().deleteRange(range).clearNodes().insertTable({ rows: 3, cols: 3 }).run();
|
||||
else editor.chain().focus().clearNodes().insertTable({ rows: 3, cols: 3 }).run();
|
||||
};
|
||||
|
||||
export const insertImage = ({
|
||||
|
||||
@@ -1,40 +1,42 @@
|
||||
// plane imports
|
||||
import { TDocumentPayload, TDuplicateAssetData, TDuplicateAssetResponse } from "@plane/types";
|
||||
import { TEditorAssetType } from "@plane/types/src/enums";
|
||||
// plane web imports
|
||||
import {
|
||||
extractAdditionalAssetsFromHTMLContent,
|
||||
replaceAdditionalAssetsInHTMLContent,
|
||||
} from "@/plane-editor/helpers/parser";
|
||||
// local imports
|
||||
import { convertHTMLDocumentToAllFormats } from "./yjs-utils";
|
||||
|
||||
/**
|
||||
* @description function to extract all image assets from HTML content
|
||||
* @description function to extract all assets from HTML content
|
||||
* @param htmlContent
|
||||
* @returns {string[]} array of image asset sources
|
||||
* @returns {string[]} array of asset sources
|
||||
*/
|
||||
export const extractImageAssetsFromHTMLContent = (htmlContent: string): string[] => {
|
||||
const extractAssetsFromHTMLContent = (htmlContent: string): string[] => {
|
||||
// create a DOM parser
|
||||
const parser = new DOMParser();
|
||||
// parse the HTML string into a DOM document
|
||||
const doc = parser.parseFromString(htmlContent, "text/html");
|
||||
// get all image components
|
||||
const imageComponents = doc.querySelectorAll("image-component");
|
||||
// collect all unique image sources
|
||||
const imageSources = new Set<string>();
|
||||
// collect all unique asset sources
|
||||
const assetSources = new Set<string>();
|
||||
// extract sources from image components
|
||||
const imageComponents = doc.querySelectorAll("image-component");
|
||||
imageComponents.forEach((component) => {
|
||||
const src = component.getAttribute("src");
|
||||
if (src) imageSources.add(src);
|
||||
if (src) assetSources.add(src);
|
||||
});
|
||||
return Array.from(imageSources);
|
||||
const additionalAssetIds = extractAdditionalAssetsFromHTMLContent(htmlContent);
|
||||
return [...Array.from(assetSources), ...additionalAssetIds];
|
||||
};
|
||||
|
||||
/**
|
||||
* @description function to replace image assets in HTML content with new IDs
|
||||
* @description function to replace assets in HTML content with new IDs
|
||||
* @param props
|
||||
* @returns {string} HTML content with replaced image assets
|
||||
* @returns {string} HTML content with replaced assets
|
||||
*/
|
||||
export const replaceImageAssetsInHTMLContent = (props: {
|
||||
htmlContent: string;
|
||||
assetMap: Record<string, string>;
|
||||
}): string => {
|
||||
const replaceAssetsInHTMLContent = (props: { htmlContent: string; assetMap: Record<string, string> }): string => {
|
||||
const { htmlContent, assetMap } = props;
|
||||
// create a DOM parser
|
||||
const parser = new DOMParser();
|
||||
@@ -48,11 +50,15 @@ export const replaceImageAssetsInHTMLContent = (props: {
|
||||
component.setAttribute("src", assetMap[oldSrc]);
|
||||
}
|
||||
});
|
||||
// serialize the document back into a string
|
||||
return doc.body.innerHTML;
|
||||
// replace additional sources
|
||||
const replacedHTMLContent = replaceAdditionalAssetsInHTMLContent({
|
||||
htmlContent: doc.body.innerHTML,
|
||||
assetMap,
|
||||
});
|
||||
return replacedHTMLContent;
|
||||
};
|
||||
|
||||
export const getEditorContentWithReplacedImageAssets = async (props: {
|
||||
export const getEditorContentWithReplacedAssets = async (props: {
|
||||
descriptionHTML: string;
|
||||
entityId: string;
|
||||
entityType: TEditorAssetType;
|
||||
@@ -63,18 +69,18 @@ export const getEditorContentWithReplacedImageAssets = async (props: {
|
||||
const { descriptionHTML, entityId, entityType, projectId, variant, duplicateAssetService } = props;
|
||||
let replacedDescription = descriptionHTML;
|
||||
// step 1: extract image assets from the description
|
||||
const imageAssets = extractImageAssetsFromHTMLContent(descriptionHTML);
|
||||
if (imageAssets.length !== 0) {
|
||||
const assetIds = extractAssetsFromHTMLContent(descriptionHTML);
|
||||
if (assetIds.length !== 0) {
|
||||
// step 2: duplicate the image assets
|
||||
const duplicateAssetsResponse = await duplicateAssetService({
|
||||
entity_id: entityId,
|
||||
entity_type: entityType,
|
||||
project_id: projectId,
|
||||
asset_ids: imageAssets,
|
||||
asset_ids: assetIds,
|
||||
});
|
||||
if (Object.keys(duplicateAssetsResponse ?? {}).length > 0) {
|
||||
// step 3: replace the image assets in the description
|
||||
replacedDescription = replaceImageAssetsInHTMLContent({
|
||||
replacedDescription = replaceAssetsInHTMLContent({
|
||||
htmlContent: descriptionHTML,
|
||||
assetMap: duplicateAssetsResponse,
|
||||
});
|
||||
|
||||
@@ -16,7 +16,7 @@ const generalSelectors = [
|
||||
"blockquote",
|
||||
"h1.editor-heading-block, h2.editor-heading-block, h3.editor-heading-block, h4.editor-heading-block, h5.editor-heading-block, h6.editor-heading-block",
|
||||
"[data-type=horizontalRule]",
|
||||
".table-wrapper",
|
||||
"table",
|
||||
".issue-embed",
|
||||
".image-component",
|
||||
".image-upload-component",
|
||||
@@ -90,7 +90,7 @@ export const nodeDOMAtCoords = (coords: { x: number; y: number }) => {
|
||||
|
||||
for (const elem of elements) {
|
||||
// Check for table wrapper first
|
||||
if (elem.matches(".table-wrapper")) {
|
||||
if (elem.matches("table")) {
|
||||
return elem;
|
||||
}
|
||||
|
||||
@@ -99,7 +99,7 @@ export const nodeDOMAtCoords = (coords: { x: number; y: number }) => {
|
||||
}
|
||||
|
||||
// Skip table cells
|
||||
if (elem.closest(".table-wrapper")) {
|
||||
if (elem.closest("table")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
}
|
||||
/* end ai handle */
|
||||
|
||||
.ProseMirror:not(.dragging) .ProseMirror-selectednode:not(.node-imageComponent):not(.node-image) {
|
||||
.ProseMirror:not(.dragging) .ProseMirror-selectednode:not(.node-imageComponent):not(.node-image):not(.table-wrapper) {
|
||||
position: relative;
|
||||
cursor: grab;
|
||||
outline: none !important;
|
||||
@@ -61,7 +61,8 @@
|
||||
}
|
||||
|
||||
&.node-imageComponent,
|
||||
&.node-image {
|
||||
&.node-image,
|
||||
&.table-wrapper {
|
||||
--horizontal-offset: 0px;
|
||||
|
||||
&::after {
|
||||
|
||||
@@ -1,115 +1,155 @@
|
||||
.table-wrapper {
|
||||
overflow-x: auto;
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
}
|
||||
padding-bottom: 30px;
|
||||
|
||||
.table-wrapper table {
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
margin: 0.5rem 0 1rem 0;
|
||||
border: 1px solid rgba(var(--color-border-200));
|
||||
width: 100%;
|
||||
}
|
||||
table {
|
||||
position: relative;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
margin: 0.5rem 0 0 0;
|
||||
border: 1px solid rgba(var(--color-border-200));
|
||||
width: 100%;
|
||||
transition: background-color 0.3s ease;
|
||||
|
||||
.table-wrapper table td,
|
||||
.table-wrapper table th {
|
||||
min-width: 1em;
|
||||
border: 1px solid rgba(var(--color-border-200));
|
||||
padding: 7px 10px;
|
||||
vertical-align: top;
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
transition: background-color 0.3s ease;
|
||||
|
||||
> * {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.table-wrapper table {
|
||||
th {
|
||||
font-weight: 500;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
tr[background="none"],
|
||||
tr:not([background]) {
|
||||
td,
|
||||
th {
|
||||
background-color: rgba(var(--color-background-90));
|
||||
min-width: 1em;
|
||||
border: 1px solid rgba(var(--color-border-300));
|
||||
padding: 7px 10px;
|
||||
vertical-align: top;
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
transition: background-color 0.3s ease;
|
||||
|
||||
> * {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Selected cell outline */
|
||||
&.selectedCell {
|
||||
user-select: none;
|
||||
|
||||
&::after {
|
||||
position: absolute;
|
||||
content: "";
|
||||
top: -1px;
|
||||
left: -1px;
|
||||
height: calc(100% + 2px);
|
||||
width: calc(100% + 2px);
|
||||
border: 2px solid transparent;
|
||||
}
|
||||
|
||||
&.selectedCell-border-top::after {
|
||||
border-top-color: rgba(var(--color-primary-100));
|
||||
}
|
||||
|
||||
&.selectedCell-border-left::after {
|
||||
border-left-color: rgba(var(--color-primary-100));
|
||||
}
|
||||
|
||||
&.selectedCell-border-bottom::after {
|
||||
border-bottom-color: rgba(var(--color-primary-100));
|
||||
}
|
||||
|
||||
&.selectedCell-border-right::after {
|
||||
border-right-color: rgba(var(--color-primary-100));
|
||||
}
|
||||
}
|
||||
/* End selected cell outline */
|
||||
}
|
||||
|
||||
th {
|
||||
font-weight: 500;
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
&:not(.ProseMirror-selectednode) {
|
||||
table {
|
||||
tr[background="none"],
|
||||
tr:not([background]) {
|
||||
th {
|
||||
background-color: rgba(var(--color-background-90));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Selected status */
|
||||
&.ProseMirror-selectednode {
|
||||
table {
|
||||
background-color: rgba(var(--color-primary-100), 0.2);
|
||||
}
|
||||
}
|
||||
/* End selected status */
|
||||
}
|
||||
|
||||
.table-wrapper table .selectedCell {
|
||||
outline: 0.5px solid rgba(var(--color-primary-100));
|
||||
}
|
||||
|
||||
/* table dropdown */
|
||||
/* Column resizer */
|
||||
.table-wrapper table .column-resize-handle {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
right: -1px;
|
||||
top: -1px;
|
||||
width: 2px;
|
||||
height: 100%;
|
||||
height: calc(100% + 2px);
|
||||
z-index: 5;
|
||||
background-color: rgba(var(--color-primary-100));
|
||||
pointer-events: none;
|
||||
}
|
||||
/* End column resizer */
|
||||
|
||||
.table-wrapper .table-controls {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.table-wrapper .table-controls .columns-control,
|
||||
.table-wrapper .table-controls .rows-control {
|
||||
transition: opacity ease-in 100ms;
|
||||
position: absolute;
|
||||
z-index: 5;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
.columns-control,
|
||||
.rows-control {
|
||||
transition: opacity ease-in 100ms;
|
||||
position: absolute;
|
||||
z-index: 5;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.table-wrapper .table-controls .columns-control {
|
||||
height: 20px;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
.columns-control {
|
||||
height: 20px;
|
||||
transform: translateY(-50%);
|
||||
|
||||
.table-wrapper .table-controls .columns-control .columns-control-div {
|
||||
color: white;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='24' height='24'%3E%3Cpath fill='none' d='M0 0h24v24H0z'/%3E%3Cpath fill='%238F95B2' d='M4.5 10.5c-.825 0-1.5.675-1.5 1.5s.675 1.5 1.5 1.5S6 12.825 6 12s-.675-1.5-1.5-1.5zm15 0c-.825 0-1.5.675-1.5 1.5s.675 1.5 1.5 1.5S21 12.825 21 12s-.675-1.5-1.5-1.5zm-7.5 0c-.825 0-1.5.675-1.5 1.5s.675 1.5 1.5 1.5 1.5-.675 1.5-1.5-.675-1.5-1.5-1.5z'/%3E%3C/svg%3E");
|
||||
width: 30px;
|
||||
height: 15px;
|
||||
}
|
||||
.columns-control-div {
|
||||
color: white;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='24' height='24'%3E%3Cpath fill='none' d='M0 0h24v24H0z'/%3E%3Cpath fill='%238F95B2' d='M4.5 10.5c-.825 0-1.5.675-1.5 1.5s.675 1.5 1.5 1.5S6 12.825 6 12s-.675-1.5-1.5-1.5zm15 0c-.825 0-1.5.675-1.5 1.5s.675 1.5 1.5 1.5S21 12.825 21 12s-.675-1.5-1.5-1.5zm-7.5 0c-.825 0-1.5.675-1.5 1.5s.675 1.5 1.5 1.5 1.5-.675 1.5-1.5-.675-1.5-1.5-1.5z'/%3E%3C/svg%3E");
|
||||
width: 30px;
|
||||
height: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
.table-wrapper .table-controls .rows-control {
|
||||
width: 20px;
|
||||
transform: translateX(-50%);
|
||||
left: -8px;
|
||||
}
|
||||
.rows-control {
|
||||
width: 20px;
|
||||
transform: translateX(-50%);
|
||||
left: -8px;
|
||||
|
||||
.table-wrapper .table-controls .rows-control .rows-control-div {
|
||||
color: white;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='24' height='24'%3E%3Cpath fill='none' d='M0 0h24v24H0z'/%3E%3Cpath fill='%238F95B2' d='M12 3c-.825 0-1.5.675-1.5 1.5S11.175 6 12 6s1.5-.675 1.5-1.5S12.825 3 12 3zm0 15c-.825 0-1.5.675-1.5 1.5S11.175 21 12 21s1.5-.675 1.5-1.5S12.825 18 12 18zm0-7.5c-.825 0-1.5.675-1.5 1.5s.675 1.5 1.5 1.5 1.5-.675 1.5-1.5-.675-1.5-1.5-1.5z'/%3E%3C/svg%3E");
|
||||
height: 30px;
|
||||
width: 15px;
|
||||
}
|
||||
.rows-control-div {
|
||||
color: white;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='24' height='24'%3E%3Cpath fill='none' d='M0 0h24v24H0z'/%3E%3Cpath fill='%238F95B2' d='M12 3c-.825 0-1.5.675-1.5 1.5S11.175 6 12 6s1.5-.675 1.5-1.5S12.825 3 12 3zm0 15c-.825 0-1.5.675-1.5 1.5S11.175 21 12 21s1.5-.675 1.5-1.5S12.825 18 12 18zm0-7.5c-.825 0-1.5.675-1.5 1.5s.675 1.5 1.5 1.5 1.5-.675 1.5-1.5-.675-1.5-1.5-1.5z'/%3E%3C/svg%3E");
|
||||
height: 30px;
|
||||
width: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
.table-wrapper .table-controls .rows-control-div,
|
||||
.table-wrapper .table-controls .columns-control-div {
|
||||
background-color: rgba(var(--color-background-80));
|
||||
border: 0.5px solid rgba(var(--color-border-200));
|
||||
border-radius: 4px;
|
||||
background-size: 1.25rem;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
transition:
|
||||
transform ease-out 100ms,
|
||||
background-color ease-out 100ms;
|
||||
outline: none;
|
||||
box-shadow: rgba(var(--color-shadow-2xs));
|
||||
cursor: pointer;
|
||||
.columns-control-div,
|
||||
.rows-control-div {
|
||||
background-color: rgba(var(--color-background-80));
|
||||
border: 0.5px solid rgba(var(--color-border-200));
|
||||
border-radius: 4px;
|
||||
background-size: 1.25rem;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
transition:
|
||||
transform ease-out 100ms,
|
||||
background-color ease-out 100ms;
|
||||
outline: none;
|
||||
box-shadow: rgba(var(--color-shadow-2xs));
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.resize-cursor .table-wrapper .table-controls .rows-control,
|
||||
@@ -119,3 +159,65 @@
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Insert buttons */
|
||||
.table-wrapper {
|
||||
.table-column-insert-button,
|
||||
.table-row-insert-button {
|
||||
position: absolute;
|
||||
background-color: rgba(var(--color-background-90));
|
||||
color: rgba(var(--color-text-300));
|
||||
border: 1px solid rgba(var(--color-border-200));
|
||||
border-radius: 4px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
outline: none;
|
||||
z-index: 1000;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(var(--color-background-80));
|
||||
color: rgba(var(--color-text-100));
|
||||
}
|
||||
|
||||
&.dragging {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
background-color: rgba(var(--color-primary-100), 0.2);
|
||||
color: rgba(var(--color-text-100));
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.table-column-insert-button {
|
||||
top: 0;
|
||||
right: -20px;
|
||||
width: 20px;
|
||||
height: 100%;
|
||||
transform: translateX(50%);
|
||||
}
|
||||
|
||||
.table-row-insert-button {
|
||||
bottom: -20px;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 20px;
|
||||
transform: translateY(50%);
|
||||
}
|
||||
|
||||
/* Show buttons on table hover */
|
||||
&:hover {
|
||||
.table-column-insert-button,
|
||||
.table-row-insert-button {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
/* End insert buttons */
|
||||
|
||||
@@ -179,9 +179,18 @@
|
||||
}
|
||||
|
||||
.ProseMirror {
|
||||
max-width: var(--editor-content-width);
|
||||
margin: 0 auto;
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
& > *:not(.editor-full-width-block) {
|
||||
max-width: var(--editor-content-width);
|
||||
margin-left: auto !important;
|
||||
margin-right: auto !important;
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
& > .editor-full-width-block {
|
||||
max-width: 100%;
|
||||
padding-inline-start: calc((100% - var(--editor-content-width)) / 2);
|
||||
padding-inline-end: var(--wide-content-margin);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,38 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import React from "react";
|
||||
// plane imports
|
||||
import { TChartData } from "@plane/types";
|
||||
import { TBarChartShapeVariant, TBarItem, TChartData } from "@plane/types";
|
||||
import { cn } from "@plane/utils";
|
||||
|
||||
// Helper to calculate percentage
|
||||
// Constants
|
||||
const MIN_BAR_HEIGHT_FOR_INTERNAL_TEXT = 14; // Minimum height required to show text inside bar
|
||||
const BAR_TOP_BORDER_RADIUS = 4; // Border radius for the top of bars
|
||||
const BAR_BOTTOM_BORDER_RADIUS = 4; // Border radius for the bottom of bars
|
||||
const DEFAULT_LOLLIPOP_LINE_WIDTH = 2; // Width of lollipop stick
|
||||
const DEFAULT_LOLLIPOP_CIRCLE_RADIUS = 8; // Radius of lollipop circle
|
||||
|
||||
// Types
|
||||
interface TShapeProps {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
dataKey: string;
|
||||
payload: any;
|
||||
opacity?: number;
|
||||
}
|
||||
|
||||
interface TBarProps extends TShapeProps {
|
||||
fill: string | ((payload: any) => string);
|
||||
stackKeys: string[];
|
||||
textClassName?: string;
|
||||
showPercentage?: boolean;
|
||||
showTopBorderRadius?: boolean;
|
||||
showBottomBorderRadius?: boolean;
|
||||
dotted?: boolean;
|
||||
}
|
||||
|
||||
// Helper Functions
|
||||
const calculatePercentage = <K extends string, T extends string>(
|
||||
data: TChartData<K, T>,
|
||||
stackKeys: T[],
|
||||
@@ -14,11 +42,36 @@ const calculatePercentage = <K extends string, T extends string>(
|
||||
return total === 0 ? 0 : Math.round((data[currentKey] / total) * 100);
|
||||
};
|
||||
|
||||
const MIN_BAR_HEIGHT_FOR_INTERNAL_TEXT = 14; // Minimum height needed to show text inside
|
||||
const BAR_TOP_BORDER_RADIUS = 4; // Border radius for each bar
|
||||
const BAR_BOTTOM_BORDER_RADIUS = 4; // Border radius for each bar
|
||||
const getBarPath = (x: number, y: number, width: number, height: number, topRadius: number, bottomRadius: number) => `
|
||||
M${x},${y + topRadius}
|
||||
Q${x},${y} ${x + topRadius},${y}
|
||||
L${x + width - topRadius},${y}
|
||||
Q${x + width},${y} ${x + width},${y + topRadius}
|
||||
L${x + width},${y + height - bottomRadius}
|
||||
Q${x + width},${y + height} ${x + width - bottomRadius},${y + height}
|
||||
L${x + bottomRadius},${y + height}
|
||||
Q${x},${y + height} ${x},${y + height - bottomRadius}
|
||||
Z
|
||||
`;
|
||||
|
||||
export const CustomBar = React.memo((props: any) => {
|
||||
const PercentageText = ({
|
||||
x,
|
||||
y,
|
||||
percentage,
|
||||
className,
|
||||
}: {
|
||||
x: number;
|
||||
y: number;
|
||||
percentage: number;
|
||||
className?: string;
|
||||
}) => (
|
||||
<text x={x} y={y} textAnchor="middle" className={cn("text-xs font-medium", className)} fill="currentColor">
|
||||
{percentage}%
|
||||
</text>
|
||||
);
|
||||
|
||||
// Base Components
|
||||
const CustomBar = React.memo((props: TBarProps) => {
|
||||
const {
|
||||
opacity,
|
||||
fill,
|
||||
@@ -34,56 +87,104 @@ export const CustomBar = React.memo((props: any) => {
|
||||
showTopBorderRadius,
|
||||
showBottomBorderRadius,
|
||||
} = props;
|
||||
// Calculate text position
|
||||
const TEXT_PADDING_Y = Math.min(6, Math.abs(MIN_BAR_HEIGHT_FOR_INTERNAL_TEXT - height / 2));
|
||||
const textY = y + height - TEXT_PADDING_Y; // Position inside bar if tall enough
|
||||
// derived values
|
||||
|
||||
if (!height) return null;
|
||||
|
||||
const currentBarPercentage = calculatePercentage(payload, stackKeys, dataKey);
|
||||
const TEXT_PADDING_Y = Math.min(6, Math.abs(MIN_BAR_HEIGHT_FOR_INTERNAL_TEXT - height / 2));
|
||||
const textY = y + height - TEXT_PADDING_Y;
|
||||
|
||||
const showText =
|
||||
// from props
|
||||
showPercentage &&
|
||||
// height of the bar is greater than or equal to the minimum height required to show the text
|
||||
height >= MIN_BAR_HEIGHT_FOR_INTERNAL_TEXT &&
|
||||
// bar percentage text has some value
|
||||
currentBarPercentage !== undefined &&
|
||||
// bar percentage is a number
|
||||
!Number.isNaN(currentBarPercentage);
|
||||
|
||||
const topBorderRadius = showTopBorderRadius ? BAR_TOP_BORDER_RADIUS : 0;
|
||||
const bottomBorderRadius = showBottomBorderRadius ? BAR_BOTTOM_BORDER_RADIUS : 0;
|
||||
|
||||
if (!height) return null;
|
||||
|
||||
return (
|
||||
<g>
|
||||
<path
|
||||
d={`
|
||||
M${x},${y + topBorderRadius}
|
||||
Q${x},${y} ${x + topBorderRadius},${y}
|
||||
L${x + width - topBorderRadius},${y}
|
||||
Q${x + width},${y} ${x + width},${y + topBorderRadius}
|
||||
L${x + width},${y + height - bottomBorderRadius}
|
||||
Q${x + width},${y + height} ${x + width - bottomBorderRadius},${y + height}
|
||||
L${x + bottomBorderRadius},${y + height}
|
||||
Q${x},${y + height} ${x},${y + height - bottomBorderRadius}
|
||||
Z
|
||||
`}
|
||||
d={getBarPath(x, y, width, height, topBorderRadius, bottomBorderRadius)}
|
||||
className="transition-opacity duration-200"
|
||||
fill={fill}
|
||||
fill={typeof fill === "function" ? fill(payload) : fill}
|
||||
opacity={opacity}
|
||||
/>
|
||||
{showText && (
|
||||
<text
|
||||
x={x + width / 2}
|
||||
y={textY}
|
||||
textAnchor="middle"
|
||||
className={cn("text-xs font-medium", textClassName)}
|
||||
fill="currentColor"
|
||||
>
|
||||
{currentBarPercentage}%
|
||||
</text>
|
||||
<PercentageText x={x + width / 2} y={textY} percentage={currentBarPercentage} className={textClassName} />
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
});
|
||||
|
||||
const CustomBarLollipop = React.memo((props: TBarProps) => {
|
||||
const { fill, x, y, width, height, dataKey, stackKeys, payload, textClassName, showPercentage, dotted } = props;
|
||||
|
||||
const currentBarPercentage = calculatePercentage(payload, stackKeys, dataKey);
|
||||
|
||||
return (
|
||||
<g>
|
||||
<line
|
||||
x1={x + width / 2}
|
||||
y1={y + height}
|
||||
x2={x + width / 2}
|
||||
y2={y}
|
||||
stroke={typeof fill === "function" ? fill(payload) : fill}
|
||||
strokeWidth={DEFAULT_LOLLIPOP_LINE_WIDTH}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={dotted ? "4 4" : "0"}
|
||||
/>
|
||||
<circle
|
||||
cx={x + width / 2}
|
||||
cy={y}
|
||||
r={DEFAULT_LOLLIPOP_CIRCLE_RADIUS}
|
||||
fill={typeof fill === "function" ? fill(payload) : fill}
|
||||
stroke="none"
|
||||
/>
|
||||
{showPercentage && (
|
||||
<PercentageText x={x + width / 2} y={y} percentage={currentBarPercentage} className={textClassName} />
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
});
|
||||
|
||||
// Shape Variants
|
||||
/**
|
||||
* Factory function to create shape variants with consistent props
|
||||
* @param Component - The base component to render
|
||||
* @param factoryProps - Additional props to pass to the component
|
||||
* @returns A function that creates the shape with proper props
|
||||
*/
|
||||
const createShapeVariant =
|
||||
(Component: React.ComponentType<TBarProps>, factoryProps?: Partial<TBarProps>) =>
|
||||
(shapeProps: TShapeProps, bar: TBarItem<string>, stackKeys: string[]): JSX.Element => {
|
||||
const showTopBorderRadius = bar.showTopBorderRadius?.(shapeProps.dataKey, shapeProps.payload);
|
||||
const showBottomBorderRadius = bar.showBottomBorderRadius?.(shapeProps.dataKey, shapeProps.payload);
|
||||
|
||||
return (
|
||||
<Component
|
||||
{...shapeProps}
|
||||
fill={typeof bar.fill === "function" ? bar.fill(shapeProps.payload) : bar.fill}
|
||||
stackKeys={stackKeys}
|
||||
textClassName={bar.textClassName}
|
||||
showPercentage={bar.showPercentage}
|
||||
showTopBorderRadius={!!showTopBorderRadius}
|
||||
showBottomBorderRadius={!!showBottomBorderRadius}
|
||||
{...factoryProps}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const barShapeVariants: Record<
|
||||
TBarChartShapeVariant,
|
||||
(props: TShapeProps, bar: TBarItem<string>, stackKeys: string[]) => JSX.Element
|
||||
> = {
|
||||
bar: createShapeVariant(CustomBar), // Standard bar with rounded corners
|
||||
lollipop: createShapeVariant(CustomBarLollipop), // Line with circle at top
|
||||
"lollipop-dotted": createShapeVariant(CustomBarLollipop, { dotted: true }), // Dotted line lollipop variant
|
||||
};
|
||||
|
||||
// Display names
|
||||
CustomBar.displayName = "CustomBar";
|
||||
CustomBarLollipop.displayName = "CustomBarLollipop";
|
||||
|
||||
@@ -19,7 +19,7 @@ import { TBarChartProps } from "@plane/types";
|
||||
import { getLegendProps } from "../components/legend";
|
||||
import { CustomXAxisTick, CustomYAxisTick } from "../components/tick";
|
||||
import { CustomTooltip } from "../components/tooltip";
|
||||
import { CustomBar } from "./bar";
|
||||
import { barShapeVariants } from "./bar";
|
||||
|
||||
export const BarChart = React.memo(<K extends string, T extends string>(props: TBarChartProps<K, T>) => {
|
||||
const {
|
||||
@@ -36,6 +36,7 @@ export const BarChart = React.memo(<K extends string, T extends string>(props: T
|
||||
y: 10,
|
||||
},
|
||||
showTooltip = true,
|
||||
customTooltipContent,
|
||||
} = props;
|
||||
// states
|
||||
const [activeBar, setActiveBar] = useState<string | null>(null);
|
||||
@@ -66,20 +67,8 @@ export const BarChart = React.memo(<K extends string, T extends string>(props: T
|
||||
stackId={bar.stackId}
|
||||
opacity={!!activeLegend && activeLegend !== bar.key ? 0.1 : 1}
|
||||
shape={(shapeProps: any) => {
|
||||
const showTopBorderRadius = bar.showTopBorderRadius?.(shapeProps.dataKey, shapeProps.payload);
|
||||
const showBottomBorderRadius = bar.showBottomBorderRadius?.(shapeProps.dataKey, shapeProps.payload);
|
||||
|
||||
return (
|
||||
<CustomBar
|
||||
{...shapeProps}
|
||||
fill={typeof bar.fill === "function" ? bar.fill(shapeProps.payload) : bar.fill}
|
||||
stackKeys={stackKeys}
|
||||
textClassName={bar.textClassName}
|
||||
showPercentage={bar.showPercentage}
|
||||
showTopBorderRadius={!!showTopBorderRadius}
|
||||
showBottomBorderRadius={!!showBottomBorderRadius}
|
||||
/>
|
||||
);
|
||||
const shapeVariant = barShapeVariants[bar.shapeVariant ?? "bar"];
|
||||
return shapeVariant(shapeProps, bar, stackKeys);
|
||||
}}
|
||||
className="[&_path]:transition-opacity [&_path]:duration-200"
|
||||
onMouseEnter={() => setActiveBar(bar.key)}
|
||||
@@ -150,17 +139,20 @@ export const BarChart = React.memo(<K extends string, T extends string>(props: T
|
||||
wrapperStyle={{
|
||||
pointerEvents: "auto",
|
||||
}}
|
||||
content={({ active, label, payload }) => (
|
||||
<CustomTooltip
|
||||
active={active}
|
||||
label={label}
|
||||
payload={payload}
|
||||
activeKey={activeBar}
|
||||
itemKeys={stackKeys}
|
||||
itemLabels={stackLabels}
|
||||
itemDotColors={stackDotColors}
|
||||
/>
|
||||
)}
|
||||
content={({ active, label, payload }) => {
|
||||
if (customTooltipContent) return customTooltipContent({ active, label, payload });
|
||||
return (
|
||||
<CustomTooltip
|
||||
active={active}
|
||||
label={label}
|
||||
payload={payload}
|
||||
activeKey={activeBar}
|
||||
itemKeys={stackKeys}
|
||||
itemLabels={stackLabels}
|
||||
itemDotColors={stackDotColors}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{renderBars}
|
||||
|
||||
Vendored
+3
@@ -53,6 +53,8 @@ type TChartProps<K extends string, T extends string> = {
|
||||
// Bar Chart
|
||||
// ============================================================
|
||||
|
||||
export type TBarChartShapeVariant = "bar" | "lollipop" | "lollipop-dotted";
|
||||
|
||||
export type TBarItem<T extends string> = {
|
||||
key: T;
|
||||
label: string;
|
||||
@@ -62,6 +64,7 @@ export type TBarItem<T extends string> = {
|
||||
stackId: string;
|
||||
showTopBorderRadius?: (barKey: string, payload: any) => boolean;
|
||||
showBottomBorderRadius?: (barKey: string, payload: any) => boolean;
|
||||
shapeVariant?: TBarChartShapeVariant;
|
||||
};
|
||||
|
||||
export type TBarChartProps<K extends string, T extends string> = TChartProps<K, T> & {
|
||||
|
||||
Binary file not shown.
+1
-2
@@ -1,10 +1,9 @@
|
||||
"use client";
|
||||
// components
|
||||
import { AppHeader, ContentWrapper } from "@/components/core";
|
||||
// plane web components
|
||||
import { WorkspaceAnalyticsHeader } from "./header";
|
||||
|
||||
export default function WorkspaceAnalyticsLayout({ children }: { children: React.ReactNode }) {
|
||||
export default function WorkspaceAnalyticsTabLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
<AppHeader header={<WorkspaceAnalyticsHeader />} />
|
||||
+30
-16
@@ -2,11 +2,11 @@
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useRouter } from "next/navigation";
|
||||
// plane package imports
|
||||
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Tabs } from "@plane/ui";
|
||||
import { type TabItem, Tabs } from "@plane/ui";
|
||||
// components
|
||||
import AnalyticsFilterActions from "@/components/analytics/analytics-filter-actions";
|
||||
import { PageHead } from "@/components/core";
|
||||
@@ -16,23 +16,33 @@ import { useCommandPalette, useEventTracker, useProject, useUserPermissions, use
|
||||
import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path";
|
||||
import { getAnalyticsTabs } from "@/plane-web/components/analytics/tabs";
|
||||
|
||||
const AnalyticsPage = observer(() => {
|
||||
type Props = {
|
||||
params: {
|
||||
tabId: string;
|
||||
workspaceSlug: string;
|
||||
};
|
||||
};
|
||||
|
||||
const AnalyticsPage = observer((props: Props) => {
|
||||
// props
|
||||
const { params } = props;
|
||||
const { tabId } = params;
|
||||
|
||||
// hooks
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
// plane imports
|
||||
const { t } = useTranslation();
|
||||
|
||||
// store hooks
|
||||
const { toggleCreateProjectModal } = useCommandPalette();
|
||||
const { setTrackElement } = useEventTracker();
|
||||
const { workspaceProjectIds, loader } = useProject();
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
|
||||
// helper hooks
|
||||
const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/onboarding/analytics" });
|
||||
// derived values
|
||||
const pageTitle = currentWorkspace?.name
|
||||
? t(`workspace_analytics.page_label`, { workspace: currentWorkspace?.name })
|
||||
: undefined;
|
||||
|
||||
// permissions
|
||||
const canPerformEmptyStateActions = allowPermissions(
|
||||
@@ -40,22 +50,25 @@ const AnalyticsPage = observer(() => {
|
||||
EUserPermissionsLevel.WORKSPACE
|
||||
);
|
||||
|
||||
// derived values
|
||||
const pageTitle = currentWorkspace?.name
|
||||
? t(`workspace_analytics.page_label`, { workspace: currentWorkspace?.name })
|
||||
: undefined;
|
||||
const ANALYTICS_TABS = useMemo(() => getAnalyticsTabs(t), [t]);
|
||||
|
||||
const tabs = useMemo(
|
||||
const tabs: TabItem[] = useMemo(
|
||||
() =>
|
||||
ANALYTICS_TABS.map((tab) => ({
|
||||
key: tab.key,
|
||||
label: tab.label,
|
||||
content: <tab.content />,
|
||||
onClick: () => {
|
||||
router.push(`?tab=${tab.key}`);
|
||||
router.push(`/${currentWorkspace?.slug}/analytics/${tab.key}`);
|
||||
},
|
||||
isDisabled: tab.isDisabled,
|
||||
disabled: tab.isDisabled,
|
||||
})),
|
||||
[ANALYTICS_TABS, router]
|
||||
[ANALYTICS_TABS, router, currentWorkspace?.slug]
|
||||
);
|
||||
const defaultTab = searchParams.get("tab") || ANALYTICS_TABS[0].key;
|
||||
const defaultTab = tabId || ANALYTICS_TABS[0].key;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -70,8 +83,9 @@ const AnalyticsPage = observer(() => {
|
||||
defaultTab={defaultTab}
|
||||
size="md"
|
||||
tabListContainerClassName="px-6 py-2 border-b border-custom-border-200 flex items-center justify-between"
|
||||
tabListClassName="my-2 max-w-36"
|
||||
tabPanelClassName="h-full w-full overflow-hidden overflow-y-auto"
|
||||
tabListClassName="my-2 w-auto"
|
||||
tabClassName="px-3"
|
||||
tabPanelClassName="h-full overflow-hidden overflow-y-auto px-2"
|
||||
storeInLocalStorage={false}
|
||||
actions={<AnalyticsFilterActions />}
|
||||
/>
|
||||
@@ -7,7 +7,7 @@ import { Home } from "lucide-react";
|
||||
import githubBlackImage from "/public/logos/github-black.png";
|
||||
import githubWhiteImage from "/public/logos/github-white.png";
|
||||
// ui
|
||||
import { GITHUB_REDIRECTED } from "@plane/constants";
|
||||
import { GITHUB_REDIRECTED_TRACKER_EVENT } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Breadcrumbs, Header } from "@plane/ui";
|
||||
// components
|
||||
@@ -39,7 +39,7 @@ export const WorkspaceDashboardHeader = () => {
|
||||
<Header.RightItem>
|
||||
<a
|
||||
onClick={() =>
|
||||
captureEvent(GITHUB_REDIRECTED, {
|
||||
captureEvent(GITHUB_REDIRECTED_TRACKER_EVENT, {
|
||||
element: "navbar",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { Search } from "lucide-react";
|
||||
// types
|
||||
import { MEMBER_INVITED, EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
||||
import { EUserPermissions, EUserPermissionsLevel, MEMBER_TRACKER_EVENTS } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { IWorkspaceBulkInviteFormData } from "@plane/types";
|
||||
// ui
|
||||
@@ -52,7 +52,7 @@ const WorkspaceMembersSettingsPage = observer(() => {
|
||||
return inviteMembersToWorkspace(workspaceSlug.toString(), data)
|
||||
.then(() => {
|
||||
setInviteModal(false);
|
||||
captureEvent(MEMBER_INVITED, {
|
||||
captureEvent(MEMBER_TRACKER_EVENTS.invite, {
|
||||
emails: [
|
||||
...data.emails.map((email) => ({
|
||||
email: email.email,
|
||||
@@ -70,7 +70,7 @@ const WorkspaceMembersSettingsPage = observer(() => {
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
captureEvent(MEMBER_INVITED, {
|
||||
captureEvent(MEMBER_TRACKER_EVENTS.invite, {
|
||||
emails: [
|
||||
...data.emails.map((email) => ({
|
||||
email: email.email,
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Controller, useForm } from "react-hook-form";
|
||||
// icons
|
||||
import { CircleCheck } from "lucide-react";
|
||||
// plane imports
|
||||
import { FORGOT_PASS_LINK, NAVIGATE_TO_SIGNUP } from "@plane/constants";
|
||||
import { AUTH_TRACKER_EVENTS } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Button, Input, TOAST_TYPE, getButtonStyling, setToast } from "@plane/ui";
|
||||
import { cn, checkEmailValidity } from "@plane/utils";
|
||||
@@ -71,7 +71,7 @@ const ForgotPasswordPage = observer(() => {
|
||||
email: formData.email,
|
||||
})
|
||||
.then(() => {
|
||||
captureEvent(FORGOT_PASS_LINK, {
|
||||
captureEvent(AUTH_TRACKER_EVENTS.forgot_password, {
|
||||
state: "SUCCESS",
|
||||
});
|
||||
setToast({
|
||||
@@ -82,7 +82,7 @@ const ForgotPasswordPage = observer(() => {
|
||||
setResendCodeTimer(30);
|
||||
})
|
||||
.catch((err) => {
|
||||
captureEvent(FORGOT_PASS_LINK, {
|
||||
captureEvent(AUTH_TRACKER_EVENTS.forgot_password, {
|
||||
state: "FAILED",
|
||||
});
|
||||
setToast({
|
||||
@@ -120,7 +120,7 @@ const ForgotPasswordPage = observer(() => {
|
||||
{t("auth.common.new_to_plane")}
|
||||
<Link
|
||||
href="/"
|
||||
onClick={() => captureEvent(NAVIGATE_TO_SIGNUP, {})}
|
||||
onClick={() => captureEvent(AUTH_TRACKER_EVENTS.navigate.sign_up, {})}
|
||||
className="font-semibold text-custom-primary-100 hover:underline"
|
||||
>
|
||||
{t("auth.common.create_account")}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useTheme } from "next-themes";
|
||||
import useSWR, { mutate } from "swr";
|
||||
import { CheckCircle2 } from "lucide-react";
|
||||
// plane imports
|
||||
import { ROLE, MEMBER_ACCEPTED, EUserPermissions } from "@plane/constants";
|
||||
import { ROLE, EUserPermissions, MEMBER_TRACKER_EVENTS } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// types
|
||||
import type { IWorkspaceMemberInvitation } from "@plane/types";
|
||||
@@ -86,7 +86,7 @@ const UserInvitationsPage = observer(() => {
|
||||
const invitation = invitations?.find((i) => i.id === firstInviteId);
|
||||
const redirectWorkspace = invitations?.find((i) => i.id === firstInviteId)?.workspace;
|
||||
joinWorkspaceMetricGroup(redirectWorkspace?.id);
|
||||
captureEvent(MEMBER_ACCEPTED, {
|
||||
captureEvent(MEMBER_TRACKER_EVENTS.accept, {
|
||||
member_id: invitation?.id,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-asserted-optional-chain
|
||||
role: getUserRole((invitation?.role as unknown as EUserPermissions)!),
|
||||
@@ -112,7 +112,7 @@ const UserInvitationsPage = observer(() => {
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
captureEvent(MEMBER_ACCEPTED, {
|
||||
captureEvent(MEMBER_TRACKER_EVENTS.accept, {
|
||||
project_id: undefined,
|
||||
accepted_from: "App",
|
||||
state: "FAILED",
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useEffect, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import useSWR from "swr";
|
||||
// types
|
||||
import { USER_ONBOARDING_COMPLETED } from "@plane/constants";
|
||||
import { USER_TRACKER_EVENTS } from "@plane/constants";
|
||||
import { TOnboardingSteps, TUserProfile } from "@plane/types";
|
||||
// ui
|
||||
import { TOAST_TYPE, setToast } from "@plane/ui";
|
||||
@@ -73,7 +73,7 @@ const OnboardingPage = observer(() => {
|
||||
|
||||
await finishUserOnboarding()
|
||||
.then(() => {
|
||||
captureEvent(USER_ONBOARDING_COMPLETED, {
|
||||
captureEvent(USER_TRACKER_EVENTS.onboarding_complete, {
|
||||
email: user.email,
|
||||
user_id: user.id,
|
||||
status: "SUCCESS",
|
||||
|
||||
@@ -6,7 +6,7 @@ import Link from "next/link";
|
||||
// ui
|
||||
import { useTheme } from "next-themes";
|
||||
// components
|
||||
import { NAVIGATE_TO_SIGNIN } from "@plane/constants";
|
||||
import { AUTH_TRACKER_EVENTS } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { AuthRoot } from "@/components/account";
|
||||
// constants
|
||||
@@ -54,7 +54,7 @@ const SignInPage = observer(() => {
|
||||
{t("auth.common.already_have_an_account")}
|
||||
<Link
|
||||
href="/"
|
||||
onClick={() => captureEvent(NAVIGATE_TO_SIGNIN, {})}
|
||||
onClick={() => captureEvent(AUTH_TRACKER_EVENTS.navigate.sign_in, {})}
|
||||
className="font-semibold text-custom-primary-100 hover:underline"
|
||||
>
|
||||
{t("auth.common.login")}
|
||||
|
||||
@@ -6,7 +6,7 @@ import Link from "next/link";
|
||||
// ui
|
||||
import { useTheme } from "next-themes";
|
||||
// components
|
||||
import { NAVIGATE_TO_SIGNUP } from "@plane/constants";
|
||||
import { AUTH_TRACKER_EVENTS } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { AuthRoot } from "@/components/account";
|
||||
import { PageHead } from "@/components/core";
|
||||
@@ -63,7 +63,7 @@ const HomePage = observer(() => {
|
||||
{t("auth.common.new_to_plane")}
|
||||
<Link
|
||||
href="/sign-up"
|
||||
onClick={() => captureEvent(NAVIGATE_TO_SIGNUP, {})}
|
||||
onClick={() => captureEvent(AUTH_TRACKER_EVENTS.navigate.sign_up, {})}
|
||||
className="font-semibold text-custom-primary-100 hover:underline"
|
||||
>
|
||||
{t("auth.common.create_account")}
|
||||
|
||||
@@ -93,7 +93,9 @@ export const commandGroups: TCommandGroups = {
|
||||
path: (page: IWorkspacePageSearchResult, projectId: string | undefined) => {
|
||||
let redirectProjectId = page?.project_ids?.[0];
|
||||
if (!!projectId && page?.project_ids?.includes(projectId)) redirectProjectId = projectId;
|
||||
return `/${page?.workspace__slug}/projects/${redirectProjectId}/pages/${page?.id}`;
|
||||
return redirectProjectId
|
||||
? `/${page?.workspace__slug}/projects/${redirectProjectId}/pages/${page?.id}`
|
||||
: `/${page?.workspace__slug}/pages/${page?.id}`;
|
||||
},
|
||||
title: "Pages",
|
||||
},
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useState, FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { FormProvider, useForm } from "react-hook-form";
|
||||
import { PROJECT_CREATED, DEFAULT_PROJECT_FORM_VALUES } from "@plane/constants";
|
||||
import { DEFAULT_PROJECT_FORM_VALUES, PROJECT_TRACKER_EVENTS } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// ui
|
||||
import { setToast, TOAST_TYPE } from "@plane/ui";
|
||||
@@ -75,7 +75,7 @@ export const CreateProjectForm: FC<TCreateProjectFormProps> = observer((props) =
|
||||
state: "SUCCESS",
|
||||
};
|
||||
captureProjectEvent({
|
||||
eventName: PROJECT_CREATED,
|
||||
eventName: PROJECT_TRACKER_EVENTS.create,
|
||||
payload: newPayload,
|
||||
});
|
||||
setToast({
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { RootStore } from "@/plane-web/store/root.store";
|
||||
import { CoreEventTrackerStore, ICoreEventTrackerStore } from "@/store/event-tracker.store";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
||||
export interface IEventTrackerStore extends ICoreEventTrackerStore {}
|
||||
|
||||
export class EventTrackerStore extends CoreEventTrackerStore implements IEventTrackerStore {
|
||||
constructor(_rootStore: RootStore) {
|
||||
super(_rootStore);
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import Link from "next/link";
|
||||
// icons
|
||||
import { Eye, EyeOff, Info, X, XCircle } from "lucide-react";
|
||||
// plane imports
|
||||
import { FORGOT_PASSWORD, SIGN_IN_WITH_CODE, SIGN_IN_WITH_PASSWORD, SIGN_UP_WITH_PASSWORD, API_BASE_URL, E_PASSWORD_STRENGTH } from "@plane/constants";
|
||||
import { API_BASE_URL, E_PASSWORD_STRENGTH, AUTH_TRACKER_EVENTS } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Button, Input, Spinner } from "@plane/ui";
|
||||
import { getPasswordStrength } from "@plane/utils";
|
||||
@@ -77,7 +77,7 @@ export const AuthPasswordForm: React.FC<Props> = observer((props: Props) => {
|
||||
|
||||
const redirectToUniqueCodeSignIn = async () => {
|
||||
handleAuthStep(EAuthSteps.UNIQUE_CODE);
|
||||
captureEvent(SIGN_IN_WITH_CODE);
|
||||
captureEvent(AUTH_TRACKER_EVENTS.sign_in_with_code);
|
||||
};
|
||||
|
||||
const passwordSupport =
|
||||
@@ -85,7 +85,7 @@ export const AuthPasswordForm: React.FC<Props> = observer((props: Props) => {
|
||||
<div className="w-full">
|
||||
{isSMTPConfigured ? (
|
||||
<Link
|
||||
onClick={() => captureEvent(FORGOT_PASSWORD)}
|
||||
onClick={() => captureEvent(AUTH_TRACKER_EVENTS.forgot_password)}
|
||||
href={`/accounts/forgot-password?email=${encodeURIComponent(email)}`}
|
||||
className="text-xs font-medium text-custom-primary-100"
|
||||
>
|
||||
@@ -154,7 +154,11 @@ export const AuthPasswordForm: React.FC<Props> = observer((props: Props) => {
|
||||
: true;
|
||||
if (isPasswordValid) {
|
||||
setIsSubmitting(true);
|
||||
captureEvent(mode === EAuthModes.SIGN_IN ? SIGN_IN_WITH_PASSWORD : SIGN_UP_WITH_PASSWORD);
|
||||
captureEvent(
|
||||
mode === EAuthModes.SIGN_IN
|
||||
? AUTH_TRACKER_EVENTS.sign_in_with_password
|
||||
: AUTH_TRACKER_EVENTS.sign_up_with_password
|
||||
);
|
||||
if (formRef.current) formRef.current.submit(); // Manually submit the form if the condition is met
|
||||
} else {
|
||||
setBannerMessage(true);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { CircleCheck, XCircle } from "lucide-react";
|
||||
import { CODE_VERIFIED, API_BASE_URL } from "@plane/constants";
|
||||
import { API_BASE_URL, AUTH_TRACKER_EVENTS } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Button, Input, Spinner } from "@plane/ui";
|
||||
// constants
|
||||
@@ -84,7 +84,7 @@ export const AuthUniqueCodeForm: React.FC<TAuthUniqueCodeForm> = (props) => {
|
||||
action={`${API_BASE_URL}/auth/${mode === EAuthModes.SIGN_IN ? "magic-sign-in" : "magic-sign-up"}/`}
|
||||
onSubmit={() => {
|
||||
setIsSubmitting(true);
|
||||
captureEvent(CODE_VERIFIED, {
|
||||
captureEvent(AUTH_TRACKER_EVENTS.code_verify, {
|
||||
state: "SUCCESS",
|
||||
first_time: !isExistingEmail,
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@ import { observer } from "mobx-react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { ArrowRight, ChevronRight } from "lucide-react";
|
||||
// Plane Imports
|
||||
import { CYCLE_STATUS, CYCLE_UPDATED, EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
||||
import { CYCLE_TRACKER_EVENTS, CYCLE_STATUS, EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { ICycle } from "@plane/types";
|
||||
import { setToast, TOAST_TYPE } from "@plane/ui";
|
||||
@@ -67,7 +67,7 @@ export const CycleSidebarHeader: FC<Props> = observer((props) => {
|
||||
await updateCycleDetails(workspaceSlug.toString(), projectId.toString(), cycleDetails.id.toString(), data)
|
||||
.then((res) => {
|
||||
captureCycleEvent({
|
||||
eventName: CYCLE_UPDATED,
|
||||
eventName: CYCLE_TRACKER_EVENTS.update,
|
||||
payload: {
|
||||
...res,
|
||||
changed_properties: [changedProperty],
|
||||
@@ -79,7 +79,7 @@ export const CycleSidebarHeader: FC<Props> = observer((props) => {
|
||||
|
||||
.catch(() => {
|
||||
captureCycleEvent({
|
||||
eventName: CYCLE_UPDATED,
|
||||
eventName: CYCLE_TRACKER_EVENTS.update,
|
||||
payload: {
|
||||
...data,
|
||||
element: "Right side-peek",
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams, useSearchParams } from "next/navigation";
|
||||
// types
|
||||
import { PROJECT_ERROR_MESSAGES, CYCLE_DELETED } from "@plane/constants";
|
||||
import { PROJECT_ERROR_MESSAGES, CYCLE_TRACKER_EVENTS } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { ICycle } from "@plane/types";
|
||||
// ui
|
||||
@@ -50,7 +50,7 @@ export const CycleDeleteModal: React.FC<ICycleDelete> = observer((props) => {
|
||||
message: "Cycle deleted successfully.",
|
||||
});
|
||||
captureCycleEvent({
|
||||
eventName: CYCLE_DELETED,
|
||||
eventName: CYCLE_TRACKER_EVENTS.delete,
|
||||
payload: { ...cycle, state: "SUCCESS" },
|
||||
});
|
||||
})
|
||||
@@ -65,7 +65,7 @@ export const CycleDeleteModal: React.FC<ICycleDelete> = observer((props) => {
|
||||
message: currentError.i18n_message && t(currentError.i18n_message),
|
||||
});
|
||||
captureCycleEvent({
|
||||
eventName: CYCLE_DELETED,
|
||||
eventName: CYCLE_TRACKER_EVENTS.delete,
|
||||
payload: { ...cycle, state: "FAILED" },
|
||||
});
|
||||
})
|
||||
|
||||
@@ -6,13 +6,7 @@ import { useParams, usePathname, useSearchParams } from "next/navigation";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { Eye, Users, ArrowRight, CalendarDays } from "lucide-react";
|
||||
// types
|
||||
import {
|
||||
CYCLE_FAVORITED,
|
||||
CYCLE_UNFAVORITED,
|
||||
EUserPermissions,
|
||||
EUserPermissionsLevel,
|
||||
IS_FAVORITE_MENU_OPEN,
|
||||
} from "@plane/constants";
|
||||
import { CYCLE_TRACKER_EVENTS, EUserPermissions, EUserPermissionsLevel, IS_FAVORITE_MENU_OPEN } from "@plane/constants";
|
||||
import { useLocalStorage } from "@plane/hooks";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { ICycle, TCycleGroups } from "@plane/types";
|
||||
@@ -62,7 +56,7 @@ export const CycleListItemAction: FC<Props> = observer((props) => {
|
||||
const searchParams = useSearchParams();
|
||||
const pathname = usePathname();
|
||||
// store hooks
|
||||
const { addCycleToFavorites, removeCycleFromFavorites, updateCycleDetails } = useCycle();
|
||||
const { addCycleToFavorites, removeCycleFromFavorites } = useCycle();
|
||||
const { captureEvent } = useEventTracker();
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
|
||||
@@ -75,7 +69,7 @@ export const CycleListItemAction: FC<Props> = observer((props) => {
|
||||
const { getUserDetails } = useMember();
|
||||
|
||||
// form
|
||||
const { control, reset, getValues } = useForm({
|
||||
const { reset } = useForm({
|
||||
defaultValues,
|
||||
});
|
||||
|
||||
@@ -107,7 +101,7 @@ export const CycleListItemAction: FC<Props> = observer((props) => {
|
||||
const addToFavoritePromise = addCycleToFavorites(workspaceSlug?.toString(), projectId.toString(), cycleId).then(
|
||||
() => {
|
||||
if (!isFavoriteMenuOpen) toggleFavoriteMenu(true);
|
||||
captureEvent(CYCLE_FAVORITED, {
|
||||
captureEvent(CYCLE_TRACKER_EVENTS.favorite, {
|
||||
cycle_id: cycleId,
|
||||
element: "List layout",
|
||||
state: "SUCCESS",
|
||||
@@ -137,7 +131,7 @@ export const CycleListItemAction: FC<Props> = observer((props) => {
|
||||
projectId.toString(),
|
||||
cycleId
|
||||
).then(() => {
|
||||
captureEvent(CYCLE_UNFAVORITED, {
|
||||
captureEvent(CYCLE_TRACKER_EVENTS.unfavorite, {
|
||||
cycle_id: cycleId,
|
||||
element: "List layout",
|
||||
state: "SUCCESS",
|
||||
|
||||
@@ -4,7 +4,7 @@ import React, { useEffect, useState } from "react";
|
||||
import { format } from "date-fns";
|
||||
import { mutate } from "swr";
|
||||
// types
|
||||
import { CYCLE_CREATED, CYCLE_UPDATED } from "@plane/constants";
|
||||
import { CYCLE_TRACKER_EVENTS } from "@plane/constants";
|
||||
import type { CycleDateCheckData, ICycle, TCycleTabOptions } from "@plane/types";
|
||||
// ui
|
||||
import { EModalPosition, EModalWidth, ModalCore, TOAST_TYPE, setToast } from "@plane/ui";
|
||||
@@ -64,7 +64,7 @@ export const CycleCreateUpdateModal: React.FC<CycleModalProps> = (props) => {
|
||||
message: "Cycle created successfully.",
|
||||
});
|
||||
captureCycleEvent({
|
||||
eventName: CYCLE_CREATED,
|
||||
eventName: CYCLE_TRACKER_EVENTS.create,
|
||||
payload: { ...res, state: "SUCCESS" },
|
||||
});
|
||||
})
|
||||
@@ -75,7 +75,7 @@ export const CycleCreateUpdateModal: React.FC<CycleModalProps> = (props) => {
|
||||
message: err?.detail ?? "Error in creating cycle. Please try again.",
|
||||
});
|
||||
captureCycleEvent({
|
||||
eventName: CYCLE_CREATED,
|
||||
eventName: CYCLE_TRACKER_EVENTS.create,
|
||||
payload: { ...payload, state: "FAILED" },
|
||||
});
|
||||
});
|
||||
@@ -89,7 +89,7 @@ export const CycleCreateUpdateModal: React.FC<CycleModalProps> = (props) => {
|
||||
.then((res) => {
|
||||
const changed_properties = Object.keys(dirtyFields);
|
||||
captureCycleEvent({
|
||||
eventName: CYCLE_UPDATED,
|
||||
eventName: CYCLE_TRACKER_EVENTS.update,
|
||||
payload: { ...res, changed_properties: changed_properties, state: "SUCCESS" },
|
||||
});
|
||||
setToast({
|
||||
@@ -100,7 +100,7 @@ export const CycleCreateUpdateModal: React.FC<CycleModalProps> = (props) => {
|
||||
})
|
||||
.catch((err) => {
|
||||
captureCycleEvent({
|
||||
eventName: CYCLE_UPDATED,
|
||||
eventName: CYCLE_TRACKER_EVENTS.update,
|
||||
payload: { ...payload, state: "FAILED" },
|
||||
});
|
||||
setToast({
|
||||
|
||||
@@ -2,7 +2,7 @@ import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// components
|
||||
import useSWR from "swr";
|
||||
import { PRODUCT_TOUR_COMPLETED } from "@plane/constants";
|
||||
import { PRODUCT_TOUR_TRACKER_EVENTS } from "@plane/constants";
|
||||
import { ContentWrapper } from "@plane/ui";
|
||||
import { cn } from "@plane/utils";
|
||||
import { TourRoot } from "@/components/onboarding";
|
||||
@@ -38,7 +38,7 @@ export const WorkspaceHomeView = observer(() => {
|
||||
const handleTourCompleted = () => {
|
||||
updateTourCompleted()
|
||||
.then(() => {
|
||||
captureEvent(PRODUCT_TOUR_COMPLETED, {
|
||||
captureEvent(PRODUCT_TOUR_TRACKER_EVENTS.complete, {
|
||||
user_id: currentUser?.id,
|
||||
state: "SUCCESS",
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Dispatch, SetStateAction, useEffect, useMemo, useRef } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { usePathname } from "next/navigation";
|
||||
// plane imports
|
||||
import { EInboxIssueSource, ISSUE_ARCHIVED, ISSUE_DELETED } from "@plane/constants";
|
||||
import { EInboxIssueSource, WORK_ITEM_TRACKER_EVENTS } from "@plane/constants";
|
||||
import { EditorRefApi } from "@plane/editor";
|
||||
import { TIssue, TNameDescriptionLoader } from "@plane/types";
|
||||
import { Loader, TOAST_TYPE, setToast } from "@plane/ui";
|
||||
@@ -105,7 +105,7 @@ export const InboxIssueMainContent: React.FC<Props> = observer((props) => {
|
||||
message: "Work item deleted successfully",
|
||||
});
|
||||
captureIssueEvent({
|
||||
eventName: ISSUE_DELETED,
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.delete,
|
||||
payload: { id: _issueId, state: "SUCCESS", element: "Work item detail page" },
|
||||
path: pathname,
|
||||
});
|
||||
@@ -117,7 +117,7 @@ export const InboxIssueMainContent: React.FC<Props> = observer((props) => {
|
||||
message: "Work item delete failed",
|
||||
});
|
||||
captureIssueEvent({
|
||||
eventName: ISSUE_DELETED,
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.delete,
|
||||
payload: { id: _issueId, state: "FAILED", element: "Work item detail page" },
|
||||
path: pathname,
|
||||
});
|
||||
@@ -156,14 +156,14 @@ export const InboxIssueMainContent: React.FC<Props> = observer((props) => {
|
||||
try {
|
||||
await archiveIssue(workspaceSlug, projectId, issueId);
|
||||
captureIssueEvent({
|
||||
eventName: ISSUE_ARCHIVED,
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.archive,
|
||||
payload: { id: issueId, state: "SUCCESS", element: "Work item details page" },
|
||||
path: pathname,
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("Error in archiving issue:", error);
|
||||
captureIssueEvent({
|
||||
eventName: ISSUE_ARCHIVED,
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.archive,
|
||||
payload: { id: issueId, state: "FAILED", element: "Work item details page" },
|
||||
path: pathname,
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@ import { FC, FormEvent, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { usePathname } from "next/navigation";
|
||||
// plane imports
|
||||
import { ETabIndices, ISSUE_CREATED } from "@plane/constants";
|
||||
import { ETabIndices, WORK_ITEM_TRACKER_EVENTS } from "@plane/constants";
|
||||
import { EditorRefApi } from "@plane/editor";
|
||||
// types
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
@@ -168,7 +168,7 @@ export const InboxIssueCreateRoot: FC<TInboxIssueCreateRoot> = observer((props)
|
||||
setFormData(defaultIssueData);
|
||||
}
|
||||
captureIssueEvent({
|
||||
eventName: ISSUE_CREATED,
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.create,
|
||||
payload: {
|
||||
...formData,
|
||||
state: "SUCCESS",
|
||||
@@ -185,7 +185,7 @@ export const InboxIssueCreateRoot: FC<TInboxIssueCreateRoot> = observer((props)
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
captureIssueEvent({
|
||||
eventName: ISSUE_CREATED,
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.create,
|
||||
payload: {
|
||||
...formData,
|
||||
state: "FAILED",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { useMemo } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
// plane imports
|
||||
import { EIssueServiceType, ISSUE_DELETED, ISSUE_UPDATED } from "@plane/constants";
|
||||
import { EIssueServiceType, WORK_ITEM_TRACKER_EVENTS } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { TIssue, TIssueServiceType } from "@plane/types";
|
||||
import { TOAST_TYPE, setToast } from "@plane/ui";
|
||||
@@ -41,7 +41,7 @@ export const useRelationOperations = (
|
||||
try {
|
||||
await updateIssue(workspaceSlug, projectId, issueId, data);
|
||||
captureIssueEvent({
|
||||
eventName: ISSUE_UPDATED,
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.update,
|
||||
payload: { ...data, issueId, state: "SUCCESS", element: "Issue detail page" },
|
||||
updates: {
|
||||
changed_property: Object.keys(data).join(","),
|
||||
@@ -56,7 +56,7 @@ export const useRelationOperations = (
|
||||
});
|
||||
} catch {
|
||||
captureIssueEvent({
|
||||
eventName: ISSUE_UPDATED,
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.update,
|
||||
payload: { state: "FAILED", element: "Issue detail page" },
|
||||
updates: {
|
||||
changed_property: Object.keys(data).join(","),
|
||||
@@ -75,14 +75,14 @@ export const useRelationOperations = (
|
||||
try {
|
||||
return removeIssue(workspaceSlug, projectId, issueId).then(() => {
|
||||
captureIssueEvent({
|
||||
eventName: ISSUE_DELETED,
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.delete,
|
||||
payload: { id: issueId, state: "SUCCESS", element: "Issue detail page" },
|
||||
path: pathname,
|
||||
});
|
||||
});
|
||||
} catch {
|
||||
captureIssueEvent({
|
||||
eventName: ISSUE_DELETED,
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.delete,
|
||||
payload: { id: issueId, state: "FAILED", element: "Issue detail page" },
|
||||
path: pathname,
|
||||
});
|
||||
|
||||
+73
-5
@@ -1,12 +1,21 @@
|
||||
// plane imports
|
||||
import { SyntheticEvent } from "react";
|
||||
import { SyntheticEvent, useMemo } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { CalendarCheck2, CalendarClock } from "lucide-react";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { IIssueDisplayProperties, TIssue } from "@plane/types";
|
||||
import { getDate, renderFormattedPayloadDate } from "@plane/utils";
|
||||
import { getDate, renderFormattedPayloadDate, shouldHighlightIssueDueDate } from "@plane/utils";
|
||||
// components
|
||||
import { PriorityDropdown, MemberDropdown, StateDropdown, DateRangeDropdown } from "@/components/dropdowns";
|
||||
import {
|
||||
PriorityDropdown,
|
||||
MemberDropdown,
|
||||
StateDropdown,
|
||||
DateRangeDropdown,
|
||||
DateDropdown,
|
||||
} from "@/components/dropdowns";
|
||||
// hooks
|
||||
import { WithDisplayPropertiesHOC } from "@/components/issues/issue-layouts/properties/with-display-properties-HOC";
|
||||
import { useProjectState } from "@/hooks/store/use-project-state";
|
||||
|
||||
type Props = {
|
||||
workspaceSlug: string;
|
||||
@@ -27,6 +36,8 @@ type Props = {
|
||||
|
||||
export const SubIssuesListItemProperties: React.FC<Props> = observer((props) => {
|
||||
const { workspaceSlug, parentIssueId, issueId, disabled, updateSubIssue, displayProperties, issue } = props;
|
||||
const { t } = useTranslation();
|
||||
const { getStateById } = useProjectState();
|
||||
|
||||
const handleEventPropagation = (e: SyntheticEvent<HTMLDivElement>) => {
|
||||
e.stopPropagation();
|
||||
@@ -49,10 +60,22 @@ export const SubIssuesListItemProperties: React.FC<Props> = observer((props) =>
|
||||
}
|
||||
};
|
||||
|
||||
//derived values
|
||||
const stateDetails = useMemo(() => getStateById(issue.state_id), [getStateById, issue.state_id]);
|
||||
const shouldHighlight = useMemo(
|
||||
() => shouldHighlightIssueDueDate(issue.target_date, stateDetails?.group),
|
||||
[issue.target_date, stateDetails?.group]
|
||||
);
|
||||
// date range is enabled only when both dates are available and both dates are enabled
|
||||
const isDateRangeEnabled: boolean = Boolean(
|
||||
issue.start_date && issue.target_date && displayProperties?.start_date && displayProperties?.due_date
|
||||
);
|
||||
|
||||
if (!displayProperties) return <></>;
|
||||
|
||||
const maxDate = getDate(issue.target_date);
|
||||
maxDate?.setDate(maxDate.getDate());
|
||||
const minDate = getDate(issue.start_date);
|
||||
|
||||
return (
|
||||
<div className="relative flex items-center gap-2">
|
||||
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="state">
|
||||
@@ -104,7 +127,7 @@ export const SubIssuesListItemProperties: React.FC<Props> = observer((props) =>
|
||||
<WithDisplayPropertiesHOC
|
||||
displayProperties={displayProperties}
|
||||
displayPropertyKey={["start_date", "due_date"]}
|
||||
shouldRenderProperty={(properties) => !!(properties.start_date || properties.due_date)}
|
||||
shouldRenderProperty={() => isDateRangeEnabled}
|
||||
>
|
||||
<div className="h-5" onFocus={handleEventPropagation} onClick={handleEventPropagation}>
|
||||
<DateRangeDropdown
|
||||
@@ -122,6 +145,7 @@ export const SubIssuesListItemProperties: React.FC<Props> = observer((props) =>
|
||||
isClearable
|
||||
mergeDates
|
||||
buttonVariant={issue.start_date || issue.target_date ? "border-with-text" : "border-without-text"}
|
||||
buttonClassName={shouldHighlight ? "text-red-500" : ""}
|
||||
disabled={!disabled}
|
||||
showTooltip
|
||||
customTooltipHeading="Date Range"
|
||||
@@ -130,6 +154,50 @@ export const SubIssuesListItemProperties: React.FC<Props> = observer((props) =>
|
||||
</div>
|
||||
</WithDisplayPropertiesHOC>
|
||||
|
||||
{/* start date */}
|
||||
<WithDisplayPropertiesHOC
|
||||
displayProperties={displayProperties}
|
||||
displayPropertyKey="start_date"
|
||||
shouldRenderProperty={() => !isDateRangeEnabled}
|
||||
>
|
||||
<div className="h-5">
|
||||
<DateDropdown
|
||||
value={issue.start_date ?? null}
|
||||
onChange={handleStartDate}
|
||||
maxDate={maxDate}
|
||||
placeholder={t("common.order_by.start_date")}
|
||||
icon={<CalendarClock className="h-3 w-3 flex-shrink-0" />}
|
||||
buttonVariant={issue.start_date ? "border-with-text" : "border-without-text"}
|
||||
optionsClassName="z-30"
|
||||
disabled={!disabled}
|
||||
showTooltip
|
||||
/>
|
||||
</div>
|
||||
</WithDisplayPropertiesHOC>
|
||||
|
||||
{/* target/due date */}
|
||||
<WithDisplayPropertiesHOC
|
||||
displayProperties={displayProperties}
|
||||
displayPropertyKey="due_date"
|
||||
shouldRenderProperty={() => !isDateRangeEnabled}
|
||||
>
|
||||
<div className="h-5">
|
||||
<DateDropdown
|
||||
value={issue?.target_date ?? null}
|
||||
onChange={handleTargetDate}
|
||||
minDate={minDate}
|
||||
placeholder={t("common.order_by.due_date")}
|
||||
icon={<CalendarCheck2 className="h-3 w-3 flex-shrink-0" />}
|
||||
buttonVariant={issue.target_date ? "border-with-text" : "border-without-text"}
|
||||
buttonClassName={shouldHighlight ? "text-red-500" : ""}
|
||||
clearIconClassName="text-custom-text-100"
|
||||
optionsClassName="z-30"
|
||||
disabled={!disabled}
|
||||
showTooltip
|
||||
/>
|
||||
</div>
|
||||
</WithDisplayPropertiesHOC>
|
||||
|
||||
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="assignee">
|
||||
<div className="h-5 flex-shrink-0">
|
||||
<MemberDropdown
|
||||
|
||||
@@ -5,12 +5,11 @@ import { observer } from "mobx-react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { ArchiveIcon, ArchiveRestoreIcon, LinkIcon, Trash2 } from "lucide-react";
|
||||
import {
|
||||
ISSUE_ARCHIVED,
|
||||
ISSUE_DELETED,
|
||||
ARCHIVABLE_STATE_GROUPS,
|
||||
EIssuesStoreType,
|
||||
EUserPermissions,
|
||||
EUserPermissionsLevel,
|
||||
WORK_ITEM_TRACKER_EVENTS,
|
||||
} from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { TOAST_TYPE, Tooltip, setToast } from "@plane/ui";
|
||||
@@ -105,19 +104,19 @@ export const IssueDetailQuickActions: FC<Props> = observer((props) => {
|
||||
return deleteIssue(workspaceSlug, projectId, issueId).then(() => {
|
||||
router.push(redirectionPath);
|
||||
captureIssueEvent({
|
||||
eventName: ISSUE_DELETED,
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.delete,
|
||||
payload: { id: issueId, state: "SUCCESS", element: "Work item detail page" },
|
||||
path: pathname,
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
} catch {
|
||||
setToast({
|
||||
title: t("toast.error "),
|
||||
type: TOAST_TYPE.ERROR,
|
||||
message: t("entity.delete.failed", { entity: t("issue.label", { count: 1 }) }),
|
||||
});
|
||||
captureIssueEvent({
|
||||
eventName: ISSUE_DELETED,
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.delete,
|
||||
payload: { id: issueId, state: "FAILED", element: "Work item detail page" },
|
||||
path: pathname,
|
||||
});
|
||||
@@ -130,13 +129,13 @@ export const IssueDetailQuickActions: FC<Props> = observer((props) => {
|
||||
router.push(`/${workspaceSlug}/projects/${projectId}/archives/issues/${issue.id}`);
|
||||
});
|
||||
captureIssueEvent({
|
||||
eventName: ISSUE_ARCHIVED,
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.archive,
|
||||
payload: { id: issueId, state: "SUCCESS", element: "Issue details page" },
|
||||
path: pathname,
|
||||
});
|
||||
} catch (error) {
|
||||
} catch {
|
||||
captureIssueEvent({
|
||||
eventName: ISSUE_ARCHIVED,
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.archive,
|
||||
payload: { id: issueId, state: "FAILED", element: "Issue details page" },
|
||||
path: pathname,
|
||||
});
|
||||
|
||||
@@ -4,14 +4,7 @@ import { FC, useMemo } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { usePathname } from "next/navigation";
|
||||
// types
|
||||
import {
|
||||
EIssuesStoreType,
|
||||
ISSUE_UPDATED,
|
||||
ISSUE_DELETED,
|
||||
ISSUE_ARCHIVED,
|
||||
EUserPermissions,
|
||||
EUserPermissionsLevel,
|
||||
} from "@plane/constants";
|
||||
import { EIssuesStoreType, EUserPermissions, EUserPermissionsLevel, WORK_ITEM_TRACKER_EVENTS } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { TIssue } from "@plane/types";
|
||||
// ui
|
||||
@@ -98,7 +91,7 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = observer((props) => {
|
||||
try {
|
||||
await updateIssue(workspaceSlug, projectId, issueId, data);
|
||||
captureIssueEvent({
|
||||
eventName: ISSUE_UPDATED,
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.update,
|
||||
payload: { ...data, issueId, state: "SUCCESS", element: "Issue detail page" },
|
||||
updates: {
|
||||
changed_property: Object.keys(data).join(","),
|
||||
@@ -109,7 +102,7 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = observer((props) => {
|
||||
} catch (error) {
|
||||
console.log("Error in updating issue:", error);
|
||||
captureIssueEvent({
|
||||
eventName: ISSUE_UPDATED,
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.update,
|
||||
payload: { state: "FAILED", element: "Issue detail page" },
|
||||
updates: {
|
||||
changed_property: Object.keys(data).join(","),
|
||||
@@ -134,7 +127,7 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = observer((props) => {
|
||||
message: t("entity.delete.success", { entity: t("issue.label") }),
|
||||
});
|
||||
captureIssueEvent({
|
||||
eventName: ISSUE_DELETED,
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.delete,
|
||||
payload: { id: issueId, state: "SUCCESS", element: "Issue detail page" },
|
||||
path: pathname,
|
||||
});
|
||||
@@ -146,7 +139,7 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = observer((props) => {
|
||||
message: t("entity.delete.failed", { entity: t("issue.label") }),
|
||||
});
|
||||
captureIssueEvent({
|
||||
eventName: ISSUE_DELETED,
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.delete,
|
||||
payload: { id: issueId, state: "FAILED", element: "Issue detail page" },
|
||||
path: pathname,
|
||||
});
|
||||
@@ -156,14 +149,14 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = observer((props) => {
|
||||
try {
|
||||
await archiveIssue(workspaceSlug, projectId, issueId);
|
||||
captureIssueEvent({
|
||||
eventName: ISSUE_ARCHIVED,
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.archive,
|
||||
payload: { id: issueId, state: "SUCCESS", element: "Issue details page" },
|
||||
path: pathname,
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("Error in archiving issue:", error);
|
||||
captureIssueEvent({
|
||||
eventName: ISSUE_ARCHIVED,
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.archive,
|
||||
payload: { id: issueId, state: "FAILED", element: "Issue details page" },
|
||||
path: pathname,
|
||||
});
|
||||
@@ -173,7 +166,7 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = observer((props) => {
|
||||
try {
|
||||
await addCycleToIssue(workspaceSlug, projectId, cycleId, issueId);
|
||||
captureIssueEvent({
|
||||
eventName: ISSUE_UPDATED,
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.update,
|
||||
payload: { issueId, state: "SUCCESS", element: "Issue detail page" },
|
||||
updates: {
|
||||
changed_property: "cycle_id",
|
||||
@@ -181,14 +174,14 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = observer((props) => {
|
||||
},
|
||||
path: pathname,
|
||||
});
|
||||
} catch (error) {
|
||||
} catch {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: t("common.error.label"),
|
||||
message: t("issue.add.cycle.failed"),
|
||||
});
|
||||
captureIssueEvent({
|
||||
eventName: ISSUE_UPDATED,
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.update,
|
||||
payload: { state: "FAILED", element: "Issue detail page" },
|
||||
updates: {
|
||||
changed_property: "cycle_id",
|
||||
@@ -202,7 +195,7 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = observer((props) => {
|
||||
try {
|
||||
await addIssueToCycle(workspaceSlug, projectId, cycleId, issueIds);
|
||||
captureIssueEvent({
|
||||
eventName: ISSUE_UPDATED,
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.update,
|
||||
payload: { ...issueIds, state: "SUCCESS", element: "Issue detail page" },
|
||||
updates: {
|
||||
changed_property: "cycle_id",
|
||||
@@ -210,14 +203,14 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = observer((props) => {
|
||||
},
|
||||
path: pathname,
|
||||
});
|
||||
} catch (error) {
|
||||
} catch {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: t("common.error.label"),
|
||||
message: t("issue.add.cycle.failed"),
|
||||
});
|
||||
captureIssueEvent({
|
||||
eventName: ISSUE_UPDATED,
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.update,
|
||||
payload: { state: "FAILED", element: "Issue detail page" },
|
||||
updates: {
|
||||
changed_property: "cycle_id",
|
||||
@@ -243,7 +236,7 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = observer((props) => {
|
||||
});
|
||||
await removeFromCyclePromise;
|
||||
captureIssueEvent({
|
||||
eventName: ISSUE_UPDATED,
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.update,
|
||||
payload: { issueId, state: "SUCCESS", element: "Issue detail page" },
|
||||
updates: {
|
||||
changed_property: "cycle_id",
|
||||
@@ -251,9 +244,9 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = observer((props) => {
|
||||
},
|
||||
path: pathname,
|
||||
});
|
||||
} catch (error) {
|
||||
} catch {
|
||||
captureIssueEvent({
|
||||
eventName: ISSUE_UPDATED,
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.update,
|
||||
payload: { state: "FAILED", element: "Issue detail page" },
|
||||
updates: {
|
||||
changed_property: "cycle_id",
|
||||
@@ -279,7 +272,7 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = observer((props) => {
|
||||
});
|
||||
await removeFromModulePromise;
|
||||
captureIssueEvent({
|
||||
eventName: ISSUE_UPDATED,
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.update,
|
||||
payload: { id: issueId, state: "SUCCESS", element: "Issue detail page" },
|
||||
updates: {
|
||||
changed_property: "module_id",
|
||||
@@ -287,9 +280,9 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = observer((props) => {
|
||||
},
|
||||
path: pathname,
|
||||
});
|
||||
} catch (error) {
|
||||
} catch {
|
||||
captureIssueEvent({
|
||||
eventName: ISSUE_UPDATED,
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.update,
|
||||
payload: { id: issueId, state: "FAILED", element: "Issue detail page" },
|
||||
updates: {
|
||||
changed_property: "module_id",
|
||||
@@ -308,7 +301,7 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = observer((props) => {
|
||||
) => {
|
||||
const promise = await changeModulesInIssue(workspaceSlug, projectId, issueId, addModuleIds, removeModuleIds);
|
||||
captureIssueEvent({
|
||||
eventName: ISSUE_UPDATED,
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.update,
|
||||
payload: { id: issueId, state: "SUCCESS", element: "Issue detail page" },
|
||||
updates: {
|
||||
changed_property: "module_id",
|
||||
@@ -333,6 +326,7 @@ export const IssueDetailRoot: FC<TIssueDetailRoot> = observer((props) => {
|
||||
removeIssueFromModule,
|
||||
captureIssueEvent,
|
||||
pathname,
|
||||
t,
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
+5
-3
@@ -11,8 +11,10 @@ import {
|
||||
EIssueFilterType,
|
||||
EIssuesStoreType,
|
||||
EViewAccess,
|
||||
GLOBAL_VIEW_UPDATED,
|
||||
EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
||||
EUserPermissions,
|
||||
EUserPermissionsLevel,
|
||||
GLOBAL_VIEW_TOUR_TRACKER_EVENTS,
|
||||
} from "@plane/constants";
|
||||
import { IIssueFilterOptions, TStaticViewTypes } from "@plane/types";
|
||||
//ui
|
||||
// components
|
||||
@@ -112,7 +114,7 @@ export const GlobalViewsAppliedFiltersRoot = observer((props: Props) => {
|
||||
|
||||
updateGlobalView(workspaceSlug.toString(), globalViewId.toString(), viewFilters).then((res) => {
|
||||
if (res)
|
||||
captureEvent(GLOBAL_VIEW_UPDATED, {
|
||||
captureEvent(GLOBAL_VIEW_TOUR_TRACKER_EVENTS.update, {
|
||||
view_id: res.id,
|
||||
applied_filters: res.filters,
|
||||
state: "SUCCESS",
|
||||
|
||||
@@ -146,6 +146,7 @@ export const BaseGanttRoot: React.FC<IBaseGanttRoot> = observer((props: IBaseGan
|
||||
canLoadMoreBlocks={nextPageResults}
|
||||
updateBlockDates={updateBlockDates}
|
||||
showAllBlocks
|
||||
enableDependency
|
||||
isEpic={isEpic}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -11,9 +11,9 @@ import {
|
||||
EIssueServiceType,
|
||||
EIssueFilterType,
|
||||
EIssuesStoreType,
|
||||
ISSUE_DELETED,
|
||||
EUserPermissions,
|
||||
EUserPermissionsLevel,
|
||||
WORK_ITEM_TRACKER_EVENTS,
|
||||
} from "@plane/constants";
|
||||
import { DeleteIssueModal } from "@/components/issues";
|
||||
//constants
|
||||
@@ -152,7 +152,7 @@ export const BaseKanBanRoot: React.FC<IBaseKanBanLayout> = observer((props: IBas
|
||||
element,
|
||||
})
|
||||
);
|
||||
}, [scrollableContainerRef?.current]);
|
||||
}, []);
|
||||
|
||||
// Make the Issue Delete Box a Drop Target
|
||||
useEffect(() => {
|
||||
@@ -181,7 +181,7 @@ export const BaseKanBanRoot: React.FC<IBaseKanBanLayout> = observer((props: IBas
|
||||
},
|
||||
})
|
||||
);
|
||||
}, [deleteAreaRef?.current, setIsDragOverDelete, setDraggedIssueId, setDeleteIssueModal]);
|
||||
}, [setIsDragOverDelete, setDraggedIssueId, setDeleteIssueModal]);
|
||||
|
||||
const renderQuickActions: TRenderQuickActions = useCallback(
|
||||
({ issue, parentRef, customActionButton }) => (
|
||||
@@ -210,7 +210,7 @@ export const BaseKanBanRoot: React.FC<IBaseKanBanLayout> = observer((props: IBas
|
||||
setDeleteIssueModal(false);
|
||||
setDraggedIssueId(undefined);
|
||||
captureIssueEvent({
|
||||
eventName: ISSUE_DELETED,
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.delete,
|
||||
payload: { id: draggedIssueId, state: "FAILED", element: "Kanban layout drag & drop" },
|
||||
path: pathname,
|
||||
});
|
||||
|
||||
@@ -5,15 +5,21 @@ import xor from "lodash/xor";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams, usePathname } from "next/navigation";
|
||||
// icons
|
||||
import { Layers, Link, Paperclip } from "lucide-react";
|
||||
import { CalendarCheck2, CalendarClock, Layers, Link, Paperclip } from "lucide-react";
|
||||
// types
|
||||
import { ISSUE_UPDATED } from "@plane/constants";
|
||||
import { WORK_ITEM_TRACKER_EVENTS } from "@plane/constants";
|
||||
// i18n
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { TIssue, IIssueDisplayProperties, TIssuePriorities } from "@plane/types";
|
||||
// ui
|
||||
import { Tooltip } from "@plane/ui";
|
||||
import { cn, getDate, renderFormattedPayloadDate, generateWorkItemLink, shouldHighlightIssueDueDate } from "@plane/utils";
|
||||
import {
|
||||
cn,
|
||||
getDate,
|
||||
renderFormattedPayloadDate,
|
||||
generateWorkItemLink,
|
||||
shouldHighlightIssueDueDate,
|
||||
} from "@plane/utils";
|
||||
// components
|
||||
import {
|
||||
EstimateDropdown,
|
||||
@@ -23,6 +29,7 @@ import {
|
||||
CycleDropdown,
|
||||
StateDropdown,
|
||||
DateRangeDropdown,
|
||||
DateDropdown,
|
||||
} from "@/components/dropdowns";
|
||||
// constants
|
||||
// helpers
|
||||
@@ -103,7 +110,7 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
|
||||
if (updateIssue)
|
||||
updateIssue(issue.project_id, issue.id, { state_id: stateId }).then(() => {
|
||||
captureIssueEvent({
|
||||
eventName: ISSUE_UPDATED,
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.update,
|
||||
payload: { ...issue, state: "SUCCESS", element: currentLayout },
|
||||
path: pathname,
|
||||
updates: {
|
||||
@@ -118,7 +125,7 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
|
||||
if (updateIssue)
|
||||
updateIssue(issue.project_id, issue.id, { priority: value }).then(() => {
|
||||
captureIssueEvent({
|
||||
eventName: ISSUE_UPDATED,
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.update,
|
||||
payload: { ...issue, state: "SUCCESS", element: currentLayout },
|
||||
path: pathname,
|
||||
updates: {
|
||||
@@ -133,7 +140,7 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
|
||||
if (updateIssue)
|
||||
updateIssue(issue.project_id, issue.id, { label_ids: ids }).then(() => {
|
||||
captureIssueEvent({
|
||||
eventName: ISSUE_UPDATED,
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.update,
|
||||
payload: { ...issue, state: "SUCCESS", element: currentLayout },
|
||||
path: pathname,
|
||||
updates: {
|
||||
@@ -148,7 +155,7 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
|
||||
if (updateIssue)
|
||||
updateIssue(issue.project_id, issue.id, { assignee_ids: ids }).then(() => {
|
||||
captureIssueEvent({
|
||||
eventName: ISSUE_UPDATED,
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.update,
|
||||
payload: { ...issue, state: "SUCCESS", element: currentLayout },
|
||||
path: pathname,
|
||||
updates: {
|
||||
@@ -173,7 +180,7 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
|
||||
if (modulesToRemove.length > 0) issueOperations.removeModulesFromIssue(modulesToRemove);
|
||||
|
||||
captureIssueEvent({
|
||||
eventName: ISSUE_UPDATED,
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.update,
|
||||
payload: { ...issue, state: "SUCCESS", element: currentLayout },
|
||||
path: pathname,
|
||||
updates: { changed_property: "module_ids", change_details: { module_ids: moduleIds } },
|
||||
@@ -189,7 +196,7 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
|
||||
else issueOperations.removeIssueFromCycle?.();
|
||||
|
||||
captureIssueEvent({
|
||||
eventName: ISSUE_UPDATED,
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.update,
|
||||
payload: { ...issue, state: "SUCCESS", element: currentLayout },
|
||||
path: pathname,
|
||||
updates: { changed_property: "cycle", change_details: { cycle_id: cycleId } },
|
||||
@@ -203,7 +210,7 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
|
||||
updateIssue(issue.project_id, issue.id, { start_date: date ? renderFormattedPayloadDate(date) : null }).then(
|
||||
() => {
|
||||
captureIssueEvent({
|
||||
eventName: ISSUE_UPDATED,
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.update,
|
||||
payload: { ...issue, state: "SUCCESS", element: currentLayout },
|
||||
path: pathname,
|
||||
updates: {
|
||||
@@ -220,7 +227,7 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
|
||||
updateIssue(issue.project_id, issue.id, { target_date: date ? renderFormattedPayloadDate(date) : null }).then(
|
||||
() => {
|
||||
captureIssueEvent({
|
||||
eventName: ISSUE_UPDATED,
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.update,
|
||||
payload: { ...issue, state: "SUCCESS", element: currentLayout },
|
||||
path: pathname,
|
||||
updates: {
|
||||
@@ -236,7 +243,7 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
|
||||
if (updateIssue)
|
||||
updateIssue(issue.project_id, issue.id, { estimate_point: value }).then(() => {
|
||||
captureIssueEvent({
|
||||
eventName: ISSUE_UPDATED,
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.update,
|
||||
payload: { ...issue, state: "SUCCESS", element: currentLayout },
|
||||
path: pathname,
|
||||
updates: {
|
||||
@@ -261,8 +268,16 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
|
||||
|
||||
if (!displayProperties || !issue.project_id) return null;
|
||||
|
||||
// date range is enabled only when both dates are available and both dates are enabled
|
||||
const isDateRangeEnabled: boolean = Boolean(
|
||||
issue.start_date && issue.target_date && displayProperties.start_date && displayProperties.due_date
|
||||
);
|
||||
|
||||
const defaultLabelOptions = issue?.label_ids?.map((id) => labelMap[id]) || [];
|
||||
|
||||
const minDate = getDate(issue.start_date);
|
||||
const maxDate = getDate(issue.target_date);
|
||||
|
||||
const handleEventPropagation = (e: SyntheticEvent<HTMLDivElement>) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
@@ -306,7 +321,7 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
|
||||
<WithDisplayPropertiesHOC
|
||||
displayProperties={displayProperties}
|
||||
displayPropertyKey={["start_date", "due_date"]}
|
||||
shouldRenderProperty={(properties) => !!(properties.start_date || properties.due_date)}
|
||||
shouldRenderProperty={() => isDateRangeEnabled}
|
||||
>
|
||||
<div className="h-5" onFocus={handleEventPropagation} onClick={handleEventPropagation}>
|
||||
<DateRangeDropdown
|
||||
@@ -325,6 +340,7 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
|
||||
mergeDates
|
||||
buttonVariant={issue.start_date || issue.target_date ? "border-with-text" : "border-without-text"}
|
||||
buttonClassName={shouldHighlightIssueDueDate(issue.target_date, stateDetails?.group) ? "text-red-500" : ""}
|
||||
clearIconClassName="!text-custom-text-100"
|
||||
disabled={isReadOnly}
|
||||
renderByDefault={isMobile}
|
||||
showTooltip
|
||||
@@ -334,6 +350,52 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
|
||||
</div>
|
||||
</WithDisplayPropertiesHOC>
|
||||
|
||||
{/* start date */}
|
||||
<WithDisplayPropertiesHOC
|
||||
displayProperties={displayProperties}
|
||||
displayPropertyKey="start_date"
|
||||
shouldRenderProperty={() => !isDateRangeEnabled}
|
||||
>
|
||||
<div className="h-5" onFocus={handleEventPropagation} onClick={handleEventPropagation}>
|
||||
<DateDropdown
|
||||
value={issue.start_date ?? null}
|
||||
onChange={handleStartDate}
|
||||
maxDate={maxDate}
|
||||
placeholder={t("common.order_by.start_date")}
|
||||
icon={<CalendarClock className="h-3 w-3 flex-shrink-0" />}
|
||||
buttonVariant={issue.start_date ? "border-with-text" : "border-without-text"}
|
||||
optionsClassName="z-10"
|
||||
disabled={isReadOnly}
|
||||
renderByDefault={isMobile}
|
||||
showTooltip
|
||||
/>
|
||||
</div>
|
||||
</WithDisplayPropertiesHOC>
|
||||
|
||||
{/* target/due date */}
|
||||
<WithDisplayPropertiesHOC
|
||||
displayProperties={displayProperties}
|
||||
displayPropertyKey="due_date"
|
||||
shouldRenderProperty={() => !isDateRangeEnabled}
|
||||
>
|
||||
<div className="h-5" onFocus={handleEventPropagation} onClick={handleEventPropagation}>
|
||||
<DateDropdown
|
||||
value={issue?.target_date ?? null}
|
||||
onChange={handleTargetDate}
|
||||
minDate={minDate}
|
||||
placeholder={t("common.order_by.due_date")}
|
||||
icon={<CalendarCheck2 className="h-3 w-3 flex-shrink-0" />}
|
||||
buttonVariant={issue.target_date ? "border-with-text" : "border-without-text"}
|
||||
buttonClassName={shouldHighlightIssueDueDate(issue.target_date, stateDetails?.group) ? "text-red-500" : ""}
|
||||
clearIconClassName="!text-custom-text-100"
|
||||
optionsClassName="z-10"
|
||||
disabled={isReadOnly}
|
||||
renderByDefault={isMobile}
|
||||
showTooltip
|
||||
/>
|
||||
</div>
|
||||
</WithDisplayPropertiesHOC>
|
||||
|
||||
{/* assignee */}
|
||||
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="assignee">
|
||||
<div className="h-5" onFocus={handleEventPropagation} onClick={handleEventPropagation}>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useParams, usePathname } from "next/navigation";
|
||||
import { useForm, UseFormRegister } from "react-hook-form";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
// plane constants
|
||||
import { EIssueLayoutTypes, EIssueServiceType, ISSUE_CREATED } from "@plane/constants";
|
||||
import { EIssueLayoutTypes, WORK_ITEM_TRACKER_EVENTS } from "@plane/constants";
|
||||
// i18n
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { IProject, TIssue } from "@plane/types";
|
||||
@@ -137,14 +137,14 @@ export const QuickAddIssueRoot: FC<TQuickAddIssueRoot> = observer((props) => {
|
||||
await quickAddPromise
|
||||
.then((res) => {
|
||||
captureIssueEvent({
|
||||
eventName: ISSUE_CREATED,
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.create,
|
||||
payload: { ...res, state: "SUCCESS", element: ` ${layout} quick add` },
|
||||
path: pathname,
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
captureIssueEvent({
|
||||
eventName: ISSUE_CREATED,
|
||||
eventName: WORK_ITEM_TRACKER_EVENTS.create,
|
||||
payload: { ...payload, state: "FAILED", element: `${layout} quick ad` },
|
||||
path: pathname,
|
||||
});
|
||||
|
||||
@@ -32,7 +32,7 @@ export const AllIssueLayoutRoot: React.FC<Props> = observer((props: Props) => {
|
||||
|
||||
// Store hooks
|
||||
const {
|
||||
issuesFilter: { filters, fetchFilters, updateFilters },
|
||||
issuesFilter: { fetchFilters, updateFilters },
|
||||
issues: { clear, groupedIssueIds, fetchIssues, fetchNextIssues },
|
||||
} = useIssues(EIssuesStoreType.GLOBAL);
|
||||
const { fetchAllGlobalViews, getViewDetailsById } = useGlobalView();
|
||||
@@ -42,8 +42,7 @@ export const AllIssueLayoutRoot: React.FC<Props> = observer((props: Props) => {
|
||||
|
||||
// Derived values
|
||||
const viewDetails = getViewDetailsById(globalViewId?.toString());
|
||||
const issueFilters = globalViewId ? filters?.[globalViewId.toString()] : undefined;
|
||||
const activeLayout: EIssueLayoutTypes | undefined = issueFilters?.displayFilters?.layout;
|
||||
const activeLayout: EIssueLayoutTypes | undefined = EIssueLayoutTypes.SPREADSHEET;
|
||||
|
||||
// Route filters
|
||||
const routeFilters: { [key: string]: string } = {};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user