Compare commits

...

68 Commits

Author SHA1 Message Date
Muhamade Sabith .T.U
86604c6f13 style: mobile responsive ui 2023-11-06 20:33:50 +05:30
Manish Gupta
13389d1b2b dev: On Demand Code Build for any branch (#2668)
* wip

* wip

* testing

* wip

* wip

* wip

* wip

* image push fix

* wip

* wip

* dynamic branch name and tag

* workflow_dispatch modified

* job splitting

* file sharing

* wip

* checking

* wip

* wip

* wip

* wip

* build fixes

* code upload download fixes

* image name change

---------

Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
2023-11-06 19:05:20 +05:30
Aaryan Khandelwal
742143766f fix: existing issues modal for cycle and module (#2664)
* fix: existing issues modal for cycle and module

* refactor: existing issues modal code

* fix: build errors
2023-11-06 16:30:09 +05:30
sriram veeraghanta
1ed72c51df fix: package version fixes and mentions build error fixes (#2665) 2023-11-06 16:28:15 +05:30
Aaryan Khandelwal
a03e0c788f fix: notifications option in the sidebar menu not collapsing (#2662) 2023-11-06 14:53:26 +05:30
guru_sainath
0c8a867565 fix: handled drag and drop issue, gantt hover issue for issue peek overview (#2660) 2023-11-06 13:52:33 +05:30
Aaryan Khandelwal
3a07bb6060 refactor: removed unused packages (#2658) 2023-11-06 13:17:02 +05:30
Aaryan Khandelwal
bf48d93a25 fix: product tour modal bugs (#2657)
* fix: product tour

* style: product tour navigation buttons

* refactor: step logic
2023-11-06 13:06:00 +05:30
M. Palanikannan
14ac885e55 [feat]: Extended Tables Support (#2596)
* migrated table to new project structure

* fixed range errors while deleting table nodes with no nodes below and removed console logs

* fixed css for rendering table menu

* removed old table menu

* added support for read only editors as well

* text-black removed

* added design colors

---------

Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
2023-11-05 18:54:00 +05:30
Henit Chobisa
f0335751b3 fix: mentions enter error (#2646)
* fix: fixed readonly lite text editor not rendering highlights

* fix: removed enter extension in lite text editor
2023-11-04 01:57:57 +05:30
Anmol Singh Bhatia
52395d0563 chore : dropdown loading state added and project card avatar fix (#2643)
* chore: project card avatar rendering fix

* chore: state, assignee and label dropdown loading state added
2023-11-04 01:56:23 +05:30
Dakshesh Jain
ad558833af refactor: archive issue (#2628)
* dev: archived issue store

* dev: archived issue layouts and store binding

* dev: archived issue detail store

* dev: is read only

* fix: archived issue delete

* fix: build error
2023-11-03 20:20:05 +05:30
sriram veeraghanta
ff258c60fd fix: open changelog in new tab (#2645) 2023-11-03 19:49:02 +05:30
Bavisetti Narayan
db2a1b8033 fix: custom analytics graph display issue (#2637)
* chore: fixed custom analytics

* chore: typo changes
2023-11-03 19:23:35 +05:30
Dakshesh Jain
91878fb3dd fix: notification read and snooze bugs (#2639)
* fix: marking notification as read doesn't remove it from un-read list

* refactor: arranged imports

* fix: past snooze notifications coming in snooze tab
2023-11-03 19:21:35 +05:30
Lakhan Baheti
c233e6e3b6 style: custom analytics bar graph label overlapping (#2636)
* style: custom analytics bar graph label overlapping

fix: Bar graph avatar image rendering & tooltip

* fix: import
2023-11-03 19:18:24 +05:30
Lakhan Baheti
0d2c399555 style: quick add issue UI improvements in all the layouts (#2615)
* style: quick add issue UI improvements in all the layouts

* style: ui improvements

* style: quick add icon size

* chore: static sizes to tailwind classes

---------

Co-authored-by: Aaryan Khandelwal <aaryankhandu123@gmail.com>
2023-11-03 19:17:50 +05:30
Aaryan Khandelwal
79cad16aba chore: update all layout selections (#2641) 2023-11-03 19:17:13 +05:30
Aaryan Khandelwal
41e9d5d7e3 chore: added missing columns to the spreadsheet layout (#2640) 2023-11-03 19:15:09 +05:30
Aaryan Khandelwal
992cf79031 chore: peek overview authorization (#2632)
* chore: peek overview authorization

* chore: comment access specifier validation
2023-11-03 19:13:10 +05:30
Aaryan Khandelwal
d48f13416f Update readme content (#2635) 2023-11-03 19:12:46 +05:30
Aaryan Khandelwal
cf19afa707 fix: string helper function (#2633) 2023-11-03 19:11:28 +05:30
sriram veeraghanta
cc26f604aa fix: next image fixes for selfhosted instances (#2642) 2023-11-03 19:09:40 +05:30
Anmol Singh Bhatia
8919b724c5 chore: breadcrumbs ui revamp and refactor (#2634) 2023-11-03 18:01:49 +05:30
Anmol Singh Bhatia
7eeac188d7 chore: peek overview improvement and bug fixes (#2627)
* chore: peekoverview issue properties text size fix

* chore: peekoverview icon updated and active view indicator added

* chore: peekoverview and issue sidebar improvement
2023-11-03 18:01:34 +05:30
Anmol Singh Bhatia
f639e467f8 refactor: replace ui components with plane ui components (#2626)
* refactor: replace button component with plane ui component and remove old button component

* refactor: replace dropdown component with plane ui component

* refactor: replace tooltip, input, textarea, spinner and loader component with plane ui component

* refactor: plane ui code refactor
2023-11-03 17:21:38 +05:30
Anmol Singh Bhatia
737fea28c6 chore: refactor and improve project member settings (#2625)
* fix: project member setting improvement and refactor

* fix: typo fix in automations setting
2023-11-03 17:20:49 +05:30
Anmol Singh Bhatia
4c1aee0cfc fix: resolve z-index and peek overview component bug (#2624)
* fix: resolved z-index issue on peek overview component

* fix: fix issue with peekover view in spreadsheet view

---------

Co-authored-by: gurusainath <gurusainath007@gmail.com>
2023-11-03 17:20:14 +05:30
guru_sainath
1352c200dd fix: Project Rendering Error in Kanban Layout and Layout Rendering Fixes in Subscribed Profile Issues (#2629)
* fix: rendering projects error in kanabn layout in profile issues and resolved multiplr layout rendering in subscribed profile issues

* fix: implemented spinner loader in profile issues and remove logs in kanban layout
2023-11-03 13:17:52 +05:30
Aaryan Khandelwal
dd2ba2ec6f fix: slug field not working (#2622) 2023-11-03 13:17:01 +05:30
Aaryan Khandelwal
c66d76df26 chore: set sub group by to null if group by and sub group by are same (#2621) 2023-11-03 13:15:50 +05:30
Bavisetti Narayan
7a11161cd0 dev: migrations for 0.14 (#2630)
* chore: migration files

* dev: deleted the old migration
2023-11-03 13:00:37 +05:30
Aaryan Khandelwal
260974b0de fix: user authentication on the index page (#2619)
* fix: user authentication on the index page

* fix: login redirection cleanup

---------

Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
2023-11-03 12:42:43 +05:30
sriram veeraghanta
5efc6993cd fix: Update analytics page layout fixes (#2623) 2023-11-03 00:09:13 +05:30
sriram veeraghanta
3c884fd46e fix: implementing layouts using _app.tsx get layout method. (#2620)
* fix: implementing layouts in all pages

* fix: layout fixes, implemting using standard nextjs parctice
2023-11-02 23:57:44 +05:30
Anmol Singh Bhatia
a582021f2c fix: active cycle fix (#2618) 2023-11-02 22:17:10 +05:30
Bavisetti Narayan
caca2bb548 chore: bug fixes (#2609)
* chore: sub issue activity task

* fix: mentions and issue comment

* chore: added string for issue

* chore: changed sub issue id
2023-11-02 19:32:44 +05:30
Henit Chobisa
da391064aa [FIX] Minor bug fixes in MentionList and MentionNode UI (#2600)
* fix: removed text color in peek view

* fix: fixed list view UI bugs and node view colors

* feat: update imports in suggestions for mentionSuggestion type

* fix: updated mention list css

* fix: updated mention node UI according to the design provided

* style: update the mentions dropdown UI

* style: mentioned users UI in the editor

---------

Co-authored-by: Aaryan Khandelwal <aaryankhandu123@gmail.com>
2023-11-02 19:31:25 +05:30
Aaryan Khandelwal
a9b72fa1d2 chore: implemented MobX in the onboarding screens (#2617)
* fix: wrap the onboarding route with the UserWrapper

* chore: implement mobx in the onboarding screens
2023-11-02 19:27:25 +05:30
Anmol Singh Bhatia
b0397dfd74 fix: module sidebar status select (#2614) 2023-11-02 18:48:57 +05:30
guru_sainath
02d4d32f7a chore: Improved Handling of Empty Properties for Labels and Assignees (#2616)
* fix: show empty group

* chore: handled None values for lables and assignees in list and kanban layouts

---------

Co-authored-by: dakshesh14 <dakshesh.jain14@gmail.com>
2023-11-02 18:44:02 +05:30
Anmol Singh Bhatia
43e42f1896 fix: project view item edit action fix (#2612) 2023-11-02 17:12:04 +05:30
Aaryan Khandelwal
d5fd69354e chore: removed unused hooks and components (#2611)
* chore: remove unused hooks

* chore: removed useProjectMembers hook

* chore: removed issue hooks

* fix: build errors
2023-11-02 17:11:33 +05:30
Aaryan Khandelwal
c987c6f308 fix: calendar layout not being rendered (#2610) 2023-11-02 17:08:48 +05:30
Anmol Singh Bhatia
56e4152756 fix: select label dropdown fix for list view (#2608) 2023-11-02 16:28:07 +05:30
Aaryan Khandelwal
7b5ed252ef chore: updated the contact email (#2605)
Updated the contact email to squawk@plane.so
2023-11-02 16:27:23 +05:30
Aaryan Khandelwal
c394a4f64e style: lite text editor editor toolbar (#2601)
* style: comment editor toolbar

* style: updated icon styling
2023-11-02 16:26:57 +05:30
Dakshesh Jain
5b808571e5 fix: exception error (#2606)
* fix: exception error

* fix: invitation type
2023-11-02 16:26:16 +05:30
Dakshesh Jain
2cda47dc8a refactor: user profile store setup and bug fixes (#2586)
* fix: autorun not working when filters are changed

* fix: filter/display on overview page

* refactor: store implementation & loader in 'created' & 'subscribed' page
2023-11-02 16:25:44 +05:30
Anmol Singh Bhatia
7f3dbe298c fix: bug fixes (#2607)
* fix: module card issue count fix

* fix: project kanban view add issue bug fix

* fix: draft issue modal button alignment fix
2023-11-02 16:03:03 +05:30
Anmol Singh Bhatia
0072160891 fix: peekoverview (#2603)
* fix: peekoverview mutation fix

* fix: peekoverview mutation fix

* fix: sub-issue peekoverview
2023-11-02 16:02:34 +05:30
Anmol Singh Bhatia
4512651f8b fix: spreadsheet view properties fix (#2599) 2023-11-02 16:01:49 +05:30
guru_sainath
f6b95b8d31 fix: Issue properties dropdown overflow issue for date and labels (#2604) 2023-11-02 15:59:43 +05:30
Anmol Singh Bhatia
325fb4a377 chore: fixes and improvement (#2595)
* fix: project card fix

* chore: bug fixes and ui improvement
2023-11-02 14:01:56 +05:30
guru_sainath
ba7b7d6f8b chore: implemented module and cycle select dropdown in issue create modal (#2602) 2023-11-02 13:55:45 +05:30
Nikhil
7249f84e18 dev: code improvements and minor performance upgrades (#2201)
* dev: remove len for empty comparison

* dev: using in instead of multiple ors

* dev: assign expression to empty variables

* dev: use f-string

* dev: remove list comprehension and use generators

* dev: remove assert from paginator

* dev: use is for identity comparison with singleton

* dev: remove unnecessary else statements

* dev: fix does not exists error for both project and workspace

* dev: remove reimports

* dev: iterate a dictionary

* dev: remove unused commented code

* dev: remove redefinition

* dev: remove unused imports

* dev: remove unused imports

* dev: remove unnecessary f strings

* dev: remove unused variables

* dev: use literal structure to create the data structure

* dev: add empty lines at the end of the file

* dev: remove user middleware

* dev: remove unnecessary default None
2023-11-01 20:35:06 +05:30
Aaryan Khandelwal
d63e7cf254 chore: filters view more and less buttons (#2583)
Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
2023-11-01 20:34:02 +05:30
Aaryan Khandelwal
36152ea2fa chore: loading state for all layouts (#2588)
* chore: add loading states to layouts

* chore: don't show count for 0 inbox issues
2023-11-01 20:24:57 +05:30
Lakhan Baheti
1a46c6c399 feat: search project & workspace members (#2590)
* feat: search project & workspace members

* chore: formatting
2023-11-01 20:23:21 +05:30
Bavisetti Narayan
4f09a89f5e chore: unarchived issue and date format changes (#2598)
* chore: unarchived issue message corrected

* chore: passing the date in archived at
2023-11-01 20:11:40 +05:30
sriram veeraghanta
8c620c4f96 fix: store level fixes (#2597) 2023-11-01 19:22:10 +05:30
Anmol Singh Bhatia
d46eb9c59a chore: add issue option added in header group (#2592)
* chore: add issue option added in list view group header

* chore: add issue option added in kanban view group header
2023-11-01 19:10:29 +05:30
Bavisetti Narayan
e9321a66e7 chore: added validation for archived issue (#2593)
* chore: added validation for archived issue

* fix: optimised code
2023-11-01 17:20:55 +05:30
sriram veeraghanta
0121a4ab51 [FED-594] fix: user change theme interface bug fixes (#2587)
* fix: user change theme interface bugfixes

* fix: handling error case
2023-11-01 17:11:29 +05:30
Anmol Singh Bhatia
548e95c7e0 fix: bug fixes (#2581)
* fix: module sidebar fix for kanban layout

* chore: cycle & module sidebar improvement

* chore: join project content updated

* chore: project empty state header fix

* chore: create project modal dropdown consistency

* chore: list view group header overlapping issue fix

* chore: popover code refactor

* chore: module sidebar fix for cycle kanban view

* chore: add existing issue option added in module empty state

* chore: add existing issue option added in cycle empty state
2023-11-01 17:11:07 +05:30
Aaryan Khandelwal
13ead7c314 fix: project wrapper (#2589)
* fix: project wrapper

* fix: project wrapper for unjoined project

* chore: update store structure
2023-11-01 17:10:10 +05:30
sriram veeraghanta
4fcc4b4a01 fix: build fixes (#2591) 2023-11-01 16:56:44 +05:30
Henit Chobisa
d511799f31 [FEATURE] Enabled User @mentions and @mention-filters in core editor package (#2544)
* feat: created custom mention component

* feat: added mention suggestions and suggestion highlights

* feat: created mention suggestion list for displaying mention suggestions

* feat: created custom mention text component, for handling click event

* feat: exposed mention component

* feat: integrated and exposed `mentions` componenet with `editor-core`

* feat: integrated mentions extension with the core editor package

* feat: exposed suggestion types from mentions

* feat: added `mention-suggestion` parameters in `r-t-e` and `l-t-e`

* feat: added `IssueMention` model in apiserver models

* chore: updated activities background job and added bs4 in requirements

* feat: added mention removal logic in issue_activity

* chore: exposed mention types from `r-t-e` and `l-t-e`

* feat: integrated mentions in side peek view description form

* feat: added mentions in issue modal form

* feat: created custom react-hook for editor suggestions

* feat: integrated mention suggestions block in RichTextEditor

* feat: added `mentions` integration in `lite-text-editor` instances

* fix: tailwind loading nodemodules from packages

* feat: added styles for the mention suggestion list

* fix: update module import to resolve build failure

* feat: added mentions as an issue filter

* feat: added UI Changes to Implement `mention` filters

* feat: added `mentions` as a filter option in the header

* feat: added mentions in the filter list options

* feat: added mentions in default display filter options

* feat: added filters in applied and issue params in store

* feat: modified types for adding mentions as a filter option

* feat: modified `notification-card` to display message when it exists in object

* feat: rewrote user mention management upon the changes made in develop

* chore: merged debounce PR with the current PR for tracing changes

* fix: mentions_filters updated with the new setup

* feat: updated requirements for bs4

* feat: modified `mentions-filter` to remove many to many dependency

* feat: implemented list manipulation instead of for loop

* feat: added readonly functionality in `read-only` editor core

* feat: added UI Changes for read-only mode

* feat: added mentions store in web Root Store

* chore: renamed `use-editor-suggestions` hook

* feat: UI Improvements for conditional highlights w.r.t readonly in mentionNode

* fix: removed mentions from `filter_set` parameters

* fix: minor merge fixes

* fix: package lock updates

---------

Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
2023-11-01 16:36:37 +05:30
508 changed files with 11532 additions and 11254 deletions

205
.github/workflows/build-branch.yml vendored Normal file
View File

@@ -0,0 +1,205 @@
name: Docker Branch Build
on:
workflow_dispatch:
inputs:
logLevel:
description: 'Log level'
required: true
default: 'warning'
tags:
description: 'Dev/QA Builds'
env:
gh_branch: ${{ github.ref_name }}
img_tag: latest
jobs:
branch_build_and_push:
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
- uses: ASzc/change-string-case-action@v2
id: gh_branch_upper_lower
with:
string: ${{ env.gh_branch }}
- uses: mad9000/actions-find-and-replace-string@2
id: gh_branch_replace_slash
with:
source: ${{ steps.gh_branch_upper_lower.outputs.lowercase }}
find: '/'
replace: '-'
- uses: mad9000/actions-find-and-replace-string@2
id: gh_branch_replace_dot
with:
source: ${{ steps.gh_branch_replace_slash.outputs.value }}
find: '.'
replace: ''
- uses: mad9000/actions-find-and-replace-string@2
id: gh_branch_clean
with:
source: ${{ steps.gh_branch_replace_dot.outputs.value }}
find: '_'
replace: ''
- name: Uploading Proxy Source
uses: actions/upload-artifact@v3
with:
name: proxy-src-code
path: ./nginx
- name: Uploading Backend Source
uses: actions/upload-artifact@v3
with:
name: backend-src-code
path: ./apiserver
- name: Uploading Web Source
uses: actions/upload-artifact@v3
with:
name: web-src-code
path: |
./
!./apiserver
!./nginx
!./deploy
!./space
- name: Uploading Space Source
uses: actions/upload-artifact@v3
with:
name: space-src-code
path: |
./
!./apiserver
!./nginx
!./deploy
!./web
outputs:
gh_branch_name: ${{ steps.gh_branch_clean.outputs.value }}
branch_build_push_frontend:
runs-on: ubuntu-20.04
needs: [ branch_build_and_push ]
steps:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2.5.0
- name: Login to Docker Hub
uses: docker/login-action@v2.1.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Downloading Web Source Code
uses: actions/download-artifact@v3
with:
name: web-src-code
- name: Build and Push Frontend to Docker Container Registry
uses: docker/build-push-action@v4.0.0
with:
context: .
file: ./web/Dockerfile.web
platforms: linux/amd64
tags: ${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend-private:${{ needs.branch_build_and_push.outputs.gh_branch_name }}
push: true
env:
DOCKER_BUILDKIT: 1
DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
DOCKET_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
branch_build_push_space:
runs-on: ubuntu-20.04
needs: [ branch_build_and_push ]
steps:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2.5.0
- name: Login to Docker Hub
uses: docker/login-action@v2.1.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Downloading Space Source Code
uses: actions/download-artifact@v3
with:
name: space-src-code
- name: Build and Push Space to Docker Hub
uses: docker/build-push-action@v4.0.0
with:
context: .
file: ./space/Dockerfile.space
platforms: linux/amd64
tags: ${{ secrets.DOCKERHUB_USERNAME }}/plane-space-private:${{ needs.branch_build_and_push.outputs.gh_branch_name }}
push: true
env:
DOCKER_BUILDKIT: 1
DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
DOCKET_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
branch_build_push_backend:
runs-on: ubuntu-20.04
needs: [ branch_build_and_push ]
steps:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2.5.0
- name: Login to Docker Hub
uses: docker/login-action@v2.1.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Downloading Backend Source Code
uses: actions/download-artifact@v3
with:
name: backend-src-code
- name: Build and Push Backend to Docker Hub
uses: docker/build-push-action@v4.0.0
with:
context: .
file: ./Dockerfile.api
platforms: linux/amd64
push: true
tags: ${{ secrets.DOCKERHUB_USERNAME }}/plane-backend-private:${{ needs.branch_build_and_push.outputs.gh_branch_name }}
env:
DOCKER_BUILDKIT: 1
DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
DOCKET_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
branch_build_push_proxy:
runs-on: ubuntu-20.04
needs: [ branch_build_and_push ]
steps:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2.5.0
- name: Login to Docker Hub
uses: docker/login-action@v2.1.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Downloading Proxy Source Code
uses: actions/download-artifact@v3
with:
name: proxy-src-code
- name: Build and Push Plane-Proxy to Docker Hub
uses: docker/build-push-action@v4.0.0
with:
context: .
file: ./Dockerfile
platforms: linux/amd64
tags: ${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy-private:${{ needs.branch_build_and_push.outputs.gh_branch_name }}
push: true
env:
DOCKER_BUILDKIT: 1
DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
DOCKET_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}

2
.gitignore vendored
View File

@@ -75,7 +75,7 @@ pnpm-lock.yaml
pnpm-workspace.yaml
.npmrc
.secrets
tmp/
## packages
dist

View File

@@ -60,7 +60,7 @@ representative at an online or offline event.
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
hello@plane.so.
squawk@plane.so.
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the

View File

@@ -7,7 +7,7 @@
</p>
<h3 align="center"><b>Plane</b></h3>
<p align="center"><b>Open-source, self-hosted project planning tool</b></p>
<p align="center"><b>Flexible, extensible open-source project management</b></p>
<p align="center">
<a href="https://discord.com/invite/A92xrEGCge">

View File

@@ -1,4 +1,4 @@
import os, sys, random, string
import os, sys
import uuid
sys.path.append("/code")

View File

@@ -3,4 +3,4 @@ from psycogreen.gevent import patch_psycopg
def post_fork(server, worker):
patch_psycopg()
worker.log.info("Made Psycopg2 Green")
worker.log.info("Made Psycopg2 Green")

View File

@@ -101,4 +101,4 @@ class ProjectLitePermission(BasePermission):
workspace__slug=view.workspace_slug,
member=request.user,
project_id=view.project_id,
).exists()
).exists()

View File

@@ -17,7 +17,7 @@ class AnalyticViewSerializer(BaseSerializer):
if bool(query_params):
validated_data["query"] = issue_filters(query_params, "POST")
else:
validated_data["query"] = dict()
validated_data["query"] = {}
return AnalyticView.objects.create(**validated_data)
def update(self, instance, validated_data):
@@ -25,6 +25,6 @@ class AnalyticViewSerializer(BaseSerializer):
if bool(query_params):
validated_data["query"] = issue_filters(query_params, "POST")
else:
validated_data["query"] = dict()
validated_data["query"] = {}
validated_data["query"] = issue_filters(query_params, "PATCH")
return super().update(instance, validated_data)

View File

@@ -1,6 +1,3 @@
# Django imports
from django.db.models.functions import TruncDate
# Third party imports
from rest_framework import serializers

View File

@@ -6,7 +6,6 @@ from .base import BaseSerializer
from .issue import IssueFlatSerializer, LabelLiteSerializer
from .project import ProjectLiteSerializer
from .state import StateLiteSerializer
from .project import ProjectLiteSerializer
from .user import UserLiteSerializer
from plane.db.models import Inbox, InboxIssue, Issue

View File

@@ -5,4 +5,4 @@ from .github import (
GithubIssueSyncSerializer,
GithubCommentSyncSerializer,
)
from .slack import SlackProjectSyncSerializer
from .slack import SlackProjectSyncSerializer

View File

@@ -8,8 +8,7 @@ from rest_framework import serializers
from .base import BaseSerializer
from .user import UserLiteSerializer
from .state import StateSerializer, StateLiteSerializer
from .user import UserLiteSerializer
from .project import ProjectSerializer, ProjectLiteSerializer
from .project import ProjectLiteSerializer
from .workspace import WorkspaceLiteSerializer
from plane.db.models import (
User,
@@ -232,25 +231,6 @@ class IssueActivitySerializer(BaseSerializer):
fields = "__all__"
class IssueCommentSerializer(BaseSerializer):
actor_detail = UserLiteSerializer(read_only=True, source="actor")
issue_detail = IssueFlatSerializer(read_only=True, source="issue")
project_detail = ProjectLiteSerializer(read_only=True, source="project")
workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace")
class Meta:
model = IssueComment
fields = "__all__"
read_only_fields = [
"workspace",
"project",
"issue",
"created_by",
"updated_by",
"created_at",
"updated_at",
]
class IssuePropertySerializer(BaseSerializer):
class Meta:
@@ -287,7 +267,6 @@ class LabelLiteSerializer(BaseSerializer):
class IssueLabelSerializer(BaseSerializer):
# label_details = LabelSerializer(read_only=True, source="label")
class Meta:
model = IssueLabel

View File

@@ -4,9 +4,8 @@ from rest_framework import serializers
# Module imports
from .base import BaseSerializer
from .user import UserLiteSerializer
from .project import ProjectSerializer, ProjectLiteSerializer
from .project import ProjectLiteSerializer
from .workspace import WorkspaceLiteSerializer
from .issue import IssueStateSerializer
from plane.db.models import (
User,

View File

@@ -57,7 +57,7 @@ class IssueViewSerializer(BaseSerializer):
if bool(query_params):
validated_data["query"] = issue_filters(query_params, "POST")
else:
validated_data["query"] = dict()
validated_data["query"] = {}
return IssueView.objects.create(**validated_data)
def update(self, instance, validated_data):
@@ -65,7 +65,7 @@ class IssueViewSerializer(BaseSerializer):
if bool(query_params):
validated_data["query"] = issue_filters(query_params, "POST")
else:
validated_data["query"] = dict()
validated_data["query"] = {}
validated_data["query"] = issue_filters(query_params, "PATCH")
return super().update(instance, validated_data)

View File

@@ -110,9 +110,8 @@ class TeamSerializer(BaseSerializer):
]
TeamMember.objects.bulk_create(team_members, batch_size=10)
return team
else:
team = Team.objects.create(**validated_data)
return team
team = Team.objects.create(**validated_data)
return team
def update(self, instance, validated_data):
if "members" in validated_data:
@@ -124,8 +123,7 @@ class TeamSerializer(BaseSerializer):
]
TeamMember.objects.bulk_create(team_members, batch_size=10)
return super().update(instance, validated_data)
else:
return super().update(instance, validated_data)
return super().update(instance, validated_data)
class WorkspaceThemeSerializer(BaseSerializer):

View File

@@ -28,7 +28,6 @@ from plane.api.views import (
## End User
# Workspaces
WorkSpaceViewSet,
UserWorkspaceInvitationsEndpoint,
UserWorkSpacesEndpoint,
InviteWorkspaceEndpoint,
JoinWorkspaceEndpoint,

View File

@@ -166,4 +166,4 @@ from .notification import NotificationViewSet, UnreadNotificationEndpoint, MarkA
from .exporter import ExportIssuesEndpoint
from .config import ConfigurationEndpoint
from .config import ConfigurationEndpoint

View File

@@ -55,11 +55,11 @@ class VerifyEmailEndpoint(BaseAPIView):
return Response(
{"email": "Successfully activated"}, status=status.HTTP_200_OK
)
except jwt.ExpiredSignatureError as indentifier:
except jwt.ExpiredSignatureError as _indentifier:
return Response(
{"email": "Activation expired"}, status=status.HTTP_400_BAD_REQUEST
)
except jwt.exceptions.DecodeError as indentifier:
except jwt.exceptions.DecodeError as _indentifier:
return Response(
{"email": "Invalid token"}, status=status.HTTP_400_BAD_REQUEST
)

View File

@@ -3,7 +3,6 @@ import json
# Django imports
from django.db.models import (
OuterRef,
Func,
F,
Q,

View File

@@ -360,8 +360,7 @@ class InboxIssuePublicViewSet(BaseViewSet):
)
.select_related("issue", "workspace", "project")
)
else:
return InboxIssue.objects.none()
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)

View File

@@ -39,7 +39,6 @@ from plane.api.serializers import (
IssueActivitySerializer,
IssueCommentSerializer,
IssuePropertySerializer,
LabelSerializer,
IssueSerializer,
LabelSerializer,
IssueFlatSerializer,
@@ -235,10 +234,7 @@ class IssueViewSet(BaseViewSet):
status=status.HTTP_200_OK,
)
return Response(
issues, status=status.HTTP_200_OK
)
return Response(issues, status=status.HTTP_200_OK)
def create(self, request, slug, project_id):
project = Project.objects.get(pk=project_id)
@@ -443,9 +439,7 @@ class UserWorkSpaceIssues(BaseAPIView):
status=status.HTTP_200_OK,
)
return Response(
issues, status=status.HTTP_200_OK
)
return Response(issues, status=status.HTTP_200_OK)
class WorkSpaceIssuesEndpoint(BaseAPIView):
@@ -623,13 +617,12 @@ class IssueUserDisplayPropertyEndpoint(BaseAPIView):
serializer = IssuePropertySerializer(issue_property)
return Response(serializer.data, status=status.HTTP_201_CREATED)
def get(self, request, slug, project_id):
issue_property, _ = IssueProperty.objects.get_or_create(
user=request.user, project_id=project_id
)
serializer = IssuePropertySerializer(issue_property)
return Response(serializer.data, status=status.HTTP_200_OK)
issue_property, _ = IssueProperty.objects.get_or_create(
user=request.user, project_id=project_id
)
serializer = IssuePropertySerializer(issue_property)
return Response(serializer.data, status=status.HTTP_200_OK)
class LabelViewSet(BaseViewSet):
@@ -780,6 +773,20 @@ class SubIssuesEndpoint(BaseAPIView):
updated_sub_issues = Issue.issue_objects.filter(id__in=sub_issue_ids)
# Track the issue
_ = [
issue_activity.delay(
type="issue.activity.updated",
requested_data=json.dumps({"parent": str(issue_id)}),
actor_id=str(request.user.id),
issue_id=str(sub_issue_id),
project_id=str(project_id),
current_instance=json.dumps({"parent": str(sub_issue_id)}),
epoch=int(timezone.now().timestamp()),
)
for sub_issue_id in sub_issue_ids
]
return Response(
IssueFlatSerializer(updated_sub_issues, many=True).data,
status=status.HTTP_200_OK,
@@ -1092,17 +1099,19 @@ class IssueArchiveViewSet(BaseViewSet):
archived_at__isnull=False,
pk=pk,
)
issue.archived_at = None
issue.save()
issue_activity.delay(
type="issue.activity.updated",
requested_data=json.dumps({"archived_at": None}),
actor_id=str(request.user.id),
issue_id=str(issue.id),
project_id=str(project_id),
current_instance=None,
current_instance=json.dumps(
IssueSerializer(issue).data, cls=DjangoJSONEncoder
),
epoch=int(timezone.now().timestamp()),
)
issue.archived_at = None
issue.save()
return Response(IssueSerializer(issue).data, status=status.HTTP_200_OK)
@@ -1396,8 +1405,7 @@ class IssueCommentPublicViewSet(BaseViewSet):
)
.distinct()
).order_by("created_at")
else:
return IssueComment.objects.none()
return IssueComment.objects.none()
except ProjectDeployBoard.DoesNotExist:
return IssueComment.objects.none()
@@ -1522,8 +1530,7 @@ class IssueReactionPublicViewSet(BaseViewSet):
.order_by("-created_at")
.distinct()
)
else:
return IssueReaction.objects.none()
return IssueReaction.objects.none()
except ProjectDeployBoard.DoesNotExist:
return IssueReaction.objects.none()
@@ -1618,8 +1625,7 @@ class CommentReactionPublicViewSet(BaseViewSet):
.order_by("-created_at")
.distinct()
)
else:
return CommentReaction.objects.none()
return CommentReaction.objects.none()
except ProjectDeployBoard.DoesNotExist:
return CommentReaction.objects.none()
@@ -1713,8 +1719,7 @@ class IssueVotePublicViewSet(BaseViewSet):
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id"))
)
else:
return IssueVote.objects.none()
return IssueVote.objects.none()
except ProjectDeployBoard.DoesNotExist:
return IssueVote.objects.none()
@@ -2160,9 +2165,7 @@ class IssueDraftViewSet(BaseViewSet):
status=status.HTTP_200_OK,
)
return Response(
issues, status=status.HTTP_200_OK
)
return Response(issues, status=status.HTTP_200_OK)
def create(self, request, slug, project_id):
project = Project.objects.get(pk=project_id)

View File

@@ -11,7 +11,6 @@ from django.conf import settings
from rest_framework.response import Response
from rest_framework import exceptions
from rest_framework.permissions import AllowAny
from rest_framework.views import APIView
from rest_framework_simplejwt.tokens import RefreshToken
from rest_framework import status
from sentry_sdk import capture_exception
@@ -113,7 +112,7 @@ def get_user_data(access_token: str) -> dict:
url="https://api.github.com/user/emails", headers=headers
).json()
[
_ = [
user_data.update({"email": item.get("email")})
for item in response
if item.get("primary") is True
@@ -147,7 +146,7 @@ class OauthEndpoint(BaseAPIView):
data = get_user_data(access_token)
email = data.get("email", None)
if email == None:
if email is None:
return Response(
{
"error": "Something went wrong. Please try again later or contact the support team."
@@ -158,7 +157,6 @@ class OauthEndpoint(BaseAPIView):
if "@" in email:
user = User.objects.get(email=email)
email = data["email"]
channel = "email"
mobile_number = uuid.uuid4().hex
email_verified = True
else:
@@ -182,7 +180,7 @@ class OauthEndpoint(BaseAPIView):
user.last_active = timezone.now()
user.last_login_time = timezone.now()
user.last_login_ip = request.META.get("REMOTE_ADDR")
user.last_login_medium = f"oauth"
user.last_login_medium = "oauth"
user.last_login_uagent = request.META.get("HTTP_USER_AGENT")
user.is_email_verified = email_verified
user.save()
@@ -233,7 +231,6 @@ class OauthEndpoint(BaseAPIView):
if "@" in email:
email = data["email"]
mobile_number = uuid.uuid4().hex
channel = "email"
email_verified = True
else:
return Response(

View File

@@ -1,5 +1,5 @@
# Python imports
from datetime import timedelta, datetime, date
from datetime import timedelta, date
# Django imports
from django.db.models import Exists, OuterRef, Q, Prefetch

View File

@@ -11,7 +11,6 @@ from django.db.models import (
Q,
Exists,
OuterRef,
Func,
F,
Func,
Subquery,
@@ -35,7 +34,6 @@ from plane.api.serializers import (
ProjectDetailSerializer,
ProjectMemberInviteSerializer,
ProjectFavoriteSerializer,
IssueLiteSerializer,
ProjectDeployBoardSerializer,
ProjectMemberAdminSerializer,
)
@@ -84,7 +82,7 @@ class ProjectViewSet(BaseViewSet):
]
def get_serializer_class(self, *args, **kwargs):
if self.action == "update" or self.action == "partial_update":
if self.action in ["update", "partial_update"]:
return ProjectSerializer
return ProjectDetailSerializer
@@ -336,7 +334,7 @@ class ProjectViewSet(BaseViewSet):
{"name": "The project name is already taken"},
status=status.HTTP_410_GONE,
)
except Project.DoesNotExist or Workspace.DoesNotExist as e:
except (Project.DoesNotExist, Workspace.DoesNotExist):
return Response(
{"error": "Project does not exist"}, status=status.HTTP_404_NOT_FOUND
)

View File

@@ -19,7 +19,6 @@ from plane.db.models import (
WorkspaceMemberInvite,
Issue,
IssueActivity,
WorkspaceMember,
)
from plane.utils.paginator import BasePaginator

View File

@@ -6,12 +6,10 @@ from uuid import uuid4
# Django imports
from django.db import IntegrityError
from django.db.models import Prefetch
from django.conf import settings
from django.utils import timezone
from django.core.exceptions import ValidationError
from django.core.validators import validate_email
from django.contrib.sites.shortcuts import get_current_site
from django.db.models import (
Prefetch,
OuterRef,
@@ -55,7 +53,6 @@ from . import BaseViewSet
from plane.db.models import (
User,
Workspace,
WorkspaceMember,
WorkspaceMemberInvite,
Team,
ProjectMember,

View File

@@ -408,7 +408,6 @@ def analytic_export_task(email, data, slug):
distribution,
x_axis,
y_axis,
segment,
key,
assignee_details,
label_details,

View File

@@ -23,7 +23,7 @@ def email_verification(first_name, email, token, current_site):
from_email_string = settings.EMAIL_FROM
subject = f"Verify your Email!"
subject = "Verify your Email!"
context = {
"first_name": first_name,

View File

@@ -4,7 +4,6 @@ import io
import json
import boto3
import zipfile
from urllib.parse import urlparse, urlunparse
# Django imports
from django.conf import settings

View File

@@ -8,8 +8,6 @@ from django.conf import settings
from celery import shared_task
from sentry_sdk import capture_exception
# Module imports
from plane.db.models import User
@shared_task
@@ -21,7 +19,7 @@ def forgot_password(first_name, email, uidb64, token, current_site):
from_email_string = settings.EMAIL_FROM
subject = f"Reset Your Password - Plane"
subject = "Reset Your Password - Plane"
context = {
"first_name": first_name,

View File

@@ -2,8 +2,6 @@
import json
import requests
import uuid
import jwt
from datetime import datetime
# Django imports
from django.conf import settings
@@ -27,7 +25,6 @@ from plane.db.models import (
User,
IssueProperty,
)
from .workspace_invitation_task import workspace_invitation
from plane.bgtasks.user_welcome_task import send_welcome_slack
@@ -58,7 +55,7 @@ def service_importer(service, importer_id):
ignore_conflicts=True,
)
[
_ = [
send_welcome_slack.delay(
str(user.id),
True,
@@ -157,7 +154,7 @@ def service_importer(service, importer_id):
)
# Create repo sync
repo_sync = GithubRepositorySync.objects.create(
_ = GithubRepositorySync.objects.create(
repository=repo,
workspace_integration=workspace_integration,
actor=workspace_integration.actor,
@@ -179,7 +176,7 @@ def service_importer(service, importer_id):
ImporterSerializer(importer).data,
cls=DjangoJSONEncoder,
)
res = requests.post(
_ = requests.post(
f"{settings.PROXY_BASE_URL}/hooks/workspaces/{str(importer.workspace_id)}/projects/{str(importer.project_id)}/importers/{str(service)}/",
json=import_data_json,
headers=headers,

View File

@@ -131,7 +131,7 @@ def track_parent(
else "",
field="parent",
project_id=project_id,
workspace=workspace_id,
workspace_id=workspace_id,
comment=f"updated the parent issue to",
old_identifier=old_parent.id if old_parent is not None else None,
new_identifier=new_parent.id if new_parent is not None else None,
@@ -334,9 +334,7 @@ def track_assignees(
issue_activities,
epoch,
):
requested_assignees = set(
[str(asg) for asg in requested_data.get("assignees", [])]
)
requested_assignees = set([str(asg) for asg in requested_data.get("assignees", [])])
current_assignees = set([str(asg) for asg in current_instance.get("assignees", [])])
added_assignees = requested_assignees - current_assignees
@@ -363,17 +361,19 @@ def track_assignees(
for dropped_assignee in dropped_assginees:
assignee = User.objects.get(pk=dropped_assignee)
issue_activities.append(
issue_id=issue_id,
actor_id=actor_id,
verb="updated",
old_value=assignee.display_name,
new_value="",
field="assignees",
project_id=project_id,
workspace_id=workspace_id,
comment=f"removed assignee ",
old_identifier=assignee.id,
epoch=epoch,
IssueActivity(
issue_id=issue_id,
actor_id=actor_id,
verb="updated",
old_value=assignee.display_name,
new_value="",
field="assignees",
project_id=project_id,
workspace_id=workspace_id,
comment=f"removed assignee ",
old_identifier=assignee.id,
epoch=epoch,
)
)
@@ -418,36 +418,37 @@ def track_archive_at(
issue_activities,
epoch,
):
if requested_data.get("archived_at") is None:
issue_activities.append(
IssueActivity(
issue_id=issue_id,
project_id=project_id,
workspace_id=workspace_id,
comment=f"has restored the issue",
verb="updated",
actor_id=actor_id,
field="archived_at",
old_value="archive",
new_value="restore",
epoch=epoch,
if current_instance.get("archived_at") != requested_data.get("archived_at"):
if requested_data.get("archived_at") is None:
issue_activities.append(
IssueActivity(
issue_id=issue_id,
project_id=project_id,
workspace_id=workspace_id,
comment="has restored the issue",
verb="updated",
actor_id=actor_id,
field="archived_at",
old_value="archive",
new_value="restore",
epoch=epoch,
)
)
)
else:
issue_activities.append(
IssueActivity(
issue_id=issue_id,
project_id=project_id,
workspace_id=workspace_id,
comment=f"Plane has archived the issue",
verb="updated",
actor_id=actor_id,
field="archived_at",
old_value=None,
new_value="archive",
epoch=epoch,
else:
issue_activities.append(
IssueActivity(
issue_id=issue_id,
project_id=project_id,
workspace_id=workspace_id,
comment="Plane has archived the issue",
verb="updated",
actor_id=actor_id,
field="archived_at",
old_value=None,
new_value="archive",
epoch=epoch,
)
)
)
def track_closed_to(
@@ -536,7 +537,7 @@ def update_issue_activity(
)
for key in requested_data:
func = ISSUE_ACTIVITY_MAPPER.get(key, None)
func = ISSUE_ACTIVITY_MAPPER.get(key)
if func is not None:
func(
requested_data=requested_data,
@@ -1534,6 +1535,8 @@ def issue_activity(
IssueActivitySerializer(issue_activities_created, many=True).data,
cls=DjangoJSONEncoder,
),
requested_data=requested_data,
current_instance=current_instance,
)
return

View File

@@ -59,7 +59,7 @@ def archive_old_issues():
# Check if Issues
if issues:
# Set the archive time to current time
archive_at = timezone.now()
archive_at = timezone.now().date()
issues_to_update = []
for issue in issues:
@@ -71,14 +71,14 @@ def archive_old_issues():
Issue.objects.bulk_update(
issues_to_update, ["archived_at"], batch_size=100
)
[
_ = [
issue_activity.delay(
type="issue.activity.updated",
requested_data=json.dumps({"archived_at": str(archive_at)}),
actor_id=str(project.created_by_id),
issue_id=issue.id,
project_id=project_id,
current_instance=None,
current_instance=json.dumps({"archived_at": None}),
subscriber=False,
epoch=int(timezone.now().timestamp())
)

View File

@@ -17,7 +17,7 @@ def magic_link(email, key, token, current_site):
from_email_string = settings.EMAIL_FROM
subject = f"Login for Plane"
subject = "Login for Plane"
context = {"magic_url": abs_url, "code": token}

View File

@@ -5,16 +5,107 @@ import json
from django.utils import timezone
# Module imports
from plane.db.models import IssueSubscriber, Project, IssueAssignee, Issue, Notification
from plane.db.models import (
IssueMention,
IssueSubscriber,
Project,
User,
IssueAssignee,
Issue,
Notification,
IssueComment,
)
# Third Party imports
from celery import shared_task
from bs4 import BeautifulSoup
def get_new_mentions(requested_instance, current_instance):
# requested_data is the newer instance of the current issue
# current_instance is the older instance of the current issue, saved in the database
# extract mentions from both the instance of data
mentions_older = extract_mentions(current_instance)
mentions_newer = extract_mentions(requested_instance)
# Getting Set Difference from mentions_newer
new_mentions = [
mention for mention in mentions_newer if mention not in mentions_older]
return new_mentions
# Get Removed Mention
def get_removed_mentions(requested_instance, current_instance):
# requested_data is the newer instance of the current issue
# current_instance is the older instance of the current issue, saved in the database
# extract mentions from both the instance of data
mentions_older = extract_mentions(current_instance)
mentions_newer = extract_mentions(requested_instance)
# Getting Set Difference from mentions_newer
removed_mentions = [
mention for mention in mentions_older if mention not in mentions_newer]
return removed_mentions
# Adds mentions as subscribers
def extract_mentions_as_subscribers(project_id, issue_id, mentions):
# mentions is an array of User IDs representing the FILTERED set of mentioned users
bulk_mention_subscribers = []
for mention_id in mentions:
# If the particular mention has not already been subscribed to the issue, he must be sent the mentioned notification
if not IssueSubscriber.objects.filter(
issue_id=issue_id,
subscriber=mention_id,
project=project_id,
).exists():
mentioned_user = User.objects.get(pk=mention_id)
project = Project.objects.get(pk=project_id)
issue = Issue.objects.get(pk=issue_id)
bulk_mention_subscribers.append(IssueSubscriber(
workspace=project.workspace,
project=project,
issue=issue,
subscriber=mentioned_user,
))
return bulk_mention_subscribers
# Parse Issue Description & extracts mentions
def extract_mentions(issue_instance):
try:
# issue_instance has to be a dictionary passed, containing the description_html and other set of activity data.
mentions = []
# Convert string to dictionary
data = json.loads(issue_instance)
html = data.get("description_html")
soup = BeautifulSoup(html, 'html.parser')
mention_tags = soup.find_all(
'mention-component', attrs={'target': 'users'})
mentions = [mention_tag['id'] for mention_tag in mention_tags]
return list(set(mentions))
except Exception as e:
return []
@shared_task
def notifications(type, issue_id, project_id, actor_id, subscriber, issue_activities_created):
def notifications(type, issue_id, project_id, actor_id, subscriber, issue_activities_created, requested_data, current_instance):
issue_activities_created = (
json.loads(issue_activities_created) if issue_activities_created is not None else None
json.loads(
issue_activities_created) if issue_activities_created is not None else None
)
if type not in [
"cycle.activity.created",
@@ -33,14 +124,35 @@ def notifications(type, issue_id, project_id, actor_id, subscriber, issue_activi
]:
# Create Notifications
bulk_notifications = []
"""
Mention Tasks
1. Perform Diffing and Extract the mentions, that mention notification needs to be sent
2. From the latest set of mentions, extract the users which are not a subscribers & make them subscribers
"""
# Get new mentions from the newer instance
new_mentions = get_new_mentions(
requested_instance=requested_data, current_instance=current_instance)
removed_mention = get_removed_mentions(
requested_instance=requested_data, current_instance=current_instance)
# Get New Subscribers from the mentions of the newer instance
requested_mentions = extract_mentions(
issue_instance=requested_data)
mention_subscribers = extract_mentions_as_subscribers(
project_id=project_id, issue_id=issue_id, mentions=requested_mentions)
issue_subscribers = list(
IssueSubscriber.objects.filter(project_id=project_id, issue_id=issue_id)
.exclude(subscriber_id=actor_id)
IssueSubscriber.objects.filter(
project_id=project_id, issue_id=issue_id)
.exclude(subscriber_id__in=list(new_mentions + [actor_id]))
.values_list("subscriber", flat=True)
)
issue_assignees = list(
IssueAssignee.objects.filter(project_id=project_id, issue_id=issue_id)
IssueAssignee.objects.filter(
project_id=project_id, issue_id=issue_id)
.exclude(assignee_id=actor_id)
.values_list("assignee", flat=True)
)
@@ -62,6 +174,9 @@ def notifications(type, issue_id, project_id, actor_id, subscriber, issue_activi
for subscriber in list(set(issue_subscribers)):
for issue_activity in issue_activities_created:
issue_comment = issue_activity.get("issue_comment")
if issue_comment is not None:
issue_comment = IssueComment.objects.get(id=issue_comment, issue_id=issue_id, project_id=project_id, workspace_id=project.workspace_id)
bulk_notifications.append(
Notification(
workspace=project.workspace,
@@ -89,7 +204,7 @@ def notifications(type, issue_id, project_id, actor_id, subscriber, issue_activi
"new_value": str(issue_activity.get("new_value")),
"old_value": str(issue_activity.get("old_value")),
"issue_comment": str(
issue_activity.get("issue_comment").comment_stripped
issue_comment.comment_stripped
if issue_activity.get("issue_comment") is not None
else ""
),
@@ -98,5 +213,62 @@ def notifications(type, issue_id, project_id, actor_id, subscriber, issue_activi
)
)
# Add Mentioned as Issue Subscribers
IssueSubscriber.objects.bulk_create(
mention_subscribers, batch_size=100)
for mention_id in new_mentions:
if (mention_id != actor_id):
for issue_activity in issue_activities_created:
bulk_notifications.append(
Notification(
workspace=project.workspace,
sender="in_app:issue_activities:mention",
triggered_by_id=actor_id,
receiver_id=mention_id,
entity_identifier=issue_id,
entity_name="issue",
project=project,
message=f"You have been mentioned in the issue {issue.name}",
data={
"issue": {
"id": str(issue_id),
"name": str(issue.name),
"identifier": str(issue.project.identifier),
"sequence_id": issue.sequence_id,
"state_name": issue.state.name,
"state_group": issue.state.group,
},
"issue_activity": {
"id": str(issue_activity.get("id")),
"verb": str(issue_activity.get("verb")),
"field": str(issue_activity.get("field")),
"actor": str(issue_activity.get("actor_id")),
"new_value": str(issue_activity.get("new_value")),
"old_value": str(issue_activity.get("old_value")),
},
},
)
)
# Create New Mentions Here
aggregated_issue_mentions = []
for mention_id in new_mentions:
mentioned_user = User.objects.get(pk=mention_id)
aggregated_issue_mentions.append(
IssueMention(
mention=mentioned_user,
issue=issue,
project=project,
workspace=project.workspace
)
)
IssueMention.objects.bulk_create(
aggregated_issue_mentions, batch_size=100)
IssueMention.objects.filter(
issue=issue, mention__in=removed_mention).delete()
# Bulk create notifications
Notification.objects.bulk_create(bulk_notifications, batch_size=100)

View File

@@ -11,7 +11,7 @@ from slack_sdk import WebClient
from slack_sdk.errors import SlackApiError
# Module imports
from plane.db.models import Workspace, User, WorkspaceMemberInvite
from plane.db.models import Workspace, WorkspaceMemberInvite
@shared_task

View File

@@ -29,4 +29,4 @@ app.conf.beat_schedule = {
# Load task modules from all registered Django app configs.
app.autodiscover_tasks()
app.conf.beat_scheduler = 'django_celery_beat.schedulers.DatabaseScheduler'
app.conf.beat_scheduler = 'django_celery_beat.schedulers.DatabaseScheduler'

View File

@@ -4,7 +4,7 @@ from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import plane.db.models.issue
import uuid
class Migration(migrations.Migration):
@@ -13,9 +13,29 @@ class Migration(migrations.Migration):
]
operations = [
migrations.CreateModel(
name="issue_mentions",
fields=[
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')),
('id', models.UUIDField(db_index=True, default=uuid.uuid4,editable=False, primary_key=True, serialize=False, unique=True)),
('mention', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='issue_mention', to=settings.AUTH_USER_MODEL)),
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL,related_name='issuemention_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
('issue', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='issue_mention', to='db.issue')),
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_issuemention', to='db.project')),
('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='issuemention_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')),
('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_issuemention', to='db.workspace')),
],
options={
'verbose_name': 'IssueMention',
'verbose_name_plural': 'IssueMentions',
'db_table': 'issue_mentions',
'ordering': ('-created_at',),
},
),
migrations.AlterField(
model_name='issueproperty',
name='properties',
field=models.JSONField(default=plane.db.models.issue.get_default_properties),
),
]
]

View File

@@ -27,12 +27,12 @@ from .issue import (
IssueActivity,
IssueProperty,
IssueComment,
IssueBlocker,
IssueLabel,
IssueAssignee,
Label,
IssueBlocker,
IssueRelation,
IssueMention,
IssueLink,
IssueSequence,
IssueAttachment,
@@ -78,4 +78,4 @@ from .analytic import AnalyticView
from .notification import Notification
from .exporter import ExporterHistory
from .exporter import ExporterHistory

View File

@@ -53,4 +53,4 @@ class ExporterHistory(BaseModel):
def __str__(self):
"""Return name of the service"""
return f"{self.provider} <{self.workspace.name}>"
return f"{self.provider} <{self.workspace.name}>"

View File

@@ -1,3 +1,3 @@
from .base import Integration, WorkspaceIntegration
from .github import GithubRepository, GithubRepositorySync, GithubIssueSync, GithubCommentSync
from .slack import SlackProjectSync
from .slack import SlackProjectSync

View File

@@ -6,7 +6,6 @@ from django.db import models
# Module imports
from plane.db.models import ProjectBaseModel
from plane.db.mixins import AuditModel
class GithubRepository(ProjectBaseModel):

View File

@@ -226,7 +226,26 @@ class IssueRelation(ProjectBaseModel):
ordering = ("-created_at",)
def __str__(self):
return f"{self.issue.name} {self.related_issue.name}"
return f"{self.issue.name} {self.related_issue.name}"
class IssueMention(ProjectBaseModel):
issue = models.ForeignKey(
Issue, on_delete=models.CASCADE, related_name="issue_mention"
)
mention = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="issue_mention",
)
class Meta:
unique_together = ["issue", "mention"]
verbose_name = "Issue Mention"
verbose_name_plural = "Issue Mentions"
db_table = "issue_mentions"
ordering = ("-created_at",)
def __str__(self):
return f"{self.issue.name} {self.mention.email}"
class IssueAssignee(ProjectBaseModel):

View File

@@ -4,9 +4,6 @@ from uuid import uuid4
# Django imports
from django.db import models
from django.conf import settings
from django.template.defaultfilters import slugify
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.core.validators import MinValueValidator, MaxValueValidator
# Modeule imports

View File

@@ -1,33 +0,0 @@
import jwt
import pytz
from django.conf import settings
from django.utils import timezone
from plane.db.models import User
class UserMiddleware(object):
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
try:
if request.headers.get("Authorization"):
authorization_header = request.headers.get("Authorization")
access_token = authorization_header.split(" ")[1]
decoded = jwt.decode(
access_token, settings.SECRET_KEY, algorithms=["HS256"]
)
id = decoded['user_id']
user = User.objects.get(id=id)
user.last_active = timezone.now()
user.token_updated_at = None
user.save()
timezone.activate(pytz.timezone(user.user_timezone))
except Exception as e:
print(e)
response = self.get_response(request)
return response

View File

@@ -4,7 +4,6 @@ import ssl
import certifi
import dj_database_url
from urllib.parse import urlparse
import sentry_sdk
from sentry_sdk.integrations.django import DjangoIntegration

View File

@@ -1 +1 @@
from .api import *
from .api import *

View File

@@ -2,16 +2,13 @@
"""
# from django.contrib import admin
from django.urls import path, include, re_path
from django.views.generic import TemplateView
from django.conf import settings
# from django.conf.urls.static import static
urlpatterns = [
# path("admin/", admin.site.urls),
path("", TemplateView.as_view(template_name="index.html")),
path("api/", include("plane.api.urls")),
path("", include("plane.web.urls")),

View File

@@ -12,19 +12,19 @@ from django.db.models.functions import Coalesce, ExtractMonth, ExtractYear, Conc
from plane.db.models import Issue
def annotate_with_monthly_dimension(queryset, field_name):
def annotate_with_monthly_dimension(queryset, field_name, attribute):
# Get the year and the months
year = ExtractYear(field_name)
month = ExtractMonth(field_name)
# Concat the year and month
dimension = Concat(year, Value("-"), month, output_field=CharField())
# Annotate the dimension
return queryset.annotate(dimension=dimension)
return queryset.annotate(**{attribute: dimension})
def extract_axis(queryset, x_axis):
# Format the dimension when the axis is in date
if x_axis in ["created_at", "start_date", "target_date", "completed_at"]:
queryset = annotate_with_monthly_dimension(queryset, x_axis)
queryset = annotate_with_monthly_dimension(queryset, x_axis, "dimension")
return queryset, "dimension"
else:
return queryset.annotate(dimension=F(x_axis)), "dimension"
@@ -47,7 +47,7 @@ def build_graph_plot(queryset, x_axis, y_axis, segment=None):
#
if segment in ["created_at", "start_date", "target_date", "completed_at"]:
queryset = annotate_with_monthly_dimension(queryset, segment)
queryset = annotate_with_monthly_dimension(queryset, segment, "segmented")
segment = "segmented"
queryset = queryset.values(x_axis)
@@ -81,7 +81,6 @@ def burndown_plot(queryset, slug, project_id, cycle_id=None, module_id=None):
# Total Issues in Cycle or Module
total_issues = queryset.total_issues
if cycle_id:
# Get all dates between the two dates
date_range = [
@@ -103,7 +102,7 @@ def burndown_plot(queryset, slug, project_id, cycle_id=None, module_id=None):
.values("date", "total_completed")
.order_by("date")
)
if module_id:
# Get all dates between the two dates
date_range = [
@@ -126,18 +125,15 @@ def burndown_plot(queryset, slug, project_id, cycle_id=None, module_id=None):
.order_by("date")
)
for date in date_range:
cumulative_pending_issues = total_issues
total_completed = 0
total_completed = sum(
[
item["total_completed"]
for item in completed_issues_distribution
if item["date"] is not None and item["date"] <= date
]
item["total_completed"]
for item in completed_issues_distribution
if item["date"] is not None and item["date"] <= date
)
cumulative_pending_issues -= total_completed
chart_data[str(date)] = cumulative_pending_issues
return chart_data
return chart_data

View File

@@ -127,7 +127,7 @@ def group_results(results_data, group_by, sub_group_by=False):
return main_responsive_dict
else:
response_dict = dict()
response_dict = {}
if group_by == "priority":
response_dict = {

View File

@@ -17,4 +17,4 @@ def import_submodules(context, root_module, path):
for k, v in six.iteritems(vars(module)):
if not k.startswith('_'):
context[k] = v
context[module_name] = module
context[module_name] = module

View File

@@ -4,4 +4,4 @@ def get_client_ip(request):
ip = x_forwarded_for.split(',')[0]
else:
ip = request.META.get('REMOTE_ADDR')
return ip
return ip

View File

@@ -150,6 +150,17 @@ def filter_assignees(params, filter, method):
filter["assignees__in"] = params.get("assignees")
return filter
def filter_mentions(params, filter, method):
if method == "GET":
mentions = [item for item in params.get("mentions").split(",") if item != 'null']
mentions = filter_valid_uuids(mentions)
if len(mentions) and "" not in mentions:
filter["issue_mention__mention__id__in"] = mentions
else:
if params.get("mentions", None) and len(params.get("mentions")) and params.get("mentions") != 'null':
filter["issue_mention__mention__id__in"] = params.get("mentions")
return filter
def filter_created_by(params, filter, method):
if method == "GET":
@@ -316,7 +327,7 @@ def filter_start_target_date_issues(params, filter, method):
def issue_filters(query_params, method):
filter = dict()
filter = {}
ISSUE_FILTER = {
"state": filter_state,
@@ -326,6 +337,7 @@ def issue_filters(query_params, method):
"parent": filter_parent,
"labels": filter_labels,
"assignees": filter_assignees,
"mentions": filter_mentions,
"created_by": filter_created_by,
"name": filter_name,
"created_at": filter_created_at,

View File

@@ -1,3 +1,3 @@
import mistune
markdown = mistune.Markdown()
markdown = mistune.Markdown()

View File

@@ -21,12 +21,7 @@ class Cursor:
)
def __repr__(self):
return "<{}: value={} offset={} is_prev={}>".format(
type(self).__name__,
self.value,
self.offset,
int(self.is_prev),
)
return f"{type(self).__name__,}: value={self.value} offset={self.offset}, is_prev={int(self.is_prev)}"
def __bool__(self):
return bool(self.has_results)
@@ -176,10 +171,6 @@ class BasePaginator:
**paginator_kwargs,
):
"""Paginate the request"""
assert (paginator and not paginator_kwargs) or (
paginator_cls and paginator_kwargs
)
per_page = self.get_per_page(request, default_per_page, max_per_page)
# Convert the cursor value to integer and float from string

View File

@@ -33,4 +33,5 @@ django_celery_beat==2.5.0
psycopg-binary==3.1.10
psycopg-c==3.1.10
scout-apm==2.26.1
openpyxl==3.1.2
openpyxl==3.1.2
beautifulsoup4==4.12.2

View File

@@ -2,6 +2,7 @@
"name": "@plane/editor-core",
"version": "0.0.1",
"description": "Core Editor that powers Plane",
"private": true,
"main": "./dist/index.mjs",
"module": "./dist/index.mjs",
"types": "./dist/index.d.mts",
@@ -21,18 +22,18 @@
"check-types": "tsc --noEmit"
},
"peerDependencies": {
"react": "^18.2.0",
"react-dom": "18.2.0",
"next": "12.3.2",
"next-themes": "^0.2.1"
"next-themes": "^0.2.1",
"react": "^18.2.0",
"react-dom": "18.2.0"
},
"dependencies": {
"react-moveable" : "^0.54.2",
"@blueprintjs/popover2": "^2.0.10",
"@tiptap/core": "^2.1.7",
"@tiptap/extension-color": "^2.1.11",
"@tiptap/extension-image": "^2.1.7",
"@tiptap/extension-link": "^2.1.7",
"@tiptap/extension-mention": "^2.1.12",
"@tiptap/extension-table": "^2.1.6",
"@tiptap/extension-table-cell": "^2.1.6",
"@tiptap/extension-table-header": "^2.1.6",
@@ -41,12 +42,15 @@
"@tiptap/extension-task-list": "^2.1.7",
"@tiptap/extension-text-style": "^2.1.11",
"@tiptap/extension-underline": "^2.1.7",
"@tiptap/prosemirror-tables": "^1.1.4",
"jsx-dom-cjs": "^8.0.3",
"@tiptap/pm": "^2.1.7",
"@tiptap/react": "^2.1.7",
"@tiptap/starter-kit": "^2.1.10",
"@tiptap/suggestion": "^2.0.4",
"@types/node": "18.15.3",
"@types/react": "^18.2.5",
"@types/react-dom": "18.0.11",
"@types/node": "18.15.3",
"class-variance-authority": "^0.7.0",
"clsx": "^1.2.1",
"eslint": "8.36.0",
@@ -54,6 +58,7 @@
"eventsource-parser": "^0.1.0",
"lucide-react": "^0.244.0",
"react-markdown": "^8.0.7",
"react-moveable": "^0.54.2",
"tailwind-merge": "^1.14.0",
"tippy.js": "^6.3.7",
"tiptap-markdown": "^0.8.2",

View File

@@ -2,8 +2,11 @@
// import "./styles/tailwind.css";
// import "./styles/editor.css";
export { isCellSelection } from "./ui/extensions/table/table/utilities/is-cell-selection";
// utils
export * from "./lib/utils";
export * from "./ui/extensions/table/table";
export { startImageUpload } from "./ui/plugins/upload-image";
// components

View File

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

View File

@@ -7,7 +7,11 @@ interface EditorContainerProps {
children: ReactNode;
}
export const EditorContainer = ({ editor, editorClassNames, children }: EditorContainerProps) => (
export const EditorContainer = ({
editor,
editorClassNames,
children,
}: EditorContainerProps) => (
<div
id="editor-container"
onClick={() => {

View File

@@ -1,7 +1,6 @@
import { Editor, EditorContent } from "@tiptap/react";
import { ReactNode } from "react";
import { ImageResizer } from "../extensions/image/image-resize";
import { TableMenu } from "../menus/table-menu";
interface EditorContentProps {
editor: Editor | null;
@@ -10,10 +9,8 @@ interface EditorContentProps {
}
export const EditorContentWrapper = ({ editor, editorContentCustomClassNames = '', children }: EditorContentProps) => (
<div className={`${editorContentCustomClassNames}`}>
{/* @ts-ignore */}
<div className={`contentEditor ${editorContentCustomClassNames}`}>
<EditorContent editor={editor} />
{editor?.isEditable && <TableMenu editor={editor} />}
{(editor?.isActive("image") && editor?.isEditable) && <ImageResizer editor={editor} />}
{children}
</div>

View File

@@ -8,18 +8,21 @@ import TaskList from "@tiptap/extension-task-list";
import { Markdown } from "tiptap-markdown";
import Gapcursor from "@tiptap/extension-gapcursor";
import { CustomTableCell } from "./table/table-cell";
import { Table } from "./table";
import { TableHeader } from "./table/table-header";
import { TableRow } from "@tiptap/extension-table-row";
import TableHeader from "./table/table-header/table-header";
import Table from "./table/table";
import TableCell from "./table/table-cell/table-cell";
import TableRow from "./table/table-row/table-row";
import ImageExtension from "./image";
import { DeleteImage } from "../../types/delete-image";
import { isValidHttpUrl } from "../../lib/utils";
import { IMentionSuggestion } from "../../types/mention-suggestion";
import { Mentions } from "../mentions";
export const CoreEditorExtensions = (
mentionConfig: { mentionSuggestions: IMentionSuggestion[], mentionHighlights: string[] },
deleteFile: DeleteImage,
) => [
StarterKit.configure({
@@ -92,6 +95,7 @@ export const CoreEditorExtensions = (
}),
Table,
TableHeader,
CustomTableCell,
TableCell,
TableRow,
Mentions(mentionConfig.mentionSuggestions, mentionConfig.mentionHighlights, false),
];

View File

@@ -1,9 +0,0 @@
import { Table as BaseTable } from "@tiptap/extension-table";
const Table = BaseTable.configure({
resizable: true,
cellMinWidth: 100,
allowTableNodeSelection: true,
});
export { Table };

View File

@@ -1,32 +0,0 @@
import { TableCell } from "@tiptap/extension-table-cell";
export const CustomTableCell = TableCell.extend({
addAttributes() {
return {
...this.parent?.(),
isHeader: {
default: false,
parseHTML: (element) => {
isHeader: element.tagName === "TD";
},
renderHTML: (attributes) => {
tag: attributes.isHeader ? "th" : "td";
},
},
};
},
renderHTML({ HTMLAttributes }) {
if (HTMLAttributes.isHeader) {
return [
"th",
{
...HTMLAttributes,
class: `relative ${HTMLAttributes.class}`,
},
["span", { class: "absolute top-0 right-0" }],
0,
];
}
return ["td", HTMLAttributes, 0];
},
});

View File

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

View File

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

View File

@@ -1,7 +0,0 @@
import { TableHeader as BaseTableHeader } from "@tiptap/extension-table-header";
const TableHeader = BaseTableHeader.extend({
content: "paragraph",
});
export { TableHeader };

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,55 @@
const icons = {
colorPicker: `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" style="transform: ;msFilter:;"><path fill="rgb(var(--color-text-300))" d="M20 14c-.092.064-2 2.083-2 3.5 0 1.494.949 2.448 2 2.5.906.044 2-.891 2-2.5 0-1.5-1.908-3.436-2-3.5zM9.586 20c.378.378.88.586 1.414.586s1.036-.208 1.414-.586l7-7-.707-.707L11 4.586 8.707 2.293 7.293 3.707 9.586 6 4 11.586c-.378.378-.586.88-.586 1.414s.208 1.036.586 1.414L9.586 20zM11 7.414 16.586 13H5.414L11 7.414z"></path></svg>`,
deleteColumn: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#e53e3e" d="M0 0H24V24H0z"/><path d="M12 3c.552 0 1 .448 1 1v8c.835-.628 1.874-1 3-1 2.761 0 5 2.239 5 5s-2.239 5-5 5c-1.032 0-1.99-.313-2.787-.848L13 20c0 .552-.448 1-1 1H6c-.552 0-1-.448-1-1V4c0-.552.448-1 1-1h6zm-1 2H7v14h4V5zm8 10h-6v2h6v-2z"/></svg>`,
deleteRow: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="#e53e3e" d="M0 0H24V24H0z"/><path d="M20 5c.552 0 1 .448 1 1v6c0 .552-.448 1-1 1 .628.835 1 1.874 1 3 0 2.761-2.239 5-5 5s-5-2.239-5-5c0-1.126.372-2.165 1-3H4c-.552 0-1-.448-1-1V6c0-.552.448-1 1-1h16zm-7 10v2h6v-2h-6zm6-8H5v4h14V7z"/></svg>`,
insertLeftTableIcon: `<svg
xmlns="http://www.w3.org/2000/svg"
width={24}
height={24}
viewBox="0 -960 960 960"
>
<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>
`,
insertRightTableIcon: `<svg
xmlns="http://www.w3.org/2000/svg"
width={24}
height={24}
viewBox="0 -960 960 960"
>
<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>
`,
insertTopTableIcon: `<svg
xmlns="http://www.w3.org/2000/svg"
width={24}
height={24}
viewBox="0 -960 960 960"
>
<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>
`,
insertBottomTableIcon:`<svg
xmlns="http://www.w3.org/2000/svg"
width={24}
height={24}
viewBox="0 -960 960 960"
>
<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 icons;

View File

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

View File

@@ -0,0 +1,117 @@
import { Plugin, PluginKey, TextSelection } from "@tiptap/pm/state";
import { findParentNode } from "@tiptap/core";
import { DecorationSet, Decoration } from "@tiptap/pm/view";
const key = new PluginKey("tableControls");
export function tableControls() {
return new Plugin({
key,
state: {
init() {
return new TableControlsState();
},
apply(tr, prev) {
return prev.apply(tr);
},
},
props: {
handleDOMEvents: {
mousemove: (view, event) => {
const pluginState = key.getState(view.state);
if (
!(event.target as HTMLElement).closest(".tableWrapper") &&
pluginState.values.hoveredTable
) {
return view.dispatch(
view.state.tr.setMeta(key, {
setHoveredTable: null,
setHoveredCell: null,
}),
);
}
const pos = view.posAtCoords({
left: event.clientX,
top: event.clientY,
});
if (!pos) return;
const table = findParentNode((node) => node.type.name === "table")(
TextSelection.create(view.state.doc, pos.pos),
);
const cell = findParentNode(
(node) =>
node.type.name === "tableCell" ||
node.type.name === "tableHeader",
)(TextSelection.create(view.state.doc, pos.pos));
if (!table || !cell) return;
if (pluginState.values.hoveredCell?.pos !== cell.pos) {
return view.dispatch(
view.state.tr.setMeta(key, {
setHoveredTable: table,
setHoveredCell: cell,
}),
);
}
},
},
decorations: (state) => {
const pluginState = key.getState(state);
if (!pluginState) {
return null;
}
const { hoveredTable, hoveredCell } = pluginState.values;
const docSize = state.doc.content.size;
if (hoveredTable && hoveredCell && hoveredTable.pos < docSize && hoveredCell.pos < docSize) {
const decorations = [
Decoration.node(
hoveredTable.pos,
hoveredTable.pos + hoveredTable.node.nodeSize,
{},
{
hoveredTable,
hoveredCell,
},
),
];
return DecorationSet.create(state.doc, decorations);
}
return null;
},
},
});
}
class TableControlsState {
values;
constructor(props = {}) {
this.values = {
hoveredTable: null,
hoveredCell: null,
...props,
};
}
apply(tr: any) {
const actions = tr.getMeta(key);
if (actions?.setHoveredTable !== undefined) {
this.values.hoveredTable = actions.setHoveredTable;
}
if (actions?.setHoveredCell !== undefined) {
this.values.hoveredCell = actions.setHoveredCell;
}
return this;
}
}

View File

@@ -0,0 +1,530 @@
import { h } from "jsx-dom-cjs";
import { Node as ProseMirrorNode } from "@tiptap/pm/model";
import { Decoration, NodeView } from "@tiptap/pm/view";
import tippy, { Instance, Props } from "tippy.js";
import { Editor } from "@tiptap/core";
import {
CellSelection,
TableMap,
updateColumnsOnResize,
} from "@tiptap/prosemirror-tables";
import icons from "./icons";
export function updateColumns(
node: ProseMirrorNode,
colgroup: HTMLElement,
table: HTMLElement,
cellMinWidth: number,
overrideCol?: number,
overrideValue?: any,
) {
let totalWidth = 0;
let fixedWidth = true;
let nextDOM = colgroup.firstChild as HTMLElement;
const row = node.firstChild;
if (!row) return;
for (let i = 0, col = 0; i < row.childCount; i += 1) {
const { colspan, colwidth } = row.child(i).attrs;
for (let j = 0; j < colspan; j += 1, col += 1) {
const hasWidth =
overrideCol === col ? overrideValue : colwidth && colwidth[j];
const cssWidth = hasWidth ? `${hasWidth}px` : "";
totalWidth += hasWidth || cellMinWidth;
if (!hasWidth) {
fixedWidth = false;
}
if (!nextDOM) {
colgroup.appendChild(document.createElement("col")).style.width =
cssWidth;
} else {
if (nextDOM.style.width !== cssWidth) {
nextDOM.style.width = cssWidth;
}
nextDOM = nextDOM.nextSibling as HTMLElement;
}
}
}
while (nextDOM) {
const after = nextDOM.nextSibling;
nextDOM.parentNode?.removeChild(nextDOM);
nextDOM = after as HTMLElement;
}
if (fixedWidth) {
table.style.width = `${totalWidth}px`;
table.style.minWidth = "";
} else {
table.style.width = "";
table.style.minWidth = `${totalWidth}px`;
}
}
const defaultTippyOptions: Partial<Props> = {
allowHTML: true,
arrow: false,
trigger: "click",
animation: "scale-subtle",
theme: "light-border no-padding",
interactive: true,
hideOnClick: true,
placement: "right",
};
function setCellsBackgroundColor(editor: Editor, backgroundColor) {
return editor
.chain()
.focus()
.updateAttributes("tableCell", {
background: backgroundColor,
})
.updateAttributes("tableHeader", {
background: backgroundColor,
})
.run();
}
const columnsToolboxItems = [
{
label: "Add Column Before",
icon: icons.insertLeftTableIcon,
action: ({ editor }: { editor: Editor }) =>
editor.chain().focus().addColumnBefore().run(),
},
{
label: "Add Column After",
icon: icons.insertRightTableIcon,
action: ({ editor }: { editor: Editor }) =>
editor.chain().focus().addColumnAfter().run(),
},
{
label: "Pick Column Color",
icon: icons.colorPicker,
action: ({
editor,
triggerButton,
controlsContainer,
}: {
editor: Editor;
triggerButton: HTMLElement;
controlsContainer;
}) => {
createColorPickerToolbox({
triggerButton,
tippyOptions: {
appendTo: controlsContainer,
},
onSelectColor: (color) => setCellsBackgroundColor(editor, color),
});
},
},
{
label: "Delete Column",
icon: icons.deleteColumn,
action: ({ editor }: { editor: Editor }) =>
editor.chain().focus().deleteColumn().run(),
},
];
const rowsToolboxItems = [
{
label: "Add Row Above",
icon: icons.insertTopTableIcon,
action: ({ editor }: { editor: Editor }) =>
editor.chain().focus().addRowBefore().run(),
},
{
label: "Add Row Below",
icon: icons.insertBottomTableIcon,
action: ({ editor }: { editor: Editor }) =>
editor.chain().focus().addRowAfter().run(),
},
{
label: "Pick Row Color",
icon: icons.colorPicker,
action: ({
editor,
triggerButton,
controlsContainer,
}: {
editor: Editor;
triggerButton: HTMLButtonElement;
controlsContainer:
| Element
| "parent"
| ((ref: Element) => Element)
| undefined;
}) => {
createColorPickerToolbox({
triggerButton,
tippyOptions: {
appendTo: controlsContainer,
},
onSelectColor: (color) => setCellsBackgroundColor(editor, color),
});
},
},
{
label: "Delete Row",
icon: icons.deleteRow,
action: ({ editor }: { editor: Editor }) =>
editor.chain().focus().deleteRow().run(),
},
];
function createToolbox({
triggerButton,
items,
tippyOptions,
onClickItem,
}: {
triggerButton: HTMLElement;
items: { icon: string; label: string }[];
tippyOptions: any;
onClickItem: any;
}): Instance<Props> {
const toolbox = tippy(triggerButton, {
content: h(
"div",
{ className: "tableToolbox" },
items.map((item) =>
h(
"div",
{
className: "toolboxItem",
onClick() {
onClickItem(item);
},
},
[
h("div", {
className: "iconContainer",
innerHTML: item.icon,
}),
h("div", { className: "label" }, item.label),
],
),
),
),
...tippyOptions,
});
return Array.isArray(toolbox) ? toolbox[0] : toolbox;
}
function createColorPickerToolbox({
triggerButton,
tippyOptions,
onSelectColor = () => {},
}: {
triggerButton: HTMLElement;
tippyOptions: Partial<Props>;
onSelectColor?: (color: string) => void;
}) {
const items = {
Default: "rgb(var(--color-primary-100))",
Orange: "#FFE5D1",
Grey: "#F1F1F1",
Yellow: "#FEF3C7",
Green: "#DCFCE7",
Red: "#FFDDDD",
Blue: "#D9E4FF",
Pink: "#FFE8FA",
Purple: "#E8DAFB",
};
const colorPicker = tippy(triggerButton, {
...defaultTippyOptions,
content: h(
"div",
{ className: "tableColorPickerToolbox" },
Object.entries(items).map(([key, value]) =>
h(
"div",
{
className: "toolboxItem",
onClick: () => {
onSelectColor(value);
colorPicker.hide();
},
},
[
h("div", {
className: "colorContainer",
style: {
backgroundColor: value,
},
}),
h(
"div",
{
className: "label",
},
key,
),
],
),
),
),
onHidden: (instance) => {
instance.destroy();
},
showOnCreate: true,
...tippyOptions,
});
return colorPicker;
}
export class TableView implements NodeView {
node: ProseMirrorNode;
cellMinWidth: number;
decorations: Decoration[];
editor: Editor;
getPos: () => number;
hoveredCell;
map: TableMap;
root: HTMLElement;
table: HTMLElement;
colgroup: HTMLElement;
tbody: HTMLElement;
rowsControl?: HTMLElement;
columnsControl?: HTMLElement;
columnsToolbox?: Instance<Props>;
rowsToolbox?: Instance<Props>;
controls?: HTMLElement;
get dom() {
return this.root;
}
get contentDOM() {
return this.tbody;
}
constructor(
node: ProseMirrorNode,
cellMinWidth: number,
decorations: Decoration[],
editor: Editor,
getPos: () => number,
) {
this.node = node;
this.cellMinWidth = cellMinWidth;
this.decorations = decorations;
this.editor = editor;
this.getPos = getPos;
this.hoveredCell = null;
this.map = TableMap.get(node);
if (editor.isEditable) {
this.rowsControl = h(
"div",
{ className: "rowsControl" },
h("button", {
onClick: () => this.selectRow(),
}),
);
this.columnsControl = h(
"div",
{ className: "columnsControl" },
h("button", {
onClick: () => this.selectColumn(),
}),
);
this.controls = h(
"div",
{ className: "tableControls", contentEditable: "false" },
this.rowsControl,
this.columnsControl,
);
this.columnsToolbox = createToolbox({
triggerButton: this.columnsControl.querySelector("button"),
items: columnsToolboxItems,
tippyOptions: {
...defaultTippyOptions,
appendTo: this.controls,
},
onClickItem: (item) => {
item.action({
editor: this.editor,
triggerButton: this.columnsControl?.firstElementChild,
controlsContainer: this.controls,
});
this.columnsToolbox?.hide();
},
});
this.rowsToolbox = createToolbox({
triggerButton: this.rowsControl.firstElementChild,
items: rowsToolboxItems,
tippyOptions: {
...defaultTippyOptions,
appendTo: this.controls,
},
onClickItem: (item) => {
item.action({
editor: this.editor,
triggerButton: this.rowsControl?.firstElementChild,
controlsContainer: this.controls,
});
this.rowsToolbox?.hide();
},
});
}
// Table
this.colgroup = h(
"colgroup",
null,
Array.from({ length: this.map.width }, () => 1).map(() => h("col")),
);
this.tbody = h("tbody");
this.table = h("table", null, this.colgroup, this.tbody);
this.root = h(
"div",
{
className: "tableWrapper controls--disabled",
},
this.controls,
this.table,
);
this.render();
}
update(node: ProseMirrorNode, decorations) {
if (node.type !== this.node.type) {
return false;
}
this.node = node;
this.decorations = decorations;
this.map = TableMap.get(this.node);
if (this.editor.isEditable) {
this.updateControls();
}
this.render();
return true;
}
render() {
if (this.colgroup.children.length !== this.map.width) {
const cols = Array.from({ length: this.map.width }, () => 1).map(() =>
h("col"),
);
this.colgroup.replaceChildren(...cols);
}
updateColumnsOnResize(
this.node,
this.colgroup,
this.table,
this.cellMinWidth,
);
}
ignoreMutation() {
return true;
}
updateControls() {
const { hoveredTable: table, hoveredCell: cell } = Object.values(
this.decorations,
).reduce(
(acc, curr) => {
if (curr.spec.hoveredCell !== undefined) {
acc["hoveredCell"] = curr.spec.hoveredCell;
}
if (curr.spec.hoveredTable !== undefined) {
acc["hoveredTable"] = curr.spec.hoveredTable;
}
return acc;
},
{} as Record<string, HTMLElement>,
) as any;
if (table === undefined || cell === undefined) {
return this.root.classList.add("controls--disabled");
}
this.root.classList.remove("controls--disabled");
this.hoveredCell = cell;
const cellDom = this.editor.view.nodeDOM(cell.pos) as HTMLElement;
const tableRect = this.table.getBoundingClientRect();
const cellRect = cellDom.getBoundingClientRect();
this.columnsControl.style.left = `${
cellRect.left - tableRect.left - this.table.parentElement!.scrollLeft
}px`;
this.columnsControl.style.width = `${cellRect.width}px`;
this.rowsControl.style.top = `${cellRect.top - tableRect.top}px`;
this.rowsControl.style.height = `${cellRect.height}px`;
}
selectColumn() {
if (!this.hoveredCell) return;
const colIndex = this.map.colCount(
this.hoveredCell.pos - (this.getPos() + 1),
);
const anchorCellPos = this.hoveredCell.pos;
const headCellPos =
this.map.map[colIndex + this.map.width * (this.map.height - 1)] +
(this.getPos() + 1);
const cellSelection = CellSelection.create(
this.editor.view.state.doc,
anchorCellPos,
headCellPos,
);
this.editor.view.dispatch(
// @ts-ignore
this.editor.state.tr.setSelection(cellSelection),
);
}
selectRow() {
if (!this.hoveredCell) return;
const anchorCellPos = this.hoveredCell.pos;
const anchorCellIndex = this.map.map.indexOf(
anchorCellPos - (this.getPos() + 1),
);
const headCellPos =
this.map.map[anchorCellIndex + (this.map.width - 1)] +
(this.getPos() + 1);
const cellSelection = CellSelection.create(
this.editor.state.doc,
anchorCellPos,
headCellPos,
);
this.editor.view.dispatch(
// @ts-ignore
this.editor.view.state.tr.setSelection(cellSelection),
);
}
}

View File

@@ -0,0 +1,298 @@
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"
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
}
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 NodeConfig<Options, Storage> {
tableRole?:
| string
| ((this: {
name: string
options: Options
storage: Storage
parent: ParentConfig<NodeConfig<Options>>["tableRole"]
}) => string)
}
}
export default Node.create({
name: "table",
addOptions() {
return {
HTMLAttributes: {},
resizable: true,
handleWidth: 5,
cellMinWidth: 100,
lastColumnResizable: true,
allowTableNodeSelection: true
}
},
content: "tableRow+",
tableRole: "table",
isolating: true,
group: "block",
allowGapCursor: false,
parseHTML() {
return [{ tag: "table" }]
},
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
)
if (dispatch) {
const offset = tr.selection.anchor + 1
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 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
)
// @ts-ignore
tr.setSelection(selection)
}
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
})
)
}
return plugins
},
extendNodeSchema(extension) {
const context = {
name: extension.name,
options: extension.options,
storage: extension.storage
}
return {
tableRole: callOrReturn(
getExtensionField(extension, "tableRole", context)
)
}
}
})

View File

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

View File

@@ -0,0 +1,45 @@
import { Fragment, Node as ProsemirrorNode, Schema } from "@tiptap/pm/model"
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>
): 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)
if (cell) {
cells.push(cell)
}
if (withHeaderRow) {
const headerCell = createCell(types.header_cell, cellContent)
if (headerCell) {
headerCells.push(headerCell)
}
}
}
const rows: ProsemirrorNode[] = []
for (let index = 0; index < rowsCount; index += 1) {
rows.push(
types.row.createChecked(
null,
withHeaderRow && index === 0 ? headerCells : cells
)
)
}
return types.table.createChecked(null, rows)
}

View File

@@ -0,0 +1,39 @@
import { findParentNodeClosestToPos, KeyboardShortcutCommand } from "@tiptap/core"
import { isCellSelection } from "./is-cell-selection"
export const deleteTableWhenAllCellsSelected: KeyboardShortcutCommand = ({
editor
}) => {
const { selection } = editor.state
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
}
if (["tableCell", "tableHeader"].includes(node.type.name)) {
cellCount += 1
}
})
const allCellsSelected = cellCount === selection.ranges.length
if (!allCellsSelected) {
return false
}
editor.commands.deleteTable()
return true
}

View File

@@ -0,0 +1,21 @@
import { NodeType, Schema } from "prosemirror-model"
export function getTableNodeTypes(schema: Schema): { [key: string]: NodeType } {
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
}
})
schema.cached.tableNodeTypes = roles
return roles
}

View File

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

View File

@@ -12,6 +12,7 @@ import { EditorProps } from "@tiptap/pm/view";
import { getTrimmedHTML } from "../../lib/utils";
import { UploadImage } from "../../types/upload-image";
import { useInitializedContent } from "./useInitializedContent";
import { IMentionSuggestion } from "../../types/mention-suggestion";
interface CustomEditorProps {
uploadFile: UploadImage;
@@ -26,6 +27,8 @@ interface CustomEditorProps {
extensions?: any;
editorProps?: EditorProps;
forwardedRef?: any;
mentionHighlights?: string[];
mentionSuggestions?: IMentionSuggestion[];
}
export const useEditor = ({
@@ -38,6 +41,8 @@ export const useEditor = ({
setIsSubmitting,
forwardedRef,
setShouldShowAlert,
mentionHighlights,
mentionSuggestions
}: CustomEditorProps) => {
const editor = useCustomEditor(
{
@@ -45,7 +50,7 @@ export const useEditor = ({
...CoreEditorProps(uploadFile, setIsSubmitting),
...editorProps,
},
extensions: [...CoreEditorExtensions(deleteFile), ...extensions],
extensions: [...CoreEditorExtensions({ mentionSuggestions: mentionSuggestions ?? [], mentionHighlights: mentionHighlights ?? []}, deleteFile), ...extensions],
content:
typeof value === "string" && value.trim() !== "" ? value : "<p></p>",
onUpdate: async ({ editor }) => {
@@ -77,4 +82,4 @@ export const useEditor = ({
}
return editor;
};
};

View File

@@ -7,21 +7,19 @@ 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 {
value: string;
forwardedRef?: any;
extensions?: any;
editorProps?: EditorProps;
mentionHighlights?: string[];
mentionSuggestions?: IMentionSuggestion[];
}
export const useReadOnlyEditor = ({
value,
forwardedRef,
extensions = [],
editorProps = {},
}: CustomReadOnlyEditorProps) => {
export const useReadOnlyEditor = ({ value, forwardedRef, extensions = [], editorProps = {}, mentionHighlights, mentionSuggestions}: CustomReadOnlyEditorProps) => {
const editor = useCustomEditor({
editable: false,
content:
@@ -30,7 +28,7 @@ export const useReadOnlyEditor = ({
...CoreReadOnlyEditorProps,
...editorProps,
},
extensions: [...CoreReadOnlyEditorExtensions, ...extensions],
extensions: [...CoreReadOnlyEditorExtensions({ mentionSuggestions: mentionSuggestions ?? [], mentionHighlights: mentionHighlights ?? []}), ...extensions],
});
const hasIntiliazedContent = useRef(false);

View File

@@ -1,13 +1,14 @@
"use client"
import * as React from 'react';
"use client";
import * as React from "react";
import { Extension } from "@tiptap/react";
import { UploadImage } from '../types/upload-image';
import { DeleteImage } from '../types/delete-image';
import { getEditorClassNames } from '../lib/utils';
import { EditorProps } from '@tiptap/pm/view';
import { useEditor } from './hooks/useEditor';
import { EditorContainer } from '../ui/components/editor-container';
import { EditorContentWrapper } from '../ui/components/editor-content';
import { UploadImage } from "../types/upload-image";
import { DeleteImage } from "../types/delete-image";
import { getEditorClassNames } from "../lib/utils";
import { EditorProps } from "@tiptap/pm/view";
import { useEditor } from "./hooks/useEditor";
import { EditorContainer } from "../ui/components/editor-container";
import { EditorContentWrapper } from "../ui/components/editor-content";
import { IMentionSuggestion } from "../types/mention-suggestion";
interface ICoreEditor {
value: string;
@@ -18,7 +19,9 @@ interface ICoreEditor {
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;
editable?: boolean;
forwardedRef?: any;
@@ -30,6 +33,8 @@ interface ICoreEditor {
key: string;
label: "Private" | "Public";
}[];
mentionHighlights?: string[];
mentionSuggestions?: IMentionSuggestion[];
extensions?: Extension[];
editorProps?: EditorProps;
}
@@ -61,7 +66,6 @@ const CoreEditor = ({
const editor = useEditor({
onChange,
debouncedUpdatesEnabled,
editable,
setIsSubmitting,
setShouldShowAlert,
value,
@@ -70,22 +74,29 @@ const CoreEditor = ({
forwardedRef,
});
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 CoreEditorWithRef = React.forwardRef<EditorHandle, ICoreEditor>((props, ref) => (
<CoreEditor {...props} forwardedRef={ref} />
));
const CoreEditorWithRef = React.forwardRef<EditorHandle, ICoreEditor>(
(props, ref) => <CoreEditor {...props} forwardedRef={ref} />,
);
CoreEditorWithRef.displayName = "CoreEditorWithRef";

View File

@@ -0,0 +1,120 @@
import { Editor } from "@tiptap/react";
import React, {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useState,
} from "react";
import { IMentionSuggestion } from "../../types/mention-suggestion";
interface MentionListProps {
items: IMentionSuggestion[];
command: (item: {
id: string;
label: string;
target: string;
redirect_uri: string;
}) => void;
editor: Editor;
}
// eslint-disable-next-line react/display-name
const MentionList = forwardRef((props: MentionListProps, ref) => {
const [selectedIndex, setSelectedIndex] = useState(0);
const selectItem = (index: number) => {
const item = props.items[index];
if (item) {
props.command({
id: item.id,
label: item.title,
target: "users",
redirect_uri: item.redirect_uri,
});
}
};
const upHandler = () => {
setSelectedIndex(
(selectedIndex + props.items.length - 1) % props.items.length,
);
};
const downHandler = () => {
setSelectedIndex((selectedIndex + 1) % props.items.length);
};
const enterHandler = () => {
selectItem(selectedIndex);
};
useEffect(() => {
setSelectedIndex(0);
}, [props.items]);
useImperativeHandle(ref, () => ({
onKeyDown: ({ event }: { event: KeyboardEvent }) => {
if (event.key === "ArrowUp") {
upHandler();
return true;
}
if (event.key === "ArrowDown") {
downHandler();
return true;
}
if (event.key === "Enter") {
enterHandler();
return true;
}
return false;
},
}));
return props.items && props.items.length !== 0 ? (
<div className="mentions absolute max-h-40 bg-custom-background-100 rounded-md shadow-custom-shadow-sm text-custom-text-300 text-sm overflow-y-auto w-48 p-1 space-y-0.5">
{props.items.length ? (
props.items.map((item, index) => (
<div
key={item.id}
className={`flex items-center gap-2 rounded p-1 hover:bg-custom-background-80 cursor-pointer ${
index === selectedIndex ? "bg-custom-background-80" : ""
}`}
onClick={() => selectItem(index)}
>
<div className="flex-shrink-0 h-4 w-4 grid place-items-center overflow-hidden">
{item.avatar && item.avatar.trim() !== "" ? (
<img
src={item.avatar}
className="h-full w-full object-cover rounded-sm"
alt={item.title}
/>
) : (
<div className="h-full w-full grid place-items-center text-xs capitalize text-white rounded-sm bg-gray-700">
{item.title[0]}
</div>
)}
</div>
<div className="flex-grow space-y-1 truncate">
<p className="text-sm font-medium truncate">{item.title}</p>
{/* <p className="text-xs text-gray-400">{item.subtitle}</p> */}
</div>
</div>
))
) : (
<div className="item">No result</div>
)}
</div>
) : (
<></>
);
});
MentionList.displayName = "MentionList";
export default MentionList;

View File

@@ -0,0 +1,55 @@
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
}
export const CustomMention = Mention.extend<CustomMentionOptions>({
addAttributes() {
return {
id: {
default: null,
},
label: {
default: null,
},
target: {
default: null,
},
self: {
default: false
},
redirect_uri: {
default: "/"
}
}
},
addNodeView() {
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')
}
},
}]
},
renderHTML({ HTMLAttributes }) {
return ['mention-component', mergeAttributes(HTMLAttributes)]
},
})

View File

@@ -0,0 +1,15 @@
// @ts-nocheck
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),
})

View File

@@ -0,0 +1,41 @@
/* eslint-disable react/display-name */
// @ts-nocheck
import { NodeViewWrapper } from "@tiptap/react";
import { cn } from "../../lib/utils";
import { useRouter } from "next/router";
import { IMentionHighlight } from "../../types/mention-suggestion";
// eslint-disable-next-line import/no-anonymous-default-export
export default (props) => {
const router = useRouter();
const highlights = props.extension.options
.mentionHighlights as IMentionHighlight[];
const handleClick = () => {
if (!props.extension.options.readonly) {
router.push(props.node.attrs.redirect_uri);
}
};
return (
<NodeViewWrapper className="w-fit inline mention-component">
<span
className={cn(
"px-1 py-0.5 bg-custom-primary-100/20 text-custom-primary-100 rounded font-medium mention",
{
"text-yellow-500 bg-yellow-500/20": highlights
? highlights.includes(props.node.attrs.id)
: false,
"cursor-pointer": !props.extension.options.readonly,
// "hover:bg-custom-primary-300" : !props.extension.options.readonly && !highlights.includes(props.node.attrs.id)
},
)}
onClick={handleClick}
data-mention-target={props.node.attrs.target}
data-mention-id={props.node.attrs.id}
>
@{props.node.attrs.label}
</span>
</NodeViewWrapper>
);
};

View File

@@ -0,0 +1,59 @@
import { ReactRenderer } from '@tiptap/react'
import { Editor } from "@tiptap/core";
import tippy from 'tippy.js'
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),
render: () => {
let reactRenderer: ReactRenderer | null = null;
let popup: any | null = null;
return {
onStart: (props: { editor: Editor; clientRect: DOMRect }) => {
reactRenderer = new ReactRenderer(MentionList, {
props,
editor: props.editor,
});
// @ts-ignore
popup = tippy("body", {
getReferenceClientRect: props.clientRect,
appendTo: () => document.querySelector("#editor-container"),
content: reactRenderer.element,
showOnCreate: true,
interactive: true,
trigger: "manual",
placement: "bottom-start",
});
},
onUpdate: (props: { editor: Editor; clientRect: DOMRect }) => {
reactRenderer?.updateProps(props)
popup &&
popup[0].setProps({
getReferenceClientRect: props.clientRect,
});
},
onKeyDown: (props: { event: KeyboardEvent }) => {
if (props.event.key === "Escape") {
popup?.[0].hide();
return true;
}
// @ts-ignore
return reactRenderer?.ref?.onKeyDown(props);
},
onExit: () => {
popup?.[0].destroy();
reactRenderer?.destroy()
},
}
},
})
export default Suggestion;

View File

@@ -1,7 +1,37 @@
import { BoldIcon, Heading1, CheckSquare, Heading2, Heading3, QuoteIcon, ImageIcon, TableIcon, ListIcon, ListOrderedIcon, ItalicIcon, UnderlineIcon, StrikethroughIcon, CodeIcon } from "lucide-react";
import {
BoldIcon,
Heading1,
CheckSquare,
Heading2,
Heading3,
QuoteIcon,
ImageIcon,
TableIcon,
ListIcon,
ListOrderedIcon,
ItalicIcon,
UnderlineIcon,
StrikethroughIcon,
CodeIcon,
} from "lucide-react";
import { Editor } from "@tiptap/react";
import { UploadImage } from "../../../types/upload-image";
import { insertImageCommand, insertTableCommand, toggleBlockquote, toggleBold, toggleBulletList, toggleCode, toggleHeadingOne, toggleHeadingThree, toggleHeadingTwo, toggleItalic, toggleOrderedList, toggleStrike, toggleTaskList, toggleUnderline, } from "../../../lib/editor-commands";
import {
insertImageCommand,
insertTableCommand,
toggleBlockquote,
toggleBold,
toggleBulletList,
toggleCode,
toggleHeadingOne,
toggleHeadingThree,
toggleHeadingTwo,
toggleItalic,
toggleOrderedList,
toggleStrike,
toggleTaskList,
toggleUnderline,
} from "../../../lib/editor-commands";
export interface EditorMenuItem {
name: string;
@@ -15,95 +45,101 @@ export const HeadingOneItem = (editor: Editor): EditorMenuItem => ({
isActive: () => editor.isActive("heading", { level: 1 }),
command: () => toggleHeadingOne(editor),
icon: Heading1,
})
});
export const HeadingTwoItem = (editor: Editor): EditorMenuItem => ({
name: "H2",
isActive: () => editor.isActive("heading", { level: 2 }),
command: () => toggleHeadingTwo(editor),
icon: Heading2,
})
});
export const HeadingThreeItem = (editor: Editor): EditorMenuItem => ({
name: "H3",
isActive: () => editor.isActive("heading", { level: 3 }),
command: () => toggleHeadingThree(editor),
icon: Heading3,
})
});
export const BoldItem = (editor: Editor): EditorMenuItem => ({
name: "bold",
isActive: () => editor?.isActive("bold"),
command: () => toggleBold(editor),
icon: BoldIcon,
})
});
export const ItalicItem = (editor: Editor): EditorMenuItem => ({
name: "italic",
isActive: () => editor?.isActive("italic"),
command: () => toggleItalic(editor),
icon: ItalicIcon,
})
});
export const UnderLineItem = (editor: Editor): EditorMenuItem => ({
name: "underline",
isActive: () => editor?.isActive("underline"),
command: () => toggleUnderline(editor),
icon: UnderlineIcon,
})
});
export const StrikeThroughItem = (editor: Editor): EditorMenuItem => ({
name: "strike",
isActive: () => editor?.isActive("strike"),
command: () => toggleStrike(editor),
icon: StrikethroughIcon,
})
});
export const CodeItem = (editor: Editor): EditorMenuItem => ({
name: "code",
isActive: () => editor?.isActive("code"),
command: () => toggleCode(editor),
icon: CodeIcon,
})
});
export const BulletListItem = (editor: Editor): EditorMenuItem => ({
name: "bullet-list",
isActive: () => editor?.isActive("bulletList"),
command: () => toggleBulletList(editor),
icon: ListIcon,
})
});
export const TodoListItem = (editor: Editor): EditorMenuItem => ({
name: "To-do List",
isActive: () => editor.isActive("taskItem"),
command: () => toggleTaskList(editor),
icon: CheckSquare,
})
});
export const NumberedListItem = (editor: Editor): EditorMenuItem => ({
name: "ordered-list",
isActive: () => editor?.isActive("orderedList"),
command: () => toggleOrderedList(editor),
icon: ListOrderedIcon
})
icon: ListOrderedIcon,
});
export const QuoteItem = (editor: Editor): EditorMenuItem => ({
name: "quote",
isActive: () => editor?.isActive("quote"),
command: () => toggleBlockquote(editor),
icon: QuoteIcon
})
icon: QuoteIcon,
});
export const TableItem = (editor: Editor): EditorMenuItem => ({
name: "quote",
name: "table",
isActive: () => editor?.isActive("table"),
command: () => insertTableCommand(editor),
icon: TableIcon
})
icon: TableIcon,
});
export const ImageItem = (editor: Editor, uploadFile: UploadImage, setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void): EditorMenuItem => ({
export const ImageItem = (
editor: Editor,
uploadFile: UploadImage,
setIsSubmitting?: (
isSubmitting: "submitting" | "submitted" | "saved",
) => void,
): EditorMenuItem => ({
name: "image",
isActive: () => editor?.isActive("image"),
command: () => insertImageCommand(editor, uploadFile, setIsSubmitting),
icon: ImageIcon,
})
});

View File

@@ -1,120 +0,0 @@
import { useState, useEffect } from "react";
import { Rows, Columns, ToggleRight } from "lucide-react";
import InsertLeftTableIcon from "./InsertLeftTableIcon";
import InsertRightTableIcon from "./InsertRightTableIcon";
import InsertTopTableIcon from "./InsertTopTableIcon";
import InsertBottomTableIcon from "./InsertBottomTableIcon";
import { cn, findTableAncestor } from "../../../lib/utils";
import { Tooltip } from "./tooltip";
interface TableMenuItem {
command: () => void;
icon: any;
key: string;
name: string;
}
export const TableMenu = ({ editor }: { editor: any }) => {
const [tableLocation, setTableLocation] = useState({ bottom: 0, left: 0 });
const isOpen = editor?.isActive("table");
const items: TableMenuItem[] = [
{
command: () => editor.chain().focus().addColumnBefore().run(),
icon: InsertLeftTableIcon,
key: "insert-column-left",
name: "Insert 1 column left",
},
{
command: () => editor.chain().focus().addColumnAfter().run(),
icon: InsertRightTableIcon,
key: "insert-column-right",
name: "Insert 1 column right",
},
{
command: () => editor.chain().focus().addRowBefore().run(),
icon: InsertTopTableIcon,
key: "insert-row-above",
name: "Insert 1 row above",
},
{
command: () => editor.chain().focus().addRowAfter().run(),
icon: InsertBottomTableIcon,
key: "insert-row-below",
name: "Insert 1 row below",
},
{
command: () => editor.chain().focus().deleteColumn().run(),
icon: Columns,
key: "delete-column",
name: "Delete column",
},
{
command: () => editor.chain().focus().deleteRow().run(),
icon: Rows,
key: "delete-row",
name: "Delete row",
},
{
command: () => editor.chain().focus().toggleHeaderRow().run(),
icon: ToggleRight,
key: "toggle-header-row",
name: "Toggle header row",
},
];
useEffect(() => {
if (!window) return;
const handleWindowClick = () => {
const selection: any = window?.getSelection();
if (selection.rangeCount !== 0) {
const range = selection.getRangeAt(0);
const tableNode = findTableAncestor(range.startContainer);
if (tableNode) {
const tableRect = tableNode.getBoundingClientRect();
const tableCenter = tableRect.left + tableRect.width / 2;
const menuWidth = 45;
const menuLeft = tableCenter - menuWidth / 2;
const tableBottom = tableRect.bottom;
setTableLocation({ bottom: tableBottom, left: menuLeft });
}
}
};
window.addEventListener("click", handleWindowClick);
return () => {
window.removeEventListener("click", handleWindowClick);
};
}, [tableLocation, editor]);
return (
<section
className={`absolute z-20 left-1/2 -translate-x-1/2 overflow-hidden rounded border border-custom-border-300 bg-custom-background-100 shadow-custom-shadow-sm p-1 ${
isOpen ? "block" : "hidden"
}`}
>
{items.map((item, index) => (
<Tooltip key={index} tooltipContent={item.name}>
<button
onClick={item.command}
className="p-1.5 text-custom-text-200 hover:bg-text-custom-text-100 hover:bg-custom-background-80 active:bg-custom-background-80 rounded"
title={item.name}
>
<item.icon
className={cn("h-4 w-4 text-lg", {
"text-red-600": item.key.includes("delete"),
})}
/>
</button>
</Tooltip>
))}
</section>
);
};

View File

@@ -8,15 +8,19 @@ import TaskList from "@tiptap/extension-task-list";
import { Markdown } from "tiptap-markdown";
import Gapcursor from "@tiptap/extension-gapcursor";
import { CustomTableCell } from "../extensions/table/table-cell";
import { Table } from "../extensions/table";
import { TableHeader } from "../extensions/table/table-header";
import { TableRow } from "@tiptap/extension-table-row";
import TableHeader from "../extensions/table/table-header/table-header";
import Table from "../extensions/table/table";
import TableCell from "../extensions/table/table-cell/table-cell";
import TableRow from "../extensions/table/table-row/table-row";
import ReadOnlyImageExtension from "../extensions/image/read-only-image";
import { isValidHttpUrl } from "../../lib/utils";
import { Mentions } from "../mentions";
import { IMentionSuggestion } from "../../types/mention-suggestion";
export const CoreReadOnlyEditorExtensions = [
export const CoreReadOnlyEditorExtensions = (
mentionConfig: { mentionSuggestions: IMentionSuggestion[], mentionHighlights: string[] },
) => [
StarterKit.configure({
bulletList: {
HTMLAttributes: {
@@ -87,6 +91,7 @@ export const CoreReadOnlyEditorExtensions = [
}),
Table,
TableHeader,
CustomTableCell,
TableCell,
TableRow,
Mentions(mentionConfig.mentionSuggestions, mentionConfig.mentionHighlights, true),
];

View File

@@ -2,6 +2,7 @@
"name": "@plane/lite-text-editor",
"version": "0.0.1",
"description": "Package that powers Plane's Comment Editor",
"private": true,
"main": "./dist/index.mjs",
"module": "./dist/index.mjs",
"types": "./dist/index.d.mts",
@@ -29,9 +30,6 @@
"dependencies": {
"@plane/editor-core": "*",
"@tiptap/extension-list-item": "^2.1.11",
"@types/node": "18.15.3",
"@types/react": "^18.2.5",
"@types/react-dom": "18.0.11",
"class-variance-authority": "^0.7.0",
"clsx": "^1.2.1",
"eslint": "8.36.0",
@@ -46,6 +44,9 @@
"use-debounce": "^9.0.4"
},
"devDependencies": {
"@types/node": "18.15.3",
"@types/react": "^18.2.35",
"@types/react-dom": "^18.2.14",
"eslint": "^7.32.0",
"postcss": "^8.4.29",
"tailwind-config-custom": "*",

View File

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

View File

@@ -1,5 +1,5 @@
import { EnterKeyExtension } from "./enter-key-extension";
export const LiteTextEditorExtensions = (onEnterKeyPress?: () => void) => [
EnterKeyExtension(onEnterKeyPress),
// EnterKeyExtension(onEnterKeyPress),
];

View File

@@ -1,4 +1,3 @@
"use client";
import * as React from "react";
import {
EditorContainer,
@@ -11,6 +10,16 @@ import { LiteTextEditorExtensions } from "./extensions";
export type UploadImage = (file: File) => Promise<string>;
export type DeleteImage = (assetUrlWithWorkspaceId: string) => Promise<any>;
export type IMentionSuggestion = {
id: string;
type: string;
avatar: string;
title: string;
subtitle: string;
redirect_uri: string;
};
export type IMentionHighlight = string;
interface ILiteTextEditor {
value: string;
@@ -38,6 +47,9 @@ interface ILiteTextEditor {
}[];
};
onEnterKeyPress?: (e?: any) => void;
mentionHighlights?: string[];
mentionSuggestions?: IMentionSuggestion[];
submitButton?: React.ReactNode;
}
interface LiteTextEditorProps extends ILiteTextEditor {
@@ -49,22 +61,27 @@ interface EditorHandle {
setEditorValue: (content: string) => void;
}
const LiteTextEditor = ({
onChange,
debouncedUpdatesEnabled,
setIsSubmitting,
setShouldShowAlert,
editorContentCustomClassNames,
value,
uploadFile,
deleteFile,
noBorder,
borderOnFocus,
customClassName,
forwardedRef,
commentAccessSpecifier,
onEnterKeyPress,
}: LiteTextEditorProps) => {
const LiteTextEditor = (props: LiteTextEditorProps) => {
const {
onChange,
debouncedUpdatesEnabled,
setIsSubmitting,
setShouldShowAlert,
editorContentCustomClassNames,
value,
uploadFile,
deleteFile,
noBorder,
borderOnFocus,
customClassName,
forwardedRef,
commentAccessSpecifier,
onEnterKeyPress,
mentionHighlights,
mentionSuggestions,
submitButton,
} = props;
const editor = useEditor({
onChange,
debouncedUpdatesEnabled,
@@ -75,6 +92,8 @@ const LiteTextEditor = ({
deleteFile,
forwardedRef,
extensions: LiteTextEditorExtensions(onEnterKeyPress),
mentionHighlights,
mentionSuggestions,
});
const editorClassNames = getEditorClassNames({
@@ -98,6 +117,7 @@ const LiteTextEditor = ({
uploadFile={uploadFile}
setIsSubmitting={setIsSubmitting}
commentAccessSpecifier={commentAccessSpecifier}
submitButton={submitButton}
/>
</div>
</div>

View File

@@ -1,5 +1,5 @@
import { Editor } from "@tiptap/react";
import { BoldIcon, LucideIcon } from "lucide-react";
import { BoldIcon } from "lucide-react";
import {
BoldItem,
@@ -14,7 +14,6 @@ import {
TableItem,
UnderLineItem,
} from "@plane/editor-core";
import { Icon } from "./icon";
import { Tooltip } from "../../tooltip";
import { UploadImage } from "../..";
@@ -41,8 +40,9 @@ type EditorBubbleMenuProps = {
};
uploadFile: UploadImage;
setIsSubmitting?: (
isSubmitting: "submitting" | "submitted" | "saved"
isSubmitting: "submitting" | "submitted" | "saved",
) => void;
submitButton: React.ReactNode;
};
export const FixedMenu = (props: EditorBubbleMenuProps) => {
@@ -73,115 +73,145 @@ export const FixedMenu = (props: EditorBubbleMenuProps) => {
};
return (
<div className="flex w-fit divide-x divide-custom-border-300 rounded border border-custom-border-300 bg-custom-background-100 shadow-xl">
<div className="flex items-stretch gap-1.5 w-full h-9 overflow-x-scroll">
{props.commentAccessSpecifier && (
<div className="flex border border-custom-border-300 mt-0 divide-x divide-custom-border-300 rounded overflow-hidden">
<div className="flex-shrink-0 flex items-stretch gap-0.5 border-[0.5px] border-custom-border-200 rounded p-1">
{props?.commentAccessSpecifier.commentAccess?.map((access) => (
<Tooltip key={access.key} tooltipContent={access.label}>
<button
type="button"
onClick={() => handleAccessChange(access.key)}
className={`grid place-basicMarkItems-center p-1 hover:bg-custom-background-80 ${
className={`aspect-square grid place-items-center p-1 rounded-sm hover:bg-custom-background-90 ${
props.commentAccessSpecifier?.accessValue === access.key
? "bg-custom-background-80"
? "bg-custom-background-90"
: ""
}`}
>
<access.icon
className={`w-4 h-4 ${
className={`w-3.5 h-3.5 ${
props.commentAccessSpecifier?.accessValue === access.key
? "!text-custom-text-100"
: "!text-custom-text-400"
? "text-custom-text-100"
: "text-custom-text-400"
}`}
strokeWidth={2}
/>
</button>
</Tooltip>
))}
</div>
)}
<div className="flex">
{basicMarkItems.map((item, index) => (
<button
key={index}
type="button"
onClick={item.command}
className={cn(
"p-2 text-custom-text-300 hover:bg-custom-primary-100/5 active:bg-custom-primary-100/5 transition-colors",
{
"text-custom-text-100 bg-custom-primary-100/5": item.isActive(),
}
)}
>
<item.icon
className={cn("h-4 w-4", {
"text-custom-text-100": item.isActive(),
})}
/>
</button>
))}
</div>
<div className="flex">
{listItems.map((item, index) => (
<button
key={index}
type="button"
onClick={item.command}
className={cn(
"p-2 text-custom-text-300 hover:bg-custom-primary-100/5 active:bg-custom-primary-100/5 transition-colors",
{
"text-custom-text-100 bg-custom-primary-100/5": item.isActive(),
}
)}
>
<item.icon
className={cn("h-4 w-4", {
"text-custom-text-100": item.isActive(),
})}
/>
</button>
))}
</div>
<div className="flex">
{userActionItems.map((item, index) => (
<button
key={index}
type="button"
onClick={item.command}
className={cn(
"p-2 text-custom-text-300 hover:bg-custom-primary-100/5 active:bg-custom-primary-100/5 transition-colors",
{
"text-custom-text-100 bg-custom-primary-100/5": item.isActive(),
}
)}
>
<item.icon
className={cn("h-4 w-4", {
"text-custom-text-100": item.isActive(),
})}
/>
</button>
))}
</div>
<div className="flex">
{complexItems.map((item, index) => (
<button
key={index}
type="button"
onClick={item.command}
className={cn(
"p-2 text-custom-text-300 hover:bg-custom-primary-100/5 active:bg-custom-primary-100/5 transition-colors",
{
"text-custom-text-100 bg-custom-primary-100/5": item.isActive(),
}
)}
>
<item.icon
className={cn("h-4 w-4", {
"text-custom-text-100": item.isActive(),
})}
/>
</button>
))}
<div className="flex items-stretch justify-between gap-2 w-full border-[0.5px] border-custom-border-200 bg-custom-background-90 rounded p-1">
<div className="flex items-stretch">
<div className="flex items-stretch gap-0.5 pr-2.5 border-r border-custom-border-200">
{basicMarkItems.map((item, index) => (
<Tooltip
key={index}
tooltipContent={<span className="capitalize">{item.name}</span>}
>
<button
type="button"
onClick={item.command}
className={cn(
"p-1 aspect-square text-custom-text-400 hover:bg-custom-background-80 rounded-sm grid place-items-center",
{
"text-custom-text-100 bg-custom-background-80":
item.isActive(),
},
)}
>
<item.icon
className={cn("h-3.5 w-3.5", {
"text-custom-text-100": item.isActive(),
})}
strokeWidth={2.5}
/>
</button>
</Tooltip>
))}
</div>
<div className="flex items-stretch gap-0.5 px-2.5 border-r border-custom-border-200">
{listItems.map((item, index) => (
<Tooltip
key={index}
tooltipContent={<span className="capitalize">{item.name}</span>}
>
<button
type="button"
onClick={item.command}
className={cn(
"p-1 aspect-square text-custom-text-400 hover:bg-custom-background-80 rounded-sm grid place-items-center",
{
"text-custom-text-100 bg-custom-background-80":
item.isActive(),
},
)}
>
<item.icon
className={cn("h-3.5 w-3.5", {
"text-custom-text-100": item.isActive(),
})}
strokeWidth={2.5}
/>
</button>
</Tooltip>
))}
</div>
<div className="flex items-stretch gap-0.5 px-2.5 border-r border-custom-border-200">
{userActionItems.map((item, index) => (
<Tooltip
key={index}
tooltipContent={<span className="capitalize">{item.name}</span>}
>
<button
type="button"
onClick={item.command}
className={cn(
"p-1 aspect-square text-custom-text-400 hover:bg-custom-background-80 rounded-sm grid place-items-center",
{
"text-custom-text-100 bg-custom-background-80":
item.isActive(),
},
)}
>
<item.icon
className={cn("h-3.5 w-3.5", {
"text-custom-text-100": item.isActive(),
})}
strokeWidth={2.5}
/>
</button>
</Tooltip>
))}
</div>
<div className="flex items-stretch gap-0.5 pl-2.5">
{complexItems.map((item, index) => (
<Tooltip
key={index}
tooltipContent={<span className="capitalize">{item.name}</span>}
>
<button
type="button"
onClick={item.command}
className={cn(
"p-1 aspect-square text-custom-text-400 hover:bg-custom-background-80 rounded-sm grid place-items-center",
{
"text-custom-text-100 bg-custom-background-80":
item.isActive(),
},
)}
>
<item.icon
className={cn("h-3.5 w-3.5", {
"text-custom-text-100": item.isActive(),
})}
strokeWidth={2.5}
/>
</button>
</Tooltip>
))}
</div>
</div>
<div className="sticky right-1">{props.submitButton}</div>
</div>
</div>
);

View File

@@ -1,6 +1,10 @@
"use client"
import { EditorContainer, EditorContentWrapper, getEditorClassNames, useReadOnlyEditor } from '@plane/editor-core';
import * as React from 'react';
import * as React from "react";
import {
EditorContainer,
EditorContentWrapper,
getEditorClassNames,
useReadOnlyEditor,
} from "@plane/editor-core";
interface ICoreReadOnlyEditor {
value: string;
@@ -8,6 +12,7 @@ interface ICoreReadOnlyEditor {
noBorder?: boolean;
borderOnFocus?: boolean;
customClassName?: string;
mentionHighlights: string[];
}
interface EditorCoreProps extends ICoreReadOnlyEditor {
@@ -26,29 +31,39 @@ const LiteReadOnlyEditor = ({
customClassName,
value,
forwardedRef,
mentionHighlights,
}: EditorCoreProps) => {
const editor = useReadOnlyEditor({
value,
forwardedRef,
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 LiteReadOnlyEditorWithRef = React.forwardRef<EditorHandle, ICoreReadOnlyEditor>((props, ref) => (
<LiteReadOnlyEditor {...props} forwardedRef={ref} />
));
const LiteReadOnlyEditorWithRef = React.forwardRef<
EditorHandle,
ICoreReadOnlyEditor
>((props, ref) => <LiteReadOnlyEditor {...props} forwardedRef={ref} />);
LiteReadOnlyEditorWithRef.displayName = "LiteReadOnlyEditorWithRef";
export { LiteReadOnlyEditor , LiteReadOnlyEditorWithRef };
export { LiteReadOnlyEditor, LiteReadOnlyEditorWithRef };

View File

@@ -1,5 +1,4 @@
import * as React from 'react';
import * as React from "react";
// next-themes
import { useTheme } from "next-themes";
// tooltip2
@@ -69,8 +68,16 @@ export const Tooltip: React.FC<Props> = ({
</div>
}
position={position}
renderTarget={({ isOpen: isTooltipOpen, ref: eleReference, ...tooltipProps }) =>
React.cloneElement(children, { ref: eleReference, ...tooltipProps, ...children.props })
renderTarget={({
isOpen: isTooltipOpen,
ref: eleReference,
...tooltipProps
}) =>
React.cloneElement(children, {
ref: eleReference,
...tooltipProps,
...children.props,
})
}
/>
);

View File

@@ -2,6 +2,7 @@
"name": "@plane/rich-text-editor",
"version": "0.0.1",
"description": "Rich Text Editor that powers Plane",
"private": true,
"main": "./dist/index.mjs",
"module": "./dist/index.mjs",
"types": "./dist/index.d.mts",
@@ -21,19 +22,19 @@
"check-types": "tsc --noEmit"
},
"peerDependencies": {
"@tiptap/core": "^2.1.11",
"next": "12.3.2",
"next-themes": "^0.2.1",
"react": "^18.2.0",
"react-dom": "18.2.0",
"@tiptap/core": "^2.1.11"
"react-dom": "18.2.0"
},
"dependencies": {
"@plane/editor-core": "*",
"@tiptap/extension-code-block-lowlight": "^2.1.11",
"@tiptap/extension-horizontal-rule": "^2.1.11",
"@tiptap/extension-placeholder": "^2.1.11",
"class-variance-authority": "^0.7.0",
"@tiptap/suggestion": "^2.1.7",
"class-variance-authority": "^0.7.0",
"clsx": "^1.2.1",
"highlight.js": "^11.8.0",
"lowlight": "^3.0.0",
@@ -41,8 +42,8 @@
},
"devDependencies": {
"@types/node": "18.15.3",
"@types/react": "^18.2.5",
"@types/react-dom": "18.0.11",
"@types/react": "^18.2.35",
"@types/react-dom": "^18.2.14",
"eslint": "^7.32.0",
"postcss": "^8.4.29",
"react": "^18.2.0",

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