Compare commits

...

54 Commits

Author SHA1 Message Date
pablohashescobar e2ecd4db0b dev: update server configuration commands 2024-02-07 11:57:40 +05:30
Nikhil 751b15a7a7 dev: update the response for conflicting errors (#3568) 2024-02-06 17:00:42 +05:30
Bavisetti Narayan ac22769220 chore: email trigger for new assignee (#3572) 2024-02-06 16:47:14 +05:30
Manish Gupta 46ae0f98dc app_release value handled (#3571) 2024-02-06 15:35:05 +05:30
Anmol Singh Bhatia 30aaec9097 chore: module date validation (#3565) 2024-02-05 20:46:01 +05:30
Anmol Singh Bhatia 4041c5bc5b chore: cycle and module sidebar improvement (#3562) 2024-02-05 19:13:21 +05:30
Bavisetti Narayan 403595a897 chore: removed email notification for new users (#3561) 2024-02-05 19:13:02 +05:30
Aaryan Khandelwal 0ee93dfd8c chore: added None filter option to the dashboard widgets (#3556)
* chore: added tab change animation

* chore: widgets filtering logic updated

* refactor: issues list widget

* fix: tab navigation transition

* fix: extra top spacing on opening the peek overview
2024-02-05 19:12:33 +05:30
Anmol Singh Bhatia ee0e3e2e25 chore: active cycle issue transfer validation (#3560)
* fix: completed cycle list layout validation

* fix: completed cycle kanban layout validation

* fix: completed cycle spreadsheet layout validation

* fix: date dropdown disabled fix

* chore: quick action validation added for list, kanban and spreadsheet layout

* fix: calendar layout validation added
2024-02-05 14:47:40 +05:30
Lakhan Baheti 0165abab3e chore: posthog events improved (#3554)
* chore: events naming convention changed

* chore: track element added for project related events

* chore: track element added for cycle related events

* chore: track element added for module related events

* chore: issue related events updated

* refactor: event tracker store

* refactor: event-tracker store

* fix: posthog changes

---------

Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
2024-02-05 13:19:07 +05:30
Anmol Singh Bhatia 7d07afd59c chore: cycle and module sidebar analytics improvement (#3559)
* chore: cycle and module store update action updated

* chore: cycle and module issue store actions updated

* chore: cycle and module retrieve endpoints updated

* fix: app sidebar z index and priority icon fix

* chore: cycle and module sidebar and stats updated
2024-02-05 12:34:53 +05:30
sriram veeraghanta efaba43494 Merge branch 'preview' of github.com:makeplane/plane into develop 2024-02-03 14:55:38 +05:30
sriram veeraghanta a8ec2b6914 fix: error handling in pages list page 2024-02-03 00:35:29 +05:30
Nikhil 39eb8c98d1 dev: validation for external id and external source (#3552)
* dev: error response for duplicate items created through external apis

* dev: return identifier and also add the validation for state

* fix: validation for external id and external source
2024-02-02 16:43:05 +05:30
rahulramesha 138d06868b fix: all issues spreadsheet sorting and kanban dnd for long lists (#3550)
* fix all issues filter for spreadsheet view

* fix kanban dnd with long lists
2024-02-02 14:50:23 +05:30
Ramesh Kumar Chandra 2eab3b41a2 chore: responsive and styling fixes (#3541) 2024-02-02 14:49:42 +05:30
Anmol Singh Bhatia 662b497082 fix: create issue modal project select (#3549) 2024-02-02 14:29:59 +05:30
Anmol Singh Bhatia 67cf1785b8 chore: breadcrumb component improvement (#3537)
* chore: breadcrumb component improvement

* chore: code refactor
2024-02-01 18:13:30 +05:30
Aaryan Khandelwal 7d08a57be6 chore: remove build warnings (#3534)
* chore: remove build warnings

* fix: posthog wrapper fixes

---------

Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
2024-02-01 14:53:08 +05:30
Anmol Singh Bhatia b0ad48e35a chore: enhance loading state for setting page updates (#3533) 2024-02-01 13:35:01 +05:30
Anmol Singh Bhatia d68669df51 fix: resolved issue with resetting the draft issue form (#3532) 2024-02-01 13:33:08 +05:30
Anmol Singh Bhatia 4e600e4e9b fix: update cycle error (#3530)
* fix: update cycle response and implement required changes

* chore: update cycle response
2024-02-01 13:32:37 +05:30
Lakhan Baheti 4fc4da7982 fix: email-template heading overview (#3525)
* fix: email-template heading

* fix: typo
2024-01-31 20:16:47 +05:30
M. Palanikannan 6f210e1f4b chore: removing unnecessary code (#3524)
* fix: adding back enter key extension with mentions

* removed unncessary code
2024-01-31 19:38:37 +05:30
Aaryan Khandelwal f7803dab56 chore: added validation to cloud hostname field (#3523) 2024-01-31 18:06:57 +05:30
M. Palanikannan 70172f8e3d fix: adding back enter key extension with mentions (#3499) 2024-01-31 18:06:12 +05:30
M. Palanikannan 21bc668a56 fix: horizontal rule no more causes issues on last node (#3507) 2024-01-31 18:05:06 +05:30
Anmol Singh Bhatia dc5a5f4a91 fix/draft issue modal focus issue fix (#3522) 2024-01-31 17:47:47 +05:30
Anmol Singh Bhatia 2c67aced15 fix: cycle and module sidebar mutation fix (#3521)
* fix: cycle and module sidebar mutation

* fix: cycle and module calendar drag n drop fix
2024-01-31 17:09:24 +05:30
Ramesh Kumar Chandra 3a4c893368 Style/workspace project settings layout (#3520)
* style: project settings layout for mobile screens

* style: workspace settings layout for mobile screens
2024-01-31 15:42:20 +05:30
Aaryan Khandelwal 0d036e6bf5 refactor: dropdown button components (#3508)
* refactor: dropdown button components

* chore: dropdowns accessibility improvement

* chore: update module dropdown

* chore: update option content

* chore: hide icon from the peek overview

---------

Co-authored-by: Anmol Singh Bhatia <anmolsinghbhatia@plane.so>
2024-01-31 15:36:55 +05:30
rahulramesha 3ef0570f6a Fix all issues custom views by defining list layout for my-issue (#3517) 2024-01-31 13:59:46 +05:30
Prateek Shourya c9d2ea36b8 chore: allow guests/ viewers to comment. (#3515) 2024-01-30 20:13:28 +05:30
Lakhan Baheti f0836ceb10 chore: email-template changes overview (#3494)
* style: template design

* fix: project url and actors

* fix: heading jinja

* fix: typo in workspace

* fix: issue title change

* fix: top project, issue redirection

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
2024-01-30 20:12:59 +05:30
Anmol Singh Bhatia 888665783e feat: issue highlighting (#3514)
* chore: kanban issue highlight

* chore: issue highlighting added
2024-01-30 20:12:39 +05:30
Prateek Shourya 817737b2c0 improvement: add hide/ unhide icon for all password field in the platform. (#3513) 2024-01-30 20:11:16 +05:30
rahulramesha 638c1e21c9 fix quick add issue creation in cycles (#3511) 2024-01-30 18:03:49 +05:30
Prateek Shourya c67e097fc2 chore: fix assignee tooltip logic in list and kanban layout for consistency. (#3510) 2024-01-30 16:25:13 +05:30
guru_sainath 804dd8300d chore: implemented multiple modules select in the issues (#3484)
* fix: add multiple module in an issue

* feat: implemented multiple modules select in the issue detail and issue peekoverview and resolved build errors.

* feat: handled module parameters type error in the issue create and draft modal

* feat: handled UI for modules select dropdown

* fix: delete module activity updated

* ui: module issue activity

* fix: module search endpoint and issue fetch in the modules

* fix: module ids optimized

* fix: replaced module_id from boolean to array of module Id's in module search modal params

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
2024-01-30 15:23:20 +05:30
Ramesh Kumar Chandra c6d6b9a0e9 style/responsive sidebar (#3505)
* style: added sidebar toggle in all the screens for mobile responsive

* chore: close sidebar on click of empty space when opened

* chore: setting the sidebar collapsed in smaller screens
2024-01-30 15:22:24 +05:30
Aaryan Khandelwal 9debd81a50 dev: show all issues on the gantt chart (#3487) 2024-01-30 14:25:15 +05:30
Anmol Singh Bhatia d53a086206 chore: project active cycle and linear progress indicator improvement (#3504)
* chore: linear progress indicator improvement

* chore: project active cycle improvement
2024-01-30 13:59:49 +05:30
Anmol Singh Bhatia ef8472ce5e chore: unused var removed (#3503) 2024-01-30 13:59:07 +05:30
Anmol Singh Bhatia 4aa34f3eda fix: create update cycle modal project id pre-load data updated (#3502) 2024-01-30 13:58:18 +05:30
Anmol Singh Bhatia c7616fda11 chore: empty state theme improvement (#3501) 2024-01-29 20:38:32 +05:30
Anmol Singh Bhatia 483fc57601 chore: issue sidebar and peek overview improvement (#3488)
* chore: issue peek overview and sidebar properties focused state improvement

* fix: added name of the issue in issue relation

* chore: issue sidebar and peek overview properties improvement

* chore: issue assignee improvement for sidebar and peek overview

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
2024-01-29 20:36:14 +05:30
Aaryan Khandelwal 09a1a55da8 fix: unique key errors (#3500) 2024-01-29 20:35:23 +05:30
Anmol Singh Bhatia 61f92563a9 fix: profile issue application error (#3496)
* fix: profile issue application error

* chore: app sidebar projects updated to your projects

* fix: profile issues update
2024-01-29 18:04:25 +05:30
rahulramesha 00e07443b0 fix: Add missing project and subscriber filters to the list of filter params (#3497)
* add missing project and subscriber filters to the list of filter params

* add toast message on successfully marking all notifications as read
2024-01-29 17:26:48 +05:30
sriram veeraghanta c4efdcd704 Merge branch 'preview' of github.com:makeplane/plane into develop 2024-01-29 16:12:57 +05:30
Aaryan Khandelwal 3c9679dff9 chore: update time in real-time in dashboard and profile sidebar (#3489)
* chore: update dashboard and profile time in realtime

* chore: remove seconds

* fix: cycle and module sidebar datepicker
2024-01-29 15:42:57 +05:30
rahulramesha b3393f5c48 fix: Filters in all issues and enable dnd in kanban based on roles (#3493)
* fix filters mutation issue

* fix all issue filters for state group

* disable drag in Kanban for non members

* remove unused imports
2024-01-29 15:27:14 +05:30
dependabot[bot] f995736642 chore(deps): bump cryptography in /apiserver/requirements (#3492)
Bumps [cryptography](https://github.com/pyca/cryptography) from 41.0.5 to 41.0.6.
- [Changelog](https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pyca/cryptography/compare/41.0.5...41.0.6)

---
updated-dependencies:
- dependency-name: cryptography
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-01-29 15:26:08 +05:30
Anmol Singh Bhatia f7f1f2bea4 fix: issue preload data in issue create update modal (#3491) 2024-01-29 14:08:45 +05:30
310 changed files with 6872 additions and 5320 deletions
+1
View File
@@ -41,6 +41,7 @@ USER captain
# Add in Django deps and generate Django's static files
COPY manage.py manage.py
COPY server.py server.py
COPY plane plane/
COPY templates templates/
COPY package.json package.json
+1 -1
View File
@@ -28,4 +28,4 @@ python manage.py configure_instance
# Create the default bucket
python manage.py create_bucket
exec gunicorn -w $GUNICORN_WORKERS -k uvicorn.workers.UvicornWorker plane.asgi:application --bind 0.0.0.0:${PORT:-8000} --max-requests 1200 --max-requests-jitter 1000 --access-logfile -
python server.py
+40
View File
@@ -243,6 +243,29 @@ class CycleAPIEndpoint(WebhookMixin, BaseAPIView):
):
serializer = CycleSerializer(data=request.data)
if serializer.is_valid():
if (
request.data.get("external_id")
and request.data.get("external_source")
and Cycle.objects.filter(
project_id=project_id,
workspace__slug=slug,
external_source=request.data.get("external_source"),
external_id=request.data.get("external_id"),
).exists()
):
cycle = Cycle.objects.filter(
workspace__slug=slug,
project_id=project_id,
external_source=request.data.get("external_source"),
external_id=request.data.get("external_id"),
).first()
return Response(
{
"error": "Cycle with the same external id and external source already exists",
"id": str(cycle.id),
},
status=status.HTTP_409_CONFLICT,
)
serializer.save(
project_id=project_id,
owned_by=request.user,
@@ -289,6 +312,23 @@ class CycleAPIEndpoint(WebhookMixin, BaseAPIView):
serializer = CycleSerializer(cycle, data=request.data, partial=True)
if serializer.is_valid():
if (
request.data.get("external_id")
and (cycle.external_id != request.data.get("external_id"))
and Cycle.objects.filter(
project_id=project_id,
workspace__slug=slug,
external_source=request.data.get("external_source", cycle.external_source),
external_id=request.data.get("external_id"),
).exists()
):
return Response(
{
"error": "Cycle with the same external id and external source already exists",
"id": str(cycle.id),
},
status=status.HTTP_409_CONFLICT,
)
serializer.save()
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
+97 -2
View File
@@ -220,6 +220,30 @@ class IssueAPIEndpoint(WebhookMixin, BaseAPIView):
)
if serializer.is_valid():
if (
request.data.get("external_id")
and request.data.get("external_source")
and Issue.objects.filter(
project_id=project_id,
workspace__slug=slug,
external_source=request.data.get("external_source"),
external_id=request.data.get("external_id"),
).exists()
):
issue = Issue.objects.filter(
workspace__slug=slug,
project_id=project_id,
external_id=request.data.get("external_id"),
external_source=request.data.get("external_source"),
).first()
return Response(
{
"error": "Issue with the same external id and external source already exists",
"id": str(issue.id),
},
status=status.HTTP_409_CONFLICT,
)
serializer.save()
# Track the issue
@@ -256,6 +280,26 @@ class IssueAPIEndpoint(WebhookMixin, BaseAPIView):
partial=True,
)
if serializer.is_valid():
if (
str(request.data.get("external_id"))
and (issue.external_id != str(request.data.get("external_id")))
and Issue.objects.filter(
project_id=project_id,
workspace__slug=slug,
external_source=request.data.get(
"external_source", issue.external_source
),
external_id=request.data.get("external_id"),
).exists()
):
return Response(
{
"error": "Issue with the same external id and external source already exists",
"id": str(issue.id),
},
status=status.HTTP_409_CONFLICT,
)
serializer.save()
issue_activity.delay(
type="issue.activity.updated",
@@ -263,6 +307,8 @@ class IssueAPIEndpoint(WebhookMixin, BaseAPIView):
actor_id=str(request.user.id),
issue_id=str(pk),
project_id=str(project_id),
external_id__isnull=False,
external_source__isnull=False,
current_instance=current_instance,
epoch=int(timezone.now().timestamp()),
)
@@ -318,6 +364,30 @@ class LabelAPIEndpoint(BaseAPIView):
try:
serializer = LabelSerializer(data=request.data)
if serializer.is_valid():
if (
request.data.get("external_id")
and request.data.get("external_source")
and Label.objects.filter(
project_id=project_id,
workspace__slug=slug,
external_source=request.data.get("external_source"),
external_id=request.data.get("external_id"),
).exists()
):
label = Label.objects.filter(
workspace__slug=slug,
project_id=project_id,
external_id=request.data.get("external_id"),
external_source=request.data.get("external_source"),
).first()
return Response(
{
"error": "Label with the same external id and external source already exists",
"id": str(label.id),
},
status=status.HTTP_409_CONFLICT,
)
serializer.save(project_id=project_id)
return Response(
serializer.data, status=status.HTTP_201_CREATED
@@ -326,11 +396,17 @@ class LabelAPIEndpoint(BaseAPIView):
serializer.errors, status=status.HTTP_400_BAD_REQUEST
)
except IntegrityError:
label = Label.objects.filter(
workspace__slug=slug,
project_id=project_id,
name=request.data.get("name"),
).first()
return Response(
{
"error": "Label with the same name already exists in the project"
"error": "Label with the same name already exists in the project",
"id": str(label.id),
},
status=status.HTTP_400_BAD_REQUEST,
status=status.HTTP_409_CONFLICT,
)
def get(self, request, slug, project_id, pk=None):
@@ -357,6 +433,25 @@ class LabelAPIEndpoint(BaseAPIView):
label = self.get_queryset().get(pk=pk)
serializer = LabelSerializer(label, data=request.data, partial=True)
if serializer.is_valid():
if (
str(request.data.get("external_id"))
and (label.external_id != str(request.data.get("external_id")))
and Issue.objects.filter(
project_id=project_id,
workspace__slug=slug,
external_source=request.data.get(
"external_source", label.external_source
),
external_id=request.data.get("external_id"),
).exists()
):
return Response(
{
"error": "Label with the same external id and external source already exists",
"id": str(label.id),
},
status=status.HTTP_409_CONFLICT,
)
serializer.save()
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
+41 -1
View File
@@ -132,6 +132,29 @@ class ModuleAPIEndpoint(WebhookMixin, BaseAPIView):
},
)
if serializer.is_valid():
if (
request.data.get("external_id")
and request.data.get("external_source")
and Module.objects.filter(
project_id=project_id,
workspace__slug=slug,
external_source=request.data.get("external_source"),
external_id=request.data.get("external_id"),
).exists()
):
module = Module.objects.filter(
project_id=project_id,
workspace__slug=slug,
external_source=request.data.get("external_source"),
external_id=request.data.get("external_id"),
).first()
return Response(
{
"error": "Module with the same external id and external source already exists",
"id": str(module.id),
},
status=status.HTTP_409_CONFLICT,
)
serializer.save()
module = Module.objects.get(pk=serializer.data["id"])
serializer = ModuleSerializer(module)
@@ -149,8 +172,25 @@ class ModuleAPIEndpoint(WebhookMixin, BaseAPIView):
partial=True,
)
if serializer.is_valid():
if (
request.data.get("external_id")
and (module.external_id != request.data.get("external_id"))
and Module.objects.filter(
project_id=project_id,
workspace__slug=slug,
external_source=request.data.get("external_source", module.external_source),
external_id=request.data.get("external_id"),
).exists()
):
return Response(
{
"error": "Module with the same external id and external source already exists",
"id": str(module.id),
},
status=status.HTTP_409_CONFLICT,
)
serializer.save()
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def get(self, request, slug, project_id, pk=None):
+41
View File
@@ -38,6 +38,30 @@ class StateAPIEndpoint(BaseAPIView):
data=request.data, context={"project_id": project_id}
)
if serializer.is_valid():
if (
request.data.get("external_id")
and request.data.get("external_source")
and State.objects.filter(
project_id=project_id,
workspace__slug=slug,
external_source=request.data.get("external_source"),
external_id=request.data.get("external_id"),
).exists()
):
state = State.objects.filter(
workspace__slug=slug,
project_id=project_id,
external_id=request.data.get("external_id"),
external_source=request.data.get("external_source"),
).first()
return Response(
{
"error": "State with the same external id and external source already exists",
"id": str(state.id),
},
status=status.HTTP_409_CONFLICT,
)
serializer.save(project_id=project_id)
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@@ -91,6 +115,23 @@ class StateAPIEndpoint(BaseAPIView):
)
serializer = StateSerializer(state, data=request.data, partial=True)
if serializer.is_valid():
if (
str(request.data.get("external_id"))
and (state.external_id != str(request.data.get("external_id")))
and State.objects.filter(
project_id=project_id,
workspace__slug=slug,
external_source=request.data.get("external_source", state.external_source),
external_id=request.data.get("external_id"),
).exists()
):
return Response(
{
"error": "State with the same external id and external source already exists",
"id": str(state.id),
},
status=status.HTTP_409_CONFLICT,
)
serializer.save()
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
-1
View File
@@ -33,7 +33,6 @@ class CycleWriteSerializer(BaseSerializer):
class CycleSerializer(BaseSerializer):
owned_by = UserLiteSerializer(read_only=True)
is_favorite = serializers.BooleanField(read_only=True)
total_issues = serializers.IntegerField(read_only=True)
cancelled_issues = serializers.IntegerField(read_only=True)
+10 -2
View File
@@ -304,6 +304,7 @@ class IssueRelationSerializer(BaseSerializer):
sequence_id = serializers.IntegerField(
source="related_issue.sequence_id", read_only=True
)
name = serializers.CharField(source="related_issue.name", read_only=True)
relation_type = serializers.CharField(read_only=True)
class Meta:
@@ -313,6 +314,7 @@ class IssueRelationSerializer(BaseSerializer):
"project_id",
"sequence_id",
"relation_type",
"name",
]
read_only_fields = [
"workspace",
@@ -328,6 +330,7 @@ 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)
class Meta:
@@ -337,6 +340,7 @@ class RelatedIssueSerializer(BaseSerializer):
"project_id",
"sequence_id",
"relation_type",
"name",
]
read_only_fields = [
"workspace",
@@ -558,7 +562,7 @@ class IssueSerializer(DynamicBaseSerializer):
state_id = serializers.PrimaryKeyRelatedField(read_only=True)
parent_id = serializers.PrimaryKeyRelatedField(read_only=True)
cycle_id = serializers.PrimaryKeyRelatedField(read_only=True)
module_id = serializers.PrimaryKeyRelatedField(read_only=True)
module_ids = serializers.SerializerMethodField()
# Many to many
label_ids = serializers.PrimaryKeyRelatedField(
@@ -593,7 +597,7 @@ class IssueSerializer(DynamicBaseSerializer):
"project_id",
"parent_id",
"cycle_id",
"module_id",
"module_ids",
"label_ids",
"assignee_ids",
"sub_issues_count",
@@ -609,6 +613,10 @@ class IssueSerializer(DynamicBaseSerializer):
]
read_only_fields = fields
def get_module_ids(self, obj):
# Access the prefetched modules and extract module IDs
return [module for module in obj.issue_module.values_list("module_id", flat=True)]
class IssueLiteSerializer(DynamicBaseSerializer):
workspace_detail = WorkspaceLiteSerializer(
+12 -3
View File
@@ -35,17 +35,26 @@ urlpatterns = [
name="project-modules",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/modules/<uuid:module_id>/module-issues/",
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/modules/",
ModuleIssueViewSet.as_view(
{
"post": "create_issue_modules",
}
),
name="issue-module",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/modules/<uuid:module_id>/issues/",
ModuleIssueViewSet.as_view(
{
"post": "create_module_issues",
"get": "list",
"post": "create",
}
),
name="project-module-issues",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/modules/<uuid:module_id>/module-issues/<uuid:issue_id>/",
"workspaces/<str:slug>/projects/<uuid:project_id>/modules/<uuid:module_id>/issues/<uuid:issue_id>/",
ModuleIssueViewSet.as_view(
{
"get": "retrieve",
+14 -19
View File
@@ -242,13 +242,13 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
.values("display_name", "assignee_id", "avatar")
.annotate(
total_issues=Count(
"assignee_id",
"id",
filter=Q(archived_at__isnull=True, is_draft=False),
),
)
.annotate(
completed_issues=Count(
"assignee_id",
"id",
filter=Q(
completed_at__isnull=False,
archived_at__isnull=True,
@@ -258,7 +258,7 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
)
.annotate(
pending_issues=Count(
"assignee_id",
"id",
filter=Q(
completed_at__isnull=True,
archived_at__isnull=True,
@@ -281,13 +281,13 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
.values("label_name", "color", "label_id")
.annotate(
total_issues=Count(
"label_id",
"id",
filter=Q(archived_at__isnull=True, is_draft=False),
)
)
.annotate(
completed_issues=Count(
"label_id",
"id",
filter=Q(
completed_at__isnull=False,
archived_at__isnull=True,
@@ -297,7 +297,7 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
)
.annotate(
pending_issues=Count(
"label_id",
"id",
filter=Q(
completed_at__isnull=True,
archived_at__isnull=True,
@@ -419,13 +419,13 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
)
.annotate(
total_issues=Count(
"assignee_id",
"id",
filter=Q(archived_at__isnull=True, is_draft=False),
),
)
.annotate(
completed_issues=Count(
"assignee_id",
"id",
filter=Q(
completed_at__isnull=False,
archived_at__isnull=True,
@@ -435,7 +435,7 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
)
.annotate(
pending_issues=Count(
"assignee_id",
"id",
filter=Q(
completed_at__isnull=True,
archived_at__isnull=True,
@@ -459,13 +459,13 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
.values("label_name", "color", "label_id")
.annotate(
total_issues=Count(
"label_id",
"id",
filter=Q(archived_at__isnull=True, is_draft=False),
),
)
.annotate(
completed_issues=Count(
"label_id",
"id",
filter=Q(
completed_at__isnull=False,
archived_at__isnull=True,
@@ -475,7 +475,7 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
)
.annotate(
pending_issues=Count(
"label_id",
"id",
filter=Q(
completed_at__isnull=True,
archived_at__isnull=True,
@@ -599,16 +599,11 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet):
)
.filter(project_id=project_id)
.filter(workspace__slug=slug)
.select_related("project")
.select_related("workspace")
.select_related("state")
.select_related("parent")
.prefetch_related("assignees")
.prefetch_related("labels")
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module")
.order_by(order_by)
.filter(**filters)
.annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(module_id=F("issue_module__module_id"))
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
+36 -4
View File
@@ -100,7 +100,7 @@ def dashboard_assigned_issues(self, request, slug):
)
.filter(**filters)
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels")
.prefetch_related("assignees", "labels", "issue_module__module")
.prefetch_related(
Prefetch(
"issue_relation",
@@ -110,7 +110,6 @@ def dashboard_assigned_issues(self, request, slug):
)
)
.annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(module_id=F("issue_module__module_id"))
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
@@ -146,6 +145,23 @@ def dashboard_assigned_issues(self, request, slug):
)
).order_by("priority_order")
if issue_type == "pending":
pending_issues_count = assigned_issues.filter(
state__group__in=["backlog", "started", "unstarted"]
).count()
pending_issues = assigned_issues.filter(
state__group__in=["backlog", "started", "unstarted"]
)[:5]
return Response(
{
"issues": IssueSerializer(
pending_issues, many=True, expand=self.expand
).data,
"count": pending_issues_count,
},
status=status.HTTP_200_OK,
)
if issue_type == "completed":
completed_issues_count = assigned_issues.filter(
state__group__in=["completed"]
@@ -221,9 +237,8 @@ def dashboard_created_issues(self, request, slug):
)
.filter(**filters)
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels")
.prefetch_related("assignees", "labels", "issue_module__module")
.annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(module_id=F("issue_module__module_id"))
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
@@ -259,6 +274,23 @@ def dashboard_created_issues(self, request, slug):
)
).order_by("priority_order")
if issue_type == "pending":
pending_issues_count = created_issues.filter(
state__group__in=["backlog", "started", "unstarted"]
).count()
pending_issues = created_issues.filter(
state__group__in=["backlog", "started", "unstarted"]
)[:5]
return Response(
{
"issues": IssueSerializer(
pending_issues, many=True, expand=self.expand
).data,
"count": pending_issues_count,
},
status=status.HTTP_200_OK,
)
if issue_type == "completed":
completed_issues_count = created_issues.filter(
state__group__in=["completed"]
+1 -2
View File
@@ -95,7 +95,7 @@ class InboxIssueViewSet(BaseViewSet):
issue_inbox__inbox_id=self.kwargs.get("inbox_id")
)
.select_related("workspace", "project", "state", "parent")
.prefetch_related("labels", "assignees")
.prefetch_related("assignees", "labels", "issue_module__module")
.prefetch_related(
Prefetch(
"issue_inbox",
@@ -105,7 +105,6 @@ class InboxIssueViewSet(BaseViewSet):
)
)
.annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(module_id=F("issue_module__module_id"))
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
+52 -51
View File
@@ -112,12 +112,8 @@ class IssueViewSet(WebhookMixin, BaseViewSet):
project_id=self.kwargs.get("project_id")
)
.filter(workspace__slug=self.kwargs.get("slug"))
.select_related("project")
.select_related("workspace")
.select_related("state")
.select_related("parent")
.prefetch_related("assignees")
.prefetch_related("labels")
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module")
.prefetch_related(
Prefetch(
"issue_reactions",
@@ -125,7 +121,6 @@ class IssueViewSet(WebhookMixin, BaseViewSet):
)
)
.annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(module_id=F("issue_module__module_id"))
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
@@ -1087,12 +1082,31 @@ class IssueArchiveViewSet(BaseViewSet):
.filter(archived_at__isnull=False)
.filter(project_id=self.kwargs.get("project_id"))
.filter(workspace__slug=self.kwargs.get("slug"))
.select_related("project")
.select_related("workspace")
.select_related("state")
.select_related("parent")
.prefetch_related("assignees")
.prefetch_related("labels")
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module")
.annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
attachment_count=IssueAttachment.objects.filter(
issue=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
sub_issues_count=Issue.issue_objects.filter(
parent=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
)
@method_decorator(gzip_page)
@@ -1120,22 +1134,6 @@ class IssueArchiveViewSet(BaseViewSet):
issue_queryset = (
self.get_queryset()
.filter(**filters)
.annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(module_id=F("issue_module__module_id"))
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
attachment_count=IssueAttachment.objects.filter(
issue=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
)
# Priority Ordering
@@ -1681,18 +1679,37 @@ class IssueDraftViewSet(BaseViewSet):
.filter(project_id=self.kwargs.get("project_id"))
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(is_draft=True)
.select_related("project")
.select_related("workspace")
.select_related("state")
.select_related("parent")
.prefetch_related("assignees")
.prefetch_related("labels")
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module")
.prefetch_related(
Prefetch(
"issue_reactions",
queryset=IssueReaction.objects.select_related("actor"),
)
)
.annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
attachment_count=IssueAttachment.objects.filter(
issue=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
sub_issues_count=Issue.issue_objects.filter(
parent=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
)
@method_decorator(gzip_page)
@@ -1719,22 +1736,6 @@ class IssueDraftViewSet(BaseViewSet):
issue_queryset = (
self.get_queryset()
.filter(**filters)
.annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(module_id=F("issue_module__module_id"))
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
attachment_count=IssueAttachment.objects.filter(
issue=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
)
# Priority Ordering
+124 -161
View File
@@ -7,6 +7,8 @@ from django.db.models import Prefetch, F, OuterRef, Func, Exists, Count, Q
from django.core import serializers
from django.utils.decorators import method_decorator
from django.views.decorators.gzip import gzip_page
from django.core.serializers.json import DjangoJSONEncoder
# Third party imports
from rest_framework.response import Response
@@ -195,7 +197,7 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
)
.annotate(
total_issues=Count(
"assignee_id",
"id",
filter=Q(
archived_at__isnull=True,
is_draft=False,
@@ -204,7 +206,7 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
)
.annotate(
completed_issues=Count(
"assignee_id",
"id",
filter=Q(
completed_at__isnull=False,
archived_at__isnull=True,
@@ -214,7 +216,7 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
)
.annotate(
pending_issues=Count(
"assignee_id",
"id",
filter=Q(
completed_at__isnull=True,
archived_at__isnull=True,
@@ -237,7 +239,7 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
.values("label_name", "color", "label_id")
.annotate(
total_issues=Count(
"label_id",
"id",
filter=Q(
archived_at__isnull=True,
is_draft=False,
@@ -246,7 +248,7 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
)
.annotate(
completed_issues=Count(
"label_id",
"id",
filter=Q(
completed_at__isnull=False,
archived_at__isnull=True,
@@ -256,7 +258,7 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
)
.annotate(
pending_issues=Count(
"label_id",
"id",
filter=Q(
completed_at__isnull=True,
archived_at__isnull=True,
@@ -296,23 +298,20 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
"issue", flat=True
)
)
issue_activity.delay(
type="module.activity.deleted",
requested_data=json.dumps(
{
"module_id": str(pk),
"module_name": str(module.name),
"issues": [str(issue_id) for issue_id in module_issues],
}
),
actor_id=str(request.user.id),
issue_id=str(pk),
project_id=str(project_id),
current_instance=None,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
_ = [
issue_activity.delay(
type="module.activity.deleted",
requested_data=json.dumps({"module_id": str(pk)}),
actor_id=str(request.user.id),
issue_id=str(issue),
project_id=project_id,
current_instance=json.dumps({"module_name": str(module.name)}),
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
for issue in module_issues
]
module.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@@ -332,62 +331,18 @@ class ModuleIssueViewSet(WebhookMixin, BaseViewSet):
ProjectEntityPermission,
]
def get_queryset(self):
return self.filter_queryset(
super()
.get_queryset()
.annotate(
sub_issues_count=Issue.issue_objects.filter(
parent=OuterRef("issue")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id"))
.filter(module_id=self.kwargs.get("module_id"))
.filter(project__project_projectmember__member=self.request.user)
.select_related("project")
.select_related("workspace")
.select_related("module")
.select_related("issue", "issue__state", "issue__project")
.prefetch_related("issue__assignees", "issue__labels")
.prefetch_related("module__members")
.distinct()
)
@method_decorator(gzip_page)
def list(self, request, slug, project_id, module_id):
fields = [
field
for field in request.GET.get("fields", "").split(",")
if field
]
order_by = request.GET.get("order_by", "created_at")
filters = issue_filters(request.query_params, "GET")
issues = (
Issue.issue_objects.filter(issue_module__module_id=module_id)
.annotate(
sub_issues_count=Issue.issue_objects.filter(
parent=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
def get_queryset(self):
return (
Issue.objects.filter(
project_id=self.kwargs.get("project_id"),
workspace__slug=self.kwargs.get("slug"),
issue_module__module_id=self.kwargs.get("module_id")
)
.filter(project_id=project_id)
.filter(workspace__slug=slug)
.select_related("project")
.select_related("workspace")
.select_related("state")
.select_related("parent")
.prefetch_related("assignees")
.prefetch_related("labels")
.order_by(order_by)
.filter(**filters)
.select_related("workspace", "project", "state", "parent")
.prefetch_related("labels", "assignees")
.prefetch_related('issue_module__module')
.annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(module_id=F("issue_module__module_id"))
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
@@ -403,105 +358,118 @@ class ModuleIssueViewSet(WebhookMixin, BaseViewSet):
.values("count")
)
.annotate(
is_subscribed=Exists(
IssueSubscriber.objects.filter(
subscriber=self.request.user, issue_id=OuterRef("id")
)
sub_issues_count=Issue.issue_objects.filter(
parent=OuterRef("id")
)
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
)
).distinct()
@method_decorator(gzip_page)
def list(self, request, slug, project_id, module_id):
fields = [
field
for field in request.GET.get("fields", "").split(",")
if field
]
filters = issue_filters(request.query_params, "GET")
issue_queryset = self.get_queryset().filter(**filters)
serializer = IssueSerializer(
issues, many=True, fields=fields if fields else None
issue_queryset, many=True, fields=fields if fields else None
)
return Response(serializer.data, status=status.HTTP_200_OK)
def create(self, request, slug, project_id, module_id):
# create multiple issues inside a module
def create_module_issues(self, request, slug, project_id, module_id):
issues = request.data.get("issues", [])
if not len(issues):
return Response(
{"error": "Issues are required"},
status=status.HTTP_400_BAD_REQUEST,
)
module = Module.objects.get(
workspace__slug=slug, project_id=project_id, pk=module_id
)
module_issues = list(ModuleIssue.objects.filter(issue_id__in=issues))
update_module_issue_activity = []
records_to_update = []
record_to_create = []
for issue in issues:
module_issue = [
module_issue
for module_issue in module_issues
if str(module_issue.issue_id) in issues
]
if len(module_issue):
if module_issue[0].module_id != module_id:
update_module_issue_activity.append(
{
"old_module_id": str(module_issue[0].module_id),
"new_module_id": str(module_id),
"issue_id": str(module_issue[0].issue_id),
}
)
module_issue[0].module_id = module_id
records_to_update.append(module_issue[0])
else:
record_to_create.append(
ModuleIssue(
module=module,
issue_id=issue,
project_id=project_id,
workspace=module.workspace,
created_by=request.user,
updated_by=request.user,
)
project = Project.objects.get(pk=project_id)
_ = ModuleIssue.objects.bulk_create(
[
ModuleIssue(
issue_id=str(issue),
module_id=module_id,
project_id=project_id,
workspace_id=project.workspace_id,
created_by=request.user,
updated_by=request.user,
)
ModuleIssue.objects.bulk_create(
record_to_create,
for issue in issues
],
batch_size=10,
ignore_conflicts=True,
)
# Bulk Update the activity
_ = [
issue_activity.delay(
type="module.activity.created",
requested_data=json.dumps({"module_id": str(module_id)}),
actor_id=str(request.user.id),
issue_id=str(issue),
project_id=project_id,
current_instance=None,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
for issue in issues
]
issues = (self.get_queryset().filter(pk__in=issues))
serializer = IssueSerializer(issues , many=True)
return Response(serializer.data, status=status.HTTP_201_CREATED)
ModuleIssue.objects.bulk_update(
records_to_update,
["module"],
# create multiple module inside an issue
def create_issue_modules(self, request, slug, project_id, issue_id):
modules = request.data.get("modules", [])
if not len(modules):
return Response(
{"error": "Modules are required"},
status=status.HTTP_400_BAD_REQUEST,
)
project = Project.objects.get(pk=project_id)
_ = ModuleIssue.objects.bulk_create(
[
ModuleIssue(
issue_id=issue_id,
module_id=module,
project_id=project_id,
workspace_id=project.workspace_id,
created_by=request.user,
updated_by=request.user,
)
for module in modules
],
batch_size=10,
ignore_conflicts=True,
)
# Bulk Update the activity
_ = [
issue_activity.delay(
type="module.activity.created",
requested_data=json.dumps({"module_id": module}),
actor_id=str(request.user.id),
issue_id=issue_id,
project_id=project_id,
current_instance=None,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
for module in modules
]
# Capture Issue Activity
issue_activity.delay(
type="module.activity.created",
requested_data=json.dumps({"modules_list": issues}),
actor_id=str(self.request.user.id),
issue_id=None,
project_id=str(self.kwargs.get("project_id", None)),
current_instance=json.dumps(
{
"updated_module_issues": update_module_issue_activity,
"created_module_issues": serializers.serialize(
"json", record_to_create
),
}
),
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
issue = (self.get_queryset().filter(pk=issue_id).first())
serializer = IssueSerializer(issue)
return Response(serializer.data, status=status.HTTP_201_CREATED)
issues = self.get_queryset().values_list("issue_id", flat=True)
return Response(
IssueSerializer(
Issue.objects.filter(pk__in=issues), many=True
).data,
status=status.HTTP_200_OK,
)
def destroy(self, request, slug, project_id, module_id, issue_id):
module_issue = ModuleIssue.objects.get(
@@ -512,16 +480,11 @@ class ModuleIssueViewSet(WebhookMixin, BaseViewSet):
)
issue_activity.delay(
type="module.activity.deleted",
requested_data=json.dumps(
{
"module_id": str(module_id),
"issues": [str(issue_id)],
}
),
requested_data=json.dumps({"module_id": str(module_id)}),
actor_id=str(request.user.id),
issue_id=str(issue_id),
project_id=str(project_id),
current_instance=None,
current_instance=json.dumps({"module_name": module_issue.module.name}),
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
+3 -3
View File
@@ -228,7 +228,7 @@ class IssueSearchEndpoint(BaseAPIView):
parent = request.query_params.get("parent", "false")
issue_relation = request.query_params.get("issue_relation", "false")
cycle = request.query_params.get("cycle", "false")
module = request.query_params.get("module", "false")
module = request.query_params.get("module", False)
sub_issue = request.query_params.get("sub_issue", "false")
issue_id = request.query_params.get("issue_id", False)
@@ -269,8 +269,8 @@ class IssueSearchEndpoint(BaseAPIView):
if cycle == "true":
issues = issues.exclude(issue_cycle__isnull=False)
if module == "true":
issues = issues.exclude(issue_module__isnull=False)
if module:
issues = issues.exclude(issue_module__module=module)
return Response(
issues.values(
+2 -14
View File
@@ -87,12 +87,8 @@ class GlobalViewIssuesViewSet(BaseViewSet):
.values("count")
)
.filter(workspace__slug=self.kwargs.get("slug"))
.select_related("project")
.select_related("workspace")
.select_related("state")
.select_related("parent")
.prefetch_related("assignees")
.prefetch_related("labels")
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module")
.prefetch_related(
Prefetch(
"issue_reactions",
@@ -127,7 +123,6 @@ class GlobalViewIssuesViewSet(BaseViewSet):
.filter(**filters)
.filter(project__project_projectmember__member=self.request.user)
.annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(module_id=F("issue_module__module_id"))
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
@@ -150,13 +145,6 @@ class GlobalViewIssuesViewSet(BaseViewSet):
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.annotate(
is_subscribed=Exists(
IssueSubscriber.objects.filter(
subscriber=self.request.user, issue_id=OuterRef("id")
)
)
)
)
# Priority Ordering
+1 -2
View File
@@ -1346,9 +1346,8 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView):
)
.filter(**filters)
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels")
.prefetch_related("assignees", "labels", "issue_module__module")
.annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(module_id=F("issue_module__module_id"))
.annotate(
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
.order_by()
@@ -148,10 +148,12 @@ def send_email_notification(
template_data = []
total_changes = 0
comments = []
actors_involved = []
for actor_id, changes in data.items():
actor = User.objects.get(pk=actor_id)
total_changes = total_changes + len(changes)
comment = changes.pop("comment", False)
actors_involved.append(actor_id)
if comment:
comments.append(
{
@@ -184,13 +186,14 @@ def send_email_notification(
}
)
summary = "updates were made to the issue by"
summary = "Updates were made to the issue by"
# Send the mail
subject = f"{issue.project.identifier}-{issue.sequence_id} {issue.name}"
context = {
"data": template_data,
"summary": summary,
"actors_involved": len(set(actors_involved)),
"issue": {
"issue_identifier": f"{str(issue.project.identifier)}-{str(issue.sequence_id)}",
"name": issue.name,
@@ -200,6 +203,9 @@ def send_email_notification(
"email": receiver.email,
},
"issue_url": f"{base_api}/{str(issue.project.workspace.slug)}/projects/{str(issue.project.id)}/issues/{str(issue.id)}",
"project_url": f"{base_api}/{str(issue.project.workspace.slug)}/projects/{str(issue.project.id)}/issues/",
"workspace":str(issue.project.workspace.slug),
"project": str(issue.project.name),
"user_preference": f"{base_api}/profile/preferences/email",
"comments": comments,
}
+62 -93
View File
@@ -30,6 +30,7 @@ from plane.app.serializers import IssueActivitySerializer
from plane.bgtasks.notification_task import notifications
from plane.settings.redis import redis_instance
# Track Changes in name
def track_name(
requested_data,
@@ -352,13 +353,18 @@ def track_assignees(
issue_activities,
epoch,
):
requested_assignees = set(
[str(asg) for asg in requested_data.get("assignee_ids", [])]
requested_assignees = (
set([str(asg) for asg in requested_data.get("assignee_ids", [])])
if requested_data is not None
else set()
)
current_assignees = set(
[str(asg) for asg in current_instance.get("assignee_ids", [])]
current_assignees = (
set([str(asg) for asg in current_instance.get("assignee_ids", [])])
if current_instance is not None
else set()
)
added_assignees = requested_assignees - current_assignees
dropped_assginees = current_assignees - requested_assignees
@@ -546,6 +552,20 @@ def create_issue_activity(
epoch=epoch,
)
)
requested_data = (
json.loads(requested_data) if requested_data is not None else None
)
if requested_data.get("assignee_ids") is not None:
track_assignees(
requested_data,
current_instance,
issue_id,
project_id,
workspace_id,
actor_id,
issue_activities,
epoch,
)
def update_issue_activity(
@@ -852,70 +872,26 @@ def create_module_issue_activity(
requested_data = (
json.loads(requested_data) if requested_data is not None else None
)
current_instance = (
json.loads(current_instance) if current_instance is not None else None
)
# Updated Records:
updated_records = current_instance.get("updated_module_issues", [])
created_records = json.loads(
current_instance.get("created_module_issues", [])
)
for updated_record in updated_records:
old_module = Module.objects.filter(
pk=updated_record.get("old_module_id", None)
).first()
new_module = Module.objects.filter(
pk=updated_record.get("new_module_id", None)
).first()
issue = Issue.objects.filter(pk=updated_record.get("issue_id")).first()
if issue:
issue.updated_at = timezone.now()
issue.save(update_fields=["updated_at"])
issue_activities.append(
IssueActivity(
issue_id=updated_record.get("issue_id"),
actor_id=actor_id,
verb="updated",
old_value=old_module.name,
new_value=new_module.name,
field="modules",
project_id=project_id,
workspace_id=workspace_id,
comment=f"updated module to ",
old_identifier=old_module.id,
new_identifier=new_module.id,
epoch=epoch,
)
)
for created_record in created_records:
module = Module.objects.filter(
pk=created_record.get("fields").get("module")
).first()
issue = Issue.objects.filter(
pk=created_record.get("fields").get("issue")
).first()
if issue:
issue.updated_at = timezone.now()
issue.save(update_fields=["updated_at"])
issue_activities.append(
IssueActivity(
issue_id=created_record.get("fields").get("issue"),
actor_id=actor_id,
verb="created",
old_value="",
new_value=module.name,
field="modules",
project_id=project_id,
workspace_id=workspace_id,
comment=f"added module {module.name}",
new_identifier=module.id,
epoch=epoch,
)
module = Module.objects.filter(pk=requested_data.get("module_id")).first()
issue = Issue.objects.filter(pk=issue_id).first()
if issue:
issue.updated_at = timezone.now()
issue.save(update_fields=["updated_at"])
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor_id=actor_id,
verb="created",
old_value="",
new_value=module.name,
field="modules",
project_id=project_id,
workspace_id=workspace_id,
comment=f"added module {module.name}",
new_identifier=requested_data.get("module_id"),
epoch=epoch,
)
)
def delete_module_issue_activity(
@@ -934,32 +910,26 @@ def delete_module_issue_activity(
current_instance = (
json.loads(current_instance) if current_instance is not None else None
)
module_id = requested_data.get("module_id", "")
module_name = requested_data.get("module_name", "")
module = Module.objects.filter(pk=module_id).first()
issues = requested_data.get("issues")
for issue in issues:
current_issue = Issue.objects.filter(pk=issue).first()
if issue:
current_issue.updated_at = timezone.now()
current_issue.save(update_fields=["updated_at"])
issue_activities.append(
IssueActivity(
issue_id=issue,
actor_id=actor_id,
verb="deleted",
old_value=module.name if module is not None else module_name,
new_value="",
field="modules",
project_id=project_id,
workspace_id=workspace_id,
comment=f"removed this issue from {module.name if module is not None else module_name}",
old_identifier=module_id if module_id is not None else None,
epoch=epoch,
)
module_name = current_instance.get("module_name")
current_issue = Issue.objects.filter(pk=issue_id).first()
if current_issue:
current_issue.updated_at = timezone.now()
current_issue.save(update_fields=["updated_at"])
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor_id=actor_id,
verb="deleted",
old_value=module_name,
new_value="",
field="modules",
project_id=project_id,
workspace_id=workspace_id,
comment=f"removed this issue from {module_name}",
old_identifier=requested_data.get("module_id") if requested_data.get("module_id") is not None else None,
epoch=epoch,
)
)
def create_link_activity(
@@ -1648,7 +1618,6 @@ def issue_activity(
)
except Exception as e:
capture_exception(e)
if notification:
notifications.delay(
@@ -0,0 +1,23 @@
# Generated by Django 4.2.7 on 2024-01-24 18:55
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('db', '0057_auto_20240122_0901'),
]
operations = [
migrations.AlterField(
model_name='moduleissue',
name='issue',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='issue_module', to='db.issue'),
),
migrations.AlterUniqueTogether(
name='moduleissue',
unique_together={('issue', 'module')},
),
]
+2 -1
View File
@@ -134,11 +134,12 @@ class ModuleIssue(ProjectBaseModel):
module = models.ForeignKey(
"db.Module", on_delete=models.CASCADE, related_name="issue_module"
)
issue = models.OneToOneField(
issue = models.ForeignKey(
"db.Issue", on_delete=models.CASCADE, related_name="issue_module"
)
class Meta:
unique_together = ["issue", "module"]
verbose_name = "Module Issue"
verbose_name_plural = "Module Issues"
db_table = "module_issues"
+5
View File
@@ -172,4 +172,9 @@ def create_user_notification(sender, instance, created, **kwargs):
from plane.db.models import UserNotificationPreference
UserNotificationPreference.objects.create(
user=instance,
property_change=False,
state_change=False,
comment=False,
mention=False,
issue_completed=False,
)
+1 -1
View File
@@ -30,7 +30,7 @@ openpyxl==3.1.2
beautifulsoup4==4.12.2
dj-database-url==2.1.0
posthog==3.0.2
cryptography==41.0.5
cryptography==41.0.6
lxml==4.9.3
boto3==1.28.40
+17
View File
@@ -0,0 +1,17 @@
import os
import uvicorn
if __name__ == "__main__":
os.environ.setdefault(
"DJANGO_SETTINGS_MODULE", "plane.settings.production"
)
uvicorn.run(
"plane.asgi:application",
host=os.environ.get("HOST", "0.0.0.0"),
port=os.environ.get("PORT", 8000),
ws="auto",
workers=int(os.environ.get("GUNICORN_WORKERS", 1)),
log_level=os.environ.get("LOG_LEVEL", "info"),
lifespan="off",
access_log="on",
)
File diff suppressed because it is too large Load Diff
+6 -1
View File
@@ -49,7 +49,7 @@ function buildLocalImage() {
cd $PLANE_TEMP_CODE_DIR
if [ "$BRANCH" == "master" ];
then
APP_RELEASE=latest
export APP_RELEASE=latest
fi
docker compose -f build.yml build --no-cache >&2
@@ -205,6 +205,11 @@ else
PULL_POLICY=never
fi
if [ "$BRANCH" == "master" ];
then
export APP_RELEASE=latest
fi
# REMOVE SPECIAL CHARACTERS FROM BRANCH NAME
if [ "$BRANCH" != "master" ];
then
@@ -1,109 +0,0 @@
import { TextSelection } from "prosemirror-state";
import { InputRule, mergeAttributes, Node, nodeInputRule, wrappingInputRule } from "@tiptap/core";
/**
* Extension based on:
* - Tiptap HorizontalRule extension (https://tiptap.dev/api/nodes/horizontal-rule)
*/
export interface HorizontalRuleOptions {
HTMLAttributes: Record<string, any>;
}
declare module "@tiptap/core" {
interface Commands<ReturnType> {
horizontalRule: {
/**
* Add a horizontal rule
*/
setHorizontalRule: () => ReturnType;
};
}
}
export const HorizontalRule = Node.create<HorizontalRuleOptions>({
name: "horizontalRule",
addOptions() {
return {
HTMLAttributes: {},
};
},
group: "block",
addAttributes() {
return {
color: {
default: "#dddddd",
},
};
},
parseHTML() {
return [
{
tag: `div[data-type="${this.name}"]`,
},
];
},
renderHTML({ HTMLAttributes }) {
return [
"div",
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
"data-type": this.name,
}),
["div", {}],
];
},
addCommands() {
return {
setHorizontalRule:
() =>
({ chain }) => {
return (
chain()
.insertContent({ type: this.name })
// set cursor after horizontal rule
.command(({ tr, dispatch }) => {
if (dispatch) {
const { $to } = tr.selection;
const posAfter = $to.end();
if ($to.nodeAfter) {
tr.setSelection(TextSelection.create(tr.doc, $to.pos));
} else {
// add node after horizontal rule if its the end of the document
const node = $to.parent.type.contentMatch.defaultType?.create();
if (node) {
tr.insert(posAfter, node);
tr.setSelection(TextSelection.create(tr.doc, posAfter));
}
}
tr.scrollIntoView();
}
return true;
})
.run()
);
},
};
},
addInputRules() {
return [
new InputRule({
find: /^(?:---|—-|___\s|\*\*\*\s)$/,
handler: ({ state, range, match }) => {
state.tr.replaceRangeWith(range.from, range.to, this.type.create());
},
}),
];
},
});
@@ -1,26 +1,25 @@
import StarterKit from "@tiptap/starter-kit";
import TiptapUnderline from "@tiptap/extension-underline";
import TextStyle from "@tiptap/extension-text-style";
import { Color } from "@tiptap/extension-color";
import TaskItem from "@tiptap/extension-task-item";
import TaskList from "@tiptap/extension-task-list";
import TextStyle from "@tiptap/extension-text-style";
import TiptapUnderline from "@tiptap/extension-underline";
import StarterKit from "@tiptap/starter-kit";
import { Markdown } from "tiptap-markdown";
import { TableHeader } from "src/ui/extensions/table/table-header/table-header";
import { Table } from "src/ui/extensions/table/table";
import { TableCell } from "src/ui/extensions/table/table-cell/table-cell";
import { TableHeader } from "src/ui/extensions/table/table-header/table-header";
import { TableRow } from "src/ui/extensions/table/table-row/table-row";
import { HorizontalRule } from "src/ui/extensions/horizontal-rule";
import { ImageExtension } from "src/ui/extensions/image";
import { isValidHttpUrl } from "src/lib/utils";
import { Mentions } from "src/ui/mentions";
import { CustomKeymap } from "src/ui/extensions/keymap";
import { CustomCodeBlockExtension } from "src/ui/extensions/code";
import { CustomQuoteExtension } from "src/ui/extensions/quote";
import { ListKeymap } from "src/ui/extensions/custom-list-keymap";
import { CustomKeymap } from "src/ui/extensions/keymap";
import { CustomQuoteExtension } from "src/ui/extensions/quote";
import { DeleteImage } from "src/types/delete-image";
import { IMentionSuggestion } from "src/types/mention-suggestion";
@@ -55,7 +54,9 @@ export const CoreEditorExtensions = (
},
code: false,
codeBlock: false,
horizontalRule: false,
horizontalRule: {
HTMLAttributes: { class: "mt-4 mb-4" },
},
blockquote: false,
dropcursor: {
color: "rgba(var(--color-text-100))",
@@ -104,7 +105,6 @@ export const CoreEditorExtensions = (
transformCopiedText: true,
transformPastedText: true,
}),
HorizontalRule,
Table,
TableHeader,
TableCell,
@@ -10,6 +10,11 @@ export interface CustomMentionOptions extends MentionOptions {
}
export const CustomMention = Mention.extend<CustomMentionOptions>({
addStorage(this) {
return {
mentionsOpen: false,
};
},
addAttributes() {
return {
id: {
@@ -14,6 +14,7 @@ export const Suggestion = (suggestions: IMentionSuggestion[]) => ({
return {
onStart: (props: { editor: Editor; clientRect: DOMRect }) => {
props.editor.storage.mentionsOpen = true;
reactRenderer = new ReactRenderer(MentionList, {
props,
editor: props.editor,
@@ -45,10 +46,18 @@ export const Suggestion = (suggestions: IMentionSuggestion[]) => ({
return true;
}
// @ts-ignore
return reactRenderer?.ref?.onKeyDown(props);
const navigationKeys = ["ArrowUp", "ArrowDown", "Enter"];
if (navigationKeys.includes(props.event.key)) {
// @ts-ignore
reactRenderer?.ref?.onKeyDown(props);
event?.stopPropagation();
return true;
}
return false;
},
onExit: () => {
onExit: (props: { editor: Editor; event: KeyboardEvent }) => {
props.editor.storage.mentionsOpen = false;
popup?.[0].destroy();
reactRenderer?.destroy();
},
@@ -11,7 +11,6 @@ import { TableHeader } from "src/ui/extensions/table/table-header/table-header";
import { Table } from "src/ui/extensions/table/table";
import { TableCell } from "src/ui/extensions/table/table-cell/table-cell";
import { TableRow } from "src/ui/extensions/table/table-row/table-row";
import { HorizontalRule } from "src/ui/extensions/horizontal-rule";
import { ReadOnlyImageExtension } from "src/ui/extensions/image/read-only-image";
import { isValidHttpUrl } from "src/lib/utils";
@@ -51,7 +50,9 @@ export const CoreReadOnlyEditorExtensions = (mentionConfig: {
},
},
codeBlock: false,
horizontalRule: false,
horizontalRule: {
HTMLAttributes: { class: "mt-4 mb-4" },
},
dropcursor: {
color: "rgba(var(--color-text-100))",
width: 2,
@@ -72,7 +73,6 @@ export const CoreReadOnlyEditorExtensions = (mentionConfig: {
class: "rounded-lg border border-custom-border-300",
},
}),
HorizontalRule,
TiptapUnderline,
TextStyle,
Color,
@@ -4,13 +4,16 @@ export const EnterKeyExtension = (onEnterKeyPress?: () => void) =>
Extension.create({
name: "enterKey",
addKeyboardShortcuts() {
addKeyboardShortcuts(this) {
return {
Enter: () => {
if (onEnterKeyPress) {
onEnterKeyPress();
if (!this.editor.storage.mentionsOpen) {
if (onEnterKeyPress) {
onEnterKeyPress();
}
return true;
}
return true;
return false;
},
"Shift-Enter": ({ editor }) =>
editor.commands.first(({ commands }) => [
@@ -1,5 +1,3 @@
import { EnterKeyExtension } from "src/ui/extensions/enter-key-extension";
export const LiteTextEditorExtensions = (onEnterKeyPress?: () => void) => [
// EnterKeyExtension(onEnterKeyPress),
];
export const LiteTextEditorExtensions = (onEnterKeyPress?: () => void) => [EnterKeyExtension(onEnterKeyPress)];
+1 -1
View File
@@ -30,7 +30,7 @@ export interface ICycle {
is_favorite: boolean;
issue: string;
name: string;
owned_by: IUser;
owned_by: string;
project: string;
project_detail: IProjectLite;
status: TCycleGroups;
+2 -1
View File
@@ -13,9 +13,10 @@ export type TWidgetKeys =
| "recent_projects"
| "recent_collaborators";
export type TIssuesListTypes = "upcoming" | "overdue" | "completed";
export type TIssuesListTypes = "pending" | "upcoming" | "overdue" | "completed";
export type TDurationFilterOptions =
| "none"
| "today"
| "this_week"
| "this_month"
+1 -1
View File
@@ -21,7 +21,7 @@ export type TIssue = {
project_id: string;
parent_id: string | null;
cycle_id: string | null;
module_id: string | null;
module_ids: string[] | null;
created_at: string;
updated_at: string;
+1 -1
View File
@@ -117,7 +117,7 @@ export type TProjectIssuesSearchParams = {
parent?: boolean;
issue_relation?: boolean;
cycle?: boolean;
module?: boolean;
module?: string[];
sub_issue?: boolean;
issue_id?: string;
workspace_search: boolean;
+1 -3
View File
@@ -64,8 +64,7 @@ export type TIssueParams =
| "order_by"
| "type"
| "sub_issue"
| "show_empty_groups"
| "start_target_date";
| "show_empty_groups";
export type TCalendarLayouts = "month" | "week";
@@ -93,7 +92,6 @@ export interface IIssueDisplayFilterOptions {
layout?: TIssueLayouts;
order_by?: TIssueOrderByOptions;
show_empty_groups?: boolean;
start_target_date?: boolean;
sub_issue?: boolean;
type?: TIssueTypeFilters;
}
+3 -36
View File
@@ -2,8 +2,6 @@ import * as React from "react";
// icons
import { ChevronRight } from "lucide-react";
// components
import { Tooltip } from "../tooltip";
type BreadcrumbsProps = {
children: any;
@@ -25,42 +23,11 @@ const Breadcrumbs = ({ children }: BreadcrumbsProps) => (
type Props = {
type?: "text" | "component";
component?: React.ReactNode;
label?: string;
icon?: React.ReactNode;
link?: string;
link?: JSX.Element;
};
const BreadcrumbItem: React.FC<Props> = (props) => {
const { type = "text", component, label, icon, link } = props;
return (
<>
{type != "text" ? (
<div className="flex items-center space-x-2">{component}</div>
) : (
<Tooltip tooltipContent={label} position="bottom">
<li className="flex items-center space-x-2">
<div className="flex flex-wrap items-center gap-2.5">
{link ? (
<a
className="flex items-center gap-1 text-sm font-medium text-custom-text-300 hover:text-custom-text-100"
href={link}
>
{icon && (
<div className="flex h-5 w-5 items-center justify-center overflow-hidden !text-[1rem]">{icon}</div>
)}
<div className="relative line-clamp-1 block max-w-[150px] overflow-hidden truncate">{label}</div>
</a>
) : (
<div className="flex cursor-default items-center gap-1 text-sm font-medium text-custom-text-100">
{icon && <div className="flex h-5 w-5 items-center justify-center overflow-hidden">{icon}</div>}
<div className="relative line-clamp-1 block max-w-[150px] overflow-hidden truncate">{label}</div>
</div>
)}
</div>
</li>
</Tooltip>
)}
</>
);
const { type = "text", component, link } = props;
return <>{type != "text" ? <div className="flex items-center space-x-2">{component}</div> : link}</>;
};
Breadcrumbs.BreadcrumbItem = BreadcrumbItem;
@@ -1,13 +1,20 @@
import React from "react";
import { Tooltip } from "../tooltip";
import { cn } from "../../helpers";
type Props = {
data: any;
noTooltip?: boolean;
inPercentage?: boolean;
size?: "sm" | "md" | "lg";
};
export const LinearProgressIndicator: React.FC<Props> = ({ data, noTooltip = false, inPercentage = false }) => {
export const LinearProgressIndicator: React.FC<Props> = ({
data,
noTooltip = false,
inPercentage = false,
size = "sm",
}) => {
const total = data.reduce((acc: any, cur: any) => acc + cur.value, 0);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
let progress = 0;
@@ -23,18 +30,24 @@ export const LinearProgressIndicator: React.FC<Props> = ({ data, noTooltip = fal
if (noTooltip) return <div style={style} />;
else
return (
<Tooltip key={item.id} tooltipContent={`${item.name} ${Math.round(item.value)}%`}>
<div style={style} className="first:rounded-l-full last:rounded-r-full" />
<Tooltip key={item.id} tooltipContent={`${item.name} ${Math.round(item.value)}${inPercentage ? "%" : ""}`}>
<div style={style} className="first:rounded-l-sm last:rounded-r-sm" />
</Tooltip>
);
});
return (
<div className="flex h-1 w-full items-center justify-between gap-1">
<div
className={cn("flex w-full items-center justify-between gap-[1px] rounded-sm", {
"h-2": size === "sm",
"h-3": size === "md",
"h-3.5": size === "lg",
})}
>
{total === 0 ? (
<div className="flex h-full w-full gap-1 bg-neutral-500">{bars}</div>
<div className="flex h-full w-full gap-[1.5px] p-[2px] bg-custom-background-90 rounded-sm">{bars}</div>
) : (
<div className="flex h-full w-full gap-1">{bars}</div>
<div className="flex h-full w-full gap-[1.5px] p-[2px] bg-custom-background-90 rounded-sm">{bars}</div>
)}
</div>
);
@@ -89,8 +89,8 @@ export const DeactivateAccountModal: React.FC<Props> = (props) => {
<div className="px-4 pb-4 pt-5 sm:p-6 sm:pb-4">
<div className="">
<div className="flex items-start gap-x-4">
<div className="grid place-items-center rounded-full bg-red-500/20 p-4">
<Trash2 className="h-6 w-6 text-red-600" aria-hidden="true" />
<div className="grid place-items-center rounded-full bg-red-500/20 p-2 sm:p-2 md:p-4 lg:p-4 mt-3 sm:mt-3 md:mt-0 lg:mt-0 ">
<Trash2 className="h-4 w-4 sm:h-4 sm:w-4 md:h-6 md:w-6 lg:h-6 lg:w-6 text-red-600" aria-hidden="true" />
</div>
<div>
<Dialog.Title as="h3" className="my-4 text-2xl font-medium leading-6 text-custom-text-100">
@@ -31,7 +31,6 @@ export const GoogleSignInButton: FC<Props> = (props) => {
size: "large",
logo_alignment: "center",
text: type === "sign_in" ? "signin_with" : "signup_with",
width: 384,
} as GsiButtonConfiguration // customization attributes
);
} catch (err) {
@@ -8,6 +8,8 @@ import useToast from "hooks/use-toast";
import { Button, Input } from "@plane/ui";
// helpers
import { checkEmailValidity } from "helpers/string.helper";
// icons
import { Eye, EyeOff } from "lucide-react";
type Props = {
email: string;
@@ -31,6 +33,7 @@ export const SignInOptionalSetPasswordForm: React.FC<Props> = (props) => {
const { email, handleSignInRedirection } = props;
// states
const [isGoingToWorkspace, setIsGoingToWorkspace] = useState(false);
const [showPassword, setShowPassword] = useState(false);
// toast alert
const { setToastAlert } = useToast();
// form info
@@ -114,17 +117,30 @@ export const SignInOptionalSetPasswordForm: React.FC<Props> = (props) => {
required: "Password is required",
}}
render={({ field: { value, onChange, ref } }) => (
<Input
type="password"
value={value}
onChange={onChange}
ref={ref}
hasError={Boolean(errors.password)}
placeholder="Enter password"
className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 placeholder:text-onboarding-text-400"
minLength={8}
autoFocus
/>
<div className="relative flex items-center rounded-md bg-onboarding-background-200">
<Input
type={showPassword ? "text" : "password"}
value={value}
onChange={onChange}
ref={ref}
hasError={Boolean(errors.password)}
placeholder="Enter password"
className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 placeholder:text-onboarding-text-400"
minLength={8}
autoFocus
/>
{showPassword ? (
<EyeOff
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
onClick={() => setShowPassword(false)}
/>
) : (
<Eye
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
onClick={() => setShowPassword(true)}
/>
)}
</div>
)}
/>
<p className="text-onboarding-text-200 text-xs mt-2 pb-3">
@@ -2,7 +2,7 @@ import React, { useState } from "react";
import Link from "next/link";
import { observer } from "mobx-react-lite";
import { Controller, useForm } from "react-hook-form";
import { XCircle } from "lucide-react";
import { Eye, EyeOff, XCircle } from "lucide-react";
// services
import { AuthService } from "services/auth.service";
// hooks
@@ -40,6 +40,7 @@ export const SignInPasswordForm: React.FC<Props> = observer((props) => {
const { email, handleStepChange, handleEmailClear, onSubmit } = props;
// states
const [isSendingUniqueCode, setIsSendingUniqueCode] = useState(false);
const [showPassword, setShowPassword] = useState(false);
// toast alert
const { setToastAlert } = useToast();
const {
@@ -157,15 +158,28 @@ export const SignInPasswordForm: React.FC<Props> = observer((props) => {
required: "Password is required",
}}
render={({ field: { value, onChange } }) => (
<Input
type="password"
value={value}
onChange={onChange}
hasError={Boolean(errors.password)}
placeholder="Enter password"
className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 placeholder:text-onboarding-text-400"
autoFocus
/>
<div className="relative flex items-center rounded-md bg-onboarding-background-200">
<Input
type={showPassword ? "text" : "password"}
value={value}
onChange={onChange}
hasError={Boolean(errors.password)}
placeholder="Enter password"
className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 placeholder:text-onboarding-text-400"
autoFocus
/>
{showPassword ? (
<EyeOff
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
onClick={() => setShowPassword(false)}
/>
) : (
<Eye
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
onClick={() => setShowPassword(true)}
/>
)}
</div>
)}
/>
<div className="w-full text-right mt-2 pb-3">
@@ -10,6 +10,8 @@ import { Button, Input } from "@plane/ui";
import { checkEmailValidity } from "helpers/string.helper";
// constants
import { ESignUpSteps } from "components/account";
// icons
import { Eye, EyeOff } from "lucide-react";
type Props = {
email: string;
@@ -34,6 +36,7 @@ export const SignUpOptionalSetPasswordForm: React.FC<Props> = (props) => {
const { email, handleSignInRedirection } = props;
// states
const [isGoingToWorkspace, setIsGoingToWorkspace] = useState(false);
const [showPassword, setShowPassword] = useState(false);
// toast alert
const { setToastAlert } = useToast();
// form info
@@ -119,16 +122,29 @@ export const SignUpOptionalSetPasswordForm: React.FC<Props> = (props) => {
required: "Password is required",
}}
render={({ field: { value, onChange } }) => (
<Input
type="password"
value={value}
onChange={onChange}
hasError={Boolean(errors.password)}
placeholder="Enter password"
className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 placeholder:text-onboarding-text-400"
minLength={8}
autoFocus
/>
<div className="relative flex items-center rounded-md bg-onboarding-background-200">
<Input
type={showPassword ? "text" : "password"}
value={value}
onChange={onChange}
hasError={Boolean(errors.password)}
placeholder="Enter password"
className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 placeholder:text-onboarding-text-400"
minLength={8}
autoFocus
/>
{showPassword ? (
<EyeOff
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
onClick={() => setShowPassword(false)}
/>
) : (
<Eye
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
onClick={() => setShowPassword(true)}
/>
)}
</div>
)}
/>
<p className="text-onboarding-text-200 text-xs mt-2 pb-3">
@@ -1,8 +1,8 @@
import React from "react";
import React, { useState } from "react";
import Link from "next/link";
import { observer } from "mobx-react-lite";
import { Controller, useForm } from "react-hook-form";
import { XCircle } from "lucide-react";
import { Eye, EyeOff, XCircle } from "lucide-react";
// services
import { AuthService } from "services/auth.service";
// hooks
@@ -32,6 +32,8 @@ const authService = new AuthService();
export const SignUpPasswordForm: React.FC<Props> = observer((props) => {
const { onSubmit } = props;
// states
const [showPassword, setShowPassword] = useState(false);
// toast alert
const { setToastAlert } = useToast();
// form info
@@ -112,15 +114,28 @@ export const SignUpPasswordForm: React.FC<Props> = observer((props) => {
required: "Password is required",
}}
render={({ field: { value, onChange } }) => (
<Input
type="password"
value={value}
onChange={onChange}
hasError={Boolean(errors.password)}
placeholder="Enter password"
className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 placeholder:text-onboarding-text-400"
autoFocus
/>
<div className="relative flex items-center rounded-md bg-onboarding-background-200">
<Input
type={showPassword ? "text" : "password"}
value={value}
onChange={onChange}
hasError={Boolean(errors.password)}
placeholder="Enter password"
className="h-[46px] w-full border border-onboarding-border-100 !bg-onboarding-background-200 pr-12 placeholder:text-onboarding-text-400"
autoFocus
/>
{showPassword ? (
<EyeOff
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
onClick={() => setShowPassword(false)}
/>
) : (
<Eye
className="absolute right-3 h-5 w-5 stroke-custom-text-400 hover:cursor-pointer"
onClick={() => setShowPassword(true)}
/>
)}
</div>
)}
/>
<p className="text-onboarding-text-200 text-xs mt-2 pb-3">
@@ -1,7 +1,7 @@
import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
// hooks
import { useCycle, useModule, useProject } from "hooks/store";
import { useCycle, useMember, useModule, useProject } from "hooks/store";
// helpers
import { renderEmoji } from "helpers/emoji.helper";
import { renderFormattedDate } from "helpers/date-time.helper";
@@ -15,10 +15,12 @@ export const CustomAnalyticsSidebarHeader = observer(() => {
const { getProjectById } = useProject();
const { getCycleById } = useCycle();
const { getModuleById } = useModule();
const { getUserDetails } = useMember();
const cycleDetails = cycleId ? getCycleById(cycleId.toString()) : undefined;
const moduleDetails = moduleId ? getModuleById(moduleId.toString()) : undefined;
const projectDetails = projectId ? getProjectById(projectId.toString()) : undefined;
const cycleOwnerDetails = cycleDetails ? getUserDetails(cycleDetails.owned_by) : undefined;
return (
<>
@@ -29,7 +31,7 @@ export const CustomAnalyticsSidebarHeader = observer(() => {
<div className="mt-4 space-y-4">
<div className="flex items-center gap-2 text-xs">
<h6 className="text-custom-text-200">Lead</h6>
<span>{cycleDetails.owned_by?.display_name}</span>
<span>{cycleOwnerDetails?.display_name}</span>
</div>
<div className="flex items-center gap-2 text-xs">
<h6 className="text-custom-text-200">Start Date</h6>
@@ -1,7 +1,7 @@
import { Command } from "cmdk";
import { ContrastIcon, FileText } from "lucide-react";
// hooks
import { useApplication } from "hooks/store";
import { useApplication, useEventTracker } from "hooks/store";
// ui
import { DiceIcon, PhotoFilterIcon } from "@plane/ui";
@@ -14,8 +14,8 @@ export const CommandPaletteProjectActions: React.FC<Props> = (props) => {
const {
commandPalette: { toggleCreateCycleModal, toggleCreateModuleModal, toggleCreatePageModal, toggleCreateViewModal },
eventTracker: { setTrackElement },
} = useApplication();
const { setTrackElement } = useEventTracker();
return (
<>
@@ -23,7 +23,7 @@ export const CommandPaletteProjectActions: React.FC<Props> = (props) => {
<Command.Item
onSelect={() => {
closePalette();
setTrackElement("COMMAND_PALETTE");
setTrackElement("Command palette");
toggleCreateCycleModal(true);
}}
className="focus:outline-none"
@@ -39,6 +39,7 @@ export const CommandPaletteProjectActions: React.FC<Props> = (props) => {
<Command.Item
onSelect={() => {
closePalette();
setTrackElement("Command palette");
toggleCreateModuleModal(true);
}}
className="focus:outline-none"
@@ -54,6 +55,7 @@ export const CommandPaletteProjectActions: React.FC<Props> = (props) => {
<Command.Item
onSelect={() => {
closePalette();
setTrackElement("Command palette");
toggleCreateViewModal(true);
}}
className="focus:outline-none"
@@ -69,6 +71,7 @@ export const CommandPaletteProjectActions: React.FC<Props> = (props) => {
<Command.Item
onSelect={() => {
closePalette();
setTrackElement("Command palette");
toggleCreatePageModal(true);
}}
className="focus:outline-none"
@@ -6,7 +6,7 @@ import { Dialog, Transition } from "@headlessui/react";
import { observer } from "mobx-react-lite";
import { FolderPlus, Search, Settings } from "lucide-react";
// hooks
import { useApplication, useProject } from "hooks/store";
import { useApplication, useEventTracker, useProject } from "hooks/store";
// services
import { WorkspaceService } from "services/workspace.service";
import { IssueService } from "services/issue";
@@ -64,8 +64,8 @@ export const CommandModal: React.FC = observer(() => {
toggleCreateIssueModal,
toggleCreateProjectModal,
},
eventTracker: { setTrackElement },
} = useApplication();
const { setTrackElement } = useEventTracker();
// router
const router = useRouter();
@@ -278,7 +278,7 @@ export const CommandModal: React.FC = observer(() => {
<Command.Item
onSelect={() => {
closePalette();
setTrackElement("COMMAND_PALETTE");
setTrackElement("Command Palette");
toggleCreateIssueModal(true);
}}
className="focus:bg-custom-background-80"
@@ -296,7 +296,7 @@ export const CommandModal: React.FC = observer(() => {
<Command.Item
onSelect={() => {
closePalette();
setTrackElement("COMMAND_PALETTE");
setTrackElement("Command palette");
toggleCreateProjectModal(true);
}}
className="focus:outline-none"
@@ -3,7 +3,7 @@ import { useRouter } from "next/router";
import useSWR from "swr";
import { observer } from "mobx-react-lite";
// hooks
import { useApplication, useIssues, useUser } from "hooks/store";
import { useApplication, useEventTracker, useIssues, useUser } from "hooks/store";
import useToast from "hooks/use-toast";
// components
import { CommandModal, ShortcutsModal } from "components/command-palette";
@@ -32,8 +32,8 @@ export const CommandPalette: FC = observer(() => {
const {
commandPalette,
theme: { toggleSidebar },
eventTracker: { setTrackElement },
} = useApplication();
const { setTrackElement } = useEventTracker();
const { currentUser } = useUser();
const {
issues: { removeIssue },
@@ -118,11 +118,10 @@ export const CommandPalette: FC = observer(() => {
toggleSidebar();
}
} else if (!isAnyModalOpen) {
setTrackElement("Shortcut key");
if (keyPressed === "c") {
setTrackElement("SHORTCUT_KEY");
toggleCreateIssueModal(true);
} else if (keyPressed === "p") {
setTrackElement("SHORTCUT_KEY");
toggleCreateProjectModal(true);
} else if (keyPressed === "h") {
toggleShortcutModal(true);
@@ -216,7 +215,7 @@ export const CommandPalette: FC = observer(() => {
<CreateUpdateIssueModal
isOpen={isCreateIssueModalOpen}
onClose={() => toggleCreateIssueModal(false)}
data={cycleId ? { cycle_id: cycleId.toString() } : moduleId ? { module_id: moduleId.toString() } : undefined}
data={cycleId ? { cycle_id: cycleId.toString() } : moduleId ? { module_ids: [moduleId.toString()] } : undefined}
storeType={createIssueStoreType}
/>
+36
View File
@@ -0,0 +1,36 @@
import { Tooltip } from "@plane/ui";
import Link from "next/link";
type Props = {
label?: string;
href?: string;
icon?: React.ReactNode | undefined;
};
export const BreadcrumbLink: React.FC<Props> = (props) => {
const { href, label, icon } = props;
return (
<Tooltip tooltipContent={label} position="bottom">
<li className="flex items-center space-x-2">
<div className="flex flex-wrap items-center gap-2.5">
{href ? (
<Link
className="flex items-center gap-1 text-sm font-medium text-custom-text-300 hover:text-custom-text-100"
href={href}
>
{icon && (
<div className="flex h-5 w-5 items-center justify-center overflow-hidden !text-[1rem]">{icon}</div>
)}
<div className="relative line-clamp-1 block max-w-[150px] overflow-hidden truncate">{label}</div>
</Link>
) : (
<div className="flex cursor-default items-center gap-1 text-sm font-medium text-custom-text-100">
{icon && <div className="flex h-5 w-5 items-center justify-center overflow-hidden">{icon}</div>}
<div className="relative line-clamp-1 block max-w-[150px] overflow-hidden truncate">{label}</div>
</div>
)}
</div>
</li>
</Tooltip>
);
};
+1
View File
@@ -1,3 +1,4 @@
export * from "./product-updates-modal";
export * from "./empty-state";
export * from "./latest-feature-block";
export * from "./breadcrumb-link";
+3 -3
View File
@@ -40,9 +40,9 @@ export const IssueLink = ({ activity }: { activity: IIssueActivity }) => {
}`}`}
target={activity.issue === null ? "_self" : "_blank"}
rel={activity.issue === null ? "" : "noopener noreferrer"}
className="inline-flex items-center gap-1 font-medium text-custom-text-100 hover:underline whitespace-nowrap"
className="inline-flex items-center gap-1 font-medium text-custom-text-100 hover:underline"
>
{`${activity.project_detail.identifier}-${activity.issue_detail.sequence_id}`}{" "}
<span className="whitespace-nowrap">{`${activity.project_detail.identifier}-${activity.issue_detail.sequence_id}`}</span>{" "}
<span className="font-normal">{activity.issue_detail?.name}</span>
</a>
) : (
@@ -267,7 +267,7 @@ const activityDetails: {
<span className="flex-shrink truncate font-medium text-custom-text-100">{activity.new_value}</span>
</span>
{showIssue && (
<span>
<span className="">
{" "}
to <IssueLink activity={activity} />
</span>
+17 -5
View File
@@ -130,17 +130,29 @@ export const ImagePickerPopover: React.FC<Props> = observer((props) => {
onChange(unsplashImages[0].urls.regular);
}, [value, onChange, unsplashImages]);
const openDropdown = () => setIsOpen(true);
const closeDropdown = () => setIsOpen(false);
const handleKeyDown = useDropdownKeyDown(openDropdown, closeDropdown, isOpen);
const handleClose = () => {
if (isOpen) setIsOpen(false);
};
useOutsideClickDetector(ref, closeDropdown);
const toggleDropdown = () => {
setIsOpen((prevIsOpen) => !prevIsOpen);
};
const handleKeyDown = useDropdownKeyDown(toggleDropdown, handleClose);
const handleOnClick = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
e.stopPropagation();
e.preventDefault();
toggleDropdown();
};
useOutsideClickDetector(ref, handleClose);
return (
<Popover className="relative z-[2]" ref={ref} tabIndex={tabIndex} onKeyDown={handleKeyDown}>
<Popover.Button
className="rounded border border-custom-border-300 bg-custom-background-100 px-2 py-1 text-xs text-custom-text-200 hover:text-custom-text-100"
onClick={openDropdown}
onClick={handleOnClick}
disabled={disabled}
>
{label}
@@ -157,6 +157,7 @@ export const GptAssistantPopover: React.FC<Props> = (props) => {
window.removeEventListener("keydown", handleEnterKeyPress);
window.removeEventListener("keydown", handleEscapeKeyPress);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen, handleSubmit, onClose]);
const responseActionButton = response !== "" && (
+12 -7
View File
@@ -86,12 +86,15 @@ const ProgressChart: React.FC<Props> = ({ distribution, startDate, endDate, tota
{
id: "pending",
color: "#3F76FF",
data: chartData.map((item, index) => ({
index,
x: item.currentDate,
y: item.pending,
color: "#3F76FF",
})),
data:
chartData.length > 0
? chartData.map((item, index) => ({
index,
x: item.currentDate,
y: item.pending,
color: "#3F76FF",
}))
: [],
enableArea: true,
},
{
@@ -121,7 +124,9 @@ const ProgressChart: React.FC<Props> = ({ distribution, startDate, endDate, tota
enableArea
colors={(datum) => datum.color ?? "#3F76FF"}
customYAxisTickValues={[0, totalIssues]}
gridXValues={chartData.map((item, index) => (index % 2 === 0 ? item.currentDate : ""))}
gridXValues={
chartData.length > 0 ? chartData.map((item, index) => (index % 2 === 0 ? item.currentDate : "")) : undefined
}
enableSlices="x"
sliceTooltip={(datum) => (
<div className="rounded-md border border-custom-border-200 bg-custom-background-80 p-2 text-xs">
@@ -0,0 +1,16 @@
import { FC } from "react";
import { Menu } from "lucide-react";
import { useApplication } from "hooks/store";
import { observer } from "mobx-react";
export const SidebarHamburgerToggle: FC = observer (() => {
const { theme: themStore } = useApplication();
return (
<div
className="w-7 h-7 rounded flex justify-center items-center bg-custom-background-80 transition-all hover:bg-custom-background-90 cursor-pointer group md:hidden"
onClick={() => themStore.toggleSidebar()}
>
<Menu size={14} className="text-custom-text-200 group-hover:text-custom-text-100 transition-all" />
</div>
);
});
@@ -125,7 +125,7 @@ export const SidebarProgressStats: React.FC<Props> = ({
</Tab>
</Tab.List>
<Tab.Panels className="flex w-full items-center justify-between text-custom-text-200">
<Tab.Panel as="div" className="flex h-44 w-full flex-col gap-1.5 overflow-y-auto pt-3.5">
<Tab.Panel as="div" className="flex min-h-44 w-full flex-col gap-1.5 overflow-y-auto pt-3.5">
{distribution?.assignees.length > 0 ? (
distribution.assignees.map((assignee, index) => {
if (assignee.assignee_id)
+15 -9
View File
@@ -2,8 +2,9 @@ import { MouseEvent } from "react";
import Link from "next/link";
import { observer } from "mobx-react-lite";
import useSWR from "swr";
import { useTheme } from "next-themes";
// hooks
import { useCycle, useIssues, useProject, useUser } from "hooks/store";
import { useCycle, useIssues, useMember, useProject, useUser } from "hooks/store";
import useToast from "hooks/use-toast";
// ui
import { SingleProgressStats } from "components/core";
@@ -43,6 +44,7 @@ interface IActiveCycleDetails {
export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = observer((props) => {
// props
const { workspaceSlug, projectId } = props;
const { resolvedTheme } = useTheme();
// store hooks
const { currentUser } = useUser();
const {
@@ -56,6 +58,7 @@ export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = observer((props
removeCycleFromFavorites,
} = useCycle();
const { currentProjectDetails } = useProject();
const { getUserDetails } = useMember();
// toast alert
const { setToastAlert } = useToast();
@@ -65,6 +68,7 @@ export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = observer((props
);
const activeCycle = currentProjectActiveCycleId ? getActiveCycleById(currentProjectActiveCycleId) : null;
const cycleOwnerDetails = activeCycle ? getUserDetails(activeCycle.owned_by) : undefined;
const { data: activeCycleIssues } = useSWR(
workspaceSlug && projectId && currentProjectActiveCycleId
@@ -76,7 +80,9 @@ export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = observer((props
);
const emptyStateDetail = CYCLE_EMPTY_STATE_DETAILS["active"];
const emptyStateImage = getEmptyStateImagePath("cycle", "active", currentUser?.theme.theme === "light");
const isLightMode = resolvedTheme ? resolvedTheme === "light" : currentUser?.theme.theme === "light";
const emptyStateImage = getEmptyStateImagePath("cycle", "active", isLightMode);
if (!activeCycle && isLoading)
return (
@@ -161,7 +167,7 @@ export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = observer((props
<h3 className="break-words text-lg font-semibold">{truncateText(activeCycle.name, 70)}</h3>
</Tooltip>
</span>
<span className="flex items-center gap-1 capitalize">
<span className="flex items-center gap-1">
<span className="flex gap-1 whitespace-nowrap rounded-sm text-sm px-3 py-0.5 bg-amber-500/10 text-amber-500">
{`${daysLeft} ${daysLeft > 1 ? "days" : "day"} left`}
</span>
@@ -199,20 +205,20 @@ export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = observer((props
<div className="flex items-center gap-4">
<div className="flex items-center gap-2.5 text-custom-text-200">
{activeCycle.owned_by.avatar && activeCycle.owned_by.avatar !== "" ? (
{cycleOwnerDetails?.avatar && cycleOwnerDetails?.avatar !== "" ? (
<img
src={activeCycle.owned_by.avatar}
src={cycleOwnerDetails?.avatar}
height={16}
width={16}
className="rounded-full"
alt={activeCycle.owned_by.display_name}
alt={cycleOwnerDetails?.display_name}
/>
) : (
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-custom-background-100 capitalize">
{activeCycle.owned_by.display_name.charAt(0)}
{cycleOwnerDetails?.display_name.charAt(0)}
</span>
)}
<span className="text-custom-text-200">{activeCycle.owned_by.display_name}</span>
<span className="text-custom-text-200">{cycleOwnerDetails?.display_name}</span>
</div>
{activeCycle.assignees.length > 0 && (
@@ -251,7 +257,7 @@ export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = observer((props
<div className="flex h-full w-full flex-col p-4 text-custom-text-200">
<div className="flex w-full items-center gap-2 py-1">
<span>Progress</span>
<LinearProgressIndicator data={progressIndicatorData} inPercentage />
<LinearProgressIndicator size="md" data={progressIndicatorData} inPercentage />
</div>
<div className="mt-2 flex flex-col items-center gap-1">
{Object.keys(groupedIssues).map((group, index) => (
+4 -5
View File
@@ -2,7 +2,7 @@ import { FC, MouseEvent, useState } from "react";
import { useRouter } from "next/router";
import Link from "next/link";
// hooks
import { useApplication, useCycle, useUser } from "hooks/store";
import { useEventTracker, useCycle, useUser } from "hooks/store";
import useToast from "hooks/use-toast";
// components
import { CycleCreateUpdateModal, CycleDeleteModal } from "components/cycles";
@@ -33,9 +33,7 @@ export const CyclesBoardCard: FC<ICyclesBoardCard> = (props) => {
// router
const router = useRouter();
// store
const {
eventTracker: { setTrackElement },
} = useApplication();
const { setTrackElement } = useEventTracker();
const {
membership: { currentProjectRole },
} = useUser();
@@ -117,14 +115,15 @@ export const CyclesBoardCard: FC<ICyclesBoardCard> = (props) => {
const handleEditCycle = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
setTrackElement("Cycles page board layout");
setUpdateModal(true);
};
const handleDeleteCycle = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
setTrackElement("Cycles page board layout");
setDeleteModal(true);
setTrackElement("CYCLE_PAGE_BOARD_LAYOUT");
};
const openCycleOverview = (e: MouseEvent<HTMLButtonElement>) => {
+6 -1
View File
@@ -1,5 +1,6 @@
import { FC } from "react";
import { observer } from "mobx-react-lite";
import { useTheme } from "next-themes";
// hooks
import { useUser } from "hooks/store";
// components
@@ -18,11 +19,15 @@ export interface ICyclesBoard {
export const CyclesBoard: FC<ICyclesBoard> = observer((props) => {
const { cycleIds, filter, workspaceSlug, projectId, peekCycle } = props;
// theme
const { resolvedTheme } = useTheme();
// store hooks
const { currentUser } = useUser();
const emptyStateDetail = CYCLE_EMPTY_STATE_DETAILS[filter as keyof typeof CYCLE_EMPTY_STATE_DETAILS];
const emptyStateImage = getEmptyStateImagePath("cycle", filter, currentUser?.theme.theme === "light");
const isLightMode = resolvedTheme ? resolvedTheme === "light" : currentUser?.theme.theme === "light";
const emptyStateImage = getEmptyStateImagePath("cycle", filter, isLightMode);
return (
<>
+4 -5
View File
@@ -2,7 +2,7 @@ import { FC, MouseEvent, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/router";
// hooks
import { useApplication, useCycle, useUser } from "hooks/store";
import { useEventTracker, useCycle, useUser } from "hooks/store";
import useToast from "hooks/use-toast";
// components
import { CycleCreateUpdateModal, CycleDeleteModal } from "components/cycles";
@@ -37,9 +37,7 @@ export const CyclesListItem: FC<TCyclesListItem> = (props) => {
// router
const router = useRouter();
// store hooks
const {
eventTracker: { setTrackElement },
} = useApplication();
const { setTrackElement } = useEventTracker();
const {
membership: { currentProjectRole },
} = useUser();
@@ -90,14 +88,15 @@ export const CyclesListItem: FC<TCyclesListItem> = (props) => {
const handleEditCycle = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
setTrackElement("Cycles page list layout");
setUpdateModal(true);
};
const handleDeleteCycle = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
setTrackElement("Cycles page list layout");
setDeleteModal(true);
setTrackElement("CYCLE_PAGE_LIST_LAYOUT");
};
const openCycleOverview = (e: MouseEvent<HTMLButtonElement>) => {
+6 -1
View File
@@ -1,5 +1,6 @@
import { FC } from "react";
import { observer } from "mobx-react-lite";
import { useTheme } from "next-themes";
// hooks
import { useUser } from "hooks/store";
// components
@@ -19,11 +20,15 @@ export interface ICyclesList {
export const CyclesList: FC<ICyclesList> = observer((props) => {
const { cycleIds, filter, workspaceSlug, projectId } = props;
// theme
const { resolvedTheme } = useTheme();
// store hooks
const { currentUser } = useUser();
const emptyStateDetail = CYCLE_EMPTY_STATE_DETAILS[filter as keyof typeof CYCLE_EMPTY_STATE_DETAILS];
const emptyStateImage = getEmptyStateImagePath("cycle", filter, currentUser?.theme.theme === "light");
const isLightMode = resolvedTheme ? resolvedTheme === "light" : currentUser?.theme.theme === "light";
const emptyStateImage = getEmptyStateImagePath("cycle", filter, isLightMode);
return (
<>
+8 -8
View File
@@ -4,7 +4,7 @@ import { Dialog, Transition } from "@headlessui/react";
import { observer } from "mobx-react-lite";
import { AlertTriangle } from "lucide-react";
// hooks
import { useApplication, useCycle } from "hooks/store";
import { useEventTracker, useCycle } from "hooks/store";
import useToast from "hooks/use-toast";
// components
import { Button } from "@plane/ui";
@@ -27,9 +27,7 @@ export const CycleDeleteModal: React.FC<ICycleDelete> = observer((props) => {
const router = useRouter();
const { cycleId, peekCycle } = router.query;
// store hooks
const {
eventTracker: { postHogEventTracker },
} = useApplication();
const { captureCycleEvent } = useEventTracker();
const { deleteCycle } = useCycle();
// toast alert
const { setToastAlert } = useToast();
@@ -46,13 +44,15 @@ export const CycleDeleteModal: React.FC<ICycleDelete> = observer((props) => {
title: "Success!",
message: "Cycle deleted successfully.",
});
postHogEventTracker("CYCLE_DELETE", {
state: "SUCCESS",
captureCycleEvent({
eventName: "Cycle deleted",
payload: { ...cycle, state: "SUCCESS" },
});
})
.catch(() => {
postHogEventTracker("CYCLE_DELETE", {
state: "FAILED",
captureCycleEvent({
eventName: "Cycle deleted",
payload: { ...cycle, state: "FAILED" },
});
});
+18 -1
View File
@@ -1,3 +1,4 @@
import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
// components
import { DateDropdown, ProjectDropdown } from "components/dropdowns";
@@ -11,19 +12,28 @@ import { ICycle } from "@plane/types";
type Props = {
handleFormSubmit: (values: Partial<ICycle>) => Promise<void>;
handleClose: () => void;
status: boolean;
projectId: string;
setActiveProject: (projectId: string) => void;
data?: ICycle | null;
};
const defaultValues: Partial<ICycle> = {
name: "",
description: "",
start_date: null,
end_date: null,
};
export const CycleForm: React.FC<Props> = (props) => {
const { handleFormSubmit, handleClose, projectId, setActiveProject, data } = props;
const { handleFormSubmit, handleClose, status, projectId, setActiveProject, data } = props;
// form data
const {
formState: { errors, isSubmitting },
handleSubmit,
control,
watch,
reset,
} = useForm<ICycle>({
defaultValues: {
project: projectId,
@@ -34,6 +44,13 @@ export const CycleForm: React.FC<Props> = (props) => {
},
});
useEffect(() => {
reset({
...defaultValues,
...data,
});
}, [data, reset]);
const startDate = watch("start_date");
const endDate = watch("end_date");
+34 -12
View File
@@ -1,9 +1,9 @@
import React, { useState } from "react";
import React, { useEffect, useState } from "react";
import { Dialog, Transition } from "@headlessui/react";
// services
import { CycleService } from "services/cycle.service";
// hooks
import { useApplication, useCycle } from "hooks/store";
import { useEventTracker, useCycle, useProject } from "hooks/store";
import useToast from "hooks/use-toast";
import useLocalStorage from "hooks/use-local-storage";
// components
@@ -25,11 +25,10 @@ const cycleService = new CycleService();
export const CycleCreateUpdateModal: React.FC<CycleModalProps> = (props) => {
const { isOpen, handleClose, data, workspaceSlug, projectId } = props;
// states
const [activeProject, setActiveProject] = useState<string>(projectId);
const [activeProject, setActiveProject] = useState<string | null>(null);
// store hooks
const {
eventTracker: { postHogEventTracker },
} = useApplication();
const { captureCycleEvent } = useEventTracker();
const { workspaceProjectIds } = useProject();
const { createCycle, updateCycleDetails } = useCycle();
// toast alert
const { setToastAlert } = useToast();
@@ -47,9 +46,9 @@ export const CycleCreateUpdateModal: React.FC<CycleModalProps> = (props) => {
title: "Success!",
message: "Cycle created successfully.",
});
postHogEventTracker("CYCLE_CREATE", {
...res,
state: "SUCCESS",
captureCycleEvent({
eventName: "Cycle created",
payload: { ...res, state: "SUCCESS" },
});
})
.catch((err) => {
@@ -58,8 +57,9 @@ export const CycleCreateUpdateModal: React.FC<CycleModalProps> = (props) => {
title: "Error!",
message: err.detail ?? "Error in creating cycle. Please try again.",
});
postHogEventTracker("CYCLE_CREATE", {
state: "FAILED",
captureCycleEvent({
eventName: "Cycle created",
payload: { ...payload, state: "FAILED" },
});
});
};
@@ -134,6 +134,27 @@ export const CycleCreateUpdateModal: React.FC<CycleModalProps> = (props) => {
});
};
useEffect(() => {
// if modal is closed, reset active project to null
// and return to avoid activeProject being set to some other project
if (!isOpen) {
setActiveProject(null);
return;
}
// if data is present, set active project to the project of the
// issue. This has more priority than the project in the url.
if (data && data.project) {
setActiveProject(data.project);
return;
}
// if data is not present, set active project to the project
// in the url. This has the least priority.
if (workspaceProjectIds && workspaceProjectIds.length > 0 && !activeProject)
setActiveProject(projectId ?? workspaceProjectIds?.[0] ?? null);
}, [activeProject, data, projectId, workspaceProjectIds, isOpen]);
return (
<Transition.Root show={isOpen} as={React.Fragment}>
<Dialog as="div" className="relative z-20" onClose={handleClose}>
@@ -164,7 +185,8 @@ export const CycleCreateUpdateModal: React.FC<CycleModalProps> = (props) => {
<CycleForm
handleFormSubmit={handleFormSubmit}
handleClose={handleClose}
projectId={activeProject}
status={data ? true : false}
projectId={activeProject ?? ""}
setActiveProject={setActiveProject}
data={data}
/>
+126 -107
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useState } from "react";
import React, { useEffect, useRef, useState } from "react";
import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
import { useForm } from "react-hook-form";
@@ -6,7 +6,7 @@ import { Disclosure, Popover, Transition } from "@headlessui/react";
// services
import { CycleService } from "services/cycle.service";
// hooks
import { useApplication, useCycle, useUser } from "hooks/store";
import { useEventTracker, useCycle, useUser, useMember } from "hooks/store";
import useToast from "hooks/use-toast";
// components
import { SidebarProgressStats } from "components/core";
@@ -46,6 +46,11 @@ type Props = {
handleClose: () => void;
};
const defaultValues: Partial<ICycle> = {
start_date: null,
end_date: null,
};
// services
const cycleService = new CycleService();
@@ -54,27 +59,25 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
const { cycleId, handleClose } = props;
// states
const [cycleDeleteModal, setCycleDeleteModal] = useState(false);
// refs
const startDateButtonRef = useRef<HTMLButtonElement | null>(null);
const endDateButtonRef = useRef<HTMLButtonElement | null>(null);
// router
const router = useRouter();
const { workspaceSlug, projectId, peekCycle } = router.query;
// store hooks
const {
eventTracker: { setTrackElement },
} = useApplication();
const { setTrackElement } = useEventTracker();
const {
membership: { currentProjectRole },
} = useUser();
const { getCycleById, updateCycleDetails } = useCycle();
const { getUserDetails } = useMember();
const cycleDetails = getCycleById(cycleId);
const cycleOwnerDetails = cycleDetails ? getUserDetails(cycleDetails.owned_by) : undefined;
const { setToastAlert } = useToast();
const defaultValues: Partial<ICycle> = {
start_date: new Date().toString(),
end_date: new Date().toString(),
};
const { setValue, reset, watch } = useForm({
defaultValues,
});
@@ -120,6 +123,9 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
const handleStartDateChange = async (date: string) => {
setValue("start_date", date);
if (!watch("end_date") || watch("end_date") === "") endDateButtonRef.current?.click();
if (watch("start_date") && watch("end_date") && watch("start_date") !== "" && watch("start_date") !== "") {
if (!isDateGreaterThanToday(`${watch("end_date")}`)) {
setToastAlert({
@@ -127,6 +133,7 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
title: "Error!",
message: "Unable to create cycle in past date. Please enter a valid date.",
});
reset({ ...cycleDetails });
return;
}
@@ -147,7 +154,6 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
title: "Success!",
message: "Cycle updated successfully.",
});
return;
} else {
setToastAlert({
type: "error",
@@ -155,8 +161,10 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
message:
"You have a cycle already on the given dates, if you want to create your draft cycle you can do that by removing dates",
});
return;
}
reset({ ...cycleDetails });
return;
}
const isDateValid = await dateChecker({
@@ -181,6 +189,7 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
message:
"You have a cycle already on the given dates, if you want to create your draft cycle you can do that by removing dates",
});
reset({ ...cycleDetails });
}
}
};
@@ -188,6 +197,8 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
const handleEndDateChange = async (date: string) => {
setValue("end_date", date);
if (!watch("start_date") || watch("start_date") === "") startDateButtonRef.current?.click();
if (watch("start_date") && watch("end_date") && watch("start_date") !== "" && watch("start_date") !== "") {
if (!isDateGreaterThanToday(`${watch("end_date")}`)) {
setToastAlert({
@@ -195,6 +206,7 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
title: "Error!",
message: "Unable to create cycle in past date. Please enter a valid date.",
});
reset({ ...cycleDetails });
return;
}
@@ -215,7 +227,6 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
title: "Success!",
message: "Cycle updated successfully.",
});
return;
} else {
setToastAlert({
type: "error",
@@ -223,8 +234,9 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
message:
"You have a cycle already on the given dates, if you want to create your draft cycle you can do that by removing dates",
});
return;
}
reset({ ...cycleDetails });
return;
}
const isDateValid = await dateChecker({
@@ -249,6 +261,7 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
message:
"You have a cycle already on the given dates, if you want to create your draft cycle you can do that by removing dates",
});
reset({ ...cycleDetails });
}
}
};
@@ -304,13 +317,7 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
const currentCycle = CYCLE_STATUS.find((status) => status.value === cycleStatus);
const issueCount =
cycleDetails.total_issues === 0
? "0 Issue"
: cycleDetails.total_issues === cycleDetails.completed_issues
? cycleDetails.total_issues > 1
? `${cycleDetails.total_issues}`
: `${cycleDetails.total_issues}`
: `${cycleDetails.completed_issues}/${cycleDetails.total_issues}`;
cycleDetails.total_issues === 0 ? "0 Issue" : `${cycleDetails.completed_issues}/${cycleDetails.total_issues}`;
const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserWorkspaceRoles.MEMBER;
@@ -387,50 +394,56 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
<div className="flex items-center justify-start gap-1">
<div className="flex w-1/2 items-center justify-start gap-2 text-custom-text-300">
<CalendarClock className="h-4 w-4" />
<span className="text-base">Start Date</span>
<span className="text-base">Start date</span>
</div>
<div className="relative flex w-1/2 items-center rounded-sm">
<Popover className="flex h-full w-full items-center justify-center rounded-lg">
<Popover.Button
className={`w-full cursor-pointer rounded-sm text-sm font-medium text-custom-text-300 hover:bg-custom-background-80 ${
isEditingAllowed ? "cursor-pointer" : "cursor-not-allowed"
}`}
disabled={isCompleted || !isEditingAllowed}
>
<span
className={`group flex w-full items-center justify-between gap-2 px-1.5 py-1 text-sm ${
watch("start_date") ? "" : "text-custom-text-400"
}`}
>
{renderFormattedDate(startDate) ?? "No date selected"}
</span>
</Popover.Button>
{({ close }) => (
<>
<Popover.Button
ref={startDateButtonRef}
className={`w-full cursor-pointer rounded-sm text-sm font-medium text-custom-text-300 hover:bg-custom-background-80 ${
isEditingAllowed ? "cursor-pointer" : "cursor-not-allowed"
}`}
disabled={isCompleted || !isEditingAllowed}
>
<span
className={`group flex w-full items-center justify-between gap-2 px-1.5 py-1 text-sm ${
watch("start_date") ? "" : "text-custom-text-400"
}`}
>
{renderFormattedDate(startDate) ?? "No date selected"}
</span>
</Popover.Button>
<Transition
as={React.Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<Popover.Panel className="absolute right-0 top-10 z-20 transform overflow-hidden">
<CustomRangeDatePicker
value={watch("start_date") ? watch("start_date") : cycleDetails?.start_date}
onChange={(val) => {
if (val) {
setTrackElement("CYCLE_PAGE_SIDEBAR_START_DATE_BUTTON");
handleStartDateChange(val);
}
}}
startDate={watch("start_date") ?? watch("end_date") ?? null}
endDate={watch("end_date") ?? watch("start_date") ?? null}
maxDate={new Date(`${watch("end_date")}`)}
selectsStart={watch("end_date") ? true : false}
/>
</Popover.Panel>
</Transition>
<Transition
as={React.Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<Popover.Panel className="absolute right-0 top-10 z-20 transform overflow-hidden">
<CustomRangeDatePicker
value={watch("start_date") ? watch("start_date") : cycleDetails?.start_date}
onChange={(val) => {
if (val) {
setTrackElement("CYCLE_PAGE_SIDEBAR_START_DATE_BUTTON");
handleStartDateChange(val);
close();
}
}}
startDate={watch("start_date") ?? watch("end_date") ?? null}
endDate={watch("end_date") ?? watch("start_date") ?? null}
maxDate={new Date(`${watch("end_date")}`)}
selectsStart={watch("end_date") ? true : false}
/>
</Popover.Panel>
</Transition>
</>
)}
</Popover>
</div>
</div>
@@ -438,52 +451,56 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
<div className="flex items-center justify-start gap-1">
<div className="flex w-1/2 items-center justify-start gap-2 text-custom-text-300">
<CalendarCheck2 className="h-4 w-4" />
<span className="text-base">Target Date</span>
<span className="text-base">Target date</span>
</div>
<div className="relative flex w-1/2 items-center rounded-sm">
<Popover className="flex h-full w-full items-center justify-center rounded-lg">
<>
<Popover.Button
className={`w-full cursor-pointer rounded-sm text-sm font-medium text-custom-text-300 hover:bg-custom-background-80 ${
isEditingAllowed ? "cursor-pointer" : "cursor-not-allowed"
}`}
disabled={isCompleted || !isEditingAllowed}
>
<span
className={`group flex w-full items-center justify-between gap-2 px-1.5 py-1 text-sm ${
watch("end_date") ? "" : "text-custom-text-400"
{({ close }) => (
<>
<Popover.Button
ref={endDateButtonRef}
className={`w-full cursor-pointer rounded-sm text-sm font-medium text-custom-text-300 hover:bg-custom-background-80 ${
isEditingAllowed ? "cursor-pointer" : "cursor-not-allowed"
}`}
disabled={isCompleted || !isEditingAllowed}
>
{renderFormattedDate(endDate) ?? "No date selected"}
</span>
</Popover.Button>
<span
className={`group flex w-full items-center justify-between gap-2 px-1.5 py-1 text-sm ${
watch("end_date") ? "" : "text-custom-text-400"
}`}
>
{renderFormattedDate(endDate) ?? "No date selected"}
</span>
</Popover.Button>
<Transition
as={React.Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<Popover.Panel className="absolute right-0 top-10 z-20 transform overflow-hidden">
<CustomRangeDatePicker
value={watch("end_date") ? watch("end_date") : cycleDetails?.end_date}
onChange={(val) => {
if (val) {
setTrackElement("CYCLE_PAGE_SIDEBAR_END_DATE_BUTTON");
handleEndDateChange(val);
}
}}
startDate={watch("start_date") ?? watch("end_date") ?? null}
endDate={watch("end_date") ?? watch("start_date") ?? null}
minDate={new Date(`${watch("start_date")}`)}
selectsEnd={watch("start_date") ? true : false}
/>
</Popover.Panel>
</Transition>
</>
<Transition
as={React.Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<Popover.Panel className="absolute right-0 top-10 z-20 transform overflow-hidden">
<CustomRangeDatePicker
value={watch("end_date") ? watch("end_date") : cycleDetails?.end_date}
onChange={(val) => {
if (val) {
setTrackElement("CYCLE_PAGE_SIDEBAR_END_DATE_BUTTON");
handleEndDateChange(val);
close();
}
}}
startDate={watch("start_date") ?? watch("end_date") ?? null}
endDate={watch("end_date") ?? watch("start_date") ?? null}
minDate={new Date(`${watch("start_date")}`)}
selectsEnd={watch("start_date") ? true : false}
/>
</Popover.Panel>
</Transition>
</>
)}
</Popover>
</div>
</div>
@@ -495,8 +512,8 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
</div>
<div className="flex w-1/2 items-center rounded-sm">
<div className="flex items-center gap-2.5">
<Avatar name={cycleDetails.owned_by.display_name} src={cycleDetails.owned_by.avatar} />
<span className="text-sm text-custom-text-200">{cycleDetails.owned_by.display_name}</span>
<Avatar name={cycleOwnerDetails?.display_name} src={cycleOwnerDetails?.avatar} />
<span className="text-sm text-custom-text-200">{cycleOwnerDetails?.display_name}</span>
</div>
</div>
</div>
@@ -550,7 +567,9 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
<Transition show={open}>
<Disclosure.Panel>
<div className="flex flex-col gap-3">
{isStartValid && isEndValid ? (
{cycleDetails.distribution?.completion_chart &&
cycleDetails.start_date &&
cycleDetails.end_date ? (
<div className="h-full w-full pt-4">
<div className="flex items-start gap-4 py-2 text-xs">
<div className="flex items-center gap-3 text-custom-text-100">
@@ -566,9 +585,9 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
</div>
<div className="relative h-40 w-80">
<ProgressChart
distribution={cycleDetails.distribution?.completion_chart ?? {}}
startDate={cycleDetails.start_date ?? ""}
endDate={cycleDetails.end_date ?? ""}
distribution={cycleDetails.distribution?.completion_chart}
startDate={cycleDetails.start_date}
endDate={cycleDetails.end_date}
totalIssues={cycleDetails.total_issues}
/>
</div>
@@ -50,11 +50,11 @@ export const DashboardWidgets = observer(() => {
// if the widget is full width, return it in a 2 column grid
if (widget.fullWidth)
return (
<div className="lg:col-span-2">
<div key={key} className="lg:col-span-2">
<WidgetComponent dashboardId={homeDashboardId} workspaceSlug={workspaceSlug} />
</div>
);
else return <WidgetComponent dashboardId={homeDashboardId} workspaceSlug={workspaceSlug} />;
else return <WidgetComponent key={key} dashboardId={homeDashboardId} workspaceSlug={workspaceSlug} />;
})}
</div>
);
@@ -1,7 +1,7 @@
import Image from "next/image";
import { observer } from "mobx-react-lite";
// hooks
import { useApplication, useUser } from "hooks/store";
import { useApplication, useEventTracker, useUser } from "hooks/store";
// ui
import { Button } from "@plane/ui";
// assets
@@ -14,6 +14,7 @@ export const DashboardProjectEmptyState = observer(() => {
const {
commandPalette: { toggleCreateProjectModal },
} = useApplication();
const { setTrackElement } = useEventTracker();
const {
membership: { currentWorkspaceRole },
} = useUser();
@@ -31,7 +32,13 @@ export const DashboardProjectEmptyState = observer(() => {
<Image src={ProjectEmptyStateImage} className="w-full" alt="Project empty state" />
{canCreateProject && (
<div className="flex justify-center">
<Button variant="primary" onClick={() => toggleCreateProjectModal(true)}>
<Button
variant="primary"
onClick={() => {
setTrackElement("Project empty state");
toggleCreateProjectModal(true);
}}
>
Build your first project
</Button>
</div>
@@ -17,7 +17,7 @@ import { getCustomDates, getRedirectionFilters } from "helpers/dashboard.helper"
// types
import { TAssignedIssuesWidgetFilters, TAssignedIssuesWidgetResponse } from "@plane/types";
// constants
import { ISSUES_TABS_LIST } from "constants/dashboard";
import { FILTERED_ISSUES_TABS_LIST, UNFILTERED_ISSUES_TABS_LIST } from "constants/dashboard";
const WIDGET_KEY = "assigned_issues";
@@ -30,6 +30,8 @@ export const AssignedIssuesWidget: React.FC<WidgetProps> = observer((props) => {
// derived values
const widgetDetails = getWidgetDetails(workspaceSlug, dashboardId, WIDGET_KEY);
const widgetStats = getWidgetStats<TAssignedIssuesWidgetResponse>(workspaceSlug, dashboardId, WIDGET_KEY);
const selectedTab = widgetDetails?.widget_filters.tab ?? "pending";
const selectedDurationFilter = widgetDetails?.widget_filters.target_date ?? "none";
const handleUpdateFilters = async (filters: Partial<TAssignedIssuesWidgetFilters>) => {
if (!widgetDetails) return;
@@ -41,68 +43,79 @@ export const AssignedIssuesWidget: React.FC<WidgetProps> = observer((props) => {
filters,
});
const filterDates = getCustomDates(filters.target_date ?? selectedDurationFilter);
fetchWidgetStats(workspaceSlug, dashboardId, {
widget_key: WIDGET_KEY,
issue_type: filters.tab ?? widgetDetails.widget_filters.tab ?? "upcoming",
target_date: getCustomDates(filters.target_date ?? widgetDetails.widget_filters.target_date ?? "this_week"),
issue_type: filters.tab ?? selectedTab,
...(filterDates.trim() !== "" ? { target_date: filterDates } : {}),
expand: "issue_relation",
}).finally(() => setFetching(false));
};
useEffect(() => {
const filterDates = getCustomDates(widgetDetails?.widget_filters.target_date ?? "this_week");
const filterDates = getCustomDates(selectedDurationFilter);
fetchWidgetStats(workspaceSlug, dashboardId, {
widget_key: WIDGET_KEY,
issue_type: widgetDetails?.widget_filters.tab ?? "upcoming",
target_date: filterDates,
issue_type: selectedTab,
...(filterDates.trim() !== "" ? { target_date: filterDates } : {}),
expand: "issue_relation",
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const filterParams = getRedirectionFilters(widgetDetails?.widget_filters.tab ?? "upcoming");
const filterParams = getRedirectionFilters(selectedTab);
const tabsList = selectedDurationFilter === "none" ? UNFILTERED_ISSUES_TABS_LIST : FILTERED_ISSUES_TABS_LIST;
if (!widgetDetails || !widgetStats) return <WidgetLoader widgetKey={WIDGET_KEY} />;
return (
<div className="bg-custom-background-100 rounded-xl border-[0.5px] border-custom-border-200 w-full hover:shadow-custom-shadow-4xl duration-300 flex flex-col min-h-96">
<div className="flex items-start justify-between gap-2 p-6 pl-7">
<div>
<Link
href={`/${workspaceSlug}/workspace-views/assigned/${filterParams}`}
className="text-lg font-semibold text-custom-text-300 hover:underline"
>
Assigned to you
</Link>
<p className="mt-3 text-xs font-medium text-custom-text-300">
Filtered by{" "}
<span className="border-[0.5px] border-custom-border-300 rounded py-1 px-2 ml-0.5">Due date</span>
</p>
</div>
<div className="flex items-center justify-between gap-2 p-6 pl-7">
<Link
href={`/${workspaceSlug}/workspace-views/assigned/${filterParams}`}
className="text-lg font-semibold text-custom-text-300 hover:underline"
>
Assigned to you
</Link>
<DurationFilterDropdown
value={widgetDetails.widget_filters.target_date ?? "this_week"}
onChange={(val) =>
handleUpdateFilters({
target_date: val,
})
}
value={selectedDurationFilter}
onChange={(val) => {
if (val === selectedDurationFilter) return;
// switch to pending tab if target date is changed to none
if (val === "none" && selectedTab !== "completed") {
handleUpdateFilters({ target_date: val, tab: "pending" });
return;
}
// switch to upcoming tab if target date is changed to other than none
if (val !== "none" && selectedDurationFilter === "none" && selectedTab !== "completed") {
handleUpdateFilters({
target_date: val,
tab: "upcoming",
});
return;
}
handleUpdateFilters({ target_date: val });
}}
/>
</div>
<Tab.Group
as="div"
defaultIndex={ISSUES_TABS_LIST.findIndex((t) => t.key === widgetDetails.widget_filters.tab ?? "upcoming")}
selectedIndex={tabsList.findIndex((tab) => tab.key === selectedTab)}
onChange={(i) => {
const selectedTab = ISSUES_TABS_LIST[i];
handleUpdateFilters({ tab: selectedTab.key ?? "upcoming" });
const selectedTab = tabsList[i];
handleUpdateFilters({ tab: selectedTab?.key ?? "pending" });
}}
className="h-full flex flex-col"
>
<div className="px-6">
<TabsList />
<TabsList durationFilter={selectedDurationFilter} selectedTab={selectedTab} />
</div>
<Tab.Panels as="div" className="h-full">
{ISSUES_TABS_LIST.map((tab) => (
{tabsList.map((tab) => (
<Tab.Panel key={tab.key} as="div" className="h-full flex flex-col">
<WidgetIssuesList
issues={widgetStats.issues}
@@ -17,7 +17,7 @@ import { getCustomDates, getRedirectionFilters } from "helpers/dashboard.helper"
// types
import { TCreatedIssuesWidgetFilters, TCreatedIssuesWidgetResponse } from "@plane/types";
// constants
import { ISSUES_TABS_LIST } from "constants/dashboard";
import { FILTERED_ISSUES_TABS_LIST, UNFILTERED_ISSUES_TABS_LIST } from "constants/dashboard";
const WIDGET_KEY = "created_issues";
@@ -30,6 +30,8 @@ export const CreatedIssuesWidget: React.FC<WidgetProps> = observer((props) => {
// derived values
const widgetDetails = getWidgetDetails(workspaceSlug, dashboardId, WIDGET_KEY);
const widgetStats = getWidgetStats<TCreatedIssuesWidgetResponse>(workspaceSlug, dashboardId, WIDGET_KEY);
const selectedTab = widgetDetails?.widget_filters.tab ?? "pending";
const selectedDurationFilter = widgetDetails?.widget_filters.target_date ?? "none";
const handleUpdateFilters = async (filters: Partial<TCreatedIssuesWidgetFilters>) => {
if (!widgetDetails) return;
@@ -41,65 +43,77 @@ export const CreatedIssuesWidget: React.FC<WidgetProps> = observer((props) => {
filters,
});
const filterDates = getCustomDates(filters.target_date ?? selectedDurationFilter);
fetchWidgetStats(workspaceSlug, dashboardId, {
widget_key: WIDGET_KEY,
issue_type: filters.tab ?? widgetDetails.widget_filters.tab ?? "upcoming",
target_date: getCustomDates(filters.target_date ?? widgetDetails.widget_filters.target_date ?? "this_week"),
issue_type: filters.tab ?? selectedTab,
...(filterDates.trim() !== "" ? { target_date: filterDates } : {}),
}).finally(() => setFetching(false));
};
useEffect(() => {
const filterDates = getCustomDates(selectedDurationFilter);
fetchWidgetStats(workspaceSlug, dashboardId, {
widget_key: WIDGET_KEY,
issue_type: widgetDetails?.widget_filters.tab ?? "upcoming",
target_date: getCustomDates(widgetDetails?.widget_filters.target_date ?? "this_week"),
issue_type: selectedTab,
...(filterDates.trim() !== "" ? { target_date: filterDates } : {}),
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const filterParams = getRedirectionFilters(widgetDetails?.widget_filters.tab ?? "upcoming");
const filterParams = getRedirectionFilters(selectedTab);
const tabsList = selectedDurationFilter === "none" ? UNFILTERED_ISSUES_TABS_LIST : FILTERED_ISSUES_TABS_LIST;
if (!widgetDetails || !widgetStats) return <WidgetLoader widgetKey={WIDGET_KEY} />;
return (
<div className="bg-custom-background-100 rounded-xl border-[0.5px] border-custom-border-200 w-full hover:shadow-custom-shadow-4xl duration-300 flex flex-col min-h-96">
<div className="flex items-start justify-between gap-2 p-6 pl-7">
<div>
<Link
href={`/${workspaceSlug}/workspace-views/created/${filterParams}`}
className="text-lg font-semibold text-custom-text-300 hover:underline"
>
Created by you
</Link>
<p className="mt-3 text-xs font-medium text-custom-text-300">
Filtered by{" "}
<span className="border-[0.5px] border-custom-border-300 rounded py-1 px-2 ml-0.5">Due date</span>
</p>
</div>
<div className="flex items-center justify-between gap-2 p-6 pl-7">
<Link
href={`/${workspaceSlug}/workspace-views/created/${filterParams}`}
className="text-lg font-semibold text-custom-text-300 hover:underline"
>
Created by you
</Link>
<DurationFilterDropdown
value={widgetDetails.widget_filters.target_date ?? "this_week"}
onChange={(val) =>
handleUpdateFilters({
target_date: val,
})
}
value={selectedDurationFilter}
onChange={(val) => {
if (val === selectedDurationFilter) return;
// switch to pending tab if target date is changed to none
if (val === "none" && selectedTab !== "completed") {
handleUpdateFilters({ target_date: val, tab: "pending" });
return;
}
// switch to upcoming tab if target date is changed to other than none
if (val !== "none" && selectedDurationFilter === "none" && selectedTab !== "completed") {
handleUpdateFilters({
target_date: val,
tab: "upcoming",
});
return;
}
handleUpdateFilters({ target_date: val });
}}
/>
</div>
<Tab.Group
as="div"
defaultIndex={ISSUES_TABS_LIST.findIndex((t) => t.key === widgetDetails.widget_filters.tab ?? "upcoming")}
selectedIndex={tabsList.findIndex((tab) => tab.key === selectedTab)}
onChange={(i) => {
const selectedTab = ISSUES_TABS_LIST[i];
handleUpdateFilters({ tab: selectedTab.key ?? "upcoming" });
const selectedTab = tabsList[i];
handleUpdateFilters({ tab: selectedTab.key ?? "pending" });
}}
className="h-full flex flex-col"
>
<div className="px-6">
<TabsList />
<TabsList durationFilter={selectedDurationFilter} selectedTab={selectedTab} />
</div>
<Tab.Panels as="div" className="h-full">
{ISSUES_TABS_LIST.map((tab) => (
<Tab.Panel as="div" className="h-full flex flex-col">
{tabsList.map((tab) => (
<Tab.Panel key={tab.key} as="div" className="h-full flex flex-col">
<WidgetIssuesList
issues={widgetStats.issues}
tab={tab.key}
@@ -26,11 +26,11 @@ export const DurationFilterDropdown: React.FC<Props> = (props) => {
placement="bottom-end"
closeOnSelect
>
{DURATION_FILTER_OPTIONS.map((option) => (
<CustomMenu.MenuItem key={option.key} onClick={() => onChange(option.key)}>
{option.label}
</CustomMenu.MenuItem>
))}
{DURATION_FILTER_OPTIONS.map((option) => (
<CustomMenu.MenuItem key={option.key} onClick={() => onChange(option.key)}>
{option.label}
</CustomMenu.MenuItem>
))}
</CustomMenu>
);
};
@@ -14,7 +14,7 @@ import {
IssueListItemProps,
} from "components/dashboard/widgets";
// ui
import { Loader, getButtonStyling } from "@plane/ui";
import { getButtonStyling } from "@plane/ui";
// helpers
import { cn } from "helpers/common.helper";
import { getRedirectionFilters } from "helpers/dashboard.helper";
@@ -41,16 +41,18 @@ export const WidgetIssuesList: React.FC<WidgetIssuesListProps> = (props) => {
const filterParams = getRedirectionFilters(tab);
const ISSUE_LIST_ITEM: {
[key in string]: {
[key: string]: {
[key in TIssuesListTypes]: React.FC<IssueListItemProps>;
};
} = {
assigned: {
pending: AssignedUpcomingIssueListItem,
upcoming: AssignedUpcomingIssueListItem,
overdue: AssignedOverdueIssueListItem,
completed: AssignedCompletedIssueListItem,
},
created: {
pending: CreatedUpcomingIssueListItem,
upcoming: CreatedUpcomingIssueListItem,
overdue: CreatedOverdueIssueListItem,
completed: CreatedCompletedIssueListItem,
@@ -61,12 +63,7 @@ export const WidgetIssuesList: React.FC<WidgetIssuesListProps> = (props) => {
<>
<div className="h-full">
{isLoading ? (
<Loader className="mt-7 mx-6 space-y-4">
<Loader.Item height="25px" />
<Loader.Item height="25px" />
<Loader.Item height="25px" />
<Loader.Item height="25px" />
</Loader>
<></>
) : issues.length > 0 ? (
<>
<div className="mt-7 mx-6 border-b-[0.5px] border-custom-border-200 grid grid-cols-6 gap-1 text-xs text-custom-text-300 pb-1">
@@ -77,11 +74,11 @@ export const WidgetIssuesList: React.FC<WidgetIssuesListProps> = (props) => {
})}
>
Issues
<span className="flex-shrink-0 bg-custom-primary-100/20 text-custom-primary-100 text-xs font-medium py-1 px-1.5 rounded-xl h-4 min-w-6 flex items-center text-center justify-center">
<span className="flex-shrink-0 bg-custom-primary-100/20 text-custom-primary-100 text-xs font-medium rounded-xl px-3 flex items-center text-center justify-center">
{totalIssues}
</span>
</h6>
{tab === "upcoming" && <h6 className="text-center">Due date</h6>}
{["upcoming", "pending"].includes(tab) && <h6 className="text-center">Due date</h6>}
{tab === "overdue" && <h6 className="text-center">Due by</h6>}
{type === "assigned" && tab !== "completed" && <h6 className="text-center">Blocked by</h6>}
{type === "created" && <h6 className="text-center">Assigned to</h6>}
@@ -1,26 +1,63 @@
import { observer } from "mobx-react";
import { Tab } from "@headlessui/react";
// helpers
import { cn } from "helpers/common.helper";
// types
import { TDurationFilterOptions, TIssuesListTypes } from "@plane/types";
// constants
import { ISSUES_TABS_LIST } from "constants/dashboard";
import { FILTERED_ISSUES_TABS_LIST, UNFILTERED_ISSUES_TABS_LIST } from "constants/dashboard";
export const TabsList = () => (
<Tab.List
as="div"
className="border-[0.5px] border-custom-border-200 rounded grid grid-cols-3 bg-custom-background-80"
>
{ISSUES_TABS_LIST.map((tab) => (
<Tab
key={tab.key}
className={({ selected }) =>
cn("font-semibold text-xs rounded py-1.5 focus:outline-none", {
"bg-custom-background-100 text-custom-text-300 shadow-[2px_0_8px_rgba(167,169,174,0.15)]": selected,
"text-custom-text-400": !selected,
})
}
>
{tab.label}
</Tab>
))}
</Tab.List>
);
type Props = {
durationFilter: TDurationFilterOptions;
selectedTab: TIssuesListTypes;
};
export const TabsList: React.FC<Props> = observer((props) => {
const { durationFilter, selectedTab } = props;
const tabsList = durationFilter === "none" ? UNFILTERED_ISSUES_TABS_LIST : FILTERED_ISSUES_TABS_LIST;
const selectedTabIndex = tabsList.findIndex((tab) => tab.key === (selectedTab ?? "pending"));
return (
<Tab.List
as="div"
className="relative border-[0.5px] border-custom-border-200 rounded bg-custom-background-80 grid"
style={{
gridTemplateColumns: `repeat(${tabsList.length}, 1fr)`,
}}
>
<div
className={cn("absolute bg-custom-background-100 rounded transition-all duration-500 ease-in-out", {
// right shadow
"shadow-[2px_0_8px_rgba(167,169,174,0.15)]": selectedTabIndex !== tabsList.length - 1,
// left shadow
"shadow-[-2px_0_8px_rgba(167,169,174,0.15)]": selectedTabIndex !== 0,
})}
style={{
height: "calc(100% - 1px)",
width: `${100 / tabsList.length}%`,
transform: `translateX(${selectedTabIndex * 100}%)`,
}}
/>
{tabsList.map((tab) => (
<Tab
key={tab.key}
className={cn(
"relative z-[1] font-semibold text-xs rounded py-1.5 text-custom-text-400 focus:outline-none",
"transition duration-500",
{
"text-custom-text-100 bg-custom-background-100": selectedTab === tab.key,
"hover:text-custom-text-300": selectedTab !== tab.key,
// // right shadow
// "shadow-[2px_0_8px_rgba(167,169,174,0.15)]": selectedTabIndex !== tabsList.length - 1,
// // left shadow
// "shadow-[-2px_0_8px_rgba(167,169,174,0.15)]": selectedTabIndex !== 0,
}
)}
>
<span className="scale-110">{tab.label}</span>
</Tab>
))}
</Tab.List>
);
});
@@ -84,16 +84,18 @@ export const IssuesByPriorityWidget: React.FC<WidgetProps> = observer((props) =>
filters,
});
const filterDates = getCustomDates(filters.target_date ?? widgetDetails.widget_filters.target_date ?? "none");
fetchWidgetStats(workspaceSlug, dashboardId, {
widget_key: WIDGET_KEY,
target_date: getCustomDates(filters.target_date ?? widgetDetails.widget_filters.target_date ?? "this_week"),
...(filterDates.trim() !== "" ? { target_date: filterDates } : {}),
});
};
useEffect(() => {
const filterDates = getCustomDates(widgetDetails?.widget_filters.target_date ?? "none");
fetchWidgetStats(workspaceSlug, dashboardId, {
widget_key: WIDGET_KEY,
target_date: getCustomDates(widgetDetails?.widget_filters.target_date ?? "this_week"),
...(filterDates.trim() !== "" ? { target_date: filterDates } : {}),
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@@ -129,21 +131,15 @@ export const IssuesByPriorityWidget: React.FC<WidgetProps> = observer((props) =>
return (
<div className="bg-custom-background-100 rounded-xl border-[0.5px] border-custom-border-200 w-full py-6 hover:shadow-custom-shadow-4xl duration-300 overflow-hidden min-h-96 flex flex-col">
<div className="flex items-start justify-between gap-2 pl-7 pr-6">
<div>
<Link
href={`/${workspaceSlug}/workspace-views/assigned`}
className="text-lg font-semibold text-custom-text-300 hover:underline"
>
Assigned by priority
</Link>
<p className="mt-3 text-xs font-medium text-custom-text-300">
Filtered by{" "}
<span className="border-[0.5px] border-custom-border-300 rounded py-1 px-2 ml-0.5">Due date</span>
</p>
</div>
<div className="flex items-center justify-between gap-2 pl-7 pr-6">
<Link
href={`/${workspaceSlug}/workspace-views/assigned`}
className="text-lg font-semibold text-custom-text-300 hover:underline"
>
Assigned by priority
</Link>
<DurationFilterDropdown
value={widgetDetails.widget_filters.target_date ?? "this_week"}
value={widgetDetails.widget_filters.target_date ?? "none"}
onChange={(val) =>
handleUpdateFilters({
target_date: val,
@@ -43,17 +43,19 @@ export const IssuesByStateGroupWidget: React.FC<WidgetProps> = observer((props)
filters,
});
const filterDates = getCustomDates(filters.target_date ?? widgetDetails.widget_filters.target_date ?? "none");
fetchWidgetStats(workspaceSlug, dashboardId, {
widget_key: WIDGET_KEY,
target_date: getCustomDates(filters.target_date ?? widgetDetails.widget_filters.target_date ?? "this_week"),
...(filterDates.trim() !== "" ? { target_date: filterDates } : {}),
});
};
// fetch widget stats
useEffect(() => {
const filterDates = getCustomDates(widgetDetails?.widget_filters.target_date ?? "none");
fetchWidgetStats(workspaceSlug, dashboardId, {
widget_key: WIDGET_KEY,
target_date: getCustomDates(widgetDetails?.widget_filters.target_date ?? "this_week"),
...(filterDates.trim() !== "" ? { target_date: filterDates } : {}),
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@@ -128,21 +130,15 @@ export const IssuesByStateGroupWidget: React.FC<WidgetProps> = observer((props)
return (
<div className="bg-custom-background-100 rounded-xl border-[0.5px] border-custom-border-200 w-full py-6 hover:shadow-custom-shadow-4xl duration-300 overflow-hidden min-h-96 flex flex-col">
<div className="flex items-start justify-between gap-2 pl-7 pr-6">
<div>
<Link
href={`/${workspaceSlug}/workspace-views/assigned`}
className="text-lg font-semibold text-custom-text-300 hover:underline"
>
Assigned by state
</Link>
<p className="mt-3 text-xs font-medium text-custom-text-300">
Filtered by{" "}
<span className="border-[0.5px] border-custom-border-300 rounded py-1 px-2 ml-0.5">Due date</span>
</p>
</div>
<div className="flex items-center justify-between gap-2 pl-7 pr-6">
<Link
href={`/${workspaceSlug}/workspace-views/assigned`}
className="text-lg font-semibold text-custom-text-300 hover:underline"
>
Assigned by state
</Link>
<DurationFilterDropdown
value={widgetDetails.widget_filters.target_date ?? "this_week"}
value={widgetDetails.widget_filters.target_date ?? "none"}
onChange={(val) =>
handleUpdateFilters({
target_date: val,
@@ -151,13 +147,13 @@ export const IssuesByStateGroupWidget: React.FC<WidgetProps> = observer((props)
/>
</div>
{totalCount > 0 ? (
<div className="flex items-center pl-20 md:pl-11 lg:pl-14 pr-11 mt-11">
<div className="flex md:flex-col lg:flex-row items-center gap-x-10 gap-y-8 w-full">
<div className="w-full flex justify-center">
<div className="flex items-center pl-10 md:pl-11 lg:pl-14 pr-11 mt-11">
<div className="flex flex-col sm:flex-row md:flex-row lg:flex-row items-center justify-evenly gap-x-10 gap-y-8 w-full">
<div>
<PieGraph
data={chartData}
height="220px"
width="220px"
width="200px"
innerRadius={0.6}
cornerRadius={5}
colors={(datum) => datum.data.color}
@@ -189,7 +185,7 @@ export const IssuesByStateGroupWidget: React.FC<WidgetProps> = observer((props)
layers={["arcs", CenteredMetric]}
/>
</div>
<div className="justify-self-end space-y-6 w-min whitespace-nowrap">
<div className="space-y-6 w-min whitespace-nowrap">
{chartData.map((item) => (
<div key={item.id} className="flex items-center justify-between gap-6">
<div className="flex items-center gap-2.5 w-24">
@@ -7,9 +7,9 @@ import { useDashboard } from "hooks/store";
import { WidgetLoader } from "components/dashboard/widgets";
// helpers
import { renderFormattedPayloadDate } from "helpers/date-time.helper";
import { cn } from "helpers/common.helper";
// types
import { TOverviewStatsWidgetResponse } from "@plane/types";
import { cn } from "helpers/common.helper";
export type WidgetProps = {
dashboardId: string;
@@ -63,34 +63,35 @@ export const OverviewStatsWidget: React.FC<WidgetProps> = observer((props) => {
if (!widgetStats) return <WidgetLoader widgetKey={WIDGET_KEY} />;
return (
<div className="bg-custom-background-100 rounded-xl border-[0.5px] border-custom-border-200 w-full grid grid-cols-4 p-0.5 hover:shadow-custom-shadow-4xl duration-300">
{STATS_LIST.map((stat, index) => {
const isFirst = index === 0;
const isLast = index === STATS_LIST.length - 1;
const isMiddle = !isFirst && !isLast;
return (
<div key={stat.key} className="flex relative">
{!isLast && (
<div className="absolute right-0 top-1/2 -translate-y-1/2 h-3/5 w-[0.5px] bg-custom-border-200" />
)}
<Link
href={stat.link}
className={cn(
`py-4 hover:bg-custom-background-80 duration-300 rounded-[10px] w-full break-words flex flex-col justify-center`,
{
"pl-11 pr-[4.725rem] mr-0.5": isFirst,
"px-[4.725rem] mx-0.5": isMiddle,
"px-[4.725rem] ml-0.5": isLast,
}
)}
>
<h5 className="font-semibold text-xl">{stat.count}</h5>
<p className="text-custom-text-300">{stat.title}</p>
</Link>
</div>
);
})}
<div
className="bg-custom-background-100 rounded-xl border-[0.5px] border-custom-border-200 w-full grid lg:grid-cols-4 md:grid-cols-2 sm:grid-cols-2 grid-cols-2 p-0.5 hover:shadow-custom-shadow-4xl duration-300
[&>div>a>div]:border-r
[&>div:last-child>a>div]:border-0
[&>div>a>div]:border-custom-border-200
[&>div:nth-child(2)>a>div]:border-0
[&>div:nth-child(2)>a>div]:lg:border-r
"
>
{STATS_LIST.map((stat, index) => (
<div
className={cn(
`w-full flex flex-col gap-2 hover:bg-custom-background-80`,
index === 0 ? "rounded-tl-xl lg:rounded-l-xl" : "",
index === STATS_LIST.length - 1 ? "rounded-br-xl lg:rounded-r-xl" : "",
index === 1 ? "rounded-tr-xl lg:rounded-[0px]" : "",
index == 2 ? "rounded-bl-xl lg:rounded-[0px]" : ""
)}
>
<Link href={stat.link} className="py-4 duration-300 rounded-[10px] w-full ">
<div className={`relative flex pl-10 sm:pl-20 md:pl-20 lg:pl-20 items-center`}>
<div>
<h5 className="font-semibold text-xl">{stat.count}</h5>
<p className="text-custom-text-300 text-sm xl:text-base">{stat.title}</p>
</div>
</div>
</Link>
</div>
))}
</div>
);
});
@@ -3,7 +3,7 @@ import Link from "next/link";
import { observer } from "mobx-react-lite";
import { Plus } from "lucide-react";
// hooks
import { useApplication, useDashboard, useProject, useUser } from "hooks/store";
import { useApplication, useEventTracker, useDashboard, useProject, useUser } from "hooks/store";
// components
import { WidgetLoader, WidgetProps } from "components/dashboard/widgets";
// ui
@@ -57,7 +57,7 @@ const ProjectListItem: React.FC<ProjectListItemProps> = observer((props) => {
<div className="mt-2">
<AvatarGroup>
{projectDetails.members?.map((member) => (
<Avatar src={member.member__avatar} name={member.member__display_name} />
<Avatar key={member.member_id} src={member.member__avatar} name={member.member__display_name} />
))}
</AvatarGroup>
</div>
@@ -72,6 +72,7 @@ export const RecentProjectsWidget: React.FC<WidgetProps> = observer((props) => {
const {
commandPalette: { toggleCreateProjectModal },
} = useApplication();
const { setTrackElement } = useEventTracker();
const {
membership: { currentWorkspaceRole },
} = useUser();
@@ -105,6 +106,7 @@ export const RecentProjectsWidget: React.FC<WidgetProps> = observer((props) => {
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setTrackElement("Sidebar");
toggleCreateProjectModal(true);
}}
>
@@ -1,23 +0,0 @@
import React, { useState, useEffect } from "react";
// react beautiful dnd
import { Droppable, DroppableProps } from "@hello-pangea/dnd";
const StrictModeDroppable = ({ children, ...props }: DroppableProps) => {
const [enabled, setEnabled] = useState(false);
useEffect(() => {
const animation = requestAnimationFrame(() => setEnabled(true));
return () => {
cancelAnimationFrame(animation);
setEnabled(false);
};
}, []);
if (!enabled) return null;
return <Droppable {...props}>{children}</Droppable>;
};
export default StrictModeDroppable;
+101
View File
@@ -0,0 +1,101 @@
// helpers
import { cn } from "helpers/common.helper";
// types
import { TButtonVariants } from "./types";
// constants
import { BACKGROUND_BUTTON_VARIANTS, BORDER_BUTTON_VARIANTS } from "./constants";
import { Tooltip } from "@plane/ui";
export type DropdownButtonProps = {
children: React.ReactNode;
className?: string;
isActive: boolean;
tooltipContent: string | React.ReactNode;
tooltipHeading: string;
showTooltip: boolean;
variant: TButtonVariants;
};
type ButtonProps = {
children: React.ReactNode;
className?: string;
isActive: boolean;
tooltipContent: string | React.ReactNode;
tooltipHeading: string;
showTooltip: boolean;
};
export const DropdownButton: React.FC<DropdownButtonProps> = (props) => {
const { children, className, isActive, tooltipContent, tooltipHeading, showTooltip, variant } = props;
const ButtonToRender: React.FC<ButtonProps> = BORDER_BUTTON_VARIANTS.includes(variant)
? BorderButton
: BACKGROUND_BUTTON_VARIANTS.includes(variant)
? BackgroundButton
: TransparentButton;
return (
<ButtonToRender
className={className}
isActive={isActive}
tooltipContent={tooltipContent}
tooltipHeading={tooltipHeading}
showTooltip={showTooltip}
>
{children}
</ButtonToRender>
);
};
const BorderButton: React.FC<ButtonProps> = (props) => {
const { children, className, isActive, tooltipContent, tooltipHeading, showTooltip } = props;
return (
<Tooltip tooltipHeading={tooltipHeading} tooltipContent={tooltipContent} disabled={!showTooltip}>
<div
className={cn(
"h-full flex items-center gap-1.5 border-[0.5px] border-custom-border-300 hover:bg-custom-background-80 rounded text-xs px-2 py-0.5",
{ "bg-custom-background-80": isActive },
className
)}
>
{children}
</div>
</Tooltip>
);
};
const BackgroundButton: React.FC<ButtonProps> = (props) => {
const { children, className, tooltipContent, tooltipHeading, showTooltip } = props;
return (
<Tooltip tooltipHeading={tooltipHeading} tooltipContent={tooltipContent} disabled={!showTooltip}>
<div
className={cn(
"h-full flex items-center gap-1.5 rounded text-xs px-2 py-0.5 bg-custom-background-80",
className
)}
>
{children}
</div>
</Tooltip>
);
};
const TransparentButton: React.FC<ButtonProps> = (props) => {
const { children, className, isActive, tooltipContent, tooltipHeading, showTooltip } = props;
return (
<Tooltip tooltipHeading={tooltipHeading} tooltipContent={tooltipContent} disabled={!showTooltip}>
<div
className={cn(
"h-full flex items-center gap-1.5 rounded text-xs px-2 py-0.5 hover:bg-custom-background-80",
{ "bg-custom-background-80": isActive },
className
)}
>
{children}
</div>
</Tooltip>
);
};
+20
View File
@@ -0,0 +1,20 @@
// types
import { TButtonVariants } from "./types";
export const BORDER_BUTTON_VARIANTS: TButtonVariants[] = ["border-with-text", "border-without-text"];
export const BACKGROUND_BUTTON_VARIANTS: TButtonVariants[] = ["background-with-text", "background-without-text"];
export const TRANSPARENT_BUTTON_VARIANTS: TButtonVariants[] = ["transparent-with-text", "transparent-without-text"];
export const BUTTON_VARIANTS_WITHOUT_TEXT: TButtonVariants[] = [
"border-without-text",
"background-without-text",
"transparent-without-text",
];
export const BUTTON_VARIANTS_WITH_TEXT: TButtonVariants[] = [
"border-with-text",
"background-with-text",
"transparent-with-text",
];
+51 -178
View File
@@ -7,13 +7,16 @@ import { Check, ChevronDown, Search } from "lucide-react";
import { useApplication, useCycle } from "hooks/store";
import { useDropdownKeyDown } from "hooks/use-dropdown-key-down";
import useOutsideClickDetector from "hooks/use-outside-click-detector";
// components
import { DropdownButton } from "./buttons";
// icons
import { ContrastIcon, Tooltip } from "@plane/ui";
import { ContrastIcon } from "@plane/ui";
// helpers
import { cn } from "helpers/common.helper";
// types
import { ICycle } from "@plane/types";
import { TDropdownProps } from "./types";
// constants
import { BUTTON_VARIANTS_WITH_TEXT } from "./constants";
type Props = TDropdownProps & {
button?: ReactNode;
@@ -24,17 +27,6 @@ type Props = TDropdownProps & {
value: string | null;
};
type ButtonProps = {
className?: string;
cycle: ICycle | null;
hideIcon: boolean;
hideText?: boolean;
dropdownArrow: boolean;
dropdownArrowClassName: string;
placeholder: string;
tooltip: boolean;
};
type DropdownOptions =
| {
value: string | null;
@@ -43,96 +35,6 @@ type DropdownOptions =
}[]
| undefined;
const BorderButton = (props: ButtonProps) => {
const {
className,
cycle,
dropdownArrow,
dropdownArrowClassName,
hideIcon = false,
hideText = false,
placeholder,
tooltip,
} = props;
return (
<Tooltip tooltipHeading="Cycle" tooltipContent={cycle?.name ?? placeholder} disabled={!tooltip}>
<div
className={cn(
"h-full flex items-center gap-1.5 border-[0.5px] border-custom-border-300 hover:bg-custom-background-80 rounded text-xs px-2 py-0.5",
className
)}
>
{!hideIcon && <ContrastIcon className="h-3 w-3 flex-shrink-0" />}{" "}
{!hideText && <span className="flex-grow truncate">{cycle?.name ?? placeholder}</span>}
{dropdownArrow && (
<ChevronDown className={cn("h-2.5 w-2.5 flex-shrink-0", dropdownArrowClassName)} aria-hidden="true" />
)}
</div>
</Tooltip>
);
};
const BackgroundButton = (props: ButtonProps) => {
const {
className,
cycle,
dropdownArrow,
dropdownArrowClassName,
hideIcon = false,
hideText = false,
placeholder,
tooltip,
} = props;
return (
<Tooltip tooltipHeading="Cycle" tooltipContent={cycle?.name ?? placeholder} disabled={!tooltip}>
<div
className={cn(
"h-full flex items-center gap-1.5 rounded text-xs px-2 py-0.5 bg-custom-background-80",
className
)}
>
{!hideIcon && <ContrastIcon className="h-3 w-3 flex-shrink-0" />}
{!hideText && <span className="flex-grow truncate">{cycle?.name ?? placeholder}</span>}
{dropdownArrow && (
<ChevronDown className={cn("h-2.5 w-2.5 flex-shrink-0", dropdownArrowClassName)} aria-hidden="true" />
)}
</div>
</Tooltip>
);
};
const TransparentButton = (props: ButtonProps) => {
const {
className,
cycle,
dropdownArrow,
dropdownArrowClassName,
hideIcon = false,
hideText = false,
placeholder,
tooltip,
} = props;
return (
<Tooltip tooltipHeading="Cycle" tooltipContent={cycle?.name ?? placeholder} disabled={!tooltip}>
<div
className={cn(
"h-full flex items-center gap-1.5 rounded text-xs px-2 py-0.5 hover:bg-custom-background-80",
className
)}
>
{!hideIcon && <ContrastIcon className="h-3 w-3 flex-shrink-0" />}
{!hideText && <span className="flex-grow truncate">{cycle?.name ?? placeholder}</span>}
{dropdownArrow && (
<ChevronDown className={cn("h-2.5 w-2.5 flex-shrink-0", dropdownArrowClassName)} aria-hidden="true" />
)}
</div>
</Tooltip>
);
};
export const CycleDropdown: React.FC<Props> = observer((props) => {
const {
button,
@@ -148,8 +50,8 @@ export const CycleDropdown: React.FC<Props> = observer((props) => {
placeholder = "Cycle",
placement,
projectId,
showTooltip = false,
tabIndex,
tooltip = false,
value,
} = props;
// states
@@ -216,13 +118,34 @@ export const CycleDropdown: React.FC<Props> = observer((props) => {
const selectedCycle = value ? getCycleById(value) : null;
const openDropdown = () => {
setIsOpen(true);
const onOpen = () => {
if (referenceElement) referenceElement.focus();
};
const closeDropdown = () => setIsOpen(false);
const handleKeyDown = useDropdownKeyDown(openDropdown, closeDropdown, isOpen);
useOutsideClickDetector(dropdownRef, closeDropdown);
const handleClose = () => {
if (isOpen) setIsOpen(false);
if (referenceElement) referenceElement.blur();
};
const toggleDropdown = () => {
if (!isOpen) onOpen();
setIsOpen((prevIsOpen) => !prevIsOpen);
};
const dropdownOnChange = (val: string | null) => {
onChange(val);
handleClose();
};
const handleKeyDown = useDropdownKeyDown(toggleDropdown, handleClose);
const handleOnClick = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
e.stopPropagation();
e.preventDefault();
toggleDropdown();
};
useOutsideClickDetector(dropdownRef, handleClose);
return (
<Combobox
@@ -231,7 +154,7 @@ export const CycleDropdown: React.FC<Props> = observer((props) => {
tabIndex={tabIndex}
className={cn("h-full", className)}
value={value}
onChange={onChange}
onChange={dropdownOnChange}
disabled={disabled}
onKeyDown={handleKeyDown}
>
@@ -241,7 +164,7 @@ export const CycleDropdown: React.FC<Props> = observer((props) => {
ref={setReferenceElement}
type="button"
className={cn("block h-full w-full outline-none", buttonContainerClassName)}
onClick={openDropdown}
onClick={handleOnClick}
>
{button}
</button>
@@ -257,73 +180,24 @@ export const CycleDropdown: React.FC<Props> = observer((props) => {
},
buttonContainerClassName
)}
onClick={openDropdown}
onClick={handleOnClick}
>
{/* TODO: move button components to a single file for each dropdown */}
{buttonVariant === "border-with-text" ? (
<BorderButton
cycle={selectedCycle}
className={buttonClassName}
dropdownArrow={dropdownArrow && !disabled}
dropdownArrowClassName={dropdownArrowClassName}
hideIcon={hideIcon}
placeholder={placeholder}
tooltip={tooltip}
/>
) : buttonVariant === "border-without-text" ? (
<BorderButton
cycle={selectedCycle}
className={buttonClassName}
dropdownArrow={dropdownArrow && !disabled}
dropdownArrowClassName={dropdownArrowClassName}
hideIcon={hideIcon}
hideText
placeholder={placeholder}
tooltip={tooltip}
/>
) : buttonVariant === "background-with-text" ? (
<BackgroundButton
cycle={selectedCycle}
className={buttonClassName}
dropdownArrow={dropdownArrow && !disabled}
dropdownArrowClassName={dropdownArrowClassName}
hideIcon={hideIcon}
placeholder={placeholder}
tooltip={tooltip}
/>
) : buttonVariant === "background-without-text" ? (
<BackgroundButton
cycle={selectedCycle}
className={buttonClassName}
dropdownArrow={dropdownArrow && !disabled}
dropdownArrowClassName={dropdownArrowClassName}
hideIcon={hideIcon}
hideText
placeholder={placeholder}
tooltip={tooltip}
/>
) : buttonVariant === "transparent-with-text" ? (
<TransparentButton
cycle={selectedCycle}
className={buttonClassName}
dropdownArrow={dropdownArrow && !disabled}
dropdownArrowClassName={dropdownArrowClassName}
hideIcon={hideIcon}
placeholder={placeholder}
tooltip={tooltip}
/>
) : buttonVariant === "transparent-without-text" ? (
<TransparentButton
cycle={selectedCycle}
className={buttonClassName}
dropdownArrow={dropdownArrow && !disabled}
dropdownArrowClassName={dropdownArrowClassName}
hideIcon={hideIcon}
hideText
placeholder={placeholder}
tooltip={tooltip}
/>
) : null}
<DropdownButton
className={buttonClassName}
isActive={isOpen}
tooltipHeading="Cycle"
tooltipContent={selectedCycle?.name ?? placeholder}
showTooltip={showTooltip}
variant={buttonVariant}
>
{!hideIcon && <ContrastIcon className="h-3 w-3 flex-shrink-0" />}
{BUTTON_VARIANTS_WITH_TEXT.includes(buttonVariant) && (
<span className="flex-grow truncate">{selectedCycle?.name ?? placeholder}</span>
)}
{dropdownArrow && (
<ChevronDown className={cn("h-2.5 w-2.5 flex-shrink-0", dropdownArrowClassName)} aria-hidden="true" />
)}
</DropdownButton>
</button>
)}
</Combobox.Button>
@@ -357,7 +231,6 @@ export const CycleDropdown: React.FC<Props> = observer((props) => {
active ? "bg-custom-background-80" : ""
} ${selected ? "text-custom-text-100" : "text-custom-text-200"}`
}
onClick={closeDropdown}
>
{({ selected }) => (
<>
+57 -229
View File
@@ -6,13 +6,15 @@ import { CalendarDays, X } from "lucide-react";
// hooks
import { useDropdownKeyDown } from "hooks/use-dropdown-key-down";
import useOutsideClickDetector from "hooks/use-outside-click-detector";
// ui
import { Tooltip } from "@plane/ui";
// components
import { DropdownButton } from "./buttons";
// helpers
import { renderFormattedDate } from "helpers/date-time.helper";
import { cn } from "helpers/common.helper";
// types
import { TDropdownProps } from "./types";
// constants
import { BUTTON_VARIANTS_WITH_TEXT } from "./constants";
type Props = TDropdownProps & {
clearIconClassName?: string;
@@ -25,145 +27,6 @@ type Props = TDropdownProps & {
closeOnSelect?: boolean;
};
type ButtonProps = {
className?: string;
clearIconClassName: string;
date: string | Date | null;
icon: React.ReactNode;
isClearable: boolean;
hideIcon?: boolean;
hideText?: boolean;
onClear: () => void;
placeholder: string;
tooltip: boolean;
};
const BorderButton = (props: ButtonProps) => {
const {
className,
clearIconClassName,
date,
icon,
isClearable,
hideIcon = false,
hideText = false,
onClear,
placeholder,
tooltip,
} = props;
return (
<Tooltip
tooltipHeading={placeholder}
tooltipContent={date ? renderFormattedDate(date) : "None"}
disabled={!tooltip}
>
<div
className={cn(
"h-full flex items-center gap-1.5 border-[0.5px] border-custom-border-300 hover:bg-custom-background-80 rounded text-xs px-2 py-0.5",
className
)}
>
{!hideIcon && icon}
{!hideText && <span className="flex-grow truncate">{date ? renderFormattedDate(date) : placeholder}</span>}
{isClearable && (
<X
className={cn("h-2 w-2 flex-shrink-0", clearIconClassName)}
onClick={(e) => {
e.stopPropagation();
onClear();
}}
/>
)}
</div>
</Tooltip>
);
};
const BackgroundButton = (props: ButtonProps) => {
const {
className,
clearIconClassName,
date,
icon,
isClearable,
hideIcon = false,
hideText = false,
onClear,
placeholder,
tooltip,
} = props;
return (
<Tooltip
tooltipHeading={placeholder}
tooltipContent={date ? renderFormattedDate(date) : "None"}
disabled={!tooltip}
>
<div
className={cn(
"h-full flex items-center gap-1.5 rounded text-xs px-2 py-0.5 bg-custom-background-80",
className
)}
>
{!hideIcon && icon}
{!hideText && <span className="flex-grow truncate">{date ? renderFormattedDate(date) : placeholder}</span>}
{isClearable && (
<X
className={cn("h-2 w-2 flex-shrink-0", clearIconClassName)}
onClick={(e) => {
e.stopPropagation();
onClear();
}}
/>
)}
</div>
</Tooltip>
);
};
const TransparentButton = (props: ButtonProps) => {
const {
className,
clearIconClassName,
date,
icon,
isClearable,
hideIcon = false,
hideText = false,
onClear,
placeholder,
tooltip,
} = props;
return (
<Tooltip
tooltipHeading={placeholder}
tooltipContent={date ? renderFormattedDate(date) : "None"}
disabled={!tooltip}
>
<div
className={cn(
"h-full flex items-center gap-1.5 rounded text-xs px-2 py-0.5 hover:bg-custom-background-80",
className
)}
>
{!hideIcon && icon}
{!hideText && <span className="flex-grow truncate">{date ? renderFormattedDate(date) : placeholder}</span>}
{isClearable && (
<X
className={cn("h-2 w-2 flex-shrink-0", clearIconClassName)}
onClick={(e) => {
e.stopPropagation();
onClear();
}}
/>
)}
</div>
</Tooltip>
);
};
export const DateDropdown: React.FC<Props> = (props) => {
const {
buttonClassName = "",
@@ -181,8 +44,8 @@ export const DateDropdown: React.FC<Props> = (props) => {
onChange,
placeholder = "Date",
placement,
showTooltip = false,
tabIndex,
tooltip = false,
value,
} = props;
const [isOpen, setIsOpen] = useState(false);
@@ -204,15 +67,36 @@ export const DateDropdown: React.FC<Props> = (props) => {
],
});
const isDateSelected = value !== null && value !== undefined && value.toString().trim() !== "";
const isDateSelected = value && value.toString().trim() !== "";
const openDropdown = () => {
setIsOpen(true);
const onOpen = () => {
if (referenceElement) referenceElement.focus();
};
const closeDropdown = () => setIsOpen(false);
const handleKeyDown = useDropdownKeyDown(openDropdown, closeDropdown, isOpen);
useOutsideClickDetector(dropdownRef, closeDropdown);
const handleClose = () => {
if (isOpen) setIsOpen(false);
if (referenceElement) referenceElement.blur();
};
const toggleDropdown = () => {
if (!isOpen) onOpen();
setIsOpen((prevIsOpen) => !prevIsOpen);
};
const dropdownOnChange = (val: Date | null) => {
onChange(val);
if (closeOnSelect) handleClose();
};
const handleKeyDown = useDropdownKeyDown(toggleDropdown, handleClose);
const handleOnClick = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
e.stopPropagation();
e.preventDefault();
toggleDropdown();
};
useOutsideClickDetector(dropdownRef, handleClose);
return (
<Combobox
@@ -221,6 +105,7 @@ export const DateDropdown: React.FC<Props> = (props) => {
tabIndex={tabIndex}
className={cn("h-full", className)}
onKeyDown={handleKeyDown}
disabled={disabled}
>
<Combobox.Button as={React.Fragment}>
<button
@@ -234,84 +119,30 @@ export const DateDropdown: React.FC<Props> = (props) => {
},
buttonContainerClassName
)}
onClick={openDropdown}
onClick={handleOnClick}
>
{buttonVariant === "border-with-text" ? (
<BorderButton
date={value}
className={buttonClassName}
clearIconClassName={clearIconClassName}
hideIcon={hideIcon}
icon={icon}
placeholder={placeholder}
isClearable={isClearable && isDateSelected}
onClear={() => onChange(null)}
tooltip={tooltip}
/>
) : buttonVariant === "border-without-text" ? (
<BorderButton
date={value}
className={buttonClassName}
clearIconClassName={clearIconClassName}
hideIcon={hideIcon}
icon={icon}
placeholder={placeholder}
isClearable={isClearable && isDateSelected}
onClear={() => onChange(null)}
tooltip={tooltip}
hideText
/>
) : buttonVariant === "background-with-text" ? (
<BackgroundButton
date={value}
className={buttonClassName}
clearIconClassName={clearIconClassName}
hideIcon={hideIcon}
icon={icon}
placeholder={placeholder}
isClearable={isClearable && isDateSelected}
onClear={() => onChange(null)}
tooltip={tooltip}
/>
) : buttonVariant === "background-without-text" ? (
<BackgroundButton
date={value}
className={buttonClassName}
clearIconClassName={clearIconClassName}
hideIcon={hideIcon}
icon={icon}
placeholder={placeholder}
isClearable={isClearable && isDateSelected}
onClear={() => onChange(null)}
tooltip={tooltip}
hideText
/>
) : buttonVariant === "transparent-with-text" ? (
<TransparentButton
date={value}
className={buttonClassName}
clearIconClassName={clearIconClassName}
hideIcon={hideIcon}
icon={icon}
placeholder={placeholder}
isClearable={isClearable && isDateSelected}
onClear={() => onChange(null)}
tooltip={tooltip}
/>
) : buttonVariant === "transparent-without-text" ? (
<TransparentButton
date={value}
className={buttonClassName}
clearIconClassName={clearIconClassName}
hideIcon={hideIcon}
icon={icon}
placeholder={placeholder}
isClearable={isClearable && isDateSelected}
onClear={() => onChange(null)}
tooltip={tooltip}
hideText
/>
) : null}
<DropdownButton
className={buttonClassName}
isActive={isOpen}
tooltipHeading={placeholder}
tooltipContent={value ? renderFormattedDate(value) : "None"}
showTooltip={showTooltip}
variant={buttonVariant}
>
{!hideIcon && icon}
{BUTTON_VARIANTS_WITH_TEXT.includes(buttonVariant) && (
<span className="flex-grow truncate">{value ? renderFormattedDate(value) : placeholder}</span>
)}
{isClearable && isDateSelected && (
<X
className={cn("h-2 w-2 flex-shrink-0", clearIconClassName)}
onClick={(e) => {
e.stopPropagation();
onChange(null);
}}
/>
)}
</DropdownButton>
</button>
</Combobox.Button>
{isOpen && (
@@ -319,10 +150,7 @@ export const DateDropdown: React.FC<Props> = (props) => {
<div className="my-1" ref={setPopperElement} style={styles.popper} {...attributes.popper}>
<DatePicker
selected={value ? new Date(value) : null}
onChange={(val) => {
onChange(val);
if (closeOnSelect) closeDropdown();
}}
onChange={dropdownOnChange}
dateFormat="dd-MM-yyyy"
minDate={minDate}
maxDate={maxDate}
+50 -196
View File
@@ -8,12 +8,14 @@ import sortBy from "lodash/sortBy";
import { useApplication, useEstimate } from "hooks/store";
import { useDropdownKeyDown } from "hooks/use-dropdown-key-down";
import useOutsideClickDetector from "hooks/use-outside-click-detector";
// ui
import { Tooltip } from "@plane/ui";
// components
import { DropdownButton } from "./buttons";
// helpers
import { cn } from "helpers/common.helper";
// types
import { TDropdownProps } from "./types";
// constants
import { BUTTON_VARIANTS_WITH_TEXT } from "./constants";
type Props = TDropdownProps & {
button?: ReactNode;
@@ -24,17 +26,6 @@ type Props = TDropdownProps & {
value: number | null;
};
type ButtonProps = {
className?: string;
estimatePoint: string | null;
dropdownArrow: boolean;
dropdownArrowClassName: string;
hideIcon?: boolean;
hideText?: boolean;
placeholder: string;
tooltip: boolean;
};
type DropdownOptions =
| {
value: number | null;
@@ -43,114 +34,6 @@ type DropdownOptions =
}[]
| undefined;
const BorderButton = (props: ButtonProps) => {
const {
className,
estimatePoint,
dropdownArrow,
dropdownArrowClassName,
hideIcon = false,
hideText = false,
placeholder,
tooltip,
} = props;
return (
<Tooltip
tooltipHeading="Estimate"
tooltipContent={estimatePoint !== null ? estimatePoint : placeholder}
disabled={!tooltip}
>
<div
className={cn(
"h-full flex items-center gap-1.5 border-[0.5px] border-custom-border-300 hover:bg-custom-background-80 rounded text-xs px-2 py-0.5",
className
)}
>
{!hideIcon && <Triangle className="h-3 w-3 flex-shrink-0" />}
{!hideText && (
<span className="flex-grow truncate">{estimatePoint !== null ? estimatePoint : placeholder}</span>
)}
{dropdownArrow && (
<ChevronDown className={cn("h-2.5 w-2.5 flex-shrink-0", dropdownArrowClassName)} aria-hidden="true" />
)}
</div>
</Tooltip>
);
};
const BackgroundButton = (props: ButtonProps) => {
const {
className,
estimatePoint,
dropdownArrow,
dropdownArrowClassName,
hideIcon = false,
hideText = false,
placeholder,
tooltip,
} = props;
return (
<Tooltip
tooltipHeading="Estimate"
tooltipContent={estimatePoint !== null ? estimatePoint : placeholder}
disabled={!tooltip}
>
<div
className={cn(
"h-full flex items-center gap-1.5 rounded text-xs px-2 py-0.5 bg-custom-background-80",
className
)}
>
{!hideIcon && <Triangle className="h-3 w-3 flex-shrink-0" />}
{!hideText && (
<span className="flex-grow truncate">{estimatePoint !== null ? estimatePoint : placeholder}</span>
)}
{dropdownArrow && (
<ChevronDown className={cn("h-2.5 w-2.5 flex-shrink-0", dropdownArrowClassName)} aria-hidden="true" />
)}
</div>
</Tooltip>
);
};
const TransparentButton = (props: ButtonProps) => {
const {
className,
estimatePoint,
dropdownArrow,
dropdownArrowClassName,
hideIcon = false,
hideText = false,
placeholder,
tooltip,
} = props;
return (
<Tooltip
tooltipHeading="Estimate"
tooltipContent={estimatePoint !== null ? estimatePoint : placeholder}
disabled={!tooltip}
>
<div
className={cn(
"h-full flex items-center gap-1.5 rounded text-xs px-2 py-0.5 hover:bg-custom-background-80",
className
)}
>
{!hideIcon && <Triangle className="h-3 w-3 flex-shrink-0" />}
{!hideText && (
<span className="flex-grow truncate">{estimatePoint !== null ? estimatePoint : placeholder}</span>
)}
{dropdownArrow && (
<ChevronDown className={cn("h-2.5 w-2.5 flex-shrink-0", dropdownArrowClassName)} aria-hidden="true" />
)}
</div>
</Tooltip>
);
};
export const EstimateDropdown: React.FC<Props> = observer((props) => {
const {
button,
@@ -166,8 +49,8 @@ export const EstimateDropdown: React.FC<Props> = observer((props) => {
placeholder = "Estimate",
placement,
projectId,
showTooltip = false,
tabIndex,
tooltip = false,
value,
} = props;
// states
@@ -223,15 +106,35 @@ export const EstimateDropdown: React.FC<Props> = observer((props) => {
const selectedEstimate = value !== null ? getEstimatePointValue(value, projectId) : null;
const openDropdown = () => {
setIsOpen(true);
const onOpen = () => {
if (!activeEstimate && workspaceSlug) fetchProjectEstimates(workspaceSlug, projectId);
if (referenceElement) referenceElement.focus();
};
const closeDropdown = () => setIsOpen(false);
const handleKeyDown = useDropdownKeyDown(openDropdown, closeDropdown, isOpen);
useOutsideClickDetector(dropdownRef, closeDropdown);
const handleClose = () => {
if (isOpen) setIsOpen(false);
if (referenceElement) referenceElement.blur();
};
const toggleDropdown = () => {
if (!isOpen) onOpen();
setIsOpen((prevIsOpen) => !prevIsOpen);
};
const dropdownOnChange = (val: number | null) => {
onChange(val);
handleClose();
};
const handleKeyDown = useDropdownKeyDown(toggleDropdown, handleClose);
const handleOnClick = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
e.stopPropagation();
e.preventDefault();
toggleDropdown();
};
useOutsideClickDetector(dropdownRef, handleClose);
return (
<Combobox
@@ -240,7 +143,7 @@ export const EstimateDropdown: React.FC<Props> = observer((props) => {
tabIndex={tabIndex}
className={cn("h-full w-full", className)}
value={value}
onChange={onChange}
onChange={dropdownOnChange}
disabled={disabled}
onKeyDown={handleKeyDown}
>
@@ -250,7 +153,7 @@ export const EstimateDropdown: React.FC<Props> = observer((props) => {
ref={setReferenceElement}
type="button"
className={cn("block h-full w-full outline-none", buttonContainerClassName)}
onClick={openDropdown}
onClick={handleOnClick}
>
{button}
</button>
@@ -266,72 +169,24 @@ export const EstimateDropdown: React.FC<Props> = observer((props) => {
},
buttonContainerClassName
)}
onClick={openDropdown}
onClick={handleOnClick}
>
{buttonVariant === "border-with-text" ? (
<BorderButton
estimatePoint={selectedEstimate}
className={buttonClassName}
dropdownArrow={dropdownArrow && !disabled}
dropdownArrowClassName={dropdownArrowClassName}
hideIcon={hideIcon}
placeholder={placeholder}
tooltip={tooltip}
/>
) : buttonVariant === "border-without-text" ? (
<BorderButton
estimatePoint={selectedEstimate}
className={buttonClassName}
dropdownArrow={dropdownArrow && !disabled}
dropdownArrowClassName={dropdownArrowClassName}
hideIcon={hideIcon}
hideText
placeholder={placeholder}
tooltip={tooltip}
/>
) : buttonVariant === "background-with-text" ? (
<BackgroundButton
estimatePoint={selectedEstimate}
className={buttonClassName}
dropdownArrow={dropdownArrow && !disabled}
dropdownArrowClassName={dropdownArrowClassName}
hideIcon={hideIcon}
placeholder={placeholder}
tooltip={tooltip}
/>
) : buttonVariant === "background-without-text" ? (
<BackgroundButton
estimatePoint={selectedEstimate}
className={buttonClassName}
dropdownArrow={dropdownArrow && !disabled}
dropdownArrowClassName={dropdownArrowClassName}
hideIcon={hideIcon}
hideText
placeholder={placeholder}
tooltip={tooltip}
/>
) : buttonVariant === "transparent-with-text" ? (
<TransparentButton
estimatePoint={selectedEstimate}
className={buttonClassName}
dropdownArrow={dropdownArrow && !disabled}
dropdownArrowClassName={dropdownArrowClassName}
hideIcon={hideIcon}
placeholder={placeholder}
tooltip={tooltip}
/>
) : buttonVariant === "transparent-without-text" ? (
<TransparentButton
estimatePoint={selectedEstimate}
className={buttonClassName}
dropdownArrow={dropdownArrow && !disabled}
dropdownArrowClassName={dropdownArrowClassName}
hideIcon={hideIcon}
hideText
placeholder={placeholder}
tooltip={tooltip}
/>
) : null}
<DropdownButton
className={buttonClassName}
isActive={isOpen}
tooltipHeading="Estimate"
tooltipContent={selectedEstimate !== null ? selectedEstimate : placeholder}
showTooltip={showTooltip}
variant={buttonVariant}
>
{!hideIcon && <Triangle className="h-3 w-3 flex-shrink-0" />}
{BUTTON_VARIANTS_WITH_TEXT.includes(buttonVariant) && (
<span className="flex-grow truncate">{selectedEstimate !== null ? selectedEstimate : placeholder}</span>
)}
{dropdownArrow && (
<ChevronDown className={cn("h-2.5 w-2.5 flex-shrink-0", dropdownArrowClassName)} aria-hidden="true" />
)}
</DropdownButton>
</button>
)}
</Combobox.Button>
@@ -365,7 +220,6 @@ export const EstimateDropdown: React.FC<Props> = observer((props) => {
active ? "bg-custom-background-80" : ""
} ${selected ? "text-custom-text-100" : "text-custom-text-200"}`
}
onClick={closeDropdown}
>
{({ selected }) => (
<>
@@ -0,0 +1,37 @@
import { observer } from "mobx-react-lite";
// hooks
import { useMember } from "hooks/store";
// ui
import { Avatar, AvatarGroup, UserGroupIcon } from "@plane/ui";
type AvatarProps = {
showTooltip: boolean;
userIds: string | string[] | null;
};
export const ButtonAvatars: React.FC<AvatarProps> = observer((props) => {
const { showTooltip, userIds } = props;
// store hooks
const { getUserDetails } = useMember();
if (Array.isArray(userIds)) {
if (userIds.length > 0)
return (
<AvatarGroup size="md" showTooltip={!showTooltip}>
{userIds.map((userId) => {
const userDetails = getUserDetails(userId);
if (!userDetails) return;
return <Avatar key={userId} src={userDetails.avatar} name={userDetails.display_name} />;
})}
</AvatarGroup>
);
} else {
if (userIds) {
const userDetails = getUserDetails(userIds);
return <Avatar src={userDetails?.avatar} name={userDetails?.display_name} size="md" showTooltip={!showTooltip} />;
}
}
return <UserGroupIcon className="h-3 w-3 flex-shrink-0" />;
});
-170
View File
@@ -1,170 +0,0 @@
import { observer } from "mobx-react-lite";
import { ChevronDown } from "lucide-react";
// hooks
import { useMember } from "hooks/store";
// ui
import { Avatar, AvatarGroup, Tooltip, UserGroupIcon } from "@plane/ui";
// helpers
import { cn } from "helpers/common.helper";
type ButtonProps = {
className?: string;
dropdownArrow: boolean;
dropdownArrowClassName: string;
placeholder: string;
hideIcon?: boolean;
hideText?: boolean;
tooltip: boolean;
userIds: string | string[] | null;
};
const ButtonAvatars = observer(({ tooltip, userIds }: { tooltip: boolean; userIds: string | string[] | null }) => {
const { getUserDetails } = useMember();
if (Array.isArray(userIds)) {
if (userIds.length > 0)
return (
<AvatarGroup size="md" showTooltip={!tooltip}>
{userIds.map((userId) => {
const userDetails = getUserDetails(userId);
if (!userDetails) return;
return <Avatar key={userId} src={userDetails.avatar} name={userDetails.display_name} />;
})}
</AvatarGroup>
);
} else {
if (userIds) {
const userDetails = getUserDetails(userIds);
return <Avatar src={userDetails?.avatar} name={userDetails?.display_name} size="md" showTooltip={!tooltip} />;
}
}
return <UserGroupIcon className="h-3 w-3 flex-shrink-0" />;
});
export const BorderButton = observer((props: ButtonProps) => {
const {
className,
dropdownArrow,
dropdownArrowClassName,
hideIcon = false,
hideText = false,
placeholder,
userIds,
tooltip,
} = props;
// store hooks
const { getUserDetails } = useMember();
const isMultiple = Array.isArray(userIds);
return (
<Tooltip
tooltipHeading={placeholder}
tooltipContent={`${userIds?.length ?? 0} assignee${userIds?.length !== 1 ? "s" : ""}`}
disabled={!tooltip}
>
<div
className={cn(
"h-full flex items-center gap-1.5 border-[0.5px] border-custom-border-300 hover:bg-custom-background-80 rounded text-xs px-2 py-0.5",
className
)}
>
{!hideIcon && <ButtonAvatars tooltip={tooltip} userIds={userIds} />}
{!hideText && (
<span className="flex-grow truncate">
{userIds ? (isMultiple ? placeholder : getUserDetails(userIds)?.display_name) : placeholder}
</span>
)}
{dropdownArrow && (
<ChevronDown className={cn("h-2.5 w-2.5 flex-shrink-0", dropdownArrowClassName)} aria-hidden="true" />
)}
</div>
</Tooltip>
);
});
export const BackgroundButton = observer((props: ButtonProps) => {
const {
className,
dropdownArrow,
dropdownArrowClassName,
hideIcon = false,
hideText = false,
placeholder,
userIds,
tooltip,
} = props;
// store hooks
const { getUserDetails } = useMember();
const isMultiple = Array.isArray(userIds);
return (
<Tooltip
tooltipHeading={placeholder}
tooltipContent={`${userIds?.length ?? 0} assignee${userIds?.length !== 1 ? "s" : ""}`}
disabled={!tooltip}
>
<div
className={cn(
"h-full flex items-center gap-1.5 rounded text-xs px-2 py-0.5 bg-custom-background-80",
className
)}
>
{!hideIcon && <ButtonAvatars tooltip={tooltip} userIds={userIds} />}
{!hideText && (
<span className="flex-grow truncate">
{userIds ? (isMultiple ? placeholder : getUserDetails(userIds)?.display_name) : placeholder}
</span>
)}
{dropdownArrow && (
<ChevronDown className={cn("h-2.5 w-2.5 flex-shrink-0", dropdownArrowClassName)} aria-hidden="true" />
)}
</div>
</Tooltip>
);
});
export const TransparentButton = observer((props: ButtonProps) => {
const {
className,
dropdownArrow,
dropdownArrowClassName,
hideIcon = false,
hideText = false,
placeholder,
userIds,
tooltip,
} = props;
// store hooks
const { getUserDetails } = useMember();
const isMultiple = Array.isArray(userIds);
return (
<Tooltip
tooltipHeading={placeholder}
tooltipContent={`${userIds?.length ?? 0} assignee${userIds?.length !== 1 ? "s" : ""}`}
disabled={!tooltip}
>
<div
className={cn(
"h-full flex items-center gap-1.5 rounded text-xs px-2 py-0.5 hover:bg-custom-background-80",
className
)}
>
{!hideIcon && <ButtonAvatars tooltip={tooltip} userIds={userIds} />}
{!hideText && (
<span className="flex-grow truncate">
{userIds ? (isMultiple ? placeholder : getUserDetails(userIds)?.display_name) : placeholder}
</span>
)}
{dropdownArrow && (
<ChevronDown className={cn("h-2.5 w-2.5 flex-shrink-0", dropdownArrowClassName)} aria-hidden="true" />
)}
</div>
</Tooltip>
);
});
-1
View File
@@ -1,3 +1,2 @@
export * from "./buttons";
export * from "./project-member";
export * from "./workspace-member";
@@ -2,19 +2,22 @@ import { Fragment, useRef, useState } from "react";
import { observer } from "mobx-react-lite";
import { Combobox } from "@headlessui/react";
import { usePopper } from "react-popper";
import { Check, Search } from "lucide-react";
import { Check, ChevronDown, Search } from "lucide-react";
// hooks
import { useApplication, useMember, useUser } from "hooks/store";
import { useDropdownKeyDown } from "hooks/use-dropdown-key-down";
import useOutsideClickDetector from "hooks/use-outside-click-detector";
// components
import { BackgroundButton, BorderButton, TransparentButton } from "components/dropdowns";
import { ButtonAvatars } from "./avatar";
import { DropdownButton } from "../buttons";
// icons
import { Avatar } from "@plane/ui";
// helpers
import { cn } from "helpers/common.helper";
// types
import { MemberDropdownProps } from "./types";
// constants
import { BUTTON_VARIANTS_WITH_TEXT } from "../constants";
type Props = {
projectId: string;
@@ -36,8 +39,8 @@ export const ProjectMemberDropdown: React.FC<Props> = observer((props) => {
placeholder = "Members",
placement,
projectId,
showTooltip = false,
tabIndex,
tooltip = false,
value,
} = props;
// states
@@ -96,15 +99,35 @@ export const ProjectMemberDropdown: React.FC<Props> = observer((props) => {
};
if (multiple) comboboxProps.multiple = true;
const openDropdown = () => {
setIsOpen(true);
const onOpen = () => {
if (!projectMemberIds && workspaceSlug) fetchProjectMembers(workspaceSlug, projectId);
if (referenceElement) referenceElement.focus();
};
const closeDropdown = () => setIsOpen(false);
const handleKeyDown = useDropdownKeyDown(openDropdown, closeDropdown, isOpen);
useOutsideClickDetector(dropdownRef, closeDropdown);
const handleClose = () => {
if (isOpen) setIsOpen(false);
if (referenceElement) referenceElement.blur();
};
const toggleDropdown = () => {
if (!isOpen) onOpen();
setIsOpen((prevIsOpen) => !prevIsOpen);
};
const dropdownOnChange = (val: string & string[]) => {
onChange(val);
if (!multiple) handleClose();
};
const handleKeyDown = useDropdownKeyDown(toggleDropdown, handleClose);
const handleOnClick = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
e.stopPropagation();
e.preventDefault();
toggleDropdown();
};
useOutsideClickDetector(dropdownRef, handleClose);
return (
<Combobox
@@ -112,6 +135,7 @@ export const ProjectMemberDropdown: React.FC<Props> = observer((props) => {
ref={dropdownRef}
tabIndex={tabIndex}
className={cn("h-full", className)}
onChange={dropdownOnChange}
onKeyDown={handleKeyDown}
{...comboboxProps}
>
@@ -121,7 +145,7 @@ export const ProjectMemberDropdown: React.FC<Props> = observer((props) => {
ref={setReferenceElement}
type="button"
className={cn("block h-full w-full outline-none", buttonContainerClassName)}
onClick={openDropdown}
onClick={handleOnClick}
>
{button}
</button>
@@ -137,72 +161,30 @@ export const ProjectMemberDropdown: React.FC<Props> = observer((props) => {
},
buttonContainerClassName
)}
onClick={openDropdown}
onClick={handleOnClick}
>
{buttonVariant === "border-with-text" ? (
<BorderButton
userIds={value}
className={buttonClassName}
dropdownArrow={dropdownArrow && !disabled}
dropdownArrowClassName={dropdownArrowClassName}
hideIcon={hideIcon}
placeholder={placeholder}
tooltip={tooltip}
/>
) : buttonVariant === "border-without-text" ? (
<BorderButton
userIds={value}
className={buttonClassName}
dropdownArrow={dropdownArrow && !disabled}
dropdownArrowClassName={dropdownArrowClassName}
hideIcon={hideIcon}
placeholder={placeholder}
tooltip={tooltip}
hideText
/>
) : buttonVariant === "background-with-text" ? (
<BackgroundButton
userIds={value}
className={buttonClassName}
dropdownArrow={dropdownArrow && !disabled}
dropdownArrowClassName={dropdownArrowClassName}
hideIcon={hideIcon}
placeholder={placeholder}
tooltip={tooltip}
/>
) : buttonVariant === "background-without-text" ? (
<BackgroundButton
userIds={value}
className={buttonClassName}
dropdownArrow={dropdownArrow && !disabled}
dropdownArrowClassName={dropdownArrowClassName}
hideIcon={hideIcon}
placeholder={placeholder}
tooltip={tooltip}
hideText
/>
) : buttonVariant === "transparent-with-text" ? (
<TransparentButton
userIds={value}
className={buttonClassName}
dropdownArrow={dropdownArrow && !disabled}
dropdownArrowClassName={dropdownArrowClassName}
hideIcon={hideIcon}
placeholder={placeholder}
tooltip={tooltip}
/>
) : buttonVariant === "transparent-without-text" ? (
<TransparentButton
userIds={value}
className={buttonClassName}
dropdownArrow={dropdownArrow && !disabled}
dropdownArrowClassName={dropdownArrowClassName}
hideIcon={hideIcon}
placeholder={placeholder}
tooltip={tooltip}
hideText
/>
) : null}
<DropdownButton
className={buttonClassName}
isActive={isOpen}
tooltipHeading={placeholder}
tooltipContent={`${value?.length ?? 0} assignee${value?.length !== 1 ? "s" : ""}`}
showTooltip={showTooltip}
variant={buttonVariant}
>
{!hideIcon && <ButtonAvatars showTooltip={showTooltip} userIds={value} />}
{BUTTON_VARIANTS_WITH_TEXT.includes(buttonVariant) && (
<span className="flex-grow truncate text-sm leading-5">
{Array.isArray(value) && value.length > 0
? value.length === 1
? getUserDetails(value[0])?.display_name
: ""
: placeholder}
</span>
)}
{dropdownArrow && (
<ChevronDown className={cn("h-2.5 w-2.5 flex-shrink-0", dropdownArrowClassName)} aria-hidden="true" />
)}
</DropdownButton>
</button>
)}
</Combobox.Button>
@@ -236,9 +218,6 @@ export const ProjectMemberDropdown: React.FC<Props> = observer((props) => {
active ? "bg-custom-background-80" : ""
} ${selected ? "text-custom-text-100" : "text-custom-text-200"}`
}
onClick={() => {
if (!multiple) closeDropdown();
}}
>
{({ selected }) => (
<>
@@ -2,19 +2,22 @@ import { Fragment, useRef, useState } from "react";
import { observer } from "mobx-react-lite";
import { Combobox } from "@headlessui/react";
import { usePopper } from "react-popper";
import { Check, Search } from "lucide-react";
import { Check, ChevronDown, Search } from "lucide-react";
// hooks
import { useMember, useUser } from "hooks/store";
import { useDropdownKeyDown } from "hooks/use-dropdown-key-down";
import useOutsideClickDetector from "hooks/use-outside-click-detector";
// components
import { BackgroundButton, BorderButton, TransparentButton } from "components/dropdowns";
import { ButtonAvatars } from "./avatar";
import { DropdownButton } from "../buttons";
// icons
import { Avatar } from "@plane/ui";
// helpers
import { cn } from "helpers/common.helper";
// types
import { MemberDropdownProps } from "./types";
// constants
import { BUTTON_VARIANTS_WITH_TEXT } from "../constants";
export const WorkspaceMemberDropdown: React.FC<MemberDropdownProps> = observer((props) => {
const {
@@ -31,13 +34,13 @@ export const WorkspaceMemberDropdown: React.FC<MemberDropdownProps> = observer((
onChange,
placeholder = "Members",
placement,
showTooltip = false,
tabIndex,
tooltip = false,
value,
} = props;
// states
const [query, setQuery] = useState("");
const [isOpen, setIsOpen] = useState(false);
const [isOpen, setIsOpen] = useState<boolean>(false);
// refs
const dropdownRef = useRef<HTMLDivElement | null>(null);
// popper-js refs
@@ -87,13 +90,34 @@ export const WorkspaceMemberDropdown: React.FC<MemberDropdownProps> = observer((
};
if (multiple) comboboxProps.multiple = true;
const openDropdown = () => {
setIsOpen(true);
const onOpen = () => {
if (referenceElement) referenceElement.focus();
};
const closeDropdown = () => setIsOpen(false);
const handleKeyDown = useDropdownKeyDown(openDropdown, closeDropdown, isOpen);
useOutsideClickDetector(dropdownRef, closeDropdown);
const handleClose = () => {
if (isOpen) setIsOpen(false);
if (referenceElement) referenceElement.blur();
};
const toggleDropdown = () => {
if (!isOpen) onOpen();
setIsOpen((prevIsOpen) => !prevIsOpen);
};
const dropdownOnChange = (val: string & string[]) => {
onChange(val);
if (!multiple) handleClose();
};
const handleKeyDown = useDropdownKeyDown(toggleDropdown, handleClose);
const handleOnClick = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
e.stopPropagation();
e.preventDefault();
toggleDropdown();
};
useOutsideClickDetector(dropdownRef, handleClose);
return (
<Combobox
@@ -103,6 +127,7 @@ export const WorkspaceMemberDropdown: React.FC<MemberDropdownProps> = observer((
className={cn("h-full", className)}
{...comboboxProps}
handleKeyDown={handleKeyDown}
onChange={dropdownOnChange}
>
<Combobox.Button as={Fragment}>
{button ? (
@@ -110,6 +135,7 @@ export const WorkspaceMemberDropdown: React.FC<MemberDropdownProps> = observer((
ref={setReferenceElement}
type="button"
className={cn("block h-full w-full outline-none", buttonContainerClassName)}
onClick={handleOnClick}
>
{button}
</button>
@@ -125,124 +151,82 @@ export const WorkspaceMemberDropdown: React.FC<MemberDropdownProps> = observer((
},
buttonContainerClassName
)}
onClick={handleOnClick}
>
{buttonVariant === "border-with-text" ? (
<BorderButton
userIds={value}
className={buttonClassName}
dropdownArrow={dropdownArrow && !disabled}
dropdownArrowClassName={dropdownArrowClassName}
hideIcon={hideIcon}
placeholder={placeholder}
tooltip={tooltip}
/>
) : buttonVariant === "border-without-text" ? (
<BorderButton
userIds={value}
className={buttonClassName}
dropdownArrow={dropdownArrow && !disabled}
dropdownArrowClassName={dropdownArrowClassName}
hideIcon={hideIcon}
placeholder={placeholder}
tooltip={tooltip}
hideText
/>
) : buttonVariant === "background-with-text" ? (
<BackgroundButton
userIds={value}
className={buttonClassName}
dropdownArrow={dropdownArrow && !disabled}
dropdownArrowClassName={dropdownArrowClassName}
hideIcon={hideIcon}
placeholder={placeholder}
tooltip={tooltip}
/>
) : buttonVariant === "background-without-text" ? (
<BackgroundButton
userIds={value}
className={buttonClassName}
dropdownArrow={dropdownArrow && !disabled}
dropdownArrowClassName={dropdownArrowClassName}
hideIcon={hideIcon}
placeholder={placeholder}
tooltip={tooltip}
hideText
/>
) : buttonVariant === "transparent-with-text" ? (
<TransparentButton
userIds={value}
className={buttonClassName}
dropdownArrow={dropdownArrow && !disabled}
dropdownArrowClassName={dropdownArrowClassName}
hideIcon={hideIcon}
placeholder={placeholder}
tooltip={tooltip}
/>
) : buttonVariant === "transparent-without-text" ? (
<TransparentButton
userIds={value}
className={buttonClassName}
dropdownArrow={dropdownArrow && !disabled}
dropdownArrowClassName={dropdownArrowClassName}
hideIcon={hideIcon}
placeholder={placeholder}
tooltip={tooltip}
hideText
/>
) : null}
<DropdownButton
className={buttonClassName}
isActive={isOpen}
tooltipHeading={placeholder}
tooltipContent={`${value?.length ?? 0} assignee${value?.length !== 1 ? "s" : ""}`}
showTooltip={showTooltip}
variant={buttonVariant}
>
{!hideIcon && <ButtonAvatars showTooltip={showTooltip} userIds={value} />}
{BUTTON_VARIANTS_WITH_TEXT.includes(buttonVariant) && (
<span className="flex-grow truncate text-sm leading-5">
{Array.isArray(value) && value.length > 0
? value.length === 1
? getUserDetails(value[0])?.display_name
: ""
: placeholder}
</span>
)}
{dropdownArrow && (
<ChevronDown className={cn("h-2.5 w-2.5 flex-shrink-0", dropdownArrowClassName)} aria-hidden="true" />
)}
</DropdownButton>
</button>
)}
</Combobox.Button>
<Combobox.Options className="fixed z-10">
<div
className="my-1 w-48 rounded border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 text-xs shadow-custom-shadow-rg focus:outline-none"
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
<div className="flex items-center gap-1.5 rounded border border-custom-border-100 bg-custom-background-90 px-2">
<Search className="h-3.5 w-3.5 text-custom-text-400" strokeWidth={1.5} />
<Combobox.Input
className="w-full bg-transparent py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search"
displayValue={(assigned: any) => assigned?.name}
/>
</div>
<div className="mt-2 max-h-48 space-y-1 overflow-y-scroll">
{filteredOptions ? (
filteredOptions.length > 0 ? (
filteredOptions.map((option) => (
<Combobox.Option
key={option.value}
value={option.value}
className={({ active, selected }) =>
`w-full truncate flex items-center justify-between gap-2 rounded px-1 py-1.5 cursor-pointer select-none ${
active ? "bg-custom-background-80" : ""
} ${selected ? "text-custom-text-100" : "text-custom-text-200"}`
}
onClick={() => {
if (!multiple) closeDropdown();
}}
>
{({ selected }) => (
<>
<span className="flex-grow truncate">{option.content}</span>
{selected && <Check className="h-3.5 w-3.5 flex-shrink-0" />}
</>
)}
</Combobox.Option>
))
{isOpen && (
<Combobox.Options className="fixed z-10" static>
<div
className="my-1 w-48 rounded border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 text-xs shadow-custom-shadow-rg focus:outline-none"
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
<div className="flex items-center gap-1.5 rounded border border-custom-border-100 bg-custom-background-90 px-2">
<Search className="h-3.5 w-3.5 text-custom-text-400" strokeWidth={1.5} />
<Combobox.Input
className="w-full bg-transparent py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search"
displayValue={(assigned: any) => assigned?.name}
/>
</div>
<div className="mt-2 max-h-48 space-y-1 overflow-y-scroll">
{filteredOptions ? (
filteredOptions.length > 0 ? (
filteredOptions.map((option) => (
<Combobox.Option
key={option.value}
value={option.value}
className={({ active, selected }) =>
`w-full truncate flex items-center justify-between gap-2 rounded px-1 py-1.5 cursor-pointer select-none ${
active ? "bg-custom-background-80" : ""
} ${selected ? "text-custom-text-100" : "text-custom-text-200"}`
}
>
{({ selected }) => (
<>
<span className="flex-grow truncate">{option.content}</span>
{selected && <Check className="h-3.5 w-3.5 flex-shrink-0" />}
</>
)}
</Combobox.Option>
))
) : (
<p className="text-custom-text-400 italic py-1 px-1.5">No matching results</p>
)
) : (
<p className="text-custom-text-400 italic py-1 px-1.5">No matching results</p>
)
) : (
<p className="text-custom-text-400 italic py-1 px-1.5">Loading...</p>
)}
<p className="text-custom-text-400 italic py-1 px-1.5">Loading...</p>
)}
</div>
</div>
</div>
</Combobox.Options>
</Combobox.Options>
)}
</Combobox>
);
});
+167 -173
View File
@@ -2,27 +2,40 @@ import { Fragment, ReactNode, useEffect, useRef, useState } from "react";
import { observer } from "mobx-react-lite";
import { Combobox } from "@headlessui/react";
import { usePopper } from "react-popper";
import { Check, ChevronDown, Search } from "lucide-react";
import { Check, ChevronDown, Search, X } from "lucide-react";
// hooks
import { useApplication, useModule } from "hooks/store";
import { useDropdownKeyDown } from "hooks/use-dropdown-key-down";
import useOutsideClickDetector from "hooks/use-outside-click-detector";
// components
import { DropdownButton } from "./buttons";
// icons
import { DiceIcon, Tooltip } from "@plane/ui";
// helpers
import { cn } from "helpers/common.helper";
// types
import { IModule } from "@plane/types";
import { TDropdownProps } from "./types";
// constants
import { BUTTON_VARIANTS_WITHOUT_TEXT } from "./constants";
type Props = TDropdownProps & {
button?: ReactNode;
dropdownArrow?: boolean;
dropdownArrowClassName?: string;
onChange: (val: string | null) => void;
projectId: string;
value: string | null;
};
showCount?: boolean;
} & (
| {
multiple: false;
onChange: (val: string | null) => void;
value: string | null;
}
| {
multiple: true;
onChange: (val: string[]) => void;
value: string[];
}
);
type DropdownOptions =
| {
@@ -32,105 +45,97 @@ type DropdownOptions =
}[]
| undefined;
type ButtonProps = {
className?: string;
type ButtonContentProps = {
disabled: boolean;
dropdownArrow: boolean;
dropdownArrowClassName: string;
hideIcon?: boolean;
hideText?: boolean;
module: IModule | null;
hideIcon: boolean;
hideText: boolean;
onChange: (moduleIds: string[]) => void;
placeholder: string;
tooltip: boolean;
showCount: boolean;
value: string | string[] | null;
};
const BorderButton = (props: ButtonProps) => {
const ButtonContent: React.FC<ButtonContentProps> = (props) => {
const {
className,
disabled,
dropdownArrow,
dropdownArrowClassName,
hideIcon = false,
hideText = false,
module,
hideIcon,
hideText,
onChange,
placeholder,
tooltip,
showCount,
value,
} = props;
// store hooks
const { getModuleById } = useModule();
return (
<Tooltip tooltipHeading="Module" tooltipContent={module?.name ?? placeholder} disabled={!tooltip}>
<div
className={cn(
"h-full flex items-center gap-1.5 border-[0.5px] border-custom-border-300 hover:bg-custom-background-80 rounded text-xs px-2 py-0.5",
className
if (Array.isArray(value))
return (
<>
{showCount ? (
<>
{!hideIcon && <DiceIcon className="h-3 w-3 flex-shrink-0" />}
<span className="flex-grow truncate text-left">
{value.length > 0 ? `${value.length} Module${value.length === 1 ? "" : "s"}` : placeholder}
</span>
</>
) : value.length > 0 ? (
<div className="flex items-center gap-2 py-0.5 flex-wrap">
{value.map((moduleId) => {
const moduleDetails = getModuleById(moduleId);
return (
<div
key={moduleId}
className="flex items-center gap-1 bg-custom-background-80 text-custom-text-200 rounded px-1.5 py-1"
>
{!hideIcon && <DiceIcon className="h-2.5 w-2.5 flex-shrink-0" />}
{!hideText && (
<Tooltip tooltipHeading="Title" tooltipContent={moduleDetails?.name}>
<span className="text-xs font-medium flex-grow truncate max-w-40">{moduleDetails?.name}</span>
</Tooltip>
)}
{!disabled && (
<Tooltip tooltipContent="Remove">
<button
type="button"
className="flex-shrink-0"
onClick={() => {
const newModuleIds = value.filter((m) => m !== moduleId);
onChange(newModuleIds);
}}
>
<X className="h-2.5 w-2.5 text-custom-text-300 hover:text-red-500" />
</button>
</Tooltip>
)}
</div>
);
})}
</div>
) : (
<>
{!hideIcon && <DiceIcon className="h-3 w-3 flex-shrink-0" />}
<span className="flex-grow truncate text-left">{placeholder}</span>
</>
)}
>
{!hideIcon && <DiceIcon className="h-3 w-3 flex-shrink-0" />}
{!hideText && <span className="flex-grow truncate">{module?.name ?? placeholder}</span>}
{dropdownArrow && (
<ChevronDown className={cn("h-2.5 w-2.5 flex-shrink-0", dropdownArrowClassName)} aria-hidden="true" />
)}
</div>
</Tooltip>
);
};
const BackgroundButton = (props: ButtonProps) => {
const {
className,
dropdownArrow,
dropdownArrowClassName,
hideIcon = false,
hideText = false,
module,
placeholder,
tooltip,
} = props;
return (
<Tooltip tooltipHeading="Module" tooltipContent={module?.name ?? placeholder} disabled={!tooltip}>
<div
className={cn(
"h-full flex items-center gap-1.5 rounded text-xs px-2 py-0.5 bg-custom-background-80",
className
)}
>
</>
);
else
return (
<>
{!hideIcon && <DiceIcon className="h-3 w-3 flex-shrink-0" />}
{!hideText && <span className="flex-grow truncate">{module?.name ?? placeholder}</span>}
{!hideText && <span className="flex-grow truncate text-left">{value ?? placeholder}</span>}
{dropdownArrow && (
<ChevronDown className={cn("h-2.5 w-2.5 flex-shrink-0", dropdownArrowClassName)} aria-hidden="true" />
)}
</div>
</Tooltip>
);
};
const TransparentButton = (props: ButtonProps) => {
const {
className,
dropdownArrow,
dropdownArrowClassName,
hideIcon = false,
hideText = false,
module,
placeholder,
tooltip,
} = props;
return (
<Tooltip tooltipHeading="Module" tooltipContent={module?.name ?? placeholder} disabled={!tooltip}>
<div
className={cn(
"h-full flex items-center gap-1.5 rounded text-xs px-2 py-0.5 hover:bg-custom-background-80",
className
)}
>
{!hideIcon && <DiceIcon className="h-3 w-3 flex-shrink-0" />}
{!hideText && <span className="flex-grow truncate">{module?.name ?? placeholder}</span>}
{dropdownArrow && (
<ChevronDown className={cn("h-2.5 w-2.5 flex-shrink-0", dropdownArrowClassName)} aria-hidden="true" />
)}
</div>
</Tooltip>
);
</>
);
};
export const ModuleDropdown: React.FC<Props> = observer((props) => {
@@ -144,12 +149,14 @@ export const ModuleDropdown: React.FC<Props> = observer((props) => {
dropdownArrow = false,
dropdownArrowClassName = "",
hideIcon = false,
multiple,
onChange,
placeholder = "Module",
placement,
projectId,
showCount = false,
showTooltip = false,
tabIndex,
tooltip = false,
value,
} = props;
// states
@@ -181,7 +188,6 @@ export const ModuleDropdown: React.FC<Props> = observer((props) => {
const options: DropdownOptions = moduleIds?.map((moduleId) => {
const moduleDetails = getModuleById(moduleId);
return {
value: moduleId,
query: `${moduleDetails?.name}`,
@@ -193,16 +199,17 @@ export const ModuleDropdown: React.FC<Props> = observer((props) => {
),
};
});
options?.unshift({
value: null,
query: "No module",
content: (
<div className="flex items-center gap-2">
<DiceIcon className="h-3 w-3 flex-shrink-0" />
<span className="flex-grow truncate">No module</span>
</div>
),
});
if (!multiple)
options?.unshift({
value: null,
query: "No module",
content: (
<div className="flex items-center gap-2">
<DiceIcon className="h-3 w-3 flex-shrink-0" />
<span className="flex-grow truncate">No module</span>
</div>
),
});
const filteredOptions =
query === "" ? options : options?.filter((o) => o.query.toLowerCase().includes(query.toLowerCase()));
@@ -214,15 +221,41 @@ export const ModuleDropdown: React.FC<Props> = observer((props) => {
if (!moduleIds) fetchModules(workspaceSlug, projectId);
}, [moduleIds, fetchModules, projectId, workspaceSlug]);
const selectedModule = value ? getModuleById(value) : null;
const openDropdown = () => {
setIsOpen(true);
const onOpen = () => {
if (referenceElement) referenceElement.focus();
};
const closeDropdown = () => setIsOpen(false);
const handleKeyDown = useDropdownKeyDown(openDropdown, closeDropdown, isOpen);
useOutsideClickDetector(dropdownRef, closeDropdown);
const handleClose = () => {
if (isOpen) setIsOpen(false);
if (referenceElement) referenceElement.blur();
};
const toggleDropdown = () => {
if (!isOpen) onOpen();
setIsOpen((prevIsOpen) => !prevIsOpen);
};
const dropdownOnChange = (val: string & string[]) => {
onChange(val);
if (!multiple) handleClose();
};
const handleKeyDown = useDropdownKeyDown(toggleDropdown, handleClose);
const handleOnClick = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
e.stopPropagation();
e.preventDefault();
toggleDropdown();
};
useOutsideClickDetector(dropdownRef, handleClose);
const comboboxProps: any = {
value,
onChange: dropdownOnChange,
disabled,
};
if (multiple) comboboxProps.multiple = true;
return (
<Combobox
@@ -230,10 +263,8 @@ export const ModuleDropdown: React.FC<Props> = observer((props) => {
ref={dropdownRef}
tabIndex={tabIndex}
className={cn("h-full", className)}
value={value}
onChange={onChange}
disabled={disabled}
onKeyDown={handleKeyDown}
{...comboboxProps}
>
<Combobox.Button as={Fragment}>
{button ? (
@@ -241,7 +272,7 @@ export const ModuleDropdown: React.FC<Props> = observer((props) => {
ref={setReferenceElement}
type="button"
className={cn("block h-full w-full outline-none", buttonContainerClassName)}
onClick={openDropdown}
onClick={handleOnClick}
>
{button}
</button>
@@ -257,72 +288,31 @@ export const ModuleDropdown: React.FC<Props> = observer((props) => {
},
buttonContainerClassName
)}
onClick={openDropdown}
onClick={handleOnClick}
>
{buttonVariant === "border-with-text" ? (
<BorderButton
module={selectedModule}
className={buttonClassName}
dropdownArrow={dropdownArrow && !disabled}
<DropdownButton
className={buttonClassName}
isActive={isOpen}
tooltipHeading="Module"
tooltipContent={
Array.isArray(value) ? `${value?.length ?? 0} module${value?.length !== 1 ? "s" : ""}` : ""
}
showTooltip={showTooltip}
variant={buttonVariant}
>
<ButtonContent
disabled={disabled}
dropdownArrow={dropdownArrow}
dropdownArrowClassName={dropdownArrowClassName}
hideIcon={hideIcon}
hideText={BUTTON_VARIANTS_WITHOUT_TEXT.includes(buttonVariant)}
placeholder={placeholder}
tooltip={tooltip}
showCount={showCount}
value={value}
// @ts-ignore
onChange={onChange}
/>
) : buttonVariant === "border-without-text" ? (
<BorderButton
module={selectedModule}
className={buttonClassName}
dropdownArrow={dropdownArrow && !disabled}
dropdownArrowClassName={dropdownArrowClassName}
hideIcon={hideIcon}
hideText
placeholder={placeholder}
tooltip={tooltip}
/>
) : buttonVariant === "background-with-text" ? (
<BackgroundButton
module={selectedModule}
className={buttonClassName}
dropdownArrow={dropdownArrow && !disabled}
dropdownArrowClassName={dropdownArrowClassName}
hideIcon={hideIcon}
placeholder={placeholder}
tooltip={tooltip}
/>
) : buttonVariant === "background-without-text" ? (
<BackgroundButton
module={selectedModule}
className={buttonClassName}
dropdownArrow={dropdownArrow && !disabled}
dropdownArrowClassName={dropdownArrowClassName}
hideIcon={hideIcon}
hideText
placeholder={placeholder}
tooltip={tooltip}
/>
) : buttonVariant === "transparent-with-text" ? (
<TransparentButton
module={selectedModule}
className={buttonClassName}
dropdownArrow={dropdownArrow && !disabled}
dropdownArrowClassName={dropdownArrowClassName}
hideIcon={hideIcon}
placeholder={placeholder}
tooltip={tooltip}
/>
) : buttonVariant === "transparent-without-text" ? (
<TransparentButton
module={selectedModule}
className={buttonClassName}
dropdownArrow={dropdownArrow && !disabled}
dropdownArrowClassName={dropdownArrowClassName}
hideIcon={hideIcon}
hideText
placeholder={placeholder}
tooltip={tooltip}
/>
) : null}
</DropdownButton>
</button>
)}
</Combobox.Button>
@@ -352,11 +342,15 @@ export const ModuleDropdown: React.FC<Props> = observer((props) => {
key={option.value}
value={option.value}
className={({ active, selected }) =>
`w-full truncate flex items-center justify-between gap-2 rounded px-1 py-1.5 cursor-pointer select-none ${
active ? "bg-custom-background-80" : ""
} ${selected ? "text-custom-text-100" : "text-custom-text-200"}`
cn(
"w-full truncate flex items-center justify-between gap-2 rounded px-1 py-1.5 cursor-pointer select-none",
{
"bg-custom-background-80": active,
"text-custom-text-100": selected,
"text-custom-text-200": !selected,
}
)
}
onClick={closeDropdown}
>
{({ selected }) => (
<>
+66 -94
View File
@@ -15,6 +15,7 @@ import { TIssuePriorities } from "@plane/types";
import { TDropdownProps } from "./types";
// constants
import { ISSUE_PRIORITIES } from "constants/issue";
import { BACKGROUND_BUTTON_VARIANTS, BORDER_BUTTON_VARIANTS, BUTTON_VARIANTS_WITHOUT_TEXT } from "./constants";
type Props = TDropdownProps & {
button?: ReactNode;
@@ -31,9 +32,10 @@ type ButtonProps = {
dropdownArrowClassName: string;
hideIcon?: boolean;
hideText?: boolean;
isActive?: boolean;
highlightUrgent: boolean;
priority: TIssuePriorities;
tooltip: boolean;
showTooltip: boolean;
};
const BorderButton = (props: ButtonProps) => {
@@ -45,7 +47,7 @@ const BorderButton = (props: ButtonProps) => {
hideText = false,
highlightUrgent,
priority,
tooltip,
showTooltip,
} = props;
const priorityDetails = ISSUE_PRIORITIES.find((p) => p.key === priority);
@@ -59,7 +61,7 @@ const BorderButton = (props: ButtonProps) => {
};
return (
<Tooltip tooltipHeading="Priority" tooltipContent={priorityDetails?.title ?? "None"} disabled={!tooltip}>
<Tooltip tooltipHeading="Priority" tooltipContent={priorityDetails?.title ?? "None"} disabled={!showTooltip}>
<div
className={cn(
"h-full flex items-center gap-1.5 border-[0.5px] rounded text-xs px-2 py-0.5",
@@ -114,7 +116,7 @@ const BackgroundButton = (props: ButtonProps) => {
hideText = false,
highlightUrgent,
priority,
tooltip,
showTooltip,
} = props;
const priorityDetails = ISSUE_PRIORITIES.find((p) => p.key === priority);
@@ -128,7 +130,7 @@ const BackgroundButton = (props: ButtonProps) => {
};
return (
<Tooltip tooltipHeading="Priority" tooltipContent={priorityDetails?.title ?? "None"} disabled={!tooltip}>
<Tooltip tooltipHeading="Priority" tooltipContent={priorityDetails?.title ?? "None"} disabled={!showTooltip}>
<div
className={cn(
"h-full flex items-center gap-1.5 rounded text-xs px-2 py-0.5",
@@ -181,9 +183,10 @@ const TransparentButton = (props: ButtonProps) => {
dropdownArrowClassName,
hideIcon = false,
hideText = false,
isActive = false,
highlightUrgent,
priority,
tooltip,
showTooltip,
} = props;
const priorityDetails = ISSUE_PRIORITIES.find((p) => p.key === priority);
@@ -197,7 +200,7 @@ const TransparentButton = (props: ButtonProps) => {
};
return (
<Tooltip tooltipHeading="Priority" tooltipContent={priorityDetails?.title ?? "None"} disabled={!tooltip}>
<Tooltip tooltipHeading="Priority" tooltipContent={priorityDetails?.title ?? "None"} disabled={!showTooltip}>
<div
className={cn(
"h-full flex items-center gap-1.5 rounded text-xs px-2 py-0.5 hover:bg-custom-background-80",
@@ -207,6 +210,7 @@ const TransparentButton = (props: ButtonProps) => {
"px-0.5": hideText,
// highlight the whole button if text is hidden and priority is urgent
"bg-red-500 border-red-500": priority === "urgent" && hideText && highlightUrgent,
"bg-custom-background-80": isActive,
},
className
)}
@@ -257,8 +261,8 @@ export const PriorityDropdown: React.FC<Props> = (props) => {
highlightUrgent = true,
onChange,
placement,
showTooltip = false,
tabIndex,
tooltip = false,
value,
} = props;
// states
@@ -299,22 +303,55 @@ export const PriorityDropdown: React.FC<Props> = (props) => {
const filteredOptions =
query === "" ? options : options.filter((o) => o.query.toLowerCase().includes(query.toLowerCase()));
const openDropdown = () => {
setIsOpen(true);
const onOpen = () => {
if (referenceElement) referenceElement.focus();
};
const closeDropdown = () => setIsOpen(false);
const handleKeyDown = useDropdownKeyDown(openDropdown, closeDropdown, isOpen);
useOutsideClickDetector(dropdownRef, closeDropdown);
const handleClose = () => {
if (isOpen) setIsOpen(false);
if (referenceElement) referenceElement.blur();
};
const toggleDropdown = () => {
if (!isOpen) onOpen();
setIsOpen((prevIsOpen) => !prevIsOpen);
};
const dropdownOnChange = (val: TIssuePriorities) => {
onChange(val);
handleClose();
};
const handleKeyDown = useDropdownKeyDown(toggleDropdown, handleClose);
const handleOnClick = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
e.stopPropagation();
e.preventDefault();
toggleDropdown();
};
useOutsideClickDetector(dropdownRef, handleClose);
const ButtonToRender = BORDER_BUTTON_VARIANTS.includes(buttonVariant)
? BorderButton
: BACKGROUND_BUTTON_VARIANTS.includes(buttonVariant)
? BackgroundButton
: TransparentButton;
return (
<Combobox
as="div"
ref={dropdownRef}
tabIndex={tabIndex}
className={cn("h-full", className)}
className={cn(
"h-full",
{
"bg-custom-background-80": isOpen,
},
className
)}
value={value}
onChange={onChange}
onChange={dropdownOnChange}
disabled={disabled}
onKeyDown={handleKeyDown}
>
@@ -324,7 +361,7 @@ export const PriorityDropdown: React.FC<Props> = (props) => {
ref={setReferenceElement}
type="button"
className={cn("block h-full w-full outline-none", buttonContainerClassName)}
onClick={openDropdown}
onClick={handleOnClick}
>
{button}
</button>
@@ -340,84 +377,20 @@ export const PriorityDropdown: React.FC<Props> = (props) => {
},
buttonContainerClassName
)}
onClick={openDropdown}
onClick={handleOnClick}
>
{buttonVariant === "border-with-text" ? (
<BorderButton
priority={value}
className={cn(buttonClassName, {
"text-white": resolvedTheme === "dark",
})}
highlightUrgent={highlightUrgent}
dropdownArrow={dropdownArrow && !disabled}
dropdownArrowClassName={dropdownArrowClassName}
hideIcon={hideIcon}
tooltip={tooltip}
/>
) : buttonVariant === "border-without-text" ? (
<BorderButton
priority={value}
className={cn(buttonClassName, {
"text-white": resolvedTheme === "dark",
})}
highlightUrgent={highlightUrgent}
dropdownArrow={dropdownArrow && !disabled}
dropdownArrowClassName={dropdownArrowClassName}
hideIcon={hideIcon}
tooltip={tooltip}
hideText
/>
) : buttonVariant === "background-with-text" ? (
<BackgroundButton
priority={value}
className={cn(buttonClassName, {
"text-white": resolvedTheme === "dark",
})}
highlightUrgent={highlightUrgent}
dropdownArrow={dropdownArrow && !disabled}
dropdownArrowClassName={dropdownArrowClassName}
hideIcon={hideIcon}
tooltip={tooltip}
/>
) : buttonVariant === "background-without-text" ? (
<BackgroundButton
priority={value}
className={cn(buttonClassName, {
"text-white": resolvedTheme === "dark",
})}
highlightUrgent={highlightUrgent}
dropdownArrow={dropdownArrow && !disabled}
dropdownArrowClassName={dropdownArrowClassName}
hideIcon={hideIcon}
tooltip={tooltip}
hideText
/>
) : buttonVariant === "transparent-with-text" ? (
<TransparentButton
priority={value}
className={cn(buttonClassName, {
"text-white": resolvedTheme === "dark",
})}
highlightUrgent={highlightUrgent}
dropdownArrow={dropdownArrow && !disabled}
dropdownArrowClassName={dropdownArrowClassName}
hideIcon={hideIcon}
tooltip={tooltip}
/>
) : buttonVariant === "transparent-without-text" ? (
<TransparentButton
priority={value}
className={cn(buttonClassName, {
"text-white": resolvedTheme === "dark",
})}
highlightUrgent={highlightUrgent}
dropdownArrow={dropdownArrow && !disabled}
dropdownArrowClassName={dropdownArrowClassName}
hideIcon={hideIcon}
tooltip={tooltip}
hideText
/>
) : null}
<ButtonToRender
priority={value}
className={cn(buttonClassName, {
"text-white": resolvedTheme === "dark",
})}
highlightUrgent={highlightUrgent}
dropdownArrow={dropdownArrow && !disabled}
dropdownArrowClassName={dropdownArrowClassName}
hideIcon={hideIcon}
showTooltip={showTooltip}
hideText={BUTTON_VARIANTS_WITHOUT_TEXT.includes(buttonVariant)}
/>
</button>
)}
</Combobox.Button>
@@ -450,7 +423,6 @@ export const PriorityDropdown: React.FC<Props> = (props) => {
active ? "bg-custom-background-80" : ""
} ${selected ? "text-custom-text-100" : "text-custom-text-200"}`
}
onClick={closeDropdown}
>
{({ selected }) => (
<>
+58 -190
View File
@@ -7,14 +7,15 @@ import { Check, ChevronDown, Search } from "lucide-react";
import { useProject } from "hooks/store";
import { useDropdownKeyDown } from "hooks/use-dropdown-key-down";
import useOutsideClickDetector from "hooks/use-outside-click-detector";
// ui
import { Tooltip } from "@plane/ui";
// components
import { DropdownButton } from "./buttons";
// helpers
import { cn } from "helpers/common.helper";
import { renderEmoji } from "helpers/emoji.helper";
// types
import { IProject } from "@plane/types";
import { TDropdownProps } from "./types";
// constants
import { BUTTON_VARIANTS_WITH_TEXT } from "./constants";
type Props = TDropdownProps & {
button?: ReactNode;
@@ -24,119 +25,6 @@ type Props = TDropdownProps & {
value: string | null;
};
type ButtonProps = {
className?: string;
dropdownArrow: boolean;
dropdownArrowClassName: string;
hideIcon?: boolean;
hideText?: boolean;
placeholder: string;
project: IProject | null;
tooltip: boolean;
};
const BorderButton = (props: ButtonProps) => {
const {
className,
dropdownArrow,
dropdownArrowClassName,
hideIcon = false,
hideText = false,
placeholder,
project,
tooltip,
} = props;
return (
<Tooltip tooltipHeading="Project" tooltipContent={project?.name ?? placeholder} disabled={!tooltip}>
<div
className={cn(
"h-full flex items-center gap-1.5 border-[0.5px] border-custom-border-300 hover:bg-custom-background-80 rounded text-xs px-2 py-0.5",
className
)}
>
{!hideIcon && (
<span className="grid place-items-center flex-shrink-0">
{project?.emoji ? renderEmoji(project?.emoji) : project?.icon_prop ? renderEmoji(project?.icon_prop) : null}
</span>
)}
{!hideText && <span className="flex-grow truncate">{project?.name ?? placeholder}</span>}
{dropdownArrow && (
<ChevronDown className={cn("h-2.5 w-2.5 flex-shrink-0", dropdownArrowClassName)} aria-hidden="true" />
)}
</div>
</Tooltip>
);
};
const BackgroundButton = (props: ButtonProps) => {
const {
className,
dropdownArrow,
dropdownArrowClassName,
hideIcon = false,
hideText = false,
placeholder,
project,
tooltip,
} = props;
return (
<Tooltip tooltipHeading="Project" tooltipContent={project?.name ?? placeholder} disabled={!tooltip}>
<div
className={cn(
"h-full flex items-center gap-1.5 rounded text-xs px-2 py-0.5 bg-custom-background-80",
className
)}
>
{!hideIcon && (
<span className="grid place-items-center flex-shrink-0">
{project?.emoji ? renderEmoji(project?.emoji) : project?.icon_prop ? renderEmoji(project?.icon_prop) : null}
</span>
)}
{!hideText && <span className="flex-grow truncate">{project?.name ?? placeholder}</span>}
{dropdownArrow && (
<ChevronDown className={cn("h-2.5 w-2.5 flex-shrink-0", dropdownArrowClassName)} aria-hidden="true" />
)}
</div>
</Tooltip>
);
};
const TransparentButton = (props: ButtonProps) => {
const {
className,
dropdownArrow,
dropdownArrowClassName,
hideIcon = false,
hideText = false,
placeholder,
project,
tooltip,
} = props;
return (
<Tooltip tooltipHeading="Project" tooltipContent={project?.name ?? placeholder} disabled={!tooltip}>
<div
className={cn(
"h-full flex items-center gap-1.5 rounded text-xs px-2 py-0.5 hover:bg-custom-background-80",
className
)}
>
{!hideIcon && (
<span className="grid place-items-center flex-shrink-0">
{project?.emoji ? renderEmoji(project?.emoji) : project?.icon_prop ? renderEmoji(project?.icon_prop) : null}
</span>
)}
{!hideText && <span className="flex-grow truncate">{project?.name ?? placeholder}</span>}
{dropdownArrow && (
<ChevronDown className={cn("h-2.5 w-2.5 flex-shrink-0", dropdownArrowClassName)} aria-hidden="true" />
)}
</div>
</Tooltip>
);
};
export const ProjectDropdown: React.FC<Props> = observer((props) => {
const {
button,
@@ -151,8 +39,8 @@ export const ProjectDropdown: React.FC<Props> = observer((props) => {
onChange,
placeholder = "Project",
placement,
showTooltip = false,
tabIndex,
tooltip = false,
value,
} = props;
// states
@@ -204,13 +92,34 @@ export const ProjectDropdown: React.FC<Props> = observer((props) => {
const selectedProject = value ? getProjectById(value) : null;
const openDropdown = () => {
setIsOpen(true);
const onOpen = () => {
if (referenceElement) referenceElement.focus();
};
const closeDropdown = () => setIsOpen(false);
const handleKeyDown = useDropdownKeyDown(openDropdown, closeDropdown, isOpen);
useOutsideClickDetector(dropdownRef, closeDropdown);
const handleClose = () => {
if (isOpen) setIsOpen(false);
if (referenceElement) referenceElement.blur();
};
const toggleDropdown = () => {
if (!isOpen) onOpen();
setIsOpen((prevIsOpen) => !prevIsOpen);
};
const dropdownOnChange = (val: string) => {
onChange(val);
handleClose();
};
const handleKeyDown = useDropdownKeyDown(toggleDropdown, handleClose);
const handleOnClick = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
e.stopPropagation();
e.preventDefault();
toggleDropdown();
};
useOutsideClickDetector(dropdownRef, handleClose);
return (
<Combobox
@@ -219,7 +128,7 @@ export const ProjectDropdown: React.FC<Props> = observer((props) => {
tabIndex={tabIndex}
className={cn("h-full", className)}
value={value}
onChange={onChange}
onChange={dropdownOnChange}
disabled={disabled}
onKeyDown={handleKeyDown}
>
@@ -229,7 +138,7 @@ export const ProjectDropdown: React.FC<Props> = observer((props) => {
ref={setReferenceElement}
type="button"
className={cn("block h-full w-full outline-none", buttonContainerClassName)}
onClick={openDropdown}
onClick={handleOnClick}
>
{button}
</button>
@@ -245,72 +154,32 @@ export const ProjectDropdown: React.FC<Props> = observer((props) => {
},
buttonContainerClassName
)}
onClick={openDropdown}
onClick={handleOnClick}
>
{buttonVariant === "border-with-text" ? (
<BorderButton
project={selectedProject}
className={buttonClassName}
dropdownArrow={dropdownArrow && !disabled}
dropdownArrowClassName={dropdownArrowClassName}
hideIcon={hideIcon}
placeholder={placeholder}
tooltip={tooltip}
/>
) : buttonVariant === "border-without-text" ? (
<BorderButton
project={selectedProject}
className={buttonClassName}
dropdownArrow={dropdownArrow && !disabled}
dropdownArrowClassName={dropdownArrowClassName}
hideIcon={hideIcon}
hideText
placeholder={placeholder}
tooltip={tooltip}
/>
) : buttonVariant === "background-with-text" ? (
<BackgroundButton
project={selectedProject}
className={buttonClassName}
dropdownArrow={dropdownArrow && !disabled}
dropdownArrowClassName={dropdownArrowClassName}
hideIcon={hideIcon}
placeholder={placeholder}
tooltip={tooltip}
/>
) : buttonVariant === "background-without-text" ? (
<BackgroundButton
project={selectedProject}
className={buttonClassName}
dropdownArrow={dropdownArrow && !disabled}
dropdownArrowClassName={dropdownArrowClassName}
hideIcon={hideIcon}
hideText
placeholder={placeholder}
tooltip={tooltip}
/>
) : buttonVariant === "transparent-with-text" ? (
<TransparentButton
project={selectedProject}
className={buttonClassName}
dropdownArrow={dropdownArrow && !disabled}
dropdownArrowClassName={dropdownArrowClassName}
hideIcon={hideIcon}
placeholder={placeholder}
tooltip={tooltip}
/>
) : buttonVariant === "transparent-without-text" ? (
<TransparentButton
project={selectedProject}
className={buttonClassName}
dropdownArrow={dropdownArrow && !disabled}
dropdownArrowClassName={dropdownArrowClassName}
hideIcon={hideIcon}
hideText
placeholder={placeholder}
tooltip={tooltip}
/>
) : null}
<DropdownButton
className={buttonClassName}
isActive={isOpen}
tooltipHeading="Project"
tooltipContent={selectedProject?.name ?? placeholder}
showTooltip={showTooltip}
variant={buttonVariant}
>
{!hideIcon && (
<span className="grid place-items-center flex-shrink-0">
{selectedProject?.emoji
? renderEmoji(selectedProject?.emoji)
: selectedProject?.icon_prop
? renderEmoji(selectedProject?.icon_prop)
: null}
</span>
)}
{BUTTON_VARIANTS_WITH_TEXT.includes(buttonVariant) && (
<span className="flex-grow truncate">{selectedProject?.name ?? placeholder}</span>
)}
{dropdownArrow && (
<ChevronDown className={cn("h-2.5 w-2.5 flex-shrink-0", dropdownArrowClassName)} aria-hidden="true" />
)}
</DropdownButton>
</button>
)}
</Combobox.Button>
@@ -344,7 +213,6 @@ export const ProjectDropdown: React.FC<Props> = observer((props) => {
active ? "bg-custom-background-80" : ""
} ${selected ? "text-custom-text-100" : "text-custom-text-200"}`
}
onClick={closeDropdown}
>
{({ selected }) => (
<>
+57 -185
View File
@@ -7,13 +7,16 @@ import { Check, ChevronDown, Search } from "lucide-react";
import { useApplication, useProjectState } from "hooks/store";
import { useDropdownKeyDown } from "hooks/use-dropdown-key-down";
import useOutsideClickDetector from "hooks/use-outside-click-detector";
// components
import { DropdownButton } from "./buttons";
// icons
import { StateGroupIcon, Tooltip } from "@plane/ui";
import { StateGroupIcon } from "@plane/ui";
// helpers
import { cn } from "helpers/common.helper";
// types
import { IState } from "@plane/types";
import { TDropdownProps } from "./types";
// constants
import { BUTTON_VARIANTS_WITH_TEXT } from "./constants";
type Props = TDropdownProps & {
button?: ReactNode;
@@ -24,121 +27,6 @@ type Props = TDropdownProps & {
value: string;
};
type ButtonProps = {
className?: string;
dropdownArrow: boolean;
dropdownArrowClassName: string;
hideIcon?: boolean;
hideText?: boolean;
state: IState | undefined;
tooltip: boolean;
};
const BorderButton = (props: ButtonProps) => {
const {
className,
dropdownArrow,
dropdownArrowClassName,
hideIcon = false,
hideText = false,
state,
tooltip,
} = props;
return (
<Tooltip tooltipHeading="State" tooltipContent={state?.name ?? "State"} disabled={!tooltip}>
<div
className={cn(
"h-full flex items-center gap-1.5 border-[0.5px] border-custom-border-300 hover:bg-custom-background-80 rounded text-xs px-2 py-0.5",
className
)}
>
{!hideIcon && (
<StateGroupIcon
stateGroup={state?.group ?? "backlog"}
color={state?.color}
className="h-3 w-3 flex-shrink-0"
/>
)}
{!hideText && <span className="flex-grow truncate">{state?.name ?? "State"}</span>}
{dropdownArrow && (
<ChevronDown className={cn("h-2.5 w-2.5 flex-shrink-0", dropdownArrowClassName)} aria-hidden="true" />
)}
</div>
</Tooltip>
);
};
const BackgroundButton = (props: ButtonProps) => {
const {
className,
dropdownArrow,
dropdownArrowClassName,
hideIcon = false,
hideText = false,
state,
tooltip,
} = props;
return (
<Tooltip tooltipHeading="State" tooltipContent={state?.name ?? "State"} disabled={!tooltip}>
<div
className={cn(
"h-full flex items-center gap-1.5 rounded text-xs px-2 py-0.5 bg-custom-background-80",
className
)}
>
{!hideIcon && (
<StateGroupIcon
stateGroup={state?.group ?? "backlog"}
color={state?.color}
className="h-3 w-3 flex-shrink-0"
/>
)}
{!hideText && <span className="flex-grow truncate">{state?.name ?? "State"}</span>}
{dropdownArrow && (
<ChevronDown className={cn("h-2.5 w-2.5 flex-shrink-0", dropdownArrowClassName)} aria-hidden="true" />
)}
</div>
</Tooltip>
);
};
const TransparentButton = (props: ButtonProps) => {
const {
className,
dropdownArrow,
dropdownArrowClassName,
hideIcon = false,
hideText = false,
state,
tooltip,
} = props;
return (
<Tooltip tooltipHeading="State" tooltipContent={state?.name ?? "State"} disabled={!tooltip}>
<div
className={cn(
"h-full flex items-center gap-1.5 rounded text-xs px-2 py-0.5 hover:bg-custom-background-80",
className
)}
>
{!hideIcon && (
<StateGroupIcon
stateGroup={state?.group ?? "backlog"}
color={state?.color}
className="h-3 w-3 flex-shrink-0"
/>
)}
{!hideText && <span className="flex-grow truncate">{state?.name ?? "State"}</span>}
{dropdownArrow && (
<ChevronDown className={cn("h-2.5 w-2.5 flex-shrink-0", dropdownArrowClassName)} aria-hidden="true" />
)}
</div>
</Tooltip>
);
};
export const StateDropdown: React.FC<Props> = observer((props) => {
const {
button,
@@ -153,8 +41,8 @@ export const StateDropdown: React.FC<Props> = observer((props) => {
onChange,
placement,
projectId,
showTooltip = false,
tabIndex,
tooltip = false,
value,
} = props;
// states
@@ -200,14 +88,35 @@ export const StateDropdown: React.FC<Props> = observer((props) => {
const selectedState = getStateById(value);
const openDropdown = () => {
setIsOpen(true);
const onOpen = () => {
if (!statesList && workspaceSlug) fetchProjectStates(workspaceSlug, projectId);
if (referenceElement) referenceElement.focus();
};
const closeDropdown = () => setIsOpen(false);
const handleKeyDown = useDropdownKeyDown(openDropdown, closeDropdown, isOpen);
useOutsideClickDetector(dropdownRef, closeDropdown);
const handleClose = () => {
if (isOpen) setIsOpen(false);
if (referenceElement) referenceElement.blur();
};
const toggleDropdown = () => {
if (!isOpen) onOpen();
setIsOpen((prevIsOpen) => !prevIsOpen);
};
const dropdownOnChange = (val: string) => {
onChange(val);
handleClose();
};
const handleKeyDown = useDropdownKeyDown(toggleDropdown, handleClose);
const handleOnClick = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
e.stopPropagation();
e.preventDefault();
toggleDropdown();
};
useOutsideClickDetector(dropdownRef, handleClose);
return (
<Combobox
@@ -216,7 +125,7 @@ export const StateDropdown: React.FC<Props> = observer((props) => {
tabIndex={tabIndex}
className={cn("h-full", className)}
value={value}
onChange={onChange}
onChange={dropdownOnChange}
disabled={disabled}
onKeyDown={handleKeyDown}
>
@@ -226,7 +135,7 @@ export const StateDropdown: React.FC<Props> = observer((props) => {
ref={setReferenceElement}
type="button"
className={cn("block h-full w-full outline-none", buttonContainerClassName)}
onClick={openDropdown}
onClick={handleOnClick}
>
{button}
</button>
@@ -242,66 +151,30 @@ export const StateDropdown: React.FC<Props> = observer((props) => {
},
buttonContainerClassName
)}
onClick={openDropdown}
onClick={handleOnClick}
>
{buttonVariant === "border-with-text" ? (
<BorderButton
state={selectedState}
className={buttonClassName}
dropdownArrow={dropdownArrow && !disabled}
dropdownArrowClassName={dropdownArrowClassName}
hideIcon={hideIcon}
tooltip={tooltip}
/>
) : buttonVariant === "border-without-text" ? (
<BorderButton
state={selectedState}
className={buttonClassName}
dropdownArrow={dropdownArrow && !disabled}
dropdownArrowClassName={dropdownArrowClassName}
hideIcon={hideIcon}
tooltip={tooltip}
hideText
/>
) : buttonVariant === "background-with-text" ? (
<BackgroundButton
state={selectedState}
className={buttonClassName}
dropdownArrow={dropdownArrow && !disabled}
dropdownArrowClassName={dropdownArrowClassName}
hideIcon={hideIcon}
tooltip={tooltip}
/>
) : buttonVariant === "background-without-text" ? (
<BackgroundButton
state={selectedState}
className={buttonClassName}
dropdownArrow={dropdownArrow && !disabled}
dropdownArrowClassName={dropdownArrowClassName}
hideIcon={hideIcon}
tooltip={tooltip}
hideText
/>
) : buttonVariant === "transparent-with-text" ? (
<TransparentButton
state={selectedState}
className={buttonClassName}
dropdownArrow={dropdownArrow && !disabled}
dropdownArrowClassName={dropdownArrowClassName}
hideIcon={hideIcon}
tooltip={tooltip}
/>
) : buttonVariant === "transparent-without-text" ? (
<TransparentButton
state={selectedState}
className={buttonClassName}
dropdownArrow={dropdownArrow && !disabled}
dropdownArrowClassName={dropdownArrowClassName}
hideIcon={hideIcon}
tooltip={tooltip}
hideText
/>
) : null}
<DropdownButton
className={buttonClassName}
isActive={isOpen}
tooltipHeading="State"
tooltipContent={selectedState?.name ?? "State"}
showTooltip={showTooltip}
variant={buttonVariant}
>
{!hideIcon && (
<StateGroupIcon
stateGroup={selectedState?.group ?? "backlog"}
color={selectedState?.color}
className="h-3 w-3 flex-shrink-0"
/>
)}
{BUTTON_VARIANTS_WITH_TEXT.includes(buttonVariant) && (
<span className="flex-grow truncate">{selectedState?.name ?? "State"}</span>
)}
{dropdownArrow && (
<ChevronDown className={cn("h-2.5 w-2.5 flex-shrink-0", dropdownArrowClassName)} aria-hidden="true" />
)}
</DropdownButton>
</button>
)}
</Combobox.Button>
@@ -335,7 +208,6 @@ export const StateDropdown: React.FC<Props> = observer((props) => {
active ? "bg-custom-background-80" : ""
} ${selected ? "text-custom-text-100" : "text-custom-text-200"}`
}
onClick={closeDropdown}
>
{({ selected }) => (
<>
+1 -2
View File
@@ -17,7 +17,6 @@ export type TDropdownProps = {
hideIcon?: boolean;
placeholder?: string;
placement?: Placement;
showTooltip?: boolean;
tabIndex?: number;
// TODO: rename this prop to showTooltip
tooltip?: boolean;
};
@@ -1,9 +1,11 @@
import { FC } from "react";
// hooks
import { useIssueDetail } from "hooks/store";
import { useChart } from "../hooks";
// helpers
import { ChartDraggable } from "../helpers/draggable";
import { ChartAddBlock, ChartDraggable } from "components/gantt-chart";
import { renderFormattedPayloadDate } from "helpers/date-time.helper";
import { cn } from "helpers/common.helper";
// types
import { IBlockUpdateData, IGanttBlock } from "../types";
@@ -15,6 +17,7 @@ export type GanttChartBlocksProps = {
enableBlockLeftResize: boolean;
enableBlockRightResize: boolean;
enableBlockMove: boolean;
showAllBlocks: boolean;
};
export const GanttChartBlocks: FC<GanttChartBlocksProps> = (props) => {
@@ -26,9 +29,11 @@ export const GanttChartBlocks: FC<GanttChartBlocksProps> = (props) => {
enableBlockLeftResize,
enableBlockRightResize,
enableBlockMove,
showAllBlocks,
} = props;
const { activeBlock, dispatch } = useChart();
const { peekIssue } = useIssueDetail();
// update the active block on hover
const updateActiveBlock = (block: IGanttBlock | null) => {
@@ -45,6 +50,8 @@ export const GanttChartBlocks: FC<GanttChartBlocksProps> = (props) => {
totalBlockShifts: number,
dragDirection: "left" | "right" | "move"
) => {
if (!block.start_date || !block.target_date) return;
const originalStartDate = new Date(block.start_date);
const updatedStartDate = new Date(originalStartDate);
@@ -75,27 +82,38 @@ export const GanttChartBlocks: FC<GanttChartBlocksProps> = (props) => {
>
{blocks &&
blocks.length > 0 &&
blocks.map(
(block) =>
block.start_date &&
block.target_date && (
<div
key={`block-${block.id}`}
className={`h-11 ${activeBlock?.id === block.id ? "bg-custom-background-80" : ""}`}
onMouseEnter={() => updateActiveBlock(block)}
onMouseLeave={() => updateActiveBlock(null)}
>
<ChartDraggable
block={block}
blockToRender={blockToRender}
handleBlock={(...args) => handleChartBlockPosition(block, ...args)}
enableBlockLeftResize={enableBlockLeftResize}
enableBlockRightResize={enableBlockRightResize}
enableBlockMove={enableBlockMove}
/>
</div>
)
)}
blocks.map((block) => {
// hide the block if it doesn't have start and target dates and showAllBlocks is false
if (!showAllBlocks && !(block.start_date && block.target_date)) return;
const isBlockVisibleOnChart = block.start_date && block.target_date;
return (
<div
key={`block-${block.id}`}
className={cn(
"h-11",
{ "rounded bg-custom-background-80": activeBlock?.id === block.id },
{
"rounded-l border border-r-0 border-custom-primary-70 hover:border-custom-primary-70":
peekIssue?.issueId === block.data.id,
}
)}
onMouseEnter={() => updateActiveBlock(block)}
onMouseLeave={() => updateActiveBlock(null)}
>
{!isBlockVisibleOnChart && <ChartAddBlock block={block} blockUpdateHandler={blockUpdateHandler} />}
<ChartDraggable
block={block}
blockToRender={blockToRender}
handleBlock={(...args) => handleChartBlockPosition(block, ...args)}
enableBlockLeftResize={enableBlockLeftResize}
enableBlockRightResize={enableBlockRightResize}
enableBlockMove={enableBlockMove}
/>
</div>
);
})}
</div>
);
};
+18 -14
View File
@@ -46,22 +46,25 @@ type ChartViewRootProps = {
enableBlockMove: boolean;
enableReorder: boolean;
bottomSpacing: boolean;
showAllBlocks: boolean;
};
export const ChartViewRoot: FC<ChartViewRootProps> = ({
border,
title,
blocks = null,
loaderTitle,
blockUpdateHandler,
sidebarToRender,
blockToRender,
enableBlockLeftResize,
enableBlockRightResize,
enableBlockMove,
enableReorder,
bottomSpacing,
}) => {
export const ChartViewRoot: FC<ChartViewRootProps> = (props) => {
const {
border,
title,
blocks = null,
loaderTitle,
blockUpdateHandler,
sidebarToRender,
blockToRender,
enableBlockLeftResize,
enableBlockRightResize,
enableBlockMove,
enableReorder,
bottomSpacing,
showAllBlocks,
} = props;
// states
const [itemsContainerWidth, setItemsContainerWidth] = useState<number>(0);
const [fullScreenMode, setFullScreenMode] = useState<boolean>(false);
@@ -311,6 +314,7 @@ export const ChartViewRoot: FC<ChartViewRootProps> = ({
enableBlockLeftResize={enableBlockLeftResize}
enableBlockRightResize={enableBlockRightResize}
enableBlockMove={enableBlockMove}
showAllBlocks={showAllBlocks}
/>
)}
</div>
@@ -1,5 +1,4 @@
import { FC } from "react";
// hooks
import { useChart } from "../hooks";
// types
@@ -0,0 +1,91 @@
import { useEffect, useRef, useState } from "react";
import { addDays } from "date-fns";
import { Plus } from "lucide-react";
// hooks
import { useChart } from "../hooks";
// ui
import { Tooltip } from "@plane/ui";
// helpers
import { renderFormattedDate, renderFormattedPayloadDate } from "helpers/date-time.helper";
// types
import { IBlockUpdateData, IGanttBlock } from "../types";
type Props = {
block: IGanttBlock;
blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void;
};
export const ChartAddBlock: React.FC<Props> = (props) => {
const { block, blockUpdateHandler } = props;
// states
const [isButtonVisible, setIsButtonVisible] = useState(false);
const [buttonXPosition, setButtonXPosition] = useState(0);
const [buttonStartDate, setButtonStartDate] = useState<Date | null>(null);
// refs
const containerRef = useRef<HTMLDivElement>(null);
// chart hook
const { currentViewData } = useChart();
const handleButtonClick = () => {
if (!currentViewData) return;
const { startDate: chartStartDate, width } = currentViewData.data;
const columnNumber = buttonXPosition / width;
const startDate = addDays(chartStartDate, columnNumber);
const endDate = addDays(startDate, 1);
blockUpdateHandler(block.data, {
start_date: renderFormattedPayloadDate(startDate) ?? undefined,
target_date: renderFormattedPayloadDate(endDate) ?? undefined,
});
};
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const handleMouseMove = (e: MouseEvent) => {
if (!currentViewData) return;
setButtonXPosition(e.offsetX);
const { startDate: chartStartDate, width } = currentViewData.data;
const columnNumber = buttonXPosition / width;
const startDate = addDays(chartStartDate, columnNumber);
setButtonStartDate(startDate);
};
container.addEventListener("mousemove", handleMouseMove);
return () => {
container?.removeEventListener("mousemove", handleMouseMove);
};
}, [buttonXPosition, currentViewData]);
return (
<div
className="relative h-full w-full"
onMouseEnter={() => setIsButtonVisible(true)}
onMouseLeave={() => setIsButtonVisible(false)}
>
<div ref={containerRef} className="h-full w-full" />
{isButtonVisible && (
<Tooltip tooltipContent={buttonStartDate && renderFormattedDate(buttonStartDate)}>
<button
type="button"
className="absolute top-1/2 -translate-x-1/2 -translate-y-1/2 h-8 w-8 bg-custom-background-80 p-1.5 rounded border border-custom-border-300 grid place-items-center text-custom-text-200 hover:text-custom-text-100"
style={{
marginLeft: `${buttonXPosition}px`,
}}
onClick={handleButtonClick}
>
<Plus className="h-3.5 w-3.5" />
</button>
</Tooltip>
)}
</div>
);
};
@@ -3,14 +3,11 @@ import { TIssue } from "@plane/types";
import { IGanttBlock } from "components/gantt-chart";
export const renderIssueBlocksStructure = (blocks: TIssue[]): IGanttBlock[] =>
blocks && blocks.length > 0
? blocks
.filter((b) => new Date(b?.start_date ?? "") <= new Date(b?.target_date ?? ""))
.map((block) => ({
data: block,
id: block.id,
sort_order: block.sort_order,
start_date: new Date(block.start_date ?? ""),
target_date: new Date(block.target_date ?? ""),
}))
: [];
blocks &&
blocks.map((block) => ({
data: block,
id: block.id,
sort_order: block.sort_order,
start_date: block.start_date ? new Date(block.start_date) : null,
target_date: block.target_date ? new Date(block.target_date) : null,
}));
@@ -1,6 +1,4 @@
import React, { useEffect, useRef, useState } from "react";
// icons
import { ArrowLeft, ArrowRight } from "lucide-react";
// hooks
import { useChart } from "../hooks";
@@ -16,23 +14,17 @@ type Props = {
enableBlockMove: boolean;
};
export const ChartDraggable: React.FC<Props> = ({
block,
blockToRender,
handleBlock,
enableBlockLeftResize,
enableBlockRightResize,
enableBlockMove,
}) => {
export const ChartDraggable: React.FC<Props> = (props) => {
const { block, blockToRender, handleBlock, enableBlockLeftResize, enableBlockRightResize, enableBlockMove } = props;
// states
const [isLeftResizing, setIsLeftResizing] = useState(false);
const [isRightResizing, setIsRightResizing] = useState(false);
const [isMoving, setIsMoving] = useState(false);
const [posFromLeft, setPosFromLeft] = useState<number | null>(null);
// refs
const resizableRef = useRef<HTMLDivElement>(null);
// chart hook
const { currentViewData, scrollLeft } = useChart();
// check if cursor reaches either end while resizing/dragging
const checkScrollEnd = (e: MouseEvent): number => {
const SCROLL_THRESHOLD = 70;
@@ -68,7 +60,6 @@ export const ChartDraggable: React.FC<Props> = ({
return delWidth;
};
// handle block resize from the left end
const handleBlockLeftResize = (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
if (!currentViewData || !resizableRef.current || !block.position) return;
@@ -120,7 +111,6 @@ export const ChartDraggable: React.FC<Props> = ({
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
};
// handle block resize from the right end
const handleBlockRightResize = (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
if (!currentViewData || !resizableRef.current || !block.position) return;
@@ -163,7 +153,6 @@ export const ChartDraggable: React.FC<Props> = ({
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
};
// handle block x-axis move
const handleBlockMove = (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
if (!enableBlockMove || !currentViewData || !resizableRef.current || !block.position) return;
@@ -210,7 +199,6 @@ export const ChartDraggable: React.FC<Props> = ({
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
};
// scroll to a hidden block
const handleScrollToBlock = () => {
const scrollContainer = document.querySelector("#scroll-container") as HTMLElement;
@@ -220,7 +208,6 @@ export const ChartDraggable: React.FC<Props> = ({
// update container's scroll position to the block's position
scrollContainer.scrollLeft = block.position.marginLeft - 4;
};
// update block position from viewport's left end on scroll
useEffect(() => {
const block = resizableRef.current;
@@ -229,7 +216,6 @@ export const ChartDraggable: React.FC<Props> = ({
setPosFromLeft(block.getBoundingClientRect().left);
}, [scrollLeft]);
// check if block is hidden on either side
const isBlockHiddenOnLeft =
block.position?.marginLeft &&

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