Compare commits

...

52 Commits

Author SHA1 Message Date
LAKHAN BAHETI
63f5429c18 Merge branch 'develop' of https://github.com/makeplane/plane into style/views_card 2023-11-14 19:08:33 +05:30
Prateek Shourya
1fe09d369f style: text overflow fix and border color update (#2769)
* style: fix text overflow in:
* Issue activity
* Cycle and Module Select in Create Issue form
* Delete Module modal
* Join Project modal

* style: update assignee select border as per design.
2023-11-14 18:34:51 +05:30
Dakshesh Jain
b7757c6b1a fix: bugs (#2761)
* fix: semicolon on estimate settings page

* refactor: project settings automations store implementation

* fix: active cycle stuck on infinite loading

* fix: removed delete project option from sidebar

* fix: discloser not opening when navigating to project

* fix: clear filter not working & filter appearing even if nothing is selected

* refactor: select label store implementation

* refactor: select state store implementation
2023-11-14 18:33:01 +05:30
Anmol Singh Bhatia
1a25bacce1 style: create update view modal consistency (#2775) 2023-11-14 18:30:10 +05:30
Anmol Singh Bhatia
6797df239d chore: no lead option added in lead select dropdown (#2774) 2023-11-14 18:29:39 +05:30
Anmol Singh Bhatia
43e7c10eb7 chore: spreadsheet layout column responsiveness (#2768) 2023-11-14 18:28:49 +05:30
Anmol Singh Bhatia
bdc9c9c2a8 chore: create update issue modal improvement (#2765) 2023-11-14 18:28:15 +05:30
Anmol Singh Bhatia
f0c72bf249 fix: breadcrumb project icon improvement (#2764) 2023-11-14 18:27:47 +05:30
sabith-tu
a8904bfc48 style: ui fixes for pages and views (#2770) 2023-11-14 18:26:50 +05:30
LAKHAN BAHETI
09a6039790 fix: description truncating after 1 line 2023-11-14 17:13:57 +05:30
LAKHAN BAHETI
442bbe41ac style: views card text overflow 2023-11-14 16:53:29 +05:30
Nikhil
b31041726b dev: create bucket through application (#2720) 2023-11-13 15:57:19 +05:30
Prateek Shourya
e6f947ad90 style: ui improvements and bug fixes (#2758)
* style: add transition to favorite projects dropdown.

* style: update project integration settings borders.

* style: fix text overflow issue in project views.

* fix: issue with non-functional cancel button in leave project modal.
2023-11-13 14:42:45 +05:30
Dakshesh Jain
7963993171 fix: workspace settings bugs (#2743)
* fix: double layout in exports

* fix: typo in jira email address section

* fix: workspace members not mutating

* fix: removed un-used variable

* fix: workspace members can't be filtered using email

* fix: autocomplete in workspace delete

* fix: autocomplete in project delete modal

* fix: update member function in store

* fix: sidebar link not active when in github/jira

* style: margin top & icon inconsistency

* fix: typo in create workspace

* fix: workspace leave flow

* fix: redirection to delete issue

* fix: autocomplete off in jira api token

* refactor: reduced api call, added optional chaining & removed variable with low scope
2023-11-13 13:34:05 +05:30
Anmol Singh Bhatia
00e61a8753 fix: peek overview comment ordering and comment icon alignment fix (#2753) 2023-11-10 18:45:41 +05:30
Anmol Singh Bhatia
733fed76cc fix: cycle card title responsiveness added (#2752) 2023-11-10 18:43:48 +05:30
Anmol Singh Bhatia
e78dd4b1c0 fix: app sidebar dropdown fix (#2751) 2023-11-10 18:43:16 +05:30
Anmol Singh Bhatia
d479781fce style: header consistency (#2750) 2023-11-10 18:30:43 +05:30
Bavisetti Narayan
c449b46bf4 fix: added external folder in urls (#2749) 2023-11-10 16:00:55 +05:30
Prateek Shourya
fd6430c3e3 style: Update modal appearance for UI consistency (#2747) 2023-11-10 15:48:34 +05:30
Ramesh Kumar Chandra
6f580ce2d9 fix: project settings layout render in export (#2746) 2023-11-10 13:07:18 +05:30
Ankush Deshmukh
2748133bd0 Fix: Show Priority icon in custom analytics table. (#2744) 2023-11-10 13:06:23 +05:30
Aaryan Khandelwal
884b219508 refactor: cycles store (#2716)
* refactor: cycles store

* refactor: active cycle details
2023-11-09 18:37:45 +05:30
Anmol Singh Bhatia
162faf8339 fix: date select tooltip fix (#2740) 2023-11-09 18:29:45 +05:30
Anmol Singh Bhatia
c291ff05ee fix: fliter list item clear button alignment fix (#2741) 2023-11-09 18:27:19 +05:30
Nikhil
446981422e feat: issues v2 endpoint (#2713)
* feat: issue v2 listing endpoint

* dev: issues v3 endpoint

* dev: add permission in the grouped endpoint

* dev: update grouped endpoint
2023-11-09 18:24:26 +05:30
Bavisetti Narayan
630e21b954 fix: favourite cycle and modules displayed at top (#2719) 2023-11-09 18:22:38 +05:30
Bavisetti Narayan
894ffb6c21 fix: mention notification (#2670)
* fix: mention notification

* feat: updated mentions for comments in the notification background task

* feat: added subscription for issue_comment_mentions as well

* fix: removed the print statement

* fix: double notification popup for mentioned assignees

* fix: added issue subscriber

* fix: removed creator for subscribed

* fix: creator will not be subscribed to issue

* fix: double notification removed

---------

Co-authored-by: Henit Chobisa <chobisa.henit@gmail.com>
2023-11-09 18:22:13 +05:30
Nikhil
515dba02d3 chore: configuration add tracker variables (#2709)
* chore: configuration add tracker variables

* dev: unsplash configuration
2023-11-09 18:20:03 +05:30
Dakshesh Jain
34bccd7e06 refactor: gantt sidebar (#2705)
* refactor: gantt sidebar

* fix: exception error

fix: file placement

* refactor: not passing sidebar block as props
2023-11-09 17:57:41 +05:30
sriram veeraghanta
79df59f618 fix: spliting out the project members from project store and service (#2739) 2023-11-09 17:56:55 +05:30
Prateek Shourya
7676aab773 fix: UI improvements. (#2734)
* style: update check icon colors to match our design.

* fix: automatically focus input box in pages `add label` modal.
2023-11-09 17:37:45 +05:30
Prateek Shourya
8832d8e00e style: sidebar UI improvements (#2735)
* updated font weight and color as per designs.
* removed background color from workspace with logo.
* updated dropdown design.
2023-11-09 17:01:48 +05:30
rahulramesha
d733a53ea6 fix: Add horizontal scroll bar to views (#2736)
* add errors for duplicate labels

* adding horizonatal scroll bar to views

---------

Co-authored-by: rahulramesha <rahul@appsmith.com>
2023-11-09 15:12:00 +05:30
Lakhan Baheti
96862e06ef fix: cystom analytics bar graph index alignment (#2737) 2023-11-09 14:36:22 +05:30
sriram veeraghanta
a6567bbce4 fix: workspace members store added and implemented across the app (#2732)
* fix: minor changes

* fix: workspace members store added and implemnted across the app
2023-11-09 00:35:12 +05:30
Nikhil
556b2d2617 feat: state list endpoint (#2717)
* feat: state list endpoint

* dev: update states endpoint

* dev: mark default state endpoint
2023-11-08 22:38:53 +05:30
Lakhan Baheti
10037222b6 fix: Tooltip content on assignee hover in all layouts (#2724)
* fix: Tooltip content on assignee hover in all layouts

* chore: comments added
2023-11-08 22:35:30 +05:30
sabith-tu
931f9d288a fix: toast alert inconsistency (#2730) 2023-11-08 20:37:47 +05:30
sriram veeraghanta
20fb79567f fix: project states fixes (#2731)
* fix: project states fixes

* fix: states fixes

* fix: formating all files
2023-11-08 20:31:46 +05:30
Ramesh Kumar Chandra
bd1a850f35 style: kanban card label overflow (#2722)
* chore: kanban card lable drop down items overflow

* style: kaban card label text overflow, tool tip, hover cursor

* style: label overflow in list layout
2023-11-08 18:12:36 +05:30
M. Palanikannan
206f5744a3 [fix]: Error Handling for Images and Table Fix for Form Submissions in Editor (#2710)
* cancellable uploads and image limits with better error handling

* fixed table row/column picker behaviour on modals

* Merge branch 'rerender-debounce-editor-fix' into editor-draggable-nodes

* fix: added mention suggestions and highlights in `create-issue-modal`

* removed uncessary files

* solved lint error of trailing spaces

* added plane/ui dependency for tooltips

---------

Co-authored-by: Henit Chobisa <chobisa.henit@gmail.com>
2023-11-08 18:00:53 +05:30
sabith-tu
faaba45e59 fix: all issues values not changeable and assignee image not rendering (#2707)
* fix: all issues values are not changeable and assignee image not rendering

* chore: removed console log
2023-11-08 17:56:09 +05:30
sabith-tu
f8002852e0 fix: issue property height and peek view date picker border radius (#2726) 2023-11-08 17:55:28 +05:30
Lakhan Baheti
2d71377722 fix: added empty project state when no project exists. (#2727)
* fix: added empty project state when no project exists

* fix: duplicate import
2023-11-08 17:54:59 +05:30
Lakhan Baheti
6ebee05951 fix: unwanted go back button in onboarding step 2 (#2714) 2023-11-08 17:53:06 +05:30
Lakhan Baheti
5a3bac998e fix: bug fixes and ui improvements (#2703)
* fix: gantt chart duration in decimal

* fix: Loading text instead Spinner in peek view

* fix: cycle more popover typo & icon overlapping

* fix: list layout properties alignment

* fix: project search empty state

* fix: calendar layout issue text overflow & redirection inconsistency

* style: urgent priority hover background color

* fix: Cycle issues kanban layout empty state missing

* style: custom snooze modal placeholder text color

* refactor: replaced unwanted anchor tag with div

* chore: removed empty state for cycle kanban layout
2023-11-08 17:52:34 +05:30
Anmol Singh Bhatia
4096136b44 style: ui consistency and improvement (#2725)
* style: create/update issue modal properties ui improvement

* style: create update issue modal improvement

* style: modal ui consistency
2023-11-08 17:51:32 +05:30
Aaryan Khandelwal
83e0c4ebbd chore: remove active ids from the MobX stores if not present in the route (#2681)
* chore: remove active ids if not present in the route

* refactor: set active id logic
2023-11-08 17:35:45 +05:30
Aaryan Khandelwal
df8bdfd5b9 fix: project automation settings flickering (#2680)
* fix: cycle and module sidebar z-index

* fix: project automation settings flickering
2023-11-08 17:34:42 +05:30
Ankush Deshmukh
da799b5a63 Fix: Render bar chart axis labels in lighter color when dark theme applied (#2721) 2023-11-08 17:34:09 +05:30
Dakshesh Jain
621d551c4a fix: project select validation (#2723) 2023-11-08 17:33:26 +05:30
422 changed files with 5308 additions and 4571 deletions

View File

@@ -8,8 +8,8 @@ Before submitting a new issue, please search the [issues](https://github.com/mak
While we want to fix all the [issues](https://github.com/makeplane/plane/issues), before fixing a bug we need to be able to reproduce and confirm it. Please provide us with a minimal reproduction scenario using a repository or [Gist](https://gist.github.com/). Having a live, reproducible scenario gives us the information without asking questions back & forth with additional questions like:
- 3rd-party libraries being used and their versions
- a use-case that fails
- 3rd-party libraries being used and their versions
- a use-case that fails
Without said minimal reproduction, we won't be able to investigate all [issues](https://github.com/makeplane/plane/issues), and the issue might not be resolved.
@@ -19,10 +19,10 @@ You can open a new issue with this [issue form](https://github.com/makeplane/pla
### Requirements
- Node.js version v16.18.0
- Python version 3.8+
- Postgres version v14
- Redis version v6.2.7
- Node.js version v16.18.0
- Python version 3.8+
- Postgres version v14
- Redis version v6.2.7
### Setup the project
@@ -81,8 +81,8 @@ If you would like to _implement_ it, an issue with your proposal must be submitt
To ensure consistency throughout the source code, please keep these rules in mind as you are working:
- All features or bug fixes must be tested by one or more specs (unit-tests).
- We use [Eslint default rule guide](https://eslint.org/docs/rules/), with minor changes. An automated formatter is available using prettier.
- All features or bug fixes must be tested by one or more specs (unit-tests).
- We use [Eslint default rule guide](https://eslint.org/docs/rules/), with minor changes. An automated formatter is available using prettier.
## Need help? Questions and suggestions
@@ -90,11 +90,11 @@ Questions, suggestions, and thoughts are most welcome. We can also be reached in
## Ways to contribute
- Try Plane Cloud and the self hosting platform and give feedback
- Add new integrations
- Help with open [issues](https://github.com/makeplane/plane/issues) or [create your own](https://github.com/makeplane/plane/issues/new/choose)
- Share your thoughts and suggestions with us
- Help create tutorials and blog posts
- Request a feature by submitting a proposal
- Report a bug
- **Improve documentation** - fix incomplete or missing [docs](https://docs.plane.so/), bad wording, examples or explanations.
- Try Plane Cloud and the self hosting platform and give feedback
- Add new integrations
- Help with open [issues](https://github.com/makeplane/plane/issues) or [create your own](https://github.com/makeplane/plane/issues/new/choose)
- Share your thoughts and suggestions with us
- Help create tutorials and blog posts
- Request a feature by submitting a proposal
- Report a bug
- **Improve documentation** - fix incomplete or missing [docs](https://docs.plane.so/), bad wording, examples or explanations.

View File

@@ -1,8 +1,10 @@
# Environment Variables
Environment variables are distributed in various files. Please refer them carefully.
Environment variables are distributed in various files. Please refer them carefully.
## {PROJECT_FOLDER}/.env
File is available in the project root folder
```
@@ -41,25 +43,37 @@ USE_MINIO=1
# Nginx Configuration
NGINX_PORT=80
```
## {PROJECT_FOLDER}/web/.env.example
```
# Enable/Disable OAUTH - default 0 for selfhosted instance
NEXT_PUBLIC_ENABLE_OAUTH=0
# Public boards deploy URL
NEXT_PUBLIC_DEPLOY_URL="http://localhost/spaces"
```
## {PROJECT_FOLDER}/spaces/.env.example
```
# Flag to toggle OAuth
NEXT_PUBLIC_ENABLE_OAUTH=0
```
## {PROJECT_FOLDER}/apiserver/.env
```
# Backend
# Debug value for api server use it as 0 for production use
@@ -126,7 +140,9 @@ ENABLE_SIGNUP="1"
# Email Redirection URL
WEB_URL="http://localhost"
```
## Updates
- The environment variable NEXT_PUBLIC_API_BASE_URL has been removed from both the web and space projects.
- The naming convention for containers and images has been updated.
- The plane-worker image will no longer be maintained, as it has been merged with plane-backend.

View File

@@ -0,0 +1,57 @@
import os, sys
import boto3
from botocore.exceptions import ClientError
sys.path.append("/code")
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "plane.settings.production")
import django
django.setup()
def create_bucket():
try:
from django.conf import settings
# Create a session using the credentials from Django settings
session = boto3.session.Session(
aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
)
# Create an S3 client using the session
s3_client = session.client('s3', endpoint_url=settings.AWS_S3_ENDPOINT_URL)
bucket_name = settings.AWS_STORAGE_BUCKET_NAME
print("Checking bucket...")
# Check if the bucket exists
s3_client.head_bucket(Bucket=bucket_name)
# If head_bucket does not raise an exception, the bucket exists
print(f"Bucket '{bucket_name}' already exists.")
except ClientError as e:
error_code = int(e.response['Error']['Code'])
bucket_name = settings.AWS_STORAGE_BUCKET_NAME
if error_code == 404:
# Bucket does not exist, create it
print(f"Bucket '{bucket_name}' does not exist. Creating bucket...")
try:
s3_client.create_bucket(Bucket=bucket_name)
print(f"Bucket '{bucket_name}' created successfully.")
except ClientError as create_error:
print(f"Failed to create bucket: {create_error}")
elif error_code == 403:
# Access to the bucket is forbidden
print(f"Access to the bucket '{bucket_name}' is forbidden. Check permissions.")
else:
# Another ClientError occurred
print(f"Failed to check bucket: {e}")
except Exception as ex:
# Handle any other exception
print(f"An error occurred: {ex}")
if __name__ == "__main__":
create_bucket()

View File

@@ -5,5 +5,7 @@ python manage.py migrate
# Create a Default User
python bin/user_script.py
# Create the default bucket
python bin/bucket_script.py
exec gunicorn -w $GUNICORN_WORKERS -k uvicorn.workers.UvicornWorker plane.asgi:application --bind 0.0.0.0:8000 --max-requests 1200 --max-requests-jitter 1000 --access-logfile -

View File

@@ -5,7 +5,7 @@ from django.utils import timezone
from rest_framework import serializers
# Module imports
from .base import BaseSerializer
from .base import BaseSerializer, DynamicBaseSerializer
from .user import UserLiteSerializer
from .state import StateSerializer, StateLiteSerializer
from .project import ProjectLiteSerializer
@@ -548,7 +548,7 @@ class IssueSerializer(BaseSerializer):
]
class IssueLiteSerializer(BaseSerializer):
class IssueLiteSerializer(DynamicBaseSerializer):
workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace")
project_detail = ProjectLiteSerializer(read_only=True, source="project")
state_detail = StateLiteSerializer(read_only=True, source="state")

View File

@@ -7,8 +7,6 @@ from plane.db.models import State
class StateSerializer(BaseSerializer):
workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace")
project_detail = ProjectLiteSerializer(read_only=True, source="project")
class Meta:
model = State

View File

@@ -4,7 +4,7 @@ from .authentication import urlpatterns as authentication_urls
from .config import urlpatterns as configuration_urls
from .cycle import urlpatterns as cycle_urls
from .estimate import urlpatterns as estimate_urls
from .gpt import urlpatterns as gpt_urls
from .external import urlpatterns as external_urls
from .importer import urlpatterns as importer_urls
from .inbox import urlpatterns as inbox_urls
from .integration import urlpatterns as integration_urls
@@ -14,10 +14,8 @@ from .notification import urlpatterns as notification_urls
from .page import urlpatterns as page_urls
from .project import urlpatterns as project_urls
from .public_board import urlpatterns as public_board_urls
from .release_note import urlpatterns as release_note_urls
from .search import urlpatterns as search_urls
from .state import urlpatterns as state_urls
from .unsplash import urlpatterns as unsplash_urls
from .user import urlpatterns as user_urls
from .views import urlpatterns as view_urls
from .workspace import urlpatterns as workspace_urls
@@ -30,7 +28,7 @@ urlpatterns = [
*configuration_urls,
*cycle_urls,
*estimate_urls,
*gpt_urls,
*external_urls,
*importer_urls,
*inbox_urls,
*integration_urls,
@@ -40,10 +38,8 @@ urlpatterns = [
*page_urls,
*project_urls,
*public_board_urls,
*release_note_urls,
*search_urls,
*state_urls,
*unsplash_urls,
*user_urls,
*view_urls,
*workspace_urls,

View File

@@ -0,0 +1,25 @@
from django.urls import path
from plane.api.views import UnsplashEndpoint
from plane.api.views import ReleaseNotesEndpoint
from plane.api.views import GPTIntegrationEndpoint
urlpatterns = [
path(
"unsplash/",
UnsplashEndpoint.as_view(),
name="unsplash",
),
path(
"release-notes/",
ReleaseNotesEndpoint.as_view(),
name="release-notes",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/ai-assistant/",
GPTIntegrationEndpoint.as_view(),
name="importer",
),
]

View File

@@ -1,13 +0,0 @@
from django.urls import path
from plane.api.views import GPTIntegrationEndpoint
urlpatterns = [
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/ai-assistant/",
GPTIntegrationEndpoint.as_view(),
name="importer",
),
]

View File

@@ -3,6 +3,8 @@ from django.urls import path
from plane.api.views import (
IssueViewSet,
IssueListEndpoint,
IssueListGroupedEndpoint,
LabelViewSet,
BulkCreateIssueLabelsEndpoint,
BulkDeleteIssuesEndpoint,
@@ -35,6 +37,16 @@ urlpatterns = [
),
name="project-issue",
),
path(
"v2/workspaces/<str:slug>/projects/<uuid:project_id>/issues/",
IssueListEndpoint.as_view(),
name="project-issue",
),
path(
"v3/workspaces/<str:slug>/projects/<uuid:project_id>/issues/",
IssueListGroupedEndpoint.as_view(),
name="project-issue",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:pk>/",
IssueViewSet.as_view(

View File

@@ -1,13 +0,0 @@
from django.urls import path
from plane.api.views import ReleaseNotesEndpoint
urlpatterns = [
path(
"release-notes/",
ReleaseNotesEndpoint.as_view(),
name="release-notes",
),
]

View File

@@ -20,11 +20,19 @@ urlpatterns = [
StateViewSet.as_view(
{
"get": "retrieve",
"put": "update",
"patch": "partial_update",
"delete": "destroy",
}
),
name="project-state",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/states/<uuid:pk>/mark-default/",
StateViewSet.as_view(
{
"post": "mark_as_default",
}
),
name="project-state",
),
]

View File

@@ -1,13 +0,0 @@
from django.urls import path
from plane.api.views import UnsplashEndpoint
urlpatterns = [
path(
"unsplash/",
UnsplashEndpoint.as_view(),
name="unsplash",
),
]

View File

@@ -54,7 +54,12 @@ from .workspace import (
LeaveWorkspaceEndpoint,
)
from .state import StateViewSet
from .view import GlobalViewViewSet, GlobalViewIssuesViewSet, IssueViewViewSet, IssueViewFavoriteViewSet
from .view import (
GlobalViewViewSet,
GlobalViewIssuesViewSet,
IssueViewViewSet,
IssueViewFavoriteViewSet,
)
from .cycle import (
CycleViewSet,
CycleIssueViewSet,
@@ -65,6 +70,8 @@ from .cycle import (
from .asset import FileAssetEndpoint, UserAssetsEndpoint
from .issue import (
IssueViewSet,
IssueListEndpoint,
IssueListGroupedEndpoint,
WorkSpaceIssuesEndpoint,
IssueActivityEndpoint,
IssueCommentViewSet,
@@ -162,7 +169,11 @@ from .analytic import (
DefaultAnalyticsEndpoint,
)
from .notification import NotificationViewSet, UnreadNotificationEndpoint, MarkAllReadNotificationViewSet
from .notification import (
NotificationViewSet,
UnreadNotificationEndpoint,
MarkAllReadNotificationViewSet,
)
from .exporter import ExportIssuesEndpoint

View File

@@ -31,4 +31,7 @@ class ConfigurationEndpoint(BaseAPIView):
os.environ.get("ENABLE_EMAIL_PASSWORD", "0") == "1"
)
data["slack_client_id"] = os.environ.get("SLACK_CLIENT_ID", None)
data["posthog_api_key"] = os.environ.get("POSTHOG_API_KEY", None)
data["posthog_host"] = os.environ.get("POSTHOG_HOST", None)
data["has_unsplash_configured"] = bool(settings.UNSPLASH_ACCESS_KEY)
return Response(data, status=status.HTTP_200_OK)

View File

@@ -176,9 +176,8 @@ class CycleViewSet(BaseViewSet):
def list(self, request, slug, project_id):
queryset = self.get_queryset()
cycle_view = request.GET.get("cycle_view", "all")
order_by = request.GET.get("order_by", "sort_order")
queryset = queryset.order_by(order_by)
queryset = queryset.order_by("-is_favorite","-created_at")
# Current Cycle
if cycle_view == "current":

View File

@@ -89,4 +89,4 @@ class UnsplashEndpoint(BaseAPIView):
}
resp = requests.get(url=url, headers=headers)
return Response(resp.json(), status=status.HTTP_200_OK)
return Response(resp.json(), status=resp.status_code)

View File

@@ -312,6 +312,104 @@ class IssueViewSet(BaseViewSet):
return Response(status=status.HTTP_204_NO_CONTENT)
class IssueListEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
]
def get(self, request, slug, project_id):
fields = [field for field in request.GET.get("fields", "").split(",") if field]
filters = issue_filters(request.query_params, "GET")
issue_queryset = (
Issue.objects.filter(workspace__slug=slug, project_id=project_id)
.select_related("project")
.select_related("workspace")
.select_related("state")
.select_related("parent")
.prefetch_related("assignees")
.prefetch_related("labels")
.prefetch_related(
Prefetch(
"issue_reactions",
queryset=IssueReaction.objects.select_related("actor"),
)
)
.filter(**filters)
.annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(module_id=F("issue_module__module_id"))
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
attachment_count=IssueAttachment.objects.filter(issue=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.distinct()
)
serializer = IssueLiteSerializer(
issue_queryset, many=True, fields=fields if fields else None
)
return Response(serializer.data, status=status.HTTP_200_OK)
class IssueListGroupedEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
]
def get(self, request, slug, project_id):
filters = issue_filters(request.query_params, "GET")
fields = [field for field in request.GET.get("fields", "").split(",") if field]
issue_queryset = (
Issue.objects.filter(workspace__slug=slug, project_id=project_id)
.select_related("project")
.select_related("workspace")
.select_related("state")
.select_related("parent")
.prefetch_related("assignees")
.prefetch_related("labels")
.prefetch_related(
Prefetch(
"issue_reactions",
queryset=IssueReaction.objects.select_related("actor"),
)
)
.filter(**filters)
.annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(module_id=F("issue_module__module_id"))
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
attachment_count=IssueAttachment.objects.filter(issue=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.distinct()
)
issues = IssueLiteSerializer(issue_queryset, many=True, fields=fields if fields else None).data
issue_dict = {str(issue["id"]): issue for issue in issues}
return Response(
issue_dict,
status=status.HTTP_200_OK,
)
class UserWorkSpaceIssues(BaseAPIView):
@method_decorator(gzip_page)
def get(self, request, slug):

View File

@@ -55,7 +55,6 @@ class ModuleViewSet(BaseViewSet):
)
def get_queryset(self):
order_by = self.request.GET.get("order_by", "sort_order")
subquery = ModuleFavorite.objects.filter(
user=self.request.user,
@@ -138,7 +137,7 @@ class ModuleViewSet(BaseViewSet):
),
)
)
.order_by(order_by, "name")
.order_by("-is_favorite","-created_at")
)
def create(self, request, slug, project_id):

View File

@@ -47,36 +47,45 @@ class StateViewSet(BaseViewSet):
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def list(self, request, slug, project_id):
state_dict = dict()
states = StateSerializer(self.get_queryset(), many=True).data
grouped = request.GET.get("grouped", False)
if grouped == "true":
state_dict = {}
for key, value in groupby(
sorted(states, key=lambda state: state["group"]),
lambda state: state.get("group"),
):
state_dict[str(key)] = list(value)
return Response(state_dict, status=status.HTTP_200_OK)
return Response(states, status=status.HTTP_200_OK)
for key, value in groupby(
sorted(states, key=lambda state: state["group"]),
lambda state: state.get("group"),
):
state_dict[str(key)] = list(value)
return Response(state_dict, status=status.HTTP_200_OK)
def mark_as_default(self, request, slug, project_id, pk):
# Select all the states which are marked as default
_ = State.objects.filter(
workspace__slug=slug, project_id=project_id, default=True
).update(default=False)
_ = State.objects.filter(
workspace__slug=slug, project_id=project_id, pk=pk
).update(default=True)
return Response(status=status.HTTP_204_NO_CONTENT)
def destroy(self, request, slug, project_id, pk):
state = State.objects.get(
~Q(name="Triage"),
pk=pk, project_id=project_id, workspace__slug=slug,
pk=pk,
project_id=project_id,
workspace__slug=slug,
)
if state.default:
return Response(
{"error": "Default state cannot be deleted"}, status=False
)
return Response({"error": "Default state cannot be deleted"}, status=False)
# Check for any issues in the state
issue_exist = Issue.issue_objects.filter(state=pk).exists()
if issue_exist:
return Response(
{
"error": "The state is not empty, only empty states can be deleted"
},
{"error": "The state is not empty, only empty states can be deleted"},
status=status.HTTP_400_BAD_REQUEST,
)

View File

@@ -1,8 +1,6 @@
# Python imports
import json
# Django imports
from django.utils import timezone
import uuid
# Module imports
from plane.db.models import (
@@ -14,6 +12,7 @@ from plane.db.models import (
Issue,
Notification,
IssueComment,
IssueActivity
)
# Third Party imports
@@ -21,12 +20,35 @@ from celery import shared_task
from bs4 import BeautifulSoup
# =========== Issue Description Html Parsing and Notification Functions ======================
def update_mentions_for_issue(issue, project, new_mentions, removed_mention):
aggregated_issue_mentions = []
for mention_id in new_mentions:
aggregated_issue_mentions.append(
IssueMention(
mention_id=mention_id,
issue=issue,
project=project,
workspace_id=project.workspace_id
)
)
IssueMention.objects.bulk_create(
aggregated_issue_mentions, batch_size=100)
IssueMention.objects.filter(
issue=issue, mention__in=removed_mention).delete()
def get_new_mentions(requested_instance, current_instance):
# requested_data is the newer instance of the current issue
# current_instance is the older instance of the current issue, saved in the database
# extract mentions from both the instance of data
mentions_older = extract_mentions(current_instance)
mentions_newer = extract_mentions(requested_instance)
# Getting Set Difference from mentions_newer
@@ -64,25 +86,26 @@ def extract_mentions_as_subscribers(project_id, issue_id, mentions):
# If the particular mention has not already been subscribed to the issue, he must be sent the mentioned notification
if not IssueSubscriber.objects.filter(
issue_id=issue_id,
subscriber=mention_id,
project=project_id,
subscriber_id=mention_id,
project_id=project_id,
).exists() and not IssueAssignee.objects.filter(
project_id=project_id, issue_id=issue_id,
assignee_id=mention_id
).exists() and not Issue.objects.filter(
project_id=project_id, pk=issue_id, created_by_id=mention_id
).exists():
mentioned_user = User.objects.get(pk=mention_id)
project = Project.objects.get(pk=project_id)
issue = Issue.objects.get(pk=issue_id)
bulk_mention_subscribers.append(IssueSubscriber(
workspace=project.workspace,
project=project,
issue=issue,
subscriber=mentioned_user,
workspace_id=project.workspace_id,
project_id=project_id,
issue_id=issue_id,
subscriber_id=mention_id,
))
return bulk_mention_subscribers
# Parse Issue Description & extracts mentions
def extract_mentions(issue_instance):
try:
# issue_instance has to be a dictionary passed, containing the description_html and other set of activity data.
@@ -99,6 +122,65 @@ def extract_mentions(issue_instance):
return list(set(mentions))
except Exception as e:
return []
# =========== Comment Parsing and Notification Functions ======================
def extract_comment_mentions(comment_value):
try:
mentions = []
soup = BeautifulSoup(comment_value, 'html.parser')
mentions_tags = soup.find_all(
'mention-component', attrs={'target': 'users'}
)
for mention_tag in mentions_tags:
mentions.append(mention_tag['id'])
return list(set(mentions))
except Exception as e:
return []
def get_new_comment_mentions(new_value, old_value):
mentions_newer = extract_comment_mentions(new_value)
if old_value is None:
return mentions_newer
mentions_older = extract_comment_mentions(old_value)
# Getting Set Difference from mentions_newer
new_mentions = [
mention for mention in mentions_newer if mention not in mentions_older]
return new_mentions
def createMentionNotification(project, notification_comment, issue, actor_id, mention_id, issue_id, activity):
return Notification(
workspace=project.workspace,
sender="in_app:issue_activities:mentioned",
triggered_by_id=actor_id,
receiver_id=mention_id,
entity_identifier=issue_id,
entity_name="issue",
project=project,
message=notification_comment,
data={
"issue": {
"id": str(issue_id),
"name": str(issue.name),
"identifier": str(issue.project.identifier),
"sequence_id": issue.sequence_id,
"state_name": issue.state.name,
"state_group": issue.state.group,
},
"issue_activity": {
"id": str(activity.get("id")),
"verb": str(activity.get("verb")),
"field": str(activity.get("field")),
"actor": str(activity.get("actor_id")),
"new_value": str(activity.get("new_value")),
"old_value": str(activity.get("old_value")),
}
},
)
@shared_task
@@ -126,61 +208,97 @@ def notifications(type, issue_id, project_id, actor_id, subscriber, issue_activi
bulk_notifications = []
"""
Mention Tasks
1. Perform Diffing and Extract the mentions, that mention notification needs to be sent
2. From the latest set of mentions, extract the users which are not a subscribers & make them subscribers
"""
Mention Tasks
1. Perform Diffing and Extract the mentions, that mention notification needs to be sent
2. From the latest set of mentions, extract the users which are not a subscribers & make them subscribers
"""
# Get new mentions from the newer instance
new_mentions = get_new_mentions(
requested_instance=requested_data, current_instance=current_instance)
removed_mention = get_removed_mentions(
requested_instance=requested_data, current_instance=current_instance)
comment_mentions = []
all_comment_mentions = []
# Get New Subscribers from the mentions of the newer instance
requested_mentions = extract_mentions(
issue_instance=requested_data)
mention_subscribers = extract_mentions_as_subscribers(
project_id=project_id, issue_id=issue_id, mentions=requested_mentions)
issue_subscribers = list(
IssueSubscriber.objects.filter(
project_id=project_id, issue_id=issue_id)
.exclude(subscriber_id__in=list(new_mentions + [actor_id]))
.values_list("subscriber", flat=True)
)
for issue_activity in issue_activities_created:
issue_comment = issue_activity.get("issue_comment")
issue_comment_new_value = issue_activity.get("new_value")
issue_comment_old_value = issue_activity.get("old_value")
if issue_comment is not None:
# TODO: Maybe save the comment mentions, so that in future, we can filter out the issues based on comment mentions as well.
all_comment_mentions = all_comment_mentions + extract_comment_mentions(issue_comment_new_value)
new_comment_mentions = get_new_comment_mentions(old_value=issue_comment_old_value, new_value=issue_comment_new_value)
comment_mentions = comment_mentions + new_comment_mentions
comment_mention_subscribers = extract_mentions_as_subscribers( project_id=project_id, issue_id=issue_id, mentions=all_comment_mentions)
"""
We will not send subscription activity notification to the below mentioned user sets
- Those who have been newly mentioned in the issue description, we will send mention notification to them.
- When the activity is a comment_created and there exist a mention in the comment, then we have to send the "mention_in_comment" notification
- When the activity is a comment_updated and there exist a mention change, then also we have to send the "mention_in_comment" notification
"""
issue_assignees = list(
IssueAssignee.objects.filter(
project_id=project_id, issue_id=issue_id)
.exclude(assignee_id=actor_id)
.exclude(assignee_id__in=list(new_mentions + comment_mentions))
.values_list("assignee", flat=True)
)
issue_subscribers = issue_subscribers + issue_assignees
issue_subscribers = list(
IssueSubscriber.objects.filter(
project_id=project_id, issue_id=issue_id)
.exclude(subscriber_id__in=list(new_mentions + comment_mentions + [actor_id]))
.values_list("subscriber", flat=True)
)
issue = Issue.objects.filter(pk=issue_id).first()
if (issue.created_by_id is not None and str(issue.created_by_id) != str(actor_id)):
issue_subscribers = issue_subscribers + [issue.created_by_id]
if subscriber:
# add the user to issue subscriber
try:
_ = IssueSubscriber.objects.get_or_create(
issue_id=issue_id, subscriber_id=actor_id
)
if str(issue.created_by_id) != str(actor_id) and uuid.UUID(actor_id) not in issue_assignees:
_ = IssueSubscriber.objects.get_or_create(
project_id=project_id, issue_id=issue_id, subscriber_id=actor_id
)
except Exception as e:
pass
project = Project.objects.get(pk=project_id)
for subscriber in list(set(issue_subscribers)):
issue_subscribers = list(set(issue_subscribers + issue_assignees) - {uuid.UUID(actor_id)})
for subscriber in issue_subscribers:
if subscriber in issue_subscribers:
sender = "in_app:issue_activities:subscribed"
if issue.created_by_id is not None and subscriber == issue.created_by_id:
sender = "in_app:issue_activities:created"
if subscriber in issue_assignees:
sender = "in_app:issue_activities:assigned"
for issue_activity in issue_activities_created:
issue_comment = issue_activity.get("issue_comment")
if issue_comment is not None:
issue_comment = IssueComment.objects.get(id=issue_comment, issue_id=issue_id, project_id=project_id, workspace_id=project.workspace_id)
issue_comment = IssueComment.objects.get(
id=issue_comment, issue_id=issue_id, project_id=project_id, workspace_id=project.workspace_id)
bulk_notifications.append(
Notification(
workspace=project.workspace,
sender="in_app:issue_activities",
sender=sender,
triggered_by_id=actor_id,
receiver_id=subscriber,
entity_identifier=issue_id,
@@ -215,15 +333,42 @@ def notifications(type, issue_id, project_id, actor_id, subscriber, issue_activi
# Add Mentioned as Issue Subscribers
IssueSubscriber.objects.bulk_create(
mention_subscribers, batch_size=100)
mention_subscribers + comment_mention_subscribers, batch_size=100)
last_activity = (
IssueActivity.objects.filter(issue_id=issue_id)
.order_by("-created_at")
.first()
)
actor = User.objects.get(pk=actor_id)
for mention_id in comment_mentions:
if (mention_id != actor_id):
for issue_activity in issue_activities_created:
notification = createMentionNotification(
project=project,
issue=issue,
notification_comment=f"{actor.display_name} has mentioned you in a comment in issue {issue.name}",
actor_id=actor_id,
mention_id=mention_id,
issue_id=issue_id,
activity=issue_activity
)
bulk_notifications.append(notification)
for mention_id in new_mentions:
if (mention_id != actor_id):
for issue_activity in issue_activities_created:
if (
last_activity is not None
and last_activity.field == "description"
and actor_id == str(last_activity.actor_id)
):
bulk_notifications.append(
Notification(
workspace=project.workspace,
sender="in_app:issue_activities:mention",
Notification(
workspace=project.workspace,
sender="in_app:issue_activities:mentioned",
triggered_by_id=actor_id,
receiver_id=mention_id,
entity_identifier=issue_id,
@@ -237,38 +382,37 @@ def notifications(type, issue_id, project_id, actor_id, subscriber, issue_activi
"identifier": str(issue.project.identifier),
"sequence_id": issue.sequence_id,
"state_name": issue.state.name,
"state_group": issue.state.group,
},
"issue_activity": {
"id": str(issue_activity.get("id")),
"verb": str(issue_activity.get("verb")),
"field": str(issue_activity.get("field")),
"actor": str(issue_activity.get("actor_id")),
"new_value": str(issue_activity.get("new_value")),
"old_value": str(issue_activity.get("old_value")),
},
},
"state_group": issue.state.group,
},
"issue_activity": {
"id": str(last_activity.id),
"verb": str(last_activity.verb),
"field": str(last_activity.field),
"actor": str(last_activity.actor_id),
"new_value": str(last_activity.new_value),
"old_value": str(last_activity.old_value),
},
},
)
)
else:
for issue_activity in issue_activities_created:
notification = createMentionNotification(
project=project,
issue=issue,
notification_comment=f"You have been mentioned in the issue {issue.name}",
actor_id=actor_id,
mention_id=mention_id,
issue_id=issue_id,
activity=issue_activity
)
)
# Create New Mentions Here
aggregated_issue_mentions = []
for mention_id in new_mentions:
mentioned_user = User.objects.get(pk=mention_id)
aggregated_issue_mentions.append(
IssueMention(
mention=mentioned_user,
issue=issue,
project=project,
workspace=project.workspace
)
)
IssueMention.objects.bulk_create(
aggregated_issue_mentions, batch_size=100)
IssueMention.objects.filter(
issue=issue, mention__in=removed_mention).delete()
bulk_notifications.append(notification)
# save new mentions for the particular issue and remove the mentions that has been deleted from the description
update_mentions_for_issue(issue=issue, project=project, new_mentions=new_mentions,
removed_mention=removed_mention)
# Bulk create notifications
Notification.objects.bulk_create(bulk_notifications, batch_size=100)

View File

@@ -19,27 +19,27 @@ This allows for extensive customization and flexibility in the Editors created u
1. useEditor - A hook that you can use to extend the Plane editor.
| Prop | Type | Description |
| --- | --- | --- |
| `extensions` | `Extension[]` | An array of custom extensions you want to add into the editor to extend it's core features |
| `editorProps` | `EditorProps` | Extend the editor props by passing in a custom props object |
| `uploadFile` | `(file: File) => Promise<string>` | A function that handles file upload. It takes a file as input and handles the process of uploading that file. |
| `deleteFile` | `(assetUrlWithWorkspaceId: string) => Promise<any>` | A function that handles deleting an image. It takes the asset url from your bucket and handles the process of deleting that image. |
| `value` | `html string` | The initial content of the editor. |
| `debouncedUpdatesEnabled` | `boolean` | If set to true, the `onChange` event handler is debounced, meaning it will only be invoked after the specified delay (default 1500ms) once the user has stopped typing. |
| `onChange` | `(json: any, html: string) => void` | This function is invoked whenever the content of the editor changes. It is passed the new content in both JSON and HTML formats. |
| `setIsSubmitting` | `(isSubmitting: "submitting" \| "submitted" \| "saved") => void` | This function is called to update the submission status. |
| `setShouldShowAlert` | `(showAlert: boolean) => void` | This function is used to show or hide an alert in case of content not being "saved". |
| `forwardedRef` | `any` | Pass this in whenever you want to control the editor's state from an external component |
| Prop | Type | Description |
| ------------------------- | ---------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `extensions` | `Extension[]` | An array of custom extensions you want to add into the editor to extend it's core features |
| `editorProps` | `EditorProps` | Extend the editor props by passing in a custom props object |
| `uploadFile` | `(file: File) => Promise<string>` | A function that handles file upload. It takes a file as input and handles the process of uploading that file. |
| `deleteFile` | `(assetUrlWithWorkspaceId: string) => Promise<any>` | A function that handles deleting an image. It takes the asset url from your bucket and handles the process of deleting that image. |
| `value` | `html string` | The initial content of the editor. |
| `debouncedUpdatesEnabled` | `boolean` | If set to true, the `onChange` event handler is debounced, meaning it will only be invoked after the specified delay (default 1500ms) once the user has stopped typing. |
| `onChange` | `(json: any, html: string) => void` | This function is invoked whenever the content of the editor changes. It is passed the new content in both JSON and HTML formats. |
| `setIsSubmitting` | `(isSubmitting: "submitting" \| "submitted" \| "saved") => void` | This function is called to update the submission status. |
| `setShouldShowAlert` | `(showAlert: boolean) => void` | This function is used to show or hide an alert in case of content not being "saved". |
| `forwardedRef` | `any` | Pass this in whenever you want to control the editor's state from an external component |
2. useReadOnlyEditor - A hook that can be used to extend a Read Only instance of the core editor.
| Prop | Type | Description |
| --- | --- | --- |
| `value` | `string` | The initial content of the editor. |
| `forwardedRef` | `any` | Pass this in whenever you want to control the editor's state from an external component |
| `extensions` | `Extension[]` | An array of custom extensions you want to add into the editor to extend it's core features |
| `editorProps` | `EditorProps` | Extend the editor props by passing in a custom props object |
| Prop | Type | Description |
| -------------- | ------------- | ------------------------------------------------------------------------------------------ |
| `value` | `string` | The initial content of the editor. |
| `forwardedRef` | `any` | Pass this in whenever you want to control the editor's state from an external component |
| `extensions` | `Extension[]` | An array of custom extensions you want to add into the editor to extend it's core features |
| `editorProps` | `EditorProps` | Extend the editor props by passing in a custom props object |
3. Items and Commands - H1, H2, H3, task list, quote, code block, etc's methods.
@@ -51,7 +51,11 @@ This allows for extensive customization and flexibility in the Editors created u
5. Extending with Custom Styles
```ts
const customEditorClassNames = getEditorClassNames({ noBorder, borderOnFocus, customClassName });
const customEditorClassNames = getEditorClassNames({
noBorder,
borderOnFocus,
customClassName,
});
```
## Core features

View File

@@ -3,18 +3,36 @@ import { UploadImage } from "../types/upload-image";
import { startImageUpload } from "../ui/plugins/upload-image";
export const toggleHeadingOne = (editor: Editor, range?: Range) => {
if (range) editor.chain().focus().deleteRange(range).setNode("heading", { level: 1 }).run();
else editor.chain().focus().toggleHeading({ level: 1 }).run()
if (range)
editor
.chain()
.focus()
.deleteRange(range)
.setNode("heading", { level: 1 })
.run();
else editor.chain().focus().toggleHeading({ level: 1 }).run();
};
export const toggleHeadingTwo = (editor: Editor, range?: Range) => {
if (range) editor.chain().focus().deleteRange(range).setNode("heading", { level: 2 }).run();
else editor.chain().focus().toggleHeading({ level: 2 }).run()
if (range)
editor
.chain()
.focus()
.deleteRange(range)
.setNode("heading", { level: 2 })
.run();
else editor.chain().focus().toggleHeading({ level: 2 }).run();
};
export const toggleHeadingThree = (editor: Editor, range?: Range) => {
if (range) editor.chain().focus().deleteRange(range).setNode("heading", { level: 3 }).run();
else editor.chain().focus().toggleHeading({ level: 3 }).run()
if (range)
editor
.chain()
.focus()
.deleteRange(range)
.setNode("heading", { level: 3 })
.run();
else editor.chain().focus().toggleHeading({ level: 3 }).run();
};
export const toggleBold = (editor: Editor, range?: Range) => {
@@ -37,7 +55,8 @@ export const toggleCode = (editor: Editor, range?: Range) => {
else editor.chain().focus().toggleCode().run();
};
export const toggleOrderedList = (editor: Editor, range?: Range) => {
if (range) editor.chain().focus().deleteRange(range).toggleOrderedList().run();
if (range)
editor.chain().focus().deleteRange(range).toggleOrderedList().run();
else editor.chain().focus().toggleOrderedList().run();
};
@@ -48,7 +67,7 @@ export const toggleBulletList = (editor: Editor, range?: Range) => {
export const toggleTaskList = (editor: Editor, range?: Range) => {
if (range) editor.chain().focus().deleteRange(range).toggleTaskList().run();
else editor.chain().focus().toggleTaskList().run()
else editor.chain().focus().toggleTaskList().run();
};
export const toggleStrike = (editor: Editor, range?: Range) => {
@@ -57,13 +76,37 @@ export const toggleStrike = (editor: Editor, range?: Range) => {
};
export const toggleBlockquote = (editor: Editor, range?: Range) => {
if (range) editor.chain().focus().deleteRange(range).toggleNode("paragraph", "paragraph").toggleBlockquote().run();
else editor.chain().focus().toggleNode("paragraph", "paragraph").toggleBlockquote().run();
if (range)
editor
.chain()
.focus()
.deleteRange(range)
.toggleNode("paragraph", "paragraph")
.toggleBlockquote()
.run();
else
editor
.chain()
.focus()
.toggleNode("paragraph", "paragraph")
.toggleBlockquote()
.run();
};
export const insertTableCommand = (editor: Editor, range?: Range) => {
if (range) editor.chain().focus().deleteRange(range).insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run();
else editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run();
if (range)
editor
.chain()
.focus()
.deleteRange(range)
.insertTable({ rows: 3, cols: 3, withHeaderRow: true })
.run();
else
editor
.chain()
.focus()
.insertTable({ rows: 3, cols: 3, withHeaderRow: true })
.run();
};
export const unsetLinkEditor = (editor: Editor) => {
@@ -74,7 +117,14 @@ export const setLinkEditor = (editor: Editor, url: string) => {
editor.chain().focus().setLink({ href: url }).run();
};
export const insertImageCommand = (editor: Editor, uploadFile: UploadImage, setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void, range?: Range) => {
export const insertImageCommand = (
editor: Editor,
uploadFile: UploadImage,
setIsSubmitting?: (
isSubmitting: "submitting" | "submitted" | "saved",
) => void,
range?: Range,
) => {
if (range) editor.chain().focus().deleteRange(range).run();
const input = document.createElement("input");
input.type = "file";
@@ -88,4 +138,3 @@ export const insertImageCommand = (editor: Editor, uploadFile: UploadImage, setI
};
input.click();
};

View File

@@ -6,19 +6,24 @@ interface EditorClassNames {
customClassName?: string;
}
export const getEditorClassNames = ({ noBorder, borderOnFocus, customClassName }: EditorClassNames) => cn(
'relative w-full max-w-full sm:rounded-lg mt-2 p-3 relative focus:outline-none rounded-md',
noBorder ? '' : 'border border-custom-border-200',
borderOnFocus ? 'focus:border border-custom-border-300' : 'focus:border-0',
customClassName
);
export const getEditorClassNames = ({
noBorder,
borderOnFocus,
customClassName,
}: EditorClassNames) =>
cn(
"relative w-full max-w-full sm:rounded-lg mt-2 p-3 relative focus:outline-none rounded-md",
noBorder ? "" : "border border-custom-border-200",
borderOnFocus ? "focus:border border-custom-border-300" : "focus:border-0",
customClassName,
);
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export const findTableAncestor = (
node: Node | null
node: Node | null,
): HTMLTableElement | null => {
while (node !== null && node.nodeName !== "TABLE") {
node = node.parentNode;
@@ -27,10 +32,10 @@ export const findTableAncestor = (
};
export const getTrimmedHTML = (html: string) => {
html = html.replace(/^(<p><\/p>)+/, '');
html = html.replace(/(<p><\/p>)+$/, '');
html = html.replace(/^(<p><\/p>)+/, "");
html = html.replace(/(<p><\/p>)+$/, "");
return html;
}
};
export const isValidHttpUrl = (string: string): boolean => {
let url: URL;
@@ -42,4 +47,4 @@ export const isValidHttpUrl = (string: string): boolean => {
}
return url.protocol === "http:" || url.protocol === "https:";
}
};

View File

@@ -1,10 +1,10 @@
export type IMentionSuggestion = {
id: string;
type: string;
avatar: string;
title: string;
subtitle: string;
redirect_uri: string;
}
id: string;
type: string;
avatar: string;
title: string;
subtitle: string;
redirect_uri: string;
};
export type IMentionHighlight = string
export type IMentionHighlight = string;

View File

@@ -8,10 +8,16 @@ interface EditorContentProps {
children?: ReactNode;
}
export const EditorContentWrapper = ({ editor, editorContentCustomClassNames = '', children }: EditorContentProps) => (
export const EditorContentWrapper = ({
editor,
editorContentCustomClassNames = "",
children,
}: EditorContentProps) => (
<div className={`contentEditor ${editorContentCustomClassNames}`}>
<EditorContent editor={editor} />
{(editor?.isActive("image") && editor?.isEditable) && <ImageResizer editor={editor} />}
{editor?.isActive("image") && editor?.isEditable && (
<ImageResizer editor={editor} />
)}
{children}
</div>
);

View File

@@ -3,7 +3,9 @@ import Moveable from "react-moveable";
export const ImageResizer = ({ editor }: { editor: Editor }) => {
const updateMediaSize = () => {
const imageInfo = document.querySelector(".ProseMirror-selectednode") as HTMLImageElement;
const imageInfo = document.querySelector(
".ProseMirror-selectednode",
) as HTMLImageElement;
if (imageInfo) {
const selection = editor.state.selection;
editor.commands.setImage({

View File

@@ -3,21 +3,28 @@ import TrackImageDeletionPlugin from "../../plugins/delete-image";
import UploadImagesPlugin from "../../plugins/upload-image";
import { DeleteImage } from "../../../types/delete-image";
const ImageExtension = (deleteImage: DeleteImage) => Image.extend({
addProseMirrorPlugins() {
return [UploadImagesPlugin(), TrackImageDeletionPlugin(deleteImage)];
},
addAttributes() {
return {
...this.parent?.(),
width: {
default: "35%",
},
height: {
default: null,
},
};
},
});
const ImageExtension = (
deleteImage: DeleteImage,
cancelUploadImage?: () => any,
) =>
Image.extend({
addProseMirrorPlugins() {
return [
UploadImagesPlugin(cancelUploadImage),
TrackImageDeletionPlugin(deleteImage),
];
},
addAttributes() {
return {
...this.parent?.(),
width: {
default: "35%",
},
height: {
default: null,
},
};
},
});
export default ImageExtension;

View File

@@ -20,82 +20,89 @@ import { isValidHttpUrl } from "../../lib/utils";
import { IMentionSuggestion } from "../../types/mention-suggestion";
import { Mentions } from "../mentions";
export const CoreEditorExtensions = (
mentionConfig: { mentionSuggestions: IMentionSuggestion[], mentionHighlights: string[] },
mentionConfig: {
mentionSuggestions: IMentionSuggestion[];
mentionHighlights: string[];
},
deleteFile: DeleteImage,
cancelUploadImage?: () => any,
) => [
StarterKit.configure({
bulletList: {
HTMLAttributes: {
class: "list-disc list-outside leading-3 -mt-2",
},
StarterKit.configure({
bulletList: {
HTMLAttributes: {
class: "list-disc list-outside leading-3 -mt-2",
},
orderedList: {
HTMLAttributes: {
class: "list-decimal list-outside leading-3 -mt-2",
},
},
orderedList: {
HTMLAttributes: {
class: "list-decimal list-outside leading-3 -mt-2",
},
listItem: {
HTMLAttributes: {
class: "leading-normal -mb-2",
},
},
listItem: {
HTMLAttributes: {
class: "leading-normal -mb-2",
},
blockquote: {
HTMLAttributes: {
class: "border-l-4 border-custom-border-300",
},
},
blockquote: {
HTMLAttributes: {
class: "border-l-4 border-custom-border-300",
},
code: {
HTMLAttributes: {
class:
"rounded-md bg-custom-primary-30 mx-1 px-1 py-1 font-mono font-medium text-custom-text-1000",
spellcheck: "false",
},
},
codeBlock: false,
horizontalRule: false,
dropcursor: {
color: "rgba(var(--color-text-100))",
width: 2,
},
gapcursor: false,
}),
Gapcursor,
TiptapLink.configure({
protocols: ["http", "https"],
validate: (url) => isValidHttpUrl(url),
},
code: {
HTMLAttributes: {
class:
"text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer",
"rounded-md bg-custom-primary-30 mx-1 px-1 py-1 font-mono font-medium text-custom-text-1000",
spellcheck: "false",
},
}),
ImageExtension(deleteFile).configure({
HTMLAttributes: {
class: "rounded-lg border border-custom-border-300",
},
}),
TiptapUnderline,
TextStyle,
Color,
TaskList.configure({
HTMLAttributes: {
class: "not-prose pl-2",
},
}),
TaskItem.configure({
HTMLAttributes: {
class: "flex items-start my-4",
},
nested: true,
}),
Markdown.configure({
html: true,
transformCopiedText: true,
}),
Table,
TableHeader,
TableCell,
TableRow,
Mentions(mentionConfig.mentionSuggestions, mentionConfig.mentionHighlights, false),
];
},
codeBlock: false,
horizontalRule: false,
dropcursor: {
color: "rgba(var(--color-text-100))",
width: 2,
},
gapcursor: false,
}),
Gapcursor,
TiptapLink.configure({
protocols: ["http", "https"],
validate: (url) => isValidHttpUrl(url),
HTMLAttributes: {
class:
"text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer",
},
}),
ImageExtension(deleteFile, cancelUploadImage).configure({
HTMLAttributes: {
class: "rounded-lg border border-custom-border-300",
},
}),
TiptapUnderline,
TextStyle,
Color,
TaskList.configure({
HTMLAttributes: {
class: "not-prose pl-2",
},
}),
TaskItem.configure({
HTMLAttributes: {
class: "flex items-start my-4",
},
nested: true,
}),
Markdown.configure({
html: true,
transformCopiedText: true,
}),
Table,
TableHeader,
TableCell,
TableRow,
Mentions(
mentionConfig.mentionSuggestions,
mentionConfig.mentionHighlights,
false,
),
];

View File

@@ -1 +1 @@
export { default as default } from "./table-cell"
export { default as default } from "./table-cell";

View File

@@ -1,7 +1,7 @@
import { mergeAttributes, Node } from "@tiptap/core"
import { mergeAttributes, Node } from "@tiptap/core";
export interface TableCellOptions {
HTMLAttributes: Record<string, any>
HTMLAttributes: Record<string, any>;
}
export default Node.create<TableCellOptions>({
@@ -9,8 +9,8 @@ export default Node.create<TableCellOptions>({
addOptions() {
return {
HTMLAttributes: {}
}
HTMLAttributes: {},
};
},
content: "paragraph+",
@@ -18,24 +18,24 @@ export default Node.create<TableCellOptions>({
addAttributes() {
return {
colspan: {
default: 1
default: 1,
},
rowspan: {
default: 1
default: 1,
},
colwidth: {
default: null,
parseHTML: (element) => {
const colwidth = element.getAttribute("colwidth")
const value = colwidth ? [parseInt(colwidth, 10)] : null
const colwidth = element.getAttribute("colwidth");
const value = colwidth ? [parseInt(colwidth, 10)] : null;
return value
}
return value;
},
},
background: {
default: "none"
}
}
default: "none",
},
};
},
tableRole: "cell",
@@ -43,16 +43,16 @@ export default Node.create<TableCellOptions>({
isolating: true,
parseHTML() {
return [{ tag: "td" }]
return [{ tag: "td" }];
},
renderHTML({ node, HTMLAttributes }) {
return [
"td",
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
style: `background-color: ${node.attrs.background}`
style: `background-color: ${node.attrs.background}`,
}),
0
]
}
})
0,
];
},
});

View File

@@ -1 +1 @@
export { default as default } from "./table-header"
export { default as default } from "./table-header";

View File

@@ -1,15 +1,15 @@
import { mergeAttributes, Node } from "@tiptap/core"
import { mergeAttributes, Node } from "@tiptap/core";
export interface TableHeaderOptions {
HTMLAttributes: Record<string, any>
HTMLAttributes: Record<string, any>;
}
export default Node.create<TableHeaderOptions>({
name: "tableHeader",
addOptions() {
return {
HTMLAttributes: {}
}
HTMLAttributes: {},
};
},
content: "paragraph+",
@@ -17,24 +17,24 @@ export default Node.create<TableHeaderOptions>({
addAttributes() {
return {
colspan: {
default: 1
default: 1,
},
rowspan: {
default: 1
default: 1,
},
colwidth: {
default: null,
parseHTML: (element) => {
const colwidth = element.getAttribute("colwidth")
const value = colwidth ? [parseInt(colwidth, 10)] : null
const colwidth = element.getAttribute("colwidth");
const value = colwidth ? [parseInt(colwidth, 10)] : null;
return value
}
return value;
},
},
background: {
default: "rgb(var(--color-primary-100))"
}
}
default: "rgb(var(--color-primary-100))",
},
};
},
tableRole: "header_cell",
@@ -42,16 +42,16 @@ export default Node.create<TableHeaderOptions>({
isolating: true,
parseHTML() {
return [{ tag: "th" }]
return [{ tag: "th" }];
},
renderHTML({ node, HTMLAttributes }) {
return [
"th",
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
style: `background-color: ${node.attrs.background}`
style: `background-color: ${node.attrs.background}`,
}),
0
]
}
})
0,
];
},
});

View File

@@ -1 +1 @@
export { default as default } from "./table-row"
export { default as default } from "./table-row";

View File

@@ -1,31 +1,31 @@
import { mergeAttributes, Node } from "@tiptap/core"
import { mergeAttributes, Node } from "@tiptap/core";
export interface TableRowOptions {
HTMLAttributes: Record<string, any>
HTMLAttributes: Record<string, any>;
}
export default Node.create<TableRowOptions>({
name: "tableRow",
name: "tableRow",
addOptions() {
return {
HTMLAttributes: {}
}
},
addOptions() {
return {
HTMLAttributes: {},
};
},
content: "(tableCell | tableHeader)*",
content: "(tableCell | tableHeader)*",
tableRole: "row",
tableRole: "row",
parseHTML() {
return [{ tag: "tr" }]
},
parseHTML() {
return [{ tag: "tr" }];
},
renderHTML({ HTMLAttributes }) {
return [
"tr",
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes),
0
]
}
})
renderHTML({ HTMLAttributes }) {
return [
"tr",
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes),
0,
];
},
});

View File

@@ -38,7 +38,7 @@ const icons = {
/>
</svg>
`,
insertBottomTableIcon:`<svg
insertBottomTableIcon: `<svg
xmlns="http://www.w3.org/2000/svg"
width={24}
height={24}

View File

@@ -1 +1 @@
export { default as default } from "./table"
export { default as default } from "./table";

View File

@@ -68,7 +68,12 @@ export function tableControls() {
const { hoveredTable, hoveredCell } = pluginState.values;
const docSize = state.doc.content.size;
if (hoveredTable && hoveredCell && hoveredTable.pos < docSize && hoveredCell.pos < docSize) {
if (
hoveredTable &&
hoveredCell &&
hoveredTable.pos < docSize &&
hoveredCell.pos < docSize
) {
const decorations = [
Decoration.node(
hoveredTable.pos,

View File

@@ -202,6 +202,7 @@ function createToolbox({
"div",
{
className: "toolboxItem",
itemType: "button",
onClick() {
onClickItem(item);
},
@@ -253,6 +254,7 @@ function createColorPickerToolbox({
"div",
{
className: "toolboxItem",
itemType: "button",
onClick: () => {
onSelectColor(value);
colorPicker.hide();
@@ -331,7 +333,9 @@ export class TableView implements NodeView {
this.rowsControl = h(
"div",
{ className: "rowsControl" },
h("button", {
h("div", {
itemType: "button",
className: "rowsControlDiv",
onClick: () => this.selectRow(),
}),
);
@@ -339,7 +343,9 @@ export class TableView implements NodeView {
this.columnsControl = h(
"div",
{ className: "columnsControl" },
h("button", {
h("div", {
itemType: "button",
className: "columnsControlDiv",
onClick: () => this.selectColumn(),
}),
);
@@ -352,7 +358,7 @@ export class TableView implements NodeView {
);
this.columnsToolbox = createToolbox({
triggerButton: this.columnsControl.querySelector("button"),
triggerButton: this.columnsControl.querySelector(".columnsControlDiv"),
items: columnsToolboxItems,
tippyOptions: {
...defaultTippyOptions,

View File

@@ -1,298 +1,312 @@
import { TextSelection } from "@tiptap/pm/state"
import { TextSelection } from "@tiptap/pm/state";
import { callOrReturn, getExtensionField, mergeAttributes, Node, ParentConfig } from "@tiptap/core"
import {
addColumnAfter,
addColumnBefore,
addRowAfter,
addRowBefore,
CellSelection,
columnResizing,
deleteColumn,
deleteRow,
deleteTable,
fixTables,
goToNextCell,
mergeCells,
setCellAttr,
splitCell,
tableEditing,
toggleHeader,
toggleHeaderCell
} from "@tiptap/prosemirror-tables"
callOrReturn,
getExtensionField,
mergeAttributes,
Node,
ParentConfig,
} from "@tiptap/core";
import {
addColumnAfter,
addColumnBefore,
addRowAfter,
addRowBefore,
CellSelection,
columnResizing,
deleteColumn,
deleteRow,
deleteTable,
fixTables,
goToNextCell,
mergeCells,
setCellAttr,
splitCell,
tableEditing,
toggleHeader,
toggleHeaderCell,
} from "@tiptap/prosemirror-tables";
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 { 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";
export interface TableOptions {
HTMLAttributes: Record<string, any>
resizable: boolean
handleWidth: number
cellMinWidth: number
lastColumnResizable: boolean
allowTableNodeSelection: boolean
HTMLAttributes: Record<string, any>;
resizable: boolean;
handleWidth: number;
cellMinWidth: number;
lastColumnResizable: boolean;
allowTableNodeSelection: boolean;
}
declare module "@tiptap/core" {
interface Commands<ReturnType> {
table: {
insertTable: (options?: {
rows?: number
cols?: number
withHeaderRow?: boolean
}) => ReturnType
addColumnBefore: () => ReturnType
addColumnAfter: () => ReturnType
deleteColumn: () => ReturnType
addRowBefore: () => ReturnType
addRowAfter: () => ReturnType
deleteRow: () => ReturnType
deleteTable: () => ReturnType
mergeCells: () => ReturnType
splitCell: () => ReturnType
toggleHeaderColumn: () => ReturnType
toggleHeaderRow: () => ReturnType
toggleHeaderCell: () => ReturnType
mergeOrSplit: () => ReturnType
setCellAttribute: (name: string, value: any) => ReturnType
goToNextCell: () => ReturnType
goToPreviousCell: () => ReturnType
fixTables: () => ReturnType
setCellSelection: (position: {
anchorCell: number
headCell?: number
}) => ReturnType
}
}
interface Commands<ReturnType> {
table: {
insertTable: (options?: {
rows?: number;
cols?: number;
withHeaderRow?: boolean;
}) => ReturnType;
addColumnBefore: () => ReturnType;
addColumnAfter: () => ReturnType;
deleteColumn: () => ReturnType;
addRowBefore: () => ReturnType;
addRowAfter: () => ReturnType;
deleteRow: () => ReturnType;
deleteTable: () => ReturnType;
mergeCells: () => ReturnType;
splitCell: () => ReturnType;
toggleHeaderColumn: () => ReturnType;
toggleHeaderRow: () => ReturnType;
toggleHeaderCell: () => ReturnType;
mergeOrSplit: () => ReturnType;
setCellAttribute: (name: string, value: any) => ReturnType;
goToNextCell: () => ReturnType;
goToPreviousCell: () => ReturnType;
fixTables: () => ReturnType;
setCellSelection: (position: {
anchorCell: number;
headCell?: number;
}) => ReturnType;
};
}
interface NodeConfig<Options, Storage> {
tableRole?:
| string
| ((this: {
name: string
options: Options
storage: Storage
parent: ParentConfig<NodeConfig<Options>>["tableRole"]
}) => string)
}
interface NodeConfig<Options, Storage> {
tableRole?:
| string
| ((this: {
name: string;
options: Options;
storage: Storage;
parent: ParentConfig<NodeConfig<Options>>["tableRole"];
}) => string);
}
}
export default Node.create({
name: "table",
name: "table",
addOptions() {
return {
HTMLAttributes: {},
resizable: true,
handleWidth: 5,
cellMinWidth: 100,
lastColumnResizable: true,
allowTableNodeSelection: true
}
},
addOptions() {
return {
HTMLAttributes: {},
resizable: true,
handleWidth: 5,
cellMinWidth: 100,
lastColumnResizable: true,
allowTableNodeSelection: true,
};
},
content: "tableRow+",
content: "tableRow+",
tableRole: "table",
tableRole: "table",
isolating: true,
isolating: true,
group: "block",
group: "block",
allowGapCursor: false,
allowGapCursor: false,
parseHTML() {
return [{ tag: "table" }]
},
parseHTML() {
return [{ tag: "table" }];
},
renderHTML({ HTMLAttributes }) {
return [
"table",
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes),
["tbody", 0]
]
},
renderHTML({ HTMLAttributes }) {
return [
"table",
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes),
["tbody", 0],
];
},
addCommands() {
return {
insertTable:
({ rows = 3, cols = 3, withHeaderRow = true} = {}) =>
({ tr, dispatch, editor }) => {
const node = createTable(
editor.schema,
rows,
cols,
withHeaderRow
)
addCommands() {
return {
insertTable:
({ rows = 3, cols = 3, withHeaderRow = true } = {}) =>
({ tr, dispatch, editor }) => {
const node = createTable(editor.schema, rows, cols, withHeaderRow);
if (dispatch) {
const offset = tr.selection.anchor + 1
if (dispatch) {
const offset = tr.selection.anchor + 1;
tr.replaceSelectionWith(node)
.scrollIntoView()
.setSelection(
TextSelection.near(tr.doc.resolve(offset))
)
}
tr.replaceSelectionWith(node)
.scrollIntoView()
.setSelection(TextSelection.near(tr.doc.resolve(offset)));
}
return true
},
addColumnBefore:
() =>
({ state, dispatch }) => addColumnBefore(state, dispatch),
addColumnAfter:
() =>
({ state, dispatch }) => addColumnAfter(state, dispatch),
deleteColumn:
() =>
({ state, dispatch }) => deleteColumn(state, dispatch),
addRowBefore:
() =>
({ state, dispatch }) => addRowBefore(state, dispatch),
addRowAfter:
() =>
({ state, dispatch }) => addRowAfter(state, dispatch),
deleteRow:
() =>
({ state, dispatch }) => deleteRow(state, dispatch),
deleteTable:
() =>
({ state, dispatch }) => deleteTable(state, dispatch),
mergeCells:
() =>
({ state, dispatch }) => mergeCells(state, dispatch),
splitCell:
() =>
({ state, dispatch }) => splitCell(state, dispatch),
toggleHeaderColumn:
() =>
({ state, dispatch }) => toggleHeader("column")(state, dispatch),
toggleHeaderRow:
() =>
({ state, dispatch }) => toggleHeader("row")(state, dispatch),
toggleHeaderCell:
() =>
({ state, dispatch }) => toggleHeaderCell(state, dispatch),
mergeOrSplit:
() =>
({ state, dispatch }) => {
if (mergeCells(state, dispatch)) {
return true
}
return true;
},
addColumnBefore:
() =>
({ state, dispatch }) =>
addColumnBefore(state, dispatch),
addColumnAfter:
() =>
({ state, dispatch }) =>
addColumnAfter(state, dispatch),
deleteColumn:
() =>
({ state, dispatch }) =>
deleteColumn(state, dispatch),
addRowBefore:
() =>
({ state, dispatch }) =>
addRowBefore(state, dispatch),
addRowAfter:
() =>
({ state, dispatch }) =>
addRowAfter(state, dispatch),
deleteRow:
() =>
({ state, dispatch }) =>
deleteRow(state, dispatch),
deleteTable:
() =>
({ state, dispatch }) =>
deleteTable(state, dispatch),
mergeCells:
() =>
({ state, dispatch }) =>
mergeCells(state, dispatch),
splitCell:
() =>
({ state, dispatch }) =>
splitCell(state, dispatch),
toggleHeaderColumn:
() =>
({ state, dispatch }) =>
toggleHeader("column")(state, dispatch),
toggleHeaderRow:
() =>
({ state, dispatch }) =>
toggleHeader("row")(state, dispatch),
toggleHeaderCell:
() =>
({ state, dispatch }) =>
toggleHeaderCell(state, dispatch),
mergeOrSplit:
() =>
({ state, dispatch }) => {
if (mergeCells(state, dispatch)) {
return true;
}
return splitCell(state, dispatch)
},
setCellAttribute:
(name, value) =>
({ state, dispatch }) => setCellAttr(name, value)(state, dispatch),
goToNextCell:
() =>
({ state, dispatch }) => goToNextCell(1)(state, dispatch),
goToPreviousCell:
() =>
({ state, dispatch }) => goToNextCell(-1)(state, dispatch),
fixTables:
() =>
({ state, dispatch }) => {
if (dispatch) {
fixTables(state)
}
return splitCell(state, dispatch);
},
setCellAttribute:
(name, value) =>
({ state, dispatch }) =>
setCellAttr(name, value)(state, dispatch),
goToNextCell:
() =>
({ state, dispatch }) =>
goToNextCell(1)(state, dispatch),
goToPreviousCell:
() =>
({ state, dispatch }) =>
goToNextCell(-1)(state, dispatch),
fixTables:
() =>
({ state, dispatch }) => {
if (dispatch) {
fixTables(state);
}
return true
},
setCellSelection:
(position) =>
({ tr, dispatch }) => {
if (dispatch) {
const selection = CellSelection.create(
tr.doc,
position.anchorCell,
position.headCell
)
return true;
},
setCellSelection:
(position) =>
({ tr, dispatch }) => {
if (dispatch) {
const selection = CellSelection.create(
tr.doc,
position.anchorCell,
position.headCell,
);
// @ts-ignore
tr.setSelection(selection)
}
// @ts-ignore
tr.setSelection(selection);
}
return true
}
}
},
return true;
},
};
},
addKeyboardShortcuts() {
return {
Tab: () => {
if (this.editor.commands.goToNextCell()) {
return true
}
if (!this.editor.can().addRowAfter()) {
return false
}
return this.editor.chain().addRowAfter().goToNextCell().run()
},
"Shift-Tab": () => this.editor.commands.goToPreviousCell(),
Backspace: deleteTableWhenAllCellsSelected,
"Mod-Backspace": deleteTableWhenAllCellsSelected,
Delete: deleteTableWhenAllCellsSelected,
"Mod-Delete": deleteTableWhenAllCellsSelected
}
},
addNodeView() {
return ({ editor, getPos, node, decorations }) => {
const { cellMinWidth } = this.options
return new TableView(
node,
cellMinWidth,
decorations,
editor,
getPos as () => number
)
}
},
addProseMirrorPlugins() {
const isResizable = this.options.resizable && this.editor.isEditable
const plugins = [
tableEditing({
allowTableNodeSelection: this.options.allowTableNodeSelection
}),
tableControls()
]
if (isResizable) {
plugins.unshift(
columnResizing({
handleWidth: this.options.handleWidth,
cellMinWidth: this.options.cellMinWidth,
// View: TableView,
// @ts-ignore
lastColumnResizable: this.options.lastColumnResizable
})
)
addKeyboardShortcuts() {
return {
Tab: () => {
if (this.editor.commands.goToNextCell()) {
return true;
}
return plugins
},
extendNodeSchema(extension) {
const context = {
name: extension.name,
options: extension.options,
storage: extension.storage
if (!this.editor.can().addRowAfter()) {
return false;
}
return {
tableRole: callOrReturn(
getExtensionField(extension, "tableRole", context)
)
}
return this.editor.chain().addRowAfter().goToNextCell().run();
},
"Shift-Tab": () => this.editor.commands.goToPreviousCell(),
Backspace: deleteTableWhenAllCellsSelected,
"Mod-Backspace": deleteTableWhenAllCellsSelected,
Delete: deleteTableWhenAllCellsSelected,
"Mod-Delete": deleteTableWhenAllCellsSelected,
};
},
addNodeView() {
return ({ editor, getPos, node, decorations }) => {
const { cellMinWidth } = this.options;
return new TableView(
node,
cellMinWidth,
decorations,
editor,
getPos as () => number,
);
};
},
addProseMirrorPlugins() {
const isResizable = this.options.resizable && this.editor.isEditable;
const plugins = [
tableEditing({
allowTableNodeSelection: this.options.allowTableNodeSelection,
}),
tableControls(),
];
if (isResizable) {
plugins.unshift(
columnResizing({
handleWidth: this.options.handleWidth,
cellMinWidth: this.options.cellMinWidth,
// View: TableView,
// @ts-ignore
lastColumnResizable: this.options.lastColumnResizable,
}),
);
}
})
return plugins;
},
extendNodeSchema(extension) {
const context = {
name: extension.name,
options: extension.options,
storage: extension.storage,
};
return {
tableRole: callOrReturn(
getExtensionField(extension, "tableRole", context),
),
};
},
});

View File

@@ -1,12 +1,12 @@
import { Fragment, Node as ProsemirrorNode, NodeType } from "prosemirror-model"
import { Fragment, Node as ProsemirrorNode, NodeType } from "prosemirror-model";
export function createCell(
cellType: NodeType,
cellContent?: Fragment | ProsemirrorNode | Array<ProsemirrorNode>
cellType: NodeType,
cellContent?: Fragment | ProsemirrorNode | Array<ProsemirrorNode>,
): ProsemirrorNode | null | undefined {
if (cellContent) {
return cellType.createChecked(null, cellContent)
}
if (cellContent) {
return cellType.createChecked(null, cellContent);
}
return cellType.createAndFill()
return cellType.createAndFill();
}

View File

@@ -1,45 +1,45 @@
import { Fragment, Node as ProsemirrorNode, Schema } from "@tiptap/pm/model"
import { Fragment, Node as ProsemirrorNode, Schema } from "@tiptap/pm/model";
import { createCell } from "./create-cell"
import { getTableNodeTypes } from "./get-table-node-types"
import { createCell } from "./create-cell";
import { getTableNodeTypes } from "./get-table-node-types";
export function createTable(
schema: Schema,
rowsCount: number,
colsCount: number,
withHeaderRow: boolean,
cellContent?: Fragment | ProsemirrorNode | Array<ProsemirrorNode>
schema: Schema,
rowsCount: number,
colsCount: number,
withHeaderRow: boolean,
cellContent?: Fragment | ProsemirrorNode | Array<ProsemirrorNode>,
): ProsemirrorNode {
const types = getTableNodeTypes(schema)
const headerCells: ProsemirrorNode[] = []
const cells: ProsemirrorNode[] = []
const types = getTableNodeTypes(schema);
const headerCells: ProsemirrorNode[] = [];
const cells: ProsemirrorNode[] = [];
for (let index = 0; index < colsCount; index += 1) {
const cell = createCell(types.cell, cellContent)
for (let index = 0; index < colsCount; index += 1) {
const cell = createCell(types.cell, cellContent);
if (cell) {
cells.push(cell)
}
if (withHeaderRow) {
const headerCell = createCell(types.header_cell, cellContent)
if (headerCell) {
headerCells.push(headerCell)
}
}
if (cell) {
cells.push(cell);
}
const rows: ProsemirrorNode[] = []
if (withHeaderRow) {
const headerCell = createCell(types.header_cell, cellContent);
for (let index = 0; index < rowsCount; index += 1) {
rows.push(
types.row.createChecked(
null,
withHeaderRow && index === 0 ? headerCells : cells
)
)
if (headerCell) {
headerCells.push(headerCell);
}
}
}
return types.table.createChecked(null, rows)
const rows: ProsemirrorNode[] = [];
for (let index = 0; index < rowsCount; index += 1) {
rows.push(
types.row.createChecked(
null,
withHeaderRow && index === 0 ? headerCells : cells,
),
);
}
return types.table.createChecked(null, rows);
}

View File

@@ -1,39 +1,42 @@
import { findParentNodeClosestToPos, KeyboardShortcutCommand } from "@tiptap/core"
import {
findParentNodeClosestToPos,
KeyboardShortcutCommand,
} from "@tiptap/core";
import { isCellSelection } from "./is-cell-selection"
import { isCellSelection } from "./is-cell-selection";
export const deleteTableWhenAllCellsSelected: KeyboardShortcutCommand = ({
editor
editor,
}) => {
const { selection } = editor.state
const { selection } = editor.state;
if (!isCellSelection(selection)) {
return false
if (!isCellSelection(selection)) {
return false;
}
let cellCount = 0;
const table = findParentNodeClosestToPos(
selection.ranges[0].$from,
(node) => node.type.name === "table",
);
table?.node.descendants((node) => {
if (node.type.name === "table") {
return false;
}
let cellCount = 0
const table = findParentNodeClosestToPos(
selection.ranges[0].$from,
(node) => node.type.name === "table"
)
table?.node.descendants((node) => {
if (node.type.name === "table") {
return false
}
if (["tableCell", "tableHeader"].includes(node.type.name)) {
cellCount += 1
}
})
const allCellsSelected = cellCount === selection.ranges.length
if (!allCellsSelected) {
return false
if (["tableCell", "tableHeader"].includes(node.type.name)) {
cellCount += 1;
}
});
editor.commands.deleteTable()
const allCellsSelected = cellCount === selection.ranges.length;
return true
}
if (!allCellsSelected) {
return false;
}
editor.commands.deleteTable();
return true;
};

View File

@@ -1,21 +1,21 @@
import { NodeType, Schema } from "prosemirror-model"
import { NodeType, Schema } from "prosemirror-model";
export function getTableNodeTypes(schema: Schema): { [key: string]: NodeType } {
if (schema.cached.tableNodeTypes) {
return schema.cached.tableNodeTypes
if (schema.cached.tableNodeTypes) {
return schema.cached.tableNodeTypes;
}
const roles: { [key: string]: NodeType } = {};
Object.keys(schema.nodes).forEach((type) => {
const nodeType = schema.nodes[type];
if (nodeType.spec.tableRole) {
roles[nodeType.spec.tableRole] = nodeType;
}
});
const roles: { [key: string]: NodeType } = {}
schema.cached.tableNodeTypes = roles;
Object.keys(schema.nodes).forEach((type) => {
const nodeType = schema.nodes[type]
if (nodeType.spec.tableRole) {
roles[nodeType.spec.tableRole] = nodeType
}
})
schema.cached.tableNodeTypes = roles
return roles
return roles;
}

View File

@@ -1,5 +1,5 @@
import { CellSelection } from "@tiptap/prosemirror-tables"
import { CellSelection } from "@tiptap/prosemirror-tables";
export function isCellSelection(value: unknown): value is CellSelection {
return value instanceof CellSelection
return value instanceof CellSelection;
}

View File

@@ -29,11 +29,13 @@ interface CustomEditorProps {
forwardedRef?: any;
mentionHighlights?: string[];
mentionSuggestions?: IMentionSuggestion[];
cancelUploadImage?: () => any;
}
export const useEditor = ({
uploadFile,
deleteFile,
cancelUploadImage,
editorProps = {},
value,
extensions = [],
@@ -42,7 +44,7 @@ export const useEditor = ({
forwardedRef,
setShouldShowAlert,
mentionHighlights,
mentionSuggestions
mentionSuggestions,
}: CustomEditorProps) => {
const editor = useCustomEditor(
{
@@ -50,7 +52,17 @@ export const useEditor = ({
...CoreEditorProps(uploadFile, setIsSubmitting),
...editorProps,
},
extensions: [...CoreEditorExtensions({ mentionSuggestions: mentionSuggestions ?? [], mentionHighlights: mentionHighlights ?? []}, deleteFile), ...extensions],
extensions: [
...CoreEditorExtensions(
{
mentionSuggestions: mentionSuggestions ?? [],
mentionHighlights: mentionHighlights ?? [],
},
deleteFile,
cancelUploadImage,
),
...extensions,
],
content:
typeof value === "string" && value.trim() !== "" ? value : "<p></p>",
onUpdate: async ({ editor }) => {
@@ -82,4 +94,4 @@ export const useEditor = ({
}
return editor;
};
};

View File

@@ -7,7 +7,7 @@ import {
} from "react";
import { CoreReadOnlyEditorExtensions } from "../../ui/read-only/extensions";
import { CoreReadOnlyEditorProps } from "../../ui/read-only/props";
import { EditorProps } from '@tiptap/pm/view';
import { EditorProps } from "@tiptap/pm/view";
import { IMentionSuggestion } from "../../types/mention-suggestion";
interface CustomReadOnlyEditorProps {
@@ -19,7 +19,14 @@ interface CustomReadOnlyEditorProps {
mentionSuggestions?: IMentionSuggestion[];
}
export const useReadOnlyEditor = ({ value, forwardedRef, extensions = [], editorProps = {}, mentionHighlights, mentionSuggestions}: CustomReadOnlyEditorProps) => {
export const useReadOnlyEditor = ({
value,
forwardedRef,
extensions = [],
editorProps = {},
mentionHighlights,
mentionSuggestions,
}: CustomReadOnlyEditorProps) => {
const editor = useCustomEditor({
editable: false,
content:
@@ -28,7 +35,13 @@ export const useReadOnlyEditor = ({ value, forwardedRef, extensions = [], editor
...CoreReadOnlyEditorProps,
...editorProps,
},
extensions: [...CoreReadOnlyEditorExtensions({ mentionSuggestions: mentionSuggestions ?? [], mentionHighlights: mentionHighlights ?? []}), ...extensions],
extensions: [
...CoreReadOnlyEditorExtensions({
mentionSuggestions: mentionSuggestions ?? [],
mentionHighlights: mentionHighlights ?? [],
}),
...extensions,
],
});
const hasIntiliazedContent = useRef(false);

View File

@@ -1,11 +1,11 @@
import { Mention, MentionOptions } from '@tiptap/extension-mention'
import { mergeAttributes } from '@tiptap/core'
import { ReactNodeViewRenderer } from '@tiptap/react'
import mentionNodeView from './mentionNodeView'
import { IMentionHighlight } from '../../types/mention-suggestion'
import { Mention, MentionOptions } from "@tiptap/extension-mention";
import { mergeAttributes } from "@tiptap/core";
import { ReactNodeViewRenderer } from "@tiptap/react";
import mentionNodeView from "./mentionNodeView";
import { IMentionHighlight } from "../../types/mention-suggestion";
export interface CustomMentionOptions extends MentionOptions {
mentionHighlights: IMentionHighlight[]
readonly?: boolean
mentionHighlights: IMentionHighlight[];
readonly?: boolean;
}
export const CustomMention = Mention.extend<CustomMentionOptions>({
@@ -21,35 +21,37 @@ export const CustomMention = Mention.extend<CustomMentionOptions>({
default: null,
},
self: {
default: false
default: false,
},
redirect_uri: {
default: "/"
}
}
default: "/",
},
};
},
addNodeView() {
return ReactNodeViewRenderer(mentionNodeView)
return ReactNodeViewRenderer(mentionNodeView);
},
parseHTML() {
return [{
tag: 'mention-component',
getAttrs: (node: string | HTMLElement) => {
if (typeof node === 'string') {
return null;
}
return {
id: node.getAttribute('data-mention-id') || '',
target: node.getAttribute('data-mention-target') || '',
label: node.innerText.slice(1) || '',
redirect_uri: node.getAttribute('redirect_uri')
}
return [
{
tag: "mention-component",
getAttrs: (node: string | HTMLElement) => {
if (typeof node === "string") {
return null;
}
return {
id: node.getAttribute("data-mention-id") || "",
target: node.getAttribute("data-mention-target") || "",
label: node.innerText.slice(1) || "",
redirect_uri: node.getAttribute("redirect_uri"),
};
},
},
}]
];
},
renderHTML({ HTMLAttributes }) {
return ['mention-component', mergeAttributes(HTMLAttributes)]
return ["mention-component", mergeAttributes(HTMLAttributes)];
},
})
});

View File

@@ -2,14 +2,21 @@
import suggestion from "./suggestion";
import { CustomMention } from "./custom";
import { IMentionHighlight, IMentionSuggestion } from "../../types/mention-suggestion";
export const Mentions = (mentionSuggestions: IMentionSuggestion[], mentionHighlights: IMentionHighlight[], readonly) => CustomMention.configure({
HTMLAttributes: {
'class' : "mention",
},
readonly: readonly,
mentionHighlights: mentionHighlights,
suggestion: suggestion(mentionSuggestions),
})
import {
IMentionHighlight,
IMentionSuggestion,
} from "../../types/mention-suggestion";
export const Mentions = (
mentionSuggestions: IMentionSuggestion[],
mentionHighlights: IMentionHighlight[],
readonly,
) =>
CustomMention.configure({
HTMLAttributes: {
class: "mention",
},
readonly: readonly,
mentionHighlights: mentionHighlights,
suggestion: suggestion(mentionSuggestions),
});

View File

@@ -1,12 +1,17 @@
import { ReactRenderer } from '@tiptap/react'
import { ReactRenderer } from "@tiptap/react";
import { Editor } from "@tiptap/core";
import tippy from 'tippy.js'
import tippy from "tippy.js";
import MentionList from './MentionList'
import { IMentionSuggestion } from '../../types/mention-suggestion';
import MentionList from "./MentionList";
import { IMentionSuggestion } from "../../types/mention-suggestion";
const Suggestion = (suggestions: IMentionSuggestion[]) => ({
items: ({ query }: { query: string }) => suggestions.filter(suggestion => suggestion.title.toLowerCase().startsWith(query.toLowerCase())).slice(0, 5),
items: ({ query }: { query: string }) =>
suggestions
.filter((suggestion) =>
suggestion.title.toLowerCase().startsWith(query.toLowerCase()),
)
.slice(0, 5),
render: () => {
let reactRenderer: ReactRenderer | null = null;
let popup: any | null = null;
@@ -30,7 +35,7 @@ const Suggestion = (suggestions: IMentionSuggestion[]) => ({
},
onUpdate: (props: { editor: Editor; clientRect: DOMRect }) => {
reactRenderer?.updateProps(props)
reactRenderer?.updateProps(props);
popup &&
popup[0].setProps({
@@ -49,11 +54,10 @@ const Suggestion = (suggestions: IMentionSuggestion[]) => ({
},
onExit: () => {
popup?.[0].destroy();
reactRenderer?.destroy()
reactRenderer?.destroy();
},
}
};
},
})
});
export default Suggestion;

View File

@@ -1,16 +0,0 @@
const InsertBottomTableIcon = (props: any) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={24}
height={24}
viewBox="0 -960 960 960"
{...props}
>
<path
d="M212.309-152.31q-30.308 0-51.308-21t-21-51.307V-360q0-30.307 21-51.307 21-21 51.308-21h535.382q30.308 0 51.308 21t21 51.307v135.383q0 30.307-21 51.307-21 21-51.308 21H212.309Zm0-375.383q-30.308 0-51.308-21t-21-51.307v-135.383q0-30.307 21-51.307 21-21 51.308-21h535.382q30.308 0 51.308 21t21 51.307V-600q0 30.307-21 51.307-21 21-51.308 21H212.309Zm535.382-219.998H212.309q-4.616 0-8.463 3.846-3.846 3.846-3.846 8.462V-600q0 4.616 3.846 8.462 3.847 3.847 8.463 3.847h535.382q4.616 0 8.463-3.847Q760-595.384 760-600v-135.383q0-4.616-3.846-8.462-3.847-3.846-8.463-3.846ZM200-587.691v-160 160Z"
fill="rgb(var(--color-text-300))"
/>
</svg>
);
export default InsertBottomTableIcon;

View File

@@ -1,15 +0,0 @@
const InsertLeftTableIcon = (props: any) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={24}
height={24}
viewBox="0 -960 960 960"
{...props}
>
<path
d="M224.617-140.001q-30.307 0-51.307-21-21-21-21-51.308v-535.382q0-30.308 21-51.308t51.307-21H360q30.307 0 51.307 21 21 21 21 51.308v535.382q0 30.308-21 51.308t-51.307 21H224.617Zm375.383 0q-30.307 0-51.307-21-21-21-21-51.308v-535.382q0-30.308 21-51.308t51.307-21h135.383q30.307 0 51.307 21 21 21 21 51.308v535.382q0 30.308-21 51.308t-51.307 21H600Zm147.691-607.69q0-4.616-3.846-8.463-3.846-3.846-8.462-3.846H600q-4.616 0-8.462 3.846-3.847 3.847-3.847 8.463v535.382q0 4.616 3.847 8.463Q595.384-200 600-200h135.383q4.616 0 8.462-3.846 3.846-3.847 3.846-8.463v-535.382ZM587.691-200h160-160Z"
fill="rgb(var(--color-text-300))"
/>
</svg>
);
export default InsertLeftTableIcon;

View File

@@ -1,16 +0,0 @@
const InsertRightTableIcon = (props: any) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={24}
height={24}
viewBox="0 -960 960 960"
{...props}
>
<path
d="M600-140.001q-30.307 0-51.307-21-21-21-21-51.308v-535.382q0-30.308 21-51.308t51.307-21h135.383q30.307 0 51.307 21 21 21 21 51.308v535.382q0 30.308-21 51.308t-51.307 21H600Zm-375.383 0q-30.307 0-51.307-21-21-21-21-51.308v-535.382q0-30.308 21-51.308t51.307-21H360q30.307 0 51.307 21 21 21 21 51.308v535.382q0 30.308-21 51.308t-51.307 21H224.617Zm-12.308-607.69v535.382q0 4.616 3.846 8.463 3.846 3.846 8.462 3.846H360q4.616 0 8.462-3.846 3.847-3.847 3.847-8.463v-535.382q0-4.616-3.847-8.463Q364.616-760 360-760H224.617q-4.616 0-8.462 3.846-3.846 3.847-3.846 8.463Zm160 547.691h-160 160Z"
fill="rgb(var(--color-text-300))"
/>
</svg>
);
export default InsertRightTableIcon;

View File

@@ -1,15 +0,0 @@
const InsertTopTableIcon = (props: any) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={24}
height={24}
viewBox="0 -960 960 960"
{...props}
>
<path
d="M212.309-527.693q-30.308 0-51.308-21t-21-51.307v-135.383q0-30.307 21-51.307 21-21 51.308-21h535.382q30.308 0 51.308 21t21 51.307V-600q0 30.307-21 51.307-21 21-51.308 21H212.309Zm0 375.383q-30.308 0-51.308-21t-21-51.307V-360q0-30.307 21-51.307 21-21 51.308-21h535.382q30.308 0 51.308 21t21 51.307v135.383q0 30.307-21 51.307-21 21-51.308 21H212.309Zm0-59.999h535.382q4.616 0 8.463-3.846 3.846-3.846 3.846-8.462V-360q0-4.616-3.846-8.462-3.847-3.847-8.463-3.847H212.309q-4.616 0-8.463 3.847Q200-364.616 200-360v135.383q0 4.616 3.846 8.462 3.847 3.846 8.463 3.846Zm-12.309-160v160-160Z"
fill="rgb(var(--color-text-300))"
/>
</svg>
);
export default InsertTopTableIcon;

View File

@@ -1,77 +0,0 @@
import * as React from 'react';
// next-themes
import { useTheme } from "next-themes";
// tooltip2
import { Tooltip2 } from "@blueprintjs/popover2";
type Props = {
tooltipHeading?: string;
tooltipContent: string | React.ReactNode;
position?:
| "top"
| "right"
| "bottom"
| "left"
| "auto"
| "auto-end"
| "auto-start"
| "bottom-left"
| "bottom-right"
| "left-bottom"
| "left-top"
| "right-bottom"
| "right-top"
| "top-left"
| "top-right";
children: JSX.Element;
disabled?: boolean;
className?: string;
openDelay?: number;
closeDelay?: number;
};
export const Tooltip: React.FC<Props> = ({
tooltipHeading,
tooltipContent,
position = "top",
children,
disabled = false,
className = "",
openDelay = 200,
closeDelay,
}) => {
const { theme } = useTheme();
return (
<Tooltip2
disabled={disabled}
hoverOpenDelay={openDelay}
hoverCloseDelay={closeDelay}
content={
<div
className={`relative z-50 max-w-xs gap-1 rounded-md p-2 text-xs shadow-md ${
theme === "custom"
? "bg-custom-background-100 text-custom-text-200"
: "bg-black text-gray-400"
} break-words overflow-hidden ${className}`}
>
{tooltipHeading && (
<h5
className={`font-medium ${
theme === "custom" ? "text-custom-text-100" : "text-white"
}`}
>
{tooltipHeading}
</h5>
)}
{tooltipContent}
</div>
}
position={position}
renderTarget={({ isOpen: isTooltipOpen, ref: eleReference, ...tooltipProps }) =>
React.cloneElement(children, { ref: eleReference, ...tooltipProps, ...children.props })
}
/>
);
};

View File

@@ -15,7 +15,11 @@ interface ImageNode extends ProseMirrorNode {
const TrackImageDeletionPlugin = (deleteImage: DeleteImage): Plugin =>
new Plugin({
key: deleteKey,
appendTransaction: (transactions: readonly Transaction[], oldState: EditorState, newState: EditorState) => {
appendTransaction: (
transactions: readonly Transaction[],
oldState: EditorState,
newState: EditorState,
) => {
const newImageSources = new Set<string>();
newState.doc.descendants((node) => {
if (node.type.name === IMAGE_NODE_TYPE) {
@@ -55,7 +59,10 @@ const TrackImageDeletionPlugin = (deleteImage: DeleteImage): Plugin =>
export default TrackImageDeletionPlugin;
async function onNodeDeleted(src: string, deleteImage: DeleteImage): Promise<void> {
async function onNodeDeleted(
src: string,
deleteImage: DeleteImage,
): Promise<void> {
try {
const assetUrlWithWorkspaceId = new URL(src).pathname.substring(1);
const resStatus = await deleteImage(assetUrlWithWorkspaceId);

View File

@@ -4,7 +4,7 @@ import { Decoration, DecorationSet, EditorView } from "@tiptap/pm/view";
const uploadKey = new PluginKey("upload-image");
const UploadImagesPlugin = () =>
const UploadImagesPlugin = (cancelUploadImage?: () => any) =>
new Plugin({
key: uploadKey,
state: {
@@ -21,15 +21,46 @@ const UploadImagesPlugin = () =>
const placeholder = document.createElement("div");
placeholder.setAttribute("class", "img-placeholder");
const image = document.createElement("img");
image.setAttribute("class", "opacity-10 rounded-lg border border-custom-border-300");
image.setAttribute(
"class",
"opacity-10 rounded-lg border border-custom-border-300",
);
image.src = src;
placeholder.appendChild(image);
// Create cancel button
const cancelButton = document.createElement("button");
cancelButton.style.position = "absolute";
cancelButton.style.right = "3px";
cancelButton.style.top = "3px";
cancelButton.setAttribute("class", "opacity-90 rounded-lg");
cancelButton.onclick = () => {
cancelUploadImage?.();
};
// Create an SVG element from the SVG string
const svgString = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-x-circle"><circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/></svg>`;
const parser = new DOMParser();
const svgElement = parser.parseFromString(
svgString,
"image/svg+xml",
).documentElement;
cancelButton.appendChild(svgElement);
placeholder.appendChild(cancelButton);
const deco = Decoration.widget(pos + 1, placeholder, {
id,
});
set = set.add(tr.doc, [deco]);
} else if (action && action.remove) {
set = set.remove(set.find(undefined, undefined, (spec) => spec.id == action.remove.id));
set = set.remove(
set.find(
undefined,
undefined,
(spec) => spec.id == action.remove.id,
),
);
}
return set;
},
@@ -48,19 +79,39 @@ function findPlaceholder(state: EditorState, id: {}) {
const found = decos.find(
undefined,
undefined,
(spec: { id: number | undefined }) => spec.id == id
(spec: { id: number | undefined }) => spec.id == id,
);
return found.length ? found[0].from : null;
}
const removePlaceholder = (view: EditorView, id: {}) => {
const removePlaceholderTr = view.state.tr.setMeta(uploadKey, {
remove: { id },
});
view.dispatch(removePlaceholderTr);
};
export async function startImageUpload(
file: File,
view: EditorView,
pos: number,
uploadFile: UploadImage,
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void
setIsSubmitting?: (
isSubmitting: "submitting" | "submitted" | "saved",
) => void,
) {
if (!file) {
alert("No file selected. Please select a file to upload.");
return;
}
if (!file.type.includes("image/")) {
alert("Invalid file type. Please select an image file.");
return;
}
if (file.size > 5 * 1024 * 1024) {
alert("File size too large. Please select a file smaller than 5MB.");
return;
}
@@ -82,28 +133,42 @@ export async function startImageUpload(
view.dispatch(tr);
};
// Handle FileReader errors
reader.onerror = (error) => {
console.error("FileReader error: ", error);
removePlaceholder(view, id);
return;
};
setIsSubmitting?.("submitting");
const src = await UploadImageHandler(file, uploadFile);
const { schema } = view.state;
pos = findPlaceholder(view.state, id);
if (pos == null) return;
const imageSrc = typeof src === "object" ? reader.result : src;
try {
const src = await UploadImageHandler(file, uploadFile);
const { schema } = view.state;
pos = findPlaceholder(view.state, id);
const node = schema.nodes.image.create({ src: imageSrc });
const transaction = view.state.tr
.replaceWith(pos, pos, node)
.setMeta(uploadKey, { remove: { id } });
view.dispatch(transaction);
if (pos == null) return;
const imageSrc = typeof src === "object" ? reader.result : src;
const node = schema.nodes.image.create({ src: imageSrc });
const transaction = view.state.tr
.replaceWith(pos, pos, node)
.setMeta(uploadKey, { remove: { id } });
view.dispatch(transaction);
} catch (error) {
console.error("Upload error: ", error);
removePlaceholder(view, id);
}
}
const UploadImageHandler = (file: File,
uploadFile: UploadImage
const UploadImageHandler = (
file: File,
uploadFile: UploadImage,
): Promise<string> => {
try {
return new Promise(async (resolve, reject) => {
try {
const imageUrl = await uploadFile(file)
const imageUrl = await uploadFile(file);
const image = new Image();
image.src = imageUrl;
@@ -118,9 +183,6 @@ const UploadImageHandler = (file: File,
}
});
} catch (error) {
if (error instanceof Error) {
console.log(error.message);
}
return Promise.reject(error);
}
};

View File

@@ -5,7 +5,9 @@ import { UploadImage } from "../types/upload-image";
export function CoreEditorProps(
uploadFile: UploadImage,
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void
setIsSubmitting?: (
isSubmitting: "submitting" | "submitted" | "saved",
) => void,
): EditorProps {
return {
attributes: {
@@ -32,7 +34,11 @@ export function CoreEditorProps(
}
}
}
if (event.clipboardData && event.clipboardData.files && event.clipboardData.files[0]) {
if (
event.clipboardData &&
event.clipboardData.files &&
event.clipboardData.files[0]
) {
event.preventDefault();
const file = event.clipboardData.files[0];
const pos = view.state.selection.from;
@@ -51,7 +57,12 @@ export function CoreEditorProps(
}
}
}
if (!moved && event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files[0]) {
if (
!moved &&
event.dataTransfer &&
event.dataTransfer.files &&
event.dataTransfer.files[0]
) {
event.preventDefault();
const file = event.dataTransfer.files[0];
const coordinates = view.posAtCoords({
@@ -59,7 +70,13 @@ export function CoreEditorProps(
top: event.clientY,
});
if (coordinates) {
startImageUpload(file, view, coordinates.pos - 1, uploadFile, setIsSubmitting);
startImageUpload(
file,
view,
coordinates.pos - 1,
uploadFile,
setIsSubmitting,
);
}
return true;
}

View File

@@ -18,9 +18,10 @@ import { isValidHttpUrl } from "../../lib/utils";
import { Mentions } from "../mentions";
import { IMentionSuggestion } from "../../types/mention-suggestion";
export const CoreReadOnlyEditorExtensions = (
mentionConfig: { mentionSuggestions: IMentionSuggestion[], mentionHighlights: string[] },
) => [
export const CoreReadOnlyEditorExtensions = (mentionConfig: {
mentionSuggestions: IMentionSuggestion[];
mentionHighlights: string[];
}) => [
StarterKit.configure({
bulletList: {
HTMLAttributes: {
@@ -57,41 +58,45 @@ export const CoreReadOnlyEditorExtensions = (
},
gapcursor: false,
}),
Gapcursor,
TiptapLink.configure({
protocols: ["http", "https"],
validate: (url) => isValidHttpUrl(url),
HTMLAttributes: {
class:
"text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer",
},
}),
ReadOnlyImageExtension.configure({
HTMLAttributes: {
class: "rounded-lg border border-custom-border-300",
},
}),
TiptapUnderline,
TextStyle,
Color,
TaskList.configure({
HTMLAttributes: {
class: "not-prose pl-2",
},
}),
TaskItem.configure({
HTMLAttributes: {
class: "flex items-start my-4",
},
nested: true,
}),
Markdown.configure({
html: true,
transformCopiedText: true,
}),
Table,
TableHeader,
TableCell,
TableRow,
Mentions(mentionConfig.mentionSuggestions, mentionConfig.mentionHighlights, true),
];
Gapcursor,
TiptapLink.configure({
protocols: ["http", "https"],
validate: (url) => isValidHttpUrl(url),
HTMLAttributes: {
class:
"text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer",
},
}),
ReadOnlyImageExtension.configure({
HTMLAttributes: {
class: "rounded-lg border border-custom-border-300",
},
}),
TiptapUnderline,
TextStyle,
Color,
TaskList.configure({
HTMLAttributes: {
class: "not-prose pl-2",
},
}),
TaskItem.configure({
HTMLAttributes: {
class: "flex items-start my-4",
},
nested: true,
}),
Markdown.configure({
html: true,
transformCopiedText: true,
}),
Table,
TableHeader,
TableCell,
TableRow,
Mentions(
mentionConfig.mentionSuggestions,
mentionConfig.mentionHighlights,
true,
),
];

View File

@@ -1,7 +1,6 @@
import { EditorProps } from "@tiptap/pm/view";
export const CoreReadOnlyEditorProps: EditorProps =
{
export const CoreReadOnlyEditorProps: EditorProps = {
attributes: {
class: `prose prose-brand max-w-full prose-headings:font-display font-default focus:outline-none`,
},

View File

@@ -10,25 +10,25 @@ The `@plane/lite-text-editor` package extends from the `editor-core` package, in
`LiteTextEditor` & `LiteTextEditorWithRef`
- **Read Only Editor Instances**: We have added a really light weight *Read Only* Editor instance for the Lite editor types (with and without Ref)
- **Read Only Editor Instances**: We have added a really light weight _Read Only_ Editor instance for the Lite editor types (with and without Ref)
`LiteReadOnlyEditor` &`LiteReadOnlyEditorWithRef`
## LiteTextEditor
| Prop | Type | Description |
| --- | --- | --- |
| `uploadFile` | `(file: File) => Promise<string>` | A function that handles file upload. It takes a file as input and handles the process of uploading that file. |
| `deleteFile` | `(assetUrlWithWorkspaceId: string) => Promise<any>` | A function that handles deleting an image. It takes the asset url from your bucket and handles the process of deleting that image. |
| `value` | `html string` | The initial content of the editor. |
| `onEnterKeyPress` | `(e) => void` | The event that happens on Enter key press |
| `debouncedUpdatesEnabled` | `boolean` | If set to true, the `onChange` event handler is debounced, meaning it will only be invoked after the specified delay (default 1500ms) once the user has stopped typing. |
| `onChange` | `(json: any, html: string) => void` | This function is invoked whenever the content of the editor changes. It is passed the new content in both JSON and HTML formats. |
| `setIsSubmitting` | `(isSubmitting: "submitting" \| "submitted" \| "saved") => void` | This function is called to update the submission status. |
| `setShouldShowAlert` | `(showAlert: boolean) => void` | This function is used to show or hide an alert incase of content not being "saved". |
| `noBorder` | `boolean` | If set to true, the editor will not have a border. |
| `borderOnFocus` | `boolean` | If set to true, the editor will show a border when it is focused. |
| `customClassName` | `string` | This is a custom CSS class that can be applied to the editor. |
| `editorContentCustomClassNames` | `string` | This is a custom CSS class that can be applied to the editor content. |
| Prop | Type | Description |
| ------------------------------- | ---------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `uploadFile` | `(file: File) => Promise<string>` | A function that handles file upload. It takes a file as input and handles the process of uploading that file. |
| `deleteFile` | `(assetUrlWithWorkspaceId: string) => Promise<any>` | A function that handles deleting an image. It takes the asset url from your bucket and handles the process of deleting that image. |
| `value` | `html string` | The initial content of the editor. |
| `onEnterKeyPress` | `(e) => void` | The event that happens on Enter key press |
| `debouncedUpdatesEnabled` | `boolean` | If set to true, the `onChange` event handler is debounced, meaning it will only be invoked after the specified delay (default 1500ms) once the user has stopped typing. |
| `onChange` | `(json: any, html: string) => void` | This function is invoked whenever the content of the editor changes. It is passed the new content in both JSON and HTML formats. |
| `setIsSubmitting` | `(isSubmitting: "submitting" \| "submitted" \| "saved") => void` | This function is called to update the submission status. |
| `setShouldShowAlert` | `(showAlert: boolean) => void` | This function is used to show or hide an alert incase of content not being "saved". |
| `noBorder` | `boolean` | If set to true, the editor will not have a border. |
| `borderOnFocus` | `boolean` | If set to true, the editor will show a border when it is focused. |
| `customClassName` | `string` | This is a custom CSS class that can be applied to the editor. |
| `editorContentCustomClassNames` | `string` | This is a custom CSS class that can be applied to the editor content. |
### Usage
@@ -36,62 +36,62 @@ The `@plane/lite-text-editor` package extends from the `editor-core` package, in
```tsx
<LiteTextEditor
onEnterKeyPress={handleSubmit(handleCommentUpdate)}
uploadFile={fileService.getUploadFileFunction(workspaceSlug)}
deleteFile={fileService.deleteImage}
value={value}
debouncedUpdatesEnabled={false}
customClassName="min-h-[50px] p-3 shadow-sm"
onChange={(comment_json: Object, comment_html: string) => {
onChange(comment_html);
}}
/>
onEnterKeyPress={handleSubmit(handleCommentUpdate)}
uploadFile={fileService.getUploadFileFunction(workspaceSlug)}
deleteFile={fileService.deleteImage}
value={value}
debouncedUpdatesEnabled={false}
customClassName="min-h-[50px] p-3 shadow-sm"
onChange={(comment_json: Object, comment_html: string) => {
onChange(comment_html);
}}
/>
```
2. Example of how to use the `LiteTextEditorWithRef` component
```tsx
const editorRef = useRef<any>(null);
const editorRef = useRef<any>(null);
// can use it to set the editor's value
editorRef.current?.setEditorValue(`${watch("description_html")}`);
// can use it to set the editor's value
editorRef.current?.setEditorValue(`${watch("description_html")}`);
// can use it to clear the editor
editorRef?.current?.clearEditor();
// can use it to clear the editor
editorRef?.current?.clearEditor();
return (
<LiteTextEditorWithRef
onEnterKeyPress={handleSubmit(handleCommentUpdate)}
uploadFile={fileService.getUploadFileFunction(workspaceSlug)}
deleteFile={fileService.deleteImage}
ref={editorRef}
value={value}
debouncedUpdatesEnabled={false}
customClassName="min-h-[50px] p-3 shadow-sm"
onChange={(comment_json: Object, comment_html: string) => {
onChange(comment_html);
}}
/>
)
return (
<LiteTextEditorWithRef
onEnterKeyPress={handleSubmit(handleCommentUpdate)}
uploadFile={fileService.getUploadFileFunction(workspaceSlug)}
deleteFile={fileService.deleteImage}
ref={editorRef}
value={value}
debouncedUpdatesEnabled={false}
customClassName="min-h-[50px] p-3 shadow-sm"
onChange={(comment_json: Object, comment_html: string) => {
onChange(comment_html);
}}
/>
);
```
## LiteReadOnlyEditor
| Prop | Type | Description |
| --- | --- | --- |
| `value` | `html string` | The initial content of the editor. |
| `noBorder` | `boolean` | If set to true, the editor will not have a border. |
| `borderOnFocus` | `boolean` | If set to true, the editor will show a border when it is focused. |
| `customClassName` | `string` | This is a custom CSS class that can be applied to the editor. |
| `editorContentCustomClassNames` | `string` | This is a custom CSS class that can be applied to the editor content. |
| Prop | Type | Description |
| ------------------------------- | ------------- | --------------------------------------------------------------------- |
| `value` | `html string` | The initial content of the editor. |
| `noBorder` | `boolean` | If set to true, the editor will not have a border. |
| `borderOnFocus` | `boolean` | If set to true, the editor will show a border when it is focused. |
| `customClassName` | `string` | This is a custom CSS class that can be applied to the editor. |
| `editorContentCustomClassNames` | `string` | This is a custom CSS class that can be applied to the editor content. |
### Usage
Here is an example of how to use the `RichReadOnlyEditor` component
```tsx
<LiteReadOnlyEditor
value={comment.comment_html}
customClassName="text-xs border border-custom-border-200 bg-custom-background-100"
/>
<LiteReadOnlyEditor
value={comment.comment_html}
customClassName="text-xs border border-custom-border-200 bg-custom-background-100"
/>
```

View File

@@ -29,6 +29,7 @@
},
"dependencies": {
"@plane/editor-core": "*",
"@plane/ui": "*",
"@tiptap/extension-list-item": "^2.1.11",
"class-variance-authority": "^0.7.0",
"clsx": "^1.2.1",

View File

@@ -1,3 +1,3 @@
export { LiteTextEditor, LiteTextEditorWithRef } from "./ui";
export { LiteReadOnlyEditor, LiteReadOnlyEditorWithRef } from "./ui/read-only";
export type { IMentionSuggestion, IMentionHighlight } from "./ui"
export type { IMentionSuggestion, IMentionHighlight } from "./ui";

View File

@@ -31,7 +31,7 @@ interface ILiteTextEditor {
editorContentCustomClassNames?: string;
onChange?: (json: any, html: string) => void;
setIsSubmitting?: (
isSubmitting: "submitting" | "submitted" | "saved"
isSubmitting: "submitting" | "submitted" | "saved",
) => void;
setShouldShowAlert?: (showAlert: boolean) => void;
forwardedRef?: any;
@@ -47,6 +47,7 @@ interface ILiteTextEditor {
}[];
};
onEnterKeyPress?: (e?: any) => void;
cancelUploadImage?: () => any;
mentionHighlights?: string[];
mentionSuggestions?: IMentionSuggestion[];
submitButton?: React.ReactNode;
@@ -64,6 +65,7 @@ interface EditorHandle {
const LiteTextEditor = (props: LiteTextEditorProps) => {
const {
onChange,
cancelUploadImage,
debouncedUpdatesEnabled,
setIsSubmitting,
setShouldShowAlert,
@@ -84,6 +86,7 @@ const LiteTextEditor = (props: LiteTextEditorProps) => {
const editor = useEditor({
onChange,
cancelUploadImage,
debouncedUpdatesEnabled,
setIsSubmitting,
setShouldShowAlert,
@@ -126,7 +129,7 @@ const LiteTextEditor = (props: LiteTextEditorProps) => {
};
const LiteTextEditorWithRef = React.forwardRef<EditorHandle, ILiteTextEditor>(
(props, ref) => <LiteTextEditor {...props} forwardedRef={ref} />
(props, ref) => <LiteTextEditor {...props} forwardedRef={ref} />,
);
LiteTextEditorWithRef.displayName = "LiteTextEditorWithRef";

View File

@@ -6,8 +6,9 @@ type Props = {
};
export const Icon: React.FC<Props> = ({ iconName, className = "" }) => (
<span className={`material-symbols-rounded text-sm leading-5 font-light ${className}`}>
<span
className={`material-symbols-rounded text-sm leading-5 font-light ${className}`}
>
{iconName}
</span>
);

View File

@@ -14,8 +14,8 @@ import {
TableItem,
UnderLineItem,
} from "@plane/editor-core";
import { Tooltip } from "../../tooltip";
import { UploadImage } from "../..";
import { Tooltip } from "@plane/ui";
import { UploadImage } from "../../";
export interface BubbleMenuItem {
name: string;

View File

@@ -10,24 +10,24 @@ The `@plane/rich-text-editor` package extends from the `editor-core` package, in
`RichTextEditor` & `RichTextEditorWithRef`
- **Read Only Editor Instances**: We have added a really light weight *Read Only* Editor instance for the Rich editor types (with and without Ref)
- **Read Only Editor Instances**: We have added a really light weight _Read Only_ Editor instance for the Rich editor types (with and without Ref)
`RichReadOnlyEditor` &`RichReadOnlyEditorWithRef`
## RichTextEditor
| Prop | Type | Description |
| --- | --- | --- |
| `uploadFile` | `(file: File) => Promise<string>` | A function that handles file upload. It takes a file as input and handles the process of uploading that file. |
| `deleteFile` | `(assetUrlWithWorkspaceId: string) => Promise<any>` | A function that handles deleting an image. It takes the asset url from your bucket and handles the process of deleting that image. |
| `value` | `html string` | The initial content of the editor. |
| `debouncedUpdatesEnabled` | `boolean` | If set to true, the `onChange` event handler is debounced, meaning it will only be invoked after the specified delay (default 1500ms) once the user has stopped typing. |
| `onChange` | `(json: any, html: string) => void` | This function is invoked whenever the content of the editor changes. It is passed the new content in both JSON and HTML formats. |
| `setIsSubmitting` | `(isSubmitting: "submitting" \| "submitted" \| "saved") => void` | This function is called to update the submission status. |
| `setShouldShowAlert` | `(showAlert: boolean) => void` | This function is used to show or hide an alert incase of content not being "saved". |
| `noBorder` | `boolean` | If set to true, the editor will not have a border. |
| `borderOnFocus` | `boolean` | If set to true, the editor will show a border when it is focused. |
| `customClassName` | `string` | This is a custom CSS class that can be applied to the editor. |
| `editorContentCustomClassNames` | `string` | This is a custom CSS class that can be applied to the editor content. |
| Prop | Type | Description |
| ------------------------------- | ---------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `uploadFile` | `(file: File) => Promise<string>` | A function that handles file upload. It takes a file as input and handles the process of uploading that file. |
| `deleteFile` | `(assetUrlWithWorkspaceId: string) => Promise<any>` | A function that handles deleting an image. It takes the asset url from your bucket and handles the process of deleting that image. |
| `value` | `html string` | The initial content of the editor. |
| `debouncedUpdatesEnabled` | `boolean` | If set to true, the `onChange` event handler is debounced, meaning it will only be invoked after the specified delay (default 1500ms) once the user has stopped typing. |
| `onChange` | `(json: any, html: string) => void` | This function is invoked whenever the content of the editor changes. It is passed the new content in both JSON and HTML formats. |
| `setIsSubmitting` | `(isSubmitting: "submitting" \| "submitted" \| "saved") => void` | This function is called to update the submission status. |
| `setShouldShowAlert` | `(showAlert: boolean) => void` | This function is used to show or hide an alert incase of content not being "saved". |
| `noBorder` | `boolean` | If set to true, the editor will not have a border. |
| `borderOnFocus` | `boolean` | If set to true, the editor will show a border when it is focused. |
| `customClassName` | `string` | This is a custom CSS class that can be applied to the editor. |
| `editorContentCustomClassNames` | `string` | This is a custom CSS class that can be applied to the editor content. |
### Usage
@@ -57,43 +57,47 @@ The `@plane/rich-text-editor` package extends from the `editor-core` package, in
2. Example of how to use the `RichTextEditorWithRef` component
```tsx
const editorRef = useRef<any>(null);
const editorRef = useRef<any>(null);
// can use it to set the editor's value
editorRef.current?.setEditorValue(`${watch("description_html")}`);
// can use it to set the editor's value
editorRef.current?.setEditorValue(`${watch("description_html")}`);
// can use it to clear the editor
editorRef?.current?.clearEditor();
// can use it to clear the editor
editorRef?.current?.clearEditor();
return (<RichTextEditorWithRef
uploadFile={fileService.getUploadFileFunction(workspaceSlug as string)}
deleteFile={fileService.deleteImage}
ref={editorRef}
debouncedUpdatesEnabled={false}
value={value}
customClassName="min-h-[150px]"
onChange={(description: Object, description_html: string) => {
onChange(description_html);
// custom stuff you want to do
} } />)
return (
<RichTextEditorWithRef
uploadFile={fileService.getUploadFileFunction(workspaceSlug as string)}
deleteFile={fileService.deleteImage}
ref={editorRef}
debouncedUpdatesEnabled={false}
value={value}
customClassName="min-h-[150px]"
onChange={(description: Object, description_html: string) => {
onChange(description_html);
// custom stuff you want to do
}}
/>
);
```
## RichReadOnlyEditor
| Prop | Type | Description |
| --- | --- | --- |
| `value` | `html string` | The initial content of the editor. |
| `noBorder` | `boolean` | If set to true, the editor will not have a border. |
| `borderOnFocus` | `boolean` | If set to true, the editor will show a border when it is focused. |
| `customClassName` | `string` | This is a custom CSS class that can be applied to the editor. |
| `editorContentCustomClassNames` | `string` | This is a custom CSS class that can be applied to the editor content. |
| Prop | Type | Description |
| ------------------------------- | ------------- | --------------------------------------------------------------------- |
| `value` | `html string` | The initial content of the editor. |
| `noBorder` | `boolean` | If set to true, the editor will not have a border. |
| `borderOnFocus` | `boolean` | If set to true, the editor will show a border when it is focused. |
| `customClassName` | `string` | This is a custom CSS class that can be applied to the editor. |
| `editorContentCustomClassNames` | `string` | This is a custom CSS class that can be applied to the editor content. |
### Usage
Here is an example of how to use the `RichReadOnlyEditor` component
```tsx
<RichReadOnlyEditor
value={issueDetails.description_html}
customClassName="p-3 min-h-[50px] shadow-sm" />
<RichReadOnlyEditor
value={issueDetails.description_html}
customClassName="p-3 min-h-[50px] shadow-sm"
/>
```

View File

@@ -2,4 +2,4 @@ import "./styles/github-dark.css";
export { RichTextEditor, RichTextEditorWithRef } from "./ui";
export { RichReadOnlyEditor, RichReadOnlyEditorWithRef } from "./ui/read-only";
export type { IMentionSuggestion, IMentionHighlight } from "./ui"
export type { IMentionSuggestion, IMentionHighlight } from "./ui";

View File

@@ -1,7 +1,7 @@
import HorizontalRule from "@tiptap/extension-horizontal-rule";
import Placeholder from "@tiptap/extension-placeholder";
import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight";
import { common, createLowlight } from 'lowlight'
import { common, createLowlight } from "lowlight";
import { InputRule } from "@tiptap/core";
import ts from "highlight.js/lib/languages/typescript";
@@ -9,51 +9,53 @@ import ts from "highlight.js/lib/languages/typescript";
import SlashCommand from "./slash-command";
import { UploadImage } from "../";
const lowlight = createLowlight(common)
const lowlight = createLowlight(common);
lowlight.register("ts", ts);
export const RichTextEditorExtensions = (
uploadFile: UploadImage,
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void
setIsSubmitting?: (
isSubmitting: "submitting" | "submitted" | "saved",
) => void,
) => [
HorizontalRule.extend({
addInputRules() {
return [
new InputRule({
find: /^(?:---|—-|___\s|\*\*\*\s)$/,
handler: ({ state, range, commands }) => {
commands.splitBlock();
HorizontalRule.extend({
addInputRules() {
return [
new InputRule({
find: /^(?:---|—-|___\s|\*\*\*\s)$/,
handler: ({ state, range, commands }) => {
commands.splitBlock();
const attributes = {};
const { tr } = state;
const start = range.from;
const end = range.to;
// @ts-ignore
tr.replaceWith(start - 1, end, this.type.create(attributes));
},
}),
];
},
}).configure({
HTMLAttributes: {
class: "mb-6 border-t border-custom-border-300",
},
}),
SlashCommand(uploadFile, setIsSubmitting),
CodeBlockLowlight.configure({
lowlight,
}),
Placeholder.configure({
placeholder: ({ node }) => {
if (node.type.name === "heading") {
return `Heading ${node.attrs.level}`;
}
if (node.type.name === "image" || node.type.name === "table") {
return "";
}
const attributes = {};
const { tr } = state;
const start = range.from;
const end = range.to;
// @ts-ignore
tr.replaceWith(start - 1, end, this.type.create(attributes));
},
}),
];
},
}).configure({
HTMLAttributes: {
class: "mb-6 border-t border-custom-border-300",
},
}),
SlashCommand(uploadFile, setIsSubmitting),
CodeBlockLowlight.configure({
lowlight,
}),
Placeholder.configure({
placeholder: ({ node }) => {
if (node.type.name === "heading") {
return `Heading ${node.attrs.level}`;
}
if (node.type.name === "image" || node.type.name === "table") {
return "";
}
return "Press '/' for commands...";
},
includeChildren: true,
}),
];
return "Press '/' for commands...";
},
includeChildren: true,
}),
];

View File

@@ -1,8 +1,13 @@
"use client"
import * as React from 'react';
import { EditorContainer, EditorContentWrapper, getEditorClassNames, useEditor } from '@plane/editor-core';
import { EditorBubbleMenu } from './menus/bubble-menu';
import { RichTextEditorExtensions } from './extensions';
"use client";
import * as React from "react";
import {
EditorContainer,
EditorContentWrapper,
getEditorClassNames,
useEditor,
} from "@plane/editor-core";
import { EditorBubbleMenu } from "./menus/bubble-menu";
import { RichTextEditorExtensions } from "./extensions";
export type UploadImage = (file: File) => Promise<string>;
export type DeleteImage = (assetUrlWithWorkspaceId: string) => Promise<any>;
@@ -14,9 +19,9 @@ export type IMentionSuggestion = {
title: string;
subtitle: string;
redirect_uri: string;
}
};
export type IMentionHighlight = string
export type IMentionHighlight = string;
interface IRichTextEditor {
value: string;
@@ -24,10 +29,13 @@ interface IRichTextEditor {
deleteFile: DeleteImage;
noBorder?: boolean;
borderOnFocus?: boolean;
cancelUploadImage?: () => any;
customClassName?: string;
editorContentCustomClassNames?: string;
onChange?: (json: any, html: string) => void;
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void;
setIsSubmitting?: (
isSubmitting: "submitting" | "submitted" | "saved",
) => void;
setShouldShowAlert?: (showAlert: boolean) => void;
forwardedRef?: any;
debouncedUpdatesEnabled?: boolean;
@@ -54,11 +62,12 @@ const RichTextEditor = ({
uploadFile,
deleteFile,
noBorder,
cancelUploadImage,
borderOnFocus,
customClassName,
forwardedRef,
mentionHighlights,
mentionSuggestions
mentionSuggestions,
}: RichTextEditorProps) => {
const editor = useEditor({
onChange,
@@ -67,14 +76,19 @@ const RichTextEditor = ({
setShouldShowAlert,
value,
uploadFile,
cancelUploadImage,
deleteFile,
forwardedRef,
extensions: RichTextEditorExtensions(uploadFile, setIsSubmitting),
mentionHighlights,
mentionSuggestions
mentionSuggestions,
});
const editorClassNames = getEditorClassNames({ noBorder, borderOnFocus, customClassName });
const editorClassNames = getEditorClassNames({
noBorder,
borderOnFocus,
customClassName,
});
if (!editor) return null;
@@ -82,16 +96,19 @@ const RichTextEditor = ({
<EditorContainer editor={editor} editorClassNames={editorClassNames}>
{editor && <EditorBubbleMenu editor={editor} />}
<div className="flex flex-col">
<EditorContentWrapper editor={editor} editorContentCustomClassNames={editorContentCustomClassNames} />
<EditorContentWrapper
editor={editor}
editorContentCustomClassNames={editorContentCustomClassNames}
/>
</div>
</EditorContainer >
</EditorContainer>
);
};
const RichTextEditorWithRef = React.forwardRef<EditorHandle, IRichTextEditor>((props, ref) => (
<RichTextEditor {...props} forwardedRef={ref} />
));
const RichTextEditorWithRef = React.forwardRef<EditorHandle, IRichTextEditor>(
(props, ref) => <RichTextEditor {...props} forwardedRef={ref} />,
);
RichTextEditorWithRef.displayName = "RichTextEditorWithRef";
export { RichTextEditor, RichTextEditorWithRef};
export { RichTextEditor, RichTextEditorWithRef };

View File

@@ -1,7 +1,19 @@
import { Editor } from "@tiptap/core";
import { Check, Trash } from "lucide-react";
import { Dispatch, FC, SetStateAction, useCallback, useEffect, useRef } from "react";
import { cn, isValidHttpUrl, setLinkEditor, unsetLinkEditor, } from "@plane/editor-core";
import {
Dispatch,
FC,
SetStateAction,
useCallback,
useEffect,
useRef,
} from "react";
import {
cn,
isValidHttpUrl,
setLinkEditor,
unsetLinkEditor,
} from "@plane/editor-core";
interface LinkSelectorProps {
editor: Editor;
@@ -9,7 +21,11 @@ interface LinkSelectorProps {
setIsOpen: Dispatch<SetStateAction<boolean>>;
}
export const LinkSelector: FC<LinkSelectorProps> = ({ editor, isOpen, setIsOpen }) => {
export const LinkSelector: FC<LinkSelectorProps> = ({
editor,
isOpen,
setIsOpen,
}) => {
const inputRef = useRef<HTMLInputElement>(null);
const onLinkSubmit = useCallback(() => {
@@ -31,7 +47,7 @@ export const LinkSelector: FC<LinkSelectorProps> = ({ editor, isOpen, setIsOpen
type="button"
className={cn(
"flex h-full items-center space-x-2 px-3 py-1.5 text-sm font-medium text-custom-text-300 hover:bg-custom-background-100 active:bg-custom-background-100",
{ "bg-custom-background-100": isOpen }
{ "bg-custom-background-100": isOpen },
)}
onClick={() => {
setIsOpen(!isOpen);

View File

@@ -1,10 +1,16 @@
import { BulletListItem, cn, CodeItem, HeadingOneItem, HeadingThreeItem, HeadingTwoItem, NumberedListItem, QuoteItem, TodoListItem } from "@plane/editor-core";
import { Editor } from "@tiptap/react";
import {
Check,
ChevronDown,
TextIcon,
} from "lucide-react";
BulletListItem,
cn,
CodeItem,
HeadingOneItem,
HeadingThreeItem,
HeadingTwoItem,
NumberedListItem,
QuoteItem,
TodoListItem,
} from "@plane/editor-core";
import { Editor } from "@tiptap/react";
import { Check, ChevronDown, TextIcon } from "lucide-react";
import { Dispatch, FC, SetStateAction } from "react";
import { BubbleMenuItem } from ".";
@@ -15,12 +21,17 @@ interface NodeSelectorProps {
setIsOpen: Dispatch<SetStateAction<boolean>>;
}
export const NodeSelector: FC<NodeSelectorProps> = ({ editor, isOpen, setIsOpen }) => {
export const NodeSelector: FC<NodeSelectorProps> = ({
editor,
isOpen,
setIsOpen,
}) => {
const items: BubbleMenuItem[] = [
{
name: "Text",
icon: TextIcon,
command: () => editor.chain().focus().toggleNode("paragraph", "paragraph").run(),
command: () =>
editor.chain().focus().toggleNode("paragraph", "paragraph").run(),
isActive: () =>
editor.isActive("paragraph") &&
!editor.isActive("bulletList") &&
@@ -63,7 +74,10 @@ export const NodeSelector: FC<NodeSelectorProps> = ({ editor, isOpen, setIsOpen
}}
className={cn(
"flex items-center justify-between rounded-sm px-2 py-1 text-sm text-custom-text-200 hover:bg-custom-primary-100/5 hover:text-custom-text-100",
{ "bg-custom-primary-100/5 text-custom-text-100": activeItem.name === item.name }
{
"bg-custom-primary-100/5 text-custom-text-100":
activeItem.name === item.name,
},
)}
>
<div className="flex items-center space-x-2">

View File

@@ -1,6 +1,11 @@
"use client"
import { EditorContainer, EditorContentWrapper, getEditorClassNames, useReadOnlyEditor } from '@plane/editor-core';
import * as React from 'react';
"use client";
import {
EditorContainer,
EditorContentWrapper,
getEditorClassNames,
useReadOnlyEditor,
} from "@plane/editor-core";
import * as React from "react";
interface IRichTextReadOnlyEditor {
value: string;
@@ -35,23 +40,31 @@ const RichReadOnlyEditor = ({
mentionHighlights,
});
const editorClassNames = getEditorClassNames({ noBorder, borderOnFocus, customClassName });
const editorClassNames = getEditorClassNames({
noBorder,
borderOnFocus,
customClassName,
});
if (!editor) return null;
return (
<EditorContainer editor={editor} editorClassNames={editorClassNames}>
<div className="flex flex-col">
<EditorContentWrapper editor={editor} editorContentCustomClassNames={editorContentCustomClassNames} />
<EditorContentWrapper
editor={editor}
editorContentCustomClassNames={editorContentCustomClassNames}
/>
</div>
</EditorContainer >
</EditorContainer>
);
};
const RichReadOnlyEditorWithRef = React.forwardRef<EditorHandle, IRichTextReadOnlyEditor>((props, ref) => (
<RichReadOnlyEditor {...props} forwardedRef={ref} />
));
const RichReadOnlyEditorWithRef = React.forwardRef<
EditorHandle,
IRichTextReadOnlyEditor
>((props, ref) => <RichReadOnlyEditor {...props} forwardedRef={ref} />);
RichReadOnlyEditorWithRef.displayName = "RichReadOnlyEditorWithRef";
export { RichReadOnlyEditor , RichReadOnlyEditorWithRef };
export { RichReadOnlyEditor, RichReadOnlyEditorWithRef };

View File

@@ -174,7 +174,7 @@ module.exports = {
DEFAULT: convertToRGB("--color-sidebar-border-200"),
},
},
backdrop: "#131313",
backdrop: "rgba(0, 0, 0, 0.25)",
},
},
keyframes: {

View File

@@ -123,7 +123,7 @@ export const Avatar: React.FC<Props> = (props) => {
size = "md",
shape = "circle",
src,
className = ""
className = "",
} = props;
// get size details based on the size prop
@@ -157,7 +157,9 @@ export const Avatar: React.FC<Props> = (props) => {
<div
className={`${
sizeInfo.fontSize
} grid place-items-center h-full w-full ${getBorderRadius(shape)} ${className}`}
} grid place-items-center h-full w-full ${getBorderRadius(
shape,
)} ${className}`}
style={{
backgroundColor:
fallbackBackgroundColor ?? "rgba(var(--color-primary-500))",

View File

@@ -58,7 +58,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
)}
</button>
);
}
},
);
Button.displayName = "plane-ui-button";

View File

@@ -102,7 +102,7 @@ export const buttonStyling: IButtonStyling = {
export const getButtonStyling = (
variant: TButtonVariant,
size: TButtonSizes,
disabled: boolean = false
disabled: boolean = false,
): string => {
let _variant: string = ``;
const currentVariant = buttonStyling[variant];

View File

@@ -35,7 +35,7 @@ export const CustomSearchSelect = (props: ICustomSearchSelectProps) => {
const [referenceElement, setReferenceElement] =
useState<HTMLButtonElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(
null
null,
);
const { styles, attributes } = usePopper(referenceElement, popperElement, {
@@ -46,7 +46,7 @@ export const CustomSearchSelect = (props: ICustomSearchSelectProps) => {
query === ""
? options
: options?.filter((option) =>
option.query.toLowerCase().includes(query.toLowerCase())
option.query.toLowerCase().includes(query.toLowerCase()),
);
const comboboxProps: any = {
@@ -87,8 +87,8 @@ export const CustomSearchSelect = (props: ICustomSearchSelectProps) => {
<button
ref={setReferenceElement}
type="button"
className={`flex items-center justify-between gap-1 w-full rounded-md border border-custom-border-300 shadow-sm duration-300 focus:outline-none ${
input ? "px-3 py-2 text-sm" : "px-2.5 py-1 text-xs"
className={`flex items-center justify-between gap-1 w-full rounded border-[0.5px] border-custom-border-300 ${
input ? "px-3 py-2 text-sm" : "px-2 py-1 text-xs"
} ${
disabled
? "cursor-not-allowed text-custom-text-200"

View File

@@ -30,7 +30,7 @@ const CustomSelect = (props: ICustomSelectProps) => {
const [referenceElement, setReferenceElement] =
useState<HTMLButtonElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(
null
null,
);
const { styles, attributes } = usePopper(referenceElement, popperElement, {
@@ -65,8 +65,8 @@ const CustomSelect = (props: ICustomSelectProps) => {
<button
ref={setReferenceElement}
type="button"
className={`flex items-center justify-between gap-1 w-full rounded-md border border-custom-border-300 shadow-sm duration-300 focus:outline-none ${
input ? "px-3 py-2 text-sm" : "px-2.5 py-1 text-xs"
className={`flex items-center justify-between gap-1 w-full rounded border-[0.5px] border-custom-border-300 ${
input ? "px-3 py-2 text-sm" : "px-2 py-1 text-xs"
} ${
disabled
? "cursor-not-allowed text-custom-text-200"

View File

@@ -1,3 +1,3 @@
export * from "./input";
export * from "./textarea";
export * from "./input-color-picker"
export * from "./input-color-picker";

View File

@@ -10,7 +10,7 @@ export interface TextAreaProps
// Updates the height of a <textarea> when the value changes.
const useAutoSizeTextArea = (
textAreaRef: HTMLTextAreaElement | null,
value: any
value: any,
) => {
React.useEffect(() => {
if (textAreaRef) {
@@ -63,7 +63,7 @@ const TextArea = React.forwardRef<HTMLTextAreaElement, TextAreaProps>(
{...rest}
/>
);
}
},
);
export { TextArea };

View File

@@ -18,18 +18,21 @@ export const PriorityIcon: React.FC<IPriorityIcon> = ({
}) => {
if (!className || className === "") className = "h-3.5 w-3.5";
// Convert to lowercase for string comparison
const lowercasePriority = priority?.toLowerCase();
return (
<>
{priority === "urgent" ? (
<AlertCircle className={`${className}`} />
) : priority === "high" ? (
<SignalHigh className={`${className}`} />
) : priority === "medium" ? (
<SignalMedium className={`${className}`} />
) : priority === "low" ? (
<SignalLow className={`${className}`} />
{lowercasePriority === "urgent" ? (
<AlertCircle className={`text-red-500 ${className}`} />
) : lowercasePriority === "high" ? (
<SignalHigh className={`text-orange-500 ${className}`} />
) : lowercasePriority === "medium" ? (
<SignalMedium className={`text-yellow-500 ${className}`} />
) : lowercasePriority === "low" ? (
<SignalLow className={`text-green-500 ${className}`} />
) : (
<Ban className={`${className}`} />
<Ban className={`text-custom-text-200 ${className}`} />
)}
</>
);

View File

@@ -9,7 +9,7 @@ interface ICircularProgressIndicator {
}
export const CircularProgressIndicator: React.FC<ICircularProgressIndicator> = (
props
props,
) => {
const { size = 40, percentage = 25, strokeWidth = 6, children } = props;

View File

@@ -76,6 +76,7 @@ export const AddComment: React.FC<Props> = observer((props) => {
handleSubmit(onSubmit)(e);
});
}}
cancelUploadImage={fileService.cancelUpload}
uploadFile={fileService.getUploadFileFunction(workspace_slug as string)}
deleteFile={fileService.deleteImage}
ref={editorRef}

View File

@@ -103,6 +103,7 @@ export const CommentCard: React.FC<Props> = observer((props) => {
render={({ field: { onChange, value } }) => (
<LiteTextEditorWithRef
onEnterKeyPress={handleSubmit(handleCommentUpdate)}
cancelUploadImage={fileService.cancelUpload}
uploadFile={fileService.getUploadFileFunction(workspaceSlug)}
deleteFile={fileService.deleteImage}
ref={editorRef}

View File

@@ -9,7 +9,6 @@ type Props = {
};
export const PeekOverviewIssueDetails: React.FC<Props> = ({ issueDetails }) => {
const mentionConfig = useEditorSuggestions();
return (
@@ -20,15 +19,19 @@ export const PeekOverviewIssueDetails: React.FC<Props> = ({ issueDetails }) => {
<h4 className="break-words text-2xl font-semibold">{issueDetails.name}</h4>
{issueDetails.description_html !== "" && issueDetails.description_html !== "<p></p>" && (
<RichReadOnlyEditor
value={!issueDetails.description_html ||
value={
!issueDetails.description_html ||
issueDetails.description_html === "" ||
(typeof issueDetails.description_html === "object" &&
Object.keys(issueDetails.description_html).length === 0)
? "<p></p>"
: issueDetails.description_html}
customClassName="p-3 min-h-[50px] shadow-sm" mentionHighlights={mentionConfig.mentionHighlights} />
? "<p></p>"
: issueDetails.description_html
}
customClassName="p-3 min-h-[50px] shadow-sm"
mentionHighlights={mentionConfig.mentionHighlights}
/>
)}
<IssueReactions />
</div>
)
);
};

View File

@@ -1,5 +1,6 @@
import APIService from "services/api.service";
import { API_BASE_URL } from "helpers/common.helper";
import axios from "axios";
interface UnSplashImage {
id: string;
@@ -26,25 +27,37 @@ interface UnSplashImageUrls {
}
class FileService extends APIService {
private cancelSource: any;
constructor() {
super(API_BASE_URL);
this.uploadFile = this.uploadFile.bind(this);
this.deleteImage = this.deleteImage.bind(this);
this.cancelUpload = this.cancelUpload.bind(this);
}
async uploadFile(workspaceSlug: string, file: FormData): Promise<any> {
this.cancelSource = axios.CancelToken.source();
return this.post(`/api/workspaces/${workspaceSlug}/file-assets/`, file, {
headers: {
...this.getHeaders(),
"Content-Type": "multipart/form-data",
},
cancelToken: this.cancelSource.token,
})
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
if (axios.isCancel(error)) {
console.log(error.message);
} else {
throw error?.response?.data;
}
});
}
cancelUpload() {
this.cancelSource.cancel("Upload cancelled");
}
getUploadFileFunction(workspaceSlug: string): (file: File) => Promise<string> {
return async (file: File) => {
const formData = new FormData();

View File

@@ -3,43 +3,41 @@ import { RootStore } from "./root";
import { computed, makeObservable } from "mobx";
export interface IMentionsStore {
// mentionSuggestions: IMentionSuggestion[];
mentionHighlights: IMentionHighlight[];
// mentionSuggestions: IMentionSuggestion[];
mentionHighlights: IMentionHighlight[];
}
export class MentionsStore implements IMentionsStore{
export class MentionsStore implements IMentionsStore {
// root store
rootStore;
// root store
rootStore;
constructor(_rootStore: RootStore) {
// rootStore
this.rootStore = _rootStore;
constructor(_rootStore: RootStore ){
makeObservable(this, {
mentionHighlights: computed,
// mentionSuggestions: computed
});
}
// rootStore
this.rootStore = _rootStore;
// get mentionSuggestions() {
// const projectMembers = this.rootStore.project.project.
makeObservable(this, {
mentionHighlights: computed,
// mentionSuggestions: computed
})
}
// const suggestions = projectMembers === null ? [] : projectMembers.map((member) => ({
// id: member.member.id,
// type: "User",
// title: member.member.display_name,
// subtitle: member.member.email ?? "",
// avatar: member.member.avatar,
// redirect_uri: `/${member.workspace.slug}/profile/${member.member.id}`,
// }))
// get mentionSuggestions() {
// const projectMembers = this.rootStore.project.project.
// return suggestions
// }
// const suggestions = projectMembers === null ? [] : projectMembers.map((member) => ({
// id: member.member.id,
// type: "User",
// title: member.member.display_name,
// subtitle: member.member.email ?? "",
// avatar: member.member.avatar,
// redirect_uri: `/${member.workspace.slug}/profile/${member.member.id}`,
// }))
// return suggestions
// }
get mentionHighlights() {
const user = this.rootStore.user.currentUser;
return user ? [user.id] : []
}
}
get mentionHighlights() {
const user = this.rootStore.user.currentUser;
return user ? [user.id] : [];
}
}

View File

@@ -92,7 +92,7 @@
transform: translateY(-50%);
}
.tableWrapper .tableControls .columnsControl > button {
.tableWrapper .tableControls .columnsControl .columnsControlDiv {
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;
@@ -104,26 +104,42 @@
transform: translateX(-50%);
}
.tableWrapper .tableControls .rowsControl > button {
.tableWrapper .tableControls .rowsControl .rowsControlDiv {
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;
}
.tableWrapper .tableControls button {
.tableWrapper .tableControls .rowsControlDiv {
background-color: rgba(var(--color-primary-100));
border: 1px solid rgba(var(--color-border-200));
border-radius: 2px;
background-size: 1.25rem;
background-repeat: no-repeat;
background-position: center;
transition: transform ease-out 100ms, background-color ease-out 100ms;
transition:
transform ease-out 100ms,
background-color ease-out 100ms;
outline: none;
box-shadow: #000 0px 2px 4px;
cursor: pointer;
}
.tableWrapper .tableControls .columnsControlDiv {
background-color: rgba(var(--color-primary-100));
border: 1px solid rgba(var(--color-border-200));
border-radius: 2px;
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: #000 0px 2px 4px;
cursor: pointer;
}
.tableWrapper .tableControls .tableToolbox,
.tableWrapper .tableControls .tableColorPickerToolbox {
border: 1px solid rgba(var(--color-border-300));

View File

@@ -32,8 +32,7 @@ export const GithubLoginButton: FC<GithubLoginButtonProps> = (props) => {
}, [code, gitCode, handleSignIn]);
useEffect(() => {
const origin =
typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
const origin = typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
setLoginCallBackURL(`${origin}/` as any);
}, []);

View File

@@ -49,10 +49,7 @@ export const CustomTooltip: React.FC<Props> = ({ datum, analytics, params }) =>
: ""
}`}
>
{params.segment === "assignees__id"
? renderAssigneeName(tooltipValue.toString())
: tooltipValue}
:
{params.segment === "assignees__id" ? renderAssigneeName(tooltipValue.toString()) : tooltipValue}:
</span>
<span>{datum.value}</span>
</div>

View File

@@ -112,8 +112,9 @@ export const AnalyticsGraph: React.FC<Props> = ({ analytics, barGraphData, param
<text
x={0}
y={datum.y}
textAnchor="end"
textAnchor={`${barGraphData.data.length > 7 ? "end" : "middle"}`}
fontSize={10}
fill="rgb(var(--color-text-200))"
className={`${barGraphData.data.length > 7 ? "-rotate-45" : ""}`}
>
{generateDisplayName(datum.value, analytics, params, "x_axis")}

View File

@@ -69,7 +69,6 @@ export const AnalyticsTable: React.FC<Props> = ({ analytics, barGraphData, param
}`}
>
{params.x_axis === "priority" ? (
// TODO: incorrect priority icon being rendered
<PriorityIcon priority={item.name as TIssuePriorities} />
) : (
<span

View File

@@ -22,9 +22,7 @@ export const AnalyticsScope: React.FC<Props> = ({ defaultAnalytics }) => (
keys={["count"]}
height="250px"
colors={() => `#f97316`}
customYAxisTickValues={defaultAnalytics.pending_issue_user.map((d) =>
d.count > 0 ? d.count : 50
)}
customYAxisTickValues={defaultAnalytics.pending_issue_user.map((d) => (d.count > 0 ? d.count : 50))}
tooltip={(datum) => {
const assignee = defaultAnalytics.pending_issue_user.find(
(a) => a.assignees__id === `${datum.indexValue}`

View File

@@ -31,9 +31,7 @@ export const NotAuthorizedView: React.FC<Props> = ({ actionButton, type }) => {
alt="ProjectSettingImg"
/>
</div>
<h1 className="text-xl font-medium text-custom-text-100">
Oops! You are not authorized to view this page
</h1>
<h1 className="text-xl font-medium text-custom-text-100">Oops! You are not authorized to view this page</h1>
<div className="w-full max-w-md text-base text-custom-text-200">
{user ? (

View File

@@ -1,7 +1,9 @@
import React, { useState } from "react";
import { observer } from "mobx-react-lite";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
// component
import { CustomSelect, ToggleSwitch } from "@plane/ui";
import { CustomSelect, Loader, ToggleSwitch } from "@plane/ui";
import { SelectMonthModal } from "components/automation";
// icon
import { ArchiveRestore } from "lucide-react";
@@ -11,15 +13,21 @@ import { PROJECT_AUTOMATION_MONTHS } from "constants/project";
import { IProject } from "types";
type Props = {
projectDetails: IProject | undefined;
handleChange: (formData: Partial<IProject>) => Promise<void>;
disabled?: boolean;
};
export const AutoArchiveAutomation: React.FC<Props> = ({ projectDetails, handleChange, disabled = false }) => {
const initialValues: Partial<IProject> = { archive_in: 1 };
export const AutoArchiveAutomation: React.FC<Props> = observer((props) => {
const { handleChange } = props;
// states
const [monthModal, setmonthModal] = useState(false);
const initialValues: Partial<IProject> = { archive_in: 1 };
const { user: userStore, project: projectStore } = useMobxStore();
const projectDetails = projectStore.currentProjectDetails;
const userRole = userStore.currentProjectRole;
return (
<>
<SelectMonthModal
@@ -48,46 +56,52 @@ export const AutoArchiveAutomation: React.FC<Props> = ({ projectDetails, handleC
projectDetails?.archive_in === 0 ? handleChange({ archive_in: 1 }) : handleChange({ archive_in: 0 })
}
size="sm"
disabled={disabled}
disabled={userRole !== 20}
/>
</div>
{projectDetails?.archive_in !== 0 && (
<div className="ml-12">
<div className="flex items-center justify-between rounded px-5 py-4 bg-custom-background-90 border-[0.5px] border-custom-border-200 gap-2 w-full">
<div className="w-1/2 text-sm font-medium">Auto-archive issues that are closed for</div>
<div className="w-1/2">
<CustomSelect
value={projectDetails?.archive_in}
label={`${projectDetails?.archive_in} ${projectDetails?.archive_in === 1 ? "Month" : "Months"}`}
onChange={(val: number) => {
handleChange({ archive_in: val });
}}
input
width="w-full"
disabled={disabled}
>
<>
{PROJECT_AUTOMATION_MONTHS.map((month) => (
<CustomSelect.Option key={month.label} value={month.value}>
<span className="text-sm">{month.label}</span>
</CustomSelect.Option>
))}
{projectDetails ? (
projectDetails.archive_in !== 0 && (
<div className="ml-12">
<div className="flex items-center justify-between rounded px-5 py-4 bg-custom-background-90 border border-custom-border-200 gap-2 w-full">
<div className="w-1/2 text-sm font-medium">Auto-archive issues that are closed for</div>
<div className="w-1/2">
<CustomSelect
value={projectDetails?.archive_in}
label={`${projectDetails?.archive_in} ${projectDetails?.archive_in === 1 ? "Month" : "Months"}`}
onChange={(val: number) => {
handleChange({ archive_in: val });
}}
input
width="w-full"
disabled={userRole !== 20}
>
<>
{PROJECT_AUTOMATION_MONTHS.map((month) => (
<CustomSelect.Option key={month.label} value={month.value}>
<span className="text-sm">{month.label}</span>
</CustomSelect.Option>
))}
<button
type="button"
className="flex w-full text-sm select-none items-center rounded px-1 py-1.5 text-custom-text-200 hover:bg-custom-background-80"
onClick={() => setmonthModal(true)}
>
Customise Time Range
</button>
</>
</CustomSelect>
<button
type="button"
className="flex w-full text-sm select-none items-center rounded px-1 py-1.5 text-custom-text-200 hover:bg-custom-background-80"
onClick={() => setmonthModal(true)}
>
Customise Time Range
</button>
</>
</CustomSelect>
</div>
</div>
</div>
</div>
)
) : (
<Loader className="ml-12">
<Loader.Item height="50px" />
</Loader>
)}
</div>
</>
);
};
});

View File

@@ -1,42 +1,32 @@
import React, { useState } from "react";
import useSWR from "swr";
import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
// component
import { SelectMonthModal } from "components/automation";
import { CustomSelect, CustomSearchSelect, ToggleSwitch, StateGroupIcon, DoubleCircleIcon } from "@plane/ui";
import { CustomSelect, CustomSearchSelect, ToggleSwitch, StateGroupIcon, DoubleCircleIcon, Loader } from "@plane/ui";
// icons
import { ArchiveX } from "lucide-react";
// services
import { ProjectStateService } from "services/project";
// constants
import { PROJECT_AUTOMATION_MONTHS } from "constants/project";
import { STATES_LIST } from "constants/fetch-keys";
// types
import { IProject } from "types";
// helper
import { getStatesList } from "helpers/state.helper";
// fetch keys
import { PROJECT_AUTOMATION_MONTHS } from "constants/project";
type Props = {
projectDetails: IProject | undefined;
handleChange: (formData: Partial<IProject>) => Promise<void>;
disabled?: boolean;
};
const projectStateService = new ProjectStateService();
export const AutoCloseAutomation: React.FC<Props> = ({ projectDetails, handleChange, disabled = false }) => {
export const AutoCloseAutomation: React.FC<Props> = observer((props) => {
const { handleChange } = props;
// states
const [monthModal, setmonthModal] = useState(false);
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { user: userStore, project: projectStore, projectState: projectStateStore } = useMobxStore();
const { data: stateGroups } = useSWR(
workspaceSlug && projectId ? STATES_LIST(projectId as string) : null,
workspaceSlug && projectId
? () => projectStateService.getStates(workspaceSlug as string, projectId as string)
: null
);
const states = getStatesList(stateGroups);
const userRole = userStore.currentProjectRole;
const projectDetails = projectStore.currentProjectDetails;
// const stateGroups = projectStateStore.groupedProjectStates ?? undefined;
const states = projectStateStore.projectStates;
const options = states
?.filter((state) => state.group === "cancelled")
@@ -53,7 +43,7 @@ export const AutoCloseAutomation: React.FC<Props> = ({ projectDetails, handleCha
const multipleOptions = (options ?? []).length > 1;
const defaultState = stateGroups && stateGroups.cancelled ? stateGroups.cancelled[0].id : null;
const defaultState = states?.find((s) => s.group === "cancelled")?.id || null;
const selectedOption = states?.find((s) => s.id === projectDetails?.default_state ?? defaultState);
const currentDefaultState = states?.find((s) => s.id === defaultState);
@@ -72,8 +62,7 @@ export const AutoCloseAutomation: React.FC<Props> = ({ projectDetails, handleCha
handleClose={() => setmonthModal(false)}
handleChange={handleChange}
/>
<div className="flex flex-col gap-4 border-b border-custom-border-100 px-4 py-6">
<div className="flex flex-col gap-4 border-b border-custom-border-200 px-4 py-6">
<div className="flex items-center justify-between">
<div className="flex items-start gap-3">
<div className="flex items-center justify-center p-3 rounded bg-custom-background-90">
@@ -82,7 +71,7 @@ export const AutoCloseAutomation: React.FC<Props> = ({ projectDetails, handleCha
<div className="">
<h4 className="text-sm font-medium">Auto-close issues</h4>
<p className="text-sm text-custom-text-200 tracking-tight">
Plane will automatically close issue that havent been completed or cancelled.
Plane will automatically close issue that haven{"'"}t been completed or cancelled.
</p>
</div>
</div>
@@ -94,87 +83,93 @@ export const AutoCloseAutomation: React.FC<Props> = ({ projectDetails, handleCha
: handleChange({ close_in: 0, default_state: null })
}
size="sm"
disabled={disabled}
disabled={userRole !== 20}
/>
</div>
{projectDetails?.close_in !== 0 && (
<div className="ml-12">
<div className="flex flex-col rounded bg-custom-background-90 border-[0.5px] border-custom-border-200 p-2">
<div className="flex items-center justify-between px-5 py-4 gap-2 w-full">
<div className="w-1/2 text-sm font-medium">Auto-close issues that are inactive for</div>
<div className="w-1/2">
<CustomSelect
value={projectDetails?.close_in}
label={`${projectDetails?.close_in} ${projectDetails?.close_in === 1 ? "Month" : "Months"}`}
onChange={(val: number) => {
handleChange({ close_in: val });
}}
input
width="w-full"
disabled={disabled}
>
<>
{PROJECT_AUTOMATION_MONTHS.map((month) => (
<CustomSelect.Option key={month.label} value={month.value}>
{month.label}
</CustomSelect.Option>
))}
<button
type="button"
className="flex w-full select-none items-center rounded px-1 py-1.5 text-custom-text-200 hover:bg-custom-background-80"
onClick={() => setmonthModal(true)}
>
Customise Time Range
</button>
</>
</CustomSelect>
{projectDetails ? (
projectDetails.close_in !== 0 && (
<div className="ml-12">
<div className="flex flex-col rounded bg-custom-background-90 border border-custom-border-200">
<div className="flex items-center justify-between px-5 py-4 gap-2 w-full">
<div className="w-1/2 text-sm font-medium">Auto-close issues that are inactive for</div>
<div className="w-1/2">
<CustomSelect
value={projectDetails?.close_in}
label={`${projectDetails?.close_in} ${projectDetails?.close_in === 1 ? "Month" : "Months"}`}
onChange={(val: number) => {
handleChange({ close_in: val });
}}
input
width="w-full"
disabled={userRole !== 20}
>
<>
{PROJECT_AUTOMATION_MONTHS.map((month) => (
<CustomSelect.Option key={month.label} value={month.value}>
{month.label}
</CustomSelect.Option>
))}
<button
type="button"
className="flex w-full select-none items-center rounded px-1 py-1.5 text-custom-text-200 hover:bg-custom-background-80"
onClick={() => setmonthModal(true)}
>
Customise Time Range
</button>
</>
</CustomSelect>
</div>
</div>
</div>
<div className="flex items-center justify-between px-5 py-4 gap-2 w-full">
<div className="w-1/2 text-sm font-medium">Auto-close Status</div>
<div className="w-1/2 ">
<CustomSearchSelect
value={projectDetails?.default_state ? projectDetails?.default_state : defaultState}
label={
<div className="flex items-center gap-2">
{selectedOption ? (
<StateGroupIcon
stateGroup={selectedOption.group}
color={selectedOption.color}
height="16px"
width="16px"
/>
) : currentDefaultState ? (
<StateGroupIcon
stateGroup={currentDefaultState.group}
color={currentDefaultState.color}
height="16px"
width="16px"
/>
) : (
<DoubleCircleIcon className="h-3.5 w-3.5 text-custom-text-200" />
)}
{selectedOption?.name
? selectedOption.name
: currentDefaultState?.name ?? <span className="text-custom-text-200">State</span>}
</div>
}
onChange={(val: string) => {
handleChange({ default_state: val });
}}
options={options}
disabled={!multipleOptions}
width="w-full"
input
/>
<div className="flex items-center justify-between px-5 py-4 gap-2 w-full">
<div className="w-1/2 text-sm font-medium">Auto-close Status</div>
<div className="w-1/2 ">
<CustomSearchSelect
value={projectDetails?.default_state ?? defaultState}
label={
<div className="flex items-center gap-2">
{selectedOption ? (
<StateGroupIcon
stateGroup={selectedOption.group}
color={selectedOption.color}
height="16px"
width="16px"
/>
) : currentDefaultState ? (
<StateGroupIcon
stateGroup={currentDefaultState.group}
color={currentDefaultState.color}
height="16px"
width="16px"
/>
) : (
<DoubleCircleIcon className="h-3.5 w-3.5 text-custom-text-200" />
)}
{selectedOption?.name
? selectedOption.name
: currentDefaultState?.name ?? <span className="text-custom-text-200">State</span>}
</div>
}
onChange={(val: string) => {
handleChange({ default_state: val });
}}
options={options}
disabled={!multipleOptions}
width="w-full"
input
/>
</div>
</div>
</div>
</div>
</div>
)
) : (
<Loader className="ml-12">
<Loader.Item height="50px" />
</Loader>
)}
</div>
</>
);
};
});

View File

@@ -54,7 +54,7 @@ export const SelectMonthModal: React.FC<Props> = ({ type, initialValues, isOpen,
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-[#131313] bg-opacity-50 transition-opacity" />
<div className="fixed inset-0 bg-custom-backdrop transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-10 overflow-y-auto">
@@ -68,7 +68,7 @@ export const SelectMonthModal: React.FC<Props> = ({ type, initialValues, isOpen,
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="relative transform rounded-lg bg-custom-background-90 px-4 pt-5 pb-4 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-2xl sm:p-6">
<Dialog.Panel className="relative transform rounded-lg bg-custom-background-100 px-4 pt-5 pb-4 text-left shadow-custom-shadow-md transition-all sm:my-8 sm:w-full sm:max-w-2xl sm:p-6">
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-custom-text-100">
@@ -144,10 +144,10 @@ export const SelectMonthModal: React.FC<Props> = ({ type, initialValues, isOpen,
</div>
</div>
<div className="mt-5 flex justify-end gap-2">
<Button variant="neutral-primary" onClick={onClose}>
<Button variant="neutral-primary" size="sm" onClick={onClose}>
Cancel
</Button>
<Button variant="primary" type="submit" loading={isSubmitting}>
<Button variant="primary" size="sm" type="submit" loading={isSubmitting}>
{isSubmitting ? "Submitting..." : "Submit"}
</Button>
</div>

View File

@@ -245,7 +245,7 @@ export const CommandModal: React.FC<Props> = observer((props) => {
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-custom-backdrop bg-opacity-50 transition-opacity" />
<div className="fixed inset-0 bg-custom-backdrop transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-30 overflow-y-auto p-4 sm:p-6 md:p-20">
@@ -259,7 +259,7 @@ export const CommandModal: React.FC<Props> = observer((props) => {
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="relative flex items-center justify-center w-full ">
<div className="w-full max-w-2xl transform divide-y divide-custom-border-200 divide-opacity-10 rounded-xl border border-custom-border-200 bg-custom-background-100 shadow-2xl transition-all">
<div className="w-full max-w-2xl transform divide-y divide-custom-border-200 divide-opacity-10 rounded-lg bg-custom-background-100 shadow-custom-shadow-md transition-all">
<Command
filter={(value, search) => {
if (value.toLowerCase().includes(search.toLowerCase())) return 1;

View File

@@ -26,15 +26,16 @@ const issueService = new IssueService();
export const ChangeIssueAssignee: FC<Props> = observer((props) => {
const { setIsPaletteOpen, issue, user } = props;
// router
const router = useRouter();
const { workspaceSlug, projectId, issueId } = router.query;
const { project: projectStore } = useMobxStore();
const members = projectId ? projectStore.members?.[projectId.toString()] : undefined;
// store
const {
projectMember: { projectMembers },
} = useMobxStore();
const options =
members?.map(({ member }) => ({
projectMembers?.map(({ member }) => ({
value: member.id,
query: member.display_name,
content: (

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