Compare commits

..
463 changed files with 8830 additions and 12630 deletions
+59
View File
@@ -0,0 +1,59 @@
/**
* Adds three new lint plugins over the existing configuration:
* This is used to lint staged files only.
* We should remove this file once the entire codebase follows these rules.
*/
module.exports = {
root: true,
extends: [
"custom",
],
parser: "@typescript-eslint/parser",
settings: {
"import/resolver": {
typescript: {},
node: {
moduleDirectory: ["node_modules", "."],
},
},
},
rules: {
"import/order": [
"error",
{
groups: ["builtin", "external", "internal", "parent", "sibling"],
pathGroups: [
{
pattern: "react",
group: "external",
position: "before",
},
{
pattern: "lucide-react",
group: "external",
position: "after",
},
{
pattern: "@headlessui/**",
group: "external",
position: "after",
},
{
pattern: "@plane/**",
group: "external",
position: "after",
},
{
pattern: "@/**",
group: "internal",
},
],
pathGroupsExcludedImportTypes: ["builtin", "internal", "react"],
alphabetize: {
order: "asc",
caseInsensitive: true,
},
},
],
},
};
+10
View File
@@ -0,0 +1,10 @@
module.exports = {
root: true,
// This tells ESLint to load the config from the package `eslint-config-custom`
extends: ["custom"],
settings: {
next: {
rootDir: ["web/", "space/", "admin/"],
},
},
};
View File
+3
View File
@@ -0,0 +1,3 @@
{
"*.{ts,tsx,js,jsx}": ["eslint -c ./.eslintrc-staged.js", "prettier --check"]
}
+1 -1
View File
@@ -4,7 +4,7 @@ Thank you for showing an interest in contributing to Plane! All kinds of contrib
## Submitting an issue
Before submitting a new issue, please search the [issues](https://github.com/makeplane/plane/issues) tab. Maybe an issue or discussion already exists and might inform you of workarounds. Otherwise, you can give new information.
Before submitting a new issue, please search the [issues](https://github.com/makeplane/plane/issues) tab. Maybe an issue or discussion already exists and might inform you of workarounds. Otherwise, you can give new informplaneation.
While we want to fix all the [issues](https://github.com/makeplane/plane/issues), before fixing a bug we need to be able to reproduce and confirm it. Please provide us with a minimal reproduction scenario using a repository or [Gist](https://gist.github.com/). Having a live, reproducible scenario gives us the information without asking questions back & forth with additional questions like:
+35 -30
View File
@@ -1,39 +1,44 @@
# Security policy
This document outlines the security protocols and vulnerability reporting guidelines for the Plane project. Ensuring the security of our systems is a top priority, and while we work diligently to maintain robust protection, vulnerabilities may still occur. We highly value the communitys role in identifying and reporting security concerns to uphold the integrity of our systems and safeguard our users.
# Security Policy
## Reporting a vulnerability
If you have identified a security vulnerability, submit your findings to [security@plane.so](mailto:security@plane.so).
Ensure your report includes all relevant information needed for us to reproduce and assess the issue. Include the IP address or URL of the affected system.
This document outlines security procedures and vulnerabilities reporting for the Plane project.
To ensure a responsible and effective disclosure process, please adhere to the following:
At Plane, we safeguarding the security of our systems with top priority. Despite our efforts, vulnerabilities may still exist. We greatly appreciate your assistance in identifying and reporting any such vulnerabilities to help us maintain the integrity of our systems and protect our clients.
- Maintain confidentiality and refrain from publicly disclosing the vulnerability until we have had the opportunity to investigate and address the issue.
- Refrain from running automated vulnerability scans on our infrastructure or dashboard without prior consent. Contact us to set up a sandbox environment if necessary.
- Do not exploit any discovered vulnerabilities for malicious purposes, such as accessing or altering user data.
- Do not engage in physical security attacks, social engineering, distributed denial of service (DDoS) attacks, spam campaigns, or attacks on third-party applications as part of your vulnerability testing.
To report a security vulnerability, please email us directly at security@plane.so with a detailed description of the vulnerability and steps to reproduce it. Please refrain from disclosing the vulnerability publicly until we have had an opportunity to review and address it.
## Out of scope
While we appreciate all efforts to assist in improving our security, please note that the following types of vulnerabilities are considered out of scope:
## Out of Scope Vulnerabilities
- Vulnerabilities requiring man-in-the-middle (MITM) attacks or physical access to a users device.
- Content spoofing or text injection issues without a clear attack vector or the ability to modify HTML/CSS.
- Issues related to email spoofing.
- Missing DNSSEC, CAA, or CSP headers.
- Absence of secure or HTTP-only flags on non-sensitive cookies.
We appreciate your help in identifying vulnerabilities. However, please note that the following types of vulnerabilities are considered out of scope:
## Our commitment
- Attacks requiring MITM or physical access to a user's device.
- Content spoofing and text injection issues without demonstrating an attack vector or ability to modify HTML/CSS.
- Email spoofing.
- Missing DNSSEC, CAA, CSP headers.
- Lack of Secure or HTTP only flag on non-sensitive cookies.
At Plane, we are committed to maintaining transparent and collaborative communication throughout the vulnerability resolution process. Here's what you can expect from us:
## Reporting Process
- **Response Time** <br/>
We will acknowledge receipt of your vulnerability report within three business days and provide an estimated timeline for resolution.
- **Legal Protection** <br/>
We will not initiate legal action against you for reporting vulnerabilities, provided you adhere to the reporting guidelines.
- **Confidentiality** <br/>
Your report will be treated with confidentiality. We will not disclose your personal information to third parties without your consent.
- **Recognition** <br/>
With your permission, we are happy to publicly acknowledge your contribution to improving our security once the issue is resolved.
- **Timely Resolution** <br/>
We are committed to working closely with you throughout the resolution process, providing timely updates as necessary. Our goal is to address all reported vulnerabilities swiftly, and we will actively engage with you to coordinate a responsible disclosure once the issue is fully resolved.
If you discover a vulnerability, please adhere to the following reporting process:
We appreciate your help in ensuring the security of our platform. Your contributions are crucial to protecting our users and maintaining a secure environment. Thank you for working with us to keep Plane safe.
1. Email your findings to security@plane.so.
2. Refrain from running automated scanners on our infrastructure or dashboard without prior consent. Contact us to set up a sandbox environment if necessary.
3. Do not exploit the vulnerability for malicious purposes, such as downloading excessive data or altering user data.
4. Maintain confidentiality and refrain from disclosing the vulnerability until it has been resolved.
5. Avoid using physical security attacks, social engineering, distributed denial of service, spam, or third-party applications.
When reporting a vulnerability, please provide sufficient information to allow us to reproduce and address the issue promptly. Include the IP address or URL of the affected system, along with a detailed description of the vulnerability.
## Our Commitment
We are committed to promptly addressing reported vulnerabilities and maintaining open communication throughout the resolution process. Here's what you can expect from us:
- **Response Time:** We will acknowledge receipt of your report within three business days and provide an expected resolution date.
- **Legal Protection:** We will not pursue legal action against you for reporting vulnerabilities, provided you adhere to the reporting guidelines.
- **Confidentiality:** Your report will be treated with strict confidentiality. We will not disclose your personal information to third parties without your consent.
- **Progress Updates:** We will keep you informed of our progress in resolving the reported vulnerability.
- **Recognition:** With your permission, we will publicly acknowledge you as the discoverer of the vulnerability.
- **Timely Resolution:** We strive to resolve all reported vulnerabilities promptly and will actively participate in the publication process once the issue is resolved.
We appreciate your cooperation in helping us maintain the security of our systems and protecting our clients. Thank you for your contributions to our security efforts.
reference: https://supabase.com/.well-known/security.txt
+48 -4
View File
@@ -1,8 +1,52 @@
module.exports = {
root: true,
extends: ["@plane/eslint-config/next.js"],
extends: ["custom"],
parser: "@typescript-eslint/parser",
parserOptions: {
project: true,
settings: {
"import/resolver": {
typescript: {},
node: {
moduleDirectory: ["node_modules", "."],
},
},
},
};
rules: {
"import/order": [
"error",
{
groups: ["builtin", "external", "internal", "parent", "sibling",],
pathGroups: [
{
pattern: "react",
group: "external",
position: "before",
},
{
pattern: "lucide-react",
group: "external",
position: "after",
},
{
pattern: "@headlessui/**",
group: "external",
position: "after",
},
{
pattern: "@plane/**",
group: "external",
position: "after",
},
{
pattern: "@/**",
group: "internal",
}
],
pathGroupsExcludedImportTypes: ["builtin", "internal", "react"],
alphabetize: {
order: "asc",
caseInsensitive: true,
},
},
],
},
}
+3 -1
View File
@@ -9,7 +9,9 @@ import { HelpSection, SidebarMenu, SidebarDropdown } from "@/components/admin-si
// hooks
import { useTheme } from "@/hooks/store";
export const InstanceSidebar: FC = observer(() => {
export interface IInstanceSidebar {}
export const InstanceSidebar: FC<IInstanceSidebar> = observer(() => {
// store
const { isSidebarCollapsed, toggleSidebar } = useTheme();
@@ -7,7 +7,11 @@ import { Button } from "@plane/ui";
import InstanceFailureDarkImage from "@/public/instance/instance-failure-dark.svg";
import InstanceFailureImage from "@/public/instance/instance-failure.svg";
export const InstanceFailureView: FC = () => {
type InstanceFailureViewProps = {
// mutate: () => void;
};
export const InstanceFailureView: FC<InstanceFailureViewProps> = () => {
const { resolvedTheme } = useTheme();
const instanceImage = resolvedTheme === "dark" ? InstanceFailureDarkImage : InstanceFailureImage;
+1 -1
View File
@@ -1,5 +1,5 @@
// helpers
import { API_BASE_URL } from "@/helpers/common.helper";
import { API_BASE_URL } from "helpers/common.helper";
// services
import { APIService } from "@/services/api.service";
+2 -2
View File
@@ -1,7 +1,7 @@
// helpers
import { API_BASE_URL } from "helpers/common.helper";
// types
import type { IUser } from "@plane/types";
// helpers
import { API_BASE_URL } from "@/helpers/common.helper";
// services
import { APIService } from "@/services/api.service";
+6 -8
View File
@@ -1,6 +1,6 @@
{
"name": "admin",
"version": "0.23.0",
"version": "0.22.0",
"private": true,
"scripts": {
"dev": "turbo run develop",
@@ -8,8 +8,7 @@
"build": "next build",
"preview": "next build && next start",
"start": "next start",
"lint": "eslint . --ext .ts,.tsx",
"lint:errors": "eslint . --ext .ts,.tsx --quiet"
"lint": "next lint"
},
"dependencies": {
"@headlessui/react": "^1.7.19",
@@ -17,7 +16,6 @@
"@plane/helpers": "*",
"@plane/types": "*",
"@plane/ui": "*",
"@sentry/nextjs": "^8.32.0",
"@tailwindcss/typography": "^0.5.9",
"@types/lodash": "^4.17.0",
"autoprefixer": "10.4.14",
@@ -27,7 +25,7 @@
"lucide-react": "^0.356.0",
"mobx": "^6.12.0",
"mobx-react": "^9.1.1",
"next": "^14.2.12",
"next": "^14.2.3",
"next-themes": "^0.2.1",
"postcss": "^8.4.38",
"react": "^18.3.1",
@@ -39,15 +37,15 @@
"zxcvbn": "^4.4.2"
},
"devDependencies": {
"@plane/eslint-config": "*",
"@plane/typescript-config": "*",
"@types/js-cookie": "^3.0.6",
"@types/node": "18.16.1",
"@types/react": "^18.2.48",
"@types/react-dom": "^18.2.18",
"@types/uuid": "^9.0.8",
"@types/zxcvbn": "^4.4.4",
"eslint-config-custom": "*",
"tailwind-config-custom": "*",
"typescript": "5.3.3"
"tsconfig": "*",
"typescript": "5.4.5"
}
}
+12 -6
View File
@@ -1,15 +1,21 @@
{
"extends": "@plane/typescript-config/nextjs.json",
"extends": "tsconfig/nextjs.json",
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"],
"compilerOptions": {
"plugins": [{ "name": "next" }],
"baseUrl": ".",
"jsx": "preserve",
"esModuleInterop": true,
"paths": {
"@/*": ["core/*"],
"@/helpers/*": ["helpers/*"],
"@/public/*": ["public/*"],
"@/plane-admin/*": ["ce/*"]
}
},
"include": ["next-env.d.ts", "next.config.js", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
},
"plugins": [
{
"name": "next"
}
]
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
{
"name": "plane-api",
"version": "0.23.0"
"version": "0.22.0"
}
+6 -2
View File
@@ -207,7 +207,8 @@ class CycleAPIEndpoint(BaseAPIView):
# Incomplete Cycles
if cycle_view == "incomplete":
queryset = queryset.filter(
Q(end_date__gte=timezone.now()) | Q(end_date__isnull=True),
Q(end_date__gte=timezone.now().date())
| Q(end_date__isnull=True),
)
return self.paginate(
request=request,
@@ -308,7 +309,10 @@ class CycleAPIEndpoint(BaseAPIView):
request_data = request.data
if cycle.end_date is not None and cycle.end_date < timezone.now():
if (
cycle.end_date is not None
and cycle.end_date < timezone.now()
):
if "sort_order" in request_data:
# Can only change sort order
request_data = {
+1 -1
View File
@@ -285,7 +285,7 @@ class InboxIssueAPIEndpoint(BaseAPIView):
)
# Only project admins and members can edit inbox issue attributes
if project_member.role > 15:
if project_member.role > 5:
serializer = InboxIssueSerializer(
inbox_issue, data=request.data, partial=True
)
+3 -8
View File
@@ -301,16 +301,11 @@ class ProjectAPIEndpoint(BaseAPIView):
if serializer.is_valid():
serializer.save()
if serializer.data["inbox_view"]:
inbox = Inbox.objects.filter(
Inbox.objects.get_or_create(
name=f"{project.name} Inbox",
project=project,
is_default=True,
).first()
if not inbox:
Inbox.objects.create(
name=f"{project.name} Inbox",
project=project,
is_default=True,
)
)
# Create the triage state in Backlog group
State.objects.get_or_create(
+1 -6
View File
@@ -44,6 +44,7 @@ from .cycle import (
CycleIssueSerializer,
CycleWriteSerializer,
CycleUserPropertiesSerializer,
CycleAnalyticsSerializer,
)
from .asset import FileAssetSerializer
from .issue import (
@@ -124,9 +125,3 @@ from .webhook import WebhookSerializer, WebhookLogSerializer
from .dashboard import DashboardSerializer, WidgetSerializer
from .favorite import UserFavoriteSerializer
from .draft import (
DraftIssueCreateSerializer,
DraftIssueSerializer,
DraftIssueDetailSerializer,
)
+8
View File
@@ -7,6 +7,7 @@ from .issue import IssueStateSerializer
from plane.db.models import (
Cycle,
CycleIssue,
CycleAnalytics,
CycleUserProperties,
)
@@ -93,6 +94,7 @@ class CycleIssueSerializer(BaseSerializer):
"cycle",
]
class CycleUserPropertiesSerializer(BaseSerializer):
class Meta:
model = CycleUserProperties
@@ -102,3 +104,9 @@ class CycleUserPropertiesSerializer(BaseSerializer):
"project",
"cycle" "user",
]
class CycleAnalyticsSerializer(BaseSerializer):
class Meta:
model = CycleAnalytics
fields = "__all__"
-290
View File
@@ -1,290 +0,0 @@
# Django imports
from django.utils import timezone
# Third Party imports
from rest_framework import serializers
# Module imports
from .base import BaseSerializer
from plane.db.models import (
User,
Issue,
Label,
State,
DraftIssue,
DraftIssueAssignee,
DraftIssueLabel,
DraftIssueCycle,
DraftIssueModule,
)
class DraftIssueCreateSerializer(BaseSerializer):
# ids
state_id = serializers.PrimaryKeyRelatedField(
source="state",
queryset=State.objects.all(),
required=False,
allow_null=True,
)
parent_id = serializers.PrimaryKeyRelatedField(
source="parent",
queryset=Issue.objects.all(),
required=False,
allow_null=True,
)
label_ids = serializers.ListField(
child=serializers.PrimaryKeyRelatedField(queryset=Label.objects.all()),
write_only=True,
required=False,
)
assignee_ids = serializers.ListField(
child=serializers.PrimaryKeyRelatedField(queryset=User.objects.all()),
write_only=True,
required=False,
)
class Meta:
model = DraftIssue
fields = "__all__"
read_only_fields = [
"workspace",
"created_by",
"updated_by",
"created_at",
"updated_at",
]
def to_representation(self, instance):
data = super().to_representation(instance)
assignee_ids = self.initial_data.get("assignee_ids")
data["assignee_ids"] = assignee_ids if assignee_ids else []
label_ids = self.initial_data.get("label_ids")
data["label_ids"] = label_ids if label_ids else []
return data
def validate(self, data):
if (
data.get("start_date", None) is not None
and data.get("target_date", None) is not None
and data.get("start_date", None) > data.get("target_date", None)
):
raise serializers.ValidationError(
"Start date cannot exceed target date"
)
return data
def create(self, validated_data):
assignees = validated_data.pop("assignee_ids", None)
labels = validated_data.pop("label_ids", None)
modules = validated_data.pop("module_ids", None)
cycle_id = self.initial_data.get("cycle_id", None)
modules = self.initial_data.get("module_ids", None)
workspace_id = self.context["workspace_id"]
project_id = self.context["project_id"]
# Create Issue
issue = DraftIssue.objects.create(
**validated_data,
workspace_id=workspace_id,
project_id=project_id,
)
# Issue Audit Users
created_by_id = issue.created_by_id
updated_by_id = issue.updated_by_id
if assignees is not None and len(assignees):
DraftIssueAssignee.objects.bulk_create(
[
DraftIssueAssignee(
assignee=user,
draft_issue=issue,
workspace_id=workspace_id,
project_id=project_id,
created_by_id=created_by_id,
updated_by_id=updated_by_id,
)
for user in assignees
],
batch_size=10,
)
if labels is not None and len(labels):
DraftIssueLabel.objects.bulk_create(
[
DraftIssueLabel(
label=label,
draft_issue=issue,
project_id=project_id,
workspace_id=workspace_id,
created_by_id=created_by_id,
updated_by_id=updated_by_id,
)
for label in labels
],
batch_size=10,
)
if cycle_id is not None:
DraftIssueCycle.objects.create(
cycle_id=cycle_id,
draft_issue=issue,
project_id=project_id,
workspace_id=workspace_id,
created_by_id=created_by_id,
updated_by_id=updated_by_id,
)
if modules is not None and len(modules):
DraftIssueModule.objects.bulk_create(
[
DraftIssueModule(
module_id=module_id,
draft_issue=issue,
project_id=project_id,
workspace_id=workspace_id,
created_by_id=created_by_id,
updated_by_id=updated_by_id,
)
for module_id in modules
],
batch_size=10,
)
return issue
def update(self, instance, validated_data):
assignees = validated_data.pop("assignee_ids", None)
labels = validated_data.pop("label_ids", None)
cycle_id = self.context.get("cycle_id", None)
modules = self.initial_data.get("module_ids", None)
# Related models
workspace_id = instance.workspace_id
project_id = instance.project_id
created_by_id = instance.created_by_id
updated_by_id = instance.updated_by_id
if assignees is not None:
DraftIssueAssignee.objects.filter(draft_issue=instance).delete()
DraftIssueAssignee.objects.bulk_create(
[
DraftIssueAssignee(
assignee=user,
draft_issue=instance,
workspace_id=workspace_id,
project_id=project_id,
created_by_id=created_by_id,
updated_by_id=updated_by_id,
)
for user in assignees
],
batch_size=10,
)
if labels is not None:
DraftIssueLabel.objects.filter(draft_issue=instance).delete()
DraftIssueLabel.objects.bulk_create(
[
DraftIssueLabel(
label=label,
draft_issue=instance,
workspace_id=workspace_id,
project_id=project_id,
created_by_id=created_by_id,
updated_by_id=updated_by_id,
)
for label in labels
],
batch_size=10,
)
if cycle_id != "not_provided":
DraftIssueCycle.objects.filter(draft_issue=instance).delete()
if cycle_id is not None:
DraftIssueCycle.objects.create(
cycle_id=cycle_id,
draft_issue=instance,
workspace_id=workspace_id,
project_id=project_id,
created_by_id=created_by_id,
updated_by_id=updated_by_id,
)
if modules is not None:
DraftIssueModule.objects.filter(draft_issue=instance).delete()
DraftIssueModule.objects.bulk_create(
[
DraftIssueModule(
module_id=module_id,
draft_issue=instance,
workspace_id=workspace_id,
project_id=project_id,
created_by_id=created_by_id,
updated_by_id=updated_by_id,
)
for module_id in modules
],
batch_size=10,
)
# Time updation occurs even when other related models are updated
instance.updated_at = timezone.now()
return super().update(instance, validated_data)
class DraftIssueSerializer(BaseSerializer):
# ids
cycle_id = serializers.PrimaryKeyRelatedField(read_only=True)
module_ids = serializers.ListField(
child=serializers.UUIDField(),
required=False,
)
# Many to many
label_ids = serializers.ListField(
child=serializers.UUIDField(),
required=False,
)
assignee_ids = serializers.ListField(
child=serializers.UUIDField(),
required=False,
)
class Meta:
model = DraftIssue
fields = [
"id",
"name",
"state_id",
"sort_order",
"completed_at",
"estimate_point",
"priority",
"start_date",
"target_date",
"project_id",
"parent_id",
"cycle_id",
"module_ids",
"label_ids",
"assignee_ids",
"created_at",
"updated_at",
"created_by",
"updated_by",
]
read_only_fields = fields
class DraftIssueDetailSerializer(DraftIssueSerializer):
description_html = serializers.CharField()
class Meta(DraftIssueSerializer.Meta):
fields = DraftIssueSerializer.Meta.fields + [
"description_html",
]
read_only_fields = fields
+6
View File
@@ -9,6 +9,7 @@ from plane.app.views import (
CycleProgressEndpoint,
CycleAnalyticsEndpoint,
TransferCycleIssueEndpoint,
CycleIssueStateAnalyticsEndpoint,
CycleUserPropertiesEndpoint,
CycleArchiveUnarchiveEndpoint,
)
@@ -118,4 +119,9 @@ urlpatterns = [
CycleAnalyticsEndpoint.as_view(),
name="project-cycle",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/cycles/<uuid:cycle_id>/cycle-progress/",
CycleIssueStateAnalyticsEndpoint.as_view(),
name="project-cycle-progress",
),
]
+22 -5
View File
@@ -11,6 +11,7 @@ from plane.app.views import (
IssueActivityEndpoint,
IssueArchiveViewSet,
IssueCommentViewSet,
IssueDraftViewSet,
IssueListEndpoint,
IssueReactionViewSet,
IssueRelationViewSet,
@@ -19,7 +20,6 @@ from plane.app.views import (
IssueViewSet,
LabelViewSet,
BulkArchiveIssuesEndpoint,
DeletedIssuesListViewSet,
IssuePaginatedViewSet,
)
@@ -39,7 +39,7 @@ urlpatterns = [
),
name="project-issue",
),
# updated v2 paginated issues
# updated v1 paginated issues
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/v2/issues/",
IssuePaginatedViewSet.as_view({"get": "list"}),
@@ -289,9 +289,26 @@ urlpatterns = [
name="issue-relation",
),
## End Issue Relation
## Issue Drafts
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/deleted-issues/",
DeletedIssuesListViewSet.as_view(),
name="deleted-issues",
"workspaces/<str:slug>/projects/<uuid:project_id>/issue-drafts/",
IssueDraftViewSet.as_view(
{
"get": "list",
"post": "create",
}
),
name="project-issue-draft",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issue-drafts/<uuid:pk>/",
IssueDraftViewSet.as_view(
{
"get": "retrieve",
"patch": "partial_update",
"delete": "destroy",
}
),
name="project-issue-draft",
),
]
-27
View File
@@ -27,7 +27,6 @@ from plane.app.views import (
WorkspaceCyclesEndpoint,
WorkspaceFavoriteEndpoint,
WorkspaceFavoriteGroupEndpoint,
WorkspaceDraftIssueViewSet,
)
@@ -255,30 +254,4 @@ urlpatterns = [
WorkspaceFavoriteGroupEndpoint.as_view(),
name="workspace-user-favorites-groups",
),
path(
"workspaces/<str:slug>/draft-issues/",
WorkspaceDraftIssueViewSet.as_view(
{
"get": "list",
"post": "create",
}
),
name="workspace-draft-issues",
),
path(
"workspaces/<str:slug>/draft-issues/<uuid:pk>/",
WorkspaceDraftIssueViewSet.as_view(
{
"get": "retrieve",
"patch": "partial_update",
"delete": "destroy",
}
),
name="workspace-drafts-issues",
),
path(
"workspaces/<str:slug>/draft-to-issue/<uuid:draft_id>/",
WorkspaceDraftIssueViewSet.as_view({"post": "create_draft_to_issue"}),
name="workspace-drafts-issues",
),
]
+4 -6
View File
@@ -40,8 +40,6 @@ from .workspace.base import (
ExportWorkspaceUserActivityEndpoint,
)
from .workspace.draft import WorkspaceDraftIssueViewSet
from .workspace.favorite import (
WorkspaceFavoriteEndpoint,
WorkspaceFavoriteGroupEndpoint,
@@ -102,10 +100,9 @@ from .cycle.base import (
TransferCycleIssueEndpoint,
CycleAnalyticsEndpoint,
CycleProgressEndpoint,
CycleIssueStateAnalyticsEndpoint,
)
from .cycle.issue import (
CycleIssueViewSet,
)
from .cycle.issue import CycleIssueViewSet
from .cycle.archive import (
CycleArchiveUnarchiveEndpoint,
)
@@ -116,7 +113,6 @@ from .issue.base import (
IssueViewSet,
IssueUserDisplayPropertyEndpoint,
BulkDeleteIssuesEndpoint,
DeletedIssuesListViewSet,
IssuePaginatedViewSet,
)
@@ -135,6 +131,8 @@ from .issue.comment import (
CommentReactionViewSet,
)
from .issue.draft import IssueDraftViewSet
from .issue.label import (
LabelViewSet,
BulkCreateIssueLabelsEndpoint,
+54 -6
View File
@@ -31,6 +31,7 @@ from plane.app.permissions import allow_permission, ROLE
from plane.app.serializers import (
CycleSerializer,
CycleUserPropertiesSerializer,
CycleAnalyticsSerializer,
CycleWriteSerializer,
)
from plane.bgtasks.issue_activities_task import issue_activity
@@ -44,6 +45,8 @@ from plane.db.models import (
User,
Project,
ProjectMember,
CycleAnalytics,
CycleIssueStateProgress,
)
from plane.utils.analytics_plot import burndown_plot
from plane.bgtasks.recent_visited_task import recent_visited_task
@@ -187,7 +190,6 @@ class CycleViewSet(BaseViewSet):
"completed_issues",
"assignee_ids",
"status",
"version",
"created_by",
)
@@ -217,7 +219,6 @@ class CycleViewSet(BaseViewSet):
"completed_issues",
"assignee_ids",
"status",
"version",
"created_by",
)
return Response(data, status=status.HTTP_200_OK)
@@ -257,7 +258,6 @@ class CycleViewSet(BaseViewSet):
"external_id",
"progress_snapshot",
"logo_props",
"version",
# meta fields
"is_favorite",
"total_issues",
@@ -309,7 +309,10 @@ class CycleViewSet(BaseViewSet):
request_data = request.data
if cycle.end_date is not None and cycle.end_date < timezone.now():
if (
cycle.end_date is not None
and cycle.end_date < timezone.now()
):
if "sort_order" in request_data:
# Can only change sort order for a completed cycle``
request_data = {
@@ -347,7 +350,6 @@ class CycleViewSet(BaseViewSet):
"external_id",
"progress_snapshot",
"logo_props",
"version",
# meta fields
"is_favorite",
"total_issues",
@@ -413,7 +415,6 @@ class CycleViewSet(BaseViewSet):
"progress_snapshot",
"sub_issues",
"logo_props",
"version",
# meta fields
"is_favorite",
"total_issues",
@@ -960,6 +961,37 @@ class TransferCycleIssueEndpoint(BaseAPIView):
updated_cycles, ["cycle_id"], batch_size=100
)
estimate_type = Project.objects.filter(
workspace__slug=slug,
pk=project_id,
estimate__isnull=False,
estimate__type="points",
).exists()
CycleIssueStateProgress.objects.bulk_create(
[
CycleIssueStateProgress(
cycle_id=new_cycle_id,
state_id=cycle_issue.issue.state_id,
issue_id=cycle_issue.issue_id,
state_group=cycle_issue.issue.state.group,
type="ADDED",
estimate_id=cycle_issue.issue.estimate_point_id,
estimate_value=(
cycle_issue.issue.estimate_point.value
if estimate_type
else None
),
project_id=project_id,
workspace_id=cycle_issue.workspace_id,
created_by_id=request.user.id,
updated_by_id=request.user.id,
)
for cycle_issue in cycle_issues
],
batch_size=10,
)
# Capture Issue Activity
issue_activity.delay(
type="cycle.activity.created",
@@ -1370,3 +1402,19 @@ class CycleAnalyticsEndpoint(BaseAPIView):
},
status=status.HTTP_200_OK,
)
class CycleIssueStateAnalyticsEndpoint(BaseAPIView):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def get(self, request, slug, project_id, cycle_id):
cycle_state_progress = CycleAnalytics.objects.filter(
cycle_id=cycle_id,
project_id=project_id,
workspace__slug=slug,
)
return Response(
CycleAnalyticsSerializer(cycle_state_progress, many=True).data,
status=status.HTTP_200_OK,
)
+85 -4
View File
@@ -24,6 +24,8 @@ from plane.db.models import (
Issue,
IssueAttachment,
IssueLink,
CycleIssueStateProgress,
Project,
)
from plane.utils.grouper import (
issue_group_values,
@@ -246,10 +248,7 @@ class CycleIssueViewSet(BaseViewSet):
workspace__slug=slug, project_id=project_id, pk=cycle_id
)
if (
cycle.end_date is not None
and cycle.end_date < timezone.now()
):
if cycle.end_date is not None and cycle.end_date < timezone.now():
return Response(
{
"error": "The Cycle has already been completed so no new issues can be added"
@@ -268,6 +267,12 @@ class CycleIssueViewSet(BaseViewSet):
]
new_issues = list(set(issues) - set(existing_issues))
# Fetch issue details
issue_objects = Issue.objects.filter(id__in=issues).annotate(
cycle_id=F("issue_cycle__cycle_id")
)
issue_dict = {str(issue.id): issue for issue in issue_objects}
# New issues to create
created_records = CycleIssue.objects.bulk_create(
[
@@ -284,6 +289,60 @@ class CycleIssueViewSet(BaseViewSet):
batch_size=10,
)
estimate_type = Project.objects.filter(
workspace__slug=slug,
pk=project_id,
estimate__isnull=False,
estimate__type="points",
).exists()
CycleIssueStateProgress.objects.bulk_create(
[
CycleIssueStateProgress(
cycle_id=cycle_id,
state_id=str(issue_dict[issue_id].state_id),
issue_id=issue_id,
state_group=issue_dict[issue_id].state.group,
type="ADDED",
estimate_id=issue_dict[issue_id].estimate_point_id,
estimate_value=(
issue_dict[issue_id].estimate_point.value
if estimate_type
else None
),
project_id=project_id,
workspace_id=cycle.workspace_id,
created_by_id=request.user.id,
updated_by_id=request.user.id,
)
for issue_id in issues
],
batch_size=10,
)
CycleIssueStateProgress.objects.bulk_create(
[
CycleIssueStateProgress(
cycle_id=issue_dict[issue_id].cycle_id,
state_id=str(issue_dict[issue_id].state_id),
issue_id=issue_id,
state_group=issue_dict[issue_id].state.group,
type="REMOVED",
estimate_id=issue_dict[issue_id].estimate_point_id,
estimate_value=(
issue_dict[issue_id].estimate_point.value
if estimate_type
else None
),
project_id=project_id,
workspace_id=cycle.workspace_id,
created_by_id=request.user.id,
updated_by_id=request.user.id,
)
for issue_id in existing_issues
]
)
# Updated Issues
updated_records = []
update_cycle_issue_activity = []
@@ -336,6 +395,28 @@ class CycleIssueViewSet(BaseViewSet):
project_id=project_id,
cycle_id=cycle_id,
)
issue = Issue.objects.get(pk=issue_id)
estimate_type = Project.objects.filter(
workspace__slug=slug,
pk=project_id,
estimate__isnull=False,
estimate__type="points",
).exists()
CycleIssueStateProgress.objects.create(
cycle_id=cycle_id,
state_id=issue.state_id,
issue_id=issue_id,
state_group=issue.state.group,
type="REMOVED",
estimate_id=issue.estimate_point_id,
estimate_value=(
issue.estimate_point.value if estimate_type else None
),
project_id=project_id,
created_by_id=request.user.id,
updated_by_id=request.user.id,
)
issue_activity.delay(
type="cycle.activity.deleted",
requested_data=json.dumps(
+2 -62
View File
@@ -1,9 +1,5 @@
import random
import string
import json
# Django imports
from django.utils import timezone
# Third party imports
from rest_framework.response import Response
@@ -23,7 +19,6 @@ from plane.app.serializers import (
EstimateReadSerializer,
)
from plane.utils.cache import invalidate_cache
from plane.bgtasks.issue_activities_task import issue_activity
def generate_random_name(length=10):
@@ -254,66 +249,11 @@ class EstimatePointEndpoint(BaseViewSet):
)
# update all the issues with the new estimate
if new_estimate_id:
issues = Issue.objects.filter(
_ = Issue.objects.filter(
project_id=project_id,
workspace__slug=slug,
estimate_point_id=estimate_point_id,
)
for issue in issues:
issue_activity.delay(
type="issue.activity.updated",
requested_data=json.dumps(
{
"estimate_point": (
str(new_estimate_id)
if new_estimate_id
else None
),
}
),
actor_id=str(request.user.id),
issue_id=issue.id,
project_id=str(project_id),
current_instance=json.dumps(
{
"estimate_point": (
str(issue.estimate_point_id)
if issue.estimate_point_id
else None
),
}
),
epoch=int(timezone.now().timestamp()),
)
issues.update(estimate_point_id=new_estimate_id)
else:
issues = Issue.objects.filter(
project_id=project_id,
workspace__slug=slug,
estimate_point_id=estimate_point_id,
)
for issue in issues:
issue_activity.delay(
type="issue.activity.updated",
requested_data=json.dumps(
{
"estimate_point": None,
}
),
actor_id=str(request.user.id),
issue_id=issue.id,
project_id=str(project_id),
current_instance=json.dumps(
{
"estimate_point": (
str(issue.estimate_point_id)
if issue.estimate_point_id
else None
),
}
),
epoch=int(timezone.now().timestamp()),
)
).update(estimate_point_id=new_estimate_id)
# delete the estimate point
old_estimate_point = EstimatePoint.objects.filter(
+2 -2
View File
@@ -323,7 +323,7 @@ class InboxIssueViewSet(BaseViewSet):
serializer.errors, status=status.HTTP_400_BAD_REQUEST
)
@allow_permission(allowed_roles=[ROLE.ADMIN], creator=True, model=Issue)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def partial_update(self, request, slug, project_id, pk):
inbox_id = Inbox.objects.filter(
workspace__slug=slug, project_id=project_id
@@ -418,7 +418,7 @@ class InboxIssueViewSet(BaseViewSet):
)
# Only project admins and members can edit inbox issue attributes
if project_member.role > 15:
if project_member.role > 5:
serializer = InboxIssueSerializer(
inbox_issue, data=request.data, partial=True
)
+33 -52
View File
@@ -42,6 +42,7 @@ from plane.db.models import (
IssueSubscriber,
Project,
ProjectMember,
CycleIssueStateProgress,
)
from plane.utils.grouper import (
issue_group_values,
@@ -234,17 +235,11 @@ class IssueViewSet(BaseViewSet):
@method_decorator(gzip_page)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def list(self, request, slug, project_id):
extra_filters = {}
if request.GET.get("updated_at__gt", None) is not None:
extra_filters = {
"updated_at__gt": request.GET.get("updated_at__gt")
}
project = Project.objects.get(pk=project_id, workspace__slug=slug)
filters = issue_filters(request.query_params, "GET")
order_by_param = request.GET.get("order_by", "-created_at")
issue_queryset = self.get_queryset().filter(**filters, **extra_filters)
issue_queryset = self.get_queryset().filter(**filters)
# Custom ordering for priority and state
# Issue queryset
@@ -607,6 +602,12 @@ class IssueViewSet(BaseViewSet):
current_instance = json.dumps(
IssueSerializer(issue).data, cls=DjangoJSONEncoder
)
estimate_type = Project.objects.filter(
workspace__slug=slug,
pk=project_id,
estimate__isnull=False,
estimate__type="points",
).exists()
requested_data = json.dumps(self.request.data, cls=DjangoJSONEncoder)
serializer = IssueCreateSerializer(
@@ -625,6 +626,23 @@ class IssueViewSet(BaseViewSet):
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
if issue.cycle_id and request.data.get("state_id"):
CycleIssueStateProgress.objects.create(
cycle_id=issue.cycle_id,
state_id=issue.state_id,
issue_id=issue.id,
state_group=issue.state.group,
type="UPDATED",
estimate_id=issue.estimate_point_id,
estimate_value=(
issue.estimate_point.value if estimate_type else None
),
project_id=project_id,
workspace_id=issue.workspace_id,
created_by_id=request.user.id,
updated_by_id=request.user.id,
)
model_activity.delay(
model_name="issue",
model_id=str(serializer.data.get("id", None)),
@@ -719,38 +737,16 @@ class BulkDeleteIssuesEndpoint(BaseAPIView):
)
class DeletedIssuesListViewSet(BaseAPIView):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def get(self, request, slug, project_id):
filters = {}
if request.GET.get("updated_at__gt", None) is not None:
filters = {"updated_at__gt": request.GET.get("updated_at__gt")}
deleted_issues = (
Issue.all_objects.filter(
workspace__slug=slug,
project_id=project_id,
)
.filter(Q(archived_at__isnull=False) | Q(deleted_at__isnull=False))
.filter(**filters)
.values_list("id", flat=True)
)
return Response(deleted_issues, status=status.HTTP_200_OK)
class IssuePaginatedViewSet(BaseViewSet):
def get_queryset(self):
workspace_slug = self.kwargs.get("slug")
project_id = self.kwargs.get("project_id")
issue_queryset = Issue.issue_objects.filter(
workspace__slug=workspace_slug, project_id=project_id
)
return (
issue_queryset.select_related(
"workspace", "project", "state", "parent"
Issue.issue_objects.filter(
workspace__slug=workspace_slug, project_id=project_id
)
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module")
.annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(
@@ -792,14 +788,13 @@ class IssuePaginatedViewSet(BaseViewSet):
def list(self, request, slug, project_id):
cursor = request.GET.get("cursor", None)
is_description_required = request.GET.get("description", False)
updated_at = request.GET.get("updated_at__gt", None)
updated_at = request.GET.get("updated_at__gte", None)
# required fields
required_fields = [
"id",
"name",
"state_id",
"state__group",
"sort_order",
"completed_at",
"estimate_point",
@@ -816,6 +811,7 @@ class IssuePaginatedViewSet(BaseViewSet):
"updated_by",
"is_draft",
"archived_at",
"deleted_at",
"module_ids",
"label_ids",
"assignee_ids",
@@ -830,28 +826,13 @@ class IssuePaginatedViewSet(BaseViewSet):
# querying issues
base_queryset = Issue.issue_objects.filter(
workspace__slug=slug, project_id=project_id
)
base_queryset = base_queryset.order_by("updated_at")
).order_by("updated_at")
queryset = self.get_queryset().order_by("updated_at")
# validation for guest user
project = Project.objects.get(pk=project_id, workspace__slug=slug)
project_member = ProjectMember.objects.filter(
workspace__slug=slug,
project_id=project_id,
member=request.user,
role=5,
is_active=True,
)
if project_member.exists() and not project.guest_view_all_features:
base_queryset = base_queryset.filter(created_by=request.user)
queryset = queryset.filter(created_by=request.user)
# filtering issues by greater then updated_at given by the user
if updated_at:
base_queryset = base_queryset.filter(updated_at__gt=updated_at)
queryset = queryset.filter(updated_at__gt=updated_at)
base_queryset = base_queryset.filter(updated_at__gte=updated_at)
queryset = queryset.filter(updated_at__gte=updated_at)
queryset = queryset.annotate(
label_ids=Coalesce(
+410
View File
@@ -0,0 +1,410 @@
# Python imports
import json
# Django imports
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
from django.core.serializers.json import DjangoJSONEncoder
from django.db.models import (
Exists,
F,
Func,
OuterRef,
Prefetch,
Q,
UUIDField,
Value,
)
from django.db.models.functions import Coalesce
from django.utils import timezone
from django.utils.decorators import method_decorator
from django.views.decorators.gzip import gzip_page
# Third Party imports
from rest_framework import status
from rest_framework.response import Response
# Module imports
from plane.app.permissions import ProjectEntityPermission
from plane.app.serializers import (
IssueCreateSerializer,
IssueDetailSerializer,
IssueFlatSerializer,
IssueSerializer,
)
from plane.bgtasks.issue_activities_task import issue_activity
from plane.db.models import (
Issue,
IssueAttachment,
IssueLink,
IssueReaction,
IssueSubscriber,
Project,
ProjectMember,
)
from plane.utils.grouper import (
issue_group_values,
issue_on_results,
issue_queryset_grouper,
)
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 .. import BaseViewSet
class IssueDraftViewSet(BaseViewSet):
permission_classes = [
ProjectEntityPermission,
]
serializer_class = IssueFlatSerializer
model = Issue
def get_queryset(self):
return (
Issue.objects.filter(project_id=self.kwargs.get("project_id"))
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(is_draft=True)
.filter(deleted_at__isnull=True)
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module")
.annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
attachment_count=IssueAttachment.objects.filter(
issue=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
sub_issues_count=Issue.issue_objects.filter(
parent=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
).distinct()
@method_decorator(gzip_page)
def list(self, request, slug, project_id):
filters = issue_filters(request.query_params, "GET")
order_by_param = request.GET.get("order_by", "-created_at")
issue_queryset = self.get_queryset().filter(**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,
)
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=project_id,
filters=filters,
),
sub_group_by_fields=issue_group_values(
field=sub_group_by,
slug=slug,
project_id=project_id,
filters=filters,
),
group_by_field_name=group_by,
sub_group_by_field_name=sub_group_by,
count_filter=Q(
Q(issue_inbox__status=1)
| Q(issue_inbox__status=-1)
| Q(issue_inbox__status=2)
| Q(issue_inbox__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=project_id,
filters=filters,
),
group_by_field_name=group_by,
count_filter=Q(
Q(issue_inbox__status=1)
| Q(issue_inbox__status=-1)
| Q(issue_inbox__status=2)
| Q(issue_inbox__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
),
)
def create(self, request, slug, project_id):
project = Project.objects.get(pk=project_id)
serializer = IssueCreateSerializer(
data=request.data,
context={
"project_id": project_id,
"workspace_id": project.workspace_id,
"default_assignee_id": project.default_assignee_id,
},
)
if serializer.is_valid():
serializer.save(is_draft=True)
# Track the issue
issue_activity.delay(
type="issue_draft.activity.created",
requested_data=json.dumps(
self.request.data, cls=DjangoJSONEncoder
),
actor_id=str(request.user.id),
issue_id=str(serializer.data.get("id", None)),
project_id=str(project_id),
current_instance=None,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
issue = (
issue_queryset_grouper(
queryset=self.get_queryset().filter(
pk=serializer.data["id"]
),
group_by=None,
sub_group_by=None,
)
.values(
"id",
"name",
"state_id",
"sort_order",
"completed_at",
"estimate_point",
"priority",
"start_date",
"target_date",
"sequence_id",
"project_id",
"parent_id",
"cycle_id",
"module_ids",
"label_ids",
"assignee_ids",
"sub_issues_count",
"created_at",
"updated_at",
"created_by",
"updated_by",
"attachment_count",
"link_count",
"is_draft",
"archived_at",
)
.first()
)
return Response(issue, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def partial_update(self, request, slug, project_id, pk):
issue = self.get_queryset().filter(pk=pk).first()
if not issue:
return Response(
{"error": "Issue does not exist"},
status=status.HTTP_404_NOT_FOUND,
)
serializer = IssueCreateSerializer(
issue, data=request.data, partial=True
)
if serializer.is_valid():
serializer.save()
issue_activity.delay(
type="issue_draft.activity.updated",
requested_data=json.dumps(request.data, cls=DjangoJSONEncoder),
actor_id=str(self.request.user.id),
issue_id=str(self.kwargs.get("pk", None)),
project_id=str(self.kwargs.get("project_id", None)),
current_instance=json.dumps(
IssueSerializer(issue).data,
cls=DjangoJSONEncoder,
),
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
return Response(status=status.HTTP_204_NO_CONTENT)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def retrieve(self, request, slug, project_id, pk=None):
issue = (
self.get_queryset()
.filter(pk=pk)
.annotate(
label_ids=Coalesce(
ArrayAgg(
"labels__id",
distinct=True,
filter=~Q(labels__id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
assignee_ids=Coalesce(
ArrayAgg(
"assignees__id",
distinct=True,
filter=~Q(assignees__id__isnull=True)
& Q(assignees__member_project__is_active=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
module_ids=Coalesce(
ArrayAgg(
"issue_module__module_id",
distinct=True,
filter=~Q(issue_module__module_id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
)
.prefetch_related(
Prefetch(
"issue_reactions",
queryset=IssueReaction.objects.select_related(
"issue", "actor"
),
)
)
.prefetch_related(
Prefetch(
"issue_attachment",
queryset=IssueAttachment.objects.select_related("issue"),
)
)
.prefetch_related(
Prefetch(
"issue_link",
queryset=IssueLink.objects.select_related("created_by"),
)
)
.annotate(
is_subscribed=Exists(
IssueSubscriber.objects.filter(
workspace__slug=slug,
project_id=project_id,
issue_id=OuterRef("pk"),
subscriber=request.user,
)
)
)
).first()
if not issue:
return Response(
{"error": "The required object does not exist."},
status=status.HTTP_404_NOT_FOUND,
)
serializer = IssueDetailSerializer(issue, expand=self.expand)
return Response(serializer.data, status=status.HTTP_200_OK)
def destroy(self, request, slug, project_id, pk=None):
issue = Issue.objects.get(
workspace__slug=slug, project_id=project_id, pk=pk
)
if issue.created_by_id != request.user.id and (
not ProjectMember.objects.filter(
workspace__slug=slug,
member=request.user,
role=20,
project_id=project_id,
is_active=True,
).exists()
):
return Response(
{"error": "Only admin or creator can delete the issue"},
status=status.HTTP_403_FORBIDDEN,
)
issue.delete()
issue_activity.delay(
type="issue_draft.activity.deleted",
requested_data=json.dumps({"issue_id": str(pk)}),
actor_id=str(request.user.id),
issue_id=str(pk),
project_id=str(project_id),
current_instance={},
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
return Response(status=status.HTTP_204_NO_CONTENT)
+1 -1
View File
@@ -267,7 +267,7 @@ class IssueRelationViewSet(BaseViewSet):
IssueRelationSerializer(issue_relation).data,
cls=DjangoJSONEncoder,
)
issue_relation.delete(soft=False)
issue_relation.delete()
issue_activity.delay(
type="issue_relation.activity.deleted",
requested_data=json.dumps(request.data, cls=DjangoJSONEncoder),
@@ -41,7 +41,7 @@ class NotificationViewSet(BaseViewSet, BasePaginator):
)
@allow_permission(
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST],
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER],
level="WORKSPACE",
)
def list(self, request, slug):
@@ -207,7 +207,7 @@ class NotificationViewSet(BaseViewSet, BasePaginator):
return Response(serializer.data, status=status.HTTP_200_OK)
@allow_permission(
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE"
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE"
)
def mark_unread(self, request, slug, pk):
notification = Notification.objects.get(
@@ -219,7 +219,7 @@ class NotificationViewSet(BaseViewSet, BasePaginator):
return Response(serializer.data, status=status.HTTP_200_OK)
@allow_permission(
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE"
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE"
)
def archive(self, request, slug, pk):
notification = Notification.objects.get(
@@ -231,7 +231,7 @@ class NotificationViewSet(BaseViewSet, BasePaginator):
return Response(serializer.data, status=status.HTTP_200_OK)
@allow_permission(
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE"
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE"
)
def unarchive(self, request, slug, pk):
notification = Notification.objects.get(
@@ -286,7 +286,7 @@ class UnreadNotificationEndpoint(BaseAPIView):
class MarkAllReadNotificationViewSet(BaseViewSet):
@allow_permission(
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE"
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE"
)
def create(self, request, slug):
snoozed = request.data.get("snoozed", False)
+5 -21
View File
@@ -173,7 +173,7 @@ class ProjectViewSet(BaseViewSet):
member=request.user,
workspace__slug=slug,
is_active=True,
role=15,
role=10,
).exists():
projects = projects.filter(
Q(
@@ -413,20 +413,9 @@ class ProjectViewSet(BaseViewSet):
status=status.HTTP_410_GONE,
)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
def partial_update(self, request, slug, pk=None):
try:
if not ProjectMember.objects.filter(
member=request.user,
workspace__slug=slug,
project_id=pk,
role=20,
is_active=True,
).exists():
return Response(
{"error": "You don't have the required permissions."},
status=status.HTTP_403_FORBIDDEN,
)
workspace = Workspace.objects.get(slug=slug)
project = Project.objects.get(pk=pk)
@@ -449,16 +438,11 @@ class ProjectViewSet(BaseViewSet):
if serializer.is_valid():
serializer.save()
if serializer.data["inbox_view"]:
inbox = Inbox.objects.filter(
Inbox.objects.get_or_create(
name=f"{project.name} Inbox",
project=project,
is_default=True,
).first()
if not inbox:
Inbox.objects.create(
name=f"{project.name} Inbox",
project=project,
is_default=True,
)
)
# Create the triage state in Backlog group
State.objects.get_or_create(
@@ -414,7 +414,6 @@ class UserProjectRolesEndpoint(BaseAPIView):
project_members = ProjectMember.objects.filter(
workspace__slug=slug,
member_id=request.user.id,
is_active=True,
).values("project_id", "role")
project_members = {
+1 -1
View File
@@ -90,7 +90,7 @@ class GlobalSearchEndpoint(BaseAPIView):
"project__identifier",
"project_id",
"workspace__slug",
)[:100]
)
def filter_cycles(self, query, slug, project_id, workspace_search):
fields = ["name"]
+1 -1
View File
@@ -97,6 +97,6 @@ class IssueSearchEndpoint(BaseAPIView):
"state__name",
"state__group",
"state__color",
)[:100],
),
status=status.HTTP_200_OK,
)
@@ -1,412 +0,0 @@
# Python imports
import json
# Django imports
from django.utils import timezone
from django.core import serializers
from django.core.serializers.json import DjangoJSONEncoder
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
from django.db.models import (
F,
Q,
UUIDField,
Value,
)
from django.db.models.functions import Coalesce
from django.utils.decorators import method_decorator
from django.views.decorators.gzip import gzip_page
# Third Party imports
from rest_framework import status
from rest_framework.response import Response
# Module imports
from plane.app.permissions import allow_permission, ROLE
from plane.app.serializers import (
IssueCreateSerializer,
DraftIssueCreateSerializer,
DraftIssueSerializer,
DraftIssueDetailSerializer,
)
from plane.db.models import (
Issue,
DraftIssue,
CycleIssue,
ModuleIssue,
DraftIssueModule,
DraftIssueCycle,
Workspace,
)
from .. import BaseViewSet
from plane.bgtasks.issue_activities_task import issue_activity
from plane.utils.issue_filters import issue_filters
class WorkspaceDraftIssueViewSet(BaseViewSet):
model = DraftIssue
@method_decorator(gzip_page)
@allow_permission(
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE"
)
def list(self, request, slug):
filters = issue_filters(request.query_params, "GET")
issues = (
DraftIssue.objects.filter(workspace__slug=slug)
.filter(created_by=request.user)
.select_related("workspace", "project", "state", "parent")
.prefetch_related(
"assignees", "labels", "draft_issue_module__module"
)
.annotate(cycle_id=F("draft_issue_cycle__cycle_id"))
.annotate(
label_ids=Coalesce(
ArrayAgg(
"labels__id",
distinct=True,
filter=~Q(labels__id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
assignee_ids=Coalesce(
ArrayAgg(
"assignees__id",
distinct=True,
filter=~Q(assignees__id__isnull=True)
& Q(assignees__member_project__is_active=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
module_ids=Coalesce(
ArrayAgg(
"draft_issue_module__module_id",
distinct=True,
filter=~Q(draft_issue_module__module_id__isnull=True)
& Q(
draft_issue_module__module__archived_at__isnull=True
),
),
Value([], output_field=ArrayField(UUIDField())),
),
)
.order_by("-created_at")
)
issues = issues.filter(**filters)
# List Paginate
return self.paginate(
request=request,
queryset=(issues),
on_results=lambda issues: DraftIssueSerializer(
issues,
many=True,
).data,
)
@allow_permission(
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE"
)
def create(self, request, slug):
workspace = Workspace.objects.get(slug=slug)
serializer = DraftIssueCreateSerializer(
data=request.data,
context={
"workspace_id": workspace.id,
"project_id": request.data.get("project_id", None),
},
)
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@allow_permission(
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER],
creator=True,
model=Issue,
level="WORKSPACE",
)
def partial_update(self, request, slug, pk):
issue = (
DraftIssue.objects.filter(workspace__slug=slug)
.filter(pk=pk)
.filter(created_by=request.user)
.select_related("workspace", "project", "state", "parent")
.prefetch_related(
"assignees", "labels", "draft_issue_module__module"
)
.annotate(cycle_id=F("draft_issue_cycle__cycle_id"))
.annotate(
label_ids=Coalesce(
ArrayAgg(
"labels__id",
distinct=True,
filter=~Q(labels__id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
assignee_ids=Coalesce(
ArrayAgg(
"assignees__id",
distinct=True,
filter=~Q(assignees__id__isnull=True)
& Q(assignees__member_project__is_active=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
module_ids=Coalesce(
ArrayAgg(
"draft_issue_module__module_id",
distinct=True,
filter=~Q(draft_issue_module__module_id__isnull=True)
& Q(
draft_issue_module__module__archived_at__isnull=True
),
),
Value([], output_field=ArrayField(UUIDField())),
),
)
.first()
)
if not issue:
return Response(
{"error": "Issue not found"},
status=status.HTTP_404_NOT_FOUND,
)
serializer = DraftIssueCreateSerializer(
issue,
data=request.data,
partial=True,
context={
"project_id": request.data.get("project_id", None),
"cycle_id": request.data.get("cycle_id", "not_provided"),
},
)
if serializer.is_valid():
serializer.save()
return Response(status=status.HTTP_204_NO_CONTENT)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@allow_permission(
allowed_roles=[ROLE.ADMIN],
creator=True,
model=Issue,
level="WORKSPACE",
)
def retrieve(self, request, slug, pk=None):
issue = (
DraftIssue.objects.filter(workspace__slug=slug)
.filter(pk=pk)
.filter(created_by=request.user)
.select_related("workspace", "project", "state", "parent")
.prefetch_related(
"assignees", "labels", "draft_issue_module__module"
)
.annotate(cycle_id=F("draft_issue_cycle__cycle_id"))
.filter(pk=pk)
.annotate(
label_ids=Coalesce(
ArrayAgg(
"labels__id",
distinct=True,
filter=~Q(labels__id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
assignee_ids=Coalesce(
ArrayAgg(
"assignees__id",
distinct=True,
filter=~Q(assignees__id__isnull=True)
& Q(assignees__member_project__is_active=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
module_ids=Coalesce(
ArrayAgg(
"draft_issue_module__module_id",
distinct=True,
filter=~Q(draft_issue_module__module_id__isnull=True)
& Q(
draft_issue_module__module__archived_at__isnull=True
),
),
Value([], output_field=ArrayField(UUIDField())),
),
)
).first()
if not issue:
return Response(
{"error": "The required object does not exist."},
status=status.HTTP_404_NOT_FOUND,
)
serializer = DraftIssueDetailSerializer(issue)
return Response(serializer.data, status=status.HTTP_200_OK)
@allow_permission(
allowed_roles=[ROLE.ADMIN],
creator=True,
model=Issue,
level="WORKSPACE",
)
def destroy(self, request, slug, pk=None):
draft_issue = DraftIssue.objects.get(workspace__slug=slug, pk=pk)
draft_issue.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@allow_permission(
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER],
level="WORKSPACE",
)
def create_draft_to_issue(self, request, slug, draft_id):
draft_issue = (
DraftIssue.objects.filter(workspace__slug=slug, pk=draft_id)
.annotate(cycle_id=F("draft_issue_cycle__cycle_id"))
.annotate(
label_ids=Coalesce(
ArrayAgg(
"labels__id",
distinct=True,
filter=~Q(labels__id__isnull=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
assignee_ids=Coalesce(
ArrayAgg(
"assignees__id",
distinct=True,
filter=~Q(assignees__id__isnull=True)
& Q(assignees__member_project__is_active=True),
),
Value([], output_field=ArrayField(UUIDField())),
),
module_ids=Coalesce(
ArrayAgg(
"draft_issue_module__module_id",
distinct=True,
filter=~Q(draft_issue_module__module_id__isnull=True)
& Q(
draft_issue_module__module__archived_at__isnull=True
),
),
Value([], output_field=ArrayField(UUIDField())),
),
)
.select_related("project", "workspace")
.first()
)
if not draft_issue.project_id:
return Response(
{"error": "Project is required to create an issue."},
status=status.HTTP_400_BAD_REQUEST,
)
serializer = IssueCreateSerializer(
data=request.data,
context={
"project_id": draft_issue.project_id,
"workspace_id": draft_issue.project.workspace_id,
"default_assignee_id": draft_issue.project.default_assignee_id,
},
)
if serializer.is_valid():
serializer.save()
issue_activity.delay(
type="issue.activity.created",
requested_data=json.dumps(
self.request.data, cls=DjangoJSONEncoder
),
actor_id=str(request.user.id),
issue_id=str(serializer.data.get("id", None)),
project_id=str(draft_issue.project_id),
current_instance=None,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
if draft_issue.cycle_id:
created_records = CycleIssue.objects.create(
cycle_id=draft_issue.cycle_id,
issue_id=serializer.data.get("id", None),
project_id=draft_issue.project_id,
workspace_id=draft_issue.workspace_id,
created_by_id=draft_issue.created_by_id,
updated_by_id=draft_issue.updated_by_id,
)
# Capture Issue Activity
issue_activity.delay(
type="cycle.activity.created",
requested_data=None,
actor_id=str(self.request.user.id),
issue_id=None,
project_id=str(self.kwargs.get("project_id", None)),
current_instance=json.dumps(
{
"updated_cycle_issues": None,
"created_cycle_issues": serializers.serialize(
"json", created_records
),
}
),
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
if draft_issue.module_ids:
# bulk create the module
ModuleIssue.objects.bulk_create(
[
ModuleIssue(
module_id=module,
issue_id=serializer.data.get("id", None),
workspace_id=draft_issue.workspace_id,
project_id=draft_issue.project_id,
created_by_id=draft_issue.created_by_id,
updated_by_id=draft_issue.updated_by_id,
)
for module in draft_issue.module_ids
],
batch_size=10,
)
# Bulk Update the activity
_ = [
issue_activity.delay(
type="module.activity.created",
requested_data=json.dumps({"module_id": str(module)}),
actor_id=str(request.user.id),
issue_id=serializer.data.get("id", None),
project_id=draft_issue.project_id,
current_instance=None,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
for module in draft_issue.module_ids
]
# delete the draft issue
draft_issue.delete()
# delete the draft issue module
DraftIssueModule.objects.filter(draft_issue=draft_issue).delete()
# delete the draft issue cycle
DraftIssueCycle.objects.filter(draft_issue=draft_issue).delete()
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@@ -74,6 +74,7 @@ class WorkSpaceMemberViewSet(BaseViewSet):
# Get all active workspace members
workspace_members = self.get_queryset()
if workspace_member.role > 5:
serializer = WorkspaceMemberAdminSerializer(
workspace_members,
+1 -1
View File
@@ -504,7 +504,7 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView):
upcoming_cycles = CycleIssue.objects.filter(
workspace__slug=slug,
cycle__start_date__gt=timezone.now(),
cycle__start_date__gt=timezone.now().date(),
issue__assignees__in=[
user_id,
],
@@ -0,0 +1,96 @@
# Django imports
from django.db.models import Sum
from django.utils import timezone
from django.db.models import Subquery, OuterRef
# Third party imports
from celery import shared_task
from plane.db.models import Cycle, CycleIssueStateProgress, CycleAnalytics
@shared_task
def track_cycle_issue_state_progress():
active_cycles = Cycle.objects.filter(
start_date__lte=timezone.now(), end_date__gte=timezone.now()
).values_list("id", "project_id", "workspace_id")
analytics_records = []
current_date = timezone.now().date()
for cycle_id, project_id, workspace_id in active_cycles:
cycle_issues = CycleIssueStateProgress.objects.filter(
id=Subquery(
CycleIssueStateProgress.objects.filter(
cycle_id="bbc67c04-9796-4773-a598-28c170567ddb",
issue=OuterRef("issue"),
)
.order_by("-created_at")
.values("id")[:1]
),
type__in=["ADDED", "UPDATED"],
)
total_issues = cycle_issues.count()
total_estimate_points = (
cycle_issues.aggregate(
total_estimate_points=Sum("estimate_value")
)["total_estimate_points"]
or 0
)
state_groups = [
"backlog",
"unstarted",
"started",
"completed",
"cancelled",
]
state_data = {
group: {
"count": cycle_issues.filter(state_group=group).count(),
"estimate_points": cycle_issues.filter(
state_group=group
).aggregate(total_estimate_points=Sum("estimate_value"))[
"total_estimate_points"
]
or 0,
}
for group in state_groups
}
# Prepare analytics record for bulk insert
analytics_records.append(
CycleAnalytics(
cycle_id=cycle_id,
date=current_date,
total_issues=total_issues,
total_estimate_points=total_estimate_points,
backlog_issues=state_data["backlog"]["count"],
unstarted_issues=state_data["unstarted"]["count"],
started_issues=state_data["started"]["count"],
completed_issues=state_data["completed"]["count"],
cancelled_issues=state_data["cancelled"]["count"],
backlog_estimate_points=state_data["backlog"][
"estimate_points"
],
unstarted_estimate_points=state_data["unstarted"][
"estimate_points"
],
started_estimate_points=state_data["started"][
"estimate_points"
],
completed_estimate_points=state_data["completed"][
"estimate_points"
],
cancelled_estimate_points=state_data["cancelled"][
"estimate_points"
],
project_id=project_id,
workspace_id=workspace_id,
)
)
# Bulk create the records at once
if analytics_records:
CycleAnalytics.objects.bulk_create(analytics_records)
+13 -21
View File
@@ -2,7 +2,6 @@
from django.utils import timezone
from django.apps import apps
from django.conf import settings
from django.db import models
from django.core.exceptions import ObjectDoesNotExist
# Third party imports
@@ -19,25 +18,17 @@ def soft_delete_related_objects(
for field in related_fields:
if field.one_to_many or field.one_to_one:
try:
# Check if the field has CASCADE on delete
if (
hasattr(field.remote_field, "on_delete")
and field.remote_field.on_delete == models.CASCADE
):
if field.one_to_many:
related_objects = getattr(instance, field.name).all()
elif field.one_to_one:
related_object = getattr(instance, field.name)
related_objects = (
[related_object]
if related_object is not None
else []
)
for obj in related_objects:
if obj:
obj.deleted_at = timezone.now()
obj.save(using=using)
if field.one_to_many:
related_objects = getattr(instance, field.name).all()
elif field.one_to_one:
related_object = getattr(instance, field.name)
related_objects = (
[related_object] if related_object is not None else []
)
for obj in related_objects:
if obj:
obj.deleted_at = timezone.now()
obj.save(using=using)
except ObjectDoesNotExist:
pass
@@ -163,7 +154,8 @@ def hard_delete():
if hasattr(model, "deleted_at"):
# Get all instances where 'deleted_at' is greater than 30 days ago
_ = model.all_objects.filter(
deleted_at__lt=timezone.now() - timezone.timedelta(days=days)
deleted_at__lt=timezone.now()
- timezone.timedelta(days=days)
).delete()
return
+20 -33
View File
@@ -347,7 +347,7 @@ def create_issues(workspace, project, user_id, issue_count):
)
)
text = fake.text(max_nb_chars=3000)
text = fake.text(max_nb_chars=60000)
issues.append(
Issue(
state_id=states[random.randint(0, len(states) - 1)],
@@ -490,23 +490,18 @@ def create_issue_assignees(workspace, project, user_id, issue_count):
def create_issue_labels(workspace, project, user_id, issue_count):
# labels
labels = Label.objects.filter(project=project).values_list("id", flat=True)
# issues = random.sample(
# list(
# Issue.objects.filter(project=project).values_list("id", flat=True)
# ),
# int(issue_count / 2),
# )
issues = list(
issues = random.sample(
list(
Issue.objects.filter(project=project).values_list("id", flat=True)
)
shuffled_labels = list(labels)
),
int(issue_count / 2),
)
# Bulk issue
bulk_issue_labels = []
for issue in issues:
random.shuffle(shuffled_labels)
for label in random.sample(
shuffled_labels, random.randint(0, 5)
list(labels), random.randint(0, len(labels) - 1)
):
bulk_issue_labels.append(
IssueLabel(
@@ -557,33 +552,25 @@ def create_module_issues(workspace, project, user_id, issue_count):
modules = Module.objects.filter(project=project).values_list(
"id", flat=True
)
# issues = random.sample(
# list(
# Issue.objects.filter(project=project).values_list("id", flat=True)
# ),
# int(issue_count / 2),
# )
issues = list(
issues = random.sample(
list(
Issue.objects.filter(project=project).values_list("id", flat=True)
)
shuffled_modules = list(modules)
),
int(issue_count / 2),
)
# Bulk issue
bulk_module_issues = []
for issue in issues:
random.shuffle(shuffled_modules)
for module in random.sample(
shuffled_modules, random.randint(0, 5)
):
bulk_module_issues.append(
ModuleIssue(
module_id=module,
issue_id=issue,
project=project,
workspace=workspace,
)
module = modules[random.randint(0, len(modules) - 1)]
bulk_module_issues.append(
ModuleIssue(
module_id=module,
issue_id=issue,
project=project,
workspace=workspace,
)
)
# Issue assignees
ModuleIssue.objects.bulk_create(
bulk_module_issues, batch_size=1000, ignore_conflicts=True
@@ -465,7 +465,7 @@ def track_estimate_points(
IssueActivity(
issue_id=issue_id,
actor_id=actor_id,
verb="removed" if new_estimate is None else "updated",
verb="updated",
old_identifier=(
current_instance.get("estimate_point")
if current_instance.get("estimate_point") is not None
@@ -1700,12 +1700,16 @@ def issue_activity(
event=(
"issue_comment"
if activity.field == "comment"
else "inbox_issue" if inbox else "issue"
else "inbox_issue"
if inbox
else "issue"
),
event_id=(
activity.issue_comment_id
if activity.field == "comment"
else inbox if inbox else activity.issue_id
else inbox
if inbox
else activity.issue_id
),
verb=activity.verb,
field=(
@@ -42,12 +42,14 @@ def archive_old_issues():
),
Q(issue_cycle__isnull=True)
| (
Q(issue_cycle__cycle__end_date__lt=timezone.now())
Q(issue_cycle__cycle__end_date__lt=timezone.now().date())
& Q(issue_cycle__isnull=False)
),
Q(issue_module__isnull=True)
| (
Q(issue_module__module__target_date__lt=timezone.now())
Q(
issue_module__module__target_date__lt=timezone.now().date()
)
& Q(issue_module__isnull=False)
),
).filter(
@@ -120,12 +122,14 @@ def close_old_issues():
),
Q(issue_cycle__isnull=True)
| (
Q(issue_cycle__cycle__end_date__lt=timezone.now())
Q(issue_cycle__cycle__end_date__lt=timezone.now().date())
& Q(issue_cycle__isnull=False)
),
Q(issue_module__isnull=True)
| (
Q(issue_module__module__target_date__lt=timezone.now())
Q(
issue_module__module__target_date__lt=timezone.now().date()
)
& Q(issue_module__isnull=False)
),
).filter(
+4
View File
@@ -40,6 +40,10 @@ app.conf.beat_schedule = {
"task": "plane.bgtasks.deletion_task.hard_delete",
"schedule": crontab(hour=0, minute=0),
},
"track-cycle-issue-state-progress": {
"task": "plane.bgtasks.cycle_issue_state_progress_task.track_cycle_issue_state_progress",
"schedule": crontab(hour=0, minute=0),
},
}
# Load task modules from all registered Django app configs.
@@ -73,7 +73,7 @@ class Command(BaseCommand):
from plane.bgtasks.dummy_data_task import create_dummy_data
create_dummy_data(
create_dummy_data.delay(
slug=workspace_slug,
email=creator,
members=members,
@@ -1,224 +1,66 @@
# Generated by Django 4.2.15 on 2024-09-24 08:44
# Generated by Django 4.2.15 on 2024-09-16 07:40
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import uuid
from django.db.models import Prefetch
def migrate_draft_issues(apps, schema_editor):
Issue = apps.get_model("db", "Issue")
DraftIssue = apps.get_model("db", "DraftIssue")
IssueAssignee = apps.get_model("db", "IssueAssignee")
DraftIssueAssignee = apps.get_model("db", "DraftIssueAssignee")
IssueLabel = apps.get_model("db", "IssueLabel")
DraftIssueLabel = apps.get_model("db", "DraftIssueLabel")
ModuleIssue = apps.get_model("db", "ModuleIssue")
DraftIssueModule = apps.get_model("db", "DraftIssueModule")
DraftIssueCycle = apps.get_model("db", "DraftIssueCycle")
def populate_cycle_issue_state_progress(apps, schema_editor):
# Get the models
CycleIssue = apps.get_model("db", "CycleIssue")
CycleIssueStateProgress = apps.get_model("db", "CycleIssueStateProgress")
Project = apps.get_model("db", "Project")
# Fetch all draft issues with their related assignees and labels
issues = (
Issue.objects.filter(is_draft=True)
.select_related("issue_cycle__cycle")
.prefetch_related(
Prefetch(
"issue_assignee",
queryset=IssueAssignee.objects.select_related("assignee"),
),
Prefetch(
"label_issue",
queryset=IssueLabel.objects.select_related("label"),
),
Prefetch(
"issue_module",
queryset=ModuleIssue.objects.select_related("module"),
),
# Prepare a list to hold all CycleIssueStateProgress objects
progress_records = []
# Iterate through all CycleIssue records
for cycle_issue in CycleIssue.objects.all():
estimate_type = Project.objects.filter(
workspace_id=cycle_issue.workspace_id,
pk=cycle_issue.project_id,
estimate__isnull=False,
estimate__type="points",
).exists()
# Create a CycleIssueStateProgress object for each cycle_issue
progress_records.append(
CycleIssueStateProgress(
cycle_id=cycle_issue.cycle_id,
state_id=cycle_issue.issue.state_id,
issue_id=cycle_issue.issue_id,
state_group=cycle_issue.issue.state.group,
type="ADDED",
estimate_id=cycle_issue.issue.estimate_point_id,
estimate_value=(
cycle_issue.issue.estimate_point.value
if estimate_type
else None
),
project_id=cycle_issue.project_id,
workspace_id=cycle_issue.workspace_id,
created_by_id=cycle_issue.created_by_id,
updated_by_id=cycle_issue.updated_by_id,
)
)
)
draft_issues = []
draft_issue_cycle = []
draft_issue_labels = []
draft_issue_modules = []
draft_issue_assignees = []
# issue_ids_to_delete = []
for issue in issues:
draft_issue = DraftIssue(
parent_id=issue.parent_id,
state_id=issue.state_id,
estimate_point_id=issue.estimate_point_id,
name=issue.name,
description=issue.description,
description_html=issue.description_html,
description_stripped=issue.description_stripped,
description_binary=issue.description_binary,
priority=issue.priority,
start_date=issue.start_date,
target_date=issue.target_date,
workspace_id=issue.workspace_id,
project_id=issue.project_id,
created_by_id=issue.created_by_id,
updated_by_id=issue.updated_by_id,
)
draft_issues.append(draft_issue)
for assignee in issue.issue_assignee.all():
draft_issue_assignees.append(
DraftIssueAssignee(
draft_issue=draft_issue,
assignee=assignee.assignee,
workspace_id=issue.workspace_id,
project_id=issue.project_id,
)
)
# Prepare labels for bulk insert
for label in issue.label_issue.all():
draft_issue_labels.append(
DraftIssueLabel(
draft_issue=draft_issue,
label=label.label,
workspace_id=issue.workspace_id,
project_id=issue.project_id,
)
)
for module_issue in issue.issue_module.all():
draft_issue_modules.append(
DraftIssueModule(
draft_issue=draft_issue,
module=module_issue.module,
workspace_id=issue.workspace_id,
project_id=issue.project_id,
)
)
if hasattr(issue, "issue_cycle") and issue.issue_cycle:
draft_issue_cycle.append(
DraftIssueCycle(
draft_issue=draft_issue,
cycle=issue.issue_cycle.cycle,
workspace_id=issue.workspace_id,
project_id=issue.project_id,
)
)
# issue_ids_to_delete.append(issue.id)
# Bulk create draft issues
DraftIssue.objects.bulk_create(draft_issues)
# Bulk create draft assignees and labels
DraftIssueLabel.objects.bulk_create(draft_issue_labels)
DraftIssueAssignee.objects.bulk_create(draft_issue_assignees)
# Bulk create draft modules
DraftIssueCycle.objects.bulk_create(draft_issue_cycle)
DraftIssueModule.objects.bulk_create(draft_issue_modules)
# Delete original issues
# Issue.objects.filter(id__in=issue_ids_to_delete).delete()
# Bulk create all records in one query
if progress_records:
CycleIssueStateProgress.objects.bulk_create(progress_records)
class Migration(migrations.Migration):
dependencies = [
("db", "0076_alter_projectmember_role_and_more"),
('db', '0076_alter_projectmember_role_and_more'),
]
operations = [
migrations.CreateModel(
name="DraftIssue",
fields=[
(
"created_at",
models.DateTimeField(
auto_now_add=True, verbose_name="Created At"
),
),
(
"updated_at",
models.DateTimeField(
auto_now=True, verbose_name="Last Modified At"
),
),
(
"deleted_at",
models.DateTimeField(
blank=True, null=True, verbose_name="Deleted At"
),
),
(
"id",
models.UUIDField(
db_index=True,
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
unique=True,
),
),
(
"name",
models.CharField(
blank=True,
max_length=255,
null=True,
verbose_name="Issue Name",
),
),
("description", models.JSONField(blank=True, default=dict)),
(
"description_html",
models.TextField(blank=True, default="<p></p>"),
),
(
"description_stripped",
models.TextField(blank=True, null=True),
),
("description_binary", models.BinaryField(null=True)),
(
"priority",
models.CharField(
choices=[
("urgent", "Urgent"),
("high", "High"),
("medium", "Medium"),
("low", "Low"),
("none", "None"),
],
default="none",
max_length=30,
verbose_name="Issue Priority",
),
),
("start_date", models.DateField(blank=True, null=True)),
("target_date", models.DateField(blank=True, null=True)),
("sort_order", models.FloatField(default=65535)),
("completed_at", models.DateTimeField(null=True)),
(
"external_source",
models.CharField(blank=True, max_length=255, null=True),
),
(
"external_id",
models.CharField(blank=True, max_length=255, null=True),
),
],
options={
"verbose_name": "DraftIssue",
"verbose_name_plural": "DraftIssues",
"db_table": "draft_issues",
"ordering": ("-created_at",),
},
),
migrations.AddField(
model_name="cycle",
name="timezone",
name="user_timezone",
field=models.CharField(
choices=[
("Africa/Abidjan", "Africa/Abidjan"),
@@ -866,7 +708,7 @@ class Migration(migrations.Migration):
),
migrations.AddField(
model_name="project",
name="timezone",
name="user_timezone",
field=models.CharField(
choices=[
("Africa/Abidjan", "Africa/Abidjan"),
@@ -1527,7 +1369,7 @@ class Migration(migrations.Migration):
),
),
migrations.CreateModel(
name="DraftIssueModule",
name="CycleUpdates",
fields=[
(
"created_at",
@@ -1558,6 +1400,25 @@ class Migration(migrations.Migration):
unique=True,
),
),
("description", models.TextField(blank=True)),
(
"status",
models.CharField(
choices=[
("ONTRACK", "On Track"),
("OFFTRACK", "Off Track"),
("AT_RISK", "At Risk"),
("STARTED", "Started"),
("SCOPE_INCREASED", "Scope Increased"),
("SCOPE_DECREASED", "Scope Decreased"),
],
max_length=30,
),
),
("completed_issues", models.FloatField(default=0)),
("total_issues", models.FloatField(default=0)),
("total_estimate_points", models.FloatField(default=0)),
("completed_estimate_points", models.FloatField(default=0)),
(
"created_by",
models.ForeignKey(
@@ -1569,25 +1430,16 @@ class Migration(migrations.Migration):
),
),
(
"draft_issue",
"cycle",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="draft_issue_module",
to="db.draftissue",
),
),
(
"module",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="draft_issue_module",
to="db.module",
related_name="cycle_updates",
to="db.cycle",
),
),
(
"project",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="project_%(class)s",
to="db.project",
@@ -1613,14 +1465,14 @@ class Migration(migrations.Migration):
),
],
options={
"verbose_name": "Draft Issue Module",
"verbose_name_plural": "Draft Issue Modules",
"db_table": "draft_issue_modules",
"verbose_name": "Cycle Updates",
"verbose_name_plural": "Cycle Updates",
"db_table": "cycle_updates",
"ordering": ("-created_at",),
},
),
migrations.CreateModel(
name="DraftIssueLabel",
name="CycleUpdateReaction",
fields=[
(
"created_at",
@@ -1651,97 +1503,13 @@ class Migration(migrations.Migration):
unique=True,
),
),
("reaction", models.CharField(max_length=20)),
(
"created_by",
"actor",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="%(class)s_created_by",
on_delete=django.db.models.deletion.CASCADE,
related_name="cycle_update_reactions",
to=settings.AUTH_USER_MODEL,
verbose_name="Created By",
),
),
(
"draft_issue",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="draft_label_issue",
to="db.draftissue",
),
),
(
"label",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="draft_label_issue",
to="db.label",
),
),
(
"project",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="project_%(class)s",
to="db.project",
),
),
(
"updated_by",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="%(class)s_updated_by",
to=settings.AUTH_USER_MODEL,
verbose_name="Last Modified By",
),
),
(
"workspace",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="workspace_%(class)s",
to="db.workspace",
),
),
],
options={
"verbose_name": "Draft Issue Label",
"verbose_name_plural": "Draft Issue Labels",
"db_table": "draft_issue_labels",
"ordering": ("-created_at",),
},
),
migrations.CreateModel(
name="DraftIssueCycle",
fields=[
(
"created_at",
models.DateTimeField(
auto_now_add=True, verbose_name="Created At"
),
),
(
"updated_at",
models.DateTimeField(
auto_now=True, verbose_name="Last Modified At"
),
),
(
"deleted_at",
models.DateTimeField(
blank=True, null=True, verbose_name="Deleted At"
),
),
(
"id",
models.UUIDField(
db_index=True,
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
unique=True,
),
),
(
@@ -1758,27 +1526,26 @@ class Migration(migrations.Migration):
"cycle",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="draft_issue_cycle",
related_name="cycle_update_reactions",
to="db.cycle",
),
),
(
"draft_issue",
models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
related_name="draft_issue_cycle",
to="db.draftissue",
),
),
(
"project",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="project_%(class)s",
to="db.project",
),
),
(
"update",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="cycle_update_reactions",
to="db.cycleupdates",
),
),
(
"updated_by",
models.ForeignKey(
@@ -1799,14 +1566,14 @@ class Migration(migrations.Migration):
),
],
options={
"verbose_name": "Draft Issue Cycle",
"verbose_name_plural": "Draft Issue Cycles",
"db_table": "draft_issue_cycles",
"verbose_name": "Cycle Update Reaction",
"verbose_name_plural": "Cycle Update Reactions",
"db_table": "cycle_update_reactions",
"ordering": ("-created_at",),
},
),
migrations.CreateModel(
name="DraftIssueAssignee",
name="CycleIssueStateProgress",
fields=[
(
"created_at",
@@ -1837,14 +1604,21 @@ class Migration(migrations.Migration):
unique=True,
),
),
("state_group", models.CharField(max_length=255)),
(
"assignee",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="draft_issue_assignee",
to=settings.AUTH_USER_MODEL,
"type",
models.CharField(
choices=[
("ADDED", "Added"),
("UPDATED", "Updated"),
("REMOVED", "Removed"),
("TRANSFER", "Transfer"),
],
max_length=30,
),
),
("estimate_id", models.UUIDField(null=True)),
("estimate_value", models.FloatField(null=True)),
(
"created_by",
models.ForeignKey(
@@ -1856,17 +1630,130 @@ class Migration(migrations.Migration):
),
),
(
"draft_issue",
"cycle",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="draft_issue_assignee",
to="db.draftissue",
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="cycle_issue_state_progress",
to="db.cycle",
),
),
(
"issue",
models.ForeignKey(
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="cycle_issue_state_progress",
to="db.issue",
),
),
(
"project",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="project_%(class)s",
to="db.project",
),
),
(
"state",
models.ForeignKey(
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="cycle_issue_state_progress",
to="db.state",
),
),
(
"updated_by",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="%(class)s_updated_by",
to=settings.AUTH_USER_MODEL,
verbose_name="Last Modified By",
),
),
(
"workspace",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="workspace_%(class)s",
to="db.workspace",
),
),
],
options={
"verbose_name": "Cycle Issue State Progress",
"verbose_name_plural": "Cycle Issue State Progress",
"db_table": "cycle_issue_state_progress",
"ordering": ("-created_at",),
},
),
migrations.CreateModel(
name="CycleAnalytics",
fields=[
(
"created_at",
models.DateTimeField(
auto_now_add=True, verbose_name="Created At"
),
),
(
"updated_at",
models.DateTimeField(
auto_now=True, verbose_name="Last Modified At"
),
),
(
"deleted_at",
models.DateTimeField(
blank=True, null=True, verbose_name="Deleted At"
),
),
(
"id",
models.UUIDField(
db_index=True,
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
unique=True,
),
),
("date", models.DateField()),
("data", models.JSONField(default=dict)),
("total_issues", models.FloatField(default=0)),
("total_estimate_points", models.FloatField(default=0)),
("backlog_issues", models.FloatField(default=0)),
("unstarted_issues", models.FloatField(default=0)),
("started_issues", models.FloatField(default=0)),
("completed_issues", models.FloatField(default=0)),
("cancelled_issues", models.FloatField(default=0)),
("backlog_estimate_points", models.FloatField(default=0)),
("unstarted_estimate_points", models.FloatField(default=0)),
("started_estimate_points", models.FloatField(default=0)),
("completed_estimate_points", models.FloatField(default=0)),
("cancelled_estimate_points", models.FloatField(default=0)),
(
"created_by",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="%(class)s_created_by",
to=settings.AUTH_USER_MODEL,
verbose_name="Created By",
),
),
(
"cycle",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="cycle_analytics",
to="db.cycle",
),
),
(
"project",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="project_%(class)s",
to="db.project",
@@ -1892,145 +1779,22 @@ class Migration(migrations.Migration):
),
],
options={
"verbose_name": "Draft Issue Assignee",
"verbose_name_plural": "Draft Issue Assignees",
"db_table": "draft_issue_assignees",
"verbose_name": "Cycle Analytics",
"verbose_name_plural": "Cycle Analytics",
"db_table": "cycle_analytics",
"ordering": ("-created_at",),
"unique_together": {("cycle", "date")},
},
),
migrations.AddField(
model_name="draftissue",
name="assignees",
field=models.ManyToManyField(
migrations.AlterField(
model_name="issue",
name="sequence_id",
field=models.IntegerField(
blank=True,
related_name="draft_assignee",
through="db.DraftIssueAssignee",
to=settings.AUTH_USER_MODEL,
),
),
migrations.AddField(
model_name="draftissue",
name="created_by",
field=models.ForeignKey(
default=1,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="%(class)s_created_by",
to=settings.AUTH_USER_MODEL,
verbose_name="Created By",
verbose_name="Issue Sequence ID",
),
),
migrations.AddField(
model_name="draftissue",
name="estimate_point",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="draft_issue_estimates",
to="db.estimatepoint",
),
),
migrations.AddField(
model_name="draftissue",
name="labels",
field=models.ManyToManyField(
blank=True,
related_name="draft_labels",
through="db.DraftIssueLabel",
to="db.label",
),
),
migrations.AddField(
model_name="draftissue",
name="parent",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="draft_parent_issue",
to="db.issue",
),
),
migrations.AddField(
model_name="draftissue",
name="project",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="project_%(class)s",
to="db.project",
),
),
migrations.AddField(
model_name="draftissue",
name="state",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="state_draft_issue",
to="db.state",
),
),
migrations.AddField(
model_name="draftissue",
name="type",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="draft_issue_type",
to="db.issuetype",
),
),
migrations.AddField(
model_name="draftissue",
name="updated_by",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="%(class)s_updated_by",
to=settings.AUTH_USER_MODEL,
verbose_name="Last Modified By",
),
),
migrations.AddField(
model_name="draftissue",
name="workspace",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="workspace_%(class)s",
to="db.workspace",
),
),
migrations.AddConstraint(
model_name="draftissuemodule",
constraint=models.UniqueConstraint(
condition=models.Q(("deleted_at__isnull", True)),
fields=("draft_issue", "module"),
name="module_draft_issue_unique_issue_module_when_deleted_at_null",
),
),
migrations.AlterUniqueTogether(
name="draftissuemodule",
unique_together={("draft_issue", "module", "deleted_at")},
),
migrations.AddConstraint(
model_name="draftissueassignee",
constraint=models.UniqueConstraint(
condition=models.Q(("deleted_at__isnull", True)),
fields=("draft_issue", "assignee"),
name="draft_issue_assignee_unique_issue_assignee_when_deleted_at_null",
),
),
migrations.AlterUniqueTogether(
name="draftissueassignee",
unique_together={("draft_issue", "assignee", "deleted_at")},
),
migrations.AddField(
model_name="cycle",
name="version",
field=models.IntegerField(default=1),
),
migrations.RunPython(migrate_draft_issues),
migrations.RunPython(populate_cycle_issue_state_progress),
]
+10 -2
View File
@@ -2,10 +2,18 @@ from .analytic import AnalyticView
from .api import APIActivityLog, APIToken
from .asset import FileAsset
from .base import BaseModel
from .cycle import Cycle, CycleFavorite, CycleIssue, CycleUserProperties
from .cycle import (
Cycle,
CycleFavorite,
CycleIssue,
CycleUserProperties,
CycleAnalytics,
CycleUpdates,
CycleUpdateReaction,
CycleIssueStateProgress,
)
from .dashboard import Dashboard, DashboardWidget, Widget
from .deploy_board import DeployBoard
from .draft import DraftIssue, DraftIssueAssignee, DraftIssueLabel, DraftIssueModule, DraftIssueCycle
from .estimate import Estimate, EstimatePoint
from .exporter import ExporterHistory
from .importer import Importer
+141 -5
View File
@@ -1,4 +1,4 @@
# Python imports
# Python Imports
import pytz
# Django imports
@@ -77,11 +77,10 @@ class Cycle(ProjectBaseModel):
archived_at = models.DateTimeField(null=True)
logo_props = models.JSONField(default=dict)
# timezone
TIMEZONE_CHOICES = tuple(zip(pytz.all_timezones, pytz.all_timezones))
timezone = models.CharField(
max_length=255, default="UTC", choices=TIMEZONE_CHOICES
USER_TIMEZONE_CHOICES = tuple(zip(pytz.all_timezones, pytz.all_timezones))
user_timezone = models.CharField(
max_length=255, default="UTC", choices=USER_TIMEZONE_CHOICES
)
version = models.IntegerField(default=1)
class Meta:
verbose_name = "Cycle"
@@ -187,3 +186,140 @@ class CycleUserProperties(ProjectBaseModel):
def __str__(self):
return f"{self.cycle.name} {self.user.email}"
class TypeEnum(models.TextChoices):
ADDED = "ADDED", "Added"
UPDATED = "UPDATED", "Updated"
REMOVED = "REMOVED", "Removed"
TRANSFER = "TRANSFER", "Transfer"
class CycleIssueStateProgress(ProjectBaseModel):
cycle = models.ForeignKey(
"db.Cycle",
on_delete=models.DO_NOTHING,
related_name="cycle_issue_state_progress",
)
state = models.ForeignKey(
"db.State",
on_delete=models.DO_NOTHING,
related_name="cycle_issue_state_progress",
)
issue = models.ForeignKey(
"db.Issue",
on_delete=models.DO_NOTHING,
related_name="cycle_issue_state_progress",
)
state_group = models.CharField(max_length=255)
type = models.CharField(
max_length=30,
choices=TypeEnum.choices,
)
estimate_id = models.UUIDField(null=True)
estimate_value = models.FloatField(null=True)
class Meta:
verbose_name = "Cycle Issue State Progress"
verbose_name_plural = "Cycle Issue State Progress"
db_table = "cycle_issue_state_progress"
ordering = ("-created_at",)
def __str__(self):
return f"{self.cycle.name} {self.issue.name}"
class CycleAnalytics(ProjectBaseModel):
cycle = models.ForeignKey(
"db.Cycle", on_delete=models.CASCADE, related_name="cycle_analytics"
)
date = models.DateField()
data = models.JSONField(default=dict)
total_issues = models.FloatField(default=0)
total_estimate_points = models.FloatField(default=0)
# state group wise distribution
backlog_issues = models.FloatField(default=0)
unstarted_issues = models.FloatField(default=0)
started_issues = models.FloatField(default=0)
completed_issues = models.FloatField(default=0)
cancelled_issues = models.FloatField(default=0)
backlog_estimate_points = models.FloatField(default=0)
unstarted_estimate_points = models.FloatField(default=0)
started_estimate_points = models.FloatField(default=0)
completed_estimate_points = models.FloatField(default=0)
cancelled_estimate_points = models.FloatField(default=0)
class Meta:
unique_together = ["cycle", "date"]
verbose_name = "Cycle Analytics"
verbose_name_plural = "Cycle Analytics"
db_table = "cycle_analytics"
ordering = ("-created_at",)
def __str__(self):
return f"{self.user.email} <{self.cycle.name}>"
class UpdatesEnum(models.TextChoices):
ONTRACK = "ONTRACK", "On Track"
OFFTRACK = "OFFTRACK", "Off Track"
AT_RISK = "AT_RISK", "At Risk"
STARTED = "STARTED", "Started"
SCOPE_INCREASED = "SCOPE_INCREASED", "Scope Increased"
SCOPE_DECREASED = "SCOPE_DECREASED", "Scope Decreased"
class CycleUpdates(ProjectBaseModel):
cycle = models.ForeignKey(
"db.Cycle", on_delete=models.CASCADE, related_name="cycle_updates"
)
description = models.TextField(blank=True)
status = models.CharField(
max_length=30,
choices=UpdatesEnum.choices,
)
completed_issues = models.FloatField(default=0)
total_issues = models.FloatField(default=0)
total_estimate_points = models.FloatField(default=0)
completed_estimate_points = models.FloatField(default=0)
class Meta:
verbose_name = "Cycle Updates"
verbose_name_plural = "Cycle Updates"
db_table = "cycle_updates"
ordering = ("-created_at",)
def __str__(self):
return f"{self.cycle.name}"
class CycleUpdateReaction(ProjectBaseModel):
cycle = models.ForeignKey(
"db.Cycle",
on_delete=models.CASCADE,
related_name="cycle_update_reactions",
)
update = models.ForeignKey(
"db.CycleUpdates",
on_delete=models.CASCADE,
related_name="cycle_update_reactions",
)
reaction = models.CharField(max_length=20)
actor = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="cycle_update_reactions",
)
class Meta:
verbose_name = "Cycle Update Reaction"
verbose_name_plural = "Cycle Update Reactions"
db_table = "cycle_update_reactions"
ordering = ("-created_at",)
def __str__(self):
return f"{self.actor.email} <{self.cycle.name}>"
-253
View File
@@ -1,253 +0,0 @@
# Django imports
from django.conf import settings
from django.db import models
from django.utils import timezone
# Module imports
from plane.utils.html_processor import strip_tags
from .workspace import WorkspaceBaseModel
class DraftIssue(WorkspaceBaseModel):
PRIORITY_CHOICES = (
("urgent", "Urgent"),
("high", "High"),
("medium", "Medium"),
("low", "Low"),
("none", "None"),
)
parent = models.ForeignKey(
"db.Issue",
on_delete=models.CASCADE,
null=True,
blank=True,
related_name="draft_parent_issue",
)
state = models.ForeignKey(
"db.State",
on_delete=models.CASCADE,
null=True,
blank=True,
related_name="state_draft_issue",
)
estimate_point = models.ForeignKey(
"db.EstimatePoint",
on_delete=models.SET_NULL,
related_name="draft_issue_estimates",
null=True,
blank=True,
)
name = models.CharField(
max_length=255, verbose_name="Issue Name", blank=True, null=True
)
description = models.JSONField(blank=True, default=dict)
description_html = models.TextField(blank=True, default="<p></p>")
description_stripped = models.TextField(blank=True, null=True)
description_binary = models.BinaryField(null=True)
priority = models.CharField(
max_length=30,
choices=PRIORITY_CHOICES,
verbose_name="Issue Priority",
default="none",
)
start_date = models.DateField(null=True, blank=True)
target_date = models.DateField(null=True, blank=True)
assignees = models.ManyToManyField(
settings.AUTH_USER_MODEL,
blank=True,
related_name="draft_assignee",
through="DraftIssueAssignee",
through_fields=("draft_issue", "assignee"),
)
labels = models.ManyToManyField(
"db.Label",
blank=True,
related_name="draft_labels",
through="DraftIssueLabel",
)
sort_order = models.FloatField(default=65535)
completed_at = models.DateTimeField(null=True)
external_source = models.CharField(max_length=255, null=True, blank=True)
external_id = models.CharField(max_length=255, blank=True, null=True)
type = models.ForeignKey(
"db.IssueType",
on_delete=models.SET_NULL,
related_name="draft_issue_type",
null=True,
blank=True,
)
class Meta:
verbose_name = "DraftIssue"
verbose_name_plural = "DraftIssues"
db_table = "draft_issues"
ordering = ("-created_at",)
def save(self, *args, **kwargs):
if self.state is None:
try:
from plane.db.models import State
default_state = State.objects.filter(
~models.Q(is_triage=True),
project=self.project,
default=True,
).first()
if default_state is None:
random_state = State.objects.filter(
~models.Q(is_triage=True), project=self.project
).first()
self.state = random_state
else:
self.state = default_state
except ImportError:
pass
else:
try:
from plane.db.models import State
if self.state.group == "completed":
self.completed_at = timezone.now()
else:
self.completed_at = None
except ImportError:
pass
if self._state.adding:
# Strip the html tags using html parser
self.description_stripped = (
None
if (
self.description_html == ""
or self.description_html is None
)
else strip_tags(self.description_html)
)
largest_sort_order = DraftIssue.objects.filter(
project=self.project, state=self.state
).aggregate(largest=models.Max("sort_order"))["largest"]
if largest_sort_order is not None:
self.sort_order = largest_sort_order + 10000
super(DraftIssue, self).save(*args, **kwargs)
else:
# Strip the html tags using html parser
self.description_stripped = (
None
if (
self.description_html == ""
or self.description_html is None
)
else strip_tags(self.description_html)
)
super(DraftIssue, self).save(*args, **kwargs)
def __str__(self):
"""Return name of the draft issue"""
return f"{self.name} <{self.project.name}>"
class DraftIssueAssignee(WorkspaceBaseModel):
draft_issue = models.ForeignKey(
DraftIssue,
on_delete=models.CASCADE,
related_name="draft_issue_assignee",
)
assignee = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="draft_issue_assignee",
)
class Meta:
unique_together = ["draft_issue", "assignee", "deleted_at"]
constraints = [
models.UniqueConstraint(
fields=["draft_issue", "assignee"],
condition=models.Q(deleted_at__isnull=True),
name="draft_issue_assignee_unique_issue_assignee_when_deleted_at_null",
)
]
verbose_name = "Draft Issue Assignee"
verbose_name_plural = "Draft Issue Assignees"
db_table = "draft_issue_assignees"
ordering = ("-created_at",)
def __str__(self):
return f"{self.draft_issue.name} {self.assignee.email}"
class DraftIssueLabel(WorkspaceBaseModel):
draft_issue = models.ForeignKey(
"db.DraftIssue",
on_delete=models.CASCADE,
related_name="draft_label_issue",
)
label = models.ForeignKey(
"db.Label", on_delete=models.CASCADE, related_name="draft_label_issue"
)
class Meta:
verbose_name = "Draft Issue Label"
verbose_name_plural = "Draft Issue Labels"
db_table = "draft_issue_labels"
ordering = ("-created_at",)
def __str__(self):
return f"{self.draft_issue.name} {self.label.name}"
class DraftIssueModule(WorkspaceBaseModel):
module = models.ForeignKey(
"db.Module",
on_delete=models.CASCADE,
related_name="draft_issue_module",
)
draft_issue = models.ForeignKey(
"db.DraftIssue",
on_delete=models.CASCADE,
related_name="draft_issue_module",
)
class Meta:
unique_together = ["draft_issue", "module", "deleted_at"]
constraints = [
models.UniqueConstraint(
fields=["draft_issue", "module"],
condition=models.Q(deleted_at__isnull=True),
name="module_draft_issue_unique_issue_module_when_deleted_at_null",
)
]
verbose_name = "Draft Issue Module"
verbose_name_plural = "Draft Issue Modules"
db_table = "draft_issue_modules"
ordering = ("-created_at",)
def __str__(self):
return f"{self.module.name} {self.draft_issue.name}"
class DraftIssueCycle(WorkspaceBaseModel):
"""
Draft Issue Cycles
"""
draft_issue = models.OneToOneField(
"db.DraftIssue",
on_delete=models.CASCADE,
related_name="draft_issue_cycle",
)
cycle = models.ForeignKey(
"db.Cycle", on_delete=models.CASCADE, related_name="draft_issue_cycle"
)
class Meta:
verbose_name = "Draft Issue Cycle"
verbose_name_plural = "Draft Issue Cycles"
db_table = "draft_issue_cycles"
ordering = ("-created_at",)
def __str__(self):
return f"{self.cycle}"
+1 -1
View File
@@ -153,7 +153,7 @@ class Issue(ProjectBaseModel):
through_fields=("issue", "assignee"),
)
sequence_id = models.IntegerField(
default=1, verbose_name="Issue Sequence ID"
default=1, verbose_name="Issue Sequence ID", null=True, blank=True
)
labels = models.ManyToManyField(
"db.Label", blank=True, related_name="labels", through="IssueLabel"
+4 -4
View File
@@ -8,7 +8,7 @@ from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from django.db.models import Q
# Module imports
# Modeule imports
from plane.db.mixins import AuditModel
# Module imports
@@ -121,9 +121,9 @@ class Project(BaseModel):
)
archived_at = models.DateTimeField(null=True)
# timezone
TIMEZONE_CHOICES = tuple(zip(pytz.all_timezones, pytz.all_timezones))
timezone = models.CharField(
max_length=255, default="UTC", choices=TIMEZONE_CHOICES
USER_TIMEZONE_CHOICES = tuple(zip(pytz.all_timezones, pytz.all_timezones))
user_timezone = models.CharField(
max_length=255, default="UTC", choices=USER_TIMEZONE_CHOICES
)
def __str__(self):
-97
View File
@@ -1,97 +0,0 @@
# Third party imports
from celery import shared_task
from opentelemetry import trace
# Module imports
from plane.license.models import Instance
from plane.db.models import (
User,
Workspace,
Project,
Issue,
Module,
Cycle,
CycleIssue,
ModuleIssue,
Page,
WorkspaceMember,
)
@shared_task
def instance_traces():
# Get the tracer
tracer = trace.get_tracer(__name__)
# Check if the instance is registered
instance = Instance.objects.first()
# If instance is None then return
if instance is None:
return
if instance.is_telemetry_enabled:
# Instance details
with tracer.start_as_current_span("instance_details") as span:
# Count of all models
workspace_count = Workspace.objects.count()
user_count = User.objects.count()
project_count = Project.objects.count()
issue_count = Issue.objects.count()
module_count = Module.objects.count()
cycle_count = Cycle.objects.count()
cycle_issue_count = CycleIssue.objects.count()
module_issue_count = ModuleIssue.objects.count()
page_count = Page.objects.count()
# Set span attributes
span.set_attribute("instance_id", instance.instance_id)
span.set_attribute("instance_name", instance.instance_name)
span.set_attribute("current_version", instance.current_version)
span.set_attribute("latest_version", instance.latest_version)
span.set_attribute(
"is_telemetry_enabled", instance.is_telemetry_enabled
)
span.set_attribute("user_count", user_count)
span.set_attribute("workspace_count", workspace_count)
span.set_attribute("project_count", project_count)
span.set_attribute("issue_count", issue_count)
span.set_attribute("module_count", module_count)
span.set_attribute("cycle_count", cycle_count)
span.set_attribute("cycle_issue_count", cycle_issue_count)
span.set_attribute("module_issue_count", module_issue_count)
span.set_attribute("page_count", page_count)
# Workspace details
for workspace in Workspace.objects.all():
# Count of all models
project_count = Project.objects.filter(workspace=workspace).count()
issue_count = Issue.objects.filter(workspace=workspace).count()
module_count = Module.objects.filter(workspace=workspace).count()
cycle_count = Cycle.objects.filter(workspace=workspace).count()
cycle_issue_count = CycleIssue.objects.filter(
workspace=workspace
).count()
module_issue_count = ModuleIssue.objects.filter(
workspace=workspace
).count()
page_count = Page.objects.filter(workspace=workspace).count()
member_count = WorkspaceMember.objects.filter(
workspace=workspace
).count()
# Set span attributes
with tracer.start_as_current_span("workspace_details") as span:
span.set_attribute("instance_id", instance.instance_id)
span.set_attribute("workspace_id", str(workspace.id))
span.set_attribute("workspace_slug", workspace.slug)
span.set_attribute("project_count", project_count)
span.set_attribute("issue_count", issue_count)
span.set_attribute("module_count", module_count)
span.set_attribute("cycle_count", cycle_count)
span.set_attribute("cycle_issue_count", cycle_issue_count)
span.set_attribute("module_issue_count", module_issue_count)
span.set_attribute("page_count", page_count)
span.set_attribute("member_count", member_count)
return
@@ -9,10 +9,7 @@ from django.conf import settings
# Module imports
from plane.license.models import Instance
from plane.db.models import (
User,
)
from plane.license.bgtasks.tracer import instance_traces
from plane.db.models import User
class Command(BaseCommand):
@@ -24,24 +21,16 @@ class Command(BaseCommand):
"machine_signature", type=str, help="Machine signature"
)
def read_package_json(self):
with open("package.json", "r") as file:
# Load JSON content from the file
data = json.load(file)
payload = {
"instance_key": settings.INSTANCE_KEY,
"version": data.get("version", 0.1),
"user_count": User.objects.filter(is_bot=False).count(),
}
return payload
def handle(self, *args, **options):
# Check if the instance is registered
instance = Instance.objects.first()
# If instance is None then register this instance
if instance is None:
with open("package.json", "r") as file:
# Load JSON content from the file
data = json.load(file)
machine_signature = options.get(
"machine_signature", "machine-signature"
)
@@ -49,7 +38,12 @@ class Command(BaseCommand):
if not machine_signature:
raise CommandError("Machine signature is required")
payload = self.read_package_json()
payload = {
"instance_key": settings.INSTANCE_KEY,
"version": data.get("version", 0.1),
"machine_signature": machine_signature,
"user_count": User.objects.filter(is_bot=False).count(),
}
instance = Instance.objects.create(
instance_name="Plane Community Edition",
@@ -66,15 +60,4 @@ class Command(BaseCommand):
self.stdout.write(
self.style.SUCCESS("Instance already registered")
)
payload = self.read_package_json()
# Update the instance details
instance.last_checked_at = timezone.now()
instance.user_count = payload.get("user_count", 0)
instance.current_version = payload.get("version")
instance.latest_version = payload.get("version")
instance.save()
# Call the instance traces task
instance_traces.delay()
return
return
+1 -25
View File
@@ -16,17 +16,6 @@ from sentry_sdk.integrations.django import DjangoIntegration
from sentry_sdk.integrations.redis import RedisIntegration
from corsheaders.defaults import default_headers
# OpenTelemetry
from opentelemetry import trace
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import (
OTLPSpanExporter,
)
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.sdk.resources import Resource
from opentelemetry.instrumentation.django import DjangoInstrumentor
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# Secret Key
@@ -35,19 +24,6 @@ SECRET_KEY = os.environ.get("SECRET_KEY", get_random_secret_key())
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = int(os.environ.get("DEBUG", "0"))
# Initialize Django instrumentation
DjangoInstrumentor().instrument()
# Configure the tracer provider
service_name = os.environ.get("SERVICE_NAME", "plane-ce-api")
resource = Resource.create({"service.name": service_name})
trace.set_tracer_provider(TracerProvider(resource=resource))
# Configure the OTLP exporter
otel_endpoint = os.environ.get("OTLP_ENDPOINT", "https://telemetry.plane.so")
otlp_exporter = OTLPSpanExporter(endpoint=otel_endpoint)
span_processor = BatchSpanProcessor(otlp_exporter)
trace.get_tracer_provider().add_span_processor(span_processor)
# Allowed Hosts
ALLOWED_HOSTS = ["*"]
@@ -303,7 +279,7 @@ CELERY_IMPORTS = (
"plane.bgtasks.file_asset_task",
"plane.bgtasks.email_notification_task",
"plane.bgtasks.api_logs_task",
"plane.license.bgtasks.tracer",
"plane.bgtasks.cycle_issue_state_progress_task",
# management tasks
"plane.bgtasks.dummy_data_task",
)
+3
View File
@@ -201,6 +201,8 @@ def burndown_plot(
)
if module_id:
# start_date = queryset.start_date.date()
# target_date = queryset.target_date.date()
# Get all dates between the two dates
date_range = [
(queryset.start_date + timedelta(days=x)).date()
@@ -254,6 +256,7 @@ def burndown_plot(
chart_data[str(date)] = cumulative_pending_issues
else:
for date in date_range:
print(date, "date")
cumulative_pending_issues = total_issues
total_completed = 0
total_completed = sum(
@@ -1,6 +1,3 @@
# python imports
from math import ceil
# constants
PAGINATOR_MAX_LIMIT = 1000
@@ -39,9 +36,6 @@ def paginate(base_queryset, queryset, cursor, on_result):
total_results = base_queryset.count()
page_size = min(cursor_object.current_page_size, PAGINATOR_MAX_LIMIT)
# getting the total pages available based on the page size
total_pages = ceil(total_results / page_size)
# Calculate the start and end index for the paginated data
start_index = 0
if cursor_object.current_page > 0:
@@ -78,7 +72,6 @@ def paginate(base_queryset, queryset, cursor, on_result):
"next_page_results": next_page_results,
"page_count": len(paginated_data),
"total_results": total_results,
"total_pages": total_pages,
"results": paginated_data,
}
+2 -2
View File
@@ -30,9 +30,9 @@ def order_issue_queryset(issue_queryset, order_by_param="-created_at"):
)
).order_by("priority_order")
order_by_param = (
"priority_order"
"-priority_order"
if order_by_param.startswith("-")
else "-priority_order"
else "priority_order"
)
# State Ordering
elif order_by_param in [
+5 -5
View File
@@ -82,7 +82,7 @@ class CursorResult(Sequence):
return f"<{type(self).__name__}: results={len(self.results)}>"
MAX_LIMIT = 1000
MAX_LIMIT = 100
class BadPaginationError(Exception):
@@ -118,7 +118,7 @@ class OffsetPaginator:
self.max_offset = max_offset
self.on_results = on_results
def get_result(self, limit=1000, cursor=None):
def get_result(self, limit=100, cursor=None):
# offset is page #
# value is page limit
if cursor is None:
@@ -727,7 +727,7 @@ class BasePaginator:
cursor_name = "cursor"
# get the per page parameter from request
def get_per_page(self, request, default_per_page=1000, max_per_page=1000):
def get_per_page(self, request, default_per_page=100, max_per_page=100):
try:
per_page = int(request.GET.get("per_page", default_per_page))
except ValueError:
@@ -747,8 +747,8 @@ class BasePaginator:
on_results=None,
paginator=None,
paginator_cls=OffsetPaginator,
default_per_page=1000,
max_per_page=1000,
default_per_page=100,
max_per_page=100,
cursor_cls=Cursor,
extra_stats=None,
controller=None,
+1 -6
View File
@@ -1,7 +1,7 @@
# base requirements
# django
Django==4.2.16
Django==4.2.15
# rest framework
djangorestframework==3.15.2
# postgres
@@ -62,8 +62,3 @@ zxcvbn==4.4.28
pytz==2024.1
# jwt
PyJWT==2.8.0
# OpenTelemetry
opentelemetry-api==1.27.0
opentelemetry-sdk==1.27.0
opentelemetry-instrumentation-django==0.48b0
opentelemetry-exporter-otlp==1.27.0
+2 -3
View File
@@ -1,8 +1,7 @@
API_BASE_URL="http://api:8000"
LIVE_BASE_PATH="/live"
REDIS_URL="redis://plane-redis:6379/"
REDIS_URL="redis://localhost:6379"
# If you prefer not to provide a Redis URL, you can set the REDIS_HOST and REDIS_PORT environment variables instead.
REDIS_PORT=6379
REDIS_HOST=plane-redis
REDIS_HOST=localhost
-4
View File
@@ -1,4 +0,0 @@
.turbo/*
out/*
dist/*
public/*
-8
View File
@@ -1,8 +0,0 @@
{
"root": true,
"extends": ["@plane/eslint-config/server.js"],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"project": true
}
}
+4 -9
View File
@@ -1,16 +1,13 @@
{
"name": "live",
"version": "0.23.0",
"version": "0.22.0",
"description": "",
"main": "./src/server.ts",
"private": true,
"type": "module",
"scripts": {
"build": "babel src --out-dir dist --extensions \".ts,.js\"",
"start": "node dist/server.js",
"lint": "eslint . --ext .ts,.tsx",
"dev": "concurrently \"babel src --out-dir dist --extensions '.ts,.js' --watch\" \"nodemon dist/server.js\"",
"lint:errors": "eslint . --ext .ts,.tsx --quiet"
"start": "node dist/server.js"
},
"keywords": [],
"author": "",
@@ -38,7 +35,6 @@
"morgan": "^1.10.0",
"pino-http": "^10.3.0",
"pino-pretty": "^11.2.2",
"uuid": "^10.0.0",
"y-prosemirror": "^1.2.9",
"y-protocols": "^1.0.6",
"yjs": "^13.6.14"
@@ -55,10 +51,9 @@
"@types/express-ws": "^3.0.4",
"@types/node": "^20.14.9",
"babel-plugin-module-resolver": "^5.0.2",
"concurrently": "^9.0.1",
"nodemon": "^3.1.7",
"nodemon": "^3.1.0",
"ts-node": "^10.9.2",
"tsup": "^7.2.0",
"typescript": "5.3.3"
"typescript": "5.4.5"
}
}
+15
View File
@@ -0,0 +1,15 @@
import { ConnectionConfiguration } from "@hocuspocus/server";
// types
import { TDocumentTypes } from "@/core/types/common.js";
type TArgs = {
connection: ConnectionConfiguration
cookie: string;
documentType: TDocumentTypes | undefined;
params: URLSearchParams;
}
export const authenticateUser = async (args: TArgs): Promise<void> => {
const { documentType } = args;
throw Error(`Authentication failed: Invalid document type ${documentType} provided.`);
}
+2 -1
View File
@@ -1 +1,2 @@
export type TAdditionalDocumentTypes = {};
// eslint-disable-next-line @typescript-eslint/ban-types
export type TAdditionalDocumentTypes = {}
+5 -8
View File
@@ -45,8 +45,7 @@ export const getExtensions: () => Promise<Extension[]> = async () => {
const documentType = params.get("documentType")?.toString() as
| TDocumentTypes
| undefined;
// TODO: Fix this lint error.
// eslint-disable-next-line no-async-promise-executor
return new Promise(async (resolve) => {
try {
let fetchedData = null;
@@ -54,7 +53,7 @@ export const getExtensions: () => Promise<Extension[]> = async () => {
fetchedData = await fetchPageDescriptionBinary(
params,
pageId,
cookie
cookie,
);
} else {
fetchedData = await fetchDocument({
@@ -84,8 +83,6 @@ export const getExtensions: () => Promise<Extension[]> = async () => {
| TDocumentTypes
| undefined;
// TODO: Fix this lint error.
// eslint-disable-next-line no-async-promise-executor
return new Promise(async () => {
try {
if (documentType === "project_page") {
@@ -124,7 +121,7 @@ export const getExtensions: () => Promise<Extension[]> = async () => {
}
manualLogger.warn(
`Redis Client wasn't able to connect, continuing without Redis (you won't be able to sync data between multiple plane live servers)`,
error
error,
);
reject(error);
});
@@ -138,12 +135,12 @@ export const getExtensions: () => Promise<Extension[]> = async () => {
} catch (error) {
manualLogger.warn(
`Redis Client wasn't able to connect, continuing without Redis (you won't be able to sync data between multiple plane live servers)`,
error
error,
);
}
} else {
manualLogger.warn(
"Redis URL is not set, continuing without Redis (you won't be able to sync data between multiple plane live servers)"
"Redis URL is not set, continuing without Redis (you won't be able to sync data between multiple plane live servers)",
);
}
+1 -1
View File
@@ -1,7 +1,7 @@
import { ErrorRequestHandler } from "express";
import { manualLogger } from "@/core/helpers/logger.js";
export const errorHandler: ErrorRequestHandler = (err, _req, res) => {
export const errorHandler: ErrorRequestHandler = (err, _req, res, _next) => {
// Log the error
manualLogger.error(err);
+7 -6
View File
@@ -1,22 +1,22 @@
import { Server } from "@hocuspocus/server";
import { v4 as uuidv4 } from "uuid";
// lib
import { handleAuthentication } from "@/core/lib/authentication.js";
// extensions
import { getExtensions } from "@/core/extensions/index.js";
export const getHocusPocusServer = async () => {
const extensions = await getExtensions();
const serverName = process.env.HOSTNAME || uuidv4();
return Server.configure({
name: serverName,
onAuthenticate: async ({
requestHeaders,
requestParameters,
connection,
// user id used as token for authentication
token,
}) => {
// request headers
const cookie = requestHeaders.cookie?.toString();
// params
const params = requestParameters;
if (!cookie) {
throw Error("Credentials not provided");
@@ -24,7 +24,9 @@ export const getHocusPocusServer = async () => {
try {
await handleAuthentication({
connection,
cookie,
params,
token,
});
} catch (error) {
@@ -32,6 +34,5 @@ export const getHocusPocusServer = async () => {
}
},
extensions,
debounce: 10000,
});
};
+46 -1
View File
@@ -1,17 +1,28 @@
import { ConnectionConfiguration } from "@hocuspocus/server";
// services
import { UserService } from "@/core/services/user.service.js";
// types
import { TDocumentTypes } from "@/core/types/common.js";
// plane live lib
import { authenticateUser } from "@/plane-live/lib/authentication.js";
// core helpers
import { manualLogger } from "@/core/helpers/logger.js";
const userService = new UserService();
type Props = {
connection: ConnectionConfiguration;
cookie: string;
params: URLSearchParams;
token: string;
};
export const handleAuthentication = async (props: Props) => {
const { cookie, token } = props;
const { connection, cookie, params, token } = props;
// params
const documentType = params.get("documentType")?.toString() as
| TDocumentTypes
| undefined;
// fetch current user info
let response;
try {
@@ -24,6 +35,40 @@ export const handleAuthentication = async (props: Props) => {
throw Error("Authentication failed: Token doesn't match the current user.");
}
if (documentType === "project_page") {
// params
const workspaceSlug = params.get("workspaceSlug")?.toString();
const projectId = params.get("projectId")?.toString();
if (!workspaceSlug || !projectId) {
throw Error(
"Authentication failed: Incomplete query params. Either workspaceSlug or projectId is missing."
);
}
// fetch current user's project membership info
try {
const projectMembershipInfo = await userService.getUserProjectMembership(
workspaceSlug,
projectId,
cookie
);
const projectRole = projectMembershipInfo.role;
// make the connection read only for roles lower than a member
if (projectRole < 15) {
connection.readOnly = true;
}
} catch (error) {
manualLogger.error("Failed to fetch project membership info:", error);
throw error;
}
} else {
await authenticateUser({
connection,
cookie,
documentType,
params,
});
}
return {
user: {
id: response.id,
+34 -1
View File
@@ -1,5 +1,5 @@
// types
import type { IUser } from "@plane/types";
import type { IProjectMember, IUser } from "@plane/types";
// services
import { API_BASE_URL, APIService } from "@/core/services/api.service.js";
@@ -25,4 +25,37 @@ export class UserService extends APIService {
throw error;
});
}
async getUserWorkspaceMembership(
workspaceSlug: string,
cookie: string
): Promise<IProjectMember> {
return this.get(`/api/workspaces/${workspaceSlug}/workspace-members/me/`,
{
headers: {
Cookie: cookie,
},
})
.then((response) => response?.data)
.catch((error) => {
throw error?.response;
});
}
async getUserProjectMembership(
workspaceSlug: string,
projectId: string,
cookie: string
): Promise<IProjectMember> {
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/project-members/me/`,
{
headers: {
Cookie: cookie,
},
})
.then((response) => response?.data)
.catch((error) => {
throw error?.response;
});
}
}
+24 -7
View File
@@ -1,26 +1,43 @@
{
"extends": "@plane/typescript-config/base.json",
"extends": "tsconfig/base.json",
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2015"],
"lib": [
"ES2015"
],
"outDir": "./dist",
"rootDir": ".",
"rootDir": "./src",
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"],
"@/plane-live/*": ["./src/ce/*"]
"@/*": [
"./src/*"
],
"@/plane-live/*": [
"./src/ce/*"
]
},
"removeComments": true,
"esModuleInterop": true,
"skipLibCheck": true,
"sourceMap": true,
"inlineSources": true,
// Set `sourceRoot` to "/" to strip the build path prefix
// from generated source code references.
// This improves issue grouping in Sentry.
"sourceRoot": "/"
},
"include": ["src/**/*.ts", "tsup.config.ts"],
"exclude": ["./dist", "./build", "./node_modules"]
"include": [
"src/**/*.ts"
],
"exclude": [
"./dist",
"./build",
"./node_modules"
]
}
+1 -1
View File
@@ -57,7 +57,7 @@ http {
proxy_set_header Upgrade ${dollar}http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host ${dollar}http_host;
proxy_pass http://live:3000/live/;
proxy_pass http://live:3000/;
}
location /spaces/ {
+22 -5
View File
@@ -1,6 +1,6 @@
{
"repository": "https://github.com/makeplane/plane.git",
"version": "0.23.0",
"version": "0.22.0",
"license": "AGPL-3.0",
"private": true,
"workspaces": [
@@ -8,25 +8,42 @@
"space",
"admin",
"live",
"packages/*"
"packages/editor",
"packages/eslint-config-custom",
"packages/tailwind-config-custom",
"packages/tsconfig",
"packages/ui",
"packages/types",
"packages/constants",
"packages/helpers"
],
"scripts": {
"build": "turbo run build",
"dev": "turbo run dev --concurrency=13",
"start": "turbo run start",
"lint": "turbo run lint",
"lint:errors": "turbo run lint:errors",
"clean": "turbo run clean",
"format": "prettier --write \"**/*.{ts,tsx,md}\""
"format": "prettier --write \"**/*.{ts,tsx,md}\"",
"prepare": "husky"
},
"devDependencies": {
"autoprefixer": "^10.4.15",
"eslint-config-custom": "*",
"eslint-plugin-prettier": "^5.1.3",
"husky": "^9.0.11",
"lint-staged": "^15.2.2",
"postcss": "^8.4.29",
"prettier": "latest",
"prettier-plugin-tailwindcss": "^0.5.4",
"tailwindcss": "^3.3.3",
"turbo": "^2.1.1"
},
"resolutions": {
"@types/react": "18.2.48"
},
"packageManager": "yarn@1.22.22",
"name": "plane"
"name": "plane",
"dependencies": {
"recharts": "^2.12.7"
}
}
-13
View File
@@ -13,19 +13,6 @@ export enum EIssueGroupByToServerOptions {
"created_by" = "created_by",
}
export enum EIssueGroupBYServerToProperty {
"state_id" = "state_id",
"priority" = "priority",
"labels__id" = "label_ids",
"state__group" = "state__group",
"assignees__id" = "assignee_ids",
"cycle_id" = "cycle_id",
"issue_module__module_id" = "module_ids",
"target_date" = "target_date",
"project_id" = "project_id",
"created_by" = "created_by",
}
export enum EServerGroupByToFilterOptions {
"state_id" = "state",
"priority" = "priority",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/constants",
"version": "0.23.0",
"version": "0.22.0",
"private": true,
"main": "./index.ts"
}
+34 -5
View File
@@ -1,9 +1,38 @@
module.exports = {
root: true,
extends: ["@plane/eslint-config/library.js"],
parser: "@typescript-eslint/parser",
parserOptions: {
project: true,
extends: ["custom"],
rules: {
"import/order": [
"error",
{
groups: ["builtin", "external", "internal", "parent", "sibling"],
pathGroups: [
{
pattern: "react",
group: "external",
position: "before",
},
{
pattern: "lucide-react",
group: "external",
position: "after",
},
{
pattern: "@plane/**",
group: "external",
position: "after",
},
{
pattern: "@/**",
group: "internal",
},
],
pathGroupsExcludedImportTypes: ["builtin", "internal", "react"],
alphabetize: {
order: "asc",
caseInsensitive: true,
},
},
],
},
rules: {},
};
+5 -9
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/editor",
"version": "0.23.0",
"version": "0.22.0",
"description": "Core Editor that powers Plane",
"private": true,
"main": "./dist/index.mjs",
@@ -26,7 +26,6 @@
"build": "tsup --minify",
"dev": "tsup --watch",
"check-types": "tsc --noEmit",
"lint": "eslint src --ext .ts,.tsx",
"format": "prettier --write \"**/*.{ts,tsx,md}\""
},
"peerDependencies": {
@@ -42,15 +41,13 @@
"@tiptap/extension-blockquote": "^2.1.13",
"@tiptap/extension-character-count": "^2.6.5",
"@tiptap/extension-collaboration": "^2.3.2",
"@tiptap/extension-color": "^2.7.1",
"@tiptap/extension-highlight": "^2.7.1",
"@tiptap/extension-image": "^2.1.13",
"@tiptap/extension-list-item": "^2.1.13",
"@tiptap/extension-mention": "^2.1.13",
"@tiptap/extension-placeholder": "^2.3.0",
"@tiptap/extension-task-item": "^2.1.13",
"@tiptap/extension-task-list": "^2.1.13",
"@tiptap/extension-text-style": "^2.7.1",
"@tiptap/extension-text-style": "^2.1.13",
"@tiptap/extension-underline": "^2.1.13",
"@tiptap/pm": "^2.1.13",
"@tiptap/react": "^2.1.13",
@@ -68,22 +65,21 @@
"tailwind-merge": "^1.14.0",
"tippy.js": "^6.3.7",
"tiptap-markdown": "^0.8.9",
"uuid": "^10.0.0",
"y-indexeddb": "^9.0.12",
"y-prosemirror": "^1.2.5",
"y-protocols": "^1.0.6",
"yjs": "^13.6.15"
},
"devDependencies": {
"@plane/eslint-config": "*",
"@plane/typescript-config": "*",
"@types/node": "18.15.3",
"@types/react": "^18.2.42",
"@types/react-dom": "^18.2.17",
"eslint-config-custom": "*",
"postcss": "^8.4.38",
"tailwind-config-custom": "*",
"tsconfig": "*",
"tsup": "^7.2.0",
"typescript": "5.3.3"
"typescript": "5.4.5"
},
"keywords": [
"editor",
@@ -1,6 +1,6 @@
import { HocuspocusProvider } from "@hocuspocus/provider";
import { Extensions } from "@tiptap/core";
import { SlashCommands } from "@/extensions";
import { SlashCommand } from "@/extensions";
// plane editor types
import { TIssueEmbedConfig } from "@/plane-editor/types";
// types
@@ -14,7 +14,7 @@ type Props = {
};
export const DocumentEditorAdditionalExtensions = (_props: Props) => {
const extensions: Extensions = [SlashCommands()];
const extensions: Extensions = [SlashCommand()];
return extensions;
};
@@ -1,6 +1,6 @@
import React from "react";
// components
import { DocumentContentLoader, PageRenderer } from "@/components/editors";
import { PageRenderer } from "@/components/editors";
// constants
import { DEFAULT_DISPLAY_CONFIG } from "@/constants/config";
// extensions
@@ -42,7 +42,7 @@ const CollaborativeDocumentEditor = (props: ICollaborativeDocumentEditor) => {
}
// use document editor
const { editor, hasServerConnectionFailed, hasServerSynced } = useCollaborativeEditor({
const { editor } = useCollaborativeEditor({
disabledExtensions,
editorClassName,
embedHandler,
@@ -67,8 +67,6 @@ const CollaborativeDocumentEditor = (props: ICollaborativeDocumentEditor) => {
if (!editor) return null;
if (!hasServerSynced && !hasServerConnectionFailed) return <DocumentContentLoader />;
return (
<PageRenderer
displayConfig={displayConfig}
@@ -1,6 +1,6 @@
import { forwardRef, MutableRefObject } from "react";
// components
import { DocumentContentLoader, PageRenderer } from "@/components/editors";
import { PageRenderer } from "@/components/editors";
// constants
import { DEFAULT_DISPLAY_CONFIG } from "@/constants/config";
// extensions
@@ -35,7 +35,7 @@ const CollaborativeDocumentReadOnlyEditor = (props: ICollaborativeDocumentReadOn
);
}
const { editor, hasServerConnectionFailed, hasServerSynced } = useReadOnlyCollaborativeEditor({
const { editor } = useReadOnlyCollaborativeEditor({
editorClassName,
extensions,
forwardedRef,
@@ -52,9 +52,6 @@ const CollaborativeDocumentReadOnlyEditor = (props: ICollaborativeDocumentReadOn
});
if (!editor) return null;
if (!hasServerSynced && !hasServerConnectionFailed) return <DocumentContentLoader />;
return (
<PageRenderer
displayConfig={displayConfig}
@@ -1,5 +1,4 @@
export * from "./collaborative-editor";
export * from "./collaborative-read-only-editor";
export * from "./loader";
export * from "./page-renderer";
export * from "./read-only-editor";
@@ -1,42 +0,0 @@
// ui
import { Loader } from "@plane/ui";
export const DocumentContentLoader = () => (
<div className="size-full px-5">
<Loader className="relative space-y-4">
<div className="space-y-2">
<div className="py-2">
<Loader.Item width="100%" height="36px" />
</div>
<Loader.Item width="80%" height="22px" />
<div className="relative flex items-center gap-2">
<Loader.Item width="30px" height="30px" />
<Loader.Item width="30%" height="22px" />
</div>
<div className="py-2">
<Loader.Item width="60%" height="36px" />
</div>
<Loader.Item width="70%" height="22px" />
<Loader.Item width="30%" height="22px" />
<div className="relative flex items-center gap-2">
<Loader.Item width="30px" height="30px" />
<Loader.Item width="30%" height="22px" />
</div>
<div className="py-2">
<Loader.Item width="50%" height="30px" />
</div>
<Loader.Item width="100%" height="22px" />
<div className="py-2">
<Loader.Item width="30%" height="30px" />
</div>
<Loader.Item width="30%" height="22px" />
<div className="relative flex items-center gap-2">
<div className="py-2">
<Loader.Item width="30px" height="30px" />
</div>
<Loader.Item width="30%" height="22px" />
</div>
</div>
</Loader>
</div>
);
@@ -18,8 +18,7 @@ interface EditorContainerProps {
export const EditorContainer: FC<EditorContainerProps> = (props) => {
const { children, displayConfig, editor, editorContainerClassName, id } = props;
const handleContainerClick = (event: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
if (event.target !== event.currentTarget) return;
const handleContainerClick = () => {
if (!editor) return;
if (!editor.isEditable) return;
try {
@@ -3,7 +3,7 @@ import { forwardRef, useCallback } from "react";
import { EditorWrapper } from "@/components/editors";
import { EditorBubbleMenu } from "@/components/menus";
// extensions
import { SideMenuExtension, SlashCommands } from "@/extensions";
import { SideMenuExtension, SlashCommand } from "@/extensions";
// types
import { EditorRefApi, IRichTextEditor } from "@/types";
@@ -11,7 +11,7 @@ const RichTextEditor = (props: IRichTextEditor) => {
const { dragDropEnabled } = props;
const getExtensions = useCallback(() => {
const extensions = [SlashCommands()];
const extensions = [SlashCommand()];
extensions.push(
SideMenuExtension({
@@ -23,6 +23,7 @@ export const AIFeaturesMenu: React.FC<Props> = (props) => {
menuRef.current.remove();
menuRef.current.style.visibility = "visible";
// @ts-expect-error - tippy types are incorrect
popup.current = tippy(document.body, {
getReferenceClientRect: null,
content: menuRef.current,
@@ -34,6 +34,7 @@ export const BlockMenu = (props: BlockMenuProps) => {
menuRef.current.remove();
menuRef.current.style.visibility = "visible";
// @ts-expect-error - tippy types are incorrect
popup.current = tippy(document.body, {
getReferenceClientRect: null,
content: menuRef.current,
@@ -1,118 +0,0 @@
import { Dispatch, FC, SetStateAction } from "react";
import { Editor } from "@tiptap/react";
import { ALargeSmall, Ban } from "lucide-react";
// constants
import { COLORS_LIST } from "@/constants/common";
// helpers
import { cn } from "@/helpers/common";
import { BackgroundColorItem, TextColorItem } from "../menu-items";
type Props = {
editor: Editor;
isOpen: boolean;
setIsOpen: Dispatch<SetStateAction<boolean>>;
};
export const BubbleMenuColorSelector: FC<Props> = (props) => {
const { editor, isOpen, setIsOpen } = props;
const activeTextColor = COLORS_LIST.find((c) => editor.getAttributes("textStyle").color === c.textColor);
const activeBackgroundColor = COLORS_LIST.find((c) =>
editor.isActive("highlight", {
color: c.backgroundColor,
})
);
return (
<div className="relative h-full">
<button
type="button"
onClick={(e) => {
setIsOpen(!isOpen);
e.stopPropagation();
}}
className="flex items-center gap-1 h-full whitespace-nowrap px-3 text-sm font-medium text-custom-text-300 hover:bg-custom-background-80 active:bg-custom-background-80 rounded transition-colors"
>
<span>Color</span>
<span
className={cn(
"flex-shrink-0 size-6 grid place-items-center rounded border-[0.5px] border-custom-border-300",
{
"bg-custom-background-100": !activeBackgroundColor,
}
)}
style={
activeBackgroundColor
? {
backgroundColor: activeBackgroundColor.backgroundColor,
}
: {}
}
>
<ALargeSmall
className={cn("size-3.5", {
"text-custom-text-100": !activeTextColor,
})}
style={
activeTextColor
? {
color: activeTextColor.textColor,
}
: {}
}
/>
</span>
</button>
{isOpen && (
<section className="fixed top-full z-[99999] mt-1 rounded-md border-[0.5px] border-custom-border-300 bg-custom-background-100 p-2 space-y-2 shadow-custom-shadow-rg animate-in fade-in slide-in-from-top-1">
<div className="space-y-1.5">
<p className="text-xs text-custom-text-300 font-semibold">Text colors</p>
<div className="flex items-center gap-2">
{COLORS_LIST.map((color) => (
<button
key={color.textColor}
type="button"
className="flex-shrink-0 size-6 rounded border-[0.5px] border-custom-border-400 hover:opacity-60 transition-opacity"
style={{
backgroundColor: color.textColor,
}}
onClick={() => TextColorItem(editor).command(color.textColor)}
/>
))}
<button
type="button"
className="flex-shrink-0 size-6 grid place-items-center rounded text-custom-text-300 border-[0.5px] border-custom-border-400 hover:bg-custom-background-80 transition-colors"
onClick={() => TextColorItem(editor).command(undefined)}
>
<Ban className="size-4" />
</button>
</div>
</div>
<div className="space-y-1.5">
<p className="text-xs text-custom-text-300 font-semibold">Background colors</p>
<div className="flex items-center gap-2">
{COLORS_LIST.map((color) => (
<button
key={color.backgroundColor}
type="button"
className="flex-shrink-0 size-6 rounded border-[0.5px] border-custom-border-400 hover:opacity-60 transition-opacity"
style={{
backgroundColor: color.backgroundColor,
}}
onClick={() => BackgroundColorItem(editor).command(color.backgroundColor)}
/>
))}
<button
type="button"
className="flex-shrink-0 size-6 grid place-items-center rounded text-custom-text-300 border-[0.5px] border-custom-border-400 hover:bg-custom-background-80 transition-colors"
onClick={() => BackgroundColorItem(editor).command(undefined)}
>
<Ban className="size-4" />
</button>
</div>
</div>
</section>
)}
</div>
);
};
@@ -1,4 +1,3 @@
export * from "./color-selector";
export * from "./link-selector";
export * from "./node-selector";
export * from "./root";
@@ -1,6 +1,6 @@
import { Dispatch, FC, SetStateAction, useCallback, useEffect, useRef } from "react";
import { Editor } from "@tiptap/core";
import { Check, Link, Trash } from "lucide-react";
import { Check, Trash } from "lucide-react";
// helpers
import { cn, isValidHttpUrl } from "@/helpers/common";
import { setLinkEditor, unsetLinkEditor } from "@/helpers/editor-commands";
@@ -11,9 +11,7 @@ type Props = {
setIsOpen: Dispatch<SetStateAction<boolean>>;
};
export const BubbleMenuLinkSelector: FC<Props> = (props) => {
const { editor, isOpen, setIsOpen } = props;
// refs
export const BubbleMenuLinkSelector: FC<Props> = ({ editor, isOpen, setIsOpen }) => {
const inputRef = useRef<HTMLInputElement>(null);
const onLinkSubmit = useCallback(() => {
@@ -30,23 +28,26 @@ export const BubbleMenuLinkSelector: FC<Props> = (props) => {
});
return (
<div className="relative h-full">
<div className="relative">
<button
type="button"
className={cn(
"h-full flex items-center gap-1 px-3 text-sm font-medium text-custom-text-300 hover:bg-custom-background-80 active:bg-custom-background-80 rounded transition-colors",
{
"bg-custom-background-80": isOpen,
"text-custom-text-100": editor.isActive("link"),
}
"flex h-full items-center space-x-2 px-3 py-1.5 text-sm font-medium text-custom-text-300 hover:bg-custom-background-100 active:bg-custom-background-100",
{ "bg-custom-background-100": isOpen }
)}
onClick={(e) => {
setIsOpen(!isOpen);
e.stopPropagation();
}}
>
<span>Link</span>
<Link className="flex-shrink-0 size-3" />
<p className="text-base"></p>
<p
className={cn("underline underline-offset-4", {
"text-custom-text-100": editor.isActive("link"),
})}
>
Link
</p>
</button>
{isOpen && (
<div
@@ -15,7 +15,7 @@ import {
HeadingFourItem,
HeadingFiveItem,
HeadingSixItem,
EditorMenuItem,
BubbleMenuItem,
} from "@/components/menus";
// helpers
import { cn } from "@/helpers/common";
@@ -26,10 +26,8 @@ type Props = {
setIsOpen: Dispatch<SetStateAction<boolean>>;
};
export const BubbleMenuNodeSelector: FC<Props> = (props) => {
const { editor, isOpen, setIsOpen } = props;
const items: EditorMenuItem[] = [
export const BubbleMenuNodeSelector: FC<Props> = ({ editor, isOpen, setIsOpen }) => {
const items: BubbleMenuItem[] = [
TextItem(editor),
HeadingOneItem(editor),
HeadingTwoItem(editor),
@@ -44,7 +42,7 @@ export const BubbleMenuNodeSelector: FC<Props> = (props) => {
CodeItem(editor),
];
const activeItem = items.filter((item) => item.isActive("")).pop() ?? {
const activeItem = items.filter((item) => item.isActive()).pop() ?? {
name: "Multiple",
};
@@ -56,11 +54,12 @@ export const BubbleMenuNodeSelector: FC<Props> = (props) => {
setIsOpen(!isOpen);
e.stopPropagation();
}}
className="flex items-center gap-1 h-full whitespace-nowrap px-3 text-sm font-medium text-custom-text-300 hover:bg-custom-background-80 active:bg-custom-background-80 rounded transition-colors"
className="flex h-full items-center gap-1 whitespace-nowrap p-2 text-sm font-medium text-custom-text-300 hover:bg-custom-primary-100/5 active:bg-custom-primary-100/5"
>
<span>{activeItem?.name}</span>
<ChevronDown className="flex-shrink-0 size-3" />
<ChevronDown className="h-4 w-4" />
</button>
{isOpen && (
<section className="fixed top-full z-[99999] mt-1 flex w-48 flex-col overflow-hidden rounded-md border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 shadow-custom-shadow-rg animate-in fade-in slide-in-from-top-1">
{items.map((item) => (
@@ -1,13 +1,12 @@
import { FC, useEffect, useState } from "react";
import { BubbleMenu, BubbleMenuProps, isNodeSelection } from "@tiptap/react";
import { LucideIcon } from "lucide-react";
// components
import {
BoldItem,
BubbleMenuColorSelector,
BubbleMenuLinkSelector,
BubbleMenuNodeSelector,
CodeItem,
EditorMenuItem,
ItalicItem,
StrikeThroughItem,
UnderLineItem,
@@ -17,23 +16,34 @@ import { isCellSelection } from "@/extensions/table/table/utilities/is-cell-sele
// helpers
import { cn } from "@/helpers/common";
export interface BubbleMenuItem {
key: string;
name: string;
isActive: () => boolean;
command: () => void;
icon: LucideIcon;
}
type EditorBubbleMenuProps = Omit<BubbleMenuProps, "children">;
export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: any) => {
// states
const [isNodeSelectorOpen, setIsNodeSelectorOpen] = useState(false);
const [isLinkSelectorOpen, setIsLinkSelectorOpen] = useState(false);
const [isColorSelectorOpen, setIsColorSelectorOpen] = useState(false);
const [isSelecting, setIsSelecting] = useState(false);
const items: EditorMenuItem[] = props.editor.isActive("code")
? [CodeItem(props.editor)]
: [BoldItem(props.editor), ItalicItem(props.editor), UnderLineItem(props.editor), StrikeThroughItem(props.editor)];
const items: BubbleMenuItem[] = [
...(props.editor.isActive("code")
? []
: [
BoldItem(props.editor),
ItalicItem(props.editor),
UnderLineItem(props.editor),
StrikeThroughItem(props.editor),
]),
CodeItem(props.editor),
];
const bubbleMenuProps: EditorBubbleMenuProps = {
...props,
shouldShow: ({ state, editor }) => {
const { selection } = state;
const { empty } = selection;
if (
@@ -53,11 +63,15 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: any) => {
onHidden: () => {
setIsNodeSelectorOpen(false);
setIsLinkSelectorOpen(false);
setIsColorSelectorOpen(false);
},
},
};
const [isNodeSelectorOpen, setIsNodeSelectorOpen] = useState(false);
const [isLinkSelectorOpen, setIsLinkSelectorOpen] = useState(false);
const [isSelecting, setIsSelecting] = useState(false);
useEffect(() => {
function handleMouseDown() {
function handleMouseMove() {
@@ -88,66 +102,51 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: any) => {
return (
<BubbleMenu
{...bubbleMenuProps}
className="flex py-2 divide-x divide-custom-border-200 rounded-lg border border-custom-border-200 bg-custom-background-100 shadow-custom-shadow-rg"
className="flex w-fit divide-x divide-custom-border-300 rounded border border-custom-border-300 bg-custom-background-100 shadow-xl"
>
{!isSelecting && (
{isSelecting ? null : (
<>
<div className="px-2">
{!props.editor.isActive("table") && (
<BubbleMenuNodeSelector
editor={props.editor!}
isOpen={isNodeSelectorOpen}
setIsOpen={() => {
setIsNodeSelectorOpen((prev) => !prev);
setIsLinkSelectorOpen(false);
setIsColorSelectorOpen(false);
}}
/>
)}
</div>
<div className="px-2">
{!props.editor.isActive("code") && (
<BubbleMenuLinkSelector
editor={props.editor}
isOpen={isLinkSelectorOpen}
setIsOpen={() => {
setIsLinkSelectorOpen((prev) => !prev);
setIsNodeSelectorOpen(false);
setIsColorSelectorOpen(false);
}}
/>
)}
</div>
<div className="px-2">
{!props.editor.isActive("code") && (
<BubbleMenuColorSelector
editor={props.editor}
isOpen={isColorSelectorOpen}
setIsOpen={() => {
setIsColorSelectorOpen((prev) => !prev);
setIsNodeSelectorOpen(false);
setIsLinkSelectorOpen(false);
}}
/>
)}
</div>
<div className="flex gap-0.5 px-2">
{!props.editor.isActive("table") && (
<BubbleMenuNodeSelector
editor={props.editor!}
isOpen={isNodeSelectorOpen}
setIsOpen={() => {
setIsNodeSelectorOpen(!isNodeSelectorOpen);
setIsLinkSelectorOpen(false);
}}
/>
)}
{!props.editor.isActive("code") && (
<BubbleMenuLinkSelector
editor={props.editor}
isOpen={isLinkSelectorOpen}
setIsOpen={() => {
setIsLinkSelectorOpen(!isLinkSelectorOpen);
setIsNodeSelectorOpen(false);
}}
/>
)}
<div className="flex">
{items.map((item) => (
<button
key={item.key}
key={item.name}
type="button"
onClick={(e) => {
item.command();
e.stopPropagation();
}}
className={cn(
"size-7 grid place-items-center rounded text-custom-text-300 hover:bg-custom-background-80 active:bg-custom-background-80 transition-colors",
"p-2 text-custom-text-300 transition-colors hover:bg-custom-primary-100/5 active:bg-custom-primary-100/5",
{
"bg-custom-background-80 text-custom-text-100": item.isActive(""),
"bg-custom-primary-100/5 text-custom-text-100": item.isActive(),
}
)}
>
<item.icon className="size-4" />
<item.icon
className={cn("h-4 w-4", {
"text-custom-text-100": item.isActive(),
})}
/>
</button>
))}
</div>
@@ -20,14 +20,11 @@ import {
Heading6,
CaseSensitive,
LucideIcon,
Palette,
} from "lucide-react";
// helpers
import {
insertImage,
insertTableCommand,
setText,
toggleBackgroundColor,
toggleBlockquote,
toggleBold,
toggleBulletList,
@@ -42,26 +39,18 @@ import {
toggleOrderedList,
toggleStrike,
toggleTaskList,
toggleTextColor,
toggleUnderline,
} from "@/helpers/editor-commands";
// types
import { TColorEditorCommands, TNonColorEditorCommands } from "@/types";
import { TEditorCommands } from "@/types";
export type EditorMenuItem = {
export interface EditorMenuItem {
key: TEditorCommands;
name: string;
command: (...args: any) => void;
isActive: () => boolean;
command: () => void;
icon: LucideIcon;
} & (
| {
key: TNonColorEditorCommands;
isActive: () => boolean;
}
| {
key: TColorEditorCommands;
isActive: (color: string | undefined) => boolean;
}
);
}
export const TextItem = (editor: Editor): EditorMenuItem => ({
key: "text",
@@ -203,30 +192,16 @@ export const ImageItem = (editor: Editor) =>
({
key: "image",
name: "Image",
isActive: () => editor?.isActive("image") || editor?.isActive("imageComponent"),
command: (savedSelection: Selection | null) => insertImage({ editor, event: "insert", pos: savedSelection?.from }),
isActive: () => editor?.isActive("image"),
command: (savedSelection: Selection | null) =>
editor?.commands.setImageUpload({ event: "insert", pos: savedSelection?.from }),
icon: ImageIcon,
}) as const;
export const TextColorItem = (editor: Editor): EditorMenuItem => ({
key: "text-color",
name: "Color",
isActive: (color) => editor.getAttributes("textStyle").color === color,
command: (color: string) => toggleTextColor(color, editor),
icon: Palette,
});
export const BackgroundColorItem = (editor: Editor): EditorMenuItem => ({
key: "background-color",
name: "Background color",
isActive: (color) => editor.isActive("highlight", { color }),
command: (color: string) => toggleBackgroundColor(color, editor),
icon: Palette,
});
export const getEditorMenuItems = (editor: Editor | null): EditorMenuItem[] => {
if (!editor) return [];
export function getEditorMenuItems(editor: Editor | null) {
if (!editor) {
return [];
}
return [
TextItem(editor),
HeadingOneItem(editor),
@@ -246,7 +221,5 @@ export const getEditorMenuItems = (editor: Editor | null): EditorMenuItem[] => {
QuoteItem(editor),
TableItem(editor),
ImageItem(editor),
TextColorItem(editor),
BackgroundColorItem(editor),
];
};
}
@@ -1,51 +0,0 @@
export const COLORS_LIST: {
backgroundColor: string;
textColor: string;
label: string;
}[] = [
// {
// backgroundColor: "#1c202426",
// textColor: "#1c2024",
// label: "Black",
// },
{
backgroundColor: "#5c5e6326",
textColor: "#5c5e63",
label: "Gray",
},
{
backgroundColor: "#ff5b5926",
textColor: "#ff5b59",
label: "Peach",
},
{
backgroundColor: "#f6538526",
textColor: "#f65385",
label: "Pink",
},
{
backgroundColor: "#fd903826",
textColor: "#fd9038",
label: "Orange",
},
{
backgroundColor: "#0fc27b26",
textColor: "#0fc27b",
label: "Green",
},
{
backgroundColor: "#17bee926",
textColor: "#17bee9",
label: "Light blue",
},
{
backgroundColor: "#266df026",
textColor: "#266df0",
label: "Dark blue",
},
{
backgroundColor: "#9162f926",
textColor: "#9162f9",
label: "Purple",
},
];
@@ -117,18 +117,14 @@ export function LowlightPlugin({
// Such transactions can happen during collab syncing via y-prosemirror, for example.
transaction.steps.some(
(step) =>
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
step.from !== undefined &&
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
step.to !== undefined &&
oldNodes.some(
(node) =>
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
node.pos >= step.from &&
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
node.pos + node.node.nodeSize <= step.to
)
@@ -1,5 +1,3 @@
import { Color } from "@tiptap/extension-color";
import Highlight from "@tiptap/extension-highlight";
import TaskItem from "@tiptap/extension-task-item";
import TaskList from "@tiptap/extension-task-list";
import TextStyle from "@tiptap/extension-text-style";
@@ -85,10 +83,6 @@ export const CoreEditorExtensionsWithoutProps = [
TableCell,
TableRow,
CustomMentionWithoutProps(),
Color,
Highlight.configure({
multicolor: true,
}),
];
export const DocumentEditorExtensionsWithoutProps = [IssueWidgetWithoutProps()];
@@ -1,194 +1,105 @@
import React, { useRef, useState, useCallback, useLayoutEffect, useEffect } from "react";
import { NodeSelection } from "@tiptap/pm/state";
// extensions
import { CustomImageNodeViewProps, ImageToolbarRoot } from "@/extensions/custom-image";
import { CustomImageNodeViewProps } from "@/extensions/custom-image";
// helpers
import { cn } from "@/helpers/common";
const MIN_SIZE = 100;
type Pixel = `${number}px`;
export const CustomImageBlock: React.FC<CustomImageNodeViewProps> = (props) => {
const { node, updateAttributes, selected, getPos, editor } = props;
const { src, width, height } = node.attrs;
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 = CustomImageNodeViewProps & {
imageFromFileSystem: string;
setFailedToLoadImage: (isError: boolean) => void;
editorContainer: HTMLDivElement | null;
setEditorContainer: (editorContainer: HTMLDivElement | null) => void;
};
export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
// props
const {
node,
updateAttributes,
setFailedToLoadImage,
imageFromFileSystem,
selected,
getPos,
editor,
editorContainer,
setEditorContainer,
} = props;
const { src: remoteImageSrc, width, height, aspectRatio } = node.attrs;
// states
const [size, setSize] = useState<Size>({
width: ensurePixelString(width, "35%"),
height: ensurePixelString(height, "auto"),
aspectRatio: aspectRatio || 1,
});
const [isResizing, setIsResizing] = useState(false);
const [size, setSize] = useState({ width: width || "35%", height: height || "auto" });
const [isLoading, setIsLoading] = useState(true);
const [initialResizeComplete, setInitialResizeComplete] = useState(false);
// refs
const isShimmerVisible = isLoading || !initialResizeComplete;
const [editorContainer, setEditorContainer] = useState<HTMLElement | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const containerRect = useRef<DOMRect | null>(null);
const imageRef = useRef<HTMLImageElement>(null);
const isResizing = useRef(false);
const aspectRatioRef = useRef<number | null>(null);
const updateAttributesSafely = useCallback(
(attributes: Partial<ImageAttributes>, errorMessage: string) => {
try {
updateAttributes(attributes);
} catch (error) {
console.error(`${errorMessage}:`, error);
useLayoutEffect(() => {
if (imageRef.current) {
const img = imageRef.current;
img.onload = () => {
const closestEditorContainer = img.closest(".editor-container");
if (!closestEditorContainer) {
console.error("Editor container not found");
return;
}
setEditorContainer(closestEditorContainer as HTMLElement);
if (width === "35%") {
const editorWidth = closestEditorContainer.clientWidth;
const initialWidth = Math.max(editorWidth * 0.35, MIN_SIZE);
const aspectRatio = img.naturalWidth / img.naturalHeight;
const initialHeight = initialWidth / aspectRatio;
const newSize = {
width: `${Math.round(initialWidth)}px`,
height: `${Math.round(initialHeight)}px`,
};
setSize(newSize);
updateAttributes(newSize);
}
setInitialResizeComplete(true);
setIsLoading(false);
};
}
}, [width, height, updateAttributes]);
useLayoutEffect(() => {
setSize({ width, height });
}, [width, height]);
const handleResizeStart = useCallback(
(e: React.MouseEvent | React.TouchEvent) => {
e.preventDefault();
e.stopPropagation();
isResizing.current = true;
if (containerRef.current && editorContainer) {
aspectRatioRef.current = Number(size.width.replace("px", "")) / Number(size.height.replace("px", ""));
containerRect.current = containerRef.current.getBoundingClientRect();
}
},
[updateAttributes]
[size, editorContainer]
);
const handleImageLoad = useCallback(() => {
const img = imageRef.current;
if (!img) return;
let closestEditorContainer: HTMLDivElement | null = null;
if (editorContainer) {
closestEditorContainer = editorContainer;
} else {
closestEditorContainer = img.closest(".editor-container") as HTMLDivElement | null;
if (!closestEditorContainer) {
console.error("Editor container not found");
return;
}
}
if (!closestEditorContainer) {
console.error("Editor container not found");
return;
}
setEditorContainer(closestEditorContainer);
const aspectRatio = img.naturalWidth / img.naturalHeight;
if (width === "35%") {
const editorWidth = closestEditorContainer.clientWidth;
const initialWidth = Math.max(editorWidth * 0.35, MIN_SIZE);
const initialHeight = initialWidth / aspectRatio;
const initialComputedSize = {
width: `${Math.round(initialWidth)}px` satisfies Pixel,
height: `${Math.round(initialHeight)}px` satisfies Pixel,
aspectRatio: aspectRatio,
};
setSize(initialComputedSize);
updateAttributesSafely(
initialComputedSize,
"Failed to update attributes while initializing an image for the first time:"
);
} else {
// as the aspect ratio in not stored for old images, we need to update the attrs
if (!aspectRatio) {
setSize((prevSize) => {
const newSize = { ...prevSize, aspectRatio };
updateAttributesSafely(
newSize,
"Failed to update attributes while initializing images with width but no aspect ratio:"
);
return newSize;
});
}
}
setInitialResizeComplete(true);
}, [width, updateAttributes, editorContainer, aspectRatio]);
// for real time resizing
useLayoutEffect(() => {
setSize((prevSize) => ({
...prevSize,
width: ensurePixelString(width),
height: ensurePixelString(height),
}));
}, [width, height]);
const handleResize = useCallback(
(e: MouseEvent | TouchEvent) => {
if (!containerRef.current || !containerRect.current || !size.aspectRatio) return;
if (!isResizing.current || !containerRef.current || !containerRect.current) return;
if (size) {
aspectRatioRef.current = Number(size.width.replace("px", "")) / Number(size.height.replace("px", ""));
}
if (!aspectRatioRef.current) return;
const clientX = "touches" in e ? e.touches[0].clientX : e.clientX;
const newWidth = Math.max(clientX - containerRect.current.left, MIN_SIZE);
const newHeight = newWidth / size.aspectRatio;
const newHeight = newWidth / aspectRatioRef.current;
setSize((prevSize) => ({ ...prevSize, width: `${newWidth}px`, height: `${newHeight}px` }));
setSize({ width: `${newWidth}px`, height: `${newHeight}px` });
},
[size]
);
const handleResizeEnd = useCallback(() => {
setIsResizing(false);
updateAttributesSafely(size, "Failed to update attributes at the end of resizing:");
if (isResizing.current) {
isResizing.current = false;
updateAttributes(size);
}
}, [size, updateAttributes]);
const handleResizeStart = useCallback((e: React.MouseEvent | React.TouchEvent) => {
e.preventDefault();
e.stopPropagation();
setIsResizing(true);
if (containerRef.current) {
containerRect.current = containerRef.current.getBoundingClientRect();
}
}, []);
useEffect(() => {
if (isResizing) {
window.addEventListener("mousemove", handleResize);
window.addEventListener("mouseup", handleResizeEnd);
window.addEventListener("mouseleave", handleResizeEnd);
return () => {
window.removeEventListener("mousemove", handleResize);
window.removeEventListener("mouseup", handleResizeEnd);
window.removeEventListener("mouseleave", handleResizeEnd);
};
}
}, [isResizing, handleResize, handleResizeEnd]);
const handleImageMouseDown = useCallback(
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
const pos = getPos();
@@ -198,88 +109,57 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
[editor, getPos]
);
// show the image loader if the remote image's src or preview image from filesystem is not set yet (while loading the image post upload) (or)
// if the initial resize (from 35% width and "auto" height attrs to the actual size in px) is not complete
const showImageLoader = !(remoteImageSrc || imageFromFileSystem) || !initialResizeComplete;
// show the image utils only if the remote image's (post upload) src is set and the initial resize is complete (but not while we're showing the preview imageFromFileSystem)
const showImageUtils = remoteImageSrc && initialResizeComplete;
// show the image resizer only if the editor is editable, the remote image's (post upload) src is set and the initial resize is complete (but not while we're showing the preview imageFromFileSystem)
const showImageResizer = editor.isEditable && remoteImageSrc && initialResizeComplete;
// show the preview image from the file system if the remote image's src is not set
const displayedImageSrc = remoteImageSrc ?? imageFromFileSystem;
useEffect(() => {
if (!editorContainer) return;
const handleMouseMove = (e: MouseEvent) => handleResize(e);
const handleMouseUp = () => handleResizeEnd();
const handleMouseLeave = () => handleResizeEnd();
editorContainer.addEventListener("mousemove", handleMouseMove);
editorContainer.addEventListener("mouseup", handleMouseUp);
editorContainer.addEventListener("mouseleave", handleMouseLeave);
return () => {
editorContainer.removeEventListener("mousemove", handleMouseMove);
editorContainer.removeEventListener("mouseup", handleMouseUp);
editorContainer.removeEventListener("mouseleave", handleMouseLeave);
};
}, [handleResize, handleResizeEnd, editorContainer]);
return (
<div
ref={containerRef}
className="group/image-component relative inline-block max-w-full"
onMouseDown={handleImageMouseDown}
onMouseDown={handleMouseDown}
style={{
width: size.width,
aspectRatio: size.aspectRatio,
height: size.height,
}}
>
{showImageLoader && (
<div
className="animate-pulse bg-custom-background-80 rounded-md"
style={{ width: size.width, height: size.height }}
/>
{isShimmerVisible && (
<div className="animate-pulse bg-custom-background-80 rounded-md" style={{ width, height }} />
)}
<img
ref={imageRef}
src={displayedImageSrc}
onLoad={handleImageLoad}
onError={(e) => {
console.error("Error loading image", e);
setFailedToLoadImage(true);
}}
src={src}
width={size.width}
className={cn("image-component block rounded-md", {
// hide the image while the background calculations of the image loader are in progress (to avoid flickering) and show the loader until then
hidden: showImageLoader,
height={size.height}
className={cn("block rounded-md", {
hidden: isShimmerVisible,
"read-only-image": !editor.isEditable,
"blur-sm opacity-80 loading-image": !remoteImageSrc,
})}
style={{
width: size.width,
aspectRatio: size.aspectRatio,
height: size.height,
}}
/>
{showImageUtils && (
<ImageToolbarRoot
containerClassName={
"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: remoteImageSrc,
aspectRatio: size.aspectRatio,
height: size.height,
width: size.width,
}}
/>
)}
{selected && displayedImageSrc === remoteImageSrc && (
<div className="absolute inset-0 size-full bg-custom-primary-500/30" />
)}
{showImageResizer && (
{editor.isEditable && selected && <div className="absolute inset-0 size-full bg-custom-primary-500/30" />}
{editor.isEditable && (
<>
<div className="opacity-0 group-hover/image-component:opacity-100 absolute inset-0 border-2 border-custom-primary-100 pointer-events-none rounded-md transition-opacity duration-100 ease-in-out" />
<div
className={cn(
"absolute inset-0 border-2 border-custom-primary-100 pointer-events-none rounded-md transition-opacity duration-100 ease-in-out",
{
"opacity-100": isResizing,
"opacity-0 group-hover/image-component:opacity-100": !isResizing,
}
)}
/>
<div
className={cn(
"absolute bottom-0 right-0 translate-y-1/2 translate-x-1/2 size-4 rounded-full bg-custom-primary-100 border-2 border-white cursor-nwse-resize transition-opacity duration-100 ease-in-out",
{
"opacity-100 pointer-events-auto": isResizing,
"opacity-0 pointer-events-none group-hover/image-component:opacity-100 group-hover/image-component:pointer-events-auto":
!isResizing,
}
)}
className="opacity-0 pointer-events-none group-hover/image-component:opacity-100 group-hover/image-component:pointer-events-auto absolute bottom-0 right-0 translate-y-1/2 translate-x-1/2 size-4 rounded-full bg-custom-primary-100 border-2 border-white cursor-nwse-resize transition-opacity duration-100 ease-in-out"
onMouseDown={handleResizeStart}
/>
</>
@@ -1,14 +1,23 @@
import { useEffect, useRef, useState } from "react";
import React, { useCallback, useEffect, useRef, useState } from "react";
import { Node as ProsemirrorNode } from "@tiptap/pm/model";
import { Editor, NodeViewWrapper } from "@tiptap/react";
// extensions
import { CustomImageBlock, CustomImageUploader, ImageAttributes } from "@/extensions/custom-image";
import {
CustomImageBlock,
CustomImageUploader,
UploadEntity,
UploadImageExtensionStorage,
} from "@/extensions/custom-image";
export type CustomImageNodeViewProps = {
getPos: () => number;
editor: Editor;
node: ProsemirrorNode & {
attrs: ImageAttributes;
attrs: {
src: string;
width: string;
height: string;
};
};
updateAttributes: (attrs: Record<string, any>) => void;
selected: boolean;
@@ -17,60 +26,94 @@ export type CustomImageNodeViewProps = {
export const CustomImageNode = (props: CustomImageNodeViewProps) => {
const { getPos, editor, node, updateAttributes, selected } = props;
const [isUploaded, setIsUploaded] = useState(false);
const [imageFromFileSystem, setImageFromFileSystem] = useState<string | undefined>(undefined);
const [failedToLoadImage, setFailedToLoadImage] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const hasTriggeredFilePickerRef = useRef(false);
const [isUploaded, setIsUploaded] = useState(!!node.attrs.src);
const [editorContainer, setEditorContainer] = useState<HTMLDivElement | null>(null);
const imageComponentRef = useRef<HTMLDivElement>(null);
const id = node.attrs.id as string;
const editorStorage = editor.storage.imageComponent as UploadImageExtensionStorage | undefined;
const getUploadEntity = useCallback(
(): UploadEntity | undefined => editorStorage?.fileMap.get(id),
[editorStorage, id]
);
const onUpload = useCallback(
(url: string) => {
if (url) {
setIsUploaded(true);
// Update the node view's src attribute
updateAttributes({ src: url });
editorStorage?.fileMap.delete(id);
}
},
[editorStorage?.fileMap, id, updateAttributes]
);
const uploadFile = useCallback(
async (file: File) => {
try {
// @ts-expect-error - TODO: fix typings, and don't remove await from
// here for now
const url: string = await editor?.commands.uploadImage(file);
if (!url) {
throw new Error("Something went wrong while uploading the image");
}
onUpload(url);
} catch (error) {
console.error("Error uploading file:", error);
}
},
[editor.commands, onUpload]
);
useEffect(() => {
const closestEditorContainer = imageComponentRef.current?.closest(".editor-container");
if (!closestEditorContainer) {
console.error("Editor container not found");
return;
const uploadEntity = getUploadEntity();
if (uploadEntity) {
if (uploadEntity.event === "drop" && "file" in uploadEntity) {
uploadFile(uploadEntity.file);
} else if (uploadEntity.event === "insert" && fileInputRef.current && !hasTriggeredFilePickerRef.current) {
const entity = editorStorage?.fileMap.get(id);
if (entity && entity.hasOpenedFileInputOnce) return;
fileInputRef.current.click();
hasTriggeredFilePickerRef.current = true;
if (!entity) return;
editorStorage?.fileMap.set(id, { ...entity, hasOpenedFileInputOnce: true });
}
}
}, [getUploadEntity, uploadFile]);
setEditorContainer(closestEditorContainer as HTMLDivElement);
}, []);
// the image is already uploaded if the image-component node has src attribute
// and we need to remove the blob from our file system
useEffect(() => {
const remoteImageSrc = node.attrs.src;
if (remoteImageSrc) {
if (node.attrs.src) {
setIsUploaded(true);
setImageFromFileSystem(undefined);
} else {
setIsUploaded(false);
}
}, [node.attrs.src]);
const existingFile = React.useMemo(() => {
const entity = getUploadEntity();
return entity && entity.event === "drop" ? entity.file : undefined;
}, [getUploadEntity]);
return (
<NodeViewWrapper>
<div className="p-0 mx-0 my-2" data-drag-handle ref={imageComponentRef}>
{(isUploaded || imageFromFileSystem) && !failedToLoadImage ? (
<div className="p-0 mx-0 my-2" data-drag-handle>
{isUploaded ? (
<CustomImageBlock
imageFromFileSystem={imageFromFileSystem}
editorContainer={editorContainer}
editor={editor}
getPos={getPos}
node={node}
setEditorContainer={setEditorContainer}
setFailedToLoadImage={setFailedToLoadImage}
selected={selected}
updateAttributes={updateAttributes}
selected={selected}
/>
) : (
<CustomImageUploader
onUpload={onUpload}
editor={editor}
failedToLoadImage={failedToLoadImage}
getPos={getPos}
loadImageFromFileSystem={setImageFromFileSystem}
node={node}
setIsUploaded={setIsUploaded}
fileInputRef={fileInputRef}
existingFile={existingFile}
selected={selected}
updateAttributes={updateAttributes}
/>
)}
</div>
@@ -1,166 +1,89 @@
import { ChangeEvent, useCallback, useEffect, useMemo, useRef } from "react";
import { Node as ProsemirrorNode } from "@tiptap/pm/model";
import { ChangeEvent, useCallback, useEffect, useRef } from "react";
import { Editor } from "@tiptap/core";
import { ImageIcon } from "lucide-react";
// helpers
import { cn } from "@/helpers/common";
// hooks
import { useUploader, useDropZone, uploadFirstImageAndInsertRemaining } from "@/hooks/use-file-upload";
// extensions
import { getImageComponentImageFileMap, ImageAttributes } from "@/extensions/custom-image";
import { useUploader, useFileUpload, useDropZone } from "@/hooks/use-file-upload";
// plugins
import { isFileValid } from "@/plugins/image";
type RefType = React.RefObject<HTMLInputElement> | ((instance: HTMLInputElement | null) => void);
const assignRef = (ref: RefType, value: HTMLInputElement | null) => {
if (typeof ref === "function") {
ref(value);
} else if (ref && typeof ref === "object") {
(ref as React.MutableRefObject<HTMLInputElement | null>).current = value;
}
};
export const CustomImageUploader = (props: {
failedToLoadImage: boolean;
onUpload: (url: string) => void;
editor: Editor;
fileInputRef: RefType;
existingFile?: File;
selected: boolean;
loadImageFromFileSystem: (file: string) => void;
setIsUploaded: (isUploaded: boolean) => void;
node: ProsemirrorNode & {
attrs: ImageAttributes;
};
updateAttributes: (attrs: Record<string, any>) => void;
getPos: () => number;
}) => {
const {
selected,
failedToLoadImage,
editor,
loadImageFromFileSystem,
node,
setIsUploaded,
updateAttributes,
getPos,
} = props;
// ref
const fileInputRef = useRef<HTMLInputElement>(null);
const { selected, onUpload, editor, fileInputRef, existingFile } = props;
const { loading, uploadFile } = useUploader({ onUpload, editor });
const { handleUploadClick, ref: internalRef } = useFileUpload();
const { draggedInside, onDrop, onDragEnter, onDragLeave } = useDropZone({ uploader: uploadFile });
const hasTriggeredFilePickerRef = useRef(false);
const imageEntityId = node.attrs.id;
const localRef = useRef<HTMLInputElement | null>(null);
const imageComponentImageFileMap = useMemo(() => getImageComponentImageFileMap(editor), [editor]);
const onUpload = useCallback(
(url: string) => {
if (url) {
setIsUploaded(true);
// Update the node view's src attribute post upload
updateAttributes({ src: url });
imageComponentImageFileMap?.delete(imageEntityId);
const pos = getPos();
// get current node
const getCurrentSelection = editor.state.selection;
const currentNode = editor.state.doc.nodeAt(getCurrentSelection.from);
// only if the cursor is at the current image component, manipulate
// the cursor position
if (currentNode && currentNode.type.name === "imageComponent" && currentNode.attrs.src === url) {
// control cursor position after upload
const nextNode = editor.state.doc.nodeAt(pos + 1);
if (nextNode && nextNode.type.name === "paragraph") {
// If there is a paragraph node after the image component, move the focus to the next node
editor.commands.setTextSelection(pos + 1);
} else {
// create a new paragraph after the image component post upload
editor.commands.createParagraphNear();
}
const onFileChange = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
if (isFileValid(file)) {
uploadFile(file);
}
}
},
[imageComponentImageFileMap, imageEntityId, updateAttributes, getPos]
);
// hooks
const { uploading: isImageBeingUploaded, uploadFile } = useUploader({ onUpload, editor, loadImageFromFileSystem });
const { draggedInside, onDrop, onDragEnter, onDragLeave } = useDropZone({
uploader: uploadFile,
editor,
pos: getPos(),
});
// the meta data of the image component
const meta = useMemo(
() => imageComponentImageFileMap?.get(imageEntityId),
[imageComponentImageFileMap, imageEntityId]
[uploadFile]
);
// after the image component is mounted we start the upload process based on
// it's uploaded
useEffect(() => {
if (meta) {
if (meta.event === "drop" && "file" in meta) {
uploadFile(meta.file);
} else if (meta.event === "insert" && fileInputRef.current && !hasTriggeredFilePickerRef.current) {
if (meta.hasOpenedFileInputOnce) return;
fileInputRef.current.click();
hasTriggeredFilePickerRef.current = true;
imageComponentImageFileMap?.set(imageEntityId, { ...meta, hasOpenedFileInputOnce: true });
}
// no need to validate as the file is already validated before the drop onto
// the editor
if (existingFile) {
uploadFile(existingFile);
}
}, [meta, uploadFile, imageComponentImageFileMap]);
const onFileChange = useCallback(
async (e: ChangeEvent<HTMLInputElement>) => {
e.preventDefault();
const fileList = e.target.files;
if (!fileList) {
return;
}
await uploadFirstImageAndInsertRemaining(editor, fileList, getPos(), uploadFile);
},
[uploadFile, editor, getPos]
);
const getDisplayMessage = useCallback(() => {
const isUploading = isImageBeingUploaded;
if (failedToLoadImage) {
return "Error loading image";
}
if (isUploading) {
return "Uploading...";
}
if (draggedInside) {
return "Drop image here";
}
return "Add an image";
}, [draggedInside, failedToLoadImage, isImageBeingUploaded]);
}, [existingFile, uploadFile]);
return (
<div
className={cn(
"image-upload-component flex items-center justify-start gap-2 py-3 px-2 rounded-lg text-custom-text-300 hover:text-custom-text-200 bg-custom-background-90 hover:bg-custom-background-80 border border-dashed border-custom-border-300 transition-all duration-200 ease-in-out cursor-default",
"image-upload-component flex items-center justify-start gap-2 py-3 px-2 rounded-lg text-custom-text-300 hover:text-custom-text-200 bg-custom-background-90 hover:bg-custom-background-80 border border-dashed border-custom-border-300 cursor-pointer transition-all duration-200 ease-in-out",
{
"hover:text-custom-text-200 cursor-pointer": editor.isEditable,
"bg-custom-background-80 text-custom-text-200": draggedInside,
"text-custom-primary-200 bg-custom-primary-100/10 hover:bg-custom-primary-100/10 hover:text-custom-primary-200 border-custom-primary-200/10":
selected,
"text-red-500 cursor-default hover:text-red-500": failedToLoadImage,
"bg-red-500/10 hover:bg-red-500/10": failedToLoadImage && selected,
},
{
"text-custom-primary-200 bg-custom-primary-100/10": selected,
}
)}
onDrop={onDrop}
onDragOver={onDragEnter}
onDragLeave={onDragLeave}
contentEditable={false}
onClick={() => {
if (!failedToLoadImage && editor.isEditable) {
fileInputRef.current?.click();
}
}}
onClick={handleUploadClick}
>
<ImageIcon className="size-4" />
<div className="text-base font-medium">{getDisplayMessage()}</div>
<div className="text-base font-medium">
{loading ? "Uploading..." : draggedInside ? "Drop image here" : existingFile ? "Uploading..." : "Add an image"}
</div>
<input
className="size-0 overflow-hidden"
ref={fileInputRef}
ref={(element) => {
localRef.current = element;
assignRef(fileInputRef, element);
assignRef(internalRef as RefType, element);
}}
hidden
type="file"
accept=".jpg,.jpeg,.png,.webp"
onChange={onFileChange}
multiple
/>
</div>
);
@@ -1,4 +1,3 @@
export * from "./toolbar";
export * from "./image-block";
export * from "./image-node";
export * from "./image-uploader";

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