Compare commits

...

45 Commits

Author SHA1 Message Date
Aaryan Khandelwal 22836ea03e fix: editor placeholder color (#6430) 2025-01-20 15:52:23 +05:30
Anmol Singh Bhatia 13cc8b0e96 chore: workspace view loading state improvement (#6423) 2025-01-17 19:50:56 +05:30
Vamsi Krishna 9addcde553 fix: padding issue cycle active cycles menu (#6424) 2025-01-17 19:46:19 +05:30
Bavisetti Narayan 26a9b7fced fix: dashboard completed issues count (#6422) 2025-01-17 18:03:28 +05:30
Vamsi Krishna 62dd80874f fix: issue store condition (#6420) 2025-01-17 16:53:33 +05:30
Anmol Singh Bhatia 9ae1ce0a9a chore: helper function added and code refactor (#6419) 2025-01-17 15:41:34 +05:30
Bavisetti Narayan 00cc338c07 [WEB-3039] fix: assignee count in dashboard (#6418)
* fix: assignee count in dashboard

* fix: removed the extra filter
2025-01-17 15:24:03 +05:30
Anmol Singh Bhatia 4432be15e4 [WEB-3166] chore: global empty state components (#6414)
* chore: detailed and simple empty state component added

* chore: section empty state component added

* chore: asset path helper hook added
2025-01-17 13:52:08 +05:30
Anmol Singh Bhatia 20893c6017 fix: draft issue fetch (#6416) 2025-01-17 13:51:17 +05:30
Nikhil 95f43a7bb6 fix: admin login when the user is not present (#6399) 2025-01-16 19:59:04 +05:30
Akshita Goyal fd7eedc343 [WEB-3096] feat: stickies page (#6380)
* feat: added independent stickies page

* chore: randomized sticky color

* chore: search in stickies

* feat: dnd

* fix: quick links

* fix: stickies abrupt rendering

* fix: handled edge cases for dnd

* fix: empty states

* fix: build and lint

* fix: handled new sticky when last sticky is emoty

* fix: new sticky condition

* refactor: stickies empty states, store

* chore: update stickies empty states

* fix: random sticky color

* fix: header

* refactor: better error handling

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
Co-authored-by: Aaryan Khandelwal <aaryankhandu123@gmail.com>
2025-01-16 19:57:51 +05:30
Aaryan Khandelwal d2c9b437f4 [WEB-3164] chore: update tailwindcss version (#6413)
* [WEB-3164] chore: update tailwind version

* chore: add tailwindcss to editor package

* chore: remove tailwindcss package from separate apps
2025-01-16 19:45:05 +05:30
Prateek Shourya 2f57d0e138 fix: add client validation and find similar language from browser config if exact match is not available (#6412) 2025-01-16 17:34:41 +05:30
Prateek Shourya 59ddc02a31 [WEB-3153] improvement: add support for nested translations and ICU formatting (#6411)
* improvement: add support for nested translations and ICU formatting

* chore: comment update
2025-01-16 17:29:57 +05:30
Anmol Singh Bhatia 3ac20741d9 fix: issue visibility (#6410) 2025-01-16 15:08:33 +05:30
Aaryan Khandelwal 8ea0772a1b fix: page open in new tab action (#6408) 2025-01-16 13:41:02 +05:30
sriram veeraghanta bddad8932b feat: Chinese language support (#6407)
* feat: chinese language support

* fix: following iso standards
2025-01-16 13:29:57 +05:30
Vamsi Krishna 8acea7f599 chore: cycle store restructuring (#6405) 2025-01-15 21:05:05 +05:30
M. Palanikannan a908bf9edd [PE-232] chore: management of disabled extensions (#6317)
* chore: added mobile editor required changes

* fix: turbo.json

---------

Co-authored-by: Lakhan <Lakhanbaheti9@gmail.com>
2025-01-15 20:23:09 +05:30
Vamsi Krishna 79fff4744a chore: modified functionality and placement for live button (#6400) 2025-01-15 16:21:00 +05:30
Vamsi Krishna 369d927321 [WEB-3102]fix: transfer issues count (#6384)
* fix: updated cancelled issues count into pending issues

* chore: code refactor

* chore: added pending issues count

* chore: added pending issues count

* chore: added pending_issues to api response

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
2025-01-15 16:19:22 +05:30
M. Palanikannan 996d11de12 [PE-210] feat: editor performance (#6269)
* bump: upgrade editor

* fix: remove editor ref in use

* fix: added editor state to reduce rerenders

* fix: add editor rerendering optimization

* fix: wrong condition in scroll summary

* fix: removing ref usage internally in read only editor as well

* fix: remove unused methods from read only editor

* fix: add editable prop again

* regression: added the types for onHeadingChange

* fix: types

* fix: improve the check condition
2025-01-15 16:18:49 +05:30
Vamsi Krishna 0345336d90 chore: removed guests from assignees filters (#6402) 2025-01-15 16:05:56 +05:30
Vamsi Krishna 75d14e7c3a fix: removed redundant custom menu (#6388) 2025-01-15 16:00:26 +05:30
Vamsi Krishna 76fdb81249 chore: added current cycle to cycle dropdown (#6376) 2025-01-15 15:59:57 +05:30
Prateek Shourya 88669af141 fix: hide transfer issues option from cycles list when used outside project scope (#6401) 2025-01-15 15:56:35 +05:30
Anmol Singh Bhatia 71dcbd938e [WEB-3078] chore: empty state config (#6397)
* chore: empty state config updated

* chore: code refactor

* chore: date range picker icon updated

* fix: tree map content css

---------

Co-authored-by: Prateek Shourya <prateekshourya29@gmail.com>
2025-01-15 15:23:30 +05:30
sriram veeraghanta 4060412b18 fix: upgrade django version to fix vulneribility 2025-01-15 14:42:36 +05:30
Zohir Tamda 9d715683f7 fix: correcting french translations. (#6383) 2025-01-15 13:56:46 +05:30
sriram veeraghanta 0eb97e32cd fix: ui kit export updated 2025-01-14 18:12:58 +05:30
sriram veeraghanta 3f708e451c fix: update tailwind config package 2025-01-14 17:09:40 +05:30
sriram veeraghanta e962770a5f fix: adding propel ui kit to packages 2025-01-14 16:50:41 +05:30
sriram veeraghanta 4bd6ee5014 fix: setting up shared state package 2025-01-14 16:37:42 +05:30
sriram veeraghanta e9372adcf4 chore: moving web constants to packages 2025-01-14 16:30:43 +05:30
sriram veeraghanta ae3b588081 fix: minor redirection issue on interceptor 2025-01-13 19:54:19 +05:30
Prateek Shourya 7183cffdb7 [WEB-3128] improvement: made tab list items modular for independent use and added few icons (#6387)
* chore: added bar and tree map icons

* improvement: made tab list items modular for independent use
2025-01-13 19:21:01 +05:30
sriram veeraghanta c779fc095c fix: deprecated dashboard fix 2025-01-13 16:29:53 +05:30
sriram veeraghanta b5493a31f8 fix: home widget reorder fix (#6386) 2025-01-13 16:27:50 +05:30
sriram veeraghanta 25eb727eb9 fix: error handling for workspace invite bg task (#6385) 2025-01-13 14:33:59 +05:30
Prateek Shourya ade4d290f5 [WEB-3066] refactor: replace Space Services with Services Package (#6353)
* [WEB-3066] refactor: replace Space Services with Services Package

* chore: minor improvements

* fix: type

---------

Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
2025-01-11 15:16:11 +05:30
sriram veeraghanta 39ca600091 fix: workspace user perferences model and migrations 2025-01-10 19:54:48 +05:30
guru_sainath cfdb3373c9 fix: removed default timezone and added the value from the user details and handle the value in timezone select (#6381) 2025-01-10 18:41:12 +05:30
Bavisetti Narayan 95175ab939 chore: changed the creator of page when duplicated (#6378) 2025-01-10 18:08:23 +05:30
Anmol Singh Bhatia 42e928138c chore: no load improvement (#6375) 2025-01-10 18:01:47 +05:30
Sangeetha 8db51ab295 [WEB 3099] fix: listing bots as project members in recents (#6377)
* fix: listing bots as project members

* chore: added a filter to removed inactive users

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
2025-01-10 17:58:04 +05:30
279 changed files with 6841 additions and 3881 deletions
-3
View File
@@ -2,7 +2,4 @@ module.exports = {
root: true,
extends: ["@plane/eslint-config/next.js"],
parser: "@typescript-eslint/parser",
parserOptions: {
project: true,
},
};
+1 -2
View File
@@ -35,19 +35,18 @@
"react-dom": "^18.3.1",
"react-hook-form": "7.51.5",
"swr": "^2.2.4",
"tailwindcss": "3.3.2",
"uuid": "^9.0.1",
"zxcvbn": "^4.4.2"
},
"devDependencies": {
"@plane/eslint-config": "*",
"@plane/tailwind-config": "*",
"@plane/typescript-config": "*",
"@types/node": "18.16.1",
"@types/react": "^18.3.11",
"@types/react-dom": "^18.2.18",
"@types/uuid": "^9.0.8",
"@types/zxcvbn": "^4.4.4",
"tailwind-config-custom": "*",
"typescript": "5.3.3"
}
}
+2 -1
View File
@@ -1,4 +1,5 @@
const sharedConfig = require("tailwind-config-custom/tailwind.config.js");
/* eslint-disable @typescript-eslint/no-require-imports */
const sharedConfig = require("@plane/tailwind-config/tailwind.config.js");
module.exports = {
presets: [sharedConfig],
+3 -3
View File
@@ -1,6 +1,6 @@
# Module imports
from .base import BaseSerializer
from plane.db.models import Dashboard, Widget
from plane.db.models import DeprecatedDashboard, DeprecatedWidget
# Third party frameworks
from rest_framework import serializers
@@ -8,7 +8,7 @@ from rest_framework import serializers
class DashboardSerializer(BaseSerializer):
class Meta:
model = Dashboard
model = DeprecatedDashboard
fields = "__all__"
@@ -17,5 +17,5 @@ class WidgetSerializer(BaseSerializer):
widget_filters = serializers.JSONField(read_only=True)
class Meta:
model = Widget
model = DeprecatedWidget
fields = ["id", "key", "is_visible", "widget_filters"]
+5 -4
View File
@@ -177,9 +177,10 @@ class ProjectRecentVisitSerializer(serializers.ModelSerializer):
fields = ["id", "name", "logo_props", "project_members", "identifier"]
def get_project_members(self, obj):
members = ProjectMember.objects.filter(project_id=obj.id).values_list(
"member", flat=True
)
members = ProjectMember.objects.filter(
project_id=obj.id, member__is_bot=False, is_active=True
).values_list("member", flat=True)
return members
@@ -248,7 +249,7 @@ class WorkspaceHomePreferenceSerializer(BaseSerializer):
class Meta:
model = WorkspaceHomePreference
fields = ["key", "is_enabled", "sort_order"]
read_only_fields = ["worspace", "created_by", "update_by"]
read_only_fields = ["workspace", "created_by", "updated_by"]
class StickySerializer(BaseSerializer):
+3 -3
View File
@@ -29,7 +29,7 @@ from plane.app.views import (
WorkspaceDraftIssueViewSet,
QuickLinkViewSet,
UserRecentVisitViewSet,
WorkspacePreferenceViewSet,
WorkspaceHomePreferenceViewSet,
WorkspaceStickyViewSet,
)
@@ -233,12 +233,12 @@ urlpatterns = [
# Widgets
path(
"workspaces/<str:slug>/home-preferences/",
WorkspacePreferenceViewSet.as_view(),
WorkspaceHomePreferenceViewSet.as_view(),
name="workspace-home-preference",
),
path(
"workspaces/<str:slug>/home-preferences/<str:key>/",
WorkspacePreferenceViewSet.as_view(),
WorkspaceHomePreferenceViewSet.as_view(),
name="workspace-home-preference",
),
path(
+2 -1
View File
@@ -41,7 +41,8 @@ from .workspace.base import (
from .workspace.draft import WorkspaceDraftIssueViewSet
from .workspace.preference import WorkspacePreferenceViewSet
from .workspace.home import WorkspaceHomePreferenceViewSet
from .workspace.favorite import (
WorkspaceFavoriteEndpoint,
WorkspaceFavoriteGroupEndpoint,
+14
View File
@@ -132,6 +132,18 @@ class CycleViewSet(BaseViewSet):
),
)
)
.annotate(
pending_issues=Count(
"issue_cycle__issue__id",
distinct=True,
filter=Q(
issue_cycle__issue__state__group__in=["backlog", "unstarted", "started"],
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
issue_cycle__deleted_at__isnull=True,
),
)
)
.annotate(
status=Case(
When(
@@ -214,6 +226,7 @@ class CycleViewSet(BaseViewSet):
"is_favorite",
"total_issues",
"completed_issues",
"pending_issues",
"assignee_ids",
"status",
"version",
@@ -245,6 +258,7 @@ class CycleViewSet(BaseViewSet):
# meta fields
"is_favorite",
"total_issues",
"pending_issues",
"completed_issues",
"assignee_ids",
"status",
+22 -16
View File
@@ -32,15 +32,15 @@ from plane.app.serializers import (
WidgetSerializer,
)
from plane.db.models import (
Dashboard,
DashboardWidget,
DeprecatedDashboard,
DeprecatedDashboardWidget,
Issue,
IssueActivity,
FileAsset,
IssueLink,
IssueRelation,
Project,
Widget,
DeprecatedWidget,
WorkspaceMember,
CycleIssue,
)
@@ -53,10 +53,10 @@ from .. import BaseAPIView
def dashboard_overview_stats(self, request, slug):
assigned_issues = (
Issue.issue_objects.filter(
(Q(assignees__in=[request.user]) & Q(issue_assignee__deleted_at__isnull=True)),
project__project_projectmember__is_active=True,
project__project_projectmember__member=request.user,
workspace__slug=slug,
assignees__in=[request.user],
)
.filter(
Q(
@@ -133,10 +133,13 @@ def dashboard_overview_stats(self, request, slug):
completed_issues_count = (
Issue.issue_objects.filter(
(
Q(assignees__in=[request.user])
& Q(issue_assignee__deleted_at__isnull=True)
),
workspace__slug=slug,
project__project_projectmember__is_active=True,
project__project_projectmember__member=request.user,
assignees__in=[request.user],
state__group="completed",
)
.filter(
@@ -176,10 +179,13 @@ def dashboard_assigned_issues(self, request, slug):
# get all the assigned issues
assigned_issues = (
Issue.issue_objects.filter(
(
Q(assignees__in=[request.user])
& Q(issue_assignee__deleted_at__isnull=True)
),
workspace__slug=slug,
project__project_projectmember__member=request.user,
project__project_projectmember__is_active=True,
assignees__in=[request.user],
)
.filter(**filters)
.select_related("workspace", "project", "state", "parent")
@@ -687,7 +693,7 @@ class DashboardEndpoint(BaseAPIView):
if not dashboard_id:
dashboard_type = request.GET.get("dashboard_type", None)
if dashboard_type == "home":
dashboard, created = Dashboard.objects.get_or_create(
dashboard, created = DeprecatedDashboard.objects.get_or_create(
type_identifier=dashboard_type,
owned_by=request.user,
is_default=True,
@@ -707,24 +713,24 @@ class DashboardEndpoint(BaseAPIView):
updated_dashboard_widgets = []
for widget_key in widgets_to_fetch:
widget = Widget.objects.filter(key=widget_key).values_list(
"id", flat=True
)
widget = DeprecatedWidget.objects.filter(
key=widget_key
).values_list("id", flat=True)
if widget:
updated_dashboard_widgets.append(
DashboardWidget(
DeprecatedDashboardWidget(
widget_id=widget, dashboard_id=dashboard.id
)
)
DashboardWidget.objects.bulk_create(
DeprecatedDashboardWidget.objects.bulk_create(
updated_dashboard_widgets, batch_size=100
)
widgets = (
Widget.objects.annotate(
DeprecatedWidget.objects.annotate(
is_visible=Exists(
DashboardWidget.objects.filter(
DeprecatedDashboardWidget.objects.filter(
widget_id=OuterRef("pk"),
dashboard_id=dashboard.id,
is_visible=True,
@@ -733,7 +739,7 @@ class DashboardEndpoint(BaseAPIView):
)
.annotate(
dashboard_filters=Subquery(
DashboardWidget.objects.filter(
DeprecatedDashboardWidget.objects.filter(
widget_id=OuterRef("pk"),
dashboard_id=dashboard.id,
filters__isnull=False,
@@ -792,7 +798,7 @@ class DashboardEndpoint(BaseAPIView):
class WidgetsEndpoint(BaseAPIView):
def patch(self, request, dashboard_id, widget_id):
dashboard_widget = DashboardWidget.objects.filter(
dashboard_widget = DeprecatedDashboardWidget.objects.filter(
widget_id=widget_id, dashboard_id=dashboard_id
).first()
dashboard_widget.is_visible = request.data.get(
+2
View File
@@ -573,6 +573,8 @@ class PageDuplicateEndpoint(BaseAPIView):
page.name = f"{page.name} (Copy)"
page.description_binary = None
page.owned_by = request.user
page.created_by = request.user
page.updated_by = request.user
page.save()
for project_id in project_ids:
@@ -5,17 +5,12 @@ from plane.app.permissions import allow_permission, ROLE
from plane.db.models import Workspace
from plane.app.serializers.workspace import WorkspaceHomePreferenceSerializer
# Django imports
from django.db.models import Count
# Third party imports
from rest_framework.response import Response
from rest_framework import status
class WorkspacePreferenceViewSet(BaseAPIView):
class WorkspaceHomePreferenceViewSet(BaseAPIView):
model = WorkspaceHomePreference
def get_serializer_class(self):
@@ -72,7 +67,7 @@ class WorkspacePreferenceViewSet(BaseAPIView):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")
def patch(self, request, slug, key):
preference = WorkspaceHomePreference.objects.filter(
key=key, workspace__slug=slug
key=key, workspace__slug=slug, user=request.user
).first()
if preference:
@@ -39,9 +39,9 @@ class WorkspaceStickyViewSet(BaseViewSet):
)
def list(self, request, slug):
query = request.query_params.get("query", False)
stickies = self.get_queryset()
stickies = self.get_queryset().order_by("-sort_order")
if query:
stickies = stickies.filter(name__icontains=query)
stickies = stickies.filter(description_stripped__icontains=query)
return self.paginate(
request=request,
@@ -49,7 +49,7 @@ class WorkspaceStickyViewSet(BaseViewSet):
on_results=lambda stickies: StickySerializer(stickies, many=True).data,
default_per_page=20,
)
@allow_permission(allowed_roles=[], creator=True, model=Sticky, level="WORKSPACE")
def partial_update(self, request, *args, **kwargs):
return super().partial_update(request, *args, **kwargs)
+20 -5
View File
@@ -375,8 +375,11 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView):
state_distribution = (
Issue.issue_objects.filter(
(
Q(assignees__in=[user_id])
& Q(issue_assignee__deleted_at__isnull=True)
),
workspace__slug=slug,
assignees__in=[user_id],
project__project_projectmember__member=request.user,
project__project_projectmember__is_active=True,
)
@@ -391,8 +394,11 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView):
priority_distribution = (
Issue.issue_objects.filter(
(
Q(assignees__in=[user_id])
& Q(issue_assignee__deleted_at__isnull=True)
),
workspace__slug=slug,
assignees__in=[user_id],
project__project_projectmember__member=request.user,
project__project_projectmember__is_active=True,
)
@@ -426,8 +432,11 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView):
assigned_issues_count = (
Issue.issue_objects.filter(
(
Q(assignees__in=[user_id])
& Q(issue_assignee__deleted_at__isnull=True)
),
workspace__slug=slug,
assignees__in=[user_id],
project__project_projectmember__member=request.user,
project__project_projectmember__is_active=True,
)
@@ -438,8 +447,11 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView):
pending_issues_count = (
Issue.issue_objects.filter(
~Q(state__group__in=["completed", "cancelled"]),
(
Q(assignees__in=[user_id])
& Q(issue_assignee__deleted_at__isnull=True)
),
workspace__slug=slug,
assignees__in=[user_id],
project__project_projectmember__member=request.user,
project__project_projectmember__is_active=True,
)
@@ -449,8 +461,11 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView):
completed_issues_count = (
Issue.issue_objects.filter(
(
Q(assignees__in=[user_id])
& Q(issue_assignee__deleted_at__isnull=True)
),
workspace__slug=slug,
assignees__in=[user_id],
state__group="completed",
project__project_projectmember__member=request.user,
project__project_projectmember__is_active=True,
@@ -16,9 +16,9 @@ from plane.utils.exception_logger import log_exception
@shared_task
def workspace_invitation(email, workspace_id, token, current_site, invitor):
def workspace_invitation(email, workspace_id, token, current_site, inviter):
try:
user = User.objects.get(email=invitor)
user = User.objects.get(email=inviter)
workspace = Workspace.objects.get(pk=workspace_id)
workspace_member_invite = WorkspaceMemberInvite.objects.get(
@@ -26,7 +26,7 @@ def workspace_invitation(email, workspace_id, token, current_site, invitor):
)
# Relative link
relative_link = f"/workspace-invitations/?invitation_id={workspace_member_invite.id}&email={email}&slug={workspace.slug}"
relative_link = f"/workspace-invitations/?invitation_id={workspace_member_invite.id}&email={email}&slug={workspace.slug}" # noqa: E501
# The complete url including the domain
abs_url = str(current_site) + relative_link
@@ -42,7 +42,7 @@ def workspace_invitation(email, workspace_id, token, current_site, invitor):
) = get_email_configuration()
# Subject of the email
subject = f"{user.first_name or user.display_name or user.email} has invited you to join them in {workspace.name} on Plane"
subject = f"{user.first_name or user.display_name or user.email} has invited you to join them in {workspace.name} on Plane" # noqa: E501
context = {
"email": email,
@@ -78,11 +78,9 @@ def workspace_invitation(email, workspace_id, token, current_site, invitor):
)
msg.attach_alternative(html_content, "text/html")
msg.send()
logging.getLogger("plane").info("Email sent succesfully")
logging.getLogger("plane").info("Email sent successfully")
return
except (Workspace.DoesNotExist, WorkspaceMemberInvite.DoesNotExist) as e:
log_exception(e)
except (Workspace.DoesNotExist, WorkspaceMemberInvite.DoesNotExist):
return
except Exception as e:
log_exception(e)
@@ -0,0 +1,87 @@
# Generated by Django 4.2.17 on 2025-01-09 14:43
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import uuid
class Migration(migrations.Migration):
dependencies = [
('db', '0089_workspacehomepreference_and_more'),
]
operations = [
migrations.RenameModel(
old_name='Dashboard',
new_name='DeprecatedDashboard',
),
migrations.RenameModel(
old_name='DashboardWidget',
new_name='DeprecatedDashboardWidget',
),
migrations.RenameModel(
old_name='Widget',
new_name='DeprecatedWidget',
),
migrations.AlterModelOptions(
name='deprecateddashboard',
options={'ordering': ('-created_at',), 'verbose_name': 'DeprecatedDashboard', 'verbose_name_plural': 'DeprecatedDashboards'},
),
migrations.AlterModelOptions(
name='deprecateddashboardwidget',
options={'ordering': ('-created_at',), 'verbose_name': 'Deprecated Dashboard Widget', 'verbose_name_plural': 'Deprecated Dashboard Widgets'},
),
migrations.AlterModelOptions(
name='deprecatedwidget',
options={'ordering': ('-created_at',), 'verbose_name': 'DeprecatedWidget', 'verbose_name_plural': 'DeprecatedWidgets'},
),
migrations.AlterField(
model_name='workspacehomepreference',
name='sort_order',
field=models.FloatField(default=65535),
),
migrations.AlterModelTable(
name='deprecateddashboard',
table='deprecated_dashboards',
),
migrations.AlterModelTable(
name='deprecateddashboardwidget',
table='deprecated_dashboard_widgets',
),
migrations.AlterModelTable(
name='deprecatedwidget',
table='deprecated_widgets',
),
migrations.CreateModel(
name='WorkspaceUserPreference',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')),
('deleted_at', models.DateTimeField(blank=True, null=True, verbose_name='Deleted At')),
('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
('key', models.CharField(max_length=255)),
('is_pinned', models.BooleanField(default=False)),
('sort_order', models.FloatField(default=65535)),
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_user_preferences', to=settings.AUTH_USER_MODEL)),
('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_user_preferences', to='db.workspace')),
],
options={
'verbose_name': 'Workspace User Preference',
'verbose_name_plural': 'Workspace User Preferences',
'db_table': 'workspace_user_preferences',
'ordering': ('-created_at',),
},
),
migrations.AddConstraint(
model_name='workspaceuserpreference',
constraint=models.UniqueConstraint(condition=models.Q(('deleted_at__isnull', True)), fields=('workspace', 'user', 'key'), name='workspace_user_preferences_unique_workspace_user_key_when_deleted_at_null'),
),
migrations.AlterUniqueTogether(
name='workspaceuserpreference',
unique_together={('workspace', 'user', 'key', 'deleted_at')},
),
]
+1 -1
View File
@@ -3,7 +3,7 @@ from .api import APIActivityLog, APIToken
from .asset import FileAsset
from .base import BaseModel
from .cycle import Cycle, CycleIssue, CycleUserProperties
from .dashboard import Dashboard, DashboardWidget, Widget
from .dashboard import DeprecatedDashboard, DeprecatedDashboardWidget, DeprecatedWidget
from .deploy_board import DeployBoard
from .draft import (
DraftIssue,
+14 -14
View File
@@ -8,7 +8,7 @@ from ..mixins import TimeAuditModel
from .base import BaseModel
class Dashboard(BaseModel):
class DeprecatedDashboard(BaseModel):
DASHBOARD_CHOICES = (
("workspace", "Workspace"),
("project", "Project"),
@@ -36,13 +36,13 @@ class Dashboard(BaseModel):
return f"{self.name}"
class Meta:
verbose_name = "Dashboard"
verbose_name_plural = "Dashboards"
db_table = "dashboards"
verbose_name = "DeprecatedDashboard"
verbose_name_plural = "DeprecatedDashboards"
db_table = "deprecated_dashboards"
ordering = ("-created_at",)
class Widget(TimeAuditModel):
class DeprecatedWidget(TimeAuditModel):
id = models.UUIDField(
default=uuid.uuid4, unique=True, editable=False, db_index=True, primary_key=True
)
@@ -55,18 +55,18 @@ class Widget(TimeAuditModel):
return f"{self.key}"
class Meta:
verbose_name = "Widget"
verbose_name_plural = "Widgets"
db_table = "widgets"
verbose_name = "DeprecatedWidget"
verbose_name_plural = "DeprecatedWidgets"
db_table = "deprecated_widgets"
ordering = ("-created_at",)
class DashboardWidget(BaseModel):
class DeprecatedDashboardWidget(BaseModel):
widget = models.ForeignKey(
Widget, on_delete=models.CASCADE, related_name="dashboard_widgets"
DeprecatedWidget, on_delete=models.CASCADE, related_name="dashboard_widgets"
)
dashboard = models.ForeignKey(
Dashboard, on_delete=models.CASCADE, related_name="dashboard_widgets"
DeprecatedDashboard, on_delete=models.CASCADE, related_name="dashboard_widgets"
)
is_visible = models.BooleanField(default=True)
sort_order = models.FloatField(default=65535)
@@ -86,7 +86,7 @@ class DashboardWidget(BaseModel):
name="dashboard_widget_unique_widget_dashboard_when_deleted_at_null",
)
]
verbose_name = "Dashboard Widget"
verbose_name_plural = "Dashboard Widgets"
db_table = "dashboard_widgets"
verbose_name = "Deprecated Dashboard Widget"
verbose_name_plural = "Deprecated Dashboard Widgets"
db_table = "deprecated_dashboard_widgets"
ordering = ("-created_at",)
+9
View File
@@ -5,6 +5,9 @@ from django.db import models
# Module imports
from .base import BaseModel
# Third party imports
from plane.utils.html_processor import strip_tags
class Sticky(BaseModel):
name = models.TextField(null=True, blank=True)
@@ -33,6 +36,12 @@ class Sticky(BaseModel):
ordering = ("-created_at",)
def save(self, *args, **kwargs):
# Strip the html tags using html parser
self.description_stripped = (
None
if (self.description_html == "" or self.description_html is None)
else strip_tags(self.description_html)
)
if self._state.adding:
# Get the maximum sequence value from the database
last_id = Sticky.objects.filter(workspace=self.workspace).aggregate(
+43 -1
View File
@@ -1,4 +1,5 @@
# Python imports
from django.db.models.functions import Ln
import pytz
# Django imports
@@ -345,6 +346,8 @@ class WorkspaceUserLink(WorkspaceBaseModel):
class WorkspaceHomePreference(BaseModel):
"""Preference for the home page of a workspace for a user"""
class HomeWidgetKeys(models.TextChoices):
QUICK_LINKS = "quick_links", "Quick Links"
RECENTS = "recents", "Recents"
@@ -365,7 +368,7 @@ class WorkspaceHomePreference(BaseModel):
key = models.CharField(max_length=255)
is_enabled = models.BooleanField(default=True)
config = models.JSONField(default=dict)
sort_order = models.PositiveIntegerField(default=65535)
sort_order = models.FloatField(default=65535)
class Meta:
unique_together = ["workspace", "user", "key", "deleted_at"]
@@ -383,3 +386,42 @@ class WorkspaceHomePreference(BaseModel):
def __str__(self):
return f"{self.workspace.name} {self.user.email} {self.key}"
class WorkspaceUserPreference(BaseModel):
"""Preference for the workspace for a user"""
class UserPreferenceKeys(models.TextChoices):
CYCLES = "cycles", "Cycles"
VIEWS = "views", "Views"
ANALYTICS = "analytics", "Analytics"
PROJECTS = "projects", "Projects"
workspace = models.ForeignKey(
"db.Workspace",
on_delete=models.CASCADE,
related_name="workspace_user_preferences",
)
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="workspace_user_preferences",
)
key = models.CharField(max_length=255)
is_pinned = models.BooleanField(default=False)
sort_order = models.FloatField(default=65535)
class Meta:
unique_together = ["workspace", "user", "key", "deleted_at"]
constraints = [
models.UniqueConstraint(
fields=["workspace", "user", "key"],
condition=models.Q(deleted_at__isnull=True),
name="workspace_user_preferences_unique_workspace_user_key_when_deleted_at_null",
)
]
verbose_name = "Workspace User Preference"
verbose_name_plural = "Workspace User Preferences"
db_table = "workspace_user_preferences"
ordering = ("-created_at",)
+9 -9
View File
@@ -290,11 +290,12 @@ class InstanceAdminSignInEndpoint(View):
# Fetch the user
user = User.objects.filter(email=email).first()
# is_active
if not user.is_active:
# Error out if the user is not present
if not user:
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["ADMIN_USER_DEACTIVATED"],
error_message="ADMIN_USER_DEACTIVATED",
error_code=AUTHENTICATION_ERROR_CODES["ADMIN_USER_DOES_NOT_EXIST"],
error_message="ADMIN_USER_DOES_NOT_EXIST",
payload={"email": email},
)
url = urljoin(
base_host(request=request, is_admin=True),
@@ -302,12 +303,11 @@ class InstanceAdminSignInEndpoint(View):
)
return HttpResponseRedirect(url)
# Error out if the user is not present
if not user:
# is_active
if not user.is_active:
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["ADMIN_USER_DOES_NOT_EXIST"],
error_message="ADMIN_USER_DOES_NOT_EXIST",
payload={"email": email},
error_code=AUTHENTICATION_ERROR_CODES["ADMIN_USER_DEACTIVATED"],
error_message="ADMIN_USER_DEACTIVATED",
)
url = urljoin(
base_host(request=request, is_admin=True),
+1 -1
View File
@@ -1,7 +1,7 @@
# base requirements
# django
Django==4.2.17
Django==4.2.18
# rest framework
djangorestframework==3.15.2
# postgres
+6 -6
View File
@@ -16,10 +16,10 @@
"author": "",
"license": "ISC",
"dependencies": {
"@hocuspocus/extension-database": "^2.11.3",
"@hocuspocus/extension-logger": "^2.11.3",
"@hocuspocus/extension-redis": "^2.13.5",
"@hocuspocus/server": "^2.11.3",
"@hocuspocus/extension-database": "^2.15.0",
"@hocuspocus/extension-logger": "^2.15.0",
"@hocuspocus/extension-redis": "^2.15.0",
"@hocuspocus/server": "^2.15.0",
"@plane/constants": "*",
"@plane/editor": "*",
"@plane/types": "*",
@@ -40,9 +40,9 @@
"pino-http": "^10.3.0",
"pino-pretty": "^11.2.2",
"uuid": "^10.0.0",
"y-prosemirror": "^1.2.9",
"y-prosemirror": "^1.2.15",
"y-protocols": "^1.0.6",
"yjs": "^13.6.14"
"yjs": "^13.6.20"
},
"devDependencies": {
"@babel/cli": "^7.25.6",
+78
View File
@@ -0,0 +1,78 @@
// types
import { TXAxisValues, TYAxisValues } from "@plane/types";
export const ANALYTICS_TABS = [
{ key: "scope_and_demand", title: "Scope and Demand" },
{ key: "custom", title: "Custom Analytics" },
];
export const ANALYTICS_X_AXIS_VALUES: { value: TXAxisValues; label: string }[] =
[
{
value: "state_id",
label: "State name",
},
{
value: "state__group",
label: "State group",
},
{
value: "priority",
label: "Priority",
},
{
value: "labels__id",
label: "Label",
},
{
value: "assignees__id",
label: "Assignee",
},
{
value: "estimate_point__value",
label: "Estimate point",
},
{
value: "issue_cycle__cycle_id",
label: "Cycle",
},
{
value: "issue_module__module_id",
label: "Module",
},
{
value: "completed_at",
label: "Completed date",
},
{
value: "target_date",
label: "Due date",
},
{
value: "start_date",
label: "Start date",
},
{
value: "created_at",
label: "Created date",
},
];
export const ANALYTICS_Y_AXIS_VALUES: { value: TYAxisValues; label: string }[] =
[
{
value: "issue_count",
label: "Issue Count",
},
{
value: "estimate",
label: "Estimate",
},
];
export const ANALYTICS_DATE_KEYS = [
"completed_at",
"target_date",
"start_date",
"created_at",
];
+10
View File
@@ -0,0 +1,10 @@
export const ISSUE_REACTION_EMOJI_CODES = [
"128077",
"128078",
"128516",
"128165",
"128533",
"129505",
"9992",
"128064",
];
+4
View File
@@ -23,3 +23,7 @@ export const WEBSITE_URL =
// support email
export const SUPPORT_EMAIL =
process.env.NEXT_PUBLIC_SUPPORT_EMAIL || "support@plane.so";
// marketing links
export const MARKETING_PRICING_PAGE_LINK = "https://plane.so/pricing";
export const MARKETING_CONTACT_US_PAGE_LINK = "https://plane.so/contact";
export const MARKETING_PLANE_ONE_PAGE_LINK = "https://plane.so/one";
+5
View File
@@ -0,0 +1,5 @@
export enum E_ARCHIVE_ERROR_CODES {
"INVALID_ARCHIVE_STATE_GROUP" = 4091,
"INVALID_ISSUE_START_DATE" = 4101,
"INVALID_ISSUE_TARGET_DATE" = 4102,
}
+4
View File
@@ -0,0 +1,4 @@
export enum E_SORT_ORDER {
ASC = "asc",
DESC = "desc",
}
@@ -1,7 +1,4 @@
// nivo
import { Theme } from "@nivo/core";
export const CHARTS_THEME: Theme = {
export const CHARTS_THEME = {
background: "transparent",
text: {
color: "rgb(var(--color-text-200))",
@@ -29,7 +26,7 @@ export const CHARTS_THEME: Theme = {
},
};
export const DEFAULT_MARGIN = {
export const CHART_DEFAULT_MARGIN = {
top: 50,
right: 50,
bottom: 50,
+4
View File
@@ -1,8 +1,11 @@
export * from "./ai";
export * from "./analytics";
export * from "./auth";
export * from "./endpoints";
export * from "./event";
export * from "./file";
export * from "./filter";
export * from "./graph";
export * from "./instance";
export * from "./issue";
export * from "./metadata";
@@ -10,3 +13,4 @@ export * from "./state";
export * from "./swr";
export * from "./user";
export * from "./workspace";
export * from "./stickies";
+23
View File
@@ -43,3 +43,26 @@ export const ARCHIVABLE_STATE_GROUPS = [
STATE_GROUPS.completed.key,
STATE_GROUPS.cancelled.key,
];
export const PROGRESS_STATE_GROUPS_DETAILS = [
{
key: "completed_issues",
title: "Completed",
color: "#16A34A",
},
{
key: "started_issues",
title: "Started",
color: "#F59E0B",
},
{
key: "unstarted_issues",
title: "Unstarted",
color: "#3A3A3A",
},
{
key: "backlog_issues",
title: "Backlog",
color: "#A3A3A3",
},
];
+1
View File
@@ -0,0 +1 @@
export const STICKIES_PER_PAGE = 30;
+7 -9
View File
@@ -12,14 +12,12 @@
"exports": {
".": {
"types": "./dist/index.d.mts",
"import": "./dist/index.mjs",
"module": "./dist/index.mjs"
"import": "./dist/index.mjs"
},
"./lib": {
"require": "./dist/lib.js",
"types": "./dist/lib.d.mts",
"import": "./dist/lib.mjs",
"module": "./dist/lib.mjs"
"import": "./dist/lib.mjs"
}
},
"scripts": {
@@ -36,7 +34,7 @@
},
"dependencies": {
"@floating-ui/react": "^0.26.4",
"@hocuspocus/provider": "^2.13.5",
"@hocuspocus/provider": "^2.15.0",
"@plane/types": "*",
"@plane/ui": "*",
"@plane/utils": "*",
@@ -67,21 +65,21 @@
"prosemirror-codemark": "^0.4.2",
"prosemirror-utils": "^1.2.2",
"tippy.js": "^6.3.7",
"tiptap-markdown": "^0.8.9",
"tiptap-markdown": "^0.8.10",
"uuid": "^10.0.0",
"y-indexeddb": "^9.0.12",
"y-prosemirror": "^1.2.5",
"y-prosemirror": "^1.2.15",
"y-protocols": "^1.0.6",
"yjs": "^13.6.15"
"yjs": "^13.6.20"
},
"devDependencies": {
"@plane/eslint-config": "*",
"@plane/tailwind-config": "*",
"@plane/typescript-config": "*",
"@types/node": "18.15.3",
"@types/react": "^18.3.11",
"@types/react-dom": "^18.2.18",
"postcss": "^8.4.38",
"tailwind-config-custom": "*",
"tsup": "^7.2.0",
"typescript": "5.3.3"
},
@@ -1,5 +1,6 @@
import { HocuspocusProvider } from "@hocuspocus/provider";
import { Extensions } from "@tiptap/core";
import { AnyExtension } from "@tiptap/core";
import { SlashCommands } from "@/extensions";
// plane editor types
import { TIssueEmbedConfig } from "@/plane-editor/types";
@@ -13,15 +14,24 @@ type Props = {
userDetails: TUserDetails;
};
export const DocumentEditorAdditionalExtensions = (_props: Props) => {
const { disabledExtensions } = _props;
const extensions: Extensions = disabledExtensions?.includes("slash-commands")
? []
: [
SlashCommands({
disabledExtensions,
}),
];
return extensions;
type ExtensionConfig = {
isEnabled: (disabledExtensions: TExtensions[]) => boolean;
getExtension: (props: Props) => AnyExtension;
};
const extensionRegistry: ExtensionConfig[] = [
{
isEnabled: (disabledExtensions) => !disabledExtensions.includes("slash-commands"),
getExtension: () => SlashCommands({}),
},
];
export const DocumentEditorAdditionalExtensions = (_props: Props) => {
const { disabledExtensions = [] } = _props;
const documentExtensions = extensionRegistry
.filter((config) => config.isEnabled(disabledExtensions))
.map((config) => config.getExtension(_props));
return documentExtensions;
};
@@ -4,7 +4,7 @@ import { TSlashCommandAdditionalOption } from "@/extensions";
import { TExtensions } from "@/types";
type Props = {
disabledExtensions: TExtensions[];
disabledExtensions?: TExtensions[];
};
export const coreEditorAdditionalSlashCommandOptions = (props: Props): TSlashCommandAdditionalOption[] => {
@@ -202,8 +202,7 @@ export const ImageItem = (editor: Editor): EditorMenuItem<"image"> => ({
key: "image",
name: "Image",
isActive: () => editor?.isActive("image") || editor?.isActive("imageComponent"),
command: ({ savedSelection }) =>
insertImage({ editor, event: "insert", pos: savedSelection?.from ?? editor.state.selection.from }),
command: () => insertImage({ editor, event: "insert", pos: editor.state.selection.from }),
icon: ImageIcon,
});
@@ -39,11 +39,11 @@ import {
setText,
} from "@/helpers/editor-commands";
// types
import { CommandProps, ISlashCommandItem, TExtensions, TSlashCommandSectionKeys } from "@/types";
import { CommandProps, ISlashCommandItem, TSlashCommandSectionKeys } from "@/types";
// plane editor extensions
import { coreEditorAdditionalSlashCommandOptions } from "@/plane-editor/extensions";
// local types
import { TSlashCommandAdditionalOption } from "./root";
import { TExtensionProps } from "./root";
export type TSlashCommandSection = {
key: TSlashCommandSectionKeys;
@@ -51,13 +51,8 @@ export type TSlashCommandSection = {
items: ISlashCommandItem[];
};
type TArgs = {
additionalOptions?: TSlashCommandAdditionalOption[];
disabledExtensions: TExtensions[];
};
export const getSlashCommandFilteredSections =
(args: TArgs) =>
(args: TExtensionProps) =>
({ query }: { query: string }): TSlashCommandSection[] => {
const { additionalOptions, disabledExtensions } = args;
const SLASH_COMMAND_SECTIONS: TSlashCommandSection[] = [
@@ -103,9 +103,9 @@ const renderItems = () => {
};
};
type TExtensionProps = {
export type TExtensionProps = {
additionalOptions?: TSlashCommandAdditionalOption[];
disabledExtensions: TExtensions[];
disabledExtensions?: TExtensions[];
};
export const SlashCommands = (props: TExtensionProps) =>
@@ -1,27 +1,21 @@
import { MutableRefObject } from "react";
import { Selection } from "@tiptap/pm/state";
import { Editor } from "@tiptap/react";
export const insertContentAtSavedSelection = (
editorRef: MutableRefObject<Editor | null>,
content: string,
savedSelection: Selection
) => {
if (!editorRef.current || editorRef.current.isDestroyed) {
export const insertContentAtSavedSelection = (editor: Editor, content: string) => {
if (!editor || editor.isDestroyed) {
console.error("Editor reference is not available or has been destroyed.");
return;
}
if (!savedSelection) {
if (!editor.state.selection) {
console.error("Saved selection is invalid.");
return;
}
const docSize = editorRef.current.state.doc.content.size;
const safePosition = Math.max(0, Math.min(savedSelection.anchor, docSize));
const docSize = editor.state.doc.content.size;
const safePosition = Math.max(0, Math.min(editor.state.selection.anchor, docSize));
try {
editorRef.current.chain().focus().insertContentAt(safePosition, content).run();
editor.chain().focus().insertContentAt(safePosition, content).run();
} catch (error) {
console.error("An error occurred while inserting content at saved selection:", error);
}
+49 -76
View File
@@ -1,12 +1,11 @@
import { useImperativeHandle, useRef, MutableRefObject, useState, useEffect } from "react";
import { HocuspocusProvider } from "@hocuspocus/provider";
import { DOMSerializer } from "@tiptap/pm/model";
import { Selection } from "@tiptap/pm/state";
import { EditorProps } from "@tiptap/pm/view";
import { useEditor as useTiptapEditor, Editor, Extensions } from "@tiptap/react";
import { useEditor as useTiptapEditor, Extensions } from "@tiptap/react";
import { useImperativeHandle, MutableRefObject, useEffect } from "react";
import * as Y from "yjs";
// components
import { EditorMenuItem, getEditorMenuItems } from "@/components/menus";
import { getEditorMenuItems } from "@/components/menus";
// extensions
import { CoreEditorExtensions } from "@/extensions";
// helpers
@@ -71,14 +70,12 @@ export const useEditor = (props: CustomEditorProps) => {
provider,
autofocus = false,
} = props;
// states
const [savedSelection, setSavedSelection] = useState<Selection | null>(null);
// refs
const editorRef: MutableRefObject<Editor | null> = useRef(null);
const savedSelectionRef = useRef(savedSelection);
const editor = useTiptapEditor(
{
editable,
immediatelyRender: false,
shouldRerenderOnTransaction: false,
autofocus,
editorProps: {
...CoreEditorProps({
@@ -100,8 +97,7 @@ export const useEditor = (props: CustomEditorProps) => {
],
content: typeof initialValue === "string" && initialValue.trim() !== "" ? initialValue : "<p></p>",
onCreate: () => handleEditorReady?.(true),
onTransaction: ({ editor }) => {
setSavedSelection(editor.state.selection);
onTransaction: () => {
onTransaction?.();
},
onUpdate: ({ editor }) => onChange?.(editor.getJSON(), editor.getHTML()),
@@ -110,23 +106,17 @@ export const useEditor = (props: CustomEditorProps) => {
[editable]
);
// Update the ref whenever savedSelection changes
useEffect(() => {
savedSelectionRef.current = savedSelection;
}, [savedSelection]);
// Effect for syncing SWR data
useEffect(() => {
// value is null when intentionally passed where syncing is not yet
// supported and value is undefined when the data from swr is not populated
if (value === null || value === undefined) return;
if (value == null) return;
if (editor && !editor.isDestroyed && !editor.storage.imageComponent.uploadInProgress) {
try {
editor.commands.setContent(value, false, { preserveWhitespace: "full" });
const currentSavedSelection = savedSelectionRef.current;
if (currentSavedSelection) {
if (editor.state.selection) {
const docLength = editor.state.doc.content.size;
const relativePosition = Math.min(currentSavedSelection.from, docLength - 1);
const relativePosition = Math.min(editor.state.selection.from, docLength - 1);
editor.commands.setTextSelection(relativePosition);
}
} catch (error) {
@@ -138,46 +128,40 @@ export const useEditor = (props: CustomEditorProps) => {
useImperativeHandle(
forwardedRef,
() => ({
blur: () => editorRef.current?.commands.blur(),
blur: () => editor.commands.blur(),
scrollToNodeViaDOMCoordinates(behavior?: ScrollBehavior, pos?: number) {
const resolvedPos = pos ?? savedSelection?.from;
if (!editorRef.current || !resolvedPos) return;
scrollToNodeViaDOMCoordinates(editorRef.current, resolvedPos, behavior);
const resolvedPos = pos ?? editor.state.selection.from;
if (!editor || !resolvedPos) return;
scrollToNodeViaDOMCoordinates(editor, resolvedPos, behavior);
},
getCurrentCursorPosition: () => savedSelection?.from,
getCurrentCursorPosition: () => editor.state.selection.from,
clearEditor: (emitUpdate = false) => {
editorRef.current?.chain().setMeta("skipImageDeletion", true).clearContent(emitUpdate).run();
editor?.chain().setMeta("skipImageDeletion", true).clearContent(emitUpdate).run();
},
setEditorValue: (content: string) => {
editorRef.current?.commands.setContent(content, false, { preserveWhitespace: "full" });
editor?.commands.setContent(content, false, { preserveWhitespace: "full" });
},
setEditorValueAtCursorPosition: (content: string) => {
if (savedSelection) {
insertContentAtSavedSelection(editorRef, content, savedSelection);
if (editor.state.selection) {
insertContentAtSavedSelection(editor, content);
}
},
executeMenuItemCommand: (props) => {
const { itemKey } = props;
const editorItems = getEditorMenuItems(editorRef.current);
const editorItems = getEditorMenuItems(editor);
const getEditorMenuItem = (itemKey: TEditorCommands) => editorItems.find((item) => item.key === itemKey);
const item = getEditorMenuItem(itemKey);
if (item) {
if (item.key === "image") {
(item as EditorMenuItem<"image">).command({
savedSelection: savedSelectionRef.current,
});
} else {
item.command(props);
}
item.command(props);
} else {
console.warn(`No command found for item: ${itemKey}`);
}
},
isMenuItemActive: (props) => {
const { itemKey } = props;
const editorItems = getEditorMenuItems(editorRef.current);
const editorItems = getEditorMenuItems(editor);
const getEditorMenuItem = (itemKey: TEditorCommands) => editorItems.find((item) => item.key === itemKey);
const item = getEditorMenuItem(itemKey);
@@ -187,20 +171,20 @@ export const useEditor = (props: CustomEditorProps) => {
},
onHeadingChange: (callback: (headings: IMarking[]) => void) => {
// Subscribe to update event emitted from headers extension
editorRef.current?.on("update", () => {
callback(editorRef.current?.storage.headingList.headings);
editor?.on("update", () => {
callback(editor?.storage.headingList.headings);
});
// Return a function to unsubscribe to the continuous transactions of
// the editor on unmounting the component that has subscribed to this
// method
return () => {
editorRef.current?.off("update");
editor?.off("update");
};
},
getHeadings: () => editorRef?.current?.storage.headingList.headings,
getHeadings: () => editor?.storage.headingList.headings,
onStateChange: (callback: () => void) => {
// Subscribe to editor state changes
editorRef.current?.on("transaction", () => {
editor?.on("transaction", () => {
callback();
});
@@ -208,17 +192,17 @@ export const useEditor = (props: CustomEditorProps) => {
// the editor on unmounting the component that has subscribed to this
// method
return () => {
editorRef.current?.off("transaction");
editor?.off("transaction");
};
},
getMarkDown: (): string => {
const markdownOutput = editorRef.current?.storage.markdown.getMarkdown();
const markdownOutput = editor?.storage.markdown.getMarkdown();
return markdownOutput;
},
getDocument: () => {
const documentBinary = provider?.document ? Y.encodeStateAsUpdate(provider?.document) : null;
const documentHTML = editorRef.current?.getHTML() ?? "<p></p>";
const documentJSON = editorRef.current?.getJSON() ?? null;
const documentHTML = editor?.getHTML() ?? "<p></p>";
const documentJSON = editor.getJSON() ?? null;
return {
binary: documentBinary,
@@ -227,19 +211,19 @@ export const useEditor = (props: CustomEditorProps) => {
};
},
scrollSummary: (marking: IMarking): void => {
if (!editorRef.current) return;
scrollSummary(editorRef.current, marking);
if (!editor) return;
scrollSummary(editor, marking);
},
isEditorReadyToDiscard: () => editorRef.current?.storage.imageComponent.uploadInProgress === false,
isEditorReadyToDiscard: () => editor?.storage.imageComponent.uploadInProgress === false,
setFocusAtPosition: (position: number) => {
if (!editorRef.current || editorRef.current.isDestroyed) {
if (!editor || editor.isDestroyed) {
console.error("Editor reference is not available or has been destroyed.");
return;
}
try {
const docSize = editorRef.current.state.doc.content.size;
const docSize = editor.state.doc.content.size;
const safePosition = Math.max(0, Math.min(position, docSize));
editorRef.current
editor
.chain()
.insertContentAt(safePosition, [{ type: "paragraph" }])
.focus()
@@ -249,17 +233,17 @@ export const useEditor = (props: CustomEditorProps) => {
}
},
getSelectedText: () => {
if (!editorRef.current) return null;
if (!editor) return null;
const { state } = editorRef.current;
const { state } = editor;
const { from, to, empty } = state.selection;
if (empty) return null;
const nodesArray: string[] = [];
state.doc.nodesBetween(from, to, (node, _pos, parent) => {
if (parent === state.doc && editorRef.current) {
const serializer = DOMSerializer.fromSchema(editorRef.current?.schema);
if (parent === state.doc && editor) {
const serializer = DOMSerializer.fromSchema(editor.schema);
const dom = serializer.serializeNode(node);
const tempDiv = document.createElement("div");
tempDiv.appendChild(dom);
@@ -270,28 +254,21 @@ export const useEditor = (props: CustomEditorProps) => {
return selection;
},
insertText: (contentHTML, insertOnNextLine) => {
if (!editorRef.current) return;
// get selection
const { from, to, empty } = editorRef.current.state.selection;
if (!editor) return;
const { from, to, empty } = editor.state.selection;
if (empty) return;
if (insertOnNextLine) {
// move cursor to the end of the selection and insert a new line
editorRef.current
.chain()
.focus()
.setTextSelection(to)
.insertContent("<br />")
.insertContent(contentHTML)
.run();
editor.chain().focus().setTextSelection(to).insertContent("<br />").insertContent(contentHTML).run();
} else {
// replace selected text with the content provided
editorRef.current.chain().focus().deleteRange({ from, to }).insertContent(contentHTML).run();
editor.chain().focus().deleteRange({ from, to }).insertContent(contentHTML).run();
}
},
getDocumentInfo: () => ({
characters: editorRef?.current?.storage?.characterCount?.characters?.() ?? 0,
paragraphs: getParagraphCount(editorRef?.current?.state),
words: editorRef?.current?.storage?.characterCount?.words?.() ?? 0,
characters: editor?.storage?.characterCount?.characters?.() ?? 0,
paragraphs: getParagraphCount(editor?.state),
words: editor?.storage?.characterCount?.words?.() ?? 0,
}),
setProviderDocument: (value) => {
const document = provider?.document;
@@ -301,16 +278,12 @@ export const useEditor = (props: CustomEditorProps) => {
emitRealTimeUpdate: (message: TDocumentEventsServer) => provider?.sendStateless(message),
listenToRealTimeUpdate: () => provider && { on: provider.on.bind(provider), off: provider.off.bind(provider) },
}),
[editorRef, savedSelection]
[editor]
);
if (!editor) {
return null;
}
// the editorRef is used to access the editor instance from outside the hook
// and should only be used after editor is initialized
editorRef.current = editor;
return editor;
};
@@ -1,7 +1,7 @@
import { useImperativeHandle, useRef, MutableRefObject, useEffect } from "react";
import { HocuspocusProvider } from "@hocuspocus/provider";
import { EditorProps } from "@tiptap/pm/view";
import { useEditor as useCustomEditor, Editor, Extensions } from "@tiptap/react";
import { useEditor as useTiptapEditor, Extensions } from "@tiptap/react";
import { useImperativeHandle, MutableRefObject, useEffect } from "react";
import * as Y from "yjs";
// extensions
import { CoreReadOnlyEditorExtensions } from "@/extensions";
@@ -11,13 +11,7 @@ import { IMarking, scrollSummary } from "@/helpers/scroll-to-node";
// props
import { CoreReadOnlyEditorProps } from "@/props";
// types
import type {
EditorReadOnlyRefApi,
TExtensions,
TDocumentEventsServer,
TFileHandler,
TReadOnlyMentionHandler,
} from "@/types";
import type { EditorReadOnlyRefApi, TExtensions, TFileHandler, TReadOnlyMentionHandler } from "@/types";
interface CustomReadOnlyEditorProps {
disabledExtensions: TExtensions[];
@@ -46,8 +40,10 @@ export const useReadOnlyEditor = (props: CustomReadOnlyEditorProps) => {
provider,
} = props;
const editor = useCustomEditor({
const editor = useTiptapEditor({
editable: false,
immediatelyRender: true,
shouldRerenderOnTransaction: false,
content: typeof initialValue === "string" && initialValue.trim() !== "" ? initialValue : "<p></p>",
editorProps: {
...CoreReadOnlyEditorProps({
@@ -77,23 +73,21 @@ export const useReadOnlyEditor = (props: CustomReadOnlyEditorProps) => {
if (editor && !editor.isDestroyed) editor?.commands.setContent(initialValue, false, { preserveWhitespace: "full" });
}, [editor, initialValue]);
const editorRef: MutableRefObject<Editor | null> = useRef(null);
useImperativeHandle(forwardedRef, () => ({
clearEditor: (emitUpdate = false) => {
editorRef.current?.chain().setMeta("skipImageDeletion", true).clearContent(emitUpdate).run();
editor?.chain().setMeta("skipImageDeletion", true).clearContent(emitUpdate).run();
},
setEditorValue: (content: string) => {
editorRef.current?.commands.setContent(content, false, { preserveWhitespace: "full" });
editor?.commands.setContent(content, false, { preserveWhitespace: "full" });
},
getMarkDown: (): string => {
const markdownOutput = editorRef.current?.storage.markdown.getMarkdown();
const markdownOutput = editor?.storage.markdown.getMarkdown();
return markdownOutput;
},
getDocument: () => {
const documentBinary = provider?.document ? Y.encodeStateAsUpdate(provider?.document) : null;
const documentHTML = editorRef.current?.getHTML() ?? "<p></p>";
const documentJSON = editorRef.current?.getJSON() ?? null;
const documentHTML = editor?.getHTML() ?? "<p></p>";
const documentJSON = editor?.getJSON() ?? null;
return {
binary: documentBinary,
@@ -102,35 +96,22 @@ export const useReadOnlyEditor = (props: CustomReadOnlyEditorProps) => {
};
},
scrollSummary: (marking: IMarking): void => {
if (!editorRef.current) return;
scrollSummary(editorRef.current, marking);
if (!editor) return;
scrollSummary(editor, marking);
},
getDocumentInfo: () => ({
characters: editorRef?.current?.storage?.characterCount?.characters?.() ?? 0,
paragraphs: getParagraphCount(editorRef?.current?.state),
words: editorRef?.current?.storage?.characterCount?.words?.() ?? 0,
}),
onHeadingChange: (callback: (headings: IMarking[]) => void) => {
// Subscribe to update event emitted from headers extension
editorRef.current?.on("update", () => {
callback(editorRef.current?.storage.headingList.headings);
});
// Return a function to unsubscribe to the continuous transactions of
// the editor on unmounting the component that has subscribed to this
// method
return () => {
editorRef.current?.off("update");
getDocumentInfo: () => {
if (!editor) return;
return {
characters: editor.storage?.characterCount?.characters?.() ?? 0,
paragraphs: getParagraphCount(editor.state),
words: editor.storage?.characterCount?.words?.() ?? 0,
};
},
emitRealTimeUpdate: (message: TDocumentEventsServer) => provider?.sendStateless(message),
listenToRealTimeUpdate: () => provider && { on: provider.on.bind(provider), off: provider.off.bind(provider) },
getHeadings: () => editorRef?.current?.storage.headingList.headings,
}));
if (!editor) {
return null;
}
editorRef.current = editor;
return editor;
};
+4 -4
View File
@@ -86,10 +86,6 @@ export type EditorReadOnlyRefApi = {
paragraphs: number;
words: number;
};
onHeadingChange: (callback: (headings: IMarking[]) => void) => () => void;
getHeadings: () => IMarking[];
emitRealTimeUpdate: (action: TDocumentEventsServer) => void;
listenToRealTimeUpdate: () => TDocumentEventEmitter | undefined;
};
export interface EditorRefApi extends EditorReadOnlyRefApi {
@@ -105,6 +101,10 @@ export interface EditorRefApi extends EditorReadOnlyRefApi {
getSelectedText: () => string | null;
insertText: (contentHTML: string, insertOnNextLine?: boolean) => void;
setProviderDocument: (value: Uint8Array) => void;
onHeadingChange: (callback: (headings: IMarking[]) => void) => () => void;
getHeadings: () => IMarking[];
emitRealTimeUpdate: (action: TDocumentEventsServer) => void;
listenToRealTimeUpdate: () => TDocumentEventEmitter | undefined;
}
// editor props
+4 -4
View File
@@ -25,7 +25,7 @@
.ProseMirror p.is-editor-empty:first-child::before {
content: attr(data-placeholder);
float: left;
color: rgb(var(--color-text-400));
color: var(--color-placeholder);
pointer-events: none;
height: 0;
}
@@ -34,7 +34,7 @@
.ProseMirror p.is-empty::before {
content: attr(data-placeholder);
float: left;
color: rgb(var(--color-text-400));
color: var(--color-placeholder);
pointer-events: none;
height: 0;
}
@@ -145,7 +145,7 @@ ul[data-type="taskList"] li > label input[type="checkbox"] {
position: relative;
-webkit-appearance: none;
appearance: none;
background-color: rgb(var(--color-background-100));
background-color: transparent;
margin: 0;
cursor: pointer;
width: 0.8rem;
@@ -192,7 +192,7 @@ ul[data-type="taskList"] li > div {
ul[data-type="taskList"] li[data-checked="true"] {
& > div > p.editor-paragraph-block {
color: rgb(var(--color-text-400));
color: var(--color-placeholder);
}
[data-text-color] {
+2 -38
View File
@@ -1,42 +1,6 @@
: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 */
}
/* text background colors */
[data-theme="light"],
[data-theme="light-contrast"] {
--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"],
[data-theme="dark-contrast"] {
--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 */
.editor-container {
--color-placeholder: rgba(var(--color-text-100), 0.5);
/* font sizes and line heights */
&.large-font {
--font-size-h1: 1.75rem;
+1 -1
View File
@@ -1,4 +1,4 @@
const sharedConfig = require("tailwind-config-custom/tailwind.config.js");
const sharedConfig = require("@plane/tailwind-config/tailwind.config.js");
module.exports = {
// prefix ui lib classes to avoid conflicting with the app
+2 -1
View File
@@ -10,7 +10,8 @@
"lint:errors": "eslint src --ext .ts,.tsx --quiet"
},
"dependencies": {
"@plane/utils": "*"
"@plane/utils": "*",
"intl-messageformat": "^10.7.11"
},
"devDependencies": {
"@plane/eslint-config": "*",
-29
View File
@@ -1,29 +0,0 @@
import { observer } from "mobx-react";
import React, { createContext, useEffect } from "react";
import { Language, languages } from "../config";
import { TranslationStore } from "./store";
// Create the store instance
const translationStore = new TranslationStore();
// Create Context
export const TranslationContext = createContext<TranslationStore>(translationStore);
export const TranslationProvider = observer(({ children }: { children: React.ReactNode }) => {
// Handle storage events for cross-tab synchronization
useEffect(() => {
const handleStorageChange = (event: StorageEvent) => {
if (event.key === "userLanguage" && event.newValue) {
const newLang = event.newValue as Language;
if (languages.includes(newLang)) {
translationStore.setLanguage(newLang);
}
}
};
window.addEventListener("storage", handleStorageChange);
return () => window.removeEventListener("storage", handleStorageChange);
}, []);
return <TranslationContext.Provider value={translationStore}>{children}</TranslationContext.Provider>;
});
-42
View File
@@ -1,42 +0,0 @@
import { makeObservable, observable } from "mobx";
import { Language, fallbackLng, languages, translations } from "../config";
export class TranslationStore {
currentLocale: Language = fallbackLng;
constructor() {
makeObservable(this, {
currentLocale: observable.ref,
});
this.initializeLanguage();
}
get availableLanguages() {
return languages;
}
t(key: string) {
return translations[this.currentLocale]?.[key] || translations[fallbackLng][key] || key;
}
setLanguage(lng: Language) {
try {
localStorage.setItem("userLanguage", lng);
this.currentLocale = lng;
} catch (error) {
console.error(error);
}
}
initializeLanguage() {
if (typeof window === "undefined") return;
const savedLocale = localStorage.getItem("userLanguage") as Language;
if (savedLocale && languages.includes(savedLocale)) {
this.setLanguage(savedLocale);
} else {
const browserLang = navigator.language.split("-")[0] as Language;
const newLocale = languages.includes(browserLang as Language) ? (browserLang as Language) : fallbackLng;
this.setLanguage(newLocale);
}
}
}
-39
View File
@@ -1,39 +0,0 @@
import en from "../locales/en/translations.json";
import fr from "../locales/fr/translations.json";
import es from "../locales/es/translations.json";
import ja from "../locales/ja/translations.json";
export type Language = (typeof languages)[number];
export type Translations = {
[key: string]: {
[key: string]: string;
};
};
export const fallbackLng = "en";
export const languages = ["en", "fr", "es", "ja"] as const;
export const translations: Translations = {
en,
fr,
es,
ja,
};
export const SUPPORTED_LANGUAGES = [
{
label: "English",
value: "en",
},
{
label: "French",
value: "fr",
},
{
label: "Spanish",
value: "es",
},
{
label: "Japanese",
value: "ja",
},
];
+1
View File
@@ -0,0 +1 @@
export * from "./language";
+13
View File
@@ -0,0 +1,13 @@
import { TLanguage, ILanguageOption } from "../types";
export const FALLBACK_LANGUAGE: TLanguage = "en";
export const SUPPORTED_LANGUAGES: ILanguageOption[] = [
{ label: "English", value: "en" },
{ label: "Français", value: "fr" },
{ label: "Español", value: "es" },
{ label: "日本語", value: "ja" },
{ label: "中文", value: "zh-CN" },
];
export const STORAGE_KEY = "userLanguage";
+19
View File
@@ -0,0 +1,19 @@
import { observer } from "mobx-react";
import React, { createContext } from "react";
// store
import { TranslationStore } from "../store";
export const TranslationContext = createContext<TranslationStore | null>(null);
interface TranslationProviderProps {
children: React.ReactNode;
}
/**
* Provides the translation store to the application
*/
export const TranslationProvider: React.FC<TranslationProviderProps> = observer(({ children }) => {
const [store] = React.useState(() => new TranslationStore());
return <TranslationContext.Provider value={store}>{children}</TranslationContext.Provider>;
});
+25 -7
View File
@@ -1,17 +1,35 @@
import { useContext } from "react";
import { TranslationContext } from "../components";
import { Language } from "../config";
import { useContext } from 'react';
// context
import { TranslationContext } from '../context';
// types
import { ILanguageOption, TLanguage } from '../types';
export function useTranslation() {
export type TTranslationStore = {
t: (key: string, params?: Record<string, any>) => string;
currentLocale: TLanguage;
changeLanguage: (lng: TLanguage) => void;
languages: ILanguageOption[];
};
/**
* Provides the translation store to the application
* @returns {TTranslationStore}
* @returns {(key: string, params?: Record<string, any>) => string} t: method to translate the key with params
* @returns {TLanguage} currentLocale - current locale language
* @returns {(lng: TLanguage) => void} changeLanguage - method to change the language
* @returns {ILanguageOption[]} languages - available languages
* @throws {Error} if the TranslationProvider is not used
*/
export function useTranslation(): TTranslationStore {
const store = useContext(TranslationContext);
if (!store) {
throw new Error("useTranslation must be used within a TranslationProvider");
throw new Error('useTranslation must be used within a TranslationProvider');
}
return {
t: (key: string) => store.t(key),
t: store.t.bind(store),
currentLocale: store.currentLocale,
changeLanguage: (lng: Language) => store.setLanguage(lng),
changeLanguage: (lng: TLanguage) => store.setLanguage(lng),
languages: store.availableLanguages,
};
}
+3 -2
View File
@@ -1,3 +1,4 @@
export * from "./config";
export * from "./components";
export * from "./constants";
export * from "./context";
export * from "./hooks";
export * from "./types";
+19 -19
View File
@@ -19,12 +19,12 @@
"remove_members": "Supprimer des membres",
"add": "Ajouter",
"remove": "Supprimer",
"add_new": "Ajouter nouveau",
"add_new": "Ajouter un nouveau",
"remove_selected": "Supprimer la sélection",
"first_name": "Prénom",
"last_name": "Nom de famille",
"email": "Email",
"display_name": "Nom d'affichage",
"display_name": "Pseudonyme",
"role": "Rôle",
"timezone": "Fuseau horaire",
"avatar": "Avatar",
@@ -32,7 +32,7 @@
"password": "Mot de passe",
"change_cover": "Modifier la couverture",
"language": "Langue",
"saving": "Enregistrement...",
"saving": "Enregistrement en cours...",
"save_changes": "Enregistrer les modifications",
"deactivate_account": "Désactiver le compte",
"deactivate_account_description": "Lors de la désactivation d'un compte, toutes les données et ressources de ce compte seront définitivement supprimées et ne pourront pas être récupérées.",
@@ -43,14 +43,14 @@
"activity": "Activité",
"appearance": "Apparence",
"notifications": "Notifications",
"workspaces": "Workspaces",
"create_workspace": "Créer un workspace",
"workspaces": "Espaces de travail",
"create_workspace": "Créer un espace de travail",
"invitations": "Invitations",
"summary": "Résumé",
"assigned": "Assigné",
"created": "Créé",
"subscribed": "Souscrit",
"you_do_not_have_the_permission_to_access_this_page": "Vous n'avez pas les permissions pour accéder à cette page.",
"you_do_not_have_the_permission_to_access_this_page": "Vous n'avez pas les permissions d'accéder à cette page.",
"failed_to_sign_out_please_try_again": "Impossible de se déconnecter. Veuillez réessayer.",
"password_changed_successfully": "Mot de passe changé avec succès.",
"something_went_wrong_please_try_again": "Quelque chose s'est mal passé. Veuillez réessayer.",
@@ -62,7 +62,7 @@
"this_field_is_required": "Ce champ est requis",
"passwords_dont_match": "Les mots de passe ne correspondent pas",
"please_enter_your_password": "Veuillez entrer votre mot de passe.",
"password_length_should_me_more_than_8_characters": "La longueur du mot de passe doit être supérieure à 8 caractères.",
"password_length_should_me_more_than_8_characters": "La longueur du mot de passe doit faire au moins 8 caractères.",
"password_is_weak": "Le mot de passe est faible.",
"password_is_strong": "Le mot de passe est fort.",
"load_more": "Charger plus",
@@ -106,8 +106,8 @@
"comments_description": "Me notifier lorsqu'un utilisateur commente un problème",
"mentions": "Mention",
"mentions_description": "Me notifier uniquement lorsqu'un utilisateur mentionne un problème",
"create_your_workspace": "Créer votre workspace",
"only_your_instance_admin_can_create_workspaces": "Seuls les administrateurs de votre instance peuvent créer des workspaces",
"create_your_workspace": "Créer votre espace de travail",
"only_your_instance_admin_can_create_workspaces": "Seuls les administrateurs de votre instance peuvent créer des espaces de travail",
"only_your_instance_admin_can_create_workspaces_description": "Si vous connaissez l'adresse email de votre administrateur d'instance, cliquez sur le bouton ci-dessous pour les contacter.",
"go_back": "Retour",
"request_instance_admin": "Demander à l'administrateur de l'instance",
@@ -135,7 +135,7 @@
"old_password": "Mot de passe actuel",
"general_settings": "Paramètres généraux",
"sign_out": "Déconnexion",
"signing_out": "Déconnexion",
"signing_out": "Déconnexion en cours",
"active_cycles": "Cycles actifs",
"active_cycles_description": "Surveillez les cycles dans les projets, suivez les issues de haute priorité et zoomez sur les cycles qui nécessitent attention.",
"on_demand_snapshots_of_all_your_cycles": "Captures instantanées sur demande de tous vos cycles",
@@ -190,17 +190,17 @@
"project_created_successfully_description": "Projet créé avec succès. Vous pouvez maintenant ajouter des issues à ce projet.",
"project_cover_image_alt": "Image de couverture du projet",
"name_is_required": "Le nom est requis",
"title_should_be_less_than_255_characters": "Le titre doit être inférieur à 255 caractères",
"title_should_be_less_than_255_characters": "Le titre ne doit pas dépasser 255 caractères",
"project_name": "Nom du projet",
"project_id_must_be_at_least_1_character": "Le projet ID doit être au moins de 1 caractère",
"project_id_must_be_at_most_5_characters": "Le projet ID doit être au plus de 5 caractères",
"project_id_must_be_at_least_1_character": "L'ID du projet doit être au moins de 1 caractère",
"project_id_must_be_at_most_5_characters": "L'ID du projet doit être au plus de 5 caractères",
"project_id": "ID du projet",
"project_id_tooltip_content": "Aide à identifier les issues du projet de manière unique. Max 5 caractères.",
"description_placeholder": "Description...",
"only_alphanumeric_non_latin_characters_allowed": "Seuls les caractères alphanumériques et non latins sont autorisés.",
"project_id_is_required": "Le projet ID est requis",
"project_id_is_required": "L'ID du projet est requis",
"select_network": "Sélectionner le réseau",
"lead": "Lead",
"lead": "Responsable",
"private": "Privé",
"public": "Public",
"accessible_only_by_invite": "Accessible uniquement par invitation",
@@ -245,11 +245,11 @@
"contact_sales": "Contacter les ventes",
"hyper_mode": "Mode hyper",
"keyboard_shortcuts": "Raccourcis clavier",
"whats_new": "Nouveautés?",
"whats_new": "Nouveautés ?",
"version": "Version",
"we_are_having_trouble_fetching_the_updates": "Nous avons des difficultés à récupérer les mises à jour.",
"our_changelogs": "nos changelogs",
"for_the_latest_updates": "pour les dernières mises à jour.",
"our_changelogs": "Notre journal de modifications",
"for_the_latest_updates": "Pour les dernières mises à jour.",
"please_visit": "Veuillez visiter",
"docs": "Documentation",
"full_changelog": "Journal complet",
@@ -316,5 +316,5 @@
"remove_parent_issue": "Supprimer le problème parent",
"add_parent": "Ajouter un parent",
"loading_members": "Chargement des membres...",
"inbox": "boîte de réception"
"inbox": "Boîte de réception"
}
@@ -0,0 +1,319 @@
{
"submit": "提交",
"cancel": "取消",
"loading": "加载中",
"error": "错误",
"success": "成功",
"warning": "警告",
"info": "信息",
"close": "关闭",
"yes": "是",
"no": "否",
"ok": "确定",
"name": "名称",
"description": "描述",
"search": "搜索",
"add_member": "添加成员",
"remove_member": "移除成员",
"add_members": "添加成员",
"remove_members": "移除成员",
"add": "添加",
"remove": "移除",
"add_new": "新增",
"remove_selected": "移除选中项",
"first_name": "名字",
"last_name": "姓氏",
"email": "电子邮件",
"display_name": "显示名称",
"role": "角色",
"timezone": "时区",
"avatar": "头像",
"cover_image": "封面图片",
"password": "密码",
"change_cover": "更换封面",
"language": "语言",
"saving": "保存中...",
"save_changes": "保存更改",
"deactivate_account": "停用账户",
"deactivate_account_description": "停用账户后,该账户内的所有数据和资源将被永久删除,且无法恢复。",
"profile_settings": "个人资料设置",
"your_account": "账户",
"profile": "个人资料",
"security": "安全",
"activity": "活动",
"appearance": "外观",
"notifications": "通知",
"workspaces": "工作区",
"create_workspace": "创建工作区",
"invitations": "邀请",
"summary": "摘要",
"assigned": "已分配",
"created": "已创建",
"subscribed": "已订阅",
"you_do_not_have_the_permission_to_access_this_page": "您没有权限访问此页面。",
"failed_to_sign_out_please_try_again": "登出失败,请重试。",
"password_changed_successfully": "密码已成功更改。",
"something_went_wrong_please_try_again": "出错了,请重试。",
"change_password": "更改密码",
"passwords_dont_match": "密码不匹配",
"current_password": "当前密码",
"new_password": "新密码",
"confirm_password": "确认密码",
"this_field_is_required": "此字段为必填项",
"changing_password": "正在更改密码",
"please_enter_your_password": "请输入您的密码。",
"password_length_should_me_more_than_8_characters": "密码长度应大于8个字符。",
"password_is_weak": "密码强度较弱。",
"password_is_strong": "密码强度较强。",
"load_more": "加载更多",
"select_or_customize_your_interface_color_scheme": "选择或自定义界面配色方案。",
"theme": "主题",
"system_preference": "系统偏好",
"light": "浅色",
"dark": "深色",
"light_contrast": "浅色高对比",
"dark_contrast": "深色高对比",
"custom": "自定义主题",
"select_your_theme": "选择主题",
"customize_your_theme": "自定义主题",
"background_color": "背景颜色",
"text_color": "文字颜色",
"primary_color": "主色调",
"sidebar_background_color": "侧边栏背景颜色",
"sidebar_text_color": "侧边栏文字颜色",
"set_theme": "设置主题",
"enter_a_valid_hex_code_of_6_characters": "请输入6位有效的十六进制代码",
"background_color_is_required": "背景颜色为必填项",
"text_color_is_required": "文字颜色为必填项",
"primary_color_is_required": "主色调为必填项",
"sidebar_background_color_is_required": "侧边栏背景颜色为必填项",
"sidebar_text_color_is_required": "侧边栏文字颜色为必填项",
"updating_theme": "正在更新主题",
"theme_updated_successfully": "主题已成功更新",
"failed_to_update_the_theme": "主题更新失败",
"email_notifications": "电子邮件通知",
"stay_in_the_loop_on_issues_you_are_subscribed_to_enable_this_to_get_notified": "启用此功能以接收您订阅问题的通知。",
"email_notification_setting_updated_successfully": "电子邮件通知设置已成功更新",
"failed_to_update_email_notification_setting": "电子邮件通知设置更新失败",
"notify_me_when": "在以下情况下通知我",
"property_changes": "属性更改",
"property_changes_description": "当负责人、优先级、估算等属性更改时通知。",
"state_change": "状态更改",
"state_change_description": "当问题状态更改时通知",
"issue_completed": "问题完成",
"issue_completed_description": "仅在问题完成时通知",
"comments": "评论",
"comments_description": "当有人在问题中添加评论时通知",
"mentions": "提及",
"mentions_description": "仅在评论或描述中提及您时通知",
"create_your_workspace": "创建工作区",
"only_your_instance_admin_can_create_workspaces": "只有实例管理员可以创建工作区",
"only_your_instance_admin_can_create_workspaces_description": "如果您知道实例管理员的电子邮件地址,请点击以下按钮联系他们。",
"go_back": "返回",
"request_instance_admin": "请求实例管理员",
"plane_logo": "Plane 徽标",
"workspace_creation_disabled": "工作区创建已禁用",
"workspace_request_subject": "新工作区请求",
"workspace_request_body": "实例管理员:\n\n请在 URL [/workspace-name] 处创建一个新的工作区。[工作区创建目的]\n\n谢谢。\n{{firstName}} {{lastName}}\n{{email}}",
"creating_workspace": "正在创建工作区",
"workspace_created_successfully": "工作区已成功创建",
"create_workspace_page": "创建工作区页面",
"workspace_could_not_be_created_please_try_again": "无法创建工作区,请重试。",
"workspace_could_not_be_created_please_try_again_description": "创建工作区时出错,请重试。",
"this_is_a_required_field": "此字段为必填项。",
"name_your_workspace": "为工作区命名",
"workspaces_names_can_contain_only_space_dash_and_alphanumeric_characters": "工作区名称只能包含空格、破折号和字母数字字符。",
"limit_your_name_to_80_characters": "名称长度限制为80个字符。",
"set_your_workspace_url": "设置工作区URL",
"limit_your_url_to_48_characters": "URL长度限制为48个字符。",
"how_many_people_will_use_this_workspace": "有多少人将使用此工作区?",
"how_many_people_will_use_this_workspace_description": "这将帮助确定购买的席位数量。",
"select_a_range": "选择一个范围",
"urls_can_contain_only_dash_and_alphanumeric_characters": "URL只能包含破折号和字母数字字符。",
"something_familiar_and_recognizable_is_always_best": "熟悉且易于识别的名称总是最佳选择。",
"workspace_url_is_already_taken": "工作区URL已被占用!",
"old_password": "旧密码",
"general_settings": "常规设置",
"sign_out": "登出",
"signing_out": "正在登出",
"active_cycles": "活跃周期",
"active_cycles_description": "监控所有项目的周期,跟踪高优先级问题,并关注需要关注的周期。",
"on_demand_snapshots_of_all_your_cycles": "所有周期的按需快照",
"upgrade": "升级",
"10000_feet_view": "所有活跃周期的概览",
"10000_feet_view_description": "放大查看所有项目的周期,而不是单独查看每个项目的周期。",
"get_snapshot_of_each_active_cycle": "获取每个活跃周期的快照",
"get_snapshot_of_each_active_cycle_description": "跟踪所有活跃周期的高级指标,查看进度并了解范围与截止日期的关系。",
"compare_burndowns": "比较燃尽图",
"compare_burndowns_description": "监控每个团队的表现,查看每个周期的燃尽报告。",
"quickly_see_make_or_break_issues": "快速查看关键问题",
"quickly_see_make_or_break_issues_description": "预览每个周期中与截止日期相关的高优先级问题。一键查看所有周期的详细信息。",
"zoom_into_cycles_that_need_attention": "关注需要关注的周期",
"zoom_into_cycles_that_need_attention_description": "一键调查未达预期的周期状态。",
"stay_ahead_of_blockers": "提前解决阻碍",
"stay_ahead_of_blockers_description": "发现项目间的挑战,查看其他视图中不明显的周期依赖关系。",
"analytics": "分析",
"workspace_invites": "工作区邀请",
"workspace_settings": "工作区设置",
"enter_god_mode": "进入上帝模式",
"workspace_logo": "工作区徽标",
"new_issue": "新建问题",
"home": "首页",
"your_work": "您的工作",
"drafts": "草稿",
"projects": "项目",
"views": "视图",
"workspace": "工作区",
"archives": "归档",
"settings": "设置",
"failed_to_move_favorite": "移动收藏失败",
"your_favorites": "您的收藏",
"no_favorites_yet": "尚无收藏",
"create_folder": "创建文件夹",
"new_folder": "新建文件夹",
"favorite_updated_successfully": "收藏已成功更新",
"favorite_created_successfully": "收藏已成功创建",
"folder_already_exists": "文件夹已存在",
"folder_name_cannot_be_empty": "文件夹名称不能为空",
"something_went_wrong": "出错了",
"failed_to_reorder_favorite": "重新排序收藏失败",
"favorite_removed_successfully": "收藏已成功移除",
"failed_to_create_favorite": "创建收藏失败",
"failed_to_rename_favorite": "重命名收藏失败",
"project_link_copied_to_clipboard": "项目链接已复制到剪贴板",
"link_copied": "链接已复制",
"your_projects": "您的项目",
"add_project": "添加项目",
"create_project": "创建项目",
"failed_to_remove_project_from_favorites": "无法从收藏中移除项目,请重试。",
"project_created_successfully": "项目已成功创建",
"project_created_successfully_description": "项目已成功创建。您现在可以开始添加问题。",
"project_cover_image_alt": "项目封面图片",
"name_is_required": "名称为必填项",
"title_should_be_less_than_255_characters": "标题应少于255个字符",
"project_name": "项目名称",
"project_id_must_be_at_least_1_character": "项目ID至少为1个字符",
"project_id_must_be_at_most_5_characters": "项目ID最多为5个字符",
"project_id": "项目ID",
"project_id_tooltip_content": "用于唯一标识项目中的问题。最多5个字符。",
"description_placeholder": "描述...",
"only_alphanumeric_non_latin_characters_allowed": "仅允许字母数字和非拉丁字符。",
"project_id_is_required": "项目ID为必填项",
"select_network": "选择网络",
"lead": "负责人",
"private": "私有",
"public": "公开",
"accessible_only_by_invite": "仅限邀请访问",
"anyone_in_the_workspace_except_guests_can_join": "除访客外,工作区中的任何人都可以加入",
"creating": "创建中",
"creating_project": "正在创建项目",
"adding_project_to_favorites": "正在将项目添加到收藏",
"project_added_to_favorites": "项目已添加到收藏",
"couldnt_add_the_project_to_favorites": "无法将项目添加到收藏,请重试。",
"removing_project_from_favorites": "正在从收藏中移除项目",
"project_removed_from_favorites": "项目已从收藏中移除",
"couldnt_remove_the_project_from_favorites": "无法从收藏中移除项目,请重试。",
"add_to_favorites": "添加到收藏",
"remove_from_favorites": "从收藏中移除",
"publish_settings": "发布设置",
"publish": "发布",
"copy_link": "复制链接",
"leave_project": "离开项目",
"join_the_project_to_rearrange": "加入项目以重新排序",
"drag_to_rearrange": "拖动以重新排序",
"congrats": "恭喜!",
"project": "项目",
"open_project": "打开项目",
"issues": "问题",
"cycles": "周期",
"modules": "模块",
"pages": "页面",
"intake": "接收",
"time_tracking": "时间跟踪",
"work_management": "工作管理",
"projects_and_issues": "项目与问题",
"projects_and_issues_description": "在此项目上启用或禁用。",
"cycles_description": "按时间段对项目工作进行时间盒管理,并调整频率。",
"modules_description": "将工作分组为类似子项目的设置,拥有自己的负责人和分配人。",
"views_description": "保存排序、过滤和显示选项以供以后使用或共享。",
"pages_description": "写下一些内容。",
"intake_description": "启用此功能以接收您订阅问题的通知。",
"time_tracking_description": "跟踪问题和项目所花费的时间。",
"work_management_description": "轻松管理工作和项目。",
"documentation": "文档",
"message_support": "联系支持",
"contact_sales": "联系销售",
"hyper_mode": "超速模式",
"keyboard_shortcuts": "键盘快捷键",
"whats_new": "新功能",
"version": "版本",
"we_are_having_trouble_fetching_the_updates": "获取更新时出现问题。",
"our_changelogs": "我们的更新日志",
"for_the_latest_updates": "获取最新更新,请访问",
"please_visit": "请访问",
"docs": "文档",
"full_changelog": "完整更新日志",
"support": "支持",
"discord": "Discord",
"powered_by_plane_pages": "由 Plane Pages 提供支持",
"please_select_at_least_one_invitation": "请至少选择一个邀请。",
"please_select_at_least_one_invitation_description": "请至少选择一个邀请以加入工作区。",
"we_see_that_someone_has_invited_you_to_join_a_workspace": "我们发现有人邀请您加入一个工作区",
"join_a_workspace": "加入工作区",
"we_see_that_someone_has_invited_you_to_join_a_workspace_description": "我们发现有人邀请您加入一个工作区",
"join_a_workspace_description": "加入工作区",
"accept_and_join": "接受并加入",
"go_home": "返回首页",
"no_pending_invites": "没有待处理的邀请",
"you_can_see_here_if_someone_invites_you_to_a_workspace": "如果有人邀请您加入工作区,您可以在此处查看",
"back_to_home": "返回首页",
"workspace_name": "工作区名称",
"deactivate_your_account": "停用您的账户",
"deactivate_your_account_description": "停用后,您将无法再分配问题,工作区的计费也将停止。要重新激活账户,您需要使用此电子邮件地址收到工作区邀请。",
"deactivating": "正在停用",
"confirm": "确认",
"draft_created": "草稿已创建",
"issue_created_successfully": "问题已成功创建",
"draft_creation_failed": "草稿创建失败",
"issue_creation_failed": "问题创建失败",
"draft_issue": "草稿问题",
"issue_updated_successfully": "问题已成功更新",
"issue_could_not_be_updated": "无法更新问题",
"create_a_draft": "创建草稿",
"save_to_drafts": "保存为草稿",
"save": "保存",
"updating": "正在更新",
"create_new_issue": "创建新问题",
"editor_is_not_ready_to_discard_changes": "编辑器尚未准备好丢弃更改",
"failed_to_move_issue_to_project": "无法将问题移动到项目",
"create_more": "创建更多",
"add_to_project": "添加到项目",
"discard": "丢弃",
"duplicate_issue_found": "发现重复问题",
"duplicate_issues_found": "发现重复问题",
"no_matching_results": "没有匹配的结果",
"title_is_required": "标题为必填项",
"title": "标题",
"state": "状态",
"priority": "优先级",
"none": "无",
"urgent": "紧急",
"high": "高",
"medium": "中",
"low": "低",
"members": "成员",
"assignee": "分配人",
"assignees": "分配人",
"you": "您",
"labels": "标签",
"create_new_label": "创建新标签",
"start_date": "开始日期",
"due_date": "截止日期",
"cycle": "周期",
"estimate": "估算",
"change_parent_issue": "更改父问题",
"remove_parent_issue": "移除父问题",
"add_parent": "添加父问题",
"loading_members": "正在加载成员...",
"inbox": "收件箱"
}
+202
View File
@@ -0,0 +1,202 @@
import IntlMessageFormat from "intl-messageformat";
import get from "lodash/get";
import { makeAutoObservable } from "mobx";
// constants
import { FALLBACK_LANGUAGE, SUPPORTED_LANGUAGES, STORAGE_KEY } from "../constants";
// types
import { TLanguage, ILanguageOption, ITranslations } from "../types";
/**
* Mobx store class for handling translations and language changes in the application
* Provides methods to translate keys with params and change the language
* Uses IntlMessageFormat to format the translations
*/
export class TranslationStore {
// List of translations for each language
private translations: ITranslations = {};
// Cache for IntlMessageFormat instances
private messageCache: Map<string, IntlMessageFormat> = new Map();
// Current language
currentLocale: TLanguage = FALLBACK_LANGUAGE;
/**
* Constructor for the TranslationStore class
*/
constructor() {
makeAutoObservable(this);
this.initializeLanguage();
this.loadTranslations();
}
/**
* Loads translations from JSON files and initializes the message cache
*/
private async loadTranslations() {
try {
// dynamic import of translations
const translations = {
en: (await import("../locales/en/translations.json")).default,
fr: (await import("../locales/fr/translations.json")).default,
es: (await import("../locales/es/translations.json")).default,
ja: (await import("../locales/ja/translations.json")).default,
"zh-CN": (await import("../locales/zh-CN/translations.json")).default,
};
this.translations = translations;
this.messageCache.clear(); // Clear cache when translations change
} catch (error) {
console.error("Failed to load translations:", error);
}
}
/** Initializes the language based on the local storage or browser language */
private initializeLanguage() {
if (typeof window === "undefined") return;
const savedLocale = localStorage.getItem(STORAGE_KEY) as TLanguage;
if (this.isValidLanguage(savedLocale)) {
this.setLanguage(savedLocale);
return;
}
const browserLang = this.getBrowserLanguage();
this.setLanguage(browserLang);
}
/** Checks if the language is valid based on the supported languages */
private isValidLanguage(lang: string | null): lang is TLanguage {
return lang !== null && this.availableLanguages.some((l) => l.value === lang);
}
/** Checks if a language code is similar to any supported language */
private findSimilarLanguage(lang: string): TLanguage | null {
// Convert to lowercase for case-insensitive comparison
const normalizedLang = lang.toLowerCase();
// Find a supported language that includes or is included in the browser language
const similarLang = this.availableLanguages.find(
(l) => normalizedLang.includes(l.value.toLowerCase()) || l.value.toLowerCase().includes(normalizedLang)
);
return similarLang ? similarLang.value : null;
}
/** Gets the browser language based on the navigator.language */
private getBrowserLanguage(): TLanguage {
const browserLang = navigator.language;
// Check exact match first
if (this.isValidLanguage(browserLang)) {
return browserLang;
}
// Check base language without region code
const baseLang = browserLang.split("-")[0];
if (this.isValidLanguage(baseLang)) {
return baseLang as TLanguage;
}
// Try to find a similar language
const similarLang = this.findSimilarLanguage(browserLang) || this.findSimilarLanguage(baseLang);
return similarLang || FALLBACK_LANGUAGE;
}
/**
* Gets the cache key for the given key and locale
* @param key - the key to get the cache key for
* @param locale - the locale to get the cache key for
* @returns the cache key for the given key and locale
*/
private getCacheKey(key: string, locale: TLanguage): string {
return `${locale}:${key}`;
}
/**
* Gets the IntlMessageFormat instance for the given key and locale
* Returns cached instance if available
* Throws an error if the key is not found in the translations
*/
private getMessageInstance(key: string, locale: TLanguage): IntlMessageFormat | null {
const cacheKey = this.getCacheKey(key, locale);
// Check if the cache already has the key
if (this.messageCache.has(cacheKey)) {
return this.messageCache.get(cacheKey) || null;
}
// Get the message from the translations
const message = get(this.translations[locale], key);
if (!message) return null;
try {
const formatter = new IntlMessageFormat(message as any, locale);
this.messageCache.set(cacheKey, formatter);
return formatter;
} catch (error) {
console.error(`Failed to create message formatter for key "${key}":`, error);
return null;
}
}
/**
* Translates a key with params using the current locale
* Falls back to the default language if the translation is not found
* Returns the key itself if the translation is not found
* @param key - The key to translate
* @param params - The params to format the translation with
* @returns The translated string
*/
t(key: string, params?: Record<string, any>): string {
try {
// Try current locale
let formatter = this.getMessageInstance(key, this.currentLocale);
// Fallback to default language if necessary
if (!formatter && this.currentLocale !== FALLBACK_LANGUAGE) {
formatter = this.getMessageInstance(key, FALLBACK_LANGUAGE);
}
// If we have a formatter, use it
if (formatter) {
return formatter.format(params || {}) as string;
}
// Last resort: return the key itself
return key;
} catch (error) {
console.error(`Translation error for key "${key}":`, error);
return key;
}
}
/**
* Sets the current language and updates the translations
* @param lng - The new language
*/
setLanguage(lng: TLanguage): void {
try {
if (!this.isValidLanguage(lng)) {
throw new Error(`Invalid language: ${lng}`);
}
if (typeof window !== "undefined") {
localStorage.setItem(STORAGE_KEY, lng);
}
this.currentLocale = lng;
this.messageCache.clear(); // Clear cache when language changes
if (typeof window !== "undefined") {
document.documentElement.lang = lng;
}
} catch (error) {
console.error("Failed to set language:", error);
}
}
/**
* Gets the available language options for the dropdown
* @returns An array of language options
*/
get availableLanguages(): ILanguageOption[] {
return SUPPORTED_LANGUAGES;
}
}
+2
View File
@@ -0,0 +1,2 @@
export * from "./language";
export * from "./translation";
+6
View File
@@ -0,0 +1,6 @@
export type TLanguage = "en" | "fr" | "es" | "ja" | "zh-CN";
export interface ILanguageOption {
label: string;
value: TLanguage;
}
+7
View File
@@ -0,0 +1,7 @@
export interface ITranslation {
[key: string]: string | ITranslation;
}
export interface ITranslations {
[locale: string]: ITranslation;
}
+3
View File
@@ -0,0 +1,3 @@
build/*
dist/*
out/*
+6
View File
@@ -0,0 +1,6 @@
/** @type {import("eslint").Linter.Config} */
module.exports = {
root: true,
extends: ["@plane/eslint-config/library.js"],
parser: "@typescript-eslint/parser",
};
+27
View File
@@ -0,0 +1,27 @@
{
"name": "@plane/propel",
"version": "0.24.1",
"private": true,
"exports": {
"./globals.css": "./src/globals.css",
"./components/*": "./src/*.tsx"
},
"devDependencies": {
"@plane/eslint-config": "*",
"@plane/tailwind-config": "*",
"@plane/typescript-config": "*",
"@types/react": "18.3.1",
"@types/react-dom": "18.3.0",
"typescript": "^5.3.3"
},
"dependencies": {
"@radix-ui/react-slot": "^1.1.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.469.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7"
}
}
+6
View File
@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+59
View File
@@ -0,0 +1,59 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 47.4% 11.2%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--card: 0 0% 100%;
--card-foreground: 222.2 47.4% 11.2%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 100% 50%;
--destructive-foreground: 210 40% 98%;
--ring: 215 20.2% 65.1%;
--radius: 0.5rem;
}
.dark {
--background: 224 71% 4%;
--foreground: 213 31% 91%;
--muted: 223 47% 11%;
--muted-foreground: 215.4 16.3% 56.9%;
--accent: 216 34% 17%;
--accent-foreground: 210 40% 98%;
--popover: 224 71% 4%;
--popover-foreground: 215 20.2% 65.1%;
--border: 216 34% 17%;
--input: 216 34% 17%;
--card: 224 71% 4%;
--card-foreground: 213 31% 91%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 1.2%;
--secondary: 222.2 47.4% 11.2%;
--secondary-foreground: 210 40% 98%;
--destructive: 0 63% 31%;
--destructive-foreground: 210 40% 98%;
--ring: 216 34% 17%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply font-sans antialiased bg-background text-foreground;
}
}
+57
View File
@@ -0,0 +1,57 @@
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import * as React from "react";
import { cn } from "@plane/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
}
);
Button.displayName = "Button";
export { Button, buttonVariants };
+7
View File
@@ -0,0 +1,7 @@
import type { Config } from "tailwindcss";
import sharedConfig from "@plane/tailwind-config/tailwind.config";
export default {
...sharedConfig,
content: ["./src/**/*.{ts,tsx}"],
} satisfies Config;
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "@plane/typescript-config/react-library.json",
"compilerOptions": {
"outDir": "dist"
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}
+14 -1
View File
@@ -1,5 +1,6 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import axios, { AxiosInstance, AxiosRequestConfig } from "axios";
import { IndexedDBService } from "./indexedDB.service";
/**
* Abstract base class for making HTTP requests using axios
@@ -34,7 +35,19 @@ export abstract class APIService {
(error) => {
if (error.response && error.response.status === 401) {
const currentPath = window.location.pathname;
window.location.replace(`/${currentPath ? `?next_path=${currentPath}` : ``}`);
let prefix = "/";
let updatedPath = currentPath;
// Check for special path prefixes
if (currentPath.startsWith("/god-mode")) {
prefix = "/god-mode";
updatedPath = currentPath.replace("/god-mode", "");
} else if (currentPath.startsWith("/spaces")) {
prefix = "/spaces";
updatedPath = currentPath.replace("/spaces", "");
}
window.location.replace(`${prefix}${updatedPath ? `?next_path=${updatedPath}` : ""}`);
}
return Promise.reject(error);
}
+1
View File
@@ -1 +1,2 @@
export * from "./auth.service";
export * from "./sites-auth.service";
@@ -0,0 +1,49 @@
import { API_BASE_URL } from "@plane/constants";
// types
import { IEmailCheckData, IEmailCheckResponse } from "@plane/types";
// services
import { APIService } from "../api.service";
/**
* Service class for handling authentication-related operations for Plane space application
* Provides methods for user authentication, password management, and session handling
* @extends {APIService}
* @remarks This service is only available for plane sites
*/
export class SitesAuthService extends APIService {
/**
* Creates an instance of SitesAuthService
* Initializes with the base API URL
*/
constructor(BASE_URL?: string) {
super(BASE_URL || API_BASE_URL);
}
/**
* Checks if an email exists in the system
* @param {IEmailCheckData} data - Email data to verify
* @returns {Promise<IEmailCheckResponse>} Response indicating email status
* @throws {Error} Throws response data if the request fails
*/
async emailCheck(data: IEmailCheckData): Promise<IEmailCheckResponse> {
return this.post("/auth/spaces/email-check/", data, { headers: {} })
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
/**
* Generates a unique code for magic link authentication
* @param {{ email: string }} data - Object containing the email address
* @returns {Promise<any>} Response containing the generated unique code
* @throws {Error} Throws response data if the request fails
*/
async generateUniqueCode(data: { email: string }): Promise<any> {
return this.post("/auth/spaces/magic-generate/", data, { headers: {} })
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
}
+1
View File
@@ -2,3 +2,4 @@ export * from "./cycle-analytics.service";
export * from "./cycle-archive.service";
export * from "./cycle-operations.service";
export * from "./cycle.service";
export * from "./sites-cycle.service";
@@ -0,0 +1,31 @@
// plane imports
import { API_BASE_URL } from "@plane/constants";
import { TPublicCycle } from "@plane/types";
// api service
import { APIService } from "../api.service";
/**
* Service class for managing cycles within plane sites application.
* Extends APIService to handle HTTP requests to the cycle-related endpoints.
* @extends {APIService}
* @remarks This service is only available for plane sites
*/
export class SitesCycleService extends APIService {
constructor(BASE_URL?: string) {
super(BASE_URL || API_BASE_URL);
}
/**
* Retrieves list of cycles for a specific anchor.
* @param anchor - The anchor identifier for the published entity
* @returns {Promise<TPublicCycle[]>} The list of cycles
* @throws {Error} If the request fails
*/
async list(anchor: string): Promise<TPublicCycle[]> {
return this.get(`/api/public/anchor/${anchor}/cycles/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
}
@@ -1,7 +1,12 @@
import axios from "axios";
// services
import { APIService } from "@/services/api.service";
// api service
import { APIService } from "../api.service";
/**
* Service class for handling file upload operations
* Handles file uploads
* @extends {APIService}
*/
export class FileUploadService extends APIService {
private cancelSource: any;
@@ -9,7 +14,17 @@ export class FileUploadService extends APIService {
super("");
}
async uploadFile(url: string, data: FormData): Promise<void> {
/**
* Uploads a file to the specified signed URL
* @param {string} url - The URL to upload the file to
* @param {FormData} data - The form data to upload
* @returns {Promise<void>} Promise resolving to void
* @throws {Error} If the request fails
*/
async uploadFile(
url: string,
data: FormData,
): Promise<void> {
this.cancelSource = axios.CancelToken.source();
return this.post(url, data, {
headers: {
@@ -28,7 +43,10 @@ export class FileUploadService extends APIService {
});
}
/**
* Cancels the upload
*/
cancelUpload() {
this.cancelSource.cancel("Upload canceled");
}
}
}
@@ -0,0 +1,67 @@
// plane imports
import { API_BASE_URL } from "@plane/constants";
// api service
import { APIService } from "../api.service";
// helpers
import { getAssetIdFromUrl } from "./helper";
/**
* Service class for managing file operations within plane applications.
* Extends APIService to handle HTTP requests to the file-related endpoints.
* @extends {APIService}
*/
export class FileService extends APIService {
/**
* Creates an instance of FileService
* @param {string} BASE_URL - The base URL for API requests
*/
constructor(BASE_URL?: string) {
super(BASE_URL || API_BASE_URL);
}
/**
* Deletes a new asset
* @param {string} assetPath - The asset path
* @returns {Promise<void>} Promise resolving to void
* @throws {Error} If the request fails
*/
async deleteNewAsset(assetPath: string): Promise<void> {
return this.delete(assetPath)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
/**
* Deletes an old editor asset
* @param {string} workspaceId - The workspace identifier
* @param {string} src - The asset source
* @returns {Promise<any>} Promise resolving to void
* @throws {Error} If the request fails
*/
async deleteOldEditorAsset(workspaceId: string, src: string): Promise<any> {
const assetKey = getAssetIdFromUrl(src);
return this.delete(`/api/workspaces/file-assets/${workspaceId}/${assetKey}/`)
.then((response) => response?.status)
.catch((error) => {
throw error?.response?.data;
});
}
/**
* Restores an old editor asset
* @param {string} workspaceId - The workspace identifier
* @param {string} src - The asset source
* @returns {Promise<void>} Promise resolving to void
* @throws {Error} If the request fails
*/
async restoreOldEditorAsset(workspaceId: string, src: string): Promise<void> {
const assetKey = getAssetIdFromUrl(src);
return this.post(`/api/workspaces/file-assets/${workspaceId}/${assetKey}/restore/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
}
+36
View File
@@ -0,0 +1,36 @@
import { TFileMetaDataLite, TFileSignedURLResponse } from "@plane/types";
/**
* @description from the provided signed URL response, generate a payload to be used to upload the file
* @param {TFileSignedURLResponse} signedURLResponse
* @param {File} file
* @returns {FormData} file upload request payload
*/
export const generateFileUploadPayload = (signedURLResponse: TFileSignedURLResponse, file: File): FormData => {
const formData = new FormData();
Object.entries(signedURLResponse.upload_data.fields).forEach(([key, value]) => formData.append(key, value));
formData.append("file", file);
return formData;
};
/**
* @description returns the necessary file meta data to upload a file
* @param {File} file
* @returns {TFileMetaDataLite} payload with file info
*/
export const getFileMetaDataForUpload = (file: File): TFileMetaDataLite => ({
name: file.name,
size: file.size,
type: file.type,
});
/**
* @description this function returns the assetId from the asset source
* @param {string} src
* @returns {string} assetId
*/
export const getAssetIdFromUrl = (src: string): string => {
const sourcePaths = src.split("/");
const assetUrl = sourcePaths[sourcePaths.length - 1];
return assetUrl;
};
+3
View File
@@ -0,0 +1,3 @@
export * from "./file-upload.service";
export * from "./sites-file.service";
export * from "./file.service";
@@ -1,22 +1,40 @@
// plane imports
import { API_BASE_URL } from "@plane/constants";
// local services
import { TFileEntityInfo, TFileSignedURLResponse } from "@plane/types";
import { FileUploadService } from "./file-upload.service";
// helpers
import { generateFileUploadPayload, getAssetIdFromUrl, getFileMetaDataForUpload } from "@/helpers/file.helper";
// services
import { APIService } from "@/services/api.service";
import { FileUploadService } from "@/services/file-upload.service";
import { FileService } from "./file.service";
import { generateFileUploadPayload, getAssetIdFromUrl, getFileMetaDataForUpload } from "./helper";
export class FileService extends APIService {
/**
* Service class for managing file operations within plane sites application.
* Extends FileService to manage file-related operations.
* @extends {FileService}
* @remarks This service is only available for plane sites
*/
export class SitesFileService extends FileService {
private cancelSource: any;
fileUploadService: FileUploadService;
constructor() {
super(API_BASE_URL);
/**
* Creates an instance of SitesFileService
* @param {string} BASE_URL - The base URL for API requests
*/
constructor(BASE_URL?: string) {
super(BASE_URL || API_BASE_URL);
this.cancelUpload = this.cancelUpload.bind(this);
// services
this.fileUploadService = new FileUploadService();
}
/**
* Updates the upload status of an asset
* @param {string} anchor - The anchor identifier
* @param {string} assetId - The asset identifier
* @returns {Promise<void>} Promise resolving to void
* @throws {Error} If the request fails
*/
private async updateAssetUploadStatus(anchor: string, assetId: string): Promise<void> {
return this.patch(`/api/public/assets/v2/anchor/${anchor}/${assetId}/`)
.then((response) => response?.data)
@@ -25,6 +43,14 @@ export class FileService extends APIService {
});
}
/**
* Updates the upload status of multiple assets
* @param {string} anchor - The anchor identifier
* @param {string} entityId - The entity identifier
* @param {Object} data - The data payload
* @returns {Promise<void>} Promise resolving to void
* @throws {Error} If the request fails
*/
async updateBulkAssetsUploadStatus(
anchor: string,
entityId: string,
@@ -39,6 +65,14 @@ export class FileService extends APIService {
});
}
/**
* Uploads a file to the specified anchor
* @param {string} anchor - The anchor identifier
* @param {TFileEntityInfo} data - The data payload
* @param {File} file - The file to upload
* @returns {Promise<TFileSignedURLResponse>} Promise resolving to the signed URL response
* @throws {Error} If the request fails
*/
async uploadAsset(anchor: string, data: TFileEntityInfo, file: File): Promise<TFileSignedURLResponse> {
const fileMetaData = getFileMetaDataForUpload(file);
return this.post(`/api/public/assets/v2/anchor/${anchor}/`, {
@@ -57,23 +91,13 @@ export class FileService extends APIService {
});
}
async deleteNewAsset(assetPath: string): Promise<void> {
return this.delete(assetPath)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async deleteOldEditorAsset(workspaceId: string, src: string): Promise<any> {
const assetKey = getAssetIdFromUrl(src);
return this.delete(`/api/workspaces/file-assets/${workspaceId}/${assetKey}/`)
.then((response) => response?.status)
.catch((error) => {
throw error?.response?.data;
});
}
/**
* Restores a new asset
* @param {string} workspaceSlug - The workspace slug
* @param {string} src - The asset source
* @returns {Promise<void>} Promise resolving to void
* @throws {Error} If the request fails
*/
async restoreNewAsset(workspaceSlug: string, src: string): Promise<void> {
// remove the last slash and get the asset id
const assetId = getAssetIdFromUrl(src);
@@ -84,16 +108,10 @@ export class FileService extends APIService {
});
}
async restoreOldEditorAsset(workspaceId: string, src: string): Promise<void> {
const assetKey = getAssetIdFromUrl(src);
return this.post(`/api/workspaces/file-assets/${workspaceId}/${assetKey}/restore/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
/**
* Cancels the upload
*/
cancelUpload() {
this.cancelSource.cancel("Upload cancelled");
this.cancelSource.cancelUpload();
}
}
+4
View File
@@ -10,3 +10,7 @@ export * from "./module";
export * from "./user";
export * from "./project";
export * from "./workspace";
export * from "./file";
export * from "./label";
export * from "./state";
export * from "./issue";
@@ -0,0 +1,62 @@
export abstract class IndexedDBService {
private dbName: string;
private version: number;
private db: IDBDatabase | null = null;
constructor(dbName: string, version: number) {
this.dbName = dbName;
this.version = version;
}
async init(): Promise<void> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, this.version);
request.onerror = () => reject(request.error);
request.onsuccess = () => {
this.db = request.result;
resolve();
};
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
if (!db.objectStoreNames.contains("workspaces")) {
db.createObjectStore("workspaces", { keyPath: "id" });
}
};
});
}
async save(workspaces: any[]): Promise<void> {
if (!this.db) throw new Error("Database not initialized");
const transaction = this.db.transaction("workspaces", "readwrite");
const store = transaction.objectStore("workspaces");
return new Promise((resolve, reject) => {
// Clear existing data
store.clear();
// Add new workspaces
workspaces.forEach((workspace) => {
store.add(workspace);
});
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
});
}
async query(): Promise<any[]> {
if (!this.db) throw new Error("Database not initialized");
const transaction = this.db.transaction("workspaces", "readonly");
const store = transaction.objectStore("workspaces");
return new Promise((resolve, reject) => {
const request = store.getAll();
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
}
+1
View File
@@ -0,0 +1 @@
export * from "./sites-issue.service";
@@ -0,0 +1,244 @@
// plane imports
import { API_BASE_URL } from "@plane/constants";
import { IPublicIssue, TIssuePublicComment, TPublicIssuesResponse } from "@plane/types";
// api service
import { APIService } from "../api.service";
/**
* Service class for managing issues within plane sites application
* Extends the APIService class to handle HTTP requests to the issue-related endpoints
* @extends {APIService}
* @remarks This service is only available for plane sites
*/
export class SitesIssueService extends APIService {
constructor(BASE_URL?: string) {
super(BASE_URL || API_BASE_URL);
}
/**
* Retrieves a paginated list of issues for a specific anchor
* @param {string} anchor - The anchor identifier
* @param {any} params - Optional query parameters
* @returns {Promise<TPublicIssuesResponse>} Promise resolving to a paginated list of issues
* @throws {Error} If the API request fails
*/
async list(anchor: string, params: any): Promise<TPublicIssuesResponse> {
return this.get(`/api/public/anchor/${anchor}/issues/`, {
params,
})
.then((response) => response?.data)
.catch((error) => {
throw error?.response;
});
}
/**
* Retrieves details of a specific issue
* @param {string} anchor - The anchor identifier
* @param {string} issueID - The issue identifier
* @returns {Promise<IPublicIssue>} Promise resolving to the issue details
* @throws {Error} If the API request fails
*/
async retrieve(anchor: string, issueID: string): Promise<IPublicIssue> {
return this.get(`/api/public/anchor/${anchor}/issues/${issueID}/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response;
});
}
/**
* Retrieves the votes associated with a specific issue
* @param {string} anchor - The anchor identifier
* @param {string} issueID - The issue identifier
* @returns {Promise<any>} Promise resolving to the votes
* @throws {Error} If the API request fails
*/
async listVotes(anchor: string, issueID: string): Promise<any> {
return this.get(`/api/public/anchor/${anchor}/issues/${issueID}/votes/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response;
});
}
/**
* Creates a new vote for a specific issue
* @param {string} anchor - The anchor identifier
* @param {string} issueID - The issue identifier
* @param {any} data - The vote data
* @returns {Promise<any>} Promise resolving to the created vote
* @throws {Error} If the API request fails
*/
async addVote(anchor: string, issueID: string, data: any): Promise<any> {
return this.post(`/api/public/anchor/${anchor}/issues/${issueID}/votes/`, data)
.then((response) => response?.data)
.catch((error) => {
throw error?.response;
});
}
/**
* Deletes a vote for a specific issue
* @param {string} anchor - The anchor identifier
* @param {string} issueID - The issue identifier
* @returns {Promise<any>} Promise resolving to the deletion response
* @throws {Error} If the API request fails
*/
async removeVote(anchor: string, issueID: string): Promise<any> {
return this.delete(`/api/public/anchor/${anchor}/issues/${issueID}/votes/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response;
});
}
/**
* Retrieves the reactions associated with a specific issue
* @param {string} anchor - The anchor identifier
* @param {string} issueID - The issue identifier
* @returns {Promise<any>} Promise resolving to the reactions
* @throws {Error} If the API request fails
*/
async listReactions(anchor: string, issueID: string): Promise<any> {
return this.get(`/api/public/anchor/${anchor}/issues/${issueID}/reactions/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response;
});
}
/**
* Creates a new reaction for a specific issue
* @param {string} anchor - The anchor identifier
* @param {string} issueID - The issue identifier
* @param {any} data - The reaction data
* @returns {Promise<any>} Promise resolving to the created reaction
* @throws {Error} If the API request fails
*/
async addReaction(anchor: string, issueID: string, data: any): Promise<any> {
return this.post(`/api/public/anchor/${anchor}/issues/${issueID}/reactions/`, data)
.then((response) => response?.data)
.catch((error) => {
throw error?.response;
});
}
/**
* Deletes a reaction for a specific issue
* @param {string} anchor - The anchor identifier
* @param {string} issueID - The issue identifier
* @param {string} reactionId - The reaction identifier
* @returns {Promise<any>} Promise resolving to the deletion response
* @throws {Error} If the API request fails
*/
async removeReaction(anchor: string, issueID: string, reactionId: string): Promise<any> {
return this.delete(`/api/public/anchor/${anchor}/issues/${issueID}/reactions/${reactionId}/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response;
});
}
/**
* Retrieves the comments associated with a specific issue
* @param {string} anchor - The anchor identifier
* @param {string} issueID - The issue identifier
* @returns {Promise<any>} Promise resolving to the comments
* @throws {Error} If the API request fails
*/
async listComments(anchor: string, issueID: string): Promise<any> {
return this.get(`/api/public/anchor/${anchor}/issues/${issueID}/comments/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response;
});
}
/**
* Creates a new comment for a specific issue
* @param {string} anchor - The anchor identifier
* @param {string} issueID - The issue identifier
* @param {any} data - The comment data
* @returns {Promise<TIssuePublicComment>} Promise resolving to the created comment
* @throws {Error} If the API request fails
*/
async addComment(anchor: string, issueID: string, data: any): Promise<TIssuePublicComment> {
return this.post(`/api/public/anchor/${anchor}/issues/${issueID}/comments/`, data)
.then((response) => response?.data)
.catch((error) => {
throw error?.response;
});
}
/**
* Updates a comment for a specific issue
* @param {string} anchor - The anchor identifier
* @param {string} issueID - The issue identifier
* @param {string} commentId - The comment identifier
* @param {any} data - The updated comment data
* @returns {Promise<any>} Promise resolving to the updated comment
* @throws {Error} If the API request fails
*/
async updateComment(anchor: string, issueID: string, commentId: string, data: any): Promise<any> {
return this.patch(`/api/public/anchor/${anchor}/issues/${issueID}/comments/${commentId}/`, data)
.then((response) => response?.data)
.catch((error) => {
throw error?.response;
});
}
/**
* Deletes a comment for a specific issue
* @param {string} anchor - The anchor identifier
* @param {string} issueID - The issue identifier
* @param {string} commentId - The comment identifier
* @returns {Promise<any>} Promise resolving to the deletion response
* @throws {Error} If the API request fails
*/
async removeComment(anchor: string, issueID: string, commentId: string): Promise<any> {
return this.delete(`/api/public/anchor/${anchor}/issues/${issueID}/comments/${commentId}/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response;
});
}
/**
* Creates a new reaction for a specific comment
* @param {string} anchor - The anchor identifier
* @param {string} commentId - The comment identifier
* @param {any} data - The reaction data
* @returns {Promise<any>} Promise resolving to the created reaction
* @throws {Error} If the API request fails
*/
async addCommentReaction(
anchor: string,
commentId: string,
data: {
reaction: string;
}
): Promise<any> {
return this.post(`/api/public/anchor/${anchor}/comments/${commentId}/reactions/`, data)
.then((response) => response?.data)
.catch((error) => {
throw error?.response;
});
}
/**
* Deletes a reaction for a specific comment
* @param {string} anchor - The anchor identifier
* @param {string} commentId - The comment identifier
* @param {string} reactionHex - The reaction identifier
* @returns {Promise<any>} Promise resolving to the deletion response
* @throws {Error} If the API request fails
*/
async removeCommentReaction(anchor: string, commentId: string, reactionHex: string): Promise<any> {
return this.delete(`/api/public/anchor/${anchor}/comments/${commentId}/reactions/${reactionHex}/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response;
});
}
}
+1
View File
@@ -0,0 +1 @@
export * from "./sites-label.service";
@@ -0,0 +1,31 @@
// plane imports
import { API_BASE_URL } from "@plane/constants";
import { IIssueLabel } from "@plane/types";
// api service
import { APIService } from "../api.service";
/**
* Service class for managing labels within plane sites application.
* Extends APIService to handle HTTP requests to the label-related endpoints.
* @extends {APIService}
* @remarks This service is only available for plane sites
*/
export class SitesLabelService extends APIService {
constructor(BASE_URL?: string) {
super(BASE_URL || API_BASE_URL);
}
/**
* Retrieves a list of labels for a specific anchor.
* @param {string} anchor - The anchor identifier
* @returns {Promise<IIssueLabel[]>} The list of labels
* @throws {Error} If the API request fails
*/
async list(anchor: string): Promise<IIssueLabel[]> {
return this.get(`/api/public/anchor/${anchor}/labels/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
}
+1
View File
@@ -1,3 +1,4 @@
export * from "./link.service";
export * from "./module.service";
export * from "./operations.service";
export * from "./sites-module.service";
@@ -0,0 +1,31 @@
// plane imports
import { API_BASE_URL } from "@plane/constants";
// api service
import { TPublicModule } from "@plane/types";
import { APIService } from "../api.service";
/**
* Service class for managing modules within plane sites application.
* Extends APIService to handle HTTP requests to the module-related endpoints.
* @extends {APIService}
* @remarks This service is only available for plane sites
*/
export class SitesModuleService extends APIService {
constructor(BASE_URL?: string) {
super(BASE_URL || API_BASE_URL);
}
/**
* Retrieves a list of modules for a specific anchor.
* @param {string} anchor - The anchor identifier
* @returns {Promise<TPublicModule[]>} The list of modules
* @throws {Error} If the API request fails
*/
async list(anchor: string): Promise<TPublicModule[]> {
return this.get(`/api/public/anchor/${anchor}/modules/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
}
+2 -1
View File
@@ -1 +1,2 @@
export * from "./view.service";
export * from "./view.service";
export * from "./sites-publish.service";
@@ -0,0 +1,46 @@
// plane imports
import { API_BASE_URL } from "@plane/constants";
import { TProjectPublishSettings } from "@plane/types";
// api service
import { APIService } from "../api.service";
/**
* Service class for managing project publish operations within plane sites application.
* Extends APIService to handle HTTP requests to the project publish-related endpoints.
* @extends {APIService}
* @remarks This service is only available for plane sites
*/
export class SitesProjectPublishService extends APIService {
constructor(BASE_URL?: string) {
super(BASE_URL || API_BASE_URL);
}
/**
* Retrieves publish settings for a specific anchor.
* @param {string} anchor - The anchor identifier
* @returns {Promise<TProjectPublishSettings>} The publish settings
* @throws {Error} If the API request fails
*/
async retrieveSettingsByAnchor(anchor: string): Promise<TProjectPublishSettings> {
return this.get(`/api/public/anchor/${anchor}/settings/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response;
});
}
/**
* Retrieves publish settings for a specific project.
* @param {string} workspaceSlug - The workspace slug
* @param {string} projectID - The project identifier
* @returns {Promise<TProjectPublishSettings>} The publish settings
* @throws {Error} If the API request fails
*/
async retrieveSettingsByProjectId(workspaceSlug: string, projectID: string): Promise<TProjectPublishSettings> {
return this.get(`/api/public/workspaces/${workspaceSlug}/projects/${projectID}/anchor/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response;
});
}
}
+1
View File
@@ -0,0 +1 @@
export * from "./sites-state.service";
@@ -0,0 +1,31 @@
// plane imports
import { API_BASE_URL } from "@plane/constants";
import { IState } from "@plane/types";
// api service
import { APIService } from "../api.service";
/**
* Service class for managing states within plane sites application.
* Extends APIService to handle HTTP requests to the state-related endpoints.
* @extends {APIService}
* @remarks This service is only available for plane sites
*/
export class SitesStateService extends APIService {
constructor(BASE_URL?: string) {
super(BASE_URL || API_BASE_URL);
}
/**
* Retrieves a list of states for a specific anchor.
* @param {string} anchor - The anchor identifier
* @returns {Promise<IState[]>} The list of states
* @throws {Error} If the API request fails
*/
async list(anchor: string): Promise<IState[]> {
return this.get(`/api/public/anchor/${anchor}/states/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
}
+1
View File
@@ -1,2 +1,3 @@
export * from "./favorite.service";
export * from "./user.service";
export * from "./sites-member.service";
@@ -0,0 +1,31 @@
// plane imports
import { API_BASE_URL } from "@plane/constants";
import { TPublicMember } from "@plane/types";
// api service
import { APIService } from "../api.service";
/**
* Service class for managing members operations within plane sites application.
* Extends APIService to handle HTTP requests to the member-related endpoints.
* @extends {APIService}
* @remarks This service is only available for plane sites
*/
export class SitesMemberService extends APIService {
constructor(BASE_URL?: string) {
super(BASE_URL || API_BASE_URL);
}
/**
* Retrieves a list of members for a specific anchor.
* @param {string} anchor - The anchor identifier
* @returns {Promise<TPublicMember[]>} The list of members
* @throws {Error} If the API request fails
*/
async list(anchor: string): Promise<TPublicMember[]> {
return this.get(`/api/public/anchor/${anchor}/members/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
}
+55 -1
View File
@@ -1,6 +1,6 @@
// plane imports
import { API_BASE_URL } from "@plane/constants";
import type { IUser } from "@plane/types";
import type { IUser, TUserProfile } from "@plane/types";
// api service
import { APIService } from "../api.service";
@@ -18,6 +18,60 @@ export class UserService extends APIService {
super(BASE_URL || API_BASE_URL);
}
/**
* Retrieves the current user details
* @returns {Promise<IUser>} Promise resolving to the current user details\
* @remarks This method uses the validateStatus: null option to bypass interceptors for unauthorized errors.
*/
async me(): Promise<IUser> {
return this.get("/api/users/me/", { validateStatus: null })
.then((response) => response?.data)
.catch((error) => {
throw error?.response;
});
}
/**
* Updates the current user details
* @param {Partial<IUser>} data Data to update the user with
* @returns {Promise<IUser>} Promise resolving to the updated user details
* @throws {Error} If the API request fails
*/
async update(data: Partial<IUser>): Promise<IUser> {
return this.patch("/api/users/me/", data)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
/**
* Retrieves the current user's profile details
* @returns {Promise<TUserProfile>} Promise resolving to the current user's profile details
* @throws {Error} If the API request fails
*/
async profile(): Promise<TUserProfile> {
return this.get("/api/users/me/profile/")
.then((response) => response?.data)
.catch((error) => {
throw error?.response;
});
}
/**
* Updates the current user's profile details
* @param {Partial<TUserProfile>} data Data to update the user's profile with
* @returns {Promise<TUserProfile>} Promise resolving to the updated user's profile details
* @throws {Error} If the API request fails
*/
async updateProfile(data: Partial<TUserProfile>): Promise<TUserProfile> {
return this.patch("/api/users/me/profile/", data)
.then((response) => response?.data)
.catch((error) => {
throw error?.response;
});
}
/**
* Retrieves the current instance admin details
* @returns {Promise<IUser>} Promise resolving to the current instance admin details
+3
View File
@@ -0,0 +1,3 @@
build/*
dist/*
out/*
+9
View File
@@ -0,0 +1,9 @@
/** @type {import("eslint").Linter.Config} */
module.exports = {
root: true,
extends: ["@plane/eslint-config/library.js"],
parser: "@typescript-eslint/parser",
parserOptions: {
project: true,
},
};
+5
View File
@@ -0,0 +1,5 @@
{
"printWidth": 120,
"tabWidth": 2,
"trailingComma": "es5"
}
+20
View File
@@ -0,0 +1,20 @@
{
"name": "@plane/shared-state",
"version": "0.24.1",
"description": "Shared state shared across multiple apps internally",
"private": true,
"main": "./src/index.ts",
"types": "./src/index.ts",
"scripts": {
"lint": "eslint src --ext .ts,.tsx",
"lint:errors": "eslint src --ext .ts,.tsx --quiet"
},
"dependencies": {
"zod": "^3.22.2"
},
"devDependencies": {
"@plane/eslint-config": "*",
"@types/node": "^22.5.4",
"typescript": "^5.3.3"
}
}
View File
@@ -0,0 +1,136 @@
import { makeObservable, observable } from "mobx";
import { IWorkspaceStore } from "./workspace.store";
export interface IUserStore {
user: any;
workspaces: Map<string, IWorkspaceStore>;
isLoading: boolean;
error: any;
}
export class UserStore implements IUserStore {
user: any = null;
workspaces: Map<string, IWorkspaceStore> = new Map();
isLoading: boolean = false;
error: any = null;
constructor() {
makeObservable(this, {
user: observable.ref,
workspaces: observable,
isLoading: observable.ref,
error: observable.ref,
});
}
}
// userStore.ts
// class UserStore {
// user: User | null = null;
// workspaces: Workspace[] = [];
// isLoading = false;
// error: string | null = null;
// private indexedDBService: IndexedDBService;
// constructor() {
// makeAutoObservable(this);
// this.indexedDBService = new IndexedDBService();
// this.init();
// }
// private async init() {
// try {
// await this.indexedDBService.init();
// await this.loadWorkspacesFromIndexedDB();
// } catch (error) {
// runInAction(() => {
// this.error = "Failed to initialize store";
// console.error("Store initialization error:", error);
// });
// }
// }
// setUser(user: User | null) {
// this.user = user;
// }
// async loadWorkspacesFromIndexedDB() {
// try {
// const workspaces = await this.indexedDBService.getWorkspaces();
// runInAction(() => {
// this.workspaces = workspaces;
// });
// } catch (error) {
// runInAction(() => {
// this.error = "Failed to load workspaces from IndexedDB";
// console.error("Load workspaces error:", error);
// });
// }
// }
// async fetchAndSyncWorkspaces() {
// this.isLoading = true;
// this.error = null;
// try {
// // Simulate API call to fetch workspaces
// const response = await fetch("/api/workspaces");
// const workspaces = await response.json();
// // Save to IndexedDB
// await this.indexedDBService.saveWorkspaces(workspaces);
// // Update MobX store
// runInAction(() => {
// this.workspaces = workspaces;
// this.isLoading = false;
// });
// } catch (error) {
// runInAction(() => {
// this.error = "Failed to fetch workspaces";
// this.isLoading = false;
// console.error("Fetch workspaces error:", error);
// });
// }
// }
// // Additional methods for workspace management
// async addWorkspace(workspace: Omit<Workspace, "id" | "createdAt" | "updatedAt">) {
// this.isLoading = true;
// this.error = null;
// try {
// // Simulate API call to create workspace
// const response = await fetch("/api/workspaces", {
// method: "POST",
// body: JSON.stringify(workspace),
// });
// const newWorkspace = await response.json();
// // Update local storage and state
// const updatedWorkspaces = [...this.workspaces, newWorkspace];
// await this.indexedDBService.saveWorkspaces(updatedWorkspaces);
// runInAction(() => {
// this.workspaces.push(newWorkspace);
// this.isLoading = false;
// });
// } catch (error) {
// runInAction(() => {
// this.error = "Failed to add workspace";
// this.isLoading = false;
// console.error("Add workspace error:", error);
// });
// }
// }
// logout() {
// this.user = null;
// this.workspaces = [];
// // Optionally clear IndexedDB data
// this.indexedDBService.init().then(() => {
// this.indexedDBService.saveWorkspaces([]);
// });
// }
// }
@@ -0,0 +1,28 @@
import { makeObservable, observable } from "mobx";
export interface IWorkspaceStore {
id: string;
name: string;
createdAt: string;
updatedAt: string;
}
export class WorkspaceStore implements IWorkspaceStore {
id: string;
name: string;
createdAt: string;
updatedAt: string;
constructor(data: IWorkspaceStore) {
makeObservable(this, {
id: observable.ref,
name: observable.ref,
createdAt: observable.ref,
updatedAt: observable.ref,
});
this.id = data.id;
this.name = data.name;
this.createdAt = data.createdAt;
this.updatedAt = data.updatedAt;
}
}
+12
View File
@@ -0,0 +1,12 @@
{
"extends": "@plane/typescript-config/react-library.json",
"compilerOptions": {
"jsx": "react",
"lib": ["esnext", "dom"],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["./src"],
"exclude": ["dist", "build", "node_modules"]
}

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