Compare commits

...

73 Commits

Author SHA1 Message Date
Palanikannan M 6c71d7c87b wip: optimization of pages binary data 2024-12-10 18:05:04 +05:30
Palanikannan M d6f27f7019 Merge branch 'preview' into fix/pages-loading-optimization 2024-12-09 20:34:10 +05:30
Palanikannan M 97ff3832fd Merge branch 'chore/page-description' into fix/pages-loading-optimization 2024-12-09 20:32:30 +05:30
Palanikannan M d29ab80762 fix: removing readonly editor 2024-12-09 20:30:44 +05:30
Bavisetti Narayan ff8bbed6f9 chore: changed the soft deletion logic (#6171) 2024-12-09 20:29:30 +05:30
Vamsi Krishna d04619477b [WEB-2382]chore: notifications code improvement (#6172)
* chore: adjusted  increment/decrement  for unread count

* chore: improved param handling for unread notification count function
2024-12-09 18:06:56 +05:30
sriram veeraghanta 547c138084 fix: ui package module resolution 2024-12-09 15:56:20 +05:30
Anmol Singh Bhatia 5c907db0e2 [WEB-2818] chore: project navigation items code refactor (#6170)
* chore: project navigation items code refactor

* fix: build error

* chore: code refactor

* chore: code refactor
2024-12-09 14:37:04 +05:30
Aaryan Khandelwal a85e592ada fix: creating a new sub-issue from workspace level (#6169) 2024-12-09 12:15:10 +05:30
sriram veeraghanta b21d190ce0 fix: added github pull request template 2024-12-09 02:55:09 +05:30
sriram veeraghanta cba41e0755 fix: upgrading the express version 2024-12-09 02:35:48 +05:30
sriram veeraghanta 02308eeb15 fix: django version upgrade 2024-12-09 02:28:06 +05:30
guru_sainath 9ee41ece98 fix: email check validation to handle case in-sensitive email (#6168) 2024-12-07 17:55:50 +05:30
Vamsi Krishna 666ddf73b6 [WEB-2382]chore:notification snooze modal (#6164)
* modified notification store

* notification snooze types fix

* handled promise

* modified notifications layout

* incresed pagination count for notifications
2024-12-06 16:27:45 +05:30
Satish Gandham 4499a5fa25 Sync issues and workspace data when the issue properties like labels/modules/cycles etc are deleted from the project (#6165) 2024-12-06 16:27:07 +05:30
sriram veeraghanta 727dd4002e fix: updated lint command in packages 2024-12-06 15:00:11 +05:30
sriram veeraghanta 4b5a2bc4e5 chore: lint related changes and packaging fixes (#6163)
* fix: lint related changes and packaging fixes

* adding color validations
2024-12-06 14:56:49 +05:30
sriram veeraghanta b1c340b199 fix: build branch workflow upload artifacts 2024-12-05 16:51:20 +05:30
rahulramesha a612a17d28 chore remove unnecessary CTA (#6161) 2024-12-05 16:37:55 +05:30
Prateek Shourya d55ee6d5b8 fix: remove unwanted states fetching logic to avoid multiple API calls. (#6158) 2024-12-05 15:26:35 +05:30
Prateek Shourya aa1e192a50 improvement: update fetch map during workspace-level module fetch to reduce redundant API calls (#6159) 2024-12-05 15:26:15 +05:30
guru_sainath 6cd8af1092 chore: updated powered by (#6160) 2024-12-05 15:12:37 +05:30
rahulramesha 66652a5d71 refactor project states to ake way for new features (#6156) 2024-12-05 12:46:51 +05:30
sriram veeraghanta 3bccda0c86 chore: formatting and typo fixes 2024-12-04 19:40:37 +05:30
sriram veeraghanta fb3295f5f4 fix: sites opengraph title and description added 2024-12-04 17:58:23 +05:30
sriram veeraghanta fa3aa362a9 fix: lint errors 2024-12-04 17:22:41 +05:30
Bavisetti Narayan b73ea37798 chore: improve the cascading logic (#6152) 2024-12-04 16:15:57 +05:30
Vamsi Krishna d537e560e3 [WEB-2802]fix: dorpdown visibility issue in safari (#6151)
* filters drop down fix safari

* added comments for translation

* fixed drop down visibility issue
2024-12-04 15:27:34 +05:30
guru_sainath 1b92a18ef8 chore: updated the ssr rendering on sites (#6145)
* fix: refactoring

* fix: site ssr implementation

* chore: fixed auto reload on file change in sites

* chore: updated constant imports and globalised powerBy component

* chore: resolved lint and updated the env

---------

Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
2024-12-04 14:24:53 +05:30
rahulramesha 31b6d52417 fix root issue store to have updated url params at all times (#6147) 2024-12-04 13:57:33 +05:30
Vamsi Krishna a153de34d6 fixed piority icons shape (#6144) 2024-12-04 13:57:14 +05:30
Aaryan Khandelwal 64a44f4fce style: add custom class to editor paragraph and heading blocks (#6143) 2024-12-04 13:43:52 +05:30
guru_sainath bb8a156bdd fix: removed changelog endpoint (#6146) 2024-12-04 13:42:15 +05:30
Akshita Goyal f02a2b04a5 fix: export btn overlap issue (#6149) 2024-12-04 13:41:48 +05:30
Bavisetti Narayan b6ab853c57 chore: filter out the removed cycle from issue detail (#6138) 2024-12-03 16:48:14 +05:30
Aaryan Khandelwal fe43300aa7 fix: pages empty state authorization (#6141) 2024-12-03 14:53:02 +05:30
Prateek Shourya 849d9891d2 chore: community edition product updates link (#6132)
* chore: community edition product updates link

* fix: iframe embed for changelog

---------

Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
2024-12-03 13:28:28 +05:30
Vamsi Krishna 2768f560ad [WEB-2802]fix:filters drop down fix safari (#6133)
* filters drop down fix safari

* added comments for translation
2024-12-03 12:51:39 +05:30
Anmol Singh Bhatia fe5999ceff fix: intake issue permission (#6136) 2024-12-02 19:49:09 +05:30
rahulramesha da0071256f fix half block dragging (#6135) 2024-12-02 19:30:58 +05:30
M. Palanikannan 3c6006d04a [PE-31] feat: Add lock unlock archive restore realtime sync (#5629)
* fix: add lock unlock archive restore realtime sync

* fix: show only after editor loads

* fix: added strong types

* fix: live events fixed

* fix: remove unused vars and logs

* fix: converted objects to enum

* fix: error handling and removing the events in read only mode

* fix: added check to only update if the image aspect ratio is not present already

* fix: imports

* fix: props order

* revert: no need of these changes anymore

* fix: updated type names

* fix: order of things

* fix: fixed types and renamed variables

* fix: better typing for the real time updates

* fix: trying multiplexing our socket connection

* fix: multiplexing socket connection in read only editor as well

* fix: remove single socket logic

* fix: fixing the cleanup deps for the provider and localprovider

* fix: add a better data structure for managing events

* chore: refactored realtime events into hooks

* feat: fetch page meta while focusing tabs

* fix: cycling through items on slash command item in down arrow

* fix: better naming convention for realtime events

* fix: simplified localprovider initialization and cleaning

* fix: types from ui

* fix: abstracted away from exposing the provider directly

* fix: coderabbit suggestions

* regression: pass user in dependency array

* fix: removed page action api calls by the other users the document is synced with

* chore: removed unused imports
2024-12-02 14:26:36 +05:30
Aaryan Khandelwal 8c04aa6f51 dev: revamp pages authorization (#6094) 2024-12-02 13:59:01 +05:30
Aaryan Khandelwal 9f14167ef5 refactor: editor code splitting (#6102)
* fix: merge conflicts resolved from preview

* fix: space app build errors

* fix: product updates modal

* fix: build errors

* fix: lite text read only editor

* refactor: additional options push logic
2024-12-02 13:51:27 +05:30
Aaryan Khandelwal 11bfbe560a fix: checked colored todo list item (#6113) 2024-12-02 13:47:50 +05:30
Aaryan Khandelwal fc52936024 fix: escape markdown content for images (#6096) 2024-12-02 13:36:12 +05:30
Vamsi Krishna 5150c661ab reduced the components moved (#6110) 2024-12-02 13:35:40 +05:30
Vamsi Krishna 63bc01f385 [WEB-2774]fix:reordering favorites and favorite folders (#6119)
* fixed re order for favorites

* fixed lint errors

* added reorder

* fixed reorder inside folder

* fixed lint issues

* memoized reorder

* removed unnecessary comments

* seprated duplicate logic to a common file

* removed code comments

* fixed favorite remove while reorder inside folder

* fixed folder remove while reorder inside folder

* fixed-reorder issue

* added last child to drop handled

* fixed orderby function

* removed unncessasary comments
2024-12-02 13:35:09 +05:30
Anmol Singh Bhatia 1953d6fe3a [WEB-2762] chore: loader code refactor (#5992)
* chore: loader code refactor

* chore: code refactor

* chore: code refactor

* chore: code refactor
2024-12-02 13:24:01 +05:30
Anmol Singh Bhatia 1b9033993d [WEB-2799] chore: global component and code refactor (#6131)
* chore: local storage helper hook added to package

* chore: tabs global component added

* chore: collapsible button improvement

* chore: linear progress indicator improvement

* chore: fill icon set added to package
2024-12-02 13:22:08 +05:30
sriram veeraghanta 75ada1bfac fix: constants package updates 2024-12-01 21:26:35 +05:30
Prateek Shourya d0f9a4d245 chore: add redirection to plane logo in invitations page (#6125) 2024-11-29 20:20:49 +05:30
sriram veeraghanta 05894c5b9c Merge pull request #6121 from makeplane/preview
release: v0.24.0
2024-11-29 19:36:12 +05:30
Prateek Shourya 5926c9e8e9 fix: comment images in profile activity page (#6123) 2024-11-29 19:20:31 +05:30
Prateek Shourya 5aeedd1e5a [WEB-2610] fix: workspace redirection from admin app (#6122) 2024-11-29 19:02:13 +05:30
sriram veeraghanta 7725b200f7 fix: changelog redirection 2024-11-29 18:13:29 +05:30
sriram veeraghanta 2c69538617 fix: hypermode text typo changes 2024-11-29 17:47:46 +05:30
pablohashescobar 41bd98dd63 fix: instance collect 2024-11-29 17:41:06 +05:30
sriram veeraghanta bf1c326b44 Merge branch 'preview' of github.com:makeplane/plane into preview 2024-11-29 17:36:00 +05:30
sriram veeraghanta 3d1485461d fix: lockfile udpated 2024-11-29 17:35:47 +05:30
rahulramesha 4251b114c3 chore: enable no load by default (#5968)
* enable no load by default

* remove help section brackets

* fallback to server with mentions
2024-11-29 14:55:39 +05:30
Prateek Shourya 712339a638 minor improvements for workspace management (#6099)
* minor improvements for workspace management

* typo fix
2024-11-29 14:53:30 +05:30
sriram veeraghanta 1c9162e1f1 chore: turbo version upgrade 2024-11-29 14:40:14 +05:30
sriram veeraghanta f1e6f59716 chore: package version updated 2024-11-29 14:37:53 +05:30
sriram veeraghanta 69f235ed24 fix: merge conflicts 2024-11-29 14:35:43 +05:30
Vamsi Krishna 4aa01ffebe [WEB-2795]chore:removed header links for project bread crumb inside project detail and list (#6116)
* removed header links for project bread crumb inside project detail

* Add total issue count while syncing project to telemetry

---------

Co-authored-by: Satish Gandham <satish.iitg@gmail.com>
2024-11-29 11:39:44 +05:30
Bavisetti Narayan 41c0ba502c fix: intake toggle (#6111) 2024-11-28 16:58:21 +05:30
Bavisetti Narayan 378e896bf0 fix: notification count (#6109) 2024-11-28 12:58:09 +05:30
Prateek Shourya e3799c8a40 fix: add back issue identifier for relation activity. (#6106) 2024-11-28 12:50:56 +05:30
sriram veeraghanta 0d70397639 chore: issue version migrations updates 2024-11-28 12:42:30 +05:30
sriram veeraghanta d2758fe5e6 Revert "fix: refactor editor extensions code spliting"
This reverts commit 234513278f.
2024-11-27 18:20:41 +05:30
Bavisetti Narayan 1420b7e7d3 chore: restrict email notifications for removed users (#6100) 2024-11-27 15:06:55 +05:30
NarayanBavisetti e5ebee664b chore: removed binary decoding 2024-11-27 00:41:34 +05:30
M. Palanikannan c68658d877 [PE-56] fix: image aspect ratio (#5794)
* regression: image aspect ratio fix

* fix: name of variables changed for clarity
2024-10-10 20:53:20 +05:30
374 changed files with 6155 additions and 4140 deletions
+20
View File
@@ -0,0 +1,20 @@
### Description
<!-- Provide a detailed description of the changes in this PR -->
### Type of Change
<!-- Put an 'x' in the boxes that apply -->
- [ ] Bug fix (non-breaking change which fixes an issue)
- [ ] Feature (non-breaking change which adds functionality)
- [ ] Improvement (change that would cause existing functionality to not work as expected)
- [ ] Code refactoring
- [ ] Performance improvements
- [ ] Documentation update
### Screenshots and Media (if applicable)
<!-- Add screenshots to help explain your changes, ideally showcasing before and after -->
### Test Scenarios
<!-- Please describe the tests that you ran to verify your changes -->
### References
<!-- Link related issues if there are any -->
+2 -2
View File
@@ -314,8 +314,8 @@ jobs:
buildx-endpoint: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }}
attach_assets_to_build:
if: ${{ needs.branch_build_setup.outputs.build_type == 'Build' }}
name: Attach Assets to Build
if: ${{ needs.branch_build_setup.outputs.build_type == 'Release' }}
name: Attach Assets to Release
runs-on: ubuntu-20.04
needs: [branch_build_setup]
steps:
+3 -1
View File
@@ -38,6 +38,8 @@ export const WorkspaceCreateForm = () => {
getValues,
formState: { errors, isSubmitting, isValid },
} = useForm<IWorkspace>({ defaultValues, mode: "onChange" });
// derived values
const workspaceBaseURL = encodeURI(WEB_BASE_URL || window.location.origin + "/");
const handleCreateWorkspace = async (formData: IWorkspace) => {
await workspaceService
@@ -124,7 +126,7 @@ export const WorkspaceCreateForm = () => {
<div className="flex flex-col gap-1">
<h4 className="text-sm text-custom-text-300">Set your workspace&apos;s URL</h4>
<div className="flex gap-0.5 w-full items-center rounded-md border-[0.5px] border-custom-border-200 px-3">
<span className="whitespace-nowrap text-sm text-custom-text-200">{WEB_BASE_URL}/</span>
<span className="whitespace-nowrap text-sm text-custom-text-200">{workspaceBaseURL}</span>
<Controller
control={control}
name="slug"
+1 -1
View File
@@ -3,7 +3,7 @@
import { FC, useEffect, useRef } from "react";
import { observer } from "mobx-react";
// plane helpers
import { useOutsideClickDetector } from "@plane/helpers";
import { useOutsideClickDetector } from "@plane/hooks";
// components
import { HelpSection, SidebarMenu, SidebarDropdown } from "@/components/admin-sidebar";
// hooks
@@ -1,5 +1,4 @@
import { observer } from "mobx-react";
import Link from "next/link";
import { ExternalLink } from "lucide-react";
// helpers
import { Tooltip } from "@plane/ui";
@@ -20,9 +19,9 @@ export const WorkspaceListItem = observer(({ workspaceId }: TWorkspaceListItemPr
if (!workspace) return null;
return (
<Link
<a
key={workspaceId}
href={encodeURI(WEB_BASE_URL + "/" + workspace.slug)}
href={`${WEB_BASE_URL}/${encodeURIComponent(workspace.slug)}`}
target="_blank"
className="group flex items-center justify-between p-4 gap-2.5 truncate border border-custom-border-200/70 hover:border-custom-border-200 hover:bg-custom-background-90 rounded-md"
>
@@ -77,6 +76,6 @@ export const WorkspaceListItem = observer(({ workspaceId }: TWorkspaceListItemPr
<div className="flex-shrink-0">
<ExternalLink size={14} className="text-custom-text-400 group-hover:text-custom-text-200" />
</div>
</Link>
</a>
);
});
+2 -1
View File
@@ -30,7 +30,8 @@ export class WorkspaceService extends APIService {
* @returns Promise<any>
*/
async workspaceSlugCheck(slug: string): Promise<any> {
return this.get(`/api/instances/workspace-slug-check/?slug=${slug}`)
const params = new URLSearchParams({ slug });
return this.get(`/api/instances/workspace-slug-check/?${params.toString()}`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
+3 -3
View File
@@ -14,7 +14,7 @@ export interface IWorkspaceStore {
// computed
workspaceIds: string[];
// helper actions
hydrate: (data: any) => void;
hydrate: (data: Record<string, IWorkspace>) => void;
getWorkspaceById: (workspaceId: string) => IWorkspace | undefined;
// fetch actions
fetchWorkspaces: () => Promise<IWorkspace[]>;
@@ -59,9 +59,9 @@ export class WorkspaceStore implements IWorkspaceStore {
// helper actions
/**
* @description Hydrates the workspaces
* @param data - any
* @param data - Record<string, IWorkspace>
*/
hydrate = (data: any) => {
hydrate = (data: Record<string, IWorkspace>) => {
if (data) this.workspaces = data;
};
+4 -3
View File
@@ -1,6 +1,6 @@
{
"name": "admin",
"version": "0.23.1",
"version": "0.24.0",
"private": true,
"scripts": {
"dev": "turbo run develop",
@@ -14,9 +14,10 @@
"dependencies": {
"@headlessui/react": "^1.7.19",
"@plane/constants": "*",
"@plane/helpers": "*",
"@plane/hooks": "*",
"@plane/types": "*",
"@plane/ui": "*",
"@plane/utils": "*",
"@sentry/nextjs": "^8.32.0",
"@tailwindcss/typography": "^0.5.9",
"@types/lodash": "^4.17.0",
@@ -26,7 +27,7 @@
"lucide-react": "^0.356.0",
"mobx": "^6.12.0",
"mobx-react": "^9.1.1",
"next": "^14.2.12",
"next": "^14.2.20",
"next-themes": "^0.2.1",
"postcss": "^8.4.38",
"react": "^18.3.1",
+1 -1
View File
@@ -1,4 +1,4 @@
{
"name": "plane-api",
"version": "0.23.1"
"version": "0.24.0"
}
+1 -3
View File
@@ -258,9 +258,7 @@ class ProjectAPIEndpoint(BaseAPIView):
ProjectSerializer(project).data, cls=DjangoJSONEncoder
)
intake_view = request.data.get(
"inbox_view", request.data.get("intake_view", False)
)
intake_view = request.data.get("inbox_view", project.intake_view)
if project.archived_at:
return Response(
@@ -13,7 +13,6 @@ from .user import (
from .workspace import (
WorkSpaceSerializer,
WorkSpaceMemberSerializer,
TeamSerializer,
WorkSpaceMemberInviteSerializer,
WorkspaceLiteSerializer,
WorkspaceThemeSerializer,
@@ -6,11 +6,8 @@ from .base import BaseSerializer, DynamicBaseSerializer
from .user import UserLiteSerializer, UserAdminLiteSerializer
from plane.db.models import (
User,
Workspace,
WorkspaceMember,
Team,
TeamMember,
WorkspaceMemberInvite,
WorkspaceTheme,
WorkspaceUserProperties,
@@ -97,52 +94,6 @@ class WorkSpaceMemberInviteSerializer(BaseSerializer):
]
class TeamSerializer(BaseSerializer):
members_detail = UserLiteSerializer(read_only=True, source="members", many=True)
members = serializers.ListField(
child=serializers.PrimaryKeyRelatedField(queryset=User.objects.all()),
write_only=True,
required=False,
)
class Meta:
model = Team
fields = "__all__"
read_only_fields = [
"workspace",
"created_by",
"updated_by",
"created_at",
"updated_at",
]
def create(self, validated_data, **kwargs):
if "members" in validated_data:
members = validated_data.pop("members")
workspace = self.context["workspace"]
team = Team.objects.create(**validated_data, workspace=workspace)
team_members = [
TeamMember(member=member, team=team, workspace=workspace)
for member in members
]
TeamMember.objects.bulk_create(team_members, batch_size=10)
return team
team = Team.objects.create(**validated_data)
return team
def update(self, instance, validated_data):
if "members" in validated_data:
members = validated_data.pop("members")
TeamMember.objects.filter(team=instance).delete()
team_members = [
TeamMember(member=member, team=instance, workspace=instance.workspace)
for member in members
]
TeamMember.objects.bulk_create(team_members, batch_size=10)
return super().update(instance, validated_data)
return super().update(instance, validated_data)
class WorkspaceThemeSerializer(BaseSerializer):
class Meta:
model = WorkspaceTheme
-6
View File
@@ -7,7 +7,6 @@ from plane.app.views import (
ProjectMemberViewSet,
ProjectMemberUserEndpoint,
ProjectJoinEndpoint,
AddTeamToProjectEndpoint,
ProjectUserViewsEndpoint,
ProjectIdentifierEndpoint,
ProjectFavoritesViewSet,
@@ -83,11 +82,6 @@ urlpatterns = [
ProjectMemberViewSet.as_view({"post": "leave"}),
name="project-member",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/team-invite/",
AddTeamToProjectEndpoint.as_view(),
name="projects",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/project-views/",
ProjectUserViewsEndpoint.as_view(),
-18
View File
@@ -10,7 +10,6 @@ from plane.app.views import (
WorkspaceMemberUserEndpoint,
WorkspaceMemberUserViewsEndpoint,
WorkSpaceAvailabilityCheckEndpoint,
TeamMemberViewSet,
UserLastProjectWithWorkspaceEndpoint,
WorkspaceThemeViewSet,
WorkspaceUserProfileStatsEndpoint,
@@ -100,23 +99,6 @@ urlpatterns = [
WorkSpaceMemberViewSet.as_view({"post": "leave"}),
name="leave-workspace-members",
),
path(
"workspaces/<str:slug>/teams/",
TeamMemberViewSet.as_view({"get": "list", "post": "create"}),
name="workspace-team-members",
),
path(
"workspaces/<str:slug>/teams/<uuid:pk>/",
TeamMemberViewSet.as_view(
{
"put": "update",
"patch": "partial_update",
"delete": "destroy",
"get": "retrieve",
}
),
name="workspace-team-members",
),
path(
"users/last-visited-workspace/",
UserLastProjectWithWorkspaceEndpoint.as_view(),
-5
View File
@@ -16,7 +16,6 @@ from .project.invite import (
from .project.member import (
ProjectMemberViewSet,
AddTeamToProjectEndpoint,
ProjectMemberUserEndpoint,
UserProjectRolesEndpoint,
)
@@ -49,7 +48,6 @@ from .workspace.favorite import (
from .workspace.member import (
WorkSpaceMemberViewSet,
TeamMemberViewSet,
WorkspaceMemberUserEndpoint,
WorkspaceProjectMemberEndpoint,
WorkspaceMemberUserViewsEndpoint,
@@ -88,8 +86,6 @@ from .cycle.base import (
CycleFavoriteViewSet,
TransferCycleIssueEndpoint,
CycleUserPropertiesEndpoint,
CycleViewSet,
TransferCycleIssueEndpoint,
CycleAnalyticsEndpoint,
CycleProgressEndpoint,
)
@@ -206,6 +202,5 @@ from .dashboard.base import DashboardEndpoint, WidgetsEndpoint
from .error_404 import custom_404_view
from .exporter.base import ExportIssuesEndpoint
from .notification.base import MarkAllReadNotificationViewSet
from .user.base import AccountEndpoint, ProfileEndpoint, UserSessionEndpoint
+4 -8
View File
@@ -15,8 +15,6 @@ from django.db.models import (
UUIDField,
Value,
Subquery,
Case,
When,
)
from django.db.models.functions import Coalesce
from django.utils import timezone
@@ -445,12 +443,10 @@ class IssueViewSet(BaseViewSet):
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module")
.annotate(
cycle_id=Case(
When(
issue_cycle__cycle__deleted_at__isnull=True,
then=F("issue_cycle__cycle_id"),
),
default=None,
cycle_id=Subquery(
CycleIssue.objects.filter(issue=OuterRef("id")).values("cycle_id")[
:1
]
)
)
.annotate(
+38 -32
View File
@@ -39,6 +39,7 @@ from ..base import BaseAPIView, BaseViewSet
from plane.bgtasks.page_transaction_task import page_transaction
from plane.bgtasks.page_version_task import page_version
from plane.bgtasks.recent_visited_task import recent_visited_task
from rest_framework.parsers import MultiPartParser, FormParser
def unarchive_archive_page_and_descendants(page_id, archived_at):
@@ -114,7 +115,7 @@ class PageViewSet(BaseViewSet):
.distinct()
)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def create(self, request, slug, project_id):
serializer = PageSerializer(
data=request.data,
@@ -134,7 +135,7 @@ class PageViewSet(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])
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def partial_update(self, request, slug, project_id, pk):
try:
page = Page.objects.get(
@@ -234,7 +235,7 @@ class PageViewSet(BaseViewSet):
)
return Response(data, status=status.HTTP_200_OK)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
@allow_permission([ROLE.ADMIN], model=Page, creator=True)
def lock(self, request, slug, project_id, pk):
page = Page.objects.filter(
pk=pk, workspace__slug=slug, projects__id=project_id
@@ -244,7 +245,7 @@ class PageViewSet(BaseViewSet):
page.save()
return Response(status=status.HTTP_204_NO_CONTENT)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
@allow_permission([ROLE.ADMIN], model=Page, creator=True)
def unlock(self, request, slug, project_id, pk):
page = Page.objects.filter(
pk=pk, workspace__slug=slug, projects__id=project_id
@@ -255,7 +256,7 @@ class PageViewSet(BaseViewSet):
return Response(status=status.HTTP_204_NO_CONTENT)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
@allow_permission([ROLE.ADMIN], model=Page, creator=True)
def access(self, request, slug, project_id, pk):
access = request.data.get("access", 0)
page = Page.objects.filter(
@@ -296,7 +297,7 @@ class PageViewSet(BaseViewSet):
pages = PageSerializer(queryset, many=True).data
return Response(pages, status=status.HTTP_200_OK)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
@allow_permission([ROLE.ADMIN], model=Page, creator=True)
def archive(self, request, slug, project_id, pk):
page = Page.objects.get(pk=pk, workspace__slug=slug, projects__id=project_id)
@@ -323,7 +324,7 @@ class PageViewSet(BaseViewSet):
return Response({"archived_at": str(datetime.now())}, status=status.HTTP_200_OK)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
@allow_permission([ROLE.ADMIN], model=Page, creator=True)
def unarchive(self, request, slug, project_id, pk):
page = Page.objects.get(pk=pk, workspace__slug=slug, projects__id=project_id)
@@ -348,7 +349,7 @@ class PageViewSet(BaseViewSet):
return Response(status=status.HTTP_204_NO_CONTENT)
@allow_permission([ROLE.ADMIN], creator=True, model=Page)
@allow_permission([ROLE.ADMIN], model=Page, creator=True)
def destroy(self, request, slug, project_id, pk):
page = Page.objects.get(pk=pk, workspace__slug=slug, projects__id=project_id)
@@ -470,6 +471,8 @@ class SubPagesEndpoint(BaseAPIView):
class PagesDescriptionViewSet(BaseViewSet):
parser_classes = [MultiPartParser]
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def retrieve(self, request, slug, project_id, pk):
page = (
@@ -526,30 +529,33 @@ class PagesDescriptionViewSet(BaseViewSet):
existing_instance = json.dumps(
{"description_html": page.description_html}, cls=DjangoJSONEncoder
)
print("before the variables")
# Get the base64 data from the request
base64_data = request.data.get("description_binary")
# If base64 data is provided
if base64_data:
# Decode the base64 data to bytes
new_binary_data = base64.b64decode(base64_data)
# capture the page transaction
if request.data.get("description_html"):
page_transaction.delay(
new_value=request.data, old_value=existing_instance, page_id=pk
)
# Store the updated binary data
page.description_binary = new_binary_data
page.description_html = request.data.get("description_html")
page.description = request.data.get("description")
page.save()
# Return a success response
page_version.delay(
page_id=page.id,
existing_instance=existing_instance,
user_id=request.user.id,
# capture the page transaction
if request.data.get("description_html"):
page_transaction.delay(
new_value=request.data, old_value=existing_instance, page_id=pk
)
return Response({"message": "Updated successfully"})
else:
if not request.data.get("description_binary"):
return Response({"error": "No binary data provided"})
# Store the updated binary data
page.description_html = request.data.get(
"description_html", page.description_html
)
page.description = request.data.get("description", page.description)
page.description_binary = request.POST.get("description_binary")
page.description_binary = base64.b64decode(
request.POST.get("description_binary")
)
print("before the ssssave")
page.save()
# Return a success response
page_version.delay(
page_id=page.id,
existing_instance=existing_instance,
user_id=request.user.id,
)
return Response({"message": "Updated successfully"})
+1 -3
View File
@@ -384,11 +384,9 @@ class ProjectViewSet(BaseViewSet):
)
workspace = Workspace.objects.get(slug=slug)
intake_view = request.data.get(
"inbox_view", request.data.get("intake_view", False)
)
project = Project.objects.get(pk=pk)
intake_view = request.data.get("inbox_view", project.intake_view)
current_instance = json.dumps(
ProjectSerializer(project).data, cls=DjangoJSONEncoder
)
+1 -56
View File
@@ -11,20 +11,12 @@ from plane.app.serializers import (
)
from plane.app.permissions import (
ProjectBasePermission,
ProjectMemberPermission,
ProjectLitePermission,
WorkspaceUserPermission,
)
from plane.db.models import (
Project,
ProjectMember,
Workspace,
TeamMember,
IssueUserProperty,
WorkspaceMember,
)
from plane.db.models import Project, ProjectMember, IssueUserProperty, WorkspaceMember
from plane.bgtasks.project_add_user_email_task import project_add_user_email
from plane.utils.host import base_host
from plane.app.permissions.base import allow_permission, ROLE
@@ -309,53 +301,6 @@ class ProjectMemberViewSet(BaseViewSet):
return Response(status=status.HTTP_204_NO_CONTENT)
class AddTeamToProjectEndpoint(BaseAPIView):
permission_classes = [ProjectBasePermission]
def post(self, request, slug, project_id):
team_members = TeamMember.objects.filter(
workspace__slug=slug, team__in=request.data.get("teams", [])
).values_list("member", flat=True)
if len(team_members) == 0:
return Response(
{"error": "No such team exists"}, status=status.HTTP_400_BAD_REQUEST
)
workspace = Workspace.objects.get(slug=slug)
project_members = []
issue_props = []
for member in team_members:
project_members.append(
ProjectMember(
project_id=project_id,
member_id=member,
workspace=workspace,
created_by=request.user,
)
)
issue_props.append(
IssueUserProperty(
project_id=project_id,
user_id=member,
workspace=workspace,
created_by=request.user,
)
)
ProjectMember.objects.bulk_create(
project_members, batch_size=10, ignore_conflicts=True
)
_ = IssueUserProperty.objects.bulk_create(
issue_props, batch_size=10, ignore_conflicts=True
)
serializer = ProjectMemberSerializer(project_members, many=True)
return Response(serializer.data, status=status.HTTP_201_CREATED)
class ProjectMemberUserEndpoint(BaseAPIView):
def get(self, request, slug, project_id):
project_member = ProjectMember.objects.get(
+4 -2
View File
@@ -41,6 +41,7 @@ from django.views.decorators.vary import vary_on_cookie
from plane.utils.constants import RESTRICTED_WORKSPACE_SLUGS
from plane.license.utils.instance_value import get_configuration_value
class WorkSpaceViewSet(BaseViewSet):
model = Workspace
serializer_class = WorkSpaceSerializer
@@ -81,12 +82,12 @@ class WorkSpaceViewSet(BaseViewSet):
def create(self, request):
try:
DISABLE_WORKSPACE_CREATION, = get_configuration_value(
(DISABLE_WORKSPACE_CREATION,) = get_configuration_value(
[
{
"key": "DISABLE_WORKSPACE_CREATION",
"default": os.environ.get("DISABLE_WORKSPACE_CREATION", "0"),
},
}
]
)
@@ -353,6 +354,7 @@ class ExportWorkspaceUserActivityEndpoint(BaseAPIView):
workspace__slug=slug,
created_at__date=request.data.get("date"),
project__project_projectmember__member=request.user,
project__project_projectmember__is_active=True,
actor_id=user_id,
).select_related("actor", "workspace", "issue", "project")[:10000]
+3 -69
View File
@@ -1,38 +1,22 @@
# Django imports
from django.db.models import CharField, Count, Q, OuterRef, Subquery, IntegerField
from django.db.models import Count, Q, OuterRef, Subquery, IntegerField
from django.db.models.functions import Coalesce
from django.db.models.functions import Cast
# Third party modules
from rest_framework import status
from rest_framework.response import Response
from plane.app.permissions import (
WorkSpaceAdminPermission,
WorkspaceEntityPermission,
allow_permission,
ROLE,
)
from plane.app.permissions import WorkspaceEntityPermission, allow_permission, ROLE
# Module imports
from plane.app.serializers import (
ProjectMemberRoleSerializer,
TeamSerializer,
UserLiteSerializer,
WorkspaceMemberAdminSerializer,
WorkspaceMemberMeSerializer,
WorkSpaceMemberSerializer,
)
from plane.app.views.base import BaseAPIView
from plane.db.models import (
Project,
ProjectMember,
Team,
User,
Workspace,
WorkspaceMember,
DraftIssue,
)
from plane.db.models import Project, ProjectMember, WorkspaceMember, DraftIssue
from plane.utils.cache import invalidate_cache
from .. import BaseViewSet
@@ -284,53 +268,3 @@ class WorkspaceProjectMemberEndpoint(BaseAPIView):
project_members_dict[str(project_id)].append(project_member)
return Response(project_members_dict, status=status.HTTP_200_OK)
class TeamMemberViewSet(BaseViewSet):
serializer_class = TeamSerializer
model = Team
permission_classes = [WorkSpaceAdminPermission]
search_fields = ["member__display_name", "member__first_name"]
def get_queryset(self):
return self.filter_queryset(
super()
.get_queryset()
.filter(workspace__slug=self.kwargs.get("slug"))
.select_related("workspace", "workspace__owner")
.prefetch_related("members")
)
def create(self, request, slug):
members = list(
WorkspaceMember.objects.filter(
workspace__slug=slug,
member__id__in=request.data.get("members", []),
is_active=True,
)
.annotate(member_str_id=Cast("member", output_field=CharField()))
.distinct()
.values_list("member_str_id", flat=True)
)
if len(members) != len(request.data.get("members", [])):
users = list(set(request.data.get("members", [])).difference(members))
users = User.objects.filter(pk__in=users)
serializer = UserLiteSerializer(users, many=True)
return Response(
{
"error": f"{len(users)} of the member(s) are not a part of the workspace",
"members": serializer.data,
},
status=status.HTTP_400_BAD_REQUEST,
)
workspace = Workspace.objects.get(slug=slug)
serializer = TeamSerializer(data=request.data, context={"workspace": workspace})
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@@ -60,6 +60,9 @@ class EmailCheckEndpoint(APIView):
)
return Response(exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST)
# Lower the email
email = str(email).lower().strip()
# Validate email
try:
validate_email(email)
@@ -60,6 +60,7 @@ class EmailCheckSpaceEndpoint(APIView):
)
return Response(exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST)
email = str(email).lower().strip()
# Validate email
try:
validate_email(email)
+92 -24
View File
@@ -3,7 +3,8 @@ from django.utils import timezone
from django.apps import apps
from django.conf import settings
from django.db import models
from django.core.exceptions import ObjectDoesNotExist
from django.db.models.fields.related import OneToOneRel
# Third party imports
from celery import shared_task
@@ -11,31 +12,98 @@ from celery import shared_task
@shared_task
def soft_delete_related_objects(app_label, model_name, instance_pk, using=None):
"""
Soft delete related objects for a given model instance
"""
# Get the model class using app registry
model_class = apps.get_model(app_label, model_name)
instance = model_class.all_objects.get(pk=instance_pk)
related_fields = instance._meta.get_fields()
for field in related_fields:
if field.one_to_many or field.one_to_one:
try:
# Check if the field has CASCADE on delete
if (
not hasattr(field.remote_field, "on_delete")
or field.remote_field.on_delete == models.CASCADE
):
if field.one_to_many:
related_objects = getattr(instance, field.name).all()
elif field.one_to_one:
related_object = getattr(instance, field.name)
related_objects = (
[related_object] if related_object is not None else []
)
for obj in related_objects:
if obj:
obj.deleted_at = timezone.now()
obj.save(using=using)
except ObjectDoesNotExist:
pass
# Get the instance using all_objects to ensure we can get even if it's already soft deleted
try:
instance = model_class.all_objects.get(pk=instance_pk)
except model_class.DoesNotExist:
return
# Get all related fields that are reverse relationships
all_related = [
f
for f in instance._meta.get_fields()
if (f.one_to_many or f.one_to_one) and f.auto_created and not f.concrete
]
# Handle each related field
for relation in all_related:
related_name = relation.get_accessor_name()
# Skip if the relation doesn't exist
if not hasattr(instance, related_name):
continue
# Get the on_delete behavior name
on_delete_name = (
relation.on_delete.__name__
if hasattr(relation.on_delete, "__name__")
else ""
)
if on_delete_name == "DO_NOTHING":
continue
elif on_delete_name == "SET_NULL":
# Handle SET_NULL relationships
if isinstance(relation, OneToOneRel):
# For OneToOne relationships
related_obj = getattr(instance, related_name, None)
if related_obj and isinstance(related_obj, models.Model):
setattr(related_obj, relation.remote_field.name, None)
related_obj.save(update_fields=[relation.remote_field.name])
else:
# For other relationships
related_queryset = getattr(instance, related_name).all()
related_queryset.update(**{relation.remote_field.name: None})
else:
# Handle CASCADE and other delete behaviors
try:
if relation.one_to_one:
# Handle OneToOne relationships
related_obj = getattr(instance, related_name, None)
if related_obj:
if hasattr(related_obj, "deleted_at"):
if not related_obj.deleted_at:
related_obj.deleted_at = timezone.now()
related_obj.save()
# Recursively handle related objects
soft_delete_related_objects(
related_obj._meta.app_label,
related_obj._meta.model_name,
related_obj.pk,
using,
)
else:
# Handle other relationships
related_queryset = getattr(instance, related_name).all()
for related_obj in related_queryset:
if hasattr(related_obj, "deleted_at"):
if not related_obj.deleted_at:
related_obj.deleted_at = timezone.now()
related_obj.save()
# Recursively handle related objects
soft_delete_related_objects(
related_obj._meta.app_label,
related_obj._meta.model_name,
related_obj.pk,
using,
)
except Exception as e:
# Log the error or handle as needed
print(f"Error handling relation {related_name}: {str(e)}")
continue
# Finally, soft delete the instance itself if it hasn't been deleted yet
if hasattr(instance, "deleted_at") and not instance.deleted_at:
instance.deleted_at = timezone.now()
instance.save()
# @shared_task
+5 -13
View File
@@ -162,8 +162,7 @@ def generate_table_row(issue):
issue["priority"],
(
f"{issue['created_by__first_name']} {issue['created_by__last_name']}"
if issue["created_by__first_name"]
and issue["created_by__last_name"]
if issue["created_by__first_name"] and issue["created_by__last_name"]
else ""
),
(
@@ -197,8 +196,7 @@ def generate_json_row(issue):
"Priority": issue["priority"],
"Created By": (
f"{issue['created_by__first_name']} {issue['created_by__last_name']}"
if issue["created_by__first_name"]
and issue["created_by__last_name"]
if issue["created_by__first_name"] and issue["created_by__last_name"]
else ""
),
"Assignee": (
@@ -208,17 +206,11 @@ def generate_json_row(issue):
),
"Labels": issue["labels__name"] if issue["labels__name"] else "",
"Cycle Name": issue["issue_cycle__cycle__name"],
"Cycle Start Date": dateConverter(
issue["issue_cycle__cycle__start_date"]
),
"Cycle Start Date": dateConverter(issue["issue_cycle__cycle__start_date"]),
"Cycle End Date": dateConverter(issue["issue_cycle__cycle__end_date"]),
"Module Name": issue["issue_module__module__name"],
"Module Start Date": dateConverter(
issue["issue_module__module__start_date"]
),
"Module Target Date": dateConverter(
issue["issue_module__module__target_date"]
),
"Module Start Date": dateConverter(issue["issue_module__module__start_date"]),
"Module Target Date": dateConverter(issue["issue_module__module__target_date"]),
"Created At": dateTimeConverter(issue["created_at"]),
"Updated At": dateTimeConverter(issue["updated_at"]),
"Completed At": dateTimeConverter(issue["completed_at"]),
+30 -8
View File
@@ -1,6 +1,8 @@
# Python imports
import json
import uuid
from uuid import UUID
# Module imports
from plane.db.models import (
@@ -16,8 +18,9 @@ from plane.db.models import (
IssueComment,
IssueActivity,
UserNotificationPreference,
ProjectMember
ProjectMember,
)
from django.db.models import Subquery
# Third Party imports
from celery import shared_task
@@ -95,7 +98,8 @@ def extract_mentions_as_subscribers(project_id, issue_id, mentions):
).exists()
and not Issue.objects.filter(
project_id=project_id, pk=issue_id, created_by_id=mention_id
).exists() and ProjectMember.objects.filter(
).exists()
and ProjectMember.objects.filter(
project_id=project_id, member_id=mention_id, is_active=True
).exists()
):
@@ -242,14 +246,21 @@ def notifications(
2. From the latest set of mentions, extract the users which are not a subscribers & make them subscribers
"""
# get the list of active project members
project_members = ProjectMember.objects.filter(
project_id=project_id, is_active=True
).values_list("member_id", flat=True)
# Get new mentions from the newer instance
new_mentions = get_new_mentions(
requested_instance=requested_data, current_instance=current_instance
)
new_mentions = list(ProjectMember.objects.filter(
project_id=project_id, member_id__in=new_mentions, is_active=True
).values_list("member_id", flat=True))
new_mentions = [str(member_id) for member_id in new_mentions]
new_mentions = [
str(mention)
for mention in new_mentions
if mention in set(project_members)
]
removed_mention = get_removed_mentions(
requested_instance=requested_data, current_instance=current_instance
)
@@ -280,6 +291,11 @@ def notifications(
new_value=issue_comment_new_value,
)
comment_mentions = comment_mentions + new_comment_mentions
comment_mentions = [
mention
for mention in comment_mentions
if UUID(mention) in set(project_members)
]
comment_mention_subscribers = extract_mentions_as_subscribers(
project_id=project_id, issue_id=issue_id, mentions=all_comment_mentions
@@ -293,7 +309,11 @@ def notifications(
# ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- #
issue_subscribers = list(
IssueSubscriber.objects.filter(project_id=project_id, issue_id=issue_id, project__project_projectmember__is_active=True,)
IssueSubscriber.objects.filter(
project_id=project_id,
issue_id=issue_id,
subscriber__in=Subquery(project_members),
)
.exclude(
subscriber_id__in=list(new_mentions + comment_mentions + [actor_id])
)
@@ -314,7 +334,9 @@ def notifications(
project = Project.objects.get(pk=project_id)
issue_assignees = IssueAssignee.objects.filter(
issue_id=issue_id, project_id=project_id
issue_id=issue_id,
project_id=project_id,
assignee__in=Subquery(project_members),
).values_list("assignee", flat=True)
issue_subscribers = list(set(issue_subscribers) - {uuid.UUID(actor_id)})
@@ -13,28 +13,14 @@ from plane.db.models import (
class Command(BaseCommand):
help = "Add a member to a project. If present in the workspace"
def add_arguments(self, parser):
# Positional argument
parser.add_argument("--project_id", type=str, nargs="?", help="Project ID")
parser.add_argument("--user_email", type=str, nargs="?", help="User Email")
parser.add_argument(
"--project_id",
type=str,
nargs="?",
help="Project ID",
)
parser.add_argument(
"--user_email",
type=str,
nargs="?",
help="User Email",
)
parser.add_argument(
"--role",
type=int,
nargs="?",
help="Role of the user in the project",
"--role", type=int, nargs="?", help="Role of the user in the project"
)
def handle(self, *args: Any, **options: Any):
@@ -67,9 +53,7 @@ class Command(BaseCommand):
# Get the smallest sort order
smallest_sort_order = (
ProjectMember.objects.filter(
workspace_id=project.workspace_id,
)
ProjectMember.objects.filter(workspace_id=project.workspace_id)
.order_by("sort_order")
.first()
)
@@ -79,22 +63,15 @@ class Command(BaseCommand):
else:
sort_order = 65535
if ProjectMember.objects.filter(
project=project,
member=user,
).exists():
if ProjectMember.objects.filter(project=project, member=user).exists():
# Update the project member
ProjectMember.objects.filter(
project=project,
member=user,
).update(is_active=True, sort_order=sort_order, role=role)
ProjectMember.objects.filter(project=project, member=user).update(
is_active=True, sort_order=sort_order, role=role
)
else:
# Create the project member
ProjectMember.objects.create(
project=project,
member=user,
role=role,
sort_order=sort_order,
project=project, member=user, role=role, sort_order=sort_order
)
# Issue Property
@@ -102,9 +79,7 @@ class Command(BaseCommand):
# Success message
self.stdout.write(
self.style.SUCCESS(
f"User {user_email} added to project {project_id}"
)
self.style.SUCCESS(f"User {user_email} added to project {project_id}")
)
return
except CommandError as e:
@@ -0,0 +1,242 @@
# Generated by Django 4.2.15 on 2024-11-27 09:07
from django.conf import settings
import django.contrib.postgres.fields
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
import plane.db.models.webhook
import uuid
class Migration(migrations.Migration):
dependencies = [
("db", "0085_intake_intakeissue_remove_inboxissue_created_by_and_more"),
]
operations = [
migrations.CreateModel(
name="IssueVersion",
fields=[
(
"created_at",
models.DateTimeField(auto_now_add=True, verbose_name="Created At"),
),
(
"updated_at",
models.DateTimeField(
auto_now=True, verbose_name="Last Modified At"
),
),
(
"deleted_at",
models.DateTimeField(
blank=True, null=True, verbose_name="Deleted At"
),
),
(
"id",
models.UUIDField(
db_index=True,
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
unique=True,
),
),
("parent", models.UUIDField(blank=True, null=True)),
("state", models.UUIDField(blank=True, null=True)),
("estimate_point", models.UUIDField(blank=True, null=True)),
("name", models.CharField(max_length=255, verbose_name="Issue Name")),
("description", models.JSONField(blank=True, default=dict)),
("description_html", models.TextField(blank=True, default="<p></p>")),
("description_stripped", models.TextField(blank=True, null=True)),
("description_binary", models.BinaryField(null=True)),
(
"priority",
models.CharField(
choices=[
("urgent", "Urgent"),
("high", "High"),
("medium", "Medium"),
("low", "Low"),
("none", "None"),
],
default="none",
max_length=30,
verbose_name="Issue Priority",
),
),
("start_date", models.DateField(blank=True, null=True)),
("target_date", models.DateField(blank=True, null=True)),
(
"sequence_id",
models.IntegerField(default=1, verbose_name="Issue Sequence ID"),
),
("sort_order", models.FloatField(default=65535)),
("completed_at", models.DateTimeField(null=True)),
("archived_at", models.DateField(null=True)),
("is_draft", models.BooleanField(default=False)),
(
"external_source",
models.CharField(blank=True, max_length=255, null=True),
),
(
"external_id",
models.CharField(blank=True, max_length=255, null=True),
),
("type", models.UUIDField(blank=True, null=True)),
(
"last_saved_at",
models.DateTimeField(default=django.utils.timezone.now),
),
("owned_by", models.UUIDField()),
(
"assignees",
django.contrib.postgres.fields.ArrayField(
base_field=models.UUIDField(),
blank=True,
default=list,
size=None,
),
),
(
"labels",
django.contrib.postgres.fields.ArrayField(
base_field=models.UUIDField(),
blank=True,
default=list,
size=None,
),
),
("cycle", models.UUIDField(blank=True, null=True)),
(
"modules",
django.contrib.postgres.fields.ArrayField(
base_field=models.UUIDField(),
blank=True,
default=list,
size=None,
),
),
("properties", models.JSONField(default=dict)),
("meta", models.JSONField(default=dict)),
(
"created_by",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="%(class)s_created_by",
to=settings.AUTH_USER_MODEL,
verbose_name="Created By",
),
),
(
"issue",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="versions",
to="db.issue",
),
),
(
"project",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="project_%(class)s",
to="db.project",
),
),
(
"updated_by",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="%(class)s_updated_by",
to=settings.AUTH_USER_MODEL,
verbose_name="Last Modified By",
),
),
(
"workspace",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="workspace_%(class)s",
to="db.workspace",
),
),
],
options={
"verbose_name": "Issue Version",
"verbose_name_plural": "Issue Versions",
"db_table": "issue_versions",
"ordering": ("-created_at",),
},
),
migrations.AlterUniqueTogether(
name="teampage",
unique_together=None,
),
migrations.RemoveField(
model_name="teampage",
name="created_by",
),
migrations.RemoveField(
model_name="teampage",
name="page",
),
migrations.RemoveField(
model_name="teampage",
name="team",
),
migrations.RemoveField(
model_name="teampage",
name="updated_by",
),
migrations.RemoveField(
model_name="teampage",
name="workspace",
),
migrations.RemoveField(
model_name="page",
name="teams",
),
migrations.RemoveField(
model_name="team",
name="members",
),
migrations.AddField(
model_name="fileasset",
name="entity_identifier",
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AddField(
model_name="webhook",
name="is_internal",
field=models.BooleanField(default=False),
),
migrations.AlterField(
model_name="fileasset",
name="entity_type",
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AlterField(
model_name="webhook",
name="url",
field=models.URLField(
max_length=1024,
validators=[
plane.db.models.webhook.validate_schema,
plane.db.models.webhook.validate_domain,
],
),
),
migrations.DeleteModel(
name="TeamMember",
),
migrations.DeleteModel(
name="TeamPage",
),
]
-12
View File
@@ -53,7 +53,6 @@ from .project import (
ProjectMemberInvite,
ProjectPublicMember,
)
from .deploy_board import DeployBoard
from .session import Session
from .social_connection import SocialLoginConnection
from .state import State
@@ -61,8 +60,6 @@ from .user import Account, Profile, User
from .view import IssueView
from .webhook import Webhook, WebhookLog
from .workspace import (
Team,
TeamMember,
Workspace,
WorkspaceBaseModel,
WorkspaceMember,
@@ -71,23 +68,14 @@ from .workspace import (
WorkspaceUserProperties,
)
from .importer import Importer
from .page import Page, PageLog, PageLabel
from .estimate import Estimate, EstimatePoint
from .intake import Intake, IntakeIssue
from .analytic import AnalyticView
from .notification import Notification, UserNotificationPreference, EmailNotificationLog
from .exporter import ExporterHistory
from .webhook import Webhook, WebhookLog
from .dashboard import Dashboard, DashboardWidget, Widget
from .favorite import UserFavorite
+2 -3
View File
@@ -61,9 +61,8 @@ class FileAsset(BaseModel):
page = models.ForeignKey(
"db.Page", on_delete=models.CASCADE, null=True, related_name="assets"
)
entity_type = models.CharField(
max_length=255, choices=EntityTypeContext.choices, null=True, blank=True
)
entity_type = models.CharField(max_length=255, null=True, blank=True)
entity_identifier = models.CharField(max_length=255, null=True, blank=True)
is_deleted = models.BooleanField(default=False)
is_archived = models.BooleanField(default=False)
external_id = models.CharField(max_length=255, null=True, blank=True)
+102 -1
View File
@@ -9,11 +9,12 @@ from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models, transaction
from django.utils import timezone
from django.db.models import Q
from django import apps
# Module imports
from plane.utils.html_processor import strip_tags
from plane.db.mixins import SoftDeletionManager
from plane.utils.exception_logger import log_exception
from .project import ProjectBaseModel
@@ -656,3 +657,103 @@ class IssueVote(ProjectBaseModel):
def __str__(self):
return f"{self.issue.name} {self.actor.email}"
class IssueVersion(ProjectBaseModel):
issue = models.ForeignKey(
"db.Issue", on_delete=models.CASCADE, related_name="versions"
)
PRIORITY_CHOICES = (
("urgent", "Urgent"),
("high", "High"),
("medium", "Medium"),
("low", "Low"),
("none", "None"),
)
parent = models.UUIDField(blank=True, null=True)
state = models.UUIDField(blank=True, null=True)
estimate_point = models.UUIDField(blank=True, null=True)
name = models.CharField(max_length=255, verbose_name="Issue Name")
description = models.JSONField(blank=True, default=dict)
description_html = models.TextField(blank=True, default="<p></p>")
description_stripped = models.TextField(blank=True, null=True)
description_binary = models.BinaryField(null=True)
priority = models.CharField(
max_length=30,
choices=PRIORITY_CHOICES,
verbose_name="Issue Priority",
default="none",
)
start_date = models.DateField(null=True, blank=True)
target_date = models.DateField(null=True, blank=True)
sequence_id = models.IntegerField(default=1, verbose_name="Issue Sequence ID")
sort_order = models.FloatField(default=65535)
completed_at = models.DateTimeField(null=True)
archived_at = models.DateField(null=True)
is_draft = models.BooleanField(default=False)
external_source = models.CharField(max_length=255, null=True, blank=True)
external_id = models.CharField(max_length=255, blank=True, null=True)
type = models.UUIDField(blank=True, null=True)
last_saved_at = models.DateTimeField(default=timezone.now)
owned_by = models.UUIDField()
assignees = ArrayField(models.UUIDField(), blank=True, default=list)
labels = ArrayField(models.UUIDField(), blank=True, default=list)
cycle = models.UUIDField(null=True, blank=True)
modules = ArrayField(models.UUIDField(), blank=True, default=list)
properties = models.JSONField(default=dict)
meta = models.JSONField(default=dict)
class Meta:
verbose_name = "Issue Version"
verbose_name_plural = "Issue Versions"
db_table = "issue_versions"
ordering = ("-created_at",)
def __str__(self):
return f"{self.name} <{self.project.name}>"
@classmethod
def log_issue_version(cls, issue, user):
try:
"""
Log the issue version
"""
Module = apps.get_model("db.Module")
CycleIssue = apps.get_model("db.CycleIssue")
cycle_issue = CycleIssue.objects.filter(issue=issue).first()
cls.objects.create(
issue=issue,
parent=issue.parent,
state=issue.state,
point=issue.point,
estimate_point=issue.estimate_point,
name=issue.name,
description=issue.description,
description_html=issue.description_html,
description_stripped=issue.description_stripped,
description_binary=issue.description_binary,
priority=issue.priority,
start_date=issue.start_date,
target_date=issue.target_date,
sequence_id=issue.sequence_id,
sort_order=issue.sort_order,
completed_at=issue.completed_at,
archived_at=issue.archived_at,
is_draft=issue.is_draft,
external_source=issue.external_source,
external_id=issue.external_id,
type=issue.type,
last_saved_at=issue.last_saved_at,
assignees=issue.assignees,
labels=issue.labels,
cycle=cycle_issue.cycle if cycle_issue else None,
modules=Module.objects.filter(issue=issue).values_list("id", flat=True),
owned_by=user,
)
return True
except Exception as e:
log_exception(e)
return False
-29
View File
@@ -50,9 +50,6 @@ class Page(BaseModel):
projects = models.ManyToManyField(
"db.Project", related_name="pages", through="db.ProjectPage"
)
teams = models.ManyToManyField(
"db.Team", related_name="pages", through="db.TeamPage"
)
class Meta:
verbose_name = "Page"
@@ -160,32 +157,6 @@ class ProjectPage(BaseModel):
return f"{self.project.name} {self.page.name}"
class TeamPage(BaseModel):
team = models.ForeignKey(
"db.Team", on_delete=models.CASCADE, related_name="team_pages"
)
page = models.ForeignKey(
"db.Page", on_delete=models.CASCADE, related_name="team_pages"
)
workspace = models.ForeignKey(
"db.Workspace", on_delete=models.CASCADE, related_name="team_pages"
)
class Meta:
unique_together = ["team", "page", "deleted_at"]
constraints = [
models.UniqueConstraint(
fields=["team", "page"],
condition=models.Q(deleted_at__isnull=True),
name="team_page_unique_team_page_when_deleted_at_null",
)
]
verbose_name = "Team Page"
verbose_name_plural = "Team Pages"
db_table = "team_pages"
ordering = ("-created_at",)
class PageVersion(BaseModel):
workspace = models.ForeignKey(
"db.Workspace", on_delete=models.CASCADE, related_name="page_versions"
+4 -1
View File
@@ -31,7 +31,9 @@ class Webhook(BaseModel):
workspace = models.ForeignKey(
"db.Workspace", on_delete=models.CASCADE, related_name="workspace_webhooks"
)
url = models.URLField(validators=[validate_schema, validate_domain])
url = models.URLField(
validators=[validate_schema, validate_domain], max_length=1024
)
is_active = models.BooleanField(default=True)
secret_key = models.CharField(max_length=255, default=generate_token)
project = models.BooleanField(default=False)
@@ -39,6 +41,7 @@ class Webhook(BaseModel):
module = models.BooleanField(default=False)
cycle = models.BooleanField(default=False)
issue_comment = models.BooleanField(default=False)
is_internal = models.BooleanField(default=False)
def __str__(self):
return f"{self.workspace.slug} {self.url}"
-34
View File
@@ -239,13 +239,6 @@ class WorkspaceMemberInvite(BaseModel):
class Team(BaseModel):
name = models.CharField(max_length=255, verbose_name="Team Name")
description = models.TextField(verbose_name="Team Description", blank=True)
members = models.ManyToManyField(
settings.AUTH_USER_MODEL,
blank=True,
related_name="members",
through="TeamMember",
through_fields=("team", "member"),
)
workspace = models.ForeignKey(
Workspace, on_delete=models.CASCADE, related_name="workspace_team"
)
@@ -270,33 +263,6 @@ class Team(BaseModel):
ordering = ("-created_at",)
class TeamMember(BaseModel):
workspace = models.ForeignKey(
Workspace, on_delete=models.CASCADE, related_name="team_member"
)
team = models.ForeignKey(Team, on_delete=models.CASCADE, related_name="team_member")
member = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="team_member"
)
def __str__(self):
return self.team.name
class Meta:
unique_together = ["team", "member", "deleted_at"]
constraints = [
models.UniqueConstraint(
fields=["team", "member"],
condition=models.Q(deleted_at__isnull=True),
name="team_member_unique_team_member_when_deleted_at_null",
)
]
verbose_name = "Team Member"
verbose_name_plural = "Team Members"
db_table = "team_members"
ordering = ("-created_at",)
class WorkspaceTheme(BaseModel):
workspace = models.ForeignKey(
"db.Workspace", on_delete=models.CASCADE, related_name="themes"
@@ -2,4 +2,4 @@ from .instance import InstanceSerializer
from .configuration import InstanceConfigurationSerializer
from .admin import InstanceAdminSerializer, InstanceAdminMeSerializer
from .workspace import WorkspaceSerializer
from .workspace import WorkspaceSerializer
@@ -1,6 +1,8 @@
from .base import BaseSerializer
from plane.db.models import User
class UserLiteSerializer(BaseSerializer):
class Meta:
model = User
fields = ["id", "email", "first_name", "last_name",]
fields = ["id", "email", "first_name", "last_name"]
@@ -18,6 +18,9 @@ class WorkspaceSerializer(BaseSerializer):
# Check if the slug is restricted
if value in RESTRICTED_WORKSPACE_SLUGS:
raise serializers.ValidationError("Slug is not valid")
# Check uniqueness case-insensitively
if Workspace.objects.filter(slug__iexact=value).exists():
raise serializers.ValidationError("Slug is already in use")
return value
class Meta:
@@ -13,6 +13,8 @@ from .admin import (
InstanceAdminUserSessionEndpoint,
)
from .changelog import ChangeLogEndpoint
from .workspace import InstanceWorkSpaceAvailabilityCheckEndpoint, InstanceWorkSpaceEndpoint
from .workspace import (
InstanceWorkSpaceAvailabilityCheckEndpoint,
InstanceWorkSpaceEndpoint,
)
@@ -1,33 +0,0 @@
# Python imports
import requests
# Django imports
from django.conf import settings
# Third party imports
from rest_framework.response import Response
from rest_framework import status
from rest_framework.permissions import AllowAny
# plane imports
from .base import BaseAPIView
class ChangeLogEndpoint(BaseAPIView):
permission_classes = [AllowAny]
def fetch_change_logs(self):
response = requests.get(settings.INSTANCE_CHANGELOG_URL)
response.raise_for_status()
return response.json()
def get(self, request):
# Fetch the changelog
if settings.INSTANCE_CHANGELOG_URL:
data = self.fetch_change_logs()
return Response(data, status=status.HTTP_200_OK)
else:
return Response(
{"error": "could not fetch changelog please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
@@ -25,7 +25,7 @@ class InstanceWorkSpaceAvailabilityCheckEndpoint(BaseAPIView):
)
workspace = (
Workspace.objects.filter(slug=slug).exists()
Workspace.objects.filter(slug__iexact=slug).exists()
or slug in RESTRICTED_WORKSPACE_SLUGS
)
return Response({"status": not workspace}, status=status.HTTP_200_OK)
@@ -43,19 +43,19 @@ class InstanceWorkSpaceEndpoint(BaseAPIView):
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
member_count = (
WorkspaceMember.objects.filter(
workspace=OuterRef("id"), member__is_bot=False, is_active=True
).select_related("owner")
)
.select_related("owner")
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
workspaces = Workspace.objects.annotate(
total_projects=project_count,
total_members=member_count,
total_projects=project_count, total_members=member_count
)
# Add search functionality
@@ -66,16 +66,14 @@ class InstanceWorkSpaceEndpoint(BaseAPIView):
return self.paginate(
request=request,
queryset=workspaces,
on_results=lambda results: WorkspaceSerializer(
results, many=True,
).data,
on_results=lambda results: WorkspaceSerializer(results, many=True).data,
max_per_page=10,
default_per_page=10,
)
def post(self, request):
try:
serializer = WorkspaceSerializer (data=request.data)
serializer = WorkspaceSerializer(data=request.data)
slug = request.data.get("slug", False)
name = request.data.get("name", False)
+1 -7
View File
@@ -11,14 +11,12 @@ from plane.license.api.views import (
InstanceAdminUserMeEndpoint,
InstanceAdminSignOutEndpoint,
InstanceAdminUserSessionEndpoint,
ChangeLogEndpoint,
InstanceWorkSpaceAvailabilityCheckEndpoint,
InstanceWorkSpaceEndpoint,
)
urlpatterns = [
path("", InstanceEndpoint.as_view(), name="instance"),
path("changelog/", ChangeLogEndpoint.as_view(), name="instance-changelog"),
path("admins/", InstanceAdminEndpoint.as_view(), name="instance-admins"),
path("admins/me/", InstanceAdminUserMeEndpoint.as_view(), name="instance-admins"),
path(
@@ -62,9 +60,5 @@ urlpatterns = [
InstanceWorkSpaceAvailabilityCheckEndpoint.as_view(),
name="instance-workspace-availability",
),
path(
"workspaces/",
InstanceWorkSpaceEndpoint.as_view(),
name="instance-workspace",
),
path("workspaces/", InstanceWorkSpaceEndpoint.as_view(), name="instance-workspace"),
]
+6
View File
@@ -10,9 +10,15 @@ from plane.space.views import (
ProjectStatesEndpoint,
ProjectLabelsEndpoint,
ProjectMembersEndpoint,
ProjectMetaDataEndpoint,
)
urlpatterns = [
path(
"anchor/<str:anchor>/meta/",
ProjectMetaDataEndpoint.as_view(),
name="project-meta",
),
path(
"anchor/<str:anchor>/settings/",
ProjectDeployBoardPublicSettingsEndpoint.as_view(),
+2
View File
@@ -25,3 +25,5 @@ from .state import ProjectStatesEndpoint
from .label import ProjectLabelsEndpoint
from .asset import EntityAssetEndpoint, AssetRestoreEndpoint, EntityBulkAssetEndpoint
from .meta import ProjectMetaDataEndpoint
+34
View File
@@ -0,0 +1,34 @@
# third party
from rest_framework.permissions import AllowAny
from rest_framework import status
from rest_framework.response import Response
from plane.db.models import DeployBoard, Project
from .base import BaseAPIView
from plane.space.serializer.project import ProjectLiteSerializer
class ProjectMetaDataEndpoint(BaseAPIView):
permission_classes = [AllowAny]
def get(self, request, anchor):
try:
deploy_board = DeployBoard.objects.filter(
anchor=anchor, entity_name="project"
).first()
except DeployBoard.DoesNotExist:
return Response(
{"error": "Project is not published"}, status=status.HTTP_404_NOT_FOUND
)
try:
project_id = deploy_board.entity_identifier
project = Project.objects.get(id=project_id)
except Project.DoesNotExist:
return Response(
{"error": "Project is not published"}, status=status.HTTP_404_NOT_FOUND
)
serializer = ProjectLiteSerializer(project)
return Response(serializer.data, status=status.HTTP_200_OK)
+25 -7
View File
@@ -1,23 +1,33 @@
# Python imports
import os
import atexit
# Third party imports
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.sdk.resources import Resource
from opentelemetry.instrumentation.django import DjangoInstrumentor
import os
# Global variable to track initialization
_TRACER_PROVIDER = None
def init_tracer():
"""Initialize OpenTelemetry with proper shutdown handling"""
global _TRACER_PROVIDER
# Check if already initialized to prevent double initialization
if trace.get_tracer_provider().__class__.__name__ == "TracerProvider":
return
# If already initialized, return existing provider
if _TRACER_PROVIDER is not None:
return _TRACER_PROVIDER
# Configure the tracer provider
service_name = os.environ.get("SERVICE_NAME", "plane-ce-api")
resource = Resource.create({"service.name": service_name})
tracer_provider = TracerProvider(resource=resource)
# Set as global tracer provider
trace.set_tracer_provider(tracer_provider)
# Configure the OTLP exporter
@@ -29,12 +39,20 @@ def init_tracer():
# Initialize Django instrumentation
DjangoInstrumentor().instrument()
# Store provider globally
_TRACER_PROVIDER = tracer_provider
# Register shutdown handler
atexit.register(shutdown_tracer)
return tracer_provider
def shutdown_tracer():
"""Shutdown OpenTelemetry tracers and processors"""
provider = trace.get_tracer_provider()
global _TRACER_PROVIDER
if hasattr(provider, "shutdown"):
provider.shutdown()
if _TRACER_PROVIDER is not None:
if hasattr(_TRACER_PROVIDER, "shutdown"):
_TRACER_PROVIDER.shutdown()
_TRACER_PROVIDER = None
+1 -1
View File
@@ -1,7 +1,7 @@
# base requirements
# django
Django==4.2.16
Django==4.2.17
# rest framework
djangorestframework==3.15.2
# postgres
+5 -5
View File
@@ -1,16 +1,16 @@
{
"name": "live",
"version": "0.23.1",
"version": "0.24.0",
"description": "",
"main": "./src/server.ts",
"private": true,
"type": "module",
"scripts": {
"dev": "concurrently \"babel src --out-dir dist --extensions '.ts,.js' --watch\" \"nodemon dist/server.js\"",
"build": "babel src --out-dir dist --extensions \".ts,.js\"",
"start": "node dist/server.js",
"lint": "eslint . --ext .ts,.tsx",
"dev": "concurrently \"babel src --out-dir dist --extensions '.ts,.js' --watch\" \"nodemon dist/server.js\"",
"lint:errors": "eslint . --ext .ts,.tsx --quiet"
"lint": "eslint src --ext .ts,.tsx",
"lint:errors": "eslint src --ext .ts,.tsx --quiet"
},
"keywords": [],
"author": "",
@@ -30,7 +30,7 @@
"compression": "^1.7.4",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.20.0",
"express": "^4.21.2",
"express-ws": "^5.0.2",
"helmet": "^7.1.0",
"ioredis": "^5.4.1",
+24 -13
View File
@@ -1,9 +1,15 @@
import { getSchema } from "@tiptap/core";
import { generateHTML, generateJSON } from "@tiptap/html";
import { prosemirrorJSONToYDoc, yXmlFragmentToProseMirrorRootNode } from "y-prosemirror";
import * as Y from "yjs"
import {
prosemirrorJSONToYDoc,
yXmlFragmentToProseMirrorRootNode,
} from "y-prosemirror";
import * as Y from "yjs";
// plane editor
import { CoreEditorExtensionsWithoutProps, DocumentEditorExtensionsWithoutProps } from "@plane/editor/lib";
import {
CoreEditorExtensionsWithoutProps,
DocumentEditorExtensionsWithoutProps,
} from "@plane/editor/lib";
const DOCUMENT_EDITOR_EXTENSIONS = [
...CoreEditorExtensionsWithoutProps,
@@ -11,7 +17,9 @@ const DOCUMENT_EDITOR_EXTENSIONS = [
];
const documentEditorSchema = getSchema(DOCUMENT_EDITOR_EXTENSIONS);
export const getAllDocumentFormatsFromBinaryData = (description: Uint8Array): {
export const getAllDocumentFormatsFromBinaryData = (
description: Uint8Array,
): {
contentBinaryEncoded: string;
contentJSON: object;
contentHTML: string;
@@ -24,7 +32,7 @@ export const getAllDocumentFormatsFromBinaryData = (description: Uint8Array): {
const type = yDoc.getXmlFragment("default");
const contentJSON = yXmlFragmentToProseMirrorRootNode(
type,
documentEditorSchema
documentEditorSchema,
).toJSON();
// convert to HTML
const contentHTML = generateHTML(contentJSON, DOCUMENT_EDITOR_EXTENSIONS);
@@ -34,26 +42,29 @@ export const getAllDocumentFormatsFromBinaryData = (description: Uint8Array): {
contentJSON,
contentHTML,
};
}
};
export const getBinaryDataFromHTMLString = (descriptionHTML: string): {
contentBinary: Uint8Array
export const getBinaryDataFromHTMLString = (
descriptionHTML: string,
): {
contentBinary: Uint8Array;
} => {
// convert HTML to JSON
const contentJSON = generateJSON(
descriptionHTML ?? "<p></p>",
DOCUMENT_EDITOR_EXTENSIONS
DOCUMENT_EDITOR_EXTENSIONS,
);
// convert JSON to Y.Doc format
const transformedData = prosemirrorJSONToYDoc(
documentEditorSchema,
contentJSON,
"default"
"default",
);
// convert Y.Doc to Uint8Array format
const encodedData = Y.encodeStateAsUpdate(transformedData);
return {
contentBinary: encodedData
}
}
contentBinary: encodedData,
};
};
+12
View File
@@ -4,6 +4,10 @@ import { v4 as uuidv4 } from "uuid";
import { handleAuthentication } from "@/core/lib/authentication.js";
// extensions
import { getExtensions } from "@/core/extensions/index.js";
import {
DocumentCollaborativeEvents,
TDocumentEventsServer,
} from "@plane/editor/lib";
// editor types
import { TUserDetails } from "@plane/editor";
// types
@@ -55,6 +59,14 @@ export const getHocusPocusServer = async () => {
throw Error("Authentication unsuccessful!");
}
},
async onStateless({ payload, document }) {
// broadcast the client event (derived from the server event) to all the clients so that they can update their state
const response =
DocumentCollaborativeEvents[payload as TDocumentEventsServer].client;
if (response) {
document.broadcastStateless(response);
}
},
extensions,
debounce: 10000,
});
+33 -6
View File
@@ -26,18 +26,41 @@ export const updatePageDescription = async (
const { contentBinaryEncoded, contentHTML, contentJSON } =
getAllDocumentFormatsFromBinaryData(updatedDescription);
try {
const payload = {
description_binary: contentBinaryEncoded,
description_html: contentHTML,
description: contentJSON,
};
// Generate a unique boundary
const boundary = `----FormBoundary${Date.now().toString()}`;
// Construct the multipart form data manually
let formData = "";
// Add binary content
formData += `--${boundary}\r\n`;
formData += 'Content-Disposition: form-data; name="description_binary"\r\n';
formData += "Content-Type: application/octet-stream\r\n\r\n";
formData += updatedDescription + "\r\n";
// Add HTML content
formData += `--${boundary}\r\n`;
formData += 'Content-Disposition: form-data; name="description_html"\r\n';
formData += "Content-Type: text/html\r\n\r\n";
formData += contentHTML + "\r\n";
// Add JSON content
formData += `--${boundary}\r\n`;
formData += 'Content-Disposition: form-data; name="description"\r\n';
formData += "Content-Type: application/json\r\n\r\n";
formData += JSON.stringify(contentJSON) + "\r\n";
// End boundary
formData += `--${boundary}--\r\n`;
await pageService.updateDescription(
workspaceSlug,
projectId,
pageId,
payload,
formData,
boundary,
cookie,
);
} catch (error) {
@@ -46,6 +69,8 @@ export const updatePageDescription = async (
}
};
// Update the service method
const fetchDescriptionHTMLAndTransform = async (
workspaceSlug: string,
projectId: string,
@@ -90,7 +115,9 @@ export const fetchPageDescriptionBinary = async (
pageId,
cookie,
);
console.log("response", response);
const binaryData = new Uint8Array(response);
console.log("binaryData", binaryData);
if (binaryData.byteLength === 0) {
const binary = await fetchDescriptionHTMLAndTransform(
+10 -12
View File
@@ -12,7 +12,7 @@ export class PageService extends APIService {
workspaceSlug: string,
projectId: string,
pageId: string,
cookie: string
cookie: string,
): Promise<TPage> {
return this.get(
`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/`,
@@ -20,7 +20,7 @@ export class PageService extends APIService {
headers: {
Cookie: cookie,
},
}
},
)
.then((response) => response?.data)
.catch((error) => {
@@ -32,7 +32,7 @@ export class PageService extends APIService {
workspaceSlug: string,
projectId: string,
pageId: string,
cookie: string
cookie: string,
): Promise<any> {
return this.get(
`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/description/`,
@@ -42,7 +42,7 @@ export class PageService extends APIService {
Cookie: cookie,
},
responseType: "arraybuffer",
}
},
)
.then((response) => response?.data)
.catch((error) => {
@@ -54,21 +54,19 @@ export class PageService extends APIService {
workspaceSlug: string,
projectId: string,
pageId: string,
data: {
description_binary: string;
description_html: string;
description: object;
},
cookie: string
formData: string,
boundary: string,
cookie: string,
): Promise<any> {
return this.patch(
`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/description/`,
data,
formData,
{
headers: {
"Content-Type": `multipart/form-data; boundary=${boundary}`,
Cookie: cookie,
},
}
},
)
.then((response) => response?.data)
.catch((error) => {
+2 -2
View File
@@ -1,6 +1,6 @@
{
"repository": "https://github.com/makeplane/plane.git",
"version": "0.23.1",
"version": "0.24.0",
"license": "AGPL-3.0",
"private": true,
"workspaces": [
@@ -22,7 +22,7 @@
"devDependencies": {
"prettier": "latest",
"prettier-plugin-tailwindcss": "^0.5.4",
"turbo": "^2.3.2"
"turbo": "^2.3.3"
},
"packageManager": "yarn@1.22.22",
"name": "plane"
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/constants",
"version": "0.23.1",
"version": "0.24.0",
"private": true,
"main": "./index.ts"
"main": "./src/index.ts"
}
+18
View File
@@ -0,0 +1,18 @@
export const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || "";
// PI Base Url
export const PI_BASE_URL = process.env.NEXT_PUBLIC_PI_BASE_URL || "";
// God Mode Admin App Base Url
export const ADMIN_BASE_URL = process.env.NEXT_PUBLIC_ADMIN_BASE_URL || "";
export const ADMIN_BASE_PATH = process.env.NEXT_PUBLIC_ADMIN_BASE_PATH || "";
export const GOD_MODE_URL = encodeURI(`${ADMIN_BASE_URL}${ADMIN_BASE_PATH}/`);
// Publish App Base Url
export const SPACE_BASE_URL = process.env.NEXT_PUBLIC_SPACE_BASE_URL || "";
export const SPACE_BASE_PATH = process.env.NEXT_PUBLIC_SPACE_BASE_PATH || "";
export const SITES_URL = encodeURI(`${SPACE_BASE_URL}${SPACE_BASE_PATH}/`);
// Live App Base Url
export const LIVE_BASE_URL = process.env.NEXT_PUBLIC_LIVE_BASE_URL || "";
export const LIVE_BASE_PATH = process.env.NEXT_PUBLIC_LIVE_BASE_PATH || "";
export const LIVE_URL = encodeURI(`${LIVE_BASE_URL}${LIVE_BASE_PATH}/`);
// plane website url
export const WEBSITE_URL =
process.env.NEXT_PUBLIC_WEBSITE_URL || "https://plane.so";
@@ -1,3 +1,4 @@
export * from "./auth";
export * from "./endpoints";
export * from "./issue";
export * from "./workspace";
+76
View File
@@ -0,0 +1,76 @@
export const ORGANIZATION_SIZE = [
"Just myself",
"2-10",
"11-50",
"51-200",
"201-500",
"500+",
];
export const RESTRICTED_URLS = [
"404",
"accounts",
"api",
"create-workspace",
"god-mode",
"installations",
"invitations",
"onboarding",
"profile",
"spaces",
"workspace-invitations",
"password",
"flags",
"monitor",
"monitoring",
"ingest",
"plane-pro",
"plane-ultimate",
"enterprise",
"plane-enterprise",
"disco",
"silo",
"chat",
"calendar",
"drive",
"channels",
"upgrade",
"billing",
"sign-in",
"sign-up",
"signin",
"signup",
"config",
"live",
"admin",
"m",
"import",
"importers",
"integrations",
"integration",
"configuration",
"initiatives",
"initiative",
"config",
"workflow",
"workflows",
"epics",
"epic",
"story",
"mobile",
"dashboard",
"desktop",
"onload",
"real-time",
"one",
"pages",
"mobile",
"business",
"pro",
"settings",
"monitor",
"license",
"licenses",
"instances",
"instance",
];
-23
View File
@@ -1,23 +0,0 @@
export const ORGANIZATION_SIZE = [
"Just myself",
"2-10",
"11-50",
"51-200",
"201-500",
"500+",
];
export const RESTRICTED_URLS = [
"404",
"accounts",
"api",
"create-workspace",
"error",
"god-mode",
"installations",
"invitations",
"onboarding",
"profile",
"spaces",
"workspace-invitations",
];
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/editor",
"version": "0.23.1",
"version": "0.24.0",
"description": "Core Editor that powers Plane",
"private": true,
"main": "./dist/index.mjs",
@@ -27,6 +27,7 @@
"dev": "tsup --watch",
"check-types": "tsc --noEmit",
"lint": "eslint src --ext .ts,.tsx",
"lint:errors": "eslint src --ext .ts,.tsx --quiet",
"format": "prettier --write \"**/*.{ts,tsx,md}\""
},
"peerDependencies": {
@@ -36,8 +37,8 @@
"dependencies": {
"@floating-ui/react": "^0.26.4",
"@hocuspocus/provider": "^2.13.5",
"@plane/helpers": "*",
"@plane/ui": "*",
"@plane/utils": "*",
"@tiptap/core": "^2.1.13",
"@tiptap/extension-blockquote": "^2.1.13",
"@tiptap/extension-character-count": "^2.6.5",
@@ -1,3 +1,12 @@
import { Extensions } from "@tiptap/core";
// types
import { TExtensions } from "@/types";
export const CoreEditorAdditionalExtensions = (): Extensions => [];
type Props = {
disabledExtensions: TExtensions[];
};
export const CoreEditorAdditionalExtensions = (props: Props): Extensions => {
const {} = props;
return [];
};
@@ -1,3 +1,12 @@
import { Extensions } from "@tiptap/core";
// types
import { TExtensions } from "@/types";
export const CoreReadOnlyEditorAdditionalExtensions = (): Extensions => [];
type Props = {
disabledExtensions: TExtensions[];
};
export const CoreReadOnlyEditorAdditionalExtensions = (props: Props): Extensions => {
const {} = props;
return [];
};
@@ -15,7 +15,13 @@ type Props = {
export const DocumentEditorAdditionalExtensions = (_props: Props) => {
const { disabledExtensions } = _props;
const extensions: Extensions = disabledExtensions?.includes("slash-commands") ? [] : [SlashCommands()];
const extensions: Extensions = disabledExtensions?.includes("slash-commands")
? []
: [
SlashCommands({
disabledExtensions,
}),
];
return extensions;
};
@@ -1,2 +1,3 @@
export * from "./core";
export * from "./document-extensions";
export * from "./slash-commands";
@@ -0,0 +1,14 @@
// extensions
import { TSlashCommandAdditionalOption } from "@/extensions";
// types
import { TExtensions } from "@/types";
type Props = {
disabledExtensions: TExtensions[];
};
export const coreEditorAdditionalSlashCommandOptions = (props: Props): TSlashCommandAdditionalOption[] => {
const {} = props;
const options: TSlashCommandAdditionalOption[] = [];
return options;
};
-1
View File
@@ -1 +0,0 @@
export type TEditorAdditionalCommands = never;
-1
View File
@@ -1,2 +1 @@
export * from "./editor";
export * from "./issue-embed";
@@ -1,4 +1,4 @@
import React from "react";
import React, { useEffect, useState } from "react";
// components
import { DocumentContentLoader, PageRenderer } from "@/components/editors";
// constants
@@ -19,6 +19,7 @@ const CollaborativeDocumentEditor = (props: ICollaborativeDocumentEditor) => {
containerClassName,
disabledExtensions,
displayConfig = DEFAULT_DISPLAY_CONFIG,
editable,
editorClassName = "",
embedHandler,
fileHandler,
@@ -43,23 +44,25 @@ const CollaborativeDocumentEditor = (props: ICollaborativeDocumentEditor) => {
}
// use document editor
const { editor, hasServerConnectionFailed, hasServerSynced } = useCollaborativeEditor({
onTransaction,
disabledExtensions,
editorClassName,
embedHandler,
extensions,
fileHandler,
forwardedRef,
handleEditorReady,
id,
mentionHandler,
placeholder,
realtimeConfig,
serverHandler,
tabIndex,
user,
});
const { editor, hasServerConnectionFailed, hasServerSynced, localProvider, hasIndexedDbSynced } =
useCollaborativeEditor({
disabledExtensions,
editable,
editorClassName,
embedHandler,
extensions,
fileHandler,
forwardedRef,
handleEditorReady,
id,
mentionHandler,
onTransaction,
placeholder,
realtimeConfig,
serverHandler,
tabIndex,
user,
});
const editorContainerClassNames = getEditorClassNames({
noBorder: true,
@@ -67,9 +70,30 @@ const CollaborativeDocumentEditor = (props: ICollaborativeDocumentEditor) => {
containerClassName,
});
const [hasIndexedDbEntry, setHasIndexedDbEntry] = useState(null);
useEffect(() => {
async function documentIndexedDbEntry(dbName: string) {
try {
const databases = await indexedDB.databases();
const hasEntry = databases.some((db) => db.name === dbName);
setHasIndexedDbEntry(hasEntry);
} catch (error) {
console.error("Error checking database existence:", error);
return false;
}
}
documentIndexedDbEntry(id);
}, [id, localProvider]);
if (!editor) return null;
if (!hasServerSynced && !hasServerConnectionFailed) return <DocumentContentLoader />;
// Wait until we know about IndexedDB status
if (hasIndexedDbEntry === null) return null;
if (hasServerConnectionFailed || (!hasIndexedDbEntry && !hasServerSynced) || !hasIndexedDbSynced) {
return <DocumentContentLoader />;
}
return (
<PageRenderer
@@ -15,6 +15,7 @@ import { EditorReadOnlyRefApi, ICollaborativeDocumentReadOnlyEditor } from "@/ty
const CollaborativeDocumentReadOnlyEditor = (props: ICollaborativeDocumentReadOnlyEditor) => {
const {
containerClassName,
disabledExtensions,
displayConfig = DEFAULT_DISPLAY_CONFIG,
editorClassName = "",
embedHandler,
@@ -37,6 +38,7 @@ const CollaborativeDocumentReadOnlyEditor = (props: ICollaborativeDocumentReadOn
}
const { editor, hasServerConnectionFailed, hasServerSynced } = useReadOnlyCollaborativeEditor({
disabledExtensions,
editorClassName,
extensions,
fileHandler,
@@ -129,6 +129,7 @@ export const PageRenderer = (props: IPageRenderer) => {
[editor, cleanup]
);
console.log("rendered");
return (
<>
<div className="frame-renderer flex-grow w-full -mx-5" onMouseOver={handleLinkHover}>
@@ -139,12 +140,12 @@ export const PageRenderer = (props: IPageRenderer) => {
id={id}
>
<EditorContentWrapper editor={editor} id={id} tabIndex={tabIndex} />
{editor.isEditable && (
<>
<BlockMenu editor={editor} />
<AIFeaturesMenu menu={aiHandler?.menu} />
</>
)}
{/* {editor.isEditable && ( */}
{/* <> */}
{/* <BlockMenu editor={editor} /> */}
{/* <AIFeaturesMenu menu={aiHandler?.menu} /> */}
{/* </> */}
{/* )} */}
</EditorContainer>
</div>
{isOpen && linkViewProps && coordinates && (
@@ -10,9 +10,10 @@ import { getEditorClassNames } from "@/helpers/common";
// hooks
import { useReadOnlyEditor } from "@/hooks/use-read-only-editor";
// types
import { EditorReadOnlyRefApi, IMentionHighlight, TDisplayConfig, TFileHandler } from "@/types";
import { EditorReadOnlyRefApi, IMentionHighlight, TDisplayConfig, TExtensions, TFileHandler } from "@/types";
interface IDocumentReadOnlyEditor {
disabledExtensions: TExtensions[];
id: string;
initialValue: string;
containerClassName: string;
@@ -31,6 +32,7 @@ interface IDocumentReadOnlyEditor {
const DocumentReadOnlyEditor = (props: IDocumentReadOnlyEditor) => {
const {
containerClassName,
disabledExtensions,
displayConfig = DEFAULT_DISPLAY_CONFIG,
editorClassName = "",
embedHandler,
@@ -51,6 +53,7 @@ const DocumentReadOnlyEditor = (props: IDocumentReadOnlyEditor) => {
}
const editor = useReadOnlyEditor({
disabledExtensions,
editorClassName,
extensions,
fileHandler,
@@ -19,7 +19,9 @@ export const EditorWrapper: React.FC<Props> = (props) => {
const {
children,
containerClassName,
disabledExtensions,
displayConfig = DEFAULT_DISPLAY_CONFIG,
editable,
editorClassName = "",
extensions,
id,
@@ -37,6 +39,8 @@ export const EditorWrapper: React.FC<Props> = (props) => {
} = props;
const editor = useEditor({
editable,
disabledExtensions,
editorClassName,
enableHistory: true,
extensions,
@@ -12,6 +12,7 @@ import { IReadOnlyEditorProps } from "@/types";
export const ReadOnlyEditorWrapper = (props: IReadOnlyEditorProps) => {
const {
containerClassName,
disabledExtensions,
displayConfig = DEFAULT_DISPLAY_CONFIG,
editorClassName = "",
fileHandler,
@@ -22,6 +23,7 @@ export const ReadOnlyEditorWrapper = (props: IReadOnlyEditorProps) => {
} = props;
const editor = useReadOnlyEditor({
disabledExtensions,
editorClassName,
fileHandler,
forwardedRef,
@@ -8,12 +8,7 @@ import { SideMenuExtension, SlashCommands } from "@/extensions";
import { EditorRefApi, IRichTextEditor } from "@/types";
const RichTextEditor = (props: IRichTextEditor) => {
const {
disabledExtensions,
dragDropEnabled,
bubbleMenuEnabled = true,
extensions: externalExtensions = [],
} = props;
const { disabledExtensions, dragDropEnabled, bubbleMenuEnabled = true, extensions: externalExtensions = [] } = props;
const getExtensions = useCallback(() => {
const extensions = [
@@ -24,7 +19,11 @@ const RichTextEditor = (props: IRichTextEditor) => {
}),
];
if (!disabledExtensions?.includes("slash-commands")) {
extensions.push(SlashCommands());
extensions.push(
SlashCommands({
disabledExtensions,
})
);
}
return extensions;
@@ -0,0 +1,6 @@
export const DocumentCollaborativeEvents = {
lock: { client: "locked", server: "lock" },
unlock: { client: "unlocked", server: "unlock" },
archive: { client: "archived", server: "archive" },
unarchive: { client: "unarchived", server: "unarchive" },
} as const;
@@ -0,0 +1,56 @@
import React, { useState } from "react";
import { NodeViewContent, NodeViewProps, NodeViewWrapper } from "@tiptap/react";
// constants
import { COLORS_LIST } from "@/constants/common";
// local components
import { CalloutBlockColorSelector } from "./color-selector";
import { CalloutBlockLogoSelector } from "./logo-selector";
// types
import { EAttributeNames, TCalloutBlockAttributes } from "./types";
// utils
import { updateStoredBackgroundColor } from "./utils";
type Props = NodeViewProps & {
node: NodeViewProps["node"] & {
attrs: TCalloutBlockAttributes;
};
updateAttributes: (attrs: Partial<TCalloutBlockAttributes>) => void;
};
export const CustomCalloutBlock: React.FC<Props> = (props) => {
const { editor, node, updateAttributes } = props;
// states
const [isEmojiPickerOpen, setIsEmojiPickerOpen] = useState(false);
const [isColorPickerOpen, setIsColorPickerOpen] = useState(false);
// derived values
const activeBackgroundColor = COLORS_LIST.find((c) => node.attrs["data-background"] === c.key)?.backgroundColor;
return (
<NodeViewWrapper
className="editor-callout-component group/callout-node relative bg-custom-background-90 rounded-lg text-custom-text-100 p-4 my-2 flex items-start gap-4 transition-colors duration-500 break-words"
style={{
backgroundColor: activeBackgroundColor,
}}
>
<CalloutBlockLogoSelector
blockAttributes={node.attrs}
disabled={!editor.isEditable}
isOpen={isEmojiPickerOpen}
handleOpen={(val) => setIsEmojiPickerOpen(val)}
updateAttributes={updateAttributes}
/>
<CalloutBlockColorSelector
disabled={!editor.isEditable}
isOpen={isColorPickerOpen}
toggleDropdown={() => setIsColorPickerOpen((prev) => !prev)}
onSelect={(val) => {
updateAttributes({
[EAttributeNames.BACKGROUND]: val,
});
updateStoredBackgroundColor(val);
}}
/>
<NodeViewContent as="div" className="w-full break-words" />
</NodeViewWrapper>
);
};
@@ -0,0 +1,75 @@
import { Ban, ChevronDown } from "lucide-react";
// constants
import { COLORS_LIST } from "@/constants/common";
// helpers
import { cn } from "@/helpers/common";
type Props = {
disabled: boolean;
isOpen: boolean;
onSelect: (color: string | null) => void;
toggleDropdown: () => void;
};
export const CalloutBlockColorSelector: React.FC<Props> = (props) => {
const { disabled, isOpen, onSelect, toggleDropdown } = props;
const handleColorSelect = (val: string | null) => {
onSelect(val);
toggleDropdown();
};
return (
<div
className={cn("opacity-0 pointer-events-none absolute top-2 right-2 z-10 transition-opacity", {
"group-hover/callout-node:opacity-100 group-hover/callout-node:pointer-events-auto": !disabled,
"opacity-100 pointer-events-auto": isOpen,
})}
contentEditable={false}
>
<div className="relative">
<button
type="button"
onClick={(e) => {
toggleDropdown();
e.stopPropagation();
}}
className={cn(
"flex items-center gap-1 h-full whitespace-nowrap py-1 px-2.5 text-sm font-medium text-custom-text-300 hover:bg-white/10 active:bg-custom-background-80 rounded transition-colors",
{
"bg-white/10": isOpen,
}
)}
disabled={disabled}
>
<span>Color</span>
<ChevronDown className="flex-shrink-0 size-3" />
</button>
{isOpen && (
<section className="absolute top-full right-0 z-10 mt-1 rounded-md border-[0.5px] border-custom-border-300 bg-custom-background-100 p-2 shadow-custom-shadow-rg animate-in fade-in slide-in-from-top-1">
<div className="flex items-center gap-2">
{COLORS_LIST.map((color) => (
<button
key={color.key}
type="button"
className="flex-shrink-0 size-6 rounded border-[0.5px] border-custom-border-400 hover:opacity-60 transition-opacity"
style={{
backgroundColor: color.backgroundColor,
}}
onClick={() => handleColorSelect(color.key)}
/>
))}
<button
type="button"
className="flex-shrink-0 size-6 grid place-items-center rounded text-custom-text-300 border-[0.5px] border-custom-border-400 hover:bg-custom-background-80 transition-colors"
onClick={() => handleColorSelect(null)}
>
<Ban className="size-4" />
</button>
</div>
</section>
)}
</div>
</div>
);
};
@@ -0,0 +1,72 @@
import { Node, mergeAttributes } from "@tiptap/core";
import { Node as NodeType } from "@tiptap/pm/model";
import { MarkdownSerializerState } from "@tiptap/pm/markdown";
// types
import { EAttributeNames, TCalloutBlockAttributes } from "./types";
// utils
import { DEFAULT_CALLOUT_BLOCK_ATTRIBUTES } from "./utils";
// Extend Tiptap's Commands interface
declare module "@tiptap/core" {
interface Commands<ReturnType> {
calloutComponent: {
insertCallout: () => ReturnType;
};
}
}
export const CustomCalloutExtensionConfig = Node.create({
name: "calloutComponent",
group: "block",
content: "block+",
addAttributes() {
const attributes = {
// Reduce instead of map to accumulate the attributes directly into an object
...Object.values(EAttributeNames).reduce((acc, value) => {
acc[value] = {
default: DEFAULT_CALLOUT_BLOCK_ATTRIBUTES[value],
};
return acc;
}, {}),
};
return attributes;
},
addStorage() {
return {
markdown: {
serialize(state: MarkdownSerializerState, node: NodeType) {
const attrs = node.attrs as TCalloutBlockAttributes;
const logoInUse = attrs["data-logo-in-use"];
// add callout logo
if (logoInUse === "emoji") {
state.write(
`> <img src="${attrs["data-emoji-url"]}" alt="${attrs["data-emoji-unicode"]}" width="30px" />\n`
);
} else {
state.write(`> <icon>${attrs["data-icon-name"]} icon</icon>\n`);
}
// add an empty line after the logo
state.write("> \n");
// add '> ' before each line of the callout content
state.wrapBlock("> ", null, node, () => state.renderContent(node));
state.closeBlock(node);
},
},
};
},
parseHTML() {
return [
{
tag: `div[${EAttributeNames.BLOCK_TYPE}="${DEFAULT_CALLOUT_BLOCK_ATTRIBUTES[EAttributeNames.BLOCK_TYPE]}"]`,
},
];
},
// Render HTML for the callout node
renderHTML({ HTMLAttributes }) {
return ["div", mergeAttributes(HTMLAttributes), 0];
},
});
@@ -0,0 +1,68 @@
import { findParentNodeClosestToPos, Predicate, ReactNodeViewRenderer } from "@tiptap/react";
// extensions
import { CustomCalloutBlock } from "@/extensions";
// helpers
import { insertEmptyParagraphAtNodeBoundaries } from "@/helpers/insert-empty-paragraph-at-node-boundary";
// config
import { CustomCalloutExtensionConfig } from "./extension-config";
// utils
import { getStoredBackgroundColor, getStoredLogo } from "./utils";
export const CustomCalloutExtension = CustomCalloutExtensionConfig.extend({
selectable: true,
draggable: true,
addCommands() {
return {
insertCallout:
() =>
({ commands }) => {
// get stored logo values and background color from the local storage
const storedLogoValues = getStoredLogo();
const storedBackgroundValue = getStoredBackgroundColor();
return commands.insertContent({
type: this.name,
content: [
{
type: "paragraph",
},
],
attrs: {
...storedLogoValues,
"data-background": storedBackgroundValue,
},
});
},
};
},
addKeyboardShortcuts() {
return {
Backspace: ({ editor }) => {
const { $from, empty } = editor.state.selection;
try {
const isParentNodeCallout: Predicate = (node) => node.type === this.type;
const parentNodeDetails = findParentNodeClosestToPos($from, isParentNodeCallout);
// Check if selection is empty and at the beginning of the callout
if (empty && parentNodeDetails) {
const isCursorAtCalloutBeginning = $from.pos === parentNodeDetails.start + 1;
if (parentNodeDetails.node.content.size > 2 && isCursorAtCalloutBeginning) {
editor.commands.setTextSelection(parentNodeDetails.pos - 1);
return true;
}
}
} catch (error) {
console.error("Error in performing backspace action on callout", error);
}
return false; // Allow the default behavior if conditions are not met
},
ArrowDown: insertEmptyParagraphAtNodeBoundaries("down", this.name),
ArrowUp: insertEmptyParagraphAtNodeBoundaries("up", this.name),
};
},
addNodeView() {
return ReactNodeViewRenderer(CustomCalloutBlock);
},
});
@@ -0,0 +1,3 @@
export * from "./block";
export * from "./extension";
export * from "./read-only-extension";
@@ -0,0 +1,97 @@
// plane helpers
import { convertHexEmojiToDecimal } from "@plane/utils";
// plane ui
import { EmojiIconPicker, EmojiIconPickerTypes, Logo, TEmojiLogoProps } from "@plane/ui";
// helpers
import { cn } from "@/helpers/common";
// types
import { TCalloutBlockAttributes } from "./types";
// utils
import { DEFAULT_CALLOUT_BLOCK_ATTRIBUTES, updateStoredLogo } from "./utils";
type Props = {
blockAttributes: TCalloutBlockAttributes;
disabled: boolean;
handleOpen: (val: boolean) => void;
isOpen: boolean;
updateAttributes: (attrs: Partial<TCalloutBlockAttributes>) => void;
};
export const CalloutBlockLogoSelector: React.FC<Props> = (props) => {
const { blockAttributes, disabled, handleOpen, isOpen, updateAttributes } = props;
const logoValue: TEmojiLogoProps = {
in_use: blockAttributes["data-logo-in-use"],
icon: {
color: blockAttributes["data-icon-color"],
name: blockAttributes["data-icon-name"],
},
emoji: {
value: blockAttributes["data-emoji-unicode"]?.toString(),
url: blockAttributes["data-emoji-url"],
},
};
return (
<div contentEditable={false}>
<EmojiIconPicker
closeOnSelect={false}
isOpen={isOpen}
handleToggle={handleOpen}
className="flex-shrink-0 grid place-items-center"
buttonClassName={cn("flex-shrink-0 size-8 grid place-items-center rounded-lg", {
"hover:bg-white/10": !disabled,
})}
label={<Logo logo={logoValue} size={18} type="lucide" />}
onChange={(val) => {
// construct the new logo value based on the type of value
let newLogoValue: Partial<TCalloutBlockAttributes> = {};
let newLogoValueToStoreInLocalStorage: TEmojiLogoProps = {
in_use: "emoji",
emoji: {
value: DEFAULT_CALLOUT_BLOCK_ATTRIBUTES["data-emoji-unicode"],
url: DEFAULT_CALLOUT_BLOCK_ATTRIBUTES["data-emoji-url"],
},
};
if (val.type === "emoji") {
newLogoValue = {
"data-emoji-unicode": convertHexEmojiToDecimal(val.value.unified),
"data-emoji-url": val.value.imageUrl,
};
newLogoValueToStoreInLocalStorage = {
in_use: "emoji",
emoji: {
value: convertHexEmojiToDecimal(val.value.unified),
url: val.value.imageUrl,
},
};
} else if (val.type === "icon") {
newLogoValue = {
"data-icon-name": val.value.name,
"data-icon-color": val.value.color,
};
newLogoValueToStoreInLocalStorage = {
in_use: "icon",
icon: {
name: val.value.name,
color: val.value.color,
},
};
}
// update node attributes
updateAttributes({
"data-logo-in-use": val.type,
...newLogoValue,
});
// update stored logo in local storage
updateStoredLogo(newLogoValueToStoreInLocalStorage);
handleOpen(false);
}}
defaultIconColor={logoValue?.in_use && logoValue.in_use === "icon" ? logoValue?.icon?.color : undefined}
defaultOpen={logoValue.in_use === "emoji" ? EmojiIconPickerTypes.EMOJI : EmojiIconPickerTypes.ICON}
disabled={disabled}
searchDisabled
/>
</div>
);
};
@@ -0,0 +1,14 @@
import { ReactNodeViewRenderer } from "@tiptap/react";
// extensions
import { CustomCalloutBlock } from "@/extensions";
// config
import { CustomCalloutExtensionConfig } from "./extension-config";
export const CustomCalloutReadOnlyExtension = CustomCalloutExtensionConfig.extend({
selectable: false,
draggable: false,
addNodeView() {
return ReactNodeViewRenderer(CustomCalloutBlock);
},
});
@@ -0,0 +1,26 @@
export enum EAttributeNames {
ICON_COLOR = "data-icon-color",
ICON_NAME = "data-icon-name",
EMOJI_UNICODE = "data-emoji-unicode",
EMOJI_URL = "data-emoji-url",
LOGO_IN_USE = "data-logo-in-use",
BACKGROUND = "data-background",
BLOCK_TYPE = "data-block-type",
}
export type TCalloutBlockIconAttributes = {
[EAttributeNames.ICON_COLOR]: string | undefined;
[EAttributeNames.ICON_NAME]: string | undefined;
};
export type TCalloutBlockEmojiAttributes = {
[EAttributeNames.EMOJI_UNICODE]: string | undefined;
[EAttributeNames.EMOJI_URL]: string | undefined;
};
export type TCalloutBlockAttributes = {
[EAttributeNames.LOGO_IN_USE]: "emoji" | "icon";
[EAttributeNames.BACKGROUND]: string;
[EAttributeNames.BLOCK_TYPE]: "callout-component";
} & TCalloutBlockIconAttributes &
TCalloutBlockEmojiAttributes;
@@ -0,0 +1,85 @@
// plane helpers
import { sanitizeHTML } from "@plane/utils";
// plane ui
import { TEmojiLogoProps } from "@plane/ui";
// types
import {
EAttributeNames,
TCalloutBlockAttributes,
TCalloutBlockEmojiAttributes,
TCalloutBlockIconAttributes,
} from "./types";
export const DEFAULT_CALLOUT_BLOCK_ATTRIBUTES: TCalloutBlockAttributes = {
"data-logo-in-use": "emoji",
"data-icon-color": null,
"data-icon-name": null,
"data-emoji-unicode": "128161",
"data-emoji-url": "https://cdn.jsdelivr.net/npm/emoji-datasource-apple/img/apple/64/1f4a1.png",
"data-background": null,
"data-block-type": "callout-component",
};
type TStoredLogoValue = Pick<TCalloutBlockAttributes, EAttributeNames.LOGO_IN_USE> &
(TCalloutBlockEmojiAttributes | TCalloutBlockIconAttributes);
// function to get the stored logo from local storage
export const getStoredLogo = (): TStoredLogoValue => {
const fallBackValues: TStoredLogoValue = {
"data-logo-in-use": "emoji",
"data-emoji-unicode": DEFAULT_CALLOUT_BLOCK_ATTRIBUTES["data-emoji-unicode"],
"data-emoji-url": DEFAULT_CALLOUT_BLOCK_ATTRIBUTES["data-emoji-url"],
};
if (typeof window !== "undefined") {
const storedData = sanitizeHTML(localStorage.getItem("editor-calloutComponent-logo"));
if (storedData) {
let parsedData: TEmojiLogoProps;
try {
parsedData = JSON.parse(storedData);
} catch (error) {
console.error(`Error parsing stored callout logo, stored value- ${storedData}`, error);
localStorage.removeItem("editor-calloutComponent-logo");
return fallBackValues;
}
if (parsedData.in_use === "emoji" && parsedData.emoji?.value) {
return {
"data-logo-in-use": "emoji",
"data-emoji-unicode": parsedData.emoji.value || DEFAULT_CALLOUT_BLOCK_ATTRIBUTES["data-emoji-unicode"],
"data-emoji-url": parsedData.emoji.url || DEFAULT_CALLOUT_BLOCK_ATTRIBUTES["data-emoji-url"],
};
}
if (parsedData.in_use === "icon" && parsedData.icon?.name) {
return {
"data-logo-in-use": "icon",
"data-icon-name": parsedData.icon.name || DEFAULT_CALLOUT_BLOCK_ATTRIBUTES["data-icon-name"],
"data-icon-color": parsedData.icon.color || DEFAULT_CALLOUT_BLOCK_ATTRIBUTES["data-icon-color"],
};
}
}
}
// fallback values
return fallBackValues;
};
// function to update the stored logo on local storage
export const updateStoredLogo = (value: TEmojiLogoProps): void => {
if (typeof window === "undefined") return;
localStorage.setItem("editor-calloutComponent-logo", JSON.stringify(value));
};
// function to get the stored background color from local storage
export const getStoredBackgroundColor = (): string | null => {
if (typeof window !== "undefined") {
return sanitizeHTML(localStorage.getItem("editor-calloutComponent-background"));
}
return null;
};
// function to update the stored background color on local storage
export const updateStoredBackgroundColor = (value: string | null): void => {
if (typeof window === "undefined") return;
if (value === null) {
localStorage.removeItem("editor-calloutComponent-background");
return;
} else {
localStorage.setItem("editor-calloutComponent-background", value);
}
};
@@ -1,4 +1,3 @@
import { Extensions } from "@tiptap/core";
import TaskItem from "@tiptap/extension-task-item";
import TaskList from "@tiptap/extension-task-list";
import TextStyle from "@tiptap/extension-text-style";
@@ -18,10 +17,12 @@ import { CustomMentionWithoutProps } from "./mentions/mentions-without-props";
import { CustomQuoteExtension } from "./quote";
import { TableHeader, TableCell, TableRow, Table } from "./table";
import { CustomTextAlignExtension } from "./text-align";
import { CustomCalloutExtensionConfig } from "./callout/extension-config";
import { CustomColorExtension } from "./custom-color";
// plane editor extensions
import { CoreEditorAdditionalExtensionsWithoutProps } from "@/plane-editor/extensions/core/without-props";
export const CoreEditorExtensionsWithoutProps: Extensions = [
export const CoreEditorExtensionsWithoutProps = [
StarterKit.configure({
bulletList: {
HTMLAttributes: {
@@ -42,6 +43,16 @@ export const CoreEditorExtensionsWithoutProps: Extensions = [
codeBlock: false,
horizontalRule: false,
blockquote: false,
paragraph: {
HTMLAttributes: {
class: "editor-paragraph-block",
},
},
heading: {
HTMLAttributes: {
class: "editor-heading-block",
},
},
dropcursor: false,
}),
CustomQuoteExtension,
@@ -88,6 +99,7 @@ export const CoreEditorExtensionsWithoutProps: Extensions = [
TableRow,
CustomMentionWithoutProps(),
CustomTextAlignExtension,
CustomCalloutExtensionConfig,
CustomColorExtension,
...CoreEditorAdditionalExtensionsWithoutProps,
];
@@ -118,7 +118,6 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
height: `${Math.round(initialHeight)}px` satisfies Pixel,
aspectRatio: aspectRatioCalculated,
};
setSize(initialComputedSize);
updateAttributesSafely(
initialComputedSize,
@@ -29,12 +29,9 @@ export const CustomImageNode = (props: CustomImageNodeProps) => {
useEffect(() => {
const closestEditorContainer = imageComponentRef.current?.closest(".editor-container");
if (!closestEditorContainer) {
console.error("Editor container not found");
return;
if (closestEditorContainer) {
setEditorContainer(closestEditorContainer as HTMLDivElement);
}
setEditorContainer(closestEditorContainer as HTMLDivElement);
}, []);
// the image is already uploaded if the image-component node has src attribute
@@ -55,7 +52,7 @@ export const CustomImageNode = (props: CustomImageNodeProps) => {
setResolvedSrc(url as string);
};
getImageSource();
}, [imageFromFileSystem, node.attrs.src]);
}, [imgNodeSrc]);
return (
<NodeViewWrapper>
@@ -127,24 +127,30 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
return "Uploading...";
}
if (draggedInside) {
if (draggedInside && editor.isEditable) {
return "Drop image here";
}
return "Add an image";
if (!editor.isEditable) {
return "Viewing Mode: Image Upload Disabled";
} else {
return "Add an image";
}
}, [draggedInside, failedToLoadImage, isImageBeingUploaded]);
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 transition-all duration-200 ease-in-out cursor-default",
"image-upload-component flex items-center justify-start gap-2 py-3 px-2 rounded-lg text-custom-text-300 bg-custom-background-90 border border-dashed border-custom-border-300 transition-all duration-200 ease-in-out cursor-default",
{
"hover:text-custom-text-200 cursor-pointer": editor.isEditable,
"bg-custom-background-80 text-custom-text-200": draggedInside,
"text-custom-primary-200 bg-custom-primary-100/10 hover:bg-custom-primary-100/10 hover:text-custom-primary-200 border-custom-primary-200/10":
selected,
"text-red-500 cursor-default hover:text-red-500": failedToLoadImage,
"bg-red-500/10 hover:bg-red-500/10": failedToLoadImage && selected,
"hover:text-custom-text-200 hover:bg-custom-background-80 cursor-pointer": editor.isEditable,
"bg-custom-background-80 text-custom-text-200": draggedInside && editor.isEditable,
"text-custom-primary-200 bg-custom-primary-100/10 border-custom-primary-200/10 hover:bg-custom-primary-100/10 hover:text-custom-primary-200":
selected && editor.isEditable,
"text-red-500 cursor-default": failedToLoadImage,
"hover:text-red-500": failedToLoadImage && editor.isEditable,
"bg-red-500/10": failedToLoadImage && selected,
"hover:bg-red-500/10": failedToLoadImage && selected && editor.isEditable,
}
)}
onDrop={onDrop}
@@ -1,11 +1,9 @@
import { Editor, mergeAttributes } from "@tiptap/core";
import { Image } from "@tiptap/extension-image";
import { MarkdownSerializerState } from "@tiptap/pm/markdown";
import { Node } from "@tiptap/pm/model";
import { ReactNodeViewRenderer } from "@tiptap/react";
import { v4 as uuidv4 } from "uuid";
// extensions
import { CustomImageNode, ImageAttributes } from "@/extensions/custom-image";
import { CustomImageNode } from "@/extensions/custom-image";
// plugins
import { TrackImageDeletionPlugin, TrackImageRestorationPlugin, isFileValid } from "@/plugins/image";
// types
@@ -126,14 +124,9 @@ export const CustomImageExtension = (props: TFileHandler) => {
deletedImageSet: new Map<string, boolean>(),
uploadInProgress: false,
maxFileSize,
// escape markdown for images
markdown: {
serialize(state: MarkdownSerializerState, node: Node) {
const attrs = node.attrs as ImageAttributes;
const imageSource = state.esc(this?.editor?.commands?.getImageSource?.(attrs.src) || attrs.src);
const imageWidth = state.esc(attrs.width?.toString());
state.write(`<img src="${state.esc(imageSource)}" width="${imageWidth}" />`);
state.closeBlock(node);
},
serialize() {},
},
};
},
@@ -1,10 +1,8 @@
import { mergeAttributes } from "@tiptap/core";
import { Image } from "@tiptap/extension-image";
import { MarkdownSerializerState } from "@tiptap/pm/markdown";
import { Node } from "@tiptap/pm/model";
import { ReactNodeViewRenderer } from "@tiptap/react";
// components
import { CustomImageNode, ImageAttributes, UploadImageExtensionStorage } from "@/extensions/custom-image";
import { CustomImageNode, UploadImageExtensionStorage } from "@/extensions/custom-image";
// types
import { TFileHandler } from "@/types";
@@ -54,14 +52,9 @@ export const CustomReadOnlyImageExtension = (props: Pick<TFileHandler, "getAsset
addStorage() {
return {
fileMap: new Map(),
// escape markdown for images
markdown: {
serialize(state: MarkdownSerializerState, node: Node) {
const attrs = node.attrs as ImageAttributes;
const imageSource = state.esc(this?.editor?.commands?.getImageSource?.(attrs.src) || attrs.src);
const imageWidth = state.esc(attrs.width?.toString());
state.write(`<img src="${state.esc(imageSource)}" width="${imageWidth}" />`);
state.closeBlock(node);
},
serialize() {},
},
};
},
+54 -45
View File
@@ -2,58 +2,67 @@ import { Extension, Editor } from "@tiptap/core";
import { Plugin, PluginKey } from "@tiptap/pm/state";
import { EditorView } from "@tiptap/pm/view";
export const DropHandlerExtension = () =>
Extension.create({
name: "dropHandler",
priority: 1000,
export const DropHandlerExtension = Extension.create({
name: "dropHandler",
priority: 1000,
addProseMirrorPlugins() {
const editor = this.editor;
return [
new Plugin({
key: new PluginKey("drop-handler-plugin"),
props: {
handlePaste: (view: EditorView, event: ClipboardEvent) => {
if (event.clipboardData && event.clipboardData.files && event.clipboardData.files.length > 0) {
event.preventDefault();
const files = Array.from(event.clipboardData.files);
const imageFiles = files.filter((file) => file.type.startsWith("image"));
addProseMirrorPlugins() {
const editor = this.editor;
return [
new Plugin({
key: new PluginKey("drop-handler-plugin"),
props: {
handlePaste: (view: EditorView, event: ClipboardEvent) => {
if (
editor.isEditable &&
event.clipboardData &&
event.clipboardData.files &&
event.clipboardData.files.length > 0
) {
event.preventDefault();
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;
if (imageFiles.length > 0) {
const pos = view.state.selection.from;
insertImagesSafely({ editor, files: imageFiles, initialPos: pos, event: "drop" });
}
return true;
}
return false;
},
handleDrop: (view: EditorView, event: DragEvent, _slice: any, moved: boolean) => {
if (
editor.isEditable &&
!moved &&
event.dataTransfer &&
event.dataTransfer.files &&
event.dataTransfer.files.length > 0
) {
event.preventDefault();
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) {
const pos = coordinates.pos;
insertImagesSafely({ editor, files: imageFiles, initialPos: pos, event: "drop" });
}
return true;
}
return false;
},
handleDrop: (view: EditorView, event: DragEvent, _slice: any, moved: boolean) => {
if (!moved && event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files.length > 0) {
event.preventDefault();
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) {
const pos = coordinates.pos;
insertImagesSafely({ editor, files: imageFiles, initialPos: pos, event: "drop" });
}
return true;
}
}
return false;
},
}
return false;
},
}),
];
},
});
},
}),
];
},
});
export const insertImagesSafely = async ({
editor,
files,
@@ -9,6 +9,7 @@ import StarterKit from "@tiptap/starter-kit";
import { Markdown } from "tiptap-markdown";
// extensions
import {
CustomCalloutExtension,
CustomCodeBlockExtension,
CustomCodeInlineExtension,
CustomCodeMarkPlugin,
@@ -32,11 +33,12 @@ import {
// helpers
import { isValidHttpUrl } from "@/helpers/common";
// types
import { IMentionHighlight, IMentionSuggestion, TFileHandler } from "@/types";
import { IMentionHighlight, IMentionSuggestion, TExtensions, TFileHandler } from "@/types";
// plane editor extensions
import { CoreEditorAdditionalExtensions } from "@/plane-editor/extensions";
type TArguments = {
disabledExtensions: TExtensions[];
enableHistory: boolean;
fileHandler: TFileHandler;
mentionConfig: {
@@ -45,10 +47,11 @@ type TArguments = {
};
placeholder?: string | ((isFocused: boolean, value: string) => string);
tabIndex?: number;
editable?: boolean;
};
export const CoreEditorExtensions = (args: TArguments): Extensions => {
const { enableHistory, fileHandler, mentionConfig, placeholder, tabIndex } = args;
const { disabledExtensions, enableHistory, fileHandler, mentionConfig, placeholder, tabIndex, editable } = args;
return [
StarterKit.configure({
@@ -71,13 +74,23 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => {
codeBlock: false,
horizontalRule: false,
blockquote: false,
paragraph: {
HTMLAttributes: {
class: "editor-paragraph-block",
},
},
heading: {
HTMLAttributes: {
class: "editor-heading-block",
},
},
dropcursor: {
class: "text-custom-text-300",
},
...(enableHistory ? {} : { history: false }),
}),
CustomQuoteExtension,
DropHandlerExtension(),
DropHandlerExtension,
CustomHorizontalRule.configure({
HTMLAttributes: {
class: "py-4 border-custom-border-400",
@@ -133,9 +146,9 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => {
TableCell,
TableRow,
CustomMention({
mentionSuggestions: mentionConfig.mentionSuggestions,
mentionSuggestions: editable ? mentionConfig.mentionSuggestions : undefined,
mentionHighlights: mentionConfig.mentionHighlights,
readonly: false,
readonly: !editable,
}),
Placeholder.configure({
placeholder: ({ editor, node }) => {
@@ -162,7 +175,10 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => {
}),
CharacterCount,
CustomTextAlignExtension,
CustomCalloutExtension,
CustomColorExtension,
...CoreEditorAdditionalExtensions(),
...CoreEditorAdditionalExtensions({
disabledExtensions,
}),
];
};
@@ -1,3 +1,4 @@
export * from "./callout";
export * from "./code";
export * from "./code-inline";
export * from "./custom-image";
@@ -23,16 +23,18 @@ import {
HeadingListExtension,
CustomReadOnlyImageExtension,
CustomTextAlignExtension,
CustomCalloutReadOnlyExtension,
CustomColorExtension,
} from "@/extensions";
// helpers
import { isValidHttpUrl } from "@/helpers/common";
// types
import { IMentionHighlight, TFileHandler } from "@/types";
import { IMentionHighlight, TExtensions, TFileHandler } from "@/types";
// plane editor extensions
import { CoreReadOnlyEditorAdditionalExtensions } from "@/plane-editor/extensions";
type Props = {
disabledExtensions: TExtensions[];
fileHandler: Pick<TFileHandler, "getAssetSrc">;
mentionConfig: {
mentionHighlights?: () => Promise<IMentionHighlight[]>;
@@ -40,7 +42,7 @@ type Props = {
};
export const CoreReadOnlyEditorExtensions = (props: Props): Extensions => {
const { fileHandler, mentionConfig } = props;
const { disabledExtensions, fileHandler, mentionConfig } = props;
return [
StarterKit.configure({
@@ -63,6 +65,16 @@ export const CoreReadOnlyEditorExtensions = (props: Props): Extensions => {
codeBlock: false,
horizontalRule: false,
blockquote: false,
paragraph: {
HTMLAttributes: {
class: "editor-paragraph-block",
},
},
heading: {
HTMLAttributes: {
class: "editor-heading-block",
},
},
dropcursor: false,
gapcursor: false,
}),
@@ -129,6 +141,9 @@ export const CoreReadOnlyEditorExtensions = (props: Props): Extensions => {
CustomColorExtension,
HeadingListExtension,
CustomTextAlignExtension,
...CoreReadOnlyEditorAdditionalExtensions(),
CustomCalloutReadOnlyExtension,
...CoreReadOnlyEditorAdditionalExtensions({
disabledExtensions,
}),
];
};
@@ -12,6 +12,7 @@ import {
List,
ListOrdered,
ListTodo,
MessageSquareText,
MinusSquare,
Table,
TextQuote,
@@ -34,10 +35,14 @@ import {
toggleTextColor,
toggleBackgroundColor,
insertImage,
insertCallout,
setText,
} from "@/helpers/editor-commands";
// types
import { CommandProps, ISlashCommandItem, TSlashCommandSectionKeys } from "@/types";
import { CommandProps, ISlashCommandItem, TExtensions, TSlashCommandSectionKeys } from "@/types";
// plane editor extensions
import { coreEditorAdditionalSlashCommandOptions } from "@/plane-editor/extensions";
// local types
import { TSlashCommandAdditionalOption } from "./root";
export type TSlashCommandSection = {
@@ -46,9 +51,15 @@ export type TSlashCommandSection = {
items: ISlashCommandItem[];
};
type TArgs = {
additionalOptions?: TSlashCommandAdditionalOption[];
disabledExtensions: TExtensions[];
};
export const getSlashCommandFilteredSections =
(additionalOptions?: TSlashCommandAdditionalOption[]) =>
(args: TArgs) =>
({ query }: { query: string }): TSlashCommandSection[] => {
const { additionalOptions, disabledExtensions } = args;
const SLASH_COMMAND_SECTIONS: TSlashCommandSection[] = [
{
key: "general",
@@ -179,6 +190,15 @@ export const getSlashCommandFilteredSections =
searchTerms: ["img", "photo", "picture", "media", "upload"],
command: ({ editor, range }: CommandProps) => insertImage({ editor, event: "insert", range }),
},
{
commandKey: "callout",
key: "callout",
title: "Callout",
icon: <MessageSquareText className="size-3.5" />,
description: "Insert callout",
searchTerms: ["callout", "comment", "message", "info", "alert"],
command: ({ editor, range }: CommandProps) => insertCallout(editor, range),
},
{
commandKey: "divider",
key: "divider",
@@ -269,15 +289,16 @@ export const getSlashCommandFilteredSections =
},
];
additionalOptions?.forEach((item) => {
[
...(additionalOptions ?? []),
...coreEditorAdditionalSlashCommandOptions({
disabledExtensions,
}),
]?.forEach((item) => {
const sectionToPushTo = SLASH_COMMAND_SECTIONS.find((s) => s.key === item.section) ?? SLASH_COMMAND_SECTIONS[0];
const itemIndexToPushAfter = sectionToPushTo.items.findIndex((i) => i.commandKey === item.pushAfter);
if (itemIndexToPushAfter !== undefined) {
const resolvedIndex =
itemIndexToPushAfter + 1 < sectionToPushTo.items.length
? itemIndexToPushAfter + 1
: sectionToPushTo.items.length - 1;
sectionToPushTo.items.splice(resolvedIndex, 0, item);
if (itemIndexToPushAfter !== -1) {
sectionToPushTo.items.splice(itemIndexToPushAfter + 1, 0, item);
} else {
sectionToPushTo.items.push(item);
}
@@ -41,7 +41,7 @@ export const SlashCommandsMenu = (props: SlashCommandsMenuProps) => {
if (nextItem < 0) {
nextSection = currentSection - 1;
if (nextSection < 0) nextSection = sections.length - 1;
nextItem = sections[nextSection].items.length - 1;
nextItem = sections[nextSection]?.items.length - 1;
}
}
if (e.key === "ArrowDown") {
@@ -3,7 +3,7 @@ import { ReactRenderer } from "@tiptap/react";
import Suggestion, { SuggestionOptions } from "@tiptap/suggestion";
import tippy from "tippy.js";
// types
import { ISlashCommandItem, TEditorCommands, TSlashCommandSectionKeys } from "@/types";
import { ISlashCommandItem, TEditorCommands, TExtensions, TSlashCommandSectionKeys } from "@/types";
// components
import { getSlashCommandFilteredSections } from "./command-items-list";
import { SlashCommandsMenu, SlashCommandsMenuProps } from "./command-menu";
@@ -107,10 +107,15 @@ const renderItems = () => {
};
};
export const SlashCommands = (additionalOptions?: TSlashCommandAdditionalOption[]) =>
type TExtensionProps = {
additionalOptions?: TSlashCommandAdditionalOption[];
disabledExtensions: TExtensions[];
};
export const SlashCommands = (props: TExtensionProps) =>
Command.configure({
suggestion: {
items: getSlashCommandFilteredSections(additionalOptions),
items: getSlashCommandFilteredSections(props),
render: renderItems,
},
});
@@ -189,3 +189,7 @@ export const insertHorizontalRule = (editor: Editor, range?: Range) => {
if (range) editor.chain().focus().deleteRange(range).setHorizontalRule().run();
else editor.chain().focus().setHorizontalRule().run();
};
export const insertCallout = (editor: Editor, range?: Range) => {
if (range) editor.chain().focus().deleteRange(range).insertCallout().run();
else editor.chain().focus().insertCallout().run();
};
@@ -0,0 +1,11 @@
import { DocumentCollaborativeEvents } from "@/constants/document-collaborative-events";
import { TDocumentEventKey, TDocumentEventsClient, TDocumentEventsServer } from "@/types/document-collaborative-events";
export const getServerEventName = (clientEvent: TDocumentEventsClient): TDocumentEventsServer | undefined => {
for (const key in DocumentCollaborativeEvents) {
if (DocumentCollaborativeEvents[key as TDocumentEventKey].client === clientEvent) {
return DocumentCollaborativeEvents[key as TDocumentEventKey].server;
}
}
return undefined;
};
@@ -1,4 +1,4 @@
import { useEffect, useLayoutEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { HocuspocusProvider } from "@hocuspocus/provider";
import Collaboration from "@tiptap/extension-collaboration";
import { IndexeddbPersistence } from "y-indexeddb";
@@ -15,6 +15,7 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorProps) => {
const {
onTransaction,
disabledExtensions,
editable,
editorClassName,
editorProps = {},
embedHandler,
@@ -33,6 +34,7 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorProps) => {
// states
const [hasServerConnectionFailed, setHasServerConnectionFailed] = useState(false);
const [hasServerSynced, setHasServerSynced] = useState(false);
const [hasIndexedDbSynced, setHasIndexedDbSynced] = useState(false);
// initialize Hocuspocus provider
const provider = useMemo(
() =>
@@ -53,30 +55,36 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorProps) => {
setHasServerConnectionFailed(true);
}
},
onSynced: () => setHasServerSynced(true),
onSynced: () => {
serverHandler?.onServerSync?.();
setHasServerSynced(true);
},
}),
[id, realtimeConfig, serverHandler, user]
);
// destroy and disconnect connection on unmount
const localProvider = useMemo(
() => (id ? new IndexeddbPersistence(id, provider.document) : undefined),
[id, provider]
);
localProvider?.on("synced", () => {
setHasIndexedDbSynced(true);
});
// destroy and disconnect all providers connection on unmount
useEffect(
() => () => {
provider.destroy();
provider.disconnect();
},
[provider]
);
// indexed db integration for offline support
useLayoutEffect(() => {
const localProvider = new IndexeddbPersistence(id, provider.document);
return () => {
provider?.destroy();
localProvider?.destroy();
};
}, [provider, id]);
},
[provider, localProvider]
);
const editor = useEditor({
disabledExtensions,
id,
onTransaction,
editable,
editorProps,
editorClassName,
enableHistory: false,
@@ -98,9 +106,10 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorProps) => {
}),
],
fileHandler,
handleEditorReady,
forwardedRef,
handleEditorReady,
mentionHandler,
onTransaction,
placeholder,
provider,
tabIndex,
@@ -110,5 +119,7 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorProps) => {
editor,
hasServerConnectionFailed,
hasServerSynced,
hasIndexedDbSynced,
localProvider,
};
};

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