Compare commits
42 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2ff9f1d775 | |||
| ddad1767a2 | |||
| 6a37a2ce21 | |||
| 01bd1bde64 | |||
| 9268180aec | |||
| ff778b98f5 | |||
| 8f5ce6b232 | |||
| 58a4ca9f36 | |||
| 312b077657 | |||
| c65e42f807 | |||
| f4af78c0fc | |||
| c0b6abc3d5 | |||
| 2f2e6626c6 | |||
| 6a8d3202b7 | |||
| 51b52a7fc3 | |||
| 23ede81737 | |||
| b698f44500 | |||
| 421839ec51 | |||
| 940b5e4e44 | |||
| 6003c88d62 | |||
| 74913a6659 | |||
| 97578684c6 | |||
| 88b4d32220 | |||
| f32635a6a8 | |||
| 7fe58e0ea9 | |||
| 7f22cd1ac1 | |||
| e2550e0b2d | |||
| b016ed78cf | |||
| c429ca7b36 | |||
| ee22dbba1b | |||
| f4a208bd44 | |||
| 8edff26ccd | |||
| d08c03f557 | |||
| 0b53912295 | |||
| 586a320d86 | |||
| 8f3a0be177 | |||
| 0679e140a2 | |||
| b611f5110f | |||
| 0f7bc6979f | |||
| 12501d0597 | |||
| 3a86fff7c1 | |||
| 58a4b45463 |
@@ -15,3 +15,4 @@ from .state import StateLiteSerializer, StateSerializer
|
||||
from .cycle import CycleSerializer, CycleIssueSerializer, CycleLiteSerializer
|
||||
from .module import ModuleSerializer, ModuleIssueSerializer, ModuleLiteSerializer
|
||||
from .intake import IntakeIssueSerializer
|
||||
from .estimate import EstimatePointSerializer
|
||||
@@ -72,6 +72,7 @@ class BaseSerializer(serializers.ModelSerializer):
|
||||
StateLiteSerializer,
|
||||
UserLiteSerializer,
|
||||
WorkspaceLiteSerializer,
|
||||
EstimatePointSerializer,
|
||||
)
|
||||
|
||||
# Expansion mapper
|
||||
@@ -88,6 +89,7 @@ class BaseSerializer(serializers.ModelSerializer):
|
||||
"owned_by": UserLiteSerializer,
|
||||
"members": UserLiteSerializer,
|
||||
"parent": IssueLiteSerializer,
|
||||
"estimate_point": EstimatePointSerializer,
|
||||
}
|
||||
# Check if field in expansion then expand the field
|
||||
if expand in expansion:
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
# Module imports
|
||||
from plane.db.models import EstimatePoint
|
||||
from .base import BaseSerializer
|
||||
|
||||
|
||||
class EstimatePointSerializer(BaseSerializer):
|
||||
class Meta:
|
||||
model = EstimatePoint
|
||||
fields = ["id", "value"]
|
||||
read_only_fields = fields
|
||||
@@ -207,6 +207,7 @@ class IssueSerializer(BaseSerializer):
|
||||
for assignee_id in assignees
|
||||
],
|
||||
batch_size=10,
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
|
||||
if labels is not None:
|
||||
@@ -224,6 +225,7 @@ class IssueSerializer(BaseSerializer):
|
||||
for label_id in labels
|
||||
],
|
||||
batch_size=10,
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
|
||||
# Time updation occues even when other related models are updated
|
||||
|
||||
@@ -71,4 +71,9 @@ urlpatterns = [
|
||||
IssueAttachmentEndpoint.as_view(),
|
||||
name="attachment",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/issue-attachments/<uuid:pk>/",
|
||||
IssueAttachmentEndpoint.as_view(),
|
||||
name="issue-attachment",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -72,6 +72,8 @@ from .issue import (
|
||||
IssueReactionLiteSerializer,
|
||||
IssueAttachmentLiteSerializer,
|
||||
IssueLinkLiteSerializer,
|
||||
IssueVersionDetailSerializer,
|
||||
IssueDescriptionVersionDetailSerializer,
|
||||
)
|
||||
|
||||
from .module import (
|
||||
|
||||
@@ -33,6 +33,8 @@ from plane.db.models import (
|
||||
IssueVote,
|
||||
IssueRelation,
|
||||
State,
|
||||
IssueVersion,
|
||||
IssueDescriptionVersion,
|
||||
)
|
||||
|
||||
|
||||
@@ -201,6 +203,7 @@ class IssueCreateSerializer(BaseSerializer):
|
||||
for user in assignees
|
||||
],
|
||||
batch_size=10,
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
|
||||
if labels is not None:
|
||||
@@ -218,6 +221,7 @@ class IssueCreateSerializer(BaseSerializer):
|
||||
for label in labels
|
||||
],
|
||||
batch_size=10,
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
|
||||
# Time updation occues even when other related models are updated
|
||||
@@ -281,10 +285,26 @@ class IssueRelationSerializer(BaseSerializer):
|
||||
)
|
||||
name = serializers.CharField(source="related_issue.name", read_only=True)
|
||||
relation_type = serializers.CharField(read_only=True)
|
||||
state_id = serializers.UUIDField(source="related_issue.state.id", read_only=True)
|
||||
priority = serializers.CharField(source="related_issue.priority", read_only=True)
|
||||
assignee_ids = serializers.ListField(
|
||||
child=serializers.PrimaryKeyRelatedField(queryset=User.objects.all()),
|
||||
write_only=True,
|
||||
required=False,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = IssueRelation
|
||||
fields = ["id", "project_id", "sequence_id", "relation_type", "name"]
|
||||
fields = [
|
||||
"id",
|
||||
"project_id",
|
||||
"sequence_id",
|
||||
"relation_type",
|
||||
"name",
|
||||
"state_id",
|
||||
"priority",
|
||||
"assignee_ids",
|
||||
]
|
||||
read_only_fields = ["workspace", "project"]
|
||||
|
||||
|
||||
@@ -296,10 +316,26 @@ class RelatedIssueSerializer(BaseSerializer):
|
||||
sequence_id = serializers.IntegerField(source="issue.sequence_id", read_only=True)
|
||||
name = serializers.CharField(source="issue.name", read_only=True)
|
||||
relation_type = serializers.CharField(read_only=True)
|
||||
state_id = serializers.UUIDField(source="issue.state.id", read_only=True)
|
||||
priority = serializers.CharField(source="issue.priority", read_only=True)
|
||||
assignee_ids = serializers.ListField(
|
||||
child=serializers.PrimaryKeyRelatedField(queryset=User.objects.all()),
|
||||
write_only=True,
|
||||
required=False,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = IssueRelation
|
||||
fields = ["id", "project_id", "sequence_id", "relation_type", "name"]
|
||||
fields = [
|
||||
"id",
|
||||
"project_id",
|
||||
"sequence_id",
|
||||
"relation_type",
|
||||
"name",
|
||||
"state_id",
|
||||
"priority",
|
||||
"assignee_ids",
|
||||
]
|
||||
read_only_fields = ["workspace", "project"]
|
||||
|
||||
|
||||
@@ -667,3 +703,64 @@ class IssueSubscriberSerializer(BaseSerializer):
|
||||
model = IssueSubscriber
|
||||
fields = "__all__"
|
||||
read_only_fields = ["workspace", "project", "issue"]
|
||||
|
||||
|
||||
class IssueVersionDetailSerializer(BaseSerializer):
|
||||
class Meta:
|
||||
model = IssueVersion
|
||||
fields = [
|
||||
"id",
|
||||
"workspace",
|
||||
"project",
|
||||
"issue",
|
||||
"parent",
|
||||
"state",
|
||||
"estimate_point",
|
||||
"name",
|
||||
"priority",
|
||||
"start_date",
|
||||
"target_date",
|
||||
"assignees",
|
||||
"sequence_id",
|
||||
"labels",
|
||||
"sort_order",
|
||||
"completed_at",
|
||||
"archived_at",
|
||||
"is_draft",
|
||||
"external_source",
|
||||
"external_id",
|
||||
"type",
|
||||
"cycle",
|
||||
"modules",
|
||||
"meta",
|
||||
"name",
|
||||
"last_saved_at",
|
||||
"owned_by",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"created_by",
|
||||
"updated_by",
|
||||
]
|
||||
read_only_fields = ["workspace", "project", "issue"]
|
||||
|
||||
|
||||
class IssueDescriptionVersionDetailSerializer(BaseSerializer):
|
||||
class Meta:
|
||||
model = IssueDescriptionVersion
|
||||
fields = [
|
||||
"id",
|
||||
"workspace",
|
||||
"project",
|
||||
"issue",
|
||||
"description_binary",
|
||||
"description_html",
|
||||
"description_stripped",
|
||||
"description_json",
|
||||
"last_saved_at",
|
||||
"owned_by",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"created_by",
|
||||
"updated_by",
|
||||
]
|
||||
read_only_fields = ["workspace", "project", "issue"]
|
||||
|
||||
@@ -22,6 +22,7 @@ from plane.db.models import (
|
||||
ProjectMember,
|
||||
WorkspaceHomePreference,
|
||||
Sticky,
|
||||
WorkspaceUserPreference,
|
||||
)
|
||||
from plane.utils.constants import RESTRICTED_WORKSPACE_SLUGS
|
||||
|
||||
@@ -258,3 +259,10 @@ class StickySerializer(BaseSerializer):
|
||||
fields = "__all__"
|
||||
read_only_fields = ["workspace", "owner"]
|
||||
extra_kwargs = {"name": {"required": False}}
|
||||
|
||||
|
||||
class WorkspaceUserPreferenceSerializer(BaseSerializer):
|
||||
class Meta:
|
||||
model = WorkspaceUserPreference
|
||||
fields = ["key", "is_pinned", "sort_order"]
|
||||
read_only_fields = ["workspace", "created_by", "updated_by"]
|
||||
|
||||
@@ -24,6 +24,8 @@ from plane.app.views import (
|
||||
IssueDetailEndpoint,
|
||||
IssueAttachmentV2Endpoint,
|
||||
IssueBulkUpdateDateEndpoint,
|
||||
IssueVersionEndpoint,
|
||||
IssueDescriptionVersionEndpoint,
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
@@ -256,4 +258,24 @@ urlpatterns = [
|
||||
IssueBulkUpdateDateEndpoint.as_view(),
|
||||
name="project-issue-dates",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/versions/",
|
||||
IssueVersionEndpoint.as_view(),
|
||||
name="page-versions",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/versions/<uuid:pk>/",
|
||||
IssueVersionEndpoint.as_view(),
|
||||
name="page-versions",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/description-versions/",
|
||||
IssueDescriptionVersionEndpoint.as_view(),
|
||||
name="page-versions",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/description-versions/<uuid:pk>/",
|
||||
IssueDescriptionVersionEndpoint.as_view(),
|
||||
name="page-versions",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -31,6 +31,7 @@ from plane.app.views import (
|
||||
UserRecentVisitViewSet,
|
||||
WorkspaceHomePreferenceViewSet,
|
||||
WorkspaceStickyViewSet,
|
||||
WorkspaceUserPreferenceViewSet,
|
||||
)
|
||||
|
||||
|
||||
@@ -258,4 +259,15 @@ urlpatterns = [
|
||||
),
|
||||
name="workspace-sticky",
|
||||
),
|
||||
# User Preference
|
||||
path(
|
||||
"workspaces/<str:slug>/sidebar-preferences/",
|
||||
WorkspaceUserPreferenceViewSet.as_view(),
|
||||
name="workspace-user-preference",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/sidebar-preferences/<str:key>/",
|
||||
WorkspaceUserPreferenceViewSet.as_view(),
|
||||
name="workspace-user-preference",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -48,6 +48,7 @@ from .workspace.favorite import (
|
||||
WorkspaceFavoriteGroupEndpoint,
|
||||
)
|
||||
from .workspace.recent_visit import UserRecentVisitViewSet
|
||||
from .workspace.user_preference import WorkspaceUserPreferenceViewSet
|
||||
|
||||
from .workspace.member import (
|
||||
WorkSpaceMemberViewSet,
|
||||
@@ -141,6 +142,8 @@ from .issue.sub_issue import SubIssuesEndpoint
|
||||
|
||||
from .issue.subscriber import IssueSubscriberViewSet
|
||||
|
||||
from .issue.version import IssueVersionEndpoint, IssueDescriptionVersionEndpoint
|
||||
|
||||
from .module.base import (
|
||||
ModuleViewSet,
|
||||
ModuleLinkViewSet,
|
||||
|
||||
@@ -47,6 +47,7 @@ from plane.db.models import (
|
||||
User,
|
||||
Project,
|
||||
ProjectMember,
|
||||
UserRecentVisit,
|
||||
)
|
||||
from plane.utils.analytics_plot import burndown_plot
|
||||
from plane.bgtasks.recent_visited_task import recent_visited_task
|
||||
@@ -543,6 +544,13 @@ class CycleViewSet(BaseViewSet):
|
||||
entity_identifier=pk,
|
||||
project_id=project_id,
|
||||
).delete()
|
||||
# Delete the cycle from recent visits
|
||||
UserRecentVisit.objects.filter(
|
||||
project_id=project_id,
|
||||
workspace__slug=slug,
|
||||
entity_identifier=pk,
|
||||
entity_name="cycle",
|
||||
).delete(soft=False)
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ from plane.db.models import (
|
||||
Project,
|
||||
ProjectMember,
|
||||
CycleIssue,
|
||||
UserRecentVisit,
|
||||
)
|
||||
from plane.utils.grouper import (
|
||||
issue_group_values,
|
||||
@@ -671,6 +672,13 @@ class IssueViewSet(BaseViewSet):
|
||||
issue = Issue.objects.get(workspace__slug=slug, project_id=project_id, pk=pk)
|
||||
|
||||
issue.delete()
|
||||
# delete the issue from recent visits
|
||||
UserRecentVisit.objects.filter(
|
||||
project_id=project_id,
|
||||
workspace__slug=slug,
|
||||
entity_identifier=pk,
|
||||
entity_name="issue",
|
||||
).delete(soft=False)
|
||||
issue_activity.delay(
|
||||
type="issue.activity.deleted",
|
||||
requested_data=json.dumps({"issue_id": str(pk)}),
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
# Third party imports
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import IssueVersion, IssueDescriptionVersion
|
||||
from ..base import BaseAPIView
|
||||
from plane.app.serializers import (
|
||||
IssueVersionDetailSerializer,
|
||||
IssueDescriptionVersionDetailSerializer,
|
||||
)
|
||||
from plane.app.permissions import allow_permission, ROLE
|
||||
from plane.utils.global_paginator import paginate
|
||||
from plane.utils.timezone_converter import user_timezone_converter
|
||||
|
||||
|
||||
class IssueVersionEndpoint(BaseAPIView):
|
||||
def process_paginated_result(self, fields, results, timezone):
|
||||
paginated_data = results.values(*fields)
|
||||
|
||||
datetime_fields = ["created_at", "updated_at"]
|
||||
paginated_data = user_timezone_converter(
|
||||
paginated_data, datetime_fields, timezone
|
||||
)
|
||||
|
||||
return paginated_data
|
||||
|
||||
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
||||
def get(self, request, slug, project_id, issue_id, pk=None):
|
||||
if pk:
|
||||
issue_version = IssueVersion.objects.get(
|
||||
workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk
|
||||
)
|
||||
|
||||
serializer = IssueVersionDetailSerializer(issue_version)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
cursor = request.GET.get("cursor", None)
|
||||
|
||||
required_fields = [
|
||||
"id",
|
||||
"workspace",
|
||||
"project",
|
||||
"issue",
|
||||
"last_saved_at",
|
||||
"owned_by",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"created_by",
|
||||
"updated_by",
|
||||
]
|
||||
|
||||
issue_versions_queryset = IssueVersion.objects.filter(
|
||||
workspace__slug=slug, project_id=project_id, issue_id=issue_id
|
||||
)
|
||||
|
||||
paginated_data = paginate(
|
||||
base_queryset=issue_versions_queryset,
|
||||
queryset=issue_versions_queryset,
|
||||
cursor=cursor,
|
||||
on_result=lambda results: self.process_paginated_result(
|
||||
required_fields, results, request.user.user_timezone
|
||||
),
|
||||
)
|
||||
|
||||
return Response(paginated_data, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class IssueDescriptionVersionEndpoint(BaseAPIView):
|
||||
def process_paginated_result(self, fields, results, timezone):
|
||||
paginated_data = results.values(*fields)
|
||||
|
||||
datetime_fields = ["created_at", "updated_at"]
|
||||
paginated_data = user_timezone_converter(
|
||||
paginated_data, datetime_fields, timezone
|
||||
)
|
||||
|
||||
return paginated_data
|
||||
|
||||
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
||||
def get(self, request, slug, project_id, issue_id, pk=None):
|
||||
if pk:
|
||||
issue_description_version = IssueDescriptionVersion.objects.get(
|
||||
workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk
|
||||
)
|
||||
|
||||
serializer = IssueDescriptionVersionDetailSerializer(
|
||||
issue_description_version
|
||||
)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
cursor = request.GET.get("cursor", None)
|
||||
|
||||
required_fields = [
|
||||
"id",
|
||||
"workspace",
|
||||
"project",
|
||||
"issue",
|
||||
"last_saved_at",
|
||||
"owned_by",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"created_by",
|
||||
"updated_by",
|
||||
]
|
||||
|
||||
issue_description_versions_queryset = IssueDescriptionVersion.objects.filter(
|
||||
workspace__slug=slug, project_id=project_id, issue_id=issue_id
|
||||
)
|
||||
paginated_data = paginate(
|
||||
base_queryset=issue_description_versions_queryset,
|
||||
queryset=issue_description_versions_queryset,
|
||||
cursor=cursor,
|
||||
on_result=lambda results: self.process_paginated_result(
|
||||
required_fields, results, request.user.user_timezone
|
||||
),
|
||||
)
|
||||
return Response(paginated_data, status=status.HTTP_200_OK)
|
||||
@@ -54,6 +54,7 @@ from plane.db.models import (
|
||||
ModuleLink,
|
||||
ModuleUserProperties,
|
||||
Project,
|
||||
UserRecentVisit,
|
||||
)
|
||||
from plane.utils.analytics_plot import burndown_plot
|
||||
from plane.utils.timezone_converter import user_timezone_converter
|
||||
@@ -808,6 +809,13 @@ class ModuleViewSet(BaseViewSet):
|
||||
entity_identifier=pk,
|
||||
project_id=project_id,
|
||||
).delete()
|
||||
# delete the module from recent visits
|
||||
UserRecentVisit.objects.filter(
|
||||
project_id=project_id,
|
||||
workspace__slug=slug,
|
||||
entity_identifier=pk,
|
||||
entity_name="module",
|
||||
).delete(soft=False)
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ from plane.db.models import (
|
||||
ProjectMember,
|
||||
ProjectPage,
|
||||
Project,
|
||||
UserRecentVisit,
|
||||
)
|
||||
from plane.utils.error_codes import ERROR_CODES
|
||||
from ..base import BaseAPIView, BaseViewSet
|
||||
@@ -387,6 +388,13 @@ class PageViewSet(BaseViewSet):
|
||||
entity_identifier=pk,
|
||||
entity_type="page",
|
||||
).delete()
|
||||
# Delete the page from recent visit
|
||||
UserRecentVisit.objects.filter(
|
||||
project_id=project_id,
|
||||
workspace__slug=slug,
|
||||
entity_identifier=pk,
|
||||
entity_name="page",
|
||||
).delete(soft=False)
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
|
||||
@@ -53,6 +53,23 @@ class StateViewSet(BaseViewSet):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
||||
def partial_update(self, request, slug, project_id, pk):
|
||||
try:
|
||||
state = State.objects.get(
|
||||
pk=pk, project_id=project_id, workspace__slug=slug
|
||||
)
|
||||
serializer = StateSerializer(state, data=request.data, partial=True)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
except IntegrityError as e:
|
||||
if "already exists" in str(e):
|
||||
return Response(
|
||||
{"name": "The state name is already taken"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
||||
def list(self, request, slug, project_id):
|
||||
|
||||
@@ -24,6 +24,7 @@ from plane.db.models import (
|
||||
ProjectMember,
|
||||
Project,
|
||||
CycleIssue,
|
||||
UserRecentVisit,
|
||||
)
|
||||
from plane.utils.grouper import (
|
||||
issue_group_values,
|
||||
@@ -495,6 +496,13 @@ class IssueViewViewSet(BaseViewSet):
|
||||
entity_identifier=pk,
|
||||
entity_type="view",
|
||||
).delete()
|
||||
# Delete the page from recent visit
|
||||
UserRecentVisit.objects.filter(
|
||||
project_id=project_id,
|
||||
workspace__slug=slug,
|
||||
entity_identifier=pk,
|
||||
entity_name="view",
|
||||
).delete(soft=False)
|
||||
else:
|
||||
return Response(
|
||||
{"error": "Only admin or owner can delete the view"},
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
# Module imports
|
||||
from ..base import BaseAPIView
|
||||
from plane.db.models.workspace import WorkspaceUserPreference
|
||||
from plane.app.serializers.workspace import WorkspaceUserPreferenceSerializer
|
||||
from plane.app.permissions import allow_permission, ROLE
|
||||
from plane.db.models import Workspace
|
||||
|
||||
|
||||
# Third party imports
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
|
||||
|
||||
class WorkspaceUserPreferenceViewSet(BaseAPIView):
|
||||
model = WorkspaceUserPreference
|
||||
|
||||
def get_serializer_class(self):
|
||||
return WorkspaceUserPreferenceSerializer
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")
|
||||
def get(self, request, slug):
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
|
||||
get_preference = WorkspaceUserPreference.objects.filter(
|
||||
user=request.user, workspace_id=workspace.id
|
||||
)
|
||||
|
||||
create_preference_keys = []
|
||||
|
||||
keys = [
|
||||
key
|
||||
for key, _ in WorkspaceUserPreference.UserPreferenceKeys.choices
|
||||
if key not in ["projects"]
|
||||
]
|
||||
|
||||
for preference in keys:
|
||||
if preference not in get_preference.values_list("key", flat=True):
|
||||
create_preference_keys.append(preference)
|
||||
|
||||
preference = WorkspaceUserPreference.objects.bulk_create(
|
||||
[
|
||||
WorkspaceUserPreference(
|
||||
key=key, user=request.user, workspace=workspace
|
||||
)
|
||||
for key in create_preference_keys
|
||||
],
|
||||
batch_size=10,
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
|
||||
preference = WorkspaceUserPreference.objects.filter(
|
||||
user=request.user, workspace_id=workspace.id
|
||||
)
|
||||
|
||||
return Response(
|
||||
preference.values("key", "is_pinned", "sort_order"),
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")
|
||||
def patch(self, request, slug, key):
|
||||
preference = WorkspaceUserPreference.objects.filter(
|
||||
key=key, workspace__slug=slug, user=request.user
|
||||
).first()
|
||||
|
||||
if preference:
|
||||
serializer = WorkspaceUserPreferenceSerializer(
|
||||
preference, data=request.data, partial=True
|
||||
)
|
||||
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
return Response(
|
||||
{"detail": "Preference not found"}, status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
@@ -53,7 +53,6 @@ urlpatterns = [
|
||||
path("magic-generate/", MagicGenerateEndpoint.as_view(), name="magic-generate"),
|
||||
path("magic-sign-in/", MagicSignInEndpoint.as_view(), name="magic-sign-in"),
|
||||
path("magic-sign-up/", MagicSignUpEndpoint.as_view(), name="magic-sign-up"),
|
||||
path("get-csrf-token/", CSRFTokenEndpoint.as_view(), name="get_csrf_token"),
|
||||
path(
|
||||
"spaces/magic-generate/",
|
||||
MagicGenerateSpaceEndpoint.as_view(),
|
||||
|
||||
@@ -69,7 +69,8 @@ from .workspace import (
|
||||
WorkspaceTheme,
|
||||
WorkspaceUserProperties,
|
||||
WorkspaceUserLink,
|
||||
WorkspaceHomePreference
|
||||
WorkspaceHomePreference,
|
||||
WorkspaceUserPreference,
|
||||
)
|
||||
|
||||
from .favorite import UserFavorite
|
||||
|
||||
@@ -388,15 +388,14 @@ class WorkspaceHomePreference(BaseModel):
|
||||
return f"{self.workspace.name} {self.user.email} {self.key}"
|
||||
|
||||
|
||||
|
||||
class WorkspaceUserPreference(BaseModel):
|
||||
"""Preference for the workspace for a user"""
|
||||
|
||||
class UserPreferenceKeys(models.TextChoices):
|
||||
PROJECTS = "projects", "Projects"
|
||||
ANALYTICS = "analytics", "Analytics"
|
||||
CYCLES = "cycles", "Cycles"
|
||||
VIEWS = "views", "Views"
|
||||
ANALYTICS = "analytics", "Analytics"
|
||||
PROJECTS = "projects", "Projects"
|
||||
|
||||
workspace = models.ForeignKey(
|
||||
"db.Workspace",
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
# Python imports
|
||||
import pytz
|
||||
from plane.db.models import Project
|
||||
from datetime import datetime, time
|
||||
from datetime import timedelta
|
||||
|
||||
# Django imports
|
||||
from django.utils import timezone
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import Project
|
||||
|
||||
|
||||
def user_timezone_converter(queryset, datetime_fields, user_timezone):
|
||||
# Create a timezone object for the user's timezone
|
||||
@@ -65,16 +71,27 @@ def convert_to_utc(
|
||||
if is_start_date:
|
||||
localized_datetime += timedelta(minutes=0, seconds=1)
|
||||
|
||||
# If it's start an end date are equal, add 23 hours, 59 minutes, and 59 seconds
|
||||
# to make it the end of the day
|
||||
if is_start_date_end_date_equal:
|
||||
localized_datetime += timedelta(hours=23, minutes=59, seconds=59)
|
||||
# Convert the localized datetime to UTC
|
||||
utc_datetime = localized_datetime.astimezone(pytz.utc)
|
||||
|
||||
# Convert the localized datetime to UTC
|
||||
utc_datetime = localized_datetime.astimezone(pytz.utc)
|
||||
current_datetime_in_project_tz = timezone.now().astimezone(local_tz)
|
||||
current_datetime_in_utc = current_datetime_in_project_tz.astimezone(pytz.utc)
|
||||
|
||||
# Return the UTC datetime for storage
|
||||
return utc_datetime
|
||||
if utc_datetime.date() == current_datetime_in_utc.date():
|
||||
return current_datetime_in_utc
|
||||
|
||||
return utc_datetime
|
||||
else:
|
||||
# If it's start an end date are equal, add 23 hours, 59 minutes, and 59 seconds
|
||||
# to make it the end of the day
|
||||
if is_start_date_end_date_equal:
|
||||
localized_datetime += timedelta(hours=23, minutes=59, seconds=59)
|
||||
|
||||
# Convert the localized datetime to UTC
|
||||
utc_datetime = localized_datetime.astimezone(pytz.utc)
|
||||
|
||||
# Return the UTC datetime for storage
|
||||
return utc_datetime
|
||||
|
||||
|
||||
def convert_utc_to_project_timezone(utc_datetime, project_id):
|
||||
|
||||
@@ -131,17 +131,18 @@ function updateEnvFile() {
|
||||
fi
|
||||
|
||||
if [ -f "$file" ]; then
|
||||
# check if key exists in the file
|
||||
grep -q "^$key=" "$file"
|
||||
if [ $? -ne 0 ]; then
|
||||
# Check if key exists in the file
|
||||
if ! grep -q "^$key=" "$file"; then
|
||||
echo "$key=$value" >> "$file"
|
||||
return
|
||||
else
|
||||
else
|
||||
# Escape special characters in value for sed
|
||||
value=$(printf '%s\n' "$value" | sed -e 's/[\/&]/\\&/g')
|
||||
|
||||
if [ "$OS_NAME" == "Darwin" ]; then
|
||||
value=$(echo "$value" | sed 's/|/\\|/g')
|
||||
sed -i '' "s|^$key=.*|$key=$value|g" "$file"
|
||||
sed -i '' "s|^$key=.*|$key=$value|" "$file"
|
||||
else
|
||||
sed -i "s/^$key=.*/$key=$value/g" "$file"
|
||||
sed -i "s|^$key=.*|$key=$value|" "$file"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
|
||||
@@ -16,6 +16,7 @@ const CollaborativeDocumentEditor = (props: ICollaborativeDocumentEditor) => {
|
||||
const {
|
||||
onTransaction,
|
||||
aiHandler,
|
||||
bubbleMenuEnabled = true,
|
||||
containerClassName,
|
||||
disabledExtensions,
|
||||
displayConfig = DEFAULT_DISPLAY_CONFIG,
|
||||
@@ -75,8 +76,9 @@ const CollaborativeDocumentEditor = (props: ICollaborativeDocumentEditor) => {
|
||||
|
||||
return (
|
||||
<PageRenderer
|
||||
displayConfig={displayConfig}
|
||||
aiHandler={aiHandler}
|
||||
bubbleMenuEnabled={bubbleMenuEnabled}
|
||||
displayConfig={displayConfig}
|
||||
editor={editor}
|
||||
editorContainerClassName={editorContainerClassNames}
|
||||
id={id}
|
||||
|
||||
@@ -15,12 +15,13 @@ import { Editor, ReactRenderer } from "@tiptap/react";
|
||||
// components
|
||||
import { EditorContainer, EditorContentWrapper } from "@/components/editors";
|
||||
import { LinkView, LinkViewProps } from "@/components/links";
|
||||
import { AIFeaturesMenu, BlockMenu } from "@/components/menus";
|
||||
import { AIFeaturesMenu, BlockMenu, EditorBubbleMenu } from "@/components/menus";
|
||||
// types
|
||||
import { TAIHandler, TDisplayConfig } from "@/types";
|
||||
|
||||
type IPageRenderer = {
|
||||
aiHandler?: TAIHandler;
|
||||
bubbleMenuEnabled: boolean;
|
||||
displayConfig: TDisplayConfig;
|
||||
editor: Editor;
|
||||
editorContainerClassName: string;
|
||||
@@ -29,7 +30,7 @@ type IPageRenderer = {
|
||||
};
|
||||
|
||||
export const PageRenderer = (props: IPageRenderer) => {
|
||||
const { aiHandler, displayConfig, editor, editorContainerClassName, id, tabIndex } = props;
|
||||
const { aiHandler, bubbleMenuEnabled, displayConfig, editor, editorContainerClassName, id, tabIndex } = props;
|
||||
// states
|
||||
const [linkViewProps, setLinkViewProps] = useState<LinkViewProps>();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
@@ -141,6 +142,7 @@ export const PageRenderer = (props: IPageRenderer) => {
|
||||
<EditorContentWrapper editor={editor} id={id} tabIndex={tabIndex} />
|
||||
{editor.isEditable && (
|
||||
<div>
|
||||
{bubbleMenuEnabled && <EditorBubbleMenu editor={editor} />}
|
||||
<BlockMenu editor={editor} />
|
||||
<AIFeaturesMenu menu={aiHandler?.menu} />
|
||||
</div>
|
||||
|
||||
@@ -69,6 +69,7 @@ const DocumentReadOnlyEditor = (props: IDocumentReadOnlyEditor) => {
|
||||
|
||||
return (
|
||||
<PageRenderer
|
||||
bubbleMenuEnabled={false}
|
||||
displayConfig={displayConfig}
|
||||
editor={editor}
|
||||
editorContainerClassName={editorContainerClassName}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Dispatch, FC, SetStateAction, useCallback, useEffect, useRef } from "react";
|
||||
import { Editor } from "@tiptap/core";
|
||||
import { Check, Link, Trash } from "lucide-react";
|
||||
import { Check, Link, Trash2 } from "lucide-react";
|
||||
import { Dispatch, FC, SetStateAction, useCallback, useRef, useState } from "react";
|
||||
// plane utils
|
||||
import { cn } from "@plane/utils";
|
||||
// helpers
|
||||
@@ -15,22 +15,26 @@ type Props = {
|
||||
|
||||
export const BubbleMenuLinkSelector: FC<Props> = (props) => {
|
||||
const { editor, isOpen, setIsOpen } = props;
|
||||
// states
|
||||
const [error, setError] = useState(false);
|
||||
// refs
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const onLinkSubmit = useCallback(() => {
|
||||
const handleLinkSubmit = useCallback(() => {
|
||||
const input = inputRef.current;
|
||||
const url = input?.value;
|
||||
if (url && isValidHttpUrl(url)) {
|
||||
if (!input) return;
|
||||
let url = input.value;
|
||||
if (!url) return;
|
||||
if (!url.startsWith("http")) url = `http://${url}`;
|
||||
if (isValidHttpUrl(url)) {
|
||||
setLinkEditor(editor, url);
|
||||
setIsOpen(false);
|
||||
setError(false);
|
||||
} else {
|
||||
setError(true);
|
||||
}
|
||||
}, [editor, inputRef, setIsOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current && inputRef.current?.focus();
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="relative h-full">
|
||||
<button
|
||||
@@ -47,52 +51,62 @@ export const BubbleMenuLinkSelector: FC<Props> = (props) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<span>Link</span>
|
||||
Link
|
||||
<Link className="flex-shrink-0 size-3" />
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div
|
||||
className="dow-xl fixed top-full z-[99999] mt-1 flex w-60 overflow-hidden rounded border border-custom-border-300 bg-custom-background-100 animate-in fade-in slide-in-from-top-1"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
onLinkSubmit();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="url"
|
||||
placeholder="Paste a link"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
className="flex-1 border-r border-custom-border-300 bg-custom-background-100 p-1 text-sm outline-none placeholder:text-custom-text-400"
|
||||
defaultValue={editor.getAttributes("link").href || ""}
|
||||
/>
|
||||
{editor.getAttributes("link").href ? (
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center rounded-sm p-1 text-red-600 transition-all hover:bg-red-100 dark:hover:bg-red-800"
|
||||
onClick={(e) => {
|
||||
unsetLinkEditor(editor);
|
||||
setIsOpen(false);
|
||||
e.stopPropagation();
|
||||
<div className="fixed top-full z-[99999] mt-1 w-60 animate-in fade-in slide-in-from-top-1 rounded bg-custom-background-100 shadow-custom-shadow-rg">
|
||||
<div
|
||||
className={cn("flex rounded border border-custom-border-300 transition-colors", {
|
||||
"border-red-500": error,
|
||||
})}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="url"
|
||||
placeholder="Enter or paste a link"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="flex-1 border-r border-custom-border-300 bg-custom-background-100 py-2 px-1.5 text-xs outline-none placeholder:text-custom-text-400 rounded"
|
||||
defaultValue={editor.getAttributes("link").href || ""}
|
||||
onKeyDown={(e) => {
|
||||
setError(false);
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleLinkSubmit();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash className="h-4 w-4" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="flex items-center rounded-sm p-1 text-custom-text-300 transition-all hover:bg-custom-background-90"
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
onLinkSubmit();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</button>
|
||||
onFocus={() => setError(false)}
|
||||
autoFocus
|
||||
/>
|
||||
{editor.getAttributes("link").href ? (
|
||||
<button
|
||||
type="button"
|
||||
className="grid place-items-center rounded-sm p-1 text-red-500 hover:bg-red-500/20 transition-all"
|
||||
onClick={(e) => {
|
||||
unsetLinkEditor(editor);
|
||||
setIsOpen(false);
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="h-full aspect-square grid place-items-center p-1 rounded-sm text-custom-text-300 hover:bg-custom-background-80 transition-all"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleLinkSubmit();
|
||||
}}
|
||||
>
|
||||
<Check className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{error && (
|
||||
<p className="text-xs text-red-500 my-1 px-2 pointer-events-none animate-in fade-in slide-in-from-top-0">
|
||||
Please enter a valid URL
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -93,6 +93,19 @@ export const CustomColorExtension = Mark.create({
|
||||
};
|
||||
},
|
||||
|
||||
addStorage() {
|
||||
return {
|
||||
markdown: {
|
||||
serialize: {
|
||||
open: "",
|
||||
close: "",
|
||||
mixable: true,
|
||||
expelEnclosingWhitespace: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
|
||||
@@ -134,10 +134,6 @@ const SideMenu = (options: SideMenuPluginProps) => {
|
||||
rect.left -= 8;
|
||||
}
|
||||
|
||||
if (node.parentElement?.matches("td") || node.parentElement?.matches("th")) {
|
||||
rect.left += 8;
|
||||
}
|
||||
|
||||
rect.width = options.dragHandleWidth;
|
||||
|
||||
if (!editorSideMenu) return;
|
||||
|
||||
@@ -39,7 +39,12 @@ export interface TableOptions {
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
table: {
|
||||
insertTable: (options?: { rows?: number; cols?: number; withHeaderRow?: boolean }) => ReturnType;
|
||||
insertTable: (options?: {
|
||||
rows?: number;
|
||||
cols?: number;
|
||||
withHeaderRow?: boolean;
|
||||
columnWidth?: number;
|
||||
}) => ReturnType;
|
||||
addColumnBefore: () => ReturnType;
|
||||
addColumnAfter: () => ReturnType;
|
||||
deleteColumn: () => ReturnType;
|
||||
@@ -108,9 +113,9 @@ export const Table = Node.create({
|
||||
addCommands() {
|
||||
return {
|
||||
insertTable:
|
||||
({ rows = 3, cols = 3, withHeaderRow = false } = {}) =>
|
||||
({ rows = 3, cols = 3, withHeaderRow = false, columnWidth = 150 } = {}) =>
|
||||
({ tr, dispatch, editor }) => {
|
||||
const node = createTable(editor.schema, rows, cols, withHeaderRow);
|
||||
const node = createTable(editor.schema, rows, cols, withHeaderRow, undefined, columnWidth);
|
||||
if (dispatch) {
|
||||
const offset = tr.selection.anchor + 1;
|
||||
|
||||
|
||||
@@ -2,11 +2,12 @@ import { Fragment, Node as ProsemirrorNode, NodeType } from "@tiptap/pm/model";
|
||||
|
||||
export function createCell(
|
||||
cellType: NodeType,
|
||||
cellContent?: Fragment | ProsemirrorNode | Array<ProsemirrorNode>
|
||||
cellContent?: Fragment | ProsemirrorNode | Array<ProsemirrorNode>,
|
||||
attrs?: Record<string, any>
|
||||
): ProsemirrorNode | null | undefined {
|
||||
if (cellContent) {
|
||||
return cellType.createChecked(null, cellContent);
|
||||
return cellType.createChecked(attrs, cellContent);
|
||||
}
|
||||
|
||||
return cellType.createAndFill();
|
||||
return cellType.createAndFill(attrs);
|
||||
}
|
||||
|
||||
@@ -8,21 +8,22 @@ export function createTable(
|
||||
rowsCount: number,
|
||||
colsCount: number,
|
||||
withHeaderRow: boolean,
|
||||
cellContent?: Fragment | ProsemirrorNode | Array<ProsemirrorNode>
|
||||
cellContent?: Fragment | ProsemirrorNode | Array<ProsemirrorNode>,
|
||||
columnWidth: number = 100
|
||||
): ProsemirrorNode {
|
||||
const types = getTableNodeTypes(schema);
|
||||
const headerCells: ProsemirrorNode[] = [];
|
||||
const cells: ProsemirrorNode[] = [];
|
||||
|
||||
for (let index = 0; index < colsCount; index += 1) {
|
||||
const cell = createCell(types.cell, cellContent);
|
||||
const cell = createCell(types.cell, cellContent, { colwidth: [columnWidth] });
|
||||
|
||||
if (cell) {
|
||||
cells.push(cell);
|
||||
}
|
||||
|
||||
if (withHeaderRow) {
|
||||
const headerCell = createCell(types.header_cell, cellContent);
|
||||
const headerCell = createCell(types.header_cell, cellContent, { colwidth: [columnWidth] });
|
||||
|
||||
if (headerCell) {
|
||||
headerCells.push(headerCell);
|
||||
|
||||
@@ -138,8 +138,9 @@ export const insertTableCommand = (editor: Editor, range?: Range) => {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (range) editor.chain().focus().deleteRange(range).clearNodes().insertTable({ rows: 3, cols: 3 }).run();
|
||||
else editor.chain().focus().clearNodes().insertTable({ rows: 3, cols: 3 }).run();
|
||||
if (range)
|
||||
editor.chain().focus().deleteRange(range).clearNodes().insertTable({ rows: 3, cols: 3, columnWidth: 150 }).run();
|
||||
else editor.chain().focus().clearNodes().insertTable({ rows: 3, cols: 3, columnWidth: 150 }).run();
|
||||
};
|
||||
|
||||
export const insertImage = ({
|
||||
|
||||
@@ -88,16 +88,18 @@ export const nodeDOMAtCoords = (coords: { x: number; y: number }) => {
|
||||
const elements = document.elementsFromPoint(coords.x, coords.y);
|
||||
|
||||
for (const elem of elements) {
|
||||
// Check for table wrapper first
|
||||
if (elem.matches(".table-wrapper")) {
|
||||
return elem;
|
||||
}
|
||||
|
||||
if (elem.matches("p:first-child") && elem.parentElement?.matches(".ProseMirror")) {
|
||||
return elem;
|
||||
}
|
||||
|
||||
// if the element is a <p> tag that is the first child of a td or th
|
||||
if (
|
||||
(elem.matches("td > p:first-child") || elem.matches("th > p:first-child")) &&
|
||||
elem?.textContent?.trim() !== ""
|
||||
) {
|
||||
return elem; // Return only if p tag is not empty in td or th
|
||||
// Skip table cells
|
||||
if (elem.closest(".table-wrapper")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// apply general selector
|
||||
|
||||
@@ -138,8 +138,9 @@ export interface IRichTextEditor extends IEditorProps {
|
||||
|
||||
export interface ICollaborativeDocumentEditor
|
||||
extends Omit<IEditorProps, "initialValue" | "onChange" | "onEnterKeyPress" | "value"> {
|
||||
editable: boolean;
|
||||
aiHandler?: TAIHandler;
|
||||
bubbleMenuEnabled?: boolean;
|
||||
editable: boolean;
|
||||
embedHandler: TEmbedConfig;
|
||||
handleEditorReady?: (value: boolean) => void;
|
||||
id: string;
|
||||
|
||||
@@ -105,14 +105,14 @@ ul[data-type="taskList"] li > div {
|
||||
}
|
||||
|
||||
ul[data-type="taskList"] li > label input[type="checkbox"] {
|
||||
border: 1px solid rgba(var(--color-border-300)) !important;
|
||||
border: 1px solid rgba(var(--color-text-100), 0.2) !important;
|
||||
outline: none;
|
||||
border-radius: 2px;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.ProseMirror[contenteditable="true"] input[type="checkbox"]:hover {
|
||||
background-color: rgba(var(--color-background-80));
|
||||
background-color: rgba(var(--color-text-100), 0.1);
|
||||
}
|
||||
|
||||
.ProseMirror[contenteditable="false"] input[type="checkbox"] {
|
||||
@@ -408,12 +408,14 @@ p.editor-paragraph-block {
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
&:not(td p.editor-paragraph-block, th p.editor-paragraph-block) {
|
||||
&:last-child {
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
&:not(:last-child) {
|
||||
padding-bottom: 8px;
|
||||
&:not(:last-child) {
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
font-size: var(--font-size-regular);
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
.table-wrapper table th {
|
||||
min-width: 1em;
|
||||
border: 1px solid rgba(var(--color-border-200));
|
||||
padding: 10px 20px;
|
||||
padding: 7px 10px;
|
||||
vertical-align: top;
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
@@ -48,7 +48,7 @@
|
||||
/* table dropdown */
|
||||
.table-wrapper table .column-resize-handle {
|
||||
position: absolute;
|
||||
right: -2px;
|
||||
right: 0;
|
||||
top: 0;
|
||||
width: 2px;
|
||||
height: 100%;
|
||||
|
||||
Vendored
+4
-4
@@ -1,7 +1,7 @@
|
||||
import { TLogoProps } from "./common";
|
||||
import { TIssuePriorities } from "./issues";
|
||||
|
||||
export type TRecentActivityFilterKeys = "all item" | "issue" | "page" | "project";
|
||||
export type TRecentActivityFilterKeys = "all item" | "issue" | "page" | "project" | "workspace_page";
|
||||
export type THomeWidgetKeys = "quick_links" | "recents" | "my_stickies" | "quick_tutorial" | "new_at_plane";
|
||||
|
||||
export type THomeWidgetProps = {
|
||||
@@ -12,9 +12,9 @@ export type TPageEntityData = {
|
||||
id: string;
|
||||
name: string;
|
||||
logo_props: TLogoProps;
|
||||
project_id: string;
|
||||
project_id?: string;
|
||||
owned_by: string;
|
||||
project_identifier: string;
|
||||
project_identifier?: string;
|
||||
};
|
||||
|
||||
export type TProjectEntityData = {
|
||||
@@ -39,7 +39,7 @@ export type TIssueEntityData = {
|
||||
|
||||
export type TActivityEntityData = {
|
||||
id: string;
|
||||
entity_name: "page" | "project" | "issue";
|
||||
entity_name: "page" | "project" | "issue" | "workspace_page";
|
||||
entity_identifier: string;
|
||||
visited_at: string;
|
||||
entity_data: TPageEntityData | TProjectEntityData | TIssueEntityData;
|
||||
|
||||
@@ -300,6 +300,53 @@
|
||||
--color-sidebar-border-300: var(--color-border-300); /* strong sidebar border- 1 */
|
||||
--color-sidebar-border-400: var(--color-border-400); /* strong sidebar border- 2 */
|
||||
}
|
||||
|
||||
/* stickies and editor colors */
|
||||
:root {
|
||||
/* text colors */
|
||||
--editor-colors-gray-text: #5c5e63;
|
||||
--editor-colors-peach-text: #ff5b59;
|
||||
--editor-colors-pink-text: #f65385;
|
||||
--editor-colors-orange-text: #fd9038;
|
||||
--editor-colors-green-text: #0fc27b;
|
||||
--editor-colors-light-blue-text: #17bee9;
|
||||
--editor-colors-dark-blue-text: #266df0;
|
||||
--editor-colors-purple-text: #9162f9;
|
||||
/* end text colors */
|
||||
|
||||
/* background colors */
|
||||
--editor-colors-gray-background: #d6d6d8;
|
||||
--editor-colors-peach-background: #ffd5d7;
|
||||
--editor-colors-pink-background: #fdd4e3;
|
||||
--editor-colors-orange-background: #ffe3cd;
|
||||
--editor-colors-green-background: #c3f0de;
|
||||
--editor-colors-light-blue-background: #c5eff9;
|
||||
--editor-colors-dark-blue-background: #c9dafb;
|
||||
--editor-colors-purple-background: #e3d8fd;
|
||||
/* end background colors */
|
||||
}
|
||||
/* background colors */
|
||||
[data-theme*="light"] {
|
||||
--editor-colors-gray-background: #d6d6d8;
|
||||
--editor-colors-peach-background: #ffd5d7;
|
||||
--editor-colors-pink-background: #fdd4e3;
|
||||
--editor-colors-orange-background: #ffe3cd;
|
||||
--editor-colors-green-background: #c3f0de;
|
||||
--editor-colors-light-blue-background: #c5eff9;
|
||||
--editor-colors-dark-blue-background: #c9dafb;
|
||||
--editor-colors-purple-background: #e3d8fd;
|
||||
}
|
||||
[data-theme*="dark"] {
|
||||
--editor-colors-gray-background: #404144;
|
||||
--editor-colors-peach-background: #593032;
|
||||
--editor-colors-pink-background: #562e3d;
|
||||
--editor-colors-orange-background: #583e2a;
|
||||
--editor-colors-green-background: #1d4a3b;
|
||||
--editor-colors-light-blue-background: #1f495c;
|
||||
--editor-colors-dark-blue-background: #223558;
|
||||
--editor-colors-purple-background: #3d325a;
|
||||
}
|
||||
/* end background colors */
|
||||
}
|
||||
|
||||
* {
|
||||
@@ -355,7 +402,6 @@ body {
|
||||
-webkit-background-clip: text;
|
||||
}
|
||||
|
||||
|
||||
@-moz-document url-prefix() {
|
||||
* {
|
||||
scrollbar-width: none;
|
||||
|
||||
@@ -241,8 +241,8 @@ export const CustomTreeMapContent: React.FC<any> = ({
|
||||
y={pY + pHeight - LAYOUT.TEXT.PADDING_LEFT}
|
||||
textAnchor="start"
|
||||
className={cn(
|
||||
"text-xs font-extralight tracking-wider select-none",
|
||||
textClassName || "text-custom-text-400"
|
||||
"text-sm font-extralight tracking-wider select-none",
|
||||
textClassName || "text-custom-text-300"
|
||||
)}
|
||||
fill="currentColor"
|
||||
>
|
||||
@@ -252,8 +252,8 @@ export const CustomTreeMapContent: React.FC<any> = ({
|
||||
{bottom.labelTruncated
|
||||
? truncateText(
|
||||
label,
|
||||
availableTextWidth - calculateContentWidth(value, LAYOUT.TEXT.FONT_SIZES.XS) - 4,
|
||||
LAYOUT.TEXT.FONT_SIZES.XS
|
||||
availableTextWidth - calculateContentWidth(value, LAYOUT.TEXT.FONT_SIZES.SM) - 4,
|
||||
LAYOUT.TEXT.FONT_SIZES.SM
|
||||
)
|
||||
: label}
|
||||
</tspan>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
import { Fragment } from "react";
|
||||
|
||||
import { DayPicker } from "react-day-picker";
|
||||
import { DayPicker, getDefaultClassNames } from "react-day-picker";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
|
||||
import { X } from "lucide-react";
|
||||
@@ -31,6 +31,8 @@ const defaultValues: TFormValues = {
|
||||
date2: new Date(new Date().getFullYear(), new Date().getMonth() + 1, new Date().getDate()),
|
||||
};
|
||||
|
||||
const defaultClassNames = getDefaultClassNames();
|
||||
|
||||
export const DateFilterModal: React.FC<Props> = ({ title, handleClose, isOpen, onSelect }) => {
|
||||
const { handleSubmit, watch, control } = useForm<TFormValues>({
|
||||
defaultValues,
|
||||
@@ -97,6 +99,10 @@ export const DateFilterModal: React.FC<Props> = ({ title, handleClose, isOpen, o
|
||||
const date2Value = getDate(watch("date2"));
|
||||
return (
|
||||
<DayPicker
|
||||
classNames={{
|
||||
root: `${defaultClassNames.root} border border-custom-border-200 p-3 rounded-md`,
|
||||
}}
|
||||
captionLayout="dropdown"
|
||||
selected={dateValue}
|
||||
defaultMonth={dateValue}
|
||||
onSelect={(date) => {
|
||||
@@ -105,7 +111,6 @@ export const DateFilterModal: React.FC<Props> = ({ title, handleClose, isOpen, o
|
||||
}}
|
||||
mode="single"
|
||||
disabled={date2Value ? [{ after: date2Value }] : undefined}
|
||||
className="border border-custom-border-200 p-3 rounded-md"
|
||||
/>
|
||||
);
|
||||
}}
|
||||
@@ -119,6 +124,10 @@ export const DateFilterModal: React.FC<Props> = ({ title, handleClose, isOpen, o
|
||||
const date1Value = getDate(watch("date1"));
|
||||
return (
|
||||
<DayPicker
|
||||
classNames={{
|
||||
root: `${defaultClassNames.root} border border-custom-border-200 p-3 rounded-md`,
|
||||
}}
|
||||
captionLayout="dropdown"
|
||||
selected={dateValue}
|
||||
defaultMonth={dateValue}
|
||||
onSelect={(date) => {
|
||||
@@ -127,7 +136,6 @@ export const DateFilterModal: React.FC<Props> = ({ title, handleClose, isOpen, o
|
||||
}}
|
||||
mode="single"
|
||||
disabled={date1Value ? [{ before: date1Value }] : undefined}
|
||||
className="border border-custom-border-200 p-3 rounded-md"
|
||||
/>
|
||||
);
|
||||
}}
|
||||
|
||||
@@ -21,8 +21,8 @@ export const SingleProgressStats: React.FC<TSingleProgressStatsProps> = ({
|
||||
} ${selected ? "bg-custom-background-90" : ""}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="w-1/2">{title}</div>
|
||||
<div className="flex w-1/2 items-center justify-end gap-1 px-2">
|
||||
<div className="w-4/6">{title}</div>
|
||||
<div className="flex w-2/6 items-center justify-end gap-1 px-2">
|
||||
<div className="flex h-5 items-center justify-center gap-1">
|
||||
<span className="w-8 text-right">
|
||||
{isNaN(Math.round((completed / total) * 100)) ? "0" : Math.round((completed / total) * 100)}%
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { Placement } from "@popperjs/core";
|
||||
import { DateRange, DayPicker, Matcher } from "react-day-picker";
|
||||
import { DateRange, DayPicker, Matcher, getDefaultClassNames } from "react-day-picker";
|
||||
import { usePopper } from "react-popper";
|
||||
import { ArrowRight, CalendarCheck2, CalendarDays } from "lucide-react";
|
||||
import { Combobox } from "@headlessui/react";
|
||||
@@ -52,6 +52,8 @@ type Props = {
|
||||
renderPlaceholder?: boolean;
|
||||
};
|
||||
|
||||
const defaultClassNames = getDefaultClassNames();
|
||||
|
||||
export const DateRangeDropdown: React.FC<Props> = (props) => {
|
||||
const {
|
||||
applyButtonText = "Apply changes",
|
||||
@@ -198,12 +200,14 @@ export const DateRangeDropdown: React.FC<Props> = (props) => {
|
||||
{isOpen && (
|
||||
<Combobox.Options className="fixed z-10" static>
|
||||
<div
|
||||
className="my-1 bg-custom-background-100 shadow-custom-shadow-rg rounded-md overflow-hidden p-3"
|
||||
className="my-1 bg-custom-background-100 shadow-custom-shadow-rg overflow-hidden"
|
||||
ref={setPopperElement}
|
||||
style={styles.popper}
|
||||
{...attributes.popper}
|
||||
>
|
||||
<DayPicker
|
||||
captionLayout="dropdown"
|
||||
classNames={{ root: `${defaultClassNames.root} p-3 rounded-md` }}
|
||||
selected={dateRange}
|
||||
onSelect={(val) => {
|
||||
// if both the dates are not required, immediately call onSelect
|
||||
@@ -216,7 +220,8 @@ export const DateRangeDropdown: React.FC<Props> = (props) => {
|
||||
mode="range"
|
||||
disabled={disabledDays}
|
||||
showOutsideDays
|
||||
initialFocus
|
||||
autoFocus
|
||||
fixedWeeks
|
||||
footer={
|
||||
bothRequired && (
|
||||
<div className="grid grid-cols-2 items-center gap-3.5 pt-6 relative">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useRef, useState } from "react";
|
||||
import { DayPicker, Matcher } from "react-day-picker";
|
||||
import { DayPicker, Matcher, getDefaultClassNames } from "react-day-picker";
|
||||
import { createPortal } from "react-dom";
|
||||
import { usePopper } from "react-popper";
|
||||
import { CalendarDays, X } from "lucide-react";
|
||||
@@ -33,6 +33,8 @@ type Props = TDropdownProps & {
|
||||
renderByDefault?: boolean;
|
||||
};
|
||||
|
||||
const defaultClassNames = getDefaultClassNames();
|
||||
|
||||
export const DateDropdown: React.FC<Props> = (props) => {
|
||||
const {
|
||||
buttonClassName = "",
|
||||
@@ -166,7 +168,7 @@ export const DateDropdown: React.FC<Props> = (props) => {
|
||||
<Combobox.Options data-prevent-outside-click static>
|
||||
<div
|
||||
className={cn(
|
||||
"my-1 bg-custom-background-100 shadow-custom-shadow-rg rounded-md overflow-hidden p-3 z-20",
|
||||
"my-1 bg-custom-background-100 shadow-custom-shadow-rg overflow-hidden z-20",
|
||||
optionsClassName
|
||||
)}
|
||||
ref={setPopperElement}
|
||||
@@ -174,15 +176,18 @@ export const DateDropdown: React.FC<Props> = (props) => {
|
||||
{...attributes.popper}
|
||||
>
|
||||
<DayPicker
|
||||
captionLayout="dropdown"
|
||||
classNames={{ root: `${defaultClassNames.root} p-3 rounded-md` }}
|
||||
selected={getDate(value)}
|
||||
defaultMonth={getDate(value)}
|
||||
onSelect={(date) => {
|
||||
dropdownOnChange(date ?? null);
|
||||
}}
|
||||
showOutsideDays
|
||||
initialFocus
|
||||
autoFocus
|
||||
disabled={disabledDays}
|
||||
mode="single"
|
||||
fixedWeeks
|
||||
/>
|
||||
</div>
|
||||
</Combobox.Options>,
|
||||
|
||||
@@ -57,7 +57,6 @@ export const IssueCommentToolbar: React.FC<Props> = (props) => {
|
||||
showSubmitButton,
|
||||
editorRef,
|
||||
} = props;
|
||||
|
||||
// State to manage active states of toolbar items
|
||||
const [activeStates, setActiveStates] = useState<Record<string, boolean>>({});
|
||||
|
||||
@@ -86,6 +85,9 @@ export const IssueCommentToolbar: React.FC<Props> = (props) => {
|
||||
return () => unsubscribe();
|
||||
}, [editorRef, updateActiveStates]);
|
||||
|
||||
const isEditorReadyToDiscard = editorRef?.isEditorReadyToDiscard();
|
||||
const isSubmitButtonDisabled = isCommentEmpty || !isEditorReadyToDiscard;
|
||||
|
||||
return (
|
||||
<div className="flex h-9 w-full items-stretch gap-1.5 bg-custom-background-90 overflow-x-scroll">
|
||||
{showAccessSpecifier && (
|
||||
@@ -166,7 +168,7 @@ export const IssueCommentToolbar: React.FC<Props> = (props) => {
|
||||
variant="primary"
|
||||
className="px-2.5 py-1.5 text-xs"
|
||||
onClick={handleSubmit}
|
||||
disabled={isCommentEmpty}
|
||||
disabled={isSubmitButtonDisabled}
|
||||
loading={isSubmitting}
|
||||
>
|
||||
Comment
|
||||
|
||||
@@ -8,7 +8,7 @@ export const STICKY_COLORS_LIST: {
|
||||
{
|
||||
key: "gray",
|
||||
label: "Gray",
|
||||
backgroundColor: "var(--editor-colors-gray-background)",
|
||||
backgroundColor: "rgba(var(--color-background-90))",
|
||||
},
|
||||
{
|
||||
key: "peach",
|
||||
|
||||
@@ -3,16 +3,15 @@ import React, { useState } from "react";
|
||||
import { EIssueCommentAccessSpecifier } from "@plane/constants";
|
||||
// plane editor
|
||||
import { EditorRefApi, ILiteTextEditor, LiteTextEditorWithRef } from "@plane/editor";
|
||||
// components
|
||||
// plane types
|
||||
import { TSticky } from "@plane/types";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
import { getEditorFileHandlers } from "@/helpers/editor.helper";
|
||||
// hooks
|
||||
// plane web hooks
|
||||
import { useEditorFlagging } from "@/plane-web/hooks/use-editor-flagging";
|
||||
import { useFileSize } from "@/plane-web/hooks/use-file-size";
|
||||
import { Toolbar } from "./toolbar";
|
||||
import { StickyEditorToolbar } from "./toolbar";
|
||||
|
||||
interface StickyEditorWrapperProps
|
||||
extends Omit<ILiteTextEditor, "disabledExtensions" | "fileHandler" | "mentionHandler"> {
|
||||
@@ -43,7 +42,6 @@ export const StickyEditor = React.forwardRef<EditorRefApi, StickyEditorWrapperPr
|
||||
showToolbarInitially = true,
|
||||
showToolbar = true,
|
||||
parentClassName = "",
|
||||
placeholder = "Add comment...",
|
||||
uploadFile,
|
||||
...rest
|
||||
} = props;
|
||||
@@ -61,7 +59,7 @@ export const StickyEditor = React.forwardRef<EditorRefApi, StickyEditorWrapperPr
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("relative border border-custom-border-200 rounded p-3", parentClassName)}
|
||||
className={cn("relative border border-custom-border-200 rounded", parentClassName)}
|
||||
onFocus={() => !showToolbarInitially && setIsFocused(true)}
|
||||
onBlur={() => !showToolbarInitially && setIsFocused(false)}
|
||||
>
|
||||
@@ -78,18 +76,17 @@ export const StickyEditor = React.forwardRef<EditorRefApi, StickyEditorWrapperPr
|
||||
mentionHandler={{
|
||||
renderComponent: () => <></>,
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
containerClassName={cn(containerClassName, "relative")}
|
||||
{...rest}
|
||||
/>
|
||||
{showToolbar && (
|
||||
<div
|
||||
className={cn(
|
||||
"transition-all duration-300 ease-out origin-top",
|
||||
isFocused ? "max-h-[200px] opacity-100 scale-y-100 mt-3" : "max-h-0 opacity-0 scale-y-0 invisible"
|
||||
)}
|
||||
className={cn("transition-all duration-300 ease-out origin-top px-4 h-[60px]", {
|
||||
"max-h-[60px] opacity-100 scale-y-100": isFocused,
|
||||
"max-h-0 opacity-0 scale-y-0 invisible": !isFocused,
|
||||
})}
|
||||
>
|
||||
<Toolbar
|
||||
<StickyEditorToolbar
|
||||
executeCommand={(item) => {
|
||||
// TODO: update this while toolbar homogenization
|
||||
// @ts-expect-error type mismatch here
|
||||
|
||||
@@ -23,7 +23,7 @@ type Props = {
|
||||
|
||||
const toolbarItems = TOOLBAR_ITEMS.sticky;
|
||||
|
||||
export const Toolbar: React.FC<Props> = (props) => {
|
||||
export const StickyEditorToolbar: React.FC<Props> = (props) => {
|
||||
const { executeCommand, editorRef, handleColorChange, handleDelete } = props;
|
||||
|
||||
// State to manage active states of toolbar items
|
||||
@@ -58,7 +58,7 @@ export const Toolbar: React.FC<Props> = (props) => {
|
||||
useOutsideClickDetector(colorPaletteRef, () => setShowColorPalette(false));
|
||||
|
||||
return (
|
||||
<div className="flex w-full justify-between mt-2 h-full">
|
||||
<div className="flex w-full justify-between h-full">
|
||||
<div className="flex my-auto gap-4" ref={colorPaletteRef}>
|
||||
{/* color palette */}
|
||||
{showColorPalette && <ColorPalette handleUpdate={handleColorChange} />}
|
||||
@@ -69,7 +69,11 @@ export const Toolbar: React.FC<Props> = (props) => {
|
||||
</p>
|
||||
}
|
||||
>
|
||||
<button onClick={() => setShowColorPalette(!showColorPalette)} className="flex text-custom-text-300">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowColorPalette(!showColorPalette)}
|
||||
className="flex text-custom-text-100/50"
|
||||
>
|
||||
<Palette className="size-4 my-auto" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
@@ -95,7 +99,7 @@ export const Toolbar: React.FC<Props> = (props) => {
|
||||
type="button"
|
||||
onClick={() => executeCommand(item)}
|
||||
className={cn(
|
||||
"grid place-items-center aspect-square rounded-sm p-0.5 text-custom-text-300",
|
||||
"grid place-items-center aspect-square rounded-sm p-0.5 text-custom-text-100/50",
|
||||
{}
|
||||
)}
|
||||
>
|
||||
@@ -122,7 +126,7 @@ export const Toolbar: React.FC<Props> = (props) => {
|
||||
</p>
|
||||
}
|
||||
>
|
||||
<button onClick={handleDelete} className="my-auto text-custom-text-300">
|
||||
<button type="button" onClick={handleDelete} className="my-auto text-custom-text-100/50">
|
||||
<Trash2 className="size-4" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
@@ -25,7 +25,7 @@ export const HOME_WIDGETS_LIST: {
|
||||
quick_links: {
|
||||
component: DashboardQuickLinks,
|
||||
fullWidth: false,
|
||||
title: "Quick links",
|
||||
title: "Quicklinks",
|
||||
},
|
||||
recents: {
|
||||
component: RecentActivityWidget,
|
||||
|
||||
@@ -1 +1,4 @@
|
||||
export * from "./root";
|
||||
export * from "./links";
|
||||
export * from "./no-projects";
|
||||
export * from "./recents";
|
||||
export * from "./stickies";
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { Link2 } from "lucide-react";
|
||||
|
||||
export const LinksEmptyState = () => (
|
||||
<div className="min-h-[110px] flex w-full justify-center py-6 bg-custom-border-100 rounded">
|
||||
<div className="m-auto flex gap-2">
|
||||
<Link2 size={30} className="text-custom-text-400/40 -rotate-45" />
|
||||
<div className="text-custom-text-400 text-sm text-center my-auto">
|
||||
Add any links you need for quick access to your work.
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-h-[110px] w-full flex items-center justify-center gap-2 py-6 bg-custom-background-90 text-custom-text-400 rounded">
|
||||
<div className="flex-shrink-0 size-[30px] grid place-items-center">
|
||||
<Link2 className="size-6 -rotate-45" />
|
||||
</div>
|
||||
);
|
||||
<p className="text-sm text-center font-medium">Save links to work things that you{"'"}d like handy.</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
+33
-40
@@ -2,6 +2,8 @@ import React from "react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { Briefcase, Hotel, Users } from "lucide-react";
|
||||
// plane ui
|
||||
import { Avatar } from "@plane/ui";
|
||||
// helpers
|
||||
import { getFileURL } from "@/helpers/file.helper";
|
||||
// hooks
|
||||
@@ -9,7 +11,7 @@ import { useCommandPalette, useEventTracker, useUser, useUserPermissions } from
|
||||
// plane web constants
|
||||
import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants";
|
||||
|
||||
export const EmptyWorkspace = () => {
|
||||
export const NoProjectsEmptyState = () => {
|
||||
// navigation
|
||||
const { workspaceSlug } = useParams();
|
||||
// store hooks
|
||||
@@ -26,11 +28,11 @@ export const EmptyWorkspace = () => {
|
||||
const EMPTY_STATE_DATA = [
|
||||
{
|
||||
id: "create-project",
|
||||
title: "Create a project",
|
||||
description: "Create your first project now to get started",
|
||||
icon: <Briefcase className="w-[40px] h-[40px] text-custom-primary-100" />,
|
||||
title: "Create a project.",
|
||||
description: "Most things start with a project in Plane.",
|
||||
icon: <Briefcase className="size-12 text-custom-primary-100" />,
|
||||
cta: {
|
||||
text: "Create Project",
|
||||
text: "Get started",
|
||||
onClick: (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
|
||||
if (!canCreateProject) return;
|
||||
e.preventDefault();
|
||||
@@ -42,66 +44,56 @@ export const EmptyWorkspace = () => {
|
||||
},
|
||||
{
|
||||
id: "invite-team",
|
||||
title: "Invite your team",
|
||||
description: "The sub text will be of two lines and that will be placed.",
|
||||
icon: <Users className="w-[40px] h-[40px] text-custom-primary-100" />,
|
||||
title: "Invite your team.",
|
||||
description: "Build, ship, and manage with coworkers.",
|
||||
icon: <Users className="size-12 text-custom-primary-100" />,
|
||||
cta: {
|
||||
text: "Invite now",
|
||||
text: "Get them in",
|
||||
link: `/${workspaceSlug}/settings/members`,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "configure-workspace",
|
||||
title: "Configure your workspace",
|
||||
description: "The sub text will be of two lines and that will be placed.",
|
||||
icon: <Hotel className="w-[40px] h-[40px] text-custom-primary-100" />,
|
||||
title: "Set up your workspace.",
|
||||
description: "Turn features on or off or go beyond that.",
|
||||
icon: <Hotel className="size-12 text-custom-primary-100" />,
|
||||
cta: {
|
||||
text: "Configure workspace",
|
||||
text: "Configure this workspace",
|
||||
link: "settings",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "personalize-account",
|
||||
title: "Personalize your account",
|
||||
description: "The sub text will be of two lines and that will be placed.",
|
||||
icon:
|
||||
currentUser?.avatar_url && currentUser?.avatar_url.trim() !== "" ? (
|
||||
<Link href={`/${workspaceSlug}/profile/${currentUser?.id}`}>
|
||||
<span className="relative flex h-6 w-6 items-center justify-center rounded-full p-4 capitalize text-white">
|
||||
<img
|
||||
src={getFileURL(currentUser?.avatar_url)}
|
||||
className="absolute left-0 top-0 h-full w-full rounded-full object-cover"
|
||||
alt={currentUser?.display_name || currentUser?.email}
|
||||
/>
|
||||
</span>
|
||||
</Link>
|
||||
) : (
|
||||
<Link href={`/${workspaceSlug}/profile/${currentUser?.id}`}>
|
||||
<span className="relative flex h-6 w-6 items-center justify-center rounded-full bg-gray-700 p-4 capitalize text-white text-sm">
|
||||
{(currentUser?.email ?? currentUser?.display_name ?? "?")[0]}
|
||||
</span>
|
||||
</Link>
|
||||
),
|
||||
title: "Make Plane yours.",
|
||||
description: "Choose your picture, colors, and more.",
|
||||
icon: (
|
||||
<Avatar
|
||||
src={getFileURL(currentUser?.avatar_url ?? "")}
|
||||
name={currentUser?.display_name}
|
||||
size={48}
|
||||
className="text-xl"
|
||||
showTooltip={false}
|
||||
/>
|
||||
),
|
||||
cta: {
|
||||
text: "Personalize account",
|
||||
text: "Personalize now",
|
||||
link: "/profile",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4">
|
||||
{EMPTY_STATE_DATA.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex flex-col items-center justify-center py-8 bg-custom-background-100 rounded-lg text-center border border-custom-border-200/40"
|
||||
className="flex flex-col items-center justify-center p-6 bg-custom-background-100 rounded-lg text-center border border-custom-border-200/40"
|
||||
>
|
||||
<div className="flex items-center justify-center bg-custom-primary-100/10 rounded-full w-[80px] h-[80px] mb-4">
|
||||
<div className="grid place-items-center bg-custom-primary-100/10 rounded-full size-24 mb-3">
|
||||
<span className="text-3xl my-auto">{item.icon}</span>
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-custom-text-100 mb-2">{item.title}</h3>
|
||||
<p className="text-sm text-custom-text-200 mb-4 w-[80%] flex-1">{item.description}</p>
|
||||
|
||||
<h3 className="text-base font-medium text-custom-text-100 mb-2">{item.title}</h3>
|
||||
<p className="text-sm text-custom-text-300 mb-2">{item.description}</p>
|
||||
{item.cta.link ? (
|
||||
<Link
|
||||
href={item.cta.link}
|
||||
@@ -111,6 +103,7 @@ export const EmptyWorkspace = () => {
|
||||
</Link>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="text-custom-primary-100 hover:text-custom-primary-200 text-sm font-medium"
|
||||
onClick={item.cta.onClick}
|
||||
>
|
||||
@@ -1,38 +1,41 @@
|
||||
import { Briefcase, FileText, History } from "lucide-react";
|
||||
// plane ui
|
||||
import { LayersIcon } from "@plane/ui";
|
||||
|
||||
const getDisplayContent = (type: string) => {
|
||||
switch (type) {
|
||||
case "project":
|
||||
return {
|
||||
icon: Briefcase,
|
||||
text: "Projects you go into or have assigned work in will show up here.",
|
||||
};
|
||||
case "page":
|
||||
return {
|
||||
icon: FileText,
|
||||
text: "Create, see, or change something on pages you have access to and see them here.",
|
||||
};
|
||||
case "issue":
|
||||
return {
|
||||
icon: LayersIcon,
|
||||
text: "Let's see some issues to see them show up here.",
|
||||
};
|
||||
default:
|
||||
return {
|
||||
icon: History,
|
||||
text: "Whatever you see and act on in Plane will show up here.",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const RecentsEmptyState = ({ type }: { type: string }) => {
|
||||
const getDisplayContent = () => {
|
||||
switch (type) {
|
||||
case "project":
|
||||
return {
|
||||
icon: <Briefcase size={30} className="text-custom-text-400/40" />,
|
||||
text: "Your recent projects will appear here once you visit one.",
|
||||
};
|
||||
case "page":
|
||||
return {
|
||||
icon: <FileText size={30} className="text-custom-text-400/40" />,
|
||||
text: "Your recent pages will appear here once you visit one.",
|
||||
};
|
||||
case "issue":
|
||||
return {
|
||||
icon: <LayersIcon className="text-custom-text-400/40 w-[30px] h-[30px]" />,
|
||||
text: "Your recent issues will appear here once you visit one.",
|
||||
};
|
||||
default:
|
||||
return {
|
||||
icon: <History size={30} className="text-custom-text-400/40" />,
|
||||
text: "You don’t have any recent items yet.",
|
||||
};
|
||||
}
|
||||
};
|
||||
const { icon, text } = getDisplayContent();
|
||||
const displayContent = getDisplayContent(type);
|
||||
|
||||
return (
|
||||
<div className="min-h-[120px] flex w-full justify-center py-6 bg-custom-border-100 rounded">
|
||||
<div className="m-auto flex gap-2">
|
||||
{icon} <div className="text-custom-text-400 text-sm text-center my-auto">{text}</div>
|
||||
<div className="min-h-[110px] w-full flex items-center justify-center gap-2 py-6 bg-custom-background-90 text-custom-text-400 rounded">
|
||||
<div className="flex-shrink-0 size-[30px] grid place-items-center">
|
||||
<displayContent.icon className="size-6" />
|
||||
</div>
|
||||
<p className="text-sm text-center font-medium">{displayContent.text}</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
import { RecentStickyIcon } from "@plane/ui";
|
||||
|
||||
export const StickiesEmptyState = () => (
|
||||
<div className="min-h-[110px] flex w-full justify-center py-6 bg-custom-border-100 rounded">
|
||||
<div className="m-auto flex gap-2">
|
||||
<RecentStickyIcon className="h-[30px] w-[30px] text-custom-text-400/40" />
|
||||
<div className="text-custom-text-400 text-sm text-center my-auto">
|
||||
No stickies yet. Add one to start making quick notes.
|
||||
</div>
|
||||
<div className="min-h-[110px] w-full flex items-center justify-center gap-2 py-6 bg-custom-background-90 text-custom-text-400 rounded">
|
||||
<div className="flex-shrink-0 size-[30px] grid place-items-center">
|
||||
<RecentStickyIcon className="size-6" />
|
||||
</div>
|
||||
<p className="text-sm text-center font-medium">
|
||||
Jot down an idea, capture an aha, or record a brainwave. Add a sticky to get started.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -14,7 +14,7 @@ export const AddLink = (props: TProps) => {
|
||||
<div className="rounded p-2 bg-custom-background-80/40 w-8 h-8 my-auto">
|
||||
<PlusIcon className="h-4 w-4 stroke-2 text-custom-text-350" />
|
||||
</div>
|
||||
<div className="text-sm font-medium my-auto">Add quick Link</div>
|
||||
<div className="text-sm font-medium my-auto">Add quicklink</div>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -66,9 +66,7 @@ export const LinkCreateUpdateModal: FC<TLinkCreateEditModal> = observer((props)
|
||||
<ModalCore isOpen={isModalOpen} handleClose={onClose}>
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)}>
|
||||
<div className="space-y-5 p-5">
|
||||
<h3 className="text-xl font-medium text-custom-text-200">
|
||||
{preloadedData?.id ? "Update" : "Add"} quick link
|
||||
</h3>
|
||||
<h3 className="text-xl font-medium text-custom-text-200">{preloadedData?.id ? "Update" : "Add"} quicklink</h3>
|
||||
<div className="mt-2 space-y-3">
|
||||
<div>
|
||||
<label htmlFor="url" className="mb-2 text-custom-text-200 text-base font-medium">
|
||||
@@ -124,7 +122,7 @@ export const LinkCreateUpdateModal: FC<TLinkCreateEditModal> = observer((props)
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="primary" size="sm" type="submit" loading={isSubmitting}>
|
||||
{preloadedData?.id ? (isSubmitting ? "Updating" : "Update") : isSubmitting ? "Adding" : "Add"} quick link
|
||||
{preloadedData?.id ? (isSubmitting ? "Updating" : "Update") : isSubmitting ? "Adding" : "Add"} quicklink
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { FC } from "react";
|
||||
// hooks
|
||||
// ui
|
||||
import { observer } from "mobx-react";
|
||||
import { Pencil, Trash2, ExternalLink, EllipsisVertical, Link2, Link } from "lucide-react";
|
||||
import { Pencil, Trash2, ExternalLink, Link2, Link } from "lucide-react";
|
||||
// plane ui
|
||||
import { TOAST_TYPE, setToast, CustomMenu, TContextMenuItem } from "@plane/ui";
|
||||
// helpers
|
||||
// plane utils
|
||||
import { cn, copyTextToClipboard } from "@plane/utils";
|
||||
// helpers
|
||||
import { calculateTimeAgo } from "@/helpers/date-time.helper";
|
||||
// hooks
|
||||
import { useHome } from "@/hooks/store/use-home";
|
||||
// types
|
||||
import { TLinkOperations } from "./use-links";
|
||||
|
||||
export type TProjectLinkDetail = {
|
||||
@@ -75,54 +77,47 @@ export const ProjectLinkDetail: FC<TProjectLinkDetail> = observer((props) => {
|
||||
return (
|
||||
<div
|
||||
onClick={handleOpenInNewTab}
|
||||
className="cursor-pointer group btn btn-primary flex bg-custom-background-100 px-4 w-[230px] h-[56px] border-[0.5px] border-custom-border-200 rounded-md gap-4 hover:shadow-md"
|
||||
className="cursor-pointer group flex items-center bg-custom-background-100 px-4 w-[230px] h-[56px] border-[0.5px] border-custom-border-200 rounded-md gap-4 hover:shadow-md transition-shadow"
|
||||
>
|
||||
<div className="rounded p-2 bg-custom-background-80/40 w-8 h-8 my-auto">
|
||||
<Link2 className="h-4 w-4 stroke-2 text-custom-text-350 -rotate-45" />
|
||||
<div className="flex-shrink-0 size-8 rounded p-2 bg-custom-background-80 grid place-items-center">
|
||||
<Link2 className="size-4 stroke-2 text-custom-text-350 -rotate-45" />
|
||||
</div>
|
||||
<div className="my-auto flex-1">
|
||||
<div className="flex-1 truncate">
|
||||
<div className="text-sm font-medium truncate">{linkDetail.title || linkDetail.url}</div>
|
||||
<div className="text-xs font-medium text-custom-text-400">{calculateTimeAgo(linkDetail.created_at)}</div>
|
||||
</div>
|
||||
|
||||
<CustomMenu
|
||||
customButton={
|
||||
<EllipsisVertical className="opacity-0 h-4 w-4 stroke-2 text-custom-text-350 group-hover:opacity-100" />
|
||||
}
|
||||
placement="bottom-end"
|
||||
menuItemsClassName="z-20"
|
||||
closeOnSelect
|
||||
className=" my-auto"
|
||||
>
|
||||
{MENU_ITEMS.map((item) => (
|
||||
<CustomMenu.MenuItem
|
||||
key={item.key}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
item.action();
|
||||
}}
|
||||
className={cn("flex items-center gap-2 w-full ", {
|
||||
"text-custom-text-400": item.disabled,
|
||||
})}
|
||||
disabled={item.disabled}
|
||||
>
|
||||
{item.icon && <item.icon className={cn("h-3 w-3", item.iconClassName)} />}
|
||||
<div>
|
||||
<h5>{item.title}</h5>
|
||||
{item.description && (
|
||||
<p
|
||||
className={cn("text-custom-text-300 whitespace-pre-line", {
|
||||
"text-custom-text-400": item.disabled,
|
||||
})}
|
||||
>
|
||||
{item.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
))}
|
||||
</CustomMenu>
|
||||
<div className="hidden group-hover:block">
|
||||
<CustomMenu placement="bottom-end" menuItemsClassName="z-20" closeOnSelect verticalEllipsis>
|
||||
{MENU_ITEMS.map((item) => (
|
||||
<CustomMenu.MenuItem
|
||||
key={item.key}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
item.action();
|
||||
}}
|
||||
className={cn("flex items-center gap-2 w-full ", {
|
||||
"text-custom-text-400": item.disabled,
|
||||
})}
|
||||
disabled={item.disabled}
|
||||
>
|
||||
{item.icon && <item.icon className={cn("h-3 w-3", item.iconClassName)} />}
|
||||
<div>
|
||||
<h5>{item.title}</h5>
|
||||
{item.description && (
|
||||
<p
|
||||
className={cn("text-custom-text-300 whitespace-pre-line", {
|
||||
"text-custom-text-400": item.disabled,
|
||||
})}
|
||||
>
|
||||
{item.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
))}
|
||||
</CustomMenu>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -38,9 +38,9 @@ export const ProjectLinkList: FC<TProjectLinkList> = observer((props) => {
|
||||
buttonClassName="bg-custom-background-90/20"
|
||||
>
|
||||
<div className="flex gap-2 mb-2 flex-wrap flex-1">
|
||||
{links &&
|
||||
links.length > 0 &&
|
||||
links.map((linkId) => <ProjectLinkDetail key={linkId} linkId={linkId} linkOperations={linkOperations} />)}
|
||||
{links.map((linkId) => (
|
||||
<ProjectLinkDetail key={linkId} linkId={linkId} linkOperations={linkOperations} />
|
||||
))}
|
||||
</div>
|
||||
</ContentOverflowWrapper>
|
||||
</div>
|
||||
|
||||
@@ -34,14 +34,14 @@ export const DashboardQuickLinks = observer((props: THomeWidgetProps) => {
|
||||
/>
|
||||
<div className="mb-2">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="text-base font-semibold text-custom-text-350">Quick links</div>
|
||||
<div className="text-base font-semibold text-custom-text-350">Quicklinks</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
toggleLinkModal(true);
|
||||
}}
|
||||
className="flex gap-1 text-sm font-medium text-custom-primary-100 my-auto"
|
||||
>
|
||||
<Plus className="size-4 my-auto" /> <span>Add quick link</span>
|
||||
<Plus className="size-4 my-auto" /> <span>Add quicklink</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap w-full">
|
||||
|
||||
@@ -2,17 +2,20 @@
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// types
|
||||
import { usePathname } from "next/navigation";
|
||||
import useSWR from "swr";
|
||||
import { Briefcase, FileText } from "lucide-react";
|
||||
// plane types
|
||||
import { TActivityEntityData, THomeWidgetProps, TRecentActivityFilterKeys } from "@plane/types";
|
||||
// components
|
||||
// plane ui
|
||||
import { LayersIcon } from "@plane/ui";
|
||||
// components
|
||||
import { ContentOverflowWrapper } from "@/components/core/content-overflow-HOC";
|
||||
// hooks
|
||||
import { useProject } from "@/hooks/store";
|
||||
// plane web services
|
||||
import { WorkspaceService } from "@/plane-web/services";
|
||||
import { EmptyWorkspace } from "../empty-states";
|
||||
import { RecentsEmptyState } from "../empty-states/recents";
|
||||
import { NoProjectsEmptyState, RecentsEmptyState } from "../empty-states";
|
||||
import { EWidgetKeys, WidgetLoader } from "../loaders";
|
||||
import { FiltersDropdown } from "./filters";
|
||||
import { RecentIssue } from "./issue";
|
||||
@@ -23,18 +26,28 @@ const WIDGET_KEY = EWidgetKeys.RECENT_ACTIVITY;
|
||||
const workspaceService = new WorkspaceService();
|
||||
const filters: { name: TRecentActivityFilterKeys; icon?: React.ReactNode }[] = [
|
||||
{ name: "all item" },
|
||||
{ name: "issue", icon: <LayersIcon className="w-4 h-4" /> },
|
||||
{ name: "page", icon: <FileText size={16} /> },
|
||||
{ name: "project", icon: <Briefcase size={16} /> },
|
||||
{ name: "issue", icon: <LayersIcon className="flex-shrink-0 size-4" /> },
|
||||
{ name: "page", icon: <FileText className="flex-shrink-0 size-4" /> },
|
||||
{ name: "project", icon: <Briefcase className="flex-shrink-0 size-4" /> },
|
||||
];
|
||||
|
||||
export const RecentActivityWidget: React.FC<THomeWidgetProps> = observer((props) => {
|
||||
const { workspaceSlug } = props;
|
||||
// state
|
||||
const [filter, setFilter] = useState<TRecentActivityFilterKeys>(filters[0].name);
|
||||
type TRecentWidgetProps = THomeWidgetProps & {
|
||||
presetFilter?: TRecentActivityFilterKeys;
|
||||
showFilterSelect?: boolean;
|
||||
};
|
||||
|
||||
export const RecentActivityWidget: React.FC<TRecentWidgetProps> = observer((props) => {
|
||||
const { presetFilter, showFilterSelect = true, workspaceSlug } = props;
|
||||
// states
|
||||
const [filter, setFilter] = useState<TRecentActivityFilterKeys>(presetFilter ?? filters[0].name);
|
||||
// navigation
|
||||
const pathname = usePathname();
|
||||
// ref
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
// store hooks
|
||||
const { joinedProjectIds, loader } = useProject();
|
||||
// derived values
|
||||
const isWikiApp = pathname.includes(`/${workspaceSlug.toString()}/pages`);
|
||||
|
||||
const { data: recents, isLoading } = useSWR(
|
||||
workspaceSlug ? `WORKSPACE_RECENT_ACTIVITY_${workspaceSlug}_${filter}` : null,
|
||||
@@ -55,6 +68,7 @@ export const RecentActivityWidget: React.FC<THomeWidgetProps> = observer((props)
|
||||
const resolveRecent = (activity: TActivityEntityData) => {
|
||||
switch (activity.entity_name) {
|
||||
case "page":
|
||||
case "workspace_page":
|
||||
return <RecentPage activity={activity} ref={ref} workspaceSlug={workspaceSlug} />;
|
||||
case "project":
|
||||
return <RecentProject activity={activity} ref={ref} workspaceSlug={workspaceSlug} />;
|
||||
@@ -65,13 +79,14 @@ export const RecentActivityWidget: React.FC<THomeWidgetProps> = observer((props)
|
||||
}
|
||||
};
|
||||
|
||||
if (!loader && joinedProjectIds?.length === 0) return <EmptyWorkspace />;
|
||||
if (!loader && !isWikiApp && joinedProjectIds?.length === 0) return <NoProjectsEmptyState />;
|
||||
|
||||
if (!isLoading && recents?.length === 0)
|
||||
return (
|
||||
<div ref={ref} className=" max-h-[500px] overflow-y-scroll">
|
||||
<div ref={ref} className="max-h-[500px] overflow-y-scroll">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="text-base font-semibold text-custom-text-350">Recents</div>
|
||||
<FiltersDropdown filters={filters} activeFilter={filter} setActiveFilter={setFilter} />
|
||||
{showFilterSelect && <FiltersDropdown filters={filters} activeFilter={filter} setActiveFilter={setFilter} />}
|
||||
</div>
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<RecentsEmptyState type={filter} />
|
||||
@@ -88,16 +103,14 @@ export const RecentActivityWidget: React.FC<THomeWidgetProps> = observer((props)
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="text-base font-semibold text-custom-text-350">Recents</div>
|
||||
|
||||
<FiltersDropdown filters={filters} activeFilter={filter} setActiveFilter={setFilter} />
|
||||
{showFilterSelect && <FiltersDropdown filters={filters} activeFilter={filter} setActiveFilter={setFilter} />}
|
||||
</div>
|
||||
<div className="min-h-[250px] flex flex-col">
|
||||
{isLoading && <WidgetLoader widgetKey={WIDGET_KEY} />}
|
||||
{!isLoading &&
|
||||
recents?.length > 0 &&
|
||||
recents
|
||||
.filter((recent: TActivityEntityData) => recent.entity_data)
|
||||
.map((activity: TActivityEntityData) => <div key={activity.id}>{resolveRecent(activity)}</div>)}
|
||||
?.filter((recent) => recent.entity_data)
|
||||
.map((activity) => <div key={activity.id}>{resolveRecent(activity)}</div>)}
|
||||
</div>
|
||||
</ContentOverflowWrapper>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
// plane types
|
||||
import { TActivityEntityData, TIssueEntityData } from "@plane/types";
|
||||
// plane ui
|
||||
import { LayersIcon, PriorityIcon, StateGroupIcon, Tooltip } from "@plane/ui";
|
||||
// components
|
||||
import { ListItem } from "@/components/core/list";
|
||||
import { MemberDropdown } from "@/components/dropdowns";
|
||||
// helpers
|
||||
import { calculateTimeAgo } from "@/helpers/date-time.helper";
|
||||
// hooks
|
||||
import { useIssueDetail, useProjectState } from "@/hooks/store";
|
||||
// plane web components
|
||||
import { IssueIdentifier } from "@/plane-web/components/issues";
|
||||
|
||||
type BlockProps = {
|
||||
@@ -24,9 +30,9 @@ export const RecentIssue = (props: BlockProps) => {
|
||||
<ListItem
|
||||
key={activity.id}
|
||||
itemLink=""
|
||||
title={""}
|
||||
title={issueDetails?.name}
|
||||
prependTitleElement={
|
||||
<div className="flex flex-shrink-0 items-center justify-center rounded-md gap-4 ">
|
||||
<div className="flex-shrink-0 flex items-center gap-2">
|
||||
{issueDetails.type ? (
|
||||
<IssueIdentifier
|
||||
size="lg"
|
||||
@@ -38,16 +44,19 @@ export const RecentIssue = (props: BlockProps) => {
|
||||
/>
|
||||
) : (
|
||||
<div className="flex gap-2 items-center justify-center">
|
||||
<div className="flex flex-shrink-0 items-center justify-center rounded gap-4 bg-custom-background-80 w-[25.5px] h-[25.5px]">
|
||||
<LayersIcon className="w-4 h-4 text-custom-text-350" />
|
||||
<div className="flex-shrink-0 grid place-items-center rounded bg-custom-background-80 size-8">
|
||||
<LayersIcon className="size-4 text-custom-text-350" />
|
||||
</div>
|
||||
<div className="font-medium text-custom-sidebar-text-400 text-sm whitespace-nowrap">
|
||||
<div className="font-medium text-custom-text-400 text-sm whitespace-nowrap">
|
||||
{issueDetails?.project_identifier}-{issueDetails?.sequence_id}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="text-custom-text-200 font-medium text-sm whitespace-nowrap">{issueDetails?.name}</div>
|
||||
<div className="font-medium text-xs text-custom-text-400">{calculateTimeAgo(activity.visited_at)}</div>
|
||||
</div>
|
||||
}
|
||||
appendTitleElement={
|
||||
<div className="flex-shrink-0 font-medium text-xs text-custom-text-400">
|
||||
{calculateTimeAgo(activity.visited_at)}
|
||||
</div>
|
||||
}
|
||||
quickActionElement={
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import { useRouter } from "next/navigation";
|
||||
import { FileText } from "lucide-react";
|
||||
// plane types
|
||||
import { TActivityEntityData, TPageEntityData } from "@plane/types";
|
||||
// plane ui
|
||||
import { Avatar, Logo } from "@plane/ui";
|
||||
// plane utils
|
||||
import { getFileURL } from "@plane/utils";
|
||||
// components
|
||||
import { ListItem } from "@/components/core/list";
|
||||
// helpers
|
||||
import { calculateTimeAgo } from "@/helpers/date-time.helper";
|
||||
import { getPageName } from "@/helpers/page.helper";
|
||||
// hooks
|
||||
import { useMember } from "@/hooks/store";
|
||||
|
||||
type BlockProps = {
|
||||
@@ -12,38 +19,44 @@ type BlockProps = {
|
||||
ref: React.RefObject<HTMLDivElement>;
|
||||
workspaceSlug: string;
|
||||
};
|
||||
|
||||
export const RecentPage = (props: BlockProps) => {
|
||||
const { activity, ref, workspaceSlug } = props;
|
||||
// router
|
||||
const router = useRouter();
|
||||
// hooks
|
||||
// store hooks
|
||||
const { getUserDetails } = useMember();
|
||||
// derived values
|
||||
const pageDetails: TPageEntityData = activity.entity_data as TPageEntityData;
|
||||
const pageDetails = activity.entity_data as TPageEntityData;
|
||||
const ownerDetails = getUserDetails(pageDetails?.owned_by);
|
||||
const pageLink = pageDetails.project_id
|
||||
? `/${workspaceSlug}/projects/${pageDetails.project_id}/pages/${pageDetails.id}`
|
||||
: `/${workspaceSlug}/pages/${pageDetails.id}`;
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
key={activity.id}
|
||||
itemLink=""
|
||||
title={""}
|
||||
title={getPageName(pageDetails?.name)}
|
||||
prependTitleElement={
|
||||
<div className="flex flex-shrink-0 items-center justify-center rounded-md gap-4 ">
|
||||
<div className="flex gap-2 items-center justify-center">
|
||||
<div className="flex flex-shrink-0 items-center justify-center rounded gap-2 bg-custom-background-80 w-[25.5px] h-[25.5px]">
|
||||
<>
|
||||
{pageDetails?.logo_props?.in_use ? (
|
||||
<Logo logo={pageDetails?.logo_props} size={16} type="lucide" />
|
||||
) : (
|
||||
<FileText className="h-4 w-4 text-custom-text-350" />
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
<div className="font-medium text-custom-sidebar-text-400 text-sm whitespace-nowrap">
|
||||
<div className="flex-shrink-0 flex items-center gap-2">
|
||||
<div className="flex-shrink-0 grid place-items-center rounded bg-custom-background-80 size-8">
|
||||
{pageDetails?.logo_props?.in_use ? (
|
||||
<Logo logo={pageDetails?.logo_props} size={16} type="lucide" />
|
||||
) : (
|
||||
<FileText className="size-4 text-custom-text-350" />
|
||||
)}
|
||||
</div>
|
||||
{pageDetails?.project_identifier && (
|
||||
<div className="font-medium text-custom-text-400 text-sm whitespace-nowrap">
|
||||
{pageDetails?.project_identifier}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-custom-text-200 font-medium text-sm whitespace-nowrap">{pageDetails?.name}</div>
|
||||
<div className="font-medium text-xs text-custom-text-400">{calculateTimeAgo(activity.visited_at)}</div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
appendTitleElement={
|
||||
<div className="flex-shrink-0 font-medium text-xs text-custom-text-400">
|
||||
{calculateTimeAgo(activity.visited_at)}
|
||||
</div>
|
||||
}
|
||||
quickActionElement={
|
||||
@@ -58,7 +71,7 @@ export const RecentPage = (props: BlockProps) => {
|
||||
onItemClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
router.push(`/${workspaceSlug}/projects/${pageDetails?.project_id}/pages/${pageDetails.id}`);
|
||||
router.push(pageLink);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { useRouter } from "next/navigation";
|
||||
// plane types
|
||||
import { TActivityEntityData, TProjectEntityData } from "@plane/types";
|
||||
// plane ui
|
||||
import { Logo } from "@plane/ui";
|
||||
// components
|
||||
import { ListItem } from "@/components/core/list";
|
||||
import { MemberDropdown } from "@/components/dropdowns";
|
||||
// helpers
|
||||
import { calculateTimeAgo } from "@/helpers/date-time.helper";
|
||||
|
||||
type BlockProps = {
|
||||
@@ -21,19 +25,18 @@ export const RecentProject = (props: BlockProps) => {
|
||||
<ListItem
|
||||
key={activity.id}
|
||||
itemLink=""
|
||||
title={""}
|
||||
title={projectDetails?.name}
|
||||
prependTitleElement={
|
||||
<div className="flex flex-shrink-0 items-center justify-center rounded-md gap-4 ">
|
||||
<div className="flex gap-2 items-center justify-center">
|
||||
<div className="flex flex-shrink-0 items-center justify-center rounded gap-4 bg-custom-background-80 w-[25.5px] h-[25.5px]">
|
||||
<Logo logo={projectDetails?.logo_props} size={16} />
|
||||
</div>
|
||||
<div className="font-medium text-custom-sidebar-text-400 text-sm whitespace-nowrap">
|
||||
{projectDetails?.identifier}
|
||||
</div>
|
||||
<div className="flex-shrink-0 flex items-center gap-2">
|
||||
<div className="flex-shrink-0 grid place-items-center rounded bg-custom-background-80 size-8">
|
||||
<Logo logo={projectDetails?.logo_props} size={16} />
|
||||
</div>
|
||||
<div className="text-custom-text-200 font-medium text-sm whitespace-nowrap">{projectDetails?.name}</div>
|
||||
<div className="font-medium text-xs text-custom-text-400">{calculateTimeAgo(activity.visited_at)}</div>
|
||||
<div className="font-medium text-custom-text-400 text-sm whitespace-nowrap">{projectDetails?.identifier}</div>
|
||||
</div>
|
||||
}
|
||||
appendTitleElement={
|
||||
<div className="flex-shrink-0 font-medium text-xs text-custom-text-400">
|
||||
{calculateTimeAgo(activity.visited_at)}
|
||||
</div>
|
||||
}
|
||||
quickActionElement={
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { FC, Fragment, useState } from "react";
|
||||
import { DayPicker } from "react-day-picker";
|
||||
import { DayPicker, getDefaultClassNames } from "react-day-picker";
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
// ui
|
||||
import { Button } from "@plane/ui";
|
||||
@@ -18,6 +18,8 @@ export const InboxIssueSnoozeModal: FC<InboxIssueSnoozeModalProps> = (props) =>
|
||||
// states
|
||||
const [date, setDate] = useState(value || new Date());
|
||||
|
||||
const defaultClassNames = getDefaultClassNames();
|
||||
|
||||
return (
|
||||
<Transition.Root show={isOpen} as={Fragment}>
|
||||
<Dialog as="div" className="relative z-20" onClose={handleClose}>
|
||||
@@ -46,6 +48,8 @@ export const InboxIssueSnoozeModal: FC<InboxIssueSnoozeModalProps> = (props) =>
|
||||
<Dialog.Panel className="relative flex transform rounded-lg bg-custom-background-100 px-5 py-8 text-left shadow-custom-shadow-md transition-all sm:my-8 sm:w-full sm:max-w-2xl sm:p-6">
|
||||
<div className="flex h-full w-full flex-col gap-y-1">
|
||||
<DayPicker
|
||||
captionLayout="dropdown"
|
||||
classNames={{root: `${defaultClassNames.root} rounded-md border border-custom-border-200 p-3`}}
|
||||
selected={date ? new Date(date) : undefined}
|
||||
defaultMonth={date ? new Date(date) : undefined}
|
||||
onSelect={(date) => {
|
||||
@@ -53,7 +57,6 @@ export const InboxIssueSnoozeModal: FC<InboxIssueSnoozeModalProps> = (props) =>
|
||||
setDate(date);
|
||||
}}
|
||||
mode="single"
|
||||
className="rounded-md border border-custom-border-200 p-3"
|
||||
disabled={[
|
||||
{
|
||||
before: new Date(),
|
||||
|
||||
+1
-6
@@ -55,12 +55,7 @@ export const IssueDetailWidgetCollapsibles: FC<Props> = observer((props) => {
|
||||
/>
|
||||
)}
|
||||
{shouldRenderRelations && (
|
||||
<RelationsCollapsible
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issueId={issueId}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<RelationsCollapsible workspaceSlug={workspaceSlug} issueId={issueId} disabled={disabled} />
|
||||
)}
|
||||
{shouldRenderLinks && (
|
||||
<LinksCollapsible workspaceSlug={workspaceSlug} projectId={projectId} issueId={issueId} disabled={disabled} />
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"use client";
|
||||
import { FC, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { EIssueServiceType } from "@plane/constants";
|
||||
import { TIssue, TIssueRelationIdMap, TIssueServiceType } from "@plane/types";
|
||||
import { TIssue, TIssueServiceType } from "@plane/types";
|
||||
import { Collapsible } from "@plane/ui";
|
||||
// components
|
||||
import { RelationIssueList } from "@/components/issues";
|
||||
@@ -11,6 +12,7 @@ import { CreateUpdateIssueModal } from "@/components/issues/issue-modal";
|
||||
// hooks
|
||||
import { useIssueDetail } from "@/hooks/store";
|
||||
// Plane-web
|
||||
import { CreateUpdateEpicModal } from "@/plane-web/components/epics";
|
||||
import { useTimeLineRelationOptions } from "@/plane-web/components/relations";
|
||||
import { TIssueRelationTypes } from "@/plane-web/types";
|
||||
// helper
|
||||
@@ -18,7 +20,6 @@ import { useRelationOperations } from "./helper";
|
||||
|
||||
type Props = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
issueId: string;
|
||||
disabled: boolean;
|
||||
issueServiceType?: TIssueServiceType;
|
||||
@@ -35,7 +36,7 @@ export type TRelationObject = {
|
||||
};
|
||||
|
||||
export const RelationsCollapsibleContent: FC<Props> = observer((props) => {
|
||||
const { workspaceSlug, projectId, issueId, disabled = false, issueServiceType = EIssueServiceType.ISSUES } = props;
|
||||
const { workspaceSlug, issueId, disabled = false, issueServiceType = EIssueServiceType.ISSUES } = props;
|
||||
// state
|
||||
const [issueCrudState, setIssueCrudState] = useState<{
|
||||
update: TIssueCrudState;
|
||||
@@ -62,6 +63,7 @@ export const RelationsCollapsibleContent: FC<Props> = observer((props) => {
|
||||
|
||||
// helper
|
||||
const issueOperations = useRelationOperations();
|
||||
const epicOperations = useRelationOperations(EIssueServiceType.EPICS);
|
||||
|
||||
// derived values
|
||||
const relations = getRelationsByIssueId(issueId);
|
||||
@@ -124,12 +126,10 @@ export const RelationsCollapsibleContent: FC<Props> = observer((props) => {
|
||||
>
|
||||
<RelationIssueList
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issueId={issueId}
|
||||
relationKey={relation.relationKey}
|
||||
issueIds={relation.issueIds}
|
||||
disabled={disabled}
|
||||
issueOperations={issueOperations}
|
||||
handleIssueCrudState={handleIssueCrudState}
|
||||
issueServiceType={issueServiceType}
|
||||
/>
|
||||
@@ -146,24 +146,56 @@ export const RelationsCollapsibleContent: FC<Props> = observer((props) => {
|
||||
toggleDeleteIssueModal(null);
|
||||
}}
|
||||
data={issueCrudState?.delete?.issue as TIssue}
|
||||
onSubmit={async () =>
|
||||
await issueOperations.remove(workspaceSlug, projectId, issueCrudState?.delete?.issue?.id as string)
|
||||
}
|
||||
onSubmit={async () => {
|
||||
if (
|
||||
issueCrudState.delete.issue &&
|
||||
issueCrudState.delete.issue.id &&
|
||||
issueCrudState.delete.issue.project_id
|
||||
) {
|
||||
const deleteOperation = !!issueCrudState.delete.issue?.is_epic
|
||||
? epicOperations.remove
|
||||
: issueOperations.remove;
|
||||
await deleteOperation(
|
||||
workspaceSlug,
|
||||
issueCrudState.delete.issue?.project_id,
|
||||
issueCrudState?.delete?.issue?.id as string
|
||||
);
|
||||
}
|
||||
}}
|
||||
isEpic={!!issueCrudState.delete.issue?.is_epic}
|
||||
/>
|
||||
)}
|
||||
|
||||
{shouldRenderIssueUpdateModal && (
|
||||
<CreateUpdateIssueModal
|
||||
isOpen={issueCrudState?.update?.toggle}
|
||||
onClose={() => {
|
||||
handleIssueCrudState("update", null, null);
|
||||
toggleCreateIssueModal(false);
|
||||
}}
|
||||
data={issueCrudState?.update?.issue ?? undefined}
|
||||
onSubmit={async (_issue: TIssue) => {
|
||||
await issueOperations.update(workspaceSlug, projectId, _issue.id, _issue);
|
||||
}}
|
||||
/>
|
||||
<>
|
||||
{!!issueCrudState?.update?.issue?.is_epic ? (
|
||||
<CreateUpdateEpicModal
|
||||
isOpen={issueCrudState?.update?.toggle}
|
||||
onClose={() => {
|
||||
handleIssueCrudState("update", null, null);
|
||||
toggleCreateIssueModal(false);
|
||||
}}
|
||||
data={issueCrudState?.update?.issue ?? undefined}
|
||||
onSubmit={async (_issue: TIssue) => {
|
||||
if (!_issue.id || !_issue.project_id) return;
|
||||
await epicOperations.update(workspaceSlug, _issue.project_id, _issue.id, _issue);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<CreateUpdateIssueModal
|
||||
isOpen={issueCrudState?.update?.toggle}
|
||||
onClose={() => {
|
||||
handleIssueCrudState("update", null, null);
|
||||
toggleCreateIssueModal(false);
|
||||
}}
|
||||
data={issueCrudState?.update?.issue ?? undefined}
|
||||
onSubmit={async (_issue: TIssue) => {
|
||||
if (!_issue.id || !_issue.project_id) return;
|
||||
await issueOperations.update(workspaceSlug, _issue.project_id, _issue.id, _issue);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -23,6 +23,8 @@ export const useRelationOperations = (
|
||||
const { updateIssue, removeIssue } = useIssueDetail(issueServiceType);
|
||||
const { captureIssueEvent } = useEventTracker();
|
||||
const pathname = usePathname();
|
||||
// derived values
|
||||
const entityName = issueServiceType === EIssueServiceType.ISSUES ? "Issue" : "Epic";
|
||||
|
||||
const issueOperations: TRelationIssueOperations = useMemo(
|
||||
() => ({
|
||||
@@ -32,7 +34,7 @@ export const useRelationOperations = (
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Link Copied!",
|
||||
message: "Issue link copied to clipboard.",
|
||||
message: `${entityName} link copied to clipboard.`,
|
||||
});
|
||||
});
|
||||
},
|
||||
@@ -51,7 +53,7 @@ export const useRelationOperations = (
|
||||
setToast({
|
||||
title: "Success!",
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
message: "Issue updated successfully",
|
||||
message: `${entityName} updated successfully`,
|
||||
});
|
||||
} catch (error) {
|
||||
captureIssueEvent({
|
||||
@@ -66,7 +68,7 @@ export const useRelationOperations = (
|
||||
setToast({
|
||||
title: "Error!",
|
||||
type: TOAST_TYPE.ERROR,
|
||||
message: "Issue update failed",
|
||||
message: `${entityName} update failed`,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
@@ -11,14 +11,13 @@ import { useIssueDetail } from "@/hooks/store";
|
||||
|
||||
type Props = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
issueId: string;
|
||||
disabled?: boolean;
|
||||
issueServiceType?: TIssueServiceType;
|
||||
};
|
||||
|
||||
export const RelationsCollapsible: FC<Props> = observer((props) => {
|
||||
const { workspaceSlug, projectId, issueId, disabled = false, issueServiceType = EIssueServiceType.ISSUES } = props;
|
||||
const { workspaceSlug, issueId, disabled = false, issueServiceType = EIssueServiceType.ISSUES } = props;
|
||||
// store hooks
|
||||
const { openWidgets, toggleOpenWidget } = useIssueDetail(issueServiceType);
|
||||
|
||||
@@ -41,7 +40,6 @@ export const RelationsCollapsible: FC<Props> = observer((props) => {
|
||||
>
|
||||
<RelationsCollapsibleContent
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issueId={issueId}
|
||||
disabled={disabled}
|
||||
issueServiceType={issueServiceType}
|
||||
|
||||
@@ -28,11 +28,13 @@ export const IssueCommentBlock: FC<TIssueCommentBlock> = observer((props) => {
|
||||
<div className={`relative flex gap-3 ${ends === "top" ? `pb-2` : ends === "bottom" ? `pt-2` : `py-2`}`}>
|
||||
<div className="absolute left-[13px] top-0 bottom-0 w-0.5 bg-custom-background-80" aria-hidden />
|
||||
<div className="flex-shrink-0 relative w-7 h-7 rounded-full flex justify-center items-center z-[3] bg-gray-500 text-white border border-white uppercase font-medium">
|
||||
{comment.actor_detail.avatar_url && comment.actor_detail.avatar_url !== "" ? (
|
||||
{comment.actor_detail?.avatar_url && comment.actor_detail?.avatar_url !== "" ? (
|
||||
<img
|
||||
src={getFileURL(comment.actor_detail.avatar_url)}
|
||||
src={getFileURL(comment.actor_detail?.avatar_url)}
|
||||
alt={
|
||||
comment.actor_detail.is_bot ? comment.actor_detail.first_name + " Bot" : comment.actor_detail.display_name
|
||||
comment.actor_detail?.is_bot
|
||||
? comment.actor_detail?.first_name + " Bot"
|
||||
: comment.actor_detail?.display_name
|
||||
}
|
||||
height={30}
|
||||
width={30}
|
||||
@@ -40,9 +42,9 @@ export const IssueCommentBlock: FC<TIssueCommentBlock> = observer((props) => {
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{comment.actor_detail.is_bot
|
||||
? comment.actor_detail.first_name.charAt(0)
|
||||
: comment.actor_detail.display_name.charAt(0)}
|
||||
{comment.actor_detail?.is_bot
|
||||
? comment.actor_detail?.first_name.charAt(0)
|
||||
: comment.actor_detail?.display_name.charAt(0)}
|
||||
</>
|
||||
)}
|
||||
<div className="absolute top-2 left-4 w-5 h-5 rounded-full overflow-hidden flex justify-center items-center bg-custom-background-80">
|
||||
@@ -53,9 +55,9 @@ export const IssueCommentBlock: FC<TIssueCommentBlock> = observer((props) => {
|
||||
<div className="w-full truncate space-y-1">
|
||||
<div>
|
||||
<div className="text-xs capitalize">
|
||||
{comment.actor_detail.is_bot
|
||||
? comment.actor_detail.first_name + " Bot"
|
||||
: comment.actor_detail.display_name}
|
||||
{comment.actor_detail?.is_bot
|
||||
? comment.actor_detail?.first_name + " Bot"
|
||||
: comment.actor_detail?.display_name}
|
||||
</div>
|
||||
<div className="text-xs text-custom-text-200">commented {calculateTimeAgo(comment.created_at)}</div>
|
||||
</div>
|
||||
|
||||
@@ -4,11 +4,16 @@ import { FC, useEffect, useRef, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { Check, Globe2, Lock, Pencil, Trash2, X } from "lucide-react";
|
||||
// plane constants
|
||||
import { EIssueCommentAccessSpecifier } from "@plane/constants";
|
||||
// plane editor
|
||||
import { EditorReadOnlyRefApi, EditorRefApi } from "@plane/editor";
|
||||
// plane types
|
||||
import { TIssueComment } from "@plane/types";
|
||||
// ui
|
||||
// plane ui
|
||||
import { CustomMenu } from "@plane/ui";
|
||||
// plane utils
|
||||
import { cn } from "@plane/utils";
|
||||
// components
|
||||
import { LiteTextEditor, LiteTextReadOnlyEditor } from "@/components/editor";
|
||||
// helpers
|
||||
@@ -42,21 +47,21 @@ export const IssueCommentCard: FC<TIssueCommentCard> = observer((props) => {
|
||||
showAccessSpecifier = false,
|
||||
disabled = false,
|
||||
} = props;
|
||||
// hooks
|
||||
// states
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
// refs
|
||||
const editorRef = useRef<EditorRefApi>(null);
|
||||
const showEditorRef = useRef<EditorReadOnlyRefApi>(null);
|
||||
// store hooks
|
||||
const {
|
||||
comment: { getCommentById },
|
||||
} = useIssueDetail();
|
||||
const { data: currentUser } = useUser();
|
||||
// refs
|
||||
const editorRef = useRef<EditorRefApi>(null);
|
||||
const showEditorRef = useRef<EditorReadOnlyRefApi>(null);
|
||||
// state
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
|
||||
// derived values
|
||||
const comment = getCommentById(commentId);
|
||||
const workspaceStore = useWorkspace();
|
||||
const workspaceId = workspaceStore.getWorkspaceBySlug(comment?.workspace_detail?.slug as string)?.id as string;
|
||||
|
||||
// form info
|
||||
const {
|
||||
formState: { isSubmitting },
|
||||
handleSubmit,
|
||||
@@ -66,6 +71,11 @@ export const IssueCommentCard: FC<TIssueCommentCard> = observer((props) => {
|
||||
} = useForm<Partial<TIssueComment>>({
|
||||
defaultValues: { comment_html: comment?.comment_html },
|
||||
});
|
||||
// derived values
|
||||
const commentHTML = watch("comment_html");
|
||||
const isEmpty = isCommentEmpty(commentHTML);
|
||||
const isEditorReadyToDiscard = editorRef.current?.isEditorReadyToDiscard();
|
||||
const isSubmitButtonDisabled = isSubmitting || !isEditorReadyToDiscard;
|
||||
|
||||
const onEnter = async (formData: Partial<TIssueComment>) => {
|
||||
if (isSubmitting || !comment) return;
|
||||
@@ -83,10 +93,8 @@ export const IssueCommentCard: FC<TIssueCommentCard> = observer((props) => {
|
||||
}
|
||||
}, [isEditing, setFocus]);
|
||||
|
||||
const commentHTML = watch("comment_html");
|
||||
const isEmpty = isCommentEmpty(commentHTML);
|
||||
|
||||
if (!comment || !currentUser) return <></>;
|
||||
|
||||
return (
|
||||
<IssueCommentBlock
|
||||
commentId={commentId}
|
||||
@@ -95,8 +103,8 @@ export const IssueCommentCard: FC<TIssueCommentCard> = observer((props) => {
|
||||
{!disabled && currentUser?.id === comment.actor && (
|
||||
<CustomMenu ellipsis closeOnSelect>
|
||||
<CustomMenu.MenuItem onClick={() => setIsEditing(true)} className="flex items-center gap-1">
|
||||
<Pencil className="h-3 w-3" />
|
||||
Edit comment
|
||||
<Pencil className="flex-shrink-0 size-3" />
|
||||
Edit
|
||||
</CustomMenu.MenuItem>
|
||||
{showAccessSpecifier && (
|
||||
<>
|
||||
@@ -107,7 +115,7 @@ export const IssueCommentCard: FC<TIssueCommentCard> = observer((props) => {
|
||||
}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<Globe2 className="h-3 w-3" />
|
||||
<Globe2 className="flex-shrink-0 size-3" />
|
||||
Switch to public comment
|
||||
</CustomMenu.MenuItem>
|
||||
) : (
|
||||
@@ -117,7 +125,7 @@ export const IssueCommentCard: FC<TIssueCommentCard> = observer((props) => {
|
||||
}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<Lock className="h-3 w-3" />
|
||||
<Lock className="flex-shrink-0 size-3" />
|
||||
Switch to private comment
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
@@ -127,8 +135,8 @@ export const IssueCommentCard: FC<TIssueCommentCard> = observer((props) => {
|
||||
onClick={() => activityOperations.removeComment(comment.id)}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
Delete comment
|
||||
<Trash2 className="flex-shrink-0 size-3" />
|
||||
Delete
|
||||
</CustomMenu.MenuItem>
|
||||
</CustomMenu>
|
||||
)}
|
||||
@@ -166,24 +174,27 @@ export const IssueCommentCard: FC<TIssueCommentCard> = observer((props) => {
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-1 self-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit(onEnter)}
|
||||
disabled={isSubmitting || isEmpty}
|
||||
className={`group rounded border border-green-500 bg-green-500/20 p-2 shadow-md duration-300 ${
|
||||
isEmpty ? "cursor-not-allowed bg-gray-200" : "hover:bg-green-500"
|
||||
}`}
|
||||
>
|
||||
<Check
|
||||
className={`h-3 w-3 text-green-500 duration-300 ${isEmpty ? "text-black" : "group-hover:text-white"}`}
|
||||
/>
|
||||
</button>
|
||||
{!isEmpty && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit(onEnter)}
|
||||
disabled={isSubmitButtonDisabled}
|
||||
className={cn(
|
||||
"group rounded border border-green-500 text-green-500 hover:text-white bg-green-500/20 hover:bg-green-500 p-2 shadow-md duration-300",
|
||||
{
|
||||
"pointer-events-none": isSubmitButtonDisabled,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<Check className="size-3" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="group rounded border border-red-500 bg-red-500/20 p-2 shadow-md duration-300 hover:bg-red-500"
|
||||
onClick={() => setIsEditing(false)}
|
||||
>
|
||||
<X className="h-3 w-3 text-red-500 duration-300 group-hover:text-white" />
|
||||
<X className="size-3 text-red-500 duration-300 group-hover:text-white" />
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -132,7 +132,7 @@ export const KanBan: React.FC<IKanBan> = observer((props) => {
|
||||
const isSubGroup = !!sub_group_id && sub_group_id !== "null";
|
||||
|
||||
return (
|
||||
<ContentWrapper className={`flex-row relative gap-4 py-4`}>
|
||||
<ContentWrapper className={`flex-row relative gap-4 !pt-2 !pb-0`}>
|
||||
{list &&
|
||||
list.length > 0 &&
|
||||
list.map((subList: IGroupByColumn, groupIndex) => {
|
||||
|
||||
@@ -42,7 +42,11 @@ export const CreateUpdateIssueModalBase: React.FC<IssuesModalProps> = observer((
|
||||
} = props;
|
||||
const issueStoreType = useIssueStoreType();
|
||||
|
||||
const storeType = issueStoreFromProps ?? issueStoreType;
|
||||
let storeType = issueStoreFromProps ?? issueStoreType;
|
||||
// Fallback to project store if epic store is used in issue modal.
|
||||
if (storeType === EIssuesStoreType.EPIC) {
|
||||
storeType = EIssuesStoreType.PROJECT;
|
||||
}
|
||||
// ref
|
||||
const issueTitleRef = useRef<HTMLInputElement>(null);
|
||||
// states
|
||||
|
||||
@@ -16,17 +16,15 @@ import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
// plane web components
|
||||
import { IssueIdentifier } from "@/plane-web/components/issues";
|
||||
import { TIssueRelationTypes } from "@/plane-web/types";
|
||||
//
|
||||
import { TRelationIssueOperations } from "../issue-detail-widgets/relations/helper";
|
||||
// local imports
|
||||
import { useRelationOperations } from "../issue-detail-widgets/relations/helper";
|
||||
|
||||
type Props = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
issueId: string;
|
||||
relationKey: TIssueRelationTypes;
|
||||
relationIssueId: string;
|
||||
disabled: boolean;
|
||||
issueOperations: TRelationIssueOperations;
|
||||
handleIssueCrudState: (key: "update" | "delete", issueId: string, issue?: TIssue | null) => void;
|
||||
issueServiceType?: TIssueServiceType;
|
||||
};
|
||||
@@ -34,12 +32,10 @@ type Props = {
|
||||
export const RelationIssueListItem: FC<Props> = observer((props) => {
|
||||
const {
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
issueId,
|
||||
relationKey,
|
||||
relationIssueId,
|
||||
disabled = false,
|
||||
issueOperations,
|
||||
handleIssueCrudState,
|
||||
issueServiceType = EIssueServiceType.ISSUES,
|
||||
} = props;
|
||||
@@ -53,20 +49,28 @@ export const RelationIssueListItem: FC<Props> = observer((props) => {
|
||||
} = useIssueDetail(issueServiceType);
|
||||
const project = useProject();
|
||||
const { getProjectStates } = useProjectState();
|
||||
const { handleRedirection } = useIssuePeekOverviewRedirection();
|
||||
const { isMobile } = usePlatformOS();
|
||||
|
||||
// derived values
|
||||
const issue = getIssueById(relationIssueId);
|
||||
const { handleRedirection } = useIssuePeekOverviewRedirection(!!issue?.is_epic);
|
||||
const issueOperations = useRelationOperations(!!issue?.is_epic ? EIssueServiceType.EPICS : EIssueServiceType.ISSUES);
|
||||
const projectDetail = (issue && issue.project_id && project.getProjectById(issue.project_id)) || undefined;
|
||||
const projectId = issue?.project_id;
|
||||
const currentIssueStateDetail =
|
||||
(issue?.project_id && getProjectStates(issue?.project_id)?.find((state) => issue?.state_id == state.id)) ||
|
||||
undefined;
|
||||
|
||||
if (!issue) return <></>;
|
||||
if (!issue || !projectId) return <></>;
|
||||
const issueLink = `/${workspaceSlug}/projects/${projectId}/${issue.is_epic ? "epics" : "issues"}/${issue.id}`;
|
||||
|
||||
// handlers
|
||||
const handleIssuePeekOverview = (issue: TIssue) => handleRedirection(workspaceSlug, issue, isMobile);
|
||||
const handleIssuePeekOverview = (issue: TIssue) => {
|
||||
if (issue.is_epic) {
|
||||
// open epics in new tab
|
||||
window.open(issueLink, "_blank");
|
||||
return;
|
||||
}
|
||||
handleRedirection(workspaceSlug, issue, isMobile);
|
||||
};
|
||||
|
||||
const handleEditIssue = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
|
||||
e.stopPropagation();
|
||||
@@ -85,7 +89,7 @@ export const RelationIssueListItem: FC<Props> = observer((props) => {
|
||||
const handleCopyIssueLink = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
issueOperations.copyText(`${workspaceSlug}/projects/${issue.project_id}/issues/${issue.id}`);
|
||||
issueOperations.copyText(issueLink);
|
||||
};
|
||||
|
||||
const handleRemoveRelation = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
|
||||
@@ -98,7 +102,7 @@ export const RelationIssueListItem: FC<Props> = observer((props) => {
|
||||
<div key={relationIssueId}>
|
||||
<ControlLink
|
||||
id={`issue-${issue.id}`}
|
||||
href={`/${workspaceSlug}/projects/${issue.project_id}/issues/${issue.id}`}
|
||||
href={issueLink}
|
||||
onClick={() => handleIssuePeekOverview(issue)}
|
||||
className="w-full cursor-pointer"
|
||||
>
|
||||
@@ -149,7 +153,7 @@ export const RelationIssueListItem: FC<Props> = observer((props) => {
|
||||
<CustomMenu.MenuItem onClick={handleEditIssue}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Pencil className="h-3.5 w-3.5" strokeWidth={2} />
|
||||
<span>Edit issue</span>
|
||||
<span>Edit</span>
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
@@ -157,7 +161,7 @@ export const RelationIssueListItem: FC<Props> = observer((props) => {
|
||||
<CustomMenu.MenuItem onClick={handleCopyIssueLink}>
|
||||
<div className="flex items-center gap-2">
|
||||
<LinkIcon className="h-3.5 w-3.5" strokeWidth={2} />
|
||||
<span>Copy issue link</span>
|
||||
<span>Copy link</span>
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
|
||||
@@ -174,7 +178,7 @@ export const RelationIssueListItem: FC<Props> = observer((props) => {
|
||||
<CustomMenu.MenuItem onClick={handleDeleteIssue}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Trash className="h-3.5 w-3.5" strokeWidth={2} />
|
||||
<span>Delete issue</span>
|
||||
<span>Delete</span>
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
|
||||
@@ -8,16 +8,12 @@ import { TIssue, TIssueServiceType } from "@plane/types";
|
||||
import { RelationIssueListItem } from "@/components/issues/relations";
|
||||
// Plane-web
|
||||
import { TIssueRelationTypes } from "@/plane-web/types";
|
||||
//
|
||||
import { TRelationIssueOperations } from "../issue-detail-widgets/relations/helper";
|
||||
|
||||
type Props = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
issueId: string;
|
||||
issueIds: string[];
|
||||
relationKey: TIssueRelationTypes;
|
||||
issueOperations: TRelationIssueOperations;
|
||||
handleIssueCrudState: (key: "update" | "delete", issueId: string, issue?: TIssue | null) => void;
|
||||
disabled?: boolean;
|
||||
issueServiceType?: TIssueServiceType;
|
||||
@@ -26,12 +22,10 @@ type Props = {
|
||||
export const RelationIssueList: FC<Props> = observer((props) => {
|
||||
const {
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
issueId,
|
||||
issueIds,
|
||||
relationKey,
|
||||
disabled = false,
|
||||
issueOperations,
|
||||
handleIssueCrudState,
|
||||
issueServiceType = EIssueServiceType.ISSUES,
|
||||
} = props;
|
||||
@@ -44,13 +38,11 @@ export const RelationIssueList: FC<Props> = observer((props) => {
|
||||
<RelationIssueListItem
|
||||
key={relationIssueId}
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issueId={issueId}
|
||||
relationKey={relationKey}
|
||||
relationIssueId={relationIssueId}
|
||||
disabled={disabled}
|
||||
handleIssueCrudState={handleIssueCrudState}
|
||||
issueOperations={issueOperations}
|
||||
issueServiceType={issueServiceType}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -135,14 +135,14 @@ export const LabelStatComponent = observer((props: TLabelStatComponent) => {
|
||||
<SingleProgressStats
|
||||
key={label.id}
|
||||
title={
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="block h-3 w-3 rounded-full"
|
||||
<div className="flex items-center gap-2 truncate">
|
||||
<div
|
||||
className="h-3 w-3 rounded-full flex-shrink-0"
|
||||
style={{
|
||||
backgroundColor: label.color ?? "transparent",
|
||||
}}
|
||||
/>
|
||||
<span className="text-xs">{label.title ?? "No labels"}</span>
|
||||
<p className="text-xs text-ellipsis truncate">{label.title ?? "No labels"}</p>
|
||||
</div>
|
||||
}
|
||||
completed={label.completed}
|
||||
|
||||
@@ -36,6 +36,7 @@ import { TPageInstance } from "@/store/pages/base-page";
|
||||
|
||||
export type TPageActions =
|
||||
| "full-screen"
|
||||
| "sticky-toolbar"
|
||||
| "copy-markdown"
|
||||
| "toggle-lock"
|
||||
| "toggle-access"
|
||||
|
||||
@@ -30,9 +30,9 @@ export const PageOptionsDropdown: React.FC<Props> = observer((props) => {
|
||||
// router
|
||||
const router = useRouter();
|
||||
// store values
|
||||
const { name } = page;
|
||||
const { name, isContentEditable } = page;
|
||||
// page filters
|
||||
const { isFullWidth, handleFullWidth } = usePageFilters();
|
||||
const { isFullWidth, handleFullWidth, isStickyToolbarEnabled, handleStickyToolbar } = usePageFilters();
|
||||
// update query params
|
||||
const { updateQueryParams } = useQueryParams();
|
||||
// menu items list
|
||||
@@ -49,6 +49,18 @@ export const PageOptionsDropdown: React.FC<Props> = observer((props) => {
|
||||
),
|
||||
className: "flex items-center justify-between gap-2",
|
||||
},
|
||||
{
|
||||
key: "sticky-toolbar",
|
||||
action: () => handleStickyToolbar(!isStickyToolbarEnabled),
|
||||
customContent: (
|
||||
<>
|
||||
Sticky toolbar
|
||||
<ToggleSwitch value={isStickyToolbarEnabled} onChange={() => {}} />
|
||||
</>
|
||||
),
|
||||
className: "flex items-center justify-between gap-2",
|
||||
shouldRender: isContentEditable,
|
||||
},
|
||||
{
|
||||
key: "copy-markdown",
|
||||
action: () => {
|
||||
@@ -86,7 +98,16 @@ export const PageOptionsDropdown: React.FC<Props> = observer((props) => {
|
||||
shouldRender: true,
|
||||
},
|
||||
],
|
||||
[editorRef, handleFullWidth, isFullWidth, router, updateQueryParams]
|
||||
[
|
||||
editorRef,
|
||||
handleFullWidth,
|
||||
handleStickyToolbar,
|
||||
isContentEditable,
|
||||
isFullWidth,
|
||||
isStickyToolbarEnabled,
|
||||
router,
|
||||
updateQueryParams,
|
||||
]
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -102,6 +123,7 @@ export const PageOptionsDropdown: React.FC<Props> = observer((props) => {
|
||||
extraOptions={EXTRA_MENU_OPTIONS}
|
||||
optionsOrder={[
|
||||
"full-screen",
|
||||
"sticky-toolbar",
|
||||
"copy-link",
|
||||
"make-a-copy",
|
||||
"move",
|
||||
|
||||
@@ -23,7 +23,7 @@ export const PageEditorHeaderRoot: React.FC<Props> = observer((props) => {
|
||||
// derived values
|
||||
const { isContentEditable } = page;
|
||||
// page filters
|
||||
const { isFullWidth } = usePageFilters();
|
||||
const { isFullWidth, isStickyToolbarEnabled } = usePageFilters();
|
||||
// derived values
|
||||
const resolvedEditorRef = editorRef.current;
|
||||
|
||||
@@ -48,7 +48,9 @@ export const PageEditorHeaderRoot: React.FC<Props> = observer((props) => {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{editorReady && isContentEditable && editorRef.current && <PageToolbar editorRef={editorRef?.current} />}
|
||||
{isStickyToolbarEnabled && editorReady && isContentEditable && editorRef.current && (
|
||||
<PageToolbar editorRef={editorRef?.current} />
|
||||
)}
|
||||
</Header.LeftItem>
|
||||
<PageExtraOptions editorRef={resolvedEditorRef} page={page} />
|
||||
</Header>
|
||||
|
||||
@@ -7,7 +7,7 @@ import { AlertModalCore, TOAST_TYPE, setToast } from "@plane/ui";
|
||||
|
||||
interface IStickyDelete {
|
||||
isOpen: boolean;
|
||||
handleSubmit: () => void;
|
||||
handleSubmit: () => Promise<void>;
|
||||
handleClose: () => void;
|
||||
}
|
||||
|
||||
@@ -20,11 +20,11 @@ export const StickyDeleteModal: React.FC<IStickyDelete> = observer((props) => {
|
||||
try {
|
||||
setLoader(true);
|
||||
await handleSubmit();
|
||||
} catch (error) {
|
||||
} catch {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Warning!",
|
||||
message: "Something went wrong please try again later.",
|
||||
message: "Something went wrong. Please try again later.",
|
||||
});
|
||||
} finally {
|
||||
setLoader(false);
|
||||
@@ -38,7 +38,7 @@ export const StickyDeleteModal: React.FC<IStickyDelete> = observer((props) => {
|
||||
isSubmitting={loader}
|
||||
isOpen={isOpen}
|
||||
title="Delete sticky"
|
||||
content={<>Are you sure you want to delete the sticky? </>}
|
||||
content="Are you sure you want to delete the sticky?"
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -7,18 +7,17 @@ import type { ElementDragPayload } from "@atlaskit/pragmatic-drag-and-drop/eleme
|
||||
import { observer } from "mobx-react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import Masonry from "react-masonry-component";
|
||||
// plane ui
|
||||
import { Loader } from "@plane/ui";
|
||||
// components
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
import { StickiesEmptyState } from "@/components/home/widgets/empty-states/stickies";
|
||||
// constants
|
||||
import { EmptyStateType } from "@/constants/empty-state";
|
||||
// hooks
|
||||
import { useSticky } from "@/hooks/use-stickies";
|
||||
import { useStickyOperations } from "../sticky/use-operations";
|
||||
import { StickiesLoader } from "./stickies-loader";
|
||||
import { StickyDNDWrapper } from "./sticky-dnd-wrapper";
|
||||
import { getInstructionFromPayload } from "./sticky.helpers";
|
||||
import { StickiesEmptyState } from "@/components/home/widgets/empty-states/stickies";
|
||||
|
||||
type TStickiesLayout = {
|
||||
workspaceSlug: string;
|
||||
@@ -42,6 +41,14 @@ export const StickiesList = observer((props: TProps) => {
|
||||
const itemWidth = `${100 / columnCount}%`;
|
||||
const totalRows = Math.ceil(workspaceStickyIds.length / columnCount);
|
||||
const isStickiesPage = pathname?.includes("stickies");
|
||||
const masonryRef = useRef<any>(null);
|
||||
|
||||
const handleLayout = () => {
|
||||
if (masonryRef.current) {
|
||||
// Force reflow
|
||||
masonryRef.current.performLayout();
|
||||
}
|
||||
};
|
||||
|
||||
// Function to determine if an item is in first or last row
|
||||
const getRowPositions = (index: number) => {
|
||||
@@ -72,19 +79,13 @@ export const StickiesList = observer((props: TProps) => {
|
||||
};
|
||||
|
||||
if (loader === "init-loader") {
|
||||
return (
|
||||
<div className="min-h-[500px] overflow-scroll pb-2">
|
||||
<Loader>
|
||||
<Loader.Item height="300px" width="255px" />
|
||||
</Loader>
|
||||
</div>
|
||||
);
|
||||
return <StickiesLoader />;
|
||||
}
|
||||
|
||||
if (loader === "loaded" && workspaceStickyIds.length === 0) {
|
||||
return (
|
||||
<div className="size-full grid place-items-center">
|
||||
{isStickiesPage ? (
|
||||
<div className="size-full grid place-items-center px-2">
|
||||
{isStickiesPage || searchQuery ? (
|
||||
<EmptyState
|
||||
type={searchQuery ? EmptyStateType.STICKIES_SEARCH : EmptyStateType.STICKIES}
|
||||
layout={searchQuery ? "screen-simple" : "screen-detailed"}
|
||||
@@ -106,7 +107,7 @@ export const StickiesList = observer((props: TProps) => {
|
||||
return (
|
||||
<div className="transition-opacity duration-300 ease-in-out">
|
||||
{/* @ts-expect-error type mismatch here */}
|
||||
<Masonry elementType="div">
|
||||
<Masonry elementType="div" ref={masonryRef}>
|
||||
{workspaceStickyIds.map((stickyId, index) => {
|
||||
const { isInFirstRow, isInLastRow } = getRowPositions(index);
|
||||
return (
|
||||
@@ -119,6 +120,7 @@ export const StickiesList = observer((props: TProps) => {
|
||||
isLastChild={index === workspaceStickyIds.length - 1}
|
||||
isInFirstRow={isInFirstRow}
|
||||
isInLastRow={isInLastRow}
|
||||
handleLayout={handleLayout}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
// plane ui
|
||||
import { Loader } from "@plane/ui";
|
||||
|
||||
export const StickiesLoader = () => (
|
||||
<div className="overflow-scroll pb-2 grid grid-cols-4 gap-4">
|
||||
{Array.from({ length: 4 }).map((_, index) => (
|
||||
<Loader key={index} className="space-y-5 border border-custom-border-200 p-3 rounded">
|
||||
<div className="space-y-2">
|
||||
<Loader.Item height="20px" />
|
||||
<Loader.Item height="15px" width="75%" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader.Item height="15px" width="15px" className="flex-shrink-0" />
|
||||
<Loader.Item height="15px" width="100%" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader.Item height="15px" width="15px" className="flex-shrink-0" />
|
||||
<Loader.Item height="15px" width="75%" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader.Item height="15px" width="15px" className="flex-shrink-0" />
|
||||
<Loader.Item height="15px" width="90%" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader.Item height="15px" width="15px" className="flex-shrink-0" />
|
||||
<Loader.Item height="15px" width="60%" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader.Item height="15px" width="15px" className="flex-shrink-0" />
|
||||
<Loader.Item height="15px" width="50%" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader.Item height="25px" width="25px" />
|
||||
<Loader.Item height="25px" width="25px" />
|
||||
<Loader.Item height="25px" width="25px" />
|
||||
</div>
|
||||
<Loader.Item height="25px" width="25px" className="flex-shrink-0" />
|
||||
</div>
|
||||
</Loader>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
@@ -10,7 +10,12 @@ import { useSticky } from "@/hooks/use-stickies";
|
||||
import { ContentOverflowWrapper } from "../../core/content-overflow-HOC";
|
||||
import { StickiesLayout } from "./stickies-list";
|
||||
|
||||
export const StickiesTruncated = observer(() => {
|
||||
type StickiesTruncatedProps = {
|
||||
handleClose?: () => void;
|
||||
};
|
||||
|
||||
export const StickiesTruncated = observer((props: StickiesTruncatedProps) => {
|
||||
const { handleClose = () => {} } = props;
|
||||
// navigation
|
||||
const { workspaceSlug } = useParams();
|
||||
// store hooks
|
||||
@@ -33,6 +38,7 @@ export const StickiesTruncated = observer(() => {
|
||||
className={cn(
|
||||
"gap-1 w-full text-custom-primary-100 text-sm font-medium transition-opacity duration-300 bg-custom-background-90/20"
|
||||
)}
|
||||
onClick={handleClose}
|
||||
>
|
||||
Show all
|
||||
</Link>
|
||||
|
||||
@@ -15,123 +15,126 @@ import { attachInstruction } from "@atlaskit/pragmatic-drag-and-drop-hitbox/tree
|
||||
import { observer } from "mobx-react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { createRoot } from "react-dom/client";
|
||||
// plane types
|
||||
import { InstructionType } from "@plane/types";
|
||||
// plane ui
|
||||
import { DropIndicator } from "@plane/ui";
|
||||
import { cn } from "@plane/utils";
|
||||
// components
|
||||
import { StickyNote } from "../sticky";
|
||||
// helpers
|
||||
import { getInstructionFromPayload } from "./sticky.helpers";
|
||||
|
||||
// Draggable Sticky Wrapper Component
|
||||
export const StickyDNDWrapper = observer(
|
||||
({
|
||||
stickyId,
|
||||
workspaceSlug,
|
||||
itemWidth,
|
||||
isLastChild,
|
||||
isInFirstRow,
|
||||
isInLastRow,
|
||||
handleDrop,
|
||||
}: {
|
||||
stickyId: string;
|
||||
workspaceSlug: string;
|
||||
itemWidth: string;
|
||||
isLastChild: boolean;
|
||||
isInFirstRow: boolean;
|
||||
isInLastRow: boolean;
|
||||
handleDrop: (self: DropTargetRecord, source: ElementDragPayload, location: DragLocationHistory) => void;
|
||||
}) => {
|
||||
const pathName = usePathname();
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [instruction, setInstruction] = useState<InstructionType | undefined>(undefined);
|
||||
const elementRef = useRef<HTMLDivElement>(null);
|
||||
type Props = {
|
||||
stickyId: string;
|
||||
workspaceSlug: string;
|
||||
itemWidth: string;
|
||||
isLastChild: boolean;
|
||||
isInFirstRow: boolean;
|
||||
isInLastRow: boolean;
|
||||
handleDrop: (self: DropTargetRecord, source: ElementDragPayload, location: DragLocationHistory) => void;
|
||||
handleLayout: () => void;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const element = elementRef.current;
|
||||
if (!element) return;
|
||||
export const StickyDNDWrapper = observer((props: Props) => {
|
||||
const { stickyId, workspaceSlug, itemWidth, isLastChild, isInFirstRow, isInLastRow, handleDrop, handleLayout } =
|
||||
props;
|
||||
// states
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [instruction, setInstruction] = useState<InstructionType | undefined>(undefined);
|
||||
// refs
|
||||
const elementRef = useRef<HTMLDivElement>(null);
|
||||
// navigation
|
||||
const pathname = usePathname();
|
||||
|
||||
const initialData = { id: stickyId, type: "sticky" };
|
||||
useEffect(() => {
|
||||
const element = elementRef.current;
|
||||
if (!element) return;
|
||||
|
||||
if (pathName.includes("stickies"))
|
||||
return combine(
|
||||
draggable({
|
||||
element,
|
||||
dragHandle: element,
|
||||
getInitialData: () => initialData,
|
||||
onDragStart: () => {
|
||||
setIsDragging(true);
|
||||
},
|
||||
onDrop: () => {
|
||||
setIsDragging(false);
|
||||
},
|
||||
onGenerateDragPreview: ({ nativeSetDragImage }) => {
|
||||
setCustomNativeDragPreview({
|
||||
getOffset: pointerOutsideOfPreview({ x: "-200px", y: "0px" }),
|
||||
render: ({ container }) => {
|
||||
const root = createRoot(container);
|
||||
root.render(
|
||||
<div className="scale-50">
|
||||
<div className="-m-2 max-h-[150px]">
|
||||
<StickyNote
|
||||
className={"w-[290px]"}
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
stickyId={stickyId}
|
||||
showToolbar={false}
|
||||
/>
|
||||
</div>
|
||||
const initialData = { id: stickyId, type: "sticky" };
|
||||
|
||||
if (pathname.includes("stickies"))
|
||||
return combine(
|
||||
draggable({
|
||||
element,
|
||||
dragHandle: element,
|
||||
getInitialData: () => initialData,
|
||||
onDragStart: () => {
|
||||
setIsDragging(true);
|
||||
},
|
||||
onDrop: () => {
|
||||
setIsDragging(false);
|
||||
},
|
||||
onGenerateDragPreview: ({ nativeSetDragImage }) => {
|
||||
setCustomNativeDragPreview({
|
||||
getOffset: pointerOutsideOfPreview({ x: "-200px", y: "0px" }),
|
||||
render: ({ container }) => {
|
||||
const root = createRoot(container);
|
||||
root.render(
|
||||
<div className="scale-50">
|
||||
<div className="-m-2 max-h-[150px]">
|
||||
<StickyNote
|
||||
className={"w-[290px]"}
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
stickyId={stickyId}
|
||||
showToolbar={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
return () => root.unmount();
|
||||
},
|
||||
nativeSetDragImage,
|
||||
});
|
||||
},
|
||||
}),
|
||||
dropTargetForElements({
|
||||
element,
|
||||
canDrop: ({ source }) => source.data?.type === "sticky",
|
||||
getData: ({ input, element }) => {
|
||||
const blockedStates: InstructionType[] = ["make-child"];
|
||||
if (!isLastChild) {
|
||||
blockedStates.push("reorder-below");
|
||||
}
|
||||
</div>
|
||||
);
|
||||
return () => root.unmount();
|
||||
},
|
||||
nativeSetDragImage,
|
||||
});
|
||||
},
|
||||
}),
|
||||
dropTargetForElements({
|
||||
element,
|
||||
canDrop: ({ source }) => source.data?.type === "sticky",
|
||||
getData: ({ input, element }) => {
|
||||
const blockedStates: InstructionType[] = ["make-child"];
|
||||
if (!isLastChild) {
|
||||
blockedStates.push("reorder-below");
|
||||
}
|
||||
|
||||
return attachInstruction(initialData, {
|
||||
input,
|
||||
element,
|
||||
currentLevel: 1,
|
||||
indentPerLevel: 0,
|
||||
mode: isLastChild ? "last-in-group" : "standard",
|
||||
block: blockedStates,
|
||||
});
|
||||
},
|
||||
onDrag: ({ self, source, location }) => {
|
||||
const instruction = getInstructionFromPayload(self, source, location);
|
||||
setInstruction(instruction);
|
||||
},
|
||||
onDragLeave: () => {
|
||||
setInstruction(undefined);
|
||||
},
|
||||
onDrop: ({ self, source, location }) => {
|
||||
setInstruction(undefined);
|
||||
handleDrop(self, source, location);
|
||||
},
|
||||
})
|
||||
);
|
||||
}, [stickyId, isDragging]);
|
||||
return attachInstruction(initialData, {
|
||||
input,
|
||||
element,
|
||||
currentLevel: 1,
|
||||
indentPerLevel: 0,
|
||||
mode: isLastChild ? "last-in-group" : "standard",
|
||||
block: blockedStates,
|
||||
});
|
||||
},
|
||||
onDrag: ({ self, source, location }) => {
|
||||
const instruction = getInstructionFromPayload(self, source, location);
|
||||
setInstruction(instruction);
|
||||
},
|
||||
onDragLeave: () => {
|
||||
setInstruction(undefined);
|
||||
},
|
||||
onDrop: ({ self, source, location }) => {
|
||||
setInstruction(undefined);
|
||||
handleDrop(self, source, location);
|
||||
},
|
||||
})
|
||||
);
|
||||
}, [handleDrop, isDragging, isLastChild, pathname, stickyId, workspaceSlug]);
|
||||
|
||||
return (
|
||||
<div className="relative" style={{ width: itemWidth }}>
|
||||
{!isInFirstRow && <DropIndicator isVisible={instruction === "reorder-above"} />}
|
||||
<div
|
||||
ref={elementRef}
|
||||
className={cn("flex min-h-[300px] box-border p-2", {
|
||||
"opacity-50": isDragging,
|
||||
})}
|
||||
>
|
||||
<StickyNote key={stickyId || "new"} workspaceSlug={workspaceSlug} stickyId={stickyId} />
|
||||
</div>
|
||||
{!isInLastRow && <DropIndicator isVisible={instruction === "reorder-below"} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col box-border p-[8px]"
|
||||
style={{
|
||||
width: itemWidth,
|
||||
}}
|
||||
>
|
||||
{/* {!isInFirstRow && <DropIndicator isVisible={instruction === "reorder-above"} />} */}
|
||||
<StickyNote
|
||||
key={stickyId || "new"}
|
||||
workspaceSlug={workspaceSlug}
|
||||
stickyId={stickyId}
|
||||
handleLayout={handleLayout}
|
||||
/>
|
||||
{/* {!isInLastRow && <DropIndicator isVisible={instruction === "reorder-below"} />} */}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { Plus, X } from "lucide-react";
|
||||
// plane ui
|
||||
import { RecentStickyIcon } from "@plane/ui";
|
||||
// hooks
|
||||
import { useSticky } from "@/hooks/use-stickies";
|
||||
// components
|
||||
import { StickiesTruncated } from "../layout/stickies-truncated";
|
||||
import { useStickyOperations } from "../sticky/use-operations";
|
||||
import { StickySearch } from "./search";
|
||||
@@ -13,8 +16,11 @@ type TProps = {
|
||||
|
||||
export const Stickies = observer((props: TProps) => {
|
||||
const { handleClose } = props;
|
||||
// navigation
|
||||
const { workspaceSlug } = useParams();
|
||||
// store hooks
|
||||
const { creatingSticky, toggleShowNewSticky } = useSticky();
|
||||
// sticky operations
|
||||
const { stickyOperations } = useStickyOperations({ workspaceSlug: workspaceSlug?.toString() });
|
||||
|
||||
return (
|
||||
@@ -22,9 +28,9 @@ export const Stickies = observer((props: TProps) => {
|
||||
{/* header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
{/* Title */}
|
||||
<div className="text-custom-text-100 flex gap-2">
|
||||
<RecentStickyIcon className="size-5 rotate-90" />
|
||||
<p className="text-lg font-medium">Your stickies</p>
|
||||
<div className="text-custom-text-200 flex items-center gap-2">
|
||||
<RecentStickyIcon className="size-5 rotate-90 flex-shrink-0" />
|
||||
<p className="text-xl font-medium">Your stickies</p>
|
||||
</div>
|
||||
{/* actions */}
|
||||
<div className="flex gap-2">
|
||||
@@ -50,6 +56,7 @@ export const Stickies = observer((props: TProps) => {
|
||||
</button>
|
||||
{handleClose && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
className="flex-shrink-0 grid place-items-center text-custom-text-300 hover:text-custom-text-100 hover:bg-custom-background-80 rounded p-1 transition-colors my-auto"
|
||||
>
|
||||
@@ -60,7 +67,7 @@ export const Stickies = observer((props: TProps) => {
|
||||
</div>
|
||||
{/* content */}
|
||||
<div className="mb-4 max-h-[625px] overflow-scroll">
|
||||
<StickiesTruncated />
|
||||
<StickiesTruncated handleClose={handleClose} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { DebouncedFunc } from "lodash";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
// plane editor
|
||||
import { EditorRefApi } from "@plane/editor";
|
||||
// plane types
|
||||
import { TSticky } from "@plane/types";
|
||||
// plane utils
|
||||
import { cn, isCommentEmpty } from "@plane/utils";
|
||||
// hooks
|
||||
import { useWorkspace } from "@/hooks/store";
|
||||
// components
|
||||
import { StickyEditor } from "../../editor";
|
||||
|
||||
type TProps = {
|
||||
stickyData: TSticky | undefined;
|
||||
stickyData: Partial<TSticky> | undefined;
|
||||
workspaceSlug: string;
|
||||
handleUpdate: DebouncedFunc<(payload: Partial<TSticky>) => Promise<void>>;
|
||||
handleUpdate: (payload: Partial<TSticky>) => void;
|
||||
stickyId: string | undefined;
|
||||
showToolbar?: boolean;
|
||||
handleChange: (data: Partial<TSticky>) => Promise<void>;
|
||||
@@ -24,10 +26,13 @@ export const StickyInput = (props: TProps) => {
|
||||
const { stickyData, workspaceSlug, handleUpdate, stickyId, handleDelete, handleChange, showToolbar } = props;
|
||||
// refs
|
||||
const editorRef = useRef<EditorRefApi>(null);
|
||||
// navigation
|
||||
const pathname = usePathname();
|
||||
// store hooks
|
||||
const { getWorkspaceBySlug } = useWorkspace();
|
||||
// derived values
|
||||
const workspaceId = getWorkspaceBySlug(workspaceSlug)?.id?.toString() ?? "";
|
||||
const isStickiesPage = pathname?.includes("stickies");
|
||||
// form info
|
||||
const { handleSubmit, reset, control } = useForm<TSticky>({
|
||||
defaultValues: {
|
||||
@@ -68,11 +73,20 @@ export const StickyInput = (props: TProps) => {
|
||||
onChange(description_html);
|
||||
handleSubmit(handleFormSubmit)();
|
||||
}}
|
||||
placeholder="Click to type here"
|
||||
containerClassName="px-0 text-base min-h-[250px] w-full"
|
||||
placeholder={(_, value) => {
|
||||
const isContentEmpty = isCommentEmpty(value);
|
||||
if (!isContentEmpty) return "";
|
||||
return "Click to type here";
|
||||
}}
|
||||
containerClassName={cn(
|
||||
"w-full min-h-[256px] max-h-[540px] overflow-y-scroll vertical-scrollbar scrollbar-sm p-4 text-base",
|
||||
{
|
||||
"max-h-[588px]": isStickiesPage,
|
||||
}
|
||||
)}
|
||||
uploadFile={async () => ""}
|
||||
showToolbar={showToolbar}
|
||||
parentClassName={"border-none p-0"}
|
||||
parentClassName="border-none p-0"
|
||||
handleDelete={handleDelete}
|
||||
handleColorChange={handleChange}
|
||||
ref={editorRef}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { debounce } from "lodash";
|
||||
import { observer } from "mobx-react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { Minimize2 } from "lucide-react";
|
||||
// plane types
|
||||
import { TSticky } from "@plane/types";
|
||||
@@ -13,8 +12,7 @@ import { useSticky } from "@/hooks/use-stickies";
|
||||
import { STICKY_COLORS_LIST } from "../../editor/sticky-editor/color-palette";
|
||||
import { StickyDeleteModal } from "../delete-modal";
|
||||
import { StickyInput } from "./inputs";
|
||||
import { StickyItemDragHandle } from "./sticky-item-drag-handle";
|
||||
import { useStickyOperations } from "./use-operations";
|
||||
import { getRandomStickyColor, useStickyOperations } from "./use-operations";
|
||||
|
||||
type TProps = {
|
||||
onClose?: () => void;
|
||||
@@ -22,11 +20,12 @@ type TProps = {
|
||||
className?: string;
|
||||
stickyId: string | undefined;
|
||||
showToolbar?: boolean;
|
||||
handleLayout?: () => void;
|
||||
};
|
||||
export const StickyNote = observer((props: TProps) => {
|
||||
const { onClose, workspaceSlug, className = "", stickyId, showToolbar } = props;
|
||||
const { onClose, workspaceSlug, className = "", stickyId, showToolbar, handleLayout } = props;
|
||||
// navigation
|
||||
const pathName = usePathname();
|
||||
// const pathName = usePathname();
|
||||
// states
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
// store hooks
|
||||
@@ -34,8 +33,8 @@ export const StickyNote = observer((props: TProps) => {
|
||||
// sticky operations
|
||||
const { stickyOperations } = useStickyOperations({ workspaceSlug });
|
||||
// derived values
|
||||
const stickyData = stickyId ? stickies[stickyId] : undefined;
|
||||
const isStickiesPage = pathName?.includes("stickies");
|
||||
const stickyData: Partial<TSticky> = stickyId ? stickies[stickyId] : { background_color: getRandomStickyColor() };
|
||||
// const isStickiesPage = pathName?.includes("stickies");
|
||||
const backgroundColor =
|
||||
STICKY_COLORS_LIST.find((c) => c.key === stickyData?.background_color)?.backgroundColor ||
|
||||
STICKY_COLORS_LIST[0].backgroundColor;
|
||||
@@ -46,6 +45,7 @@ export const StickyNote = observer((props: TProps) => {
|
||||
await stickyOperations.update(stickyId, payload);
|
||||
} else {
|
||||
await stickyOperations.create({
|
||||
...stickyData,
|
||||
...payload,
|
||||
});
|
||||
}
|
||||
@@ -74,12 +74,12 @@ export const StickyNote = observer((props: TProps) => {
|
||||
handleClose={() => setIsDeleteModalOpen(false)}
|
||||
/>
|
||||
<div
|
||||
className={cn("w-full flex flex-col h-fit rounded p-4 group/sticky", className)}
|
||||
className={cn("w-full h-fit flex flex-col rounded group/sticky overflow-y-scroll", className)}
|
||||
style={{
|
||||
backgroundColor,
|
||||
}}
|
||||
>
|
||||
{isStickiesPage && <StickyItemDragHandle isDragging={false} />}{" "}
|
||||
{/* {isStickiesPage && <StickyItemDragHandle isDragging={false} />}{" "} */}
|
||||
{onClose && (
|
||||
<button type="button" className="flex w-full" onClick={onClose}>
|
||||
<Minimize2 className="size-4 m-auto mr-0" />
|
||||
@@ -89,7 +89,10 @@ export const StickyNote = observer((props: TProps) => {
|
||||
<StickyInput
|
||||
stickyData={stickyData}
|
||||
workspaceSlug={workspaceSlug}
|
||||
handleUpdate={debouncedFormSave}
|
||||
handleUpdate={(payload) => {
|
||||
handleLayout?.();
|
||||
debouncedFormSave(payload);
|
||||
}}
|
||||
stickyId={stickyId}
|
||||
handleDelete={() => setIsDeleteModalOpen(true)}
|
||||
handleChange={handleChange}
|
||||
|
||||
@@ -922,7 +922,7 @@ const emptyStateDetails: Record<EmptyStateType, EmptyStateDetails> = {
|
||||
key: EmptyStateType.STICKIES,
|
||||
title: "Stickies are quick notes and to-dos you take down on the fly.",
|
||||
description:
|
||||
"Capture your thoughts and ideas effortlessly by creating stickies that you can access anytime and from anywhere.",
|
||||
"Capture ideas, ahas, brainwaves, light-bulb moments without scrambling for a pen and paper, hunting for the recorder app on your phone, or firing up a notes app only to forget all about it later. Keep them all right next to your work so you can easily come back, build them up, or well, discard them.",
|
||||
path: "/empty-state/stickies/stickies",
|
||||
primaryButton: {
|
||||
icon: <Plus className="size-4" />,
|
||||
@@ -945,8 +945,8 @@ const emptyStateDetails: Record<EmptyStateType, EmptyStateDetails> = {
|
||||
},
|
||||
[EmptyStateType.HOME_WIDGETS]: {
|
||||
key: EmptyStateType.HOME_WIDGETS,
|
||||
title: "It's Quiet Without Widgets, Turn Them On",
|
||||
description: "It looks like all your widgets are turned off. Enable them\nnow to enhance your experience!",
|
||||
title: "So much to add, yet such empty",
|
||||
description: "You have turned off all your widgets. Turn some or\nall of them back on to see delightful things.",
|
||||
path: "/empty-state/dashboard/widgets",
|
||||
primaryButton: {
|
||||
icon: <Shapes className="size-4" />,
|
||||
|
||||
@@ -7,7 +7,7 @@ export const IssuesStoreContext = createContext<EIssuesStoreType | undefined>(un
|
||||
|
||||
export const useIssueStoreType = () => {
|
||||
const storeType = useContext(IssuesStoreContext);
|
||||
const { globalViewId, viewId, projectId, cycleId, moduleId, userId, epicId, teamId } = useParams();
|
||||
const { globalViewId, viewId, projectId, cycleId, moduleId, userId, epicId, teamspaceId } = useParams();
|
||||
|
||||
// If store type exists in context, use that store type
|
||||
if (storeType) return storeType;
|
||||
@@ -27,9 +27,9 @@ export const useIssueStoreType = () => {
|
||||
|
||||
if (projectId) return EIssuesStoreType.PROJECT;
|
||||
|
||||
if (teamId) return EIssuesStoreType.TEAM;
|
||||
if (teamspaceId) return EIssuesStoreType.TEAM;
|
||||
|
||||
if (teamId && viewId) return EIssuesStoreType.TEAM_VIEW;
|
||||
if (teamspaceId && viewId) return EIssuesStoreType.TEAM_VIEW;
|
||||
|
||||
return EIssuesStoreType.PROJECT;
|
||||
};
|
||||
|
||||
@@ -8,12 +8,14 @@ export type TPagesPersonalizationConfig = {
|
||||
full_width: boolean;
|
||||
font_size: TEditorFontSize;
|
||||
font_style: TEditorFontStyle;
|
||||
sticky_toolbar: boolean;
|
||||
};
|
||||
|
||||
const DEFAULT_PERSONALIZATION_VALUES: TPagesPersonalizationConfig = {
|
||||
full_width: false,
|
||||
font_size: "large-font",
|
||||
font_style: "sans-serif",
|
||||
sticky_toolbar: true,
|
||||
};
|
||||
|
||||
export const usePageFilters = () => {
|
||||
@@ -23,7 +25,17 @@ export const usePageFilters = () => {
|
||||
DEFAULT_PERSONALIZATION_VALUES
|
||||
);
|
||||
// stored values
|
||||
const isFullWidth = useMemo(() => !!pagesConfig?.full_width, [pagesConfig?.full_width]);
|
||||
const isFullWidth = useMemo(
|
||||
() => (pagesConfig?.full_width === undefined ? DEFAULT_PERSONALIZATION_VALUES.full_width : pagesConfig?.full_width),
|
||||
[pagesConfig?.full_width]
|
||||
);
|
||||
const isStickyToolbarEnabled = useMemo(
|
||||
() =>
|
||||
pagesConfig?.sticky_toolbar === undefined
|
||||
? DEFAULT_PERSONALIZATION_VALUES.sticky_toolbar
|
||||
: pagesConfig?.sticky_toolbar,
|
||||
[pagesConfig?.sticky_toolbar]
|
||||
);
|
||||
const fontSize = useMemo(
|
||||
() => pagesConfig?.font_size ?? DEFAULT_PERSONALIZATION_VALUES.font_size,
|
||||
[pagesConfig?.font_size]
|
||||
@@ -78,6 +90,18 @@ export const usePageFilters = () => {
|
||||
},
|
||||
[handleUpdateConfig]
|
||||
);
|
||||
/**
|
||||
* @description action to update full_width value
|
||||
* @param {boolean} value
|
||||
*/
|
||||
const handleStickyToolbar = useCallback(
|
||||
(value: boolean) => {
|
||||
handleUpdateConfig({
|
||||
sticky_toolbar: value,
|
||||
});
|
||||
},
|
||||
[handleUpdateConfig]
|
||||
);
|
||||
|
||||
return {
|
||||
fontSize,
|
||||
@@ -86,5 +110,7 @@ export const usePageFilters = () => {
|
||||
handleFontStyle,
|
||||
isFullWidth,
|
||||
handleFullWidth,
|
||||
isStickyToolbarEnabled,
|
||||
handleStickyToolbar,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -117,6 +117,10 @@ export class IssueService extends APIService {
|
||||
if (response.data && this.serviceType === EIssueServiceType.ISSUES) {
|
||||
updateIssue({ ...response.data, is_local_update: 1 });
|
||||
}
|
||||
// add is_epic flag when the service type is epic
|
||||
if (response.data && this.serviceType === EIssueServiceType.EPICS) {
|
||||
response.data.is_epic = true;
|
||||
}
|
||||
return response?.data;
|
||||
})
|
||||
.catch((error) => {
|
||||
|
||||
@@ -21,12 +21,13 @@ export class StickyService extends APIService {
|
||||
async getStickies(
|
||||
workspaceSlug: string,
|
||||
cursor: string,
|
||||
query?: string
|
||||
query?: string,
|
||||
per_page?: number
|
||||
): Promise<{ results: TSticky[]; total_pages: number }> {
|
||||
return this.get(`/api/workspaces/${workspaceSlug}/stickies/`, {
|
||||
params: {
|
||||
cursor,
|
||||
per_page: STICKIES_PER_PAGE,
|
||||
per_page: per_page || STICKIES_PER_PAGE,
|
||||
query,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
TSearchResponse,
|
||||
TSearchEntityRequestPayload,
|
||||
TWidgetEntityData,
|
||||
TActivityEntityData,
|
||||
} from "@plane/types";
|
||||
import { APIService } from "@/services/api.service";
|
||||
// helpers
|
||||
@@ -282,7 +283,7 @@ export class WorkspaceService extends APIService {
|
||||
});
|
||||
}
|
||||
|
||||
// quick links
|
||||
// quicklinks
|
||||
async fetchWorkspaceLinks(workspaceSlug: string): Promise<TLink[]> {
|
||||
return this.get(`/api/workspaces/${workspaceSlug}/quick-links/`)
|
||||
.then((response) => response?.data)
|
||||
@@ -329,7 +330,7 @@ export class WorkspaceService extends APIService {
|
||||
}
|
||||
|
||||
// recents
|
||||
async fetchWorkspaceRecents(workspaceSlug: string, entity_name?: string) {
|
||||
async fetchWorkspaceRecents(workspaceSlug: string, entity_name?: string): Promise<TActivityEntityData[]> {
|
||||
return this.get(`/api/workspaces/${workspaceSlug}/recent-visits/`, {
|
||||
params: {
|
||||
entity_name,
|
||||
|
||||
@@ -188,6 +188,7 @@ export class IssueStore implements IIssueStore {
|
||||
updated_by: issue?.updated_by,
|
||||
is_draft: issue?.is_draft,
|
||||
is_subscribed: issue?.is_subscribed,
|
||||
is_epic: issue?.is_epic,
|
||||
};
|
||||
|
||||
this.rootIssueDetailStore.rootIssueStore.issues.addIssue([issuePayload]);
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
// root store
|
||||
import { RootStore } from "@/plane-web/store/root.store";
|
||||
import { IWorkspaceMembership } from "@/store/member/workspace-member.store";
|
||||
import { IStateStore, StateStore } from "../state.store";
|
||||
// issues data store
|
||||
import { IArchivedIssuesFilter, ArchivedIssuesFilter, IArchivedIssues, ArchivedIssues } from "./archived";
|
||||
import { ICycleIssuesFilter, CycleIssuesFilter, ICycleIssues, CycleIssues } from "./cycle";
|
||||
@@ -44,7 +43,7 @@ import {
|
||||
export interface IIssueRootStore {
|
||||
currentUserId: string | undefined;
|
||||
workspaceSlug: string | undefined;
|
||||
teamId: string | undefined;
|
||||
teamspaceId: string | undefined;
|
||||
projectId: string | undefined;
|
||||
cycleId: string | undefined;
|
||||
moduleId: string | undefined;
|
||||
@@ -112,7 +111,7 @@ export interface IIssueRootStore {
|
||||
export class IssueRootStore implements IIssueRootStore {
|
||||
currentUserId: string | undefined = undefined;
|
||||
workspaceSlug: string | undefined = undefined;
|
||||
teamId: string | undefined = undefined;
|
||||
teamspaceId: string | undefined = undefined;
|
||||
projectId: string | undefined = undefined;
|
||||
cycleId: string | undefined = undefined;
|
||||
moduleId: string | undefined = undefined;
|
||||
@@ -179,7 +178,7 @@ export class IssueRootStore implements IIssueRootStore {
|
||||
constructor(rootStore: RootStore, serviceType: TIssueServiceType = EIssueServiceType.ISSUES) {
|
||||
makeObservable(this, {
|
||||
workspaceSlug: observable.ref,
|
||||
teamId: observable.ref,
|
||||
teamspaceId: observable.ref,
|
||||
projectId: observable.ref,
|
||||
cycleId: observable.ref,
|
||||
moduleId: observable.ref,
|
||||
@@ -203,7 +202,7 @@ export class IssueRootStore implements IIssueRootStore {
|
||||
autorun(() => {
|
||||
if (rootStore?.user?.data?.id) this.currentUserId = rootStore?.user?.data?.id;
|
||||
if (this.workspaceSlug !== rootStore.router.workspaceSlug) this.workspaceSlug = rootStore.router.workspaceSlug;
|
||||
if (this.teamId !== rootStore.router.teamId) this.teamId = rootStore.router.teamId;
|
||||
if (this.teamspaceId !== rootStore.router.teamspaceId) this.teamspaceId = rootStore.router.teamspaceId;
|
||||
if (this.projectId !== rootStore.router.projectId) this.projectId = rootStore.router.projectId;
|
||||
if (this.cycleId !== rootStore.router.cycleId) this.cycleId = rootStore.router.cycleId;
|
||||
if (this.moduleId !== rootStore.router.moduleId) this.moduleId = rootStore.router.moduleId;
|
||||
|
||||
@@ -9,7 +9,7 @@ export interface IRouterStore {
|
||||
setQuery: (query: ParsedUrlQuery) => void;
|
||||
// computed
|
||||
workspaceSlug: string | undefined;
|
||||
teamId: string | undefined;
|
||||
teamspaceId: string | undefined;
|
||||
projectId: string | undefined;
|
||||
cycleId: string | undefined;
|
||||
moduleId: string | undefined;
|
||||
@@ -36,7 +36,7 @@ export class RouterStore implements IRouterStore {
|
||||
setQuery: action.bound,
|
||||
//computed
|
||||
workspaceSlug: computed,
|
||||
teamId: computed,
|
||||
teamspaceId: computed,
|
||||
projectId: computed,
|
||||
cycleId: computed,
|
||||
moduleId: computed,
|
||||
@@ -71,11 +71,11 @@ export class RouterStore implements IRouterStore {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the team id from the query
|
||||
* Returns the teamspace id from the query
|
||||
* @returns string|undefined
|
||||
*/
|
||||
get teamId() {
|
||||
return this.query?.teamId?.toString();
|
||||
get teamspaceId() {
|
||||
return this.query?.teamspaceId?.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -96,7 +96,7 @@ export class StickyStore implements IStickyStore {
|
||||
};
|
||||
|
||||
fetchRecentSticky = async (workspaceSlug: string) => {
|
||||
const response = await this.stickyService.getStickies(workspaceSlug, "1:0:0");
|
||||
const response = await this.stickyService.getStickies(workspaceSlug, "1:0:0", undefined, 1);
|
||||
runInAction(() => {
|
||||
this.recentStickyId = response.results[0]?.id;
|
||||
this.stickies[response.results[0]?.id] = response.results[0];
|
||||
|
||||
+1
-1
@@ -55,7 +55,7 @@
|
||||
"posthog-js": "^1.131.3",
|
||||
"react": "^18.3.1",
|
||||
"react-color": "^2.19.3",
|
||||
"react-day-picker": "^8.10.0",
|
||||
"react-day-picker": "^9.5.0",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-dropzone": "^14.2.3",
|
||||
"react-hook-form": "7.51.5",
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 76 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 83 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user