Compare commits

...
Author SHA1 Message Date
akshat5302 205437921b fix changes 2025-04-01 13:55:57 +05:30
akshat5302 0e4c40f5f1 fix: seprated docker compose and swarm compose files 2025-04-01 13:16:40 +05:30
Anmol Singh BhatiaandGitHub 921dfe3222 [WEB-3704] chore: work item store optimization and code refactor (#6846)
* chore: work item store optimization and code refactor

* chore: code refactor
2025-03-28 18:38:44 +05:30
Anmol Singh BhatiaandGitHub 8216785b27 [WEB-3704] fix: sub work item #6844 2025-03-28 17:02:17 +05:30
sriram veeraghanta 2bfe4d6a6e fix: tsup version upgrade 2025-03-28 15:52:56 +05:30
Prateek ShouryaandGitHub 691cbef1f2 [WEB-3701] fix: use getCycleById to ensure null handling for cycle access (#6838)
* [WEB-3701] fix: use `getCycleById` to ensure null handling for cycle access

* fix: cycle sidebar storage values
2025-03-28 15:12:40 +05:30
Prateek ShouryaandGitHub fed0ef6185 [WEB-3705] improvement: clear local db on version change (#6843)
* [WEB-3705] improvement: clear local db on version change

* chore: remove console.log
2025-03-28 15:12:03 +05:30
Akshita GoyalandGitHub e8779511ad [WEB-3673] fix: password change form (#6839)
* fix: change password

* fix: added store action for change password

* fix: type

* fix: store refactor
2025-03-28 13:35:42 +05:30
Anmol Singh BhatiaandGitHub 99dba80d19 [WEB-3540] dev: color picker component (#6823)
* dev: color picker component added

* chore: helper function added

* chore: code refactor

* chore: code refactor

* chore: code refactor

* chore: code refactor
2025-03-27 17:48:39 +05:30
Anmol Singh BhatiaandGitHub 471fefce8b [WEB-3697] chore: chart components (#6835) 2025-03-27 17:46:43 +05:30
869c755065 [WEB-3698] fix: comments refactor (#6759)
* fix: comments refactor

* fix: add edited at

* chore: add edited_at validation at issue comment update

* fix: comment mentions

* fix: edited at

* fix: css

* fix: added bulk asset upload api

* fix: projectId prop fixed

* fix: css

* fix: refactor

* fix: translation

---------

Co-authored-by: pablohashescobar <nikhilschacko@gmail.com>
2025-03-27 17:28:52 +05:30
Vamsi KrishnaandGitHub a5ffbffed9 [WEB-3694] feat: added dates display in user timezone in analytics sidebar (#6834) 2025-03-27 17:18:09 +05:30
Dheeraj Kumar KetireddyandGitHub 784d651c5b fix: Removed hardcoded timezone offsets and reduced the cache to 2 hours (#6837) 2025-03-27 16:53:20 +05:30
DanciaandGitHub b19bca3b50 docs: updated links in README.md file (#6833) 2025-03-27 15:11:43 +05:30
Vamsi KrishnaandGitHub 1121c58ada fix: label update for date dropdown (#6832) 2025-03-27 13:52:30 +05:30
sriram veeraghanta fb2987e9ef chore: updated gitignore 2025-03-27 12:53:29 +05:30
Aaryan KhandelwalandGitHub a25cd426a9 style: page editor width and layout updates (#6826) 2025-03-26 21:10:44 +05:30
M. PalanikannanandGitHub 993713925a feat: express decorators for rest apis and websocket (#6818)
* feat: express decorators for rest apis and websocket

* fix: added package dependency

* fix: refactor decorators
2025-03-26 20:24:05 +05:30
Vamsi KrishnaandGitHub ae6e5a48fa [WEB-3681]feat: added user timezone dates for cycle (#6820)
* feat: added user timezone dates for cycle

* *chore: added translations
*chore: refactored user timezone functions
2025-03-26 20:23:19 +05:30
c125bc54ba [WEB-3686] feat: romanian and indonesian language support (#6825)
* Add ro Romanian Language locale (#6809)

* feat: add Indonesian language support (#6794)

Co-authored-by: Anmol Singh Bhatia <121005188+anmolsinghbhatia@users.noreply.github.com>

* chore: core translation added and code refactor

---------

Co-authored-by: mnbro <107358316+mnbro@users.noreply.github.com>
Co-authored-by: Rasyid Ridho <rasyid@sekeco.id>
2025-03-26 20:10:20 +05:30
41447e566a [WEB-3600] fix: private project join issue (#6799)
* fix: private project join issue

* chore: return network value

* fix: refactor

* fix: refactor

* fix: type

* chore: added restricition for private projects

* chore: removed extra validations

* chore: added value to access enum

---------

Co-authored-by: sangeethailango <sangeethailango21@gmail.com>
Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
2025-03-25 20:17:16 +05:30
Prateek ShouryaandGitHub cebd0b3599 [RANTS-68] fix: z-index for image picker popover button (change project cover) (#6812) 2025-03-25 20:16:06 +05:30
Prateek ShouryaandGitHub caa522118d [RANTS-29] fix: enter key does not work in the workspace member invite modal (#6816) 2025-03-25 20:15:35 +05:30
Prateek ShouryaandGitHub aea5f39059 [RANTS-31] improvement: optimistic update for home widget reordering (#6817) 2025-03-25 20:15:00 +05:30
sriram veeraghanta f29867968a chore: removed sentry instrumentation dependencies 2025-03-25 15:54:44 +05:30
sriram veeraghanta c91972cc0a chore: removing sentry instrumentation 2025-03-25 15:45:31 +05:30
Anmol Singh BhatiaandGitHub 84c7375d2a [WEB-3601] chore: content updated (#6811) 2025-03-24 19:57:13 +05:30
Anmol Singh BhatiaandGitHub 5cb37a0b9c [WEB-3560] fix: table layout issue block and code refactor (#6805) 2025-03-24 19:06:36 +05:30
Anmol Singh BhatiaandGitHub c347dd7dcd [WEB-3614] chore: list layout display filters (#6801) 2025-03-24 19:05:53 +05:30
sriram veeraghanta 0ec206b75d fix: transpile packages update on space and admin apps 2025-03-24 18:55:59 +05:30
Vamsi KrishnaandGitHub e8718a84fe chore: issue detail refactor (#6803) 2025-03-24 18:33:22 +05:30
Anmol Singh BhatiaandGitHub 983e0fa081 [WEB-3438] fix: transfer completed cycle issue modal (#6802) 2025-03-24 18:30:31 +05:30
Aaryan KhandelwalandGitHub ef108839c4 [RANTS-57] chore: replace target date with due date in work item filters dropdown (#6806) 2025-03-24 18:24:10 +05:30
Bavisetti NarayanandGitHub fe04e5a292 [WEB-3658] fix: remove cycles and modules when issues are bulk deleted (#6807) 2025-03-24 18:23:09 +05:30
Aaryan KhandelwalandGitHub 50e0cb7ffd [RANTS-75] chore: update profile sidebar icons and copy for consistency (#6808) 2025-03-24 18:21:12 +05:30
Akshita GoyalandGitHub d37d210921 [WEB-3677] fix: settings dynamic pages permissions (#6804)
* fix: settings dynamic pages permissions

* fix: refactor
2025-03-24 18:15:43 +05:30
Anmol Singh BhatiaandGitHub ab3eadf767 [WEB-3614] fix: cmd-k item focus state (#6800) 2025-03-24 18:13:49 +05:30
sriram veeraghanta dbdf2f001a fix: transpile packages for web application 2025-03-24 13:47:00 +05:30
Prateek ShouryaandGitHub 0d069bf46e [RANTS-65] fix: undefined work item sequence in bulk delete work item modal (#6797) 2025-03-24 13:41:02 +05:30
Prateek ShouryaandGitHub 962923ff4f fix: admin build (#6798) 2025-03-24 13:40:07 +05:30
Samuel TorresandGitHub f720a9afb2 feat: validate github organization during OAuth login (#6700)
* feat: add GITHUB_ORGANIZATION_ID support for GitHub OAuth integration

* fix: remove debug print statements from InstanceConfigurationEndpoint
2025-03-24 12:55:20 +05:30
4032aa62c5 [WEB-3551] fix: role improvements (#6763)
* Return Cycle start and end dates in project's timezone

* fix: role improvements

* chore: role updates

* chore: update role endpoint to update workspace admin permissions

* fix: conditions

* chore: update member role for workspace members

* chore: update workspace permission role

* fix: currentAdmin permissions

---------

Co-authored-by: Dheeraj Kumar Ketireddy <dheeru0198@gmail.com>
Co-authored-by: pablohashescobar <nikhilschacko@gmail.com>
2025-03-24 12:52:57 +05:30
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
cbe248591e chore(deps): bump next in the npm_and_yarn group across 1 directory (#6796)
Bumps the npm_and_yarn group with 1 update in the / directory: [next](https://github.com/vercel/next.js).


Updates `next` from 14.2.24 to 14.2.25
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/compare/v14.2.24...v14.2.25)

---
updated-dependencies:
- dependency-name: next
  dependency-type: direct:production
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-24 12:52:16 +05:30
Dheeraj Kumar KetireddyandGitHub 75a9b71edb [WEB-3513] fix: return cycle start and end dates in project's timezone 2025-03-24 12:51:44 +05:30
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
ef42ce04a4 chore(deps): bump gunicorn (#6793)
Bumps the pip group with 1 update in the /apiserver/requirements directory: [gunicorn](https://github.com/benoitc/gunicorn).


Updates `gunicorn` from 22.0.0 to 23.0.0
- [Release notes](https://github.com/benoitc/gunicorn/releases)
- [Commits](https://github.com/benoitc/gunicorn/compare/22.0.0...23.0.0)

---
updated-dependencies:
- dependency-name: gunicorn
  dependency-type: direct:production
  dependency-group: pip
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-24 12:48:05 +05:30
6bafdb6dd8 [PE-298] Fix: Copy markdown to clipboard (#6675)
* fix: markdown for mentions fixed

* fix: copying text in mentions

* fix: refactored the component to use the same function

* chore: renamed funcion name

* add the new copy extension

* init working fix

* remove useless code

* improve readibility

* update node import

* better smaller logic

* remove log

* add open close end handler

* update readabliity

* handle tables

* handle triple click in cell

* triple tap select current line

* handle block and list

* lists fixed

* handle all possible cases of copy in table

* update the min elements

* handle multi types in table

* handle table seletion cases

* handle whole table handler

* feat: all case converd

* update markdown handling code

* update return statement

* handle using group block

* handle param

* handle multple cell in table

* handle using recursion

* add types

* fix code rabbit  suggestions

* fix root node bug

* update recursion with loop

* update transform copied to false

* refactor clipboard extension: remove options and integrate MarkdownClipboard into core extensions

* fix: header and code handler

* fix: store hooks fixed

* fix: mention id

---------

Co-authored-by: Palanikannan M <akashmalinimurugu@gmail.com>
2025-03-24 12:32:11 +05:30
sriram veeraghanta 2eb1d03c20 fix: transpile and optimize package imports 2025-03-21 01:51:50 +05:30
206 changed files with 8674 additions and 2780 deletions
+1
View File
@@ -291,4 +291,5 @@ jobs:
${{ github.workspace }}/deploy/selfhost/swarm.sh
${{ github.workspace }}/deploy/selfhost/restore.sh
${{ github.workspace }}/deploy/selfhost/docker-compose.yml
${{ github.workspace }}/deploy/selfhost/swarm-compose.yml
${{ github.workspace }}/deploy/selfhost/variables.env
+6
View File
@@ -78,10 +78,16 @@ pnpm-workspace.yaml
.npmrc
.secrets
tmp/
## packages
dist
.temp/
deploy/selfhost/plane-app/
## Storybook
*storybook.log
output.css
# Redis
*.rdb
*.rdb.gz
-3
View File
@@ -43,9 +43,6 @@ NGINX_PORT=80
# Debug value for api server use it as 0 for production use
DEBUG=0
CORS_ALLOWED_ORIGINS="http://localhost"
# Error logs
SENTRY_DSN=""
SENTRY_ENVIRONMENT="development"
# Database Settings
POSTGRES_USER="plane"
POSTGRES_PASSWORD="plane"
+5 -5
View File
@@ -16,10 +16,10 @@
</p>
<p align="center">
<a href="https://dub.sh/plane-website-readme"><b>Website</b></a> •
<a href="https://git.new/releases"><b>Releases</b></a> •
<a href="https://dub.sh/planepowershq"><b>Twitter</b></a> •
<a href="https://dub.sh/planedocs"><b>Documentation</b></a>
<a href="https://plane.so/"><b>Website</b></a> •
<a href="https://github.com/makeplane/plane/releases"><b>Releases</b></a> •
<a href="https://twitter.com/planepowers"><b>Twitter</b></a> •
<a href="https://docs.plane.so/"><b>Documentation</b></a>
</p>
<p>
@@ -39,7 +39,7 @@
</a>
</p>
Meet [Plane](https://dub.sh/plane-website-readme), an open-source project management tool to track issues, run ~sprints~ cycles, and manage product roadmaps without the chaos of managing the tool itself. 🧘‍♀️
Meet [Plane](https://plane.so/), an open-source project management tool to track issues, run ~sprints~ cycles, and manage product roadmaps without the chaos of managing the tool itself. 🧘‍♀️
> Plane is evolving every day. Your suggestions, ideas, and reported bugs help us immensely. Do not hesitate to join in the conversation on [Discord](https://discord.com/invite/A92xrEGCge) or raise a GitHub issue. We read everything and respond to most.
+15
View File
@@ -43,6 +43,7 @@ export const InstanceGithubConfigForm: FC<Props> = (props) => {
defaultValues: {
GITHUB_CLIENT_ID: config["GITHUB_CLIENT_ID"],
GITHUB_CLIENT_SECRET: config["GITHUB_CLIENT_SECRET"],
GITHUB_ORGANIZATION_ID: config["GITHUB_ORGANIZATION_ID"],
},
});
@@ -93,6 +94,19 @@ export const InstanceGithubConfigForm: FC<Props> = (props) => {
error: Boolean(errors.GITHUB_CLIENT_SECRET),
required: true,
},
{
key: "GITHUB_ORGANIZATION_ID",
type: "text",
label: "Organization ID",
description: (
<>
The organization github ID.
</>
),
placeholder: "123456789",
error: Boolean(errors.GITHUB_ORGANIZATION_ID),
required: false,
},
];
const GITHUB_SERVICE_FIELD: TCopyField[] = [
@@ -150,6 +164,7 @@ export const InstanceGithubConfigForm: FC<Props> = (props) => {
reset({
GITHUB_CLIENT_ID: response.find((item) => item.key === "GITHUB_CLIENT_ID")?.value,
GITHUB_CLIENT_SECRET: response.find((item) => item.key === "GITHUB_CLIENT_SECRET")?.value,
GITHUB_ORGANIZATION_ID: response.find((item) => item.key === "GITHUB_ORGANIZATION_ID")?.value,
});
})
.catch((err) => console.error(err));
+13
View File
@@ -9,6 +9,19 @@ const nextConfig = {
unoptimized: true,
},
basePath: process.env.NEXT_PUBLIC_ADMIN_BASE_PATH || "",
transpilePackages: [
"@plane/constants",
"@plane/editor",
"@plane/hooks",
"@plane/i18n",
"@plane/logger",
"@plane/propel",
"@plane/services",
"@plane/shared-state",
"@plane/types",
"@plane/ui",
"@plane/utils",
],
};
module.exports = nextConfig;
+1 -2
View File
@@ -21,7 +21,6 @@
"@plane/ui": "*",
"@plane/utils": "*",
"@plane/services": "*",
"@sentry/nextjs": "^8.54.0",
"@tailwindcss/typography": "^0.5.9",
"@types/lodash": "^4.17.0",
"autoprefixer": "10.4.14",
@@ -30,7 +29,7 @@
"lucide-react": "^0.469.0",
"mobx": "^6.12.0",
"mobx-react": "^9.1.1",
"next": "^14.2.20",
"next": "^14.2.25",
"next-themes": "^0.2.1",
"postcss": "^8.4.38",
"react": "^18.3.1",
-3
View File
@@ -145,11 +145,8 @@ RUN chmod +x /app/pg-setup.sh
# APPLICATION ENVIRONMENT SETTINGS
# *****************************************************************************
ENV APP_DOMAIN=localhost
ENV WEB_URL=http://${APP_DOMAIN}
ENV DEBUG=0
ENV SENTRY_DSN=
ENV SENTRY_ENVIRONMENT=production
ENV CORS_ALLOWED_ORIGINS=http://${APP_DOMAIN},https://${APP_DOMAIN}
# Secret Key
ENV SECRET_KEY=60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5
-4
View File
@@ -3,10 +3,6 @@
DEBUG=0
CORS_ALLOWED_ORIGINS="http://localhost"
# Error logs
SENTRY_DSN=""
SENTRY_ENVIRONMENT="development"
# Database Settings
POSTGRES_USER="plane"
POSTGRES_PASSWORD="plane"
View File
+9
View File
@@ -1,4 +1,5 @@
# Third party imports
import pytz
from rest_framework import serializers
# Module imports
@@ -18,6 +19,14 @@ class CycleSerializer(BaseSerializer):
completed_estimates = serializers.FloatField(read_only=True)
started_estimates = serializers.FloatField(read_only=True)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
project = self.context.get("project")
if project and project.timezone:
project_timezone = pytz.timezone(project.timezone)
self.fields["start_date"].timezone = project_timezone
self.fields["end_date"].timezone = project_timezone
def validate(self, data):
if (
data.get("start_date", None) is not None
+15 -7
View File
@@ -137,10 +137,12 @@ class CycleAPIEndpoint(BaseAPIView):
)
def get(self, request, slug, project_id, pk=None):
project = Project.objects.get(workspace__slug=slug, pk=project_id)
if pk:
queryset = self.get_queryset().filter(archived_at__isnull=True).get(pk=pk)
data = CycleSerializer(
queryset, fields=self.fields, expand=self.expand
queryset, fields=self.fields,
expand=self.expand, context={"project": project}
).data
return Response(data, status=status.HTTP_200_OK)
queryset = self.get_queryset().filter(archived_at__isnull=True)
@@ -152,7 +154,8 @@ class CycleAPIEndpoint(BaseAPIView):
start_date__lte=timezone.now(), end_date__gte=timezone.now()
)
data = CycleSerializer(
queryset, many=True, fields=self.fields, expand=self.expand
queryset, many=True, fields=self.fields,
expand=self.expand, context={"project": project}
).data
return Response(data, status=status.HTTP_200_OK)
@@ -163,7 +166,8 @@ class CycleAPIEndpoint(BaseAPIView):
request=request,
queryset=(queryset),
on_results=lambda cycles: CycleSerializer(
cycles, many=True, fields=self.fields, expand=self.expand
cycles, many=True, fields=self.fields,
expand=self.expand, context={"project": project}
).data,
)
@@ -174,7 +178,8 @@ class CycleAPIEndpoint(BaseAPIView):
request=request,
queryset=(queryset),
on_results=lambda cycles: CycleSerializer(
cycles, many=True, fields=self.fields, expand=self.expand
cycles, many=True, fields=self.fields,
expand=self.expand, context={"project": project}
).data,
)
@@ -185,7 +190,8 @@ class CycleAPIEndpoint(BaseAPIView):
request=request,
queryset=(queryset),
on_results=lambda cycles: CycleSerializer(
cycles, many=True, fields=self.fields, expand=self.expand
cycles, many=True, fields=self.fields,
expand=self.expand, context={"project": project}
).data,
)
@@ -198,14 +204,16 @@ class CycleAPIEndpoint(BaseAPIView):
request=request,
queryset=(queryset),
on_results=lambda cycles: CycleSerializer(
cycles, many=True, fields=self.fields, expand=self.expand
cycles, many=True, fields=self.fields,
expand=self.expand, context={"project": project}
).data,
)
return self.paginate(
request=request,
queryset=(queryset),
on_results=lambda cycles: CycleSerializer(
cycles, many=True, fields=self.fields, expand=self.expand
cycles, many=True, fields=self.fields,
expand=self.expand, context={"project": project}
).data,
)
+15 -6
View File
@@ -268,7 +268,7 @@ class CycleViewSet(BaseViewSet):
)
datetime_fields = ["start_date", "end_date"]
data = user_timezone_converter(
data, datetime_fields, request.user.user_timezone
data, datetime_fields, project_timezone
)
return Response(data, status=status.HTTP_200_OK)
@@ -318,9 +318,13 @@ class CycleViewSet(BaseViewSet):
.first()
)
# Fetch the project timezone
project = Project.objects.get(id=self.kwargs.get("project_id"))
project_timezone = project.timezone
datetime_fields = ["start_date", "end_date"]
cycle = user_timezone_converter(
cycle, datetime_fields, request.user.user_timezone
cycle, datetime_fields, project_timezone
)
# Send the model activity
@@ -407,9 +411,13 @@ class CycleViewSet(BaseViewSet):
"created_by",
).first()
# Fetch the project timezone
project = Project.objects.get(id=self.kwargs.get("project_id"))
project_timezone = project.timezone
datetime_fields = ["start_date", "end_date"]
cycle = user_timezone_converter(
cycle, datetime_fields, request.user.user_timezone
cycle, datetime_fields, project_timezone
)
# Send the model activity
@@ -480,10 +488,11 @@ class CycleViewSet(BaseViewSet):
)
queryset = queryset.first()
# Fetch the project timezone
project = Project.objects.get(id=self.kwargs.get("project_id"))
project_timezone = project.timezone
datetime_fields = ["start_date", "end_date"]
data = user_timezone_converter(
data, datetime_fields, request.user.user_timezone
)
data = user_timezone_converter(data, datetime_fields, project_timezone)
recent_visited_task.delay(
slug=slug,
+8
View File
@@ -45,6 +45,7 @@ from plane.db.models import (
ProjectMember,
CycleIssue,
UserRecentVisit,
ModuleIssue,
)
from plane.utils.grouper import (
issue_group_values,
@@ -738,6 +739,13 @@ class BulkDeleteIssuesEndpoint(BaseAPIView):
total_issues = len(issues)
# First, delete all related cycle issues
CycleIssue.objects.filter(issue_id__in=issue_ids).delete()
# Then, delete all related module issues
ModuleIssue.objects.filter(issue_id__in=issue_ids).delete()
# Finally, delete the issues themselves
issues.delete()
return Response(
+7 -1
View File
@@ -105,7 +105,13 @@ class IssueCommentViewSet(BaseViewSet):
issue_comment, data=request.data, partial=True
)
if serializer.is_valid():
serializer.save()
if (
"comment_html" in request.data
and request.data["comment_html"] != issue_comment.comment_html
):
serializer.save(edited_at=timezone.now())
else:
serializer.save()
issue_activity.delay(
type="comment.activity.updated",
requested_data=requested_data,
@@ -179,6 +179,7 @@ class ProjectViewSet(BaseViewSet):
"inbox_view",
"guest_view_all_features",
"project_lead",
"network",
"created_at",
"updated_at",
"created_by",
+17 -7
View File
@@ -16,17 +16,17 @@ from rest_framework.permissions import AllowAny
# Module imports
from .base import BaseViewSet, BaseAPIView
from plane.app.serializers import ProjectMemberInviteSerializer
from plane.app.permissions import allow_permission, ROLE
from plane.db.models import (
ProjectMember,
Workspace,
ProjectMemberInvite,
User,
WorkspaceMember,
Project,
IssueUserProperty,
)
from plane.db.models.project import ProjectNetwork
class ProjectInvitationsViewset(BaseViewSet):
@@ -128,6 +128,7 @@ class UserProjectInvitationsViewset(BaseViewSet):
.select_related("workspace", "workspace__owner", "project")
)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
def create(self, request, slug):
project_ids = request.data.get("project_ids", [])
@@ -136,11 +137,20 @@ class UserProjectInvitationsViewset(BaseViewSet):
member=request.user, workspace__slug=slug, is_active=True
)
if workspace_member.role not in [ROLE.ADMIN.value, ROLE.MEMBER.value]:
return Response(
{"error": "You do not have permission to join the project"},
status=status.HTTP_403_FORBIDDEN,
)
# Get all the projects
projects = Project.objects.filter(
id__in=project_ids, workspace__slug=slug
).only("id", "network")
# Check if user has permission to join each project
for project in projects:
if (
project.network == ProjectNetwork.SECRET.value
and workspace_member.role != ROLE.ADMIN.value
):
return Response(
{"error": "Only workspace admins can join private project"},
status=status.HTTP_403_FORBIDDEN,
)
workspace_role = workspace_member.role
workspace = workspace_member.workspace
+12 -18
View File
@@ -10,11 +10,7 @@ from plane.app.serializers import (
ProjectMemberRoleSerializer,
)
from plane.app.permissions import (
ProjectMemberPermission,
ProjectLitePermission,
WorkspaceUserPermission,
)
from plane.app.permissions import WorkspaceUserPermission
from plane.db.models import Project, ProjectMember, IssueUserProperty, WorkspaceMember
from plane.bgtasks.project_add_user_email_task import project_add_user_email
@@ -26,14 +22,6 @@ class ProjectMemberViewSet(BaseViewSet):
serializer_class = ProjectMemberAdminSerializer
model = ProjectMember
def get_permissions(self):
if self.action == "leave":
self.permission_classes = [ProjectLitePermission]
else:
self.permission_classes = [ProjectMemberPermission]
return super(ProjectMemberViewSet, self).get_permissions()
search_fields = ["member__display_name", "member__first_name"]
def get_queryset(self):
@@ -187,12 +175,20 @@ class ProjectMemberViewSet(BaseViewSet):
)
return Response(serializer.data, status=status.HTTP_200_OK)
@allow_permission([ROLE.ADMIN])
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def partial_update(self, request, slug, project_id, pk):
project_member = ProjectMember.objects.get(
pk=pk, workspace__slug=slug, project_id=project_id, is_active=True
)
if request.user.id == project_member.member_id:
# Fetch the workspace role of the project member
workspace_role = WorkspaceMember.objects.get(
workspace__slug=slug, member=project_member.member, is_active=True
).role
is_workspace_admin = workspace_role == ROLE.ADMIN.value
# Check if the user is not editing their own role if they are not an admin
if request.user.id == project_member.member_id and not is_workspace_admin:
return Response(
{"error": "You cannot update your own role"},
status=status.HTTP_400_BAD_REQUEST,
@@ -205,9 +201,6 @@ class ProjectMemberViewSet(BaseViewSet):
is_active=True,
)
workspace_role = WorkspaceMember.objects.get(
workspace__slug=slug, member=project_member.member, is_active=True
).role
if workspace_role in [5] and int(
request.data.get("role", project_member.role)
) in [15, 20]:
@@ -222,6 +215,7 @@ class ProjectMemberViewSet(BaseViewSet):
"role" in request.data
and int(request.data.get("role", project_member.role))
> requested_project_member.role
and not is_workspace_admin
):
return Response(
{"error": "You cannot update a role that is higher than your own role"},
+145 -205
View File
@@ -21,221 +21,161 @@ class TimezoneEndpoint(APIView):
throttle_classes = [AuthenticationThrottle]
@method_decorator(cache_page(60 * 60 * 24))
@method_decorator(cache_page(60 * 60 * 2))
def get(self, request):
timezone_mapping = {
"-1100": [
("Midway Island", "Pacific/Midway"),
("American Samoa", "Pacific/Pago_Pago"),
],
"-1000": [
("Hawaii", "Pacific/Honolulu"),
("Aleutian Islands", "America/Adak"),
],
"-0930": [("Marquesas Islands", "Pacific/Marquesas")],
"-0900": [
("Alaska", "America/Anchorage"),
("Gambier Islands", "Pacific/Gambier"),
],
"-0800": [
("Pacific Time (US and Canada)", "America/Los_Angeles"),
("Baja California", "America/Tijuana"),
],
"-0700": [
("Mountain Time (US and Canada)", "America/Denver"),
("Arizona", "America/Phoenix"),
("Chihuahua, Mazatlan", "America/Chihuahua"),
],
"-0600": [
("Central Time (US and Canada)", "America/Chicago"),
("Saskatchewan", "America/Regina"),
("Guadalajara, Mexico City, Monterrey", "America/Mexico_City"),
("Tegucigalpa, Honduras", "America/Tegucigalpa"),
("Costa Rica", "America/Costa_Rica"),
],
"-0500": [
("Eastern Time (US and Canada)", "America/New_York"),
("Lima", "America/Lima"),
("Bogota", "America/Bogota"),
("Quito", "America/Guayaquil"),
("Chetumal", "America/Cancun"),
],
"-0430": [("Caracas (Old Venezuela Time)", "America/Caracas")],
"-0400": [
("Atlantic Time (Canada)", "America/Halifax"),
("Caracas", "America/Caracas"),
("Santiago", "America/Santiago"),
("La Paz", "America/La_Paz"),
("Manaus", "America/Manaus"),
("Georgetown", "America/Guyana"),
("Bermuda", "Atlantic/Bermuda"),
],
"-0330": [("Newfoundland Time (Canada)", "America/St_Johns")],
"-0300": [
("Buenos Aires", "America/Argentina/Buenos_Aires"),
("Brasilia", "America/Sao_Paulo"),
("Greenland", "America/Godthab"),
("Montevideo", "America/Montevideo"),
("Falkland Islands", "Atlantic/Stanley"),
],
"-0200": [
(
"South Georgia and the South Sandwich Islands",
"Atlantic/South_Georgia",
)
],
"-0100": [
("Azores", "Atlantic/Azores"),
("Cape Verde Islands", "Atlantic/Cape_Verde"),
],
"+0000": [
("Dublin", "Europe/Dublin"),
("Reykjavik", "Atlantic/Reykjavik"),
("Lisbon", "Europe/Lisbon"),
("Monrovia", "Africa/Monrovia"),
("Casablanca", "Africa/Casablanca"),
],
"+0100": [
("Central European Time (Berlin, Rome, Paris)", "Europe/Paris"),
("West Central Africa", "Africa/Lagos"),
("Algiers", "Africa/Algiers"),
("Lagos", "Africa/Lagos"),
("Tunis", "Africa/Tunis"),
],
"+0200": [
("Eastern European Time (Cairo, Helsinki, Kyiv)", "Europe/Kiev"),
("Athens", "Europe/Athens"),
("Jerusalem", "Asia/Jerusalem"),
("Johannesburg", "Africa/Johannesburg"),
("Harare, Pretoria", "Africa/Harare"),
],
"+0300": [
("Moscow Time", "Europe/Moscow"),
("Baghdad", "Asia/Baghdad"),
("Nairobi", "Africa/Nairobi"),
("Kuwait, Riyadh", "Asia/Riyadh"),
],
"+0330": [("Tehran", "Asia/Tehran")],
"+0400": [
("Abu Dhabi", "Asia/Dubai"),
("Baku", "Asia/Baku"),
("Yerevan", "Asia/Yerevan"),
("Astrakhan", "Europe/Astrakhan"),
("Tbilisi", "Asia/Tbilisi"),
("Mauritius", "Indian/Mauritius"),
],
"+0500": [
("Islamabad", "Asia/Karachi"),
("Karachi", "Asia/Karachi"),
("Tashkent", "Asia/Tashkent"),
("Yekaterinburg", "Asia/Yekaterinburg"),
("Maldives", "Indian/Maldives"),
("Chagos", "Indian/Chagos"),
],
"+0530": [
("Chennai", "Asia/Kolkata"),
("Kolkata", "Asia/Kolkata"),
("Mumbai", "Asia/Kolkata"),
("New Delhi", "Asia/Kolkata"),
("Sri Jayawardenepura", "Asia/Colombo"),
],
"+0545": [("Kathmandu", "Asia/Kathmandu")],
"+0600": [
("Dhaka", "Asia/Dhaka"),
("Almaty", "Asia/Almaty"),
("Bishkek", "Asia/Bishkek"),
("Thimphu", "Asia/Thimphu"),
],
"+0630": [
("Yangon (Rangoon)", "Asia/Yangon"),
("Cocos Islands", "Indian/Cocos"),
],
"+0700": [
("Bangkok", "Asia/Bangkok"),
("Hanoi", "Asia/Ho_Chi_Minh"),
("Jakarta", "Asia/Jakarta"),
("Novosibirsk", "Asia/Novosibirsk"),
("Krasnoyarsk", "Asia/Krasnoyarsk"),
],
"+0800": [
("Beijing", "Asia/Shanghai"),
("Singapore", "Asia/Singapore"),
("Perth", "Australia/Perth"),
("Hong Kong", "Asia/Hong_Kong"),
("Ulaanbaatar", "Asia/Ulaanbaatar"),
("Palau", "Pacific/Palau"),
],
"+0845": [("Eucla", "Australia/Eucla")],
"+0900": [
("Tokyo", "Asia/Tokyo"),
("Seoul", "Asia/Seoul"),
("Yakutsk", "Asia/Yakutsk"),
],
"+0930": [
("Adelaide", "Australia/Adelaide"),
("Darwin", "Australia/Darwin"),
],
"+1000": [
("Sydney", "Australia/Sydney"),
("Brisbane", "Australia/Brisbane"),
("Guam", "Pacific/Guam"),
("Vladivostok", "Asia/Vladivostok"),
("Tahiti", "Pacific/Tahiti"),
],
"+1030": [("Lord Howe Island", "Australia/Lord_Howe")],
"+1100": [
("Solomon Islands", "Pacific/Guadalcanal"),
("Magadan", "Asia/Magadan"),
("Norfolk Island", "Pacific/Norfolk"),
("Bougainville Island", "Pacific/Bougainville"),
("Chokurdakh", "Asia/Srednekolymsk"),
],
"+1200": [
("Auckland", "Pacific/Auckland"),
("Wellington", "Pacific/Auckland"),
("Fiji Islands", "Pacific/Fiji"),
("Anadyr", "Asia/Anadyr"),
],
"+1245": [("Chatham Islands", "Pacific/Chatham")],
"+1300": [("Nuku'alofa", "Pacific/Tongatapu"), ("Samoa", "Pacific/Apia")],
"+1400": [("Kiritimati Island", "Pacific/Kiritimati")],
}
timezone_locations = [
('Midway Island', 'Pacific/Midway'), # UTC-11:00
('American Samoa', 'Pacific/Pago_Pago'), # UTC-11:00
('Hawaii', 'Pacific/Honolulu'), # UTC-10:00
('Aleutian Islands', 'America/Adak'), # UTC-10:00 (DST: UTC-09:00)
('Marquesas Islands', 'Pacific/Marquesas'), # UTC-09:30
('Alaska', 'America/Anchorage'), # UTC-09:00 (DST: UTC-08:00)
('Gambier Islands', 'Pacific/Gambier'), # UTC-09:00
('Pacific Time (US and Canada)', 'America/Los_Angeles'), # UTC-08:00 (DST: UTC-07:00)
('Baja California', 'America/Tijuana'), # UTC-08:00 (DST: UTC-07:00)
('Mountain Time (US and Canada)', 'America/Denver'), # UTC-07:00 (DST: UTC-06:00)
('Arizona', 'America/Phoenix'), # UTC-07:00
('Chihuahua, Mazatlan', 'America/Chihuahua'), # UTC-07:00 (DST: UTC-06:00)
('Central Time (US and Canada)', 'America/Chicago'), # UTC-06:00 (DST: UTC-05:00)
('Saskatchewan', 'America/Regina'), # UTC-06:00
('Guadalajara, Mexico City, Monterrey', 'America/Mexico_City'), # UTC-06:00 (DST: UTC-05:00)
('Tegucigalpa, Honduras', 'America/Tegucigalpa'), # UTC-06:00
('Costa Rica', 'America/Costa_Rica'), # UTC-06:00
('Eastern Time (US and Canada)', 'America/New_York'), # UTC-05:00 (DST: UTC-04:00)
('Lima', 'America/Lima'), # UTC-05:00
('Bogota', 'America/Bogota'), # UTC-05:00
('Quito', 'America/Guayaquil'), # UTC-05:00
('Chetumal', 'America/Cancun'), # UTC-05:00 (DST: UTC-04:00)
('Caracas (Old Venezuela Time)', 'America/Caracas'), # UTC-04:30
('Atlantic Time (Canada)', 'America/Halifax'), # UTC-04:00 (DST: UTC-03:00)
('Caracas', 'America/Caracas'), # UTC-04:00
('Santiago', 'America/Santiago'), # UTC-04:00 (DST: UTC-03:00)
('La Paz', 'America/La_Paz'), # UTC-04:00
('Manaus', 'America/Manaus'), # UTC-04:00
('Georgetown', 'America/Guyana'), # UTC-04:00
('Bermuda', 'Atlantic/Bermuda'), # UTC-04:00 (DST: UTC-03:00)
('Newfoundland Time (Canada)', 'America/St_Johns'), # UTC-03:30 (DST: UTC-02:30)
('Buenos Aires', 'America/Argentina/Buenos_Aires'), # UTC-03:00
('Brasilia', 'America/Sao_Paulo'), # UTC-03:00
('Greenland', 'America/Godthab'), # UTC-03:00 (DST: UTC-02:00)
('Montevideo', 'America/Montevideo'), # UTC-03:00
('Falkland Islands', 'Atlantic/Stanley'), # UTC-03:00
('South Georgia and the South Sandwich Islands', 'Atlantic/South_Georgia'), # UTC-02:00
('Azores', 'Atlantic/Azores'), # UTC-01:00 (DST: UTC+00:00)
('Cape Verde Islands', 'Atlantic/Cape_Verde'), # UTC-01:00
('Dublin', 'Europe/Dublin'), # UTC+00:00 (DST: UTC+01:00)
('Reykjavik', 'Atlantic/Reykjavik'), # UTC+00:00
('Lisbon', 'Europe/Lisbon'), # UTC+00:00 (DST: UTC+01:00)
('Monrovia', 'Africa/Monrovia'), # UTC+00:00
('Casablanca', 'Africa/Casablanca'), # UTC+00:00 (DST: UTC+01:00)
('Central European Time (Berlin, Rome, Paris)', 'Europe/Paris'), # UTC+01:00 (DST: UTC+02:00)
('West Central Africa', 'Africa/Lagos'), # UTC+01:00
('Algiers', 'Africa/Algiers'), # UTC+01:00
('Lagos', 'Africa/Lagos'), # UTC+01:00
('Tunis', 'Africa/Tunis'), # UTC+01:00
('Eastern European Time (Cairo, Helsinki, Kyiv)', 'Europe/Kiev'), # UTC+02:00 (DST: UTC+03:00)
('Athens', 'Europe/Athens'), # UTC+02:00 (DST: UTC+03:00)
('Jerusalem', 'Asia/Jerusalem'), # UTC+02:00 (DST: UTC+03:00)
('Johannesburg', 'Africa/Johannesburg'), # UTC+02:00
('Harare, Pretoria', 'Africa/Harare'), # UTC+02:00
('Moscow Time', 'Europe/Moscow'), # UTC+03:00
('Baghdad', 'Asia/Baghdad'), # UTC+03:00
('Nairobi', 'Africa/Nairobi'), # UTC+03:00
('Kuwait, Riyadh', 'Asia/Riyadh'), # UTC+03:00
('Tehran', 'Asia/Tehran'), # UTC+03:30 (DST: UTC+04:30)
('Abu Dhabi', 'Asia/Dubai'), # UTC+04:00
('Baku', 'Asia/Baku'), # UTC+04:00 (DST: UTC+05:00)
('Yerevan', 'Asia/Yerevan'), # UTC+04:00 (DST: UTC+05:00)
('Astrakhan', 'Europe/Astrakhan'), # UTC+04:00
('Tbilisi', 'Asia/Tbilisi'), # UTC+04:00
('Mauritius', 'Indian/Mauritius'), # UTC+04:00
('Islamabad', 'Asia/Karachi'), # UTC+05:00
('Karachi', 'Asia/Karachi'), # UTC+05:00
('Tashkent', 'Asia/Tashkent'), # UTC+05:00
('Yekaterinburg', 'Asia/Yekaterinburg'), # UTC+05:00
('Maldives', 'Indian/Maldives'), # UTC+05:00
('Chagos', 'Indian/Chagos'), # UTC+05:00
('Chennai', 'Asia/Kolkata'), # UTC+05:30
('Kolkata', 'Asia/Kolkata'), # UTC+05:30
('Mumbai', 'Asia/Kolkata'), # UTC+05:30
('New Delhi', 'Asia/Kolkata'), # UTC+05:30
('Sri Jayawardenepura', 'Asia/Colombo'), # UTC+05:30
('Kathmandu', 'Asia/Kathmandu'), # UTC+05:45
('Dhaka', 'Asia/Dhaka'), # UTC+06:00
('Almaty', 'Asia/Almaty'), # UTC+06:00
('Bishkek', 'Asia/Bishkek'), # UTC+06:00
('Thimphu', 'Asia/Thimphu'), # UTC+06:00
('Yangon (Rangoon)', 'Asia/Yangon'), # UTC+06:30
('Cocos Islands', 'Indian/Cocos'), # UTC+06:30
('Bangkok', 'Asia/Bangkok'), # UTC+07:00
('Hanoi', 'Asia/Ho_Chi_Minh'), # UTC+07:00
('Jakarta', 'Asia/Jakarta'), # UTC+07:00
('Novosibirsk', 'Asia/Novosibirsk'), # UTC+07:00
('Krasnoyarsk', 'Asia/Krasnoyarsk'), # UTC+07:00
('Beijing', 'Asia/Shanghai'), # UTC+08:00
('Singapore', 'Asia/Singapore'), # UTC+08:00
('Perth', 'Australia/Perth'), # UTC+08:00
('Hong Kong', 'Asia/Hong_Kong'), # UTC+08:00
('Ulaanbaatar', 'Asia/Ulaanbaatar'), # UTC+08:00
('Palau', 'Pacific/Palau'), # UTC+08:00
('Eucla', 'Australia/Eucla'), # UTC+08:45
('Tokyo', 'Asia/Tokyo'), # UTC+09:00
('Seoul', 'Asia/Seoul'), # UTC+09:00
('Yakutsk', 'Asia/Yakutsk'), # UTC+09:00
('Adelaide', 'Australia/Adelaide'), # UTC+09:30 (DST: UTC+10:30)
('Darwin', 'Australia/Darwin'), # UTC+09:30
('Sydney', 'Australia/Sydney'), # UTC+10:00 (DST: UTC+11:00)
('Brisbane', 'Australia/Brisbane'), # UTC+10:00
('Guam', 'Pacific/Guam'), # UTC+10:00
('Vladivostok', 'Asia/Vladivostok'), # UTC+10:00
('Tahiti', 'Pacific/Tahiti'), # UTC+10:00
('Lord Howe Island', 'Australia/Lord_Howe'), # UTC+10:30 (DST: UTC+11:00)
('Solomon Islands', 'Pacific/Guadalcanal'), # UTC+11:00
('Magadan', 'Asia/Magadan'), # UTC+11:00
('Norfolk Island', 'Pacific/Norfolk'), # UTC+11:00
('Bougainville Island', 'Pacific/Bougainville'), # UTC+11:00
('Chokurdakh', 'Asia/Srednekolymsk'), # UTC+11:00
('Auckland', 'Pacific/Auckland'), # UTC+12:00 (DST: UTC+13:00)
('Wellington', 'Pacific/Auckland'), # UTC+12:00 (DST: UTC+13:00)
('Fiji Islands', 'Pacific/Fiji'), # UTC+12:00 (DST: UTC+13:00)
('Anadyr', 'Asia/Anadyr'), # UTC+12:00
('Chatham Islands', 'Pacific/Chatham'), # UTC+12:45 (DST: UTC+13:45)
("Nuku'alofa", 'Pacific/Tongatapu'), # UTC+13:00
('Samoa', 'Pacific/Apia'), # UTC+13:00 (DST: UTC+14:00)
('Kiritimati Island', 'Pacific/Kiritimati') # UTC+14:00
]
timezone_list = []
now = datetime.now()
# Process timezone mapping
for offset, locations in timezone_mapping.items():
sign = "-" if offset.startswith("-") else "+"
hours = offset[1:3]
minutes = offset[3:] if len(offset) > 3 else "00"
for friendly_name, tz_identifier in timezone_locations:
for friendly_name, tz_identifier in locations:
try:
tz = pytz.timezone(tz_identifier)
current_offset = now.astimezone(tz).strftime("%z")
try:
tz = pytz.timezone(tz_identifier)
current_offset = now.astimezone(tz).strftime("%z")
# converting and formatting UTC offset to GMT offset
current_utc_offset = now.astimezone(tz).utcoffset()
total_seconds = int(current_utc_offset.total_seconds())
hours_offset = total_seconds // 3600
minutes_offset = abs(total_seconds % 3600) // 60
gmt_offset = (
f"GMT{'+' if hours_offset >= 0 else '-'}"
f"{abs(hours_offset):02}:{minutes_offset:02}"
)
# converting and formatting UTC offset to GMT offset
current_utc_offset = now.astimezone(tz).utcoffset()
total_seconds = int(current_utc_offset.total_seconds())
hours_offset = total_seconds // 3600
minutes_offset = abs(total_seconds % 3600) // 60
offset = (
f"{'+' if hours_offset >= 0 else '-'}"
f"{abs(hours_offset):02}:{minutes_offset:02}"
)
timezone_value = {
"offset": int(current_offset),
"utc_offset": f"UTC{sign}{hours}:{minutes}",
"gmt_offset": gmt_offset,
"value": tz_identifier,
"label": f"{friendly_name}",
}
timezone_value = {
"offset": int(current_offset),
"utc_offset": f"UTC{offset}",
"gmt_offset": f"GMT{offset}",
"value": tz_identifier,
"label": f"{friendly_name}",
}
timezone_list.append(timezone_value)
except pytz.exceptions.UnknownTimeZoneError:
continue
timezone_list.append(timezone_value)
except pytz.exceptions.UnknownTimeZoneError:
continue
# Sort by offset and then by label
timezone_list.sort(key=lambda x: (x["offset"], x["label"]))
@@ -68,10 +68,11 @@ class WorkSpaceMemberViewSet(BaseViewSet):
status=status.HTTP_400_BAD_REQUEST,
)
if workspace_member.role > int(request.data.get("role")):
_ = ProjectMember.objects.filter(
# If a user is moved to a guest role he can't have any other role in projects
if "role" in request.data and int(request.data.get("role")) == 5:
ProjectMember.objects.filter(
workspace__slug=slug, member_id=workspace_member.member_id
).update(role=int(request.data.get("role")))
).update(role=5)
serializer = WorkSpaceMemberSerializer(
workspace_member, data=request.data, partial=True
@@ -36,10 +36,12 @@ AUTHENTICATION_ERROR_CODES = {
"OAUTH_NOT_CONFIGURED": 5104,
"GOOGLE_NOT_CONFIGURED": 5105,
"GITHUB_NOT_CONFIGURED": 5110,
"GITHUB_USER_NOT_IN_ORG": 5122,
"GITLAB_NOT_CONFIGURED": 5111,
"GOOGLE_OAUTH_PROVIDER_ERROR": 5115,
"GITHUB_OAUTH_PROVIDER_ERROR": 5120,
"GITLAB_OAUTH_PROVIDER_ERROR": 5121,
# Reset Password
"INVALID_PASSWORD_TOKEN": 5125,
"EXPIRED_PASSWORD_TOKEN": 5130,
@@ -18,11 +18,16 @@ from plane.authentication.adapter.error import (
class GitHubOAuthProvider(OauthAdapter):
token_url = "https://github.com/login/oauth/access_token"
userinfo_url = "https://api.github.com/user"
org_membership_url = f"https://api.github.com/orgs"
provider = "github"
scope = "read:user user:email"
organization_scope = "read:org"
def __init__(self, request, code=None, state=None, callback=None):
GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET = get_configuration_value(
GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET, GITHUB_ORGANIZATION_ID = get_configuration_value(
[
{
"key": "GITHUB_CLIENT_ID",
@@ -32,6 +37,10 @@ class GitHubOAuthProvider(OauthAdapter):
"key": "GITHUB_CLIENT_SECRET",
"default": os.environ.get("GITHUB_CLIENT_SECRET"),
},
{
"key": "GITHUB_ORGANIZATION_ID",
"default": os.environ.get("GITHUB_ORGANIZATION_ID"),
},
]
)
@@ -43,6 +52,10 @@ class GitHubOAuthProvider(OauthAdapter):
client_id = GITHUB_CLIENT_ID
client_secret = GITHUB_CLIENT_SECRET
self.organization_id = GITHUB_ORGANIZATION_ID
if self.organization_id:
self.scope += f" {self.organization_scope}"
redirect_uri = f"""{"https" if request.is_secure() else "http"}://{request.get_host()}/auth/github/callback/"""
url_params = {
@@ -113,12 +126,26 @@ class GitHubOAuthProvider(OauthAdapter):
error_message="GITHUB_OAUTH_PROVIDER_ERROR",
)
def is_user_in_organization(self, github_username):
headers = {"Authorization": f"Bearer {self.token_data.get('access_token')}"}
response = requests.get(f"{self.org_membership_url}/{self.organization_id}/memberships/{github_username}", headers=headers)
return response.status_code == 200 # 200 means the user is a member
def set_user_data(self):
user_info_response = self.get_user_response()
headers = {
"Authorization": f"Bearer {self.token_data.get('access_token')}",
"Accept": "application/json",
}
if self.organization_id:
if not self.is_user_in_organization(user_info_response.get("login")):
raise AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["GITHUB_USER_NOT_IN_ORG"],
error_message="GITHUB_USER_NOT_IN_ORG",
)
email = self.__get_email(headers=headers)
super().set_user_data(
{
+16 -3
View File
@@ -44,10 +44,21 @@ class ChangePasswordEndpoint(APIView):
def post(self, request):
user = User.objects.get(pk=request.user.id)
old_password = request.data.get("old_password", False)
# If the user password is not autoset then we need to check the old passwords
if not user.is_password_autoset:
old_password = request.data.get("old_password", False)
if not old_password:
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["MISSING_PASSWORD"],
error_message="MISSING_PASSWORD",
payload={"error": "Old password is missing"},
)
return Response(exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST)
# Get the new password
new_password = request.data.get("new_password", False)
if not old_password or not new_password:
if not new_password:
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["MISSING_PASSWORD"],
error_message="MISSING_PASSWORD",
@@ -55,7 +66,9 @@ class ChangePasswordEndpoint(APIView):
)
return Response(exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST)
if not user.check_password(old_password):
# If the user password is not autoset then we need to check the old passwords
if not user.is_password_autoset and not user.check_password(old_password):
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["INCORRECT_OLD_PASSWORD"],
error_message="INCORRECT_OLD_PASSWORD",
@@ -3,7 +3,6 @@
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
from sentry_sdk import capture_exception
import uuid
@@ -29,7 +28,6 @@ def create_issue_relation(apps, schema_editor):
)
except Exception as e:
print(e)
capture_exception(e)
def update_issue_priority_choice(apps, schema_editor):
+10
View File
@@ -1,6 +1,7 @@
# Python imports
import pytz
from uuid import uuid4
from enum import Enum
# Django imports
from django.conf import settings
@@ -17,6 +18,15 @@ from .base import BaseModel
ROLE_CHOICES = ((20, "Admin"), (15, "Member"), (5, "Guest"))
class ProjectNetwork(Enum):
SECRET = 0
PUBLIC = 2
@classmethod
def choices(cls):
return [(0, "Secret"), (2, "Public")]
def get_default_props():
return {
"filters": {
@@ -71,6 +71,12 @@ class Command(BaseCommand):
"category": "GITHUB",
"is_encrypted": True,
},
{
"key": "GITHUB_ORGANIZATION_ID",
"value": os.environ.get("GITHUB_ORGANIZATION_ID"),
"category": "GITHUB",
"is_encrypted": False,
},
{
"key": "GITLAB_HOST",
"value": os.environ.get("GITLAB_HOST"),
-23
View File
@@ -7,13 +7,9 @@ from urllib.parse import urlparse
# Third party imports
import dj_database_url
import sentry_sdk
# Django imports
from django.core.management.utils import get_random_secret_key
from sentry_sdk.integrations.celery import CeleryIntegration
from sentry_sdk.integrations.django import DjangoIntegration
from sentry_sdk.integrations.redis import RedisIntegration
from corsheaders.defaults import default_headers
@@ -267,25 +263,6 @@ CELERY_IMPORTS = (
"plane.bgtasks.issue_description_version_sync",
)
# Sentry Settings
# Enable Sentry Settings
if bool(os.environ.get("SENTRY_DSN", False)) and os.environ.get(
"SENTRY_DSN"
).startswith("https://"):
sentry_sdk.init(
dsn=os.environ.get("SENTRY_DSN", ""),
integrations=[
DjangoIntegration(),
RedisIntegration(),
CeleryIntegration(monitor_beat_tasks=True),
],
traces_sample_rate=1,
send_default_pii=True,
environment=os.environ.get("SENTRY_ENVIRONMENT", "development"),
profiles_sample_rate=float(os.environ.get("SENTRY_PROFILE_SAMPLE_RATE", 0)),
)
FILE_SIZE_LIMIT = int(os.environ.get("FILE_SIZE_LIMIT", 5242880))
# Unsplash Access key
@@ -5,9 +5,6 @@ import traceback
# Django imports
from django.conf import settings
# Third party imports
from sentry_sdk import capture_exception
def log_exception(e):
# Log the error
@@ -18,6 +15,4 @@ def log_exception(e):
# Print the traceback if in debug mode
print(traceback.format_exc())
# Capture in sentry if configured
capture_exception(e)
return
-2
View File
@@ -26,8 +26,6 @@ faker==25.0.0
django-filter==24.2
# json model
jsonmodels==2.7.0
# sentry
sentry-sdk==2.8.0
# storage
django-storages==1.14.2
# user management
+1 -1
View File
@@ -1,3 +1,3 @@
-r base.txt
# server
gunicorn==22.0.0
gunicorn==23.0.0
+3 -15
View File
@@ -6,16 +6,8 @@
"website": "https://plane.so/",
"success_url": "/",
"stack": "heroku-22",
"keywords": [
"plane",
"project management",
"django",
"next"
],
"addons": [
"heroku-postgresql:mini",
"heroku-redis:mini"
],
"keywords": ["plane", "project management", "django", "next"],
"addons": ["heroku-postgresql:mini", "heroku-redis:mini"],
"buildpacks": [
{
"url": "https://github.com/heroku/heroku-buildpack-python.git"
@@ -61,10 +53,6 @@
"description": "AWS Bucket Name to use for S3",
"value": ""
},
"SENTRY_DSN": {
"description": "",
"value": ""
},
"WEB_URL": {
"description": "Web URL for Plane this will be used for redirections in the emails",
"value": ""
@@ -82,4 +70,4 @@
"value": ""
}
}
}
}
+35 -42
View File
@@ -42,8 +42,6 @@ x-live-env: &live-env
x-app-env: &app-env
WEB_URL: ${WEB_URL:-http://localhost}
DEBUG: ${DEBUG:-0}
SENTRY_DSN: ${SENTRY_DSN}
SENTRY_ENVIRONMENT: ${SENTRY_ENVIRONMENT:-production}
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS}
GUNICORN_WORKERS: 1
USE_MINIO: ${USE_MINIO:-1}
@@ -57,10 +55,11 @@ services:
web:
image: ${DOCKERHUB_USER:-makeplane}/plane-frontend:${APP_RELEASE:-stable}
command: node web/server.js web
platform: ${DOCKER_PLATFORM:-}
pull_policy: if_not_present
restart: unless-stopped
deploy:
replicas: ${WEB_REPLICAS:-1}
restart_policy:
condition: on-failure
depends_on:
- api
- worker
@@ -68,10 +67,11 @@ services:
space:
image: ${DOCKERHUB_USER:-makeplane}/plane-space:${APP_RELEASE:-stable}
command: node space/server.js space
platform: ${DOCKER_PLATFORM:-}
pull_policy: if_not_present
restart: unless-stopped
deploy:
replicas: ${SPACE_REPLICAS:-1}
restart_policy:
condition: on-failure
depends_on:
- api
- worker
@@ -80,10 +80,11 @@ services:
admin:
image: ${DOCKERHUB_USER:-makeplane}/plane-admin:${APP_RELEASE:-stable}
command: node admin/server.js admin
platform: ${DOCKER_PLATFORM:-}
pull_policy: if_not_present
restart: unless-stopped
deploy:
replicas: ${ADMIN_REPLICAS:-1}
restart_policy:
condition: on-failure
depends_on:
- api
- web
@@ -93,10 +94,11 @@ services:
command: node live/dist/server.js live
environment:
<<: [*live-env]
platform: ${DOCKER_PLATFORM:-}
pull_policy: if_not_present
restart: unless-stopped
deploy:
replicas: ${LIVE_REPLICAS:-1}
restart_policy:
condition: on-failure
depends_on:
- api
- web
@@ -104,10 +106,11 @@ services:
api:
image: ${DOCKERHUB_USER:-makeplane}/plane-backend:${APP_RELEASE:-stable}
command: ./bin/docker-entrypoint-api.sh
platform: ${DOCKER_PLATFORM:-}
pull_policy: if_not_present
restart: unless-stopped
deploy:
replicas: ${API_REPLICAS:-1}
restart_policy:
condition: on-failure
volumes:
- logs_api:/code/plane/logs
environment:
@@ -120,10 +123,11 @@ services:
worker:
image: ${DOCKERHUB_USER:-makeplane}/plane-backend:${APP_RELEASE:-stable}
command: ./bin/docker-entrypoint-worker.sh
platform: ${DOCKER_PLATFORM:-}
pull_policy: if_not_present
restart: unless-stopped
deploy:
replicas: ${WORKER_REPLICAS:-1}
restart_policy:
condition: on-failure
volumes:
- logs_worker:/code/plane/logs
environment:
@@ -137,10 +141,11 @@ services:
beat-worker:
image: ${DOCKERHUB_USER:-makeplane}/plane-backend:${APP_RELEASE:-stable}
command: ./bin/docker-entrypoint-beat.sh
platform: ${DOCKER_PLATFORM:-}
pull_policy: if_not_present
restart: unless-stopped
deploy:
replicas: ${BEAT_WORKER_REPLICAS:-1}
restart_policy:
condition: on-failure
volumes:
- logs_beat-worker:/code/plane/logs
environment:
@@ -154,10 +159,11 @@ services:
migrator:
image: ${DOCKERHUB_USER:-makeplane}/plane-backend:${APP_RELEASE:-stable}
command: ./bin/docker-entrypoint-migrator.sh
platform: ${DOCKER_PLATFORM:-}
pull_policy: if_not_present
restart: no
deploy:
replicas: 1
restart_policy:
condition: on-failure
volumes:
- logs_migrator:/code/plane/logs
environment:
@@ -170,10 +176,8 @@ services:
plane-db:
image: postgres:15.7-alpine
command: postgres -c 'max_connections=1000'
deploy:
replicas: 1
restart_policy:
condition: on-failure
pull_policy: if_not_present
restart: unless-stopped
environment:
<<: *db-env
volumes:
@@ -181,19 +185,15 @@ services:
plane-redis:
image: valkey/valkey:7.2.5-alpine
deploy:
replicas: 1
restart_policy:
condition: on-failure
pull_policy: if_not_present
restart: unless-stopped
volumes:
- redisdata:/data
plane-mq:
image: rabbitmq:3.13.6-management-alpine
deploy:
replicas: 1
restart_policy:
condition: on-failure
pull_policy: if_not_present
restart: unless-stopped
environment:
<<: *mq-env
volumes:
@@ -203,10 +203,8 @@ services:
plane-minio:
image: minio/minio:latest
command: server /export --console-address ":9090"
deploy:
replicas: 1
restart_policy:
condition: on-failure
pull_policy: if_not_present
restart: unless-stopped
environment:
<<: *minio-env
volumes:
@@ -215,17 +213,12 @@ services:
# Comment this if you already have a reverse proxy running
proxy:
image: ${DOCKERHUB_USER:-makeplane}/plane-proxy:${APP_RELEASE:-stable}
pull_policy: if_not_present
restart: unless-stopped
ports:
- target: 80
published: ${NGINX_PORT:-80}
protocol: tcp
mode: host
- ${NGINX_PORT:-80}:80
environment:
<<: *proxy-env
deploy:
replicas: 1
restart_policy:
condition: on-failure
depends_on:
- web
- api
+240
View File
@@ -0,0 +1,240 @@
x-db-env: &db-env
PGHOST: ${PGHOST:-plane-db}
PGDATABASE: ${PGDATABASE:-plane}
POSTGRES_USER: ${POSTGRES_USER:-plane}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-plane}
POSTGRES_DB: ${POSTGRES_DB:-plane}
POSTGRES_PORT: ${POSTGRES_PORT:-5432}
PGDATA: ${PGDATA:-/var/lib/postgresql/data}
x-redis-env: &redis-env
REDIS_HOST: ${REDIS_HOST:-plane-redis}
REDIS_PORT: ${REDIS_PORT:-6379}
REDIS_URL: ${REDIS_URL:-redis://plane-redis:6379/}
x-minio-env: &minio-env
MINIO_ROOT_USER: ${AWS_ACCESS_KEY_ID:-access-key}
MINIO_ROOT_PASSWORD: ${AWS_SECRET_ACCESS_KEY:-secret-key}
x-aws-s3-env: &aws-s3-env
AWS_REGION: ${AWS_REGION:-}
AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID:-access-key}
AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY:-secret-key}
AWS_S3_ENDPOINT_URL: ${AWS_S3_ENDPOINT_URL:-http://plane-minio:9000}
AWS_S3_BUCKET_NAME: ${AWS_S3_BUCKET_NAME:-uploads}
x-proxy-env: &proxy-env
NGINX_PORT: ${NGINX_PORT:-80}
BUCKET_NAME: ${AWS_S3_BUCKET_NAME:-uploads}
FILE_SIZE_LIMIT: ${FILE_SIZE_LIMIT:-5242880}
x-mq-env: &mq-env # RabbitMQ Settings
RABBITMQ_HOST: ${RABBITMQ_HOST:-plane-mq}
RABBITMQ_PORT: ${RABBITMQ_PORT:-5672}
RABBITMQ_DEFAULT_USER: ${RABBITMQ_USER:-plane}
RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASSWORD:-plane}
RABBITMQ_DEFAULT_VHOST: ${RABBITMQ_VHOST:-plane}
RABBITMQ_VHOST: ${RABBITMQ_VHOST:-plane}
x-live-env: &live-env
API_BASE_URL: ${API_BASE_URL:-http://api:8000}
x-app-env: &app-env
WEB_URL: ${WEB_URL:-http://localhost}
DEBUG: ${DEBUG:-0}
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS}
GUNICORN_WORKERS: 1
USE_MINIO: ${USE_MINIO:-1}
DATABASE_URL: ${DATABASE_URL:-postgresql://plane:plane@plane-db/plane}
SECRET_KEY: ${SECRET_KEY:-60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5}
AMQP_URL: ${AMQP_URL:-amqp://plane:plane@plane-mq:5672/plane}
API_KEY_RATE_LIMIT: ${API_KEY_RATE_LIMIT:-60/minute}
MINIO_ENDPOINT_SSL: ${MINIO_ENDPOINT_SSL:-0}
services:
web:
image: ${DOCKERHUB_USER:-makeplane}/plane-frontend:${APP_RELEASE:-stable}
command: node web/server.js web
deploy:
replicas: ${WEB_REPLICAS:-1}
restart_policy:
condition: on-failure
depends_on:
- api
- worker
space:
image: ${DOCKERHUB_USER:-makeplane}/plane-space:${APP_RELEASE:-stable}
command: node space/server.js space
deploy:
replicas: ${SPACE_REPLICAS:-1}
restart_policy:
condition: on-failure
depends_on:
- api
- worker
- web
admin:
image: ${DOCKERHUB_USER:-makeplane}/plane-admin:${APP_RELEASE:-stable}
command: node admin/server.js admin
deploy:
replicas: ${ADMIN_REPLICAS:-1}
restart_policy:
condition: on-failure
depends_on:
- api
- web
live:
image: ${DOCKERHUB_USER:-makeplane}/plane-live:${APP_RELEASE:-stable}
command: node live/dist/server.js live
environment:
<<: [*live-env]
deploy:
replicas: ${LIVE_REPLICAS:-1}
restart_policy:
condition: on-failure
depends_on:
- api
- web
api:
image: ${DOCKERHUB_USER:-makeplane}/plane-backend:${APP_RELEASE:-stable}
command: ./bin/docker-entrypoint-api.sh
deploy:
replicas: ${API_REPLICAS:-1}
restart_policy:
condition: on-failure
volumes:
- logs_api:/code/plane/logs
environment:
<<: [*app-env, *db-env, *redis-env, *minio-env, *aws-s3-env, *proxy-env]
depends_on:
- plane-db
- plane-redis
- plane-mq
worker:
image: ${DOCKERHUB_USER:-makeplane}/plane-backend:${APP_RELEASE:-stable}
command: ./bin/docker-entrypoint-worker.sh
deploy:
replicas: ${WORKER_REPLICAS:-1}
restart_policy:
condition: on-failure
volumes:
- logs_worker:/code/plane/logs
environment:
<<: [*app-env, *db-env, *redis-env, *minio-env, *aws-s3-env, *proxy-env]
depends_on:
- api
- plane-db
- plane-redis
- plane-mq
beat-worker:
image: ${DOCKERHUB_USER:-makeplane}/plane-backend:${APP_RELEASE:-stable}
command: ./bin/docker-entrypoint-beat.sh
deploy:
replicas: ${BEAT_WORKER_REPLICAS:-1}
restart_policy:
condition: on-failure
volumes:
- logs_beat-worker:/code/plane/logs
environment:
<<: [*app-env, *db-env, *redis-env, *minio-env, *aws-s3-env, *proxy-env]
depends_on:
- api
- plane-db
- plane-redis
- plane-mq
migrator:
image: ${DOCKERHUB_USER:-makeplane}/plane-backend:${APP_RELEASE:-stable}
command: ./bin/docker-entrypoint-migrator.sh
deploy:
replicas: 1
restart_policy:
condition: on-failure
volumes:
- logs_migrator:/code/plane/logs
environment:
<<: [*app-env, *db-env, *redis-env, *minio-env, *aws-s3-env, *proxy-env]
depends_on:
- plane-db
- plane-redis
# Comment this if you already have a database running
plane-db:
image: postgres:15.7-alpine
command: postgres -c 'max_connections=1000'
deploy:
replicas: 1
restart_policy:
condition: on-failure
environment:
<<: *db-env
volumes:
- pgdata:/var/lib/postgresql/data
plane-redis:
image: valkey/valkey:7.2.5-alpine
deploy:
replicas: 1
restart_policy:
condition: on-failure
volumes:
- redisdata:/data
plane-mq:
image: rabbitmq:3.13.6-management-alpine
deploy:
replicas: 1
restart_policy:
condition: on-failure
environment:
<<: *mq-env
volumes:
- rabbitmq_data:/var/lib/rabbitmq
# Comment this if you using any external s3 compatible storage
plane-minio:
image: minio/minio:latest
command: server /export --console-address ":9090"
deploy:
replicas: 1
restart_policy:
condition: on-failure
environment:
<<: *minio-env
volumes:
- uploads:/export
# Comment this if you already have a reverse proxy running
proxy:
image: ${DOCKERHUB_USER:-makeplane}/plane-proxy:${APP_RELEASE:-stable}
ports:
- target: 80
published: ${NGINX_PORT:-80}
protocol: tcp
mode: host
environment:
<<: *proxy-env
deploy:
replicas: 1
restart_policy:
condition: on-failure
depends_on:
- web
- api
- space
volumes:
pgdata:
redisdata:
uploads:
logs_api:
logs_worker:
logs_beat-worker:
logs_migrator:
rabbitmq_data:
+12 -15
View File
@@ -16,7 +16,7 @@ OS_NAME=$(uname)
# Create necessary directories
mkdir -p $PLANE_INSTALL_DIR/archive
DOCKER_FILE_PATH=$PLANE_INSTALL_DIR/docker-compose.yml
DOCKER_FILE_PATH=$PLANE_INSTALL_DIR/swarm-compose.yml
DOCKER_ENV_PATH=$PLANE_INSTALL_DIR/plane.env
function print_header() {
@@ -150,33 +150,33 @@ function updateEnvFile() {
function download() {
cd $SCRIPT_DIR || exit 1
TS=$(date +%s)
if [ -f "$PLANE_INSTALL_DIR/docker-compose.yml" ]
if [ -f "$PLANE_INSTALL_DIR/swarm-compose.yml" ]
then
mv $PLANE_INSTALL_DIR/docker-compose.yml $PLANE_INSTALL_DIR/archive/$TS.docker-compose.yml
mv $PLANE_INSTALL_DIR/swarm-compose.yml $PLANE_INSTALL_DIR/archive/$TS.swarm-compose.yml
fi
echo $RELEASE_DOWNLOAD_URL
echo $FALLBACK_DOWNLOAD_URL
echo $APP_RELEASE
RESPONSE=$(curl -H 'Cache-Control: no-cache, no-store' -s -w "HTTPSTATUS:%{http_code}" "$RELEASE_DOWNLOAD_URL/$APP_RELEASE/docker-compose.yml?$(date +%s)")
RESPONSE=$(curl -H 'Cache-Control: no-cache, no-store' -s -w "HTTPSTATUS:%{http_code}" "$RELEASE_DOWNLOAD_URL/$APP_RELEASE/swarm-compose.yml?$(date +%s)")
BODY=$(echo "$RESPONSE" | sed -e 's/HTTPSTATUS\:.*//g')
STATUS=$(echo "$RESPONSE" | tr -d '\n' | sed -e 's/.*HTTPSTATUS://')
if [ "$STATUS" -eq 200 ]; then
echo "$BODY" > $PLANE_INSTALL_DIR/docker-compose.yml
echo "$BODY" > $PLANE_INSTALL_DIR/swarm-compose.yml
else
# Fallback to download from the raw github url
RESPONSE=$(curl -H 'Cache-Control: no-cache, no-store' -s -w "HTTPSTATUS:%{http_code}" "$FALLBACK_DOWNLOAD_URL/docker-compose.yml?$(date +%s)")
RESPONSE=$(curl -H 'Cache-Control: no-cache, no-store' -s -w "HTTPSTATUS:%{http_code}" "$FALLBACK_DOWNLOAD_URL/swarm-compose.yml?$(date +%s)")
BODY=$(echo "$RESPONSE" | sed -e 's/HTTPSTATUS\:.*//g')
STATUS=$(echo "$RESPONSE" | tr -d '\n' | sed -e 's/.*HTTPSTATUS://')
if [ "$STATUS" -eq 200 ]; then
echo "$BODY" > $PLANE_INSTALL_DIR/docker-compose.yml
echo "$BODY" > $PLANE_INSTALL_DIR/swarm-compose.yml
else
echo "Failed to download docker-compose.yml. HTTP Status: $STATUS"
echo "URL: $RELEASE_DOWNLOAD_URL/$APP_RELEASE/docker-compose.yml"
mv $PLANE_INSTALL_DIR/archive/$TS.docker-compose.yml $PLANE_INSTALL_DIR/docker-compose.yml
echo "Failed to download swarm-compose.yml. HTTP Status: $STATUS"
echo "URL: $RELEASE_DOWNLOAD_URL/$APP_RELEASE/swarm-compose.yml"
mv $PLANE_INSTALL_DIR/archive/$TS.swarm-compose.yml $PLANE_INSTALL_DIR/swarm-compose.yml
exit 1
fi
fi
@@ -198,7 +198,7 @@ function download() {
else
echo "Failed to download variables.env. HTTP Status: $STATUS"
echo "URL: $RELEASE_DOWNLOAD_URL/$APP_RELEASE/variables.env"
mv $PLANE_INSTALL_DIR/archive/$TS.docker-compose.yml $PLANE_INSTALL_DIR/docker-compose.yml
mv $PLANE_INSTALL_DIR/archive/$TS.swarm-compose.yml $PLANE_INSTALL_DIR/swarm-compose.yml
exit 1
fi
fi
@@ -266,11 +266,8 @@ function deployStack() {
echo "Please check the logs for the 'migrator' service and resolve the issue(s)."
echo "Stop the services by running the command: ./swarm.sh stop"
exit 1
else
echo " Data Migration completed successfully ✅"
fi
else
echo "Warning: Could not find migrator container to check exit status"
fi
fi
-2
View File
@@ -12,8 +12,6 @@ LIVE_REPLICAS=1
NGINX_PORT=80
WEB_URL=http://${APP_DOMAIN}
DEBUG=0
SENTRY_DSN=
SENTRY_ENVIRONMENT=production
CORS_ALLOWED_ORIGINS=http://${APP_DOMAIN}
API_BASE_URL=http://api:8000
-2
View File
@@ -23,8 +23,6 @@
"@plane/constants": "*",
"@plane/editor": "*",
"@plane/types": "*",
"@sentry/node": "^9.0.1",
"@sentry/profiling-node": "^8.28.0",
"@tiptap/core": "2.10.4",
"@tiptap/html": "2.11.0",
"axios": "^1.8.3",
-19
View File
@@ -1,19 +0,0 @@
import * as Sentry from "@sentry/node";
import { nodeProfilingIntegration } from "@sentry/profiling-node";
// Ensure to call this before importing any other modules!
Sentry.init({
dsn: process.env.LIVE_SENTRY_DSN,
environment: process.env.LIVE_SENTRY_ENVIRONMENT || "development",
integrations: [
// Add our Profiling integration
nodeProfilingIntegration(),
],
// Add Tracing by setting tracesSampleRate
// We recommend adjusting this value in production
tracesSampleRate: Number(process.env.LIVE_SENTRY_TRACES_SAMPLE_RATE) || 0.5,
// Set sampling rate for profiling
// This is relative to tracesSampleRate
profilesSampleRate: 1.0,
});
+1 -6
View File
@@ -1,11 +1,8 @@
import * as Sentry from "@sentry/node";
import compression from "compression";
import cors from "cors";
import expressWs from "express-ws";
import express from "express";
import helmet from "helmet";
// config
import "@/core/config/sentry-config.js";
// hocuspocus server
import { getHocusPocusServer } from "@/core/hocuspocus-server.js";
// helpers
@@ -15,7 +12,7 @@ import { errorHandler } from "@/core/helpers/error-handler.js";
// types
import { TConvertDocumentRequestBody } from "@/core/types/common.js";
const app = express();
const app: any = express();
expressWs(app);
app.set("port", process.env.PORT || 3000);
@@ -92,8 +89,6 @@ app.use((_req, res) => {
res.status(404).send("Not Found");
});
Sentry.setupExpressErrorHandler(app);
app.use(errorHandler);
const liveServer = app.listen(app.get("port"), () => {
-3
View File
@@ -16,9 +16,6 @@
"skipLibCheck": true,
"sourceMap": true,
"inlineSources": true,
// Set `sourceRoot` to "/" to strip the build path prefix
// from generated source code references.
// This improves issue grouping in Sentry.
"sourceRoot": "/"
},
"include": ["src/**/*.ts", "tsup.config.ts"],
+1 -1
View File
@@ -1,2 +1,2 @@
export const LABEL_CLASSNAME = "uppercase text-custom-text-300/60 text-sm tracking-wide";
export const AXIS_LINE_CLASSNAME = "text-custom-text-400/70";
export const AXIS_LABEL_CLASSNAME = "uppercase text-custom-text-300/60 text-sm tracking-wide";
+1
View File
@@ -339,6 +339,7 @@ export const ISSUE_DISPLAY_FILTERS_BY_PAGE: TIssueFiltersToDisplayByPageType = {
"-updated_at",
"start_date",
"-priority",
"target_date",
],
type: [null, "active", "backlog"],
},
+6 -2
View File
@@ -83,14 +83,14 @@ export const WORKSPACE_SETTINGS = {
key: "general",
i18n_label: "workspace_settings.settings.general.title",
href: `/settings`,
access: [EUserWorkspaceRoles.ADMIN],
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER],
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/`,
},
members: {
key: "members",
i18n_label: "workspace_settings.settings.members.title",
href: `/settings/members`,
access: [EUserWorkspaceRoles.ADMIN],
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER],
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/members/`,
},
"billing-and-plans": {
@@ -123,6 +123,10 @@ export const WORKSPACE_SETTINGS = {
},
};
export const WORKSPACE_SETTINGS_ACCESS = Object.fromEntries(
Object.entries(WORKSPACE_SETTINGS).map(([_, { href, access }]) => [href, access])
);
export const WORKSPACE_SETTINGS_LINKS: {
key: string;
i18n_label: string;
+4
View File
@@ -0,0 +1,4 @@
node_modules
build/*
dist/*
out/*
+10
View File
@@ -0,0 +1,10 @@
/** @type {import("eslint").Linter.Config} */
module.exports = {
root: true,
extends: ["@plane/eslint-config/library.js"],
parser: "@typescript-eslint/parser",
parserOptions: {
project: true,
},
};
+99
View File
@@ -0,0 +1,99 @@
# @plane/decorators
A lightweight TypeScript decorator library for building Express.js controllers with a clean, declarative syntax.
## Features
- TypeScript-first design
- Decorators for HTTP methods (GET, POST, PUT, PATCH, DELETE)
- WebSocket support
- Middleware support
- No build step required - works directly with TypeScript files
## Installation
This package is part of the Plane workspace and can be used by adding it to your project's dependencies:
```json
{
"dependencies": {
"@plane/decorators": "*"
}
}
```
## Usage
### Basic REST Controller
```typescript
import { Controller, Get, Post, BaseController } from "@plane/decorators";
import { Router, Request, Response } from "express";
@Controller("/api/users")
class UserController extends BaseController {
@Get("/")
async getUsers(req: Request, res: Response) {
return res.json({ users: [] });
}
@Post("/")
async createUser(req: Request, res: Response) {
return res.json({ success: true });
}
}
// Register routes
const router = Router();
const userController = new UserController();
userController.registerRoutes(router);
```
### WebSocket Controller
```typescript
import {
Controller,
WebSocket,
BaseWebSocketController,
} from "@plane/decorators";
import { Request } from "express";
import { WebSocket as WS } from "ws";
@Controller("/ws/chat")
class ChatController extends BaseWebSocketController {
@WebSocket("/")
handleConnection(ws: WS, req: Request) {
ws.on("message", (message) => {
ws.send(`Received: ${message}`);
});
}
}
// Register WebSocket routes
const router = require("express-ws")(app).router;
const chatController = new ChatController();
chatController.registerWebSocketRoutes(router);
```
## API Reference
### Decorators
- `@Controller(baseRoute: string)` - Class decorator for defining a base route
- `@Get(route: string)` - Method decorator for HTTP GET endpoints
- `@Post(route: string)` - Method decorator for HTTP POST endpoints
- `@Put(route: string)` - Method decorator for HTTP PUT endpoints
- `@Patch(route: string)` - Method decorator for HTTP PATCH endpoints
- `@Delete(route: string)` - Method decorator for HTTP DELETE endpoints
- `@WebSocket(route: string)` - Method decorator for WebSocket endpoints
- `@Middleware(middleware: RequestHandler)` - Method decorator for applying middleware
### Classes
- `BaseController` - Base class for REST controllers
- `BaseWebSocketController` - Base class for WebSocket controllers
## License
This project is licensed under the [GNU Affero General Public License v3.0](https://github.com/makeplane/plane/blob/master/LICENSE.txt).
+42
View File
@@ -0,0 +1,42 @@
{
"name": "@plane/decorators",
"version": "0.1.0",
"description": "Controller and route decorators for Express.js applications",
"license": "AGPL-3.0",
"private": true,
"main": "./dist/index.js",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"files": [
"dist/**"
],
"scripts": {
"build": "tsup src/index.ts --format esm,cjs --dts --external express,ws",
"dev": "tsup src/index.ts --format esm,cjs --watch --dts --external express,ws",
"lint": "eslint src --ext .ts,.tsx",
"lint:errors": "eslint src --ext .ts,.tsx --quiet"
},
"dependencies": {
"reflect-metadata": "^0.2.2",
"express": "^4.21.2"
},
"devDependencies": {
"@plane/eslint-config": "*",
"@types/express": "^4.17.21",
"@types/reflect-metadata": "^0.1.0",
"@plane/typescript-config": "*",
"@types/node": "^20.14.9",
"@types/ws": "^8.5.10",
"tsup": "8.4.0",
"typescript": "^5.3.3"
},
"peerDependencies": {
"express": ">=4.21.2",
"ws": ">=8.0.0"
},
"peerDependenciesMeta": {
"ws": {
"optional": true
}
}
}
+61
View File
@@ -0,0 +1,61 @@
import { RequestHandler, Router } from "express";
import "reflect-metadata";
type HttpMethod =
| "get"
| "post"
| "put"
| "delete"
| "patch"
| "options"
| "head"
| "ws";
interface ControllerInstance {
[key: string]: unknown;
}
interface ControllerConstructor {
new (...args: any[]): ControllerInstance;
prototype: ControllerInstance;
}
export function registerControllers(
router: Router,
Controller: ControllerConstructor,
): void {
const instance = new Controller();
const baseRoute = Reflect.getMetadata("baseRoute", Controller) as string;
Object.getOwnPropertyNames(Controller.prototype).forEach((methodName) => {
if (methodName === "constructor") return; // Skip the constructor
const method = Reflect.getMetadata(
"method",
instance,
methodName,
) as HttpMethod;
const route = Reflect.getMetadata("route", instance, methodName) as string;
const middlewares =
(Reflect.getMetadata(
"middlewares",
instance,
methodName,
) as RequestHandler[]) || [];
if (method && route) {
const handler = instance[methodName] as unknown;
if (typeof handler === "function") {
if (method !== "ws") {
(
router[method] as (
path: string,
...handlers: RequestHandler[]
) => void
)(`${baseRoute}${route}`, ...middlewares, handler.bind(instance));
}
}
}
});
}
+15
View File
@@ -0,0 +1,15 @@
// Export individual decorators
export { Controller, Middleware } from "./rest";
export { Get, Post, Put, Patch, Delete } from "./rest";
export { WebSocket } from "./websocket";
export { registerControllers } from "./controller";
export { registerWebSocketControllers } from "./websocket-controller";
// Also provide namespaced exports for better organization
import * as RestDecorators from "./rest";
import * as WebSocketDecorators from "./websocket";
// Named namespace exports
export const Rest = RestDecorators;
export const WebSocketNS = WebSocketDecorators;
+61
View File
@@ -0,0 +1,61 @@
import "reflect-metadata";
import { RequestHandler } from "express";
// Define valid HTTP methods
type RestMethod = "get" | "post" | "put" | "patch" | "delete";
/**
* Controller decorator
* @param baseRoute
* @returns
*/
export function Controller(baseRoute: string = ""): ClassDecorator {
return function (target: Function) {
Reflect.defineMetadata("baseRoute", baseRoute, target);
};
}
/**
* Factory function to create HTTP method decorators
* @param method HTTP method to handle
* @returns Method decorator
*/
function createHttpMethodDecorator(
method: RestMethod
): (route: string) => MethodDecorator {
return function (route: string): MethodDecorator {
return function (
target: object,
propertyKey: string | symbol,
descriptor: PropertyDescriptor
) {
Reflect.defineMetadata("method", method, target, propertyKey);
Reflect.defineMetadata("route", route, target, propertyKey);
};
};
}
// Export HTTP method decorators using the factory
export const Get = createHttpMethodDecorator("get");
export const Post = createHttpMethodDecorator("post");
export const Put = createHttpMethodDecorator("put");
export const Patch = createHttpMethodDecorator("patch");
export const Delete = createHttpMethodDecorator("delete");
/**
* Middleware decorator
* @param middleware
* @returns
*/
export function Middleware(middleware: RequestHandler): MethodDecorator {
return function (
target: object,
propertyKey: string | symbol,
descriptor: PropertyDescriptor,
) {
const middlewares =
Reflect.getMetadata("middlewares", target, propertyKey) || [];
middlewares.push(middleware);
Reflect.defineMetadata("middlewares", middlewares, target, propertyKey);
};
}
@@ -0,0 +1,85 @@
import { Router, Request } from "express";
import type { WebSocket } from "ws";
import "reflect-metadata";
interface ControllerInstance {
[key: string]: unknown;
}
interface ControllerConstructor {
new (...args: any[]): ControllerInstance;
prototype: ControllerInstance;
}
export function registerWebSocketControllers(
router: Router,
Controller: ControllerConstructor,
existingInstance?: ControllerInstance,
): void {
const instance = existingInstance || new Controller();
const baseRoute = Reflect.getMetadata("baseRoute", Controller) as string;
Object.getOwnPropertyNames(Controller.prototype).forEach((methodName) => {
if (methodName === "constructor") return; // Skip the constructor
const method = Reflect.getMetadata(
"method",
instance,
methodName,
) as string;
const route = Reflect.getMetadata("route", instance, methodName) as string;
if (method === "ws" && route) {
const handler = instance[methodName] as unknown;
if (
typeof handler === "function" &&
typeof (router as any).ws === "function"
) {
(router as any).ws(
`${baseRoute}${route}`,
(ws: WebSocket, req: Request) => {
try {
handler.call(instance, ws, req);
} catch (error) {
console.error(
`WebSocket error in ${Controller.name}.${methodName}`,
error,
);
ws.close(
1011,
error instanceof Error
? error.message
: "Internal server error",
);
}
},
);
}
}
});
}
/**
* Base controller class for WebSocket endpoints
*/
export abstract class BaseWebSocketController {
protected router: Router;
constructor() {
this.router = Router();
}
/**
* Get the base route for this controller
*/
protected getBaseRoute(): string {
return Reflect.getMetadata("baseRoute", this.constructor) || "";
}
/**
* Abstract method to handle WebSocket connections
* Implement this in your derived class
*/
abstract handleConnection(ws: WebSocket, req: Request): void;
}
+17
View File
@@ -0,0 +1,17 @@
import "reflect-metadata";
/**
* WebSocket method decorator
* @param route
* @returns
*/
export function WebSocket(route: string): MethodDecorator {
return function (
target: object,
propertyKey: string | symbol,
descriptor: PropertyDescriptor,
) {
Reflect.defineMetadata("method", "ws", target, propertyKey);
Reflect.defineMetadata("route", route, target, propertyKey);
};
}
+21
View File
@@ -0,0 +1,21 @@
{
"extends": "@plane/typescript-config/node-library.json",
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"lib": ["ES2020"],
"rootDir": ".",
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"./src"
],
"exclude": [
"dist",
"build",
"node_modules"
]
}
+12
View File
@@ -0,0 +1,12 @@
import { defineConfig } from 'tsup';
export default defineConfig({
entry: ['src/index.ts'],
format: ['esm', 'cjs'],
dts: true,
splitting: false,
sourcemap: true,
clean: true,
external: ['express', 'ws'],
treeshake: true,
});
@@ -1,5 +1,7 @@
import { Extensions } from "@tiptap/core";
import React from "react";
// plane imports
import { cn } from "@plane/utils";
// components
import { DocumentContentLoader, PageRenderer } from "@/components/editors";
// constants
@@ -73,7 +75,11 @@ const CollaborativeDocumentEditor = (props: ICollaborativeDocumentEditor) => {
if (!editor) return null;
if (!hasServerSynced && !hasServerConnectionFailed) return <DocumentContentLoader />;
const blockWidthClassName = cn("w-full max-w-[720px] mx-auto transition-all duration-200 ease-in-out", {
"max-w-[1152px]": displayConfig.wideLayout,
});
if (!hasServerSynced && !hasServerConnectionFailed) return <DocumentContentLoader className={blockWidthClassName} />;
return (
<PageRenderer
@@ -81,7 +87,7 @@ const CollaborativeDocumentEditor = (props: ICollaborativeDocumentEditor) => {
bubbleMenuEnabled={bubbleMenuEnabled}
displayConfig={displayConfig}
editor={editor}
editorContainerClassName={editorContainerClassNames}
editorContainerClassName={cn(editorContainerClassNames, "document-editor")}
id={id}
tabIndex={tabIndex}
/>
@@ -1,42 +1,51 @@
// ui
// plane imports
import { Loader } from "@plane/ui";
import { cn } from "@plane/utils";
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">
type Props = {
className?: string;
};
export const DocumentContentLoader = (props: Props) => {
const { className } = props;
return (
<div className={cn("size-full", className)}>
<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>
</div>
</Loader>
</div>
);
</Loader>
</div>
);
};
@@ -132,7 +132,7 @@ export const PageRenderer = (props: IPageRenderer) => {
return (
<>
<div className="frame-renderer flex-grow w-full -mx-5" onMouseOver={handleLinkHover}>
<div className="frame-renderer flex-grow w-full" onMouseOver={handleLinkHover}>
<EditorContainer
displayConfig={displayConfig}
editor={editor}
@@ -1,5 +1,7 @@
import { Extensions } from "@tiptap/core";
import { forwardRef, MutableRefObject } from "react";
// plane imports
import { cn } from "@plane/utils";
// components
import { PageRenderer } from "@/components/editors";
// constants
@@ -79,7 +81,7 @@ const DocumentReadOnlyEditor = (props: IDocumentReadOnlyEditor) => {
bubbleMenuEnabled={false}
displayConfig={displayConfig}
editor={editor}
editorContainerClassName={editorContainerClassName}
editorContainerClassName={cn(editorContainerClassName, "document-editor")}
id={id}
/>
);
@@ -74,6 +74,7 @@ export const EditorContainer: FC<EditorContainerProps> = (props) => {
`editor-container cursor-text relative line-spacing-${displayConfig.lineSpacing ?? DEFAULT_DISPLAY_CONFIG.lineSpacing}`,
{
"active-editor": editor?.isFocused && editor?.isEditable,
"wide-layout": displayConfig.wideLayout,
},
displayConfig.fontSize ?? DEFAULT_DISPLAY_CONFIG.fontSize,
displayConfig.fontStyle ?? DEFAULT_DISPLAY_CONFIG.fontStyle,
@@ -5,6 +5,7 @@ export const DEFAULT_DISPLAY_CONFIG: TDisplayConfig = {
fontSize: "large-font",
fontStyle: "sans-serif",
lineSpacing: "regular",
wideLayout: false,
};
export const ACCEPTED_FILE_MIME_TYPES = ["image/jpeg", "image/jpg", "image/png", "image/webp", "image/gif"];
@@ -0,0 +1,89 @@
import { Extension } from "@tiptap/core";
import { Fragment, Node } from "@tiptap/pm/model";
import { Plugin, PluginKey } from "@tiptap/pm/state";
export const MarkdownClipboard = Extension.create({
name: "markdownClipboard",
addProseMirrorPlugins() {
return [
new Plugin({
key: new PluginKey("markdownClipboard"),
props: {
clipboardTextSerializer: (slice) => {
const markdownSerializer = this.editor.storage.markdown.serializer;
const isTableRow = slice.content.firstChild?.type?.name === "tableRow";
const nodeSelect = slice.openStart === 0 && slice.openEnd === 0;
if (nodeSelect) {
return markdownSerializer.serialize(slice.content);
}
const processTableContent = (tableNode: Node | Fragment) => {
let result = "";
tableNode.content?.forEach?.((tableRowNode: Node | Fragment) => {
tableRowNode.content?.forEach?.((cell: Node) => {
const cellContent = cell.content ? markdownSerializer.serialize(cell.content) : "";
result += cellContent + "\n";
});
});
return result;
};
if (isTableRow) {
const rowsCount = slice.content?.childCount || 0;
const cellsCount = slice.content?.firstChild?.content?.childCount || 0;
if (rowsCount === 1 || cellsCount === 1) {
return processTableContent(slice.content);
} else {
return markdownSerializer.serialize(slice.content);
}
}
const traverseToParentOfLeaf = (
node: Node | null,
parent: Fragment | Node,
depth: number
): Node | Fragment => {
let currentNode = node;
let currentParent = parent;
let currentDepth = depth;
while (currentNode && currentDepth > 1 && currentNode.content?.firstChild) {
if (currentNode.content?.childCount > 1) {
if (currentNode.content.firstChild?.type?.name === "listItem") {
return currentParent;
} else {
return currentNode.content;
}
}
currentParent = currentNode;
currentNode = currentNode.content?.firstChild || null;
currentDepth--;
}
return currentParent;
};
if (slice.content.childCount > 1) {
return markdownSerializer.serialize(slice.content);
} else {
const targetNode = traverseToParentOfLeaf(slice.content.firstChild, slice.content, slice.openStart);
let currentNode = targetNode;
while (currentNode && currentNode.content && currentNode.childCount === 1 && currentNode.firstChild) {
currentNode = currentNode.firstChild;
}
if (currentNode instanceof Node && currentNode.isText) {
return currentNode.text;
}
return markdownSerializer.serialize(targetNode);
}
},
},
}),
];
},
});
@@ -29,6 +29,7 @@ import {
TableCell,
TableHeader,
TableRow,
MarkdownClipboard,
} from "@/extensions";
// helpers
import { isValidHttpUrl } from "@/helpers/common";
@@ -130,10 +131,11 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => {
CustomCodeInlineExtension,
Markdown.configure({
html: true,
transformCopiedText: true,
transformCopiedText: false,
transformPastedText: true,
breaks: true,
}),
MarkdownClipboard,
Table,
TableHeader,
TableCell,
@@ -23,3 +23,4 @@ export * from "./quote";
export * from "./read-only-extensions";
export * from "./side-menu";
export * from "./text-align";
export * from "./clipboard";
@@ -1,12 +1,15 @@
import { mergeAttributes } from "@tiptap/core";
import Mention, { MentionOptions } from "@tiptap/extension-mention";
import { MarkdownSerializerState } from "@tiptap/pm/markdown";
import { Node as NodeType } from "@tiptap/pm/model";
// types
import { TMentionHandler } from "@/types";
// local types
import { EMentionComponentAttributeNames } from "./types";
import { EMentionComponentAttributeNames, TMentionComponentAttributes } from "./types";
export type TMentionExtensionOptions = MentionOptions & {
renderComponent: TMentionHandler["renderComponent"];
getMentionedEntityDetails: TMentionHandler["getMentionedEntityDetails"];
};
export const CustomMentionExtensionConfig = Mention.extend<TMentionExtensionOptions>({
@@ -40,9 +43,26 @@ export const CustomMentionExtensionConfig = Mention.extend<TMentionExtensionOpti
class: "mention",
},
addStorage(this) {
renderText({ node }) {
return getMentionDisplayText(this.options, node);
},
addStorage() {
const options = this.options;
return {
mentionsOpen: false,
markdown: {
serialize(state: MarkdownSerializerState, node: NodeType) {
state.write(getMentionDisplayText(options, node));
},
},
};
},
});
function getMentionDisplayText(options: TMentionExtensionOptions, node: NodeType): string {
const attrs = node.attrs as TMentionComponentAttributes;
const mentionEntityId = attrs[EMentionComponentAttributeNames.ENTITY_IDENTIFIER];
const mentionEntityDetails = options.getMentionedEntityDetails?.(mentionEntityId ?? "");
return `@${mentionEntityDetails?.display_name ?? attrs[EMentionComponentAttributeNames.ID] ?? mentionEntityId}`;
}
@@ -9,12 +9,13 @@ import { MentionNodeView } from "./mention-node-view";
import { renderMentionsDropdown } from "./utils";
export const CustomMentionExtension = (props: TMentionHandler) => {
const { searchCallback, renderComponent } = props;
const { searchCallback, renderComponent, getMentionedEntityDetails } = props;
return CustomMentionExtensionConfig.extend({
addOptions(this) {
return {
...this.parent?.(),
renderComponent,
getMentionedEntityDetails,
};
},
@@ -24,6 +24,7 @@ import {
CustomTextAlignExtension,
CustomCalloutReadOnlyExtension,
CustomColorExtension,
MarkdownClipboard,
} from "@/extensions";
// helpers
import { isValidHttpUrl } from "@/helpers/common";
@@ -114,8 +115,9 @@ export const CoreReadOnlyEditorExtensions = (props: Props): Extensions => {
CustomCodeInlineExtension,
Markdown.configure({
html: true,
transformCopiedText: true,
transformCopiedText: false,
}),
MarkdownClipboard,
Table,
TableHeader,
TableCell,
@@ -16,6 +16,22 @@ export function tableControls() {
},
},
props: {
handleTripleClickOn(view, pos, node, nodePos, event, direct) {
if (node.type.name === 'tableCell') {
event.preventDefault();
const $pos = view.state.doc.resolve(pos);
const line = $pos.parent;
const linePos = $pos.start();
const start = linePos;
const end = linePos + line.nodeSize - 1;
const tr = view.state.tr.setSelection(
TextSelection.create(view.state.doc, start, end)
);
view.dispatch(tr);
return true;
}
return false;
},
handleDOMEvents: {
mousemove: (view, event) => {
const pluginState = key.getState(view.state);
+1 -1
View File
@@ -49,7 +49,7 @@ export const isValidHttpUrl = (string: string): boolean => {
try {
url = new URL(string);
} catch (_) {
} catch {
return false;
}
+1
View File
@@ -29,4 +29,5 @@ export type TDisplayConfig = {
fontStyle?: TEditorFontStyle;
fontSize?: TEditorFontSize;
lineSpacing?: TEditorLineSpacing;
wideLayout?: boolean;
};
+2 -1
View File
@@ -1,5 +1,5 @@
// plane types
import { TSearchEntities } from "@plane/types";
import { IUserLite, TSearchEntities } from "@plane/types";
export type TMentionSuggestion = {
entity_identifier: string;
@@ -20,6 +20,7 @@ export type TMentionComponentProps = Pick<TMentionSuggestion, "entity_identifier
export type TReadOnlyMentionHandler = {
renderComponent: (props: TMentionComponentProps) => React.ReactNode;
getMentionedEntityDetails?: (entity_identifier: string) => { display_name: string } | undefined;
};
export type TMentionHandler = TReadOnlyMentionHandler & {
+1 -1
View File
@@ -8,7 +8,7 @@
-moz-user-select: text;
-ms-user-select: text;
user-select: text;
outline: none;
outline: none !important;
cursor: text;
font-family: var(--font-style);
font-size: var(--font-size-regular);
+137
View File
@@ -1,3 +1,47 @@
:root {
/* text colors */
--editor-colors-gray-text: #5c5e63;
--editor-colors-peach-text: #ff5b59;
--editor-colors-pink-text: #f65385;
--editor-colors-orange-text: #fd9038;
--editor-colors-green-text: #0fc27b;
--editor-colors-light-blue-text: #17bee9;
--editor-colors-dark-blue-text: #266df0;
--editor-colors-purple-text: #9162f9;
/* end text colors */
/* layout */
--normal-content-width: 720px;
--wide-content-width: 1152px;
--normal-content-margin: 20px;
--wide-content-margin: 96px;
/* end layout */
}
/* text background colors */
[data-theme*="light"] {
--editor-colors-gray-background: #d6d6d8;
--editor-colors-peach-background: #ffd5d7;
--editor-colors-pink-background: #fdd4e3;
--editor-colors-orange-background: #ffe3cd;
--editor-colors-green-background: #c3f0de;
--editor-colors-light-blue-background: #c5eff9;
--editor-colors-dark-blue-background: #c9dafb;
--editor-colors-purple-background: #e3d8fd;
}
[data-theme*="dark"] {
--editor-colors-gray-background: #404144;
--editor-colors-peach-background: #593032;
--editor-colors-pink-background: #562e3d;
--editor-colors-orange-background: #583e2a;
--editor-colors-green-background: #1d4a3b;
--editor-colors-light-blue-background: #1f495c;
--editor-colors-dark-blue-background: #223558;
--editor-colors-purple-background: #3d325a;
}
/* end text background colors */
/* font size and style */
.editor-container {
--color-placeholder: rgba(var(--color-text-100), 0.5);
@@ -47,6 +91,8 @@
/* end font sizes and line heights */
/* font styles */
--font-style: "Inter", sans-serif;
&.sans-serif {
--font-style: "Inter", sans-serif;
}
@@ -102,3 +148,94 @@
}
/* end spacing */
}
/* end font size and style */
/* layout config */
#page-header-container {
container-name: page-header-container;
container-type: inline-size;
.page-header-content {
--header-width: var(--normal-content-width);
&.wide-layout {
--header-width: var(--wide-content-width);
}
padding-left: calc(((100% - var(--header-width)) / 2) - 10px);
}
}
#page-content-container {
container-name: page-content-container;
container-type: inline-size;
}
.editor-container.document-editor {
--editor-content-width: var(--normal-content-width);
&.wide-layout {
--editor-content-width: var(--wide-content-width);
}
.ProseMirror {
max-width: var(--editor-content-width);
margin: 0 auto;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
}
/* keep a static padding of 96px for wide layouts for container width >912px and <1344px */
@container page-header-container (min-width: 912px) and (max-width: 1344px) {
.page-header-content.wide-layout {
padding-left: var(--wide-content-margin) !important;
}
}
/* keep a static padding of 96px for wide layouts for container width <912px */
@container page-header-container (max-width: 912) {
.page-header-content.wide-layout {
padding-left: var(--wide-content-margin) !important;
}
}
/* end layout config */
/* keep a static padding of 20px for wide layouts for container width <760px */
@container page-header-container (max-width: 760px) {
.page-header-content {
padding-left: var(--normal-content-margin) !important;
}
}
/* end layout config */
/* keep a static padding of 96px for wide layouts for container width >912px and <1344px */
@container page-content-container (min-width: 912px) and (max-width: 1344px) {
.editor-container.wide-layout,
.page-title-container {
padding-left: var(--wide-content-margin);
padding-right: var(--wide-content-margin);
}
}
/* keep a static padding of 20px for wide layouts for container width <912px */
@container page-content-container (max-width: 912px) {
.editor-container.wide-layout,
.page-title-container {
padding-left: var(--normal-content-margin);
padding-right: var(--normal-content-margin);
}
}
/* keep a static padding of 20px for normal layouts for container width <760px */
@container page-content-container (max-width: 760px) {
.editor-container:not(.wide-layout),
.page-title-container {
padding-left: var(--normal-content-margin);
padding-right: var(--normal-content-margin);
}
.page-summary-container {
display: none;
}
}
/* end layout config */
+2
View File
@@ -17,6 +17,8 @@ export const SUPPORTED_LANGUAGES: ILanguageOption[] = [
{ label: "Українська", value: "ua" },
{ label: "Polski", value: "pl" },
{ label: "한국어", value: "ko" },
{ label: "Indonesian", value: "id" },
{ label: "Română", value: "ro" },
];
export const LANGUAGE_STORAGE_KEY = "userLanguage";
@@ -499,6 +499,7 @@
"re_generate_key": "Znovu generovat klíč",
"export": "Exportovat",
"member": "{count, plural, one{# člen} few{# členové} other{# členů}}",
"new_password_must_be_different_from_old_password": "Nové heslo musí být odlišné od starého hesla",
"project_view": {
"sort_by": {
@@ -1774,6 +1775,12 @@
"remove_filters_to_see_all_cycles": "Odeberte filtry pro zobrazení všech cyklů",
"remove_search_criteria_to_see_all_cycles": "Odeberte kritéria pro zobrazení všech cyklů",
"only_completed_cycles_can_be_archived": "Lze archivovat pouze dokončené cykly",
"start_date": "Začátek data",
"end_date": "Konec data",
"in_your_timezone": "V časovém pásmu",
"transfer_work_items": "Převést {count} pracovních položek",
"date_range": "Období data",
"add_date": "Přidat datum",
"active_cycle": {
"label": "Aktivní cyklus",
"progress": "Pokrok",
@@ -499,6 +499,8 @@
"re_generate_key": "Schlüssel neu generieren",
"export": "Exportieren",
"member": "{count, plural, one{# Mitglied} few{# Mitglieder} other{# Mitglieder}}",
"new_password_must_be_different_from_old_password": "Das neue Passwort muss von dem alten Passwort abweichen",
"project_view": {
"sort_by": {
"created_at": "Erstellt am",
@@ -1747,6 +1749,12 @@
"remove_filters_to_see_all_cycles": "Entfernen Sie Filter, um alle Zyklen anzuzeigen",
"remove_search_criteria_to_see_all_cycles": "Entfernen Sie Suchkriterien, um alle Zyklen anzuzeigen",
"only_completed_cycles_can_be_archived": "Nur abgeschlossene Zyklen können archiviert werden",
"start_date": "Startdatum",
"end_date": "Enddatum",
"in_your_timezone": "In Ihrer Zeitzone",
"transfer_work_items": "Übertragen von {count} Arbeitselementen",
"date_range": "Datumsbereich",
"add_date": "Datum hinzufügen",
"active_cycle": {
"label": "Aktiver Zyklus",
"progress": "Fortschritt",
@@ -2321,4 +2329,4 @@
"label": "{count, plural, one {Modul} few {Module} other {Module}}",
"no_module": "Kein Modul"
}
}
}
+1 -1
View File
@@ -9,7 +9,7 @@
"workspace": "Workspace",
"views": "Views",
"analytics": "Analytics",
"work_items": "Work Items",
"work_items": "Work items",
"cycles": "Cycles",
"modules": "Modules",
"intake": "Intake",
@@ -331,6 +331,9 @@
"re_generate_key": "Re-generate key",
"export": "Export",
"member": "{count, plural, one{# member} other{# members}}",
"new_password_must_be_different_from_old_password": "New password must be different from old password",
"edited": "edited",
"bot": "Bot",
"project_view": {
"sort_by": {
@@ -1606,6 +1609,12 @@
"remove_filters_to_see_all_cycles": "Remove the filters to see all cycles",
"remove_search_criteria_to_see_all_cycles": "Remove the search criteria to see all cycles",
"only_completed_cycles_can_be_archived": "Only completed cycles can be archived",
"start_date": "Start date",
"end_date": "End date",
"in_your_timezone": "In your timezone",
"transfer_work_items": "Transfer {count} work items",
"date_range": "Date range",
"add_date": "Add date",
"active_cycle": {
"label": "Active cycle",
"progress": "Progress",
@@ -503,6 +503,9 @@
"re_generate_key": "Regenerar clave",
"export": "Exportar",
"member": "{count, plural, one{# miembro} other{# miembros}}",
"new_password_must_be_different_from_old_password": "La nueva contraseña debe ser diferente a la contraseña anterior",
"edited": "Modificado",
"bot": "Bot",
"project_view": {
"sort_by": {
@@ -1776,6 +1779,12 @@
"remove_filters_to_see_all_cycles": "Elimina los filtros para ver todos los ciclos",
"remove_search_criteria_to_see_all_cycles": "Elimina los criterios de búsqueda para ver todos los ciclos",
"only_completed_cycles_can_be_archived": "Solo los ciclos completados pueden ser archivados",
"start_date": "Fecha de inicio",
"end_date": "Fecha de finalización",
"in_your_timezone": "En tu zona horaria",
"transfer_work_items": "Transferir {count} elementos de trabajo",
"date_range": "Rango de fechas",
"add_date": "Agregar fecha",
"active_cycle": {
"label": "Ciclo activo",
"progress": "Progreso",
@@ -501,6 +501,9 @@
"re_generate_key": "Régénérer la clé",
"export": "Exporter",
"member": "{count, plural, one{# membre} other{# membres}}",
"new_password_must_be_different_from_old_password": "Le nouveau mot de passe doit être différent du mot de passe précédent",
"edited": "Modifié",
"bot": "Bot",
"project_view": {
"sort_by": {
@@ -1774,6 +1777,12 @@
"remove_filters_to_see_all_cycles": "Supprimez les filtres pour voir tous les cycles",
"remove_search_criteria_to_see_all_cycles": "Supprimez les critères de recherche pour voir tous les cycles",
"only_completed_cycles_can_be_archived": "Seuls les cycles terminés peuvent être archivés",
"start_date": "Date de début",
"end_date": "Date de fin",
"in_your_timezone": "Dans votre fuseau horaire",
"transfer_work_items": "Transférer {count} éléments de travail",
"date_range": "Plage de dates",
"add_date": "Ajouter une date",
"active_cycle": {
"label": "Cycle actif",
"progress": "Progression",
File diff suppressed because it is too large Load Diff
@@ -500,6 +500,10 @@
"re_generate_key": "Rigenera chiave",
"export": "Esporta",
"member": "{count, plural, one {# membro} other {# membri}}",
"new_password_must_be_different_from_old_password": "La nuova password deve essere diversa dalla password precedente",
"edited": "Modificato",
"bot": "Bot",
"project_view": {
"sort_by": {
@@ -1772,6 +1776,12 @@
"remove_filters_to_see_all_cycles": "Rimuovi i filtri per vedere tutti i cicli",
"remove_search_criteria_to_see_all_cycles": "Rimuovi i criteri di ricerca per vedere tutti i cicli",
"only_completed_cycles_can_be_archived": "Solo i cicli completati possono essere archiviati",
"start_date": "Data di inizio",
"end_date": "Data di fine",
"in_your_timezone": "Nel tuo fuso orario",
"transfer_work_items": "Trasferisci {count} elementi di lavoro",
"date_range": "Intervallo di date",
"add_date": "Aggiungi data",
"active_cycle": {
"label": "Ciclo attivo",
"progress": "Avanzamento",
@@ -501,6 +501,9 @@
"re_generate_key": "キーを再生成",
"export": "エクスポート",
"member": "{count, plural, other{# メンバー}}",
"new_password_must_be_different_from_old_password": "新しいパスワードは古いパスワードと異なる必要があります",
"edited": "編集済み",
"bot": "ボット",
"project_view": {
"sort_by": {
@@ -1774,6 +1777,12 @@
"remove_filters_to_see_all_cycles": "すべてのサイクルを表示するにはフィルターを解除してください",
"remove_search_criteria_to_see_all_cycles": "すべてのサイクルを表示するには検索条件を解除してください",
"only_completed_cycles_can_be_archived": "完了したサイクルのみアーカイブできます",
"start_date": "開始日",
"end_date": "終了日",
"in_your_timezone": "あなたのタイムゾーン",
"transfer_work_items": "作業項目を転送 {count}",
"date_range": "日付範囲",
"add_date": "日付を追加",
"active_cycle": {
"label": "アクティブなサイクル",
"progress": "進捗",
@@ -501,6 +501,9 @@
"re_generate_key": "키 다시 생성",
"export": "내보내기",
"member": "{count, plural, one{# 멤버} other{# 멤버}}",
"new_password_must_be_different_from_old_password": "새 비밀번호는 이전 비밀번호와 다르게 설정해야 합니다",
"edited": "수정됨",
"bot": "봇",
"project_view": {
"sort_by": {
@@ -1776,6 +1779,12 @@
"remove_filters_to_see_all_cycles": "모든 주기를 보려면 필터를 제거하세요",
"remove_search_criteria_to_see_all_cycles": "모든 주기를 보려면 검색 기준을 제거하세요",
"only_completed_cycles_can_be_archived": "완료된 주기만 아카이브할 수 있습니다",
"start_date": "시작일",
"end_date": "종료일",
"in_your_timezone": "내 시간대",
"transfer_work_items": "{count}개의 작업 항목 이전",
"date_range": "날짜 범위",
"add_date": "날짜 추가",
"active_cycle": {
"label": "활성 주기",
"progress": "진행",
+12 -1
View File
@@ -499,6 +499,11 @@
"re_generate_key": "Wygeneruj klucz ponownie",
"export": "Eksportuj",
"member": "{count, plural, one{# członek} few{# członkowie} other{# członków}}",
"new_password_must_be_different_from_old_password": "Nowe hasło musi być innym niż stare hasło",
"edited": "Edytowano",
"bot": "Bot",
"project_view": {
"sort_by": {
"created_at": "Utworzono dnia",
@@ -1747,6 +1752,12 @@
"remove_filters_to_see_all_cycles": "Usuń filtry, aby wyświetlić wszystkie cykle",
"remove_search_criteria_to_see_all_cycles": "Usuń kryteria wyszukiwania, aby wyświetlić wszystkie cykle",
"only_completed_cycles_can_be_archived": "Można archiwizować tylko ukończone cykle",
"start_date": "Data początku",
"end_date": "Data końca",
"in_your_timezone": "W Twojej strefie czasowej",
"transfer_work_items": "Przenieś {count} elementów pracy",
"date_range": "Zakres dat",
"add_date": "Dodaj datę",
"active_cycle": {
"label": "Aktywny cykl",
"progress": "Postęp",
@@ -2321,4 +2332,4 @@
"label": "{count, plural, one {Moduł} few {Moduły} other {Modułów}}",
"no_module": "Brak modułu"
}
}
}
File diff suppressed because it is too large Load Diff
@@ -499,6 +499,9 @@
"re_generate_key": "Перегенерировать ключ",
"export": "Экспорт",
"member": "{count, plural, one{# участник} few{# участника} other{# участников}}",
"new_password_must_be_different_from_old_password": "Новое пароль должен отличаться от старого пароля",
"edited": "Редактировано",
"bot": "Бот",
"project_view": {
"sort_by": {
@@ -1774,6 +1777,12 @@
"remove_filters_to_see_all_cycles": "Снимите фильтры для просмотра всех циклов",
"remove_search_criteria_to_see_all_cycles": "Очистите поиск для просмотра всех циклов",
"only_completed_cycles_can_be_archived": "Только завершённые циклы можно архивировать",
"start_date": "Дата начала",
"end_date": "Дата окончания",
"in_your_timezone": "В вашем часовом поясе",
"transfer_work_items": "Перенести {count} рабочих элементов",
"date_range": "Диапазон дат",
"add_date": "Добавить дату",
"active_cycle": {
"label": "Активный цикл",
"progress": "Прогресс",
@@ -499,6 +499,9 @@
"re_generate_key": "Znova generovať kľúč",
"export": "Exportovať",
"member": "{count, plural, one{# člen} few{# členovia} other{# členov}}",
"new_password_must_be_different_from_old_password": "Nové heslo musí byť odlišné od starého hesla",
"edited": "Upravené",
"bot": "Bot",
"project_view": {
"sort_by": {
@@ -1773,6 +1776,12 @@
"remove_filters_to_see_all_cycles": "Odstráňte filtre pre zobrazenie všetkých cyklov",
"remove_search_criteria_to_see_all_cycles": "Odstráňte kritériá pre zobrazenie všetkých cyklov",
"only_completed_cycles_can_be_archived": "Archivovať je možné iba dokončené cykly",
"start_date": "Dátum začiatku",
"end_date": "Dátum konca",
"in_your_timezone": "Váš časový pásmo",
"transfer_work_items": "Presunúť {count} pracovných položiek",
"date_range": "Dátumový rozsah",
"add_date": "Pridať dátum",
"active_cycle": {
"label": "Aktívny cyklus",
"progress": "Pokrok",
@@ -499,6 +499,10 @@
"re_generate_key": "Повторно згенерувати ключ",
"export": "Експортувати",
"member": "{count, plural, one{# учасник} few{# учасники} other{# учасників}}",
"new_password_must_be_different_from_old_password": "Новий пароль повинен бути відмінним від старого пароля",
"edited": "Редагувано",
"bot": "Бот",
"project_view": {
"sort_by": {
"created_at": "Створено",
@@ -1747,6 +1751,12 @@
"remove_filters_to_see_all_cycles": "Приберіть фільтри, щоб побачити всі цикли",
"remove_search_criteria_to_see_all_cycles": "Приберіть критерії пошуку, щоб побачити всі цикли",
"only_completed_cycles_can_be_archived": "Архівувати можна лише завершені цикли",
"start_date": "Дата початку",
"end_date": "Дата завершення",
"in_your_timezone": "У вашому часовому поясі",
"transfer_work_items": "Перенести {count} робочих одиниць",
"date_range": "Діапазон дат",
"add_date": "Додати дату",
"active_cycle": {
"label": "Активний цикл",
"progress": "Прогрес",
@@ -501,6 +501,9 @@
"re_generate_key": "重新生成密钥",
"export": "导出",
"member": "{count, plural, other{# 成员}}",
"new_password_must_be_different_from_old_password": "新密码必须不同于旧密码",
"edited": "已编辑",
"bot": "机器人",
"project_view": {
"sort_by": {
@@ -1774,6 +1777,12 @@
"remove_filters_to_see_all_cycles": "移除筛选器以查看所有周期",
"remove_search_criteria_to_see_all_cycles": "移除搜索条件以查看所有周期",
"only_completed_cycles_can_be_archived": "只能归档已完成的周期",
"start_date": "开始日期",
"end_date": "结束日期",
"in_your_timezone": "在您的时区",
"transfer_work_items": "转移 {count} 工作项",
"date_range": "日期范围",
"add_date": "添加日期",
"active_cycle": {
"label": "活动周期",
"progress": "进度",
@@ -501,6 +501,9 @@
"re_generate_key": "重新產生金鑰",
"export": "匯出",
"member": "{count, plural, one{# 位成員} other{# 位成員}}",
"new_password_must_be_different_from_old_password": "新密碼必須與舊密碼不同",
"edited": "已編輯",
"bot": "機器人",
"project_view": {
"sort_by": {
@@ -1776,6 +1779,12 @@
"remove_filters_to_see_all_cycles": "移除篩選器以檢視所有週期",
"remove_search_criteria_to_see_all_cycles": "移除搜尋條件以檢視所有週期",
"only_completed_cycles_can_be_archived": "只有已完成的週期可以封存",
"start_date": "開始日期",
"end_date": "結束日期",
"in_your_timezone": "在您的時區",
"transfer_work_items": "轉移 {count} 工作事項",
"date_range": "日期範圍",
"add_date": "新增日期",
"active_cycle": {
"label": "使用中的週期",
"progress": "進度",
+4
View File
@@ -165,6 +165,10 @@ export class TranslationStore {
return import("../locales/pl/translations.json");
case "ko":
return import("../locales/ko/translations.json");
case "id":
return import("../locales/id/translations.json");
case "ro":
return import("../locales/ro/translations.json");
default:
throw new Error(`Unsupported language: ${language}`);
}
+17 -1
View File
@@ -1,4 +1,20 @@
export type TLanguage = "en" | "fr" | "es" | "ja" | "zh-CN" | "zh-TW" | "ru" | "it" | "cs" | "sk" | "de" | "ua" | "pl" | "ko";
export type TLanguage =
| "en"
| "fr"
| "es"
| "ja"
| "zh-CN"
| "zh-TW"
| "ru"
| "it"
| "cs"
| "sk"
| "de"
| "ua"
| "pl"
| "ko"
| "id"
| "ro";
export interface ILanguageOption {
label: string;
+121 -54
View File
@@ -1,14 +1,14 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
"use client";
import React, { useMemo } from "react";
import { AreaChart as CoreAreaChart, Area, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
import React, { useMemo, useState } from "react";
import { Area, Legend, ResponsiveContainer, Tooltip, XAxis, YAxis, Line, ComposedChart, CartesianGrid } from "recharts";
// plane imports
import { AXIS_LINE_CLASSNAME, LABEL_CLASSNAME } from "@plane/constants";
import { AXIS_LABEL_CLASSNAME } from "@plane/constants";
import { TAreaChartProps } from "@plane/types";
// local components
import { CustomXAxisTick, CustomYAxisTick } from "../tick";
import { CustomTooltip } from "../tooltip";
import { getLegendProps } from "../components/legend";
import { CustomXAxisTick, CustomYAxisTick } from "../components/tick";
import { CustomTooltip } from "../components/tooltip";
export const AreaChart = React.memo(<K extends string, T extends string>(props: TAreaChartProps<K, T>) => {
const {
@@ -16,107 +16,174 @@ export const AreaChart = React.memo(<K extends string, T extends string>(props:
areas,
xAxis,
yAxis,
className = "w-full h-96",
className,
legend,
margin,
tickCount = {
x: undefined,
y: 10,
},
showTooltip = true,
comparisonLine,
} = props;
// states
const [activeArea, setActiveArea] = useState<string | null>(null);
const [activeLegend, setActiveLegend] = useState<string | null>(null);
// derived values
const itemKeys = useMemo(() => areas.map((area) => area.key), [areas]);
const itemDotClassNames = useMemo(
() => areas.reduce((acc, area) => ({ ...acc, [area.key]: area.dotClassName }), {}),
const itemLabels: Record<string, string> = useMemo(
() => areas.reduce((acc, area) => ({ ...acc, [area.key]: area.label }), {}),
[areas]
);
const itemDotColors = useMemo(() => areas.reduce((acc, area) => ({ ...acc, [area.key]: area.fill }), {}), [areas]);
const renderAreas = useMemo(
() =>
areas.map((area) => (
<Area
key={area.key}
type="monotone"
type={area.smoothCurves ? "monotone" : "linear"}
dataKey={area.key}
stackId={area.stackId}
className={area.className}
stroke="inherit"
fill="inherit"
fill={area.fill}
opacity={!!activeLegend && activeLegend !== area.key ? 0.1 : 1}
fillOpacity={area.fillOpacity}
strokeOpacity={area.strokeOpacity}
stroke={area.strokeColor}
strokeWidth={2}
style={area.style}
dot={
area.showDot
? {
fill: area.fill,
fillOpacity: 1,
}
: false
}
activeDot={{
stroke: area.fill,
}}
onMouseEnter={() => setActiveArea(area.key)}
onMouseLeave={() => setActiveArea(null)}
className="[&_path]:transition-opacity [&_path]:duration-200"
/>
)),
[areas]
[activeLegend, areas]
);
// create comparison line data for straight line from origin to last point
const comparisonLineData = useMemo(() => {
if (!data || data.length === 0) return [];
// get the last data point
const lastPoint = data[data.length - 1];
// for the y-value in the last point, use its yAxis key value
const lastYValue = lastPoint[yAxis.key] || 0;
// create data for a straight line that has points at each x-axis position
return data.map((item, index) => {
// calculate the y value for this point on the straight line
// using linear interpolation between (0,0) and (last_x, last_y)
const ratio = index / (data.length - 1);
const interpolatedValue = ratio * lastYValue;
return {
[xAxis.key]: item[xAxis.key],
comparisonLine: interpolatedValue,
};
});
}, [data, xAxis.key]);
return (
<div className={className}>
<ResponsiveContainer width="100%" height="100%">
<CoreAreaChart
width={500}
height={300}
<ComposedChart
data={data}
margin={{
top: 5,
right: 30,
left: 20,
bottom: 5,
top: margin?.top === undefined ? 5 : margin.top,
right: margin?.right === undefined ? 30 : margin.right,
bottom: margin?.bottom === undefined ? 5 : margin.bottom,
left: margin?.left === undefined ? 20 : margin.left,
}}
reverseStackOrder
>
<CartesianGrid stroke="rgba(var(--color-border-100), 0.8)" vertical={false} />
<XAxis
dataKey={xAxis.key}
tick={(props) => <CustomXAxisTick {...props} />}
tickLine={{
stroke: "currentColor",
className: AXIS_LINE_CLASSNAME,
}}
axisLine={{
stroke: "currentColor",
className: AXIS_LINE_CLASSNAME,
}}
label={{
value: xAxis.label,
dy: 28,
className: LABEL_CLASSNAME,
}}
tickLine={false}
axisLine={false}
label={
xAxis.label && {
value: xAxis.label,
dy: 28,
className: AXIS_LABEL_CLASSNAME,
}
}
tickCount={tickCount.x}
/>
<YAxis
domain={yAxis.domain}
tickLine={{
stroke: "currentColor",
className: AXIS_LINE_CLASSNAME,
}}
axisLine={{
stroke: "currentColor",
className: AXIS_LINE_CLASSNAME,
}}
label={{
value: yAxis.label,
angle: -90,
position: "bottom",
offset: -24,
dx: -16,
className: LABEL_CLASSNAME,
}}
tickLine={false}
axisLine={false}
label={
yAxis.label && {
value: yAxis.label,
angle: -90,
position: "bottom",
offset: -24,
dx: -16,
className: AXIS_LABEL_CLASSNAME,
}
}
tick={(props) => <CustomYAxisTick {...props} />}
tickCount={tickCount.y}
allowDecimals={!!yAxis.allowDecimals}
/>
{legend && (
// @ts-expect-error recharts types are not up to date
<Legend
formatter={(value) => itemLabels[value]}
onMouseEnter={(payload) => setActiveLegend(payload.value)}
onMouseLeave={() => setActiveLegend(null)}
{...getLegendProps(legend)}
/>
)}
{showTooltip && (
<Tooltip
cursor={{ fill: "currentColor", className: "text-custom-background-90/80 cursor-pointer" }}
cursor={{
stroke: "rgba(var(--color-text-300))",
strokeDasharray: "4 4",
}}
wrapperStyle={{
pointerEvents: "auto",
}}
content={({ active, label, payload }) => (
<CustomTooltip
active={active}
activeKey={activeArea}
label={label}
payload={payload}
itemKeys={itemKeys}
itemDotClassNames={itemDotClassNames}
itemLabels={itemLabels}
itemDotColors={itemDotColors}
/>
)}
/>
)}
{renderAreas}
</CoreAreaChart>
{comparisonLine && (
<Line
data={comparisonLineData}
type="linear"
dataKey="comparisonLine"
stroke={comparisonLine.strokeColor}
fill={comparisonLine.strokeColor}
strokeWidth={2}
strokeDasharray={comparisonLine.dashedLine ? "4 4" : "none"}
activeDot={false}
legendType="none"
name="Comparison line"
/>
)}
</ComposedChart>
</ResponsiveContainer>
</div>
);
+34 -15
View File
@@ -15,10 +15,25 @@ const calculatePercentage = <K extends string, T extends string>(
};
const MIN_BAR_HEIGHT_FOR_INTERNAL_TEXT = 14; // Minimum height needed to show text inside
const BAR_BORDER_RADIUS = 2; // Border radius for each bar
const BAR_TOP_BORDER_RADIUS = 4; // Border radius for each bar
const BAR_BOTTOM_BORDER_RADIUS = 4; // Border radius for each bar
export const CustomBar = React.memo((props: any) => {
const { fill, x, y, width, height, dataKey, stackKeys, payload, textClassName, showPercentage } = props;
const {
opacity,
fill,
x,
y,
width,
height,
dataKey,
stackKeys,
payload,
textClassName,
showPercentage,
showTopBorderRadius,
showBottomBorderRadius,
} = props;
// Calculate text position
const TEXT_PADDING_Y = Math.min(6, Math.abs(MIN_BAR_HEIGHT_FOR_INTERNAL_TEXT - height / 2));
const textY = y + height - TEXT_PADDING_Y; // Position inside bar if tall enough
@@ -34,24 +49,28 @@ export const CustomBar = React.memo((props: any) => {
// bar percentage is a number
!Number.isNaN(currentBarPercentage);
const topBorderRadius = showTopBorderRadius ? BAR_TOP_BORDER_RADIUS : 0;
const bottomBorderRadius = showBottomBorderRadius ? BAR_BOTTOM_BORDER_RADIUS : 0;
if (!height) return null;
return (
<g>
<path
d={`
M${x + BAR_BORDER_RADIUS},${y + height}
L${x + BAR_BORDER_RADIUS},${y}
Q${x},${y} ${x},${y + BAR_BORDER_RADIUS}
L${x},${y + height - BAR_BORDER_RADIUS}
Q${x},${y + height} ${x + BAR_BORDER_RADIUS},${y + height}
L${x + width - BAR_BORDER_RADIUS},${y + height}
Q${x + width},${y + height} ${x + width},${y + height - BAR_BORDER_RADIUS}
L${x + width},${y + BAR_BORDER_RADIUS}
Q${x + width},${y} ${x + width - BAR_BORDER_RADIUS},${y}
L${x + BAR_BORDER_RADIUS},${y}
`}
className={cn("transition-colors duration-200", fill)}
fill="currentColor"
M${x},${y + topBorderRadius}
Q${x},${y} ${x + topBorderRadius},${y}
L${x + width - topBorderRadius},${y}
Q${x + width},${y} ${x + width},${y + topBorderRadius}
L${x + width},${y + height - bottomBorderRadius}
Q${x + width},${y + height} ${x + width - bottomBorderRadius},${y + height}
L${x + bottomBorderRadius},${y + height}
Q${x},${y + height} ${x},${y + height - bottomBorderRadius}
Z
`}
className="transition-opacity duration-200"
fill={fill}
opacity={opacity}
/>
{showText && (
<text
+77 -39
View File
@@ -1,14 +1,24 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
"use client";
import React, { useMemo } from "react";
import { BarChart as CoreBarChart, Bar, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
import React, { useMemo, useState } from "react";
import {
BarChart as CoreBarChart,
Bar,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
Legend,
CartesianGrid,
} from "recharts";
// plane imports
import { AXIS_LINE_CLASSNAME, LABEL_CLASSNAME } from "@plane/constants";
import { AXIS_LABEL_CLASSNAME } from "@plane/constants";
import { TBarChartProps } from "@plane/types";
// local components
import { CustomXAxisTick, CustomYAxisTick } from "../tick";
import { CustomTooltip } from "../tooltip";
import { getLegendProps } from "../components/legend";
import { CustomXAxisTick, CustomYAxisTick } from "../components/tick";
import { CustomTooltip } from "../components/tooltip";
import { CustomBar } from "./bar";
export const BarChart = React.memo(<K extends string, T extends string>(props: TBarChartProps<K, T>) => {
@@ -18,19 +28,25 @@ export const BarChart = React.memo(<K extends string, T extends string>(props: T
xAxis,
yAxis,
barSize = 40,
className = "w-full h-96",
className,
legend,
margin,
tickCount = {
x: undefined,
y: 10,
},
showTooltip = true,
} = props;
// states
const [activeBar, setActiveBar] = useState<string | null>(null);
const [activeLegend, setActiveLegend] = useState<string | null>(null);
// derived values
const stackKeys = useMemo(() => bars.map((bar) => bar.key), [bars]);
const stackDotClassNames = useMemo(
() => bars.reduce((acc, bar) => ({ ...acc, [bar.key]: bar.dotClassName }), {}),
const stackLabels: Record<string, string> = useMemo(
() => bars.reduce((acc, bar) => ({ ...acc, [bar.key]: bar.label }), {}),
[bars]
);
const stackDotColors = useMemo(() => bars.reduce((acc, bar) => ({ ...acc, [bar.key]: bar.fill }), {}), [bars]);
const renderBars = useMemo(
() =>
@@ -39,18 +55,29 @@ export const BarChart = React.memo(<K extends string, T extends string>(props: T
key={bar.key}
dataKey={bar.key}
stackId={bar.stackId}
fill={bar.fillClassName}
shape={(shapeProps: any) => (
<CustomBar
{...shapeProps}
stackKeys={stackKeys}
textClassName={bar.textClassName}
showPercentage={bar.showPercentage}
/>
)}
opacity={!!activeLegend && activeLegend !== bar.key ? 0.1 : 1}
fill={bar.fill}
shape={(shapeProps: any) => {
const showTopBorderRadius = bar.showTopBorderRadius?.(shapeProps.dataKey, shapeProps.payload);
const showBottomBorderRadius = bar.showBottomBorderRadius?.(shapeProps.dataKey, shapeProps.payload);
return (
<CustomBar
{...shapeProps}
stackKeys={stackKeys}
textClassName={bar.textClassName}
showPercentage={bar.showPercentage}
showTopBorderRadius={!!showTopBorderRadius}
showBottomBorderRadius={!!showBottomBorderRadius}
/>
);
}}
className="[&_path]:transition-opacity [&_path]:duration-200"
onMouseEnter={() => setActiveBar(bar.key)}
onMouseLeave={() => setActiveBar(null)}
/>
)),
[stackKeys, bars]
[activeLegend, stackKeys, bars]
);
return (
@@ -58,60 +85,71 @@ export const BarChart = React.memo(<K extends string, T extends string>(props: T
<ResponsiveContainer width="100%" height="100%">
<CoreBarChart
data={data}
margin={{ top: 10, right: 10, left: 10, bottom: 40 }}
margin={{
top: margin?.top === undefined ? 5 : margin.top,
right: margin?.right === undefined ? 30 : margin.right,
bottom: margin?.bottom === undefined ? 5 : margin.bottom,
left: margin?.left === undefined ? 20 : margin.left,
}}
barSize={barSize}
className="recharts-wrapper"
>
<CartesianGrid stroke="rgba(var(--color-border-100), 0.8)" vertical={false} />
<XAxis
dataKey={xAxis.key}
tick={(props) => <CustomXAxisTick {...props} />}
tickLine={{
stroke: "currentColor",
className: AXIS_LINE_CLASSNAME,
}}
axisLine={{
stroke: "currentColor",
className: AXIS_LINE_CLASSNAME,
}}
tickLine={false}
axisLine={false}
label={{
value: xAxis.label,
dy: 28,
className: LABEL_CLASSNAME,
className: AXIS_LABEL_CLASSNAME,
}}
tickCount={tickCount.x}
/>
<YAxis
domain={yAxis.domain}
tickLine={{
stroke: "currentColor",
className: AXIS_LINE_CLASSNAME,
}}
axisLine={{
stroke: "currentColor",
className: AXIS_LINE_CLASSNAME,
}}
tickLine={false}
axisLine={false}
label={{
value: yAxis.label,
angle: -90,
position: "bottom",
offset: -24,
dx: -16,
className: LABEL_CLASSNAME,
className: AXIS_LABEL_CLASSNAME,
}}
tick={(props) => <CustomYAxisTick {...props} />}
tickCount={tickCount.y}
allowDecimals={!!yAxis.allowDecimals}
/>
{legend && (
// @ts-expect-error recharts types are not up to date
<Legend
onMouseEnter={(payload) => setActiveLegend(payload.value)}
onMouseLeave={() => setActiveLegend(null)}
formatter={(value) => stackLabels[value]}
{...getLegendProps(legend)}
/>
)}
{showTooltip && (
<Tooltip
cursor={{ fill: "currentColor", className: "text-custom-background-90/80 cursor-pointer" }}
cursor={{
fill: "currentColor",
className: "text-custom-background-90/80 cursor-pointer",
}}
wrapperStyle={{
pointerEvents: "auto",
}}
content={({ active, label, payload }) => (
<CustomTooltip
active={active}
label={label}
payload={payload}
activeKey={activeBar}
itemKeys={stackKeys}
itemDotClassNames={stackDotClassNames}
itemLabels={stackLabels}
itemDotColors={stackDotColors}
/>
)}
/>
@@ -0,0 +1,77 @@
import React from "react";
import { LegendProps } from "recharts";
// plane imports
import { TChartLegend } from "@plane/types";
import { cn } from "@plane/utils";
export const getLegendProps = (args: TChartLegend): LegendProps => {
const { align, layout, verticalAlign } = args;
return {
layout,
align,
verticalAlign,
wrapperStyle: {
display: "flex",
overflow: "hidden",
...(layout === "vertical"
? {
top: 0,
alignItems: "center",
height: "100%",
}
: {
left: 0,
bottom: 0,
width: "100%",
justifyContent: "center",
}),
},
content: <CustomLegend {...args} />,
};
};
const CustomLegend = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> &
Pick<LegendProps, "payload" | "formatter" | "onClick" | "onMouseEnter" | "onMouseLeave"> &
TChartLegend
>((props, ref) => {
const { formatter, layout, onClick, onMouseEnter, onMouseLeave, payload } = props;
if (!payload?.length) return null;
return (
<div
ref={ref}
className={cn("flex items-center px-4 overflow-scroll vertical-scrollbar scrollbar-sm", {
"max-h-full flex-col items-start py-4": layout === "vertical",
})}
>
{payload.map((item, index) => (
<div
key={item.value}
className={cn("flex items-center gap-1.5 text-custom-text-300 text-sm font-medium whitespace-nowrap", {
"px-2": layout === "horizontal",
"py-2": layout === "vertical",
"pl-0 pt-0": index === 0,
"pr-0 pb-0": index === payload.length - 1,
"cursor-pointer": !!props.onClick,
})}
onClick={(e) => onClick?.(item, index, e)}
onMouseEnter={(e) => onMouseEnter?.(item, index, e)}
onMouseLeave={(e) => onMouseLeave?.(item, index, e)}
>
<div
className="flex-shrink-0 size-2 rounded-sm"
style={{
backgroundColor: item.color,
}}
/>
{/* @ts-expect-error recharts types are not up to date */}
{formatter?.(item.value, { value: item.value }, index) ?? item.payload?.name}
</div>
))}
</div>
);
});
CustomLegend.displayName = "CustomLegend";
@@ -2,7 +2,7 @@
import React from "react";
// Common classnames
const AXIS_TICK_CLASSNAME = "fill-custom-text-400 text-sm capitalize";
const AXIS_TICK_CLASSNAME = "fill-custom-text-300 text-sm";
export const CustomXAxisTick = React.memo<any>(({ x, y, payload }: any) => (
<g transform={`translate(${x},${y})`}>
@@ -0,0 +1,60 @@
import React from "react";
import { NameType, Payload, ValueType } from "recharts/types/component/DefaultTooltipContent";
// plane imports
import { Card, ECardSpacing } from "@plane/ui";
import { cn } from "@plane/utils";
type Props = {
active: boolean | undefined;
activeKey?: string | null;
label: string | undefined;
payload: Payload<ValueType, NameType>[] | undefined;
itemKeys: string[];
itemLabels: Record<string, string>;
itemDotColors: Record<string, string>;
};
export const CustomTooltip = React.memo((props: Props) => {
const { active, activeKey, label, payload, itemKeys, itemLabels, itemDotColors } = props;
// derived values
const filteredPayload = payload?.filter((item) => item.dataKey && itemKeys.includes(`${item.dataKey}`));
if (!active || !filteredPayload || !filteredPayload.length) return null;
return (
<Card
className="flex flex-col max-h-[40vh] w-[12rem] overflow-y-scroll vertical-scrollbar scrollbar-sm"
spacing={ECardSpacing.SM}
>
<p className="flex-shrink-0 text-xs text-custom-text-100 font-medium border-b border-custom-border-200 pb-2 truncate">
{label}
</p>
{filteredPayload.map((item) => {
if (!item.dataKey) return null;
return (
<div
key={item?.dataKey}
className={cn("flex items-center gap-2 text-xs transition-opacity", {
"opacity-20": activeKey && item.dataKey !== activeKey,
})}
>
<div className="flex items-center gap-2 truncate">
{itemDotColors[item?.dataKey] && (
<div
className="flex-shrink-0 size-2 rounded-sm"
style={{
backgroundColor: itemDotColors[item?.dataKey],
}}
/>
)}
<span className="text-custom-text-300 truncate">{itemLabels[item?.dataKey]}:</span>
</div>
<span className="flex-shrink-0 font-medium text-custom-text-200">{item?.value}</span>
</div>
);
})}
</Card>
);
});
CustomTooltip.displayName = "CustomTooltip";
+94 -47
View File
@@ -1,107 +1,154 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
"use client";
import React, { useMemo } from "react";
import { LineChart as CoreLineChart, Line, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
import React, { useMemo, useState } from "react";
import {
CartesianGrid,
LineChart as CoreLineChart,
Legend,
Line,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
// plane imports
import { AXIS_LINE_CLASSNAME, LABEL_CLASSNAME } from "@plane/constants";
import { AXIS_LABEL_CLASSNAME } from "@plane/constants";
import { TLineChartProps } from "@plane/types";
// local components
import { CustomXAxisTick, CustomYAxisTick } from "../tick";
import { CustomTooltip } from "../tooltip";
import { getLegendProps } from "../components/legend";
import { CustomXAxisTick, CustomYAxisTick } from "../components/tick";
import { CustomTooltip } from "../components/tooltip";
export const LineChart = React.memo(<K extends string, T extends string>(props: TLineChartProps<K, T>) => {
const {
data,
lines,
margin,
xAxis,
yAxis,
className = "w-full h-96",
className,
tickCount = {
x: undefined,
y: 10,
},
legend,
showTooltip = true,
} = props;
// states
const [activeLine, setActiveLine] = useState<string | null>(null);
const [activeLegend, setActiveLegend] = useState<string | null>(null);
// derived values
const itemKeys = useMemo(() => lines.map((line) => line.key), [lines]);
const itemDotClassNames = useMemo(
() => lines.reduce((acc, line) => ({ ...acc, [line.key]: line.dotClassName }), {}),
const itemLabels: Record<string, string> = useMemo(
() => lines.reduce((acc, line) => ({ ...acc, [line.key]: line.label }), {}),
[lines]
);
const itemDotColors = useMemo(() => lines.reduce((acc, line) => ({ ...acc, [line.key]: line.stroke }), {}), [lines]);
const renderLines = useMemo(
() =>
lines.map((line) => (
<Line key={line.key} dataKey={line.key} type="monotone" className={line.className} stroke="inherit" />
<Line
key={line.key}
dataKey={line.key}
type={line.smoothCurves ? "monotone" : "linear"}
className="[&_path]:transition-opacity [&_path]:duration-200"
opacity={!!activeLegend && activeLegend !== line.key ? 0.1 : 1}
fill={line.fill}
stroke={line.stroke}
strokeWidth={2}
strokeDasharray={line.dashedLine ? "4 4" : "none"}
dot={
line.showDot
? {
fill: line.fill,
fillOpacity: 1,
}
: false
}
activeDot={{
stroke: line.fill,
}}
onMouseEnter={() => setActiveLine(line.key)}
onMouseLeave={() => setActiveLine(null)}
/>
)),
[lines]
[activeLegend, lines]
);
return (
<div className={className}>
<ResponsiveContainer width="100%" height="100%">
<CoreLineChart
width={500}
height={300}
data={data}
margin={{
top: 5,
right: 30,
left: 20,
bottom: 5,
top: margin?.top === undefined ? 5 : margin.top,
right: margin?.right === undefined ? 30 : margin.right,
bottom: margin?.bottom === undefined ? 5 : margin.bottom,
left: margin?.left === undefined ? 20 : margin.left,
}}
>
<CartesianGrid stroke="rgba(var(--color-border-100), 0.8)" vertical={false} />
<XAxis
dataKey={xAxis.key}
tick={(props) => <CustomXAxisTick {...props} />}
tickLine={{
stroke: "currentColor",
className: AXIS_LINE_CLASSNAME,
}}
axisLine={{
stroke: "currentColor",
className: AXIS_LINE_CLASSNAME,
}}
label={{
value: xAxis.label,
dy: 28,
className: LABEL_CLASSNAME,
}}
tickLine={false}
axisLine={false}
label={
xAxis.label && {
value: xAxis.label,
dy: 28,
className: AXIS_LABEL_CLASSNAME,
}
}
tickCount={tickCount.x}
/>
<YAxis
domain={yAxis.domain}
tickLine={{
stroke: "currentColor",
className: AXIS_LINE_CLASSNAME,
}}
axisLine={{
stroke: "currentColor",
className: AXIS_LINE_CLASSNAME,
}}
label={{
value: yAxis.label,
angle: -90,
position: "bottom",
offset: -24,
dx: -16,
className: LABEL_CLASSNAME,
}}
tickLine={false}
axisLine={false}
label={
yAxis.label && {
value: yAxis.label,
angle: -90,
position: "bottom",
offset: -24,
dx: -16,
className: AXIS_LABEL_CLASSNAME,
}
}
tick={(props) => <CustomYAxisTick {...props} />}
tickCount={tickCount.y}
allowDecimals={!!yAxis.allowDecimals}
/>
{legend && (
// @ts-expect-error recharts types are not up to date
<Legend
onMouseEnter={(payload) => setActiveLegend(payload.value)}
onMouseLeave={() => setActiveLegend(null)}
formatter={(value) => itemLabels[value]}
{...getLegendProps(legend)}
/>
)}
{showTooltip && (
<Tooltip
cursor={{ fill: "currentColor", className: "text-custom-background-90/80 cursor-pointer" }}
cursor={{
stroke: "rgba(var(--color-text-300))",
strokeDasharray: "4 4",
}}
wrapperStyle={{
pointerEvents: "auto",
}}
content={({ active, label, payload }) => (
<CustomTooltip
active={active}
activeKey={activeLine}
label={label}
payload={payload}
itemKeys={itemKeys}
itemDotClassNames={itemDotClassNames}
itemLabels={itemLabels}
itemDotColors={itemDotColors}
/>
)}
/>
@@ -0,0 +1,31 @@
import React from "react";
import { Sector } from "recharts";
export const CustomActiveShape = React.memo((props: any) => {
const { cx, cy, cornerRadius, innerRadius, outerRadius, startAngle, endAngle, fill } = props;
return (
<g>
<Sector
cx={cx}
cy={cy}
innerRadius={innerRadius}
outerRadius={outerRadius}
cornerRadius={cornerRadius}
startAngle={startAngle}
endAngle={endAngle}
fill={fill}
/>
<Sector
cx={cx}
cy={cy}
startAngle={startAngle}
endAngle={endAngle}
cornerRadius={cornerRadius}
innerRadius={outerRadius + 6}
outerRadius={outerRadius + 10}
fill={fill}
/>
</g>
);
});
+115 -15
View File
@@ -1,45 +1,145 @@
"use client";
import React, { useMemo } from "react";
import { Cell, PieChart as CorePieChart, Pie, ResponsiveContainer, Tooltip } from "recharts";
import React, { useMemo, useState } from "react";
import { Cell, PieChart as CorePieChart, Label, Legend, Pie, ResponsiveContainer, Tooltip } from "recharts";
// plane imports
import { TPieChartProps } from "@plane/types";
// local components
import { getLegendProps } from "../components/legend";
import { CustomActiveShape } from "./active-shape";
import { CustomPieChartTooltip } from "./tooltip";
export const PieChart = React.memo(<K extends string, T extends string>(props: TPieChartProps<K, T>) => {
const { data, dataKey, cells, className = "w-full h-96", innerRadius, outerRadius, showTooltip = true } = props;
const {
data,
dataKey,
cells,
className,
innerRadius,
legend,
margin,
outerRadius,
showTooltip = true,
showLabel,
customLabel,
centerLabel,
cornerRadius,
paddingAngle,
tooltipLabel,
} = props;
// states
const [activeIndex, setActiveIndex] = useState<number | null>(null);
const [activeLegend, setActiveLegend] = useState<string | null>(null);
const renderCells = useMemo(
() => cells.map((cell) => <Cell key={cell.key} className={cell.className} style={cell.style} />),
[cells]
() =>
cells.map((cell, index) => (
<Cell
key={cell.key}
className="transition-opacity duration-200"
fill={cell.fill}
opacity={!!activeLegend && activeLegend !== cell.key ? 0.1 : 1}
style={{
outline: "none",
}}
onMouseEnter={() => setActiveIndex(index)}
onMouseLeave={() => setActiveIndex(null)}
/>
)),
[activeLegend, cells]
);
return (
<div className={className}>
<ResponsiveContainer width="100%" height="100%">
<CorePieChart
width={500}
height={300}
data={data}
margin={{
top: 5,
right: 30,
left: 20,
bottom: 5,
top: margin?.top === undefined ? 5 : margin.top,
right: margin?.right === undefined ? 30 : margin.right,
bottom: margin?.bottom === undefined ? 5 : margin.bottom,
left: margin?.left === undefined ? 20 : margin.left,
}}
>
<Pie data={data} dataKey={dataKey} cx="50%" cy="50%" innerRadius={innerRadius} outerRadius={outerRadius}>
<Pie
activeIndex={activeIndex === null ? undefined : activeIndex}
onMouseLeave={() => setActiveIndex(null)}
data={data}
dataKey={dataKey}
cx="50%"
cy="50%"
blendStroke
activeShape={<CustomActiveShape />}
innerRadius={innerRadius}
outerRadius={outerRadius}
cornerRadius={cornerRadius}
paddingAngle={paddingAngle}
labelLine={false}
label={
showLabel
? ({ payload, ...props }) => (
<text
className="text-sm font-medium transition-opacity duration-200"
cx={props.cx}
cy={props.cy}
x={props.x}
y={props.y}
textAnchor={props.textAnchor}
dominantBaseline={props.dominantBaseline}
fill="rgba(var(--color-text-200))"
opacity={!!activeLegend && activeLegend !== payload.key ? 0.1 : 1}
>
{customLabel?.(payload.count) ?? payload.count}
</text>
)
: undefined
}
>
{renderCells}
{centerLabel && (
<Label
value={centerLabel.text}
fill={centerLabel.fill}
position="center"
opacity={activeLegend ? 0.1 : 1}
style={centerLabel.style}
className={centerLabel.className}
/>
)}
</Pie>
{legend && (
// @ts-expect-error recharts types are not up to date
<Legend
onMouseEnter={(payload) => {
// @ts-expect-error recharts types are not up to date
const key: string | undefined = payload.payload?.key;
if (!key) return;
setActiveLegend(key);
setActiveIndex(null);
}}
onMouseLeave={() => setActiveLegend(null)}
{...getLegendProps(legend)}
/>
)}
{showTooltip && (
<Tooltip
cursor={{ fill: "currentColor", className: "text-custom-background-90/80 cursor-pointer" }}
cursor={{
fill: "currentColor",
className: "text-custom-background-90/80 cursor-pointer",
}}
wrapperStyle={{
pointerEvents: "auto",
}}
content={({ active, payload }) => {
if (!active || !payload || !payload.length) return null;
const cellData = cells.find((c) => c.key === payload[0].name);
const cellData = cells.find((c) => c.key === payload[0].payload.key);
if (!cellData) return null;
return <CustomPieChartTooltip dotClassName={cellData.dotClassName} label={dataKey} payload={payload} />;
const label = tooltipLabel
? typeof tooltipLabel === "function"
? tooltipLabel(payload[0]?.payload?.payload)
: tooltipLabel
: dataKey;
return <CustomPieChartTooltip dotColor={cellData.fill} label={label} payload={payload} />;
}}
/>
)}

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