Compare commits

...

49 Commits

Author SHA1 Message Date
sriramveeraghanta b23004e302 chore: tsc alias 2025-07-01 21:16:26 +05:30
sriramveeraghanta d0e0cd324b chore: replace tsup with tsx 2025-07-01 20:54:43 +05:30
sriram veeraghanta fa9c63716c fix: circular dependencies between packages (#7277) 2025-07-01 19:19:44 +05:30
sriramveeraghanta d3f1b511ad sync: canary changes to preview 2025-07-01 17:42:13 +05:30
Bavisetti Narayan e624a17868 [WEB-4418]fix: fixed the pagination for issues #7301 2025-07-01 15:03:20 +05:30
Vamsi Krishna 670da9fa03 [WEB-4416]fix: Unnecessary border on Intake header #7297 2025-07-01 13:21:29 +05:30
Aaron b03844ee2d [WEB-4417] chore: optimize package imports instead of transpile #7292 2025-07-01 12:54:39 +05:30
Dheeraj Kumar Ketireddy f36d0691fe Chore: replace relation choices with TextChoices in IssueRelation model (#7295) 2025-07-01 12:36:27 +05:30
sriram veeraghanta e7d888d817 chore: package version updated 2025-06-30 23:56:34 +05:30
Jayash Tripathy 2f6923fca0 fix: work item peek infinite loop (#7284)
* fix: removed t function from dependency array which was causing infinite loop

* fix: add eslint disable comment for exhaustive-deps warning in IssuePeekOverview
2025-06-30 16:10:50 +05:30
Vamsi Krishna 912246c592 [WEB-4365] fix: dates display properties toggle #7262 2025-06-30 16:10:06 +05:30
Aaryan Khandelwal 4a065e14d0 [WEB-4409] refactor: event constants (#7276)
* refactor: event constants

* fix: cycle event keys

* chore: store extension

* chore: update events naming convention

---------

Co-authored-by: sriramveeraghanta <veeraghanta.sriram@gmail.com>
2025-06-27 19:01:00 +05:30
Aaryan Khandelwal e09aeab5b8 [WIKI-481] refactor: editor parser #7261 2025-06-27 16:05:38 +05:30
Akshita Goyal 25a6cd49fc fix: added @plane/services to the web dependencies (#7271) 2025-06-26 14:33:13 +05:30
JayashTripathy b5538565c7 [WEB-4371] feat: bar chart component with lollipop shape variant (#7268)
* feat: enhance bar chart component with shape variants and custom tooltip

* Update packages/propel/src/charts/bar-chart/bar.tsx

removed the unknown props

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* Update packages/propel/src/charts/bar-chart/bar.tsx

removed console log

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* refactor: replace inline percentage text with PercentageText component in bar chart

* Added new variant - lollipop-dotted

* added some comments

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-06-25 23:43:10 +05:30
Nikhil b8043f92b1 [WEB-4373]: optimize backend query for workspace views and Project gantt view (#7267)
* feat: add IssueListDetailSerializer for detailed issue representation

- Introduced IssueListDetailSerializer to enhance issue data representation with expanded fields.
- Updated issue detail endpoint to utilize the new serializer for improved data handling.
- Added methods for retrieving related module, label, and assignee IDs, along with support for expanded relations.

* feat: add ViewIssueListSerializer and enhance issue ordering

- Introduced ViewIssueListSerializer for improved issue representation, including assignee, label, and module IDs.
- Updated WorkspaceViewIssuesViewSet to utilize the new serializer and optimized queryset with prefetching.
- Enhanced order_issue_queryset to maintain consistent ordering by created_at alongside other fields.
- Modified pagination logic to support total count retrieval for better performance.

* fix: optimize issue filtering and pagination logic

- Updated WorkspaceViewIssuesViewSet to apply filters more efficiently in the issue query.
- Refined pagination logic in OffsetPaginator to ensure consistent behavior using limit instead of cursor.value, improving overall pagination accuracy.

* fix: improve pagination logic in OffsetPaginator

- Updated the next_cursor calculation to use the length of results instead of cursor.value, ensuring accurate pagination behavior.
- Added a comment to clarify the purpose of checking for additional results after the current page.

* Move the common permission filters into a separate method

* fix: handle deleted related issues in serializers

- Updated IssueListDetailSerializer to skip null related issues when building relations.
- Enhanced ViewIssueListSerializer to safely access state.group, returning None if state is not present.
- Removed unused User import in base.py for cleaner code.

---------

Co-authored-by: Dheeraj Kumar Ketireddy <dheeru0198@gmail.com>
2025-06-25 19:10:24 +05:30
Aaryan Khandelwal 0e91feacc3 [WIKI-74] fix: peek overview closing on escape key #7259 2025-06-25 19:09:54 +05:30
Prateek Shourya 40c0922726 [WEB-4387] fix: global layout when filter is set to list (#7264) 2025-06-24 20:25:26 +05:30
Aaryan Khandelwal dee8f00a71 [WEB-4384] fix: power k page search redirection #7263 2025-06-24 20:24:35 +05:30
Aaryan Khandelwal 072f2e2cac [WEB-4363] regression: analytics tabs improvements #7260 2025-06-24 16:08:30 +05:30
Prateek Shourya 79c2dfd293 [WEB-4375] fix: minor issues in timeline layout (#7257) 2025-06-24 14:19:00 +05:30
JayashTripathy 22a9d48ca3 [WEB-4363]: Analytics tab improvements #7248
fix: padding in tabs
2025-06-24 14:17:40 +05:30
Vipin Chaudhary fbcc8fc8a0 [WIKI-421] fix: Toolbar not reflecting strikethrough state (#7255)
* fix: strick through

* fix: bubble menu options types

---------

Co-authored-by: vipin chaudhary <vipinchaudhary@vipins-MacBook-Pro.local>
2025-06-24 14:16:07 +05:30
Aaryan Khandelwal c1fa372c84 [WIKI-471] refactor: custom image extension (#7247)
* refactor: custom image extension

* refactor: extension config

* revert: image full screen component

* fix: undo operation
2025-06-24 14:05:11 +05:30
Prateek Shourya 7045a1f2af [WEB-4361] fix: add onChange to collaborative editor #7246 2025-06-20 17:24:49 +05:30
Prateek Shourya f26b4d3d06 [WEB-4359] fix: application crash when creating work item via quick add (#7245) 2025-06-20 15:16:16 +05:30
Prateek Shourya c3c1aef7a9 [WEB-4357] fix: remove trailing slash from asset url #7240 2025-06-19 19:09:59 +05:30
Vipin Chaudhary 24e57009af [WIKI-465] fix : Add new node on click of doc end (#7063)
* fix : handle last node

* fix : handle unexpected node

* remove logs

* feat: handle focus

---------

Co-authored-by: M. Palanikannan <73993394+Palanikannan1437@users.noreply.github.com>
2025-06-19 17:17:56 +05:30
Anmol Singh Bhatia 2b7a17b484 [WEB-4050] feat: breadcrumbs revamp (#7188)
* chore: project feature enum added

* feat: revamp breadcrumb and add navigation dropdown component

* chore: custom search select component refactoring

* chore: breadcrumb stories added

* chore: switch label and breadcrumb link component refactor

* chore: project navigation helper function added

* chore: common breadcrumb component added

* chore: breadcrumb refactoring

* chore: code refactor

* chore: code refactor

* fix: build error

* fix: nprogress and button tooltip

* chore: code refactor

* chore: workspace view breadcrumb improvements

* chore: code refactor

* chore: code refactor

* chore: code refactor

* chore: code refactor

---------

Co-authored-by: vamsikrishnamathala <matalav55@gmail.com>
2025-06-19 17:17:14 +05:30
Vamsi Krishna 64fd0b2830 [WEB-4321]chore: workspace views refactor (#7214)
* chore: workspace views reafactor

* chore: resolved coderabbit suggestions

* chore: added project level workspace filter

* chore: added enum for roles

* chore: removed redundant type definition

* chore: optimised the query

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
2025-06-19 16:26:32 +05:30
Aaryan Khandelwal 8988cf9a85 [WEB-462] refactor: editor props structure (#7233)
* refactor: editor props structure

* chore: add missing prop

* fix: space app build

* chore: export ce types
2025-06-19 16:25:52 +05:30
Aaryan Khandelwal eb5ffebcc6 [WIKI-458] refactor: base page instance for additional properties (#7228)
* refactor: create a super class for base page

* fix: path

---------

Co-authored-by: Palanikannan M <akashmalinimurugu@gmail.com>
2025-06-19 16:00:18 +05:30
M. Palanikannan 414010688d [WIKI-384] chore: editor core refactor (#7235)
* fix: extra actions

* chore: page flags
2025-06-19 15:59:38 +05:30
JayashTripathy 171099667e [WEB-4339] fix: projects dropdown shows all projects (#7238)
* fix: projects drop only shows joined project

* refactor: removed unused things from header
2025-06-19 15:57:19 +05:30
Akshita Goyal d65f0e264e [WEB-4327] Chore PAT permissions (#7224)
* chore: improved pat permissions

* fix: err message

* fix: removed permission from backend

* [WEB-4330] refactor: update API token endpoints to use user context instead of workspace slug

- Changed URL patterns for API token endpoints to use "users/api-tokens/" instead of "workspaces/<str:slug>/api-tokens/".
- Refactored ApiTokenEndpoint methods to remove workspace slug parameter and adjust database queries accordingly.
- Added new test cases for API token creation, retrieval, deletion, and updates, including support for bot users and minimal data submissions.

* fix: removed workspace slug from api-tokens

* fix: refactor

* chore: url.py code rabbit suggestion

* fix: APITokenService moved to package

---------

Co-authored-by: Dheeraj Kumar Ketireddy <dheeru0198@gmail.com>
Co-authored-by: sriramveeraghanta <veeraghanta.sriram@gmail.com>
2025-06-18 16:08:11 +05:30
Akshita Goyal c7d17d00b7 [WEB-4017] fix: hooks and store refactoring for issue-details (#7107)
* fix: hooks and store splitting for issue-details

* fix: refactoring

* fix: refactoring

* fix: refactor

* fix: css
2025-06-18 15:59:26 +05:30
JayashTripathy 9cdfb2224a [WEB-4160]: Context menu close after clicking on menu item of project #7231 2025-06-18 15:33:06 +05:30
Sangeetha 8129f5f969 [WEB-4340] fix: duplicate assignees in user recents (#7216)
* fix: duplicate assignees in user recents

* chore: optimize filtering logic

* chore: filter with deleted_at field

* chore: tests for IssueRecentSerializer
2025-06-18 15:14:21 +05:30
Prateek Shourya 89b8cdbe6e [WEB-4335] improvement: optimize assignee grouping with improved member scope handling (#7227) 2025-06-17 17:17:04 +05:30
Prateek Shourya 53e6a62a12 fix: move lucide related constants to ui package (#7226)
* fix: move lucide related constants to ui package

* chore: update yarn.lock
2025-06-17 17:06:05 +05:30
Prateek Shourya 75f89c4c12 fix: docker build (#7220)
* fix: docker build

* fix: build
2025-06-17 14:08:50 +05:30
Anmol Singh Bhatia 0983e5f44d [WEB-4281] chore: project error message updated (#7190)
* chore: project error message updated

* fix: error message for project creation

* fix: incorrect error code

* chore: code refactor

* chore: code refactor

---------

Co-authored-by: sangeethailango <sangeethailango21@gmail.com>
2025-06-16 17:19:44 +05:30
Prateek Shourya 2014400bed refactor: move web utils to packages (#7145)
* refactor: move web utils to packages

* fix: build and lint errors

* chore: update drag handle plugin

* chore: update table cell type to fix build errors

* fix: build errors

* chore: sync few changes

* fix: build errors

* chore: minor fixes related to duplicate assets imports

* fix: build errors

* chore: minor changes
2025-06-16 17:18:41 +05:30
sriram veeraghanta dffcc6dc10 chore(deps): brace-expansion upgraded to 2.0.2 2025-06-16 17:10:08 +05:30
sriram veeraghanta 640b23fb1b chore(deps): nextjs upgrade to 14.2.30 2025-06-16 17:02:04 +05:30
JayashTripathy e13d8aa4b3 [WEB-4231] Pie chart tooltip #7192 2025-06-16 14:03:07 +05:30
Prateek Shourya cf595de7c7 [WEB-4311] fix: membership data handling and state reversal on error (#7205) 2025-06-16 14:02:47 +05:30
JayashTripathy 0fa9c8b015 [WEB-4323] refactor: Analytics refactor (#7213)
* chore: updated label for epics

* chore: improved export logic

* refactor: move csvConfig to export.ts and clean up export logic

* refactor: remove unused CSV export logic from WorkItemsInsightTable component

* refactor: streamline data handling in InsightTable component for improved rendering

* feat: add translation for "No. of {entity}" and update priority chart y-axis label to use new translation

* refactor: cleaned up some component and added utilitites

* feat: add "at_risk" translation to multiple languages in translations.json files

* refactor: update TrendPiece component to use new status variants for analytics

* fix: adjust TrendPiece component logic for on-track and off-track status

* refactor: use nullish coalescing operator for yAxis.dx in line and scatter charts

* feat: add "at_risk" translation to various languages in translations.json files

* feat: add "no_of" translation to various languages in translations.json files

* feat: update "at_risk" translation in Ukrainian, Vietnamese, and Chinese locales in translations.json files

* refactor: rename insightsFields to ANALYTICS_INSIGHTS_FIELDS and update analytics tab import to use getAnalyticsTabs function

* feat: update AnalyticsWrapper to use i18n for titles and add new translation for "no_of" in Russian

* fix: update yAxis labels and offsets in various charts to use new translation key and improve layout

* feat: define AnalyticsTab interface and refactor getAnalyticsTabs function for improved type safety

* fix: update AnalyticsTab interface to use TAnalyticsTabsBase for improved type safety

* fix: add whitespace-nowrap class to TableHead for improved header layout in DataTable component
2025-06-16 14:01:49 +05:30
Aaryan Khandelwal 6fe0415d66 [WEB-4316] chore: new endpoints to download an asset (#7207)
* chore: new endpoints to download an asset

* chore: add exception handling
2025-06-13 14:41:08 +05:30
1064 changed files with 10365 additions and 10385 deletions
+4 -6
View File
@@ -67,9 +67,8 @@ export const InstanceHeader: FC = observer(() => {
{breadcrumbItems.length >= 0 && (
<div>
<Breadcrumbs>
<Breadcrumbs.BreadcrumbItem
type="text"
link={
<Breadcrumbs.Item
component={
<BreadcrumbLink
href="/general/"
label="Settings"
@@ -80,10 +79,9 @@ export const InstanceHeader: FC = observer(() => {
{breadcrumbItems.map(
(item) =>
item.title && (
<Breadcrumbs.BreadcrumbItem
<Breadcrumbs.Item
key={item.title}
type="text"
link={<BreadcrumbLink href={item.href} label={item.title} />}
component={<BreadcrumbLink href={item.href} label={item.title} />}
/>
)
)}
@@ -1,11 +1,11 @@
import { FC } from "react";
import { Info, X } from "lucide-react";
// plane constants
import { TAuthErrorInfo } from "@plane/constants";
import { TAdminAuthErrorInfo } from "@plane/constants";
type TAuthBanner = {
bannerData: TAuthErrorInfo | undefined;
handleBannerData?: (bannerData: TAuthErrorInfo | undefined) => void;
bannerData: TAdminAuthErrorInfo | undefined;
handleBannerData?: (bannerData: TAdminAuthErrorInfo | undefined) => void;
};
export const AuthBanner: FC<TAuthBanner> = (props) => {
+2 -2
View File
@@ -4,7 +4,7 @@ import { FC, useEffect, useMemo, useState } from "react";
import { useSearchParams } from "next/navigation";
import { Eye, EyeOff } from "lucide-react";
// plane internal packages
import { API_BASE_URL, EAdminAuthErrorCodes, TAuthErrorInfo } from "@plane/constants";
import { API_BASE_URL, EAdminAuthErrorCodes, TAdminAuthErrorInfo } from "@plane/constants";
import { AuthService } from "@plane/services";
import { Button, Input, Spinner } from "@plane/ui";
// components
@@ -54,7 +54,7 @@ export const InstanceSignInForm: FC = (props) => {
const [csrfToken, setCsrfToken] = useState<string | undefined>(undefined);
const [formData, setFormData] = useState<TFormData>(defaultFromData);
const [isSubmitting, setIsSubmitting] = useState(false);
const [errorInfo, setErrorInfo] = useState<TAuthErrorInfo | undefined>(undefined);
const [errorInfo, setErrorInfo] = useState<TAdminAuthErrorInfo | undefined>(undefined);
const handleFormChange = (key: keyof TFormData, value: string | boolean) =>
setFormData((prev) => ({ ...prev, [key]: value }));
+2 -2
View File
@@ -3,7 +3,7 @@ import Image from "next/image";
import Link from "next/link";
import { KeyRound, Mails } from "lucide-react";
// plane packages
import { SUPPORT_EMAIL, EAdminAuthErrorCodes, TAuthErrorInfo } from "@plane/constants";
import { SUPPORT_EMAIL, EAdminAuthErrorCodes, TAdminAuthErrorInfo } from "@plane/constants";
import { TGetBaseAuthenticationModeProps, TInstanceAuthenticationModes } from "@plane/types";
import { resolveGeneralTheme } from "@plane/utils";
// components
@@ -89,7 +89,7 @@ const errorCodeMessages: {
export const authErrorHandler = (
errorCode: EAdminAuthErrorCodes,
email?: string | undefined
): TAuthErrorInfo | undefined => {
): TAdminAuthErrorInfo | undefined => {
const bannerAlertErrorCodes = [
EAdminAuthErrorCodes.ADMIN_ALREADY_EXIST,
EAdminAuthErrorCodes.REQUIRED_ADMIN_EMAIL_PASSWORD_FIRST_NAME,
+15 -13
View File
@@ -9,19 +9,21 @@ const nextConfig = {
unoptimized: true,
},
basePath: process.env.NEXT_PUBLIC_ADMIN_BASE_PATH || "",
transpilePackages: [
"@plane/constants",
"@plane/editor",
"@plane/hooks",
"@plane/i18n",
"@plane/logger",
"@plane/propel",
"@plane/services",
"@plane/shared-state",
"@plane/types",
"@plane/ui",
"@plane/utils",
],
experimental: {
optimizePackageImports: [
"@plane/constants",
"@plane/editor",
"@plane/hooks",
"@plane/i18n",
"@plane/logger",
"@plane/propel",
"@plane/services",
"@plane/shared-state",
"@plane/types",
"@plane/ui",
"@plane/utils",
],
},
};
module.exports = nextConfig;
+3 -3
View File
@@ -1,7 +1,7 @@
{
"name": "admin",
"description": "Admin UI for Plane",
"version": "0.26.1",
"version": "0.27.0",
"license": "AGPL-3.0",
"private": true,
"scripts": {
@@ -31,9 +31,9 @@
"lucide-react": "^0.469.0",
"mobx": "^6.12.0",
"mobx-react": "^9.1.1",
"next": "^14.2.29",
"next": "14.2.30",
"next-themes": "^0.2.1",
"postcss": "^8.4.38",
"postcss": "^8.4.49",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hook-form": "7.51.5",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "plane-api",
"version": "0.26.1",
"version": "0.27.0",
"license": "AGPL-3.0",
"private": true,
"description": "API server powering Plane's backend"
+2 -1
View File
@@ -39,7 +39,7 @@ from .project import (
ProjectMemberRoleSerializer,
)
from .state import StateSerializer, StateLiteSerializer
from .view import IssueViewSerializer
from .view import IssueViewSerializer, ViewIssueListSerializer
from .cycle import (
CycleSerializer,
CycleIssueSerializer,
@@ -74,6 +74,7 @@ from .issue import (
IssueLinkLiteSerializer,
IssueVersionDetailSerializer,
IssueDescriptionVersionDetailSerializer,
IssueListDetailSerializer,
)
from .module import (
+104
View File
@@ -725,6 +725,110 @@ class IssueSerializer(DynamicBaseSerializer):
read_only_fields = fields
class IssueListDetailSerializer(serializers.Serializer):
def __init__(self, *args, **kwargs):
# Extract expand parameter and store it as instance variable
self.expand = kwargs.pop("expand", []) or []
# Extract fields parameter and store it as instance variable
self.fields = kwargs.pop("fields", []) or []
super().__init__(*args, **kwargs)
def get_module_ids(self, obj):
return [module.module_id for module in obj.issue_module.all()]
def get_label_ids(self, obj):
return [label.label_id for label in obj.label_issue.all()]
def get_assignee_ids(self, obj):
return [assignee.assignee_id for assignee in obj.issue_assignee.all()]
def to_representation(self, instance):
data = {
# Basic fields
"id": instance.id,
"name": instance.name,
"state_id": instance.state_id,
"sort_order": instance.sort_order,
"completed_at": instance.completed_at,
"estimate_point": instance.estimate_point_id,
"priority": instance.priority,
"start_date": instance.start_date,
"target_date": instance.target_date,
"sequence_id": instance.sequence_id,
"project_id": instance.project_id,
"parent_id": instance.parent_id,
"created_at": instance.created_at,
"updated_at": instance.updated_at,
"created_by": instance.created_by_id,
"updated_by": instance.updated_by_id,
"is_draft": instance.is_draft,
"archived_at": instance.archived_at,
# Computed fields
"cycle_id": instance.cycle_id,
"module_ids": self.get_module_ids(instance),
"label_ids": self.get_label_ids(instance),
"assignee_ids": self.get_assignee_ids(instance),
"sub_issues_count": instance.sub_issues_count,
"attachment_count": instance.attachment_count,
"link_count": instance.link_count,
}
# Handle expanded fields only when requested - using direct field access
if self.expand:
if "issue_relation" in self.expand:
relations = []
for relation in instance.issue_relation.all():
related_issue = relation.related_issue
# If the related issue is deleted, skip it
if not related_issue:
continue
# Add the related issue to the relations list
relations.append(
{
"id": related_issue.id,
"project_id": related_issue.project_id,
"sequence_id": related_issue.sequence_id,
"name": related_issue.name,
"relation_type": relation.relation_type,
"state_id": related_issue.state_id,
"priority": related_issue.priority,
"created_by": related_issue.created_by_id,
"created_at": related_issue.created_at,
"updated_at": related_issue.updated_at,
"updated_by": related_issue.updated_by_id,
}
)
data["issue_relation"] = relations
if "issue_related" in self.expand:
related = []
for relation in instance.issue_related.all():
issue = relation.issue
# If the related issue is deleted, skip it
if not issue:
continue
# Add the related issue to the related list
related.append(
{
"id": issue.id,
"project_id": issue.project_id,
"sequence_id": issue.sequence_id,
"name": issue.name,
"relation_type": relation.relation_type,
"state_id": issue.state_id,
"priority": issue.priority,
"created_by": issue.created_by_id,
"created_at": issue.created_at,
"updated_at": issue.updated_at,
"updated_by": issue.updated_by_id,
}
)
data["issue_related"] = related
return data
class IssueLiteSerializer(DynamicBaseSerializer):
class Meta:
model = Issue
+43
View File
@@ -7,6 +7,49 @@ from plane.db.models import IssueView
from plane.utils.issue_filters import issue_filters
class ViewIssueListSerializer(serializers.Serializer):
def get_assignee_ids(self, instance):
return [assignee.assignee_id for assignee in instance.issue_assignee.all()]
def get_label_ids(self, instance):
return [label.label_id for label in instance.label_issue.all()]
def get_module_ids(self, instance):
return [module.module_id for module in instance.issue_module.all()]
def to_representation(self, instance):
data = {
"id": instance.id,
"name": instance.name,
"state_id": instance.state_id,
"sort_order": instance.sort_order,
"completed_at": instance.completed_at,
"estimate_point": instance.estimate_point_id,
"priority": instance.priority,
"start_date": instance.start_date,
"target_date": instance.target_date,
"sequence_id": instance.sequence_id,
"project_id": instance.project_id,
"parent_id": instance.parent_id,
"cycle_id": instance.cycle_id,
"sub_issues_count": instance.sub_issues_count,
"created_at": instance.created_at,
"updated_at": instance.updated_at,
"created_by": instance.created_by_id,
"updated_by": instance.updated_by_id,
"attachment_count": instance.attachment_count,
"link_count": instance.link_count,
"is_draft": instance.is_draft,
"archived_at": instance.archived_at,
"state__group": instance.state.group if instance.state else None,
"assignee_ids": self.get_assignee_ids(instance),
"label_ids": self.get_label_ids(instance),
"module_ids": self.get_module_ids(instance),
}
return data
class IssueViewSerializer(DynamicBaseSerializer):
is_favorite = serializers.BooleanField(read_only=True)
+8 -3
View File
@@ -1,7 +1,5 @@
# Third party imports
from rest_framework import serializers
from rest_framework import status
from rest_framework.response import Response
# Module imports
from .base import BaseSerializer, DynamicBaseSerializer
@@ -198,6 +196,7 @@ class WorkspaceUserLinkSerializer(BaseSerializer):
class IssueRecentVisitSerializer(serializers.ModelSerializer):
project_identifier = serializers.SerializerMethodField()
assignees = serializers.SerializerMethodField()
class Meta:
model = Issue
@@ -215,9 +214,15 @@ class IssueRecentVisitSerializer(serializers.ModelSerializer):
def get_project_identifier(self, obj):
project = obj.project
return project.identifier if project else None
def get_assignees(self, obj):
return list(
obj.assignees.filter(issue_assignee__deleted_at__isnull=True).values_list(
"id", flat=True
)
)
class ProjectRecentVisitSerializer(serializers.ModelSerializer):
project_members = serializers.SerializerMethodField()
+3 -3
View File
@@ -4,14 +4,14 @@ from plane.app.views import ApiTokenEndpoint, ServiceApiTokenEndpoint
urlpatterns = [
# API Tokens
path(
"workspaces/<str:slug>/api-tokens/",
"users/api-tokens/",
ApiTokenEndpoint.as_view(),
name="api-tokens",
),
path(
"workspaces/<str:slug>/api-tokens/<uuid:pk>/",
"users/api-tokens/<uuid:pk>/",
ApiTokenEndpoint.as_view(),
name="api-tokens",
name="api-tokens-details",
),
path(
"workspaces/<str:slug>/service-api-tokens/",
+12
View File
@@ -13,6 +13,8 @@ from plane.app.views import (
ProjectAssetEndpoint,
ProjectBulkAssetEndpoint,
AssetCheckEndpoint,
WorkspaceAssetDownloadEndpoint,
ProjectAssetDownloadEndpoint,
)
@@ -89,4 +91,14 @@ urlpatterns = [
AssetCheckEndpoint.as_view(),
name="asset-check",
),
path(
"assets/v2/workspaces/<str:slug>/download/<uuid:asset_id>/",
WorkspaceAssetDownloadEndpoint.as_view(),
name="workspace-asset-download",
),
path(
"assets/v2/workspaces/<str:slug>/projects/<uuid:project_id>/download/<uuid:asset_id>/",
ProjectAssetDownloadEndpoint.as_view(),
name="project-asset-download",
),
]
+2
View File
@@ -107,6 +107,8 @@ from .asset.v2 import (
ProjectAssetEndpoint,
ProjectBulkAssetEndpoint,
AssetCheckEndpoint,
WorkspaceAssetDownloadEndpoint,
ProjectAssetDownloadEndpoint,
)
from .issue.base import (
IssueListEndpoint,
+11 -19
View File
@@ -1,8 +1,10 @@
# Python import
from uuid import uuid4
from typing import Optional
# Third party
from rest_framework.response import Response
from rest_framework.request import Request
from rest_framework import status
# Module import
@@ -13,12 +15,9 @@ from plane.app.permissions import WorkspaceEntityPermission
class ApiTokenEndpoint(BaseAPIView):
permission_classes = [WorkspaceEntityPermission]
def post(self, request, slug):
def post(self, request: Request) -> Response:
label = request.data.get("label", str(uuid4().hex))
description = request.data.get("description", "")
workspace = Workspace.objects.get(slug=slug)
expired_at = request.data.get("expired_at", None)
# Check the user type
@@ -28,7 +27,6 @@ class ApiTokenEndpoint(BaseAPIView):
label=label,
description=description,
user=request.user,
workspace=workspace,
user_type=user_type,
expired_at=expired_at,
)
@@ -37,29 +35,23 @@ class ApiTokenEndpoint(BaseAPIView):
# Token will be only visible while creating
return Response(serializer.data, status=status.HTTP_201_CREATED)
def get(self, request, slug, pk=None):
def get(self, request: Request, pk: Optional[str] = None) -> Response:
if pk is None:
api_tokens = APIToken.objects.filter(
user=request.user, workspace__slug=slug, is_service=False
)
api_tokens = APIToken.objects.filter(user=request.user, is_service=False)
serializer = APITokenReadSerializer(api_tokens, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
else:
api_tokens = APIToken.objects.get(
user=request.user, workspace__slug=slug, pk=pk
)
api_tokens = APIToken.objects.get(user=request.user, pk=pk)
serializer = APITokenReadSerializer(api_tokens)
return Response(serializer.data, status=status.HTTP_200_OK)
def delete(self, request, slug, pk):
api_token = APIToken.objects.get(
workspace__slug=slug, user=request.user, pk=pk, is_service=False
)
def delete(self, request: Request, pk: str) -> Response:
api_token = APIToken.objects.get(user=request.user, pk=pk, is_service=False)
api_token.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
def patch(self, request, slug, pk):
api_token = APIToken.objects.get(workspace__slug=slug, user=request.user, pk=pk)
def patch(self, request: Request, pk: str) -> Response:
api_token = APIToken.objects.get(user=request.user, pk=pk)
serializer = APITokenSerializer(api_token, data=request.data, partial=True)
if serializer.is_valid():
serializer.save()
@@ -70,7 +62,7 @@ class ApiTokenEndpoint(BaseAPIView):
class ServiceApiTokenEndpoint(BaseAPIView):
permission_classes = [WorkspaceEntityPermission]
def post(self, request, slug):
def post(self, request: Request, slug: str) -> Response:
workspace = Workspace.objects.get(slug=slug)
api_token = APIToken.objects.filter(
+53
View File
@@ -718,3 +718,56 @@ class AssetCheckEndpoint(BaseAPIView):
id=asset_id, workspace__slug=slug, deleted_at__isnull=True
).exists()
return Response({"exists": asset}, status=status.HTTP_200_OK)
class WorkspaceAssetDownloadEndpoint(BaseAPIView):
"""Endpoint to generate a download link for an asset with content-disposition=attachment."""
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")
def get(self, request, slug, asset_id):
try:
asset = FileAsset.objects.get(
id=asset_id,
workspace__slug=slug,
is_uploaded=True,
)
except FileAsset.DoesNotExist:
return Response(
{"error": "The requested asset could not be found."},
status=status.HTTP_404_NOT_FOUND,
)
storage = S3Storage(request=request)
signed_url = storage.generate_presigned_url(
object_name=asset.asset.name,
disposition=f"attachment; filename={asset.asset.name}",
)
return HttpResponseRedirect(signed_url)
class ProjectAssetDownloadEndpoint(BaseAPIView):
"""Endpoint to generate a download link for an asset with content-disposition=attachment."""
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="PROJECT")
def get(self, request, slug, project_id, asset_id):
try:
asset = FileAsset.objects.get(
id=asset_id,
workspace__slug=slug,
project_id=project_id,
is_uploaded=True,
)
except FileAsset.DoesNotExist:
return Response(
{"error": "The requested asset could not be found."},
status=status.HTTP_404_NOT_FOUND,
)
storage = S3Storage(request=request)
signed_url = storage.generate_presigned_url(
object_name=asset.asset.name,
disposition=f"attachment; filename={asset.asset.name}",
)
return HttpResponseRedirect(signed_url)
+72 -40
View File
@@ -32,6 +32,7 @@ from plane.app.serializers import (
IssueDetailSerializer,
IssueUserPropertySerializer,
IssueSerializer,
IssueListDetailSerializer,
)
from plane.bgtasks.issue_activities_task import issue_activity
from plane.db.models import (
@@ -46,6 +47,9 @@ from plane.db.models import (
CycleIssue,
UserRecentVisit,
ModuleIssue,
IssueRelation,
IssueAssignee,
IssueLabel,
)
from plane.utils.grouper import (
issue_group_values,
@@ -944,10 +948,57 @@ class IssueDetailEndpoint(BaseAPIView):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def get(self, request, slug, project_id):
filters = issue_filters(request.query_params, "GET")
# check for the project member role, if the role is 5 then check for the guest_view_all_features
# if it is true then show all the issues else show only the issues created by the user
permission_subquery = (
Issue.issue_objects.filter(
workspace__slug=slug, project_id=project_id, id=OuterRef("id")
)
.filter(
Q(
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
project__project_projectmember__role__gt=ROLE.GUEST.value,
)
| Q(
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
project__project_projectmember__role=ROLE.GUEST.value,
project__guest_view_all_features=True,
)
| Q(
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
project__project_projectmember__role=ROLE.GUEST.value,
project__guest_view_all_features=False,
created_by=self.request.user,
)
)
.values("id")
)
# Main issue query
issue = (
Issue.issue_objects.filter(workspace__slug=slug, project_id=project_id)
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module")
.filter(Exists(permission_subquery))
.prefetch_related(
Prefetch(
"issue_assignee",
queryset=IssueAssignee.objects.all(),
)
)
.prefetch_related(
Prefetch(
"label_issue",
queryset=IssueLabel.objects.all(),
)
)
.prefetch_related(
Prefetch(
"issue_module",
queryset=ModuleIssue.objects.all(),
)
)
.annotate(
cycle_id=Subquery(
CycleIssue.objects.filter(
@@ -955,43 +1006,6 @@ class IssueDetailEndpoint(BaseAPIView):
).values("cycle_id")[:1]
)
)
.annotate(
label_ids=Coalesce(
ArrayAgg(
"labels__id",
distinct=True,
filter=Q(
~Q(labels__id__isnull=True)
& Q(label_issue__deleted_at__isnull=True)
),
),
Value([], output_field=ArrayField(UUIDField())),
),
assignee_ids=Coalesce(
ArrayAgg(
"assignees__id",
distinct=True,
filter=Q(
~Q(assignees__id__isnull=True)
& Q(assignees__member_project__is_active=True)
& Q(issue_assignee__deleted_at__isnull=True)
),
),
Value([], output_field=ArrayField(UUIDField())),
),
module_ids=Coalesce(
ArrayAgg(
"issue_module__module_id",
distinct=True,
filter=Q(
~Q(issue_module__module_id__isnull=True)
& Q(issue_module__module__archived_at__isnull=True)
& Q(issue_module__deleted_at__isnull=True)
),
),
Value([], output_field=ArrayField(UUIDField())),
),
)
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
@@ -1014,6 +1028,24 @@ class IssueDetailEndpoint(BaseAPIView):
.values("count")
)
)
# Add additional prefetch based on expand parameter
if self.expand:
if "issue_relation" in self.expand:
issue = issue.prefetch_related(
Prefetch(
"issue_relation",
queryset=IssueRelation.objects.select_related("related_issue"),
)
)
if "issue_related" in self.expand:
issue = issue.prefetch_related(
Prefetch(
"issue_related",
queryset=IssueRelation.objects.select_related("issue"),
)
)
issue = issue.filter(**filters)
order_by_param = request.GET.get("order_by", "-created_at")
# Issue queryset
@@ -1024,7 +1056,7 @@ class IssueDetailEndpoint(BaseAPIView):
request=request,
order_by=order_by_param,
queryset=(issue),
on_results=lambda issue: IssueSerializer(
on_results=lambda issue: IssueListDetailSerializer(
issue, many=True, fields=self.fields, expand=self.expand
).data,
)
+8 -2
View File
@@ -341,7 +341,10 @@ class ProjectViewSet(BaseViewSet):
except IntegrityError as e:
if "already exists" in str(e):
return Response(
{"name": "The project name is already taken"},
{
"name": "The project name is already taken",
"code": "PROJECT_NAME_ALREADY_EXIST",
},
status=status.HTTP_409_CONFLICT,
)
except Workspace.DoesNotExist:
@@ -350,7 +353,10 @@ class ProjectViewSet(BaseViewSet):
)
except serializers.ValidationError:
return Response(
{"identifier": "The project identifier is already taken"},
{
"identifier": "The project identifier is already taken",
"code": "PROJECT_IDENTIFIER_ALREADY_EXIST",
},
status=status.HTTP_409_CONFLICT,
)
+73 -163
View File
@@ -1,8 +1,13 @@
# Django imports
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
from django.db.models import Exists, F, Func, OuterRef, Q, UUIDField, Value, Subquery
from django.db.models.functions import Coalesce
from django.db.models import (
Exists,
F,
Func,
OuterRef,
Q,
Subquery,
Prefetch,
)
from django.utils.decorators import method_decorator
from django.views.decorators.gzip import gzip_page
from django.db import transaction
@@ -13,7 +18,7 @@ from rest_framework.response import Response
# Module imports
from plane.app.permissions import allow_permission, ROLE
from plane.app.serializers import IssueViewSerializer
from plane.app.serializers import IssueViewSerializer, ViewIssueListSerializer
from plane.db.models import (
Issue,
FileAsset,
@@ -25,15 +30,12 @@ from plane.db.models import (
Project,
CycleIssue,
UserRecentVisit,
)
from plane.utils.grouper import (
issue_group_values,
issue_on_results,
issue_queryset_grouper,
IssueAssignee,
IssueLabel,
ModuleIssue,
)
from plane.utils.issue_filters import issue_filters
from plane.utils.order_queryset import order_issue_queryset
from plane.utils.paginator import GroupedOffsetPaginator, SubGroupedOffsetPaginator
from plane.bgtasks.recent_visited_task import recent_visited_task
from .. import BaseViewSet
from plane.db.models import UserFavorite
@@ -143,6 +145,28 @@ class WorkspaceViewViewSet(BaseViewSet):
class WorkspaceViewIssuesViewSet(BaseViewSet):
def _get_project_permission_filters(self):
"""
Get common project permission filters for guest users and role-based access control.
Returns Q object for filtering issues based on user role and project settings.
"""
return Q(
Q(
project__project_projectmember__role=5,
project__guest_view_all_features=True,
)
| Q(
project__project_projectmember__role=5,
project__guest_view_all_features=False,
created_by=self.request.user,
)
|
# For other roles (role > 5), show all issues
Q(project__project_projectmember__role__gt=5),
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
)
def get_queryset(self):
return (
Issue.issue_objects.annotate(
@@ -152,12 +176,25 @@ class WorkspaceViewIssuesViewSet(BaseViewSet):
.values("count")
)
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
.select_related("state")
.prefetch_related(
Prefetch(
"issue_assignee",
queryset=IssueAssignee.objects.all(),
)
)
.prefetch_related(
Prefetch(
"label_issue",
queryset=IssueLabel.objects.all(),
)
)
.prefetch_related(
Prefetch(
"issue_module",
queryset=ModuleIssue.objects.all(),
)
)
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module")
.annotate(
cycle_id=Subquery(
CycleIssue.objects.filter(
@@ -186,43 +223,6 @@ class WorkspaceViewIssuesViewSet(BaseViewSet):
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
label_ids=Coalesce(
ArrayAgg(
"labels__id",
distinct=True,
filter=Q(
~Q(labels__id__isnull=True)
& Q(label_issue__deleted_at__isnull=True)
),
),
Value([], output_field=ArrayField(UUIDField())),
),
assignee_ids=Coalesce(
ArrayAgg(
"assignees__id",
distinct=True,
filter=Q(
~Q(assignees__id__isnull=True)
& Q(assignees__member_project__is_active=True)
& Q(issue_assignee__deleted_at__isnull=True)
),
),
Value([], output_field=ArrayField(UUIDField())),
),
module_ids=Coalesce(
ArrayAgg(
"issue_module__module_id",
distinct=True,
filter=Q(
~Q(issue_module__module_id__isnull=True)
& Q(issue_module__module__archived_at__isnull=True)
& Q(issue_module__deleted_at__isnull=True)
),
),
Value([], output_field=ArrayField(UUIDField())),
),
)
)
@method_decorator(gzip_page)
@@ -233,126 +233,36 @@ class WorkspaceViewIssuesViewSet(BaseViewSet):
filters = issue_filters(request.query_params, "GET")
order_by_param = request.GET.get("order_by", "-created_at")
issue_queryset = (
self.get_queryset()
.filter(**filters)
.annotate(
cycle_id=Subquery(
CycleIssue.objects.filter(
issue=OuterRef("id"), deleted_at__isnull=True
).values("cycle_id")[:1]
)
)
issue_queryset = self.get_queryset().filter(**filters)
# Get common project permission filters
permission_filters = self._get_project_permission_filters()
# Base query for the counts
total_issue_count = (
Issue.issue_objects.filter(**filters)
.filter(workspace__slug=slug)
.filter(permission_filters)
.only("id")
)
# check for the project member role, if the role is 5 then check for the guest_view_all_features if it is true then show all the issues else show only the issues created by the user
issue_queryset = issue_queryset.filter(
Q(
project__project_projectmember__role=5,
project__guest_view_all_features=True,
)
| Q(
project__project_projectmember__role=5,
project__guest_view_all_features=False,
created_by=self.request.user,
)
|
# For other roles (role < 5), show all issues
Q(project__project_projectmember__role__gt=5),
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
)
# Apply project permission filters to the issue queryset
issue_queryset = issue_queryset.filter(permission_filters)
# Issue queryset
issue_queryset, order_by_param = order_issue_queryset(
issue_queryset=issue_queryset, order_by_param=order_by_param
)
# Group by
group_by = request.GET.get("group_by", False)
sub_group_by = request.GET.get("sub_group_by", False)
# issue queryset
issue_queryset = issue_queryset_grouper(
queryset=issue_queryset, group_by=group_by, sub_group_by=sub_group_by
# List Paginate
return self.paginate(
order_by=order_by_param,
request=request,
queryset=issue_queryset,
on_results=lambda issues: ViewIssueListSerializer(issues, many=True).data,
total_count_queryset=total_issue_count,
)
if group_by:
# Check group and sub group value paginate
if sub_group_by:
if group_by == sub_group_by:
return Response(
{
"error": "Group by and sub group by cannot have same parameters"
},
status=status.HTTP_400_BAD_REQUEST,
)
else:
# group and sub group pagination
return self.paginate(
request=request,
order_by=order_by_param,
queryset=issue_queryset,
on_results=lambda issues: issue_on_results(
group_by=group_by, issues=issues, sub_group_by=sub_group_by
),
paginator_cls=SubGroupedOffsetPaginator,
group_by_fields=issue_group_values(
field=group_by, slug=slug, project_id=None, filters=filters
),
sub_group_by_fields=issue_group_values(
field=sub_group_by,
slug=slug,
project_id=None,
filters=filters,
),
group_by_field_name=group_by,
sub_group_by_field_name=sub_group_by,
count_filter=Q(
Q(issue_intake__status=1)
| Q(issue_intake__status=-1)
| Q(issue_intake__status=2)
| Q(issue_intake__isnull=True),
archived_at__isnull=True,
is_draft=False,
),
)
# Group Paginate
else:
# Group paginate
return self.paginate(
request=request,
order_by=order_by_param,
queryset=issue_queryset,
on_results=lambda issues: issue_on_results(
group_by=group_by, issues=issues, sub_group_by=sub_group_by
),
paginator_cls=GroupedOffsetPaginator,
group_by_fields=issue_group_values(
field=group_by, slug=slug, project_id=None, filters=filters
),
group_by_field_name=group_by,
count_filter=Q(
Q(issue_intake__status=1)
| Q(issue_intake__status=-1)
| Q(issue_intake__status=2)
| Q(issue_intake__isnull=True),
archived_at__isnull=True,
is_draft=False,
),
)
else:
# List Paginate
return self.paginate(
order_by=order_by_param,
request=request,
queryset=issue_queryset,
on_results=lambda issues: issue_on_results(
group_by=group_by, issues=issues, sub_group_by=sub_group_by
),
)
class IssueViewViewSet(BaseViewSet):
serializer_class = IssueViewSerializer
+10 -10
View File
@@ -271,15 +271,15 @@ class IssueBlocker(ProjectBaseModel):
return f"{self.block.name} {self.blocked_by.name}"
class IssueRelation(ProjectBaseModel):
RELATION_CHOICES = (
("duplicate", "Duplicate"),
("relates_to", "Relates To"),
("blocked_by", "Blocked By"),
("start_before", "Start Before"),
("finish_before", "Finish Before"),
)
class IssueRelationChoices(models.TextChoices):
DUPLICATE = "duplicate", "Duplicate"
RELATES_TO = "relates_to", "Relates To"
BLOCKED_BY = "blocked_by", "Blocked By"
START_BEFORE = "start_before", "Start Before"
FINISH_BEFORE = "finish_before", "Finish Before"
class IssueRelation(ProjectBaseModel):
issue = models.ForeignKey(
Issue, related_name="issue_relation", on_delete=models.CASCADE
)
@@ -288,9 +288,9 @@ class IssueRelation(ProjectBaseModel):
)
relation_type = models.CharField(
max_length=20,
choices=RELATION_CHOICES,
choices=IssueRelationChoices.choices,
verbose_name="Issue Relation Type",
default="blocked_by",
default=IssueRelationChoices.BLOCKED_BY,
)
class Meta:
+45 -3
View File
@@ -27,7 +27,7 @@ def user_data():
"email": "test@plane.so",
"password": "test-password",
"first_name": "Test",
"last_name": "User"
"last_name": "User",
}
@@ -37,7 +37,7 @@ def create_user(db, user_data):
user = User.objects.create(
email=user_data["email"],
first_name=user_data["first_name"],
last_name=user_data["last_name"]
last_name=user_data["last_name"],
)
user.set_password(user_data["password"])
user.save()
@@ -69,10 +69,52 @@ def session_client(api_client, create_user):
return api_client
@pytest.fixture
def create_bot_user(db):
"""Create and return a bot user instance"""
from uuid import uuid4
unique_id = uuid4().hex[:8]
user = User.objects.create(
email=f"bot-{unique_id}@plane.so",
username=f"bot_user_{unique_id}",
first_name="Bot",
last_name="User",
is_bot=True,
)
user.set_password("bot@123")
user.save()
return user
@pytest.fixture
def api_token_data():
"""Return sample API token data for testing"""
from django.utils import timezone
from datetime import timedelta
return {
"label": "Test API Token",
"description": "Test description for API token",
"expired_at": (timezone.now() + timedelta(days=30)).isoformat(),
}
@pytest.fixture
def create_api_token_for_user(db, create_user):
"""Create and return an API token for a specific user"""
return APIToken.objects.create(
label="Test Token",
description="Test token description",
user=create_user,
user_type=0,
)
@pytest.fixture
def plane_server(live_server):
"""
Renamed version of live_server fixture to avoid name clashes.
Returns a live Django server for testing HTTP requests.
"""
return live_server
return live_server
@@ -0,0 +1,372 @@
import pytest
from datetime import timedelta
from uuid import uuid4
from django.urls import reverse
from django.utils import timezone
from rest_framework import status
from plane.db.models import APIToken, User
@pytest.mark.contract
class TestApiTokenEndpoint:
"""Test cases for ApiTokenEndpoint"""
# POST /user/api-tokens/ tests
@pytest.mark.django_db
def test_create_api_token_success(
self, session_client, create_user, api_token_data
):
"""Test successful API token creation"""
# Arrange
session_client.force_authenticate(user=create_user)
url = reverse("api-tokens")
# Act
response = session_client.post(url, api_token_data, format="json")
# Assert
assert response.status_code == status.HTTP_201_CREATED
assert "token" in response.data
assert response.data["label"] == api_token_data["label"]
assert response.data["description"] == api_token_data["description"]
assert response.data["user_type"] == 0 # Human user
# Verify token was created in database
token = APIToken.objects.get(pk=response.data["id"])
assert token.user == create_user
assert token.label == api_token_data["label"]
@pytest.mark.django_db
def test_create_api_token_for_bot_user(
self, session_client, create_bot_user, api_token_data
):
"""Test API token creation for bot user"""
# Arrange
session_client.force_authenticate(user=create_bot_user)
url = reverse("api-tokens")
# Act
response = session_client.post(url, api_token_data, format="json")
# Assert
assert response.status_code == status.HTTP_201_CREATED
assert response.data["user_type"] == 1 # Bot user
@pytest.mark.django_db
def test_create_api_token_minimal_data(self, session_client, create_user):
"""Test API token creation with minimal data"""
# Arrange
session_client.force_authenticate(user=create_user)
url = reverse("api-tokens")
# Act
response = session_client.post(url, {}, format="json")
# Assert
assert response.status_code == status.HTTP_201_CREATED
assert "token" in response.data
assert len(response.data["label"]) == 32 # UUID hex length
assert response.data["description"] == ""
@pytest.mark.django_db
def test_create_api_token_with_expiry(self, session_client, create_user):
"""Test API token creation with expiry date"""
# Arrange
session_client.force_authenticate(user=create_user)
url = reverse("api-tokens")
future_date = timezone.now() + timedelta(days=30)
data = {"label": "Expiring Token", "expired_at": future_date.isoformat()}
# Act
response = session_client.post(url, data, format="json")
# Assert
assert response.status_code == status.HTTP_201_CREATED
# Verify expiry date was set
token = APIToken.objects.get(pk=response.data["id"])
assert token.expired_at is not None
@pytest.mark.django_db
def test_create_api_token_unauthenticated(self, api_client, api_token_data):
"""Test API token creation without authentication"""
# Arrange
url = reverse("api-tokens")
# Act
response = api_client.post(url, api_token_data, format="json")
# Assert
assert response.status_code == status.HTTP_401_UNAUTHORIZED
# GET /user/api-tokens/ tests
@pytest.mark.django_db
def test_get_all_api_tokens(self, session_client, create_user):
"""Test retrieving all API tokens for user"""
# Arrange
session_client.force_authenticate(user=create_user)
# Create multiple tokens
APIToken.objects.create(label="Token 1", user=create_user, user_type=0)
APIToken.objects.create(label="Token 2", user=create_user, user_type=0)
# Create a service token (should be excluded)
APIToken.objects.create(
label="Service Token", user=create_user, user_type=0, is_service=True
)
url = reverse("api-tokens")
# Act
response = session_client.get(url)
# Assert
assert response.status_code == status.HTTP_200_OK
assert len(response.data) == 2 # Only non-service tokens
assert all(token["is_service"] is False for token in response.data)
@pytest.mark.django_db
def test_get_empty_api_tokens_list(self, session_client, create_user):
"""Test retrieving API tokens when none exist"""
# Arrange
session_client.force_authenticate(user=create_user)
url = reverse("api-tokens")
# Act
response = session_client.get(url)
# Assert
assert response.status_code == status.HTTP_200_OK
assert response.data == []
# GET /user/api-tokens/<pk>/ tests
@pytest.mark.django_db
def test_get_specific_api_token(
self, session_client, create_user, create_api_token_for_user
):
"""Test retrieving a specific API token"""
# Arrange
session_client.force_authenticate(user=create_user)
url = reverse("api-tokens", kwargs={"pk": create_api_token_for_user.pk})
# Act
response = session_client.get(url)
# Assert
assert response.status_code == status.HTTP_200_OK
assert str(response.data["id"]) == str(create_api_token_for_user.pk)
assert response.data["label"] == create_api_token_for_user.label
assert (
"token" not in response.data
) # Token should not be visible in read serializer
@pytest.mark.django_db
def test_get_nonexistent_api_token(self, session_client, create_user):
"""Test retrieving a non-existent API token"""
# Arrange
session_client.force_authenticate(user=create_user)
fake_pk = uuid4()
url = reverse("api-tokens", kwargs={"pk": fake_pk})
# Act
response = session_client.get(url)
# Assert
assert response.status_code == status.HTTP_404_NOT_FOUND
@pytest.mark.django_db
def test_get_other_users_api_token(self, session_client, create_user, db):
"""Test retrieving another user's API token (should fail)"""
# Arrange
# Create another user and their token with unique email and username
unique_id = uuid4().hex[:8]
unique_email = f"other-{unique_id}@plane.so"
unique_username = f"other_user_{unique_id}"
other_user = User.objects.create(email=unique_email, username=unique_username)
other_token = APIToken.objects.create(
label="Other Token", user=other_user, user_type=0
)
session_client.force_authenticate(user=create_user)
url = reverse("api-tokens", kwargs={"pk": other_token.pk})
# Act
response = session_client.get(url)
# Assert
assert response.status_code == status.HTTP_404_NOT_FOUND
# DELETE /user/api-tokens/<pk>/ tests
@pytest.mark.django_db
def test_delete_api_token_success(
self, session_client, create_user, create_api_token_for_user
):
"""Test successful API token deletion"""
# Arrange
session_client.force_authenticate(user=create_user)
url = reverse("api-tokens", kwargs={"pk": create_api_token_for_user.pk})
# Act
response = session_client.delete(url)
# Assert
assert response.status_code == status.HTTP_204_NO_CONTENT
assert not APIToken.objects.filter(pk=create_api_token_for_user.pk).exists()
@pytest.mark.django_db
def test_delete_nonexistent_api_token(self, session_client, create_user):
"""Test deleting a non-existent API token"""
# Arrange
session_client.force_authenticate(user=create_user)
fake_pk = uuid4()
url = reverse("api-tokens", kwargs={"pk": fake_pk})
# Act
response = session_client.delete(url)
# Assert
assert response.status_code == status.HTTP_404_NOT_FOUND
@pytest.mark.django_db
def test_delete_other_users_api_token(self, session_client, create_user, db):
"""Test deleting another user's API token (should fail)"""
# Arrange
# Create another user and their token with unique email and username
unique_id = uuid4().hex[:8]
unique_email = f"delete-other-{unique_id}@plane.so"
unique_username = f"delete_other_user_{unique_id}"
other_user = User.objects.create(email=unique_email, username=unique_username)
other_token = APIToken.objects.create(
label="Other Token", user=other_user, user_type=0
)
session_client.force_authenticate(user=create_user)
url = reverse("api-tokens", kwargs={"pk": other_token.pk})
# Act
response = session_client.delete(url)
# Assert
assert response.status_code == status.HTTP_404_NOT_FOUND
# Verify token still exists
assert APIToken.objects.filter(pk=other_token.pk).exists()
@pytest.mark.django_db
def test_delete_service_api_token_forbidden(self, session_client, create_user):
"""Test deleting a service API token (should fail)"""
# Arrange
service_token = APIToken.objects.create(
label="Service Token", user=create_user, user_type=0, is_service=True
)
session_client.force_authenticate(user=create_user)
url = reverse("api-tokens", kwargs={"pk": service_token.pk})
# Act
response = session_client.delete(url)
# Assert
assert response.status_code == status.HTTP_404_NOT_FOUND
# Verify token still exists
assert APIToken.objects.filter(pk=service_token.pk).exists()
# PATCH /user/api-tokens/<pk>/ tests
@pytest.mark.django_db
def test_patch_api_token_success(
self, session_client, create_user, create_api_token_for_user
):
"""Test successful API token update"""
# Arrange
session_client.force_authenticate(user=create_user)
url = reverse("api-tokens", kwargs={"pk": create_api_token_for_user.pk})
update_data = {
"label": "Updated Token Label",
"description": "Updated description",
}
# Act
response = session_client.patch(url, update_data, format="json")
# Assert
assert response.status_code == status.HTTP_200_OK
assert response.data["label"] == update_data["label"]
assert response.data["description"] == update_data["description"]
# Verify database was updated
create_api_token_for_user.refresh_from_db()
assert create_api_token_for_user.label == update_data["label"]
assert create_api_token_for_user.description == update_data["description"]
@pytest.mark.django_db
def test_patch_api_token_partial_update(
self, session_client, create_user, create_api_token_for_user
):
"""Test partial API token update"""
# Arrange
session_client.force_authenticate(user=create_user)
url = reverse("api-tokens", kwargs={"pk": create_api_token_for_user.pk})
original_description = create_api_token_for_user.description
update_data = {"label": "Only Label Updated"}
# Act
response = session_client.patch(url, update_data, format="json")
# Assert
assert response.status_code == status.HTTP_200_OK
assert response.data["label"] == update_data["label"]
assert response.data["description"] == original_description
@pytest.mark.django_db
def test_patch_nonexistent_api_token(self, session_client, create_user):
"""Test updating a non-existent API token"""
# Arrange
session_client.force_authenticate(user=create_user)
fake_pk = uuid4()
url = reverse("api-tokens", kwargs={"pk": fake_pk})
update_data = {"label": "New Label"}
# Act
response = session_client.patch(url, update_data, format="json")
# Assert
assert response.status_code == status.HTTP_404_NOT_FOUND
@pytest.mark.django_db
def test_patch_other_users_api_token(self, session_client, create_user, db):
"""Test updating another user's API token (should fail)"""
# Arrange
# Create another user and their token with unique email and username
unique_id = uuid4().hex[:8]
unique_email = f"patch-other-{unique_id}@plane.so"
unique_username = f"patch_other_user_{unique_id}"
other_user = User.objects.create(email=unique_email, username=unique_username)
other_token = APIToken.objects.create(
label="Other Token", user=other_user, user_type=0
)
session_client.force_authenticate(user=create_user)
url = reverse("api-tokens", kwargs={"pk": other_token.pk})
update_data = {"label": "Hacked Label"}
# Act
response = session_client.patch(url, update_data, format="json")
# Assert
assert response.status_code == status.HTTP_404_NOT_FOUND
# Verify token was not updated
other_token.refresh_from_db()
assert other_token.label == "Other Token"
# Authentication tests
@pytest.mark.django_db
def test_all_endpoints_require_authentication(self, api_client):
"""Test that all endpoints require authentication"""
# Arrange
endpoints = [
(reverse("api-tokens"), "get"),
(reverse("api-tokens"), "post"),
(reverse("api-tokens", kwargs={"pk": uuid4()}), "get"),
(reverse("api-tokens", kwargs={"pk": uuid4()}), "patch"),
(reverse("api-tokens", kwargs={"pk": uuid4()}), "delete"),
]
# Act & Assert
for url, method in endpoints:
response = getattr(api_client, method)(url)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
@@ -0,0 +1,75 @@
import pytest
from plane.db.models import (
Workspace,
Project,
Issue,
User,
IssueAssignee,
WorkspaceMember,
ProjectMember,
)
from plane.app.serializers.workspace import IssueRecentVisitSerializer
from django.utils import timezone
@pytest.mark.unit
class TestIssueRecentVisitSerializer:
"""Test the IssueRecentVisitSerializer"""
def test_issue_recent_visit_serializer_fields(self, db):
"""Test that the serializer includes the correct fields"""
test_user_1 = User.objects.create(
email="test_user_1@example.com", first_name="Test", last_name="User"
)
# To test for deleted issue assignee
test_user_2 = User.objects.create(
email="test_user_2@example.com",
first_name="Other",
last_name="User",
username="some user name",
)
workspace = Workspace.objects.create(
name="Test Workspace", slug="test-workspace", owner=test_user_1
)
WorkspaceMember.objects.create(member=test_user_2, role=15, workspace=workspace)
project = Project.objects.create(
name="Test Project", identifier="test-project", workspace=workspace
)
ProjectMember.objects.create(project=project, member=test_user_2)
issue = Issue.objects.create(
name="Test Issue",
workspace=workspace,
project=project,
)
IssueAssignee.objects.create(issue=issue, assignee=test_user_1, project=project)
# Deleted issue assignee
IssueAssignee.objects.create(
issue=issue,
assignee=test_user_2,
project=project,
deleted_at=timezone.now(),
)
serialized_data = IssueRecentVisitSerializer(
issue,
).data
# Check fields are present and correct
assert "name" in serialized_data
assert "assignees" in serialized_data
assert "project_identifier" in serialized_data
assert serialized_data["name"] == "Test Issue"
assert serialized_data["project_identifier"] == "TEST-PROJECT"
# Only including non-deleted issue assignees
assert serialized_data["assignees"] == [test_user_1.id]
+11 -4
View File
@@ -16,7 +16,7 @@ def order_issue_queryset(issue_queryset, order_by_param="-created_at"):
],
output_field=CharField(),
)
).order_by("priority_order")
).order_by("priority_order", "-created_at")
order_by_param = (
"priority_order" if order_by_param.startswith("-") else "-priority_order"
)
@@ -36,7 +36,7 @@ def order_issue_queryset(issue_queryset, order_by_param="-created_at"):
default=Value(len(state_order)),
output_field=CharField(),
)
).order_by("state_order")
).order_by("state_order", "-created_at")
order_by_param = (
"-state_order" if order_by_param.startswith("-") else "state_order"
)
@@ -55,11 +55,18 @@ def order_issue_queryset(issue_queryset, order_by_param="-created_at"):
if order_by_param.startswith("-")
else order_by_param
)
).order_by("-min_values" if order_by_param.startswith("-") else "min_values")
).order_by(
"-min_values" if order_by_param.startswith("-") else "min_values",
"-created_at",
)
order_by_param = (
"-min_values" if order_by_param.startswith("-") else "min_values"
)
else:
issue_queryset = issue_queryset.order_by(order_by_param)
# If the order_by_param is created_at, then don't add the -created_at
if "created_at" in order_by_param:
issue_queryset = issue_queryset.order_by(order_by_param)
else:
issue_queryset = issue_queryset.order_by(order_by_param, "-created_at")
order_by_param = order_by_param
return issue_queryset, order_by_param
+25 -6
View File
@@ -102,6 +102,7 @@ class OffsetPaginator:
max_limit=MAX_LIMIT,
max_offset=None,
on_results=None,
total_count_queryset=None,
):
# Key tuple and remove `-` if descending order by
self.key = (
@@ -115,6 +116,7 @@ class OffsetPaginator:
self.max_limit = max_limit
self.max_offset = max_offset
self.on_results = on_results
self.total_count_queryset = total_count_queryset
def get_result(self, limit=1000, cursor=None):
# offset is page #
@@ -138,9 +140,9 @@ class OffsetPaginator:
)
# The current page
page = cursor.offset
# The offset
offset = cursor.offset * cursor.value
stop = offset + (cursor.value or limit) + 1
# The offset - use limit instead of cursor.value for consistent pagination
offset = cursor.offset * limit
stop = offset + limit + 1
if self.max_offset is not None and offset >= self.max_offset:
raise BadPaginationError("Pagination offset too large")
@@ -148,11 +150,23 @@ class OffsetPaginator:
raise BadPaginationError("Pagination offset cannot be negative")
results = queryset[offset:stop]
if cursor.value != limit:
# Duplicate the queryset so it does not evaluate on any python ops
page_results = queryset[offset:stop].values("id")
# Only slice from the end if we're going backwards (previous page)
if cursor.value != limit and cursor.is_prev:
results = results[-(limit + 1) :]
total_count = (
self.total_count_queryset.count()
if self.total_count_queryset
else results.count()
)
# Check if there are more results available after the current page
# Adjust cursors based on the results for pagination
next_cursor = Cursor(limit, page + 1, False, results.count() > limit)
next_cursor = Cursor(limit, page + 1, False, page_results.count() > limit)
# If the page is greater than 0, then set the previous cursor
prev_cursor = Cursor(limit, page - 1, True, page > 0)
@@ -164,7 +178,7 @@ class OffsetPaginator:
results = self.on_results(results)
# Count the queryset
count = queryset.count()
count = total_count
# Optionally, calculate the total count and max_hits if needed
max_hits = math.ceil(count / limit)
@@ -196,6 +210,7 @@ class GroupedOffsetPaginator(OffsetPaginator):
group_by_field_name,
group_by_fields,
count_filter,
total_count_queryset=None,
*args,
**kwargs,
):
@@ -404,6 +419,7 @@ class SubGroupedOffsetPaginator(OffsetPaginator):
group_by_fields,
sub_group_by_fields,
count_filter,
total_count_queryset=None,
*args,
**kwargs,
):
@@ -694,6 +710,7 @@ class BasePaginator:
sub_group_by_field_name=None,
sub_group_by_fields=None,
count_filter=None,
total_count_queryset=None,
**paginator_kwargs,
):
"""Paginate the request"""
@@ -719,6 +736,8 @@ class BasePaginator:
)
paginator_kwargs["sub_group_by_fields"] = sub_group_by_fields
paginator_kwargs["total_count_queryset"] = total_count_queryset
paginator = paginator_cls(**paginator_kwargs)
try:
-23
View File
@@ -1,23 +0,0 @@
{
"presets": [
[
"@babel/preset-env",
{
"modules": false
}
],
"@babel/preset-typescript"
],
"plugins": [
[
"module-resolver",
{
"root": ["./src"],
"alias": {
"@/core": "./src/core",
"@/plane-live": "./src/ce"
}
}
]
]
}
+1 -4
View File
@@ -1,8 +1,5 @@
{
"root": true,
"extends": ["@plane/eslint-config/server.js"],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"project": true
}
"parser": "@typescript-eslint/parser"
}
+8 -9
View File
@@ -1,14 +1,14 @@
{
"name": "live",
"version": "0.26.1",
"version": "0.27.0",
"license": "AGPL-3.0",
"description": "A realtime collaborative server powers Plane's rich text editor",
"main": "./src/server.ts",
"private": true,
"type": "module",
"scripts": {
"dev": "PORT=3100 concurrently \"babel src --out-dir dist --extensions '.ts,.js' --watch\" \"nodemon dist/server.js\"",
"build": "babel src --out-dir dist --extensions \".ts,.js\"",
"dev": "nodemon --exec \"node -r ./src/index.ts\" -e .ts",
"build": "tsc && tsc-alias",
"start": "node dist/server.js",
"lint": "eslint src --ext .ts,.tsx",
"lint:errors": "eslint src --ext .ts,.tsx --quiet"
@@ -21,7 +21,9 @@
"@hocuspocus/extension-redis": "^2.15.0",
"@hocuspocus/server": "^2.15.0",
"@plane/constants": "*",
"@plane/decorators": "*",
"@plane/editor": "*",
"@plane/logger": "*",
"@plane/types": "*",
"@tiptap/core": "2.10.4",
"@tiptap/html": "2.11.0",
@@ -43,20 +45,17 @@
"yjs": "^13.6.20"
},
"devDependencies": {
"@babel/cli": "^7.25.6",
"@babel/core": "^7.25.2",
"@babel/preset-env": "^7.25.4",
"@babel/preset-typescript": "^7.24.7",
"@plane/typescript-config": "*",
"@types/compression": "^1.7.5",
"@types/cors": "^2.8.17",
"@types/dotenv": "^8.2.0",
"@types/express": "^4.17.21",
"@types/express-ws": "^3.0.4",
"@types/node": "^20.14.9",
"babel-plugin-module-resolver": "^5.0.2",
"@types/pino-http": "^5.8.4",
"concurrently": "^9.0.1",
"nodemon": "^3.1.7",
"ts-node": "^10.9.2",
"tsc-alias": "^1.8.16",
"tsup": "8.4.0",
"typescript": "5.8.3"
}
+2 -1
View File
@@ -1,4 +1,5 @@
import { pinoHttp } from "pino-http";
import { Logger } from "pino";
const transport = {
target: "pino-pretty",
@@ -35,4 +36,4 @@ export const logger = pinoHttp({
},
});
export const manualLogger = logger.logger;
export const manualLogger: Logger = logger.logger;
+1 -1
View File
@@ -1 +1 @@
export * from "../../ce/lib/authentication.js"
// export * from "@/plane-live/lib/authentication.js";
+6 -6
View File
@@ -1,7 +1,7 @@
import compression from "compression";
import cors from "cors";
import expressWs from "express-ws";
import express from "express";
import express, { Request, Response } from "express";
import helmet from "helmet";
// hocuspocus server
import { getHocusPocusServer } from "@/core/hocuspocus-server.js";
@@ -38,18 +38,18 @@ app.use(express.urlencoded({ extended: true }));
// cors middleware
app.use(cors());
const router = express.Router();
const router: any = express.Router();
const HocusPocusServer = await getHocusPocusServer().catch((err) => {
manualLogger.error("Failed to initialize HocusPocusServer:", err);
process.exit(1);
});
router.get("/health", (_req, res) => {
router.get("/health", (_req: Request, res: Response) => {
res.status(200).json({ status: "OK" });
});
router.ws("/collaboration", (ws, req) => {
router.ws("/collaboration", (ws: any, req: any) => {
try {
HocusPocusServer.handleConnection(ws, req);
} catch (err) {
@@ -58,7 +58,7 @@ router.ws("/collaboration", (ws, req) => {
}
});
router.post("/convert-document", (req, res) => {
router.post("/convert-document", (req: Request, res: Response) => {
const { description_html, variant } = req.body as TConvertDocumentRequestBody;
try {
if (description_html === undefined || variant === undefined) {
@@ -85,7 +85,7 @@ router.post("/convert-document", (req, res) => {
app.use(process.env.LIVE_BASE_PATH || "/live", router);
app.use((_req, res) => {
app.use((_req: Request, res: Response) => {
res.status(404).send("Not Found");
});
+9 -8
View File
@@ -1,15 +1,16 @@
{
"extends": "@plane/typescript-config/base.json",
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2015"],
"module": "ESNext",
"moduleResolution": "bundler",
"target": "ESNext",
"lib": ["ESNext"],
"outDir": "./dist",
"rootDir": ".",
"baseUrl": ".",
"rootDir": "./src",
"baseUrl": "./src",
"paths": {
"@/*": ["./src/*"],
"@/plane-live/*": ["./src/ce/*"]
"@/*": ["./*"],
"@/plane-live/*": ["./ce/*"]
},
"removeComments": true,
"esModuleInterop": true,
@@ -18,6 +19,6 @@
"inlineSources": true,
"sourceRoot": "/"
},
"include": ["src/**/*.ts", "tsup.config.ts"],
"include": ["src"],
"exclude": ["./dist", "./build", "./node_modules"]
}
+14 -5
View File
@@ -2,10 +2,19 @@ import { defineConfig, Options } from "tsup";
export default defineConfig((options: Options) => ({
entry: ["src/server.ts"],
format: ["cjs", "esm"],
dts: true,
clean: false,
external: ["react"],
injectStyle: true,
format: ["esm"],
dts: false,
clean: true,
target: "node18",
sourcemap: true,
splitting: false,
bundle: true,
outDir: "dist",
esbuildOptions(options) {
options.alias = {
"@/core": "./src/core",
"@/plane-live": "./src/ce"
};
},
...options,
}));
+2 -1
View File
@@ -2,7 +2,7 @@
"name": "plane",
"description": "Open-source project management that unlocks customer value",
"repository": "https://github.com/makeplane/plane.git",
"version": "0.26.1",
"version": "0.27.0",
"license": "AGPL-3.0",
"private": true,
"workspaces": [
@@ -27,6 +27,7 @@
"turbo": "^2.5.4"
},
"resolutions": {
"brace-expansion": "2.0.2",
"nanoid": "3.3.8",
"esbuild": "0.25.0",
"@babel/helpers": "7.26.10",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/constants",
"version": "0.26.1",
"version": "0.27.0",
"private": true,
"main": "./src/index.ts",
"license": "AGPL-3.0"
+2 -3
View File
@@ -1,5 +1,4 @@
import { TAnalyticsTabsBase } from "@plane/types";
import { ChartXAxisProperty, ChartYAxisMetric } from "../chart";
import { ChartXAxisProperty, ChartYAxisMetric, TAnalyticsTabsBase } from "@plane/types";
export interface IInsightField {
key: string;
@@ -11,7 +10,7 @@ export interface IInsightField {
};
}
export const insightsFields: Record<TAnalyticsTabsBase, IInsightField[]> = {
export const ANALYTICS_INSIGHTS_FIELDS: Record<TAnalyticsTabsBase, IInsightField[]> = {
overview: [
{
key: "total_users",
+8 -1
View File
@@ -69,7 +69,7 @@ export enum EErrorAlertType {
export type TAuthErrorInfo = {
type: EErrorAlertType;
code: EAdminAuthErrorCodes;
code: EAuthErrorCodes;
title: string;
message: any;
};
@@ -87,6 +87,13 @@ export enum EAdminAuthErrorCodes {
ADMIN_USER_DEACTIVATED = "5190",
}
export type TAdminAuthErrorInfo = {
type: EErrorAlertType;
code: EAdminAuthErrorCodes;
title: string;
message: any;
};
export enum EAuthErrorCodes {
// Global
INSTANCE_NOT_CONFIGURED = "5000",
+1 -32
View File
@@ -1,40 +1,9 @@
import { TChartColorScheme } from "@plane/types";
import { ChartXAxisProperty, TChartColorScheme } from "@plane/types";
export const LABEL_CLASSNAME = "uppercase text-custom-text-300/60 text-sm tracking-wide";
export const AXIS_LABEL_CLASSNAME = "uppercase text-custom-text-300/60 text-sm tracking-wide";
export enum ChartXAxisProperty {
STATES = "STATES",
STATE_GROUPS = "STATE_GROUPS",
LABELS = "LABELS",
ASSIGNEES = "ASSIGNEES",
ESTIMATE_POINTS = "ESTIMATE_POINTS",
CYCLES = "CYCLES",
MODULES = "MODULES",
PRIORITY = "PRIORITY",
START_DATE = "START_DATE",
TARGET_DATE = "TARGET_DATE",
CREATED_AT = "CREATED_AT",
COMPLETED_AT = "COMPLETED_AT",
CREATED_BY = "CREATED_BY",
WORK_ITEM_TYPES = "WORK_ITEM_TYPES",
PROJECTS = "PROJECTS",
EPICS = "EPICS",
}
export enum ChartYAxisMetric {
WORK_ITEM_COUNT = "WORK_ITEM_COUNT",
ESTIMATE_POINT_COUNT = "ESTIMATE_POINT_COUNT",
PENDING_WORK_ITEM_COUNT = "PENDING_WORK_ITEM_COUNT",
COMPLETED_WORK_ITEM_COUNT = "COMPLETED_WORK_ITEM_COUNT",
IN_PROGRESS_WORK_ITEM_COUNT = "IN_PROGRESS_WORK_ITEM_COUNT",
WORK_ITEM_DUE_THIS_WEEK_COUNT = "WORK_ITEM_DUE_THIS_WEEK_COUNT",
WORK_ITEM_DUE_TODAY_COUNT = "WORK_ITEM_DUE_TODAY_COUNT",
BLOCKED_WORK_ITEM_COUNT = "BLOCKED_WORK_ITEM_COUNT",
}
export enum ChartXAxisDateGrouping {
DAY = "DAY",
WEEK = "WEEK",
+7 -9
View File
@@ -1,28 +1,26 @@
export const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || "";
export const API_BASE_PATH = process.env.NEXT_PUBLIC_API_BASE_PATH || "/";
export const API_BASE_PATH = process.env.NEXT_PUBLIC_API_BASE_PATH || "";
export const API_URL = encodeURI(`${API_BASE_URL}${API_BASE_PATH}`);
// 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 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 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_BASE_PATH = process.env.NEXT_PUBLIC_LIVE_BASE_PATH || "";
export const LIVE_URL = encodeURI(`${LIVE_BASE_URL}${LIVE_BASE_PATH}`);
// Web App Base Url
export const WEB_BASE_URL = process.env.NEXT_PUBLIC_WEB_BASE_URL || "";
export const WEB_BASE_PATH = process.env.NEXT_PUBLIC_WEB_BASE_PATH || "/";
export const WEB_BASE_PATH = process.env.NEXT_PUBLIC_WEB_BASE_PATH || "";
export const WEB_URL = encodeURI(`${WEB_BASE_URL}${WEB_BASE_PATH}`);
// plane website url
export const WEBSITE_URL =
process.env.NEXT_PUBLIC_WEBSITE_URL || "https://plane.so";
export const WEBSITE_URL = process.env.NEXT_PUBLIC_WEBSITE_URL || "https://plane.so";
// support email
export const SUPPORT_EMAIL =
process.env.NEXT_PUBLIC_SUPPORT_EMAIL || "support@plane.so";
export const SUPPORT_EMAIL = process.env.NEXT_PUBLIC_SUPPORT_EMAIL || "support@plane.so";
// marketing links
export const MARKETING_PRICING_PAGE_LINK = "https://plane.so/pricing";
export const MARKETING_CONTACT_US_PAGE_LINK = "https://plane.so/contact";
@@ -1,4 +1,4 @@
// types
// plane imports
import { TEstimateSystems } from "@plane/types";
export const MAX_ESTIMATE_POINT_INPUT_LENGTH = 20;
-238
View File
@@ -1,238 +0,0 @@
export type IssueEventProps = {
eventName: string;
payload: any;
updates?: any;
path?: string;
};
export type EventProps = {
eventName: string;
payload: any;
updates?: any;
path?: string;
};
export const getWorkspaceEventPayload = (payload: any) => ({
workspace_id: payload.id,
created_at: payload.created_at,
updated_at: payload.updated_at,
organization_size: payload.organization_size,
first_time: payload.first_time,
state: payload.state,
element: payload.element,
});
export const getProjectEventPayload = (payload: any) => ({
workspace_id: payload.workspace_id,
project_id: payload.id,
identifier: payload.identifier,
project_visibility: payload.network == 2 ? "Public" : "Private",
changed_properties: payload.changed_properties,
lead_id: payload.project_lead,
created_at: payload.created_at,
updated_at: payload.updated_at,
state: payload.state,
element: payload.element,
});
export const getCycleEventPayload = (payload: any) => ({
workspace_id: payload.workspace_id,
project_id: payload.project,
cycle_id: payload.id,
created_at: payload.created_at,
updated_at: payload.updated_at,
start_date: payload.start_date,
target_date: payload.target_date,
cycle_status: payload.status,
changed_properties: payload.changed_properties,
state: payload.state,
element: payload.element,
});
export const getModuleEventPayload = (payload: any) => ({
workspace_id: payload.workspace_id,
project_id: payload.project,
module_id: payload.id,
created_at: payload.created_at,
updated_at: payload.updated_at,
start_date: payload.start_date,
target_date: payload.target_date,
module_status: payload.status,
lead_id: payload.lead,
changed_properties: payload.changed_properties,
member_ids: payload.members,
state: payload.state,
element: payload.element,
});
export const getPageEventPayload = (payload: any) => ({
workspace_id: payload.workspace_id,
project_id: payload.project,
created_at: payload.created_at,
updated_at: payload.updated_at,
access: payload.access === 0 ? "Public" : "Private",
is_locked: payload.is_locked,
archived_at: payload.archived_at,
created_by: payload.created_by,
state: payload.state,
element: payload.element,
});
export const getIssueEventPayload = (props: IssueEventProps) => {
const { eventName, payload, updates, path } = props;
let eventPayload: any = {
issue_id: payload.id,
estimate_point: payload.estimate_point,
link_count: payload.link_count,
target_date: payload.target_date,
is_draft: payload.is_draft,
label_ids: payload.label_ids,
assignee_ids: payload.assignee_ids,
created_at: payload.created_at,
updated_at: payload.updated_at,
sequence_id: payload.sequence_id,
module_ids: payload.module_ids,
sub_issues_count: payload.sub_issues_count,
parent_id: payload.parent_id,
project_id: payload.project_id,
workspace_id: payload.workspace_id,
priority: payload.priority,
state_id: payload.state_id,
start_date: payload.start_date,
attachment_count: payload.attachment_count,
cycle_id: payload.cycle_id,
module_id: payload.module_id,
archived_at: payload.archived_at,
state: payload.state,
view_id:
path?.includes("workspace-views") || path?.includes("views")
? path.split("/").pop()
: "",
};
if (eventName === ISSUE_UPDATED) {
eventPayload = {
...eventPayload,
...updates,
updated_from: props.path?.includes("workspace-views")
? "All views"
: props.path?.includes("cycles")
? "Cycle"
: props.path?.includes("modules")
? "Module"
: props.path?.includes("views")
? "Project view"
: props.path?.includes("inbox")
? "Inbox"
: props.path?.includes("draft")
? "Draft"
: "Project",
};
}
return eventPayload;
};
export const getProjectStateEventPayload = (payload: any) => ({
workspace_id: payload.workspace_id,
project_id: payload.id,
state_id: payload.id,
created_at: payload.created_at,
updated_at: payload.updated_at,
group: payload.group,
color: payload.color,
default: payload.default,
state: payload.state,
element: payload.element,
});
// Workspace crud Events
export const WORKSPACE_CREATED = "Workspace created";
export const WORKSPACE_UPDATED = "Workspace updated";
export const WORKSPACE_DELETED = "Workspace deleted";
// Project Events
export const PROJECT_CREATED = "Project created";
export const PROJECT_UPDATED = "Project updated";
export const PROJECT_DELETED = "Project deleted";
// Cycle Events
export const CYCLE_CREATED = "Cycle created";
export const CYCLE_UPDATED = "Cycle updated";
export const CYCLE_DELETED = "Cycle deleted";
export const CYCLE_FAVORITED = "Cycle favorited";
export const CYCLE_UNFAVORITED = "Cycle unfavorited";
// Module Events
export const MODULE_CREATED = "Module created";
export const MODULE_UPDATED = "Module updated";
export const MODULE_DELETED = "Module deleted";
export const MODULE_FAVORITED = "Module favorited";
export const MODULE_UNFAVORITED = "Module unfavorited";
export const MODULE_LINK_CREATED = "Module link created";
export const MODULE_LINK_UPDATED = "Module link updated";
export const MODULE_LINK_DELETED = "Module link deleted";
// Issue Events
export const ISSUE_CREATED = "Work item created";
export const ISSUE_UPDATED = "Work item updated";
export const ISSUE_DELETED = "Work item deleted";
export const ISSUE_ARCHIVED = "Work item archived";
export const ISSUE_RESTORED = "Work item restored";
export const ISSUE_OPENED = "Work item opened";
// Project State Events
export const STATE_CREATED = "State created";
export const STATE_UPDATED = "State updated";
export const STATE_DELETED = "State deleted";
// Project Page Events
export const PAGE_CREATED = "Page created";
export const PAGE_UPDATED = "Page updated";
export const PAGE_DELETED = "Page deleted";
// Member Events
export const MEMBER_INVITED = "Member invited";
export const MEMBER_ACCEPTED = "Member accepted";
export const PROJECT_MEMBER_ADDED = "Project member added";
export const PROJECT_MEMBER_LEAVE = "Project member leave";
export const WORKSPACE_MEMBER_LEAVE = "Workspace member leave";
// Sign-in & Sign-up Events
export const NAVIGATE_TO_SIGNUP = "Navigate to sign-up page";
export const NAVIGATE_TO_SIGNIN = "Navigate to sign-in page";
export const CODE_VERIFIED = "Code verified";
export const SETUP_PASSWORD = "Password setup";
export const PASSWORD_CREATE_SELECTED = "Password created";
export const PASSWORD_CREATE_SKIPPED = "Skipped to setup";
export const SIGN_IN_WITH_PASSWORD = "Sign in with password";
export const SIGN_UP_WITH_PASSWORD = "Sign up with password";
export const SIGN_IN_WITH_CODE = "Sign in with magic link";
export const FORGOT_PASSWORD = "Forgot password clicked";
export const FORGOT_PASS_LINK = "Forgot password link generated";
export const NEW_PASS_CREATED = "New password created";
// Onboarding Events
export const USER_DETAILS = "User details added";
export const USER_ONBOARDING_COMPLETED = "User onboarding completed";
// Product Tour Events
export const PRODUCT_TOUR_STARTED = "Product tour started";
export const PRODUCT_TOUR_COMPLETED = "Product tour completed";
export const PRODUCT_TOUR_SKIPPED = "Product tour skipped";
// Dashboard Events
export const CHANGELOG_REDIRECTED = "Changelog redirected";
export const GITHUB_REDIRECTED = "GitHub redirected";
// Sidebar Events
export const SIDEBAR_CLICKED = "Sidenav clicked";
// Global View Events
export const GLOBAL_VIEW_CREATED = "Global view created";
export const GLOBAL_VIEW_UPDATED = "Global view updated";
export const GLOBAL_VIEW_DELETED = "Global view deleted";
export const GLOBAL_VIEW_OPENED = "Global view opened";
// Notification Events
export const NOTIFICATION_ARCHIVED = "Notification archived";
export const NOTIFICATION_SNOOZED = "Notification snoozed";
export const NOTIFICATION_READ = "Notification marked read";
export const UNREAD_NOTIFICATIONS = "Unread notifications viewed";
export const NOTIFICATIONS_READ = "All notifications marked read";
export const SNOOZED_NOTIFICATIONS = "Snoozed notifications viewed";
export const ARCHIVED_NOTIFICATIONS = "Archived notifications viewed";
// Groups
export const GROUP_WORKSPACE = "Workspace_metrics";
//Elements
export const E_ONBOARDING = "Onboarding";
export const E_ONBOARDING_STEP_1 = "Onboarding step 1";
export const E_ONBOARDING_STEP_2 = "Onboarding step 2";
// Favorites
export const FAVORITE_ADDED = "Favorite added";
@@ -0,0 +1,258 @@
export type IssueEventProps = {
eventName: string;
payload: any;
updates?: any;
path?: string;
};
export type EventProps = {
eventName: string;
payload: any;
updates?: any;
path?: string;
};
export const getWorkspaceEventPayload = (payload: any) => ({
workspace_id: payload.id,
created_at: payload.created_at,
updated_at: payload.updated_at,
organization_size: payload.organization_size,
first_time: payload.first_time,
state: payload.state,
element: payload.element,
});
export const getProjectEventPayload = (payload: any) => ({
workspace_id: payload.workspace_id,
project_id: payload.id,
identifier: payload.identifier,
project_visibility: payload.network == 2 ? "Public" : "Private",
changed_properties: payload.changed_properties,
lead_id: payload.project_lead,
created_at: payload.created_at,
updated_at: payload.updated_at,
state: payload.state,
element: payload.element,
});
export const getCycleEventPayload = (payload: any) => ({
workspace_id: payload.workspace_id,
project_id: payload.project,
cycle_id: payload.id,
created_at: payload.created_at,
updated_at: payload.updated_at,
start_date: payload.start_date,
target_date: payload.target_date,
cycle_status: payload.status,
changed_properties: payload.changed_properties,
state: payload.state,
element: payload.element,
});
export const getModuleEventPayload = (payload: any) => ({
workspace_id: payload.workspace_id,
project_id: payload.project,
module_id: payload.id,
created_at: payload.created_at,
updated_at: payload.updated_at,
start_date: payload.start_date,
target_date: payload.target_date,
module_status: payload.status,
lead_id: payload.lead,
changed_properties: payload.changed_properties,
member_ids: payload.members,
state: payload.state,
element: payload.element,
});
export const getPageEventPayload = (payload: any) => ({
workspace_id: payload.workspace_id,
project_id: payload.project,
created_at: payload.created_at,
updated_at: payload.updated_at,
access: payload.access === 0 ? "Public" : "Private",
is_locked: payload.is_locked,
archived_at: payload.archived_at,
created_by: payload.created_by,
state: payload.state,
element: payload.element,
});
export const getIssueEventPayload = (props: IssueEventProps) => {
const { eventName, payload, updates, path } = props;
let eventPayload: any = {
issue_id: payload.id,
estimate_point: payload.estimate_point,
link_count: payload.link_count,
target_date: payload.target_date,
is_draft: payload.is_draft,
label_ids: payload.label_ids,
assignee_ids: payload.assignee_ids,
created_at: payload.created_at,
updated_at: payload.updated_at,
sequence_id: payload.sequence_id,
module_ids: payload.module_ids,
sub_issues_count: payload.sub_issues_count,
parent_id: payload.parent_id,
project_id: payload.project_id,
workspace_id: payload.workspace_id,
priority: payload.priority,
state_id: payload.state_id,
start_date: payload.start_date,
attachment_count: payload.attachment_count,
cycle_id: payload.cycle_id,
module_id: payload.module_id,
archived_at: payload.archived_at,
state: payload.state,
view_id: path?.includes("workspace-views") || path?.includes("views") ? path.split("/").pop() : "",
};
if (eventName === WORK_ITEM_TRACKER_EVENTS.update) {
eventPayload = {
...eventPayload,
...updates,
updated_from: props.path?.includes("workspace-views")
? "All views"
: props.path?.includes("cycles")
? "Cycle"
: props.path?.includes("modules")
? "Module"
: props.path?.includes("views")
? "Project view"
: props.path?.includes("inbox")
? "Inbox"
: props.path?.includes("draft")
? "Draft"
: "Project",
};
}
return eventPayload;
};
export const getProjectStateEventPayload = (payload: any) => ({
workspace_id: payload.workspace_id,
project_id: payload.id,
state_id: payload.id,
created_at: payload.created_at,
updated_at: payload.updated_at,
group: payload.group,
color: payload.color,
default: payload.default,
state: payload.state,
element: payload.element,
});
// Dashboard Events
export const GITHUB_REDIRECTED_TRACKER_EVENT = "github_redirected";
// Groups
export const GROUP_WORKSPACE_TRACKER_EVENT = "workspace_metrics";
export const WORKSPACE_TRACKER_EVENTS = {
create: "workspace_created",
update: "workspace_updated",
delete: "workspace_deleted",
};
export const PROJECT_TRACKER_EVENTS = {
create: "project_created",
update: "project_updated",
delete: "project_deleted",
};
export const CYCLE_TRACKER_EVENTS = {
create: "cycle_created",
update: "cycle_updated",
delete: "cycle_deleted",
favorite: "cycle_favorited",
unfavorite: "cycle_unfavorited",
};
export const MODULE_TRACKER_EVENTS = {
create: "module_created",
update: "module_updated",
delete: "module_deleted",
favorite: "module_favorited",
unfavorite: "module_unfavorited",
link: {
create: "module_link_created",
update: "module_link_updated",
delete: "module_link_deleted",
},
};
export const WORK_ITEM_TRACKER_EVENTS = {
create: "work_item_created",
update: "work_item_updated",
delete: "work_item_deleted",
archive: "work_item_archived",
restore: "work_item_restored",
};
export const STATE_TRACKER_EVENTS = {
create: "state_created",
update: "state_updated",
delete: "state_deleted",
};
export const PROJECT_PAGE_TRACKER_EVENTS = {
create: "project_page_created",
update: "project_page_updated",
delete: "project_page_deleted",
};
export const MEMBER_TRACKER_EVENTS = {
invite: "member_invited",
accept: "member_accepted",
project: {
add: "project_member_added",
leave: "project_member_left",
},
workspace: {
leave: "workspace_member_left",
},
};
export const AUTH_TRACKER_EVENTS = {
navigate: {
sign_up: "navigate_to_sign_up_page",
sign_in: "navigate_to_sign_in_page",
},
code_verify: "code_verified",
sign_up_with_password: "sign_up_with_password",
sign_in_with_password: "sign_in_with_password",
sign_in_with_code: "sign_in_with_magic_link",
forgot_password: "forgot_password_clicked",
};
export const PRODUCT_TOUR_TRACKER_EVENTS = {
start: "product_tour_started",
complete: "product_tour_completed",
skip: "product_tour_skipped",
};
export const GLOBAL_VIEW_TOUR_TRACKER_EVENTS = {
create: "global_view_created",
update: "global_view_updated",
delete: "global_view_deleted",
open: "global_view_opened",
};
export const NOTIFICATION_TRACKER_EVENTS = {
archive: "notification_archived",
all_marked_read: "all_notifications_marked_read",
};
export const USER_TRACKER_EVENTS = {
add_details: "user_details_added",
onboarding_complete: "user_onboarding_completed",
};
export const ONBOARDING_TRACKER_EVENTS = {
root: "onboarding",
step_1: "onboarding_step_1",
step_2: "onboarding_step_2",
};
export const SIDEBAR_TRACKER_EVENTS = {
click: "sidenav_clicked",
};
@@ -0,0 +1 @@
export * from "./core";
+2 -1
View File
@@ -21,7 +21,7 @@ export * from "./module";
export * from "./project";
export * from "./views";
export * from "./themes";
export * from "./inbox";
export * from "./intake";
export * from "./profile";
export * from "./workspace-drafts";
export * from "./label";
@@ -33,4 +33,5 @@ export * from "./emoji";
export * from "./subscription";
export * from "./settings";
export * from "./icon";
export * from "./estimates";
export * from "./analytics";
@@ -1,36 +1,4 @@
import { TInboxDuplicateIssueDetails, TIssue } from "@plane/types";
export enum EInboxIssueCurrentTab {
OPEN = "open",
CLOSED = "closed",
}
export enum EInboxIssueStatus {
PENDING = -2,
DECLINED = -1,
SNOOZED = 0,
ACCEPTED = 1,
DUPLICATE = 2,
}
export enum EInboxIssueSource {
IN_APP = "IN_APP",
FORMS = "FORMS",
EMAIL = "EMAIL",
}
export type TInboxIssueCurrentTab = EInboxIssueCurrentTab;
export type TInboxIssueStatus = EInboxIssueStatus;
export type TInboxIssue = {
id: string;
status: TInboxIssueStatus;
snoozed_till: Date | null;
duplicate_to: string | undefined;
source: EInboxIssueSource | undefined;
issue: TIssue;
created_by: string;
duplicate_issue_detail: TInboxDuplicateIssueDetails | undefined;
};
import { EInboxIssueStatus, TInboxIssueStatus } from "@plane/types";
export const INBOX_STATUS: {
key: string;
@@ -95,3 +63,32 @@ export const INBOX_ISSUE_SORT_BY_OPTIONS = [
i18n_label: "common.sort.desc",
},
];
export enum EPastDurationFilters {
TODAY = "today",
YESTERDAY = "yesterday",
LAST_7_DAYS = "last_7_days",
LAST_30_DAYS = "last_30_days",
}
export const PAST_DURATION_FILTER_OPTIONS: {
name: string;
value: string;
}[] = [
{
name: "Today",
value: EPastDurationFilters.TODAY,
},
{
name: "Yesterday",
value: EPastDurationFilters.YESTERDAY,
},
{
name: "Last 7 days",
value: EPastDurationFilters.LAST_7_DAYS,
},
{
name: "Last 30 days",
value: EPastDurationFilters.LAST_30_DAYS,
},
];
+1 -22
View File
@@ -4,6 +4,7 @@ import {
IIssueDisplayProperties,
IIssueFilterOptions,
TIssue,
EIssuesStoreType,
} from "@plane/types";
export const ALL_ISSUES = "All Issues";
@@ -44,28 +45,6 @@ export enum EIssueGroupBYServerToProperty {
"created_by" = "created_by",
}
export enum EIssueServiceType {
ISSUES = "issues",
EPICS = "epics",
WORK_ITEMS = "work-items",
}
export enum EIssuesStoreType {
GLOBAL = "GLOBAL",
PROFILE = "PROFILE",
TEAM = "TEAM",
PROJECT = "PROJECT",
CYCLE = "CYCLE",
MODULE = "MODULE",
TEAM_VIEW = "TEAM_VIEW",
PROJECT_VIEW = "PROJECT_VIEW",
ARCHIVED = "ARCHIVED",
DRAFT = "DRAFT",
DEFAULT = "DEFAULT",
WORKSPACE_DRAFT = "WORKSPACE_DRAFT",
EPIC = "EPIC",
}
export enum EIssueCommentAccessSpecifier {
EXTERNAL = "EXTERNAL",
INTERNAL = "INTERNAL",
+1 -2
View File
@@ -1,8 +1,7 @@
import { ILayoutDisplayFiltersOptions, TIssueActivityComment } from "@plane/types";
import { EIssuesStoreType, ILayoutDisplayFiltersOptions, TIssueActivityComment } from "@plane/types";
import {
TIssueFilterPriorityObject,
ISSUE_DISPLAY_PROPERTIES_KEYS,
EIssuesStoreType,
SUB_ISSUES_DISPLAY_PROPERTIES_KEYS,
} from "./common";
+1 -12
View File
@@ -1,15 +1,4 @@
import { IPaymentProduct, TBillingFrequency, TProductBillingFrequency } from "@plane/types";
/**
* Enum representing different product subscription types
*/
export enum EProductSubscriptionEnum {
FREE = "FREE",
ONE = "ONE",
PRO = "PRO",
BUSINESS = "BUSINESS",
ENTERPRISE = "ENTERPRISE",
}
import { EProductSubscriptionEnum, IPaymentProduct, TBillingFrequency, TProductBillingFrequency } from "@plane/types";
/**
* Default billing frequency for each product subscription type
+2 -14
View File
@@ -1,3 +1,5 @@
import { EStartOfTheWeek } from "@plane/types";
export const PROFILE_SETTINGS = {
profile: {
key: "profile",
@@ -103,20 +105,6 @@ export const PREFERENCE_OPTIONS: {
},
];
/**
* @description The start of the week for the user
* @enum {number}
*/
export enum EStartOfTheWeek {
SUNDAY = 0,
MONDAY = 1,
TUESDAY = 2,
WEDNESDAY = 3,
THURSDAY = 4,
FRIDAY = 5,
SATURDAY = 6,
}
/**
* @description The options for the start of the week
* @type {Array<{value: EStartOfTheWeek, label: string}>}
+9
View File
@@ -149,3 +149,12 @@ export const DEFAULT_PROJECT_FORM_VALUES: Partial<IProject> = {
network: 2,
project_lead: null,
};
export enum EProjectFeatureKey {
WORK_ITEMS = "work_items",
CYCLES = "cycles",
MODULES = "modules",
VIEWS = "views",
PAGES = "pages",
INTAKE = "intake",
}
+29 -3
View File
@@ -1,5 +1,4 @@
"use client"
export type TStateGroups = "backlog" | "unstarted" | "started" | "completed" | "cancelled";
import { TStateGroups } from "@plane/types";
export type TDraggableData = {
groupKey: TStateGroups;
@@ -55,6 +54,34 @@ export const PENDING_STATE_GROUPS = [
STATE_GROUPS.cancelled.key,
];
export const STATE_DISTRIBUTION = {
[STATE_GROUPS.backlog.key]: {
key: STATE_GROUPS.backlog.key,
issues: "backlog_issues",
points: "backlog_estimate_points",
},
[STATE_GROUPS.unstarted.key]: {
key: STATE_GROUPS.unstarted.key,
issues: "unstarted_issues",
points: "unstarted_estimate_points",
},
[STATE_GROUPS.started.key]: {
key: STATE_GROUPS.started.key,
issues: "started_issues",
points: "started_estimate_points",
},
[STATE_GROUPS.completed.key]: {
key: STATE_GROUPS.completed.key,
issues: "completed_issues",
points: "completed_estimate_points",
},
[STATE_GROUPS.cancelled.key]: {
key: STATE_GROUPS.cancelled.key,
issues: "cancelled_issues",
points: "cancelled_estimate_points",
},
};
export const PROGRESS_STATE_GROUPS_DETAILS = [
{
key: "completed_issues",
@@ -78,5 +105,4 @@ export const PROGRESS_STATE_GROUPS_DETAILS = [
},
];
export const DISPLAY_WORKFLOW_PRO_CTA = false;
-12
View File
@@ -25,18 +25,6 @@ export enum EUserPermissionsLevel {
PROJECT = "PROJECT",
}
export enum EUserWorkspaceRoles {
ADMIN = 20,
MEMBER = 15,
GUEST = 5,
}
export enum EUserProjectRoles {
ADMIN = 20,
MEMBER = 15,
GUEST = 5,
}
export type TUserPermissionsLevel = EUserPermissionsLevel;
export enum EUserPermissions {
+1 -4
View File
@@ -1,7 +1,4 @@
export enum EViewAccess {
PRIVATE,
PUBLIC,
}
import { EViewAccess } from "@plane/types";
export const VIEW_ACCESS_SPECIFIERS: {
key: EViewAccess;
+1 -2
View File
@@ -1,5 +1,4 @@
import { TStaticViewTypes, IWorkspaceSearchResults } from "@plane/types";
import { EUserWorkspaceRoles } from "./user";
import { TStaticViewTypes, IWorkspaceSearchResults, EUserWorkspaceRoles } from "@plane/types";
export const ORGANIZATION_SIZE = [
"Just myself", // TODO: translate
+1 -1
View File
@@ -17,7 +17,7 @@ This package is part of the Plane workspace and can be used by adding it to your
```json
{
"dependencies": {
"@plane/decorators": "*"
"@plane/decorators": "workspace:*"
}
}
```
+4 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/editor",
"version": "0.26.1",
"version": "0.27.0",
"description": "Core Editor that powers Plane",
"license": "AGPL-3.0",
"private": true,
@@ -34,8 +34,11 @@
"react-dom": "18.3.1"
},
"dependencies": {
"@floating-ui/dom": "^1.7.1",
"@floating-ui/react": "^0.26.4",
"@headlessui/react": "^1.7.3",
"@hocuspocus/provider": "^2.15.0",
"@plane/constants": "*",
"@plane/types": "*",
"@plane/ui": "*",
"@plane/utils": "*",
@@ -1,13 +1,13 @@
import { Extensions } from "@tiptap/core";
import type { Extensions } from "@tiptap/core";
// types
import { TExtensions, TFileHandler } from "@/types";
import type { IEditorProps } from "@/types";
type Props = {
disabledExtensions: TExtensions[];
fileHandler: TFileHandler;
};
export type TCoreAdditionalExtensionsProps = Pick<
IEditorProps,
"disabledExtensions" | "flaggedExtensions" | "fileHandler"
>;
export const CoreEditorAdditionalExtensions = (props: Props): Extensions => {
export const CoreEditorAdditionalExtensions = (props: TCoreAdditionalExtensionsProps): Extensions => {
const {} = props;
return [];
};
@@ -1,12 +1,15 @@
import { Extensions } from "@tiptap/core";
import type { Extensions } from "@tiptap/core";
// types
import { TExtensions } from "@/types";
import type { IReadOnlyEditorProps } from "@/types";
type Props = {
disabledExtensions: TExtensions[];
};
export type TCoreReadOnlyEditorAdditionalExtensionsProps = Pick<
IReadOnlyEditorProps,
"disabledExtensions" | "flaggedExtensions"
>;
export const CoreReadOnlyEditorAdditionalExtensions = (props: Props): Extensions => {
export const CoreReadOnlyEditorAdditionalExtensions = (
props: TCoreReadOnlyEditorAdditionalExtensionsProps
): Extensions => {
const {} = props;
return [];
};
@@ -1,37 +1,39 @@
import { HocuspocusProvider } from "@hocuspocus/provider";
import { AnyExtension } from "@tiptap/core";
import type { HocuspocusProvider } from "@hocuspocus/provider";
import type { AnyExtension } from "@tiptap/core";
import { SlashCommands } from "@/extensions";
// plane editor types
import { TEmbedConfig } from "@/plane-editor/types";
import type { TEmbedConfig } from "@/plane-editor/types";
// types
import { TExtensions, TFileHandler, TUserDetails } from "@/types";
import type { IEditorProps, TExtensions, TUserDetails } from "@/types";
export type TDocumentEditorAdditionalExtensionsProps = {
disabledExtensions: TExtensions[];
export type TDocumentEditorAdditionalExtensionsProps = Pick<
IEditorProps,
"disabledExtensions" | "flaggedExtensions" | "fileHandler"
> & {
embedConfig: TEmbedConfig | undefined;
fileHandler: TFileHandler;
provider?: HocuspocusProvider;
userDetails: TUserDetails;
};
export type TDocumentEditorAdditionalExtensionsRegistry = {
isEnabled: (disabledExtensions: TExtensions[]) => boolean;
isEnabled: (disabledExtensions: TExtensions[], flaggedExtensions: TExtensions[]) => boolean;
getExtension: (props: TDocumentEditorAdditionalExtensionsProps) => AnyExtension;
};
const extensionRegistry: TDocumentEditorAdditionalExtensionsRegistry[] = [
{
isEnabled: (disabledExtensions) => !disabledExtensions.includes("slash-commands"),
getExtension: ({ disabledExtensions }) => SlashCommands({ disabledExtensions }),
getExtension: ({ disabledExtensions, flaggedExtensions }) =>
SlashCommands({ disabledExtensions, flaggedExtensions }),
},
];
export const DocumentEditorAdditionalExtensions = (_props: TDocumentEditorAdditionalExtensionsProps) => {
const { disabledExtensions = [] } = _props;
export const DocumentEditorAdditionalExtensions = (props: TDocumentEditorAdditionalExtensionsProps) => {
const { disabledExtensions, flaggedExtensions } = props;
const documentExtensions = extensionRegistry
.filter((config) => config.isEnabled(disabledExtensions))
.map((config) => config.getExtension(_props));
.filter((config) => config.isEnabled(disabledExtensions, flaggedExtensions))
.map((config) => config.getExtension(props));
return documentExtensions;
};
@@ -2,19 +2,19 @@ import { AnyExtension, Extensions } from "@tiptap/core";
// extensions
import { SlashCommands } from "@/extensions/slash-commands/root";
// types
import { TExtensions, TFileHandler } from "@/types";
import { IEditorProps, TExtensions } from "@/types";
export type TRichTextEditorAdditionalExtensionsProps = {
disabledExtensions: TExtensions[];
fileHandler: TFileHandler;
};
export type TRichTextEditorAdditionalExtensionsProps = Pick<
IEditorProps,
"disabledExtensions" | "flaggedExtensions" | "fileHandler"
>;
/**
* Registry entry configuration for extensions
*/
export type TRichTextEditorAdditionalExtensionsRegistry = {
/** Determines if the extension should be enabled based on disabled extensions */
isEnabled: (disabledExtensions: TExtensions[]) => boolean;
isEnabled: (disabledExtensions: TExtensions[], flaggedExtensions: TExtensions[]) => boolean;
/** Returns the extension instance(s) when enabled */
getExtension: (props: TRichTextEditorAdditionalExtensionsProps) => AnyExtension | undefined;
};
@@ -22,18 +22,19 @@ export type TRichTextEditorAdditionalExtensionsRegistry = {
const extensionRegistry: TRichTextEditorAdditionalExtensionsRegistry[] = [
{
isEnabled: (disabledExtensions) => !disabledExtensions.includes("slash-commands"),
getExtension: ({ disabledExtensions }) =>
getExtension: ({ disabledExtensions, flaggedExtensions }) =>
SlashCommands({
disabledExtensions,
flaggedExtensions,
}),
},
];
export const RichTextEditorAdditionalExtensions = (props: TRichTextEditorAdditionalExtensionsProps) => {
const { disabledExtensions } = props;
const { disabledExtensions, flaggedExtensions } = props;
const extensions: Extensions = extensionRegistry
.filter((config) => config.isEnabled(disabledExtensions))
.filter((config) => config.isEnabled(disabledExtensions, flaggedExtensions))
.map((config) => config.getExtension(props))
.filter((extension): extension is AnyExtension => extension !== undefined);
@@ -1,11 +1,11 @@
import { AnyExtension, Extensions } from "@tiptap/core";
// types
import { TExtensions, TReadOnlyFileHandler } from "@/types";
import { IReadOnlyEditorProps, TExtensions } from "@/types";
export type TRichTextReadOnlyEditorAdditionalExtensionsProps = {
disabledExtensions: TExtensions[];
fileHandler: TReadOnlyFileHandler;
};
export type TRichTextReadOnlyEditorAdditionalExtensionsProps = Pick<
IReadOnlyEditorProps,
"disabledExtensions" | "flaggedExtensions" | "fileHandler"
>;
/**
* Registry entry configuration for extensions
@@ -1,11 +1,9 @@
// extensions
import { TSlashCommandAdditionalOption } from "@/extensions";
import type { TSlashCommandAdditionalOption } from "@/extensions";
// types
import { TExtensions } from "@/types";
import type { IEditorProps } from "@/types";
type Props = {
disabledExtensions?: TExtensions[];
};
type Props = Pick<IEditorProps, "disabledExtensions" | "flaggedExtensions">;
export const coreEditorAdditionalSlashCommandOptions = (props: Props): TSlashCommandAdditionalOption[] => {
const {} = props;
+19
View File
@@ -0,0 +1,19 @@
/**
* @description function to extract all additional assets from HTML content
* @param htmlContent
* @returns {string[]} array of additional asset sources
*/
export const extractAdditionalAssetsFromHTMLContent = (_htmlContent: string): string[] => [];
/**
* @description function to replace additional assets in HTML content with new IDs
* @param props
* @returns {string} HTML content with replaced additional assets
*/
export const replaceAdditionalAssetsInHTMLContent = (props: {
htmlContent: string;
assetMap: Record<string, string>;
}): string => {
const { htmlContent } = props;
return htmlContent;
};
+1 -1
View File
@@ -2,7 +2,7 @@
import { CORE_EXTENSIONS } from "@/constants/extension";
// extensions
import { type HeadingExtensionStorage } from "@/extensions";
import { type CustomImageExtensionStorage } from "@/extensions/custom-image";
import { type CustomImageExtensionStorage } from "@/extensions/custom-image/types";
import { type CustomLinkStorage } from "@/extensions/custom-link";
import { type ImageExtensionStorage } from "@/extensions/image";
import { type MentionExtensionStorage } from "@/extensions/mentions";
@@ -13,10 +13,11 @@ import { getEditorClassNames } from "@/helpers/common";
// hooks
import { useCollaborativeEditor } from "@/hooks/use-collaborative-editor";
// types
import { EditorRefApi, ICollaborativeDocumentEditor } from "@/types";
import { EditorRefApi, ICollaborativeDocumentEditorProps } from "@/types";
const CollaborativeDocumentEditor = (props: ICollaborativeDocumentEditor) => {
const CollaborativeDocumentEditor: React.FC<ICollaborativeDocumentEditorProps> = (props) => {
const {
onChange,
onTransaction,
aiHandler,
bubbleMenuEnabled = true,
@@ -27,6 +28,7 @@ const CollaborativeDocumentEditor = (props: ICollaborativeDocumentEditor) => {
editorClassName = "",
embedHandler,
fileHandler,
flaggedExtensions,
forwardedRef,
handleEditorReady,
id,
@@ -56,10 +58,12 @@ const CollaborativeDocumentEditor = (props: ICollaborativeDocumentEditor) => {
embedHandler,
extensions,
fileHandler,
flaggedExtensions,
forwardedRef,
handleEditorReady,
id,
mentionHandler,
onChange,
onTransaction,
placeholder,
realtimeConfig,
@@ -95,7 +99,7 @@ const CollaborativeDocumentEditor = (props: ICollaborativeDocumentEditor) => {
);
};
const CollaborativeDocumentEditorWithRef = React.forwardRef<EditorRefApi, ICollaborativeDocumentEditor>(
const CollaborativeDocumentEditorWithRef = React.forwardRef<EditorRefApi, ICollaborativeDocumentEditorProps>(
(props, ref) => (
<CollaborativeDocumentEditor {...props} forwardedRef={ref as React.MutableRefObject<EditorRefApi | null>} />
)
@@ -5,7 +5,7 @@ import { AIFeaturesMenu, BlockMenu, EditorBubbleMenu } from "@/components/menus"
// types
import { TAIHandler, TDisplayConfig } from "@/types";
type IPageRenderer = {
type Props = {
aiHandler?: TAIHandler;
bubbleMenuEnabled: boolean;
displayConfig: TDisplayConfig;
@@ -15,7 +15,7 @@ type IPageRenderer = {
tabIndex?: number;
};
export const PageRenderer = (props: IPageRenderer) => {
export const PageRenderer = (props: Props) => {
const { aiHandler, bubbleMenuEnabled, displayConfig, editor, editorContainerClassName, id, tabIndex } = props;
return (
@@ -1,5 +1,5 @@
import { Extensions } from "@tiptap/core";
import { forwardRef, MutableRefObject } from "react";
import React, { forwardRef, MutableRefObject } from "react";
// plane imports
import { cn } from "@plane/utils";
// components
@@ -13,30 +13,9 @@ import { getEditorClassNames } from "@/helpers/common";
// hooks
import { useReadOnlyEditor } from "@/hooks/use-read-only-editor";
// types
import {
EditorReadOnlyRefApi,
TDisplayConfig,
TExtensions,
TReadOnlyFileHandler,
TReadOnlyMentionHandler,
} from "@/types";
import { EditorReadOnlyRefApi, IDocumentReadOnlyEditorProps } from "@/types";
interface IDocumentReadOnlyEditor {
disabledExtensions: TExtensions[];
id: string;
initialValue: string;
containerClassName: string;
displayConfig?: TDisplayConfig;
editorClassName?: string;
embedHandler: any;
fileHandler: TReadOnlyFileHandler;
tabIndex?: number;
handleEditorReady?: (value: boolean) => void;
mentionHandler: TReadOnlyMentionHandler;
forwardedRef?: React.MutableRefObject<EditorReadOnlyRefApi | null>;
}
const DocumentReadOnlyEditor = (props: IDocumentReadOnlyEditor) => {
const DocumentReadOnlyEditor: React.FC<IDocumentReadOnlyEditorProps> = (props) => {
const {
containerClassName,
disabledExtensions,
@@ -44,6 +23,7 @@ const DocumentReadOnlyEditor = (props: IDocumentReadOnlyEditor) => {
editorClassName = "",
embedHandler,
fileHandler,
flaggedExtensions,
id,
forwardedRef,
handleEditorReady,
@@ -64,6 +44,7 @@ const DocumentReadOnlyEditor = (props: IDocumentReadOnlyEditor) => {
editorClassName,
extensions,
fileHandler,
flaggedExtensions,
forwardedRef,
handleEditorReady,
initialValue,
@@ -87,7 +68,7 @@ const DocumentReadOnlyEditor = (props: IDocumentReadOnlyEditor) => {
);
};
const DocumentReadOnlyEditorWithRef = forwardRef<EditorReadOnlyRefApi, IDocumentReadOnlyEditor>((props, ref) => (
const DocumentReadOnlyEditorWithRef = forwardRef<EditorReadOnlyRefApi, IDocumentReadOnlyEditorProps>((props, ref) => (
<DocumentReadOnlyEditor {...props} forwardedRef={ref as MutableRefObject<EditorReadOnlyRefApi | null>} />
));
@@ -53,17 +53,14 @@ export const EditorContainer: FC<EditorContainerProps> = (props) => {
const lastNodePos = editor.state.doc.resolve(Math.max(0, docSize - 2));
const lastNode = lastNodePos.node();
// Check if the last node is a not paragraph
if (lastNode && lastNode.type.name !== CORE_EXTENSIONS.PARAGRAPH) {
// If last node is not a paragraph, insert a new paragraph at the end
const endPosition = editor?.state.doc.content.size;
editor?.chain().insertContentAt(endPosition, { type: CORE_EXTENSIONS.PARAGRAPH }).run();
// Focus the newly added paragraph for immediate editing
editor
.chain()
.setTextSelection(endPosition + 1)
.run();
// Check if its last node and add new node
if (lastNode) {
const isLastNodeEmptyParagraph = lastNode.type.name === CORE_EXTENSIONS.PARAGRAPH && lastNode.content.size === 0;
// Only insert a new paragraph if the last node is not an empty paragraph and not a doc node
if (!isLastNodeEmptyParagraph && lastNode.type.name !== "doc") {
const endPosition = editor?.state.doc.content.size;
editor?.chain().insertContentAt(endPosition, { type: "paragraph" }).focus("end").run();
}
}
} catch (error) {
console.error("An error occurred while handling container click to insert new empty node at bottom:", error);
@@ -26,6 +26,7 @@ export const EditorWrapper: React.FC<Props> = (props) => {
id,
initialValue,
fileHandler,
flaggedExtensions,
forwardedRef,
mentionHandler,
onChange,
@@ -44,6 +45,7 @@ export const EditorWrapper: React.FC<Props> = (props) => {
enableHistory: true,
extensions,
fileHandler,
flaggedExtensions,
forwardedRef,
id,
initialValue,
@@ -4,23 +4,25 @@ import { EditorWrapper } from "@/components/editors/editor-wrapper";
// extensions
import { EnterKeyExtension } from "@/extensions";
// types
import { EditorRefApi, ILiteTextEditor } from "@/types";
import { EditorRefApi, ILiteTextEditorProps } from "@/types";
const LiteTextEditor = (props: ILiteTextEditor) => {
const LiteTextEditor: React.FC<ILiteTextEditorProps> = (props) => {
const { onEnterKeyPress, disabledExtensions, extensions: externalExtensions = [] } = props;
const extensions = useMemo(
() => [
...externalExtensions,
...(disabledExtensions?.includes("enter-key") ? [] : [EnterKeyExtension(onEnterKeyPress)]),
],
[externalExtensions, disabledExtensions, onEnterKeyPress]
);
const extensions = useMemo(() => {
const resolvedExtensions = [...externalExtensions];
if (!disabledExtensions?.includes("enter-key")) {
resolvedExtensions.push(EnterKeyExtension(onEnterKeyPress));
}
return resolvedExtensions;
}, [externalExtensions, disabledExtensions, onEnterKeyPress]);
return <EditorWrapper {...props} extensions={extensions} />;
};
const LiteTextEditorWithRef = forwardRef<EditorRefApi, ILiteTextEditor>((props, ref) => (
const LiteTextEditorWithRef = forwardRef<EditorRefApi, ILiteTextEditorProps>((props, ref) => (
<LiteTextEditor {...props} forwardedRef={ref as React.MutableRefObject<EditorRefApi | null>} />
));
@@ -2,9 +2,9 @@ import { forwardRef } from "react";
// components
import { ReadOnlyEditorWrapper } from "@/components/editors";
// types
import { EditorReadOnlyRefApi, ILiteTextReadOnlyEditor } from "@/types";
import { EditorReadOnlyRefApi, ILiteTextReadOnlyEditorProps } from "@/types";
const LiteTextReadOnlyEditorWithRef = forwardRef<EditorReadOnlyRefApi, ILiteTextReadOnlyEditor>((props, ref) => (
const LiteTextReadOnlyEditorWithRef = forwardRef<EditorReadOnlyRefApi, ILiteTextReadOnlyEditorProps>((props, ref) => (
<ReadOnlyEditorWrapper {...props} forwardedRef={ref as React.MutableRefObject<EditorReadOnlyRefApi | null>} />
));
@@ -17,6 +17,7 @@ export const ReadOnlyEditorWrapper = (props: IReadOnlyEditorProps) => {
editorClassName = "",
extensions,
fileHandler,
flaggedExtensions,
forwardedRef,
id,
initialValue,
@@ -28,6 +29,7 @@ export const ReadOnlyEditorWrapper = (props: IReadOnlyEditorProps) => {
editorClassName,
extensions,
fileHandler,
flaggedExtensions,
forwardedRef,
initialValue,
mentionHandler,
@@ -7,15 +7,16 @@ import { SideMenuExtension } from "@/extensions";
// plane editor imports
import { RichTextEditorAdditionalExtensions } from "@/plane-editor/extensions/rich-text/extensions";
// types
import { EditorRefApi, IRichTextEditor } from "@/types";
import { EditorRefApi, IRichTextEditorProps } from "@/types";
const RichTextEditor = (props: IRichTextEditor) => {
const RichTextEditor: React.FC<IRichTextEditorProps> = (props) => {
const {
bubbleMenuEnabled = true,
disabledExtensions,
dragDropEnabled,
fileHandler,
bubbleMenuEnabled = true,
extensions: externalExtensions = [],
fileHandler,
flaggedExtensions,
} = props;
const getExtensions = useCallback(() => {
@@ -28,11 +29,12 @@ const RichTextEditor = (props: IRichTextEditor) => {
...RichTextEditorAdditionalExtensions({
disabledExtensions,
fileHandler,
flaggedExtensions,
}),
];
return extensions;
}, [dragDropEnabled, disabledExtensions, externalExtensions, fileHandler]);
}, [dragDropEnabled, disabledExtensions, externalExtensions, fileHandler, flaggedExtensions]);
return (
<EditorWrapper {...props} extensions={getExtensions()}>
@@ -41,7 +43,7 @@ const RichTextEditor = (props: IRichTextEditor) => {
);
};
const RichTextEditorWithRef = forwardRef<EditorRefApi, IRichTextEditor>((props, ref) => (
const RichTextEditorWithRef = forwardRef<EditorRefApi, IRichTextEditorProps>((props, ref) => (
<RichTextEditor {...props} forwardedRef={ref as React.MutableRefObject<EditorRefApi | null>} />
));
@@ -2,23 +2,22 @@ import { forwardRef, useCallback } from "react";
// plane editor extensions
import { RichTextReadOnlyEditorAdditionalExtensions } from "@/plane-editor/extensions/rich-text/read-only-extensions";
// types
import { EditorReadOnlyRefApi, IRichTextReadOnlyEditor } from "@/types";
import { EditorReadOnlyRefApi, IRichTextReadOnlyEditorProps } from "@/types";
// local imports
import { ReadOnlyEditorWrapper } from "../read-only-editor-wrapper";
const RichTextReadOnlyEditorWithRef = forwardRef<EditorReadOnlyRefApi, IRichTextReadOnlyEditor>((props, ref) => {
const { disabledExtensions, fileHandler } = props;
const RichTextReadOnlyEditorWithRef = forwardRef<EditorReadOnlyRefApi, IRichTextReadOnlyEditorProps>((props, ref) => {
const { disabledExtensions, fileHandler, flaggedExtensions } = props;
const getExtensions = useCallback(() => {
const extensions = [
...RichTextReadOnlyEditorAdditionalExtensions({
disabledExtensions,
fileHandler,
}),
];
const extensions = RichTextReadOnlyEditorAdditionalExtensions({
disabledExtensions,
fileHandler,
flaggedExtensions,
});
return extensions;
}, [disabledExtensions, fileHandler]);
}, [disabledExtensions, fileHandler, flaggedExtensions]);
return (
<ReadOnlyEditorWrapper
@@ -10,6 +10,7 @@ import {
BubbleMenuLinkSelector,
BubbleMenuNodeSelector,
CodeItem,
EditorMenuItem,
ItalicItem,
StrikeThroughItem,
TextAlignItem,
@@ -23,6 +24,7 @@ import { CORE_EXTENSIONS } from "@/constants/extension";
import { isCellSelection } from "@/extensions/table/table/utilities/is-cell-selection";
// local components
import { TextAlignmentSelector } from "./alignment-selector";
import { TEditorCommands } from "@/types";
type EditorBubbleMenuProps = Omit<BubbleMenuProps, "children">;
@@ -31,19 +33,19 @@ export interface EditorStateType {
bold: boolean;
italic: boolean;
underline: boolean;
strike: boolean;
strikethrough: boolean;
left: boolean;
right: boolean;
center: boolean;
color: { key: string; label: string; textColor: string; backgroundColor: string } | undefined;
backgroundColor:
| {
key: string;
label: string;
textColor: string;
backgroundColor: string;
}
| undefined;
| {
key: string;
label: string;
textColor: string;
backgroundColor: string;
}
| undefined;
}
export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: { editor: Editor }) => {
@@ -58,8 +60,10 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: { editor: Edi
bold: BoldItem(props.editor),
italic: ItalicItem(props.editor),
underline: UnderLineItem(props.editor),
strike: StrikeThroughItem(props.editor),
textAlign: TextAlignItem(props.editor),
strikethrough: StrikeThroughItem(props.editor),
"text-align": TextAlignItem(props.editor),
} satisfies {
[K in TEditorCommands]?: EditorMenuItem<K>;
};
const editorState: EditorStateType = useEditorState({
@@ -69,10 +73,10 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: { editor: Edi
bold: formattingItems.bold.isActive(),
italic: formattingItems.italic.isActive(),
underline: formattingItems.underline.isActive(),
strike: formattingItems.strike.isActive(),
left: formattingItems.textAlign.isActive({ alignment: "left" }),
right: formattingItems.textAlign.isActive({ alignment: "right" }),
center: formattingItems.textAlign.isActive({ alignment: "center" }),
strikethrough: formattingItems.strikethrough.isActive(),
left: formattingItems["text-align"].isActive({ alignment: "left" }),
right: formattingItems["text-align"].isActive({ alignment: "right" }),
center: formattingItems["text-align"].isActive({ alignment: "center" }),
color: COLORS_LIST.find((c) => TextColorItem(editor).isActive({ color: c.key })),
backgroundColor: COLORS_LIST.find((c) => BackgroundColorItem(editor).isActive({ color: c.key })),
}),
@@ -80,7 +84,7 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: { editor: Edi
const basicFormattingOptions = editorState.code
? [formattingItems.code]
: [formattingItems.bold, formattingItems.italic, formattingItems.underline, formattingItems.strike];
: [formattingItems.bold, formattingItems.italic, formattingItems.underline, formattingItems.strikethrough];
const bubbleMenuProps: EditorBubbleMenuProps = {
...props,
@@ -12,10 +12,10 @@ import { CustomCalloutExtensionConfig } from "./callout/extension-config";
import { CustomCodeBlockExtensionWithoutProps } from "./code/without-props";
import { CustomCodeInlineExtension } from "./code-inline";
import { CustomColorExtension } from "./custom-color";
import { CustomImageExtensionConfig } from "./custom-image/extension-config";
import { CustomLinkExtension } from "./custom-link";
import { CustomHorizontalRule } from "./horizontal-rule";
import { ImageExtensionWithoutProps } from "./image";
import { CustomImageComponentWithoutProps } from "./image/image-component-without-props";
import { ImageExtensionConfig } from "./image";
import { CustomMentionExtensionConfig } from "./mentions/extension-config";
import { CustomQuoteExtension } from "./quote";
import { TableHeader, TableCell, TableRow, Table } from "./table";
@@ -72,12 +72,8 @@ export const CoreEditorExtensionsWithoutProps = [
"text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer",
},
}),
ImageExtensionWithoutProps.configure({
HTMLAttributes: {
class: "rounded-md",
},
}),
CustomImageComponentWithoutProps,
ImageExtensionConfig,
CustomImageExtensionConfig,
TiptapUnderline,
TextStyle,
TaskList.configure({
@@ -1,68 +1,42 @@
import { NodeSelection } from "@tiptap/pm/state";
import React, { useRef, useState, useCallback, useLayoutEffect, useEffect } from "react";
// plane utils
// plane imports
import { cn } from "@plane/utils";
// extensions
import { CustomBaseImageNodeViewProps, ImageToolbarRoot } from "@/extensions/custom-image";
// local imports
import { Pixel, TCustomImageAttributes, TCustomImageSize } from "../types";
import { ensurePixelString } from "../utils";
import type { CustomImageNodeViewProps } from "./node-view";
import { ImageToolbarRoot } from "./toolbar";
import { ImageUploadStatus } from "./upload-status";
const MIN_SIZE = 100;
type Pixel = `${number}px`;
type PixelAttribute<TDefault> = Pixel | TDefault;
export type ImageAttributes = {
src: string | null;
width: PixelAttribute<"35%" | number>;
height: PixelAttribute<"auto" | number>;
aspectRatio: number | null;
id: string | null;
};
type Size = {
width: PixelAttribute<"35%">;
height: PixelAttribute<"auto">;
aspectRatio: number | null;
};
const ensurePixelString = <TDefault,>(value: Pixel | TDefault | number | undefined | null, defaultValue?: TDefault) => {
if (!value || value === defaultValue) {
return defaultValue;
}
if (typeof value === "number") {
return `${value}px` satisfies Pixel;
}
return value;
};
type CustomImageBlockProps = CustomBaseImageNodeViewProps & {
imageFromFileSystem: string | undefined;
setFailedToLoadImage: (isError: boolean) => void;
type CustomImageBlockProps = CustomImageNodeViewProps & {
editorContainer: HTMLDivElement | null;
imageFromFileSystem: string | undefined;
setEditorContainer: (editorContainer: HTMLDivElement | null) => void;
setFailedToLoadImage: (isError: boolean) => void;
src: string | undefined;
};
export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
// props
const {
node,
updateAttributes,
setFailedToLoadImage,
imageFromFileSystem,
selected,
getPos,
editor,
editorContainer,
src: resolvedImageSrc,
extension,
getPos,
imageFromFileSystem,
node,
selected,
setEditorContainer,
setFailedToLoadImage,
src: resolvedImageSrc,
updateAttributes,
} = props;
const { width: nodeWidth, height: nodeHeight, aspectRatio: nodeAspectRatio, src: imgNodeSrc } = node.attrs;
// states
const [size, setSize] = useState<Size>({
const [size, setSize] = useState<TCustomImageSize>({
width: ensurePixelString(nodeWidth, "35%") ?? "35%",
height: ensurePixelString(nodeHeight, "auto") ?? "auto",
aspectRatio: nodeAspectRatio || null,
@@ -77,7 +51,7 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
const [hasTriedRestoringImageOnce, setHasTriedRestoringImageOnce] = useState(false);
const updateAttributesSafely = useCallback(
(attributes: Partial<ImageAttributes>, errorMessage: string) => {
(attributes: Partial<TCustomImageAttributes>, errorMessage: string) => {
try {
updateAttributes(attributes);
} catch (error) {
@@ -114,7 +88,7 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
const initialWidth = Math.max(editorWidth * 0.35, MIN_SIZE);
const initialHeight = initialWidth / aspectRatioCalculated;
const initialComputedSize = {
const initialComputedSize: TCustomImageSize = {
width: `${Math.round(initialWidth)}px` satisfies Pixel,
height: `${Math.round(initialHeight)}px` satisfies Pixel,
aspectRatio: aspectRatioCalculated,
@@ -139,7 +113,7 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
}
}
setInitialResizeComplete(true);
}, [nodeWidth, updateAttributes, editorContainer, nodeAspectRatio]);
}, [nodeWidth, updateAttributesSafely, editorContainer, nodeAspectRatio, setEditorContainer]);
// for real time resizing
useLayoutEffect(() => {
@@ -168,7 +142,7 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
const handleResizeEnd = useCallback(() => {
setIsResizing(false);
updateAttributesSafely(size, "Failed to update attributes at the end of resizing:");
}, [size, updateAttributes]);
}, [size, updateAttributesSafely]);
const handleResizeStart = useCallback((e: React.MouseEvent | React.TouchEvent) => {
e.preventDefault();
@@ -242,7 +216,7 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
onLoad={handleImageLoad}
onError={async (e) => {
// for old image extension this command doesn't exist or if the image failed to load for the first time
if (!editor?.commands.restoreImage || hasTriedRestoringImageOnce) {
if (!extension.options.restoreImage || hasTriedRestoringImageOnce) {
setFailedToLoadImage(true);
return;
}
@@ -253,7 +227,7 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
if (!imgNodeSrc) {
throw new Error("No source image to restore from");
}
await editor?.commands.restoreImage?.(imgNodeSrc);
await extension.options.restoreImage?.(imgNodeSrc);
if (!imageRef.current) {
throw new Error("Image reference not found");
}
@@ -289,10 +263,10 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
"absolute top-1 right-1 z-20 bg-black/40 rounded opacity-0 pointer-events-none group-hover/image-component:opacity-100 group-hover/image-component:pointer-events-auto transition-opacity"
}
image={{
src: resolvedImageSrc,
aspectRatio: size.aspectRatio === null ? 1 : size.aspectRatio,
height: size.height,
width: size.width,
height: size.height,
aspectRatio: size.aspectRatio === null ? 1 : size.aspectRatio,
src: resolvedImageSrc,
}}
/>
)}
@@ -1,4 +0,0 @@
export * from "./toolbar";
export * from "./image-block";
export * from "./image-node";
export * from "./image-uploader";
@@ -2,25 +2,26 @@ import { Editor, NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import { useEffect, useRef, useState } from "react";
// constants
import { CORE_EXTENSIONS } from "@/constants/extension";
// extensions
import { CustomImageBlock, CustomImageUploader, ImageAttributes } from "@/extensions/custom-image";
// helpers
import { getExtensionStorage } from "@/helpers/get-extension-storage";
// local imports
import type { CustomImageExtension, TCustomImageAttributes } from "../types";
import { CustomImageBlock } from "./block";
import { CustomImageUploader } from "./uploader";
export type CustomBaseImageNodeViewProps = {
export type CustomImageNodeViewProps = Omit<NodeViewProps, "extension" | "updateAttributes"> & {
extension: CustomImageExtension;
getPos: () => number;
editor: Editor;
node: NodeViewProps["node"] & {
attrs: ImageAttributes;
attrs: TCustomImageAttributes;
};
updateAttributes: (attrs: Partial<ImageAttributes>) => void;
updateAttributes: (attrs: Partial<TCustomImageAttributes>) => void;
selected: boolean;
};
export type CustomImageNodeProps = NodeViewProps & CustomBaseImageNodeViewProps;
export const CustomImageNode = (props: CustomImageNodeProps) => {
const { getPos, editor, node, updateAttributes, selected } = props;
export const CustomImageNodeView: React.FC<CustomImageNodeViewProps> = (props) => {
const { editor, extension, node } = props;
const { src: imgNodeSrc } = node.attrs;
const [isUploaded, setIsUploaded] = useState(false);
@@ -50,41 +51,37 @@ export const CustomImageNode = (props: CustomImageNodeProps) => {
}, [resolvedSrc]);
useEffect(() => {
if (!imgNodeSrc) {
setResolvedSrc(undefined);
return;
}
const getImageSource = async () => {
// @ts-expect-error function not expected here, but will still work and don't remove await
const url: string = await editor?.commands?.getImageSource?.(imgNodeSrc);
setResolvedSrc(url as string);
const url = await extension.options.getImageSource?.(imgNodeSrc);
setResolvedSrc(url);
};
getImageSource();
}, [imgNodeSrc]);
}, [imgNodeSrc, extension.options]);
return (
<NodeViewWrapper>
<div className="p-0 mx-0 my-2" data-drag-handle ref={imageComponentRef}>
{(isUploaded || imageFromFileSystem) && !failedToLoadImage ? (
<CustomImageBlock
imageFromFileSystem={imageFromFileSystem}
editorContainer={editorContainer}
editor={editor}
src={resolvedSrc}
getPos={getPos}
node={node}
imageFromFileSystem={imageFromFileSystem}
setEditorContainer={setEditorContainer}
setFailedToLoadImage={setFailedToLoadImage}
selected={selected}
updateAttributes={updateAttributes}
src={resolvedSrc}
{...props}
/>
) : (
<CustomImageUploader
editor={editor}
failedToLoadImage={failedToLoadImage}
getPos={getPos}
loadImageFromFileSystem={setImageFromFileSystem}
maxFileSize={getExtensionStorage(editor, CORE_EXTENSIONS.CUSTOM_IMAGE).maxFileSize}
node={node}
setIsUploaded={setIsUploaded}
selected={selected}
updateAttributes={updateAttributes}
{...props}
/>
)}
</div>
@@ -1,14 +1,14 @@
import { ExternalLink, Maximize, Minus, Plus, X } from "lucide-react";
import { useCallback, useEffect, useMemo, useState, useRef } from "react";
// plane utils
// plane imports
import { cn } from "@plane/utils";
type Props = {
image: {
src: string;
height: string;
width: string;
height: string;
aspectRatio: number;
src: string;
};
isOpen: boolean;
toggleFullScreenMode: (val: boolean) => void;
@@ -189,7 +189,7 @@ export const ImageFullScreenAction: React.FC<Props> = (props) => {
<>
<div
className={cn("fixed inset-0 size-full z-20 bg-black/90 opacity-0 pointer-events-none transition-opacity", {
"opacity-100 pointer-events-auto": isFullScreenEnabled,
"opacity-100 pointer-events-auto editor-image-full-screen-modal": isFullScreenEnabled,
"cursor-default": !isDragging,
"cursor-grabbing": isDragging,
})}
@@ -1,16 +1,16 @@
import { useState } from "react";
// plane utils
// plane imports
import { cn } from "@plane/utils";
// components
// local imports
import { ImageFullScreenAction } from "./full-screen";
type Props = {
containerClassName?: string;
image: {
src: string;
height: string;
width: string;
height: string;
aspectRatio: number;
src: string;
};
};
@@ -1,28 +1,30 @@
import { ImageIcon } from "lucide-react";
import { ChangeEvent, useCallback, useEffect, useMemo, useRef } from "react";
// plane utils
// plane imports
import { cn } from "@plane/utils";
// constants
import { ACCEPTED_IMAGE_MIME_TYPES } from "@/constants/config";
import { CORE_EXTENSIONS } from "@/constants/extension";
// extensions
import { CustomBaseImageNodeViewProps, getImageComponentImageFileMap } from "@/extensions/custom-image";
// helpers
import { EFileError } from "@/helpers/file";
import { getExtensionStorage } from "@/helpers/get-extension-storage";
// hooks
import { useUploader, useDropZone, uploadFirstFileAndInsertRemaining } from "@/hooks/use-file-upload";
// local imports
import { getImageComponentImageFileMap } from "../utils";
import type { CustomImageNodeViewProps } from "./node-view";
type CustomImageUploaderProps = CustomBaseImageNodeViewProps & {
maxFileSize: number;
loadImageFromFileSystem: (file: string) => void;
type CustomImageUploaderProps = CustomImageNodeViewProps & {
failedToLoadImage: boolean;
loadImageFromFileSystem: (file: string) => void;
maxFileSize: number;
setIsUploaded: (isUploaded: boolean) => void;
};
export const CustomImageUploader = (props: CustomImageUploaderProps) => {
const {
editor,
extension,
failedToLoadImage,
getPos,
loadImageFromFileSystem,
@@ -71,12 +73,13 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
}
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[imageComponentImageFileMap, imageEntityId, updateAttributes, getPos]
);
const uploadImageEditorCommand = useCallback(
async (file: File) => await editor?.commands.uploadImage(imageEntityId ?? "", file),
[editor, imageEntityId]
async (file: File) => await extension.options.uploadImage?.(imageEntityId ?? "", file),
[extension.options, imageEntityId]
);
const handleProgressStatus = useCallback(
@@ -93,7 +96,6 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
// hooks
const { isUploading: isImageBeingUploaded, uploadFile } = useUploader({
acceptedMimeTypes: ACCEPTED_IMAGE_MIME_TYPES,
// @ts-expect-error - TODO: fix typings, and don't remove await from here for now
editorCommand: uploadImageEditorCommand,
handleProgressStatus,
loadFileFromFileSystem: loadImageFromFileSystem,
@@ -128,7 +130,7 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
imageComponentImageFileMap?.set(imageEntityId ?? "", { ...meta, hasOpenedFileInputOnce: true });
}
}
}, [meta, uploadFile, imageComponentImageFileMap]);
}, [meta, uploadFile, imageComponentImageFileMap, imageEntityId]);
const onFileChange = useCallback(
async (e: ChangeEvent<HTMLInputElement>) => {
@@ -163,7 +165,7 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
}
return "Add an image";
}, [draggedInside, failedToLoadImage, isImageBeingUploaded]);
}, [draggedInside, failedToLoadImage, isImageBeingUploaded, editor.isEditable]);
return (
<div
@@ -1,180 +0,0 @@
import { Editor, mergeAttributes } from "@tiptap/core";
import { Image as BaseImageExtension } from "@tiptap/extension-image";
import { ReactNodeViewRenderer } from "@tiptap/react";
import { v4 as uuidv4 } from "uuid";
// constants
import { ACCEPTED_IMAGE_MIME_TYPES } from "@/constants/config";
import { CORE_EXTENSIONS } from "@/constants/extension";
// extensions
import { CustomImageNode } from "@/extensions/custom-image";
// helpers
import { isFileValid } from "@/helpers/file";
import { getExtensionStorage } from "@/helpers/get-extension-storage";
import { insertEmptyParagraphAtNodeBoundaries } from "@/helpers/insert-empty-paragraph-at-node-boundary";
// types
import { TFileHandler } from "@/types";
export type InsertImageComponentProps = {
file?: File;
pos?: number;
event: "insert" | "drop";
};
declare module "@tiptap/core" {
interface Commands<ReturnType> {
[CORE_EXTENSIONS.CUSTOM_IMAGE]: {
insertImageComponent: ({ file, pos, event }: InsertImageComponentProps) => ReturnType;
uploadImage: (blockId: string, file: File) => () => Promise<string> | undefined;
getImageSource?: (path: string) => () => Promise<string>;
restoreImage: (src: string) => () => Promise<void>;
};
}
}
export const getImageComponentImageFileMap = (editor: Editor) =>
getExtensionStorage(editor, CORE_EXTENSIONS.CUSTOM_IMAGE)?.fileMap;
export interface CustomImageExtensionStorage {
fileMap: Map<string, UploadEntity>;
deletedImageSet: Map<string, boolean>;
maxFileSize: number;
}
export type UploadEntity = ({ event: "insert" } | { event: "drop"; file: File }) & { hasOpenedFileInputOnce?: boolean };
export const CustomImageExtension = (props: TFileHandler) => {
const {
getAssetSrc,
upload,
restore: restoreImageFn,
validation: { maxFileSize },
} = props;
return BaseImageExtension.extend<Record<string, unknown>, CustomImageExtensionStorage>({
name: CORE_EXTENSIONS.CUSTOM_IMAGE,
selectable: true,
group: "block",
atom: true,
draggable: true,
addAttributes() {
return {
...this.parent?.(),
width: {
default: "35%",
},
src: {
default: null,
},
height: {
default: "auto",
},
["id"]: {
default: null,
},
aspectRatio: {
default: null,
},
};
},
parseHTML() {
return [
{
tag: "image-component",
},
];
},
renderHTML({ HTMLAttributes }) {
return ["image-component", mergeAttributes(HTMLAttributes)];
},
addKeyboardShortcuts() {
return {
ArrowDown: insertEmptyParagraphAtNodeBoundaries("down", this.name),
ArrowUp: insertEmptyParagraphAtNodeBoundaries("up", this.name),
};
},
addStorage() {
return {
fileMap: new Map(),
deletedImageSet: new Map<string, boolean>(),
maxFileSize,
// escape markdown for images
markdown: {
serialize() {},
},
};
},
addCommands() {
return {
insertImageComponent:
(props) =>
({ commands }) => {
// Early return if there's an invalid file being dropped
if (
props?.file &&
!isFileValid({
acceptedMimeTypes: ACCEPTED_IMAGE_MIME_TYPES,
file: props.file,
maxFileSize,
onError: (_error, message) => alert(message),
})
) {
return false;
}
// generate a unique id for the image to keep track of dropped
// files' file data
const fileId = uuidv4();
const imageComponentImageFileMap = getImageComponentImageFileMap(this.editor);
if (imageComponentImageFileMap) {
if (props?.event === "drop" && props.file) {
imageComponentImageFileMap.set(fileId, {
file: props.file,
event: props.event,
});
} else if (props.event === "insert") {
imageComponentImageFileMap.set(fileId, {
event: props.event,
hasOpenedFileInputOnce: false,
});
}
}
const attributes = {
id: fileId,
};
if (props.pos) {
return commands.insertContentAt(props.pos, {
type: this.name,
attrs: attributes,
});
}
return commands.insertContent({
type: this.name,
attrs: attributes,
});
},
uploadImage: (blockId, file) => async () => {
const fileUrl = await upload(blockId, file);
return fileUrl;
},
getImageSource: (path) => async () => await getAssetSrc(path),
restoreImage: (src) => async () => {
await restoreImageFn(src);
},
};
},
addNodeView() {
return ReactNodeViewRenderer(CustomImageNode);
},
});
};
@@ -0,0 +1,47 @@
import { mergeAttributes } from "@tiptap/core";
import { Image as BaseImageExtension } from "@tiptap/extension-image";
// constants
import { CORE_EXTENSIONS } from "@/constants/extension";
// local imports
import { type CustomImageExtension, ECustomImageAttributeNames, type InsertImageComponentProps } from "./types";
import { DEFAULT_CUSTOM_IMAGE_ATTRIBUTES } from "./utils";
declare module "@tiptap/core" {
interface Commands<ReturnType> {
[CORE_EXTENSIONS.CUSTOM_IMAGE]: {
insertImageComponent: ({ file, pos, event }: InsertImageComponentProps) => ReturnType;
};
}
}
export const CustomImageExtensionConfig: CustomImageExtension = BaseImageExtension.extend({
name: CORE_EXTENSIONS.CUSTOM_IMAGE,
group: "block",
atom: true,
addAttributes() {
const attributes = {
...this.parent?.(),
...Object.values(ECustomImageAttributeNames).reduce((acc, value) => {
acc[value] = {
default: DEFAULT_CUSTOM_IMAGE_ATTRIBUTES[value],
};
return acc;
}, {}),
};
return attributes;
},
parseHTML() {
return [
{
tag: "image-component",
},
];
},
renderHTML({ HTMLAttributes }) {
return ["image-component", mergeAttributes(HTMLAttributes)];
},
});
@@ -0,0 +1,121 @@
import { ReactNodeViewRenderer } from "@tiptap/react";
import { v4 as uuidv4 } from "uuid";
// constants
import { ACCEPTED_IMAGE_MIME_TYPES } from "@/constants/config";
// helpers
import { isFileValid } from "@/helpers/file";
import { insertEmptyParagraphAtNodeBoundaries } from "@/helpers/insert-empty-paragraph-at-node-boundary";
// types
import type { TFileHandler, TReadOnlyFileHandler } from "@/types";
// local imports
import { CustomImageNodeView } from "./components/node-view";
import { CustomImageExtensionConfig } from "./extension-config";
import { getImageComponentImageFileMap } from "./utils";
type Props = {
fileHandler: TFileHandler | TReadOnlyFileHandler;
isEditable: boolean;
};
export const CustomImageExtension = (props: Props) => {
const { fileHandler, isEditable } = props;
// derived values
const { getAssetSrc, restore: restoreImageFn } = fileHandler;
return CustomImageExtensionConfig.extend({
selectable: isEditable,
draggable: isEditable,
addOptions() {
const upload = "upload" in fileHandler ? fileHandler.upload : undefined;
return {
...this.parent?.(),
getImageSource: getAssetSrc,
restoreImage: restoreImageFn,
uploadImage: upload,
};
},
addStorage() {
const maxFileSize = "validation" in fileHandler ? fileHandler.validation?.maxFileSize : 0;
return {
fileMap: new Map(),
deletedImageSet: new Map<string, boolean>(),
maxFileSize,
// escape markdown for images
markdown: {
serialize() {},
},
};
},
addCommands() {
return {
insertImageComponent:
(props) =>
({ commands }) => {
// Early return if there's an invalid file being dropped
if (
props?.file &&
!isFileValid({
acceptedMimeTypes: ACCEPTED_IMAGE_MIME_TYPES,
file: props.file,
maxFileSize: this.storage.maxFileSize,
onError: (_error, message) => alert(message),
})
) {
return false;
}
// generate a unique id for the image to keep track of dropped
// files' file data
const fileId = uuidv4();
const imageComponentImageFileMap = getImageComponentImageFileMap(this.editor);
if (imageComponentImageFileMap) {
if (props?.event === "drop" && props.file) {
imageComponentImageFileMap.set(fileId, {
file: props.file,
event: props.event,
});
} else if (props.event === "insert") {
imageComponentImageFileMap.set(fileId, {
event: props.event,
hasOpenedFileInputOnce: false,
});
}
}
const attributes = {
id: fileId,
};
if (props.pos) {
return commands.insertContentAt(props.pos, {
type: this.name,
attrs: attributes,
});
}
return commands.insertContent({
type: this.name,
attrs: attributes,
});
},
};
},
addKeyboardShortcuts() {
return {
ArrowDown: insertEmptyParagraphAtNodeBoundaries("down", this.name),
ArrowUp: insertEmptyParagraphAtNodeBoundaries("up", this.name),
};
},
addNodeView() {
return ReactNodeViewRenderer(CustomImageNodeView);
},
});
};
@@ -1,3 +0,0 @@
export * from "./components";
export * from "./custom-image";
export * from "./read-only-custom-image";
@@ -1,79 +0,0 @@
import { mergeAttributes } from "@tiptap/core";
import { Image as BaseImageExtension } from "@tiptap/extension-image";
import { ReactNodeViewRenderer } from "@tiptap/react";
// constants
import { CORE_EXTENSIONS } from "@/constants/extension";
// components
import { CustomImageNode, CustomImageExtensionStorage } from "@/extensions/custom-image";
// types
import { TReadOnlyFileHandler } from "@/types";
export const CustomReadOnlyImageExtension = (props: TReadOnlyFileHandler) => {
const { getAssetSrc, restore: restoreImageFn } = props;
return BaseImageExtension.extend<Record<string, unknown>, CustomImageExtensionStorage>({
name: CORE_EXTENSIONS.CUSTOM_IMAGE,
selectable: false,
group: "block",
atom: true,
draggable: false,
addAttributes() {
return {
...this.parent?.(),
width: {
default: "35%",
},
src: {
default: null,
},
height: {
default: "auto",
},
["id"]: {
default: null,
},
aspectRatio: {
default: null,
},
};
},
parseHTML() {
return [
{
tag: "image-component",
},
];
},
renderHTML({ HTMLAttributes }) {
return ["image-component", mergeAttributes(HTMLAttributes)];
},
addStorage() {
return {
fileMap: new Map(),
deletedImageSet: new Map<string, boolean>(),
maxFileSize: 0,
// escape markdown for images
markdown: {
serialize() {},
},
};
},
addCommands() {
return {
getImageSource: (path: string) => async () => await getAssetSrc(path),
restoreImage: (src) => async () => {
await restoreImageFn(src);
},
};
},
addNodeView() {
return ReactNodeViewRenderer(CustomImageNode);
},
});
};
@@ -0,0 +1,51 @@
import type { Node } from "@tiptap/core";
// types
import type { TFileHandler } from "@/types";
export enum ECustomImageAttributeNames {
ID = "id",
WIDTH = "width",
HEIGHT = "height",
ASPECT_RATIO = "aspectRatio",
SOURCE = "src",
}
export type Pixel = `${number}px`;
export type PixelAttribute<TDefault> = Pixel | TDefault;
export type TCustomImageSize = {
width: PixelAttribute<"35%">;
height: PixelAttribute<"auto">;
aspectRatio: number | null;
};
export type TCustomImageAttributes = {
[ECustomImageAttributeNames.ID]: string | null;
[ECustomImageAttributeNames.WIDTH]: PixelAttribute<"35%" | number> | null;
[ECustomImageAttributeNames.HEIGHT]: PixelAttribute<"auto" | number> | null;
[ECustomImageAttributeNames.ASPECT_RATIO]: number | null;
[ECustomImageAttributeNames.SOURCE]: string | null;
};
export type UploadEntity = ({ event: "insert" } | { event: "drop"; file: File }) & { hasOpenedFileInputOnce?: boolean };
export type InsertImageComponentProps = {
file?: File;
pos?: number;
event: "insert" | "drop";
};
export type CustomImageExtensionOptions = {
getImageSource: TFileHandler["getAssetSrc"];
restoreImage: TFileHandler["restore"];
uploadImage?: TFileHandler["upload"];
};
export type CustomImageExtensionStorage = {
fileMap: Map<string, UploadEntity>;
deletedImageSet: Map<string, boolean>;
maxFileSize: number;
};
export type CustomImageExtension = Node<CustomImageExtensionOptions, CustomImageExtensionStorage>;
@@ -0,0 +1,33 @@
import type { Editor } from "@tiptap/core";
// constants
import { CORE_EXTENSIONS } from "@/constants/extension";
// helpers
import { getExtensionStorage } from "@/helpers/get-extension-storage";
// local imports
import { ECustomImageAttributeNames, type Pixel, type TCustomImageAttributes } from "./types";
export const DEFAULT_CUSTOM_IMAGE_ATTRIBUTES: TCustomImageAttributes = {
[ECustomImageAttributeNames.SOURCE]: null,
[ECustomImageAttributeNames.ID]: null,
[ECustomImageAttributeNames.WIDTH]: "35%",
[ECustomImageAttributeNames.HEIGHT]: "auto",
[ECustomImageAttributeNames.ASPECT_RATIO]: null,
};
export const getImageComponentImageFileMap = (editor: Editor) =>
getExtensionStorage(editor, CORE_EXTENSIONS.CUSTOM_IMAGE)?.fileMap;
export const ensurePixelString = <TDefault>(
value: Pixel | TDefault | number | undefined | null,
defaultValue?: TDefault
) => {
if (!value || value === defaultValue) {
return defaultValue;
}
if (typeof value === "number") {
return `${value}px` satisfies Pixel;
}
return value;
};
@@ -16,7 +16,6 @@ import {
CustomCodeInlineExtension,
CustomColorExtension,
CustomHorizontalRule,
CustomImageExtension,
CustomKeymap,
CustomLinkExtension,
CustomMentionExtension,
@@ -37,20 +36,29 @@ import { getExtensionStorage } from "@/helpers/get-extension-storage";
// plane editor extensions
import { CoreEditorAdditionalExtensions } from "@/plane-editor/extensions";
// types
import { TExtensions, TFileHandler, TMentionHandler } from "@/types";
import type { IEditorProps } from "@/types";
// local imports
import { CustomImageExtension } from "./custom-image/extension";
type TArguments = {
disabledExtensions: TExtensions[];
type TArguments = Pick<
IEditorProps,
"disabledExtensions" | "flaggedExtensions" | "fileHandler" | "mentionHandler" | "placeholder" | "tabIndex"
> & {
enableHistory: boolean;
fileHandler: TFileHandler;
mentionHandler: TMentionHandler;
placeholder?: string | ((isFocused: boolean, value: string) => string);
tabIndex?: number;
editable: boolean;
};
export const CoreEditorExtensions = (args: TArguments): Extensions => {
const { disabledExtensions, enableHistory, fileHandler, mentionHandler, placeholder, tabIndex, editable } = args;
const {
disabledExtensions,
enableHistory,
fileHandler,
flaggedExtensions,
mentionHandler,
placeholder,
tabIndex,
editable,
} = args;
const extensions = [
StarterKit.configure({
@@ -177,18 +185,20 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => {
CustomColorExtension,
...CoreEditorAdditionalExtensions({
disabledExtensions,
flaggedExtensions,
fileHandler,
}),
];
if (!disabledExtensions.includes("image")) {
extensions.push(
ImageExtension(fileHandler).configure({
HTMLAttributes: {
class: "rounded-md",
},
ImageExtension({
fileHandler,
}),
CustomImageExtension(fileHandler)
CustomImageExtension({
fileHandler,
isEditable: editable,
})
);
}
@@ -1,6 +1,12 @@
import { Image as BaseImageExtension } from "@tiptap/extension-image";
// local imports
import { CustomImageExtensionOptions } from "../custom-image/types";
import { ImageExtensionStorage } from "./extension";
export const ImageExtensionWithoutProps = BaseImageExtension.extend({
export const ImageExtensionConfig = BaseImageExtension.extend<
Pick<CustomImageExtensionOptions, "getImageSource">,
ImageExtensionStorage
>({
addAttributes() {
return {
...this.parent?.(),
@@ -1,23 +1,33 @@
import { Image as BaseImageExtension } from "@tiptap/extension-image";
import { ReactNodeViewRenderer } from "@tiptap/react";
// extensions
import { CustomImageNode } from "@/extensions";
// helpers
import { insertEmptyParagraphAtNodeBoundaries } from "@/helpers/insert-empty-paragraph-at-node-boundary";
// types
import { TFileHandler } from "@/types";
import type { TFileHandler, TReadOnlyFileHandler } from "@/types";
// local imports
import { CustomImageNodeView } from "../custom-image/components/node-view";
import { ImageExtensionConfig } from "./extension-config";
export type ImageExtensionStorage = {
deletedImageSet: Map<string, boolean>;
};
export const ImageExtension = (fileHandler: TFileHandler) => {
const {
getAssetSrc,
validation: { maxFileSize },
} = fileHandler;
type Props = {
fileHandler: TFileHandler | TReadOnlyFileHandler;
};
export const ImageExtension = (props: Props) => {
const { fileHandler } = props;
// derived values
const { getAssetSrc } = fileHandler;
return ImageExtensionConfig.extend({
addOptions() {
return {
...this.parent?.(),
getImageSource: getAssetSrc,
};
},
return BaseImageExtension.extend<unknown, ImageExtensionStorage>({
addKeyboardShortcuts() {
return {
ArrowDown: insertEmptyParagraphAtNodeBoundaries("down", this.name),
@@ -27,36 +37,17 @@ export const ImageExtension = (fileHandler: TFileHandler) => {
// storage to keep track of image states Map<src, isDeleted>
addStorage() {
const maxFileSize = "validation" in fileHandler ? fileHandler.validation?.maxFileSize : 0;
return {
deletedImageSet: new Map<string, boolean>(),
maxFileSize,
};
},
addAttributes() {
return {
...this.parent?.(),
width: {
default: "35%",
},
height: {
default: null,
},
aspectRatio: {
default: null,
},
};
},
addCommands() {
return {
getImageSource: (path: string) => async () => await getAssetSrc(path),
};
},
// render custom image node
addNodeView() {
return ReactNodeViewRenderer(CustomImageNode);
return ReactNodeViewRenderer(CustomImageNodeView);
},
});
};
@@ -1,56 +0,0 @@
import { mergeAttributes } from "@tiptap/core";
import { Image as BaseImageExtension } from "@tiptap/extension-image";
// local imports
import { ImageExtensionStorage } from "./extension";
export const CustomImageComponentWithoutProps = BaseImageExtension.extend<
Record<string, unknown>,
ImageExtensionStorage
>({
name: "imageComponent",
selectable: true,
group: "block",
atom: true,
draggable: true,
addAttributes() {
return {
...this.parent?.(),
width: {
default: "35%",
},
src: {
default: null,
},
height: {
default: "auto",
},
["id"]: {
default: null,
},
aspectRatio: {
default: null,
},
};
},
parseHTML() {
return [
{
tag: "image-component",
},
];
},
renderHTML({ HTMLAttributes }) {
return ["image-component", mergeAttributes(HTMLAttributes)];
},
addStorage() {
return {
fileMap: new Map(),
deletedImageSet: new Map<string, boolean>(),
maxFileSize: 0,
};
},
});
@@ -1,3 +1,2 @@
export * from "./extension";
export * from "./image-extension-without-props";
export * from "./read-only-image";
export * from "./extension-config";
@@ -1,37 +0,0 @@
import { Image as BaseImageExtension } from "@tiptap/extension-image";
import { ReactNodeViewRenderer } from "@tiptap/react";
// extensions
import { CustomImageNode } from "@/extensions";
// types
import { TReadOnlyFileHandler } from "@/types";
export const ReadOnlyImageExtension = (props: TReadOnlyFileHandler) => {
const { getAssetSrc } = props;
return BaseImageExtension.extend({
addAttributes() {
return {
...this.parent?.(),
width: {
default: "35%",
},
height: {
default: null,
},
aspectRatio: {
default: null,
},
};
},
addCommands() {
return {
getImageSource: (path: string) => async () => await getAssetSrc(path),
};
},
addNodeView() {
return ReactNodeViewRenderer(CustomImageNode);
},
});
};
@@ -1,7 +1,6 @@
export * from "./callout";
export * from "./code";
export * from "./code-inline";
export * from "./custom-image";
export * from "./custom-link";
export * from "./custom-list-keymap";
export * from "./image";
@@ -12,7 +12,6 @@ import {
CustomHorizontalRule,
CustomLinkExtension,
CustomTypographyExtension,
ReadOnlyImageExtension,
CustomCodeBlockExtension,
CustomCodeInlineExtension,
TableHeader,
@@ -20,27 +19,25 @@ import {
TableRow,
Table,
CustomMentionExtension,
CustomReadOnlyImageExtension,
CustomTextAlignExtension,
CustomCalloutReadOnlyExtension,
CustomColorExtension,
UtilityExtension,
ImageExtension,
} from "@/extensions";
// helpers
import { isValidHttpUrl } from "@/helpers/common";
// plane editor extensions
import { CoreReadOnlyEditorAdditionalExtensions } from "@/plane-editor/extensions";
// types
import { TExtensions, TReadOnlyFileHandler, TReadOnlyMentionHandler } from "@/types";
import type { IReadOnlyEditorProps } from "@/types";
// local imports
import { CustomImageExtension } from "./custom-image/extension";
type Props = {
disabledExtensions: TExtensions[];
fileHandler: TReadOnlyFileHandler;
mentionHandler: TReadOnlyMentionHandler;
};
type Props = Pick<IReadOnlyEditorProps, "disabledExtensions" | "flaggedExtensions" | "fileHandler" | "mentionHandler">;
export const CoreReadOnlyEditorExtensions = (props: Props): Extensions => {
const { disabledExtensions, fileHandler, mentionHandler } = props;
const { disabledExtensions, fileHandler, flaggedExtensions, mentionHandler } = props;
const extensions = [
StarterKit.configure({
@@ -133,17 +130,19 @@ export const CoreReadOnlyEditorExtensions = (props: Props): Extensions => {
}),
...CoreReadOnlyEditorAdditionalExtensions({
disabledExtensions,
flaggedExtensions,
}),
];
if (!disabledExtensions.includes("image")) {
extensions.push(
ReadOnlyImageExtension(fileHandler).configure({
HTMLAttributes: {
class: "rounded-md",
},
ImageExtension({
fileHandler,
}),
CustomReadOnlyImageExtension(fileHandler)
CustomImageExtension({
fileHandler,
isEditable: false,
})
);
}
@@ -49,7 +49,7 @@ export type TSlashCommandSection = {
export const getSlashCommandFilteredSections =
(args: TExtensionProps) =>
({ query }: { query: string }): TSlashCommandSection[] => {
const { additionalOptions: externalAdditionalOptions, disabledExtensions } = args;
const { additionalOptions: externalAdditionalOptions, disabledExtensions, flaggedExtensions } = args;
const SLASH_COMMAND_SECTIONS: TSlashCommandSection[] = [
{
key: "general",
@@ -290,6 +290,7 @@ export const getSlashCommandFilteredSections =
...(externalAdditionalOptions ?? []),
...coreEditorAdditionalSlashCommandOptions({
disabledExtensions,
flaggedExtensions,
}),
]?.forEach((item) => {
const sectionToPushTo = SLASH_COMMAND_SECTIONS.find((s) => s.key === item.section) ?? SLASH_COMMAND_SECTIONS[0];
@@ -7,7 +7,7 @@ import { CORE_EXTENSIONS } from "@/constants/extension";
// helpers
import { CommandListInstance } from "@/helpers/tippy";
// types
import { ISlashCommandItem, TEditorCommands, TExtensions, TSlashCommandSectionKeys } from "@/types";
import { IEditorProps, ISlashCommandItem, TEditorCommands, TSlashCommandSectionKeys } from "@/types";
// components
import { getSlashCommandFilteredSections } from "./command-items-list";
import { SlashCommandsMenu, SlashCommandsMenuProps } from "./command-menu";
@@ -106,9 +106,8 @@ const renderItems = () => {
};
};
export type TExtensionProps = {
export type TExtensionProps = Pick<IEditorProps, "disabledExtensions" | "flaggedExtensions"> & {
additionalOptions?: TSlashCommandAdditionalOption[];
disabledExtensions?: TExtensions[];
};
export const SlashCommands = (props: TExtensionProps) =>
@@ -8,7 +8,7 @@ import { DropHandlerPlugin } from "@/plugins/drop";
import { FilePlugins } from "@/plugins/file/root";
import { MarkdownClipboardPlugin } from "@/plugins/markdown-clipboard";
// types
import { TExtensions, TFileHandler, TReadOnlyFileHandler } from "@/types";
import type { IEditorProps, TFileHandler, TReadOnlyFileHandler } from "@/types";
declare module "@tiptap/core" {
interface Commands {
@@ -23,8 +23,7 @@ export interface UtilityExtensionStorage {
uploadInProgress: boolean;
}
type Props = {
disabledExtensions: TExtensions[];
type Props = Pick<IEditorProps, "disabledExtensions"> & {
fileHandler: TFileHandler | TReadOnlyFileHandler;
isEditable: boolean;
};

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