Compare commits

...

42 Commits

Author SHA1 Message Date
akshat5302 2ff9f1d775 fix: sed cmd warnings fixed 2025-01-31 15:20:57 +05:30
Aaryan Khandelwal ddad1767a2 fix: table flixker on resize (#6524) 2025-01-31 02:31:17 +05:30
Aaryan Khandelwal 6a37a2ce21 fix: link without protocol (#6517) 2025-01-30 20:25:00 +05:30
Aaryan Khandelwal 01bd1bde64 fix: table resize overflow issues (#6520) 2025-01-30 19:27:12 +05:30
Abenezer Belachew 9268180aec path already defined on line 51 (#6427) 2025-01-30 13:36:16 +05:30
Aaryan Khandelwal ff778b98f5 [WEB-3095] fix: recents widget title truncate (#6512)
* fix: recents widget title truncate

* chore: revert prop changes

* chore: revert list item changes
2025-01-30 13:34:42 +05:30
Sangeetha 8f5ce6b232 feat: user preference url and sort order change (#6505)
* fix: change url

* Change order of user preference keys
2025-01-30 13:29:39 +05:30
Anmol Singh Bhatia 58a4ca9f36 chore: board layout padding improvement (#6507) 2025-01-29 16:56:27 +05:30
guru_sainath 312b077657 [WEB-3177] fix: resolve cycle creation issue for equal start_date and completed_date (#6504)
* fix: fixed cycle startdate if the the start_date and completed cyles dates are today

* chore: updated validation for date match
2025-01-29 16:35:25 +05:30
Bavisetti Narayan c65e42f807 chore: add attachment in intake issue (#6503) 2025-01-29 15:53:34 +05:30
Prateek Shourya f4af78c0fc [WEB-3218] fix: redirection for cross projects issue relations (#6457) 2025-01-29 13:00:24 +05:30
Prateek Shourya c0b6abc3d5 refactor: minor store level changes (#6500) 2025-01-29 01:04:54 +05:30
sriram veeraghanta 2f2e6626c6 fix: typo fixes 2025-01-28 21:07:59 +05:30
Sangeetha 6a8d3202b7 feat: workspace user preference api (#6497)
* feat: workspace user preference api

* feat: remove sort order calculation

* Return 404 error
2025-01-28 20:50:24 +05:30
Bavisetti Narayan 51b52a7fc3 [WEB-3249] chore: delete the user recent visits (#6496)
* chore: delete the user recent visits

* chore: hard deleted the recent visits
2025-01-28 20:50:15 +05:30
Bavisetti Narayan 23ede81737 chore: update project state (#6467) 2025-01-28 20:49:36 +05:30
Aaryan Khandelwal b698f44500 [PE-155] chore: floating toolbar for pages (#6482)
* chore: add floating toolbar to pages

* fix: locked page toolbar
2025-01-28 20:21:09 +05:30
M. Palanikannan 421839ec51 [PE-255] fix: remove drag handles from content within table cells (#6487)
* fix: remove drag handles from content within table cells

* style: table cell padding

* style: table cell padding

* fix: insert resizable tables

---------

Co-authored-by: Aaryan Khandelwal <aaryankhandu123@gmail.com>
2025-01-28 20:20:40 +05:30
M. Palanikannan 940b5e4e44 fix: custom color extension markdown rule added now (#6471) 2025-01-28 20:20:23 +05:30
Aaryan Khandelwal 6003c88d62 fix: disable comment submit while uploading an image (#6445) 2025-01-28 20:19:01 +05:30
Aaryan Khandelwal 74913a6659 fix: page name and recents empty state (#6491) 2025-01-28 17:13:20 +05:30
sriram veeraghanta 97578684c6 chore: lock file updates 2025-01-28 16:18:30 +05:30
Aaryan Khandelwal 88b4d32220 [WEB-3237, 3238] dev: date picker enhancements (#6470)
* [WEB-3238] dev: datepicker with month and year selection dropdowns (#6391)

* feat: react-day-picker upgrade and caption dropdowns

* style fixes

* style: css and autofocus improved

* fix: fixed weeks for datepicker to ensure static height

---------

Co-authored-by: Vineet K <55555696+vineetk13@users.noreply.github.com>
2025-01-28 16:15:18 +05:30
Aaryan Khandelwal f32635a6a8 [WEB-3203] fix: stickies height, overflow (#6484)
* fix: stickies height

* chore: remove unused drop indicators
2025-01-28 15:33:25 +05:30
Bavisetti Narayan 7fe58e0ea9 chore: added estimate point value in expand issues (#6483) 2025-01-28 15:11:33 +05:30
Prateek Shourya 7f22cd1ac1 [WEB-3229] fix: issue creation from modal (#6460) 2025-01-27 13:13:32 +05:30
Prateek Shourya e2550e0b2d [WEB-3201] improvement: minor enhancements for tree map text size and color (#6458) 2025-01-24 19:20:44 +05:30
Aaryan Khandelwal b016ed78cf [PE-248] regression: recent widgets refactor for scalability (#6456)
* fix: recent widgets types and filters

* fix: recent widgets types
2025-01-24 17:23:15 +05:30
Aaryan Khandelwal c429ca7b36 refactor: recents widget for scalability (#6453)
Co-authored-by: pushya22 <130810100+pushya22@users.noreply.github.com>
2025-01-24 15:34:28 +05:30
Bavisetti Narayan ee22dbba1b chore: added a condition to restrict duplicate user creation (#6447) 2025-01-24 15:33:08 +05:30
Vamsi Krishna f4a208bd44 fix: handled label overflow in modules (#6451) 2025-01-24 15:32:44 +05:30
Aaryan Khandelwal 8edff26ccd fix: space app editor colors (#6446) 2025-01-24 15:27:57 +05:30
Aaryan Khandelwal d08c03f557 [WEB-3203] fix: dashboard widgets' empty state content and assets (#6450)
* fix: empty state content

* chore: replace margin with padding
2025-01-24 15:23:41 +05:30
Prateek Shourya 0b53912295 [WEB-3207] chore: add state_id, priority and assignee_ids to create issue relation response (#6448) 2025-01-23 14:16:06 +05:30
Prateek Shourya 586a320d86 fix: minor fixes for issue relations list and retrival (#6444) 2025-01-23 12:42:35 +05:30
Vamsi Krishna 8f3a0be177 [WEB-3194]fix: stickies modal close while redirection (#6440)
* fix: stickies modal close while redirection

* chore: added deafult for optional prop
2025-01-22 14:22:24 +05:30
Prateek Shourya 0679e140a2 [WEB-3192] fix: issue relation redirection (#6441) 2025-01-22 14:01:21 +05:30
guru_sainath b611f5110f chore: issue and issue description version endpoints (#6434) 2025-01-21 20:34:43 +05:30
Akshita Goyal 0f7bc6979f fix: new sticky color + recent sticky api call + sticky max height (#6438) 2025-01-21 20:34:00 +05:30
Prateek Shourya 12501d0597 fix: add optional chaning check for actor details (#6437) 2025-01-21 20:33:38 +05:30
Aaryan Khandelwal 3a86fff7c1 [WEB-3045] fix: sticky placeholder, gray color value (#6436)
* fix: sticky placeholder

* chore: update gray color
2025-01-21 20:32:45 +05:30
Aaryan Khandelwal 58a4b45463 [WEB-3045] fix: stickies bugs (#6433)
* fix: stickies bugs

* fix: sticky height fixed

---------

Co-authored-by: gakshita <akshitagoyal1516@gmail.com>
2025-01-21 16:37:27 +05:30
102 changed files with 1636 additions and 904 deletions
@@ -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
+2
View File
@@ -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
+2
View File
@@ -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
+5
View File
@@ -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 (
+99 -2
View File
@@ -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"]
+22
View File
@@ -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",
),
]
+12
View File
@@ -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",
),
]
+3
View File
@@ -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,
+8
View File
@@ -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)
+8
View File
@@ -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)}),
+118
View File
@@ -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)
+8
View File
@@ -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)
+8
View File
@@ -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)
+17
View File
@@ -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):
+8
View File
@@ -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
)
-1
View File
@@ -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(),
+2 -1
View File
@@ -69,7 +69,8 @@ from .workspace import (
WorkspaceTheme,
WorkspaceUserProperties,
WorkspaceUserLink,
WorkspaceHomePreference
WorkspaceHomePreference,
WorkspaceUserPreference,
)
from .favorite import UserFavorite
+2 -3
View File
@@ -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",
+26 -9
View File
@@ -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):
+8 -7
View File
@@ -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
+2 -1
View File
@@ -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;
+9 -7
View File
@@ -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);
+2 -2
View File
@@ -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%;
+4 -4
View File
@@ -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;
+47 -1
View File
@@ -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)}%
+8 -3
View File
@@ -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">
+8 -3
View File
@@ -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>
);
@@ -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 dont 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(),
@@ -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>
);
+20 -6
View File
@@ -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}
+13 -10
View File
@@ -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}
+3 -3
View File
@@ -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" />,
+3 -3
View File
@@ -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;
};
+27 -1
View File
@@ -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,
};
};
+4
View File
@@ -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) => {
+3 -2
View File
@@ -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,
},
})
+3 -2
View File
@@ -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]);
+4 -5
View File
@@ -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;
+5 -5
View File
@@ -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();
}
/**
+1 -1
View File
@@ -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
View File
@@ -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