Compare commits
150
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
33a5d0a5d3 | ||
|
|
8a866e440c | ||
|
|
7495a7d0cb | ||
|
|
2b1da96c3f | ||
|
|
daa06f1831 | ||
|
|
b97fcfb46d | ||
|
|
852fc9bac1 | ||
|
|
55f44e0245 | ||
|
|
8981e52dcc | ||
|
|
d92dbaea72 | ||
|
|
58f3d0a68c | ||
|
|
6d78418e79 | ||
|
|
6e52f1b434 | ||
|
|
c3c1ea727d | ||
|
|
5afc576dec | ||
|
|
50ae32f3e1 | ||
|
|
0451593057 | ||
|
|
be092ac99f | ||
|
|
f73a603226 | ||
|
|
b27249486a | ||
|
|
20c9e232e7 | ||
|
|
d168fd4bfa | ||
|
|
7317975b04 | ||
|
|
39195d0d89 | ||
|
|
6bf0e27b66 | ||
|
|
5fb7e98b7c | ||
|
|
328b6961a2 | ||
|
|
39eabc28b5 | ||
|
|
d97ca68229 | ||
|
|
c92fe6191e | ||
|
|
7bb04003ea | ||
|
|
19dab1fad0 | ||
|
|
5f7b6ecf7f | ||
|
|
dfd3af13cf | ||
|
|
4cc1b79d81 | ||
|
|
4a6f646317 | ||
|
|
b8e21d92bf | ||
|
|
b87d5c5be6 | ||
|
|
ceda06e88d | ||
|
|
eb344881c2 | ||
|
|
01257a6936 | ||
|
|
51b01ebcac | ||
|
|
0a8d66dcc3 | ||
|
|
ec22f1fc53 | ||
|
|
a5e3e4fe7d | ||
|
|
f1a0a8d925 | ||
|
|
ee0dce46de | ||
|
|
b7ee7e19fc | ||
|
|
8291043704 | ||
|
|
bc41b1113a | ||
|
|
77d4a8379d | ||
|
|
c90df623de | ||
|
|
62c45f3bb1 | ||
|
|
96dc9db237 | ||
|
|
5474ab326d | ||
|
|
4940dc2193 | ||
|
|
632282d0df | ||
|
|
33f6c1fe9e | ||
|
|
927d265209 | ||
|
|
bfef0e89e0 | ||
|
|
e9d5db0093 | ||
|
|
bcd46b6aa9 | ||
|
|
66ca1663bf | ||
|
|
944f3417a1 | ||
|
|
193d530b40 | ||
|
|
3b0f3ca761 | ||
|
|
7f5a898cec | ||
|
|
bf6588b573 | ||
|
|
c25fa594fe | ||
|
|
b1dccf3773 | ||
|
|
04686d1721 | ||
|
|
ec08fb078d | ||
|
|
8aa32d410c | ||
|
|
ade03e9f8f | ||
|
|
d253933995 | ||
|
|
150af986fd | ||
|
|
f3340749e8 | ||
|
|
6e0ece496a | ||
|
|
0068ea93de | ||
|
|
6942e491d0 | ||
|
|
22623fad33 | ||
|
|
85f7483b1b | ||
|
|
fbb60941ef | ||
|
|
20e569294d | ||
|
|
117afdb67f | ||
|
|
3df230393a | ||
|
|
8dabe839f3 | ||
|
|
6b63e050ae | ||
|
|
6170a80757 | ||
|
|
5ca794b648 | ||
|
|
f38755b755 | ||
|
|
2153eda9a8 | ||
|
|
83bfca6f2d | ||
|
|
e143e0a051 | ||
|
|
50af7c5bf6 | ||
|
|
846398df41 | ||
|
|
0853a2790f | ||
|
|
ed39f2dc37 | ||
|
|
45fded9842 | ||
|
|
76a34440c3 | ||
|
|
4d200ff0a3 | ||
|
|
f49a2aa9e3 | ||
|
|
83b83326c5 | ||
|
|
3c1779b287 | ||
|
|
22b32fd5c6 | ||
|
|
c4c2d81d24 | ||
|
|
f9a8896486 | ||
|
|
ae1a63f832 | ||
|
|
a05876552c | ||
|
|
b6e813cb9a | ||
|
|
f328772b82 | ||
|
|
604ddad3fa | ||
|
|
66cfc7344e | ||
|
|
a4933b5614 | ||
|
|
e70e27296b | ||
|
|
361ef9236e | ||
|
|
450bb42c46 | ||
|
|
77152b3119 | ||
|
|
e9464f9e68 | ||
|
|
c8c9638e5a | ||
|
|
bd0ca0cded | ||
|
|
96781dbb0f | ||
|
|
19132d15b8 | ||
|
|
6befc6e564 | ||
|
|
441e5fc054 | ||
|
|
43633f2f28 | ||
|
|
3a9f01b9eb | ||
|
|
5e83da9ca1 | ||
|
|
aec4162c22 | ||
|
|
44542fdd6b | ||
|
|
5ad6e99327 | ||
|
|
30018d64a2 | ||
|
|
1c0c1586cb | ||
|
|
524033411e | ||
|
|
3b40158d9a | ||
|
|
4d9115d51e | ||
|
|
707570ca7a | ||
|
|
c76af7d7d6 | ||
|
|
1dcea9bcc8 | ||
|
|
da957e06b6 | ||
|
|
a0b9596cb4 | ||
|
|
f71e8a3a0f | ||
|
|
002fb4547b | ||
|
|
c1b1ba35c1 | ||
|
|
4566d6e80c | ||
|
|
e8d359e625 | ||
|
|
351eba8d61 | ||
|
|
1e27e37b51 | ||
|
|
7df2e9cf11 | ||
|
|
c6e3f1b932 |
@@ -1,59 +0,0 @@
|
||||
/**
|
||||
* 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,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -1,10 +0,0 @@
|
||||
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/"],
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"*.{ts,tsx,js,jsx}": ["eslint -c ./.eslintrc-staged.js", "prettier --check"]
|
||||
}
|
||||
+1
-1
@@ -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 informplaneation.
|
||||
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.
|
||||
|
||||
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:
|
||||
|
||||
|
||||
+30
-35
@@ -1,44 +1,39 @@
|
||||
# Security Policy
|
||||
# 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 community’s role in identifying and reporting security concerns to uphold the integrity of our systems and safeguard our users.
|
||||
|
||||
This document outlines security procedures and vulnerabilities reporting for the Plane project.
|
||||
## 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.
|
||||
|
||||
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.
|
||||
To ensure a responsible and effective disclosure process, please adhere to the following:
|
||||
|
||||
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.
|
||||
- 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.
|
||||
|
||||
## Out of Scope Vulnerabilities
|
||||
## 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:
|
||||
|
||||
We appreciate your help in identifying vulnerabilities. However, please note that the following types of vulnerabilities are considered out of scope:
|
||||
- Vulnerabilities requiring man-in-the-middle (MITM) attacks or physical access to a user’s 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.
|
||||
|
||||
- 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.
|
||||
## Our commitment
|
||||
|
||||
## Reporting Process
|
||||
At Plane, we are committed to maintaining transparent and collaborative communication throughout the vulnerability resolution process. Here's what you can expect from us:
|
||||
|
||||
If you discover a vulnerability, please adhere to the following 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.
|
||||
|
||||
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
|
||||
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.
|
||||
+4
-48
@@ -1,52 +1,8 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
extends: ["custom"],
|
||||
extends: ["@plane/eslint-config/next.js"],
|
||||
parser: "@typescript-eslint/parser",
|
||||
settings: {
|
||||
"import/resolver": {
|
||||
typescript: {},
|
||||
node: {
|
||||
moduleDirectory: ["node_modules", "."],
|
||||
},
|
||||
},
|
||||
parserOptions: {
|
||||
project: true,
|
||||
},
|
||||
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,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
@@ -9,9 +9,7 @@ import { HelpSection, SidebarMenu, SidebarDropdown } from "@/components/admin-si
|
||||
// hooks
|
||||
import { useTheme } from "@/hooks/store";
|
||||
|
||||
export interface IInstanceSidebar {}
|
||||
|
||||
export const InstanceSidebar: FC<IInstanceSidebar> = observer(() => {
|
||||
export const InstanceSidebar: FC = observer(() => {
|
||||
// store
|
||||
const { isSidebarCollapsed, toggleSidebar } = useTheme();
|
||||
|
||||
|
||||
@@ -7,11 +7,7 @@ import { Button } from "@plane/ui";
|
||||
import InstanceFailureDarkImage from "@/public/instance/instance-failure-dark.svg";
|
||||
import InstanceFailureImage from "@/public/instance/instance-failure.svg";
|
||||
|
||||
type InstanceFailureViewProps = {
|
||||
// mutate: () => void;
|
||||
};
|
||||
|
||||
export const InstanceFailureView: FC<InstanceFailureViewProps> = () => {
|
||||
export const InstanceFailureView: FC = () => {
|
||||
const { resolvedTheme } = useTheme();
|
||||
|
||||
const instanceImage = resolvedTheme === "dark" ? InstanceFailureDarkImage : InstanceFailureImage;
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
+8
-6
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "admin",
|
||||
"version": "0.22.0",
|
||||
"version": "0.23.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "turbo run develop",
|
||||
@@ -8,7 +8,8 @@
|
||||
"build": "next build",
|
||||
"preview": "next build && next start",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
"lint": "eslint . --ext .ts,.tsx",
|
||||
"lint:errors": "eslint . --ext .ts,.tsx --quiet"
|
||||
},
|
||||
"dependencies": {
|
||||
"@headlessui/react": "^1.7.19",
|
||||
@@ -16,6 +17,7 @@
|
||||
"@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",
|
||||
@@ -25,7 +27,7 @@
|
||||
"lucide-react": "^0.356.0",
|
||||
"mobx": "^6.12.0",
|
||||
"mobx-react": "^9.1.1",
|
||||
"next": "^14.2.3",
|
||||
"next": "^14.2.12",
|
||||
"next-themes": "^0.2.1",
|
||||
"postcss": "^8.4.38",
|
||||
"react": "^18.3.1",
|
||||
@@ -37,15 +39,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": "*",
|
||||
"tsconfig": "*",
|
||||
"typescript": "5.4.5"
|
||||
"typescript": "5.3.3"
|
||||
}
|
||||
}
|
||||
|
||||
+6
-12
@@ -1,21 +1,15 @@
|
||||
{
|
||||
"extends": "tsconfig/nextjs.json",
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"],
|
||||
"extends": "@plane/typescript-config/nextjs.json",
|
||||
"compilerOptions": {
|
||||
"plugins": [{ "name": "next" }],
|
||||
"baseUrl": ".",
|
||||
"jsx": "preserve",
|
||||
"esModuleInterop": true,
|
||||
"paths": {
|
||||
"@/*": ["core/*"],
|
||||
"@/helpers/*": ["helpers/*"],
|
||||
"@/public/*": ["public/*"],
|
||||
"@/plane-admin/*": ["ce/*"]
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "next.config.js", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"name": "plane-api",
|
||||
"version": "0.22.0"
|
||||
"version": "0.23.0"
|
||||
}
|
||||
|
||||
@@ -207,8 +207,7 @@ class CycleAPIEndpoint(BaseAPIView):
|
||||
# Incomplete Cycles
|
||||
if cycle_view == "incomplete":
|
||||
queryset = queryset.filter(
|
||||
Q(end_date__gte=timezone.now().date())
|
||||
| Q(end_date__isnull=True),
|
||||
Q(end_date__gte=timezone.now()) | Q(end_date__isnull=True),
|
||||
)
|
||||
return self.paginate(
|
||||
request=request,
|
||||
@@ -309,10 +308,7 @@ class CycleAPIEndpoint(BaseAPIView):
|
||||
|
||||
request_data = request.data
|
||||
|
||||
if (
|
||||
cycle.end_date is not None
|
||||
and cycle.end_date < timezone.now().date()
|
||||
):
|
||||
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 = {
|
||||
@@ -537,7 +533,7 @@ class CycleArchiveUnarchiveAPIEndpoint(BaseAPIView):
|
||||
cycle = Cycle.objects.get(
|
||||
pk=cycle_id, project_id=project_id, workspace__slug=slug
|
||||
)
|
||||
if cycle.end_date >= timezone.now().date():
|
||||
if cycle.end_date >= timezone.now():
|
||||
return Response(
|
||||
{"error": "Only completed cycles can be archived"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
@@ -1146,7 +1142,7 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView):
|
||||
|
||||
if (
|
||||
new_cycle.end_date is not None
|
||||
and new_cycle.end_date < timezone.now().date()
|
||||
and new_cycle.end_date < timezone.now()
|
||||
):
|
||||
return Response(
|
||||
{
|
||||
|
||||
@@ -285,7 +285,7 @@ class InboxIssueAPIEndpoint(BaseAPIView):
|
||||
)
|
||||
|
||||
# Only project admins and members can edit inbox issue attributes
|
||||
if project_member.role > 5:
|
||||
if project_member.role > 15:
|
||||
serializer = InboxIssueSerializer(
|
||||
inbox_issue, data=request.data, partial=True
|
||||
)
|
||||
|
||||
@@ -301,11 +301,16 @@ class ProjectAPIEndpoint(BaseAPIView):
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
if serializer.data["inbox_view"]:
|
||||
Inbox.objects.get_or_create(
|
||||
name=f"{project.name} Inbox",
|
||||
inbox = Inbox.objects.filter(
|
||||
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(
|
||||
|
||||
@@ -124,3 +124,9 @@ from .webhook import WebhookSerializer, WebhookLogSerializer
|
||||
from .dashboard import DashboardSerializer, WidgetSerializer
|
||||
|
||||
from .favorite import UserFavoriteSerializer
|
||||
|
||||
from .draft import (
|
||||
DraftIssueCreateSerializer,
|
||||
DraftIssueSerializer,
|
||||
DraftIssueDetailSerializer,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
# 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
|
||||
@@ -11,7 +11,6 @@ from plane.app.views import (
|
||||
IssueActivityEndpoint,
|
||||
IssueArchiveViewSet,
|
||||
IssueCommentViewSet,
|
||||
IssueDraftViewSet,
|
||||
IssueListEndpoint,
|
||||
IssueReactionViewSet,
|
||||
IssueRelationViewSet,
|
||||
@@ -20,6 +19,7 @@ from plane.app.views import (
|
||||
IssueViewSet,
|
||||
LabelViewSet,
|
||||
BulkArchiveIssuesEndpoint,
|
||||
DeletedIssuesListViewSet,
|
||||
IssuePaginatedViewSet,
|
||||
)
|
||||
|
||||
@@ -39,7 +39,7 @@ urlpatterns = [
|
||||
),
|
||||
name="project-issue",
|
||||
),
|
||||
# updated v1 paginated issues
|
||||
# updated v2 paginated issues
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/v2/issues/",
|
||||
IssuePaginatedViewSet.as_view({"get": "list"}),
|
||||
@@ -289,26 +289,9 @@ urlpatterns = [
|
||||
name="issue-relation",
|
||||
),
|
||||
## End Issue Relation
|
||||
## Issue Drafts
|
||||
path(
|
||||
"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",
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/deleted-issues/",
|
||||
DeletedIssuesListViewSet.as_view(),
|
||||
name="deleted-issues",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -27,6 +27,7 @@ from plane.app.views import (
|
||||
WorkspaceCyclesEndpoint,
|
||||
WorkspaceFavoriteEndpoint,
|
||||
WorkspaceFavoriteGroupEndpoint,
|
||||
WorkspaceDraftIssueViewSet,
|
||||
)
|
||||
|
||||
|
||||
@@ -254,4 +255,30 @@ 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",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -40,6 +40,8 @@ from .workspace.base import (
|
||||
ExportWorkspaceUserActivityEndpoint,
|
||||
)
|
||||
|
||||
from .workspace.draft import WorkspaceDraftIssueViewSet
|
||||
|
||||
from .workspace.favorite import (
|
||||
WorkspaceFavoriteEndpoint,
|
||||
WorkspaceFavoriteGroupEndpoint,
|
||||
@@ -114,6 +116,7 @@ from .issue.base import (
|
||||
IssueViewSet,
|
||||
IssueUserDisplayPropertyEndpoint,
|
||||
BulkDeleteIssuesEndpoint,
|
||||
DeletedIssuesListViewSet,
|
||||
IssuePaginatedViewSet,
|
||||
)
|
||||
|
||||
@@ -132,8 +135,6 @@ from .issue.comment import (
|
||||
CommentReactionViewSet,
|
||||
)
|
||||
|
||||
from .issue.draft import IssueDraftViewSet
|
||||
|
||||
from .issue.label import (
|
||||
LabelViewSet,
|
||||
BulkCreateIssueLabelsEndpoint,
|
||||
|
||||
@@ -604,7 +604,7 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView):
|
||||
pk=cycle_id, project_id=project_id, workspace__slug=slug
|
||||
)
|
||||
|
||||
if cycle.end_date >= timezone.now().date():
|
||||
if cycle.end_date >= timezone.now():
|
||||
return Response(
|
||||
{"error": "Only completed cycles can be archived"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
|
||||
@@ -187,6 +187,7 @@ class CycleViewSet(BaseViewSet):
|
||||
"completed_issues",
|
||||
"assignee_ids",
|
||||
"status",
|
||||
"version",
|
||||
"created_by",
|
||||
)
|
||||
|
||||
@@ -216,6 +217,7 @@ class CycleViewSet(BaseViewSet):
|
||||
"completed_issues",
|
||||
"assignee_ids",
|
||||
"status",
|
||||
"version",
|
||||
"created_by",
|
||||
)
|
||||
return Response(data, status=status.HTTP_200_OK)
|
||||
@@ -255,6 +257,7 @@ class CycleViewSet(BaseViewSet):
|
||||
"external_id",
|
||||
"progress_snapshot",
|
||||
"logo_props",
|
||||
"version",
|
||||
# meta fields
|
||||
"is_favorite",
|
||||
"total_issues",
|
||||
@@ -306,10 +309,7 @@ class CycleViewSet(BaseViewSet):
|
||||
|
||||
request_data = request.data
|
||||
|
||||
if (
|
||||
cycle.end_date is not None
|
||||
and cycle.end_date < timezone.now().date()
|
||||
):
|
||||
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,6 +347,7 @@ class CycleViewSet(BaseViewSet):
|
||||
"external_id",
|
||||
"progress_snapshot",
|
||||
"logo_props",
|
||||
"version",
|
||||
# meta fields
|
||||
"is_favorite",
|
||||
"total_issues",
|
||||
@@ -412,6 +413,7 @@ class CycleViewSet(BaseViewSet):
|
||||
"progress_snapshot",
|
||||
"sub_issues",
|
||||
"logo_props",
|
||||
"version",
|
||||
# meta fields
|
||||
"is_favorite",
|
||||
"total_issues",
|
||||
@@ -925,7 +927,7 @@ class TransferCycleIssueEndpoint(BaseAPIView):
|
||||
|
||||
if (
|
||||
new_cycle.end_date is not None
|
||||
and new_cycle.end_date < timezone.now().date()
|
||||
and new_cycle.end_date < timezone.now()
|
||||
):
|
||||
return Response(
|
||||
{
|
||||
@@ -1148,6 +1150,7 @@ class CycleProgressEndpoint(BaseAPIView):
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
|
||||
class CycleAnalyticsEndpoint(BaseAPIView):
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
||||
|
||||
@@ -248,7 +248,7 @@ class CycleIssueViewSet(BaseViewSet):
|
||||
|
||||
if (
|
||||
cycle.end_date is not None
|
||||
and cycle.end_date < timezone.now().date()
|
||||
and cycle.end_date < timezone.now()
|
||||
):
|
||||
return Response(
|
||||
{
|
||||
|
||||
@@ -40,8 +40,6 @@ from plane.db.models import (
|
||||
IssueLink,
|
||||
IssueRelation,
|
||||
Project,
|
||||
ProjectMember,
|
||||
User,
|
||||
Widget,
|
||||
WorkspaceMember,
|
||||
)
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import random
|
||||
import string
|
||||
import json
|
||||
|
||||
# Django imports
|
||||
from django.utils import timezone
|
||||
|
||||
# Third party imports
|
||||
from rest_framework.response import Response
|
||||
@@ -19,6 +23,7 @@ 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):
|
||||
@@ -249,11 +254,66 @@ class EstimatePointEndpoint(BaseViewSet):
|
||||
)
|
||||
# update all the issues with the new estimate
|
||||
if new_estimate_id:
|
||||
_ = Issue.objects.filter(
|
||||
issues = Issue.objects.filter(
|
||||
project_id=project_id,
|
||||
workspace__slug=slug,
|
||||
estimate_point_id=estimate_point_id,
|
||||
).update(estimate_point_id=new_estimate_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()),
|
||||
)
|
||||
|
||||
# delete the estimate point
|
||||
old_estimate_point = EstimatePoint.objects.filter(
|
||||
|
||||
@@ -167,10 +167,10 @@ class InboxIssueViewSet(BaseViewSet):
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
||||
def list(self, request, slug, project_id):
|
||||
inbox_id = Inbox.objects.get(
|
||||
inbox_id = Inbox.objects.filter(
|
||||
workspace__slug=slug, project_id=project_id
|
||||
)
|
||||
project = Project.objects.get(pk=project_id)
|
||||
).first()
|
||||
project = Project.objects.get(pk=project_id)
|
||||
filters = issue_filters(request.GET, "GET", "issue__")
|
||||
inbox_issue = (
|
||||
InboxIssue.objects.filter(
|
||||
@@ -323,7 +323,7 @@ class InboxIssueViewSet(BaseViewSet):
|
||||
serializer.errors, status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
||||
@allow_permission(allowed_roles=[ROLE.ADMIN], creator=True, model=Issue)
|
||||
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 > 5:
|
||||
if project_member.role > 15:
|
||||
serializer = InboxIssueSerializer(
|
||||
inbox_issue, data=request.data, partial=True
|
||||
)
|
||||
@@ -527,9 +527,9 @@ class InboxIssueViewSet(BaseViewSet):
|
||||
model=Issue,
|
||||
)
|
||||
def retrieve(self, request, slug, project_id, pk):
|
||||
inbox_id = Inbox.objects.get(
|
||||
inbox_id = Inbox.objects.filter(
|
||||
workspace__slug=slug, project_id=project_id
|
||||
)
|
||||
).first()
|
||||
project = Project.objects.get(pk=project_id)
|
||||
inbox_issue = (
|
||||
InboxIssue.objects.select_related("issue")
|
||||
|
||||
@@ -234,11 +234,17 @@ 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)
|
||||
issue_queryset = self.get_queryset().filter(**filters, **extra_filters)
|
||||
# Custom ordering for priority and state
|
||||
|
||||
# Issue queryset
|
||||
@@ -713,16 +719,38 @@ 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.issue_objects.filter(
|
||||
workspace__slug=workspace_slug, project_id=project_id
|
||||
issue_queryset.select_related(
|
||||
"workspace", "project", "state", "parent"
|
||||
)
|
||||
.select_related("workspace", "project", "state", "parent")
|
||||
.prefetch_related("assignees", "labels", "issue_module__module")
|
||||
.annotate(cycle_id=F("issue_cycle__cycle_id"))
|
||||
.annotate(
|
||||
@@ -764,13 +792,14 @@ 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__gte", None)
|
||||
updated_at = request.GET.get("updated_at__gt", None)
|
||||
|
||||
# required fields
|
||||
required_fields = [
|
||||
"id",
|
||||
"name",
|
||||
"state_id",
|
||||
"state__group",
|
||||
"sort_order",
|
||||
"completed_at",
|
||||
"estimate_point",
|
||||
@@ -787,7 +816,6 @@ class IssuePaginatedViewSet(BaseViewSet):
|
||||
"updated_by",
|
||||
"is_draft",
|
||||
"archived_at",
|
||||
"deleted_at",
|
||||
"module_ids",
|
||||
"label_ids",
|
||||
"assignee_ids",
|
||||
@@ -802,13 +830,28 @@ class IssuePaginatedViewSet(BaseViewSet):
|
||||
# querying issues
|
||||
base_queryset = Issue.issue_objects.filter(
|
||||
workspace__slug=slug, project_id=project_id
|
||||
).order_by("updated_at")
|
||||
)
|
||||
|
||||
base_queryset = base_queryset.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__gte=updated_at)
|
||||
queryset = queryset.filter(updated_at__gte=updated_at)
|
||||
base_queryset = base_queryset.filter(updated_at__gt=updated_at)
|
||||
queryset = queryset.filter(updated_at__gt=updated_at)
|
||||
|
||||
queryset = queryset.annotate(
|
||||
label_ids=Coalesce(
|
||||
|
||||
@@ -1,410 +0,0 @@
|
||||
# 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)
|
||||
@@ -267,7 +267,7 @@ class IssueRelationViewSet(BaseViewSet):
|
||||
IssueRelationSerializer(issue_relation).data,
|
||||
cls=DjangoJSONEncoder,
|
||||
)
|
||||
issue_relation.delete()
|
||||
issue_relation.delete(soft=False)
|
||||
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],
|
||||
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST],
|
||||
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], level="WORKSPACE"
|
||||
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], 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], level="WORKSPACE"
|
||||
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], 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], level="WORKSPACE"
|
||||
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], 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], level="WORKSPACE"
|
||||
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE"
|
||||
)
|
||||
def create(self, request, slug):
|
||||
snoozed = request.data.get("snoozed", False)
|
||||
|
||||
@@ -173,7 +173,7 @@ class ProjectViewSet(BaseViewSet):
|
||||
member=request.user,
|
||||
workspace__slug=slug,
|
||||
is_active=True,
|
||||
role=10,
|
||||
role=15,
|
||||
).exists():
|
||||
projects = projects.filter(
|
||||
Q(
|
||||
@@ -413,9 +413,20 @@ 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)
|
||||
@@ -438,11 +449,16 @@ class ProjectViewSet(BaseViewSet):
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
if serializer.data["inbox_view"]:
|
||||
Inbox.objects.get_or_create(
|
||||
name=f"{project.name} Inbox",
|
||||
inbox = Inbox.objects.filter(
|
||||
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,6 +414,7 @@ class UserProjectRolesEndpoint(BaseAPIView):
|
||||
project_members = ProjectMember.objects.filter(
|
||||
workspace__slug=slug,
|
||||
member_id=request.user.id,
|
||||
is_active=True,
|
||||
).values("project_id", "role")
|
||||
|
||||
project_members = {
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -97,6 +97,6 @@ class IssueSearchEndpoint(BaseAPIView):
|
||||
"state__name",
|
||||
"state__group",
|
||||
"state__color",
|
||||
),
|
||||
)[:100],
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,412 @@
|
||||
# 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,7 +74,6 @@ class WorkSpaceMemberViewSet(BaseViewSet):
|
||||
|
||||
# Get all active workspace members
|
||||
workspace_members = self.get_queryset()
|
||||
|
||||
if workspace_member.role > 5:
|
||||
serializer = WorkspaceMemberAdminSerializer(
|
||||
workspace_members,
|
||||
|
||||
@@ -504,7 +504,7 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView):
|
||||
|
||||
upcoming_cycles = CycleIssue.objects.filter(
|
||||
workspace__slug=slug,
|
||||
cycle__start_date__gt=timezone.now().date(),
|
||||
cycle__start_date__gt=timezone.now(),
|
||||
issue__assignees__in=[
|
||||
user_id,
|
||||
],
|
||||
@@ -512,8 +512,8 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView):
|
||||
|
||||
present_cycle = CycleIssue.objects.filter(
|
||||
workspace__slug=slug,
|
||||
cycle__start_date__lt=timezone.now().date(),
|
||||
cycle__end_date__gt=timezone.now().date(),
|
||||
cycle__start_date__lt=timezone.now(),
|
||||
cycle__end_date__gt=timezone.now(),
|
||||
issue__assignees__in=[
|
||||
user_id,
|
||||
],
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
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
|
||||
@@ -18,17 +19,25 @@ def soft_delete_related_objects(
|
||||
for field in related_fields:
|
||||
if field.one_to_many or field.one_to_one:
|
||||
try:
|
||||
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)
|
||||
# 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)
|
||||
except ObjectDoesNotExist:
|
||||
pass
|
||||
|
||||
@@ -154,8 +163,7 @@ 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
|
||||
|
||||
@@ -347,7 +347,7 @@ def create_issues(workspace, project, user_id, issue_count):
|
||||
)
|
||||
)
|
||||
|
||||
text = fake.text(max_nb_chars=60000)
|
||||
text = fake.text(max_nb_chars=3000)
|
||||
issues.append(
|
||||
Issue(
|
||||
state_id=states[random.randint(0, len(states) - 1)],
|
||||
@@ -490,18 +490,23 @@ 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(
|
||||
# issues = random.sample(
|
||||
# list(
|
||||
# Issue.objects.filter(project=project).values_list("id", flat=True)
|
||||
# ),
|
||||
# int(issue_count / 2),
|
||||
# )
|
||||
issues = list(
|
||||
Issue.objects.filter(project=project).values_list("id", flat=True)
|
||||
),
|
||||
int(issue_count / 2),
|
||||
)
|
||||
)
|
||||
shuffled_labels = list(labels)
|
||||
|
||||
# Bulk issue
|
||||
bulk_issue_labels = []
|
||||
for issue in issues:
|
||||
random.shuffle(shuffled_labels)
|
||||
for label in random.sample(
|
||||
list(labels), random.randint(0, len(labels) - 1)
|
||||
shuffled_labels, random.randint(0, 5)
|
||||
):
|
||||
bulk_issue_labels.append(
|
||||
IssueLabel(
|
||||
@@ -552,25 +557,33 @@ 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(
|
||||
# issues = random.sample(
|
||||
# list(
|
||||
# Issue.objects.filter(project=project).values_list("id", flat=True)
|
||||
# ),
|
||||
# int(issue_count / 2),
|
||||
# )
|
||||
issues = list(
|
||||
Issue.objects.filter(project=project).values_list("id", flat=True)
|
||||
),
|
||||
int(issue_count / 2),
|
||||
)
|
||||
)
|
||||
|
||||
shuffled_modules = list(modules)
|
||||
|
||||
# Bulk issue
|
||||
bulk_module_issues = []
|
||||
for issue in issues:
|
||||
module = modules[random.randint(0, len(modules) - 1)]
|
||||
bulk_module_issues.append(
|
||||
ModuleIssue(
|
||||
module_id=module,
|
||||
issue_id=issue,
|
||||
project=project,
|
||||
workspace=workspace,
|
||||
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,
|
||||
)
|
||||
)
|
||||
)
|
||||
# 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="updated",
|
||||
verb="removed" if new_estimate is None else "updated",
|
||||
old_identifier=(
|
||||
current_instance.get("estimate_point")
|
||||
if current_instance.get("estimate_point") is not None
|
||||
@@ -1700,16 +1700,12 @@ 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,14 +42,12 @@ def archive_old_issues():
|
||||
),
|
||||
Q(issue_cycle__isnull=True)
|
||||
| (
|
||||
Q(issue_cycle__cycle__end_date__lt=timezone.now().date())
|
||||
Q(issue_cycle__cycle__end_date__lt=timezone.now())
|
||||
& Q(issue_cycle__isnull=False)
|
||||
),
|
||||
Q(issue_module__isnull=True)
|
||||
| (
|
||||
Q(
|
||||
issue_module__module__target_date__lt=timezone.now().date()
|
||||
)
|
||||
Q(issue_module__module__target_date__lt=timezone.now())
|
||||
& Q(issue_module__isnull=False)
|
||||
),
|
||||
).filter(
|
||||
@@ -122,14 +120,12 @@ def close_old_issues():
|
||||
),
|
||||
Q(issue_cycle__isnull=True)
|
||||
| (
|
||||
Q(issue_cycle__cycle__end_date__lt=timezone.now().date())
|
||||
Q(issue_cycle__cycle__end_date__lt=timezone.now())
|
||||
& Q(issue_cycle__isnull=False)
|
||||
),
|
||||
Q(issue_module__isnull=True)
|
||||
| (
|
||||
Q(
|
||||
issue_module__module__target_date__lt=timezone.now().date()
|
||||
)
|
||||
Q(issue_module__module__target_date__lt=timezone.now())
|
||||
& Q(issue_module__isnull=False)
|
||||
),
|
||||
).filter(
|
||||
|
||||
@@ -73,7 +73,7 @@ class Command(BaseCommand):
|
||||
|
||||
from plane.bgtasks.dummy_data_task import create_dummy_data
|
||||
|
||||
create_dummy_data.delay(
|
||||
create_dummy_data(
|
||||
slug=workspace_slug,
|
||||
email=creator,
|
||||
members=members,
|
||||
|
||||
+2036
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,7 @@ from .base import BaseModel
|
||||
from .cycle import Cycle, CycleFavorite, CycleIssue, CycleUserProperties
|
||||
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
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
# Python imports
|
||||
import pytz
|
||||
|
||||
# Django imports
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
@@ -55,10 +58,12 @@ class Cycle(ProjectBaseModel):
|
||||
description = models.TextField(
|
||||
verbose_name="Cycle Description", blank=True
|
||||
)
|
||||
start_date = models.DateField(
|
||||
start_date = models.DateTimeField(
|
||||
verbose_name="Start Date", blank=True, null=True
|
||||
)
|
||||
end_date = models.DateField(verbose_name="End Date", blank=True, null=True)
|
||||
end_date = models.DateTimeField(
|
||||
verbose_name="End Date", blank=True, null=True
|
||||
)
|
||||
owned_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
@@ -71,6 +76,12 @@ class Cycle(ProjectBaseModel):
|
||||
progress_snapshot = models.JSONField(default=dict)
|
||||
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
|
||||
)
|
||||
version = models.IntegerField(default=1)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Cycle"
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
# 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,4 +1,5 @@
|
||||
# Python imports
|
||||
import pytz
|
||||
from uuid import uuid4
|
||||
|
||||
# Django imports
|
||||
@@ -7,7 +8,7 @@ from django.core.validators import MaxValueValidator, MinValueValidator
|
||||
from django.db import models
|
||||
from django.db.models import Q
|
||||
|
||||
# Modeule imports
|
||||
# Module imports
|
||||
from plane.db.mixins import AuditModel
|
||||
|
||||
# Module imports
|
||||
@@ -119,6 +120,11 @@ class Project(BaseModel):
|
||||
related_name="default_state",
|
||||
)
|
||||
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
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
"""Return name of the project"""
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
# 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,7 +9,10 @@ from django.conf import settings
|
||||
|
||||
# Module imports
|
||||
from plane.license.models import Instance
|
||||
from plane.db.models import User
|
||||
from plane.db.models import (
|
||||
User,
|
||||
)
|
||||
from plane.license.bgtasks.tracer import instance_traces
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
@@ -21,16 +24,24 @@ 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"
|
||||
)
|
||||
@@ -38,12 +49,7 @@ class Command(BaseCommand):
|
||||
if not machine_signature:
|
||||
raise CommandError("Machine signature is required")
|
||||
|
||||
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(),
|
||||
}
|
||||
payload = self.read_package_json()
|
||||
|
||||
instance = Instance.objects.create(
|
||||
instance_name="Plane Community Edition",
|
||||
@@ -60,4 +66,15 @@ class Command(BaseCommand):
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS("Instance already registered")
|
||||
)
|
||||
return
|
||||
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
|
||||
|
||||
@@ -16,6 +16,17 @@ 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
|
||||
@@ -24,6 +35,19 @@ 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 = ["*"]
|
||||
|
||||
@@ -279,6 +303,7 @@ CELERY_IMPORTS = (
|
||||
"plane.bgtasks.file_asset_task",
|
||||
"plane.bgtasks.email_notification_task",
|
||||
"plane.bgtasks.api_logs_task",
|
||||
"plane.license.bgtasks.tracer",
|
||||
# management tasks
|
||||
"plane.bgtasks.dummy_data_task",
|
||||
)
|
||||
|
||||
@@ -163,7 +163,7 @@ def burndown_plot(
|
||||
if queryset.end_date and queryset.start_date:
|
||||
# Get all dates between the two dates
|
||||
date_range = [
|
||||
queryset.start_date + timedelta(days=x)
|
||||
(queryset.start_date + timedelta(days=x)).date()
|
||||
for x in range(
|
||||
(queryset.end_date - queryset.start_date).days + 1
|
||||
)
|
||||
@@ -203,7 +203,7 @@ def burndown_plot(
|
||||
if module_id:
|
||||
# Get all dates between the two dates
|
||||
date_range = [
|
||||
queryset.start_date + timedelta(days=x)
|
||||
(queryset.start_date + timedelta(days=x)).date()
|
||||
for x in range(
|
||||
(queryset.target_date - queryset.start_date).days + 1
|
||||
)
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
# python imports
|
||||
from math import ceil
|
||||
|
||||
# constants
|
||||
PAGINATOR_MAX_LIMIT = 1000
|
||||
|
||||
@@ -36,6 +39,9 @@ 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:
|
||||
@@ -72,6 +78,7 @@ 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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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 [
|
||||
|
||||
@@ -82,7 +82,7 @@ class CursorResult(Sequence):
|
||||
return f"<{type(self).__name__}: results={len(self.results)}>"
|
||||
|
||||
|
||||
MAX_LIMIT = 100
|
||||
MAX_LIMIT = 1000
|
||||
|
||||
|
||||
class BadPaginationError(Exception):
|
||||
@@ -118,7 +118,7 @@ class OffsetPaginator:
|
||||
self.max_offset = max_offset
|
||||
self.on_results = on_results
|
||||
|
||||
def get_result(self, limit=100, cursor=None):
|
||||
def get_result(self, limit=1000, 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=100, max_per_page=100):
|
||||
def get_per_page(self, request, default_per_page=1000, max_per_page=1000):
|
||||
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=100,
|
||||
max_per_page=100,
|
||||
default_per_page=1000,
|
||||
max_per_page=1000,
|
||||
cursor_cls=Cursor,
|
||||
extra_stats=None,
|
||||
controller=None,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# base requirements
|
||||
|
||||
# django
|
||||
Django==4.2.15
|
||||
Django==4.2.16
|
||||
# rest framework
|
||||
djangorestframework==3.15.2
|
||||
# postgres
|
||||
@@ -62,3 +62,8 @@ 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
|
||||
+3
-2
@@ -1,7 +1,8 @@
|
||||
API_BASE_URL="http://api:8000"
|
||||
LIVE_BASE_PATH="/live"
|
||||
REDIS_URL="redis://localhost:6379"
|
||||
|
||||
REDIS_URL="redis://plane-redis: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=localhost
|
||||
REDIS_HOST=plane-redis
|
||||
@@ -0,0 +1,4 @@
|
||||
.turbo/*
|
||||
out/*
|
||||
dist/*
|
||||
public/*
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"root": true,
|
||||
"extends": ["@plane/eslint-config/server.js"],
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"parserOptions": {
|
||||
"project": true
|
||||
}
|
||||
}
|
||||
+9
-4
@@ -1,13 +1,16 @@
|
||||
{
|
||||
"name": "live",
|
||||
"version": "0.22.0",
|
||||
"version": "0.23.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"
|
||||
"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"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
@@ -35,6 +38,7 @@
|
||||
"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"
|
||||
@@ -51,9 +55,10 @@
|
||||
"@types/express-ws": "^3.0.4",
|
||||
"@types/node": "^20.14.9",
|
||||
"babel-plugin-module-resolver": "^5.0.2",
|
||||
"nodemon": "^3.1.0",
|
||||
"concurrently": "^9.0.1",
|
||||
"nodemon": "^3.1.7",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsup": "^7.2.0",
|
||||
"typescript": "5.4.5"
|
||||
"typescript": "5.3.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
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.`);
|
||||
}
|
||||
Vendored
+1
-2
@@ -1,2 +1 @@
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
export type TAdditionalDocumentTypes = {}
|
||||
export type TAdditionalDocumentTypes = {};
|
||||
|
||||
@@ -45,7 +45,8 @@ 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;
|
||||
@@ -53,7 +54,7 @@ export const getExtensions: () => Promise<Extension[]> = async () => {
|
||||
fetchedData = await fetchPageDescriptionBinary(
|
||||
params,
|
||||
pageId,
|
||||
cookie,
|
||||
cookie
|
||||
);
|
||||
} else {
|
||||
fetchedData = await fetchDocument({
|
||||
@@ -83,6 +84,8 @@ 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") {
|
||||
@@ -121,7 +124,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);
|
||||
});
|
||||
@@ -135,12 +138,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,7 +1,7 @@
|
||||
import { ErrorRequestHandler } from "express";
|
||||
import { manualLogger } from "@/core/helpers/logger.js";
|
||||
|
||||
export const errorHandler: ErrorRequestHandler = (err, _req, res, _next) => {
|
||||
export const errorHandler: ErrorRequestHandler = (err, _req, res) => {
|
||||
// Log the error
|
||||
manualLogger.error(err);
|
||||
|
||||
|
||||
@@ -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,9 +24,7 @@ export const getHocusPocusServer = async () => {
|
||||
|
||||
try {
|
||||
await handleAuthentication({
|
||||
connection,
|
||||
cookie,
|
||||
params,
|
||||
token,
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -34,5 +32,6 @@ export const getHocusPocusServer = async () => {
|
||||
}
|
||||
},
|
||||
extensions,
|
||||
debounce: 10000,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,28 +1,17 @@
|
||||
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 { connection, cookie, params, token } = props;
|
||||
// params
|
||||
const documentType = params.get("documentType")?.toString() as
|
||||
| TDocumentTypes
|
||||
| undefined;
|
||||
const { cookie, token } = props;
|
||||
// fetch current user info
|
||||
let response;
|
||||
try {
|
||||
@@ -35,40 +24,6 @@ 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,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// types
|
||||
import type { IProjectMember, IUser } from "@plane/types";
|
||||
import type { IUser } from "@plane/types";
|
||||
// services
|
||||
import { API_BASE_URL, APIService } from "@/core/services/api.service.js";
|
||||
|
||||
@@ -25,37 +25,4 @@ 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;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+7
-24
@@ -1,43 +1,26 @@
|
||||
{
|
||||
"extends": "tsconfig/base.json",
|
||||
"extends": "@plane/typescript-config/base.json",
|
||||
"compilerOptions": {
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
|
||||
"lib": [
|
||||
"ES2015"
|
||||
],
|
||||
|
||||
"lib": ["ES2015"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"rootDir": ".",
|
||||
"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"
|
||||
],
|
||||
"exclude": [
|
||||
"./dist",
|
||||
"./build",
|
||||
"./node_modules"
|
||||
]
|
||||
"include": ["src/**/*.ts", "tsup.config.ts"],
|
||||
"exclude": ["./dist", "./build", "./node_modules"]
|
||||
}
|
||||
|
||||
@@ -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/;
|
||||
proxy_pass http://live:3000/live/;
|
||||
}
|
||||
|
||||
location /spaces/ {
|
||||
|
||||
+4
-18
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"repository": "https://github.com/makeplane/plane.git",
|
||||
"version": "0.22.0",
|
||||
"version": "0.23.0",
|
||||
"license": "AGPL-3.0",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
@@ -8,34 +8,20 @@
|
||||
"space",
|
||||
"admin",
|
||||
"live",
|
||||
"packages/editor",
|
||||
"packages/eslint-config-custom",
|
||||
"packages/tailwind-config-custom",
|
||||
"packages/tsconfig",
|
||||
"packages/ui",
|
||||
"packages/types",
|
||||
"packages/constants",
|
||||
"packages/helpers"
|
||||
"packages/*"
|
||||
],
|
||||
"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}\"",
|
||||
"prepare": "husky"
|
||||
"format": "prettier --write \"**/*.{ts,tsx,md}\""
|
||||
},
|
||||
"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": {
|
||||
|
||||
@@ -13,6 +13,19 @@ 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,6 +1,6 @@
|
||||
{
|
||||
"name": "@plane/constants",
|
||||
"version": "0.22.0",
|
||||
"version": "0.23.0",
|
||||
"private": true,
|
||||
"main": "./index.ts"
|
||||
}
|
||||
|
||||
@@ -1,38 +1,9 @@
|
||||
module.exports = {
|
||||
root: 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,
|
||||
},
|
||||
},
|
||||
],
|
||||
extends: ["@plane/eslint-config/library.js"],
|
||||
parser: "@typescript-eslint/parser",
|
||||
parserOptions: {
|
||||
project: true,
|
||||
},
|
||||
rules: {},
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@plane/editor",
|
||||
"version": "0.22.0",
|
||||
"version": "0.23.0",
|
||||
"description": "Core Editor that powers Plane",
|
||||
"private": true,
|
||||
"main": "./dist/index.mjs",
|
||||
@@ -26,6 +26,7 @@
|
||||
"build": "tsup --minify",
|
||||
"dev": "tsup --watch",
|
||||
"check-types": "tsc --noEmit",
|
||||
"lint": "eslint src --ext .ts,.tsx",
|
||||
"format": "prettier --write \"**/*.{ts,tsx,md}\""
|
||||
},
|
||||
"peerDependencies": {
|
||||
@@ -41,13 +42,15 @@
|
||||
"@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.1.13",
|
||||
"@tiptap/extension-text-style": "^2.7.1",
|
||||
"@tiptap/extension-underline": "^2.1.13",
|
||||
"@tiptap/pm": "^2.1.13",
|
||||
"@tiptap/react": "^2.1.13",
|
||||
@@ -65,21 +68,22 @@
|
||||
"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.4.5"
|
||||
"typescript": "5.3.3"
|
||||
},
|
||||
"keywords": [
|
||||
"editor",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { HocuspocusProvider } from "@hocuspocus/provider";
|
||||
import { Extensions } from "@tiptap/core";
|
||||
import { SlashCommand } from "@/extensions";
|
||||
import { SlashCommands } 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 = [SlashCommand()];
|
||||
const extensions: Extensions = [SlashCommands()];
|
||||
|
||||
return extensions;
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from "react";
|
||||
// components
|
||||
import { PageRenderer } from "@/components/editors";
|
||||
import { DocumentContentLoader, 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 } = useCollaborativeEditor({
|
||||
const { editor, hasServerConnectionFailed, hasServerSynced } = useCollaborativeEditor({
|
||||
disabledExtensions,
|
||||
editorClassName,
|
||||
embedHandler,
|
||||
@@ -67,6 +67,8 @@ const CollaborativeDocumentEditor = (props: ICollaborativeDocumentEditor) => {
|
||||
|
||||
if (!editor) return null;
|
||||
|
||||
if (!hasServerSynced && !hasServerConnectionFailed) return <DocumentContentLoader />;
|
||||
|
||||
return (
|
||||
<PageRenderer
|
||||
displayConfig={displayConfig}
|
||||
|
||||
+5
-2
@@ -1,6 +1,6 @@
|
||||
import { forwardRef, MutableRefObject } from "react";
|
||||
// components
|
||||
import { PageRenderer } from "@/components/editors";
|
||||
import { DocumentContentLoader, PageRenderer } from "@/components/editors";
|
||||
// constants
|
||||
import { DEFAULT_DISPLAY_CONFIG } from "@/constants/config";
|
||||
// extensions
|
||||
@@ -35,7 +35,7 @@ const CollaborativeDocumentReadOnlyEditor = (props: ICollaborativeDocumentReadOn
|
||||
);
|
||||
}
|
||||
|
||||
const { editor } = useReadOnlyCollaborativeEditor({
|
||||
const { editor, hasServerConnectionFailed, hasServerSynced } = useReadOnlyCollaborativeEditor({
|
||||
editorClassName,
|
||||
extensions,
|
||||
forwardedRef,
|
||||
@@ -52,6 +52,9 @@ const CollaborativeDocumentReadOnlyEditor = (props: ICollaborativeDocumentReadOn
|
||||
});
|
||||
|
||||
if (!editor) return null;
|
||||
|
||||
if (!hasServerSynced && !hasServerConnectionFailed) return <DocumentContentLoader />;
|
||||
|
||||
return (
|
||||
<PageRenderer
|
||||
displayConfig={displayConfig}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from "./collaborative-editor";
|
||||
export * from "./collaborative-read-only-editor";
|
||||
export * from "./loader";
|
||||
export * from "./page-renderer";
|
||||
export * from "./read-only-editor";
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
// 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,7 +18,8 @@ interface EditorContainerProps {
|
||||
export const EditorContainer: FC<EditorContainerProps> = (props) => {
|
||||
const { children, displayConfig, editor, editorContainerClassName, id } = props;
|
||||
|
||||
const handleContainerClick = () => {
|
||||
const handleContainerClick = (event: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
|
||||
if (event.target !== event.currentTarget) return;
|
||||
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, SlashCommand } from "@/extensions";
|
||||
import { SideMenuExtension, SlashCommands } from "@/extensions";
|
||||
// types
|
||||
import { EditorRefApi, IRichTextEditor } from "@/types";
|
||||
|
||||
@@ -11,7 +11,7 @@ const RichTextEditor = (props: IRichTextEditor) => {
|
||||
const { dragDropEnabled } = props;
|
||||
|
||||
const getExtensions = useCallback(() => {
|
||||
const extensions = [SlashCommand()];
|
||||
const extensions = [SlashCommands()];
|
||||
|
||||
extensions.push(
|
||||
SideMenuExtension({
|
||||
|
||||
@@ -23,7 +23,6 @@ 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,7 +34,6 @@ 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,
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
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,3 +1,4 @@
|
||||
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, Trash } from "lucide-react";
|
||||
import { Check, Link, Trash } from "lucide-react";
|
||||
// helpers
|
||||
import { cn, isValidHttpUrl } from "@/helpers/common";
|
||||
import { setLinkEditor, unsetLinkEditor } from "@/helpers/editor-commands";
|
||||
@@ -11,7 +11,9 @@ type Props = {
|
||||
setIsOpen: Dispatch<SetStateAction<boolean>>;
|
||||
};
|
||||
|
||||
export const BubbleMenuLinkSelector: FC<Props> = ({ editor, isOpen, setIsOpen }) => {
|
||||
export const BubbleMenuLinkSelector: FC<Props> = (props) => {
|
||||
const { editor, isOpen, setIsOpen } = props;
|
||||
// refs
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const onLinkSubmit = useCallback(() => {
|
||||
@@ -28,26 +30,23 @@ export const BubbleMenuLinkSelector: FC<Props> = ({ editor, isOpen, setIsOpen })
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="relative h-full">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex h-full items-center space-x-2 px-3 py-1.5 text-sm font-medium text-custom-text-300 hover:bg-custom-background-100 active:bg-custom-background-100",
|
||||
{ "bg-custom-background-100": isOpen }
|
||||
"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"),
|
||||
}
|
||||
)}
|
||||
onClick={(e) => {
|
||||
setIsOpen(!isOpen);
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<p className="text-base">↗</p>
|
||||
<p
|
||||
className={cn("underline underline-offset-4", {
|
||||
"text-custom-text-100": editor.isActive("link"),
|
||||
})}
|
||||
>
|
||||
Link
|
||||
</p>
|
||||
<span>Link</span>
|
||||
<Link className="flex-shrink-0 size-3" />
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
HeadingFourItem,
|
||||
HeadingFiveItem,
|
||||
HeadingSixItem,
|
||||
BubbleMenuItem,
|
||||
EditorMenuItem,
|
||||
} from "@/components/menus";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common";
|
||||
@@ -26,8 +26,10 @@ type Props = {
|
||||
setIsOpen: Dispatch<SetStateAction<boolean>>;
|
||||
};
|
||||
|
||||
export const BubbleMenuNodeSelector: FC<Props> = ({ editor, isOpen, setIsOpen }) => {
|
||||
const items: BubbleMenuItem[] = [
|
||||
export const BubbleMenuNodeSelector: FC<Props> = (props) => {
|
||||
const { editor, isOpen, setIsOpen } = props;
|
||||
|
||||
const items: EditorMenuItem[] = [
|
||||
TextItem(editor),
|
||||
HeadingOneItem(editor),
|
||||
HeadingTwoItem(editor),
|
||||
@@ -42,7 +44,7 @@ export const BubbleMenuNodeSelector: FC<Props> = ({ editor, isOpen, setIsOpen })
|
||||
CodeItem(editor),
|
||||
];
|
||||
|
||||
const activeItem = items.filter((item) => item.isActive()).pop() ?? {
|
||||
const activeItem = items.filter((item) => item.isActive("")).pop() ?? {
|
||||
name: "Multiple",
|
||||
};
|
||||
|
||||
@@ -54,12 +56,11 @@ export const BubbleMenuNodeSelector: FC<Props> = ({ editor, isOpen, setIsOpen })
|
||||
setIsOpen(!isOpen);
|
||||
e.stopPropagation();
|
||||
}}
|
||||
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"
|
||||
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>{activeItem?.name}</span>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
<ChevronDown className="flex-shrink-0 size-3" />
|
||||
</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,12 +1,13 @@
|
||||
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,
|
||||
@@ -16,34 +17,23 @@ 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) => {
|
||||
const items: BubbleMenuItem[] = [
|
||||
...(props.editor.isActive("code")
|
||||
? []
|
||||
: [
|
||||
BoldItem(props.editor),
|
||||
ItalicItem(props.editor),
|
||||
UnderLineItem(props.editor),
|
||||
StrikeThroughItem(props.editor),
|
||||
]),
|
||||
CodeItem(props.editor),
|
||||
];
|
||||
// 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 bubbleMenuProps: EditorBubbleMenuProps = {
|
||||
...props,
|
||||
shouldShow: ({ state, editor }) => {
|
||||
const { selection } = state;
|
||||
|
||||
const { empty } = selection;
|
||||
|
||||
if (
|
||||
@@ -63,15 +53,11 @@ 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() {
|
||||
@@ -102,51 +88,66 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: any) => {
|
||||
return (
|
||||
<BubbleMenu
|
||||
{...bubbleMenuProps}
|
||||
className="flex w-fit divide-x divide-custom-border-300 rounded border border-custom-border-300 bg-custom-background-100 shadow-xl"
|
||||
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"
|
||||
>
|
||||
{isSelecting ? null : (
|
||||
{!isSelecting && (
|
||||
<>
|
||||
{!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">
|
||||
<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">
|
||||
{items.map((item) => (
|
||||
<button
|
||||
key={item.name}
|
||||
key={item.key}
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
item.command();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
className={cn(
|
||||
"p-2 text-custom-text-300 transition-colors hover:bg-custom-primary-100/5 active:bg-custom-primary-100/5",
|
||||
"size-7 grid place-items-center rounded text-custom-text-300 hover:bg-custom-background-80 active:bg-custom-background-80 transition-colors",
|
||||
{
|
||||
"bg-custom-primary-100/5 text-custom-text-100": item.isActive(),
|
||||
"bg-custom-background-80 text-custom-text-100": item.isActive(""),
|
||||
}
|
||||
)}
|
||||
>
|
||||
<item.icon
|
||||
className={cn("h-4 w-4", {
|
||||
"text-custom-text-100": item.isActive(),
|
||||
})}
|
||||
/>
|
||||
<item.icon className="size-4" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -20,11 +20,14 @@ import {
|
||||
Heading6,
|
||||
CaseSensitive,
|
||||
LucideIcon,
|
||||
Palette,
|
||||
} from "lucide-react";
|
||||
// helpers
|
||||
import {
|
||||
insertImage,
|
||||
insertTableCommand,
|
||||
setText,
|
||||
toggleBackgroundColor,
|
||||
toggleBlockquote,
|
||||
toggleBold,
|
||||
toggleBulletList,
|
||||
@@ -39,18 +42,26 @@ import {
|
||||
toggleOrderedList,
|
||||
toggleStrike,
|
||||
toggleTaskList,
|
||||
toggleTextColor,
|
||||
toggleUnderline,
|
||||
} from "@/helpers/editor-commands";
|
||||
// types
|
||||
import { TEditorCommands } from "@/types";
|
||||
import { TColorEditorCommands, TNonColorEditorCommands } from "@/types";
|
||||
|
||||
export interface EditorMenuItem {
|
||||
key: TEditorCommands;
|
||||
export type EditorMenuItem = {
|
||||
name: string;
|
||||
isActive: () => boolean;
|
||||
command: () => void;
|
||||
command: (...args: any) => void;
|
||||
icon: LucideIcon;
|
||||
}
|
||||
} & (
|
||||
| {
|
||||
key: TNonColorEditorCommands;
|
||||
isActive: () => boolean;
|
||||
}
|
||||
| {
|
||||
key: TColorEditorCommands;
|
||||
isActive: (color: string | undefined) => boolean;
|
||||
}
|
||||
);
|
||||
|
||||
export const TextItem = (editor: Editor): EditorMenuItem => ({
|
||||
key: "text",
|
||||
@@ -192,16 +203,30 @@ export const ImageItem = (editor: Editor) =>
|
||||
({
|
||||
key: "image",
|
||||
name: "Image",
|
||||
isActive: () => editor?.isActive("image"),
|
||||
command: (savedSelection: Selection | null) =>
|
||||
editor?.commands.setImageUpload({ event: "insert", pos: savedSelection?.from }),
|
||||
isActive: () => editor?.isActive("image") || editor?.isActive("imageComponent"),
|
||||
command: (savedSelection: Selection | null) => insertImage({ editor, event: "insert", pos: savedSelection?.from }),
|
||||
icon: ImageIcon,
|
||||
}) as const;
|
||||
|
||||
export function getEditorMenuItems(editor: Editor | null) {
|
||||
if (!editor) {
|
||||
return [];
|
||||
}
|
||||
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 [];
|
||||
|
||||
return [
|
||||
TextItem(editor),
|
||||
HeadingOneItem(editor),
|
||||
@@ -221,5 +246,7 @@ export function getEditorMenuItems(editor: Editor | null) {
|
||||
QuoteItem(editor),
|
||||
TableItem(editor),
|
||||
ImageItem(editor),
|
||||
TextColorItem(editor),
|
||||
BackgroundColorItem(editor),
|
||||
];
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
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,14 +117,18 @@ 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,3 +1,5 @@
|
||||
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";
|
||||
@@ -83,6 +85,10 @@ export const CoreEditorExtensionsWithoutProps = [
|
||||
TableCell,
|
||||
TableRow,
|
||||
CustomMentionWithoutProps(),
|
||||
Color,
|
||||
Highlight.configure({
|
||||
multicolor: true,
|
||||
}),
|
||||
];
|
||||
|
||||
export const DocumentEditorExtensionsWithoutProps = [IssueWidgetWithoutProps()];
|
||||
|
||||
@@ -1,105 +1,194 @@
|
||||
import React, { useRef, useState, useCallback, useLayoutEffect, useEffect } from "react";
|
||||
import { NodeSelection } from "@tiptap/pm/state";
|
||||
// extensions
|
||||
import { CustomImageNodeViewProps } from "@/extensions/custom-image";
|
||||
import { CustomImageNodeViewProps, ImageToolbarRoot } from "@/extensions/custom-image";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common";
|
||||
|
||||
const MIN_SIZE = 100;
|
||||
|
||||
export const CustomImageBlock: React.FC<CustomImageNodeViewProps> = (props) => {
|
||||
const { node, updateAttributes, selected, getPos, editor } = props;
|
||||
const { src, width, height } = node.attrs;
|
||||
type Pixel = `${number}px`;
|
||||
|
||||
const [size, setSize] = useState({ width: width || "35%", height: height || "auto" });
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
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 [initialResizeComplete, setInitialResizeComplete] = useState(false);
|
||||
const isShimmerVisible = isLoading || !initialResizeComplete;
|
||||
const [editorContainer, setEditorContainer] = useState<HTMLElement | null>(null);
|
||||
|
||||
// refs
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const containerRect = useRef<DOMRect | null>(null);
|
||||
const imageRef = useRef<HTMLImageElement>(null);
|
||||
const isResizing = useRef(false);
|
||||
const aspectRatioRef = useRef<number | null>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (imageRef.current) {
|
||||
const img = imageRef.current;
|
||||
img.onload = () => {
|
||||
const closestEditorContainer = img.closest(".editor-container");
|
||||
if (!closestEditorContainer) {
|
||||
console.error("Editor container not found");
|
||||
return;
|
||||
}
|
||||
|
||||
setEditorContainer(closestEditorContainer as HTMLElement);
|
||||
|
||||
if (width === "35%") {
|
||||
const editorWidth = closestEditorContainer.clientWidth;
|
||||
const initialWidth = Math.max(editorWidth * 0.35, MIN_SIZE);
|
||||
const aspectRatio = img.naturalWidth / img.naturalHeight;
|
||||
const initialHeight = initialWidth / aspectRatio;
|
||||
|
||||
const newSize = {
|
||||
width: `${Math.round(initialWidth)}px`,
|
||||
height: `${Math.round(initialHeight)}px`,
|
||||
};
|
||||
|
||||
setSize(newSize);
|
||||
updateAttributes(newSize);
|
||||
}
|
||||
setInitialResizeComplete(true);
|
||||
setIsLoading(false);
|
||||
};
|
||||
}
|
||||
}, [width, height, updateAttributes]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
setSize({ width, height });
|
||||
}, [width, height]);
|
||||
|
||||
const handleResizeStart = useCallback(
|
||||
(e: React.MouseEvent | React.TouchEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
isResizing.current = true;
|
||||
if (containerRef.current && editorContainer) {
|
||||
aspectRatioRef.current = Number(size.width.replace("px", "")) / Number(size.height.replace("px", ""));
|
||||
containerRect.current = containerRef.current.getBoundingClientRect();
|
||||
const updateAttributesSafely = useCallback(
|
||||
(attributes: Partial<ImageAttributes>, errorMessage: string) => {
|
||||
try {
|
||||
updateAttributes(attributes);
|
||||
} catch (error) {
|
||||
console.error(`${errorMessage}:`, error);
|
||||
}
|
||||
},
|
||||
[size, editorContainer]
|
||||
[updateAttributes]
|
||||
);
|
||||
|
||||
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 (!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;
|
||||
if (!containerRef.current || !containerRect.current || !size.aspectRatio) return;
|
||||
|
||||
const clientX = "touches" in e ? e.touches[0].clientX : e.clientX;
|
||||
|
||||
const newWidth = Math.max(clientX - containerRect.current.left, MIN_SIZE);
|
||||
const newHeight = newWidth / aspectRatioRef.current;
|
||||
const newHeight = newWidth / size.aspectRatio;
|
||||
|
||||
setSize({ width: `${newWidth}px`, height: `${newHeight}px` });
|
||||
setSize((prevSize) => ({ ...prevSize, width: `${newWidth}px`, height: `${newHeight}px` }));
|
||||
},
|
||||
[size]
|
||||
);
|
||||
|
||||
const handleResizeEnd = useCallback(() => {
|
||||
if (isResizing.current) {
|
||||
isResizing.current = false;
|
||||
updateAttributes(size);
|
||||
}
|
||||
setIsResizing(false);
|
||||
updateAttributesSafely(size, "Failed to update attributes at the end of resizing:");
|
||||
}, [size, updateAttributes]);
|
||||
|
||||
const handleMouseDown = useCallback(
|
||||
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(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const pos = getPos();
|
||||
@@ -109,57 +198,88 @@ export const CustomImageBlock: React.FC<CustomImageNodeViewProps> = (props) => {
|
||||
[editor, getPos]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editorContainer) return;
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => handleResize(e);
|
||||
const handleMouseUp = () => handleResizeEnd();
|
||||
const handleMouseLeave = () => handleResizeEnd();
|
||||
|
||||
editorContainer.addEventListener("mousemove", handleMouseMove);
|
||||
editorContainer.addEventListener("mouseup", handleMouseUp);
|
||||
editorContainer.addEventListener("mouseleave", handleMouseLeave);
|
||||
|
||||
return () => {
|
||||
editorContainer.removeEventListener("mousemove", handleMouseMove);
|
||||
editorContainer.removeEventListener("mouseup", handleMouseUp);
|
||||
editorContainer.removeEventListener("mouseleave", handleMouseLeave);
|
||||
};
|
||||
}, [handleResize, handleResizeEnd, editorContainer]);
|
||||
// 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;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="group/image-component relative inline-block max-w-full"
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseDown={handleImageMouseDown}
|
||||
style={{
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
aspectRatio: size.aspectRatio,
|
||||
}}
|
||||
>
|
||||
{isShimmerVisible && (
|
||||
<div className="animate-pulse bg-custom-background-80 rounded-md" style={{ width, height }} />
|
||||
{showImageLoader && (
|
||||
<div
|
||||
className="animate-pulse bg-custom-background-80 rounded-md"
|
||||
style={{ width: size.width, height: size.height }}
|
||||
/>
|
||||
)}
|
||||
<img
|
||||
ref={imageRef}
|
||||
src={src}
|
||||
src={displayedImageSrc}
|
||||
onLoad={handleImageLoad}
|
||||
onError={(e) => {
|
||||
console.error("Error loading image", e);
|
||||
setFailedToLoadImage(true);
|
||||
}}
|
||||
width={size.width}
|
||||
height={size.height}
|
||||
className={cn("block rounded-md", {
|
||||
hidden: isShimmerVisible,
|
||||
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,
|
||||
"read-only-image": !editor.isEditable,
|
||||
"blur-sm opacity-80 loading-image": !remoteImageSrc,
|
||||
})}
|
||||
style={{
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
aspectRatio: size.aspectRatio,
|
||||
}}
|
||||
/>
|
||||
{editor.isEditable && selected && <div className="absolute inset-0 size-full bg-custom-primary-500/30" />}
|
||||
{editor.isEditable && (
|
||||
{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 && (
|
||||
<>
|
||||
<div className="opacity-0 group-hover/image-component:opacity-100 absolute inset-0 border-2 border-custom-primary-100 pointer-events-none rounded-md transition-opacity duration-100 ease-in-out" />
|
||||
<div
|
||||
className="opacity-0 pointer-events-none group-hover/image-component:opacity-100 group-hover/image-component:pointer-events-auto absolute bottom-0 right-0 translate-y-1/2 translate-x-1/2 size-4 rounded-full bg-custom-primary-100 border-2 border-white cursor-nwse-resize transition-opacity duration-100 ease-in-out"
|
||||
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,
|
||||
}
|
||||
)}
|
||||
onMouseDown={handleResizeStart}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -1,23 +1,14 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Node as ProsemirrorNode } from "@tiptap/pm/model";
|
||||
import { Editor, NodeViewWrapper } from "@tiptap/react";
|
||||
// extensions
|
||||
import {
|
||||
CustomImageBlock,
|
||||
CustomImageUploader,
|
||||
UploadEntity,
|
||||
UploadImageExtensionStorage,
|
||||
} from "@/extensions/custom-image";
|
||||
import { CustomImageBlock, CustomImageUploader, ImageAttributes } from "@/extensions/custom-image";
|
||||
|
||||
export type CustomImageNodeViewProps = {
|
||||
getPos: () => number;
|
||||
editor: Editor;
|
||||
node: ProsemirrorNode & {
|
||||
attrs: {
|
||||
src: string;
|
||||
width: string;
|
||||
height: string;
|
||||
};
|
||||
attrs: ImageAttributes;
|
||||
};
|
||||
updateAttributes: (attrs: Record<string, any>) => void;
|
||||
selected: boolean;
|
||||
@@ -26,94 +17,60 @@ export type CustomImageNodeViewProps = {
|
||||
export const CustomImageNode = (props: CustomImageNodeViewProps) => {
|
||||
const { getPos, editor, node, updateAttributes, selected } = props;
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const hasTriggeredFilePickerRef = useRef(false);
|
||||
const [isUploaded, setIsUploaded] = useState(!!node.attrs.src);
|
||||
const [isUploaded, setIsUploaded] = useState(false);
|
||||
const [imageFromFileSystem, setImageFromFileSystem] = useState<string | undefined>(undefined);
|
||||
const [failedToLoadImage, setFailedToLoadImage] = useState(false);
|
||||
|
||||
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]
|
||||
);
|
||||
const [editorContainer, setEditorContainer] = useState<HTMLDivElement | null>(null);
|
||||
const imageComponentRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const uploadEntity = getUploadEntity();
|
||||
|
||||
if (uploadEntity) {
|
||||
if (uploadEntity.event === "drop" && "file" in uploadEntity) {
|
||||
uploadFile(uploadEntity.file);
|
||||
} else if (uploadEntity.event === "insert" && fileInputRef.current && !hasTriggeredFilePickerRef.current) {
|
||||
const entity = editorStorage?.fileMap.get(id);
|
||||
if (entity && entity.hasOpenedFileInputOnce) return;
|
||||
fileInputRef.current.click();
|
||||
hasTriggeredFilePickerRef.current = true;
|
||||
if (!entity) return;
|
||||
editorStorage?.fileMap.set(id, { ...entity, hasOpenedFileInputOnce: true });
|
||||
}
|
||||
const closestEditorContainer = imageComponentRef.current?.closest(".editor-container");
|
||||
if (!closestEditorContainer) {
|
||||
console.error("Editor container not found");
|
||||
return;
|
||||
}
|
||||
}, [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(() => {
|
||||
if (node.attrs.src) {
|
||||
const remoteImageSrc = node.attrs.src;
|
||||
if (remoteImageSrc) {
|
||||
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>
|
||||
{isUploaded ? (
|
||||
<div className="p-0 mx-0 my-2" data-drag-handle ref={imageComponentRef}>
|
||||
{(isUploaded || imageFromFileSystem) && !failedToLoadImage ? (
|
||||
<CustomImageBlock
|
||||
imageFromFileSystem={imageFromFileSystem}
|
||||
editorContainer={editorContainer}
|
||||
editor={editor}
|
||||
getPos={getPos}
|
||||
node={node}
|
||||
updateAttributes={updateAttributes}
|
||||
setEditorContainer={setEditorContainer}
|
||||
setFailedToLoadImage={setFailedToLoadImage}
|
||||
selected={selected}
|
||||
updateAttributes={updateAttributes}
|
||||
/>
|
||||
) : (
|
||||
<CustomImageUploader
|
||||
onUpload={onUpload}
|
||||
editor={editor}
|
||||
fileInputRef={fileInputRef}
|
||||
existingFile={existingFile}
|
||||
failedToLoadImage={failedToLoadImage}
|
||||
getPos={getPos}
|
||||
loadImageFromFileSystem={setImageFromFileSystem}
|
||||
node={node}
|
||||
setIsUploaded={setIsUploaded}
|
||||
selected={selected}
|
||||
updateAttributes={updateAttributes}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,89 +1,166 @@
|
||||
import { ChangeEvent, useCallback, useEffect, useRef } from "react";
|
||||
import { ChangeEvent, useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { Node as ProsemirrorNode } from "@tiptap/pm/model";
|
||||
import { Editor } from "@tiptap/core";
|
||||
import { ImageIcon } from "lucide-react";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common";
|
||||
// hooks
|
||||
import { useUploader, useFileUpload, useDropZone } from "@/hooks/use-file-upload";
|
||||
// plugins
|
||||
import { isFileValid } from "@/plugins/image";
|
||||
|
||||
type RefType = React.RefObject<HTMLInputElement> | ((instance: HTMLInputElement | null) => void);
|
||||
|
||||
const assignRef = (ref: RefType, value: HTMLInputElement | null) => {
|
||||
if (typeof ref === "function") {
|
||||
ref(value);
|
||||
} else if (ref && typeof ref === "object") {
|
||||
(ref as React.MutableRefObject<HTMLInputElement | null>).current = value;
|
||||
}
|
||||
};
|
||||
import { useUploader, useDropZone, uploadFirstImageAndInsertRemaining } from "@/hooks/use-file-upload";
|
||||
// extensions
|
||||
import { getImageComponentImageFileMap, ImageAttributes } from "@/extensions/custom-image";
|
||||
|
||||
export const CustomImageUploader = (props: {
|
||||
onUpload: (url: string) => void;
|
||||
failedToLoadImage: boolean;
|
||||
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, 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 {
|
||||
selected,
|
||||
failedToLoadImage,
|
||||
editor,
|
||||
loadImageFromFileSystem,
|
||||
node,
|
||||
setIsUploaded,
|
||||
updateAttributes,
|
||||
getPos,
|
||||
} = props;
|
||||
// ref
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const localRef = useRef<HTMLInputElement | null>(null);
|
||||
const hasTriggeredFilePickerRef = useRef(false);
|
||||
const imageEntityId = node.attrs.id;
|
||||
|
||||
const onFileChange = useCallback(
|
||||
(e: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
if (isFileValid(file)) {
|
||||
uploadFile(file);
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[uploadFile]
|
||||
[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]
|
||||
);
|
||||
|
||||
// after the image component is mounted we start the upload process based on
|
||||
// it's uploaded
|
||||
useEffect(() => {
|
||||
// no need to validate as the file is already validated before the drop onto
|
||||
// the editor
|
||||
if (existingFile) {
|
||||
uploadFile(existingFile);
|
||||
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 });
|
||||
}
|
||||
}
|
||||
}, [existingFile, uploadFile]);
|
||||
}, [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]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"image-upload-component flex items-center justify-start gap-2 py-3 px-2 rounded-lg text-custom-text-300 hover:text-custom-text-200 bg-custom-background-90 hover:bg-custom-background-80 border border-dashed border-custom-border-300 cursor-pointer transition-all duration-200 ease-in-out",
|
||||
"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",
|
||||
{
|
||||
"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": selected,
|
||||
"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,
|
||||
}
|
||||
)}
|
||||
onDrop={onDrop}
|
||||
onDragOver={onDragEnter}
|
||||
onDragLeave={onDragLeave}
|
||||
contentEditable={false}
|
||||
onClick={handleUploadClick}
|
||||
onClick={() => {
|
||||
if (!failedToLoadImage && editor.isEditable) {
|
||||
fileInputRef.current?.click();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ImageIcon className="size-4" />
|
||||
<div className="text-base font-medium">
|
||||
{loading ? "Uploading..." : draggedInside ? "Drop image here" : existingFile ? "Uploading..." : "Add an image"}
|
||||
</div>
|
||||
<div className="text-base font-medium">{getDisplayMessage()}</div>
|
||||
<input
|
||||
className="size-0 overflow-hidden"
|
||||
ref={(element) => {
|
||||
localRef.current = element;
|
||||
assignRef(fileInputRef, element);
|
||||
assignRef(internalRef as RefType, element);
|
||||
}}
|
||||
ref={fileInputRef}
|
||||
hidden
|
||||
type="file"
|
||||
accept=".jpg,.jpeg,.png,.webp"
|
||||
onChange={onFileChange}
|
||||
multiple
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from "./toolbar";
|
||||
export * from "./image-block";
|
||||
export * from "./image-node";
|
||||
export * from "./image-uploader";
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { ExternalLink, Maximize, Minus, Plus, X } from "lucide-react";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common";
|
||||
|
||||
type Props = {
|
||||
image: {
|
||||
src: string;
|
||||
height: string;
|
||||
width: string;
|
||||
aspectRatio: number;
|
||||
};
|
||||
isOpen: boolean;
|
||||
toggleFullScreenMode: (val: boolean) => void;
|
||||
};
|
||||
|
||||
const MAGNIFICATION_VALUES = [0.5, 0.75, 1, 1.5, 1.75, 2];
|
||||
|
||||
export const ImageFullScreenAction: React.FC<Props> = (props) => {
|
||||
const { image, isOpen: isFullScreenEnabled, toggleFullScreenMode } = props;
|
||||
const { src, width, aspectRatio } = image;
|
||||
// states
|
||||
const [magnification, setMagnification] = useState(1);
|
||||
// refs
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
// derived values
|
||||
const widthInNumber = useMemo(() => Number(width?.replace("px", "")), [width]);
|
||||
// close handler
|
||||
const handleClose = useCallback(() => {
|
||||
toggleFullScreenMode(false);
|
||||
setTimeout(() => {
|
||||
setMagnification(1);
|
||||
}, 200);
|
||||
}, [toggleFullScreenMode]);
|
||||
// download handler
|
||||
const handleOpenInNewTab = () => {
|
||||
const link = document.createElement("a");
|
||||
link.href = src;
|
||||
link.target = "_blank";
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
};
|
||||
// magnification decrease handler
|
||||
const handleDecreaseMagnification = useCallback(() => {
|
||||
const currentIndex = MAGNIFICATION_VALUES.indexOf(magnification);
|
||||
if (currentIndex === 0) return;
|
||||
setMagnification(MAGNIFICATION_VALUES[currentIndex - 1]);
|
||||
}, [magnification]);
|
||||
// magnification increase handler
|
||||
const handleIncreaseMagnification = useCallback(() => {
|
||||
const currentIndex = MAGNIFICATION_VALUES.indexOf(magnification);
|
||||
if (currentIndex === MAGNIFICATION_VALUES.length - 1) return;
|
||||
setMagnification(MAGNIFICATION_VALUES[currentIndex + 1]);
|
||||
}, [magnification]);
|
||||
// keydown handler
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" || e.key === "+" || e.key === "=" || e.key === "-") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (e.key === "Escape") handleClose();
|
||||
if (e.key === "+" || e.key === "=") handleIncreaseMagnification();
|
||||
if (e.key === "-") handleDecreaseMagnification();
|
||||
}
|
||||
},
|
||||
[handleClose, handleDecreaseMagnification, handleIncreaseMagnification]
|
||||
);
|
||||
// click outside handler
|
||||
const handleClickOutside = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
|
||||
if (modalRef.current && e.target === modalRef.current) {
|
||||
handleClose();
|
||||
}
|
||||
},
|
||||
[handleClose]
|
||||
);
|
||||
// register keydown listener
|
||||
useEffect(() => {
|
||||
if (isFullScreenEnabled) {
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}
|
||||
}, [handleKeyDown, isFullScreenEnabled]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
"fixed inset-0 size-full z-20 bg-black/90 opacity-0 pointer-events-none cursor-default transition-opacity",
|
||||
{
|
||||
"opacity-100 pointer-events-auto": isFullScreenEnabled,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<div ref={modalRef} onClick={handleClickOutside} className="relative size-full grid place-items-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
className="absolute top-10 right-10 size-8 grid place-items-center"
|
||||
>
|
||||
<X className="size-8 text-white/60 hover:text-white transition-colors" />
|
||||
</button>
|
||||
<img
|
||||
src={src}
|
||||
className="read-only-image rounded-lg transition-all duration-200"
|
||||
style={{
|
||||
width: `${widthInNumber * magnification}px`,
|
||||
aspectRatio,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="fixed bottom-10 left-1/2 -translate-x-1/2 flex items-center justify-center gap-1 rounded-md border border-white/20 py-2 divide-x divide-white/20 bg-black">
|
||||
<div className="flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDecreaseMagnification}
|
||||
className="size-6 grid place-items-center text-white/60 hover:text-white disabled:text-white/30 transition-colors duration-200"
|
||||
disabled={magnification === MAGNIFICATION_VALUES[0]}
|
||||
>
|
||||
<Minus className="size-4" />
|
||||
</button>
|
||||
<span className="text-sm w-12 text-center text-white">{(100 * magnification).toFixed(0)}%</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleIncreaseMagnification}
|
||||
className="size-6 grid place-items-center text-white/60 hover:text-white disabled:text-white/30 transition-colors duration-200"
|
||||
disabled={magnification === MAGNIFICATION_VALUES[MAGNIFICATION_VALUES.length - 1]}
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpenInNewTab}
|
||||
className="flex-shrink-0 size-8 grid place-items-center text-white/60 hover:text-white transition-colors duration-200"
|
||||
>
|
||||
<ExternalLink className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
toggleFullScreenMode(true);
|
||||
}}
|
||||
className="size-5 grid place-items-center hover:bg-black/40 text-white rounded transition-colors"
|
||||
>
|
||||
<Maximize className="size-3" />
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./root";
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useState } from "react";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common";
|
||||
// components
|
||||
import { ImageFullScreenAction } from "./full-screen";
|
||||
|
||||
type Props = {
|
||||
containerClassName?: string;
|
||||
image: {
|
||||
src: string;
|
||||
height: string;
|
||||
width: string;
|
||||
aspectRatio: number;
|
||||
};
|
||||
};
|
||||
|
||||
export const ImageToolbarRoot: React.FC<Props> = (props) => {
|
||||
const { containerClassName, image } = props;
|
||||
// state
|
||||
const [isFullScreenEnabled, setIsFullScreenEnabled] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cn(containerClassName, {
|
||||
"opacity-100 pointer-events-auto": isFullScreenEnabled,
|
||||
})}
|
||||
>
|
||||
<ImageFullScreenAction
|
||||
image={image}
|
||||
isOpen={isFullScreenEnabled}
|
||||
toggleFullScreenMode={(val) => setIsFullScreenEnabled(val)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user