Compare commits

...
Author SHA1 Message Date
rahulrameshaandGitHub 66cfc7344e change kanban group virtualization logic (#5664) 2024-09-20 14:39:28 +05:30
Aaryan KhandelwalandGitHub a4933b5614 chore: remove modal for creating a page (#5561) 2024-09-19 20:26:11 +05:30
Ketan SharmaandGitHub e70e27296b changes for web-2425 (#5616) 2024-09-19 20:15:10 +05:30
Prateek ShouryaandGitHub 361ef9236e [WEB-1970] fix: onboarding invitation page fluctuation on refresh. (#5627) 2024-09-19 17:51:22 +05:30
Ketan SharmaandGitHub 450bb42c46 [WEB-2330] fix: you don't have permission toast on on bulk delete (#5599)
* fix logic check boolean then call function

* minor code improvement

* fixed logic error
2024-09-19 17:49:30 +05:30
Aaryan KhandelwalandGitHub 77152b3119 style: remove side menu position transition (#5637) 2024-09-19 17:47:34 +05:30
Ketan SharmaandGitHub e9464f9e68 [WEB-2475] fix: applied filters header z-index and transparency (#5632)
* fixed only for spreadsheet

* change package for global change
2024-09-19 17:36:52 +05:30
rahulrameshaandGitHub c8c9638e5a fix render-if-visible-hoc's style calculation performance issue (#5647) 2024-09-19 10:02:46 +05:30
Akshita GoyalandGitHub bd0ca0cded fix: archive page break issue resolved (#5644) 2024-09-18 20:08:27 +05:30
Anmol Singh BhatiaandGitHub 96781dbb0f fix: workspace view applied filters (#5651) 2024-09-18 20:07:01 +05:30
Bavisetti NarayanandGitHub 19132d15b8 chore: pick first inbox issue (#5650) 2024-09-18 19:10:36 +05:30
sriram veeraghanta 6befc6e564 fix: upgrading nextjs package 2024-09-18 18:56:38 +05:30
Aaryan KhandelwalandGitHub 441e5fc054 chore: update page lock authorization (#5635) 2024-09-18 18:21:05 +05:30
Aaryan KhandelwalandGitHub 43633f2f28 fix: issue description value (#5636) 2024-09-18 18:20:43 +05:30
Anmol Singh BhatiaandGitHub 3a9f01b9eb [WEB-2462] [WEB-2461] fix: project intake filters (#5645)
* chore: intake order by options updated

* fix: intake filters icon and spacing

* chore: code refactor
2024-09-18 18:10:30 +05:30
rahulrameshaandGitHub 5e83da9ca1 [WEB-2316] chore: Kanban group virtualization (#5565)
* kanban group virtualization

* minor name change
2024-09-18 18:03:49 +05:30
Akshita GoyalandGitHub aec4162c22 fix: webhook modal spacing (#5641) 2024-09-18 15:35:46 +05:30
Anmol Singh BhatiaandGitHub 44542fdd6b fix: list layout quick action styling (#5639) 2024-09-18 15:33:20 +05:30
Anmol Singh BhatiaandGitHub 5ad6e99327 fix: project settings layout (#5638) 2024-09-18 15:01:35 +05:30
Bavisetti NarayanandGitHub 30018d64a2 chore: restrict member to see private projects (#5640) 2024-09-18 14:54:35 +05:30
Prateek ShouryaandGitHub 1c0c1586cb [WEB-2308] fix: descritpion editor loader on issue modal when edition a sub issue from another project. (#5625) 2024-09-18 13:38:01 +05:30
Prateek ShouryaandGitHub 524033411e [WEB-2250] fix: filter projects with create permission while selecting the project in create issue modal. (#5630) 2024-09-18 13:32:24 +05:30
Prateek ShouryaandGitHub 3b40158d9a [WEB-2395] chore: minor UX copy update for what's new link. (#5626)
* [WEB-2395] chore: minor ux copy update for what's new link.

* fix: import errors.
2024-09-18 13:22:51 +05:30
Bavisetti NarayanandGitHub 4d9115d51e chore: inbox rename (#5628) 2024-09-18 13:18:45 +05:30
M. PalanikannanandGitHub 146a500f9f [WEB-2450] fix: image resize component (#5623)
* fix: image resize fixed for initial render

* fix: working image resize with mousemove handler only inside the editor

* fix: unnecessary calc

* fix: setting state to true
2024-09-17 16:54:42 +05:30
Anmol Singh BhatiaandGitHub 7d7415b235 [WEB-2467] fix: platform bug (#5621)
* fix: reaction endpoint

* fix: project label edit permission

* fix: guest role upgrade

* fix: list layout dnd permission

* fix: module and cycle toast alert

* fix: leave project redirection
2024-09-17 16:43:51 +05:30
Akshita GoyalandGitHub 7aea820cfa [WEB-2459] Fix: analytics scroll + dashboard stat minor padding (#5613)
* fix: analytics scroll + dashboard stat minor padding

* fix: build issue
2024-09-17 16:33:34 +05:30
sriram veeraghanta 69b4f155fc fix: yjs dependencies revert 2024-09-16 21:06:40 +05:30
sriram veeraghanta 8f492e4c6c fix: disable turbo telemetry on live service 2024-09-16 20:58:35 +05:30
8533eba07d [WEB-2450] dev: custom image extension (#5585)
* fix: svg not supported in image uploads

* fix: svg image file error message fixed

* feat: add custom image node for uploads

* fix: combine two extensions

* fix: added new image extension to backend

* fix: type errors

* style: image drop node

* style: image resize handler

* fix: removed unused stuff

* fix: types of updateAttributes

* fix: image insertion at pos and loading effect added

* fix: resize image real time sync

* fix: drag drop menu

* feat: custom image component editor

* fix: reverted back styles

* fix: reverted back document info changes

* fix: css image css

* style: image selected and hover states

* refactor: custom image extension folder structure

* style: read-only image

* chore: remove file handler

* fix: fixed multi time file opener

* fix: editor readonly content set properly

* fix: old images not rendered as new ones

* fix: drop upload fixed

* chore: remove console logs

* fix: src of image node as dependency

* fix: helper library build fix

* fix: improved reflow/layout and fixed resizing

---------

Co-authored-by: Aaryan Khandelwal <aaryankhandu123@gmail.com>
2024-09-16 19:36:20 +05:30
Anmol Singh BhatiaandGitHub edf0ab8175 fix: build error (#5617) 2024-09-16 19:22:47 +05:30
45da70cf6a [WEB-2460] fix: role permission validation (#5615)
* fix: workspace menu quick action

* fix: guest role upgrade flow validation

* fix: create issue validation

* fix: create issue validation

* fix: cmd k permission validation

* fix: subscription validation

* fix: create label permission validation

* fix: build error

* chore: guest can comment in their created issues

* chore: changed the queryset

* chore: code refactor

* chore: code refactor

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
2024-09-16 18:56:28 +05:30
2e816656e5 [WEB-2112 | WEB-2113] dev: billing and change-log improvements. (#5614)
* chore: minor improvements in billing and changelogs.

* fix: lint errors.

---------

Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
2024-09-16 18:36:17 +05:30
Aaryan KhandelwalandGitHub 6826ce0465 [WEB-1116] chore: remove yjs packages from the editor (#5603)
* chore: remove yjs packages from the editor

* chore: updated yarn lock file
2024-09-16 18:28:09 +05:30
sriram veeraghanta c4b5c737f3 fix: adding types in package 2024-09-16 17:54:23 +05:30
sriram veeraghanta 89a1c0b534 fix: build errors 2024-09-16 17:48:10 +05:30
74507559b8 [WEB-2456] Chore: workspace member list additional info (#5604)
* chore: added last login medium

* chore: added email and authentication columns in member settings

* fix: revoked lock file changes

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
2024-09-16 14:28:41 +05:30
Bavisetti NarayanandGitHub 3ce84f78f1 chore: roles demotion (#5612) 2024-09-16 14:25:27 +05:30
Anmol Singh BhatiaandGitHub 5ba1eeaf4c [WEB-2443] fix: join project flicker (#5602)
* fix: join project flicker

* fix: leave project project mutation and code refactor
2024-09-16 14:16:23 +05:30
Anmol Singh BhatiaandGitHub c14d20c2e0 fix: workspace settings access validation updated (#5606) 2024-09-16 14:03:06 +05:30
sriram veeraghanta f155a13929 fix: adding new session cookie name 2024-09-13 16:59:47 +05:30
Anmol Singh BhatiaandGitHub 485caaf2ec [WEB-2443] fix: project member validation (#5601)
* fix: project member validation

* fix: project member validation
2024-09-13 16:28:03 +05:30
Ketan SharmaandGitHub b44dd28ac0 [WEB-2445] fix: date picker and member picker dropdown z-index for list, kanban and spreadsheet views (#5597)
* changes for list and kanban

* passing values for list and kanban

* spreadsheet changes

* fix use different props for different stylings

* fix z index
2024-09-13 12:03:00 +05:30
sriram veeraghanta 1b0e31027e fix: lint fixes and typescript version fixes 2024-09-12 20:39:31 +05:30
Anmol Singh BhatiaandGitHub 1efb067274 fix: build error (#5598) 2024-09-12 20:22:50 +05:30
Prateek ShouryaandGitHub b2533b94ce [WEB-2444] improvement: performance improvement for useOutsideClickDetector and usePeekOverviewOutsideClickDetector. (#5595)
* [WEB-2444] improvement: performace improvement for `useOutsideClickDetector` and `usePeekOverviewOutsideClickDetector`.

* Move outside click detector to plane helpers package.

* chore: remove plane helpers yarn.lock
2024-09-12 20:10:04 +05:30
Anmol Singh BhatiaandGitHub 441385fc95 [WEB-2443] fix: role validation and code refactor (#5596)
* chore: delete cycle toast message updated

* fix: view page empty state

* fix: project settings automation

* fix: intake delete action

* fix: project label validation

* fix: project label validation

* fix: project state permission updated

* chore: code refactor
2024-09-12 20:08:13 +05:30
sriram veeraghantaandGitHub 5f1939cdeb fix: workflow sync fixes (#5594) 2024-09-12 17:22:41 +05:30
Anmol Singh BhatiaandGitHub 9d694ab006 fix: not authorized flicker (#5593) 2024-09-12 16:26:57 +05:30
Anmol Singh BhatiaandGitHub 48e97477ed fix: issue properties dropdown (#5592) 2024-09-12 16:02:56 +05:30
33dd5fe8cc [WEB-2443] fix: project intake edit permission (#5588)
* fix: project intake edit permission

* chore: inbox issue validation changes

* fix: intake edit permission updated

* fix: project invite modal

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
2024-09-12 14:44:21 +05:30
Anmol Singh BhatiaandGitHub aed2f2dd47 fix: page permission validation (#5589) 2024-09-12 14:39:38 +05:30
Ketan SharmaandGitHub eb84f165f4 [WEB-2282] fix: date picker and member picker dropdown z-index for list, kanban and spreadsheet views (#5555)
* changes for list and kanban

* passing values for list and kanban

* spreadsheet changes
2024-09-12 14:35:45 +05:30
MihirandGitHub 572644f7f9 Updated alignment inside kanban header (#5559) 2024-09-12 14:34:24 +05:30
Aaryan KhandelwalandGitHub ddbd9dfdc8 chore: add toast alerts post access change of a page (#5569) 2024-09-12 14:32:54 +05:30
MihirandGitHub 09578c9a7d Updates theme options to include custom theme option (#5574) 2024-09-12 14:32:14 +05:30
MihirandGitHub e5ddfd322d [WEB-2393] chore: removal of .svg from supported image formats (#5582)
* Updated supported image formats

* Updated image accepting functions
2024-09-12 14:25:06 +05:30
Anmol Singh BhatiaandGitHub 87d6544b72 fix: project favorite permission validation (#5587) 2024-09-12 14:09:19 +05:30
215 changed files with 2240 additions and 1194 deletions
+2 -18
View File
@@ -8,7 +8,6 @@ on:
env:
CURRENT_BRANCH: ${{ github.ref_name }}
SOURCE_BRANCH: ${{ vars.SYNC_SOURCE_BRANCH_NAME }} # The sync branch such as "sync/ce"
TARGET_BRANCH: ${{ vars.SYNC_TARGET_BRANCH_NAME }} # The target branch that you would like to merge changes like develop
GITHUB_TOKEN: ${{ secrets.ACCESS_TOKEN }} # Personal access token required to modify contents and workflows
REVIEWER: ${{ vars.SYNC_PR_REVIEWER }}
@@ -16,22 +15,7 @@ env:
ACCOUNT_USER_EMAIL: ${{ vars.ACCOUNT_USER_EMAIL }}
jobs:
Check_Branch:
runs-on: ubuntu-latest
outputs:
BRANCH_MATCH: ${{ steps.check-branch.outputs.MATCH }}
steps:
- name: Check if current branch matches the secret
id: check-branch
run: |
if [ "$CURRENT_BRANCH" = "$SOURCE_BRANCH" ]; then
echo "MATCH=true" >> $GITHUB_OUTPUT
else
echo "MATCH=false" >> $GITHUB_OUTPUT
fi
Create_PR:
if: ${{ needs.Check_Branch.outputs.BRANCH_MATCH == 'true' }}
needs: [Check_Branch]
runs-on: ubuntu-latest
permissions:
pull-requests: write
@@ -59,11 +43,11 @@ jobs:
- name: Create PR to Target Branch
run: |
# get all pull requests and check if there is already a PR
PR_EXISTS=$(gh pr list --base $TARGET_BRANCH --head $SOURCE_BRANCH --state open --json number | jq '.[] | .number')
PR_EXISTS=$(gh pr list --base $TARGET_BRANCH --head $CURRENT_BRANCH --state open --json number | jq '.[] | .number')
if [ -n "$PR_EXISTS" ]; then
echo "Pull Request already exists: $PR_EXISTS"
else
echo "Creating new pull request"
PR_URL=$(gh pr create --base $TARGET_BRANCH --head $SOURCE_BRANCH --title "sync: community changes" --body "")
PR_URL=$(gh pr create --base $TARGET_BRANCH --head $CURRENT_BRANCH --title "sync: community changes" --body "")
echo "Pull Request created: $PR_URL"
fi
+2 -1
View File
@@ -35,8 +35,9 @@ jobs:
env:
GH_TOKEN: ${{ secrets.ACCESS_TOKEN }}
run: |
RUN_ID="${{ github.run_id }}"
TARGET_REPO="${{ vars.SYNC_TARGET_REPO }}"
TARGET_BRANCH="${{ vars.SYNC_TARGET_BRANCH_NAME }}"
TARGET_BRANCH="sync/${RUN_ID}"
SOURCE_BRANCH="${{ env.SOURCE_BRANCH_NAME }}"
git checkout $SOURCE_BRANCH
+5 -4
View File
@@ -2,11 +2,12 @@
import { FC, useEffect, useRef } from "react";
import { observer } from "mobx-react";
// hooks
import { HelpSection, SidebarMenu, SidebarDropdown } from "@/components/admin-sidebar";
import { useTheme } from "@/hooks/store";
import useOutsideClickDetector from "@/hooks/use-outside-click-detector";
// plane helpers
import { useOutsideClickDetector } from "@plane/helpers";
// components
import { HelpSection, SidebarMenu, SidebarDropdown } from "@/components/admin-sidebar";
// hooks
import { useTheme } from "@/hooks/store";
export interface IInstanceSidebar {}
@@ -1,21 +0,0 @@
"use client";
import React, { useEffect } from "react";
const useOutsideClickDetector = (ref: React.RefObject<HTMLElement>, callback: () => void) => {
const handleClick = (event: MouseEvent) => {
if (ref.current && !ref.current.contains(event.target as Node)) {
callback();
}
};
useEffect(() => {
document.addEventListener("mousedown", handleClick);
return () => {
document.removeEventListener("mousedown", handleClick);
};
});
};
export default useOutsideClickDetector;
+1 -1
View File
@@ -2,4 +2,4 @@
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
+4 -3
View File
@@ -12,9 +12,10 @@
},
"dependencies": {
"@headlessui/react": "^1.7.19",
"@plane/constants": "*",
"@plane/helpers": "*",
"@plane/types": "*",
"@plane/ui": "*",
"@plane/constants": "*",
"@tailwindcss/typography": "^0.5.9",
"@types/lodash": "^4.17.0",
"autoprefixer": "10.4.14",
@@ -24,7 +25,7 @@
"lucide-react": "^0.356.0",
"mobx": "^6.12.0",
"mobx-react": "^9.1.1",
"next": "^14.2.3",
"next": "^14.2.12",
"next-themes": "^0.2.1",
"postcss": "^8.4.38",
"react": "^18.3.1",
@@ -45,6 +46,6 @@
"eslint-config-custom": "*",
"tailwind-config-custom": "*",
"tsconfig": "*",
"typescript": "^5.4.2"
"typescript": "5.4.5"
}
}
+1 -1
View File
@@ -302,7 +302,7 @@ class ProjectAPIEndpoint(BaseAPIView):
serializer.save()
if serializer.data["inbox_view"]:
Inbox.objects.get_or_create(
name=f"{project.name} Inbox",
defaults={"name": f"{project.name} Inbox"},
project=project,
is_default=True,
)
+1
View File
@@ -176,6 +176,7 @@ class UserAdminLiteSerializer(BaseSerializer):
"is_bot",
"display_name",
"email",
"last_login_medium",
]
read_only_fields = [
"id",
+32 -11
View File
@@ -167,9 +167,10 @@ class InboxIssueViewSet(BaseViewSet):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def list(self, request, slug, project_id):
inbox_id = Inbox.objects.get(
inbox_id = Inbox.objects.filter(
workspace__slug=slug, project_id=project_id
)
).first()
project = Project.objects.get(pk=project_id)
filters = issue_filters(request.GET, "GET", "issue__")
inbox_issue = (
InboxIssue.objects.filter(
@@ -199,13 +200,16 @@ class InboxIssueViewSet(BaseViewSet):
if inbox_status:
inbox_issue = inbox_issue.filter(status__in=inbox_status)
if ProjectMember.objects.filter(
workspace__slug=slug,
project_id=project_id,
member=request.user,
role=5,
is_active=True,
).exists():
if (
ProjectMember.objects.filter(
workspace__slug=slug,
project_id=project_id,
member=request.user,
role=5,
is_active=True,
).exists()
and not project.guest_view_all_features
):
inbox_issue = inbox_issue.filter(created_by=request.user)
return self.paginate(
request=request,
@@ -517,14 +521,16 @@ class InboxIssueViewSet(BaseViewSet):
allowed_roles=[
ROLE.ADMIN,
ROLE.MEMBER,
ROLE.GUEST,
],
creator=True,
model=Issue,
)
def retrieve(self, request, slug, project_id, pk):
inbox_id = Inbox.objects.get(
inbox_id = Inbox.objects.filter(
workspace__slug=slug, project_id=project_id
)
).first()
project = Project.objects.get(pk=project_id)
inbox_issue = (
InboxIssue.objects.select_related("issue")
.prefetch_related(
@@ -551,6 +557,21 @@ class InboxIssueViewSet(BaseViewSet):
)
.get(inbox_id=inbox_id.id, issue_id=pk, project_id=project_id)
)
if (
ProjectMember.objects.filter(
workspace__slug=slug,
project_id=project_id,
member=request.user,
role=5,
is_active=True,
).exists()
and not project.guest_view_all_features
and not inbox_issue.created_by == request.user
):
return Response(
{"error": "You are not allowed to view this issue"},
status=status.HTTP_400_BAD_REQUEST,
)
issue = InboxIssueDetailSerializer(inbox_issue).data
return Response(
issue,
+23 -1
View File
@@ -21,6 +21,8 @@ from plane.db.models import (
IssueComment,
ProjectMember,
CommentReaction,
Project,
Issue,
)
from plane.bgtasks.issue_activities_task import issue_activity
@@ -67,9 +69,27 @@ class IssueCommentViewSet(BaseViewSet):
[
ROLE.ADMIN,
ROLE.MEMBER,
ROLE.GUEST,
]
)
def create(self, request, slug, project_id, issue_id):
project = Project.objects.get(pk=project_id)
issue = Issue.objects.get(pk=issue_id)
if (
ProjectMember.objects.filter(
workspace__slug=slug,
project_id=project_id,
member=request.user,
role=5,
is_active=True,
).exists()
and not project.guest_view_all_features
and not issue.created_by == request.user
):
return Response(
{"error": "You are not allowed to comment on the issue"},
status=status.HTTP_400_BAD_REQUEST,
)
serializer = IssueCommentSerializer(data=request.data)
if serializer.is_valid():
serializer.save(
@@ -94,7 +114,7 @@ class IssueCommentViewSet(BaseViewSet):
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@allow_permission(
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER],
allowed_roles=[ROLE.ADMIN],
creator=True,
model=IssueComment,
)
@@ -182,6 +202,7 @@ class CommentReactionViewSet(BaseViewSet):
[
ROLE.ADMIN,
ROLE.MEMBER,
ROLE.GUEST,
]
)
def create(self, request, slug, project_id, comment_id):
@@ -210,6 +231,7 @@ class CommentReactionViewSet(BaseViewSet):
[
ROLE.ADMIN,
ROLE.MEMBER,
ROLE.GUEST,
]
)
def destroy(self, request, slug, project_id, comment_id, reaction_code):
+3 -4
View File
@@ -12,7 +12,7 @@ from rest_framework import status
# Module imports
from .. import BaseViewSet
from plane.app.serializers import IssueReactionSerializer
from plane.app.permissions import ProjectLitePermission
from plane.app.permissions import allow_permission, ROLE
from plane.db.models import IssueReaction
from plane.bgtasks.issue_activities_task import issue_activity
@@ -20,9 +20,6 @@ from plane.bgtasks.issue_activities_task import issue_activity
class IssueReactionViewSet(BaseViewSet):
serializer_class = IssueReactionSerializer
model = IssueReaction
permission_classes = [
ProjectLitePermission,
]
def get_queryset(self):
return (
@@ -40,6 +37,7 @@ class IssueReactionViewSet(BaseViewSet):
.distinct()
)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def create(self, request, slug, project_id, issue_id):
serializer = IssueReactionSerializer(data=request.data)
if serializer.is_valid():
@@ -62,6 +60,7 @@ class IssueReactionViewSet(BaseViewSet):
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def destroy(self, request, slug, project_id, issue_id, reaction_code):
issue_reaction = IssueReaction.objects.get(
workspace__slug=slug,
+2 -2
View File
@@ -173,7 +173,7 @@ class ProjectViewSet(BaseViewSet):
member=request.user,
workspace__slug=slug,
is_active=True,
role=10,
role=15,
).exists():
projects = projects.filter(
Q(
@@ -439,7 +439,7 @@ class ProjectViewSet(BaseViewSet):
serializer.save()
if serializer.data["inbox_view"]:
Inbox.objects.get_or_create(
name=f"{project.name} Inbox",
defaults={"name": f"{project.name} Inbox"},
project=project,
is_default=True,
)
+17 -23
View File
@@ -17,7 +17,7 @@ from rest_framework.permissions import AllowAny
from .base import BaseViewSet, BaseAPIView
from plane.app.serializers import ProjectMemberInviteSerializer
from plane.app.permissions import ProjectBasePermission
from plane.app.permissions import allow_permission, ROLE
from plane.db.models import (
ProjectMember,
@@ -35,10 +35,6 @@ class ProjectInvitationsViewset(BaseViewSet):
search_fields = []
permission_classes = [
ProjectBasePermission,
]
def get_queryset(self):
return self.filter_queryset(
super()
@@ -49,6 +45,7 @@ class ProjectInvitationsViewset(BaseViewSet):
.select_related("workspace", "workspace__owner")
)
@allow_permission([ROLE.ADMIN])
def create(self, request, slug, project_id):
emails = request.data.get("emails", [])
@@ -59,24 +56,21 @@ class ProjectInvitationsViewset(BaseViewSet):
status=status.HTTP_400_BAD_REQUEST,
)
requesting_user = ProjectMember.objects.get(
workspace__slug=slug,
project_id=project_id,
member_id=request.user.id,
)
for email in emails:
workspace_role = WorkspaceMember.objects.filter(
workspace__slug=slug,
member__email=email.get("email"),
is_active=True,
).role
# Check if any invited user has an higher role
if len(
[
email
for email in emails
if int(email.get("role", 5)) > requesting_user.role
]
):
return Response(
{"error": "You cannot invite a user with higher role"},
status=status.HTTP_400_BAD_REQUEST,
)
if workspace_role in [5, 20] and workspace_role != email.get(
"role", 5
):
return Response(
{
"error": "You cannot invite a user with different role than workspace role"
},
)
workspace = Workspace.objects.get(slug=slug)
@@ -170,7 +164,7 @@ class UserProjectInvitationsViewset(BaseViewSet):
ProjectMember(
project_id=project_id,
member=request.user,
role=15 if workspace_role >= 15 else 5,
role=workspace_role,
workspace=workspace,
created_by=request.user,
)
@@ -414,6 +414,7 @@ class UserProjectRolesEndpoint(BaseAPIView):
project_members = ProjectMember.objects.filter(
workspace__slug=slug,
member_id=request.user.id,
is_active=True,
).values("project_id", "role")
project_members = {
+7 -27
View File
@@ -14,7 +14,7 @@ from plane.app.permissions import (
WorkSpaceAdminPermission,
WorkspaceEntityPermission,
allow_permission,
ROLE
ROLE,
)
# Module imports
@@ -44,7 +44,6 @@ class WorkSpaceMemberViewSet(BaseViewSet):
serializer_class = WorkspaceMemberAdminSerializer
model = WorkspaceMember
search_fields = [
"member__display_name",
"member__first_name",
@@ -96,9 +95,7 @@ class WorkSpaceMemberViewSet(BaseViewSet):
user=False,
multiple=True,
)
@allow_permission(
allowed_roles=[ROLE.ADMIN], level="WORKSPACE"
)
@allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE")
def partial_update(self, request, slug, pk):
workspace_member = WorkspaceMember.objects.get(
pk=pk,
@@ -112,25 +109,10 @@ class WorkSpaceMemberViewSet(BaseViewSet):
status=status.HTTP_400_BAD_REQUEST,
)
# Get the requested user role
requested_workspace_member = WorkspaceMember.objects.get(
workspace__slug=slug,
member=request.user,
is_active=True,
)
# Check if role is being updated
# One cannot update role higher than his own role
if (
"role" in request.data
and int(request.data.get("role", workspace_member.role))
> requested_workspace_member.role
):
return Response(
{
"error": "You cannot update a role that is higher than your own role"
},
status=status.HTTP_400_BAD_REQUEST,
)
if workspace_member.role > int(request.data.get("role")):
_ = ProjectMember.objects.filter(
workspace__slug=slug, member_id=workspace_member.member_id
).update(role=int(request.data.get("role")))
serializer = WorkSpaceMemberSerializer(
workspace_member, data=request.data, partial=True
@@ -151,9 +133,7 @@ class WorkSpaceMemberViewSet(BaseViewSet):
@invalidate_cache(
path="/api/users/me/workspaces/", user=False, multiple=True
)
@allow_permission(
allowed_roles=[ROLE.ADMIN], level="WORKSPACE"
)
@allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE")
def destroy(self, request, slug, pk):
# Check the user role who is deleting the user
workspace_member = WorkspaceMember.objects.get(
+3 -3
View File
@@ -265,7 +265,7 @@ if AMQP_URL:
CELERY_BROKER_URL = AMQP_URL
else:
CELERY_BROKER_URL = f"amqp://{RABBITMQ_USER}:{RABBITMQ_PASSWORD}@{RABBITMQ_HOST}:{RABBITMQ_PORT}/{RABBITMQ_VHOST}"
CELERY_TIMEZONE = TIME_ZONE
CELERY_TASK_SERIALIZER = "json"
CELERY_RESULT_SERIALIZER = "json"
@@ -336,14 +336,14 @@ SESSION_COOKIE_SECURE = secure_origins
SESSION_COOKIE_HTTPONLY = True
SESSION_ENGINE = "plane.db.models.session"
SESSION_COOKIE_AGE = os.environ.get("SESSION_COOKIE_AGE", 604800)
SESSION_COOKIE_NAME = "plane-session-id"
SESSION_COOKIE_NAME = os.environ.get("SESSION_COOKIE_NAME", "session-id")
SESSION_COOKIE_DOMAIN = os.environ.get("COOKIE_DOMAIN", None)
SESSION_SAVE_EVERY_REQUEST = (
os.environ.get("SESSION_SAVE_EVERY_REQUEST", "0") == "1"
)
# Admin Cookie
ADMIN_SESSION_COOKIE_NAME = "plane-admin-session-id"
ADMIN_SESSION_COOKIE_NAME = "admin-session-id"
ADMIN_SESSION_COOKIE_AGE = os.environ.get("ADMIN_SESSION_COOKIE_AGE", 3600)
# CSRF cookies
+3 -1
View File
@@ -3,11 +3,13 @@ RUN apk add --no-cache libc6-compat
# Set working directory
WORKDIR /app
COPY . .
RUN yarn global add turbo
RUN yarn install
EXPOSE 3003
ENV TURBO_TELEMETRY_DISABLED 1
VOLUME [ "/app/node_modules", "/app/live/node_modules"]
CMD ["yarn","dev", "--filter=live"]
+4
View File
@@ -28,6 +28,8 @@ RUN yarn install
COPY --from=builder /app/out/full/ .
COPY turbo.json turbo.json
ENV TURBO_TELEMETRY_DISABLED 1
RUN yarn turbo build --filter=live
FROM base AS runner
@@ -36,4 +38,6 @@ WORKDIR /app
COPY --from=installer /app .
# COPY --from=installer /app/live/node_modules ./node_modules
ENV TURBO_TELEMETRY_DISABLED 1
EXPOSE 3000
+1 -1
View File
@@ -54,6 +54,6 @@
"nodemon": "^3.1.0",
"ts-node": "^10.9.2",
"tsup": "^7.2.0",
"typescript": "^5.4.5"
"typescript": "5.4.5"
}
}
+2 -1
View File
@@ -14,7 +14,8 @@
"packages/tsconfig",
"packages/ui",
"packages/types",
"packages/constants"
"packages/constants",
"packages/helpers"
],
"scripts": {
"build": "turbo run build",
+1 -1
View File
@@ -79,7 +79,7 @@
"tailwind-config-custom": "*",
"tsconfig": "*",
"tsup": "^7.2.0",
"typescript": "4.9.5"
"typescript": "5.4.5"
},
"keywords": [
"editor",
@@ -4,20 +4,17 @@ import { SlashCommand } from "@/extensions";
// plane editor types
import { TIssueEmbedConfig } from "@/plane-editor/types";
// types
import { TExtensions, TFileHandler, TUserDetails } from "@/types";
import { TExtensions, TUserDetails } from "@/types";
type Props = {
disabledExtensions?: TExtensions[];
fileHandler: TFileHandler;
issueEmbedConfig: TIssueEmbedConfig | undefined;
provider: HocuspocusProvider;
userDetails: TUserDetails;
};
export const DocumentEditorAdditionalExtensions = (props: Props) => {
const { fileHandler } = props;
const extensions: Extensions = [SlashCommand(fileHandler.upload)];
export const DocumentEditorAdditionalExtensions = (_props: Props) => {
const extensions: Extensions = [SlashCommand()];
return extensions;
};
@@ -1,7 +1,5 @@
import { FC, ReactNode } from "react";
import { Editor, EditorContent } from "@tiptap/react";
// extensions
import { ImageResizer } from "@/extensions/image";
interface EditorContentProps {
children?: ReactNode;
@@ -16,7 +14,6 @@ export const EditorContentWrapper: FC<EditorContentProps> = (props) => {
return (
<div tabIndex={tabIndex} onFocus={() => editor?.chain().focus(undefined, { scrollIntoView: false }).run()}>
<EditorContent editor={editor} />
{editor?.isActive("image") && editor?.isEditable && <ImageResizer editor={editor} id={id} />}
{children}
</div>
);
@@ -8,10 +8,10 @@ import { SideMenuExtension, SlashCommand } from "@/extensions";
import { EditorRefApi, IRichTextEditor } from "@/types";
const RichTextEditor = (props: IRichTextEditor) => {
const { dragDropEnabled, fileHandler } = props;
const { dragDropEnabled } = props;
const getExtensions = useCallback(() => {
const extensions = [SlashCommand(fileHandler.upload)];
const extensions = [SlashCommand()];
extensions.push(
SideMenuExtension({
@@ -21,7 +21,7 @@ const RichTextEditor = (props: IRichTextEditor) => {
);
return extensions;
}, [dragDropEnabled, fileHandler.upload]);
}, [dragDropEnabled]);
return (
<EditorWrapper {...props} extensions={getExtensions()}>
@@ -101,7 +101,8 @@ export const BlockMenu = (props: BlockMenuProps) => {
icon: Copy,
key: "duplicate",
label: "Duplicate",
isDisabled: editor.state.selection.content().content.firstChild?.type.name === "image",
isDisabled:
editor.state.selection.content().content.firstChild?.type.name === "image" || editor.isActive("imageComponent"),
onClick: (e) => {
e.preventDefault();
e.stopPropagation();
@@ -23,7 +23,6 @@ import {
} from "lucide-react";
// helpers
import {
insertImageCommand,
insertTableCommand,
setText,
toggleBlockquote,
@@ -43,7 +42,7 @@ import {
toggleUnderline,
} from "@/helpers/editor-commands";
// types
import { TEditorCommands, UploadImage } from "@/types";
import { TEditorCommands } from "@/types";
export interface EditorMenuItem {
key: TEditorCommands;
@@ -189,16 +188,17 @@ export const TableItem = (editor: Editor): EditorMenuItem => ({
icon: TableIcon,
});
export const ImageItem = (editor: Editor, uploadFile: UploadImage) =>
export const ImageItem = (editor: Editor) =>
({
key: "image",
name: "Image",
isActive: () => editor?.isActive("image"),
command: (savedSelection: Selection | null) => insertImageCommand(editor, uploadFile, savedSelection),
command: (savedSelection: Selection | null) =>
editor?.commands.setImageUpload({ event: "insert", pos: savedSelection?.from }),
icon: ImageIcon,
}) as const;
export function getEditorMenuItems(editor: Editor | null, uploadFile: UploadImage) {
export function getEditorMenuItems(editor: Editor | null) {
if (!editor) {
return [];
}
@@ -220,6 +220,6 @@ export function getEditorMenuItems(editor: Editor | null, uploadFile: UploadImag
NumberedListItem(editor),
QuoteItem(editor),
TableItem(editor),
ImageItem(editor, uploadFile),
ImageItem(editor),
];
}
@@ -11,6 +11,7 @@ import { CustomCodeInlineExtension } from "./code-inline";
import { CustomLinkExtension } from "./custom-link";
import { CustomHorizontalRule } from "./horizontal-rule";
import { ImageExtensionWithoutProps } from "./image";
import { CustomImageComponentWithoutProps } from "./image/image-component-without-props";
import { IssueWidgetWithoutProps } from "./issue-embed/issue-embed-without-props";
import { CustomMentionWithoutProps } from "./mentions/mentions-without-props";
import { CustomQuoteExtension } from "./quote";
@@ -61,6 +62,7 @@ export const CoreEditorExtensionsWithoutProps = [
class: "rounded-md",
},
}),
CustomImageComponentWithoutProps(),
TiptapUnderline,
TextStyle,
TaskList.configure({
@@ -0,0 +1,169 @@
import React, { useRef, useState, useCallback, useLayoutEffect, useEffect } from "react";
import { NodeSelection } from "@tiptap/pm/state";
// extensions
import { CustomImageNodeViewProps } from "@/extensions/custom-image";
// helpers
import { cn } from "@/helpers/common";
const MIN_SIZE = 100;
export const CustomImageBlock: React.FC<CustomImageNodeViewProps> = (props) => {
const { node, updateAttributes, selected, getPos, editor } = props;
const { src, width, height } = node.attrs;
const [size, setSize] = useState({ width: width || "35%", height: height || "auto" });
const [isLoading, setIsLoading] = useState(true);
const [initialResizeComplete, setInitialResizeComplete] = useState(false);
const isShimmerVisible = isLoading || !initialResizeComplete;
const [editorContainer, setEditorContainer] = useState<HTMLElement | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const containerRect = useRef<DOMRect | null>(null);
const imageRef = useRef<HTMLImageElement>(null);
const isResizing = useRef(false);
const aspectRatioRef = useRef<number | null>(null);
useLayoutEffect(() => {
if (imageRef.current) {
const img = imageRef.current;
img.onload = () => {
const closestEditorContainer = img.closest(".editor-container");
if (!closestEditorContainer) {
console.error("Editor container not found");
return;
}
setEditorContainer(closestEditorContainer as HTMLElement);
if (width === "35%") {
const editorWidth = closestEditorContainer.clientWidth;
const initialWidth = Math.max(editorWidth * 0.35, MIN_SIZE);
const aspectRatio = img.naturalWidth / img.naturalHeight;
const initialHeight = initialWidth / aspectRatio;
const newSize = {
width: `${Math.round(initialWidth)}px`,
height: `${Math.round(initialHeight)}px`,
};
setSize(newSize);
updateAttributes(newSize);
}
setInitialResizeComplete(true);
setIsLoading(false);
};
}
}, [width, height, updateAttributes]);
useLayoutEffect(() => {
setSize({ width, height });
}, [width, height]);
const handleResizeStart = useCallback(
(e: React.MouseEvent | React.TouchEvent) => {
e.preventDefault();
e.stopPropagation();
isResizing.current = true;
if (containerRef.current && editorContainer) {
aspectRatioRef.current = Number(size.width.replace("px", "")) / Number(size.height.replace("px", ""));
containerRect.current = containerRef.current.getBoundingClientRect();
}
},
[size, editorContainer]
);
const handleResize = useCallback(
(e: MouseEvent | TouchEvent) => {
if (!isResizing.current || !containerRef.current || !containerRect.current) return;
if (size) {
aspectRatioRef.current = Number(size.width.replace("px", "")) / Number(size.height.replace("px", ""));
}
if (!aspectRatioRef.current) return;
const clientX = "touches" in e ? e.touches[0].clientX : e.clientX;
const newWidth = Math.max(clientX - containerRect.current.left, MIN_SIZE);
const newHeight = newWidth / aspectRatioRef.current;
setSize({ width: `${newWidth}px`, height: `${newHeight}px` });
},
[size]
);
const handleResizeEnd = useCallback(() => {
if (isResizing.current) {
isResizing.current = false;
updateAttributes(size);
}
}, [size, updateAttributes]);
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
const pos = getPos();
const nodeSelection = NodeSelection.create(editor.state.doc, pos);
editor.view.dispatch(editor.state.tr.setSelection(nodeSelection));
},
[editor, getPos]
);
useEffect(() => {
if (!editorContainer) return;
const handleMouseMove = (e: MouseEvent) => handleResize(e);
const handleMouseUp = () => handleResizeEnd();
const handleMouseLeave = () => handleResizeEnd();
editorContainer.addEventListener("mousemove", handleMouseMove);
editorContainer.addEventListener("mouseup", handleMouseUp);
editorContainer.addEventListener("mouseleave", handleMouseLeave);
return () => {
editorContainer.removeEventListener("mousemove", handleMouseMove);
editorContainer.removeEventListener("mouseup", handleMouseUp);
editorContainer.removeEventListener("mouseleave", handleMouseLeave);
};
}, [handleResize, handleResizeEnd, editorContainer]);
return (
<div
ref={containerRef}
className="group/image-component relative inline-block max-w-full"
onMouseDown={handleMouseDown}
style={{
width: size.width,
height: size.height,
}}
>
{isShimmerVisible && (
<div className="animate-pulse bg-custom-background-80 rounded-md" style={{ width, height }} />
)}
<img
ref={imageRef}
src={src}
width={size.width}
height={size.height}
className={cn("block rounded-md", {
hidden: isShimmerVisible,
"read-only-image": !editor.isEditable,
})}
style={{
width: size.width,
height: size.height,
}}
/>
{editor.isEditable && selected && <div className="absolute inset-0 size-full bg-custom-primary-500/30" />}
{editor.isEditable && (
<>
<div className="opacity-0 group-hover/image-component:opacity-100 absolute inset-0 border-2 border-custom-primary-100 pointer-events-none rounded-md transition-opacity duration-100 ease-in-out" />
<div
className="opacity-0 pointer-events-none group-hover/image-component:opacity-100 group-hover/image-component:pointer-events-auto absolute bottom-0 right-0 translate-y-1/2 translate-x-1/2 size-4 rounded-full bg-custom-primary-100 border-2 border-white cursor-nwse-resize transition-opacity duration-100 ease-in-out"
onMouseDown={handleResizeStart}
/>
</>
)}
</div>
);
};
@@ -0,0 +1,122 @@
import React, { useCallback, useEffect, useRef, useState } from "react";
import { Node as ProsemirrorNode } from "@tiptap/pm/model";
import { Editor, NodeViewWrapper } from "@tiptap/react";
// extensions
import {
CustomImageBlock,
CustomImageUploader,
UploadEntity,
UploadImageExtensionStorage,
} from "@/extensions/custom-image";
export type CustomImageNodeViewProps = {
getPos: () => number;
editor: Editor;
node: ProsemirrorNode & {
attrs: {
src: string;
width: string;
height: string;
};
};
updateAttributes: (attrs: Record<string, any>) => void;
selected: boolean;
};
export const CustomImageNode = (props: CustomImageNodeViewProps) => {
const { getPos, editor, node, updateAttributes, selected } = props;
const fileInputRef = useRef<HTMLInputElement>(null);
const hasTriggeredFilePickerRef = useRef(false);
const [isUploaded, setIsUploaded] = useState(!!node.attrs.src);
const id = node.attrs.id as string;
const editorStorage = editor.storage.imageComponent as UploadImageExtensionStorage | undefined;
const getUploadEntity = useCallback(
(): UploadEntity | undefined => editorStorage?.fileMap.get(id),
[editorStorage, id]
);
const onUpload = useCallback(
(url: string) => {
if (url) {
setIsUploaded(true);
// Update the node view's src attribute
updateAttributes({ src: url });
editorStorage?.fileMap.delete(id);
}
},
[editorStorage?.fileMap, id, updateAttributes]
);
const uploadFile = useCallback(
async (file: File) => {
try {
// @ts-expect-error - TODO: fix typings, and don't remove await from
// here for now
const url: string = await editor?.commands.uploadImage(file);
if (!url) {
throw new Error("Something went wrong while uploading the image");
}
onUpload(url);
} catch (error) {
console.error("Error uploading file:", error);
}
},
[editor.commands, onUpload]
);
useEffect(() => {
const uploadEntity = getUploadEntity();
if (uploadEntity) {
if (uploadEntity.event === "drop" && "file" in uploadEntity) {
uploadFile(uploadEntity.file);
} else if (uploadEntity.event === "insert" && fileInputRef.current && !hasTriggeredFilePickerRef.current) {
const entity = editorStorage?.fileMap.get(id);
if (entity && entity.hasOpenedFileInputOnce) return;
fileInputRef.current.click();
hasTriggeredFilePickerRef.current = true;
if (!entity) return;
editorStorage?.fileMap.set(id, { ...entity, hasOpenedFileInputOnce: true });
}
}
}, [getUploadEntity, uploadFile]);
useEffect(() => {
if (node.attrs.src) {
setIsUploaded(true);
}
}, [node.attrs.src]);
const existingFile = React.useMemo(() => {
const entity = getUploadEntity();
return entity && entity.event === "drop" ? entity.file : undefined;
}, [getUploadEntity]);
return (
<NodeViewWrapper>
<div className="p-0 mx-0 my-2" data-drag-handle>
{isUploaded ? (
<CustomImageBlock
editor={editor}
getPos={getPos}
node={node}
updateAttributes={updateAttributes}
selected={selected}
/>
) : (
<CustomImageUploader
onUpload={onUpload}
editor={editor}
fileInputRef={fileInputRef}
existingFile={existingFile}
selected={selected}
/>
)}
</div>
</NodeViewWrapper>
);
};
@@ -0,0 +1,90 @@
import { ChangeEvent, useCallback, useEffect, useRef } from "react";
import { Editor } from "@tiptap/core";
import { ImageIcon } from "lucide-react";
// helpers
import { cn } from "@/helpers/common";
// hooks
import { useUploader, useFileUpload, useDropZone } from "@/hooks/use-file-upload";
// plugins
import { isFileValid } from "@/plugins/image";
type RefType = React.RefObject<HTMLInputElement> | ((instance: HTMLInputElement | null) => void);
const assignRef = (ref: RefType, value: HTMLInputElement | null) => {
if (typeof ref === "function") {
ref(value);
} else if (ref && typeof ref === "object") {
(ref as React.MutableRefObject<HTMLInputElement | null>).current = value;
}
};
export const CustomImageUploader = (props: {
onUpload: (url: string) => void;
editor: Editor;
fileInputRef: RefType;
existingFile?: File;
selected: boolean;
}) => {
const { selected, onUpload, editor, fileInputRef, existingFile } = props;
const { loading, uploadFile } = useUploader({ onUpload, editor });
const { handleUploadClick, ref: internalRef } = useFileUpload();
const { draggedInside, onDrop, onDragEnter, onDragLeave } = useDropZone({ uploader: uploadFile });
const localRef = useRef<HTMLInputElement | null>(null);
const onFileChange = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
if (isFileValid(file)) {
uploadFile(file);
}
}
},
[uploadFile]
);
useEffect(() => {
// no need to validate as the file is already validated before the drop onto
// the editor
if (existingFile) {
uploadFile(existingFile);
}
}, [existingFile, uploadFile]);
return (
<div
className={cn(
"image-upload-component flex items-center justify-start gap-2 py-3 px-2 rounded-lg text-custom-text-300 hover:text-custom-text-200 bg-custom-background-90 hover:bg-custom-background-80 border border-dashed border-custom-border-300 cursor-pointer transition-all duration-200 ease-in-out",
{
"bg-custom-background-80 text-custom-text-200": draggedInside,
},
{
"text-custom-primary-200 bg-custom-primary-100/10": selected,
}
)}
onDrop={onDrop}
onDragOver={onDragEnter}
onDragLeave={onDragLeave}
contentEditable={false}
onClick={handleUploadClick}
>
<ImageIcon className="size-4" />
<div className="text-base font-medium">
{loading ? "Uploading..." : draggedInside ? "Drop image here" : existingFile ? "Uploading..." : "Add an image"}
</div>
<input
className="size-0 overflow-hidden"
ref={(element) => {
localRef.current = element;
assignRef(fileInputRef, element);
assignRef(internalRef as RefType, element);
}}
hidden
type="file"
accept=".jpg,.jpeg,.png,.webp"
onChange={onFileChange}
/>
</div>
);
};
@@ -0,0 +1,3 @@
export * from "./image-block";
export * from "./image-node";
export * from "./image-uploader";
@@ -0,0 +1,157 @@
import { mergeAttributes } from "@tiptap/core";
import { Image } from "@tiptap/extension-image";
import { ReactNodeViewRenderer } from "@tiptap/react";
import { v4 as uuidv4 } from "uuid";
// extensions
import { CustomImageNode } from "@/extensions/custom-image";
// plugins
import { TrackImageDeletionPlugin, TrackImageRestorationPlugin, isFileValid } from "@/plugins/image";
// types
import { TFileHandler } from "@/types";
// helpers
import { insertEmptyParagraphAtNodeBoundaries } from "@/helpers/insert-empty-paragraph-at-node-boundary";
declare module "@tiptap/core" {
interface Commands<ReturnType> {
imageComponent: {
setImageUpload: ({ file, pos, event }: { file?: File; pos?: number; event: "insert" | "drop" }) => ReturnType;
uploadImage: (file: File) => () => Promise<string> | undefined;
};
}
}
export interface UploadImageExtensionStorage {
fileMap: Map<string, UploadEntity>;
}
export type UploadEntity = ({ event: "insert" } | { event: "drop"; file: File }) & { hasOpenedFileInputOnce?: boolean };
export const CustomImageExtension = (props: TFileHandler) => {
const { upload, delete: deleteImage, restore: restoreImage } = props;
return Image.extend<Record<string, unknown>, UploadImageExtensionStorage>({
name: "imageComponent",
selectable: true,
group: "block",
atom: true,
draggable: true,
addAttributes() {
return {
...this.parent?.(),
width: {
default: "35%",
},
src: {
default: null,
},
height: {
default: "auto",
},
["id"]: {
default: null,
},
};
},
parseHTML() {
return [
{
tag: "image-component",
},
];
},
renderHTML({ HTMLAttributes }) {
return ["image-component", mergeAttributes(HTMLAttributes)];
},
onCreate(this) {
const imageSources = new Set<string>();
this.editor.state.doc.descendants((node) => {
if (node.type.name === this.name) {
imageSources.add(node.attrs.src);
}
});
imageSources.forEach(async (src) => {
try {
const assetUrlWithWorkspaceId = new URL(src).pathname.substring(1);
await restoreImage(assetUrlWithWorkspaceId);
} catch (error) {
console.error("Error restoring image: ", error);
}
});
},
addKeyboardShortcuts() {
return {
ArrowDown: insertEmptyParagraphAtNodeBoundaries("down", this.name),
ArrowUp: insertEmptyParagraphAtNodeBoundaries("up", this.name),
};
},
addProseMirrorPlugins() {
return [
TrackImageDeletionPlugin(this.editor, deleteImage, this.name),
TrackImageRestorationPlugin(this.editor, restoreImage, this.name),
];
},
addStorage() {
return {
fileMap: new Map(),
deletedImageSet: new Map<string, boolean>(),
};
},
addCommands() {
return {
setImageUpload:
(props: { file?: File; pos?: number; event: "insert" | "drop" }) =>
({ commands }) => {
// Early return if there's an invalid file being dropped
if (props?.file && !isFileValid(props.file)) {
return false;
}
// generate a unique id for the image to keep track of dropped
// files' file data
const fileId = uuidv4();
if (props?.event === "drop" && props.file) {
(this.editor.storage.imageComponent as UploadImageExtensionStorage).fileMap.set(fileId, {
file: props.file,
event: props.event,
});
} else if (props.event === "insert") {
(this.editor.storage.imageComponent as UploadImageExtensionStorage).fileMap.set(fileId, {
event: props.event,
});
}
const attributes = {
id: fileId,
};
if (props.pos) {
return commands.insertContentAt(props.pos, {
type: this.name,
attrs: attributes,
});
}
return commands.insertContent({
type: this.name,
attrs: attributes,
});
},
uploadImage: (file: File) => async () => {
const fileUrl = await upload(file);
return fileUrl;
},
};
},
addNodeView() {
return ReactNodeViewRenderer(CustomImageNode);
},
});
};
@@ -0,0 +1,3 @@
export * from "./components";
export * from "./custom-image";
export * from "./read-only-custom-image";
@@ -0,0 +1,54 @@
import { mergeAttributes } from "@tiptap/core";
import { Image } from "@tiptap/extension-image";
import { ReactNodeViewRenderer } from "@tiptap/react";
// components
import { CustomImageNode, UploadImageExtensionStorage } from "@/extensions/custom-image";
export const CustomReadOnlyImageExtension = () =>
Image.extend<Record<string, unknown>, UploadImageExtensionStorage>({
name: "imageComponent",
selectable: false,
group: "block",
atom: true,
draggable: false,
addAttributes() {
return {
...this.parent?.(),
width: {
default: "35%",
},
src: {
default: null,
},
height: {
default: "auto",
},
["id"]: {
default: null,
},
};
},
parseHTML() {
return [
{
tag: "image-component",
},
];
},
renderHTML({ HTMLAttributes }) {
return ["image-component", mergeAttributes(HTMLAttributes)];
},
addStorage() {
return {
fileMap: new Map(),
};
},
addNodeView() {
return ReactNodeViewRenderer(CustomImageNode);
},
});
+41 -21
View File
@@ -1,11 +1,8 @@
import { Extension } from "@tiptap/core";
import { Plugin, PluginKey } from "prosemirror-state";
// plugins
import { startImageUpload } from "@/plugins/image";
// types
import { UploadImage } from "@/types";
import { EditorView } from "prosemirror-view";
export const DropHandlerExtension = (uploadFile: UploadImage) =>
export const DropHandlerExtension = () =>
Extension.create({
name: "dropHandler",
priority: 1000,
@@ -15,28 +12,51 @@ export const DropHandlerExtension = (uploadFile: UploadImage) =>
new Plugin({
key: new PluginKey("drop-handler-plugin"),
props: {
handlePaste: (view, event) => {
if (event.clipboardData && event.clipboardData.files && event.clipboardData.files[0]) {
handlePaste: (view: EditorView, event: ClipboardEvent) => {
if (event.clipboardData && event.clipboardData.files && event.clipboardData.files.length > 0) {
event.preventDefault();
const file = event.clipboardData.files[0];
const pos = view.state.selection.from;
startImageUpload(this.editor, file, view, pos, uploadFile);
return true;
const files = Array.from(event.clipboardData.files);
const imageFiles = files.filter((file) => file.type.startsWith("image"));
if (imageFiles.length > 0) {
const pos = view.state.selection.from;
imageFiles.forEach((file, index) => {
this.editor
.chain()
.focus()
.setImageUpload({ file, pos: pos + index, event: "drop" })
.run();
});
return true;
}
}
return false;
},
handleDrop: (view, event, _slice, moved) => {
if (!moved && event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files[0]) {
handleDrop: (view: EditorView, event: DragEvent, _slice: any, moved: boolean) => {
if (!moved && event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files.length > 0) {
event.preventDefault();
const file = event.dataTransfer.files[0];
const coordinates = view.posAtCoords({
left: event.clientX,
top: event.clientY,
});
if (coordinates) {
startImageUpload(this.editor, file, view, coordinates.pos - 1, uploadFile);
const files = Array.from(event.dataTransfer.files);
const imageFiles = files.filter((file) => file.type.startsWith("image"));
if (imageFiles.length > 0) {
const coordinates = view.posAtCoords({
left: event.clientX,
top: event.clientY,
});
if (coordinates) {
imageFiles.forEach((file, index) => {
setTimeout(() => {
this.editor
.chain()
.focus()
.setImageUpload({ file, pos: coordinates.pos + index, event: "drop" })
.run();
}, index * 100); // Slight delay between insertions
});
}
return true;
}
return true;
}
return false;
},
@@ -12,6 +12,7 @@ import {
CustomCodeInlineExtension,
CustomCodeMarkPlugin,
CustomHorizontalRule,
CustomImageExtension,
CustomKeymap,
CustomLinkExtension,
CustomMention,
@@ -79,7 +80,7 @@ export const CoreEditorExtensions = ({
...(enableHistory ? {} : { history: false }),
}),
CustomQuoteExtension,
DropHandlerExtension(uploadFile),
DropHandlerExtension(),
CustomHorizontalRule.configure({
HTMLAttributes: {
class: "my-4 border-custom-border-400",
@@ -104,6 +105,12 @@ export const CoreEditorExtensions = ({
class: "rounded-md",
},
}),
CustomImageExtension({
delete: deleteFile,
restore: restoreFile,
upload: uploadFile,
cancel: cancelUploadImage ?? (() => {}),
}),
TiptapUnderline,
TextStyle,
TaskList.configure({
@@ -142,7 +149,7 @@ export const CoreEditorExtensions = ({
placeholder: ({ editor, node }) => {
if (node.type.name === "heading") return `Heading ${node.attrs.level}`;
if (editor.storage.image.uploadInProgress) return "";
// if (editor.storage.image.uploadInProgress) return "";
const shouldHidePlaceholder =
editor.isActive("table") || editor.isActive("codeBlock") || editor.isActive("image");
@@ -1,37 +1,33 @@
import ImageExt from "@tiptap/extension-image";
import { ReactNodeViewRenderer } from "@tiptap/react";
// helpers
import { insertEmptyParagraphAtNodeBoundaries } from "@/helpers/insert-empty-paragraph-at-node-boundary";
// plugins
import {
IMAGE_NODE_TYPE,
ImageExtensionStorage,
TrackImageDeletionPlugin,
TrackImageRestorationPlugin,
UploadImagesPlugin,
} from "@/plugins/image";
import { ImageExtensionStorage, TrackImageDeletionPlugin, TrackImageRestorationPlugin } from "@/plugins/image";
// types
import { DeleteImage, RestoreImage } from "@/types";
// extensions
import { CustomImageNode } from "@/extensions";
export const ImageExtension = (deleteImage: DeleteImage, restoreImage: RestoreImage, cancelUploadImage?: () => void) =>
ImageExt.extend<any, ImageExtensionStorage>({
addKeyboardShortcuts() {
return {
ArrowDown: insertEmptyParagraphAtNodeBoundaries("down", "image"),
ArrowUp: insertEmptyParagraphAtNodeBoundaries("up", "image"),
ArrowDown: insertEmptyParagraphAtNodeBoundaries("down", this.name),
ArrowUp: insertEmptyParagraphAtNodeBoundaries("up", this.name),
};
},
addProseMirrorPlugins() {
return [
UploadImagesPlugin(this.editor, cancelUploadImage),
TrackImageDeletionPlugin(this.editor, deleteImage),
TrackImageRestorationPlugin(this.editor, restoreImage),
TrackImageDeletionPlugin(this.editor, deleteImage, this.name),
TrackImageRestorationPlugin(this.editor, restoreImage, this.name),
];
},
onCreate(this) {
const imageSources = new Set<string>();
this.editor.state.doc.descendants((node) => {
if (node.type.name === IMAGE_NODE_TYPE) {
if (node.type.name === this.name) {
imageSources.add(node.attrs.src);
}
});
@@ -64,4 +60,9 @@ export const ImageExtension = (deleteImage: DeleteImage, restoreImage: RestoreIm
},
};
},
// render custom image node
addNodeView() {
return ReactNodeViewRenderer(CustomImageNode);
},
});
@@ -0,0 +1,57 @@
import { mergeAttributes } from "@tiptap/core";
import { ReactNodeViewRenderer } from "@tiptap/react";
import { Image } from "@tiptap/extension-image";
// extensions
import { CustomImageNode, UploadImageExtensionStorage } from "@/extensions";
export const CustomImageComponentWithoutProps = () =>
Image.extend<Record<string, unknown>, UploadImageExtensionStorage>({
name: "imageComponent",
selectable: true,
group: "block",
atom: true,
draggable: true,
addAttributes() {
return {
...this.parent?.(),
width: {
default: "35%",
},
src: {
default: null,
},
height: {
default: "auto",
},
["id"]: {
default: null,
},
};
},
parseHTML() {
return [
{
tag: "image-component",
},
];
},
renderHTML({ HTMLAttributes }) {
return ["image-component", mergeAttributes(HTMLAttributes)];
},
addStorage() {
return {
fileMap: new Map(),
deletedImageSet: new Map<string, boolean>(),
};
},
addNodeView() {
return ReactNodeViewRenderer(CustomImageNode);
},
});
export default CustomImageComponentWithoutProps;
@@ -1,4 +1,7 @@
import ImageExt from "@tiptap/extension-image";
import { ReactNodeViewRenderer } from "@tiptap/react";
// extensions
import { CustomImageNode } from "@/extensions";
export const ImageExtensionWithoutProps = () =>
ImageExt.extend({
@@ -13,4 +16,8 @@ export const ImageExtensionWithoutProps = () =>
},
};
},
addNodeView() {
return ReactNodeViewRenderer(CustomImageNode);
},
});
@@ -1,85 +0,0 @@
import { useState } from "react";
import { Editor } from "@tiptap/react";
import Moveable from "react-moveable";
type Props = {
editor: Editor;
id: string;
};
const getImageElement = (editorId: string): HTMLImageElement | null =>
document.querySelector(`#editor-container-${editorId}.active-editor .ProseMirror-selectednode`);
export const ImageResizer = (props: Props) => {
const { editor, id } = props;
// states
const [aspectRatio, setAspectRatio] = useState(1);
const updateMediaSize = () => {
const imageElement = getImageElement(id);
if (!imageElement) return;
const selection = editor.state.selection;
// Use the style width/height if available, otherwise fall back to the element's natural width/height
const width = imageElement.style.width
? Number(imageElement.style.width.replace("px", ""))
: imageElement.getAttribute("width");
const height = imageElement.style.height
? Number(imageElement.style.height.replace("px", ""))
: imageElement.getAttribute("height");
editor.commands.setImage({
src: imageElement.src,
width: width,
height: height,
} as any);
editor.commands.setNodeSelection(selection.from);
};
return (
<Moveable
target={getImageElement(id)}
container={null}
origin={false}
edge={false}
throttleDrag={0}
keepRatio
resizable
throttleResize={0}
onResizeStart={() => {
const imageElement = getImageElement(id);
if (imageElement) {
const originalWidth = Number(imageElement.width);
const originalHeight = Number(imageElement.height);
setAspectRatio(originalWidth / originalHeight);
}
}}
onResize={({ target, width, height, delta }) => {
if (delta[0] || delta[1]) {
let newWidth, newHeight;
if (delta[0]) {
// Width change detected
newWidth = Math.max(width, 100);
newHeight = newWidth / aspectRatio;
} else if (delta[1]) {
// Height change detected
newHeight = Math.max(height, 100);
newWidth = newHeight * aspectRatio;
}
target.style.width = `${newWidth}px`;
target.style.height = `${newHeight}px`;
}
}}
onResizeEnd={() => {
updateMediaSize();
}}
scalable
renderDirections={["se"]}
onScale={({ target, transform }) => {
target.style.transform = transform;
}}
/>
);
};
@@ -1,4 +1,3 @@
export * from "./extension";
export * from "./image-extension-without-props";
export * from "./image-resize";
export * from "./read-only-image";
@@ -1,4 +1,7 @@
import Image from "@tiptap/extension-image";
import { ReactNodeViewRenderer } from "@tiptap/react";
// extensions
import { CustomImageNode } from "@/extensions";
export const ReadOnlyImageExtension = Image.extend({
addAttributes() {
@@ -12,4 +15,7 @@ export const ReadOnlyImageExtension = Image.extend({
},
};
},
addNodeView() {
return ReactNodeViewRenderer(CustomImageNode);
},
});
@@ -1,5 +1,6 @@
export * from "./code";
export * from "./code-inline";
export * from "./custom-image";
export * from "./custom-link";
export * from "./custom-list-keymap";
export * from "./image";
@@ -19,6 +19,7 @@ import {
TableRow,
Table,
CustomMention,
CustomReadOnlyImageExtension,
} from "@/extensions";
// helpers
import { isValidHttpUrl } from "@/helpers/common";
@@ -74,6 +75,7 @@ export const CoreReadOnlyEditorExtensions = (mentionConfig: {
class: "rounded-md",
},
}),
CustomReadOnlyImageExtension(),
TiptapUnderline,
TextStyle,
TaskList.configure({
@@ -3,7 +3,7 @@ import { Plugin, PluginKey } from "@tiptap/pm/state";
import { EditorView } from "@tiptap/pm/view";
// plugins
import { AIHandlePlugin } from "@/plugins/ai-handle";
import { DragHandlePlugin } from "@/plugins/drag-handle";
import { DragHandlePlugin, nodeDOMAtCoords } from "@/plugins/drag-handle";
type Props = {
aiEnabled: boolean;
@@ -59,41 +59,6 @@ const absoluteRect = (node: Element) => {
};
};
const nodeDOMAtCoords = (coords: { x: number; y: number }) => {
const elements = document.elementsFromPoint(coords.x, coords.y);
const generalSelectors = [
"li",
"p:not(:first-child)",
".code-block",
"blockquote",
"img",
"h1, h2, h3, h4, h5, h6",
"[data-type=horizontalRule]",
".table-wrapper",
".issue-embed",
].join(", ");
for (const elem of elements) {
if (elem.matches("p:first-child") && elem.parentElement?.matches(".ProseMirror")) {
return elem;
}
// if the element is a <p> tag that is the first child of a td or th
if (
(elem.matches("td > p:first-child") || elem.matches("th > p:first-child")) &&
elem?.textContent?.trim() !== ""
) {
return elem; // Return only if p tag is not empty in td or th
}
// apply general selector
if (elem.matches(generalSelectors)) {
return elem;
}
}
return null;
};
const SideMenu = (options: SideMenuPluginProps) => {
const { handlesConfig } = options;
const editorSideMenu: HTMLDivElement | null = document.createElement("div");
@@ -28,7 +28,6 @@ import {
toggleBulletList,
toggleOrderedList,
toggleTaskList,
insertImageCommand,
toggleHeadingOne,
toggleHeadingTwo,
toggleHeadingThree,
@@ -37,7 +36,7 @@ import {
toggleHeadingSix,
} from "@/helpers/editor-commands";
// types
import { CommandProps, ISlashCommandItem, UploadImage } from "@/types";
import { CommandProps, ISlashCommandItem } from "@/types";
interface CommandItemProps {
key: string;
@@ -63,7 +62,7 @@ const Command = Extension.create<SlashCommandOptions>({
const { selection } = editor.state;
const parentNode = selection.$from.node(selection.$from.depth);
const blockType = parentNode?.type?.name;
const blockType = parentNode.type.name;
if (blockType === "codeBlock") {
return false;
@@ -89,7 +88,7 @@ const Command = Extension.create<SlashCommandOptions>({
});
const getSuggestionItems =
(uploadFile: UploadImage, additionalOptions?: Array<ISlashCommandItem>) =>
(additionalOptions?: Array<ISlashCommandItem>) =>
({ query }: { query: string }) => {
let slashCommands: ISlashCommandItem[] = [
{
@@ -224,11 +223,11 @@ const getSuggestionItems =
{
key: "image",
title: "Image",
description: "Upload an image from your computer.",
searchTerms: ["img", "photo", "picture", "media"],
icon: <ImageIcon className="size-3.5" />,
description: "Insert an image",
searchTerms: ["img", "photo", "picture", "media", "upload"],
command: ({ editor, range }: CommandProps) => {
insertImageCommand(editor, uploadFile, null, range);
editor.chain().focus().deleteRange(range).setImageUpload({ event: "insert" }).run();
},
},
{
@@ -415,10 +414,10 @@ const renderItems = () => {
};
};
export const SlashCommand = (uploadFile: UploadImage, additionalOptions?: Array<ISlashCommandItem>) =>
export const SlashCommand = (additionalOptions?: Array<ISlashCommandItem>) =>
Command.configure({
suggestion: {
items: getSuggestionItems(uploadFile, additionalOptions),
items: getSuggestionItems(additionalOptions),
render: renderItems,
},
});
-76
View File
@@ -1,76 +0,0 @@
import { Schema } from "@tiptap/pm/model";
import { prosemirrorJSONToYDoc } from "y-prosemirror";
import * as Y from "yjs";
const defaultSchema: Schema = new Schema({
nodes: {
text: {},
doc: { content: "text*" },
},
});
/**
* @description converts ProseMirror JSON to Yjs document
* @param document prosemirror JSON
* @param fieldName
* @param schema
* @returns {Y.Doc} Yjs document
*/
export const proseMirrorJSONToBinaryString = (
document: any,
fieldName: string | Array<string> = "default",
schema?: Schema
): string => {
if (!document) {
throw new Error(
`You've passed an empty or invalid document to the Transformer. Make sure to pass ProseMirror-compatible JSON. Actually passed JSON: ${document}`
);
}
// allow a single field name
if (typeof fieldName === "string") {
const yDoc = prosemirrorJSONToYDoc(schema ?? defaultSchema, document, fieldName);
const docAsUint8Array = Y.encodeStateAsUpdate(yDoc);
const base64Doc = Buffer.from(docAsUint8Array).toString("base64");
return base64Doc;
}
const yDoc = new Y.Doc();
fieldName.forEach((field) => {
const update = Y.encodeStateAsUpdate(prosemirrorJSONToYDoc(schema ?? defaultSchema, document, field));
Y.applyUpdate(yDoc, update);
});
const docAsUint8Array = Y.encodeStateAsUpdate(yDoc);
const base64Doc = Buffer.from(docAsUint8Array).toString("base64");
return base64Doc;
};
/**
* @description apply updates to a doc and return the updated doc in base64(binary) format
* @param {Uint8Array} document
* @param {Uint8Array} updates
* @returns {string} base64(binary) form of the updated doc
*/
export const applyUpdates = (document: Uint8Array, updates: Uint8Array): string => {
const yDoc = new Y.Doc();
Y.applyUpdate(yDoc, document);
Y.applyUpdate(yDoc, updates);
const encodedDoc = Y.encodeStateAsUpdate(yDoc);
const base64Updates = Buffer.from(encodedDoc).toString("base64");
return base64Updates;
};
/**
* @description merge multiple updates into one single update
* @param {Uint8Array[]} updates
* @returns {Uint8Array} merged updates
*/
export const mergeUpdates = (updates: Uint8Array[]): Uint8Array => {
const mergedUpdates = Y.mergeUpdates(updates);
return mergedUpdates;
};
@@ -83,7 +83,6 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorProps) => {
...(extensions ?? []),
...DocumentEditorAdditionalExtensions({
disabledExtensions,
fileHandler,
issueEmbedConfig: embedHandler?.issue,
provider,
userDetails: user,
+15 -9
View File
@@ -91,6 +91,7 @@ export const useEditor = (props: CustomEditorProps) => {
onUpdate: ({ editor }) => onChange?.(editor.getJSON(), editor.getHTML()),
onDestroy: () => handleEditorReady?.(false),
});
// Update the ref whenever savedSelection changes
useEffect(() => {
savedSelectionRef.current = savedSelection;
@@ -123,7 +124,7 @@ export const useEditor = (props: CustomEditorProps) => {
editorRef.current?.commands.clearContent(emitUpdate);
},
setEditorValue: (content: string) => {
editorRef.current?.commands.setContent(content);
editorRef.current?.commands.setContent(content, false, { preserveWhitespace: "full" });
},
setEditorValueAtCursorPosition: (content: string) => {
if (savedSelection) {
@@ -131,7 +132,7 @@ export const useEditor = (props: CustomEditorProps) => {
}
},
executeMenuItemCommand: (itemKey: TEditorCommands) => {
const editorItems = getEditorMenuItems(editorRef.current, fileHandler.upload);
const editorItems = getEditorMenuItems(editorRef.current);
const getEditorMenuItem = (itemKey: TEditorCommands) => editorItems.find((item) => item.key === itemKey);
@@ -147,7 +148,7 @@ export const useEditor = (props: CustomEditorProps) => {
}
},
isMenuItemActive: (itemName: TEditorCommands): boolean => {
const editorItems = getEditorMenuItems(editorRef.current, fileHandler.upload);
const editorItems = getEditorMenuItems(editorRef.current);
const getEditorMenuItem = (itemName: TEditorCommands) => editorItems.find((item) => item.key === itemName);
const item = getEditorMenuItem(itemName);
@@ -214,20 +215,25 @@ export const useEditor = (props: CustomEditorProps) => {
}
});
const selection = nodesArray.join("");
console.log(selection);
return selection;
},
insertText: (contentHTML, insertOnNextLine) => {
if (!editor) return;
if (!editorRef.current) return;
// get selection
const { from, to, empty } = editor.state.selection;
const { from, to, empty } = editorRef.current.state.selection;
if (empty) return;
if (insertOnNextLine) {
// move cursor to the end of the selection and insert a new line
editor.chain().focus().setTextSelection(to).insertContent("<br />").insertContent(contentHTML).run();
editorRef.current
.chain()
.focus()
.setTextSelection(to)
.insertContent("<br />")
.insertContent(contentHTML)
.run();
} else {
// replace selected text with the content provided
editor.chain().focus().deleteRange({ from, to }).insertContent(contentHTML).run();
editorRef.current.chain().focus().deleteRange({ from, to }).insertContent(contentHTML).run();
}
},
getDocumentInfo: () => {
@@ -238,7 +244,7 @@ export const useEditor = (props: CustomEditorProps) => {
};
},
}),
[editorRef, savedSelection, fileHandler.upload]
[editorRef, savedSelection]
);
if (!editor) {
@@ -0,0 +1,111 @@
import { DragEvent, useCallback, useEffect, useRef, useState } from "react";
import { Editor } from "@tiptap/core";
import { isFileValid } from "@/plugins/image";
export const useUploader = ({ onUpload, editor }: { onUpload: (url: string) => void; editor: Editor }) => {
const [loading, setLoading] = useState(false);
const uploadFile = useCallback(
async (file: File) => {
setLoading(true);
try {
// @ts-expect-error - TODO: fix typings, and don't remove await from
// here for now
const url: string = await editor?.commands.uploadImage(file);
if (!url) {
throw new Error("Something went wrong while uploading the image");
}
onUpload(url);
} catch (errPayload: any) {
console.log(errPayload);
const error = errPayload?.response?.data?.error || "Something went wrong";
console.error(error);
}
setLoading(false);
},
[onUpload, editor]
);
return { loading, uploadFile };
};
export const useFileUpload = () => {
const fileInput = useRef<HTMLInputElement>(null);
const handleUploadClick = useCallback(() => {
fileInput.current?.click();
}, []);
return { ref: fileInput, handleUploadClick };
};
export const useDropZone = ({ uploader }: { uploader: (file: File) => void }) => {
const [isDragging, setIsDragging] = useState<boolean>(false);
const [draggedInside, setDraggedInside] = useState<boolean>(false);
useEffect(() => {
const dragStartHandler = () => {
setIsDragging(true);
};
const dragEndHandler = () => {
setIsDragging(false);
};
document.body.addEventListener("dragstart", dragStartHandler);
document.body.addEventListener("dragend", dragEndHandler);
return () => {
document.body.removeEventListener("dragstart", dragStartHandler);
document.body.removeEventListener("dragend", dragEndHandler);
};
}, []);
const onDrop = useCallback(
(e: DragEvent<HTMLDivElement>) => {
setDraggedInside(false);
if (e.dataTransfer.files.length === 0) {
return;
}
const fileList = e.dataTransfer.files;
const files: File[] = [];
for (let i = 0; i < fileList.length; i += 1) {
const item = fileList.item(i);
if (item) {
files.push(item);
}
}
if (files.some((file) => file.type.indexOf("image") === -1)) {
return;
}
e.preventDefault();
const filteredFiles = files.filter((f) => f.type.indexOf("image") !== -1);
const file = filteredFiles.length > 0 ? filteredFiles[0] : undefined;
if (file) {
const isValid = isFileValid(file);
if (isValid) {
uploader(file);
}
}
},
[uploader]
);
const onDragEnter = () => {
setDraggedInside(true);
};
const onDragLeave = () => {
setDraggedInside(false);
};
return { isDragging, draggedInside, onDragEnter, onDragLeave, onDrop };
};
@@ -58,7 +58,7 @@ export const useReadOnlyEditor = ({
// for syncing swr data on tab refocus etc
useEffect(() => {
if (initialValue === null || initialValue === undefined) return;
if (editor && !editor.isDestroyed) editor?.commands.setContent(initialValue);
if (editor && !editor.isDestroyed) editor?.commands.setContent(initialValue, false, { preserveWhitespace: "full" });
}, [editor, initialValue]);
const editorRef: MutableRefObject<Editor | null> = useRef(null);
@@ -68,7 +68,7 @@ export const useReadOnlyEditor = ({
editorRef.current?.commands.clearContent();
},
setEditorValue: (content: string) => {
editorRef.current?.commands.setContent(content);
editorRef.current?.commands.setContent(content, false, { preserveWhitespace: "full" });
},
getMarkDown: (): string => {
const markdownOutput = editorRef.current?.storage.markdown.getMarkdown();
@@ -30,7 +30,7 @@ const createDragHandleElement = (): HTMLElement => {
return dragHandleElement;
};
const nodeDOMAtCoords = (coords: { x: number; y: number }) => {
export const nodeDOMAtCoords = (coords: { x: number; y: number }) => {
const elements = document.elementsFromPoint(coords.x, coords.y);
const generalSelectors = [
"li",
@@ -42,13 +42,34 @@ const nodeDOMAtCoords = (coords: { x: number; y: number }) => {
"[data-type=horizontalRule]",
".table-wrapper",
".issue-embed",
".image-upload-component",
].join(", ");
const hasNestedImg = (el: Element): boolean => {
if (el.tagName.toLowerCase() === "img") return true;
// @ts-expect-error todo
for (const child of el.children) {
if (hasNestedImg(child)) return true;
}
return false;
};
for (const elem of elements) {
const elemHasNestedImg = hasNestedImg(elem);
if (elem.matches("p:first-child") && elem.parentElement?.matches(".ProseMirror")) {
return elem;
}
// if the element is a <p> tag and has a nested img i.e. the new image
// component
if (elem.matches("p") && elemHasNestedImg) {
return null;
}
if (elem.matches("div") && elemHasNestedImg) {
return elem;
}
// if the element is a <p> tag that is the first child of a td or th
if (
(elem.matches("td > p:first-child") || elem.matches("th > p:first-child")) &&
@@ -1,17 +1,17 @@
import { Editor } from "@tiptap/core";
import { EditorState, Plugin, Transaction } from "@tiptap/pm/state";
import { EditorState, Plugin, PluginKey, Transaction } from "@tiptap/pm/state";
// plugins
import { IMAGE_NODE_TYPE, deleteKey, type ImageNode } from "@/plugins/image";
import { type ImageNode } from "@/plugins/image";
// types
import { DeleteImage } from "@/types";
export const TrackImageDeletionPlugin = (editor: Editor, deleteImage: DeleteImage): Plugin =>
export const TrackImageDeletionPlugin = (editor: Editor, deleteImage: DeleteImage, nodeType: string): Plugin =>
new Plugin({
key: deleteKey,
key: new PluginKey(`delete-${nodeType}`),
appendTransaction: (transactions: readonly Transaction[], oldState: EditorState, newState: EditorState) => {
const newImageSources = new Set<string>();
newState.doc.descendants((node) => {
if (node.type.name === IMAGE_NODE_TYPE) {
if (node.type.name === nodeType) {
newImageSources.add(node.attrs.src);
}
});
@@ -25,7 +25,7 @@ export const TrackImageDeletionPlugin = (editor: Editor, deleteImage: DeleteImag
// iterate through all the nodes in the old state
oldState.doc.descendants((oldNode) => {
// if the node is not an image, then return as no point in checking
if (oldNode.type.name !== IMAGE_NODE_TYPE) return;
if (oldNode.type.name !== nodeType) return;
// Check if the node has been deleted or replaced
if (!newImageSources.has(oldNode.attrs.src)) {
@@ -35,7 +35,7 @@ export const TrackImageDeletionPlugin = (editor: Editor, deleteImage: DeleteImag
removedImages.forEach(async (node) => {
const src = node.attrs.src;
editor.storage.image.deletedImageSet.set(src, true);
editor.storage[nodeType].deletedImageSet.set(src, true);
await onNodeDeleted(src, deleteImage);
});
});
@@ -46,6 +46,7 @@ export const TrackImageDeletionPlugin = (editor: Editor, deleteImage: DeleteImag
async function onNodeDeleted(src: string, deleteImage: DeleteImage): Promise<void> {
try {
if (!src) return;
const assetUrlWithWorkspaceId = new URL(src).pathname.substring(1);
await deleteImage(assetUrlWithWorkspaceId);
} catch (error) {
@@ -1,17 +1,17 @@
import { Editor } from "@tiptap/core";
import { EditorState, Plugin, Transaction } from "@tiptap/pm/state";
import { EditorState, Plugin, PluginKey, Transaction } from "@tiptap/pm/state";
// plugins
import { IMAGE_NODE_TYPE, ImageNode, restoreKey } from "@/plugins/image";
import { ImageNode } from "@/plugins/image";
// types
import { RestoreImage } from "@/types";
export const TrackImageRestorationPlugin = (editor: Editor, restoreImage: RestoreImage): Plugin =>
export const TrackImageRestorationPlugin = (editor: Editor, restoreImage: RestoreImage, nodeType: string): Plugin =>
new Plugin({
key: restoreKey,
key: new PluginKey(`restore-${nodeType}`),
appendTransaction: (transactions: readonly Transaction[], oldState: EditorState, newState: EditorState) => {
const oldImageSources = new Set<string>();
oldState.doc.descendants((node) => {
if (node.type.name === IMAGE_NODE_TYPE) {
if (node.type.name === nodeType) {
oldImageSources.add(node.attrs.src);
}
});
@@ -22,20 +22,21 @@ export const TrackImageRestorationPlugin = (editor: Editor, restoreImage: Restor
const addedImages: ImageNode[] = [];
newState.doc.descendants((node, pos) => {
if (node.type.name !== IMAGE_NODE_TYPE) return;
if (node.type.name !== nodeType) return;
if (pos < 0 || pos > newState.doc.content.size) return;
if (oldImageSources.has(node.attrs.src)) return;
addedImages.push(node as ImageNode);
});
addedImages.forEach(async (image) => {
const wasDeleted = editor.storage.image.deletedImageSet.get(image.attrs.src);
const src = image.attrs.src;
const wasDeleted = editor.storage[nodeType].deletedImageSet.get(src);
if (wasDeleted === undefined) {
editor.storage.image.deletedImageSet.set(image.attrs.src, false);
editor.storage[nodeType].deletedImageSet.set(src, false);
} else if (wasDeleted === true) {
try {
await onNodeRestored(image.attrs.src, restoreImage);
editor.storage.image.deletedImageSet.set(image.attrs.src, false);
await onNodeRestored(src, restoreImage);
editor.storage[nodeType].deletedImageSet.set(src, false);
} catch (error) {
console.error("Error restoring image: ", error);
}
@@ -48,6 +49,7 @@ export const TrackImageRestorationPlugin = (editor: Editor, restoreImage: Restor
async function onNodeRestored(src: string, restoreImage: RestoreImage): Promise<void> {
try {
if (!src) return;
const assetUrlWithWorkspaceId = new URL(src).pathname.substring(1);
await restoreImage(assetUrlWithWorkspaceId);
} catch (error) {
@@ -1,17 +1,23 @@
export function isFileValid(file: File): boolean {
export function isFileValid(file: File, showAlert = true): boolean {
if (!file) {
alert("No file selected. Please select a file to upload.");
if (showAlert) {
alert("No file selected. Please select a file to upload.");
}
return false;
}
const allowedTypes = ["image/jpeg", "image/jpg", "image/png", "image/webp"];
if (!allowedTypes.includes(file.type)) {
alert("Invalid file type. Please select a JPEG, JPG, PNG, or WEBP image file.");
if (showAlert) {
alert("Invalid file type. Please select a JPEG, JPG, PNG, or WEBP image file.");
}
return false;
}
if (file.size > 5 * 1024 * 1024) {
alert("File size too large. Please select a file smaller than 5MB.");
if (showAlert) {
alert("File size too large. Please select a file smaller than 5MB.");
}
return false;
}
-2
View File
@@ -21,9 +21,7 @@ export { isCellSelection } from "@/extensions/table/table/utilities/is-cell-sele
// helpers
export * from "@/helpers/common";
export * from "@/helpers/editor-commands";
export * from "@/helpers/yjs";
export * from "@/extensions/table/table";
export { startImageUpload } from "@/plugins/image";
// components
export * from "@/components/menus";
+13 -6
View File
@@ -4,10 +4,7 @@
display: flex;
align-items: center;
opacity: 1;
transition:
opacity 0.2s ease 0.2s,
top 0.2s ease,
left 0.2s ease;
transition: opacity 0.2s ease 0.2s;
transform: translateX(-50%);
&.side-menu-hidden {
@@ -39,7 +36,7 @@
}
/* end ai handle */
.ProseMirror:not(.dragging) .ProseMirror-selectednode {
.ProseMirror:not(.dragging) .ProseMirror-selectednode:not(.node-imageComponent):not(.node-image) {
position: relative;
cursor: grab;
outline: none !important;
@@ -63,6 +60,15 @@
border-radius: 4px;
pointer-events: none;
}
&.node-imageComponent,
&.node-image {
--horizontal-offset: 0px;
&::after {
background-color: rgba(var(--color-background-100), 0.2);
}
}
}
/* for targeting the task list items */
@@ -96,7 +102,8 @@ ol > li:nth-child(n + 100).ProseMirror-selectednode:not(.dragging)::after {
margin-left: -35px;
}
.ProseMirror img {
.ProseMirror node-image,
.ProseMirror node-imageComponent {
transition: filter 0.1s ease-in-out;
cursor: pointer;
+12 -29
View File
@@ -122,18 +122,21 @@
/* Custom image styles */
.ProseMirror img {
transition: filter 0.1s ease-in-out;
margin-top: 8px;
margin-top: 0 !important;
margin-bottom: 0;
&:hover {
cursor: pointer;
filter: brightness(90%);
}
&:not(.read-only-image) {
transition: filter 0.1s ease-in-out;
&.ProseMirror-selectednode {
outline: 3px solid rgba(var(--color-primary-100));
filter: brightness(90%);
&:hover {
cursor: pointer;
filter: brightness(90%);
}
&.ProseMirror-selectednode {
outline: 3px solid rgba(var(--color-primary-100));
filter: brightness(90%);
}
}
}
@@ -261,26 +264,6 @@ ul[data-type="taskList"] li[data-checked="true"] > div > p {
transition: opacity 0.2s ease-out;
}
.img-placeholder {
position: relative;
width: 35%;
margin-top: 0 !important;
margin-bottom: 0 !important;
&::before {
content: "";
box-sizing: border-box;
position: absolute;
top: 50%;
left: 45%;
width: 20px;
height: 20px;
border-radius: 50%;
border: 3px solid rgba(var(--color-text-200));
border-top-color: rgba(var(--color-text-800));
animation: spinning 0.6s linear infinite;
}
}
@keyframes spinning {
to {
+1 -1
View File
@@ -4,7 +4,7 @@ export default defineConfig((options: Options) => ({
entry: ["src/index.ts", "src/lib.ts"],
format: ["cjs", "esm"],
dts: true,
clean: true,
clean: false,
external: ["react"],
injectStyle: true,
...options,
+1 -1
View File
@@ -8,7 +8,7 @@ module.exports = {
plugins: ["react", "@typescript-eslint", "import"],
settings: {
next: {
rootDir: ["web/", "space/", "admin/", "packages/*/"],
rootDir: ["."],
},
},
globals: {
+1 -2
View File
@@ -4,7 +4,6 @@
"version": "0.22.0",
"main": "index.js",
"license": "MIT",
"devDependencies": {},
"dependencies": {
"@typescript-eslint/eslint-plugin": "^7.1.1",
"@typescript-eslint/parser": "^7.1.1",
@@ -14,6 +13,6 @@
"eslint-config-turbo": "^1.12.4",
"eslint-plugin-import": "^2.29.1",
"eslint-plugin-react": "^7.33.2",
"typescript": "^5.3.3"
"typescript": "5.4.5"
}
}
+1
View File
@@ -0,0 +1 @@
export * from "./use-outside-click-detector";
@@ -0,0 +1,29 @@
import React, { useEffect } from "react";
export const useOutsideClickDetector = (
ref: React.RefObject<HTMLElement>,
callback: () => void,
useCapture = false
) => {
const handleClick = (event: MouseEvent) => {
if (ref.current && !ref.current.contains(event.target as Node)) {
// check for the closest element with attribute name data-prevent-outside-click
const preventOutsideClickElement = (
event.target as HTMLElement | undefined
)?.closest("[data-prevent-outside-click]");
// if the closest element with attribute name data-prevent-outside-click is found, return
if (preventOutsideClickElement) {
return;
}
// else call the callback
callback();
}
};
useEffect(() => {
document.addEventListener("mousedown", handleClick, useCapture);
return () => {
document.removeEventListener("mousedown", handleClick, useCapture);
};
});
};
+1
View File
@@ -0,0 +1 @@
export * from "./hooks";
+24
View File
@@ -0,0 +1,24 @@
{
"name": "@plane/helpers",
"version": "0.22.0",
"description": "Helper functions shared across multiple apps internally",
"private": true,
"main": "./dist/index.js",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"files": [
"dist/**"
],
"scripts": {
"build": "tsup ./index.ts --format esm,cjs --dts --external react --minify"
},
"devDependencies": {
"@types/node": "^22.5.4",
"@types/react": "^18.3.5",
"typescript": "^5.6.2",
"tsup": "^7.2.0"
},
"dependencies": {
"react": "^18.3.1"
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "tsconfig/react-library.json",
"compilerOptions": {
"jsx": "react"
},
"include": ["."],
"exclude": ["dist", "build", "node_modules"]
}
+1
View File
@@ -2,5 +2,6 @@
"name": "@plane/types",
"version": "0.22.0",
"private": true,
"types": "./src/index.d.ts",
"main": "./src/index.d.ts"
}
+1 -11
View File
@@ -1,14 +1,4 @@
type TLogoProps = {
in_use: "emoji" | "icon";
emoji?: {
value?: string;
url?: string;
};
icon?: {
name?: string;
color?: string;
};
};
import { TLogoProps } from "../common";
export type IFavorite = {
id: string;
+1
View File
@@ -77,6 +77,7 @@ export interface IWorkspaceMember {
last_name?: string;
joining_date?: string;
display_name?: string;
last_login_medium?: string;
}
export interface IWorkspaceMemberMe {
+2 -1
View File
@@ -26,6 +26,7 @@
"@blueprintjs/popover2": "^1.13.3",
"@headlessui/react": "^1.7.3",
"@popperjs/core": "^2.11.8",
"@plane/helpers": "*",
"clsx": "^2.0.0",
"emoji-picker-react": "^4.5.16",
"lodash": "^4.17.21",
@@ -65,6 +66,6 @@
"tailwindcss": "^3.4.3",
"tsconfig": "*",
"tsup": "^7.2.0",
"typescript": "4.7.4"
"typescript": "5.4.5"
}
}
+2 -1
View File
@@ -4,12 +4,13 @@ import sortBy from "lodash/sortBy";
import { Combobox } from "@headlessui/react";
// popper-js
import { usePopper } from "react-popper";
// plane helpers
import { useOutsideClickDetector } from "@plane/helpers";
// components
import { DropdownButton } from "./common";
import { DropdownOptions } from "./common/options";
// hooks
import { useDropdownKeyPressed } from "../hooks/use-dropdown-key-pressed";
import useOutsideClickDetector from "../hooks/use-outside-click-detector";
// helper
import { cn } from "../../helpers";
// types
+2 -1
View File
@@ -4,12 +4,13 @@ import sortBy from "lodash/sortBy";
import { Combobox } from "@headlessui/react";
// popper-js
import { usePopper } from "react-popper";
// plane helpers
import { useOutsideClickDetector } from "@plane/helpers";
// components
import { DropdownButton } from "./common";
import { DropdownOptions } from "./common/options";
// hooks
import { useDropdownKeyPressed } from "../hooks/use-dropdown-key-pressed";
import useOutsideClickDetector from "../hooks/use-outside-click-detector";
// helper
import { cn } from "../../helpers";
// types
@@ -1,11 +1,12 @@
import React, { useEffect, useRef, useState } from "react";
import ReactDOM from "react-dom";
// plane helpers
import { useOutsideClickDetector } from "@plane/helpers";
// components
import { ContextMenuItem } from "./item";
// helpers
import { cn } from "../../../helpers";
// hooks
import useOutsideClickDetector from "../../hooks/use-outside-click-detector";
import { usePlatformOS } from "../../hooks/use-platform-os";
export type TContextMenuItem = {
+2 -1
View File
@@ -3,9 +3,10 @@ import ReactDOM from "react-dom";
import { Menu } from "@headlessui/react";
import { usePopper } from "react-popper";
import { ChevronDown, MoreHorizontal } from "lucide-react";
// plane helpers
import { useOutsideClickDetector } from "@plane/helpers";
// hooks
import { useDropdownKeyDown } from "../hooks/use-dropdown-key-down";
import useOutsideClickDetector from "../hooks/use-outside-click-detector";
// helpers
import { cn } from "../../helpers";
// types
@@ -3,9 +3,10 @@ import { usePopper } from "react-popper";
import { Combobox } from "@headlessui/react";
import { Check, ChevronDown, Search } from "lucide-react";
import { createPortal } from "react-dom";
// plane helpers
import { useOutsideClickDetector } from "@plane/helpers";
// hooks
import { useDropdownKeyDown } from "../hooks/use-dropdown-key-down";
import useOutsideClickDetector from "../hooks/use-outside-click-detector";
// helpers
import { cn } from "../../helpers";
// types
+2 -1
View File
@@ -2,9 +2,10 @@ import React, { useRef, useState } from "react";
import { usePopper } from "react-popper";
import { Listbox } from "@headlessui/react";
import { Check, ChevronDown } from "lucide-react";
// plane helpers
import { useOutsideClickDetector } from "@plane/helpers";
// hooks
import { useDropdownKeyDown } from "../hooks/use-dropdown-key-down";
import useOutsideClickDetector from "../hooks/use-outside-click-detector";
// helpers
import { cn } from "../../helpers";
// types
@@ -2,10 +2,11 @@ import React, { useRef, useState } from "react";
import { usePopper } from "react-popper";
import { Popover, Tab } from "@headlessui/react";
import EmojiPicker from "emoji-picker-react";
// plane helpers
import { useOutsideClickDetector } from "@plane/helpers";
// helpers
import { cn } from "../../helpers";
// hooks
import useOutsideClickDetector from "../hooks/use-outside-click-detector";
import { LucideIconsList } from "./lucide-icons-list";
// helpers
import { EmojiIconPickerTypes, TABS_LIST, TCustomEmojiPicker } from "./emoji-icon-helper";
+2 -1
View File
@@ -2,12 +2,13 @@ import React, { useRef, useState } from "react";
import { usePopper } from "react-popper";
import EmojiPicker from "emoji-picker-react";
import { Popover, Tab } from "@headlessui/react";
// plane helpers
import { useOutsideClickDetector } from "@plane/helpers";
// components
import { IconsList } from "./icons-list";
// helpers
import { cn } from "../../helpers";
// hooks
import useOutsideClickDetector from "../hooks/use-outside-click-detector";
import { EmojiIconPickerTypes, TABS_LIST, TCustomEmojiPicker } from "./emoji-icon-helper";
export const CustomEmojiIconPicker: React.FC<TCustomEmojiPicker> = (props) => {
+1 -1
View File
@@ -12,7 +12,7 @@ export const headerStyle: IHeaderProperties = {
[EHeaderVariant.PRIMARY]:
"relative flex w-full flex-shrink-0 flex-row items-center justify-between gap-x-2 gap-y-4 bg-custom-sidebar-background-100",
[EHeaderVariant.SECONDARY]: "!py-0 overflow-y-hidden border-b border-custom-border-200 justify-between",
[EHeaderVariant.TERNARY]: "flex flex-wrap justify-between py-2 border-b border-custom-border-200 gap-2",
[EHeaderVariant.TERNARY]: "flex flex-wrap justify-between py-2 border-b border-custom-border-200 gap-2 bg-custom-background-100 z-[12]",
};
export const minHeights: IHeaderProperties = {
[EHeaderVariant.PRIMARY]: "",
@@ -1,42 +0,0 @@
import React, { useEffect } from "react";
// TODO: move it to helpers package
const useOutsideClickDetector = (ref: React.RefObject<HTMLElement>, callback: () => void, useCapture = false) => {
const handleClick = (event: MouseEvent) => {
if (ref.current && !ref.current.contains(event.target as Node)) {
// get all the element with attribute name data-prevent-outside-click
const preventOutsideClickElements = document.querySelectorAll("[data-prevent-outside-click]");
// check if the click target is any of the elements with attribute name data-prevent-outside-click
for (let i = 0; i < preventOutsideClickElements.length; i++) {
if (preventOutsideClickElements[i].contains(event.target as Node)) {
// if the click target is any of the elements with attribute name data-prevent-outside-click, return
return;
}
}
// get all the element with attribute name data-delay-outside-click
const delayOutsideClickElements = document.querySelectorAll("[data-delay-outside-click]");
// check if the click target is any of the elements with attribute name data-delay-outside-click
for (let i = 0; i < delayOutsideClickElements.length; i++) {
if (delayOutsideClickElements[i].contains(event.target as Node)) {
// if the click target is any of the elements with attribute name data-delay-outside-click, delay the callback
setTimeout(() => {
callback();
}, 1);
return;
}
}
// else, call the callback immediately
callback();
}
};
useEffect(() => {
document.addEventListener("mousedown", handleClick, useCapture);
return () => {
document.removeEventListener("mousedown", handleClick, useCapture);
};
});
};
export default useOutsideClickDetector;
+14 -8
View File
@@ -2,7 +2,7 @@ import * as React from "react";
import { ISvgIcons } from "./type";
export const DiscordIcon: React.FC<ISvgIcons> = ({ width = "24", height = "24", className, color }) => (
export const DiscordIcon: React.FC<ISvgIcons> = ({ width = "24", height = "24", className }) => (
<svg
width={width}
height={height}
@@ -11,15 +11,21 @@ export const DiscordIcon: React.FC<ISvgIcons> = ({ width = "24", height = "24",
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<g clipPath="url(#clip0_282_229)">
<path
fill={color ? color : "rgb(var(--color-text-200))"}
d="M16.9312 3.64157C15.6346 3.04643 14.2662 2.62206 12.8604 2.37907C12.8476 2.37657 12.8343 2.37821 12.8225 2.38375C12.8106 2.38929 12.8009 2.39845 12.7946 2.4099C12.6196 2.7224 12.4246 3.1299 12.2879 3.45157C10.7724 3.22139 9.23088 3.22139 7.7154 3.45157C7.5633 3.09515 7.39165 2.7474 7.20123 2.4099C7.19467 2.39871 7.18486 2.38977 7.1731 2.38426C7.16135 2.37876 7.1482 2.37695 7.1354 2.37907C5.72944 2.62155 4.36101 3.04595 3.06457 3.64157C3.05359 3.64617 3.04429 3.65402 3.0379 3.66407C0.444567 7.53823 -0.266266 11.3166 0.0829005 15.0474C0.0837487 15.0567 0.0864772 15.0656 0.0909192 15.0738C0.0953611 15.082 0.101423 15.0892 0.108734 15.0949C1.6184 16.2134 3.30716 17.0672 5.1029 17.6199C5.11556 17.6236 5.12903 17.6233 5.14153 17.6191C5.15403 17.615 5.16497 17.6071 5.1729 17.5966C5.55895 17.072 5.90069 16.5162 6.19457 15.9349C6.19866 15.9269 6.20103 15.9182 6.2015 15.9093C6.20198 15.9003 6.20056 15.8914 6.19733 15.8831C6.1941 15.8747 6.18914 15.8671 6.18278 15.8609C6.17641 15.8546 6.16878 15.8497 6.1604 15.8466C5.62159 15.6404 5.09995 15.3918 4.6004 15.1032C4.59124 15.0979 4.58354 15.0905 4.57797 15.0815C4.5724 15.0725 4.56914 15.0622 4.56848 15.0517C4.56782 15.0411 4.56978 15.0306 4.57418 15.021C4.57859 15.0113 4.58531 15.003 4.59373 14.9966C4.69893 14.9179 4.80229 14.8367 4.90373 14.7532C4.91261 14.746 4.92331 14.7414 4.93464 14.74C4.94597 14.7385 4.95748 14.7402 4.9679 14.7449C8.24123 16.2391 11.7846 16.2391 15.0196 14.7449C15.0301 14.74 15.0418 14.7382 15.0533 14.7397C15.0648 14.7412 15.0756 14.7459 15.0846 14.7532C15.1846 14.8349 15.2896 14.9182 15.3954 14.9966C15.4037 15.0029 15.4104 15.0111 15.4148 15.0205C15.4193 15.03 15.4213 15.0404 15.4208 15.0508C15.4203 15.0612 15.4173 15.0714 15.412 15.0804C15.4067 15.0894 15.3993 15.0969 15.3904 15.1024C14.892 15.3937 14.3699 15.6424 13.8296 15.8457C13.8212 15.849 13.8135 15.8539 13.8071 15.8603C13.8008 15.8666 13.7958 15.8743 13.7926 15.8827C13.7894 15.8911 13.788 15.9001 13.7884 15.9091C13.7889 15.9181 13.7913 15.9269 13.7954 15.9349C14.0954 16.5166 14.4387 17.0699 14.8162 17.5957C14.824 17.6064 14.8349 17.6145 14.8475 17.6186C14.86 17.6228 14.8736 17.623 14.8862 17.6191C16.685 17.0681 18.3765 16.2142 19.8879 15.0941C19.8953 15.0889 19.9014 15.0822 19.906 15.0744C19.9106 15.0667 19.9135 15.058 19.9146 15.0491C20.3312 10.7349 19.2162 6.9874 16.9571 3.66573C16.9518 3.65453 16.9426 3.64564 16.9312 3.64073V3.64157ZM6.68373 12.7749C5.6979 12.7749 4.88623 11.8707 4.88623 10.7591C4.88623 9.64823 5.6829 8.74323 6.68373 8.74323C7.69207 8.74323 8.49707 9.65657 8.48123 10.7599C8.48123 11.8707 7.68457 12.7749 6.68373 12.7749ZM13.3296 12.7749C12.3437 12.7749 11.5321 11.8707 11.5321 10.7591C11.5321 9.64823 12.3279 8.74323 13.3296 8.74323C14.3379 8.74323 15.1429 9.65657 15.1271 10.7599C15.1271 11.8707 14.3387 12.7749 13.3296 12.7749Z"
/>
<g clip-path="url(#clip0_4112_17440)">
<mask id="mask0_4112_17440" maskUnits="userSpaceOnUse" x="0" y="0" width={width} height={height}>
<path d="M24 0H0V24H24V0Z" fill="white" />
</mask>
<g mask="url(#mask0_4112_17440)">
<path
d="M18.9312 5.63864C17.6346 5.0435 16.2662 4.61913 14.8604 4.37614C14.8476 4.37364 14.8343 4.37528 14.8225 4.38082C14.8106 4.38636 14.8009 4.39552 14.7946 4.40697C14.6196 4.71947 14.4246 5.12697 14.2879 5.44864C12.7724 5.21846 11.2309 5.21846 9.7154 5.44864C9.5633 5.09222 9.39165 4.74447 9.20123 4.40697C9.19467 4.39578 9.18486 4.38684 9.1731 4.38133C9.16135 4.37583 9.1482 4.37402 9.1354 4.37614C7.72944 4.61862 6.36101 5.04302 5.06457 5.63864C5.05359 5.64324 5.04429 5.65109 5.0379 5.66114C2.44457 9.5353 1.73373 13.3137 2.0829 17.0445C2.08375 17.0538 2.08648 17.0627 2.09092 17.0709C2.09536 17.0791 2.10142 17.0863 2.10873 17.092C3.6184 18.2105 5.30716 19.0643 7.1029 19.617C7.11556 19.6207 7.12903 19.6204 7.14153 19.6162C7.15403 19.6121 7.16497 19.6042 7.1729 19.5937C7.55895 19.0691 7.90069 18.5133 8.19457 17.932C8.19866 17.924 8.20103 17.9153 8.2015 17.9064C8.20198 17.8974 8.20056 17.8885 8.19733 17.8802C8.1941 17.8718 8.18914 17.8642 8.18278 17.858C8.17641 17.8517 8.16878 17.8468 8.1604 17.8437C7.62159 17.6375 7.09995 17.3889 6.6004 17.1003C6.59124 17.095 6.58354 17.0876 6.57797 17.0786C6.5724 17.0696 6.56914 17.0593 6.56848 17.0488C6.56782 17.0382 6.56978 17.0277 6.57418 17.0181C6.57859 17.0084 6.58531 17.0001 6.59373 16.9937C6.69893 16.915 6.80229 16.8338 6.90373 16.7503C6.91261 16.7431 6.92331 16.7385 6.93464 16.7371C6.94597 16.7356 6.95748 16.7373 6.9679 16.742C10.2412 18.2362 13.7846 18.2362 17.0196 16.742C17.0301 16.7371 17.0418 16.7353 17.0533 16.7368C17.0648 16.7383 17.0756 16.743 17.0846 16.7503C17.1846 16.832 17.2896 16.9153 17.3954 16.9937C17.4037 17 17.4104 17.0082 17.4148 17.0176C17.4193 17.0271 17.4213 17.0375 17.4208 17.0479C17.4203 17.0583 17.4173 17.0685 17.412 17.0775C17.4067 17.0865 17.3993 17.094 17.3904 17.0995C16.892 17.3908 16.3699 17.6395 15.8296 17.8428C15.8212 17.8461 15.8135 17.851 15.8071 17.8574C15.8008 17.8637 15.7958 17.8714 15.7926 17.8798C15.7894 17.8882 15.788 17.8972 15.7884 17.9062C15.7889 17.9152 15.7913 17.924 15.7954 17.932C16.0954 18.5137 16.4387 19.067 16.8162 19.5928C16.824 19.6035 16.8349 19.6116 16.8475 19.6157C16.86 19.6199 16.8736 19.6201 16.8862 19.6162C18.685 19.0652 20.3765 18.2113 21.8879 17.0912C21.8953 17.086 21.9014 17.0793 21.906 17.0715C21.9106 17.0638 21.9135 17.0551 21.9146 17.0462C22.3312 12.732 21.2162 8.98447 18.9571 5.6628C18.9518 5.6516 18.9426 5.64355 18.9312 5.63864ZM8.68373 14.772C7.6979 14.772 6.88623 13.8678 6.88623 12.7562C6.88623 11.6453 7.6829 10.7403 8.68373 10.7403C9.69207 10.7403 10.4971 11.6536 10.4812 12.757C10.4812 13.8678 9.68457 14.772 8.68373 14.772ZM15.3296 14.772C14.3437 14.772 13.5321 13.8678 13.5321 12.7562C13.5321 11.6453 14.3279 10.7403 15.3296 10.7403C16.3379 10.7403 17.1429 11.6536 17.1271 12.757C17.1271 13.8678 16.3387 14.772 15.3296 14.772Z"
fill="currentColor"
stroke="currentColor"
/>
</g>
</g>
<defs>
<clipPath id="clip0_282_229">
<rect width={width} height={height} />
<clipPath id="clip0_4112_17440">
<rect width={width} height={height} fill="white" />
</clipPath>
</defs>
</svg>
-21
View File
@@ -1,21 +0,0 @@
"use client";
import { useEffect } from "react";
const useOutSideClick = (ref: any, callback: any) => {
const handleClick = (e: any) => {
if (ref.current && !ref.current.contains(e.target)) {
callback();
}
};
useEffect(() => {
document.addEventListener("click", handleClick);
return () => {
document.removeEventListener("click", handleClick);
};
});
};
export default useOutSideClick;
+3 -1
View File
@@ -69,7 +69,9 @@ export const isCommentEmpty = (comment: string | undefined): boolean => {
// return true if comment is undefined
if (!comment) return true;
return (
comment?.trim() === "" || comment === "<p></p>" || isEmptyHtmlString(comment ?? "", ["img", "mention-component"])
comment?.trim() === "" ||
comment === "<p></p>" ||
isEmptyHtmlString(comment ?? "", ["img", "mention-component", "image-component"])
);
};
+4 -4
View File
@@ -17,10 +17,10 @@
"@emotion/styled": "^11.11.0",
"@headlessui/react": "^1.7.13",
"@mui/material": "^5.14.1",
"@plane/constants": "*",
"@plane/editor": "*",
"@plane/types": "*",
"@plane/ui": "*",
"@plane/constants": "*",
"@sentry/nextjs": "^8",
"axios": "^1.7.4",
"clsx": "^2.0.0",
@@ -34,7 +34,7 @@
"mobx": "^6.10.0",
"mobx-react": "^9.1.1",
"mobx-utils": "^6.0.8",
"next": "^14.2.3",
"next": "^14.2.12",
"next-themes": "^0.2.1",
"nprogress": "^0.2.0",
"react": "^18.3.1",
@@ -44,7 +44,6 @@
"react-popper": "^2.3.0",
"swr": "^2.2.2",
"tailwind-merge": "^2.0.0",
"typescript": "4.9.5",
"uuid": "^9.0.0",
"zxcvbn": "^4.4.2"
},
@@ -61,6 +60,7 @@
"@typescript-eslint/eslint-plugin": "^5.48.2",
"eslint-config-custom": "*",
"tailwind-config-custom": "*",
"tsconfig": "*"
"tsconfig": "*",
"typescript": "5.4.5"
}
}
+1 -4
View File
@@ -23,10 +23,7 @@
"SENTRY_PROJECT_ID",
"NEXT_PUBLIC_SENTRY_ENVIRONMENT",
"NEXT_PUBLIC_SENTRY_DSN",
"SENTRY_MONITORING_ENABLED",
"NEXT_PUBLIC_PRO_PLAN_MONTHLY_PAYMENT_URL",
"NEXT_PUBLIC_PRO_PLAN_YEARLY_PAYMENT_URL",
"NEXT_PUBLIC_PLANE_ONE_PAYMENT_URL"
"SENTRY_MONITORING_ENABLED"
],
"tasks": {
"build": {
+5 -2
View File
@@ -9,12 +9,15 @@ module.exports = {
moduleDirectory: ["node_modules", "."],
},
},
next: {
rootDir: ["."],
},
},
rules: {
"import/order": [
"error",
{
groups: ["builtin", "external", "internal", "parent", "sibling",],
groups: ["builtin", "external", "internal", "parent", "sibling"],
pathGroups: [
{
pattern: "react",
@@ -39,7 +42,7 @@ module.exports = {
{
pattern: "@/**",
group: "internal",
}
},
],
pathGroupsExcludedImportTypes: ["builtin", "internal", "react"],
alphabetize: {
@@ -24,7 +24,7 @@ const ArchivedIssueDetailsPage = observer(() => {
const { getProjectById } = useProject();
const { isLoading, data: swrArchivedIssueDetails } = useSWR(
const { isLoading } = useSWR(
workspaceSlug && projectId && archivedIssueId
? `ARCHIVED_ISSUE_DETAIL_${workspaceSlug}_${projectId}_${archivedIssueId}`
: null,
@@ -65,7 +65,6 @@ const ArchivedIssueDetailsPage = observer(() => {
<div className="h-full w-full space-y-3 divide-y-2 divide-custom-border-200 overflow-y-auto">
{workspaceSlug && projectId && archivedIssueId && (
<IssueDetailRoot
swrIssueDetails={swrArchivedIssueDetails}
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
issueId={archivedIssueId.toString()}
@@ -32,11 +32,7 @@ const IssueDetailsPage = observer(() => {
const { getProjectById } = useProject();
const { toggleIssueDetailSidebar, issueDetailSidebarCollapsed } = useAppTheme();
// fetching issue details
const {
isLoading,
data: swrIssueDetails,
error,
} = useSWR(
const { isLoading, error } = useSWR(
workspaceSlug && projectId && issueId ? `ISSUE_DETAIL_${workspaceSlug}_${projectId}_${issueId}` : null,
workspaceSlug && projectId && issueId
? () => fetchIssue(workspaceSlug.toString(), projectId.toString(), issueId.toString())
@@ -95,7 +91,6 @@ const IssueDetailsPage = observer(() => {
projectId &&
issueId && (
<IssueDetailRoot
swrIssueDetails={swrIssueDetails}
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
issueId={issueId.toString()}
@@ -15,10 +15,11 @@ import { PageEditInformationPopover } from "@/components/pages";
import { convertHexEmojiToDecimal } from "@/helpers/emoji.helper";
import { getPageName } from "@/helpers/page.helper";
// hooks
import { usePage, useProject } from "@/hooks/store";
import { usePage, useProject, useUser, useUserPermissions } from "@/hooks/store";
import { usePlatformOS } from "@/hooks/use-platform-os";
// plane web components
import { PageDetailsHeaderExtraActions } from "@/plane-web/components/pages";
import { EUserPermissions, EUserPermissionsLevel } from "ee/constants/user-permissions";
export interface IPagesHeaderProps {
showButton?: boolean;
@@ -32,10 +33,17 @@ export const PageDetailsHeader = observer(() => {
// store hooks
const { currentProjectDetails, loader } = useProject();
const page = usePage(pageId?.toString() ?? "");
const { name, logo_props, updatePageLogo } = page;
const { name, logo_props, updatePageLogo, owned_by } = page;
const { allowPermissions } = useUserPermissions();
const { data: currentUser } = useUser();
// use platform
const { isMobile } = usePlatformOS();
const isAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT);
const isOwner = owned_by === currentUser?.id;
const isEditable = isAdmin || isOwner;
const handlePageLogoUpdate = async (data: TLogoProps) => {
if (data) {
updatePageLogo(data)
@@ -144,6 +152,7 @@ export const PageDetailsHeader = observer(() => {
? EmojiIconPickerTypes.EMOJI
: EmojiIconPickerTypes.ICON
}
disabled={!isEditable}
/>
</div>
<Tooltip tooltipContent={pageTitle} position="bottom" isMobile={isMobile}>
@@ -1,34 +1,64 @@
"use client";
import { useState } from "react";
import { observer } from "mobx-react";
import { useParams, useSearchParams } from "next/navigation";
import { useParams, useRouter, useSearchParams } from "next/navigation";
import { FileText } from "lucide-react";
// ui
import { Breadcrumbs, Button, Header } from "@plane/ui";
// plane types
import { TPage } from "@plane/types";
// plane ui
import { Breadcrumbs, Button, Header, setToast, TOAST_TYPE } from "@plane/ui";
// helpers
import { BreadcrumbLink, Logo } from "@/components/common";
// constants
import { EPageAccess } from "@/constants/page";
// hooks
import { useCommandPalette, useEventTracker, useProject, useUserPermissions } from "@/hooks/store";
import { useEventTracker, useProject, useProjectPages, useUserPermissions } from "@/hooks/store";
// plane web hooks
import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions";
export const PagesListHeader = observer(() => {
// states
const [isCreatingPage, setIsCreatingPage] = useState(false);
// router
const router = useRouter();
const { workspaceSlug } = useParams();
const searchParams = useSearchParams();
const pageType = searchParams.get("type");
// store hooks
const { toggleCreatePageModal } = useCommandPalette();
const { allowPermissions } = useUserPermissions();
const { currentProjectDetails, loader } = useProject();
const { createPage } = useProjectPages();
const { setTrackElement } = useEventTracker();
// auth
const canUserCreatePage = allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
[EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST],
EUserPermissionsLevel.PROJECT
);
// handle page create
const handleCreatePage = async () => {
setIsCreatingPage(true);
setTrackElement("Project pages page");
const payload: Partial<TPage> = {
access: pageType === "private" ? EPageAccess.PRIVATE : EPageAccess.PUBLIC,
};
await createPage(payload)
.then((res) => {
const pageId = `/${workspaceSlug}/projects/${currentProjectDetails?.id}/pages/${res?.id}`;
router.push(pageId);
})
.catch((err) =>
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: err?.data?.error || "Page could not be created. Please try again.",
})
)
.finally(() => setIsCreatingPage(false));
};
return (
<Header>
@@ -60,18 +90,8 @@ export const PagesListHeader = observer(() => {
</Header.LeftItem>
{canUserCreatePage ? (
<Header.RightItem>
<Button
variant="primary"
size="sm"
onClick={() => {
setTrackElement("Project pages page");
toggleCreatePageModal({
isOpen: true,
pageAccess: pageType === "private" ? EPageAccess.PRIVATE : EPageAccess.PUBLIC,
});
}}
>
Add page
<Button variant="primary" size="sm" onClick={handleCreatePage} loading={isCreatingPage}>
{isCreatingPage ? "Adding" : "Add page"}
</Button>
</Header.RightItem>
) : (
@@ -21,7 +21,7 @@ const ProjectSettingLayout: FC<IProjectSettingLayout> = (props) => {
<ProjectSettingsSidebar />
</div>
<div className="flex flex-col relative w-full overflow-hidden">
<div className="w-full overflow-x-hidden overflow-y-scroll vertical-scrollbar scrollbar-md px-page-x md:px-9 py-page-y">
<div className="h-full w-full overflow-x-hidden overflow-y-scroll vertical-scrollbar scrollbar-md px-page-x md:px-9 py-page-y">
{children}
</div>
</div>
@@ -1,8 +1,14 @@
"use client";
import { ReactNode } from "react";
import { FC, ReactNode } from "react";
import { observer } from "mobx-react";
// components
import { NotAuthorizedView } from "@/components/auth-screens";
import { AppHeader } from "@/components/core";
// hooks
import { useUserPermissions } from "@/hooks/store";
// plane web constants
import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions";
// local components
import { WorkspaceSettingHeader } from "./header";
import { MobileWorkspaceSettingsTabs } from "./mobile-header-tabs";
@@ -12,23 +18,36 @@ export interface IWorkspaceSettingLayout {
children: ReactNode;
}
export default function WorkspaceSettingLayout(props: IWorkspaceSettingLayout) {
const WorkspaceSettingLayout: FC<IWorkspaceSettingLayout> = observer((props) => {
const { children } = props;
const { workspaceUserInfo, allowPermissions } = useUserPermissions();
// derived values
const isWorkspaceAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE);
return (
<>
<AppHeader header={<WorkspaceSettingHeader />} />
<MobileWorkspaceSettingsTabs />
<div className="inset-y-0 flex flex-row vertical-scrollbar scrollbar-lg h-full w-full overflow-y-auto">
<div className="px-page-x !pr-0 py-page-y flex-shrink-0 overflow-y-hidden sm:hidden hidden md:block lg:block">
<WorkspaceSettingsSidebar />
</div>
<div className="flex flex-col relative w-full overflow-hidden">
<div className="w-full h-full overflow-x-hidden overflow-y-scroll vertical-scrollbar scrollbar-md px-page-x md:px-9 py-page-y">
{children}
</div>
</div>
{workspaceUserInfo && !isWorkspaceAdmin ? (
<NotAuthorizedView section="settings" />
) : (
<>
<div className="px-page-x !pr-0 py-page-y flex-shrink-0 overflow-y-hidden sm:hidden hidden md:block lg:block">
<WorkspaceSettingsSidebar />
</div>
<div className="flex flex-col relative w-full overflow-hidden">
<div className="w-full h-full overflow-x-hidden overflow-y-scroll vertical-scrollbar scrollbar-md px-page-x md:px-9 py-page-y">
{children}
</div>
</div>
</>
)}
</div>
</>
);
}
});
export default WorkspaceSettingLayout;
@@ -1,5 +1,7 @@
import { FC, useEffect, useRef } from "react";
import { observer } from "mobx-react";
// plane helpers
import { useOutsideClickDetector } from "@plane/helpers";
// components
import {
SidebarDropdown,
@@ -14,7 +16,6 @@ import { SidebarFavoritesMenu } from "@/components/workspace/sidebar/favorites/f
import { cn } from "@/helpers/common.helper";
// hooks
import { useAppTheme, useUserPermissions } from "@/hooks/store";
import useOutsideClickDetector from "@/hooks/use-outside-click-detector";
// plane web components
import useSize from "@/hooks/use-window-size";
import { SidebarAppSwitcher } from "@/plane-web/components/sidebar";
+1 -1
View File
@@ -156,7 +156,7 @@ const OnboardingPage = observer(() => {
/>
) : step === EOnboardingSteps.WORKSPACE_CREATE_OR_JOIN ? (
<CreateOrJoinWorkspaces
invitations={invitations ?? []}
invitations={invitations ?? []}
totalSteps={totalSteps}
stepChange={stepChange}
finishOnboarding={finishOnboarding}
+2 -1
View File
@@ -6,6 +6,8 @@ import Link from "next/link";
import { usePathname } from "next/navigation";
// icons
import { ChevronLeft, LogOut, MoveLeft, Plus, UserPlus } from "lucide-react";
// plane helpers
import { useOutsideClickDetector } from "@plane/helpers";
// ui
import { TOAST_TYPE, Tooltip, setToast } from "@plane/ui";
// components
@@ -16,7 +18,6 @@ import { PROFILE_ACTION_LINKS } from "@/constants/profile";
import { cn } from "@/helpers/common.helper";
// hooks
import { useAppTheme, useUser, useUserSettings, useWorkspace } from "@/hooks/store";
import useOutsideClickDetector from "@/hooks/use-outside-click-detector";
import { usePlatformOS } from "@/hooks/use-platform-os";
const WORKSPACE_ACTION_LINKS = [
+1 -1
View File
@@ -15,7 +15,7 @@ export const ProductUpdates: FC<ProductUpdatesProps> = observer(() => (
target="_blank"
className="flex w-full items-center justify-start text-xs hover:bg-custom-background-80"
>
<span className="text-xs">What&apos;s new?</span>
<span className="text-xs">What&apos;s new</span>
</Link>
</CustomMenu.MenuItem>
));
+2 -1
View File
@@ -1,6 +1,8 @@
import { FC, useEffect, useRef } from "react";
import { observer } from "mobx-react";
import { UseFormRegister, UseFormSetFocus } from "react-hook-form";
// plane helpers
import { useOutsideClickDetector } from "@plane/helpers";
// types
import { TIssue } from "@plane/types";
// components
@@ -17,7 +19,6 @@ import { EIssueLayoutTypes } from "@/constants/issue";
// hooks
import { useProject } from "@/hooks/store";
import useKeypress from "@/hooks/use-keypress";
import useOutsideClickDetector from "@/hooks/use-outside-click-detector";
export type TQuickAddIssueFormRoot = {
isOpen: boolean;
@@ -9,6 +9,7 @@ import { TActivityFilters, ACTIVITY_FILTER_TYPE_OPTIONS, TActivityFilterOption }
export type TActivityFilterRoot = {
selectedFilters: TActivityFilters[];
toggleFilter: (filter: TActivityFilters) => void;
isIntakeIssue?: boolean;
};
export const ActivityFilterRoot: FC<TActivityFilterRoot> = (props) => {
+1 -1
View File
@@ -4,7 +4,7 @@ import { Button } from "@plane/ui";
import { MARKETING_PRICING_PAGE_LINK } from "@/constants/common";
export const BillingRoot = () => (
<section className="w-full overflow-y-auto md:pr-9 pr-4">
<section className="w-full overflow-y-auto">
<div>
<div className="flex items-center border-b border-custom-border-100 pb-3.5">
<h3 className="text-xl font-medium">Billing and Plans</h3>
@@ -25,8 +25,8 @@ export const useMemberColumns = () => {
const columns = [
{
key: "Full Name",
content: "Full Name",
key: "Full name",
content: "Full name",
thClassName: "text-left",
tdRender: (rowData: RowData) => (
<NameColumn
@@ -38,21 +38,36 @@ export const useMemberColumns = () => {
/>
),
},
{
key: "Display Name",
content: "Display Name",
key: "Display name",
content: "Display name",
tdRender: (rowData: RowData) => <div className="w-32">{rowData.member.display_name}</div>,
},
{
key: "Account Type",
content: "Account Type",
key: "Email address",
content: "Email address",
tdRender: (rowData: RowData) => <div className="w-48 truncate">{rowData.member.email}</div>,
},
{
key: "Account type",
content: "Account type",
tdRender: (rowData: RowData) => <AccountTypeColumn rowData={rowData} workspaceSlug={workspaceSlug as string} />,
},
{
key: "Joining Date",
content: "Joining Date",
key: "Authentication",
content: "Authentication",
tdRender: (rowData: RowData) => (
<div className="capitalize">{rowData.member.last_login_medium?.replace("-", " ")}</div>
),
},
{
key: "Joining date",
content: "Joining date",
tdRender: (rowData: RowData) => <div>{getFormattedDate(rowData?.member?.joining_date || "")}</div>,
},
];

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