Compare commits

...

43 Commits

Author SHA1 Message Date
Ramesh Kumar Chandra 0aa5a4bdb7 conflict: merge conflicts resolved 2023-11-09 16:28:45 +05:30
rahulramesha d733a53ea6 fix: Add horizontal scroll bar to views (#2736)
* add errors for duplicate labels

* adding horizonatal scroll bar to views

---------

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

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

* dev: update states endpoint

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

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

* fix: states fixes

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

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

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

* fixed table row/column picker behaviour on modals

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

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

* removed uncessary files

* solved lint error of trailing spaces

* added plane/ui dependency for tooltips

---------

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

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

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

* fix: Loading text instead Spinner in peek view

* fix: cycle more popover typo & icon overlapping

* fix: list layout properties alignment

* fix: project search empty state

* fix: calendar layout issue text overflow & redirection inconsistency

* style: urgent priority hover background color

* fix: Cycle issues kanban layout empty state missing

* style: custom snooze modal placeholder text color

* refactor: replaced unwanted anchor tag with div

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

* style: create update issue modal improvement

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

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

* fix: project automation settings flickering
2023-11-08 17:34:42 +05:30
Ankush Deshmukh da799b5a63 Fix: Render bar chart axis labels in lighter color when dark theme applied (#2721) 2023-11-08 17:34:09 +05:30
Dakshesh Jain 621d551c4a fix: project select validation (#2723) 2023-11-08 17:33:26 +05:30
Aaryan Khandelwal 5a84ed279d refactor: replace keyboard events with command palette store (#2688) 2023-11-08 13:51:55 +05:30
Dakshesh Jain 53e7da08e4 fix: quick add not working for labels & assignee (#2689)
* fix: quick add not working for labels & assignee

* fix: build error on spreadsheet view
2023-11-08 12:28:02 +05:30
Ramesh Kumar Chandra de6c574cbd chore: typo changes for the buttons of issues, projects, pages, cycles, modules, sub-issues 2023-11-08 11:29:50 +05:30
Anmol Singh Bhatia 9f206331bc style: spinner component improvement (#2708) 2023-11-07 18:35:05 +05:30
rahulramesha b56d188a83 add errors for duplicate labels (#2706)
Co-authored-by: rahulramesha <rahul@appsmith.com>
2023-11-07 18:22:52 +05:30
Bavisetti Narayan 8d3853b129 fix: issue draft delete functionality (#2696) 2023-11-07 18:19:32 +05:30
Bavisetti Narayan 30d6235108 fix: add issues to cycles and modules (#2659)
* fix: able to add issue in cycle and module

* fix: issue activity message
2023-11-07 18:18:51 +05:30
Prateek Shourya 6e461dd8c3 style: update user profile button alignment. (#2695) 2023-11-07 18:17:44 +05:30
Nikhil 86379c51b7 dev: worker count (#2573)
* dev: workers count

* dev: update the worker count variable to GUNICORN_WORKERS
2023-11-07 18:05:38 +05:30
Manish Gupta f7cc2eca36 dev: Modified the branch-build action yaml (#2704)
* cherrypicked code

* removed PUSH event
2023-11-07 17:41:26 +05:30
Prateek Shourya 63d1ad286b style: update font weight in project general setting section. (#2697) 2023-11-07 17:39:12 +05:30
Manish Gupta 1412c1c94a dev: modified the branch wise build (#2702)
* cherrypicked branch build code

* trigger on pull request

* branch filter

* checking branch filter

* checking push

* checking push again

* code cleanup before PR
2023-11-07 17:23:32 +05:30
sriram veeraghanta 26de35bd8d fix: environment config changes in the API are replicated in web and space app (#2699)
* fix: envconfig type changes

* chore: configuration variables  (#2692)

* chore: update avatar group logic (#2672)

* chore: configuration variables

---------

* fix: replacing slack client id with env config

---------

Co-authored-by: Nikhil <118773738+pablohashescobar@users.noreply.github.com>
2023-11-07 17:17:10 +05:30
Aaryan Khandelwal 1986c0dfd4 fix: state icon (#2678) 2023-11-07 16:15:34 +05:30
Anmol Singh Bhatia 25f3a5b2e4 chore: peek overview improvement and bug fixes (#2683)
* style: issue peek overview improvement

* style: peek overview improvement

* fix: subscribe issue from peek overview fix and validation added

* fix: build error
2023-11-07 15:58:42 +05:30
Anmol Singh Bhatia f30b16e9d8 chore: user profile issue improvement (#2679)
* fix: user profile filters z-index

* chore: user profile issue state group heading fix

* fix: build error
2023-11-07 15:58:19 +05:30
Anmol Singh Bhatia 93d03f82b4 chore: spreadsheet layout improvement (#2677)
* style: spreadsheet column width fix

* style: spreadsheet label column styling

* chore: spreadsheet layout issue properties improvement

* fix: build error
2023-11-07 15:58:00 +05:30
Anmol Singh Bhatia 98974fdc50 fix: cycle and module bug fixes and improvement (#2691)
* fix: cycle and module card issue count fix

* fix: cycle and module list progress icon fix

* fix: module card progress fix

* style: cycle & module empty date label updated

* fix: build error
2023-11-07 15:14:47 +05:30
sriram veeraghanta 040563d148 fix: replacing jira importer image (#2685) 2023-11-07 14:35:04 +05:30
Nikhil 4de64f112f fix: slack project integration (#2684) 2023-11-07 14:34:30 +05:30
Ramesh Kumar Chandra 0afb900678 fix: kanban card state name and drop down items text overflow (#2686) 2023-11-07 14:31:29 +05:30
Prateek Shourya baf17a109b style: update project description as per design. (#2682) 2023-11-07 13:13:14 +05:30
Prateek Shourya 37bf465fcd style: update border across workspace and project settings. (#2669)
* style: update border across workspace and project settings.

* update border width
2023-11-07 13:12:05 +05:30
371 changed files with 3896 additions and 3637 deletions
+24 -16
View File
@@ -1,33 +1,41 @@
name: Docker Branch Build
name: Branch Build
on:
workflow_dispatch:
inputs:
logLevel:
description: 'Log level'
required: true
default: 'warning'
tags:
description: 'Dev/QA Builds'
pull_request:
types:
- closed
branches:
- master
- release
- qa
- develop
env:
gh_branch: ${{ github.ref_name }}
img_tag: latest
TARGET_BRANCH: ${{ github.event.pull_request.base.ref }}
jobs:
branch_build_and_push:
if: ${{ (github.event_name == 'pull_request' && github.event.action =='closed' && github.event.pull_request.merged == true) }}
name: Build-Push Web/Space/API/Proxy Docker Image
runs-on: ubuntu-20.04
steps:
- name: Check out the repo
uses: actions/checkout@v3.3.0
# - name: Set Target Branch Name on PR close
# if: ${{ github.event_name == 'pull_request' && github.event.action =='closed' }}
# run: echo "TARGET_BRANCH=${{ github.event.pull_request.base.ref }}" >> $GITHUB_ENV
# - name: Set Target Branch Name on other than PR close
# if: ${{ github.event_name == 'push' }}
# run: echo "TARGET_BRANCH=${{ github.ref_name }}" >> $GITHUB_ENV
- uses: ASzc/change-string-case-action@v2
id: gh_branch_upper_lower
with:
string: ${{ env.gh_branch }}
string: ${{env.TARGET_BRANCH}}
- uses: mad9000/actions-find-and-replace-string@2
id: gh_branch_replace_slash
@@ -111,7 +119,7 @@ jobs:
env:
DOCKER_BUILDKIT: 1
DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
DOCKET_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
branch_build_push_space:
runs-on: ubuntu-20.04
@@ -141,7 +149,7 @@ jobs:
env:
DOCKER_BUILDKIT: 1
DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
DOCKET_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
branch_build_push_backend:
runs-on: ubuntu-20.04
@@ -171,7 +179,7 @@ jobs:
env:
DOCKER_BUILDKIT: 1
DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
DOCKET_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
branch_build_push_proxy:
runs-on: ubuntu-20.04
@@ -202,4 +210,4 @@ jobs:
env:
DOCKER_BUILDKIT: 1
DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
DOCKET_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }}
+16 -16
View File
@@ -8,8 +8,8 @@ Before submitting a new issue, please search the [issues](https://github.com/mak
While we want to fix all the [issues](https://github.com/makeplane/plane/issues), before fixing a bug we need to be able to reproduce and confirm it. Please provide us with a minimal reproduction scenario using a repository or [Gist](https://gist.github.com/). Having a live, reproducible scenario gives us the information without asking questions back & forth with additional questions like:
- 3rd-party libraries being used and their versions
- a use-case that fails
- 3rd-party libraries being used and their versions
- a use-case that fails
Without said minimal reproduction, we won't be able to investigate all [issues](https://github.com/makeplane/plane/issues), and the issue might not be resolved.
@@ -19,10 +19,10 @@ You can open a new issue with this [issue form](https://github.com/makeplane/pla
### Requirements
- Node.js version v16.18.0
- Python version 3.8+
- Postgres version v14
- Redis version v6.2.7
- Node.js version v16.18.0
- Python version 3.8+
- Postgres version v14
- Redis version v6.2.7
### Setup the project
@@ -81,8 +81,8 @@ If you would like to _implement_ it, an issue with your proposal must be submitt
To ensure consistency throughout the source code, please keep these rules in mind as you are working:
- All features or bug fixes must be tested by one or more specs (unit-tests).
- We use [Eslint default rule guide](https://eslint.org/docs/rules/), with minor changes. An automated formatter is available using prettier.
- All features or bug fixes must be tested by one or more specs (unit-tests).
- We use [Eslint default rule guide](https://eslint.org/docs/rules/), with minor changes. An automated formatter is available using prettier.
## Need help? Questions and suggestions
@@ -90,11 +90,11 @@ Questions, suggestions, and thoughts are most welcome. We can also be reached in
## Ways to contribute
- Try Plane Cloud and the self hosting platform and give feedback
- Add new integrations
- Help with open [issues](https://github.com/makeplane/plane/issues) or [create your own](https://github.com/makeplane/plane/issues/new/choose)
- Share your thoughts and suggestions with us
- Help create tutorials and blog posts
- Request a feature by submitting a proposal
- Report a bug
- **Improve documentation** - fix incomplete or missing [docs](https://docs.plane.so/), bad wording, examples or explanations.
- Try Plane Cloud and the self hosting platform and give feedback
- Add new integrations
- Help with open [issues](https://github.com/makeplane/plane/issues) or [create your own](https://github.com/makeplane/plane/issues/new/choose)
- Share your thoughts and suggestions with us
- Help create tutorials and blog posts
- Request a feature by submitting a proposal
- Report a bug
- **Improve documentation** - fix incomplete or missing [docs](https://docs.plane.so/), bad wording, examples or explanations.
+17 -1
View File
@@ -1,8 +1,10 @@
# Environment Variables
Environment variables are distributed in various files. Please refer them carefully.
Environment variables are distributed in various files. Please refer them carefully.
## {PROJECT_FOLDER}/.env
File is available in the project root folder
```
@@ -41,25 +43,37 @@ USE_MINIO=1
# Nginx Configuration
NGINX_PORT=80
```
## {PROJECT_FOLDER}/web/.env.example
```
# Enable/Disable OAUTH - default 0 for selfhosted instance
NEXT_PUBLIC_ENABLE_OAUTH=0
# Public boards deploy URL
NEXT_PUBLIC_DEPLOY_URL="http://localhost/spaces"
```
## {PROJECT_FOLDER}/spaces/.env.example
```
# Flag to toggle OAuth
NEXT_PUBLIC_ENABLE_OAUTH=0
```
## {PROJECT_FOLDER}/apiserver/.env
```
# Backend
# Debug value for api server use it as 0 for production use
@@ -126,7 +140,9 @@ ENABLE_SIGNUP="1"
# Email Redirection URL
WEB_URL="http://localhost"
```
## Updates
- The environment variable NEXT_PUBLIC_API_BASE_URL has been removed from both the web and space projects.
- The naming convention for containers and images has been updated.
- The plane-worker image will no longer be maintained, as it has been merged with plane-backend.
+3
View File
@@ -70,3 +70,6 @@ ENABLE_MAGIC_LINK_LOGIN="0"
# Email redirections and minio domain settings
WEB_URL="http://localhost"
# Gunicorn Workers
GUNICORN_WORKERS=2
+1 -1
View File
@@ -6,4 +6,4 @@ python manage.py migrate
# Create a Default User
python bin/user_script.py
exec gunicorn -w 8 -k uvicorn.workers.UvicornWorker plane.asgi:application --bind 0.0.0.0:8000 --max-requests 1200 --max-requests-jitter 1000 --access-logfile -
exec gunicorn -w $GUNICORN_WORKERS -k uvicorn.workers.UvicornWorker plane.asgi:application --bind 0.0.0.0:8000 --max-requests 1200 --max-requests-jitter 1000 --access-logfile -
-2
View File
@@ -7,8 +7,6 @@ from plane.db.models import State
class StateSerializer(BaseSerializer):
workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace")
project_detail = ProjectLiteSerializer(read_only=True, source="project")
class Meta:
model = State
+9 -1
View File
@@ -20,11 +20,19 @@ urlpatterns = [
StateViewSet.as_view(
{
"get": "retrieve",
"put": "update",
"patch": "partial_update",
"delete": "destroy",
}
),
name="project-state",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/states/<uuid:pk>/mark-default/",
StateViewSet.as_view(
{
"post": "mark_as_default",
}
),
name="project-state",
),
]
+3 -3
View File
@@ -21,8 +21,8 @@ class ConfigurationEndpoint(BaseAPIView):
def get(self, request):
data = {}
data["google"] = os.environ.get("GOOGLE_CLIENT_ID", None)
data["github"] = os.environ.get("GITHUB_CLIENT_ID", None)
data["google_client_id"] = os.environ.get("GOOGLE_CLIENT_ID", None)
data["github_client_id"] = os.environ.get("GITHUB_CLIENT_ID", None)
data["github_app_name"] = os.environ.get("GITHUB_APP_NAME", None)
data["magic_login"] = (
bool(settings.EMAIL_HOST_USER) and bool(settings.EMAIL_HOST_PASSWORD)
@@ -30,5 +30,5 @@ class ConfigurationEndpoint(BaseAPIView):
data["email_password_login"] = (
os.environ.get("ENABLE_EMAIL_PASSWORD", "0") == "1"
)
data["slack"] = os.environ.get("SLACK_CLIENT_ID", None)
data["slack_client_id"] = os.environ.get("SLACK_CLIENT_ID", None)
return Response(data, status=status.HTTP_200_OK)
+5 -9
View File
@@ -479,13 +479,13 @@ class CycleViewSet(BaseViewSet):
)
)
cycle = Cycle.objects.get(workspace__slug=slug, project_id=project_id, pk=pk)
# Delete the cycle
cycle.delete()
issue_activity.delay(
type="cycle.activity.deleted",
requested_data=json.dumps(
{
"cycle_id": str(pk),
"cycle_name": str(cycle.name),
"issues": [str(issue_id) for issue_id in cycle_issues],
}
),
@@ -495,6 +495,8 @@ class CycleViewSet(BaseViewSet):
current_instance=None,
epoch=int(timezone.now().timestamp()),
)
# Delete the cycle
cycle.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@@ -511,12 +513,6 @@ class CycleIssueViewSet(BaseViewSet):
"issue__assignees__id",
]
def perform_create(self, serializer):
serializer.save(
project_id=self.kwargs.get("project_id"),
cycle_id=self.kwargs.get("cycle_id"),
)
def get_queryset(self):
return self.filter_queryset(
super()
@@ -669,7 +665,7 @@ class CycleIssueViewSet(BaseViewSet):
type="cycle.activity.created",
requested_data=json.dumps({"cycles_list": issues}),
actor_id=str(self.request.user.id),
issue_id=str(self.kwargs.get("pk", None)),
issue_id=None,
project_id=str(self.kwargs.get("project_id", None)),
current_instance=json.dumps(
{
@@ -59,6 +59,7 @@ class SlackProjectSyncViewSet(BaseViewSet):
team_id=slack_response.get("team", {}).get("id"),
team_name=slack_response.get("team", {}).get("name"),
workspace_integration=workspace_integration,
project_id=project_id,
)
_ = ProjectMember.objects.get_or_create(
member=workspace_integration.actor, role=20, project_id=project_id
+1 -1
View File
@@ -2230,7 +2230,7 @@ class IssueDraftViewSet(BaseViewSet):
def destroy(self, request, slug, project_id, pk=None):
issue = Issue.objects.get(workspace__slug=slug, project_id=project_id, pk=pk)
current_instance = json.dumps(
IssueSerializer(current_instance).data, cls=DjangoJSONEncoder
IssueSerializer(issue).data, cls=DjangoJSONEncoder
)
issue.delete()
issue_activity.delay(
+3 -8
View File
@@ -266,12 +266,12 @@ class ModuleViewSet(BaseViewSet):
module_issues = list(
ModuleIssue.objects.filter(module_id=pk).values_list("issue", flat=True)
)
module.delete()
issue_activity.delay(
type="module.activity.deleted",
requested_data=json.dumps(
{
"module_id": str(pk),
"module_name": str(module.name),
"issues": [str(issue_id) for issue_id in module_issues],
}
),
@@ -281,6 +281,7 @@ class ModuleViewSet(BaseViewSet):
current_instance=None,
epoch=int(timezone.now().timestamp()),
)
module.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@@ -297,12 +298,6 @@ class ModuleIssueViewSet(BaseViewSet):
ProjectEntityPermission,
]
def perform_create(self, serializer):
serializer.save(
project_id=self.kwargs.get("project_id"),
module_id=self.kwargs.get("module_id"),
)
def get_queryset(self):
return self.filter_queryset(
super()
@@ -446,7 +441,7 @@ class ModuleIssueViewSet(BaseViewSet):
type="module.activity.created",
requested_data=json.dumps({"modules_list": issues}),
actor_id=str(self.request.user.id),
issue_id=str(self.kwargs.get("pk", None)),
issue_id=None,
project_id=str(self.kwargs.get("project_id", None)),
current_instance=json.dumps(
{
+24 -15
View File
@@ -47,36 +47,45 @@ class StateViewSet(BaseViewSet):
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def list(self, request, slug, project_id):
state_dict = dict()
states = StateSerializer(self.get_queryset(), many=True).data
grouped = request.GET.get("grouped", False)
if grouped == "true":
state_dict = {}
for key, value in groupby(
sorted(states, key=lambda state: state["group"]),
lambda state: state.get("group"),
):
state_dict[str(key)] = list(value)
return Response(state_dict, status=status.HTTP_200_OK)
return Response(states, status=status.HTTP_200_OK)
for key, value in groupby(
sorted(states, key=lambda state: state["group"]),
lambda state: state.get("group"),
):
state_dict[str(key)] = list(value)
return Response(state_dict, status=status.HTTP_200_OK)
def mark_as_default(self, request, slug, project_id, pk):
# Select all the states which are marked as default
_ = State.objects.filter(
workspace__slug=slug, project_id=project_id, default=True
).update(default=False)
_ = State.objects.filter(
workspace__slug=slug, project_id=project_id, pk=pk
).update(default=True)
return Response(status=status.HTTP_204_NO_CONTENT)
def destroy(self, request, slug, project_id, pk):
state = State.objects.get(
~Q(name="Triage"),
pk=pk, project_id=project_id, workspace__slug=slug,
pk=pk,
project_id=project_id,
workspace__slug=slug,
)
if state.default:
return Response(
{"error": "Default state cannot be deleted"}, status=False
)
return Response({"error": "Default state cannot be deleted"}, status=False)
# Check for any issues in the state
issue_exist = Issue.issue_objects.filter(state=pk).exists()
if issue_exist:
return Response(
{
"error": "The state is not empty, only empty states can be deleted"
},
{"error": "The state is not empty, only empty states can be deleted"},
status=status.HTTP_400_BAD_REQUEST,
)
+40 -13
View File
@@ -691,6 +691,10 @@ def create_cycle_issue_activity(
new_cycle = Cycle.objects.filter(
pk=updated_record.get("new_cycle_id", None)
).first()
issue = Issue.objects.filter(pk=updated_record.get("issue_id")).first()
if issue:
issue.updated_at = timezone.now()
issue.save(update_fields=["updated_at"])
issue_activities.append(
IssueActivity(
@@ -713,6 +717,10 @@ def create_cycle_issue_activity(
cycle = Cycle.objects.filter(
pk=created_record.get("fields").get("cycle")
).first()
issue = Issue.objects.filter(pk=created_record.get("fields").get("issue")).first()
if issue:
issue.updated_at = timezone.now()
issue.save(update_fields=["updated_at"])
issue_activities.append(
IssueActivity(
@@ -747,22 +755,27 @@ def delete_cycle_issue_activity(
)
cycle_id = requested_data.get("cycle_id", "")
cycle_name = requested_data.get("cycle_name", "")
cycle = Cycle.objects.filter(pk=cycle_id).first()
issues = requested_data.get("issues")
for issue in issues:
current_issue = Issue.objects.filter(pk=issue).first()
if issue:
current_issue.updated_at = timezone.now()
current_issue.save(update_fields=["updated_at"])
issue_activities.append(
IssueActivity(
issue_id=issue,
actor_id=actor_id,
verb="deleted",
old_value=cycle.name if cycle is not None else "",
old_value=cycle.name if cycle is not None else cycle_name,
new_value="",
field="cycles",
project_id=project_id,
workspace_id=workspace_id,
comment=f"removed this issue from {cycle.name if cycle is not None else None}",
old_identifier=cycle.id if cycle is not None else None,
comment=f"removed this issue from {cycle.name if cycle is not None else cycle_name}",
old_identifier=cycle_id if cycle_id is not None else None,
epoch=epoch,
)
)
@@ -794,6 +807,10 @@ def create_module_issue_activity(
new_module = Module.objects.filter(
pk=updated_record.get("new_module_id", None)
).first()
issue = Issue.objects.filter(pk=updated_record.get("issue_id")).first()
if issue:
issue.updated_at = timezone.now()
issue.save(update_fields=["updated_at"])
issue_activities.append(
IssueActivity(
@@ -816,6 +833,10 @@ def create_module_issue_activity(
module = Module.objects.filter(
pk=created_record.get("fields").get("module")
).first()
issue = Issue.objects.filter(pk=created_record.get("fields").get("issue")).first()
if issue:
issue.updated_at = timezone.now()
issue.save(update_fields=["updated_at"])
issue_activities.append(
IssueActivity(
issue_id=created_record.get("fields").get("issue"),
@@ -849,22 +870,27 @@ def delete_module_issue_activity(
)
module_id = requested_data.get("module_id", "")
module_name = requested_data.get("module_name", "")
module = Module.objects.filter(pk=module_id).first()
issues = requested_data.get("issues")
for issue in issues:
current_issue = Issue.objects.filter(pk=issue).first()
if issue:
current_issue.updated_at = timezone.now()
current_issue.save(update_fields=["updated_at"])
issue_activities.append(
IssueActivity(
issue_id=issue,
actor_id=actor_id,
verb="deleted",
old_value=module.name if module is not None else "",
old_value=module.name if module is not None else module_name,
new_value="",
field="modules",
project_id=project_id,
workspace_id=workspace_id,
comment=f"removed this issue from ",
old_identifier=module.id if module is not None else None,
comment=f"removed this issue from {module.name if module is not None else module_name}",
old_identifier=module_id if module_id is not None else None,
epoch=epoch,
)
)
@@ -1452,15 +1478,16 @@ def issue_activity(
issue_activities = []
project = Project.objects.get(pk=project_id)
issue = Issue.objects.filter(pk=issue_id).first()
workspace_id = project.workspace_id
if issue is not None:
try:
issue.updated_at = timezone.now()
issue.save(update_fields=["updated_at"])
except Exception as e:
pass
if issue_id is not None:
issue = Issue.objects.filter(pk=issue_id).first()
if issue:
try:
issue.updated_at = timezone.now()
issue.save(update_fields=["updated_at"])
except Exception as e:
pass
ACTIVITY_MAPPER = {
"issue.activity.created": create_issue_activity,
+2
View File
@@ -10,6 +10,8 @@ x-app-env : &app-env
- SENTRY_DSN=${SENTRY_DSN:-""}
- GITHUB_CLIENT_SECRET=${GITHUB_CLIENT_SECRET:-""}
- DOCKERIZED=${DOCKERIZED:-1}
# Gunicorn Workers
- GUNICORN_WORKERS=${GUNICORN_WORKERS:-2}
#DB SETTINGS
- PGHOST=${PGHOST:-plane-db}
- PGDATABASE=${PGDATABASE:-plane}
+2
View File
@@ -61,3 +61,5 @@ MINIO_ROOT_PASSWORD="secret-key"
BUCKET_NAME=uploads
FILE_SIZE_LIMIT=5242880
# Gunicorn Workers
GUNICORN_WORKERS=2
+23 -19
View File
@@ -19,27 +19,27 @@ This allows for extensive customization and flexibility in the Editors created u
1. useEditor - A hook that you can use to extend the Plane editor.
| Prop | Type | Description |
| --- | --- | --- |
| `extensions` | `Extension[]` | An array of custom extensions you want to add into the editor to extend it's core features |
| `editorProps` | `EditorProps` | Extend the editor props by passing in a custom props object |
| `uploadFile` | `(file: File) => Promise<string>` | A function that handles file upload. It takes a file as input and handles the process of uploading that file. |
| `deleteFile` | `(assetUrlWithWorkspaceId: string) => Promise<any>` | A function that handles deleting an image. It takes the asset url from your bucket and handles the process of deleting that image. |
| `value` | `html string` | The initial content of the editor. |
| `debouncedUpdatesEnabled` | `boolean` | If set to true, the `onChange` event handler is debounced, meaning it will only be invoked after the specified delay (default 1500ms) once the user has stopped typing. |
| `onChange` | `(json: any, html: string) => void` | This function is invoked whenever the content of the editor changes. It is passed the new content in both JSON and HTML formats. |
| `setIsSubmitting` | `(isSubmitting: "submitting" \| "submitted" \| "saved") => void` | This function is called to update the submission status. |
| `setShouldShowAlert` | `(showAlert: boolean) => void` | This function is used to show or hide an alert in case of content not being "saved". |
| `forwardedRef` | `any` | Pass this in whenever you want to control the editor's state from an external component |
| Prop | Type | Description |
| ------------------------- | ---------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `extensions` | `Extension[]` | An array of custom extensions you want to add into the editor to extend it's core features |
| `editorProps` | `EditorProps` | Extend the editor props by passing in a custom props object |
| `uploadFile` | `(file: File) => Promise<string>` | A function that handles file upload. It takes a file as input and handles the process of uploading that file. |
| `deleteFile` | `(assetUrlWithWorkspaceId: string) => Promise<any>` | A function that handles deleting an image. It takes the asset url from your bucket and handles the process of deleting that image. |
| `value` | `html string` | The initial content of the editor. |
| `debouncedUpdatesEnabled` | `boolean` | If set to true, the `onChange` event handler is debounced, meaning it will only be invoked after the specified delay (default 1500ms) once the user has stopped typing. |
| `onChange` | `(json: any, html: string) => void` | This function is invoked whenever the content of the editor changes. It is passed the new content in both JSON and HTML formats. |
| `setIsSubmitting` | `(isSubmitting: "submitting" \| "submitted" \| "saved") => void` | This function is called to update the submission status. |
| `setShouldShowAlert` | `(showAlert: boolean) => void` | This function is used to show or hide an alert in case of content not being "saved". |
| `forwardedRef` | `any` | Pass this in whenever you want to control the editor's state from an external component |
2. useReadOnlyEditor - A hook that can be used to extend a Read Only instance of the core editor.
| Prop | Type | Description |
| --- | --- | --- |
| `value` | `string` | The initial content of the editor. |
| `forwardedRef` | `any` | Pass this in whenever you want to control the editor's state from an external component |
| `extensions` | `Extension[]` | An array of custom extensions you want to add into the editor to extend it's core features |
| `editorProps` | `EditorProps` | Extend the editor props by passing in a custom props object |
| Prop | Type | Description |
| -------------- | ------------- | ------------------------------------------------------------------------------------------ |
| `value` | `string` | The initial content of the editor. |
| `forwardedRef` | `any` | Pass this in whenever you want to control the editor's state from an external component |
| `extensions` | `Extension[]` | An array of custom extensions you want to add into the editor to extend it's core features |
| `editorProps` | `EditorProps` | Extend the editor props by passing in a custom props object |
3. Items and Commands - H1, H2, H3, task list, quote, code block, etc's methods.
@@ -51,7 +51,11 @@ This allows for extensive customization and flexibility in the Editors created u
5. Extending with Custom Styles
```ts
const customEditorClassNames = getEditorClassNames({ noBorder, borderOnFocus, customClassName });
const customEditorClassNames = getEditorClassNames({
noBorder,
borderOnFocus,
customClassName,
});
```
## Core features
+63 -14
View File
@@ -3,18 +3,36 @@ import { UploadImage } from "../types/upload-image";
import { startImageUpload } from "../ui/plugins/upload-image";
export const toggleHeadingOne = (editor: Editor, range?: Range) => {
if (range) editor.chain().focus().deleteRange(range).setNode("heading", { level: 1 }).run();
else editor.chain().focus().toggleHeading({ level: 1 }).run()
if (range)
editor
.chain()
.focus()
.deleteRange(range)
.setNode("heading", { level: 1 })
.run();
else editor.chain().focus().toggleHeading({ level: 1 }).run();
};
export const toggleHeadingTwo = (editor: Editor, range?: Range) => {
if (range) editor.chain().focus().deleteRange(range).setNode("heading", { level: 2 }).run();
else editor.chain().focus().toggleHeading({ level: 2 }).run()
if (range)
editor
.chain()
.focus()
.deleteRange(range)
.setNode("heading", { level: 2 })
.run();
else editor.chain().focus().toggleHeading({ level: 2 }).run();
};
export const toggleHeadingThree = (editor: Editor, range?: Range) => {
if (range) editor.chain().focus().deleteRange(range).setNode("heading", { level: 3 }).run();
else editor.chain().focus().toggleHeading({ level: 3 }).run()
if (range)
editor
.chain()
.focus()
.deleteRange(range)
.setNode("heading", { level: 3 })
.run();
else editor.chain().focus().toggleHeading({ level: 3 }).run();
};
export const toggleBold = (editor: Editor, range?: Range) => {
@@ -37,7 +55,8 @@ export const toggleCode = (editor: Editor, range?: Range) => {
else editor.chain().focus().toggleCode().run();
};
export const toggleOrderedList = (editor: Editor, range?: Range) => {
if (range) editor.chain().focus().deleteRange(range).toggleOrderedList().run();
if (range)
editor.chain().focus().deleteRange(range).toggleOrderedList().run();
else editor.chain().focus().toggleOrderedList().run();
};
@@ -48,7 +67,7 @@ export const toggleBulletList = (editor: Editor, range?: Range) => {
export const toggleTaskList = (editor: Editor, range?: Range) => {
if (range) editor.chain().focus().deleteRange(range).toggleTaskList().run();
else editor.chain().focus().toggleTaskList().run()
else editor.chain().focus().toggleTaskList().run();
};
export const toggleStrike = (editor: Editor, range?: Range) => {
@@ -57,13 +76,37 @@ export const toggleStrike = (editor: Editor, range?: Range) => {
};
export const toggleBlockquote = (editor: Editor, range?: Range) => {
if (range) editor.chain().focus().deleteRange(range).toggleNode("paragraph", "paragraph").toggleBlockquote().run();
else editor.chain().focus().toggleNode("paragraph", "paragraph").toggleBlockquote().run();
if (range)
editor
.chain()
.focus()
.deleteRange(range)
.toggleNode("paragraph", "paragraph")
.toggleBlockquote()
.run();
else
editor
.chain()
.focus()
.toggleNode("paragraph", "paragraph")
.toggleBlockquote()
.run();
};
export const insertTableCommand = (editor: Editor, range?: Range) => {
if (range) editor.chain().focus().deleteRange(range).insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run();
else editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run();
if (range)
editor
.chain()
.focus()
.deleteRange(range)
.insertTable({ rows: 3, cols: 3, withHeaderRow: true })
.run();
else
editor
.chain()
.focus()
.insertTable({ rows: 3, cols: 3, withHeaderRow: true })
.run();
};
export const unsetLinkEditor = (editor: Editor) => {
@@ -74,7 +117,14 @@ export const setLinkEditor = (editor: Editor, url: string) => {
editor.chain().focus().setLink({ href: url }).run();
};
export const insertImageCommand = (editor: Editor, uploadFile: UploadImage, setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void, range?: Range) => {
export const insertImageCommand = (
editor: Editor,
uploadFile: UploadImage,
setIsSubmitting?: (
isSubmitting: "submitting" | "submitted" | "saved",
) => void,
range?: Range,
) => {
if (range) editor.chain().focus().deleteRange(range).run();
const input = document.createElement("input");
input.type = "file";
@@ -88,4 +138,3 @@ export const insertImageCommand = (editor: Editor, uploadFile: UploadImage, setI
};
input.click();
};
+16 -11
View File
@@ -6,19 +6,24 @@ interface EditorClassNames {
customClassName?: string;
}
export const getEditorClassNames = ({ noBorder, borderOnFocus, customClassName }: EditorClassNames) => cn(
'relative w-full max-w-full sm:rounded-lg mt-2 p-3 relative focus:outline-none rounded-md',
noBorder ? '' : 'border border-custom-border-200',
borderOnFocus ? 'focus:border border-custom-border-300' : 'focus:border-0',
customClassName
);
export const getEditorClassNames = ({
noBorder,
borderOnFocus,
customClassName,
}: EditorClassNames) =>
cn(
"relative w-full max-w-full sm:rounded-lg mt-2 p-3 relative focus:outline-none rounded-md",
noBorder ? "" : "border border-custom-border-200",
borderOnFocus ? "focus:border border-custom-border-300" : "focus:border-0",
customClassName,
);
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export const findTableAncestor = (
node: Node | null
node: Node | null,
): HTMLTableElement | null => {
while (node !== null && node.nodeName !== "TABLE") {
node = node.parentNode;
@@ -27,10 +32,10 @@ export const findTableAncestor = (
};
export const getTrimmedHTML = (html: string) => {
html = html.replace(/^(<p><\/p>)+/, '');
html = html.replace(/(<p><\/p>)+$/, '');
html = html.replace(/^(<p><\/p>)+/, "");
html = html.replace(/(<p><\/p>)+$/, "");
return html;
}
};
export const isValidHttpUrl = (string: string): boolean => {
let url: URL;
@@ -42,4 +47,4 @@ export const isValidHttpUrl = (string: string): boolean => {
}
return url.protocol === "http:" || url.protocol === "https:";
}
};
@@ -1,10 +1,10 @@
export type IMentionSuggestion = {
id: string;
type: string;
avatar: string;
title: string;
subtitle: string;
redirect_uri: string;
}
id: string;
type: string;
avatar: string;
title: string;
subtitle: string;
redirect_uri: string;
};
export type IMentionHighlight = string
export type IMentionHighlight = string;
@@ -8,10 +8,16 @@ interface EditorContentProps {
children?: ReactNode;
}
export const EditorContentWrapper = ({ editor, editorContentCustomClassNames = '', children }: EditorContentProps) => (
export const EditorContentWrapper = ({
editor,
editorContentCustomClassNames = "",
children,
}: EditorContentProps) => (
<div className={`contentEditor ${editorContentCustomClassNames}`}>
<EditorContent editor={editor} />
{(editor?.isActive("image") && editor?.isEditable) && <ImageResizer editor={editor} />}
{editor?.isActive("image") && editor?.isEditable && (
<ImageResizer editor={editor} />
)}
{children}
</div>
);
@@ -3,7 +3,9 @@ import Moveable from "react-moveable";
export const ImageResizer = ({ editor }: { editor: Editor }) => {
const updateMediaSize = () => {
const imageInfo = document.querySelector(".ProseMirror-selectednode") as HTMLImageElement;
const imageInfo = document.querySelector(
".ProseMirror-selectednode",
) as HTMLImageElement;
if (imageInfo) {
const selection = editor.state.selection;
editor.commands.setImage({
@@ -3,21 +3,28 @@ import TrackImageDeletionPlugin from "../../plugins/delete-image";
import UploadImagesPlugin from "../../plugins/upload-image";
import { DeleteImage } from "../../../types/delete-image";
const ImageExtension = (deleteImage: DeleteImage) => Image.extend({
addProseMirrorPlugins() {
return [UploadImagesPlugin(), TrackImageDeletionPlugin(deleteImage)];
},
addAttributes() {
return {
...this.parent?.(),
width: {
default: "35%",
},
height: {
default: null,
},
};
},
});
const ImageExtension = (
deleteImage: DeleteImage,
cancelUploadImage?: () => any,
) =>
Image.extend({
addProseMirrorPlugins() {
return [
UploadImagesPlugin(cancelUploadImage),
TrackImageDeletionPlugin(deleteImage),
];
},
addAttributes() {
return {
...this.parent?.(),
width: {
default: "35%",
},
height: {
default: null,
},
};
},
});
export default ImageExtension;
@@ -20,82 +20,89 @@ import { isValidHttpUrl } from "../../lib/utils";
import { IMentionSuggestion } from "../../types/mention-suggestion";
import { Mentions } from "../mentions";
export const CoreEditorExtensions = (
mentionConfig: { mentionSuggestions: IMentionSuggestion[], mentionHighlights: string[] },
mentionConfig: {
mentionSuggestions: IMentionSuggestion[];
mentionHighlights: string[];
},
deleteFile: DeleteImage,
cancelUploadImage?: () => any,
) => [
StarterKit.configure({
bulletList: {
HTMLAttributes: {
class: "list-disc list-outside leading-3 -mt-2",
},
StarterKit.configure({
bulletList: {
HTMLAttributes: {
class: "list-disc list-outside leading-3 -mt-2",
},
orderedList: {
HTMLAttributes: {
class: "list-decimal list-outside leading-3 -mt-2",
},
},
orderedList: {
HTMLAttributes: {
class: "list-decimal list-outside leading-3 -mt-2",
},
listItem: {
HTMLAttributes: {
class: "leading-normal -mb-2",
},
},
listItem: {
HTMLAttributes: {
class: "leading-normal -mb-2",
},
blockquote: {
HTMLAttributes: {
class: "border-l-4 border-custom-border-300",
},
},
blockquote: {
HTMLAttributes: {
class: "border-l-4 border-custom-border-300",
},
code: {
HTMLAttributes: {
class:
"rounded-md bg-custom-primary-30 mx-1 px-1 py-1 font-mono font-medium text-custom-text-1000",
spellcheck: "false",
},
},
codeBlock: false,
horizontalRule: false,
dropcursor: {
color: "rgba(var(--color-text-100))",
width: 2,
},
gapcursor: false,
}),
Gapcursor,
TiptapLink.configure({
protocols: ["http", "https"],
validate: (url) => isValidHttpUrl(url),
},
code: {
HTMLAttributes: {
class:
"text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer",
"rounded-md bg-custom-primary-30 mx-1 px-1 py-1 font-mono font-medium text-custom-text-1000",
spellcheck: "false",
},
}),
ImageExtension(deleteFile).configure({
HTMLAttributes: {
class: "rounded-lg border border-custom-border-300",
},
}),
TiptapUnderline,
TextStyle,
Color,
TaskList.configure({
HTMLAttributes: {
class: "not-prose pl-2",
},
}),
TaskItem.configure({
HTMLAttributes: {
class: "flex items-start my-4",
},
nested: true,
}),
Markdown.configure({
html: true,
transformCopiedText: true,
}),
Table,
TableHeader,
TableCell,
TableRow,
Mentions(mentionConfig.mentionSuggestions, mentionConfig.mentionHighlights, false),
];
},
codeBlock: false,
horizontalRule: false,
dropcursor: {
color: "rgba(var(--color-text-100))",
width: 2,
},
gapcursor: false,
}),
Gapcursor,
TiptapLink.configure({
protocols: ["http", "https"],
validate: (url) => isValidHttpUrl(url),
HTMLAttributes: {
class:
"text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer",
},
}),
ImageExtension(deleteFile, cancelUploadImage).configure({
HTMLAttributes: {
class: "rounded-lg border border-custom-border-300",
},
}),
TiptapUnderline,
TextStyle,
Color,
TaskList.configure({
HTMLAttributes: {
class: "not-prose pl-2",
},
}),
TaskItem.configure({
HTMLAttributes: {
class: "flex items-start my-4",
},
nested: true,
}),
Markdown.configure({
html: true,
transformCopiedText: true,
}),
Table,
TableHeader,
TableCell,
TableRow,
Mentions(
mentionConfig.mentionSuggestions,
mentionConfig.mentionHighlights,
false,
),
];
@@ -1 +1 @@
export { default as default } from "./table-cell"
export { default as default } from "./table-cell";
@@ -1,7 +1,7 @@
import { mergeAttributes, Node } from "@tiptap/core"
import { mergeAttributes, Node } from "@tiptap/core";
export interface TableCellOptions {
HTMLAttributes: Record<string, any>
HTMLAttributes: Record<string, any>;
}
export default Node.create<TableCellOptions>({
@@ -9,8 +9,8 @@ export default Node.create<TableCellOptions>({
addOptions() {
return {
HTMLAttributes: {}
}
HTMLAttributes: {},
};
},
content: "paragraph+",
@@ -18,24 +18,24 @@ export default Node.create<TableCellOptions>({
addAttributes() {
return {
colspan: {
default: 1
default: 1,
},
rowspan: {
default: 1
default: 1,
},
colwidth: {
default: null,
parseHTML: (element) => {
const colwidth = element.getAttribute("colwidth")
const value = colwidth ? [parseInt(colwidth, 10)] : null
const colwidth = element.getAttribute("colwidth");
const value = colwidth ? [parseInt(colwidth, 10)] : null;
return value
}
return value;
},
},
background: {
default: "none"
}
}
default: "none",
},
};
},
tableRole: "cell",
@@ -43,16 +43,16 @@ export default Node.create<TableCellOptions>({
isolating: true,
parseHTML() {
return [{ tag: "td" }]
return [{ tag: "td" }];
},
renderHTML({ node, HTMLAttributes }) {
return [
"td",
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
style: `background-color: ${node.attrs.background}`
style: `background-color: ${node.attrs.background}`,
}),
0
]
}
})
0,
];
},
});
@@ -1 +1 @@
export { default as default } from "./table-header"
export { default as default } from "./table-header";
@@ -1,15 +1,15 @@
import { mergeAttributes, Node } from "@tiptap/core"
import { mergeAttributes, Node } from "@tiptap/core";
export interface TableHeaderOptions {
HTMLAttributes: Record<string, any>
HTMLAttributes: Record<string, any>;
}
export default Node.create<TableHeaderOptions>({
name: "tableHeader",
addOptions() {
return {
HTMLAttributes: {}
}
HTMLAttributes: {},
};
},
content: "paragraph+",
@@ -17,24 +17,24 @@ export default Node.create<TableHeaderOptions>({
addAttributes() {
return {
colspan: {
default: 1
default: 1,
},
rowspan: {
default: 1
default: 1,
},
colwidth: {
default: null,
parseHTML: (element) => {
const colwidth = element.getAttribute("colwidth")
const value = colwidth ? [parseInt(colwidth, 10)] : null
const colwidth = element.getAttribute("colwidth");
const value = colwidth ? [parseInt(colwidth, 10)] : null;
return value
}
return value;
},
},
background: {
default: "rgb(var(--color-primary-100))"
}
}
default: "rgb(var(--color-primary-100))",
},
};
},
tableRole: "header_cell",
@@ -42,16 +42,16 @@ export default Node.create<TableHeaderOptions>({
isolating: true,
parseHTML() {
return [{ tag: "th" }]
return [{ tag: "th" }];
},
renderHTML({ node, HTMLAttributes }) {
return [
"th",
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
style: `background-color: ${node.attrs.background}`
style: `background-color: ${node.attrs.background}`,
}),
0
]
}
})
0,
];
},
});
@@ -1 +1 @@
export { default as default } from "./table-row"
export { default as default } from "./table-row";
@@ -1,31 +1,31 @@
import { mergeAttributes, Node } from "@tiptap/core"
import { mergeAttributes, Node } from "@tiptap/core";
export interface TableRowOptions {
HTMLAttributes: Record<string, any>
HTMLAttributes: Record<string, any>;
}
export default Node.create<TableRowOptions>({
name: "tableRow",
name: "tableRow",
addOptions() {
return {
HTMLAttributes: {}
}
},
addOptions() {
return {
HTMLAttributes: {},
};
},
content: "(tableCell | tableHeader)*",
content: "(tableCell | tableHeader)*",
tableRole: "row",
tableRole: "row",
parseHTML() {
return [{ tag: "tr" }]
},
parseHTML() {
return [{ tag: "tr" }];
},
renderHTML({ HTMLAttributes }) {
return [
"tr",
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes),
0
]
}
})
renderHTML({ HTMLAttributes }) {
return [
"tr",
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes),
0,
];
},
});
@@ -38,7 +38,7 @@ const icons = {
/>
</svg>
`,
insertBottomTableIcon:`<svg
insertBottomTableIcon: `<svg
xmlns="http://www.w3.org/2000/svg"
width={24}
height={24}
@@ -1 +1 @@
export { default as default } from "./table"
export { default as default } from "./table";
@@ -68,7 +68,12 @@ export function tableControls() {
const { hoveredTable, hoveredCell } = pluginState.values;
const docSize = state.doc.content.size;
if (hoveredTable && hoveredCell && hoveredTable.pos < docSize && hoveredCell.pos < docSize) {
if (
hoveredTable &&
hoveredCell &&
hoveredTable.pos < docSize &&
hoveredCell.pos < docSize
) {
const decorations = [
Decoration.node(
hoveredTable.pos,
@@ -202,6 +202,7 @@ function createToolbox({
"div",
{
className: "toolboxItem",
itemType: "button",
onClick() {
onClickItem(item);
},
@@ -253,6 +254,7 @@ function createColorPickerToolbox({
"div",
{
className: "toolboxItem",
itemType: "button",
onClick: () => {
onSelectColor(value);
colorPicker.hide();
@@ -331,7 +333,9 @@ export class TableView implements NodeView {
this.rowsControl = h(
"div",
{ className: "rowsControl" },
h("button", {
h("div", {
itemType: "button",
className: "rowsControlDiv",
onClick: () => this.selectRow(),
}),
);
@@ -339,7 +343,9 @@ export class TableView implements NodeView {
this.columnsControl = h(
"div",
{ className: "columnsControl" },
h("button", {
h("div", {
itemType: "button",
className: "columnsControlDiv",
onClick: () => this.selectColumn(),
}),
);
@@ -352,7 +358,7 @@ export class TableView implements NodeView {
);
this.columnsToolbox = createToolbox({
triggerButton: this.columnsControl.querySelector("button"),
triggerButton: this.columnsControl.querySelector(".columnsControlDiv"),
items: columnsToolboxItems,
tippyOptions: {
...defaultTippyOptions,
@@ -1,298 +1,312 @@
import { TextSelection } from "@tiptap/pm/state"
import { TextSelection } from "@tiptap/pm/state";
import { callOrReturn, getExtensionField, mergeAttributes, Node, ParentConfig } from "@tiptap/core"
import {
addColumnAfter,
addColumnBefore,
addRowAfter,
addRowBefore,
CellSelection,
columnResizing,
deleteColumn,
deleteRow,
deleteTable,
fixTables,
goToNextCell,
mergeCells,
setCellAttr,
splitCell,
tableEditing,
toggleHeader,
toggleHeaderCell
} from "@tiptap/prosemirror-tables"
callOrReturn,
getExtensionField,
mergeAttributes,
Node,
ParentConfig,
} from "@tiptap/core";
import {
addColumnAfter,
addColumnBefore,
addRowAfter,
addRowBefore,
CellSelection,
columnResizing,
deleteColumn,
deleteRow,
deleteTable,
fixTables,
goToNextCell,
mergeCells,
setCellAttr,
splitCell,
tableEditing,
toggleHeader,
toggleHeaderCell,
} from "@tiptap/prosemirror-tables";
import { tableControls } from "./table-controls"
import { TableView } from "./table-view"
import { createTable } from "./utilities/create-table"
import { deleteTableWhenAllCellsSelected } from "./utilities/delete-table-when-all-cells-selected"
import { tableControls } from "./table-controls";
import { TableView } from "./table-view";
import { createTable } from "./utilities/create-table";
import { deleteTableWhenAllCellsSelected } from "./utilities/delete-table-when-all-cells-selected";
export interface TableOptions {
HTMLAttributes: Record<string, any>
resizable: boolean
handleWidth: number
cellMinWidth: number
lastColumnResizable: boolean
allowTableNodeSelection: boolean
HTMLAttributes: Record<string, any>;
resizable: boolean;
handleWidth: number;
cellMinWidth: number;
lastColumnResizable: boolean;
allowTableNodeSelection: boolean;
}
declare module "@tiptap/core" {
interface Commands<ReturnType> {
table: {
insertTable: (options?: {
rows?: number
cols?: number
withHeaderRow?: boolean
}) => ReturnType
addColumnBefore: () => ReturnType
addColumnAfter: () => ReturnType
deleteColumn: () => ReturnType
addRowBefore: () => ReturnType
addRowAfter: () => ReturnType
deleteRow: () => ReturnType
deleteTable: () => ReturnType
mergeCells: () => ReturnType
splitCell: () => ReturnType
toggleHeaderColumn: () => ReturnType
toggleHeaderRow: () => ReturnType
toggleHeaderCell: () => ReturnType
mergeOrSplit: () => ReturnType
setCellAttribute: (name: string, value: any) => ReturnType
goToNextCell: () => ReturnType
goToPreviousCell: () => ReturnType
fixTables: () => ReturnType
setCellSelection: (position: {
anchorCell: number
headCell?: number
}) => ReturnType
}
}
interface Commands<ReturnType> {
table: {
insertTable: (options?: {
rows?: number;
cols?: number;
withHeaderRow?: boolean;
}) => ReturnType;
addColumnBefore: () => ReturnType;
addColumnAfter: () => ReturnType;
deleteColumn: () => ReturnType;
addRowBefore: () => ReturnType;
addRowAfter: () => ReturnType;
deleteRow: () => ReturnType;
deleteTable: () => ReturnType;
mergeCells: () => ReturnType;
splitCell: () => ReturnType;
toggleHeaderColumn: () => ReturnType;
toggleHeaderRow: () => ReturnType;
toggleHeaderCell: () => ReturnType;
mergeOrSplit: () => ReturnType;
setCellAttribute: (name: string, value: any) => ReturnType;
goToNextCell: () => ReturnType;
goToPreviousCell: () => ReturnType;
fixTables: () => ReturnType;
setCellSelection: (position: {
anchorCell: number;
headCell?: number;
}) => ReturnType;
};
}
interface NodeConfig<Options, Storage> {
tableRole?:
| string
| ((this: {
name: string
options: Options
storage: Storage
parent: ParentConfig<NodeConfig<Options>>["tableRole"]
}) => string)
}
interface NodeConfig<Options, Storage> {
tableRole?:
| string
| ((this: {
name: string;
options: Options;
storage: Storage;
parent: ParentConfig<NodeConfig<Options>>["tableRole"];
}) => string);
}
}
export default Node.create({
name: "table",
name: "table",
addOptions() {
return {
HTMLAttributes: {},
resizable: true,
handleWidth: 5,
cellMinWidth: 100,
lastColumnResizable: true,
allowTableNodeSelection: true
}
},
addOptions() {
return {
HTMLAttributes: {},
resizable: true,
handleWidth: 5,
cellMinWidth: 100,
lastColumnResizable: true,
allowTableNodeSelection: true,
};
},
content: "tableRow+",
content: "tableRow+",
tableRole: "table",
tableRole: "table",
isolating: true,
isolating: true,
group: "block",
group: "block",
allowGapCursor: false,
allowGapCursor: false,
parseHTML() {
return [{ tag: "table" }]
},
parseHTML() {
return [{ tag: "table" }];
},
renderHTML({ HTMLAttributes }) {
return [
"table",
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes),
["tbody", 0]
]
},
renderHTML({ HTMLAttributes }) {
return [
"table",
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes),
["tbody", 0],
];
},
addCommands() {
return {
insertTable:
({ rows = 3, cols = 3, withHeaderRow = true} = {}) =>
({ tr, dispatch, editor }) => {
const node = createTable(
editor.schema,
rows,
cols,
withHeaderRow
)
addCommands() {
return {
insertTable:
({ rows = 3, cols = 3, withHeaderRow = true } = {}) =>
({ tr, dispatch, editor }) => {
const node = createTable(editor.schema, rows, cols, withHeaderRow);
if (dispatch) {
const offset = tr.selection.anchor + 1
if (dispatch) {
const offset = tr.selection.anchor + 1;
tr.replaceSelectionWith(node)
.scrollIntoView()
.setSelection(
TextSelection.near(tr.doc.resolve(offset))
)
}
tr.replaceSelectionWith(node)
.scrollIntoView()
.setSelection(TextSelection.near(tr.doc.resolve(offset)));
}
return true
},
addColumnBefore:
() =>
({ state, dispatch }) => addColumnBefore(state, dispatch),
addColumnAfter:
() =>
({ state, dispatch }) => addColumnAfter(state, dispatch),
deleteColumn:
() =>
({ state, dispatch }) => deleteColumn(state, dispatch),
addRowBefore:
() =>
({ state, dispatch }) => addRowBefore(state, dispatch),
addRowAfter:
() =>
({ state, dispatch }) => addRowAfter(state, dispatch),
deleteRow:
() =>
({ state, dispatch }) => deleteRow(state, dispatch),
deleteTable:
() =>
({ state, dispatch }) => deleteTable(state, dispatch),
mergeCells:
() =>
({ state, dispatch }) => mergeCells(state, dispatch),
splitCell:
() =>
({ state, dispatch }) => splitCell(state, dispatch),
toggleHeaderColumn:
() =>
({ state, dispatch }) => toggleHeader("column")(state, dispatch),
toggleHeaderRow:
() =>
({ state, dispatch }) => toggleHeader("row")(state, dispatch),
toggleHeaderCell:
() =>
({ state, dispatch }) => toggleHeaderCell(state, dispatch),
mergeOrSplit:
() =>
({ state, dispatch }) => {
if (mergeCells(state, dispatch)) {
return true
}
return true;
},
addColumnBefore:
() =>
({ state, dispatch }) =>
addColumnBefore(state, dispatch),
addColumnAfter:
() =>
({ state, dispatch }) =>
addColumnAfter(state, dispatch),
deleteColumn:
() =>
({ state, dispatch }) =>
deleteColumn(state, dispatch),
addRowBefore:
() =>
({ state, dispatch }) =>
addRowBefore(state, dispatch),
addRowAfter:
() =>
({ state, dispatch }) =>
addRowAfter(state, dispatch),
deleteRow:
() =>
({ state, dispatch }) =>
deleteRow(state, dispatch),
deleteTable:
() =>
({ state, dispatch }) =>
deleteTable(state, dispatch),
mergeCells:
() =>
({ state, dispatch }) =>
mergeCells(state, dispatch),
splitCell:
() =>
({ state, dispatch }) =>
splitCell(state, dispatch),
toggleHeaderColumn:
() =>
({ state, dispatch }) =>
toggleHeader("column")(state, dispatch),
toggleHeaderRow:
() =>
({ state, dispatch }) =>
toggleHeader("row")(state, dispatch),
toggleHeaderCell:
() =>
({ state, dispatch }) =>
toggleHeaderCell(state, dispatch),
mergeOrSplit:
() =>
({ state, dispatch }) => {
if (mergeCells(state, dispatch)) {
return true;
}
return splitCell(state, dispatch)
},
setCellAttribute:
(name, value) =>
({ state, dispatch }) => setCellAttr(name, value)(state, dispatch),
goToNextCell:
() =>
({ state, dispatch }) => goToNextCell(1)(state, dispatch),
goToPreviousCell:
() =>
({ state, dispatch }) => goToNextCell(-1)(state, dispatch),
fixTables:
() =>
({ state, dispatch }) => {
if (dispatch) {
fixTables(state)
}
return splitCell(state, dispatch);
},
setCellAttribute:
(name, value) =>
({ state, dispatch }) =>
setCellAttr(name, value)(state, dispatch),
goToNextCell:
() =>
({ state, dispatch }) =>
goToNextCell(1)(state, dispatch),
goToPreviousCell:
() =>
({ state, dispatch }) =>
goToNextCell(-1)(state, dispatch),
fixTables:
() =>
({ state, dispatch }) => {
if (dispatch) {
fixTables(state);
}
return true
},
setCellSelection:
(position) =>
({ tr, dispatch }) => {
if (dispatch) {
const selection = CellSelection.create(
tr.doc,
position.anchorCell,
position.headCell
)
return true;
},
setCellSelection:
(position) =>
({ tr, dispatch }) => {
if (dispatch) {
const selection = CellSelection.create(
tr.doc,
position.anchorCell,
position.headCell,
);
// @ts-ignore
tr.setSelection(selection)
}
// @ts-ignore
tr.setSelection(selection);
}
return true
}
}
},
return true;
},
};
},
addKeyboardShortcuts() {
return {
Tab: () => {
if (this.editor.commands.goToNextCell()) {
return true
}
if (!this.editor.can().addRowAfter()) {
return false
}
return this.editor.chain().addRowAfter().goToNextCell().run()
},
"Shift-Tab": () => this.editor.commands.goToPreviousCell(),
Backspace: deleteTableWhenAllCellsSelected,
"Mod-Backspace": deleteTableWhenAllCellsSelected,
Delete: deleteTableWhenAllCellsSelected,
"Mod-Delete": deleteTableWhenAllCellsSelected
}
},
addNodeView() {
return ({ editor, getPos, node, decorations }) => {
const { cellMinWidth } = this.options
return new TableView(
node,
cellMinWidth,
decorations,
editor,
getPos as () => number
)
}
},
addProseMirrorPlugins() {
const isResizable = this.options.resizable && this.editor.isEditable
const plugins = [
tableEditing({
allowTableNodeSelection: this.options.allowTableNodeSelection
}),
tableControls()
]
if (isResizable) {
plugins.unshift(
columnResizing({
handleWidth: this.options.handleWidth,
cellMinWidth: this.options.cellMinWidth,
// View: TableView,
// @ts-ignore
lastColumnResizable: this.options.lastColumnResizable
})
)
addKeyboardShortcuts() {
return {
Tab: () => {
if (this.editor.commands.goToNextCell()) {
return true;
}
return plugins
},
extendNodeSchema(extension) {
const context = {
name: extension.name,
options: extension.options,
storage: extension.storage
if (!this.editor.can().addRowAfter()) {
return false;
}
return {
tableRole: callOrReturn(
getExtensionField(extension, "tableRole", context)
)
}
return this.editor.chain().addRowAfter().goToNextCell().run();
},
"Shift-Tab": () => this.editor.commands.goToPreviousCell(),
Backspace: deleteTableWhenAllCellsSelected,
"Mod-Backspace": deleteTableWhenAllCellsSelected,
Delete: deleteTableWhenAllCellsSelected,
"Mod-Delete": deleteTableWhenAllCellsSelected,
};
},
addNodeView() {
return ({ editor, getPos, node, decorations }) => {
const { cellMinWidth } = this.options;
return new TableView(
node,
cellMinWidth,
decorations,
editor,
getPos as () => number,
);
};
},
addProseMirrorPlugins() {
const isResizable = this.options.resizable && this.editor.isEditable;
const plugins = [
tableEditing({
allowTableNodeSelection: this.options.allowTableNodeSelection,
}),
tableControls(),
];
if (isResizable) {
plugins.unshift(
columnResizing({
handleWidth: this.options.handleWidth,
cellMinWidth: this.options.cellMinWidth,
// View: TableView,
// @ts-ignore
lastColumnResizable: this.options.lastColumnResizable,
}),
);
}
})
return plugins;
},
extendNodeSchema(extension) {
const context = {
name: extension.name,
options: extension.options,
storage: extension.storage,
};
return {
tableRole: callOrReturn(
getExtensionField(extension, "tableRole", context),
),
};
},
});
@@ -1,12 +1,12 @@
import { Fragment, Node as ProsemirrorNode, NodeType } from "prosemirror-model"
import { Fragment, Node as ProsemirrorNode, NodeType } from "prosemirror-model";
export function createCell(
cellType: NodeType,
cellContent?: Fragment | ProsemirrorNode | Array<ProsemirrorNode>
cellType: NodeType,
cellContent?: Fragment | ProsemirrorNode | Array<ProsemirrorNode>,
): ProsemirrorNode | null | undefined {
if (cellContent) {
return cellType.createChecked(null, cellContent)
}
if (cellContent) {
return cellType.createChecked(null, cellContent);
}
return cellType.createAndFill()
return cellType.createAndFill();
}
@@ -1,45 +1,45 @@
import { Fragment, Node as ProsemirrorNode, Schema } from "@tiptap/pm/model"
import { Fragment, Node as ProsemirrorNode, Schema } from "@tiptap/pm/model";
import { createCell } from "./create-cell"
import { getTableNodeTypes } from "./get-table-node-types"
import { createCell } from "./create-cell";
import { getTableNodeTypes } from "./get-table-node-types";
export function createTable(
schema: Schema,
rowsCount: number,
colsCount: number,
withHeaderRow: boolean,
cellContent?: Fragment | ProsemirrorNode | Array<ProsemirrorNode>
schema: Schema,
rowsCount: number,
colsCount: number,
withHeaderRow: boolean,
cellContent?: Fragment | ProsemirrorNode | Array<ProsemirrorNode>,
): ProsemirrorNode {
const types = getTableNodeTypes(schema)
const headerCells: ProsemirrorNode[] = []
const cells: ProsemirrorNode[] = []
const types = getTableNodeTypes(schema);
const headerCells: ProsemirrorNode[] = [];
const cells: ProsemirrorNode[] = [];
for (let index = 0; index < colsCount; index += 1) {
const cell = createCell(types.cell, cellContent)
for (let index = 0; index < colsCount; index += 1) {
const cell = createCell(types.cell, cellContent);
if (cell) {
cells.push(cell)
}
if (withHeaderRow) {
const headerCell = createCell(types.header_cell, cellContent)
if (headerCell) {
headerCells.push(headerCell)
}
}
if (cell) {
cells.push(cell);
}
const rows: ProsemirrorNode[] = []
if (withHeaderRow) {
const headerCell = createCell(types.header_cell, cellContent);
for (let index = 0; index < rowsCount; index += 1) {
rows.push(
types.row.createChecked(
null,
withHeaderRow && index === 0 ? headerCells : cells
)
)
if (headerCell) {
headerCells.push(headerCell);
}
}
}
return types.table.createChecked(null, rows)
const rows: ProsemirrorNode[] = [];
for (let index = 0; index < rowsCount; index += 1) {
rows.push(
types.row.createChecked(
null,
withHeaderRow && index === 0 ? headerCells : cells,
),
);
}
return types.table.createChecked(null, rows);
}
@@ -1,39 +1,42 @@
import { findParentNodeClosestToPos, KeyboardShortcutCommand } from "@tiptap/core"
import {
findParentNodeClosestToPos,
KeyboardShortcutCommand,
} from "@tiptap/core";
import { isCellSelection } from "./is-cell-selection"
import { isCellSelection } from "./is-cell-selection";
export const deleteTableWhenAllCellsSelected: KeyboardShortcutCommand = ({
editor
editor,
}) => {
const { selection } = editor.state
const { selection } = editor.state;
if (!isCellSelection(selection)) {
return false
if (!isCellSelection(selection)) {
return false;
}
let cellCount = 0;
const table = findParentNodeClosestToPos(
selection.ranges[0].$from,
(node) => node.type.name === "table",
);
table?.node.descendants((node) => {
if (node.type.name === "table") {
return false;
}
let cellCount = 0
const table = findParentNodeClosestToPos(
selection.ranges[0].$from,
(node) => node.type.name === "table"
)
table?.node.descendants((node) => {
if (node.type.name === "table") {
return false
}
if (["tableCell", "tableHeader"].includes(node.type.name)) {
cellCount += 1
}
})
const allCellsSelected = cellCount === selection.ranges.length
if (!allCellsSelected) {
return false
if (["tableCell", "tableHeader"].includes(node.type.name)) {
cellCount += 1;
}
});
editor.commands.deleteTable()
const allCellsSelected = cellCount === selection.ranges.length;
return true
}
if (!allCellsSelected) {
return false;
}
editor.commands.deleteTable();
return true;
};
@@ -1,21 +1,21 @@
import { NodeType, Schema } from "prosemirror-model"
import { NodeType, Schema } from "prosemirror-model";
export function getTableNodeTypes(schema: Schema): { [key: string]: NodeType } {
if (schema.cached.tableNodeTypes) {
return schema.cached.tableNodeTypes
if (schema.cached.tableNodeTypes) {
return schema.cached.tableNodeTypes;
}
const roles: { [key: string]: NodeType } = {};
Object.keys(schema.nodes).forEach((type) => {
const nodeType = schema.nodes[type];
if (nodeType.spec.tableRole) {
roles[nodeType.spec.tableRole] = nodeType;
}
});
const roles: { [key: string]: NodeType } = {}
schema.cached.tableNodeTypes = roles;
Object.keys(schema.nodes).forEach((type) => {
const nodeType = schema.nodes[type]
if (nodeType.spec.tableRole) {
roles[nodeType.spec.tableRole] = nodeType
}
})
schema.cached.tableNodeTypes = roles
return roles
return roles;
}
@@ -1,5 +1,5 @@
import { CellSelection } from "@tiptap/prosemirror-tables"
import { CellSelection } from "@tiptap/prosemirror-tables";
export function isCellSelection(value: unknown): value is CellSelection {
return value instanceof CellSelection
return value instanceof CellSelection;
}
@@ -29,11 +29,13 @@ interface CustomEditorProps {
forwardedRef?: any;
mentionHighlights?: string[];
mentionSuggestions?: IMentionSuggestion[];
cancelUploadImage?: () => any;
}
export const useEditor = ({
uploadFile,
deleteFile,
cancelUploadImage,
editorProps = {},
value,
extensions = [],
@@ -42,7 +44,7 @@ export const useEditor = ({
forwardedRef,
setShouldShowAlert,
mentionHighlights,
mentionSuggestions
mentionSuggestions,
}: CustomEditorProps) => {
const editor = useCustomEditor(
{
@@ -50,7 +52,17 @@ export const useEditor = ({
...CoreEditorProps(uploadFile, setIsSubmitting),
...editorProps,
},
extensions: [...CoreEditorExtensions({ mentionSuggestions: mentionSuggestions ?? [], mentionHighlights: mentionHighlights ?? []}, deleteFile), ...extensions],
extensions: [
...CoreEditorExtensions(
{
mentionSuggestions: mentionSuggestions ?? [],
mentionHighlights: mentionHighlights ?? [],
},
deleteFile,
cancelUploadImage,
),
...extensions,
],
content:
typeof value === "string" && value.trim() !== "" ? value : "<p></p>",
onUpdate: async ({ editor }) => {
@@ -82,4 +94,4 @@ export const useEditor = ({
}
return editor;
};
};
@@ -7,7 +7,7 @@ import {
} from "react";
import { CoreReadOnlyEditorExtensions } from "../../ui/read-only/extensions";
import { CoreReadOnlyEditorProps } from "../../ui/read-only/props";
import { EditorProps } from '@tiptap/pm/view';
import { EditorProps } from "@tiptap/pm/view";
import { IMentionSuggestion } from "../../types/mention-suggestion";
interface CustomReadOnlyEditorProps {
@@ -19,7 +19,14 @@ interface CustomReadOnlyEditorProps {
mentionSuggestions?: IMentionSuggestion[];
}
export const useReadOnlyEditor = ({ value, forwardedRef, extensions = [], editorProps = {}, mentionHighlights, mentionSuggestions}: CustomReadOnlyEditorProps) => {
export const useReadOnlyEditor = ({
value,
forwardedRef,
extensions = [],
editorProps = {},
mentionHighlights,
mentionSuggestions,
}: CustomReadOnlyEditorProps) => {
const editor = useCustomEditor({
editable: false,
content:
@@ -28,7 +35,13 @@ export const useReadOnlyEditor = ({ value, forwardedRef, extensions = [], editor
...CoreReadOnlyEditorProps,
...editorProps,
},
extensions: [...CoreReadOnlyEditorExtensions({ mentionSuggestions: mentionSuggestions ?? [], mentionHighlights: mentionHighlights ?? []}), ...extensions],
extensions: [
...CoreReadOnlyEditorExtensions({
mentionSuggestions: mentionSuggestions ?? [],
mentionHighlights: mentionHighlights ?? [],
}),
...extensions,
],
});
const hasIntiliazedContent = useRef(false);
+29 -27
View File
@@ -1,11 +1,11 @@
import { Mention, MentionOptions } from '@tiptap/extension-mention'
import { mergeAttributes } from '@tiptap/core'
import { ReactNodeViewRenderer } from '@tiptap/react'
import mentionNodeView from './mentionNodeView'
import { IMentionHighlight } from '../../types/mention-suggestion'
import { Mention, MentionOptions } from "@tiptap/extension-mention";
import { mergeAttributes } from "@tiptap/core";
import { ReactNodeViewRenderer } from "@tiptap/react";
import mentionNodeView from "./mentionNodeView";
import { IMentionHighlight } from "../../types/mention-suggestion";
export interface CustomMentionOptions extends MentionOptions {
mentionHighlights: IMentionHighlight[]
readonly?: boolean
mentionHighlights: IMentionHighlight[];
readonly?: boolean;
}
export const CustomMention = Mention.extend<CustomMentionOptions>({
@@ -21,35 +21,37 @@ export const CustomMention = Mention.extend<CustomMentionOptions>({
default: null,
},
self: {
default: false
default: false,
},
redirect_uri: {
default: "/"
}
}
default: "/",
},
};
},
addNodeView() {
return ReactNodeViewRenderer(mentionNodeView)
return ReactNodeViewRenderer(mentionNodeView);
},
parseHTML() {
return [{
tag: 'mention-component',
getAttrs: (node: string | HTMLElement) => {
if (typeof node === 'string') {
return null;
}
return {
id: node.getAttribute('data-mention-id') || '',
target: node.getAttribute('data-mention-target') || '',
label: node.innerText.slice(1) || '',
redirect_uri: node.getAttribute('redirect_uri')
}
return [
{
tag: "mention-component",
getAttrs: (node: string | HTMLElement) => {
if (typeof node === "string") {
return null;
}
return {
id: node.getAttribute("data-mention-id") || "",
target: node.getAttribute("data-mention-target") || "",
label: node.innerText.slice(1) || "",
redirect_uri: node.getAttribute("redirect_uri"),
};
},
},
}]
];
},
renderHTML({ HTMLAttributes }) {
return ['mention-component', mergeAttributes(HTMLAttributes)]
return ["mention-component", mergeAttributes(HTMLAttributes)];
},
})
});
+17 -10
View File
@@ -2,14 +2,21 @@
import suggestion from "./suggestion";
import { CustomMention } from "./custom";
import { IMentionHighlight, IMentionSuggestion } from "../../types/mention-suggestion";
export const Mentions = (mentionSuggestions: IMentionSuggestion[], mentionHighlights: IMentionHighlight[], readonly) => CustomMention.configure({
HTMLAttributes: {
'class' : "mention",
},
readonly: readonly,
mentionHighlights: mentionHighlights,
suggestion: suggestion(mentionSuggestions),
})
import {
IMentionHighlight,
IMentionSuggestion,
} from "../../types/mention-suggestion";
export const Mentions = (
mentionSuggestions: IMentionSuggestion[],
mentionHighlights: IMentionHighlight[],
readonly,
) =>
CustomMention.configure({
HTMLAttributes: {
class: "mention",
},
readonly: readonly,
mentionHighlights: mentionHighlights,
suggestion: suggestion(mentionSuggestions),
});
@@ -1,12 +1,17 @@
import { ReactRenderer } from '@tiptap/react'
import { ReactRenderer } from "@tiptap/react";
import { Editor } from "@tiptap/core";
import tippy from 'tippy.js'
import tippy from "tippy.js";
import MentionList from './MentionList'
import { IMentionSuggestion } from '../../types/mention-suggestion';
import MentionList from "./MentionList";
import { IMentionSuggestion } from "../../types/mention-suggestion";
const Suggestion = (suggestions: IMentionSuggestion[]) => ({
items: ({ query }: { query: string }) => suggestions.filter(suggestion => suggestion.title.toLowerCase().startsWith(query.toLowerCase())).slice(0, 5),
items: ({ query }: { query: string }) =>
suggestions
.filter((suggestion) =>
suggestion.title.toLowerCase().startsWith(query.toLowerCase()),
)
.slice(0, 5),
render: () => {
let reactRenderer: ReactRenderer | null = null;
let popup: any | null = null;
@@ -30,7 +35,7 @@ const Suggestion = (suggestions: IMentionSuggestion[]) => ({
},
onUpdate: (props: { editor: Editor; clientRect: DOMRect }) => {
reactRenderer?.updateProps(props)
reactRenderer?.updateProps(props);
popup &&
popup[0].setProps({
@@ -49,11 +54,10 @@ const Suggestion = (suggestions: IMentionSuggestion[]) => ({
},
onExit: () => {
popup?.[0].destroy();
reactRenderer?.destroy()
reactRenderer?.destroy();
},
}
};
},
})
});
export default Suggestion;
@@ -1,16 +0,0 @@
const InsertBottomTableIcon = (props: any) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={24}
height={24}
viewBox="0 -960 960 960"
{...props}
>
<path
d="M212.309-152.31q-30.308 0-51.308-21t-21-51.307V-360q0-30.307 21-51.307 21-21 51.308-21h535.382q30.308 0 51.308 21t21 51.307v135.383q0 30.307-21 51.307-21 21-51.308 21H212.309Zm0-375.383q-30.308 0-51.308-21t-21-51.307v-135.383q0-30.307 21-51.307 21-21 51.308-21h535.382q30.308 0 51.308 21t21 51.307V-600q0 30.307-21 51.307-21 21-51.308 21H212.309Zm535.382-219.998H212.309q-4.616 0-8.463 3.846-3.846 3.846-3.846 8.462V-600q0 4.616 3.846 8.462 3.847 3.847 8.463 3.847h535.382q4.616 0 8.463-3.847Q760-595.384 760-600v-135.383q0-4.616-3.846-8.462-3.847-3.846-8.463-3.846ZM200-587.691v-160 160Z"
fill="rgb(var(--color-text-300))"
/>
</svg>
);
export default InsertBottomTableIcon;
@@ -1,15 +0,0 @@
const InsertLeftTableIcon = (props: any) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={24}
height={24}
viewBox="0 -960 960 960"
{...props}
>
<path
d="M224.617-140.001q-30.307 0-51.307-21-21-21-21-51.308v-535.382q0-30.308 21-51.308t51.307-21H360q30.307 0 51.307 21 21 21 21 51.308v535.382q0 30.308-21 51.308t-51.307 21H224.617Zm375.383 0q-30.307 0-51.307-21-21-21-21-51.308v-535.382q0-30.308 21-51.308t51.307-21h135.383q30.307 0 51.307 21 21 21 21 51.308v535.382q0 30.308-21 51.308t-51.307 21H600Zm147.691-607.69q0-4.616-3.846-8.463-3.846-3.846-8.462-3.846H600q-4.616 0-8.462 3.846-3.847 3.847-3.847 8.463v535.382q0 4.616 3.847 8.463Q595.384-200 600-200h135.383q4.616 0 8.462-3.846 3.846-3.847 3.846-8.463v-535.382ZM587.691-200h160-160Z"
fill="rgb(var(--color-text-300))"
/>
</svg>
);
export default InsertLeftTableIcon;
@@ -1,16 +0,0 @@
const InsertRightTableIcon = (props: any) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={24}
height={24}
viewBox="0 -960 960 960"
{...props}
>
<path
d="M600-140.001q-30.307 0-51.307-21-21-21-21-51.308v-535.382q0-30.308 21-51.308t51.307-21h135.383q30.307 0 51.307 21 21 21 21 51.308v535.382q0 30.308-21 51.308t-51.307 21H600Zm-375.383 0q-30.307 0-51.307-21-21-21-21-51.308v-535.382q0-30.308 21-51.308t51.307-21H360q30.307 0 51.307 21 21 21 21 51.308v535.382q0 30.308-21 51.308t-51.307 21H224.617Zm-12.308-607.69v535.382q0 4.616 3.846 8.463 3.846 3.846 8.462 3.846H360q4.616 0 8.462-3.846 3.847-3.847 3.847-8.463v-535.382q0-4.616-3.847-8.463Q364.616-760 360-760H224.617q-4.616 0-8.462 3.846-3.846 3.847-3.846 8.463Zm160 547.691h-160 160Z"
fill="rgb(var(--color-text-300))"
/>
</svg>
);
export default InsertRightTableIcon;
@@ -1,15 +0,0 @@
const InsertTopTableIcon = (props: any) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={24}
height={24}
viewBox="0 -960 960 960"
{...props}
>
<path
d="M212.309-527.693q-30.308 0-51.308-21t-21-51.307v-135.383q0-30.307 21-51.307 21-21 51.308-21h535.382q30.308 0 51.308 21t21 51.307V-600q0 30.307-21 51.307-21 21-51.308 21H212.309Zm0 375.383q-30.308 0-51.308-21t-21-51.307V-360q0-30.307 21-51.307 21-21 51.308-21h535.382q30.308 0 51.308 21t21 51.307v135.383q0 30.307-21 51.307-21 21-51.308 21H212.309Zm0-59.999h535.382q4.616 0 8.463-3.846 3.846-3.846 3.846-8.462V-360q0-4.616-3.846-8.462-3.847-3.847-8.463-3.847H212.309q-4.616 0-8.463 3.847Q200-364.616 200-360v135.383q0 4.616 3.846 8.462 3.847 3.846 8.463 3.846Zm-12.309-160v160-160Z"
fill="rgb(var(--color-text-300))"
/>
</svg>
);
export default InsertTopTableIcon;
@@ -1,77 +0,0 @@
import * as React from 'react';
// next-themes
import { useTheme } from "next-themes";
// tooltip2
import { Tooltip2 } from "@blueprintjs/popover2";
type Props = {
tooltipHeading?: string;
tooltipContent: string | React.ReactNode;
position?:
| "top"
| "right"
| "bottom"
| "left"
| "auto"
| "auto-end"
| "auto-start"
| "bottom-left"
| "bottom-right"
| "left-bottom"
| "left-top"
| "right-bottom"
| "right-top"
| "top-left"
| "top-right";
children: JSX.Element;
disabled?: boolean;
className?: string;
openDelay?: number;
closeDelay?: number;
};
export const Tooltip: React.FC<Props> = ({
tooltipHeading,
tooltipContent,
position = "top",
children,
disabled = false,
className = "",
openDelay = 200,
closeDelay,
}) => {
const { theme } = useTheme();
return (
<Tooltip2
disabled={disabled}
hoverOpenDelay={openDelay}
hoverCloseDelay={closeDelay}
content={
<div
className={`relative z-50 max-w-xs gap-1 rounded-md p-2 text-xs shadow-md ${
theme === "custom"
? "bg-custom-background-100 text-custom-text-200"
: "bg-black text-gray-400"
} break-words overflow-hidden ${className}`}
>
{tooltipHeading && (
<h5
className={`font-medium ${
theme === "custom" ? "text-custom-text-100" : "text-white"
}`}
>
{tooltipHeading}
</h5>
)}
{tooltipContent}
</div>
}
position={position}
renderTarget={({ isOpen: isTooltipOpen, ref: eleReference, ...tooltipProps }) =>
React.cloneElement(children, { ref: eleReference, ...tooltipProps, ...children.props })
}
/>
);
};
@@ -15,7 +15,11 @@ interface ImageNode extends ProseMirrorNode {
const TrackImageDeletionPlugin = (deleteImage: DeleteImage): Plugin =>
new Plugin({
key: deleteKey,
appendTransaction: (transactions: readonly Transaction[], oldState: EditorState, newState: EditorState) => {
appendTransaction: (
transactions: readonly Transaction[],
oldState: EditorState,
newState: EditorState,
) => {
const newImageSources = new Set<string>();
newState.doc.descendants((node) => {
if (node.type.name === IMAGE_NODE_TYPE) {
@@ -55,7 +59,10 @@ const TrackImageDeletionPlugin = (deleteImage: DeleteImage): Plugin =>
export default TrackImageDeletionPlugin;
async function onNodeDeleted(src: string, deleteImage: DeleteImage): Promise<void> {
async function onNodeDeleted(
src: string,
deleteImage: DeleteImage,
): Promise<void> {
try {
const assetUrlWithWorkspaceId = new URL(src).pathname.substring(1);
const resStatus = await deleteImage(assetUrlWithWorkspaceId);
@@ -4,7 +4,7 @@ import { Decoration, DecorationSet, EditorView } from "@tiptap/pm/view";
const uploadKey = new PluginKey("upload-image");
const UploadImagesPlugin = () =>
const UploadImagesPlugin = (cancelUploadImage?: () => any) =>
new Plugin({
key: uploadKey,
state: {
@@ -21,15 +21,46 @@ const UploadImagesPlugin = () =>
const placeholder = document.createElement("div");
placeholder.setAttribute("class", "img-placeholder");
const image = document.createElement("img");
image.setAttribute("class", "opacity-10 rounded-lg border border-custom-border-300");
image.setAttribute(
"class",
"opacity-10 rounded-lg border border-custom-border-300",
);
image.src = src;
placeholder.appendChild(image);
// Create cancel button
const cancelButton = document.createElement("button");
cancelButton.style.position = "absolute";
cancelButton.style.right = "3px";
cancelButton.style.top = "3px";
cancelButton.setAttribute("class", "opacity-90 rounded-lg");
cancelButton.onclick = () => {
cancelUploadImage?.();
};
// Create an SVG element from the SVG string
const svgString = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-x-circle"><circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/></svg>`;
const parser = new DOMParser();
const svgElement = parser.parseFromString(
svgString,
"image/svg+xml",
).documentElement;
cancelButton.appendChild(svgElement);
placeholder.appendChild(cancelButton);
const deco = Decoration.widget(pos + 1, placeholder, {
id,
});
set = set.add(tr.doc, [deco]);
} else if (action && action.remove) {
set = set.remove(set.find(undefined, undefined, (spec) => spec.id == action.remove.id));
set = set.remove(
set.find(
undefined,
undefined,
(spec) => spec.id == action.remove.id,
),
);
}
return set;
},
@@ -48,19 +79,39 @@ function findPlaceholder(state: EditorState, id: {}) {
const found = decos.find(
undefined,
undefined,
(spec: { id: number | undefined }) => spec.id == id
(spec: { id: number | undefined }) => spec.id == id,
);
return found.length ? found[0].from : null;
}
const removePlaceholder = (view: EditorView, id: {}) => {
const removePlaceholderTr = view.state.tr.setMeta(uploadKey, {
remove: { id },
});
view.dispatch(removePlaceholderTr);
};
export async function startImageUpload(
file: File,
view: EditorView,
pos: number,
uploadFile: UploadImage,
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void
setIsSubmitting?: (
isSubmitting: "submitting" | "submitted" | "saved",
) => void,
) {
if (!file) {
alert("No file selected. Please select a file to upload.");
return;
}
if (!file.type.includes("image/")) {
alert("Invalid file type. Please select an image file.");
return;
}
if (file.size > 5 * 1024 * 1024) {
alert("File size too large. Please select a file smaller than 5MB.");
return;
}
@@ -82,28 +133,42 @@ export async function startImageUpload(
view.dispatch(tr);
};
// Handle FileReader errors
reader.onerror = (error) => {
console.error("FileReader error: ", error);
removePlaceholder(view, id);
return;
};
setIsSubmitting?.("submitting");
const src = await UploadImageHandler(file, uploadFile);
const { schema } = view.state;
pos = findPlaceholder(view.state, id);
if (pos == null) return;
const imageSrc = typeof src === "object" ? reader.result : src;
try {
const src = await UploadImageHandler(file, uploadFile);
const { schema } = view.state;
pos = findPlaceholder(view.state, id);
const node = schema.nodes.image.create({ src: imageSrc });
const transaction = view.state.tr
.replaceWith(pos, pos, node)
.setMeta(uploadKey, { remove: { id } });
view.dispatch(transaction);
if (pos == null) return;
const imageSrc = typeof src === "object" ? reader.result : src;
const node = schema.nodes.image.create({ src: imageSrc });
const transaction = view.state.tr
.replaceWith(pos, pos, node)
.setMeta(uploadKey, { remove: { id } });
view.dispatch(transaction);
} catch (error) {
console.error("Upload error: ", error);
removePlaceholder(view, id);
}
}
const UploadImageHandler = (file: File,
uploadFile: UploadImage
const UploadImageHandler = (
file: File,
uploadFile: UploadImage,
): Promise<string> => {
try {
return new Promise(async (resolve, reject) => {
try {
const imageUrl = await uploadFile(file)
const imageUrl = await uploadFile(file);
const image = new Image();
image.src = imageUrl;
@@ -118,9 +183,6 @@ const UploadImageHandler = (file: File,
}
});
} catch (error) {
if (error instanceof Error) {
console.log(error.message);
}
return Promise.reject(error);
}
};
+21 -4
View File
@@ -5,7 +5,9 @@ import { UploadImage } from "../types/upload-image";
export function CoreEditorProps(
uploadFile: UploadImage,
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void
setIsSubmitting?: (
isSubmitting: "submitting" | "submitted" | "saved",
) => void,
): EditorProps {
return {
attributes: {
@@ -32,7 +34,11 @@ export function CoreEditorProps(
}
}
}
if (event.clipboardData && event.clipboardData.files && event.clipboardData.files[0]) {
if (
event.clipboardData &&
event.clipboardData.files &&
event.clipboardData.files[0]
) {
event.preventDefault();
const file = event.clipboardData.files[0];
const pos = view.state.selection.from;
@@ -51,7 +57,12 @@ export function CoreEditorProps(
}
}
}
if (!moved && event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files[0]) {
if (
!moved &&
event.dataTransfer &&
event.dataTransfer.files &&
event.dataTransfer.files[0]
) {
event.preventDefault();
const file = event.dataTransfer.files[0];
const coordinates = view.posAtCoords({
@@ -59,7 +70,13 @@ export function CoreEditorProps(
top: event.clientY,
});
if (coordinates) {
startImageUpload(file, view, coordinates.pos - 1, uploadFile, setIsSubmitting);
startImageUpload(
file,
view,
coordinates.pos - 1,
uploadFile,
setIsSubmitting,
);
}
return true;
}
@@ -18,9 +18,10 @@ import { isValidHttpUrl } from "../../lib/utils";
import { Mentions } from "../mentions";
import { IMentionSuggestion } from "../../types/mention-suggestion";
export const CoreReadOnlyEditorExtensions = (
mentionConfig: { mentionSuggestions: IMentionSuggestion[], mentionHighlights: string[] },
) => [
export const CoreReadOnlyEditorExtensions = (mentionConfig: {
mentionSuggestions: IMentionSuggestion[];
mentionHighlights: string[];
}) => [
StarterKit.configure({
bulletList: {
HTMLAttributes: {
@@ -57,41 +58,45 @@ export const CoreReadOnlyEditorExtensions = (
},
gapcursor: false,
}),
Gapcursor,
TiptapLink.configure({
protocols: ["http", "https"],
validate: (url) => isValidHttpUrl(url),
HTMLAttributes: {
class:
"text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer",
},
}),
ReadOnlyImageExtension.configure({
HTMLAttributes: {
class: "rounded-lg border border-custom-border-300",
},
}),
TiptapUnderline,
TextStyle,
Color,
TaskList.configure({
HTMLAttributes: {
class: "not-prose pl-2",
},
}),
TaskItem.configure({
HTMLAttributes: {
class: "flex items-start my-4",
},
nested: true,
}),
Markdown.configure({
html: true,
transformCopiedText: true,
}),
Table,
TableHeader,
TableCell,
TableRow,
Mentions(mentionConfig.mentionSuggestions, mentionConfig.mentionHighlights, true),
];
Gapcursor,
TiptapLink.configure({
protocols: ["http", "https"],
validate: (url) => isValidHttpUrl(url),
HTMLAttributes: {
class:
"text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer",
},
}),
ReadOnlyImageExtension.configure({
HTMLAttributes: {
class: "rounded-lg border border-custom-border-300",
},
}),
TiptapUnderline,
TextStyle,
Color,
TaskList.configure({
HTMLAttributes: {
class: "not-prose pl-2",
},
}),
TaskItem.configure({
HTMLAttributes: {
class: "flex items-start my-4",
},
nested: true,
}),
Markdown.configure({
html: true,
transformCopiedText: true,
}),
Table,
TableHeader,
TableCell,
TableRow,
Mentions(
mentionConfig.mentionSuggestions,
mentionConfig.mentionHighlights,
true,
),
];
@@ -1,7 +1,6 @@
import { EditorProps } from "@tiptap/pm/view";
export const CoreReadOnlyEditorProps: EditorProps =
{
export const CoreReadOnlyEditorProps: EditorProps = {
attributes: {
class: `prose prose-brand max-w-full prose-headings:font-display font-default focus:outline-none`,
},
+55 -55
View File
@@ -10,25 +10,25 @@ The `@plane/lite-text-editor` package extends from the `editor-core` package, in
`LiteTextEditor` & `LiteTextEditorWithRef`
- **Read Only Editor Instances**: We have added a really light weight *Read Only* Editor instance for the Lite editor types (with and without Ref)
- **Read Only Editor Instances**: We have added a really light weight _Read Only_ Editor instance for the Lite editor types (with and without Ref)
`LiteReadOnlyEditor` &`LiteReadOnlyEditorWithRef`
## LiteTextEditor
| Prop | Type | Description |
| --- | --- | --- |
| `uploadFile` | `(file: File) => Promise<string>` | A function that handles file upload. It takes a file as input and handles the process of uploading that file. |
| `deleteFile` | `(assetUrlWithWorkspaceId: string) => Promise<any>` | A function that handles deleting an image. It takes the asset url from your bucket and handles the process of deleting that image. |
| `value` | `html string` | The initial content of the editor. |
| `onEnterKeyPress` | `(e) => void` | The event that happens on Enter key press |
| `debouncedUpdatesEnabled` | `boolean` | If set to true, the `onChange` event handler is debounced, meaning it will only be invoked after the specified delay (default 1500ms) once the user has stopped typing. |
| `onChange` | `(json: any, html: string) => void` | This function is invoked whenever the content of the editor changes. It is passed the new content in both JSON and HTML formats. |
| `setIsSubmitting` | `(isSubmitting: "submitting" \| "submitted" \| "saved") => void` | This function is called to update the submission status. |
| `setShouldShowAlert` | `(showAlert: boolean) => void` | This function is used to show or hide an alert incase of content not being "saved". |
| `noBorder` | `boolean` | If set to true, the editor will not have a border. |
| `borderOnFocus` | `boolean` | If set to true, the editor will show a border when it is focused. |
| `customClassName` | `string` | This is a custom CSS class that can be applied to the editor. |
| `editorContentCustomClassNames` | `string` | This is a custom CSS class that can be applied to the editor content. |
| Prop | Type | Description |
| ------------------------------- | ---------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `uploadFile` | `(file: File) => Promise<string>` | A function that handles file upload. It takes a file as input and handles the process of uploading that file. |
| `deleteFile` | `(assetUrlWithWorkspaceId: string) => Promise<any>` | A function that handles deleting an image. It takes the asset url from your bucket and handles the process of deleting that image. |
| `value` | `html string` | The initial content of the editor. |
| `onEnterKeyPress` | `(e) => void` | The event that happens on Enter key press |
| `debouncedUpdatesEnabled` | `boolean` | If set to true, the `onChange` event handler is debounced, meaning it will only be invoked after the specified delay (default 1500ms) once the user has stopped typing. |
| `onChange` | `(json: any, html: string) => void` | This function is invoked whenever the content of the editor changes. It is passed the new content in both JSON and HTML formats. |
| `setIsSubmitting` | `(isSubmitting: "submitting" \| "submitted" \| "saved") => void` | This function is called to update the submission status. |
| `setShouldShowAlert` | `(showAlert: boolean) => void` | This function is used to show or hide an alert incase of content not being "saved". |
| `noBorder` | `boolean` | If set to true, the editor will not have a border. |
| `borderOnFocus` | `boolean` | If set to true, the editor will show a border when it is focused. |
| `customClassName` | `string` | This is a custom CSS class that can be applied to the editor. |
| `editorContentCustomClassNames` | `string` | This is a custom CSS class that can be applied to the editor content. |
### Usage
@@ -36,62 +36,62 @@ The `@plane/lite-text-editor` package extends from the `editor-core` package, in
```tsx
<LiteTextEditor
onEnterKeyPress={handleSubmit(handleCommentUpdate)}
uploadFile={fileService.getUploadFileFunction(workspaceSlug)}
deleteFile={fileService.deleteImage}
value={value}
debouncedUpdatesEnabled={false}
customClassName="min-h-[50px] p-3 shadow-sm"
onChange={(comment_json: Object, comment_html: string) => {
onChange(comment_html);
}}
/>
onEnterKeyPress={handleSubmit(handleCommentUpdate)}
uploadFile={fileService.getUploadFileFunction(workspaceSlug)}
deleteFile={fileService.deleteImage}
value={value}
debouncedUpdatesEnabled={false}
customClassName="min-h-[50px] p-3 shadow-sm"
onChange={(comment_json: Object, comment_html: string) => {
onChange(comment_html);
}}
/>
```
2. Example of how to use the `LiteTextEditorWithRef` component
```tsx
const editorRef = useRef<any>(null);
const editorRef = useRef<any>(null);
// can use it to set the editor's value
editorRef.current?.setEditorValue(`${watch("description_html")}`);
// can use it to set the editor's value
editorRef.current?.setEditorValue(`${watch("description_html")}`);
// can use it to clear the editor
editorRef?.current?.clearEditor();
// can use it to clear the editor
editorRef?.current?.clearEditor();
return (
<LiteTextEditorWithRef
onEnterKeyPress={handleSubmit(handleCommentUpdate)}
uploadFile={fileService.getUploadFileFunction(workspaceSlug)}
deleteFile={fileService.deleteImage}
ref={editorRef}
value={value}
debouncedUpdatesEnabled={false}
customClassName="min-h-[50px] p-3 shadow-sm"
onChange={(comment_json: Object, comment_html: string) => {
onChange(comment_html);
}}
/>
)
return (
<LiteTextEditorWithRef
onEnterKeyPress={handleSubmit(handleCommentUpdate)}
uploadFile={fileService.getUploadFileFunction(workspaceSlug)}
deleteFile={fileService.deleteImage}
ref={editorRef}
value={value}
debouncedUpdatesEnabled={false}
customClassName="min-h-[50px] p-3 shadow-sm"
onChange={(comment_json: Object, comment_html: string) => {
onChange(comment_html);
}}
/>
);
```
## LiteReadOnlyEditor
| Prop | Type | Description |
| --- | --- | --- |
| `value` | `html string` | The initial content of the editor. |
| `noBorder` | `boolean` | If set to true, the editor will not have a border. |
| `borderOnFocus` | `boolean` | If set to true, the editor will show a border when it is focused. |
| `customClassName` | `string` | This is a custom CSS class that can be applied to the editor. |
| `editorContentCustomClassNames` | `string` | This is a custom CSS class that can be applied to the editor content. |
| Prop | Type | Description |
| ------------------------------- | ------------- | --------------------------------------------------------------------- |
| `value` | `html string` | The initial content of the editor. |
| `noBorder` | `boolean` | If set to true, the editor will not have a border. |
| `borderOnFocus` | `boolean` | If set to true, the editor will show a border when it is focused. |
| `customClassName` | `string` | This is a custom CSS class that can be applied to the editor. |
| `editorContentCustomClassNames` | `string` | This is a custom CSS class that can be applied to the editor content. |
### Usage
Here is an example of how to use the `RichReadOnlyEditor` component
```tsx
<LiteReadOnlyEditor
value={comment.comment_html}
customClassName="text-xs border border-custom-border-200 bg-custom-background-100"
/>
<LiteReadOnlyEditor
value={comment.comment_html}
customClassName="text-xs border border-custom-border-200 bg-custom-background-100"
/>
```
@@ -29,6 +29,7 @@
},
"dependencies": {
"@plane/editor-core": "*",
"@plane/ui": "*",
"@tiptap/extension-list-item": "^2.1.11",
"class-variance-authority": "^0.7.0",
"clsx": "^1.2.1",
@@ -1,3 +1,3 @@
export { LiteTextEditor, LiteTextEditorWithRef } from "./ui";
export { LiteReadOnlyEditor, LiteReadOnlyEditorWithRef } from "./ui/read-only";
export type { IMentionSuggestion, IMentionHighlight } from "./ui"
export type { IMentionSuggestion, IMentionHighlight } from "./ui";
@@ -31,7 +31,7 @@ interface ILiteTextEditor {
editorContentCustomClassNames?: string;
onChange?: (json: any, html: string) => void;
setIsSubmitting?: (
isSubmitting: "submitting" | "submitted" | "saved"
isSubmitting: "submitting" | "submitted" | "saved",
) => void;
setShouldShowAlert?: (showAlert: boolean) => void;
forwardedRef?: any;
@@ -47,6 +47,7 @@ interface ILiteTextEditor {
}[];
};
onEnterKeyPress?: (e?: any) => void;
cancelUploadImage?: () => any;
mentionHighlights?: string[];
mentionSuggestions?: IMentionSuggestion[];
submitButton?: React.ReactNode;
@@ -64,6 +65,7 @@ interface EditorHandle {
const LiteTextEditor = (props: LiteTextEditorProps) => {
const {
onChange,
cancelUploadImage,
debouncedUpdatesEnabled,
setIsSubmitting,
setShouldShowAlert,
@@ -84,6 +86,7 @@ const LiteTextEditor = (props: LiteTextEditorProps) => {
const editor = useEditor({
onChange,
cancelUploadImage,
debouncedUpdatesEnabled,
setIsSubmitting,
setShouldShowAlert,
@@ -126,7 +129,7 @@ const LiteTextEditor = (props: LiteTextEditorProps) => {
};
const LiteTextEditorWithRef = React.forwardRef<EditorHandle, ILiteTextEditor>(
(props, ref) => <LiteTextEditor {...props} forwardedRef={ref} />
(props, ref) => <LiteTextEditor {...props} forwardedRef={ref} />,
);
LiteTextEditorWithRef.displayName = "LiteTextEditorWithRef";
@@ -6,8 +6,9 @@ type Props = {
};
export const Icon: React.FC<Props> = ({ iconName, className = "" }) => (
<span className={`material-symbols-rounded text-sm leading-5 font-light ${className}`}>
<span
className={`material-symbols-rounded text-sm leading-5 font-light ${className}`}
>
{iconName}
</span>
);
@@ -14,8 +14,8 @@ import {
TableItem,
UnderLineItem,
} from "@plane/editor-core";
import { Tooltip } from "../../tooltip";
import { UploadImage } from "../..";
import { Tooltip } from "@plane/ui";
import { UploadImage } from "../../";
export interface BubbleMenuItem {
name: string;
+44 -40
View File
@@ -10,24 +10,24 @@ The `@plane/rich-text-editor` package extends from the `editor-core` package, in
`RichTextEditor` & `RichTextEditorWithRef`
- **Read Only Editor Instances**: We have added a really light weight *Read Only* Editor instance for the Rich editor types (with and without Ref)
- **Read Only Editor Instances**: We have added a really light weight _Read Only_ Editor instance for the Rich editor types (with and without Ref)
`RichReadOnlyEditor` &`RichReadOnlyEditorWithRef`
## RichTextEditor
| Prop | Type | Description |
| --- | --- | --- |
| `uploadFile` | `(file: File) => Promise<string>` | A function that handles file upload. It takes a file as input and handles the process of uploading that file. |
| `deleteFile` | `(assetUrlWithWorkspaceId: string) => Promise<any>` | A function that handles deleting an image. It takes the asset url from your bucket and handles the process of deleting that image. |
| `value` | `html string` | The initial content of the editor. |
| `debouncedUpdatesEnabled` | `boolean` | If set to true, the `onChange` event handler is debounced, meaning it will only be invoked after the specified delay (default 1500ms) once the user has stopped typing. |
| `onChange` | `(json: any, html: string) => void` | This function is invoked whenever the content of the editor changes. It is passed the new content in both JSON and HTML formats. |
| `setIsSubmitting` | `(isSubmitting: "submitting" \| "submitted" \| "saved") => void` | This function is called to update the submission status. |
| `setShouldShowAlert` | `(showAlert: boolean) => void` | This function is used to show or hide an alert incase of content not being "saved". |
| `noBorder` | `boolean` | If set to true, the editor will not have a border. |
| `borderOnFocus` | `boolean` | If set to true, the editor will show a border when it is focused. |
| `customClassName` | `string` | This is a custom CSS class that can be applied to the editor. |
| `editorContentCustomClassNames` | `string` | This is a custom CSS class that can be applied to the editor content. |
| Prop | Type | Description |
| ------------------------------- | ---------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `uploadFile` | `(file: File) => Promise<string>` | A function that handles file upload. It takes a file as input and handles the process of uploading that file. |
| `deleteFile` | `(assetUrlWithWorkspaceId: string) => Promise<any>` | A function that handles deleting an image. It takes the asset url from your bucket and handles the process of deleting that image. |
| `value` | `html string` | The initial content of the editor. |
| `debouncedUpdatesEnabled` | `boolean` | If set to true, the `onChange` event handler is debounced, meaning it will only be invoked after the specified delay (default 1500ms) once the user has stopped typing. |
| `onChange` | `(json: any, html: string) => void` | This function is invoked whenever the content of the editor changes. It is passed the new content in both JSON and HTML formats. |
| `setIsSubmitting` | `(isSubmitting: "submitting" \| "submitted" \| "saved") => void` | This function is called to update the submission status. |
| `setShouldShowAlert` | `(showAlert: boolean) => void` | This function is used to show or hide an alert incase of content not being "saved". |
| `noBorder` | `boolean` | If set to true, the editor will not have a border. |
| `borderOnFocus` | `boolean` | If set to true, the editor will show a border when it is focused. |
| `customClassName` | `string` | This is a custom CSS class that can be applied to the editor. |
| `editorContentCustomClassNames` | `string` | This is a custom CSS class that can be applied to the editor content. |
### Usage
@@ -57,43 +57,47 @@ The `@plane/rich-text-editor` package extends from the `editor-core` package, in
2. Example of how to use the `RichTextEditorWithRef` component
```tsx
const editorRef = useRef<any>(null);
const editorRef = useRef<any>(null);
// can use it to set the editor's value
editorRef.current?.setEditorValue(`${watch("description_html")}`);
// can use it to set the editor's value
editorRef.current?.setEditorValue(`${watch("description_html")}`);
// can use it to clear the editor
editorRef?.current?.clearEditor();
// can use it to clear the editor
editorRef?.current?.clearEditor();
return (<RichTextEditorWithRef
uploadFile={fileService.getUploadFileFunction(workspaceSlug as string)}
deleteFile={fileService.deleteImage}
ref={editorRef}
debouncedUpdatesEnabled={false}
value={value}
customClassName="min-h-[150px]"
onChange={(description: Object, description_html: string) => {
onChange(description_html);
// custom stuff you want to do
} } />)
return (
<RichTextEditorWithRef
uploadFile={fileService.getUploadFileFunction(workspaceSlug as string)}
deleteFile={fileService.deleteImage}
ref={editorRef}
debouncedUpdatesEnabled={false}
value={value}
customClassName="min-h-[150px]"
onChange={(description: Object, description_html: string) => {
onChange(description_html);
// custom stuff you want to do
}}
/>
);
```
## RichReadOnlyEditor
| Prop | Type | Description |
| --- | --- | --- |
| `value` | `html string` | The initial content of the editor. |
| `noBorder` | `boolean` | If set to true, the editor will not have a border. |
| `borderOnFocus` | `boolean` | If set to true, the editor will show a border when it is focused. |
| `customClassName` | `string` | This is a custom CSS class that can be applied to the editor. |
| `editorContentCustomClassNames` | `string` | This is a custom CSS class that can be applied to the editor content. |
| Prop | Type | Description |
| ------------------------------- | ------------- | --------------------------------------------------------------------- |
| `value` | `html string` | The initial content of the editor. |
| `noBorder` | `boolean` | If set to true, the editor will not have a border. |
| `borderOnFocus` | `boolean` | If set to true, the editor will show a border when it is focused. |
| `customClassName` | `string` | This is a custom CSS class that can be applied to the editor. |
| `editorContentCustomClassNames` | `string` | This is a custom CSS class that can be applied to the editor content. |
### Usage
Here is an example of how to use the `RichReadOnlyEditor` component
```tsx
<RichReadOnlyEditor
value={issueDetails.description_html}
customClassName="p-3 min-h-[50px] shadow-sm" />
<RichReadOnlyEditor
value={issueDetails.description_html}
customClassName="p-3 min-h-[50px] shadow-sm"
/>
```
@@ -2,4 +2,4 @@ import "./styles/github-dark.css";
export { RichTextEditor, RichTextEditorWithRef } from "./ui";
export { RichReadOnlyEditor, RichReadOnlyEditorWithRef } from "./ui/read-only";
export type { IMentionSuggestion, IMentionHighlight } from "./ui"
export type { IMentionSuggestion, IMentionHighlight } from "./ui";
@@ -1,7 +1,7 @@
import HorizontalRule from "@tiptap/extension-horizontal-rule";
import Placeholder from "@tiptap/extension-placeholder";
import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight";
import { common, createLowlight } from 'lowlight'
import { common, createLowlight } from "lowlight";
import { InputRule } from "@tiptap/core";
import ts from "highlight.js/lib/languages/typescript";
@@ -9,51 +9,53 @@ import ts from "highlight.js/lib/languages/typescript";
import SlashCommand from "./slash-command";
import { UploadImage } from "../";
const lowlight = createLowlight(common)
const lowlight = createLowlight(common);
lowlight.register("ts", ts);
export const RichTextEditorExtensions = (
uploadFile: UploadImage,
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void
setIsSubmitting?: (
isSubmitting: "submitting" | "submitted" | "saved",
) => void,
) => [
HorizontalRule.extend({
addInputRules() {
return [
new InputRule({
find: /^(?:---|—-|___\s|\*\*\*\s)$/,
handler: ({ state, range, commands }) => {
commands.splitBlock();
HorizontalRule.extend({
addInputRules() {
return [
new InputRule({
find: /^(?:---|—-|___\s|\*\*\*\s)$/,
handler: ({ state, range, commands }) => {
commands.splitBlock();
const attributes = {};
const { tr } = state;
const start = range.from;
const end = range.to;
// @ts-ignore
tr.replaceWith(start - 1, end, this.type.create(attributes));
},
}),
];
},
}).configure({
HTMLAttributes: {
class: "mb-6 border-t border-custom-border-300",
},
}),
SlashCommand(uploadFile, setIsSubmitting),
CodeBlockLowlight.configure({
lowlight,
}),
Placeholder.configure({
placeholder: ({ node }) => {
if (node.type.name === "heading") {
return `Heading ${node.attrs.level}`;
}
if (node.type.name === "image" || node.type.name === "table") {
return "";
}
const attributes = {};
const { tr } = state;
const start = range.from;
const end = range.to;
// @ts-ignore
tr.replaceWith(start - 1, end, this.type.create(attributes));
},
}),
];
},
}).configure({
HTMLAttributes: {
class: "mb-6 border-t border-custom-border-300",
},
}),
SlashCommand(uploadFile, setIsSubmitting),
CodeBlockLowlight.configure({
lowlight,
}),
Placeholder.configure({
placeholder: ({ node }) => {
if (node.type.name === "heading") {
return `Heading ${node.attrs.level}`;
}
if (node.type.name === "image" || node.type.name === "table") {
return "";
}
return "Press '/' for commands...";
},
includeChildren: true,
}),
];
return "Press '/' for commands...";
},
includeChildren: true,
}),
];
@@ -1,8 +1,13 @@
"use client"
import * as React from 'react';
import { EditorContainer, EditorContentWrapper, getEditorClassNames, useEditor } from '@plane/editor-core';
import { EditorBubbleMenu } from './menus/bubble-menu';
import { RichTextEditorExtensions } from './extensions';
"use client";
import * as React from "react";
import {
EditorContainer,
EditorContentWrapper,
getEditorClassNames,
useEditor,
} from "@plane/editor-core";
import { EditorBubbleMenu } from "./menus/bubble-menu";
import { RichTextEditorExtensions } from "./extensions";
export type UploadImage = (file: File) => Promise<string>;
export type DeleteImage = (assetUrlWithWorkspaceId: string) => Promise<any>;
@@ -14,9 +19,9 @@ export type IMentionSuggestion = {
title: string;
subtitle: string;
redirect_uri: string;
}
};
export type IMentionHighlight = string
export type IMentionHighlight = string;
interface IRichTextEditor {
value: string;
@@ -24,10 +29,13 @@ interface IRichTextEditor {
deleteFile: DeleteImage;
noBorder?: boolean;
borderOnFocus?: boolean;
cancelUploadImage?: () => any;
customClassName?: string;
editorContentCustomClassNames?: string;
onChange?: (json: any, html: string) => void;
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void;
setIsSubmitting?: (
isSubmitting: "submitting" | "submitted" | "saved",
) => void;
setShouldShowAlert?: (showAlert: boolean) => void;
forwardedRef?: any;
debouncedUpdatesEnabled?: boolean;
@@ -54,11 +62,12 @@ const RichTextEditor = ({
uploadFile,
deleteFile,
noBorder,
cancelUploadImage,
borderOnFocus,
customClassName,
forwardedRef,
mentionHighlights,
mentionSuggestions
mentionSuggestions,
}: RichTextEditorProps) => {
const editor = useEditor({
onChange,
@@ -67,14 +76,19 @@ const RichTextEditor = ({
setShouldShowAlert,
value,
uploadFile,
cancelUploadImage,
deleteFile,
forwardedRef,
extensions: RichTextEditorExtensions(uploadFile, setIsSubmitting),
mentionHighlights,
mentionSuggestions
mentionSuggestions,
});
const editorClassNames = getEditorClassNames({ noBorder, borderOnFocus, customClassName });
const editorClassNames = getEditorClassNames({
noBorder,
borderOnFocus,
customClassName,
});
if (!editor) return null;
@@ -82,16 +96,19 @@ const RichTextEditor = ({
<EditorContainer editor={editor} editorClassNames={editorClassNames}>
{editor && <EditorBubbleMenu editor={editor} />}
<div className="flex flex-col">
<EditorContentWrapper editor={editor} editorContentCustomClassNames={editorContentCustomClassNames} />
<EditorContentWrapper
editor={editor}
editorContentCustomClassNames={editorContentCustomClassNames}
/>
</div>
</EditorContainer >
</EditorContainer>
);
};
const RichTextEditorWithRef = React.forwardRef<EditorHandle, IRichTextEditor>((props, ref) => (
<RichTextEditor {...props} forwardedRef={ref} />
));
const RichTextEditorWithRef = React.forwardRef<EditorHandle, IRichTextEditor>(
(props, ref) => <RichTextEditor {...props} forwardedRef={ref} />,
);
RichTextEditorWithRef.displayName = "RichTextEditorWithRef";
export { RichTextEditor, RichTextEditorWithRef};
export { RichTextEditor, RichTextEditorWithRef };
@@ -1,7 +1,19 @@
import { Editor } from "@tiptap/core";
import { Check, Trash } from "lucide-react";
import { Dispatch, FC, SetStateAction, useCallback, useEffect, useRef } from "react";
import { cn, isValidHttpUrl, setLinkEditor, unsetLinkEditor, } from "@plane/editor-core";
import {
Dispatch,
FC,
SetStateAction,
useCallback,
useEffect,
useRef,
} from "react";
import {
cn,
isValidHttpUrl,
setLinkEditor,
unsetLinkEditor,
} from "@plane/editor-core";
interface LinkSelectorProps {
editor: Editor;
@@ -9,7 +21,11 @@ interface LinkSelectorProps {
setIsOpen: Dispatch<SetStateAction<boolean>>;
}
export const LinkSelector: FC<LinkSelectorProps> = ({ editor, isOpen, setIsOpen }) => {
export const LinkSelector: FC<LinkSelectorProps> = ({
editor,
isOpen,
setIsOpen,
}) => {
const inputRef = useRef<HTMLInputElement>(null);
const onLinkSubmit = useCallback(() => {
@@ -31,7 +47,7 @@ export const LinkSelector: FC<LinkSelectorProps> = ({ editor, isOpen, setIsOpen
type="button"
className={cn(
"flex h-full items-center space-x-2 px-3 py-1.5 text-sm font-medium text-custom-text-300 hover:bg-custom-background-100 active:bg-custom-background-100",
{ "bg-custom-background-100": isOpen }
{ "bg-custom-background-100": isOpen },
)}
onClick={() => {
setIsOpen(!isOpen);
@@ -1,10 +1,16 @@
import { BulletListItem, cn, CodeItem, HeadingOneItem, HeadingThreeItem, HeadingTwoItem, NumberedListItem, QuoteItem, TodoListItem } from "@plane/editor-core";
import { Editor } from "@tiptap/react";
import {
Check,
ChevronDown,
TextIcon,
} from "lucide-react";
BulletListItem,
cn,
CodeItem,
HeadingOneItem,
HeadingThreeItem,
HeadingTwoItem,
NumberedListItem,
QuoteItem,
TodoListItem,
} from "@plane/editor-core";
import { Editor } from "@tiptap/react";
import { Check, ChevronDown, TextIcon } from "lucide-react";
import { Dispatch, FC, SetStateAction } from "react";
import { BubbleMenuItem } from ".";
@@ -15,12 +21,17 @@ interface NodeSelectorProps {
setIsOpen: Dispatch<SetStateAction<boolean>>;
}
export const NodeSelector: FC<NodeSelectorProps> = ({ editor, isOpen, setIsOpen }) => {
export const NodeSelector: FC<NodeSelectorProps> = ({
editor,
isOpen,
setIsOpen,
}) => {
const items: BubbleMenuItem[] = [
{
name: "Text",
icon: TextIcon,
command: () => editor.chain().focus().toggleNode("paragraph", "paragraph").run(),
command: () =>
editor.chain().focus().toggleNode("paragraph", "paragraph").run(),
isActive: () =>
editor.isActive("paragraph") &&
!editor.isActive("bulletList") &&
@@ -63,7 +74,10 @@ export const NodeSelector: FC<NodeSelectorProps> = ({ editor, isOpen, setIsOpen
}}
className={cn(
"flex items-center justify-between rounded-sm px-2 py-1 text-sm text-custom-text-200 hover:bg-custom-primary-100/5 hover:text-custom-text-100",
{ "bg-custom-primary-100/5 text-custom-text-100": activeItem.name === item.name }
{
"bg-custom-primary-100/5 text-custom-text-100":
activeItem.name === item.name,
},
)}
>
<div className="flex items-center space-x-2">
@@ -1,6 +1,11 @@
"use client"
import { EditorContainer, EditorContentWrapper, getEditorClassNames, useReadOnlyEditor } from '@plane/editor-core';
import * as React from 'react';
"use client";
import {
EditorContainer,
EditorContentWrapper,
getEditorClassNames,
useReadOnlyEditor,
} from "@plane/editor-core";
import * as React from "react";
interface IRichTextReadOnlyEditor {
value: string;
@@ -35,23 +40,31 @@ const RichReadOnlyEditor = ({
mentionHighlights,
});
const editorClassNames = getEditorClassNames({ noBorder, borderOnFocus, customClassName });
const editorClassNames = getEditorClassNames({
noBorder,
borderOnFocus,
customClassName,
});
if (!editor) return null;
return (
<EditorContainer editor={editor} editorClassNames={editorClassNames}>
<div className="flex flex-col">
<EditorContentWrapper editor={editor} editorContentCustomClassNames={editorContentCustomClassNames} />
<EditorContentWrapper
editor={editor}
editorContentCustomClassNames={editorContentCustomClassNames}
/>
</div>
</EditorContainer >
</EditorContainer>
);
};
const RichReadOnlyEditorWithRef = React.forwardRef<EditorHandle, IRichTextReadOnlyEditor>((props, ref) => (
<RichReadOnlyEditor {...props} forwardedRef={ref} />
));
const RichReadOnlyEditorWithRef = React.forwardRef<
EditorHandle,
IRichTextReadOnlyEditor
>((props, ref) => <RichReadOnlyEditor {...props} forwardedRef={ref} />);
RichReadOnlyEditorWithRef.displayName = "RichReadOnlyEditorWithRef";
export { RichReadOnlyEditor , RichReadOnlyEditorWithRef };
export { RichReadOnlyEditor, RichReadOnlyEditorWithRef };
+10 -3
View File
@@ -42,6 +42,10 @@ type Props = {
* The source of the avatar image
*/
src?: string;
/**
* The custom CSS class name to apply to the component
*/
className?: string;
};
/**
@@ -94,7 +98,7 @@ export const getBorderRadius = (shape: "circle" | "square") => {
case "circle":
return "rounded-full";
case "square":
return "rounded-md";
return "rounded";
default:
return "rounded-full";
}
@@ -119,6 +123,7 @@ export const Avatar: React.FC<Props> = (props) => {
size = "md",
shape = "circle",
src,
className = "",
} = props;
// get size details based on the size prop
@@ -145,14 +150,16 @@ export const Avatar: React.FC<Props> = (props) => {
{src ? (
<img
src={src}
className={`h-full w-full ${getBorderRadius(shape)}`}
className={`h-full w-full ${getBorderRadius(shape)} ${className}`}
alt={name}
/>
) : (
<div
className={`${
sizeInfo.fontSize
} grid place-items-center h-full w-full ${getBorderRadius(shape)}`}
} grid place-items-center h-full w-full ${getBorderRadius(
shape,
)} ${className}`}
style={{
backgroundColor:
fallbackBackgroundColor ?? "rgba(var(--color-primary-500))",
+1 -1
View File
@@ -58,7 +58,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
)}
</button>
);
}
},
);
Button.displayName = "plane-ui-button";
+1 -1
View File
@@ -102,7 +102,7 @@ export const buttonStyling: IButtonStyling = {
export const getButtonStyling = (
variant: TButtonVariant,
size: TButtonSizes,
disabled: boolean = false
disabled: boolean = false,
): string => {
let _variant: string = ``;
const currentVariant = buttonStyling[variant];
@@ -35,7 +35,7 @@ export const CustomSearchSelect = (props: ICustomSearchSelectProps) => {
const [referenceElement, setReferenceElement] =
useState<HTMLButtonElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(
null
null,
);
const { styles, attributes } = usePopper(referenceElement, popperElement, {
@@ -46,7 +46,7 @@ export const CustomSearchSelect = (props: ICustomSearchSelectProps) => {
query === ""
? options
: options?.filter((option) =>
option.query.toLowerCase().includes(query.toLowerCase())
option.query.toLowerCase().includes(query.toLowerCase()),
);
const comboboxProps: any = {
@@ -87,8 +87,8 @@ export const CustomSearchSelect = (props: ICustomSearchSelectProps) => {
<button
ref={setReferenceElement}
type="button"
className={`flex items-center justify-between gap-1 w-full rounded-md border border-custom-border-300 shadow-sm duration-300 focus:outline-none ${
input ? "px-3 py-2 text-sm" : "px-2.5 py-1 text-xs"
className={`flex items-center justify-between gap-1 w-full rounded border-[0.5px] border-custom-border-300 ${
input ? "px-3 py-2 text-sm" : "px-2 py-1 text-xs"
} ${
disabled
? "cursor-not-allowed text-custom-text-200"
+3 -3
View File
@@ -30,7 +30,7 @@ const CustomSelect = (props: ICustomSelectProps) => {
const [referenceElement, setReferenceElement] =
useState<HTMLButtonElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(
null
null,
);
const { styles, attributes } = usePopper(referenceElement, popperElement, {
@@ -65,8 +65,8 @@ const CustomSelect = (props: ICustomSelectProps) => {
<button
ref={setReferenceElement}
type="button"
className={`flex items-center justify-between gap-1 w-full rounded-md border border-custom-border-300 shadow-sm duration-300 focus:outline-none ${
input ? "px-3 py-2 text-sm" : "px-2.5 py-1 text-xs"
className={`flex items-center justify-between gap-1 w-full rounded border-[0.5px] border-custom-border-300 ${
input ? "px-3 py-2 text-sm" : "px-2 py-1 text-xs"
} ${
disabled
? "cursor-not-allowed text-custom-text-200"
+1 -1
View File
@@ -1,3 +1,3 @@
export * from "./input";
export * from "./textarea";
export * from "./input-color-picker"
export * from "./input-color-picker";
+2 -2
View File
@@ -10,7 +10,7 @@ export interface TextAreaProps
// Updates the height of a <textarea> when the value changes.
const useAutoSizeTextArea = (
textAreaRef: HTMLTextAreaElement | null,
value: any
value: any,
) => {
React.useEffect(() => {
if (textAreaRef) {
@@ -63,7 +63,7 @@ const TextArea = React.forwardRef<HTMLTextAreaElement, TextAreaProps>(
{...rest}
/>
);
}
},
);
export { TextArea };
@@ -9,7 +9,7 @@ interface ICircularProgressIndicator {
}
export const CircularProgressIndicator: React.FC<ICircularProgressIndicator> = (
props
props,
) => {
const { size = 40, percentage = 25, strokeWidth = 6, children } = props;
+15 -2
View File
@@ -1,10 +1,23 @@
import * as React from "react";
export const Spinner: React.FC = () => (
export interface ISpinner extends React.SVGAttributes<SVGElement> {
height?: string;
width?: string;
className?: string | undefined;
}
export const Spinner: React.FC<ISpinner> = ({
height = "32px",
width = "32px",
className = "",
...rest
}) => (
<div role="status">
<svg
aria-hidden="true"
className="mr-2 h-8 w-8 animate-spin fill-blue-600 text-custom-text-200"
height={height}
width={width}
className={`mr-2 animate-spin fill-blue-600 text-custom-text-200 ${className}`}
viewBox="0 0 100 101"
fill="none"
xmlns="http://www.w3.org/2000/svg"
+3 -1
View File
@@ -116,7 +116,9 @@ export const SignInView = observer(() => {
)}
<div className="flex flex-col items-center justify-center gap-4 pt-7 sm:w-[360px] mx-auto overflow-hidden">
{data?.google && <GoogleLoginButton clientId={data.google} handleSignIn={handleGoogleSignIn} />}
{data?.google_client_id && (
<GoogleLoginButton clientId={data.google_client_id} handleSignIn={handleGoogleSignIn} />
)}
</div>
<p className="pt-16 text-custom-text-200 text-sm text-center">
@@ -29,7 +29,7 @@ export const RenderIssueState = observer(({ state }: { state: IIssueState }) =>
return (
<div className={`inline-flex items-center gap-x-1 rounded-full px-2 py-0.5 ${stateGroup.className || ``}`}>
<div className="flex-shrink-0 w-3 h-3 flex justify-center items-center overflow-hidden rounded-full">
<stateGroup.icon />
{/* <stateGroup.icon /> */}
</div>
<div className="text-xs font-medium whitespace-nowrap">{state?.name}</div>
<div
@@ -77,7 +77,7 @@ export const NavbarIssueFilter = observer(() => {
return {
display: (
<span className="capitalize flex items-center gap-x-2">
{stateGroup && <stateGroup.icon />}
{/* {stateGroup && <stateGroup.icon />} */}
{state.name}
</span>
),
@@ -76,6 +76,7 @@ export const AddComment: React.FC<Props> = observer((props) => {
handleSubmit(onSubmit)(e);
});
}}
cancelUploadImage={fileService.cancelUpload}
uploadFile={fileService.getUploadFileFunction(workspace_slug as string)}
deleteFile={fileService.deleteImage}
ref={editorRef}
@@ -103,6 +103,7 @@ export const CommentCard: React.FC<Props> = observer((props) => {
render={({ field: { onChange, value } }) => (
<LiteTextEditorWithRef
onEnterKeyPress={handleSubmit(handleCommentUpdate)}
cancelUploadImage={fileService.cancelUpload}
uploadFile={fileService.getUploadFileFunction(workspaceSlug)}
deleteFile={fileService.deleteImage}
ref={editorRef}
@@ -9,7 +9,6 @@ type Props = {
};
export const PeekOverviewIssueDetails: React.FC<Props> = ({ issueDetails }) => {
const mentionConfig = useEditorSuggestions();
return (
@@ -20,15 +19,19 @@ export const PeekOverviewIssueDetails: React.FC<Props> = ({ issueDetails }) => {
<h4 className="break-words text-2xl font-semibold">{issueDetails.name}</h4>
{issueDetails.description_html !== "" && issueDetails.description_html !== "<p></p>" && (
<RichReadOnlyEditor
value={!issueDetails.description_html ||
value={
!issueDetails.description_html ||
issueDetails.description_html === "" ||
(typeof issueDetails.description_html === "object" &&
Object.keys(issueDetails.description_html).length === 0)
? "<p></p>"
: issueDetails.description_html}
customClassName="p-3 min-h-[50px] shadow-sm" mentionHighlights={mentionConfig.mentionHighlights} />
? "<p></p>"
: issueDetails.description_html
}
customClassName="p-3 min-h-[50px] shadow-sm"
mentionHighlights={mentionConfig.mentionHighlights}
/>
)}
<IssueReactions />
</div>
)
);
};
@@ -1,5 +1,7 @@
// hooks
import useToast from "hooks/use-toast";
// ui
import { StateGroupIcon } from "@plane/ui";
// icons
import { Icon } from "components/ui";
// helpers
@@ -63,7 +65,7 @@ export const PeekOverviewIssueProperties: React.FC<Props> = ({ issueDetails, mod
{stateGroup && (
<div className="inline-flex bg-custom-background-80 text-sm rounded px-2.5 py-0.5">
<div className="flex items-center gap-1.5 text-left text-custom-text-100">
<stateGroup.icon />
<StateGroupIcon stateGroup={state.group} color={state.color} />
{addSpaceIfCamelCase(state?.name ?? "")}
</div>
</div>
-14
View File
@@ -1,6 +1,5 @@
// interfaces
import {
IIssueBoardViews,
// priority
TIssuePriorityKey,
// state groups
@@ -8,14 +7,6 @@ import {
IIssuePriorityFilters,
IIssueGroup,
} from "types/issue";
// icons
import {
BacklogStateIcon,
UnstartedStateIcon,
StartedStateIcon,
CompletedStateIcon,
CancelledStateIcon,
} from "components/icons";
// all issue views
export const issueViews: any = {
@@ -92,35 +83,30 @@ export const issueGroups: IIssueGroup[] = [
title: "Backlog",
color: "#d9d9d9",
className: `text-[#d9d9d9] bg-[#d9d9d9]/10`,
icon: BacklogStateIcon,
},
{
key: "unstarted",
title: "Unstarted",
color: "#3f76ff",
className: `text-[#3f76ff] bg-[#3f76ff]/10`,
icon: UnstartedStateIcon,
},
{
key: "started",
title: "Started",
color: "#f59e0b",
className: `text-[#f59e0b] bg-[#f59e0b]/10`,
icon: StartedStateIcon,
},
{
key: "completed",
title: "Completed",
color: "#16a34a",
className: `text-[#16a34a] bg-[#16a34a]/10`,
icon: CompletedStateIcon,
},
{
key: "cancelled",
title: "Cancelled",
color: "#dc2626",
className: `text-[#dc2626] bg-[#dc2626]/10`,
icon: CancelledStateIcon,
},
];
+6 -5
View File
@@ -3,12 +3,13 @@ import APIService from "services/api.service";
// helper
import { API_BASE_URL } from "helpers/common.helper";
export interface IEnvConfig {
github: string;
google: string;
github_app_name: string | null;
export interface IAppConfig {
email_password_login: boolean;
google_client_id: string | null;
github_app_name: string | null;
github_client_id: string | null;
magic_login: boolean;
slack_client_id: string | null;
}
export class AppConfigService extends APIService {
@@ -16,7 +17,7 @@ export class AppConfigService extends APIService {
super(API_BASE_URL);
}
async envConfig(): Promise<IEnvConfig> {
async envConfig(): Promise<IAppConfig> {
return this.get("/api/configs/", {
headers: {
"Content-Type": "application/json",
+14 -1
View File
@@ -1,5 +1,6 @@
import APIService from "services/api.service";
import { API_BASE_URL } from "helpers/common.helper";
import axios from "axios";
interface UnSplashImage {
id: string;
@@ -26,25 +27,37 @@ interface UnSplashImageUrls {
}
class FileService extends APIService {
private cancelSource: any;
constructor() {
super(API_BASE_URL);
this.uploadFile = this.uploadFile.bind(this);
this.deleteImage = this.deleteImage.bind(this);
this.cancelUpload = this.cancelUpload.bind(this);
}
async uploadFile(workspaceSlug: string, file: FormData): Promise<any> {
this.cancelSource = axios.CancelToken.source();
return this.post(`/api/workspaces/${workspaceSlug}/file-assets/`, file, {
headers: {
...this.getHeaders(),
"Content-Type": "multipart/form-data",
},
cancelToken: this.cancelSource.token,
})
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
if (axios.isCancel(error)) {
console.log(error.message);
} else {
throw error?.response?.data;
}
});
}
cancelUpload() {
this.cancelSource.cancel("Upload cancelled");
}
getUploadFileFunction(workspaceSlug: string): (file: File) => Promise<string> {
return async (file: File) => {
const formData = new FormData();
+30 -32
View File
@@ -3,43 +3,41 @@ import { RootStore } from "./root";
import { computed, makeObservable } from "mobx";
export interface IMentionsStore {
// mentionSuggestions: IMentionSuggestion[];
mentionHighlights: IMentionHighlight[];
// mentionSuggestions: IMentionSuggestion[];
mentionHighlights: IMentionHighlight[];
}
export class MentionsStore implements IMentionsStore{
export class MentionsStore implements IMentionsStore {
// root store
rootStore;
// root store
rootStore;
constructor(_rootStore: RootStore) {
// rootStore
this.rootStore = _rootStore;
constructor(_rootStore: RootStore ){
makeObservable(this, {
mentionHighlights: computed,
// mentionSuggestions: computed
});
}
// rootStore
this.rootStore = _rootStore;
// get mentionSuggestions() {
// const projectMembers = this.rootStore.project.project.
makeObservable(this, {
mentionHighlights: computed,
// mentionSuggestions: computed
})
}
// const suggestions = projectMembers === null ? [] : projectMembers.map((member) => ({
// id: member.member.id,
// type: "User",
// title: member.member.display_name,
// subtitle: member.member.email ?? "",
// avatar: member.member.avatar,
// redirect_uri: `/${member.workspace.slug}/profile/${member.member.id}`,
// }))
// get mentionSuggestions() {
// const projectMembers = this.rootStore.project.project.
// return suggestions
// }
// const suggestions = projectMembers === null ? [] : projectMembers.map((member) => ({
// id: member.member.id,
// type: "User",
// title: member.member.display_name,
// subtitle: member.member.email ?? "",
// avatar: member.member.avatar,
// redirect_uri: `/${member.workspace.slug}/profile/${member.member.id}`,
// }))
// return suggestions
// }
get mentionHighlights() {
const user = this.rootStore.user.currentUser;
return user ? [user.id] : []
}
}
get mentionHighlights() {
const user = this.rootStore.user.currentUser;
return user ? [user.id] : [];
}
}
+20 -4
View File
@@ -92,7 +92,7 @@
transform: translateY(-50%);
}
.tableWrapper .tableControls .columnsControl > button {
.tableWrapper .tableControls .columnsControl .columnsControlDiv {
color: white;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='24' height='24'%3E%3Cpath fill='none' d='M0 0h24v24H0z'/%3E%3Cpath fill='%238F95B2' d='M4.5 10.5c-.825 0-1.5.675-1.5 1.5s.675 1.5 1.5 1.5S6 12.825 6 12s-.675-1.5-1.5-1.5zm15 0c-.825 0-1.5.675-1.5 1.5s.675 1.5 1.5 1.5S21 12.825 21 12s-.675-1.5-1.5-1.5zm-7.5 0c-.825 0-1.5.675-1.5 1.5s.675 1.5 1.5 1.5 1.5-.675 1.5-1.5-.675-1.5-1.5-1.5z'/%3E%3C/svg%3E");
width: 30px;
@@ -104,26 +104,42 @@
transform: translateX(-50%);
}
.tableWrapper .tableControls .rowsControl > button {
.tableWrapper .tableControls .rowsControl .rowsControlDiv {
color: white;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='24' height='24'%3E%3Cpath fill='none' d='M0 0h24v24H0z'/%3E%3Cpath fill='%238F95B2' d='M12 3c-.825 0-1.5.675-1.5 1.5S11.175 6 12 6s1.5-.675 1.5-1.5S12.825 3 12 3zm0 15c-.825 0-1.5.675-1.5 1.5S11.175 21 12 21s1.5-.675 1.5-1.5S12.825 18 12 18zm0-7.5c-.825 0-1.5.675-1.5 1.5s.675 1.5 1.5 1.5 1.5-.675 1.5-1.5-.675-1.5-1.5-1.5z'/%3E%3C/svg%3E");
height: 30px;
width: 15px;
}
.tableWrapper .tableControls button {
.tableWrapper .tableControls .rowsControlDiv {
background-color: rgba(var(--color-primary-100));
border: 1px solid rgba(var(--color-border-200));
border-radius: 2px;
background-size: 1.25rem;
background-repeat: no-repeat;
background-position: center;
transition: transform ease-out 100ms, background-color ease-out 100ms;
transition:
transform ease-out 100ms,
background-color ease-out 100ms;
outline: none;
box-shadow: #000 0px 2px 4px;
cursor: pointer;
}
.tableWrapper .tableControls .columnsControlDiv {
background-color: rgba(var(--color-primary-100));
border: 1px solid rgba(var(--color-border-200));
border-radius: 2px;
background-size: 1.25rem;
background-repeat: no-repeat;
background-position: center;
transition:
transform ease-out 100ms,
background-color ease-out 100ms;
outline: none;
box-shadow: #000 0px 2px 4px;
cursor: pointer;
}
.tableWrapper .tableControls .tableToolbox,
.tableWrapper .tableControls .tableColorPickerToolbox {
border: 1px solid rgba(var(--color-border-300));
+6 -2
View File
@@ -24,7 +24,6 @@ export interface IIssueGroup {
title: TIssueGroupTitle;
color: string;
className: string;
icon: React.FC;
}
export interface IIssue {
@@ -40,7 +39,12 @@ export interface IIssue {
sequence_id: number;
start_date: any;
state: string;
state_detail: any;
state_detail: {
id: string;
name: string;
group: TIssueGroupKey;
color: string;
};
target_date: any;
votes: IVote[];
}
+1 -3
View File
@@ -5,7 +5,6 @@
"NEXT_PUBLIC_DEPLOY_URL",
"NEXT_PUBLIC_SENTRY_DSN",
"NEXT_PUBLIC_SENTRY_ENVIRONMENT",
"NEXT_PUBLIC_GITHUB_APP_NAME",
"NEXT_PUBLIC_ENABLE_SENTRY",
"NEXT_PUBLIC_ENABLE_OAUTH",
"NEXT_PUBLIC_TRACK_EVENTS",
@@ -22,8 +21,7 @@
"SLACK_CLIENT_SECRET",
"JITSU_TRACKER_ACCESS_KEY",
"JITSU_TRACKER_HOST",
"UNSPLASH_ACCESS_KEY",
"NEXT_PUBLIC_SLACK_CLIENT_ID"
"UNSPLASH_ACCESS_KEY"
],
"pipeline": {
"build": {
@@ -32,8 +32,7 @@ export const GithubLoginButton: FC<GithubLoginButtonProps> = (props) => {
}, [code, gitCode, handleSignIn]);
useEffect(() => {
const origin =
typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
const origin = typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
setLoginCallBackURL(`${origin}/` as any);
}, []);
@@ -49,10 +49,7 @@ export const CustomTooltip: React.FC<Props> = ({ datum, analytics, params }) =>
: ""
}`}
>
{params.segment === "assignees__id"
? renderAssigneeName(tooltipValue.toString())
: tooltipValue}
:
{params.segment === "assignees__id" ? renderAssigneeName(tooltipValue.toString()) : tooltipValue}:
</span>
<span>{datum.value}</span>
</div>
@@ -112,8 +112,9 @@ export const AnalyticsGraph: React.FC<Props> = ({ analytics, barGraphData, param
<text
x={0}
y={datum.y}
textAnchor="end"
textAnchor={`${barGraphData.data.length > 7 ? "end" : "middle"}`}
fontSize={10}
fill="rgb(var(--color-text-200))"
className={`${barGraphData.data.length > 7 ? "-rotate-45" : ""}`}
>
{generateDisplayName(datum.value, analytics, params, "x_axis")}
@@ -22,9 +22,7 @@ export const AnalyticsScope: React.FC<Props> = ({ defaultAnalytics }) => (
keys={["count"]}
height="250px"
colors={() => `#f97316`}
customYAxisTickValues={defaultAnalytics.pending_issue_user.map((d) =>
d.count > 0 ? d.count : 50
)}
customYAxisTickValues={defaultAnalytics.pending_issue_user.map((d) => (d.count > 0 ? d.count : 50))}
tooltip={(datum) => {
const assignee = defaultAnalytics.pending_issue_user.find(
(a) => a.assignees__id === `${datum.indexValue}`
@@ -31,9 +31,7 @@ export const NotAuthorizedView: React.FC<Props> = ({ actionButton, type }) => {
alt="ProjectSettingImg"
/>
</div>
<h1 className="text-xl font-medium text-custom-text-100">
Oops! You are not authorized to view this page
</h1>
<h1 className="text-xl font-medium text-custom-text-100">Oops! You are not authorized to view this page</h1>
<div className="w-full max-w-md text-base text-custom-text-200">
{user ? (
@@ -1,7 +1,9 @@
import React, { useState } from "react";
import { observer } from "mobx-react-lite";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
// component
import { CustomSelect, ToggleSwitch } from "@plane/ui";
import { CustomSelect, Loader, ToggleSwitch } from "@plane/ui";
import { SelectMonthModal } from "components/automation";
// icon
import { ArchiveRestore } from "lucide-react";
@@ -11,15 +13,21 @@ import { PROJECT_AUTOMATION_MONTHS } from "constants/project";
import { IProject } from "types";
type Props = {
projectDetails: IProject | undefined;
handleChange: (formData: Partial<IProject>) => Promise<void>;
disabled?: boolean;
};
export const AutoArchiveAutomation: React.FC<Props> = ({ projectDetails, handleChange, disabled = false }) => {
const initialValues: Partial<IProject> = { archive_in: 1 };
export const AutoArchiveAutomation: React.FC<Props> = observer((props) => {
const { handleChange } = props;
// states
const [monthModal, setmonthModal] = useState(false);
const initialValues: Partial<IProject> = { archive_in: 1 };
const { user: userStore, project: projectStore } = useMobxStore();
const projectDetails = projectStore.currentProjectDetails;
const userRole = userStore.currentProjectRole;
return (
<>
<SelectMonthModal
@@ -29,7 +37,7 @@ export const AutoArchiveAutomation: React.FC<Props> = ({ projectDetails, handleC
handleClose={() => setmonthModal(false)}
handleChange={handleChange}
/>
<div className="flex flex-col gap-4 border-b border-custom-border-200 px-4 py-6">
<div className="flex flex-col gap-4 border-b border-custom-border-100 px-4 py-6">
<div className="flex items-center justify-between">
<div className="flex items-start gap-3">
<div className="flex items-center justify-center p-3 rounded bg-custom-background-90">
@@ -48,46 +56,52 @@ export const AutoArchiveAutomation: React.FC<Props> = ({ projectDetails, handleC
projectDetails?.archive_in === 0 ? handleChange({ archive_in: 1 }) : handleChange({ archive_in: 0 })
}
size="sm"
disabled={disabled}
disabled={userRole !== 20}
/>
</div>
{projectDetails?.archive_in !== 0 && (
<div className="ml-12">
<div className="flex items-center justify-between rounded px-5 py-4 bg-custom-background-90 border border-custom-border-200 gap-2 w-full">
<div className="w-1/2 text-sm font-medium">Auto-archive issues that are closed for</div>
<div className="w-1/2">
<CustomSelect
value={projectDetails?.archive_in}
label={`${projectDetails?.archive_in} ${projectDetails?.archive_in === 1 ? "Month" : "Months"}`}
onChange={(val: number) => {
handleChange({ archive_in: val });
}}
input
width="w-full"
disabled={disabled}
>
<>
{PROJECT_AUTOMATION_MONTHS.map((month) => (
<CustomSelect.Option key={month.label} value={month.value}>
<span className="text-sm">{month.label}</span>
</CustomSelect.Option>
))}
{projectDetails ? (
projectDetails.archive_in !== 0 && (
<div className="ml-12">
<div className="flex items-center justify-between rounded px-5 py-4 bg-custom-background-90 border border-custom-border-200 gap-2 w-full">
<div className="w-1/2 text-sm font-medium">Auto-archive issues that are closed for</div>
<div className="w-1/2">
<CustomSelect
value={projectDetails?.archive_in}
label={`${projectDetails?.archive_in} ${projectDetails?.archive_in === 1 ? "Month" : "Months"}`}
onChange={(val: number) => {
handleChange({ archive_in: val });
}}
input
width="w-full"
disabled={userRole !== 20}
>
<>
{PROJECT_AUTOMATION_MONTHS.map((month) => (
<CustomSelect.Option key={month.label} value={month.value}>
<span className="text-sm">{month.label}</span>
</CustomSelect.Option>
))}
<button
type="button"
className="flex w-full text-sm select-none items-center rounded px-1 py-1.5 text-custom-text-200 hover:bg-custom-background-80"
onClick={() => setmonthModal(true)}
>
Customise Time Range
</button>
</>
</CustomSelect>
<button
type="button"
className="flex w-full text-sm select-none items-center rounded px-1 py-1.5 text-custom-text-200 hover:bg-custom-background-80"
onClick={() => setmonthModal(true)}
>
Customise Time Range
</button>
</>
</CustomSelect>
</div>
</div>
</div>
</div>
)
) : (
<Loader className="ml-12">
<Loader.Item height="50px" />
</Loader>
)}
</div>
</>
);
};
});
@@ -1,42 +1,32 @@
import React, { useState } from "react";
import useSWR from "swr";
import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
// component
import { SelectMonthModal } from "components/automation";
import { CustomSelect, CustomSearchSelect, ToggleSwitch, StateGroupIcon, DoubleCircleIcon } from "@plane/ui";
import { CustomSelect, CustomSearchSelect, ToggleSwitch, StateGroupIcon, DoubleCircleIcon, Loader } from "@plane/ui";
// icons
import { ArchiveX } from "lucide-react";
// services
import { ProjectStateService } from "services/project";
// constants
import { PROJECT_AUTOMATION_MONTHS } from "constants/project";
import { STATES_LIST } from "constants/fetch-keys";
// types
import { IProject } from "types";
// helper
import { getStatesList } from "helpers/state.helper";
// fetch keys
import { PROJECT_AUTOMATION_MONTHS } from "constants/project";
type Props = {
projectDetails: IProject | undefined;
handleChange: (formData: Partial<IProject>) => Promise<void>;
disabled?: boolean;
};
const projectStateService = new ProjectStateService();
export const AutoCloseAutomation: React.FC<Props> = ({ projectDetails, handleChange, disabled = false }) => {
export const AutoCloseAutomation: React.FC<Props> = observer((props) => {
const { handleChange } = props;
// states
const [monthModal, setmonthModal] = useState(false);
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { user: userStore, project: projectStore, projectState: projectStateStore } = useMobxStore();
const { data: stateGroups } = useSWR(
workspaceSlug && projectId ? STATES_LIST(projectId as string) : null,
workspaceSlug && projectId
? () => projectStateService.getStates(workspaceSlug as string, projectId as string)
: null
);
const states = getStatesList(stateGroups);
const userRole = userStore.currentProjectRole;
const projectDetails = projectStore.currentProjectDetails;
// const stateGroups = projectStateStore.groupedProjectStates ?? undefined;
const states = projectStateStore.projectStates;
const options = states
?.filter((state) => state.group === "cancelled")
@@ -53,7 +43,7 @@ export const AutoCloseAutomation: React.FC<Props> = ({ projectDetails, handleCha
const multipleOptions = (options ?? []).length > 1;
const defaultState = stateGroups && stateGroups.cancelled ? stateGroups.cancelled[0].id : null;
const defaultState = states?.find((s) => s.group === "cancelled")?.id || null;
const selectedOption = states?.find((s) => s.id === projectDetails?.default_state ?? defaultState);
const currentDefaultState = states?.find((s) => s.id === defaultState);
@@ -72,7 +62,6 @@ export const AutoCloseAutomation: React.FC<Props> = ({ projectDetails, handleCha
handleClose={() => setmonthModal(false)}
handleChange={handleChange}
/>
<div className="flex flex-col gap-4 border-b border-custom-border-200 px-4 py-6">
<div className="flex items-center justify-between">
<div className="flex items-start gap-3">
@@ -82,7 +71,7 @@ export const AutoCloseAutomation: React.FC<Props> = ({ projectDetails, handleCha
<div className="">
<h4 className="text-sm font-medium">Auto-close issues</h4>
<p className="text-sm text-custom-text-200 tracking-tight">
Plane will automatically close issue that havent been completed or cancelled.
Plane will automatically close issue that haven{"'"}t been completed or cancelled.
</p>
</div>
</div>
@@ -94,87 +83,93 @@ export const AutoCloseAutomation: React.FC<Props> = ({ projectDetails, handleCha
: handleChange({ close_in: 0, default_state: null })
}
size="sm"
disabled={disabled}
disabled={userRole !== 20}
/>
</div>
{projectDetails?.close_in !== 0 && (
<div className="ml-12">
<div className="flex flex-col rounded bg-custom-background-90 border border-custom-border-200 p-2">
<div className="flex items-center justify-between px-5 py-4 gap-2 w-full">
<div className="w-1/2 text-sm font-medium">Auto-close issues that are inactive for</div>
<div className="w-1/2">
<CustomSelect
value={projectDetails?.close_in}
label={`${projectDetails?.close_in} ${projectDetails?.close_in === 1 ? "Month" : "Months"}`}
onChange={(val: number) => {
handleChange({ close_in: val });
}}
input
width="w-full"
disabled={disabled}
>
<>
{PROJECT_AUTOMATION_MONTHS.map((month) => (
<CustomSelect.Option key={month.label} value={month.value}>
{month.label}
</CustomSelect.Option>
))}
<button
type="button"
className="flex w-full select-none items-center rounded px-1 py-1.5 text-custom-text-200 hover:bg-custom-background-80"
onClick={() => setmonthModal(true)}
>
Customise Time Range
</button>
</>
</CustomSelect>
{projectDetails ? (
projectDetails.close_in !== 0 && (
<div className="ml-12">
<div className="flex flex-col rounded bg-custom-background-90 border border-custom-border-200">
<div className="flex items-center justify-between px-5 py-4 gap-2 w-full">
<div className="w-1/2 text-sm font-medium">Auto-close issues that are inactive for</div>
<div className="w-1/2">
<CustomSelect
value={projectDetails?.close_in}
label={`${projectDetails?.close_in} ${projectDetails?.close_in === 1 ? "Month" : "Months"}`}
onChange={(val: number) => {
handleChange({ close_in: val });
}}
input
width="w-full"
disabled={userRole !== 20}
>
<>
{PROJECT_AUTOMATION_MONTHS.map((month) => (
<CustomSelect.Option key={month.label} value={month.value}>
{month.label}
</CustomSelect.Option>
))}
<button
type="button"
className="flex w-full select-none items-center rounded px-1 py-1.5 text-custom-text-200 hover:bg-custom-background-80"
onClick={() => setmonthModal(true)}
>
Customise Time Range
</button>
</>
</CustomSelect>
</div>
</div>
</div>
<div className="flex items-center justify-between px-5 py-4 gap-2 w-full">
<div className="w-1/2 text-sm font-medium">Auto-close Status</div>
<div className="w-1/2 ">
<CustomSearchSelect
value={projectDetails?.default_state ? projectDetails?.default_state : defaultState}
label={
<div className="flex items-center gap-2">
{selectedOption ? (
<StateGroupIcon
stateGroup={selectedOption.group}
color={selectedOption.color}
height="16px"
width="16px"
/>
) : currentDefaultState ? (
<StateGroupIcon
stateGroup={currentDefaultState.group}
color={currentDefaultState.color}
height="16px"
width="16px"
/>
) : (
<DoubleCircleIcon className="h-3.5 w-3.5 text-custom-text-200" />
)}
{selectedOption?.name
? selectedOption.name
: currentDefaultState?.name ?? <span className="text-custom-text-200">State</span>}
</div>
}
onChange={(val: string) => {
handleChange({ default_state: val });
}}
options={options}
disabled={!multipleOptions}
width="w-full"
input
/>
<div className="flex items-center justify-between px-5 py-4 gap-2 w-full">
<div className="w-1/2 text-sm font-medium">Auto-close Status</div>
<div className="w-1/2 ">
<CustomSearchSelect
value={projectDetails?.default_state ?? defaultState}
label={
<div className="flex items-center gap-2">
{selectedOption ? (
<StateGroupIcon
stateGroup={selectedOption.group}
color={selectedOption.color}
height="16px"
width="16px"
/>
) : currentDefaultState ? (
<StateGroupIcon
stateGroup={currentDefaultState.group}
color={currentDefaultState.color}
height="16px"
width="16px"
/>
) : (
<DoubleCircleIcon className="h-3.5 w-3.5 text-custom-text-200" />
)}
{selectedOption?.name
? selectedOption.name
: currentDefaultState?.name ?? <span className="text-custom-text-200">State</span>}
</div>
}
onChange={(val: string) => {
handleChange({ default_state: val });
}}
options={options}
disabled={!multipleOptions}
width="w-full"
input
/>
</div>
</div>
</div>
</div>
</div>
)
) : (
<Loader className="ml-12">
<Loader.Item height="50px" />
</Loader>
)}
</div>
</>
);
};
});
@@ -3,6 +3,7 @@ import { useRouter } from "next/router";
import useSWR, { mutate } from "swr";
import { Command } from "cmdk";
import { Dialog, Transition } from "@headlessui/react";
import { observer } from "mobx-react-lite";
import {
FileText,
FolderPlus,
@@ -16,12 +17,13 @@ import {
UserMinus2,
UserPlus2,
} from "lucide-react";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
// services
import { WorkspaceService } from "services/workspace.service";
import { IssueService } from "services/issue";
// hooks
import useDebounce from "hooks/use-debounce";
import useUser from "hooks/use-user";
import useToast from "hooks/use-toast";
// components
import {
@@ -61,11 +63,8 @@ type Props = {
const workspaceService = new WorkspaceService();
const issueService = new IssueService();
export const CommandModal: React.FC<Props> = (props) => {
export const CommandModal: React.FC<Props> = observer((props) => {
const { deleteIssue, isPaletteOpen, closePalette } = props;
// router
const router = useRouter();
const { workspaceSlug, projectId, issueId } = router.query;
// states
const [placeholder, setPlaceholder] = useState("Type a command or search...");
const [resultsCount, setResultsCount] = useState(0);
@@ -86,14 +85,19 @@ export const CommandModal: React.FC<Props> = (props) => {
const [isWorkspaceLevel, setIsWorkspaceLevel] = useState(false);
const [pages, setPages] = useState<string[]>([]);
const { user: userStore, commandPalette: commandPaletteStore } = useMobxStore();
const user = userStore.currentUser ?? undefined;
// router
const router = useRouter();
const { workspaceSlug, projectId, issueId } = router.query;
const page = pages[pages.length - 1];
const debouncedSearchTerm = useDebounce(searchTerm, 500);
const { setToastAlert } = useToast();
const { user } = useUser();
const { data: issueDetails } = useSWR(
workspaceSlug && projectId && issueId ? ISSUE_DETAILS(issueId as string) : null,
workspaceSlug && projectId && issueId
@@ -468,10 +472,7 @@ export const CommandModal: React.FC<Props> = (props) => {
<Command.Item
onSelect={() => {
closePalette();
const e = new KeyboardEvent("keydown", {
key: "c",
});
document.dispatchEvent(e);
commandPaletteStore.toggleCreateIssueModal(true);
}}
className="focus:bg-custom-background-80"
>
@@ -488,10 +489,7 @@ export const CommandModal: React.FC<Props> = (props) => {
<Command.Item
onSelect={() => {
closePalette();
const e = new KeyboardEvent("keydown", {
key: "p",
});
document.dispatchEvent(e);
commandPaletteStore.toggleCreateProjectModal(true);
}}
className="focus:outline-none"
>
@@ -510,10 +508,7 @@ export const CommandModal: React.FC<Props> = (props) => {
<Command.Item
onSelect={() => {
closePalette();
const e = new KeyboardEvent("keydown", {
key: "q",
});
document.dispatchEvent(e);
commandPaletteStore.toggleCreateCycleModal(true);
}}
className="focus:outline-none"
>
@@ -528,10 +523,7 @@ export const CommandModal: React.FC<Props> = (props) => {
<Command.Item
onSelect={() => {
closePalette();
const e = new KeyboardEvent("keydown", {
key: "m",
});
document.dispatchEvent(e);
commandPaletteStore.toggleCreateModuleModal(true);
}}
className="focus:outline-none"
>
@@ -546,10 +538,7 @@ export const CommandModal: React.FC<Props> = (props) => {
<Command.Item
onSelect={() => {
closePalette();
const e = new KeyboardEvent("keydown", {
key: "v",
});
document.dispatchEvent(e);
commandPaletteStore.toggleCreateViewModal(true);
}}
className="focus:outline-none"
>
@@ -564,10 +553,7 @@ export const CommandModal: React.FC<Props> = (props) => {
<Command.Item
onSelect={() => {
closePalette();
const e = new KeyboardEvent("keydown", {
key: "d",
});
document.dispatchEvent(e);
commandPaletteStore.toggleCreatePageModal(true);
}}
className="focus:outline-none"
>
@@ -621,10 +607,7 @@ export const CommandModal: React.FC<Props> = (props) => {
<Command.Item
onSelect={() => {
closePalette();
const e = new KeyboardEvent("keydown", {
key: "h",
});
document.dispatchEvent(e);
commandPaletteStore.toggleShortcutModal(true);
}}
className="focus:outline-none"
>
@@ -762,4 +745,4 @@ export const CommandModal: React.FC<Props> = (props) => {
</Dialog>
</Transition.Root>
);
};
});
@@ -1,9 +1,6 @@
import React, { Dispatch, SetStateAction, useCallback } from "react";
import { useRouter } from "next/router";
import useSWR, { mutate } from "swr";
// cmdk
import { Command } from "cmdk";
// services
@@ -13,8 +10,6 @@ import { ProjectStateService } from "services/project";
import { Spinner, StateGroupIcon } from "@plane/ui";
// icons
import { Check } from "lucide-react";
// helpers
import { getStatesList } from "helpers/state.helper";
// types
import { IUser, IIssue } from "types";
// fetch keys
@@ -34,11 +29,10 @@ export const ChangeIssueState: React.FC<Props> = ({ setIsPaletteOpen, issue, use
const router = useRouter();
const { workspaceSlug, projectId, issueId } = router.query;
const { data: stateGroups, mutate: mutateIssueDetails } = useSWR(
const { data: states, mutate: mutateStates } = useSWR(
workspaceSlug && projectId ? STATES_LIST(projectId as string) : null,
workspaceSlug && projectId ? () => stateService.getStates(workspaceSlug as string, projectId as string) : null
);
const states = getStatesList(stateGroups);
const submitChanges = useCallback(
async (formData: Partial<IIssue>) => {
@@ -60,14 +54,14 @@ export const ChangeIssueState: React.FC<Props> = ({ setIsPaletteOpen, issue, use
await issueService
.patchIssue(workspaceSlug as string, projectId as string, issueId as string, payload, user)
.then(() => {
mutateIssueDetails();
mutateStates();
mutate(PROJECT_ISSUES_ACTIVITY(issueId as string));
})
.catch((e) => {
console.error(e);
});
},
[workspaceSlug, issueId, projectId, mutateIssueDetails, user]
[workspaceSlug, issueId, projectId, mutateStates, user]
);
const handleIssueState = (stateId: string) => {

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