Compare commits
64 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2fbf0d6eda | |||
| 4e381c0943 | |||
| 563ca2ff07 | |||
| 76c80ced14 | |||
| a49d899ea1 | |||
| 3f6ef56a0f | |||
| cba27c348d | |||
| ffe87cc3b4 | |||
| 473932af0a | |||
| a9aeeb6707 | |||
| 075eefe1a5 | |||
| 54bdd62d0c | |||
| d4ee32cb41 | |||
| 31bba2926d | |||
| d6c25a76f6 | |||
| 8a792d381b | |||
| 4353cc0c4a | |||
| 82eea3e802 | |||
| bf1f12378e | |||
| c4a3e1e8ac | |||
| b62b2710f5 | |||
| 71b41fa22b | |||
| 3528d2c934 | |||
| 39ecfbe7e1 | |||
| a95864ba11 | |||
| b88ae112f9 | |||
| 2d20278c9b | |||
| 8cff059868 | |||
| 6a3ccafe35 | |||
| cc9b448a9b | |||
| e071bf4861 | |||
| b9da7df6b7 | |||
| 03cc819601 | |||
| e1943ee11e | |||
| b47d2b8825 | |||
| 300b47f9a1 | |||
| 03a4a97375 | |||
| 6157d5771d | |||
| eee43be99a | |||
| 4db95cc941 | |||
| 6aa139a851 | |||
| ac74cd9e92 | |||
| 7ae841d525 | |||
| 7aa5b6aa91 | |||
| 28c3f9d0cc | |||
| 9d01a6d5d7 | |||
| 4fd8b4a3a9 | |||
| 49cc73b6ed | |||
| 363507f987 | |||
| 30453d1c79 | |||
| dff12729c0 | |||
| 8efe692c80 | |||
| ce57c1423c | |||
| 1eb1e82fe4 | |||
| a2328d0cbe | |||
| 5096a15051 | |||
| 55c2511ab5 | |||
| 16bc64e2fa | |||
| 14083ea7da | |||
| feb88e64a4 | |||
| a00bb35e54 | |||
| 20ba91b98c | |||
| 456c7f55a9 | |||
| c2da3ea4c8 |
@@ -25,6 +25,10 @@ on:
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
push:
|
||||
branches:
|
||||
- preview
|
||||
- canary
|
||||
|
||||
env:
|
||||
TARGET_BRANCH: ${{ github.ref_name }}
|
||||
|
||||
@@ -71,7 +71,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18.x
|
||||
node-version: 20.x
|
||||
- run: yarn install
|
||||
- run: yarn lint --filter=admin
|
||||
|
||||
@@ -84,7 +84,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18.x
|
||||
node-version: 20.x
|
||||
- run: yarn install
|
||||
- run: yarn lint --filter=space
|
||||
|
||||
@@ -97,7 +97,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18.x
|
||||
node-version: 20.x
|
||||
- run: yarn install
|
||||
- run: yarn lint --filter=web
|
||||
|
||||
@@ -109,7 +109,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18.x
|
||||
node-version: 20.x
|
||||
- run: yarn install
|
||||
- run: yarn build --filter=admin
|
||||
|
||||
@@ -121,7 +121,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18.x
|
||||
node-version: 20.x
|
||||
- run: yarn install
|
||||
- run: yarn build --filter=space
|
||||
|
||||
@@ -133,6 +133,6 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18.x
|
||||
node-version: 20.x
|
||||
- run: yarn install
|
||||
- run: yarn build --filter=web
|
||||
|
||||
@@ -123,7 +123,7 @@ export const GeneralConfigurationForm: FC<IGeneralConfigurationForm> = observer(
|
||||
No PII is collected.This anonymized data is used to understand how you use Plane and build new features
|
||||
in line with{" "}
|
||||
<a
|
||||
href="https://docs.plane.so/self-hosting/telemetry"
|
||||
href="https://developers.plane.so/self-hosting/telemetry"
|
||||
target="_blank"
|
||||
className="text-custom-primary-100 hover:underline"
|
||||
rel="noreferrer"
|
||||
|
||||
@@ -336,7 +336,7 @@ export const InstanceSetupForm: FC = (props) => {
|
||||
</label>
|
||||
<a
|
||||
tabIndex={-1}
|
||||
href="https://docs.plane.so/telemetry"
|
||||
href="https://developers.plane.so/self-hosting/telemetry"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm font-medium text-blue-500 hover:text-blue-600"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# Django imports
|
||||
from django.utils import timezone
|
||||
from lxml import html
|
||||
from django.db import IntegrityError
|
||||
|
||||
# Third party imports
|
||||
from rest_framework import serializers
|
||||
@@ -138,47 +139,56 @@ class IssueSerializer(BaseSerializer):
|
||||
updated_by_id = issue.updated_by_id
|
||||
|
||||
if assignees is not None and len(assignees):
|
||||
IssueAssignee.objects.bulk_create(
|
||||
[
|
||||
IssueAssignee(
|
||||
assignee_id=assignee_id,
|
||||
try:
|
||||
IssueAssignee.objects.bulk_create(
|
||||
[
|
||||
IssueAssignee(
|
||||
assignee_id=assignee_id,
|
||||
issue=issue,
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
created_by_id=created_by_id,
|
||||
updated_by_id=updated_by_id,
|
||||
)
|
||||
for assignee_id in assignees
|
||||
],
|
||||
batch_size=10,
|
||||
)
|
||||
except IntegrityError:
|
||||
pass
|
||||
else:
|
||||
try:
|
||||
# Then assign it to default assignee
|
||||
if default_assignee_id is not None:
|
||||
IssueAssignee.objects.create(
|
||||
assignee_id=default_assignee_id,
|
||||
issue=issue,
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
created_by_id=created_by_id,
|
||||
updated_by_id=updated_by_id,
|
||||
)
|
||||
for assignee_id in assignees
|
||||
],
|
||||
batch_size=10,
|
||||
)
|
||||
else:
|
||||
# Then assign it to default assignee
|
||||
if default_assignee_id is not None:
|
||||
IssueAssignee.objects.create(
|
||||
assignee_id=default_assignee_id,
|
||||
issue=issue,
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
created_by_id=created_by_id,
|
||||
updated_by_id=updated_by_id,
|
||||
)
|
||||
except IntegrityError:
|
||||
pass
|
||||
|
||||
if labels is not None and len(labels):
|
||||
IssueLabel.objects.bulk_create(
|
||||
[
|
||||
IssueLabel(
|
||||
label_id=label_id,
|
||||
issue=issue,
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
created_by_id=created_by_id,
|
||||
updated_by_id=updated_by_id,
|
||||
)
|
||||
for label_id in labels
|
||||
],
|
||||
batch_size=10,
|
||||
)
|
||||
try:
|
||||
IssueLabel.objects.bulk_create(
|
||||
[
|
||||
IssueLabel(
|
||||
label_id=label_id,
|
||||
issue=issue,
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
created_by_id=created_by_id,
|
||||
updated_by_id=updated_by_id,
|
||||
)
|
||||
for label_id in labels
|
||||
],
|
||||
batch_size=10,
|
||||
)
|
||||
except IntegrityError:
|
||||
pass
|
||||
|
||||
return issue
|
||||
|
||||
@@ -194,39 +204,45 @@ class IssueSerializer(BaseSerializer):
|
||||
|
||||
if assignees is not None:
|
||||
IssueAssignee.objects.filter(issue=instance).delete()
|
||||
IssueAssignee.objects.bulk_create(
|
||||
[
|
||||
IssueAssignee(
|
||||
assignee_id=assignee_id,
|
||||
issue=instance,
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
created_by_id=created_by_id,
|
||||
updated_by_id=updated_by_id,
|
||||
)
|
||||
for assignee_id in assignees
|
||||
],
|
||||
batch_size=10,
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
try:
|
||||
IssueAssignee.objects.bulk_create(
|
||||
[
|
||||
IssueAssignee(
|
||||
assignee_id=assignee_id,
|
||||
issue=instance,
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
created_by_id=created_by_id,
|
||||
updated_by_id=updated_by_id,
|
||||
)
|
||||
for assignee_id in assignees
|
||||
],
|
||||
batch_size=10,
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
except IntegrityError:
|
||||
pass
|
||||
|
||||
if labels is not None:
|
||||
IssueLabel.objects.filter(issue=instance).delete()
|
||||
IssueLabel.objects.bulk_create(
|
||||
[
|
||||
IssueLabel(
|
||||
label_id=label_id,
|
||||
issue=instance,
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
created_by_id=created_by_id,
|
||||
updated_by_id=updated_by_id,
|
||||
)
|
||||
for label_id in labels
|
||||
],
|
||||
batch_size=10,
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
try:
|
||||
IssueLabel.objects.bulk_create(
|
||||
[
|
||||
IssueLabel(
|
||||
label_id=label_id,
|
||||
issue=instance,
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
created_by_id=created_by_id,
|
||||
updated_by_id=updated_by_id,
|
||||
)
|
||||
for label_id in labels
|
||||
],
|
||||
batch_size=10,
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
except IntegrityError:
|
||||
pass
|
||||
|
||||
# Time updation occues even when other related models are updated
|
||||
instance.updated_at = timezone.now()
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
from django.utils import timezone
|
||||
from django.core.validators import URLValidator
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import IntegrityError
|
||||
|
||||
# Third Party imports
|
||||
from rest_framework import serializers
|
||||
@@ -134,47 +135,56 @@ class IssueCreateSerializer(BaseSerializer):
|
||||
updated_by_id = issue.updated_by_id
|
||||
|
||||
if assignees is not None and len(assignees):
|
||||
IssueAssignee.objects.bulk_create(
|
||||
[
|
||||
IssueAssignee(
|
||||
assignee=user,
|
||||
issue=issue,
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
created_by_id=created_by_id,
|
||||
updated_by_id=updated_by_id,
|
||||
)
|
||||
for user in assignees
|
||||
],
|
||||
batch_size=10,
|
||||
)
|
||||
try:
|
||||
IssueAssignee.objects.bulk_create(
|
||||
[
|
||||
IssueAssignee(
|
||||
assignee=user,
|
||||
issue=issue,
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
created_by_id=created_by_id,
|
||||
updated_by_id=updated_by_id,
|
||||
)
|
||||
for user in assignees
|
||||
],
|
||||
batch_size=10,
|
||||
)
|
||||
except IntegrityError:
|
||||
pass
|
||||
else:
|
||||
# Then assign it to default assignee
|
||||
if default_assignee_id is not None:
|
||||
IssueAssignee.objects.create(
|
||||
assignee_id=default_assignee_id,
|
||||
issue=issue,
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
created_by_id=created_by_id,
|
||||
updated_by_id=updated_by_id,
|
||||
)
|
||||
|
||||
if labels is not None and len(labels):
|
||||
IssueLabel.objects.bulk_create(
|
||||
[
|
||||
IssueLabel(
|
||||
label=label,
|
||||
try:
|
||||
IssueAssignee.objects.create(
|
||||
assignee_id=default_assignee_id,
|
||||
issue=issue,
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
created_by_id=created_by_id,
|
||||
updated_by_id=updated_by_id,
|
||||
)
|
||||
for label in labels
|
||||
],
|
||||
batch_size=10,
|
||||
)
|
||||
except IntegrityError:
|
||||
pass
|
||||
|
||||
if labels is not None and len(labels):
|
||||
try:
|
||||
IssueLabel.objects.bulk_create(
|
||||
[
|
||||
IssueLabel(
|
||||
label=label,
|
||||
issue=issue,
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
created_by_id=created_by_id,
|
||||
updated_by_id=updated_by_id,
|
||||
)
|
||||
for label in labels
|
||||
],
|
||||
batch_size=10,
|
||||
)
|
||||
except IntegrityError:
|
||||
pass
|
||||
|
||||
return issue
|
||||
|
||||
@@ -190,39 +200,45 @@ class IssueCreateSerializer(BaseSerializer):
|
||||
|
||||
if assignees is not None:
|
||||
IssueAssignee.objects.filter(issue=instance).delete()
|
||||
IssueAssignee.objects.bulk_create(
|
||||
[
|
||||
IssueAssignee(
|
||||
assignee=user,
|
||||
issue=instance,
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
created_by_id=created_by_id,
|
||||
updated_by_id=updated_by_id,
|
||||
)
|
||||
for user in assignees
|
||||
],
|
||||
batch_size=10,
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
try:
|
||||
IssueAssignee.objects.bulk_create(
|
||||
[
|
||||
IssueAssignee(
|
||||
assignee=user,
|
||||
issue=instance,
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
created_by_id=created_by_id,
|
||||
updated_by_id=updated_by_id,
|
||||
)
|
||||
for user in assignees
|
||||
],
|
||||
batch_size=10,
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
except IntegrityError:
|
||||
pass
|
||||
|
||||
if labels is not None:
|
||||
IssueLabel.objects.filter(issue=instance).delete()
|
||||
IssueLabel.objects.bulk_create(
|
||||
[
|
||||
IssueLabel(
|
||||
label=label,
|
||||
issue=instance,
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
created_by_id=created_by_id,
|
||||
updated_by_id=updated_by_id,
|
||||
)
|
||||
for label in labels
|
||||
],
|
||||
batch_size=10,
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
try:
|
||||
IssueLabel.objects.bulk_create(
|
||||
[
|
||||
IssueLabel(
|
||||
label=label,
|
||||
issue=instance,
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
created_by_id=created_by_id,
|
||||
updated_by_id=updated_by_id,
|
||||
)
|
||||
for label in labels
|
||||
],
|
||||
batch_size=10,
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
except IntegrityError:
|
||||
pass
|
||||
|
||||
# Time updation occues even when other related models are updated
|
||||
instance.updated_at = timezone.now()
|
||||
|
||||
@@ -32,10 +32,9 @@ from django.core.exceptions import ValidationError
|
||||
|
||||
|
||||
class WorkSpaceSerializer(DynamicBaseSerializer):
|
||||
owner = UserLiteSerializer(read_only=True)
|
||||
total_members = serializers.IntegerField(read_only=True)
|
||||
total_issues = serializers.IntegerField(read_only=True)
|
||||
logo_url = serializers.CharField(read_only=True)
|
||||
role = serializers.IntegerField(read_only=True)
|
||||
|
||||
def validate_slug(self, value):
|
||||
# Check if the slug is restricted
|
||||
@@ -60,7 +59,7 @@ class WorkSpaceSerializer(DynamicBaseSerializer):
|
||||
class WorkspaceLiteSerializer(BaseSerializer):
|
||||
class Meta:
|
||||
model = Workspace
|
||||
fields = ["name", "slug", "id"]
|
||||
fields = ["name", "slug", "id", "logo_url"]
|
||||
read_only_fields = fields
|
||||
|
||||
|
||||
@@ -91,9 +90,11 @@ class WorkspaceMemberAdminSerializer(DynamicBaseSerializer):
|
||||
|
||||
|
||||
class WorkSpaceMemberInviteSerializer(BaseSerializer):
|
||||
workspace = WorkSpaceSerializer(read_only=True)
|
||||
total_members = serializers.IntegerField(read_only=True)
|
||||
created_by_detail = UserLiteSerializer(read_only=True, source="created_by")
|
||||
workspace = WorkspaceLiteSerializer(read_only=True)
|
||||
invite_link = serializers.SerializerMethodField()
|
||||
|
||||
def get_invite_link(self, obj):
|
||||
return f"/workspace-invitations/?invitation_id={obj.id}&email={obj.email}&slug={obj.workspace.slug}"
|
||||
|
||||
class Meta:
|
||||
model = WorkspaceMemberInvite
|
||||
@@ -107,6 +108,7 @@ class WorkSpaceMemberInviteSerializer(BaseSerializer):
|
||||
"responded_at",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"invite_link",
|
||||
]
|
||||
|
||||
|
||||
@@ -147,6 +149,42 @@ class WorkspaceUserLinkSerializer(BaseSerializer):
|
||||
return value
|
||||
|
||||
|
||||
def create(self, validated_data):
|
||||
# Filtering the WorkspaceUserLink with the given url to check if the link already exists.
|
||||
|
||||
url = validated_data.get("url")
|
||||
|
||||
workspace_user_link = WorkspaceUserLink.objects.filter(
|
||||
url=url,
|
||||
workspace_id=validated_data.get("workspace_id"),
|
||||
owner_id=validated_data.get("owner_id")
|
||||
)
|
||||
|
||||
if workspace_user_link.exists():
|
||||
raise serializers.ValidationError(
|
||||
{"error": "URL already exists for this workspace and owner"}
|
||||
)
|
||||
|
||||
return super().create(validated_data)
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
# Filtering the WorkspaceUserLink with the given url to check if the link already exists.
|
||||
|
||||
url = validated_data.get("url")
|
||||
|
||||
workspace_user_link = WorkspaceUserLink.objects.filter(
|
||||
url=url,
|
||||
workspace_id=instance.workspace_id,
|
||||
owner=instance.owner
|
||||
)
|
||||
|
||||
if workspace_user_link.exclude(pk=instance.id).exists():
|
||||
raise serializers.ValidationError(
|
||||
{"error": "URL already exists for this workspace and owner"}
|
||||
)
|
||||
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
class IssueRecentVisitSerializer(serializers.ModelSerializer):
|
||||
project_identifier = serializers.SerializerMethodField()
|
||||
|
||||
|
||||
@@ -26,6 +26,8 @@ from plane.app.views import (
|
||||
IssueBulkUpdateDateEndpoint,
|
||||
IssueVersionEndpoint,
|
||||
IssueDescriptionVersionEndpoint,
|
||||
IssueMetaEndpoint,
|
||||
IssueDetailIdentifierEndpoint,
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
@@ -278,4 +280,14 @@ urlpatterns = [
|
||||
IssueDescriptionVersionEndpoint.as_view(),
|
||||
name="page-versions",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/meta/",
|
||||
IssueMetaEndpoint.as_view(),
|
||||
name="issue-meta",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/work-items/<str:project_identifier>-<str:issue_identifier>/",
|
||||
IssueDetailIdentifierEndpoint.as_view(),
|
||||
name="issue-detail-identifier",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -116,6 +116,8 @@ from .issue.base import (
|
||||
IssuePaginatedViewSet,
|
||||
IssueDetailEndpoint,
|
||||
IssueBulkUpdateDateEndpoint,
|
||||
IssueMetaEndpoint,
|
||||
IssueDetailIdentifierEndpoint,
|
||||
)
|
||||
|
||||
from .issue.activity import IssueActivityEndpoint
|
||||
|
||||
@@ -5,6 +5,7 @@ import uuid
|
||||
from django.conf import settings
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.utils import timezone
|
||||
from django.db import IntegrityError
|
||||
|
||||
# Third party imports
|
||||
from rest_framework import status
|
||||
@@ -679,15 +680,30 @@ class ProjectBulkAssetEndpoint(BaseAPIView):
|
||||
[self.save_project_cover(asset, project_id) for asset in assets]
|
||||
|
||||
if asset.entity_type == FileAsset.EntityTypeContext.ISSUE_DESCRIPTION:
|
||||
assets.update(issue_id=entity_id)
|
||||
# For some cases, the bulk api is called after the issue is deleted creating
|
||||
# an integrity error
|
||||
try:
|
||||
assets.update(issue_id=entity_id)
|
||||
except IntegrityError:
|
||||
pass
|
||||
|
||||
if asset.entity_type == FileAsset.EntityTypeContext.COMMENT_DESCRIPTION:
|
||||
assets.update(comment_id=entity_id)
|
||||
# For some cases, the bulk api is called after the comment is deleted
|
||||
# creating an integrity error
|
||||
try:
|
||||
assets.update(comment_id=entity_id)
|
||||
except IntegrityError:
|
||||
pass
|
||||
|
||||
if asset.entity_type == FileAsset.EntityTypeContext.PAGE_DESCRIPTION:
|
||||
assets.update(page_id=entity_id)
|
||||
|
||||
if asset.entity_type == FileAsset.EntityTypeContext.DRAFT_ISSUE_DESCRIPTION:
|
||||
assets.update(draft_issue_id=entity_id)
|
||||
# For some cases, the bulk api is called after the draft issue is deleted
|
||||
# creating an integrity error
|
||||
try:
|
||||
assets.update(draft_issue_id=entity_id)
|
||||
except IntegrityError:
|
||||
pass
|
||||
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@@ -134,11 +134,11 @@ class CycleViewSet(BaseViewSet):
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
pending_issues=Count(
|
||||
cancelled_issues=Count(
|
||||
"issue_cycle__issue__id",
|
||||
distinct=True,
|
||||
filter=Q(
|
||||
issue_cycle__issue__state__group__in=["backlog", "unstarted", "started"],
|
||||
issue_cycle__issue__state__group__in=["cancelled"],
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
issue_cycle__deleted_at__isnull=True,
|
||||
@@ -227,7 +227,7 @@ class CycleViewSet(BaseViewSet):
|
||||
"is_favorite",
|
||||
"total_issues",
|
||||
"completed_issues",
|
||||
"pending_issues",
|
||||
"cancelled_issues",
|
||||
"assignee_ids",
|
||||
"status",
|
||||
"version",
|
||||
@@ -259,7 +259,7 @@ class CycleViewSet(BaseViewSet):
|
||||
# meta fields
|
||||
"is_favorite",
|
||||
"total_issues",
|
||||
"pending_issues",
|
||||
"cancelled_issues",
|
||||
"completed_issues",
|
||||
"assignee_ids",
|
||||
"status",
|
||||
|
||||
@@ -1096,3 +1096,178 @@ class IssueBulkUpdateDateEndpoint(BaseAPIView):
|
||||
return Response(
|
||||
{"message": "Issues updated successfully"}, status=status.HTTP_200_OK
|
||||
)
|
||||
|
||||
|
||||
class IssueMetaEndpoint(BaseAPIView):
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="PROJECT")
|
||||
def get(self, request, slug, project_id, issue_id):
|
||||
issue = Issue.issue_objects.only("sequence_id", "project__identifier").get(
|
||||
id=issue_id, project_id=project_id, workspace__slug=slug
|
||||
)
|
||||
return Response(
|
||||
{
|
||||
"sequence_id": issue.sequence_id,
|
||||
"project_identifier": issue.project.identifier,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
|
||||
class IssueDetailIdentifierEndpoint(BaseAPIView):
|
||||
|
||||
def get(self, request, slug, project_identifier, issue_identifier):
|
||||
|
||||
# Fetch the project
|
||||
project = Project.objects.get(
|
||||
identifier__iexact=project_identifier,
|
||||
workspace__slug=slug,
|
||||
)
|
||||
|
||||
# Check if the user is a member of the project
|
||||
if not ProjectMember.objects.filter(
|
||||
workspace__slug=slug,
|
||||
project_id=project.id,
|
||||
member=request.user,
|
||||
is_active=True,
|
||||
).exists():
|
||||
return Response(
|
||||
{"error": "You are not allowed to view this issue"},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
# Fetch the issue
|
||||
issue = (
|
||||
Issue.issue_objects.filter(project_id=project.id)
|
||||
.filter(workspace__slug=slug)
|
||||
.select_related("workspace", "project", "state", "parent")
|
||||
.prefetch_related("assignees", "labels", "issue_module__module")
|
||||
.annotate(
|
||||
cycle_id=Subquery(
|
||||
CycleIssue.objects.filter(issue=OuterRef("id")).values("cycle_id")[
|
||||
:1
|
||||
]
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
attachment_count=FileAsset.objects.filter(
|
||||
issue_id=OuterRef("id"),
|
||||
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.filter(sequence_id=issue_identifier)
|
||||
.annotate(
|
||||
label_ids=Coalesce(
|
||||
ArrayAgg(
|
||||
"labels__id",
|
||||
distinct=True,
|
||||
filter=Q(
|
||||
~Q(labels__id__isnull=True)
|
||||
& Q(label_issue__deleted_at__isnull=True)
|
||||
),
|
||||
),
|
||||
Value([], output_field=ArrayField(UUIDField())),
|
||||
),
|
||||
assignee_ids=Coalesce(
|
||||
ArrayAgg(
|
||||
"assignees__id",
|
||||
distinct=True,
|
||||
filter=Q(
|
||||
~Q(assignees__id__isnull=True)
|
||||
& Q(assignees__member_project__is_active=True)
|
||||
& Q(issue_assignee__deleted_at__isnull=True)
|
||||
),
|
||||
),
|
||||
Value([], output_field=ArrayField(UUIDField())),
|
||||
),
|
||||
module_ids=Coalesce(
|
||||
ArrayAgg(
|
||||
"issue_module__module_id",
|
||||
distinct=True,
|
||||
filter=Q(
|
||||
~Q(issue_module__module_id__isnull=True)
|
||||
& Q(issue_module__module__archived_at__isnull=True)
|
||||
& Q(issue_module__deleted_at__isnull=True)
|
||||
),
|
||||
),
|
||||
Value([], output_field=ArrayField(UUIDField())),
|
||||
),
|
||||
)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_reactions",
|
||||
queryset=IssueReaction.objects.select_related("issue", "actor"),
|
||||
)
|
||||
)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_link",
|
||||
queryset=IssueLink.objects.select_related("created_by"),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
is_subscribed=Exists(
|
||||
IssueSubscriber.objects.filter(
|
||||
workspace__slug=slug,
|
||||
project_id=project.id,
|
||||
issue__sequence_id=issue_identifier,
|
||||
subscriber=request.user,
|
||||
)
|
||||
)
|
||||
)
|
||||
).first()
|
||||
|
||||
# Check if the issue exists
|
||||
if not issue:
|
||||
return Response(
|
||||
{"error": "The required object does not exist."},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
|
||||
"""
|
||||
if the role is guest and guest_view_all_features is false and owned by is not
|
||||
the requesting user then dont show the issue
|
||||
"""
|
||||
|
||||
if (
|
||||
ProjectMember.objects.filter(
|
||||
workspace__slug=slug,
|
||||
project_id=project.id,
|
||||
member=request.user,
|
||||
role=5,
|
||||
is_active=True,
|
||||
).exists()
|
||||
and not project.guest_view_all_features
|
||||
and not issue.created_by == request.user
|
||||
):
|
||||
return Response(
|
||||
{"error": "You are not allowed to view this issue"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
recent_visited_task.delay(
|
||||
slug=slug,
|
||||
entity_name="issue",
|
||||
entity_identifier=str(issue.id),
|
||||
user_id=str(request.user.id),
|
||||
project_id=str(project.id),
|
||||
)
|
||||
|
||||
# Serialize the issue
|
||||
serializer = IssueDetailSerializer(issue, expand=self.expand)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
@@ -5,6 +5,7 @@ import json
|
||||
from django.utils import timezone
|
||||
from django.db.models import Exists
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
from django.db import IntegrityError
|
||||
|
||||
# Third Party imports
|
||||
from rest_framework.response import Response
|
||||
@@ -164,24 +165,32 @@ class CommentReactionViewSet(BaseViewSet):
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
||||
def create(self, request, slug, project_id, comment_id):
|
||||
serializer = CommentReactionSerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
serializer.save(
|
||||
project_id=project_id, actor_id=request.user.id, comment_id=comment_id
|
||||
try:
|
||||
serializer = CommentReactionSerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
serializer.save(
|
||||
project_id=project_id,
|
||||
actor_id=request.user.id,
|
||||
comment_id=comment_id,
|
||||
)
|
||||
issue_activity.delay(
|
||||
type="comment_reaction.activity.created",
|
||||
requested_data=json.dumps(request.data, cls=DjangoJSONEncoder),
|
||||
actor_id=str(request.user.id),
|
||||
issue_id=None,
|
||||
project_id=str(project_id),
|
||||
current_instance=None,
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
except IntegrityError:
|
||||
return Response(
|
||||
{"error": "Reaction already exists for the user"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
issue_activity.delay(
|
||||
type="comment_reaction.activity.created",
|
||||
requested_data=json.dumps(request.data, cls=DjangoJSONEncoder),
|
||||
actor_id=str(request.user.id),
|
||||
issue_id=None,
|
||||
project_id=str(project_id),
|
||||
current_instance=None,
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
||||
def destroy(self, request, slug, project_id, comment_id, reaction_code):
|
||||
|
||||
@@ -35,7 +35,9 @@ class LabelViewSet(BaseViewSet):
|
||||
.order_by("sort_order")
|
||||
)
|
||||
|
||||
@invalidate_cache(path="/api/workspaces/:slug/labels/", url_params=True, user=False)
|
||||
@invalidate_cache(
|
||||
path="/api/workspaces/:slug/labels/", url_params=True, user=False, multiple=True
|
||||
)
|
||||
@allow_permission([ROLE.ADMIN])
|
||||
def create(self, request, slug, project_id):
|
||||
try:
|
||||
@@ -53,6 +55,20 @@ class LabelViewSet(BaseViewSet):
|
||||
@invalidate_cache(path="/api/workspaces/:slug/labels/", url_params=True, user=False)
|
||||
@allow_permission([ROLE.ADMIN])
|
||||
def partial_update(self, request, *args, **kwargs):
|
||||
# Check if the label name is unique within the project
|
||||
if (
|
||||
"name" in request.data
|
||||
and Label.objects.filter(
|
||||
project_id=kwargs["project_id"], name=request.data["name"]
|
||||
)
|
||||
.exclude(pk=kwargs["pk"])
|
||||
.exists()
|
||||
):
|
||||
return Response(
|
||||
{"error": "Label with the same name already exists in the project"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
# call the parent method to perform the update
|
||||
return super().partial_update(request, *args, **kwargs)
|
||||
|
||||
@invalidate_cache(path="/api/workspaces/:slug/labels/", url_params=True, user=False)
|
||||
@@ -72,7 +88,7 @@ class BulkCreateIssueLabelsEndpoint(BaseAPIView):
|
||||
Label(
|
||||
name=label.get("name", "Migrated"),
|
||||
description=label.get("description", "Migrated Issue"),
|
||||
color=f"#{random.randint(0, 0xFFFFFF+1):06X}",
|
||||
color=f"#{random.randint(0, 0xFFFFFF + 1):06X}",
|
||||
project_id=project_id,
|
||||
workspace_id=project.workspace_id,
|
||||
created_by=request.user,
|
||||
|
||||
@@ -272,10 +272,9 @@ class IssueRelationViewSet(BaseViewSet):
|
||||
|
||||
issue_relations = IssueRelation.objects.filter(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
).filter(
|
||||
Q(issue_id=related_issue, related_issue_id=issue_id) |
|
||||
Q(issue_id=issue_id, related_issue_id=related_issue)
|
||||
Q(issue_id=related_issue, related_issue_id=issue_id)
|
||||
| Q(issue_id=issue_id, related_issue_id=related_issue)
|
||||
)
|
||||
issue_relations = issue_relations.first()
|
||||
current_instance = json.dumps(
|
||||
|
||||
@@ -40,7 +40,7 @@ from ..base import BaseAPIView, BaseViewSet
|
||||
from plane.bgtasks.page_transaction_task import page_transaction
|
||||
from plane.bgtasks.page_version_task import page_version
|
||||
from plane.bgtasks.recent_visited_task import recent_visited_task
|
||||
|
||||
from plane.bgtasks.copy_s3_object import copy_s3_objects
|
||||
|
||||
def unarchive_archive_page_and_descendants(page_id, archived_at):
|
||||
# Your SQL query
|
||||
@@ -597,6 +597,16 @@ class PageDuplicateEndpoint(BaseAPIView):
|
||||
page_transaction.delay(
|
||||
{"description_html": page.description_html}, None, page.id
|
||||
)
|
||||
|
||||
# Copy the s3 objects uploaded in the page
|
||||
copy_s3_objects.delay(
|
||||
entity_name="PAGE",
|
||||
entity_identifier=page.id,
|
||||
project_id=project_id,
|
||||
slug=slug,
|
||||
user_id=request.user.id,
|
||||
)
|
||||
|
||||
page = (
|
||||
Page.objects.filter(pk=page.id)
|
||||
.annotate(
|
||||
|
||||
@@ -7,9 +7,11 @@ from datetime import date
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from django.db import IntegrityError
|
||||
from django.db.models import Count, F, Func, OuterRef, Prefetch, Q
|
||||
|
||||
from django.db.models.fields import DateField
|
||||
from django.db.models.functions import Cast, ExtractDay, ExtractWeek
|
||||
|
||||
|
||||
# Django imports
|
||||
from django.http import HttpResponse
|
||||
from django.utils import timezone
|
||||
@@ -62,12 +64,6 @@ class WorkSpaceViewSet(BaseViewSet):
|
||||
.values("count")
|
||||
)
|
||||
|
||||
issue_count = (
|
||||
Issue.issue_objects.filter(workspace=OuterRef("id"))
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
return (
|
||||
self.filter_queryset(super().get_queryset().select_related("owner"))
|
||||
.order_by("name")
|
||||
@@ -76,8 +72,6 @@ class WorkSpaceViewSet(BaseViewSet):
|
||||
workspace_member__is_active=True,
|
||||
)
|
||||
.annotate(total_members=member_count)
|
||||
.annotate(total_issues=issue_count)
|
||||
.select_related("owner")
|
||||
)
|
||||
|
||||
def create(self, request):
|
||||
@@ -123,7 +117,14 @@ class WorkSpaceViewSet(BaseViewSet):
|
||||
role=20,
|
||||
company_role=request.data.get("company_role", ""),
|
||||
)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
# Get total members and role
|
||||
total_members=WorkspaceMember.objects.filter(workspace_id=serializer.data["id"]).count()
|
||||
data = serializer.data
|
||||
data["total_members"] = total_members
|
||||
data["role"] = 20
|
||||
|
||||
return Response(data, status=status.HTTP_201_CREATED)
|
||||
return Response(
|
||||
[serializer.errors[error][0] for error in serializer.errors],
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
@@ -166,11 +167,9 @@ class UserWorkSpacesEndpoint(BaseAPIView):
|
||||
.values("count")
|
||||
)
|
||||
|
||||
issue_count = (
|
||||
Issue.issue_objects.filter(workspace=OuterRef("id"))
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
role = (
|
||||
WorkspaceMember.objects.filter(workspace=OuterRef("id"), member=request.user, is_active=True)
|
||||
.values("role")
|
||||
)
|
||||
|
||||
workspace = (
|
||||
@@ -182,19 +181,19 @@ class UserWorkSpacesEndpoint(BaseAPIView):
|
||||
),
|
||||
)
|
||||
)
|
||||
.select_related("owner")
|
||||
.annotate(total_members=member_count)
|
||||
.annotate(total_issues=issue_count)
|
||||
.annotate(role=role, total_members=member_count)
|
||||
.filter(
|
||||
workspace_member__member=request.user, workspace_member__is_active=True
|
||||
)
|
||||
.distinct()
|
||||
)
|
||||
|
||||
workspaces = WorkSpaceSerializer(
|
||||
self.filter_queryset(workspace),
|
||||
fields=fields if fields else None,
|
||||
many=True,
|
||||
).data
|
||||
|
||||
return Response(workspaces, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ from rest_framework.response import Response
|
||||
|
||||
# Django modules
|
||||
from django.db.models import Q
|
||||
from django.db import IntegrityError
|
||||
|
||||
# Module imports
|
||||
from plane.app.views.base import BaseAPIView
|
||||
@@ -31,16 +32,21 @@ class WorkspaceFavoriteEndpoint(BaseAPIView):
|
||||
|
||||
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
|
||||
def post(self, request, slug):
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
serializer = UserFavoriteSerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
serializer.save(
|
||||
user_id=request.user.id,
|
||||
workspace=workspace,
|
||||
project_id=request.data.get("project_id", None),
|
||||
try:
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
serializer = UserFavoriteSerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
serializer.save(
|
||||
user_id=request.user.id,
|
||||
workspace=workspace,
|
||||
project_id=request.data.get("project_id", None),
|
||||
)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
except IntegrityError:
|
||||
return Response(
|
||||
{"error": "Favorite already exists"}, status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
|
||||
def patch(self, request, slug, favorite_id):
|
||||
|
||||
@@ -251,8 +251,7 @@ class UserWorkspaceInvitationsViewSet(BaseViewSet):
|
||||
super()
|
||||
.get_queryset()
|
||||
.filter(email=self.request.user.email)
|
||||
.select_related("workspace", "workspace__owner", "created_by")
|
||||
.annotate(total_members=Count("workspace__workspace_member"))
|
||||
.select_related("workspace")
|
||||
)
|
||||
|
||||
@invalidate_cache(path="/api/workspaces/", user=False)
|
||||
|
||||
@@ -21,7 +21,7 @@ class QuickLinkViewSet(BaseViewSet):
|
||||
serializer = WorkspaceUserLinkSerializer(data=request.data)
|
||||
|
||||
if serializer.is_valid():
|
||||
serializer.save(workspace_id=workspace.id, owner=request.user)
|
||||
serializer.save(workspace_id=workspace.id, owner_id=request.user.id)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
@@ -30,7 +30,6 @@ class WorkspaceUserPreferenceViewSet(BaseAPIView):
|
||||
keys = [
|
||||
key
|
||||
for key, _ in WorkspaceUserPreference.UserPreferenceKeys.choices
|
||||
if key not in ["projects"]
|
||||
]
|
||||
|
||||
for preference in keys:
|
||||
@@ -40,20 +39,28 @@ class WorkspaceUserPreferenceViewSet(BaseAPIView):
|
||||
preference = WorkspaceUserPreference.objects.bulk_create(
|
||||
[
|
||||
WorkspaceUserPreference(
|
||||
key=key, user=request.user, workspace=workspace
|
||||
key=key, user=request.user, workspace=workspace, sort_order=(65535 + (i*10000))
|
||||
)
|
||||
for key in create_preference_keys
|
||||
for i, key in enumerate(create_preference_keys)
|
||||
],
|
||||
batch_size=10,
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
|
||||
preference = WorkspaceUserPreference.objects.filter(
|
||||
preferences = WorkspaceUserPreference.objects.filter(
|
||||
user=request.user, workspace_id=workspace.id
|
||||
)
|
||||
).order_by("sort_order").values("key", "is_pinned", "sort_order")
|
||||
|
||||
|
||||
user_preferences = {}
|
||||
|
||||
for preference in preferences:
|
||||
user_preferences[(str(preference["key"]))] = {
|
||||
"is_pinned": preference["is_pinned"],
|
||||
"sort_order": preference["sort_order"],
|
||||
}
|
||||
return Response(
|
||||
preference.values("key", "is_pinned", "sort_order"),
|
||||
user_preferences,
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
# Python imports
|
||||
import uuid
|
||||
import base64
|
||||
import requests
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
# Django imports
|
||||
from django.conf import settings
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import FileAsset, Page, Issue
|
||||
from plane.utils.exception_logger import log_exception
|
||||
from plane.settings.storage import S3Storage
|
||||
from celery import shared_task
|
||||
|
||||
|
||||
def get_entity_id_field(entity_type, entity_id):
|
||||
entity_mapping = {
|
||||
FileAsset.EntityTypeContext.WORKSPACE_LOGO: {"workspace_id": entity_id},
|
||||
FileAsset.EntityTypeContext.PROJECT_COVER: {"project_id": entity_id},
|
||||
FileAsset.EntityTypeContext.USER_AVATAR: {"user_id": entity_id},
|
||||
FileAsset.EntityTypeContext.USER_COVER: {"user_id": entity_id},
|
||||
FileAsset.EntityTypeContext.ISSUE_ATTACHMENT: {"issue_id": entity_id},
|
||||
FileAsset.EntityTypeContext.ISSUE_DESCRIPTION: {"issue_id": entity_id},
|
||||
FileAsset.EntityTypeContext.PAGE_DESCRIPTION: {"page_id": entity_id},
|
||||
FileAsset.EntityTypeContext.COMMENT_DESCRIPTION: {"comment_id": entity_id},
|
||||
FileAsset.EntityTypeContext.DRAFT_ISSUE_DESCRIPTION: {
|
||||
"draft_issue_id": entity_id
|
||||
},
|
||||
}
|
||||
return entity_mapping.get(entity_type, {})
|
||||
|
||||
|
||||
def extract_asset_ids(html, tag):
|
||||
try:
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
return [tag.get("src") for tag in soup.find_all(tag) if tag.get("src")]
|
||||
except Exception as e:
|
||||
log_exception(e)
|
||||
return []
|
||||
|
||||
|
||||
def replace_asset_ids(html, tag, duplicated_assets):
|
||||
try:
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
for mention_tag in soup.find_all(tag):
|
||||
for asset in duplicated_assets:
|
||||
if mention_tag.get("src") == asset["old_asset_id"]:
|
||||
mention_tag["src"] = asset["new_asset_id"]
|
||||
return str(soup)
|
||||
except Exception as e:
|
||||
log_exception(e)
|
||||
return html
|
||||
|
||||
|
||||
def update_description(entity, duplicated_assets, tag):
|
||||
updated_html = replace_asset_ids(entity.description_html, tag, duplicated_assets)
|
||||
entity.description_html = updated_html
|
||||
entity.save()
|
||||
return updated_html
|
||||
|
||||
|
||||
# Get the description binary and description from the live server
|
||||
def sync_with_external_service(entity_name, description_html):
|
||||
try:
|
||||
data = {
|
||||
"description_html": description_html,
|
||||
"variant": "rich" if entity_name == "PAGE" else "document",
|
||||
}
|
||||
response = requests.post(
|
||||
f"{settings.LIVE_BASE_URL}/convert-document/",
|
||||
json=data,
|
||||
headers=None,
|
||||
)
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
except requests.RequestException as e:
|
||||
log_exception(e)
|
||||
return {}
|
||||
|
||||
|
||||
@shared_task
|
||||
def copy_s3_objects(entity_name, entity_identifier, project_id, slug, user_id):
|
||||
"""
|
||||
Step 1: Extract asset ids from the description_html of the entity
|
||||
Step 2: Duplicate the assets
|
||||
Step 3: Update the description_html of the entity with the new asset ids (change the src of img tag)
|
||||
Step 4: Request the live server to generate the description_binary and description for the entity
|
||||
|
||||
"""
|
||||
try:
|
||||
model_class = {"PAGE": Page, "ISSUE": Issue}.get(entity_name)
|
||||
if not model_class:
|
||||
raise ValueError(f"Unsupported entity_name: {entity_name}")
|
||||
|
||||
entity = model_class.objects.get(id=entity_identifier)
|
||||
asset_ids = extract_asset_ids(entity.description_html, "image-component")
|
||||
|
||||
duplicated_assets = []
|
||||
workspace = entity.workspace
|
||||
storage = S3Storage()
|
||||
original_assets = FileAsset.objects.filter(
|
||||
workspace=workspace, project_id=project_id, id__in=asset_ids
|
||||
)
|
||||
|
||||
for original_asset in original_assets:
|
||||
destination_key = f"{workspace.id}/{uuid.uuid4().hex}-{original_asset.attributes.get('name')}"
|
||||
duplicated_asset = FileAsset.objects.create(
|
||||
attributes={
|
||||
"name": original_asset.attributes.get("name"),
|
||||
"type": original_asset.attributes.get("type"),
|
||||
"size": original_asset.attributes.get("size"),
|
||||
},
|
||||
asset=destination_key,
|
||||
size=original_asset.size,
|
||||
workspace=workspace,
|
||||
created_by_id=user_id,
|
||||
entity_type=original_asset.entity_type,
|
||||
project_id=project_id,
|
||||
storage_metadata=original_asset.storage_metadata,
|
||||
**get_entity_id_field(original_asset.entity_type, entity_identifier),
|
||||
)
|
||||
storage.copy_object(original_asset.asset, destination_key)
|
||||
duplicated_assets.append(
|
||||
{
|
||||
"new_asset_id": str(duplicated_asset.id),
|
||||
"old_asset_id": str(original_asset.id),
|
||||
}
|
||||
)
|
||||
|
||||
if duplicated_assets:
|
||||
FileAsset.objects.filter(
|
||||
pk__in=[item["new_asset_id"] for item in duplicated_assets]
|
||||
).update(is_uploaded=True)
|
||||
updated_html = update_description(
|
||||
entity, duplicated_assets, "image-component"
|
||||
)
|
||||
external_data = sync_with_external_service(entity_name, updated_html)
|
||||
|
||||
if external_data:
|
||||
entity.description = external_data.get("description")
|
||||
entity.description_binary = base64.b64decode(
|
||||
external_data.get("description_binary")
|
||||
)
|
||||
entity.save()
|
||||
|
||||
return
|
||||
except Exception as e:
|
||||
log_exception(e)
|
||||
return []
|
||||
@@ -82,7 +82,10 @@ def soft_delete_related_objects(app_label, model_name, instance_pk, using=None):
|
||||
)
|
||||
else:
|
||||
# Handle other relationships
|
||||
related_queryset = getattr(instance, related_name).all()
|
||||
related_queryset = getattr(instance, related_name)(
|
||||
manager="objects"
|
||||
).all()
|
||||
|
||||
for related_obj in related_queryset:
|
||||
if hasattr(related_obj, "deleted_at"):
|
||||
if not related_obj.deleted_at:
|
||||
|
||||
@@ -9,10 +9,11 @@ from celery import shared_task
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
from django.utils import timezone
|
||||
|
||||
from plane.app.serializers import IssueActivitySerializer
|
||||
from plane.bgtasks.notification_task import notifications
|
||||
|
||||
# Module imports
|
||||
from plane.utils.valid_uuid import is_valid_uuid
|
||||
from plane.app.serializers import IssueActivitySerializer
|
||||
from plane.bgtasks.notification_task import notifications
|
||||
from plane.db.models import (
|
||||
CommentReaction,
|
||||
Cycle,
|
||||
@@ -790,14 +791,15 @@ def create_cycle_issue_activity(
|
||||
issue_id=updated_record.get("issue_id"),
|
||||
actor_id=actor_id,
|
||||
verb="updated",
|
||||
old_value=old_cycle.name,
|
||||
new_value=new_cycle.name,
|
||||
old_value=old_cycle.name if old_cycle else "",
|
||||
new_value=new_cycle.name if new_cycle else "",
|
||||
field="cycles",
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
comment=f"updated cycle from {old_cycle.name} to {new_cycle.name}",
|
||||
old_identifier=old_cycle.id,
|
||||
new_identifier=new_cycle.id,
|
||||
comment=f"""updated cycle from {old_cycle.name if old_cycle else ""}
|
||||
to {new_cycle.name if new_cycle else ""}""",
|
||||
old_identifier=old_cycle.id if old_cycle else None,
|
||||
new_identifier=new_cycle.id if new_cycle else None,
|
||||
epoch=epoch,
|
||||
)
|
||||
)
|
||||
@@ -893,11 +895,11 @@ def create_module_issue_activity(
|
||||
actor_id=actor_id,
|
||||
verb="created",
|
||||
old_value="",
|
||||
new_value=module.name,
|
||||
new_value=module.name if module else "",
|
||||
field="modules",
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
comment=f"added module {module.name}",
|
||||
comment=f"added module {module.name if module else ''}",
|
||||
new_identifier=requested_data.get("module_id"),
|
||||
epoch=epoch,
|
||||
)
|
||||
@@ -1413,7 +1415,7 @@ def delete_issue_relation_activity(
|
||||
),
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
comment=f'deleted {requested_data.get("relation_type")} relation',
|
||||
comment=f"deleted {requested_data.get('relation_type')} relation",
|
||||
old_identifier=requested_data.get("related_issue"),
|
||||
epoch=epoch,
|
||||
)
|
||||
@@ -1567,6 +1569,10 @@ def issue_activity(
|
||||
try:
|
||||
issue_activities = []
|
||||
|
||||
# Validate UUIDs
|
||||
if not is_valid_uuid(project_id):
|
||||
return
|
||||
|
||||
project = Project.objects.get(pk=project_id)
|
||||
workspace_id = project.workspace_id
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# Python imports
|
||||
from django.utils import timezone
|
||||
from django.db import DatabaseError
|
||||
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
@@ -22,8 +23,12 @@ def recent_visited_task(entity_name, entity_identifier, user_id, project_id, slu
|
||||
).first()
|
||||
|
||||
if recent_visited:
|
||||
recent_visited.visited_at = timezone.now()
|
||||
recent_visited.save(update_fields=["visited_at"])
|
||||
# Check if the database is available
|
||||
try:
|
||||
recent_visited.visited_at = timezone.now()
|
||||
recent_visited.save(update_fields=["visited_at"])
|
||||
except DatabaseError:
|
||||
pass
|
||||
else:
|
||||
recent_visited_count = UserRecentVisit.objects.filter(
|
||||
user_id=user_id, workspace_id=workspace.id
|
||||
|
||||
@@ -391,13 +391,13 @@ class WorkspaceHomePreference(BaseModel):
|
||||
class WorkspaceUserPreference(BaseModel):
|
||||
"""Preference for the workspace for a user"""
|
||||
|
||||
class UserPreferenceKeys(models.TextChoices):
|
||||
PROJECTS = "projects", "Projects"
|
||||
ANALYTICS = "analytics", "Analytics"
|
||||
CYCLES = "cycles", "Cycles"
|
||||
class UserPreferenceKeys(models.TextChoices):
|
||||
VIEWS = "views", "Views"
|
||||
ACTIVE_CYCLES = "active_cycles", "Active Cycles"
|
||||
ANALYTICS = "analytics", "Analytics"
|
||||
DRAFTS = "drafts", "Drafts"
|
||||
YOUR_WORK = "your_work", "Your Work"
|
||||
|
||||
ARCHIVES = "archives", "Archives"
|
||||
|
||||
workspace = models.ForeignKey(
|
||||
"db.Workspace",
|
||||
|
||||
@@ -336,6 +336,8 @@ CSRF_FAILURE_VIEW = "plane.authentication.views.common.csrf_failure"
|
||||
ADMIN_BASE_URL = os.environ.get("ADMIN_BASE_URL", None)
|
||||
SPACE_BASE_URL = os.environ.get("SPACE_BASE_URL", None)
|
||||
APP_BASE_URL = os.environ.get("APP_BASE_URL")
|
||||
LIVE_BASE_URL = os.environ.get("LIVE_BASE_URL")
|
||||
|
||||
|
||||
HARD_DELETE_AFTER_DAYS = int(os.environ.get("HARD_DELETE_AFTER_DAYS", 60))
|
||||
|
||||
|
||||
@@ -151,3 +151,17 @@ class S3Storage(S3Boto3Storage):
|
||||
"ETag": response.get("ETag"),
|
||||
"Metadata": response.get("Metadata", {}),
|
||||
}
|
||||
|
||||
def copy_object(self, object_name, new_object_name):
|
||||
"""Copy an S3 object to a new location"""
|
||||
try:
|
||||
response = self.s3_client.copy_object(
|
||||
Bucket=self.aws_storage_bucket_name,
|
||||
CopySource={"Bucket": self.aws_storage_bucket_name, "Key": object_name},
|
||||
Key=new_object_name,
|
||||
)
|
||||
except ClientError as e:
|
||||
log_exception(e)
|
||||
return None
|
||||
|
||||
return response
|
||||
|
||||
@@ -14,9 +14,9 @@ class ProjectMetaDataEndpoint(BaseAPIView):
|
||||
|
||||
def get(self, request, anchor):
|
||||
try:
|
||||
deploy_board = DeployBoard.objects.filter(
|
||||
deploy_board = DeployBoard.objects.get(
|
||||
anchor=anchor, entity_name="project"
|
||||
).first()
|
||||
)
|
||||
except DeployBoard.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Project is not published"}, status=status.HTTP_404_NOT_FOUND
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import uuid
|
||||
|
||||
def is_valid_uuid(uuid_to_test, version=4):
|
||||
try:
|
||||
# check for validity of Uuid
|
||||
uuid_obj = uuid.UUID(uuid_to_test, version=version)
|
||||
except ValueError:
|
||||
return False
|
||||
return True
|
||||
@@ -51,7 +51,7 @@ beautifulsoup4==4.12.3
|
||||
# analytics
|
||||
posthog==3.5.0
|
||||
# crypto
|
||||
cryptography==43.0.1
|
||||
cryptography==44.0.1
|
||||
# html validator
|
||||
lxml==5.2.1
|
||||
# s3
|
||||
|
||||
+1
-1
@@ -23,7 +23,7 @@
|
||||
"@plane/constants": "*",
|
||||
"@plane/editor": "*",
|
||||
"@plane/types": "*",
|
||||
"@sentry/node": "^8.28.0",
|
||||
"@sentry/node": "^9.0.1",
|
||||
"@sentry/profiling-node": "^8.28.0",
|
||||
"@tiptap/core": "2.10.4",
|
||||
"@tiptap/html": "2.11.0",
|
||||
|
||||
+5
-1
@@ -22,7 +22,11 @@
|
||||
"devDependencies": {
|
||||
"prettier": "latest",
|
||||
"prettier-plugin-tailwindcss": "^0.5.4",
|
||||
"turbo": "^2.3.3"
|
||||
"turbo": "^2.4.1"
|
||||
},
|
||||
"resolutions": {
|
||||
"nanoid": "3.3.8",
|
||||
"esbuild": "0.25.0"
|
||||
},
|
||||
"packageManager": "yarn@1.22.22",
|
||||
"name": "plane"
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export const LABEL_CLASSNAME = "uppercase text-custom-text-300/60 text-sm tracking-wide";
|
||||
export const AXIS_LINE_CLASSNAME = "text-custom-text-400/70";
|
||||
@@ -1,6 +1,7 @@
|
||||
export * from "./ai";
|
||||
export * from "./analytics";
|
||||
export * from "./auth";
|
||||
export * from "./chart";
|
||||
export * from "./endpoints";
|
||||
export * from "./file";
|
||||
export * from "./filter";
|
||||
|
||||
@@ -2,7 +2,6 @@ export const ISSUE_FORM_TAB_INDICES = [
|
||||
"name",
|
||||
"description_html",
|
||||
"feeling_lucky",
|
||||
"ai_assistant",
|
||||
"state_id",
|
||||
"priority",
|
||||
"assignee_ids",
|
||||
|
||||
@@ -84,48 +84,42 @@ export const WORKSPACE_SETTINGS = {
|
||||
i18n_label: "workspace_settings.settings.general.title",
|
||||
href: `/settings`,
|
||||
access: [EUserWorkspaceRoles.ADMIN],
|
||||
highlight: (pathname: string, baseUrl: string) =>
|
||||
pathname === `${baseUrl}/settings/`,
|
||||
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/`,
|
||||
},
|
||||
members: {
|
||||
key: "members",
|
||||
i18n_label: "workspace_settings.settings.members.title",
|
||||
href: `/settings/members`,
|
||||
access: [EUserWorkspaceRoles.ADMIN],
|
||||
highlight: (pathname: string, baseUrl: string) =>
|
||||
pathname === `${baseUrl}/settings/members/`,
|
||||
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/members/`,
|
||||
},
|
||||
"billing-and-plans": {
|
||||
key: "billing-and-plans",
|
||||
i18n_label: "workspace_settings.settings.billing_and_plans.title",
|
||||
href: `/settings/billing`,
|
||||
access: [EUserWorkspaceRoles.ADMIN],
|
||||
highlight: (pathname: string, baseUrl: string) =>
|
||||
pathname === `${baseUrl}/settings/billing/`,
|
||||
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/billing/`,
|
||||
},
|
||||
export: {
|
||||
key: "export",
|
||||
i18n_label: "workspace_settings.settings.exports.title",
|
||||
href: `/settings/exports`,
|
||||
access: [EUserWorkspaceRoles.ADMIN],
|
||||
highlight: (pathname: string, baseUrl: string) =>
|
||||
pathname === `${baseUrl}/settings/exports/`,
|
||||
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/exports/`,
|
||||
},
|
||||
webhooks: {
|
||||
key: "webhooks",
|
||||
i18n_label: "workspace_settings.settings.webhooks.title",
|
||||
href: `/settings/webhooks`,
|
||||
access: [EUserWorkspaceRoles.ADMIN],
|
||||
highlight: (pathname: string, baseUrl: string) =>
|
||||
pathname === `${baseUrl}/settings/webhooks/`,
|
||||
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/webhooks/`,
|
||||
},
|
||||
"api-tokens": {
|
||||
key: "api-tokens",
|
||||
i18n_label: "workspace_settings.settings.api_tokens.title",
|
||||
href: `/settings/api-tokens`,
|
||||
access: [EUserWorkspaceRoles.ADMIN],
|
||||
highlight: (pathname: string, baseUrl: string) =>
|
||||
pathname === `${baseUrl}/settings/api-tokens/`,
|
||||
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/api-tokens/`,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -256,3 +250,84 @@ export const DEFAULT_GLOBAL_VIEWS_LIST: {
|
||||
i18n_label: "default_global_view.subscribed",
|
||||
},
|
||||
];
|
||||
|
||||
export interface IWorkspaceSidebarNavigationItem {
|
||||
key: string;
|
||||
labelTranslationKey: string;
|
||||
href: string;
|
||||
access: EUserWorkspaceRoles[];
|
||||
}
|
||||
|
||||
export const WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS: Record<string, IWorkspaceSidebarNavigationItem> = {
|
||||
"your-work": {
|
||||
key: "your_work",
|
||||
labelTranslationKey: "your_work",
|
||||
href: `/profile/`,
|
||||
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER],
|
||||
},
|
||||
views: {
|
||||
key: "views",
|
||||
labelTranslationKey: "views",
|
||||
href: `/workspace-views/all-issues/`,
|
||||
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER, EUserWorkspaceRoles.GUEST],
|
||||
},
|
||||
"active-cycles": {
|
||||
key: "active_cycles",
|
||||
labelTranslationKey: "cycles",
|
||||
href: `/active-cycles/`,
|
||||
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER],
|
||||
},
|
||||
analytics: {
|
||||
key: "analytics",
|
||||
labelTranslationKey: "analytics",
|
||||
href: `/analytics/`,
|
||||
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER],
|
||||
},
|
||||
drafts: {
|
||||
key: "drafts",
|
||||
labelTranslationKey: "drafts",
|
||||
href: `/drafts/`,
|
||||
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER],
|
||||
},
|
||||
archives: {
|
||||
key: "archives",
|
||||
labelTranslationKey: "archives",
|
||||
href: `/projects/archives/`,
|
||||
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER],
|
||||
},
|
||||
};
|
||||
export const WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS_LINKS: IWorkspaceSidebarNavigationItem[] = [
|
||||
WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS["views"],
|
||||
WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS["active-cycles"],
|
||||
WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS["analytics"],
|
||||
WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS["your-work"],
|
||||
WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS["drafts"],
|
||||
WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS["archives"],
|
||||
];
|
||||
|
||||
export const WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS: Record<string, IWorkspaceSidebarNavigationItem> = {
|
||||
home: {
|
||||
key: "home",
|
||||
labelTranslationKey: "home.title",
|
||||
href: `/`,
|
||||
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER, EUserWorkspaceRoles.GUEST],
|
||||
},
|
||||
notifications: {
|
||||
key: "notifications",
|
||||
labelTranslationKey: "notification.label",
|
||||
href: `/notifications/`,
|
||||
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER, EUserWorkspaceRoles.GUEST],
|
||||
},
|
||||
projects: {
|
||||
key: "projects",
|
||||
labelTranslationKey: "projects",
|
||||
href: `/projects/`,
|
||||
access: [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER, EUserWorkspaceRoles.GUEST],
|
||||
},
|
||||
};
|
||||
|
||||
export const WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS_LINKS: IWorkspaceSidebarNavigationItem[] = [
|
||||
WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS["home"],
|
||||
WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS["notifications"],
|
||||
WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS["projects"],
|
||||
];
|
||||
|
||||
@@ -51,6 +51,10 @@ export const ListKeymap = ({ tabIndex }: { tabIndex?: number }) =>
|
||||
} else if (this.editor.commands.liftListItem("taskItem")) {
|
||||
return true;
|
||||
}
|
||||
// if tabIndex is set, we don't want to handle Tab key
|
||||
if (tabIndex !== undefined && tabIndex !== null) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
Delete: ({ editor }) => {
|
||||
|
||||
@@ -8,9 +8,9 @@ export const useOutsideClickDetector = (
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(event.target as any)) {
|
||||
// check for the closest element with attribute name data-prevent-outside-click
|
||||
const preventOutsideClickElement = (
|
||||
event.target as unknown as HTMLElement | undefined
|
||||
)?.closest("[data-prevent-outside-click]");
|
||||
const preventOutsideClickElement = (event.target as unknown as HTMLElement | undefined)?.closest(
|
||||
"[data-prevent-outside-click]"
|
||||
);
|
||||
// if the closest element with attribute name data-prevent-outside-click is found, return
|
||||
if (preventOutsideClickElement) {
|
||||
return;
|
||||
|
||||
@@ -376,6 +376,8 @@
|
||||
|
||||
"home": {
|
||||
"empty": {
|
||||
"quickstart_guide": "Your quickstart guide",
|
||||
"not_right_now": "Not right now",
|
||||
"create_project": {
|
||||
"title": "Create a project",
|
||||
"description": "Most things start with a project in Plane.",
|
||||
@@ -416,7 +418,7 @@
|
||||
"project": "Your recent projects will appear here once you visit one.",
|
||||
"page": "Your recent pages will appear here once you visit one.",
|
||||
"issue": "Your recent work items will appear here once you visit one.",
|
||||
"default": "You don't have any recent items yet."
|
||||
"default": "You don't have any recents yet."
|
||||
},
|
||||
"filters": {
|
||||
"all": "All items",
|
||||
@@ -518,6 +520,7 @@
|
||||
"customize_time_range": "Customize time range",
|
||||
"loading": "Loading",
|
||||
"attachments": "Attachments",
|
||||
"property": "Property",
|
||||
"properties": "Properties",
|
||||
"parent": "Parent",
|
||||
"remove": "Remove",
|
||||
@@ -670,7 +673,21 @@
|
||||
"disconnect": "Disconnect",
|
||||
"disconnecting": "Disconnecting",
|
||||
"installing": "Installing",
|
||||
"install": "Install"
|
||||
"install": "Install",
|
||||
"category": "Category",
|
||||
"categories": "Categories",
|
||||
"saving": "Saving",
|
||||
"save_changes": "Save changes",
|
||||
"delete": "Delete",
|
||||
"deleting": "Deleting",
|
||||
"pending": "Pending",
|
||||
"invite": "Invite"
|
||||
},
|
||||
|
||||
"chart": {
|
||||
"x_axis": "X-axis",
|
||||
"y_axis": "Y-axis",
|
||||
"metric": "Metric"
|
||||
},
|
||||
|
||||
"form": {
|
||||
@@ -1251,9 +1268,19 @@
|
||||
"company_size": "Company size",
|
||||
"url": "Workspace URL",
|
||||
"update_workspace": "Update workspace",
|
||||
"delete_workspace": "Delete workspace",
|
||||
"delete_workspace": "Delete this workspace",
|
||||
"delete_workspace_description": "When deleting a workspace, all of the data and resources within that workspace will be permanently removed and cannot be recovered.",
|
||||
"delete_btn": "Delete my workspace",
|
||||
"delete_btn": "Delete this workspace",
|
||||
"delete_modal": {
|
||||
"title": "Are you sure you want to delete this workspace?",
|
||||
"description": "You have an active trial to one of our paid plans. Please cancel it first to proceed.",
|
||||
"dismiss": "Dismiss",
|
||||
"cancel": "Cancel trial",
|
||||
"success_title": "Workspace deleted.",
|
||||
"success_message": "You will soon go to your profile page.",
|
||||
"error_title": "That didn't work.",
|
||||
"error_message": "Try again, please."
|
||||
},
|
||||
"errors": {
|
||||
"name": {
|
||||
"required": "Name is required",
|
||||
@@ -1267,6 +1294,7 @@
|
||||
"members": {
|
||||
"title": "Members",
|
||||
"add_member": "Add member",
|
||||
"pending_invites": "Pending invites",
|
||||
"invitations_sent_successfully": "Invitations sent successfully",
|
||||
"leave_confirmation": "Are you sure you want to leave the workspace? You will no longer have access to this workspace. This action cannot be undone.",
|
||||
"details": {
|
||||
@@ -1503,7 +1531,11 @@
|
||||
}
|
||||
},
|
||||
"states": {
|
||||
"describe_this_state_for_your_members": "Describe this state for your members."
|
||||
"describe_this_state_for_your_members": "Describe this state for your members.",
|
||||
"empty_state": {
|
||||
"title": "No states available for the {groupKey} group",
|
||||
"description": "Please create a new state"
|
||||
}
|
||||
},
|
||||
"labels": {
|
||||
"label_title": "Label title",
|
||||
|
||||
@@ -546,6 +546,8 @@
|
||||
|
||||
"home": {
|
||||
"empty": {
|
||||
"quickstart_guide": "Guía de inicio rápido",
|
||||
"not_right_now": "Ahora no",
|
||||
"create_project": {
|
||||
"title": "Crear un proyecto",
|
||||
"description": "La mayoría de las cosas comienzan con un proyecto en Plane.",
|
||||
@@ -688,6 +690,7 @@
|
||||
"customize_time_range": "Personalizar rango de tiempo",
|
||||
"loading": "Cargando",
|
||||
"attachments": "Archivos adjuntos",
|
||||
"property": "Propiedad",
|
||||
"properties": "Propiedades",
|
||||
"parent": "Padre",
|
||||
"remove": "Eliminar",
|
||||
@@ -840,7 +843,21 @@
|
||||
"disconnect": "Desconectar",
|
||||
"disconnecting": "Desconectando",
|
||||
"installing": "Instalando",
|
||||
"install": "Instalar"
|
||||
"install": "Instalar",
|
||||
"category": "Categoría",
|
||||
"categories": "Categorías",
|
||||
"saving": "Guardando",
|
||||
"save_changes": "Guardar cambios",
|
||||
"delete": "Eliminar",
|
||||
"deleting": "Eliminando",
|
||||
"pending": "Pendiente",
|
||||
"invite": "Invitar"
|
||||
},
|
||||
|
||||
"chart": {
|
||||
"x_axis": "Eje X",
|
||||
"y_axis": "Eje Y",
|
||||
"metric": "Métrica"
|
||||
},
|
||||
|
||||
"form": {
|
||||
@@ -1420,9 +1437,19 @@
|
||||
"company_size": "Tamaño de la empresa",
|
||||
"url": "URL del espacio de trabajo",
|
||||
"update_workspace": "Actualizar espacio de trabajo",
|
||||
"delete_workspace": "Eliminar espacio de trabajo",
|
||||
"delete_workspace_description": "Al eliminar un espacio de trabajo, todos los datos y recursos dentro de ese espacio de trabajo se eliminarán permanentemente y no se podrán recuperar.",
|
||||
"delete_btn": "Eliminar mi espacio de trabajo",
|
||||
"delete_workspace": "Eliminar este espacio de trabajo",
|
||||
"delete_workspace_description": "Al eliminar un espacio de trabajo, todos los datos y recursos dentro de ese espacio se eliminarán permanentemente y no podrán recuperarse.",
|
||||
"delete_btn": "Eliminar este espacio de trabajo",
|
||||
"delete_modal": {
|
||||
"title": "¿Está seguro de que desea eliminar este espacio de trabajo?",
|
||||
"description": "Tiene una prueba activa de uno de nuestros planes de pago. Por favor, cancelela primero para continuar.",
|
||||
"dismiss": "Descartar",
|
||||
"cancel": "Cancelar prueba",
|
||||
"success_title": "Espacio de trabajo eliminado.",
|
||||
"success_message": "Pronto irá a su página de perfil.",
|
||||
"error_title": "Eso no funcionó.",
|
||||
"error_message": "Por favor, inténtelo de nuevo."
|
||||
},
|
||||
"errors": {
|
||||
"name": {
|
||||
"required": "El nombre es obligatorio",
|
||||
@@ -1436,6 +1463,7 @@
|
||||
"members": {
|
||||
"title": "Miembros",
|
||||
"add_member": "Agregar miembro",
|
||||
"pending_invites": "Invitaciones pendientes",
|
||||
"invitations_sent_successfully": "Invitaciones enviadas exitosamente",
|
||||
"leave_confirmation": "¿Estás seguro de que quieres abandonar el espacio de trabajo? Ya no tendrás acceso a este espacio de trabajo. Esta acción no se puede deshacer.",
|
||||
"details": {
|
||||
@@ -1672,7 +1700,11 @@
|
||||
}
|
||||
},
|
||||
"states": {
|
||||
"describe_this_state_for_your_members": "Describe este estado para tus miembros."
|
||||
"describe_this_state_for_your_members": "Describe este estado para tus miembros.",
|
||||
"empty_state": {
|
||||
"title": "No estados disponibles para el grupo {groupKey}",
|
||||
"description": "Por favor, crea un nuevo estado"
|
||||
}
|
||||
},
|
||||
"labels": {
|
||||
"label_title": "Título de la etiqueta",
|
||||
|
||||
@@ -546,6 +546,8 @@
|
||||
|
||||
"home": {
|
||||
"empty": {
|
||||
"quickstart_guide": "Guide de démarrage rapide",
|
||||
"not_right_now": "Pas maintenant",
|
||||
"create_project": {
|
||||
"title": "Créer un projet",
|
||||
"description": "La plupart des choses commencent par un projet dans Plane.",
|
||||
@@ -688,6 +690,7 @@
|
||||
"customize_time_range": "Personnaliser la plage de temps",
|
||||
"loading": "Chargement",
|
||||
"attachments": "Pièces jointes",
|
||||
"property": "Propriété",
|
||||
"properties": "Propriétés",
|
||||
"parent": "Parent",
|
||||
"remove": "Supprimer",
|
||||
@@ -840,7 +843,21 @@
|
||||
"disconnect": "Déconnecter",
|
||||
"disconnecting": "Déconnexion",
|
||||
"installing": "Installation",
|
||||
"install": "Installer"
|
||||
"install": "Installer",
|
||||
"category": "Catégorie",
|
||||
"categories": "Catégories",
|
||||
"saving": "Enregistrement",
|
||||
"save_changes": "Enregistrer les modifications",
|
||||
"delete": "Supprimer",
|
||||
"deleting": "Suppression",
|
||||
"pending": "En attente",
|
||||
"invite": "Inviter"
|
||||
},
|
||||
|
||||
"chart": {
|
||||
"x_axis": "Axe X",
|
||||
"y_axis": "Axe Y",
|
||||
"metric": "Métrique"
|
||||
},
|
||||
|
||||
"form": {
|
||||
@@ -1420,9 +1437,19 @@
|
||||
"company_size": "Taille de l'entreprise",
|
||||
"url": "URL de l'espace de travail",
|
||||
"update_workspace": "Mettre à jour l'espace de travail",
|
||||
"delete_workspace": "Supprimer l'espace de travail",
|
||||
"delete_workspace_description": "Lors de la suppression d'un espace de travail, toutes les données et ressources au sein de cet espace de travail seront définitivement supprimées et ne pourront pas être récupérées.",
|
||||
"delete_btn": "Supprimer mon espace de travail",
|
||||
"delete_workspace": "Supprimer cet espace de travail",
|
||||
"delete_workspace_description": "Lors de la suppression d'un espace de travail, toutes les données et ressources au sein de cet espace seront définitivement supprimées et ne pourront pas être récupérées.",
|
||||
"delete_btn": "Supprimer cet espace de travail",
|
||||
"delete_modal": {
|
||||
"title": "Êtes-vous sûr de vouloir supprimer cet espace de travail ?",
|
||||
"description": "Vous avez un essai actif sur l'un de nos forfaits payants. Veuillez d'abord l'annuler pour continuer.",
|
||||
"dismiss": "Fermer",
|
||||
"cancel": "Annuler l'essai",
|
||||
"success_title": "Espace de travail supprimé.",
|
||||
"success_message": "Vous serez bientôt redirigé vers votre page de profil.",
|
||||
"error_title": "Cela n'a pas fonctionné.",
|
||||
"error_message": "Veuillez réessayer."
|
||||
},
|
||||
"errors": {
|
||||
"name": {
|
||||
"required": "Le nom est requis",
|
||||
@@ -1436,6 +1463,7 @@
|
||||
"members": {
|
||||
"title": "Membres",
|
||||
"add_member": "Ajouter un membre",
|
||||
"pending_invites": "Invitations en attente",
|
||||
"invitations_sent_successfully": "Invitations envoyées avec succès",
|
||||
"leave_confirmation": "Êtes-vous sûr de vouloir quitter l'espace de travail ? Vous n'aurez plus accès à cet espace de travail. Cette action ne peut pas être annulée.",
|
||||
"details": {
|
||||
@@ -1672,7 +1700,11 @@
|
||||
}
|
||||
},
|
||||
"states": {
|
||||
"describe_this_state_for_your_members": "Décrivez cet état pour vos membres."
|
||||
"describe_this_state_for_your_members": "Décrivez cet état pour vos membres.",
|
||||
"empty_state": {
|
||||
"title": "Aucun état disponible pour le groupe {groupKey}",
|
||||
"description": "Veuillez créer un nouvel état"
|
||||
}
|
||||
},
|
||||
"labels": {
|
||||
"label_title": "Titre de l'étiquette",
|
||||
|
||||
@@ -546,6 +546,8 @@
|
||||
|
||||
"home": {
|
||||
"empty": {
|
||||
"quickstart_guide": "クイックスタートガイド",
|
||||
"not_right_now": "今はしない",
|
||||
"create_project": {
|
||||
"title": "プロジェクトを作成",
|
||||
"description": "Planeのほとんどはプロジェクトから始まります。",
|
||||
@@ -688,6 +690,7 @@
|
||||
"customize_time_range": "期間をカスタマイズ",
|
||||
"loading": "読み込み中",
|
||||
"attachments": "添付ファイル",
|
||||
"property": "プロパティ",
|
||||
"properties": "プロパティ",
|
||||
"parent": "親",
|
||||
"remove": "削除",
|
||||
@@ -840,7 +843,21 @@
|
||||
"disconnect": "切断",
|
||||
"disconnecting": "切断中",
|
||||
"installing": "インストール中",
|
||||
"install": "インストール"
|
||||
"install": "インストール",
|
||||
"category": "カテゴリー",
|
||||
"categories": "カテゴリーズ",
|
||||
"saving": "セービング",
|
||||
"save_changes": "セーブ チェンジズ",
|
||||
"delete": "デリート",
|
||||
"deleting": "デリーティング",
|
||||
"pending": "保留中",
|
||||
"invite": "招待"
|
||||
},
|
||||
|
||||
"chart": {
|
||||
"x_axis": "エックス アクシス",
|
||||
"y_axis": "ワイ アクシス",
|
||||
"metric": "メトリック"
|
||||
},
|
||||
|
||||
"form": {
|
||||
@@ -1420,9 +1437,19 @@
|
||||
"company_size": "会社の規模",
|
||||
"url": "ワークスペースURL",
|
||||
"update_workspace": "ワークスペースを更新",
|
||||
"delete_workspace": "ワークスペースを削除",
|
||||
"delete_workspace_description": "ワークスペースを削除すると、そのワークスペース内のすべてのデータとリソースが永久に削除され、復元できなくなります。",
|
||||
"delete_btn": "ワークスペースを削除",
|
||||
"delete_workspace": "このワークスペースを削除",
|
||||
"delete_workspace_description": "ワークスペースを削除すると、そのワークスペース内のすべてのデータとリソースが完全に削除され、復元することはできません。",
|
||||
"delete_btn": "このワークスペースを削除",
|
||||
"delete_modal": {
|
||||
"title": "このワークスペースを削除してもよろしいですか?",
|
||||
"description": "有料プランの無料トライアルが有効です。続行するには、まずトライアルをキャンセルしてください。",
|
||||
"dismiss": "閉じる",
|
||||
"cancel": "トライアルをキャンセル",
|
||||
"success_title": "ワークスペースが削除されました。",
|
||||
"success_message": "まもなくプロフィールページに移動します。",
|
||||
"error_title": "操作に失敗しました。",
|
||||
"error_message": "もう一度お試しください。"
|
||||
},
|
||||
"errors": {
|
||||
"name": {
|
||||
"required": "名前は必須です",
|
||||
@@ -1436,6 +1463,7 @@
|
||||
"members": {
|
||||
"title": "メンバー",
|
||||
"add_member": "メンバーを追加",
|
||||
"pending_invites": "保留中の招待",
|
||||
"invitations_sent_successfully": "招待が正常に送信されました",
|
||||
"leave_confirmation": "ワークスペースから退出してもよろしいですか?このワークスペースにアクセスできなくなります。この操作は取り消せません。",
|
||||
"details": {
|
||||
@@ -1672,7 +1700,11 @@
|
||||
}
|
||||
},
|
||||
"states": {
|
||||
"describe_this_state_for_your_members": "このステータスについてメンバーに説明してください。"
|
||||
"describe_this_state_for_your_members": "このステータスについてメンバーに説明してください。",
|
||||
"empty_state": {
|
||||
"title": "{groupKey}グループのステータスがありません",
|
||||
"description": "新しいステータスを作成してください"
|
||||
}
|
||||
},
|
||||
"labels": {
|
||||
"label_title": "ラベルタイトル",
|
||||
|
||||
@@ -546,6 +546,8 @@
|
||||
|
||||
"home": {
|
||||
"empty": {
|
||||
"quickstart_guide": "快速入门指南",
|
||||
"not_right_now": "暂时不要",
|
||||
"create_project": {
|
||||
"title": "创建项目",
|
||||
"description": "在Plane中,大多数事情都从项目开始。",
|
||||
@@ -688,6 +690,7 @@
|
||||
"customize_time_range": "自定义时间范围",
|
||||
"loading": "加载中",
|
||||
"attachments": "附件",
|
||||
"property": "属性",
|
||||
"properties": "属性",
|
||||
"parent": "父项",
|
||||
"remove": "移除",
|
||||
@@ -840,7 +843,21 @@
|
||||
"disconnect": "断开连接",
|
||||
"disconnecting": "正在断开连接",
|
||||
"installing": "正在安装",
|
||||
"install": "安装"
|
||||
"install": "安装",
|
||||
"category": "类别",
|
||||
"categories": "类别",
|
||||
"saving": "保存中",
|
||||
"save_changes": "保存更改",
|
||||
"delete": "删除",
|
||||
"deleting": "删除中",
|
||||
"pending": "待处理",
|
||||
"invite": "邀请"
|
||||
},
|
||||
|
||||
"chart": {
|
||||
"x_axis": "X轴",
|
||||
"y_axis": "Y轴",
|
||||
"metric": "指标"
|
||||
},
|
||||
|
||||
"form": {
|
||||
@@ -1420,9 +1437,19 @@
|
||||
"company_size": "公司规模",
|
||||
"url": "工作区网址",
|
||||
"update_workspace": "更新工作区",
|
||||
"delete_workspace": "删除工作区",
|
||||
"delete_workspace_description": "删除工作区时,该工作区内的所有数据和资源将被永久删除且无法恢复。",
|
||||
"delete_btn": "删除我的工作区",
|
||||
"delete_workspace": "删除此工作区",
|
||||
"delete_workspace_description": "删除工作区时,该工作区内的所有数据和资源将被永久删除,且无法恢复。",
|
||||
"delete_btn": "删除此工作区",
|
||||
"delete_modal": {
|
||||
"title": "确定要删除此工作区吗?",
|
||||
"description": "您目前正在试用我们的付费方案。请先取消试用后再继续。",
|
||||
"dismiss": "关闭",
|
||||
"cancel": "取消试用",
|
||||
"success_title": "工作区已删除。",
|
||||
"success_message": "即将跳转到您的个人资料页面。",
|
||||
"error_title": "操作失败。",
|
||||
"error_message": "请重试。"
|
||||
},
|
||||
"errors": {
|
||||
"name": {
|
||||
"required": "名称为必填项",
|
||||
@@ -1436,6 +1463,7 @@
|
||||
"members": {
|
||||
"title": "成员",
|
||||
"add_member": "添加成员",
|
||||
"pending_invites": "待处理邀请",
|
||||
"invitations_sent_successfully": "邀请发送成功",
|
||||
"leave_confirmation": "您确定要离开工作区吗?您将无法再访问此工作区。此操作无法撤消。",
|
||||
"details": {
|
||||
@@ -1672,7 +1700,11 @@
|
||||
}
|
||||
},
|
||||
"states": {
|
||||
"describe_this_state_for_your_members": "为您的成员描述此状态。"
|
||||
"describe_this_state_for_your_members": "为您的成员描述此状态。",
|
||||
"empty_state": {
|
||||
"title": "{groupKey} 组中没有状态",
|
||||
"description": "请创建一个新状态"
|
||||
}
|
||||
},
|
||||
"labels": {
|
||||
"label_title": "标签标题",
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
.next
|
||||
.turbo
|
||||
out/
|
||||
dist/
|
||||
build/
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"printWidth": 120,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "es5"
|
||||
}
|
||||
@@ -2,9 +2,22 @@
|
||||
"name": "@plane/propel",
|
||||
"version": "0.24.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"lint": "eslint src --ext .ts,.tsx",
|
||||
"lint:errors": "eslint src --ext .ts,.tsx --quiet"
|
||||
},
|
||||
"exports": {
|
||||
"./globals.css": "./src/globals.css",
|
||||
"./components/*": "./src/*.tsx"
|
||||
"./ui/*": "./src/ui/*.tsx",
|
||||
"./charts/*": "./src/charts/*/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "^1.1.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"lucide-react": "^0.469.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"recharts": "^2.15.1",
|
||||
"tailwindcss-animate": "^1.0.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@plane/eslint-config": "*",
|
||||
@@ -13,15 +26,5 @@
|
||||
"@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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
"use client";
|
||||
|
||||
import React, { useMemo } from "react";
|
||||
import { AreaChart as CoreAreaChart, Area, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
|
||||
// plane imports
|
||||
import { AXIS_LINE_CLASSNAME, LABEL_CLASSNAME } from "@plane/constants";
|
||||
import { TAreaChartProps } from "@plane/types";
|
||||
// local components
|
||||
import { CustomXAxisTick, CustomYAxisTick } from "../tick";
|
||||
import { CustomTooltip } from "../tooltip";
|
||||
|
||||
export const AreaChart = React.memo(<K extends string, T extends string>(props: TAreaChartProps<K, T>) => {
|
||||
const {
|
||||
data,
|
||||
areas,
|
||||
xAxis,
|
||||
yAxis,
|
||||
className = "w-full h-96",
|
||||
tickCount = {
|
||||
x: undefined,
|
||||
y: 10,
|
||||
},
|
||||
showTooltip = true,
|
||||
} = props;
|
||||
// derived values
|
||||
const itemKeys = useMemo(() => areas.map((area) => area.key), [areas]);
|
||||
const itemDotClassNames = useMemo(
|
||||
() => areas.reduce((acc, area) => ({ ...acc, [area.key]: area.dotClassName }), {}),
|
||||
[areas]
|
||||
);
|
||||
|
||||
const renderAreas = useMemo(
|
||||
() =>
|
||||
areas.map((area) => (
|
||||
<Area
|
||||
key={area.key}
|
||||
type="monotone"
|
||||
dataKey={area.key}
|
||||
stackId={area.stackId}
|
||||
className={area.className}
|
||||
stroke="inherit"
|
||||
fill="inherit"
|
||||
/>
|
||||
)),
|
||||
[areas]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<CoreAreaChart
|
||||
width={500}
|
||||
height={300}
|
||||
data={data}
|
||||
margin={{
|
||||
top: 5,
|
||||
right: 30,
|
||||
left: 20,
|
||||
bottom: 5,
|
||||
}}
|
||||
reverseStackOrder
|
||||
>
|
||||
<XAxis
|
||||
dataKey={xAxis.key}
|
||||
tick={(props) => <CustomXAxisTick {...props} />}
|
||||
tickLine={{
|
||||
stroke: "currentColor",
|
||||
className: AXIS_LINE_CLASSNAME,
|
||||
}}
|
||||
axisLine={{
|
||||
stroke: "currentColor",
|
||||
className: AXIS_LINE_CLASSNAME,
|
||||
}}
|
||||
label={{
|
||||
value: xAxis.label,
|
||||
dy: 28,
|
||||
className: LABEL_CLASSNAME,
|
||||
}}
|
||||
tickCount={tickCount.x}
|
||||
/>
|
||||
<YAxis
|
||||
domain={yAxis.domain}
|
||||
tickLine={{
|
||||
stroke: "currentColor",
|
||||
className: AXIS_LINE_CLASSNAME,
|
||||
}}
|
||||
axisLine={{
|
||||
stroke: "currentColor",
|
||||
className: AXIS_LINE_CLASSNAME,
|
||||
}}
|
||||
label={{
|
||||
value: yAxis.label,
|
||||
angle: -90,
|
||||
position: "bottom",
|
||||
offset: -24,
|
||||
dx: -16,
|
||||
className: LABEL_CLASSNAME,
|
||||
}}
|
||||
tick={(props) => <CustomYAxisTick {...props} />}
|
||||
tickCount={tickCount.y}
|
||||
allowDecimals={!!yAxis.allowDecimals}
|
||||
/>
|
||||
{showTooltip && (
|
||||
<Tooltip
|
||||
cursor={{ fill: "currentColor", className: "text-custom-background-90/80 cursor-pointer" }}
|
||||
content={({ active, label, payload }) => (
|
||||
<CustomTooltip
|
||||
active={active}
|
||||
label={label}
|
||||
payload={payload}
|
||||
itemKeys={itemKeys}
|
||||
itemDotClassNames={itemDotClassNames}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{renderAreas}
|
||||
</CoreAreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
AreaChart.displayName = "AreaChart";
|
||||
@@ -0,0 +1,70 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import React from "react";
|
||||
// plane imports
|
||||
import { TChartData } from "@plane/types";
|
||||
import { cn } from "@plane/utils";
|
||||
|
||||
// Helper to calculate percentage
|
||||
const calculatePercentage = <K extends string, T extends string>(
|
||||
data: TChartData<K, T>,
|
||||
stackKeys: T[],
|
||||
currentKey: T
|
||||
): number => {
|
||||
const total = stackKeys.reduce((sum, key) => sum + data[key], 0);
|
||||
return total === 0 ? 0 : Math.round((data[currentKey] / total) * 100);
|
||||
};
|
||||
|
||||
const MIN_BAR_HEIGHT_FOR_INTERNAL_TEXT = 14; // Minimum height needed to show text inside
|
||||
const BAR_BORDER_RADIUS = 2; // Border radius for each bar
|
||||
|
||||
export const CustomBar = React.memo((props: any) => {
|
||||
const { fill, x, y, width, height, dataKey, stackKeys, payload, textClassName, showPercentage } = props;
|
||||
// Calculate text position
|
||||
const TEXT_PADDING_Y = Math.min(6, Math.abs(MIN_BAR_HEIGHT_FOR_INTERNAL_TEXT - height / 2));
|
||||
const textY = y + height - TEXT_PADDING_Y; // Position inside bar if tall enough
|
||||
// derived values
|
||||
const currentBarPercentage = calculatePercentage(payload, stackKeys, dataKey);
|
||||
const showText =
|
||||
// from props
|
||||
showPercentage &&
|
||||
// height of the bar is greater than or equal to the minimum height required to show the text
|
||||
height >= MIN_BAR_HEIGHT_FOR_INTERNAL_TEXT &&
|
||||
// bar percentage text has some value
|
||||
currentBarPercentage !== undefined &&
|
||||
// bar percentage is a number
|
||||
!Number.isNaN(currentBarPercentage);
|
||||
|
||||
if (!height) return null;
|
||||
return (
|
||||
<g>
|
||||
<path
|
||||
d={`
|
||||
M${x + BAR_BORDER_RADIUS},${y + height}
|
||||
L${x + BAR_BORDER_RADIUS},${y}
|
||||
Q${x},${y} ${x},${y + BAR_BORDER_RADIUS}
|
||||
L${x},${y + height - BAR_BORDER_RADIUS}
|
||||
Q${x},${y + height} ${x + BAR_BORDER_RADIUS},${y + height}
|
||||
L${x + width - BAR_BORDER_RADIUS},${y + height}
|
||||
Q${x + width},${y + height} ${x + width},${y + height - BAR_BORDER_RADIUS}
|
||||
L${x + width},${y + BAR_BORDER_RADIUS}
|
||||
Q${x + width},${y} ${x + width - BAR_BORDER_RADIUS},${y}
|
||||
L${x + BAR_BORDER_RADIUS},${y}
|
||||
`}
|
||||
className={cn("transition-colors duration-200", fill)}
|
||||
fill="currentColor"
|
||||
/>
|
||||
{showText && (
|
||||
<text
|
||||
x={x + width / 2}
|
||||
y={textY}
|
||||
textAnchor="middle"
|
||||
className={cn("text-xs font-medium", textClassName)}
|
||||
fill="currentColor"
|
||||
>
|
||||
{currentBarPercentage}%
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
});
|
||||
CustomBar.displayName = "CustomBar";
|
||||
@@ -0,0 +1,125 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
"use client";
|
||||
|
||||
import React, { useMemo } from "react";
|
||||
import { BarChart as CoreBarChart, Bar, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
|
||||
// plane imports
|
||||
import { AXIS_LINE_CLASSNAME, LABEL_CLASSNAME } from "@plane/constants";
|
||||
import { TBarChartProps } from "@plane/types";
|
||||
// local components
|
||||
import { CustomXAxisTick, CustomYAxisTick } from "../tick";
|
||||
import { CustomTooltip } from "../tooltip";
|
||||
import { CustomBar } from "./bar";
|
||||
|
||||
export const BarChart = React.memo(<K extends string, T extends string>(props: TBarChartProps<K, T>) => {
|
||||
const {
|
||||
data,
|
||||
bars,
|
||||
xAxis,
|
||||
yAxis,
|
||||
barSize = 40,
|
||||
className = "w-full h-96",
|
||||
tickCount = {
|
||||
x: undefined,
|
||||
y: 10,
|
||||
},
|
||||
showTooltip = true,
|
||||
} = props;
|
||||
// derived values
|
||||
const stackKeys = useMemo(() => bars.map((bar) => bar.key), [bars]);
|
||||
const stackDotClassNames = useMemo(
|
||||
() => bars.reduce((acc, bar) => ({ ...acc, [bar.key]: bar.dotClassName }), {}),
|
||||
[bars]
|
||||
);
|
||||
|
||||
const renderBars = useMemo(
|
||||
() =>
|
||||
bars.map((bar) => (
|
||||
<Bar
|
||||
key={bar.key}
|
||||
dataKey={bar.key}
|
||||
stackId={bar.stackId}
|
||||
fill={bar.fillClassName}
|
||||
shape={(shapeProps: any) => (
|
||||
<CustomBar
|
||||
{...shapeProps}
|
||||
stackKeys={stackKeys}
|
||||
textClassName={bar.textClassName}
|
||||
showPercentage={bar.showPercentage}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)),
|
||||
[stackKeys, bars]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<CoreBarChart
|
||||
data={data}
|
||||
margin={{ top: 10, right: 10, left: 10, bottom: 40 }}
|
||||
barSize={barSize}
|
||||
className="recharts-wrapper"
|
||||
>
|
||||
<XAxis
|
||||
dataKey={xAxis.key}
|
||||
tick={(props) => <CustomXAxisTick {...props} />}
|
||||
tickLine={{
|
||||
stroke: "currentColor",
|
||||
className: AXIS_LINE_CLASSNAME,
|
||||
}}
|
||||
axisLine={{
|
||||
stroke: "currentColor",
|
||||
className: AXIS_LINE_CLASSNAME,
|
||||
}}
|
||||
label={{
|
||||
value: xAxis.label,
|
||||
dy: 28,
|
||||
className: LABEL_CLASSNAME,
|
||||
}}
|
||||
tickCount={tickCount.x}
|
||||
/>
|
||||
<YAxis
|
||||
domain={yAxis.domain}
|
||||
tickLine={{
|
||||
stroke: "currentColor",
|
||||
className: AXIS_LINE_CLASSNAME,
|
||||
}}
|
||||
axisLine={{
|
||||
stroke: "currentColor",
|
||||
className: AXIS_LINE_CLASSNAME,
|
||||
}}
|
||||
label={{
|
||||
value: yAxis.label,
|
||||
angle: -90,
|
||||
position: "bottom",
|
||||
offset: -24,
|
||||
dx: -16,
|
||||
className: LABEL_CLASSNAME,
|
||||
}}
|
||||
tick={(props) => <CustomYAxisTick {...props} />}
|
||||
tickCount={tickCount.y}
|
||||
allowDecimals={!!yAxis.allowDecimals}
|
||||
/>
|
||||
{showTooltip && (
|
||||
<Tooltip
|
||||
cursor={{ fill: "currentColor", className: "text-custom-background-90/80 cursor-pointer" }}
|
||||
content={({ active, label, payload }) => (
|
||||
<CustomTooltip
|
||||
active={active}
|
||||
label={label}
|
||||
payload={payload}
|
||||
itemKeys={stackKeys}
|
||||
itemDotClassNames={stackDotClassNames}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{renderBars}
|
||||
</CoreBarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
BarChart.displayName = "BarChart";
|
||||
@@ -0,0 +1,115 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
"use client";
|
||||
|
||||
import React, { useMemo } from "react";
|
||||
import { LineChart as CoreLineChart, Line, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
|
||||
// plane imports
|
||||
import { AXIS_LINE_CLASSNAME, LABEL_CLASSNAME } from "@plane/constants";
|
||||
import { TLineChartProps } from "@plane/types";
|
||||
// local components
|
||||
import { CustomXAxisTick, CustomYAxisTick } from "../tick";
|
||||
import { CustomTooltip } from "../tooltip";
|
||||
|
||||
export const LineChart = React.memo(<K extends string, T extends string>(props: TLineChartProps<K, T>) => {
|
||||
const {
|
||||
data,
|
||||
lines,
|
||||
xAxis,
|
||||
yAxis,
|
||||
className = "w-full h-96",
|
||||
tickCount = {
|
||||
x: undefined,
|
||||
y: 10,
|
||||
},
|
||||
showTooltip = true,
|
||||
} = props;
|
||||
// derived values
|
||||
const itemKeys = useMemo(() => lines.map((line) => line.key), [lines]);
|
||||
const itemDotClassNames = useMemo(
|
||||
() => lines.reduce((acc, line) => ({ ...acc, [line.key]: line.dotClassName }), {}),
|
||||
[lines]
|
||||
);
|
||||
|
||||
const renderLines = useMemo(
|
||||
() =>
|
||||
lines.map((line) => (
|
||||
<Line key={line.key} dataKey={line.key} type="monotone" className={line.className} stroke="inherit" />
|
||||
)),
|
||||
[lines]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<CoreLineChart
|
||||
width={500}
|
||||
height={300}
|
||||
data={data}
|
||||
margin={{
|
||||
top: 5,
|
||||
right: 30,
|
||||
left: 20,
|
||||
bottom: 5,
|
||||
}}
|
||||
>
|
||||
<XAxis
|
||||
dataKey={xAxis.key}
|
||||
tick={(props) => <CustomXAxisTick {...props} />}
|
||||
tickLine={{
|
||||
stroke: "currentColor",
|
||||
className: AXIS_LINE_CLASSNAME,
|
||||
}}
|
||||
axisLine={{
|
||||
stroke: "currentColor",
|
||||
className: AXIS_LINE_CLASSNAME,
|
||||
}}
|
||||
label={{
|
||||
value: xAxis.label,
|
||||
dy: 28,
|
||||
className: LABEL_CLASSNAME,
|
||||
}}
|
||||
tickCount={tickCount.x}
|
||||
/>
|
||||
<YAxis
|
||||
domain={yAxis.domain}
|
||||
tickLine={{
|
||||
stroke: "currentColor",
|
||||
className: AXIS_LINE_CLASSNAME,
|
||||
}}
|
||||
axisLine={{
|
||||
stroke: "currentColor",
|
||||
className: AXIS_LINE_CLASSNAME,
|
||||
}}
|
||||
label={{
|
||||
value: yAxis.label,
|
||||
angle: -90,
|
||||
position: "bottom",
|
||||
offset: -24,
|
||||
dx: -16,
|
||||
className: LABEL_CLASSNAME,
|
||||
}}
|
||||
tick={(props) => <CustomYAxisTick {...props} />}
|
||||
tickCount={tickCount.y}
|
||||
allowDecimals={!!yAxis.allowDecimals}
|
||||
/>
|
||||
{showTooltip && (
|
||||
<Tooltip
|
||||
cursor={{ fill: "currentColor", className: "text-custom-background-90/80 cursor-pointer" }}
|
||||
content={({ active, label, payload }) => (
|
||||
<CustomTooltip
|
||||
active={active}
|
||||
label={label}
|
||||
payload={payload}
|
||||
itemKeys={itemKeys}
|
||||
itemDotClassNames={itemDotClassNames}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{renderLines}
|
||||
</CoreLineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
LineChart.displayName = "LineChart";
|
||||
@@ -0,0 +1,51 @@
|
||||
"use client";
|
||||
|
||||
import React, { useMemo } from "react";
|
||||
import { Cell, PieChart as CorePieChart, Pie, ResponsiveContainer, Tooltip } from "recharts";
|
||||
// plane imports
|
||||
import { TPieChartProps } from "@plane/types";
|
||||
// local components
|
||||
import { CustomPieChartTooltip } from "./tooltip";
|
||||
|
||||
export const PieChart = React.memo(<K extends string, T extends string>(props: TPieChartProps<K, T>) => {
|
||||
const { data, dataKey, cells, className = "w-full h-96", innerRadius, outerRadius, showTooltip = true } = props;
|
||||
|
||||
const renderCells = useMemo(
|
||||
() => cells.map((cell) => <Cell key={cell.key} className={cell.className} style={cell.style} />),
|
||||
[cells]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<CorePieChart
|
||||
width={500}
|
||||
height={300}
|
||||
data={data}
|
||||
margin={{
|
||||
top: 5,
|
||||
right: 30,
|
||||
left: 20,
|
||||
bottom: 5,
|
||||
}}
|
||||
>
|
||||
<Pie data={data} dataKey={dataKey} cx="50%" cy="50%" innerRadius={innerRadius} outerRadius={outerRadius}>
|
||||
{renderCells}
|
||||
</Pie>
|
||||
{showTooltip && (
|
||||
<Tooltip
|
||||
cursor={{ fill: "currentColor", className: "text-custom-background-90/80 cursor-pointer" }}
|
||||
content={({ active, payload }) => {
|
||||
if (!active || !payload || !payload.length) return null;
|
||||
const cellData = cells.find((c) => c.key === payload[0].name);
|
||||
if (!cellData) return null;
|
||||
return <CustomPieChartTooltip dotClassName={cellData.dotClassName} label={dataKey} payload={payload} />;
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</CorePieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
PieChart.displayName = "PieChart";
|
||||
@@ -0,0 +1,31 @@
|
||||
import React from "react";
|
||||
import { NameType, Payload, ValueType } from "recharts/types/component/DefaultTooltipContent";
|
||||
// plane imports
|
||||
import { Card, ECardSpacing } from "@plane/ui";
|
||||
import { cn } from "@plane/utils";
|
||||
|
||||
type Props = {
|
||||
dotClassName?: string;
|
||||
label: string;
|
||||
payload: Payload<ValueType, NameType>[];
|
||||
};
|
||||
|
||||
export const CustomPieChartTooltip = React.memo((props: Props) => {
|
||||
const { dotClassName, label, payload } = props;
|
||||
|
||||
return (
|
||||
<Card className="flex flex-col" spacing={ECardSpacing.SM}>
|
||||
<p className="text-xs text-custom-text-100 font-medium border-b border-custom-border-200 pb-2 capitalize">
|
||||
{label}
|
||||
</p>
|
||||
{payload?.map((item) => (
|
||||
<div key={item?.dataKey} className="flex items-center gap-2 text-xs capitalize">
|
||||
<div className={cn("size-2 rounded-full", dotClassName)} />
|
||||
<span className="text-custom-text-300">{item?.name}:</span>
|
||||
<span className="font-medium text-custom-text-200">{item?.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
);
|
||||
});
|
||||
CustomPieChartTooltip.displayName = "CustomPieChartTooltip";
|
||||
@@ -0,0 +1,41 @@
|
||||
import React from "react";
|
||||
import { NameType, Payload, ValueType } from "recharts/types/component/DefaultTooltipContent";
|
||||
// plane imports
|
||||
import { Card, ECardSpacing } from "@plane/ui";
|
||||
import { cn } from "@plane/utils";
|
||||
|
||||
type Props = {
|
||||
active: boolean | undefined;
|
||||
label: string | undefined;
|
||||
payload: Payload<ValueType, NameType>[] | undefined;
|
||||
itemKeys: string[];
|
||||
itemDotClassNames: Record<string, string>;
|
||||
};
|
||||
|
||||
export const CustomTooltip = React.memo((props: Props) => {
|
||||
const { active, label, payload, itemKeys, itemDotClassNames } = props;
|
||||
// derived values
|
||||
const filteredPayload = payload?.filter((item) => item.dataKey && itemKeys.includes(`${item.dataKey}`));
|
||||
|
||||
if (!active || !filteredPayload || !filteredPayload.length) return null;
|
||||
return (
|
||||
<Card className="flex flex-col" spacing={ECardSpacing.SM}>
|
||||
<p className="text-xs text-custom-text-100 font-medium border-b border-custom-border-200 pb-2 capitalize">
|
||||
{label}
|
||||
</p>
|
||||
{filteredPayload.map((item) => {
|
||||
if (!item.dataKey) return null;
|
||||
return (
|
||||
<div key={item?.dataKey} className="flex items-center gap-2 text-xs capitalize">
|
||||
{itemDotClassNames[item?.dataKey] && (
|
||||
<div className={cn("size-2 rounded-full", itemDotClassNames[item?.dataKey])} />
|
||||
)}
|
||||
<span className="text-custom-text-300">{item?.name}:</span>
|
||||
<span className="font-medium text-custom-text-200">{item?.value}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Card>
|
||||
);
|
||||
});
|
||||
CustomTooltip.displayName = "CustomTooltip";
|
||||
@@ -2,53 +2,6 @@
|
||||
@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;
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
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 };
|
||||
@@ -19,6 +19,7 @@ module.exports = {
|
||||
"./app/**/*.tsx",
|
||||
"./ui/**/*.tsx",
|
||||
"../packages/ui/src/**/*.{js,ts,jsx,tsx}",
|
||||
"../packages/propel/src/**/*.{js,ts,jsx,tsx}",
|
||||
"../packages/editor/src/**/*.{js,ts,jsx,tsx}",
|
||||
"!../packages/ui/**/*.stories{js,ts,jsx,tsx}",
|
||||
],
|
||||
@@ -202,6 +203,11 @@ module.exports = {
|
||||
},
|
||||
},
|
||||
backdrop: "rgba(0, 0, 0, 0.25)",
|
||||
scrollbar: {
|
||||
neutral: "rgba(96, 100, 108, 0.1)",
|
||||
hover: "rgba(96, 100, 108, 0.25)",
|
||||
active: "rgba(96, 100, 108, 0.7)",
|
||||
},
|
||||
},
|
||||
onboarding: {
|
||||
background: {
|
||||
|
||||
Vendored
+61
-16
@@ -1,29 +1,20 @@
|
||||
export type TStackItem<T extends string> = {
|
||||
key: T;
|
||||
fillClassName: string;
|
||||
textClassName: string;
|
||||
dotClassName?: string;
|
||||
showPercentage?: boolean;
|
||||
};
|
||||
|
||||
export type TStackChartData<K extends string, T extends string> = {
|
||||
export type TChartData<K extends string, T extends string> = {
|
||||
// required key
|
||||
[key in K]: string | number;
|
||||
} & Record<T, any>;
|
||||
|
||||
export type TStackedBarChartProps<K extends string, T extends string> = {
|
||||
data: TStackChartData<K, T>[];
|
||||
stacks: TStackItem<T>[];
|
||||
type TChartProps<K extends string, T extends string> = {
|
||||
data: TChartData<K, T>[];
|
||||
xAxis: {
|
||||
key: keyof TStackChartData<K, T>;
|
||||
key: keyof TChartData<K, T>;
|
||||
label: string;
|
||||
};
|
||||
yAxis: {
|
||||
key: keyof TStackChartData<K, T>;
|
||||
key: keyof TChartData<K, T>;
|
||||
label: string;
|
||||
domain?: [number, number];
|
||||
allowDecimals?: boolean;
|
||||
};
|
||||
barSize?: number;
|
||||
className?: string;
|
||||
tickCount?: {
|
||||
x?: number;
|
||||
@@ -32,6 +23,60 @@ export type TStackedBarChartProps<K extends string, T extends string> = {
|
||||
showTooltip?: boolean;
|
||||
};
|
||||
|
||||
export type TBarItem<T extends string> = {
|
||||
key: T;
|
||||
fillClassName: string;
|
||||
textClassName: string;
|
||||
dotClassName?: string;
|
||||
showPercentage?: boolean;
|
||||
stackId: string;
|
||||
};
|
||||
|
||||
export type TBarChartProps<K extends string, T extends string> = TChartProps<K, T> & {
|
||||
bars: TBarItem<T>[];
|
||||
barSize?: number;
|
||||
};
|
||||
|
||||
export type TLineItem<T extends string> = {
|
||||
key: T;
|
||||
className?: string;
|
||||
style?: Record<string, string | number>;
|
||||
dotClassName?: string;
|
||||
};
|
||||
|
||||
export type TLineChartProps<K extends string, T extends string> = TChartProps<K, T> & {
|
||||
lines: TLineItem<T>[];
|
||||
};
|
||||
|
||||
export type TAreaItem<T extends string> = {
|
||||
key: T;
|
||||
stackId: string;
|
||||
className?: string;
|
||||
style?: Record<string, string | number>;
|
||||
dotClassName?: string;
|
||||
};
|
||||
|
||||
export type TAreaChartProps<K extends string, T extends string> = TChartProps<K, T> & {
|
||||
areas: TAreaItem<T>[];
|
||||
};
|
||||
|
||||
export type TCellItem<T extends string> = {
|
||||
key: T;
|
||||
className?: string;
|
||||
style?: Record<string, string | number>;
|
||||
dotClassName?: string;
|
||||
};
|
||||
|
||||
export type TPieChartProps<K extends string, T extends string> = Pick<
|
||||
TChartProps<K, T>,
|
||||
"className" | "data" | "showTooltip"
|
||||
> & {
|
||||
dataKey: T;
|
||||
cells: TCellItem<T>[];
|
||||
innerRadius?: number;
|
||||
outerRadius?: number;
|
||||
};
|
||||
|
||||
export type TreeMapItem = {
|
||||
name: string;
|
||||
value: number;
|
||||
@@ -45,7 +90,7 @@ export type TreeMapItem = {
|
||||
| {
|
||||
fillClassName: string;
|
||||
}
|
||||
);
|
||||
);
|
||||
|
||||
export type TreeMapChartProps = {
|
||||
data: TreeMapItem[];
|
||||
|
||||
Vendored
-1
@@ -104,7 +104,6 @@ export interface ICycle extends TProgressSnapshot {
|
||||
project_detail: IProjectDetails;
|
||||
progress: any[];
|
||||
version: number;
|
||||
pending_issues: number;
|
||||
}
|
||||
|
||||
export interface CycleIssueResponse {
|
||||
|
||||
Vendored
-1
@@ -26,7 +26,6 @@ export * from "./waitlist";
|
||||
export * from "./webhook";
|
||||
export * from "./workspace-views";
|
||||
export * from "./common";
|
||||
export * from "./power-k";
|
||||
export * from "./pragmatic";
|
||||
export * from "./publish";
|
||||
export * from "./search";
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@ export type TIssueActivityProjectDetail = {
|
||||
|
||||
export type TIssueActivityIssueDetail = {
|
||||
id: string;
|
||||
sequence_id: boolean;
|
||||
sequence_id: number;
|
||||
sort_order: boolean;
|
||||
name: string;
|
||||
description_html: string;
|
||||
|
||||
Vendored
-24
@@ -1,24 +0,0 @@
|
||||
export type TPowerKPageKeys =
|
||||
// work-item actions
|
||||
| "change-work-item-assignee"
|
||||
| "change-work-item-priority"
|
||||
| "change-work-item-state"
|
||||
// module actions
|
||||
| "change-module-member"
|
||||
| "change-module-status"
|
||||
// configs
|
||||
| "workspace-settings"
|
||||
| "project-settings"
|
||||
| "profile-settings"
|
||||
// personalization
|
||||
| "change-theme";
|
||||
|
||||
export type TPowerKCreateActionKeys = "cycle" | "issue" | "module" | "page" | "project" | "view" | "workspace";
|
||||
export type TPowerKCreateAction = {
|
||||
key: TPowerKCreateActionKeys;
|
||||
icon: any;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
shortcut?: string;
|
||||
shouldRender?: boolean;
|
||||
};
|
||||
Vendored
+14
-9
@@ -1,10 +1,4 @@
|
||||
import type {
|
||||
ICycle,
|
||||
IProjectMember,
|
||||
IUser,
|
||||
IUserLite,
|
||||
IWorkspaceViewProps,
|
||||
} from "@plane/types";
|
||||
import type { ICycle, IProjectMember, IUser, IUserLite, IWorkspaceViewProps, TPaginationInfo } from "@plane/types";
|
||||
import { EUserWorkspaceRoles } from "@plane/constants"; // TODO: check if importing this over here causes circular dependency
|
||||
import { TUserPermissions } from "./enums";
|
||||
|
||||
@@ -21,8 +15,8 @@ export interface IWorkspace {
|
||||
readonly created_by: string;
|
||||
readonly updated_by: string;
|
||||
organization_size: string;
|
||||
total_issues: number;
|
||||
total_projects?: number;
|
||||
role: number;
|
||||
}
|
||||
|
||||
export interface IWorkspaceLite {
|
||||
@@ -39,9 +33,10 @@ export interface IWorkspaceMemberInvitation {
|
||||
responded_at: Date;
|
||||
role: TUserPermissions;
|
||||
token: string;
|
||||
invite_link: string;
|
||||
workspace: {
|
||||
id: string;
|
||||
logo: string;
|
||||
logo_url: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
};
|
||||
@@ -228,3 +223,13 @@ export interface IWorkspaceAnalyticsResponse {
|
||||
export type TWorkspacePaginationInfo = TPaginationInfo & {
|
||||
results: IWorkspace[];
|
||||
};
|
||||
|
||||
export interface IWorkspaceSidebarNavigationItem {
|
||||
key?: string;
|
||||
is_pinned: boolean;
|
||||
sort_order: number;
|
||||
}
|
||||
|
||||
export interface IWorkspaceSidebarNavigation {
|
||||
[key: string]: IWorkspaceSidebarNavigationItem;
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
"@plane/hooks": "*",
|
||||
"@plane/utils": "*",
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"@radix-ui/react-scroll-area": "^1.2.3",
|
||||
"clsx": "^2.0.0",
|
||||
"emoji-picker-react": "^4.5.16",
|
||||
"lodash": "^4.17.21",
|
||||
|
||||
@@ -2,20 +2,13 @@ import * as React from "react";
|
||||
|
||||
import { ISvgIcons } from "./type";
|
||||
|
||||
export const OverviewIcon: React.FC<ISvgIcons> = ({ width = "16", height = "16", className = "" }) => (
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
className={className}
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
export const OverviewIcon: React.FC<ISvgIcons> = ({ className = "text-current", ...rest }) => (
|
||||
<svg viewBox="0 0 16 16" className={className} xmlns="http://www.w3.org/2000/svg" {...rest}>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M2.5 3C2.5 2.86739 2.55268 2.74021 2.64645 2.64645C2.74021 2.55268 2.86739 2.5 3 2.5H3.5C9.02267 2.5 13.5 6.97733 13.5 12.5V13C13.5 13.1326 13.4473 13.2598 13.3536 13.3536C13.2598 13.4473 13.1326 13.5 13 13.5H12.5C12.3674 13.5 12.2402 13.4473 12.1464 13.3536C12.0527 13.2598 12 13.1326 12 13V12.5C12 7.80533 8.19467 4 3.5 4H3C2.86739 4 2.74021 3.94732 2.64645 3.85355C2.55268 3.75979 2.5 3.63261 2.5 3.5V3ZM2.5 7.5C2.5 7.36739 2.55268 7.24022 2.64645 7.14645C2.74021 7.05268 2.86739 7 3 7H3.5C4.22227 7 4.93747 7.14226 5.60476 7.41866C6.27205 7.69506 6.87837 8.10019 7.38909 8.61091C7.89981 9.12164 8.30494 9.72795 8.58134 10.3952C8.85774 11.0625 9 11.7777 9 12.5V13C9 13.1326 8.94732 13.2598 8.85355 13.3536C8.75978 13.4473 8.63261 13.5 8.5 13.5H8C7.86739 13.5 7.74022 13.4473 7.64645 13.3536C7.55268 13.2598 7.5 13.1326 7.5 13V12.5C7.5 11.4391 7.07857 10.4217 6.32843 9.67157C5.57828 8.92143 4.56087 8.5 3.5 8.5H3C2.86739 8.5 2.74021 8.44732 2.64645 8.35355C2.55268 8.25978 2.5 8.13261 2.5 8V7.5ZM2.5 12.5C2.5 12.2348 2.60536 11.9804 2.79289 11.7929C2.98043 11.6054 3.23478 11.5 3.5 11.5C3.76522 11.5 4.01957 11.6054 4.20711 11.7929C4.39464 11.9804 4.5 12.2348 4.5 12.5C4.5 12.7652 4.39464 13.0196 4.20711 13.2071C4.01957 13.3946 3.76522 13.5 3.5 13.5C3.23478 13.5 2.98043 13.3946 2.79289 13.2071C2.60536 13.0196 2.5 12.7652 2.5 12.5Z"
|
||||
fill="#455068"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
@@ -25,6 +25,7 @@ export * from "./popovers";
|
||||
export * from "./tables";
|
||||
export * from "./header";
|
||||
export * from "./row";
|
||||
export * from "./scroll-area";
|
||||
export * from "./content-wrapper";
|
||||
export * from "./card";
|
||||
export * from "./tag";
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
import * as RadixScrollArea from "@radix-ui/react-scroll-area";
|
||||
import React, { FC } from "react";
|
||||
import { cn } from "../helpers";
|
||||
|
||||
type TScrollAreaProps = {
|
||||
type?: "auto" | "always" | "scroll" | "hover";
|
||||
className?: string;
|
||||
scrollHideDelay?: number;
|
||||
size?: "sm" | "md" | "lg";
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
const sizeStyles = {
|
||||
sm: "p-[0.112rem] data-[orientation=vertical]:w-2.5 data-[orientation=horizontal]:h-2.5",
|
||||
md: "p-[0.152rem] data-[orientation=vertical]:w-3 data-[orientation=horizontal]:h-3",
|
||||
lg: "p-[0.225rem] data-[orientation=vertical]:w-4 data-[orientation=horizontal]:h-4",
|
||||
};
|
||||
|
||||
const thumbSizeStyles = {
|
||||
sm: "before:absolute before:left-1/2 before:top-1/2 before:size-full before:min-h-11 before:min-w-11 before:-translate-x-1/2 before:-translate-y-1/2",
|
||||
md: "before:absolute before:left-1/2 before:top-1/2 before:size-full before:min-h-14 before:min-w-14 before:-translate-x-1/2 before:-translate-y-1/2",
|
||||
lg: "before:absolute before:left-1/2 before:top-1/2 before:size-full before:min-h-17 before:min-w-17 before:-translate-x-1/2 before:-translate-y-1/2",
|
||||
};
|
||||
|
||||
export const ScrollArea: FC<TScrollAreaProps> = (props) => {
|
||||
const { type = "always", className = "", scrollHideDelay = 600, size = "md", children } = props;
|
||||
|
||||
return (
|
||||
<RadixScrollArea.Root
|
||||
type={type}
|
||||
className={cn("group overflow-hidden", className)}
|
||||
scrollHideDelay={scrollHideDelay}
|
||||
>
|
||||
<RadixScrollArea.Viewport className="size-full">{children}</RadixScrollArea.Viewport>
|
||||
<RadixScrollArea.Scrollbar
|
||||
className={cn(
|
||||
"group/track flex touch-none select-none bg-transparent transition-colors duration-[160ms] ease-out",
|
||||
sizeStyles[size]
|
||||
)}
|
||||
orientation="vertical"
|
||||
>
|
||||
<RadixScrollArea.Thumb
|
||||
className={cn(
|
||||
"relative flex-1 rounded-[10px] bg-custom-scrollbar-neutral group-hover:bg-custom-scrollbar-hover group-active/track:bg-custom-scrollbar-active",
|
||||
thumbSizeStyles[size]
|
||||
)}
|
||||
/>
|
||||
</RadixScrollArea.Scrollbar>
|
||||
<RadixScrollArea.Scrollbar
|
||||
className={cn(
|
||||
"group/track flex touch-none select-none bg-transparent transition-colors duration-[160ms] ease-out",
|
||||
sizeStyles[size]
|
||||
)}
|
||||
orientation="horizontal"
|
||||
>
|
||||
<RadixScrollArea.Thumb
|
||||
className={cn(
|
||||
"relative flex-1 rounded-[10px] bg-custom-scrollbar-neutral group-hover:bg-custom-scrollbar-hover group-active/track:bg-custom-scrollbar-active",
|
||||
thumbSizeStyles[size]
|
||||
)}
|
||||
/>
|
||||
</RadixScrollArea.Scrollbar>
|
||||
</RadixScrollArea.Root>
|
||||
);
|
||||
};
|
||||
@@ -7,6 +7,8 @@
|
||||
*/
|
||||
export type RGB = { r: number; g: number; b: number };
|
||||
|
||||
export type HSL = { h: number; s: number; l: number };
|
||||
|
||||
/**
|
||||
* @description Validates and clamps color values to RGB range (0-255)
|
||||
* @param {number} value - The color value to validate
|
||||
@@ -62,3 +64,81 @@ export const hexToRgb = (hex: string): RGB => {
|
||||
* rgbToHex({ r: 0, g: 0, b: 255 }) // returns "#0000ff"
|
||||
*/
|
||||
export const rgbToHex = ({ r, g, b }: RGB): string => `#${toHex(r)}${toHex(g)}${toHex(b)}`;
|
||||
|
||||
/**
|
||||
* Converts Hex values to HSL values
|
||||
* @param {string} hex - The hexadecimal color code (e.g., "#ff0000" for red)
|
||||
* @returns {HSL} An object containing the HSL values
|
||||
* @example
|
||||
* hexToHsl("#ff0000") // returns { h: 0, s: 100, l: 50 }
|
||||
* hexToHsl("#00ff00") // returns { h: 120, s: 100, l: 50 }
|
||||
* hexToHsl("#0000ff") // returns { h: 240, s: 100, l: 50 }
|
||||
*/
|
||||
export const hexToHsl = (hex: string): HSL => {
|
||||
// return default value for invalid hex
|
||||
if (!/^#[0-9A-Fa-f]{6}$/.test(hex)) return { h: 0, s: 0, l: 0 };
|
||||
|
||||
const r = parseInt(hex.slice(1, 3), 16) / 255;
|
||||
const g = parseInt(hex.slice(3, 5), 16) / 255;
|
||||
const b = parseInt(hex.slice(5, 7), 16) / 255;
|
||||
|
||||
const max = Math.max(r, g, b);
|
||||
const min = Math.min(r, g, b);
|
||||
let h = 0;
|
||||
let s = 0;
|
||||
const l = (max + min) / 2;
|
||||
|
||||
if (max !== min) {
|
||||
const d = max - min;
|
||||
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||
switch (max) {
|
||||
case r:
|
||||
h = (g - b) / d + (g < b ? 6 : 0);
|
||||
break;
|
||||
case g:
|
||||
h = (b - r) / d + 2;
|
||||
break;
|
||||
case b:
|
||||
h = (r - g) / d + 4;
|
||||
break;
|
||||
}
|
||||
h /= 6;
|
||||
}
|
||||
|
||||
return {
|
||||
h: h * 360,
|
||||
s: s * 100,
|
||||
l: l * 100,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts HSL values to a hexadecimal color code
|
||||
* @param {HSL} hsl - An object containing HSL values
|
||||
* @param {number} hsl.h - Hue component (0-360)
|
||||
* @param {number} hsl.s - Saturation component (0-100)
|
||||
* @param {number} hsl.l - Lightness component (0-100)
|
||||
* @returns {string} The hexadecimal color code (e.g., "#ff0000" for red)
|
||||
* @example
|
||||
* hslToHex({ h: 0, s: 100, l: 50 }) // returns "#ff0000"
|
||||
* hslToHex({ h: 120, s: 100, l: 50 }) // returns "#00ff00"
|
||||
* hslToHex({ h: 240, s: 100, l: 50 }) // returns "#0000ff"
|
||||
*/
|
||||
export const hslToHex = ({ h, s, l }: HSL): string => {
|
||||
if (h < 0 || h > 360) return "#000000";
|
||||
if (s < 0 || s > 100) return "#000000";
|
||||
if (l < 0 || l > 100) return "#000000";
|
||||
|
||||
l /= 100;
|
||||
const a = (s * Math.min(l, 1 - l)) / 100;
|
||||
|
||||
const f = (n: number) => {
|
||||
const k = (n + h / 30) % 12;
|
||||
const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
|
||||
return Math.round(255 * color)
|
||||
.toString(16)
|
||||
.padStart(2, "0");
|
||||
};
|
||||
|
||||
return `#${f(0)}${f(8)}${f(4)}`;
|
||||
};
|
||||
|
||||
@@ -10,3 +10,4 @@ export * from "./issue";
|
||||
export * from "./state";
|
||||
export * from "./string";
|
||||
export * from "./theme";
|
||||
export * from "./workspace";
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
// plane imports
|
||||
import { IWorkspace } from "@plane/types";
|
||||
|
||||
export const orderWorkspacesList = (workspaces: IWorkspace[]): IWorkspace[] =>
|
||||
workspaces.sort((a, b) => a.name.localeCompare(b.name));
|
||||
@@ -0,0 +1,13 @@
|
||||
import { List, Kanban, LucideProps } from "lucide-react";
|
||||
import { TIssueLayout } from "@plane/constants";
|
||||
|
||||
export const IssueLayoutIcon = ({ layout, ...props }: { layout: TIssueLayout } & LucideProps) => {
|
||||
switch (layout) {
|
||||
case "list":
|
||||
return <List {...props} />;
|
||||
case "kanban":
|
||||
return <Kanban {...props} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
@@ -14,6 +14,7 @@ import { queryParamGenerator } from "@/helpers/query-param-generator";
|
||||
import { useIssueFilter } from "@/hooks/store";
|
||||
// mobx
|
||||
import { TIssueLayout } from "@/types/issue";
|
||||
import { IssueLayoutIcon } from "./layout-icon";
|
||||
|
||||
type Props = {
|
||||
anchor: string;
|
||||
@@ -57,8 +58,8 @@ export const IssuesLayoutSelection: FC<Props> = observer((props) => {
|
||||
}`}
|
||||
onClick={() => handleCurrentBoardView(layout.key)}
|
||||
>
|
||||
<layout.icon
|
||||
strokeWidth={2}
|
||||
<IssueLayoutIcon
|
||||
layout={layout.key}
|
||||
className={`size-3.5 ${activeLayout == layout.key ? "text-custom-text-100" : "text-custom-text-200"}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { Briefcase } from "lucide-react";
|
||||
// ui
|
||||
import { Breadcrumbs, LayersIcon, Header, Logo } from "@plane/ui";
|
||||
// components
|
||||
import { BreadcrumbLink } from "@/components/common";
|
||||
import { IssueDetailQuickActions } from "@/components/issues";
|
||||
// hooks
|
||||
import { useIssueDetail, useProject } from "@/hooks/store";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
|
||||
export const ProjectIssueDetailsHeader = observer(() => {
|
||||
// router
|
||||
const router = useAppRouter();
|
||||
const { workspaceSlug, workItem } = useParams();
|
||||
// store hooks
|
||||
const { getProjectById, loader } = useProject();
|
||||
const {
|
||||
issue: { getIssueById, getIssueIdByIdentifier },
|
||||
} = useIssueDetail();
|
||||
// derived values
|
||||
const issueId = getIssueIdByIdentifier(workItem?.toString());
|
||||
const issueDetails = issueId ? getIssueById(issueId.toString()) : undefined;
|
||||
const projectId = issueDetails ? issueDetails?.project_id : undefined;
|
||||
const projectDetails = projectId ? getProjectById(projectId?.toString()) : undefined;
|
||||
|
||||
if (!workspaceSlug || !projectId || !issueId) return null;
|
||||
|
||||
return (
|
||||
<Header>
|
||||
<Header.LeftItem>
|
||||
<div>
|
||||
<Breadcrumbs onBack={router.back} isLoading={loader === "init-loader"}>
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="text"
|
||||
link={
|
||||
<BreadcrumbLink
|
||||
label={projectDetails?.name ?? "Project"}
|
||||
icon={
|
||||
projectDetails ? (
|
||||
projectDetails && (
|
||||
<span className="grid place-items-center flex-shrink-0 h-4 w-4">
|
||||
<Logo logo={projectDetails?.logo_props} size={16} />
|
||||
</span>
|
||||
)
|
||||
) : (
|
||||
<span className="grid place-items-center flex-shrink-0 h-4 w-4">
|
||||
<Briefcase className="h-4 w-4" />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="text"
|
||||
link={
|
||||
<BreadcrumbLink
|
||||
href={`/${workspaceSlug}/projects/${projectId}/issues`}
|
||||
label="Issues"
|
||||
icon={<LayersIcon className="h-4 w-4 text-custom-text-300" />}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="text"
|
||||
link={
|
||||
<BreadcrumbLink
|
||||
label={
|
||||
projectDetails && issueDetails ? `${projectDetails.identifier}-${issueDetails.sequence_id}` : ""
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
</div>
|
||||
</Header.LeftItem>
|
||||
<Header.RightItem>
|
||||
{projectId && issueId && (
|
||||
<IssueDetailQuickActions
|
||||
workspaceSlug={workspaceSlug?.toString()}
|
||||
projectId={projectId?.toString()}
|
||||
issueId={issueId?.toString()}
|
||||
/>
|
||||
)}
|
||||
</Header.RightItem>
|
||||
</Header>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useTheme } from "next-themes";
|
||||
import useSWR from "swr";
|
||||
// plane imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Loader } from "@plane/ui";
|
||||
// components
|
||||
import { EmptyState } from "@/components/common";
|
||||
import { PageHead } from "@/components/core";
|
||||
import { IssueDetailRoot } from "@/components/issues";
|
||||
// hooks
|
||||
import { useAppTheme, useIssueDetail, useProject } from "@/hooks/store";
|
||||
// assets
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
import { ProjectAuthWrapper } from "@/plane-web/layouts/project-wrapper";
|
||||
import emptyIssueDark from "@/public/empty-state/search/issues-dark.webp";
|
||||
import emptyIssueLight from "@/public/empty-state/search/issues-light.webp";
|
||||
|
||||
const IssueDetailsPage = observer(() => {
|
||||
// router
|
||||
const router = useAppRouter();
|
||||
const { workspaceSlug, workItem } = useParams();
|
||||
// hooks
|
||||
const { resolvedTheme } = useTheme();
|
||||
// store hooks
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
fetchIssueWithIdentifier,
|
||||
issue: { getIssueById },
|
||||
} = useIssueDetail();
|
||||
const { getProjectById } = useProject();
|
||||
const { toggleIssueDetailSidebar, issueDetailSidebarCollapsed } = useAppTheme();
|
||||
|
||||
const projectIdentifier = workItem?.toString().split("-")[0];
|
||||
const sequence_id = workItem?.toString().split("-")[1];
|
||||
|
||||
// fetching issue details
|
||||
const { data, isLoading, error } = useSWR(
|
||||
workspaceSlug && workItem ? `ISSUE_DETAIL_${workspaceSlug}_${projectIdentifier}_${sequence_id}` : null,
|
||||
workspaceSlug && workItem
|
||||
? () => fetchIssueWithIdentifier(workspaceSlug.toString(), projectIdentifier, sequence_id)
|
||||
: null
|
||||
);
|
||||
const issueId = data?.id;
|
||||
const projectId = data?.project_id;
|
||||
// derived values
|
||||
const issue = getIssueById(issueId?.toString() || "") || undefined;
|
||||
const project = (issue?.project_id && getProjectById(issue?.project_id)) || undefined;
|
||||
const issueLoader = !issue || isLoading;
|
||||
const pageTitle = project && issue ? `${project?.identifier}-${issue?.sequence_id} ${issue?.name}` : undefined;
|
||||
|
||||
useEffect(() => {
|
||||
const handleToggleIssueDetailSidebar = () => {
|
||||
if (window && window.innerWidth < 768) {
|
||||
toggleIssueDetailSidebar(true);
|
||||
}
|
||||
if (window && issueDetailSidebarCollapsed && window.innerWidth >= 768) {
|
||||
toggleIssueDetailSidebar(false);
|
||||
}
|
||||
};
|
||||
window.addEventListener("resize", handleToggleIssueDetailSidebar);
|
||||
handleToggleIssueDetailSidebar();
|
||||
return () => window.removeEventListener("resize", handleToggleIssueDetailSidebar);
|
||||
}, [issueDetailSidebarCollapsed, toggleIssueDetailSidebar]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title={pageTitle} />
|
||||
{error ? (
|
||||
<EmptyState
|
||||
image={resolvedTheme === "dark" ? emptyIssueDark : emptyIssueLight}
|
||||
title={t("issue.empty_state.issue_detail.title")}
|
||||
description={t("issue.empty_state.issue_detail.description")}
|
||||
primaryButton={{
|
||||
text: t("issue.empty_state.issue_detail.primary_button.text"),
|
||||
onClick: () => router.push(`/${workspaceSlug}/workspace-views/all-issues/`),
|
||||
}}
|
||||
/>
|
||||
) : issueLoader ? (
|
||||
<Loader className="flex h-full gap-5 p-5">
|
||||
<div className="basis-2/3 space-y-2">
|
||||
<Loader.Item height="30px" width="40%" />
|
||||
<Loader.Item height="15px" width="60%" />
|
||||
<Loader.Item height="15px" width="60%" />
|
||||
<Loader.Item height="15px" width="40%" />
|
||||
</div>
|
||||
<div className="basis-1/3 space-y-3">
|
||||
<Loader.Item height="30px" />
|
||||
<Loader.Item height="30px" />
|
||||
<Loader.Item height="30px" />
|
||||
<Loader.Item height="30px" />
|
||||
</div>
|
||||
</Loader>
|
||||
) : (
|
||||
workspaceSlug &&
|
||||
projectId &&
|
||||
issueId && (
|
||||
<ProjectAuthWrapper workspaceSlug={workspaceSlug?.toString()} projectId={projectId?.toString()}>
|
||||
<IssueDetailRoot
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
projectId={projectId.toString()}
|
||||
issueId={issueId.toString()}
|
||||
/>
|
||||
</ProjectAuthWrapper>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default IssueDetailsPage;
|
||||
@@ -0,0 +1,155 @@
|
||||
"use client";
|
||||
|
||||
import React, { useRef, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// plane imports
|
||||
import { Plus, Search } from "lucide-react";
|
||||
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { setToast, TOAST_TYPE, Tooltip } from "@plane/ui";
|
||||
import { cn } from "@plane/utils";
|
||||
// components
|
||||
import { CreateProjectModal } from "@/components/project";
|
||||
import { SidebarProjectsListItem } from "@/components/workspace";
|
||||
// hooks
|
||||
import { orderJoinedProjects } from "@/helpers/project.helper";
|
||||
import { useAppTheme, useProject, useUserPermissions } from "@/hooks/store";
|
||||
import useExtendedSidebarOutsideClickDetector from "@/hooks/use-extended-sidebar-overview-outside-click";
|
||||
import { TProject } from "@/plane-web/types";
|
||||
|
||||
export const ExtendedProjectSidebar = observer(() => {
|
||||
// refs
|
||||
const extendedProjectSidebarRef = useRef<HTMLDivElement | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState<string>("");
|
||||
// states
|
||||
const [isProjectModalOpen, setIsProjectModalOpen] = useState(false);
|
||||
// routers
|
||||
const { workspaceSlug } = useParams();
|
||||
// store hooks
|
||||
const { t } = useTranslation();
|
||||
const { sidebarCollapsed, extendedProjectSidebarCollapsed, toggleExtendedProjectSidebar } = useAppTheme();
|
||||
const { getPartialProjectById, joinedProjectIds: joinedProjects, updateProjectView } = useProject();
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
|
||||
const handleOnProjectDrop = (
|
||||
sourceId: string | undefined,
|
||||
destinationId: string | undefined,
|
||||
shouldDropAtEnd: boolean
|
||||
) => {
|
||||
if (!sourceId || !destinationId || !workspaceSlug) return;
|
||||
if (sourceId === destinationId) return;
|
||||
|
||||
const joinedProjectsList: TProject[] = [];
|
||||
joinedProjects.map((projectId) => {
|
||||
const projectDetails = getPartialProjectById(projectId);
|
||||
if (projectDetails) joinedProjectsList.push(projectDetails);
|
||||
});
|
||||
|
||||
const sourceIndex = joinedProjects.indexOf(sourceId);
|
||||
const destinationIndex = shouldDropAtEnd ? joinedProjects.length : joinedProjects.indexOf(destinationId);
|
||||
|
||||
if (joinedProjectsList.length <= 0) return;
|
||||
|
||||
const updatedSortOrder = orderJoinedProjects(sourceIndex, destinationIndex, sourceId, joinedProjectsList);
|
||||
if (updatedSortOrder != undefined)
|
||||
updateProjectView(workspaceSlug.toString(), sourceId, { sort_order: updatedSortOrder }).catch(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: t("error"),
|
||||
message: t("something_went_wrong"),
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// filter projects based on search query
|
||||
const filteredProjects = joinedProjects.filter((projectId) => {
|
||||
const project = getPartialProjectById(projectId);
|
||||
if (!project) return false;
|
||||
return project.name.toLowerCase().includes(searchQuery.toLowerCase()) || project.identifier.includes(searchQuery);
|
||||
});
|
||||
|
||||
// auth
|
||||
const isAuthorizedUser = allowPermissions(
|
||||
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
|
||||
EUserPermissionsLevel.WORKSPACE
|
||||
);
|
||||
|
||||
useExtendedSidebarOutsideClickDetector(
|
||||
extendedProjectSidebarRef,
|
||||
() => {
|
||||
if (!isProjectModalOpen) {
|
||||
toggleExtendedProjectSidebar(false);
|
||||
}
|
||||
},
|
||||
"extended-project-sidebar-toggle"
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{workspaceSlug && (
|
||||
<CreateProjectModal
|
||||
isOpen={isProjectModalOpen}
|
||||
onClose={() => setIsProjectModalOpen(false)}
|
||||
setToFavorite={false}
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
ref={extendedProjectSidebarRef}
|
||||
className={cn(
|
||||
"fixed top-0 h-full z-[19] flex flex-col gap-2 w-[300px] transform transition-all duration-300 ease-in-out bg-custom-sidebar-background-100 border-r border-custom-sidebar-border-200 p-4 shadow-md",
|
||||
{
|
||||
"translate-x-0 opacity-100": extendedProjectSidebarCollapsed,
|
||||
"-translate-x-full opacity-0": !extendedProjectSidebarCollapsed,
|
||||
"left-[70px]": sidebarCollapsed,
|
||||
"left-[250px]": !sidebarCollapsed,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col gap-1 w-full">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-semibold text-custom-text-300 py-1.5">Projects</span>
|
||||
{isAuthorizedUser && (
|
||||
<Tooltip tooltipHeading={t("create_project")} tooltipContent="">
|
||||
<button
|
||||
type="button"
|
||||
className="p-0.5 rounded hover:bg-custom-sidebar-background-80 flex-shrink-0"
|
||||
onClick={() => {
|
||||
setIsProjectModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<Plus className="size-3" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-1.5 rounded-md border border-custom-border-200 bg-custom-background-100 px-2.5 py-1 w-full">
|
||||
<Search className="h-3.5 w-3.5 text-custom-text-400" />
|
||||
<input
|
||||
className="w-full max-w-[234px] border-none bg-transparent text-sm outline-none placeholder:text-custom-text-400"
|
||||
placeholder={t("search")}
|
||||
value={searchQuery}
|
||||
autoFocus
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{filteredProjects.map((projectId, index) => (
|
||||
<SidebarProjectsListItem
|
||||
key={projectId}
|
||||
projectId={projectId}
|
||||
handleCopyText={() => {}}
|
||||
projectListType={"JOINED"}
|
||||
disableDrag={false}
|
||||
disableDrop={false}
|
||||
isLastChild={index === joinedProjects.length - 1}
|
||||
handleOnProjectDrop={handleOnProjectDrop}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
"use client";
|
||||
|
||||
import React, { useMemo, useRef } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// plane imports
|
||||
import { EUserWorkspaceRoles, WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS_LINKS } from "@plane/constants";
|
||||
import { cn } from "@plane/utils";
|
||||
// hooks
|
||||
import { useAppTheme, useWorkspace } from "@/hooks/store";
|
||||
import useExtendedSidebarOutsideClickDetector from "@/hooks/use-extended-sidebar-overview-outside-click";
|
||||
// plane-web imports
|
||||
import { ExtendedSidebarItem } from "@/plane-web/components/workspace/sidebar";
|
||||
|
||||
export const ExtendedAppSidebar = observer(() => {
|
||||
// refs
|
||||
const extendedSidebarRef = useRef<HTMLDivElement | null>(null);
|
||||
// routers
|
||||
const { workspaceSlug } = useParams();
|
||||
// store hooks
|
||||
const { sidebarCollapsed, extendedSidebarCollapsed, toggleExtendedSidebar } = useAppTheme();
|
||||
const { updateSidebarPreference, getNavigationPreferences } = useWorkspace();
|
||||
|
||||
// derived values
|
||||
const currentWorkspaceNavigationPreferences = getNavigationPreferences(workspaceSlug.toString());
|
||||
|
||||
const sortedNavigationItems = useMemo(
|
||||
() =>
|
||||
WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS_LINKS.map((item) => {
|
||||
const preference = currentWorkspaceNavigationPreferences?.[item.key];
|
||||
return {
|
||||
...item,
|
||||
sort_order: preference ? preference.sort_order : 0,
|
||||
};
|
||||
}).sort((a, b) => a.sort_order - b.sort_order),
|
||||
[currentWorkspaceNavigationPreferences]
|
||||
);
|
||||
|
||||
const sortedNavigationItemsKeys = sortedNavigationItems.map((item) => item.key);
|
||||
|
||||
const orderNavigationItem = (
|
||||
sourceIndex: number,
|
||||
destinationIndex: number,
|
||||
navigationList: {
|
||||
sort_order: number;
|
||||
key: string;
|
||||
labelTranslationKey: string;
|
||||
href: string;
|
||||
access: EUserWorkspaceRoles[];
|
||||
}[]
|
||||
): number | undefined => {
|
||||
if (sourceIndex < 0 || destinationIndex < 0 || navigationList.length <= 0) return undefined;
|
||||
|
||||
let updatedSortOrder: number | undefined = undefined;
|
||||
const sortOrderDefaultValue = 10000;
|
||||
|
||||
if (destinationIndex === 0) {
|
||||
// updating project at the top of the project
|
||||
const currentSortOrder = navigationList[destinationIndex].sort_order || 0;
|
||||
updatedSortOrder = currentSortOrder - sortOrderDefaultValue;
|
||||
} else if (destinationIndex === navigationList.length) {
|
||||
// updating project at the bottom of the project
|
||||
const currentSortOrder = navigationList[destinationIndex - 1].sort_order || 0;
|
||||
updatedSortOrder = currentSortOrder + sortOrderDefaultValue;
|
||||
} else {
|
||||
// updating project in the middle of the project
|
||||
const destinationTopProjectSortOrder = navigationList[destinationIndex - 1].sort_order || 0;
|
||||
const destinationBottomProjectSortOrder = navigationList[destinationIndex].sort_order || 0;
|
||||
const updatedValue = (destinationTopProjectSortOrder + destinationBottomProjectSortOrder) / 2;
|
||||
updatedSortOrder = updatedValue;
|
||||
}
|
||||
|
||||
return updatedSortOrder;
|
||||
};
|
||||
|
||||
const handleOnNavigationItemDrop = (
|
||||
sourceId: string | undefined,
|
||||
destinationId: string | undefined,
|
||||
shouldDropAtEnd: boolean
|
||||
) => {
|
||||
if (!sourceId || !destinationId || !workspaceSlug) return;
|
||||
if (sourceId === destinationId) return;
|
||||
|
||||
const sourceIndex = sortedNavigationItemsKeys.indexOf(sourceId);
|
||||
const destinationIndex = shouldDropAtEnd
|
||||
? sortedNavigationItemsKeys.length
|
||||
: sortedNavigationItemsKeys.indexOf(destinationId);
|
||||
|
||||
const updatedSortOrder = orderNavigationItem(sourceIndex, destinationIndex, sortedNavigationItems);
|
||||
|
||||
if (updatedSortOrder != undefined)
|
||||
updateSidebarPreference(workspaceSlug.toString(), sourceId, {
|
||||
sort_order: updatedSortOrder,
|
||||
});
|
||||
};
|
||||
|
||||
useExtendedSidebarOutsideClickDetector(
|
||||
extendedSidebarRef,
|
||||
() => toggleExtendedSidebar(false),
|
||||
"extended-sidebar-toggle"
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={extendedSidebarRef}
|
||||
className={cn(
|
||||
"fixed top-0 h-full z-[19] flex flex-col w-[300px] transform transition-all duration-300 ease-in-out bg-custom-sidebar-background-100 border-r border-custom-sidebar-border-200 p-4 shadow-md",
|
||||
{
|
||||
"translate-x-0 opacity-100": extendedSidebarCollapsed,
|
||||
"-translate-x-full opacity-0": !extendedSidebarCollapsed,
|
||||
"left-[70px]": sidebarCollapsed,
|
||||
"left-[250px]": !sidebarCollapsed,
|
||||
}
|
||||
)}
|
||||
>
|
||||
{sortedNavigationItems.map((item, index) => (
|
||||
<ExtendedSidebarItem
|
||||
key={item.key}
|
||||
item={item}
|
||||
isLastChild={index === sortedNavigationItems.length - 1}
|
||||
handleOnNavigationItemDrop={handleOnNavigationItemDrop}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
+25
-70
@@ -1,70 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { redirect, useParams } from "next/navigation";
|
||||
import { useTheme } from "next-themes";
|
||||
import useSWR from "swr";
|
||||
// i18n
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// ui
|
||||
import { Loader } from "@plane/ui";
|
||||
// components
|
||||
import { EmptyState } from "@/components/common";
|
||||
import { PageHead } from "@/components/core";
|
||||
import { IssueDetailRoot } from "@/components/issues";
|
||||
import { EmptyState, LogoSpinner } from "@/components/common";
|
||||
// hooks
|
||||
import { useAppTheme, useIssueDetail, useProject } from "@/hooks/store";
|
||||
// assets
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
// assets
|
||||
import emptyIssueDark from "@/public/empty-state/search/issues-dark.webp";
|
||||
import emptyIssueLight from "@/public/empty-state/search/issues-light.webp";
|
||||
// services
|
||||
import { IssueService } from "@/services/issue/issue.service";
|
||||
|
||||
const issueService = new IssueService();
|
||||
|
||||
const IssueDetailsPage = observer(() => {
|
||||
// i18n
|
||||
const { t } = useTranslation();
|
||||
// router
|
||||
const router = useAppRouter();
|
||||
const { t } = useTranslation();
|
||||
const { workspaceSlug, projectId, issueId } = useParams();
|
||||
// hooks
|
||||
const { resolvedTheme } = useTheme();
|
||||
// store hooks
|
||||
const {
|
||||
fetchIssue,
|
||||
issue: { getIssueById },
|
||||
} = useIssueDetail();
|
||||
const { getProjectById } = useProject();
|
||||
const { toggleIssueDetailSidebar, issueDetailSidebarCollapsed } = useAppTheme();
|
||||
// fetching work item details
|
||||
const { isLoading, error } = useSWR(
|
||||
workspaceSlug && projectId && issueId ? `ISSUE_DETAIL_${workspaceSlug}_${projectId}_${issueId}` : null,
|
||||
|
||||
const { data, isLoading, error } = useSWR(
|
||||
workspaceSlug && projectId && issueId ? `ISSUE_DETAIL_META_${workspaceSlug}_${projectId}_${issueId}` : null,
|
||||
workspaceSlug && projectId && issueId
|
||||
? () => fetchIssue(workspaceSlug.toString(), projectId.toString(), issueId.toString())
|
||||
? () => issueService.getIssueMetaFromURL(workspaceSlug.toString(), projectId.toString(), issueId.toString())
|
||||
: null
|
||||
);
|
||||
// derived values
|
||||
const issue = getIssueById(issueId?.toString() || "") || undefined;
|
||||
const project = (issue?.project_id && getProjectById(issue?.project_id)) || undefined;
|
||||
const issueLoader = !issue || isLoading;
|
||||
const pageTitle = project && issue ? `${project?.identifier}-${issue?.sequence_id} ${issue?.name}` : undefined;
|
||||
|
||||
useEffect(() => {
|
||||
const handleToggleIssueDetailSidebar = () => {
|
||||
if (window && window.innerWidth < 768) {
|
||||
toggleIssueDetailSidebar(true);
|
||||
}
|
||||
if (window && issueDetailSidebarCollapsed && window.innerWidth >= 768) {
|
||||
toggleIssueDetailSidebar(false);
|
||||
}
|
||||
};
|
||||
window.addEventListener("resize", handleToggleIssueDetailSidebar);
|
||||
handleToggleIssueDetailSidebar();
|
||||
return () => window.removeEventListener("resize", handleToggleIssueDetailSidebar);
|
||||
}, [issueDetailSidebarCollapsed, toggleIssueDetailSidebar]);
|
||||
if (data) {
|
||||
redirect(`/${workspaceSlug}/browse/${data.project_identifier}-${data.sequence_id}`);
|
||||
}
|
||||
}, [workspaceSlug, data]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title={pageTitle} />
|
||||
<div className="flex items-center justify-center size-full">
|
||||
{error ? (
|
||||
<EmptyState
|
||||
image={resolvedTheme === "dark" ? emptyIssueDark : emptyIssueLight}
|
||||
@@ -72,36 +46,17 @@ const IssueDetailsPage = observer(() => {
|
||||
description={t("issue.empty_state.issue_detail.description")}
|
||||
primaryButton={{
|
||||
text: t("issue.empty_state.issue_detail.primary_button.text"),
|
||||
onClick: () => router.push(`/${workspaceSlug}/projects/${projectId}/issues`),
|
||||
onClick: () => router.push(`/${workspaceSlug}/workspace-views/all-issues/`),
|
||||
}}
|
||||
/>
|
||||
) : issueLoader ? (
|
||||
<Loader className="flex h-full gap-5 p-5">
|
||||
<div className="basis-2/3 space-y-2">
|
||||
<Loader.Item height="30px" width="40%" />
|
||||
<Loader.Item height="15px" width="60%" />
|
||||
<Loader.Item height="15px" width="60%" />
|
||||
<Loader.Item height="15px" width="40%" />
|
||||
</div>
|
||||
<div className="basis-1/3 space-y-3">
|
||||
<Loader.Item height="30px" />
|
||||
<Loader.Item height="30px" />
|
||||
<Loader.Item height="30px" />
|
||||
<Loader.Item height="30px" />
|
||||
</div>
|
||||
</Loader>
|
||||
) : isLoading ? (
|
||||
<>
|
||||
<LogoSpinner />
|
||||
</>
|
||||
) : (
|
||||
workspaceSlug &&
|
||||
projectId &&
|
||||
issueId && (
|
||||
<IssueDetailRoot
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
projectId={projectId.toString()}
|
||||
issueId={issueId.toString()}
|
||||
/>
|
||||
)
|
||||
<></>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
-73
@@ -1,73 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// i18n
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// ui
|
||||
import { Breadcrumbs, LayersIcon, Header } from "@plane/ui";
|
||||
// components
|
||||
import { BreadcrumbLink } from "@/components/common";
|
||||
import { IssueDetailQuickActions } from "@/components/issues";
|
||||
// hooks
|
||||
import { useIssueDetail, useProject } from "@/hooks/store";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
// plane web
|
||||
import { ProjectBreadcrumb } from "@/plane-web/components/breadcrumbs";
|
||||
|
||||
export const ProjectIssueDetailsHeader = observer(() => {
|
||||
const { t } = useTranslation();
|
||||
// router
|
||||
const router = useAppRouter();
|
||||
const { workspaceSlug, projectId, issueId } = useParams();
|
||||
// store hooks
|
||||
const { currentProjectDetails, loader } = useProject();
|
||||
const {
|
||||
issue: { getIssueById },
|
||||
} = useIssueDetail();
|
||||
// derived values
|
||||
const issueDetails = issueId ? getIssueById(issueId.toString()) : undefined;
|
||||
|
||||
return (
|
||||
<Header>
|
||||
<Header.LeftItem>
|
||||
<div>
|
||||
<Breadcrumbs onBack={router.back} isLoading={loader === "init-loader"}>
|
||||
<ProjectBreadcrumb />
|
||||
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="text"
|
||||
link={
|
||||
<BreadcrumbLink
|
||||
href={`/${workspaceSlug}/projects/${projectId}/issues`}
|
||||
label={t("issue.label", { count: 2 })} // count is for pluralization
|
||||
icon={<LayersIcon className="h-4 w-4 text-custom-text-300" />}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="text"
|
||||
link={
|
||||
<BreadcrumbLink
|
||||
label={
|
||||
currentProjectDetails && issueDetails
|
||||
? `${currentProjectDetails.identifier}-${issueDetails.sequence_id}`
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
</div>
|
||||
</Header.LeftItem>
|
||||
<Header.RightItem>
|
||||
<IssueDetailQuickActions
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
projectId={projectId.toString()}
|
||||
issueId={issueId.toString()}
|
||||
/>
|
||||
</Header.RightItem>
|
||||
</Header>
|
||||
);
|
||||
});
|
||||
@@ -1,11 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
// plane web layouts
|
||||
import { ProjectAuthWrapper } from "@/plane-web/layouts/project-wrapper";
|
||||
|
||||
const ProjectDetailLayout = ({ children }: { children: ReactNode }) => (
|
||||
<ProjectAuthWrapper>{children}</ProjectAuthWrapper>
|
||||
);
|
||||
const ProjectDetailLayout = ({ children }: { children: ReactNode }) => {
|
||||
// router
|
||||
const { workspaceSlug, projectId } = useParams();
|
||||
return (
|
||||
<ProjectAuthWrapper workspaceSlug={workspaceSlug?.toString()} projectId={projectId?.toString()}>
|
||||
{children}
|
||||
</ProjectAuthWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectDetailLayout;
|
||||
|
||||
@@ -12,14 +12,17 @@ import { IWorkspaceBulkInviteFormData } from "@plane/types";
|
||||
import { Button, TOAST_TYPE, setToast } from "@plane/ui";
|
||||
// components
|
||||
import { NotAuthorizedView } from "@/components/auth-screens";
|
||||
import { CountChip } from "@/components/common";
|
||||
import { PageHead } from "@/components/core";
|
||||
import { SendWorkspaceInvitationModal, WorkspaceMembersList } from "@/components/workspace";
|
||||
// constants
|
||||
import { WorkspaceMembersList } from "@/components/workspace";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
import { getUserRole } from "@/helpers/user.helper";
|
||||
// hooks
|
||||
import { useEventTracker, useMember, useUserPermissions, useWorkspace } from "@/hooks/store";
|
||||
// plane web components
|
||||
import { BillingActionsButton } from "@/plane-web/components/workspace/billing";
|
||||
import { SendWorkspaceInvitationModal } from "@/plane-web/components/workspace/members";
|
||||
|
||||
const WorkspaceMembersSettingsPage = observer(() => {
|
||||
// states
|
||||
@@ -31,7 +34,7 @@ const WorkspaceMembersSettingsPage = observer(() => {
|
||||
const { workspaceUserInfo, allowPermissions } = useUserPermissions();
|
||||
const { captureEvent } = useEventTracker();
|
||||
const {
|
||||
workspace: { inviteMembersToWorkspace },
|
||||
workspace: { workspaceMemberIds, inviteMembersToWorkspace },
|
||||
} = useMember();
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const { t } = useTranslation();
|
||||
@@ -83,6 +86,7 @@ const WorkspaceMembersSettingsPage = observer(() => {
|
||||
title: "Error!",
|
||||
message: `${err.error ?? t("something_went_wrong_please_try_again")}`,
|
||||
});
|
||||
throw err;
|
||||
});
|
||||
};
|
||||
|
||||
@@ -103,12 +107,17 @@ const WorkspaceMembersSettingsPage = observer(() => {
|
||||
onSubmit={handleWorkspaceInvite}
|
||||
/>
|
||||
<section
|
||||
className={cn("w-full overflow-y-auto", {
|
||||
className={cn("w-full h-full overflow-y-auto", {
|
||||
"opacity-60": !canPerformWorkspaceMemberActions,
|
||||
})}
|
||||
>
|
||||
<div className="flex justify-between gap-4 pb-3.5 items-start ">
|
||||
<h4 className="text-xl font-medium">{t("workspace_settings.settings.members.title")}</h4>
|
||||
<div className="flex justify-between gap-4 pb-3.5 items-start">
|
||||
<h4 className="flex items-center gap-2.5 text-xl font-medium">
|
||||
{t("workspace_settings.settings.members.title")}
|
||||
{workspaceMemberIds && workspaceMemberIds.length > 0 && (
|
||||
<CountChip count={workspaceMemberIds.length} className="h-5 m-auto" />
|
||||
)}
|
||||
</h4>
|
||||
<div className="ml-auto flex items-center gap-1.5 rounded-md border border-custom-border-200 bg-custom-background-100 px-2.5 py-1.5">
|
||||
<Search className="h-3.5 w-3.5 text-custom-text-400" />
|
||||
<input
|
||||
@@ -124,6 +133,7 @@ const WorkspaceMembersSettingsPage = observer(() => {
|
||||
{t("workspace_settings.settings.members.add_member")}
|
||||
</Button>
|
||||
)}
|
||||
<BillingActionsButton canPerformWorkspaceAdminActions={canPerformWorkspaceAdminActions} />
|
||||
</div>
|
||||
<WorkspaceMembersList searchQuery={searchQuery} isAdmin={canPerformWorkspaceAdminActions} />
|
||||
</section>
|
||||
|
||||
@@ -5,16 +5,10 @@ import { observer } from "mobx-react";
|
||||
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
||||
import { useOutsideClickDetector } from "@plane/hooks";
|
||||
// components
|
||||
import {
|
||||
SidebarDropdown,
|
||||
SidebarHelpSection,
|
||||
SidebarProjectsList,
|
||||
SidebarQuickActions,
|
||||
SidebarUserMenu,
|
||||
SidebarWorkspaceMenu,
|
||||
} from "@/components/workspace";
|
||||
// helpers
|
||||
import { SidebarDropdown, SidebarHelpSection, SidebarProjectsList, SidebarQuickActions } from "@/components/workspace";
|
||||
import { SidebarFavoritesMenu } from "@/components/workspace/sidebar/favorites/favorites-menu";
|
||||
import { SidebarMenuItems } from "@/components/workspace/sidebar/sidebar-menu-items";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
// hooks
|
||||
import { useAppTheme, useUserPermissions } from "@/hooks/store";
|
||||
@@ -23,6 +17,8 @@ import useSize from "@/hooks/use-window-size";
|
||||
// plane web components
|
||||
import { SidebarAppSwitcher } from "@/plane-web/components/sidebar";
|
||||
import { SidebarTeamsList } from "@/plane-web/components/workspace/sidebar/teams-sidebar-list";
|
||||
import { ExtendedProjectSidebar } from "./extended-project-sidebar";
|
||||
import { ExtendedAppSidebar } from "./extended-sidebar";
|
||||
|
||||
export const AppSidebar: FC = observer(() => {
|
||||
// store hooks
|
||||
@@ -55,62 +51,63 @@ export const AppSidebar: FC = observer(() => {
|
||||
const isFavoriteEmpty = isEmpty(groupedFavorites);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"fixed inset-y-0 z-20 flex h-full flex-shrink-0 flex-grow-0 flex-col border-r border-custom-sidebar-border-200 bg-custom-sidebar-background-100 duration-300 w-[250px] md:relative md:ml-0",
|
||||
{
|
||||
"w-[70px] -ml-[250px]": sidebarCollapsed,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<>
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("size-full flex flex-col flex-1 pt-4 pb-0", {
|
||||
"p-2 pt-4": sidebarCollapsed,
|
||||
})}
|
||||
className={cn(
|
||||
"fixed inset-y-0 z-20 flex h-full flex-shrink-0 flex-grow-0 flex-col border-r border-custom-sidebar-border-200 bg-custom-sidebar-background-100 duration-300 w-[250px] md:relative md:ml-0",
|
||||
{
|
||||
"w-[70px] -ml-[250px]": sidebarCollapsed,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn("px-2", {
|
||||
"px-4": !sidebarCollapsed,
|
||||
ref={ref}
|
||||
className={cn("size-full flex flex-col flex-1 pt-4 pb-0", {
|
||||
"p-2 pt-4": sidebarCollapsed,
|
||||
})}
|
||||
>
|
||||
{/* Workspace switcher and settings */}
|
||||
<SidebarDropdown />
|
||||
<div className="flex-shrink-0 h-4" />
|
||||
{/* App switcher */}
|
||||
{canPerformWorkspaceMemberActions && <SidebarAppSwitcher />}
|
||||
{/* Quick actions */}
|
||||
<SidebarQuickActions />
|
||||
</div>
|
||||
<hr
|
||||
className={cn("flex-shrink-0 border-custom-sidebar-border-300 h-[0.5px] w-3/5 mx-auto my-1", {
|
||||
"opacity-0": !sidebarCollapsed,
|
||||
})}
|
||||
/>
|
||||
<div
|
||||
className={cn("overflow-x-hidden scrollbar-sm h-full w-full overflow-y-auto px-2 py-0.5", {
|
||||
"vertical-scrollbar px-4": !sidebarCollapsed,
|
||||
})}
|
||||
>
|
||||
{/* User Menu */}
|
||||
<SidebarUserMenu />
|
||||
{/* Workspace Menu */}
|
||||
<SidebarWorkspaceMenu />
|
||||
<div
|
||||
className={cn("px-2", {
|
||||
"px-4": !sidebarCollapsed,
|
||||
})}
|
||||
>
|
||||
{/* Workspace switcher and settings */}
|
||||
<SidebarDropdown />
|
||||
<div className="flex-shrink-0 h-4" />
|
||||
{/* App switcher */}
|
||||
{canPerformWorkspaceMemberActions && <SidebarAppSwitcher />}
|
||||
{/* Quick actions */}
|
||||
<SidebarQuickActions />
|
||||
</div>
|
||||
<hr
|
||||
className={cn("flex-shrink-0 border-custom-sidebar-border-300 h-[0.5px] w-3/5 mx-auto my-1", {
|
||||
"opacity-0": !sidebarCollapsed,
|
||||
})}
|
||||
/>
|
||||
{/* Favorites Menu */}
|
||||
{canPerformWorkspaceMemberActions && !isFavoriteEmpty && <SidebarFavoritesMenu />}
|
||||
{/* Teams List */}
|
||||
<SidebarTeamsList />
|
||||
{/* Projects List */}
|
||||
<SidebarProjectsList />
|
||||
<div
|
||||
className={cn("overflow-x-hidden scrollbar-sm h-full w-full overflow-y-auto px-2 py-0.5", {
|
||||
"vertical-scrollbar px-4": !sidebarCollapsed,
|
||||
})}
|
||||
>
|
||||
<SidebarMenuItems />
|
||||
<hr
|
||||
className={cn("flex-shrink-0 border-custom-sidebar-border-300 h-[0.5px] w-3/5 mx-auto my-1", {
|
||||
"opacity-0": !sidebarCollapsed,
|
||||
})}
|
||||
/>
|
||||
{/* Favorites Menu */}
|
||||
{canPerformWorkspaceMemberActions && !isFavoriteEmpty && <SidebarFavoritesMenu />}
|
||||
{/* Teams List */}
|
||||
<SidebarTeamsList />
|
||||
{/* Projects List */}
|
||||
<SidebarProjectsList />
|
||||
</div>
|
||||
{/* Help Section */}
|
||||
<SidebarHelpSection />
|
||||
</div>
|
||||
{/* Help Section */}
|
||||
<SidebarHelpSection />
|
||||
</div>
|
||||
</div>
|
||||
<ExtendedAppSidebar />
|
||||
<ExtendedProjectSidebar />
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -17,6 +17,7 @@ import type { IWorkspaceMemberInvitation } from "@plane/types";
|
||||
import { Button, TOAST_TYPE, setToast } from "@plane/ui";
|
||||
// components
|
||||
import { EmptyState } from "@/components/common";
|
||||
import { WorkspaceLogo } from "@/components/workspace/logo";
|
||||
import { USER_WORKSPACES_LIST } from "@/constants/fetch-keys";
|
||||
// helpers
|
||||
import { truncateText } from "@/helpers/string.helper";
|
||||
@@ -167,21 +168,11 @@ const UserInvitationsPage = observer(() => {
|
||||
onClick={() => handleInvitation(invitation, isSelected ? "withdraw" : "accepted")}
|
||||
>
|
||||
<div className="flex-shrink-0">
|
||||
<div className="grid h-9 w-9 place-items-center rounded">
|
||||
{invitation.workspace.logo && invitation.workspace.logo.trim() !== "" ? (
|
||||
<img
|
||||
src={invitation.workspace.logo}
|
||||
height="100%"
|
||||
width="100%"
|
||||
className="rounded"
|
||||
alt={invitation.workspace.name}
|
||||
/>
|
||||
) : (
|
||||
<span className="grid h-9 w-9 place-items-center rounded bg-gray-700 px-3 py-1.5 uppercase text-white">
|
||||
{invitation.workspace.name[0]}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<WorkspaceLogo
|
||||
logo={invitation.workspace.logo_url}
|
||||
name={invitation.workspace.name}
|
||||
classNames="size-9 flex-shrink-0"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium">{truncateText(invitation.workspace.name, 30)}</div>
|
||||
|
||||
+3
-3
@@ -1,9 +1,9 @@
|
||||
import { Metadata, Viewport } from "next";
|
||||
import Script from "next/script";
|
||||
// styles
|
||||
import "@/styles/emoji.css";
|
||||
import "@/styles/globals.css";
|
||||
import "@/styles/power-k.css";
|
||||
import "@/styles/command-pallette.css";
|
||||
import "@/styles/emoji.css";
|
||||
import "@/styles/react-day-picker.css";
|
||||
// meta data info
|
||||
|
||||
@@ -67,7 +67,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
||||
<link rel="preload" href={`${API_BASE_URL}/api/users/me/settings/ `} as="fetch" crossOrigin="use-credentials" />
|
||||
<link
|
||||
rel="preload"
|
||||
href={`${API_BASE_URL}/api/users/me/workspaces/`}
|
||||
href={`${API_BASE_URL}/api/users/me/workspaces/?v=${Date.now()}`}
|
||||
as="fetch"
|
||||
crossOrigin="use-credentials"
|
||||
/>
|
||||
|
||||
@@ -5,27 +5,22 @@ import useSWR from "swr";
|
||||
import { BulkDeleteIssuesModal } from "@/components/core";
|
||||
import { CreateUpdateIssueModal, DeleteIssueModal } from "@/components/issues";
|
||||
// constants
|
||||
import { ISSUE_DETAILS } from "@/constants/fetch-keys";
|
||||
// hooks
|
||||
import { useCommandPalette, useUser } from "@/hooks/store";
|
||||
import { useCommandPalette, useIssueDetail, useUser } from "@/hooks/store";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
import { useIssuesStore } from "@/hooks/use-issue-layout-store";
|
||||
// services
|
||||
import { IssueService } from "@/services/issue";
|
||||
|
||||
// services
|
||||
const issueService = new IssueService();
|
||||
|
||||
export const IssueLevelModals = observer(() => {
|
||||
// router
|
||||
const pathname = usePathname();
|
||||
const { workspaceSlug, projectId, issueId, cycleId, moduleId } = useParams();
|
||||
const { workspaceSlug, projectId: paramsProjectId, workItem, cycleId, moduleId } = useParams();
|
||||
const router = useAppRouter();
|
||||
// store hooks
|
||||
const { data: currentUser } = useUser();
|
||||
const {
|
||||
issues: { removeIssue },
|
||||
} = useIssuesStore();
|
||||
const { fetchIssueWithIdentifier } = useIssueDetail();
|
||||
const {
|
||||
isCreateIssueModalOpen,
|
||||
toggleCreateIssueModal,
|
||||
@@ -37,13 +32,19 @@ export const IssueLevelModals = observer(() => {
|
||||
// derived values
|
||||
const isDraftIssue = pathname?.includes("draft-issues") || false;
|
||||
|
||||
const projectIdentifier = workItem?.toString().split("-")[0];
|
||||
const sequence_id = workItem?.toString().split("-")[1];
|
||||
|
||||
const { data: issueDetails } = useSWR(
|
||||
workspaceSlug && projectId && issueId ? ISSUE_DETAILS(issueId as string) : null,
|
||||
workspaceSlug && projectId && issueId
|
||||
? () => issueService.retrieve(workspaceSlug as string, projectId as string, issueId as string)
|
||||
workspaceSlug && workItem ? `ISSUE_DETAIL_${workspaceSlug}_${projectIdentifier}_${sequence_id}` : null,
|
||||
workspaceSlug && workItem
|
||||
? () => fetchIssueWithIdentifier(workspaceSlug.toString(), projectIdentifier, sequence_id)
|
||||
: null
|
||||
);
|
||||
|
||||
const issueId = issueDetails?.id;
|
||||
const projectId = paramsProjectId ?? issueDetails?.project_id;
|
||||
|
||||
return (
|
||||
<>
|
||||
<CreateUpdateIssueModal
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import { useParams } from "next/navigation";
|
||||
// plane types
|
||||
import { TPowerKPageKeys } from "@plane/types";
|
||||
// components
|
||||
import { PowerKIssueActionsMenu } from "@/components/command-palette/power-k/context-based-actions";
|
||||
|
||||
type Props = {
|
||||
activePage: TPowerKPageKeys | undefined;
|
||||
handleClose: () => void;
|
||||
handleUpdateSearchTerm: (searchTerm: string) => void;
|
||||
handleUpdatePage: (page: TPowerKPageKeys) => void;
|
||||
};
|
||||
|
||||
export const PowerKContextBasedActions: React.FC<Props> = (props) => {
|
||||
const { activePage, handleClose, handleUpdateSearchTerm, handleUpdatePage } = props;
|
||||
// navigation
|
||||
const { issueId } = useParams();
|
||||
|
||||
return (
|
||||
<>
|
||||
{issueId && (
|
||||
<PowerKIssueActionsMenu
|
||||
handleClose={handleClose}
|
||||
activePage={activePage}
|
||||
handleUpdatePage={handleUpdatePage}
|
||||
issueId={issueId.toString()}
|
||||
handleUpdateSearchTerm={handleUpdateSearchTerm}
|
||||
/>
|
||||
)}
|
||||
{/* {moduleId && (
|
||||
<PowerKModuleActionsMenu
|
||||
handleClose={handleClose}
|
||||
activePage={activePage}
|
||||
handleUpdatePage={handleUpdatePage}
|
||||
moduleId={moduleId.toString()}
|
||||
handleUpdateSearchTerm={handleUpdateSearchTerm}
|
||||
/>
|
||||
)} */}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,105 +0,0 @@
|
||||
import { FileText, FolderPlus, Layers, SquarePlus } from "lucide-react";
|
||||
// plane imports
|
||||
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
||||
import { TPowerKCreateAction, TPowerKCreateActionKeys } from "@plane/types";
|
||||
import { ContrastIcon, DiceIcon, LayersIcon } from "@plane/ui";
|
||||
// lib
|
||||
import { TAppRouterInstance } from "@/lib/n-progress/AppProgressBar";
|
||||
import { store } from "@/lib/store-context";
|
||||
|
||||
export const commonCreateActions = (
|
||||
router: TAppRouterInstance
|
||||
): Record<TPowerKCreateActionKeys, TPowerKCreateAction> => {
|
||||
// store
|
||||
const {
|
||||
canPerformAnyCreateAction,
|
||||
permission: { allowPermissions },
|
||||
} = store.user;
|
||||
const { workspaceProjectIds, currentProjectDetails } = store.projectRoot.project;
|
||||
const {
|
||||
toggleCreateCycleModal,
|
||||
toggleCreateIssueModal,
|
||||
toggleCreateModuleModal,
|
||||
toggleCreatePageModal,
|
||||
toggleCreateProjectModal,
|
||||
toggleCreateViewModal,
|
||||
} = store.commandPalette;
|
||||
// derived values
|
||||
const canCreateIssue = workspaceProjectIds && workspaceProjectIds.length > 0;
|
||||
const canCreateProject = allowPermissions(
|
||||
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
|
||||
EUserPermissionsLevel.WORKSPACE
|
||||
);
|
||||
|
||||
const options: Record<TPowerKCreateActionKeys, TPowerKCreateAction> = {
|
||||
issue: {
|
||||
key: "issue",
|
||||
onClick: () => toggleCreateIssueModal(true),
|
||||
label: "New work item",
|
||||
icon: LayersIcon,
|
||||
shortcut: "C",
|
||||
shouldRender: canCreateIssue,
|
||||
},
|
||||
page: {
|
||||
key: "page",
|
||||
onClick: () => toggleCreatePageModal({ isOpen: true }),
|
||||
label: "New page",
|
||||
icon: FileText,
|
||||
shortcut: "D",
|
||||
shouldRender: currentProjectDetails?.page_view && canPerformAnyCreateAction,
|
||||
},
|
||||
view: {
|
||||
key: "view",
|
||||
onClick: () => toggleCreateViewModal(true),
|
||||
label: "New view",
|
||||
icon: Layers,
|
||||
shortcut: "V",
|
||||
shouldRender: currentProjectDetails?.issue_views_view && canPerformAnyCreateAction,
|
||||
},
|
||||
cycle: {
|
||||
key: "cycle",
|
||||
onClick: () => toggleCreateCycleModal(true),
|
||||
label: "New cycle",
|
||||
icon: ContrastIcon,
|
||||
shortcut: "Q",
|
||||
shouldRender: currentProjectDetails?.cycle_view && canPerformAnyCreateAction,
|
||||
},
|
||||
module: {
|
||||
key: "module",
|
||||
onClick: () => toggleCreateModuleModal(true),
|
||||
label: "New module",
|
||||
icon: DiceIcon,
|
||||
shortcut: "M",
|
||||
shouldRender: currentProjectDetails?.module_view && canPerformAnyCreateAction,
|
||||
},
|
||||
project: {
|
||||
key: "project",
|
||||
onClick: () => toggleCreateProjectModal(true),
|
||||
label: "New project",
|
||||
icon: FolderPlus,
|
||||
shortcut: "P",
|
||||
shouldRender: canCreateProject,
|
||||
},
|
||||
workspace: {
|
||||
key: "workspace",
|
||||
onClick: () => router.push("/create-workspace"),
|
||||
label: "New workspace",
|
||||
icon: SquarePlus,
|
||||
},
|
||||
};
|
||||
|
||||
return options;
|
||||
};
|
||||
|
||||
export const getCreateActionsList = (router: TAppRouterInstance): TPowerKCreateAction[] => {
|
||||
const optionsList = commonCreateActions(router);
|
||||
return [
|
||||
optionsList["issue"],
|
||||
optionsList["page"],
|
||||
optionsList["view"],
|
||||
optionsList["cycle"],
|
||||
optionsList["module"],
|
||||
optionsList["project"],
|
||||
optionsList["workspace"],
|
||||
];
|
||||
};
|
||||
@@ -1,3 +0,0 @@
|
||||
export * from "./context-based-actions";
|
||||
export * from "./create-actions";
|
||||
export * from "./placeholder";
|
||||
@@ -1,19 +0,0 @@
|
||||
// plane types
|
||||
import { TPowerKPageKeys } from "@plane/types";
|
||||
|
||||
export const POWER_K_PLACEHOLDER_TEXT: Record<TPowerKPageKeys | "default", string> = {
|
||||
// issue actions
|
||||
"change-issue-assignee": "Assign to",
|
||||
"change-issue-priority": "Change priority",
|
||||
"change-issue-state": "Change state",
|
||||
// module actions
|
||||
"change-module-member": "Add/remove members",
|
||||
"change-module-status": "Change status",
|
||||
// configs
|
||||
"workspace-settings": "Search workspace settings",
|
||||
"project-settings": "Search project settings",
|
||||
"profile-settings": "Search profile settings",
|
||||
// personalization
|
||||
"change-theme": "Change theme",
|
||||
default: "Type a command or search",
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./subscription";
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user