Compare commits
45
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6947a0ea43 | ||
|
|
a531334e76 | ||
|
|
87cd47ff08 | ||
|
|
b60083dd05 | ||
|
|
ed84060fae | ||
|
|
fb44455503 | ||
|
|
a6567bbce4 | ||
|
|
556b2d2617 | ||
|
|
10037222b6 | ||
|
|
931f9d288a | ||
|
|
20fb79567f | ||
|
|
bd1a850f35 | ||
|
|
206f5744a3 | ||
|
|
faaba45e59 | ||
|
|
f8002852e0 | ||
|
|
2d71377722 | ||
|
|
6ebee05951 | ||
|
|
5a3bac998e | ||
|
|
4096136b44 | ||
|
|
83e0c4ebbd | ||
|
|
df8bdfd5b9 | ||
|
|
da799b5a63 | ||
|
|
621d551c4a | ||
|
|
5a84ed279d | ||
|
|
53e7da08e4 | ||
|
|
9f206331bc | ||
|
|
b56d188a83 | ||
|
|
8d3853b129 | ||
|
|
30d6235108 | ||
|
|
6e461dd8c3 | ||
|
|
86379c51b7 | ||
|
|
f7cc2eca36 | ||
|
|
63d1ad286b | ||
|
|
1412c1c94a | ||
|
|
26de35bd8d | ||
|
|
1986c0dfd4 | ||
|
|
25f3a5b2e4 | ||
|
|
f30b16e9d8 | ||
|
|
93d03f82b4 | ||
|
|
98974fdc50 | ||
|
|
040563d148 | ||
|
|
4de64f112f | ||
|
|
0afb900678 | ||
|
|
baf17a109b | ||
|
|
37bf465fcd |
@@ -1,33 +1,41 @@
|
||||
|
||||
name: Docker Branch Build
|
||||
name: Branch Build
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
logLevel:
|
||||
description: 'Log level'
|
||||
required: true
|
||||
default: 'warning'
|
||||
tags:
|
||||
description: 'Dev/QA Builds'
|
||||
pull_request:
|
||||
types:
|
||||
- closed
|
||||
branches:
|
||||
- master
|
||||
- release
|
||||
- qa
|
||||
- develop
|
||||
|
||||
env:
|
||||
gh_branch: ${{ github.ref_name }}
|
||||
img_tag: latest
|
||||
TARGET_BRANCH: ${{ github.event.pull_request.base.ref }}
|
||||
|
||||
jobs:
|
||||
branch_build_and_push:
|
||||
if: ${{ (github.event_name == 'pull_request' && github.event.action =='closed' && github.event.pull_request.merged == true) }}
|
||||
name: Build-Push Web/Space/API/Proxy Docker Image
|
||||
runs-on: ubuntu-20.04
|
||||
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v3.3.0
|
||||
|
||||
# - name: Set Target Branch Name on PR close
|
||||
# if: ${{ github.event_name == 'pull_request' && github.event.action =='closed' }}
|
||||
# run: echo "TARGET_BRANCH=${{ github.event.pull_request.base.ref }}" >> $GITHUB_ENV
|
||||
|
||||
# - name: Set Target Branch Name on other than PR close
|
||||
# if: ${{ github.event_name == 'push' }}
|
||||
# run: echo "TARGET_BRANCH=${{ github.ref_name }}" >> $GITHUB_ENV
|
||||
|
||||
- uses: ASzc/change-string-case-action@v2
|
||||
id: gh_branch_upper_lower
|
||||
with:
|
||||
string: ${{ env.gh_branch }}
|
||||
string: ${{env.TARGET_BRANCH}}
|
||||
|
||||
- uses: mad9000/actions-find-and-replace-string@2
|
||||
id: gh_branch_replace_slash
|
||||
@@ -111,7 +119,7 @@ jobs:
|
||||
env:
|
||||
DOCKER_BUILDKIT: 1
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
DOCKET_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
branch_build_push_space:
|
||||
runs-on: ubuntu-20.04
|
||||
@@ -141,7 +149,7 @@ jobs:
|
||||
env:
|
||||
DOCKER_BUILDKIT: 1
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
DOCKET_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
branch_build_push_backend:
|
||||
runs-on: ubuntu-20.04
|
||||
@@ -171,7 +179,7 @@ jobs:
|
||||
env:
|
||||
DOCKER_BUILDKIT: 1
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
DOCKET_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
branch_build_push_proxy:
|
||||
runs-on: ubuntu-20.04
|
||||
@@ -202,4 +210,4 @@ jobs:
|
||||
env:
|
||||
DOCKER_BUILDKIT: 1
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
DOCKET_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
+16
-16
@@ -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.
|
||||
|
||||
+17
-1
@@ -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.
|
||||
|
||||
@@ -70,3 +70,6 @@ ENABLE_MAGIC_LINK_LOGIN="0"
|
||||
# Email redirections and minio domain settings
|
||||
WEB_URL="http://localhost"
|
||||
|
||||
|
||||
# Gunicorn Workers
|
||||
GUNICORN_WORKERS=2
|
||||
|
||||
@@ -6,4 +6,4 @@ python manage.py migrate
|
||||
# Create a Default User
|
||||
python bin/user_script.py
|
||||
|
||||
exec gunicorn -w 8 -k uvicorn.workers.UvicornWorker plane.asgi:application --bind 0.0.0.0:8000 --max-requests 1200 --max-requests-jitter 1000 --access-logfile -
|
||||
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 -
|
||||
|
||||
@@ -13,14 +13,15 @@ Guest = 5
|
||||
|
||||
class ProjectBasePermission(BasePermission):
|
||||
def has_permission(self, request, view):
|
||||
|
||||
if request.user.is_anonymous:
|
||||
return False
|
||||
|
||||
## Safe Methods -> Handle the filtering logic in queryset
|
||||
if request.method in SAFE_METHODS:
|
||||
return WorkspaceMember.objects.filter(
|
||||
workspace__slug=view.workspace_slug, member=request.user
|
||||
workspace__slug=view.workspace_slug,
|
||||
member=request.user,
|
||||
is_active=True,
|
||||
).exists()
|
||||
|
||||
## Only workspace owners or admins can create the projects
|
||||
@@ -29,6 +30,7 @@ class ProjectBasePermission(BasePermission):
|
||||
workspace__slug=view.workspace_slug,
|
||||
member=request.user,
|
||||
role__in=[Admin, Member],
|
||||
is_active=True,
|
||||
).exists()
|
||||
|
||||
## Only Project Admins can update project attributes
|
||||
@@ -37,19 +39,21 @@ class ProjectBasePermission(BasePermission):
|
||||
member=request.user,
|
||||
role=Admin,
|
||||
project_id=view.project_id,
|
||||
is_active=True,
|
||||
).exists()
|
||||
|
||||
|
||||
class ProjectMemberPermission(BasePermission):
|
||||
def has_permission(self, request, view):
|
||||
|
||||
if request.user.is_anonymous:
|
||||
return False
|
||||
|
||||
## Safe Methods -> Handle the filtering logic in queryset
|
||||
if request.method in SAFE_METHODS:
|
||||
return ProjectMember.objects.filter(
|
||||
workspace__slug=view.workspace_slug, member=request.user
|
||||
workspace__slug=view.workspace_slug,
|
||||
member=request.user,
|
||||
is_active=True,
|
||||
).exists()
|
||||
## Only workspace owners or admins can create the projects
|
||||
if request.method == "POST":
|
||||
@@ -57,6 +61,7 @@ class ProjectMemberPermission(BasePermission):
|
||||
workspace__slug=view.workspace_slug,
|
||||
member=request.user,
|
||||
role__in=[Admin, Member],
|
||||
is_active=True,
|
||||
).exists()
|
||||
|
||||
## Only Project Admins can update project attributes
|
||||
@@ -65,12 +70,12 @@ class ProjectMemberPermission(BasePermission):
|
||||
member=request.user,
|
||||
role__in=[Admin, Member],
|
||||
project_id=view.project_id,
|
||||
is_active=True,
|
||||
).exists()
|
||||
|
||||
|
||||
class ProjectEntityPermission(BasePermission):
|
||||
def has_permission(self, request, view):
|
||||
|
||||
if request.user.is_anonymous:
|
||||
return False
|
||||
|
||||
@@ -80,6 +85,7 @@ class ProjectEntityPermission(BasePermission):
|
||||
workspace__slug=view.workspace_slug,
|
||||
member=request.user,
|
||||
project_id=view.project_id,
|
||||
is_active=True,
|
||||
).exists()
|
||||
|
||||
## Only project members or admins can create and edit the project attributes
|
||||
@@ -88,17 +94,18 @@ class ProjectEntityPermission(BasePermission):
|
||||
member=request.user,
|
||||
role__in=[Admin, Member],
|
||||
project_id=view.project_id,
|
||||
is_active=True,
|
||||
).exists()
|
||||
|
||||
|
||||
class ProjectLitePermission(BasePermission):
|
||||
|
||||
def has_permission(self, request, view):
|
||||
if request.user.is_anonymous:
|
||||
return False
|
||||
|
||||
|
||||
return ProjectMember.objects.filter(
|
||||
workspace__slug=view.workspace_slug,
|
||||
member=request.user,
|
||||
project_id=view.project_id,
|
||||
is_active=True,
|
||||
).exists()
|
||||
|
||||
@@ -32,12 +32,16 @@ class WorkSpaceBasePermission(BasePermission):
|
||||
member=request.user,
|
||||
workspace__slug=view.workspace_slug,
|
||||
role__in=[Owner, Admin],
|
||||
is_active=True,
|
||||
).exists()
|
||||
|
||||
# allow only owner to delete the workspace
|
||||
if request.method == "DELETE":
|
||||
return WorkspaceMember.objects.filter(
|
||||
member=request.user, workspace__slug=view.workspace_slug, role=Owner
|
||||
member=request.user,
|
||||
workspace__slug=view.workspace_slug,
|
||||
role=Owner,
|
||||
is_active=True,
|
||||
).exists()
|
||||
|
||||
|
||||
@@ -50,6 +54,7 @@ class WorkSpaceAdminPermission(BasePermission):
|
||||
member=request.user,
|
||||
workspace__slug=view.workspace_slug,
|
||||
role__in=[Owner, Admin],
|
||||
is_active=True,
|
||||
).exists()
|
||||
|
||||
|
||||
@@ -63,12 +68,14 @@ class WorkspaceEntityPermission(BasePermission):
|
||||
return WorkspaceMember.objects.filter(
|
||||
workspace__slug=view.workspace_slug,
|
||||
member=request.user,
|
||||
is_active=True,
|
||||
).exists()
|
||||
|
||||
return WorkspaceMember.objects.filter(
|
||||
member=request.user,
|
||||
workspace__slug=view.workspace_slug,
|
||||
role__in=[Owner, Admin],
|
||||
is_active=True,
|
||||
).exists()
|
||||
|
||||
|
||||
@@ -78,5 +85,8 @@ class WorkspaceViewerPermission(BasePermission):
|
||||
return False
|
||||
|
||||
return WorkspaceMember.objects.filter(
|
||||
member=request.user, workspace__slug=view.workspace_slug, role__gte=10
|
||||
member=request.user,
|
||||
workspace__slug=view.workspace_slug,
|
||||
role__gte=10,
|
||||
is_active=True,
|
||||
).exists()
|
||||
|
||||
@@ -103,7 +103,10 @@ class ProjectListSerializer(DynamicBaseSerializer):
|
||||
members = serializers.SerializerMethodField()
|
||||
|
||||
def get_members(self, obj):
|
||||
project_members = ProjectMember.objects.filter(project_id=obj.id).values(
|
||||
project_members = ProjectMember.objects.filter(
|
||||
project_id=obj.id,
|
||||
is_active=True,
|
||||
).values(
|
||||
"id",
|
||||
"member_id",
|
||||
"member__display_name",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -11,7 +11,6 @@ from plane.api.views import (
|
||||
ProjectUserViewsEndpoint,
|
||||
ProjectIdentifierEndpoint,
|
||||
ProjectFavoritesViewSet,
|
||||
LeaveProjectEndpoint,
|
||||
ProjectPublicCoverImagesEndpoint,
|
||||
)
|
||||
|
||||
@@ -51,7 +50,12 @@ urlpatterns = [
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/members/",
|
||||
ProjectMemberViewSet.as_view({"get": "list", "post": "create"}),
|
||||
ProjectMemberViewSet.as_view(
|
||||
{
|
||||
"get": "list",
|
||||
"post": "create",
|
||||
}
|
||||
),
|
||||
name="project-member",
|
||||
),
|
||||
path(
|
||||
@@ -65,6 +69,15 @@ urlpatterns = [
|
||||
),
|
||||
name="project-member",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/members/leave/",
|
||||
ProjectMemberViewSet.as_view(
|
||||
{
|
||||
"post": "leave",
|
||||
}
|
||||
),
|
||||
name="project-member",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/join/",
|
||||
ProjectJoinEndpoint.as_view(),
|
||||
@@ -119,11 +132,6 @@ urlpatterns = [
|
||||
),
|
||||
name="project-favorite",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/members/leave/",
|
||||
LeaveProjectEndpoint.as_view(),
|
||||
name="leave-project",
|
||||
),
|
||||
path(
|
||||
"project-covers/",
|
||||
ProjectPublicCoverImagesEndpoint.as_view(),
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -26,7 +26,11 @@ urlpatterns = [
|
||||
path(
|
||||
"users/me/",
|
||||
UserEndpoint.as_view(
|
||||
{"get": "retrieve", "patch": "partial_update", "delete": "destroy"}
|
||||
{
|
||||
"get": "retrieve",
|
||||
"patch": "partial_update",
|
||||
"delete": "deactivate",
|
||||
}
|
||||
),
|
||||
name="users",
|
||||
),
|
||||
|
||||
@@ -17,7 +17,6 @@ from plane.api.views import (
|
||||
WorkspaceUserProfileEndpoint,
|
||||
WorkspaceUserProfileIssuesEndpoint,
|
||||
WorkspaceLabelsEndpoint,
|
||||
LeaveWorkspaceEndpoint,
|
||||
)
|
||||
|
||||
|
||||
@@ -85,6 +84,15 @@ urlpatterns = [
|
||||
),
|
||||
name="workspace-member",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/members/leave/",
|
||||
WorkSpaceMemberViewSet.as_view(
|
||||
{
|
||||
"post": "leave",
|
||||
},
|
||||
),
|
||||
name="leave-workspace-members",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/teams/",
|
||||
TeamMemberViewSet.as_view(
|
||||
@@ -168,9 +176,4 @@ urlpatterns = [
|
||||
WorkspaceLabelsEndpoint.as_view(),
|
||||
name="workspace-labels",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/members/leave/",
|
||||
LeaveWorkspaceEndpoint.as_view(),
|
||||
name="leave-workspace-members",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -14,7 +14,6 @@ from .project import (
|
||||
ProjectDeployBoardViewSet,
|
||||
ProjectDeployBoardPublicSettingsEndpoint,
|
||||
WorkspaceProjectDeployBoardEndpoint,
|
||||
LeaveProjectEndpoint,
|
||||
ProjectPublicCoverImagesEndpoint,
|
||||
)
|
||||
from .user import (
|
||||
@@ -51,7 +50,6 @@ from .workspace import (
|
||||
WorkspaceUserProfileEndpoint,
|
||||
WorkspaceUserProfileIssuesEndpoint,
|
||||
WorkspaceLabelsEndpoint,
|
||||
LeaveWorkspaceEndpoint,
|
||||
)
|
||||
from .state import StateViewSet
|
||||
from .view import GlobalViewViewSet, GlobalViewIssuesViewSet, IssueViewViewSet, IssueViewFavoriteViewSet
|
||||
|
||||
@@ -319,6 +319,13 @@ class MagicSignInEndpoint(BaseAPIView):
|
||||
if str(token) == str(user_token):
|
||||
if User.objects.filter(email=email).exists():
|
||||
user = User.objects.get(email=email)
|
||||
if not user.is_active:
|
||||
return Response(
|
||||
{
|
||||
"error": "Your account has been deactivated. Please contact your site administrator."
|
||||
},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
# Send event to Jitsu for tracking
|
||||
if settings.ANALYTICS_BASE_API:
|
||||
_ = requests.post(
|
||||
|
||||
@@ -21,8 +21,8 @@ class ConfigurationEndpoint(BaseAPIView):
|
||||
|
||||
def get(self, request):
|
||||
data = {}
|
||||
data["google"] = os.environ.get("GOOGLE_CLIENT_ID", None)
|
||||
data["github"] = os.environ.get("GITHUB_CLIENT_ID", None)
|
||||
data["google_client_id"] = os.environ.get("GOOGLE_CLIENT_ID", None)
|
||||
data["github_client_id"] = os.environ.get("GITHUB_CLIENT_ID", None)
|
||||
data["github_app_name"] = os.environ.get("GITHUB_APP_NAME", None)
|
||||
data["magic_login"] = (
|
||||
bool(settings.EMAIL_HOST_USER) and bool(settings.EMAIL_HOST_PASSWORD)
|
||||
@@ -30,5 +30,5 @@ class ConfigurationEndpoint(BaseAPIView):
|
||||
data["email_password_login"] = (
|
||||
os.environ.get("ENABLE_EMAIL_PASSWORD", "0") == "1"
|
||||
)
|
||||
data["slack"] = os.environ.get("SLACK_CLIENT_ID", None)
|
||||
data["slack_client_id"] = os.environ.get("SLACK_CLIENT_ID", None)
|
||||
return Response(data, status=status.HTTP_200_OK)
|
||||
|
||||
@@ -479,13 +479,13 @@ class CycleViewSet(BaseViewSet):
|
||||
)
|
||||
)
|
||||
cycle = Cycle.objects.get(workspace__slug=slug, project_id=project_id, pk=pk)
|
||||
# Delete the cycle
|
||||
cycle.delete()
|
||||
|
||||
issue_activity.delay(
|
||||
type="cycle.activity.deleted",
|
||||
requested_data=json.dumps(
|
||||
{
|
||||
"cycle_id": str(pk),
|
||||
"cycle_name": str(cycle.name),
|
||||
"issues": [str(issue_id) for issue_id in cycle_issues],
|
||||
}
|
||||
),
|
||||
@@ -495,6 +495,8 @@ class CycleViewSet(BaseViewSet):
|
||||
current_instance=None,
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
)
|
||||
# Delete the cycle
|
||||
cycle.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
@@ -511,12 +513,6 @@ class CycleIssueViewSet(BaseViewSet):
|
||||
"issue__assignees__id",
|
||||
]
|
||||
|
||||
def perform_create(self, serializer):
|
||||
serializer.save(
|
||||
project_id=self.kwargs.get("project_id"),
|
||||
cycle_id=self.kwargs.get("cycle_id"),
|
||||
)
|
||||
|
||||
def get_queryset(self):
|
||||
return self.filter_queryset(
|
||||
super()
|
||||
@@ -669,7 +665,7 @@ class CycleIssueViewSet(BaseViewSet):
|
||||
type="cycle.activity.created",
|
||||
requested_data=json.dumps({"cycles_list": issues}),
|
||||
actor_id=str(self.request.user.id),
|
||||
issue_id=str(self.kwargs.get("pk", None)),
|
||||
issue_id=None,
|
||||
project_id=str(self.kwargs.get("project_id", None)),
|
||||
current_instance=json.dumps(
|
||||
{
|
||||
|
||||
@@ -64,9 +64,7 @@ class InboxViewSet(BaseViewSet):
|
||||
serializer.save(project_id=self.kwargs.get("project_id"))
|
||||
|
||||
def destroy(self, request, slug, project_id, pk):
|
||||
inbox = Inbox.objects.get(
|
||||
workspace__slug=slug, project_id=project_id, pk=pk
|
||||
)
|
||||
inbox = Inbox.objects.get(workspace__slug=slug, project_id=project_id, pk=pk)
|
||||
# Handle default inbox delete
|
||||
if inbox.is_default:
|
||||
return Response(
|
||||
@@ -128,9 +126,7 @@ class InboxIssueViewSet(BaseViewSet):
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
attachment_count=IssueAttachment.objects.filter(
|
||||
issue=OuterRef("id")
|
||||
)
|
||||
attachment_count=IssueAttachment.objects.filter(issue=OuterRef("id"))
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
@@ -150,7 +146,6 @@ class InboxIssueViewSet(BaseViewSet):
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
|
||||
def create(self, request, slug, project_id, inbox_id):
|
||||
if not request.data.get("issue", {}).get("name", False):
|
||||
return Response(
|
||||
@@ -198,7 +193,7 @@ class InboxIssueViewSet(BaseViewSet):
|
||||
issue_id=str(issue.id),
|
||||
project_id=str(project_id),
|
||||
current_instance=None,
|
||||
epoch=int(timezone.now().timestamp())
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
)
|
||||
# create an inbox issue
|
||||
InboxIssue.objects.create(
|
||||
@@ -216,10 +211,20 @@ class InboxIssueViewSet(BaseViewSet):
|
||||
pk=pk, workspace__slug=slug, project_id=project_id, inbox_id=inbox_id
|
||||
)
|
||||
# Get the project member
|
||||
project_member = ProjectMember.objects.get(workspace__slug=slug, project_id=project_id, member=request.user)
|
||||
project_member = ProjectMember.objects.get(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
member=request.user,
|
||||
is_active=True,
|
||||
)
|
||||
# Only project members admins and created_by users can access this endpoint
|
||||
if project_member.role <= 10 and str(inbox_issue.created_by_id) != str(request.user.id):
|
||||
return Response({"error": "You cannot edit inbox issues"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
if project_member.role <= 10 and str(inbox_issue.created_by_id) != str(
|
||||
request.user.id
|
||||
):
|
||||
return Response(
|
||||
{"error": "You cannot edit inbox issues"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Get issue data
|
||||
issue_data = request.data.pop("issue", False)
|
||||
@@ -230,11 +235,13 @@ class InboxIssueViewSet(BaseViewSet):
|
||||
)
|
||||
# Only allow guests and viewers to edit name and description
|
||||
if project_member.role <= 10:
|
||||
# viewers and guests since only viewers and guests
|
||||
# viewers and guests since only viewers and guests
|
||||
issue_data = {
|
||||
"name": issue_data.get("name", issue.name),
|
||||
"description_html": issue_data.get("description_html", issue.description_html),
|
||||
"description": issue_data.get("description", issue.description)
|
||||
"description_html": issue_data.get(
|
||||
"description_html", issue.description_html
|
||||
),
|
||||
"description": issue_data.get("description", issue.description),
|
||||
}
|
||||
|
||||
issue_serializer = IssueCreateSerializer(
|
||||
@@ -256,7 +263,7 @@ class InboxIssueViewSet(BaseViewSet):
|
||||
IssueSerializer(current_instance).data,
|
||||
cls=DjangoJSONEncoder,
|
||||
),
|
||||
epoch=int(timezone.now().timestamp())
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
)
|
||||
issue_serializer.save()
|
||||
else:
|
||||
@@ -307,7 +314,9 @@ class InboxIssueViewSet(BaseViewSet):
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
else:
|
||||
return Response(InboxIssueSerializer(inbox_issue).data, status=status.HTTP_200_OK)
|
||||
return Response(
|
||||
InboxIssueSerializer(inbox_issue).data, status=status.HTTP_200_OK
|
||||
)
|
||||
|
||||
def retrieve(self, request, slug, project_id, inbox_id, pk):
|
||||
inbox_issue = InboxIssue.objects.get(
|
||||
@@ -324,15 +333,27 @@ class InboxIssueViewSet(BaseViewSet):
|
||||
pk=pk, workspace__slug=slug, project_id=project_id, inbox_id=inbox_id
|
||||
)
|
||||
# Get the project member
|
||||
project_member = ProjectMember.objects.get(workspace__slug=slug, project_id=project_id, member=request.user)
|
||||
project_member = ProjectMember.objects.get(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
member=request.user,
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
if project_member.role <= 10 and str(inbox_issue.created_by_id) != str(request.user.id):
|
||||
return Response({"error": "You cannot delete inbox issue"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
if project_member.role <= 10 and str(inbox_issue.created_by_id) != str(
|
||||
request.user.id
|
||||
):
|
||||
return Response(
|
||||
{"error": "You cannot delete inbox issue"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Check the issue status
|
||||
if inbox_issue.status in [-2, -1, 0, 2]:
|
||||
# Delete the issue also
|
||||
Issue.objects.filter(workspace__slug=slug, project_id=project_id, pk=inbox_issue.issue_id).delete()
|
||||
Issue.objects.filter(
|
||||
workspace__slug=slug, project_id=project_id, pk=inbox_issue.issue_id
|
||||
).delete()
|
||||
|
||||
inbox_issue.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
@@ -347,7 +368,10 @@ class InboxIssuePublicViewSet(BaseViewSet):
|
||||
]
|
||||
|
||||
def get_queryset(self):
|
||||
project_deploy_board = ProjectDeployBoard.objects.get(workspace__slug=self.kwargs.get("slug"), project_id=self.kwargs.get("project_id"))
|
||||
project_deploy_board = ProjectDeployBoard.objects.get(
|
||||
workspace__slug=self.kwargs.get("slug"),
|
||||
project_id=self.kwargs.get("project_id"),
|
||||
)
|
||||
if project_deploy_board is not None:
|
||||
return self.filter_queryset(
|
||||
super()
|
||||
@@ -363,9 +387,14 @@ class InboxIssuePublicViewSet(BaseViewSet):
|
||||
return InboxIssue.objects.none()
|
||||
|
||||
def list(self, request, slug, project_id, inbox_id):
|
||||
project_deploy_board = ProjectDeployBoard.objects.get(workspace__slug=slug, project_id=project_id)
|
||||
project_deploy_board = ProjectDeployBoard.objects.get(
|
||||
workspace__slug=slug, project_id=project_id
|
||||
)
|
||||
if project_deploy_board.inbox is None:
|
||||
return Response({"error": "Inbox is not enabled for this Project Board"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
return Response(
|
||||
{"error": "Inbox is not enabled for this Project Board"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
filters = issue_filters(request.query_params, "GET")
|
||||
issues = (
|
||||
@@ -392,9 +421,7 @@ class InboxIssuePublicViewSet(BaseViewSet):
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
attachment_count=IssueAttachment.objects.filter(
|
||||
issue=OuterRef("id")
|
||||
)
|
||||
attachment_count=IssueAttachment.objects.filter(issue=OuterRef("id"))
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
@@ -415,9 +442,14 @@ class InboxIssuePublicViewSet(BaseViewSet):
|
||||
)
|
||||
|
||||
def create(self, request, slug, project_id, inbox_id):
|
||||
project_deploy_board = ProjectDeployBoard.objects.get(workspace__slug=slug, project_id=project_id)
|
||||
project_deploy_board = ProjectDeployBoard.objects.get(
|
||||
workspace__slug=slug, project_id=project_id
|
||||
)
|
||||
if project_deploy_board.inbox is None:
|
||||
return Response({"error": "Inbox is not enabled for this Project Board"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
return Response(
|
||||
{"error": "Inbox is not enabled for this Project Board"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
if not request.data.get("issue", {}).get("name", False):
|
||||
return Response(
|
||||
@@ -465,7 +497,7 @@ class InboxIssuePublicViewSet(BaseViewSet):
|
||||
issue_id=str(issue.id),
|
||||
project_id=str(project_id),
|
||||
current_instance=None,
|
||||
epoch=int(timezone.now().timestamp())
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
)
|
||||
# create an inbox issue
|
||||
InboxIssue.objects.create(
|
||||
@@ -479,34 +511,41 @@ class InboxIssuePublicViewSet(BaseViewSet):
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
def partial_update(self, request, slug, project_id, inbox_id, pk):
|
||||
project_deploy_board = ProjectDeployBoard.objects.get(workspace__slug=slug, project_id=project_id)
|
||||
project_deploy_board = ProjectDeployBoard.objects.get(
|
||||
workspace__slug=slug, project_id=project_id
|
||||
)
|
||||
if project_deploy_board.inbox is None:
|
||||
return Response({"error": "Inbox is not enabled for this Project Board"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
return Response(
|
||||
{"error": "Inbox is not enabled for this Project Board"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
inbox_issue = InboxIssue.objects.get(
|
||||
pk=pk, workspace__slug=slug, project_id=project_id, inbox_id=inbox_id
|
||||
)
|
||||
# Get the project member
|
||||
if str(inbox_issue.created_by_id) != str(request.user.id):
|
||||
return Response({"error": "You cannot edit inbox issues"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
return Response(
|
||||
{"error": "You cannot edit inbox issues"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Get issue data
|
||||
issue_data = request.data.pop("issue", False)
|
||||
|
||||
|
||||
issue = Issue.objects.get(
|
||||
pk=inbox_issue.issue_id, workspace__slug=slug, project_id=project_id
|
||||
)
|
||||
# viewers and guests since only viewers and guests
|
||||
# viewers and guests since only viewers and guests
|
||||
issue_data = {
|
||||
"name": issue_data.get("name", issue.name),
|
||||
"description_html": issue_data.get("description_html", issue.description_html),
|
||||
"description": issue_data.get("description", issue.description)
|
||||
"description_html": issue_data.get(
|
||||
"description_html", issue.description_html
|
||||
),
|
||||
"description": issue_data.get("description", issue.description),
|
||||
}
|
||||
|
||||
issue_serializer = IssueCreateSerializer(
|
||||
issue, data=issue_data, partial=True
|
||||
)
|
||||
issue_serializer = IssueCreateSerializer(issue, data=issue_data, partial=True)
|
||||
|
||||
if issue_serializer.is_valid():
|
||||
current_instance = issue
|
||||
@@ -523,17 +562,22 @@ class InboxIssuePublicViewSet(BaseViewSet):
|
||||
IssueSerializer(current_instance).data,
|
||||
cls=DjangoJSONEncoder,
|
||||
),
|
||||
epoch=int(timezone.now().timestamp())
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
)
|
||||
issue_serializer.save()
|
||||
return Response(issue_serializer.data, status=status.HTTP_200_OK)
|
||||
return Response(issue_serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def retrieve(self, request, slug, project_id, inbox_id, pk):
|
||||
project_deploy_board = ProjectDeployBoard.objects.get(workspace__slug=slug, project_id=project_id)
|
||||
project_deploy_board = ProjectDeployBoard.objects.get(
|
||||
workspace__slug=slug, project_id=project_id
|
||||
)
|
||||
if project_deploy_board.inbox is None:
|
||||
return Response({"error": "Inbox is not enabled for this Project Board"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
return Response(
|
||||
{"error": "Inbox is not enabled for this Project Board"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
inbox_issue = InboxIssue.objects.get(
|
||||
pk=pk, workspace__slug=slug, project_id=project_id, inbox_id=inbox_id
|
||||
)
|
||||
@@ -544,16 +588,24 @@ class InboxIssuePublicViewSet(BaseViewSet):
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
def destroy(self, request, slug, project_id, inbox_id, pk):
|
||||
project_deploy_board = ProjectDeployBoard.objects.get(workspace__slug=slug, project_id=project_id)
|
||||
project_deploy_board = ProjectDeployBoard.objects.get(
|
||||
workspace__slug=slug, project_id=project_id
|
||||
)
|
||||
if project_deploy_board.inbox is None:
|
||||
return Response({"error": "Inbox is not enabled for this Project Board"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
return Response(
|
||||
{"error": "Inbox is not enabled for this Project Board"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
inbox_issue = InboxIssue.objects.get(
|
||||
pk=pk, workspace__slug=slug, project_id=project_id, inbox_id=inbox_id
|
||||
)
|
||||
|
||||
if str(inbox_issue.created_by_id) != str(request.user.id):
|
||||
return Response({"error": "You cannot delete inbox issue"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
return Response(
|
||||
{"error": "You cannot delete inbox issue"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
inbox_issue.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@@ -59,6 +59,7 @@ class SlackProjectSyncViewSet(BaseViewSet):
|
||||
team_id=slack_response.get("team", {}).get("id"),
|
||||
team_name=slack_response.get("team", {}).get("name"),
|
||||
workspace_integration=workspace_integration,
|
||||
project_id=project_id,
|
||||
)
|
||||
_ = ProjectMember.objects.get_or_create(
|
||||
member=workspace_integration.actor, role=20, project_id=project_id
|
||||
|
||||
@@ -525,6 +525,7 @@ class IssueCommentViewSet(BaseViewSet):
|
||||
workspace__slug=self.kwargs.get("slug"),
|
||||
project_id=self.kwargs.get("project_id"),
|
||||
member_id=self.request.user.id,
|
||||
is_active=True,
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -1156,7 +1157,11 @@ class IssueSubscriberViewSet(BaseViewSet):
|
||||
|
||||
def list(self, request, slug, project_id, issue_id):
|
||||
members = (
|
||||
ProjectMember.objects.filter(workspace__slug=slug, project_id=project_id)
|
||||
ProjectMember.objects.filter(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
is_active=True,
|
||||
)
|
||||
.annotate(
|
||||
is_subscribed=Exists(
|
||||
IssueSubscriber.objects.filter(
|
||||
@@ -1400,6 +1405,7 @@ class IssueCommentPublicViewSet(BaseViewSet):
|
||||
workspace__slug=self.kwargs.get("slug"),
|
||||
project_id=self.kwargs.get("project_id"),
|
||||
member_id=self.request.user.id,
|
||||
is_active=True,
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -1440,6 +1446,7 @@ class IssueCommentPublicViewSet(BaseViewSet):
|
||||
if not ProjectMember.objects.filter(
|
||||
project_id=project_id,
|
||||
member=request.user,
|
||||
is_active=True,
|
||||
).exists():
|
||||
# Add the user for workspace tracking
|
||||
_ = ProjectPublicMember.objects.get_or_create(
|
||||
@@ -1553,6 +1560,7 @@ class IssueReactionPublicViewSet(BaseViewSet):
|
||||
if not ProjectMember.objects.filter(
|
||||
project_id=project_id,
|
||||
member=request.user,
|
||||
is_active=True,
|
||||
).exists():
|
||||
# Add the user for workspace tracking
|
||||
_ = ProjectPublicMember.objects.get_or_create(
|
||||
@@ -1646,7 +1654,9 @@ class CommentReactionPublicViewSet(BaseViewSet):
|
||||
project_id=project_id, comment_id=comment_id, actor=request.user
|
||||
)
|
||||
if not ProjectMember.objects.filter(
|
||||
project_id=project_id, member=request.user
|
||||
project_id=project_id,
|
||||
member=request.user,
|
||||
is_active=True,
|
||||
).exists():
|
||||
# Add the user for workspace tracking
|
||||
_ = ProjectPublicMember.objects.get_or_create(
|
||||
@@ -1731,7 +1741,9 @@ class IssueVotePublicViewSet(BaseViewSet):
|
||||
)
|
||||
# Add the user for workspace tracking
|
||||
if not ProjectMember.objects.filter(
|
||||
project_id=project_id, member=request.user
|
||||
project_id=project_id,
|
||||
member=request.user,
|
||||
is_active=True,
|
||||
).exists():
|
||||
_ = ProjectPublicMember.objects.get_or_create(
|
||||
project_id=project_id,
|
||||
@@ -2230,7 +2242,7 @@ class IssueDraftViewSet(BaseViewSet):
|
||||
def destroy(self, request, slug, project_id, pk=None):
|
||||
issue = Issue.objects.get(workspace__slug=slug, project_id=project_id, pk=pk)
|
||||
current_instance = json.dumps(
|
||||
IssueSerializer(current_instance).data, cls=DjangoJSONEncoder
|
||||
IssueSerializer(issue).data, cls=DjangoJSONEncoder
|
||||
)
|
||||
issue.delete()
|
||||
issue_activity.delay(
|
||||
|
||||
@@ -266,12 +266,12 @@ class ModuleViewSet(BaseViewSet):
|
||||
module_issues = list(
|
||||
ModuleIssue.objects.filter(module_id=pk).values_list("issue", flat=True)
|
||||
)
|
||||
module.delete()
|
||||
issue_activity.delay(
|
||||
type="module.activity.deleted",
|
||||
requested_data=json.dumps(
|
||||
{
|
||||
"module_id": str(pk),
|
||||
"module_name": str(module.name),
|
||||
"issues": [str(issue_id) for issue_id in module_issues],
|
||||
}
|
||||
),
|
||||
@@ -281,6 +281,7 @@ class ModuleViewSet(BaseViewSet):
|
||||
current_instance=None,
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
)
|
||||
module.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
@@ -297,12 +298,6 @@ class ModuleIssueViewSet(BaseViewSet):
|
||||
ProjectEntityPermission,
|
||||
]
|
||||
|
||||
def perform_create(self, serializer):
|
||||
serializer.save(
|
||||
project_id=self.kwargs.get("project_id"),
|
||||
module_id=self.kwargs.get("module_id"),
|
||||
)
|
||||
|
||||
def get_queryset(self):
|
||||
return self.filter_queryset(
|
||||
super()
|
||||
@@ -446,7 +441,7 @@ class ModuleIssueViewSet(BaseViewSet):
|
||||
type="module.activity.created",
|
||||
requested_data=json.dumps({"modules_list": issues}),
|
||||
actor_id=str(self.request.user.id),
|
||||
issue_id=str(self.kwargs.get("pk", None)),
|
||||
issue_id=None,
|
||||
project_id=str(self.kwargs.get("project_id", None)),
|
||||
current_instance=json.dumps(
|
||||
{
|
||||
|
||||
@@ -85,7 +85,10 @@ class NotificationViewSet(BaseViewSet, BasePaginator):
|
||||
# Created issues
|
||||
if type == "created":
|
||||
if WorkspaceMember.objects.filter(
|
||||
workspace__slug=slug, member=request.user, role__lt=15
|
||||
workspace__slug=slug,
|
||||
member=request.user,
|
||||
role__lt=15,
|
||||
is_active=True,
|
||||
).exists():
|
||||
notifications = Notification.objects.none()
|
||||
else:
|
||||
@@ -255,7 +258,10 @@ class MarkAllReadNotificationViewSet(BaseViewSet):
|
||||
# Created issues
|
||||
if type == "created":
|
||||
if WorkspaceMember.objects.filter(
|
||||
workspace__slug=slug, member=request.user, role__lt=15
|
||||
workspace__slug=slug,
|
||||
member=request.user,
|
||||
role__lt=15,
|
||||
is_active=True,
|
||||
).exists():
|
||||
notifications = Notification.objects.none()
|
||||
else:
|
||||
|
||||
@@ -168,7 +168,6 @@ class OauthEndpoint(BaseAPIView):
|
||||
)
|
||||
|
||||
## Login Case
|
||||
|
||||
if not user.is_active:
|
||||
return Response(
|
||||
{
|
||||
|
||||
@@ -110,12 +110,15 @@ class ProjectViewSet(BaseViewSet):
|
||||
member=self.request.user,
|
||||
project_id=OuterRef("pk"),
|
||||
workspace__slug=self.kwargs.get("slug"),
|
||||
is_active=True,
|
||||
)
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
total_members=ProjectMember.objects.filter(
|
||||
project_id=OuterRef("id"), member__is_bot=False
|
||||
project_id=OuterRef("id"),
|
||||
member__is_bot=False,
|
||||
is_active=True,
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
@@ -137,6 +140,7 @@ class ProjectViewSet(BaseViewSet):
|
||||
member_role=ProjectMember.objects.filter(
|
||||
project_id=OuterRef("pk"),
|
||||
member_id=self.request.user.id,
|
||||
is_active=True,
|
||||
).values("role")
|
||||
)
|
||||
.annotate(
|
||||
@@ -157,6 +161,7 @@ class ProjectViewSet(BaseViewSet):
|
||||
member=request.user,
|
||||
project_id=OuterRef("pk"),
|
||||
workspace__slug=self.kwargs.get("slug"),
|
||||
is_active=True,
|
||||
).values("sort_order")
|
||||
projects = (
|
||||
self.get_queryset()
|
||||
@@ -166,6 +171,7 @@ class ProjectViewSet(BaseViewSet):
|
||||
"project_projectmember",
|
||||
queryset=ProjectMember.objects.filter(
|
||||
workspace__slug=slug,
|
||||
is_active=True,
|
||||
).select_related("member"),
|
||||
)
|
||||
)
|
||||
@@ -361,14 +367,15 @@ class InviteProjectEndpoint(BaseAPIView):
|
||||
)
|
||||
|
||||
validate_email(email)
|
||||
# Check if user is already a member of workspace
|
||||
# Check if user is already a member of project
|
||||
if ProjectMember.objects.filter(
|
||||
project_id=project_id,
|
||||
member__email=email,
|
||||
member__is_bot=False,
|
||||
is_active=True,
|
||||
).exists():
|
||||
return Response(
|
||||
{"error": "User is already member of workspace"},
|
||||
{"error": "User is already member of Project"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
@@ -475,6 +482,7 @@ class ProjectMemberViewSet(BaseViewSet):
|
||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.filter(project_id=self.kwargs.get("project_id"))
|
||||
.filter(member__is_bot=False)
|
||||
.filter()
|
||||
.select_related("project")
|
||||
.select_related("member")
|
||||
.select_related("workspace", "workspace__owner")
|
||||
@@ -498,6 +506,7 @@ class ProjectMemberViewSet(BaseViewSet):
|
||||
ProjectMember.objects.filter(
|
||||
workspace__slug=slug,
|
||||
member_id__in=[member.get("member_id") for member in members],
|
||||
is_active=True,
|
||||
)
|
||||
.values("member_id", "sort_order")
|
||||
.order_by("sort_order")
|
||||
@@ -542,13 +551,17 @@ class ProjectMemberViewSet(BaseViewSet):
|
||||
|
||||
def list(self, request, slug, project_id):
|
||||
project_member = ProjectMember.objects.get(
|
||||
member=request.user, workspace__slug=slug, project_id=project_id
|
||||
member=request.user,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
project_members = ProjectMember.objects.filter(
|
||||
project_id=project_id,
|
||||
workspace__slug=slug,
|
||||
member__is_bot=False,
|
||||
is_active=True,
|
||||
).select_related("project", "member", "workspace")
|
||||
|
||||
if project_member.role > 10:
|
||||
@@ -559,7 +572,7 @@ class ProjectMemberViewSet(BaseViewSet):
|
||||
|
||||
def partial_update(self, request, slug, project_id, pk):
|
||||
project_member = ProjectMember.objects.get(
|
||||
pk=pk, workspace__slug=slug, project_id=project_id
|
||||
pk=pk, workspace__slug=slug, project_id=project_id, is_active=True,
|
||||
)
|
||||
if request.user.id == project_member.member_id:
|
||||
return Response(
|
||||
@@ -568,7 +581,7 @@ class ProjectMemberViewSet(BaseViewSet):
|
||||
)
|
||||
# Check while updating user roles
|
||||
requested_project_member = ProjectMember.objects.get(
|
||||
project_id=project_id, workspace__slug=slug, member=request.user
|
||||
project_id=project_id, workspace__slug=slug, member=request.user, is_active=True,
|
||||
)
|
||||
if (
|
||||
"role" in request.data
|
||||
@@ -591,54 +604,66 @@ class ProjectMemberViewSet(BaseViewSet):
|
||||
|
||||
def destroy(self, request, slug, project_id, pk):
|
||||
project_member = ProjectMember.objects.get(
|
||||
workspace__slug=slug, project_id=project_id, pk=pk
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
pk=pk,
|
||||
member__is_bot=False,
|
||||
is_active=True,
|
||||
)
|
||||
# check requesting user role
|
||||
requesting_project_member = ProjectMember.objects.get(
|
||||
workspace__slug=slug, member=request.user, project_id=project_id
|
||||
workspace__slug=slug,
|
||||
member=request.user,
|
||||
project_id=project_id,
|
||||
is_active=True,
|
||||
)
|
||||
# User cannot remove himself
|
||||
if str(project_member.id) == str(requesting_project_member.id):
|
||||
return Response(
|
||||
{
|
||||
"error": "You cannot remove yourself from the workspace. Please use leave workspace"
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
# User cannot deactivate higher role
|
||||
if requesting_project_member.role < project_member.role:
|
||||
return Response(
|
||||
{"error": "You cannot remove a user having role higher than yourself"},
|
||||
{"error": "You cannot remove a user having role higher than you"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Remove all favorites
|
||||
ProjectFavorite.objects.filter(
|
||||
workspace__slug=slug, project_id=project_id, user=project_member.member
|
||||
).delete()
|
||||
CycleFavorite.objects.filter(
|
||||
workspace__slug=slug, project_id=project_id, user=project_member.member
|
||||
).delete()
|
||||
ModuleFavorite.objects.filter(
|
||||
workspace__slug=slug, project_id=project_id, user=project_member.member
|
||||
).delete()
|
||||
PageFavorite.objects.filter(
|
||||
workspace__slug=slug, project_id=project_id, user=project_member.member
|
||||
).delete()
|
||||
IssueViewFavorite.objects.filter(
|
||||
workspace__slug=slug, project_id=project_id, user=project_member.member
|
||||
).delete()
|
||||
# Also remove issue from issue assigned
|
||||
IssueAssignee.objects.filter(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
assignee=project_member.member,
|
||||
).delete()
|
||||
project_member.is_deactivated = True
|
||||
project_member.save()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
# Remove if module member
|
||||
ModuleMember.objects.filter(
|
||||
def leave(self, request, slug, project_id):
|
||||
project_member = ProjectMember.objects.get(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
member=project_member.member,
|
||||
).delete()
|
||||
# Delete owned Pages
|
||||
Page.objects.filter(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
owned_by=project_member.member,
|
||||
).delete()
|
||||
project_member.delete()
|
||||
member=request.user,
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
# Check if the leaving user is the only admin of the project
|
||||
if (
|
||||
project_member.role == 20
|
||||
and not ProjectMember.objects.filter(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
role=20,
|
||||
is_active=True,
|
||||
).count()
|
||||
> 1
|
||||
):
|
||||
return Response(
|
||||
{
|
||||
"error": "You cannot leave the project as your the only admin of the project you will have to either delete the project or create an another admin",
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
# Deactivate the user
|
||||
project_member.is_deactivated = True
|
||||
project_member.save()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
@@ -780,7 +805,9 @@ class ProjectJoinEndpoint(BaseAPIView):
|
||||
|
||||
# Get the workspace user role
|
||||
workspace_member = WorkspaceMember.objects.get(
|
||||
member=request.user, workspace__slug=slug
|
||||
member=request.user,
|
||||
workspace__slug=slug,
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
workspace_role = workspace_member.role
|
||||
@@ -826,7 +853,7 @@ class ProjectUserViewsEndpoint(BaseAPIView):
|
||||
project = Project.objects.get(pk=project_id, workspace__slug=slug)
|
||||
|
||||
project_member = ProjectMember.objects.filter(
|
||||
member=request.user, project=project
|
||||
member=request.user, project=project, is_active=True,
|
||||
).first()
|
||||
|
||||
if project_member is None:
|
||||
@@ -850,7 +877,7 @@ class ProjectUserViewsEndpoint(BaseAPIView):
|
||||
class ProjectMemberUserEndpoint(BaseAPIView):
|
||||
def get(self, request, slug, project_id):
|
||||
project_member = ProjectMember.objects.get(
|
||||
project_id=project_id, workspace__slug=slug, member=request.user
|
||||
project_id=project_id, workspace__slug=slug, member=request.user, is_active=True,
|
||||
)
|
||||
serializer = ProjectMemberSerializer(project_member)
|
||||
|
||||
@@ -983,39 +1010,6 @@ class WorkspaceProjectDeployBoardEndpoint(BaseAPIView):
|
||||
return Response(projects, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class LeaveProjectEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
ProjectLitePermission,
|
||||
]
|
||||
|
||||
def delete(self, request, slug, project_id):
|
||||
project_member = ProjectMember.objects.get(
|
||||
workspace__slug=slug,
|
||||
member=request.user,
|
||||
project_id=project_id,
|
||||
)
|
||||
|
||||
# Only Admin case
|
||||
if (
|
||||
project_member.role == 20
|
||||
and ProjectMember.objects.filter(
|
||||
workspace__slug=slug,
|
||||
role=20,
|
||||
project_id=project_id,
|
||||
).count()
|
||||
== 1
|
||||
):
|
||||
return Response(
|
||||
{
|
||||
"error": "You cannot leave the project since you are the only admin of the project you should delete the project"
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
# Delete the member from workspace
|
||||
project_member.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
class ProjectPublicCoverImagesEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
AllowAny,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -13,13 +13,7 @@ from plane.api.serializers import (
|
||||
)
|
||||
|
||||
from plane.api.views.base import BaseViewSet, BaseAPIView
|
||||
from plane.db.models import (
|
||||
User,
|
||||
Workspace,
|
||||
WorkspaceMemberInvite,
|
||||
Issue,
|
||||
IssueActivity,
|
||||
)
|
||||
from plane.db.models import User, IssueActivity, WorkspaceMember
|
||||
from plane.utils.paginator import BasePaginator
|
||||
|
||||
|
||||
@@ -41,10 +35,28 @@ class UserEndpoint(BaseViewSet):
|
||||
serialized_data = UserMeSettingsSerializer(request.user).data
|
||||
return Response(serialized_data, status=status.HTTP_200_OK)
|
||||
|
||||
def deactivate(self, request):
|
||||
# Check all workspace user is active
|
||||
user = self.get_object()
|
||||
if WorkspaceMember.objects.filter(
|
||||
member=request.user, is_deactivated=False
|
||||
).exists():
|
||||
return Response(
|
||||
{
|
||||
"error": "User cannot deactivate account as user is active in some workspaces"
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Deactivate the user
|
||||
user.is_active = False
|
||||
user.save()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
class UpdateUserOnBoardedEndpoint(BaseAPIView):
|
||||
def patch(self, request):
|
||||
user = User.objects.get(pk=request.user.id)
|
||||
user = User.objects.get(pk=request.user.id, is_active=True)
|
||||
user.is_onboarded = request.data.get("is_onboarded", False)
|
||||
user.save()
|
||||
return Response({"message": "Updated successfully"}, status=status.HTTP_200_OK)
|
||||
@@ -52,7 +64,7 @@ class UpdateUserOnBoardedEndpoint(BaseAPIView):
|
||||
|
||||
class UpdateUserTourCompletedEndpoint(BaseAPIView):
|
||||
def patch(self, request):
|
||||
user = User.objects.get(pk=request.user.id)
|
||||
user = User.objects.get(pk=request.user.id, is_active=True)
|
||||
user.is_tour_completed = request.data.get("is_tour_completed", False)
|
||||
user.save()
|
||||
return Response({"message": "Updated successfully"}, status=status.HTTP_200_OK)
|
||||
|
||||
@@ -59,14 +59,6 @@ from plane.db.models import (
|
||||
IssueActivity,
|
||||
Issue,
|
||||
WorkspaceTheme,
|
||||
IssueAssignee,
|
||||
ProjectFavorite,
|
||||
CycleFavorite,
|
||||
ModuleMember,
|
||||
ModuleFavorite,
|
||||
PageFavorite,
|
||||
Page,
|
||||
IssueViewFavorite,
|
||||
IssueLink,
|
||||
IssueAttachment,
|
||||
IssueSubscriber,
|
||||
@@ -106,7 +98,9 @@ class WorkSpaceViewSet(BaseViewSet):
|
||||
def get_queryset(self):
|
||||
member_count = (
|
||||
WorkspaceMember.objects.filter(
|
||||
workspace=OuterRef("id"), member__is_bot=False
|
||||
workspace=OuterRef("id"),
|
||||
member__is_bot=False,
|
||||
is_active=True,
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
@@ -181,7 +175,9 @@ class UserWorkSpacesEndpoint(BaseAPIView):
|
||||
def get(self, request):
|
||||
member_count = (
|
||||
WorkspaceMember.objects.filter(
|
||||
workspace=OuterRef("id"), member__is_bot=False
|
||||
workspace=OuterRef("id"),
|
||||
member__is_bot=False,
|
||||
is_active=True,
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
@@ -242,7 +238,9 @@ class InviteWorkspaceEndpoint(BaseAPIView):
|
||||
|
||||
# check for role level
|
||||
requesting_user = WorkspaceMember.objects.get(
|
||||
workspace__slug=slug, member=request.user
|
||||
workspace__slug=slug,
|
||||
member=request.user,
|
||||
is_active=True,
|
||||
)
|
||||
if len(
|
||||
[
|
||||
@@ -262,6 +260,7 @@ class InviteWorkspaceEndpoint(BaseAPIView):
|
||||
workspace_members = WorkspaceMember.objects.filter(
|
||||
workspace_id=workspace.id,
|
||||
member__email__in=[email.get("email") for email in emails],
|
||||
is_active=True,
|
||||
).select_related("member", "workspace", "workspace__owner")
|
||||
|
||||
if len(workspace_members):
|
||||
@@ -481,20 +480,24 @@ class WorkSpaceMemberViewSet(BaseViewSet):
|
||||
return self.filter_queryset(
|
||||
super()
|
||||
.get_queryset()
|
||||
.filter(workspace__slug=self.kwargs.get("slug"), member__is_bot=False)
|
||||
.filter(
|
||||
workspace__slug=self.kwargs.get("slug"),
|
||||
member__is_bot=False,
|
||||
is_active=True,
|
||||
)
|
||||
.select_related("workspace", "workspace__owner")
|
||||
.select_related("member")
|
||||
)
|
||||
|
||||
def list(self, request, slug):
|
||||
workspace_member = WorkspaceMember.objects.get(
|
||||
member=request.user, workspace__slug=slug
|
||||
member=request.user,
|
||||
workspace__slug=slug,
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
workspace_members = WorkspaceMember.objects.filter(
|
||||
workspace__slug=slug,
|
||||
member__is_bot=False,
|
||||
).select_related("workspace", "member")
|
||||
# Get all active workspace members
|
||||
workspace_members = self.get_queryset()
|
||||
|
||||
if workspace_member.role > 10:
|
||||
serializer = WorkspaceMemberAdminSerializer(workspace_members, many=True)
|
||||
@@ -506,7 +509,12 @@ class WorkSpaceMemberViewSet(BaseViewSet):
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
def partial_update(self, request, slug, pk):
|
||||
workspace_member = WorkspaceMember.objects.get(pk=pk, workspace__slug=slug)
|
||||
workspace_member = WorkspaceMember.objects.get(
|
||||
pk=pk,
|
||||
workspace__slug=slug,
|
||||
member__is_bot=False,
|
||||
is_active=True,
|
||||
)
|
||||
if request.user.id == workspace_member.member_id:
|
||||
return Response(
|
||||
{"error": "You cannot update your own role"},
|
||||
@@ -515,7 +523,9 @@ class WorkSpaceMemberViewSet(BaseViewSet):
|
||||
|
||||
# Get the requested user role
|
||||
requested_workspace_member = WorkspaceMember.objects.get(
|
||||
workspace__slug=slug, member=request.user
|
||||
workspace__slug=slug,
|
||||
member=request.user,
|
||||
is_active=True,
|
||||
)
|
||||
# Check if role is being updated
|
||||
# One cannot update role higher than his own role
|
||||
@@ -540,68 +550,117 @@ class WorkSpaceMemberViewSet(BaseViewSet):
|
||||
|
||||
def destroy(self, request, slug, pk):
|
||||
# Check the user role who is deleting the user
|
||||
workspace_member = WorkspaceMember.objects.get(workspace__slug=slug, pk=pk)
|
||||
workspace_member = WorkspaceMember.objects.get(
|
||||
workspace__slug=slug,
|
||||
pk=pk,
|
||||
member__is_bot=False,
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
# check requesting user role
|
||||
requesting_workspace_member = WorkspaceMember.objects.get(
|
||||
workspace__slug=slug, member=request.user
|
||||
workspace__slug=slug,
|
||||
member=request.user,
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
if str(workspace_member.id) == str(requesting_workspace_member.id):
|
||||
return Response(
|
||||
{
|
||||
"error": "You cannot remove yourself from the workspace. Please use leave workspace"
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
if requesting_workspace_member.role < workspace_member.role:
|
||||
return Response(
|
||||
{"error": "You cannot remove a user having role higher than you"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Check for the only member in the workspace
|
||||
if (
|
||||
workspace_member.role == 20
|
||||
and WorkspaceMember.objects.filter(
|
||||
workspace__slug=slug,
|
||||
role=20,
|
||||
member__is_bot=False,
|
||||
).count()
|
||||
== 1
|
||||
Project.objects.annotate(
|
||||
total_members=Count("project_projectmember"),
|
||||
member_with_role=Count(
|
||||
"project_projectmember",
|
||||
filter=Q(
|
||||
project_projectmember__member_id=request.user.id,
|
||||
project_projectmember__role=20,
|
||||
),
|
||||
),
|
||||
)
|
||||
.filter(total_members=1, member_with_role=1, workspace__slug=slug)
|
||||
.exists()
|
||||
):
|
||||
return Response(
|
||||
{"error": "Cannot delete the only Admin for the workspace"},
|
||||
{
|
||||
"error": "User is part of some projects where they are the only admin you should leave that project first"
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Delete the user also from all the projects
|
||||
ProjectMember.objects.filter(
|
||||
workspace__slug=slug, member=workspace_member.member
|
||||
).delete()
|
||||
# Remove all favorites
|
||||
ProjectFavorite.objects.filter(
|
||||
workspace__slug=slug, user=workspace_member.member
|
||||
).delete()
|
||||
CycleFavorite.objects.filter(
|
||||
workspace__slug=slug, user=workspace_member.member
|
||||
).delete()
|
||||
ModuleFavorite.objects.filter(
|
||||
workspace__slug=slug, user=workspace_member.member
|
||||
).delete()
|
||||
PageFavorite.objects.filter(
|
||||
workspace__slug=slug, user=workspace_member.member
|
||||
).delete()
|
||||
IssueViewFavorite.objects.filter(
|
||||
workspace__slug=slug, user=workspace_member.member
|
||||
).delete()
|
||||
# Also remove issue from issue assigned
|
||||
IssueAssignee.objects.filter(
|
||||
workspace__slug=slug, assignee=workspace_member.member
|
||||
).delete()
|
||||
# Deactivate the users from the projects where the user is part of
|
||||
_ = ProjectMember.objects.filter(
|
||||
workspace__slug=slug, member_id=workspace_member.member_id, is_active=True,
|
||||
).update(is_deactivated=True)
|
||||
|
||||
# Remove if module member
|
||||
ModuleMember.objects.filter(
|
||||
workspace__slug=slug, member=workspace_member.member
|
||||
).delete()
|
||||
# Delete owned Pages
|
||||
Page.objects.filter(
|
||||
workspace__slug=slug, owned_by=workspace_member.member
|
||||
).delete()
|
||||
workspace_member.is_deactivated = True
|
||||
workspace_member.save()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
workspace_member.delete()
|
||||
def leave(self, request, slug):
|
||||
workspace_member = WorkspaceMember.objects.get(
|
||||
workspace__slug=slug,
|
||||
member=request.user,
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
# Check if the leaving user is the only admin of the workspace
|
||||
if (
|
||||
workspace_member.role == 20
|
||||
and not WorkspaceMember.objects.filter(
|
||||
workspace__slug=slug,
|
||||
role=20,
|
||||
is_active=True,
|
||||
).count()
|
||||
> 1
|
||||
):
|
||||
return Response(
|
||||
{
|
||||
"error": "You cannot leave the workspace as your the only admin of the workspace you will have to either delete the workspace or create an another admin"
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
if (
|
||||
Project.objects.annotate(
|
||||
total_members=Count("project_projectmember"),
|
||||
member_with_role=Count(
|
||||
"project_projectmember",
|
||||
filter=Q(
|
||||
project_projectmember__member_id=request.user.id,
|
||||
project_projectmember__role=20,
|
||||
),
|
||||
),
|
||||
)
|
||||
.filter(total_members=1, member_with_role=1, workspace__slug=slug)
|
||||
.exists()
|
||||
):
|
||||
return Response(
|
||||
{
|
||||
"error": "User is part of some projects where they are the only admin you should leave that project first"
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# # Deactivate the users from the projects where the user is part of
|
||||
_ = ProjectMember.objects.filter(
|
||||
workspace__slug=slug, member_id=workspace_member.member_id, is_active=True,
|
||||
).update(is_deactivated=True)
|
||||
|
||||
# # Deactivate the user
|
||||
workspace_member.is_deactivated = True
|
||||
workspace_member.save()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
@@ -629,7 +688,9 @@ class TeamMemberViewSet(BaseViewSet):
|
||||
def create(self, request, slug):
|
||||
members = list(
|
||||
WorkspaceMember.objects.filter(
|
||||
workspace__slug=slug, member__id__in=request.data.get("members", [])
|
||||
workspace__slug=slug,
|
||||
member__id__in=request.data.get("members", []),
|
||||
is_active=True,
|
||||
)
|
||||
.annotate(member_str_id=Cast("member", output_field=CharField()))
|
||||
.distinct()
|
||||
@@ -711,7 +772,9 @@ class UserLastProjectWithWorkspaceEndpoint(BaseAPIView):
|
||||
class WorkspaceMemberUserEndpoint(BaseAPIView):
|
||||
def get(self, request, slug):
|
||||
workspace_member = WorkspaceMember.objects.get(
|
||||
member=request.user, workspace__slug=slug
|
||||
member=request.user,
|
||||
workspace__slug=slug,
|
||||
is_active=True,
|
||||
)
|
||||
serializer = WorkspaceMemberMeSerializer(workspace_member)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
@@ -720,7 +783,9 @@ class WorkspaceMemberUserEndpoint(BaseAPIView):
|
||||
class WorkspaceMemberUserViewsEndpoint(BaseAPIView):
|
||||
def post(self, request, slug):
|
||||
workspace_member = WorkspaceMember.objects.get(
|
||||
workspace__slug=slug, member=request.user
|
||||
workspace__slug=slug,
|
||||
member=request.user,
|
||||
is_active=True,
|
||||
)
|
||||
workspace_member.view_props = request.data.get("view_props", {})
|
||||
workspace_member.save()
|
||||
@@ -1046,7 +1111,9 @@ class WorkspaceUserProfileEndpoint(BaseAPIView):
|
||||
user_data = User.objects.get(pk=user_id)
|
||||
|
||||
requesting_workspace_member = WorkspaceMember.objects.get(
|
||||
workspace__slug=slug, member=request.user
|
||||
workspace__slug=slug,
|
||||
member=request.user,
|
||||
is_active=True,
|
||||
)
|
||||
projects = []
|
||||
if requesting_workspace_member.role >= 10:
|
||||
@@ -1250,9 +1317,7 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView):
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
return Response(
|
||||
issues, status=status.HTTP_200_OK
|
||||
)
|
||||
return Response(issues, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class WorkspaceLabelsEndpoint(BaseAPIView):
|
||||
@@ -1266,30 +1331,3 @@ class WorkspaceLabelsEndpoint(BaseAPIView):
|
||||
project__project_projectmember__member=request.user,
|
||||
).values("parent", "name", "color", "id", "project_id", "workspace__slug")
|
||||
return Response(labels, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class LeaveWorkspaceEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
WorkspaceEntityPermission,
|
||||
]
|
||||
|
||||
def delete(self, request, slug):
|
||||
workspace_member = WorkspaceMember.objects.get(
|
||||
workspace__slug=slug, member=request.user
|
||||
)
|
||||
|
||||
# Only Admin case
|
||||
if (
|
||||
workspace_member.role == 20
|
||||
and WorkspaceMember.objects.filter(workspace__slug=slug, role=20).count()
|
||||
== 1
|
||||
):
|
||||
return Response(
|
||||
{
|
||||
"error": "You cannot leave the workspace since you are the only admin of the workspace you should delete the workspace"
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
# Delete the member from workspace
|
||||
workspace_member.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@@ -691,6 +691,10 @@ def create_cycle_issue_activity(
|
||||
new_cycle = Cycle.objects.filter(
|
||||
pk=updated_record.get("new_cycle_id", None)
|
||||
).first()
|
||||
issue = Issue.objects.filter(pk=updated_record.get("issue_id")).first()
|
||||
if issue:
|
||||
issue.updated_at = timezone.now()
|
||||
issue.save(update_fields=["updated_at"])
|
||||
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
@@ -713,6 +717,10 @@ def create_cycle_issue_activity(
|
||||
cycle = Cycle.objects.filter(
|
||||
pk=created_record.get("fields").get("cycle")
|
||||
).first()
|
||||
issue = Issue.objects.filter(pk=created_record.get("fields").get("issue")).first()
|
||||
if issue:
|
||||
issue.updated_at = timezone.now()
|
||||
issue.save(update_fields=["updated_at"])
|
||||
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
@@ -747,22 +755,27 @@ def delete_cycle_issue_activity(
|
||||
)
|
||||
|
||||
cycle_id = requested_data.get("cycle_id", "")
|
||||
cycle_name = requested_data.get("cycle_name", "")
|
||||
cycle = Cycle.objects.filter(pk=cycle_id).first()
|
||||
issues = requested_data.get("issues")
|
||||
|
||||
for issue in issues:
|
||||
current_issue = Issue.objects.filter(pk=issue).first()
|
||||
if issue:
|
||||
current_issue.updated_at = timezone.now()
|
||||
current_issue.save(update_fields=["updated_at"])
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
issue_id=issue,
|
||||
actor_id=actor_id,
|
||||
verb="deleted",
|
||||
old_value=cycle.name if cycle is not None else "",
|
||||
old_value=cycle.name if cycle is not None else cycle_name,
|
||||
new_value="",
|
||||
field="cycles",
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
comment=f"removed this issue from {cycle.name if cycle is not None else None}",
|
||||
old_identifier=cycle.id if cycle is not None else None,
|
||||
comment=f"removed this issue from {cycle.name if cycle is not None else cycle_name}",
|
||||
old_identifier=cycle_id if cycle_id is not None else None,
|
||||
epoch=epoch,
|
||||
)
|
||||
)
|
||||
@@ -794,6 +807,10 @@ def create_module_issue_activity(
|
||||
new_module = Module.objects.filter(
|
||||
pk=updated_record.get("new_module_id", None)
|
||||
).first()
|
||||
issue = Issue.objects.filter(pk=updated_record.get("issue_id")).first()
|
||||
if issue:
|
||||
issue.updated_at = timezone.now()
|
||||
issue.save(update_fields=["updated_at"])
|
||||
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
@@ -816,6 +833,10 @@ def create_module_issue_activity(
|
||||
module = Module.objects.filter(
|
||||
pk=created_record.get("fields").get("module")
|
||||
).first()
|
||||
issue = Issue.objects.filter(pk=created_record.get("fields").get("issue")).first()
|
||||
if issue:
|
||||
issue.updated_at = timezone.now()
|
||||
issue.save(update_fields=["updated_at"])
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
issue_id=created_record.get("fields").get("issue"),
|
||||
@@ -849,22 +870,27 @@ def delete_module_issue_activity(
|
||||
)
|
||||
|
||||
module_id = requested_data.get("module_id", "")
|
||||
module_name = requested_data.get("module_name", "")
|
||||
module = Module.objects.filter(pk=module_id).first()
|
||||
issues = requested_data.get("issues")
|
||||
|
||||
for issue in issues:
|
||||
current_issue = Issue.objects.filter(pk=issue).first()
|
||||
if issue:
|
||||
current_issue.updated_at = timezone.now()
|
||||
current_issue.save(update_fields=["updated_at"])
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
issue_id=issue,
|
||||
actor_id=actor_id,
|
||||
verb="deleted",
|
||||
old_value=module.name if module is not None else "",
|
||||
old_value=module.name if module is not None else module_name,
|
||||
new_value="",
|
||||
field="modules",
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
comment=f"removed this issue from ",
|
||||
old_identifier=module.id if module is not None else None,
|
||||
comment=f"removed this issue from {module.name if module is not None else module_name}",
|
||||
old_identifier=module_id if module_id is not None else None,
|
||||
epoch=epoch,
|
||||
)
|
||||
)
|
||||
@@ -1452,15 +1478,16 @@ def issue_activity(
|
||||
issue_activities = []
|
||||
|
||||
project = Project.objects.get(pk=project_id)
|
||||
issue = Issue.objects.filter(pk=issue_id).first()
|
||||
workspace_id = project.workspace_id
|
||||
|
||||
if issue is not None:
|
||||
try:
|
||||
issue.updated_at = timezone.now()
|
||||
issue.save(update_fields=["updated_at"])
|
||||
except Exception as e:
|
||||
pass
|
||||
if issue_id is not None:
|
||||
issue = Issue.objects.filter(pk=issue_id).first()
|
||||
if issue:
|
||||
try:
|
||||
issue.updated_at = timezone.now()
|
||||
issue.save(update_fields=["updated_at"])
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
ACTIVITY_MAPPER = {
|
||||
"issue.activity.created": create_issue_activity,
|
||||
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
# Generated by Django 4.2.5 on 2023-11-09 11:19
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('db', '0046_alter_analyticview_created_by_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='projectmember',
|
||||
name='is_deactivated',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='workspacemember',
|
||||
name='is_deactivated',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
# Generated by Django 4.2.5 on 2023-11-10 09:41
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('db', '0047_issuemention_projectmember_is_deactivated_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='projectmember',
|
||||
name='is_active',
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='workspacemember',
|
||||
name='is_active',
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
]
|
||||
@@ -166,6 +166,7 @@ class ProjectMember(ProjectBaseModel):
|
||||
default_props = models.JSONField(default=get_default_props)
|
||||
preferences = models.JSONField(default=get_default_preferences)
|
||||
sort_order = models.FloatField(default=65535)
|
||||
is_active = models.BooleanField(default=True)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if self._state.adding:
|
||||
|
||||
@@ -99,6 +99,7 @@ class WorkspaceMember(BaseModel):
|
||||
view_props = models.JSONField(default=get_default_props)
|
||||
default_props = models.JSONField(default=get_default_props)
|
||||
issue_props = models.JSONField(default=get_issue_props)
|
||||
is_active = models.BooleanField(default=True)
|
||||
|
||||
class Meta:
|
||||
unique_together = ["workspace", "member"]
|
||||
|
||||
@@ -10,6 +10,8 @@ x-app-env : &app-env
|
||||
- SENTRY_DSN=${SENTRY_DSN:-""}
|
||||
- GITHUB_CLIENT_SECRET=${GITHUB_CLIENT_SECRET:-""}
|
||||
- DOCKERIZED=${DOCKERIZED:-1}
|
||||
# Gunicorn Workers
|
||||
- GUNICORN_WORKERS=${GUNICORN_WORKERS:-2}
|
||||
#DB SETTINGS
|
||||
- PGHOST=${PGHOST:-plane-db}
|
||||
- PGDATABASE=${PGDATABASE:-plane}
|
||||
|
||||
@@ -61,3 +61,5 @@ MINIO_ROOT_PASSWORD="secret-key"
|
||||
BUCKET_NAME=uploads
|
||||
FILE_SIZE_LIMIT=5242880
|
||||
|
||||
# Gunicorn Workers
|
||||
GUNICORN_WORKERS=2
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
|
||||
@@ -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:";
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
];
|
||||
|
||||
@@ -1 +1 @@
|
||||
export { default as default } from "./table-cell"
|
||||
export { default as default } from "./table-cell";
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1 +1 @@
|
||||
export { default as default } from "./table-header"
|
||||
export { default as default } from "./table-header";
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1 +1 @@
|
||||
export { default as default } from "./table-row"
|
||||
export { default as default } from "./table-row";
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
@@ -38,7 +38,7 @@ const icons = {
|
||||
/>
|
||||
</svg>
|
||||
`,
|
||||
insertBottomTableIcon:`<svg
|
||||
insertBottomTableIcon: `<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={24}
|
||||
height={24}
|
||||
|
||||
@@ -1 +1 @@
|
||||
export { default as default } from "./table"
|
||||
export { default as default } from "./table";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
+32
-29
@@ -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;
|
||||
};
|
||||
|
||||
+15
-15
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)];
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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 })
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
];
|
||||
|
||||
@@ -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`,
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
```
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
```
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
];
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -42,6 +42,10 @@ type Props = {
|
||||
* The source of the avatar image
|
||||
*/
|
||||
src?: string;
|
||||
/**
|
||||
* The custom CSS class name to apply to the component
|
||||
*/
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -94,7 +98,7 @@ export const getBorderRadius = (shape: "circle" | "square") => {
|
||||
case "circle":
|
||||
return "rounded-full";
|
||||
case "square":
|
||||
return "rounded-md";
|
||||
return "rounded";
|
||||
default:
|
||||
return "rounded-full";
|
||||
}
|
||||
@@ -119,6 +123,7 @@ export const Avatar: React.FC<Props> = (props) => {
|
||||
size = "md",
|
||||
shape = "circle",
|
||||
src,
|
||||
className = "",
|
||||
} = props;
|
||||
|
||||
// get size details based on the size prop
|
||||
@@ -145,14 +150,16 @@ export const Avatar: React.FC<Props> = (props) => {
|
||||
{src ? (
|
||||
<img
|
||||
src={src}
|
||||
className={`h-full w-full ${getBorderRadius(shape)}`}
|
||||
className={`h-full w-full ${getBorderRadius(shape)} ${className}`}
|
||||
alt={name}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={`${
|
||||
sizeInfo.fontSize
|
||||
} grid place-items-center h-full w-full ${getBorderRadius(shape)}`}
|
||||
} grid place-items-center h-full w-full ${getBorderRadius(
|
||||
shape,
|
||||
)} ${className}`}
|
||||
style={{
|
||||
backgroundColor:
|
||||
fallbackBackgroundColor ?? "rgba(var(--color-primary-500))",
|
||||
|
||||
@@ -58,7 +58,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
Button.displayName = "plane-ui-button";
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
export * from "./input";
|
||||
export * from "./textarea";
|
||||
export * from "./input-color-picker"
|
||||
export * from "./input-color-picker";
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -9,7 +9,7 @@ interface ICircularProgressIndicator {
|
||||
}
|
||||
|
||||
export const CircularProgressIndicator: React.FC<ICircularProgressIndicator> = (
|
||||
props
|
||||
props,
|
||||
) => {
|
||||
const { size = 40, percentage = 25, strokeWidth = 6, children } = props;
|
||||
|
||||
|
||||
@@ -1,10 +1,23 @@
|
||||
import * as React from "react";
|
||||
|
||||
export const Spinner: React.FC = () => (
|
||||
export interface ISpinner extends React.SVGAttributes<SVGElement> {
|
||||
height?: string;
|
||||
width?: string;
|
||||
className?: string | undefined;
|
||||
}
|
||||
|
||||
export const Spinner: React.FC<ISpinner> = ({
|
||||
height = "32px",
|
||||
width = "32px",
|
||||
className = "",
|
||||
...rest
|
||||
}) => (
|
||||
<div role="status">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="mr-2 h-8 w-8 animate-spin fill-blue-600 text-custom-text-200"
|
||||
height={height}
|
||||
width={width}
|
||||
className={`mr-2 animate-spin fill-blue-600 text-custom-text-200 ${className}`}
|
||||
viewBox="0 0 100 101"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
|
||||
@@ -116,7 +116,9 @@ export const SignInView = observer(() => {
|
||||
)}
|
||||
|
||||
<div className="flex flex-col items-center justify-center gap-4 pt-7 sm:w-[360px] mx-auto overflow-hidden">
|
||||
{data?.google && <GoogleLoginButton clientId={data.google} handleSignIn={handleGoogleSignIn} />}
|
||||
{data?.google_client_id && (
|
||||
<GoogleLoginButton clientId={data.google_client_id} handleSignIn={handleGoogleSignIn} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="pt-16 text-custom-text-200 text-sm text-center">
|
||||
|
||||
@@ -29,7 +29,7 @@ export const RenderIssueState = observer(({ state }: { state: IIssueState }) =>
|
||||
return (
|
||||
<div className={`inline-flex items-center gap-x-1 rounded-full px-2 py-0.5 ${stateGroup.className || ``}`}>
|
||||
<div className="flex-shrink-0 w-3 h-3 flex justify-center items-center overflow-hidden rounded-full">
|
||||
<stateGroup.icon />
|
||||
{/* <stateGroup.icon /> */}
|
||||
</div>
|
||||
<div className="text-xs font-medium whitespace-nowrap">{state?.name}</div>
|
||||
<div
|
||||
|
||||
@@ -77,7 +77,7 @@ export const NavbarIssueFilter = observer(() => {
|
||||
return {
|
||||
display: (
|
||||
<span className="capitalize flex items-center gap-x-2">
|
||||
{stateGroup && <stateGroup.icon />}
|
||||
{/* {stateGroup && <stateGroup.icon />} */}
|
||||
{state.name}
|
||||
</span>
|
||||
),
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user