Compare commits

..
Author SHA1 Message Date
AnkushD f3d7f779bf Initial playwright setup 2023-11-03 13:26:24 +05:30
Aaryan KhandelwalandGitHub 490e032ac6 style: new avatar and avatar group components (#2584)
* style: new avatar components

* chore: bug fixes

* chore: add pixel to size

* chore: add comments to helper functions

* fix: build errors
2023-11-01 15:24:11 +05:30
Bavisetti NarayanandGitHub 1a24f9ec25 chore: removed start date and target date filter (#2582) 2023-11-01 14:34:03 +05:30
Bavisetti NarayanandGitHub 2cb94b4105 chore: added permission for importer (#2577) 2023-11-01 14:32:33 +05:30
Bavisetti NarayanandGitHub ecde7edf09 fix: removed numbers from magic code (#2570) 2023-11-01 14:31:42 +05:30
Bavisetti NarayanandGitHub 02f4916e49 chore: workspace members and project members endpoint (#2560)
* fix: removed members endpoint

* fix: changed project permisson class for project member

* fix: permission changed in workspace and project

* fix: added project filter in members
2023-11-01 14:30:45 +05:30
1be82814fc style: issue peek overview ui improvement (#2574)
* style: issue peek overview ui improvement

* chore: implemented issue subscription in peek overview

* chore: issue properties dropdown refactor

* fix: build error

* chore: label select refactor

* chore: issue peekoverview revamp and refactor

* chore: issue peekoverview properties added and code refactor

---------

Co-authored-by: gurusainath <gurusainath007@gmail.com>
2023-11-01 14:22:29 +05:30
sriram veeraghantaandGitHub 10e35d9a06 chore: delete unused files (#2585) 2023-11-01 13:45:04 +05:30
Dakshesh JainandGitHub 2d64caef90 refactor: project settings (#2575)
* refactor: project setting estimate

* refactor: project setting label

* refactor: project setting state

* refactor: project setting integration

* refactor: project settings member

* fix: estimate not updating

* fix: estimate not in observable

* fix: build error
2023-11-01 13:42:51 +05:30
deepsource-autofix[bot]GitHubdeepsource-autofix[bot] <62050782+deepsource-autofix[bot]@users.noreply.github.com>
80e6d7e1ea refactor: remove true from boolean attribute (#2579)
When using a boolean attribute in JSX, you can set the attribute value to true or omit the value. This helps to keep consistency in code.

Co-authored-by: deepsource-autofix[bot] <62050782+deepsource-autofix[bot]@users.noreply.github.com>
2023-11-01 12:30:21 +05:30
sriram veeraghantaandGitHub b7d5a42d45 fix: added deepsource config file (#2578) 2023-10-31 19:52:13 +05:30
sriram veeraghantaandGitHub 2b1e1557ca fix: upgrading to turbo new version (#2576) 2023-10-31 19:27:56 +05:30
Aaryan KhandelwalandGitHub 705b33377c fix: members list endpoint authorization (#2571)
* fix: members list endpoint authorization

* chore: update user types
2023-10-31 13:11:13 +05:30
NikhilandGitHub 49fd4427c8 chore: user settings endpoint (#2557)
* chore: user settings endpoint

* dev: fix the user settings
2023-10-31 13:08:13 +05:30
Bavisetti NarayanandGitHub bdbb64f385 fix: changed assignees and labels in pages and modules (#2553) 2023-10-31 13:06:57 +05:30
Aaryan KhandelwalandGitHub 98716859d5 chore: reove unused files (#2567) 2023-10-31 12:43:08 +05:30
M. PalanikannanandGitHub 8072bbb559 fix: Debounce title and Editor initialization (#2530)
* fixed debounce logic and extracted the same

* fixed editor mounting with custom hook

* removed console logs and improved structure

* fixed comment editor behavior on Shift-Enter

* fixed editor initialization behaviour for new peek view

* fixed button type to avoid reload while editing comments

* fixed initialization of content in peek overview

* improved naming variables in updated title debounce logic

* added react-hook-form support to the issue detail in peek view with save states

* delete image plugin's ts support improved
2023-10-31 12:26:10 +05:30
Aaryan KhandelwalandGitHub 442c83eea2 style: spreadsheet columns (#2554)
* style: spreadsheet columns

* fix: build errors
2023-10-31 12:18:04 +05:30
Aaryan KhandelwalandGitHub cb533849e8 chore: update members endpoint (#2569) 2023-10-31 12:16:40 +05:30
Aaryan KhandelwalandGitHub 59c52023fb style: list layout (#2566) 2023-10-31 12:14:06 +05:30
Aaryan KhandelwalandGitHub 08ca016f65 fix: custom theme form validations (#2565) 2023-10-31 12:12:24 +05:30
Aaryan KhandelwalandGitHub 1c2ea6da5e fix: edit project button redirection (#2564)
* fix: redirect to project settings

* fix: 404 page button alignment
2023-10-31 12:06:55 +05:30
Aaryan KhandelwalandGitHub 8b7b5c54b9 fix: global views bugs (#2563) 2023-10-31 12:06:11 +05:30
guru_sainathandGitHub 52474715de chore: handled next_url redirection issue (#2562) 2023-10-31 12:04:36 +05:30
Aaryan KhandelwalandGitHub dcf81e28e4 dev: implemented MobX in workspace settings and create workspace form (#2561)
* dev: implement mobx store for workspace settings

* chore: workspace general settings mobx integration

* chore: workspace members settings mobx integration
2023-10-30 20:38:50 +05:30
Aaryan KhandelwalandGitHub 050406b8a4 chore: add empty state for list and spreadsheet layouts (#2531)
* chore: add empty state for list and spreadsheet layouts

* fix: build errors
2023-10-30 20:09:04 +05:30
Anmol Singh BhatiaandGitHub 8eaac60aa5 style: cycle ui revamp, and chore: code refactor (#2558)
* chore: cycle custom svg icon added and code refactor

* chore: module code refactor

* style: cycle ui revamp and code refactor

* chore: cycle card view layout fix

* chore: layout fix

* style: module and cycle title tooltip position
2023-10-30 19:22:27 +05:30
NikhilandGitHub 7edaa49c21 revert: issues endpoint (#2555) 2023-10-30 15:05:25 +05:30
Dakshesh JainandGitHub 8cc61bc427 fix: html sensitization function (#2552) 2023-10-30 13:59:00 +05:30
Anmol Singh BhatiaandGitHub fc82d6fc23 style: module ui revamp (#2548)
* chore: module constant and helper function added

* style: module card ui revamp

* chore: custom media query added

* chore: circular progress indicator added

* chore: module card item ui improvement

* chore: module list view added

* chore: module sidebar added in list and card view

* chore: module list and card ui improvement

* chore: module sidebar select, avatar and link list component improvement

* chore: sidebar improvement and refactor

* style: module sidebar revamp

* style: module sidebar ui improvement

* chore: module sidebar lead and member select improvement

* style: module sidebar progress section empty state added

* chore: module card issue count validation added

* style: module card and list item ui improvement
2023-10-27 18:45:10 +05:30
080b5a29ae refactor: issue activity (#2503)
* dev: update project and workspace save in issue activity

* chore: issue activity structuring

* chore: added workspace id

---------

Co-authored-by: pablohashescobar <nikhilschacko@gmail.com>
2023-10-27 15:38:54 +05:30
Bavisetti NarayanandGitHub 9ee3fb9c6c chore: removed duplicate issues (#2418) 2023-10-27 15:37:26 +05:30
Bavisetti NarayanandGitHub b0a24ab57b fix: issue filters validation (#2417) 2023-10-27 15:36:27 +05:30
NikhilandGitHub 3e706f9653 chore: project create/update endpoint to be simillar to list (#2476)
* chore: project create endpoint to be simillar to list

* dev: make project create and update response same
2023-10-27 15:35:15 +05:30
NikhilandGitHub 4e86110123 chore: database configuration (#2497) 2023-10-27 15:34:01 +05:30
6bebb8a93b chore: user issue display properties (#2258)
* chore: user issue display properties

* chore: added issue property

* fix: migrations and url change

* dev: add a default condition on get for issue properties

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
2023-10-27 15:32:42 +05:30
NikhilandGitHub c8f98a9bc2 chore: make module create and list endpoint response structure simillar (#2524) 2023-10-27 15:31:15 +05:30
55b2927a17 chore: total issues count for issue listing endpoint (#2534)
* chore: total issues count for issue listing endpoint

* dev: add print for DEBUG mode

* fix: changed assignees_list and label_list

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
2023-10-27 15:30:28 +05:30
sriram veeraghantaandGitHub 597ea26d7b fix: enabling the nextjs static site generation for web and space (#2547) 2023-10-27 13:14:03 +05:30
Dakshesh JainandGitHub 4aad35e007 refactor: quick add (#2541)
* refactor: store and helper setup for quick-add

* refactor: kanban quick add with optimistic issue create

* refactor: added function definition

* refactor: list quick add with optimistic issue create

* refactor: spreadsheet quick add with optimistic issue create

* refactor: calender quick add with optimistic issue create

* refactor: gantt quick add with optimistic issue create

* refactor: input component and pre-loading data logic

* style: calender quick-add height and content shift

* feat: sub-group quick-add issue

* feat: displaying loading state when issue is being created

* fix: setting string null to null
2023-10-27 12:32:24 +05:30
400 changed files with 14519 additions and 16950 deletions
+17
View File
@@ -0,0 +1,17 @@
version = 1
[[analyzers]]
name = "shell"
[[analyzers]]
name = "javascript"
[analyzers.meta]
plugins = ["react"]
environment = ["nodejs"]
[[analyzers]]
name = "python"
[analyzers.meta]
runtime_version = "3.x.x"
+1 -2
View File
@@ -16,8 +16,7 @@ AWS_ACCESS_KEY_ID="access-key"
AWS_SECRET_ACCESS_KEY="secret-key"
AWS_S3_ENDPOINT_URL="http://plane-minio:9000"
# Changing this requires change in the nginx.conf for uploads if using minio setup
AWS_PUBLIC_STORAGE_BUCKET_NAME="uploads"
AWS_PRIVATE_STORAGE_BUCKET_NAME="uploads-private"
AWS_S3_BUCKET_NAME="uploads"
# Maximum file upload limit
FILE_SIZE_LIMIT=5242880
+2 -1
View File
@@ -16,7 +16,8 @@ node_modules
# Production
/build
dist
dist/
out/
# Misc
.DS_Store
+12 -6
View File
@@ -75,13 +75,13 @@ class IssueCreateSerializer(BaseSerializer):
project_detail = ProjectLiteSerializer(read_only=True, source="project")
workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace")
assignees_list = serializers.ListField(
assignees = serializers.ListField(
child=serializers.PrimaryKeyRelatedField(queryset=User.objects.all()),
write_only=True,
required=False,
)
labels_list = serializers.ListField(
labels = serializers.ListField(
child=serializers.PrimaryKeyRelatedField(queryset=Label.objects.all()),
write_only=True,
required=False,
@@ -99,6 +99,12 @@ class IssueCreateSerializer(BaseSerializer):
"updated_at",
]
def to_representation(self, instance):
data = super().to_representation(instance)
data['assignees'] = [str(assignee.id) for assignee in instance.assignees.all()]
data['labels'] = [str(label.id) for label in instance.labels.all()]
return data
def validate(self, data):
if (
data.get("start_date", None) is not None
@@ -109,8 +115,8 @@ class IssueCreateSerializer(BaseSerializer):
return data
def create(self, validated_data):
assignees = validated_data.pop("assignees_list", None)
labels = validated_data.pop("labels_list", None)
assignees = validated_data.pop("assignees", None)
labels = validated_data.pop("labels", None)
project_id = self.context["project_id"]
workspace_id = self.context["workspace_id"]
@@ -168,8 +174,8 @@ class IssueCreateSerializer(BaseSerializer):
return issue
def update(self, instance, validated_data):
assignees = validated_data.pop("assignees_list", None)
labels = validated_data.pop("labels_list", None)
assignees = validated_data.pop("assignees", None)
labels = validated_data.pop("labels", None)
# Related models
project_id = instance.project_id
+8 -3
View File
@@ -19,7 +19,7 @@ from plane.db.models import (
class ModuleWriteSerializer(BaseSerializer):
members_list = serializers.ListField(
members = serializers.ListField(
child=serializers.PrimaryKeyRelatedField(queryset=User.objects.all()),
write_only=True,
required=False,
@@ -39,6 +39,11 @@ class ModuleWriteSerializer(BaseSerializer):
"created_at",
"updated_at",
]
def to_representation(self, instance):
data = super().to_representation(instance)
data['members'] = [str(member.id) for member in instance.members.all()]
return data
def validate(self, data):
if data.get("start_date", None) is not None and data.get("target_date", None) is not None and data.get("start_date", None) > data.get("target_date", None):
@@ -46,7 +51,7 @@ class ModuleWriteSerializer(BaseSerializer):
return data
def create(self, validated_data):
members = validated_data.pop("members_list", None)
members = validated_data.pop("members", None)
project = self.context["project"]
@@ -72,7 +77,7 @@ class ModuleWriteSerializer(BaseSerializer):
return module
def update(self, instance, validated_data):
members = validated_data.pop("members_list", None)
members = validated_data.pop("members", None)
if members is not None:
ModuleMember.objects.filter(module=instance).delete()
+7 -3
View File
@@ -33,7 +33,7 @@ class PageBlockLiteSerializer(BaseSerializer):
class PageSerializer(BaseSerializer):
is_favorite = serializers.BooleanField(read_only=True)
label_details = LabelLiteSerializer(read_only=True, source="labels", many=True)
labels_list = serializers.ListField(
labels = serializers.ListField(
child=serializers.PrimaryKeyRelatedField(queryset=Label.objects.all()),
write_only=True,
required=False,
@@ -50,9 +50,13 @@ class PageSerializer(BaseSerializer):
"project",
"owned_by",
]
def to_representation(self, instance):
data = super().to_representation(instance)
data['labels'] = [str(label.id) for label in instance.labels.all()]
return data
def create(self, validated_data):
labels = validated_data.pop("labels_list", None)
labels = validated_data.pop("labels", None)
project_id = self.context["project_id"]
owned_by_id = self.context["owned_by_id"]
page = Page.objects.create(
@@ -77,7 +81,7 @@ class PageSerializer(BaseSerializer):
return page
def update(self, instance, validated_data):
labels = validated_data.pop("labels_list", None)
labels = validated_data.pop("labels", None)
if labels is not None:
PageLabel.objects.filter(page=instance).delete()
PageLabel.objects.bulk_create(
+4 -4
View File
@@ -79,14 +79,14 @@ class UserMeSettingsSerializer(BaseSerializer):
email=obj.email
).count()
if obj.last_workspace_id is not None:
workspace = Workspace.objects.get(
workspace = Workspace.objects.filter(
pk=obj.last_workspace_id, workspace_member__member=obj.id
)
).first()
return {
"last_workspace_id": obj.last_workspace_id,
"last_workspace_slug": workspace.slug,
"last_workspace_slug": workspace.slug if workspace is not None else "",
"fallback_workspace_id": obj.last_workspace_id,
"fallback_workspace_slug": workspace.slug,
"fallback_workspace_slug": workspace.slug if workspace is not None else "",
"invites": workspace_invites,
}
else:
+5 -22
View File
@@ -17,7 +17,7 @@ from plane.api.views import (
IssueSubscriberViewSet,
IssueReactionViewSet,
CommentReactionViewSet,
IssuePropertyViewSet,
IssueUserDisplayPropertyEndpoint,
IssueArchiveViewSet,
IssueRelationViewSet,
IssueDraftViewSet,
@@ -235,28 +235,11 @@ urlpatterns = [
## End Comment Reactions
## IssueProperty
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issue-properties/",
IssuePropertyViewSet.as_view(
{
"get": "list",
"post": "create",
}
),
name="project-issue-roadmap",
"workspaces/<str:slug>/projects/<uuid:project_id>/issue-display-properties/",
IssueUserDisplayPropertyEndpoint.as_view(),
name="project-issue-display-properties",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issue-properties/<uuid:pk>/",
IssuePropertyViewSet.as_view(
{
"get": "retrieve",
"put": "update",
"patch": "partial_update",
"delete": "destroy",
}
),
name="project-issue-roadmap",
),
## IssueProperty Ebd
## IssueProperty End
## Issue Archives
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/archived-issues/",
+2 -14
View File
@@ -4,17 +4,15 @@ from plane.api.views import (
ProjectViewSet,
InviteProjectEndpoint,
ProjectMemberViewSet,
ProjectMemberEndpoint,
ProjectMemberInvitationsViewset,
ProjectMemberUserEndpoint,
AddMemberToProjectEndpoint,
ProjectJoinEndpoint,
AddTeamToProjectEndpoint,
ProjectUserViewsEndpoint,
ProjectIdentifierEndpoint,
ProjectFavoritesViewSet,
LeaveProjectEndpoint,
ProjectPublicCoverImagesEndpoint
ProjectPublicCoverImagesEndpoint,
)
@@ -53,7 +51,7 @@ urlpatterns = [
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/members/",
ProjectMemberViewSet.as_view({"get": "list"}),
ProjectMemberViewSet.as_view({"get": "list", "post": "create"}),
name="project-member",
),
path(
@@ -67,16 +65,6 @@ urlpatterns = [
),
name="project-member",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/project-members/",
ProjectMemberEndpoint.as_view(),
name="project-member",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/members/add/",
AddMemberToProjectEndpoint.as_view(),
name="project",
),
path(
"workspaces/<str:slug>/projects/join/",
ProjectJoinEndpoint.as_view(),
-6
View File
@@ -5,7 +5,6 @@ from plane.api.views import (
WorkSpaceViewSet,
InviteWorkspaceEndpoint,
WorkSpaceMemberViewSet,
WorkspaceMembersEndpoint,
WorkspaceInvitationsViewset,
WorkspaceMemberUserEndpoint,
WorkspaceMemberUserViewsEndpoint,
@@ -86,11 +85,6 @@ urlpatterns = [
),
name="workspace-member",
),
path(
"workspaces/<str:slug>/workspace-members/",
WorkspaceMembersEndpoint.as_view(),
name="workspace-members",
),
path(
"workspaces/<str:slug>/teams/",
TeamMemberViewSet.as_view(
+4 -21
View File
@@ -82,7 +82,7 @@ from plane.api.views import (
BulkDeleteIssuesEndpoint,
BulkImportIssuesEndpoint,
ProjectUserViewsEndpoint,
IssuePropertyViewSet,
IssueUserDisplayPropertyEndpoint,
LabelViewSet,
SubIssuesEndpoint,
IssueLinkViewSet,
@@ -1008,26 +1008,9 @@ urlpatterns = [
## End Comment Reactions
## IssueProperty
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issue-properties/",
IssuePropertyViewSet.as_view(
{
"get": "list",
"post": "create",
}
),
name="project-issue-roadmap",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issue-properties/<uuid:pk>/",
IssuePropertyViewSet.as_view(
{
"get": "retrieve",
"put": "update",
"patch": "partial_update",
"delete": "destroy",
}
),
name="project-issue-roadmap",
"workspaces/<str:slug>/projects/<uuid:project_id>/issue-display-properties/",
IssueUserDisplayPropertyEndpoint.as_view(),
name="project-issue-display-properties",
),
## IssueProperty Ebd
## Issue Archives
+1 -4
View File
@@ -7,14 +7,12 @@ from .project import (
ProjectMemberInvitationsViewset,
ProjectMemberInviteDetailViewSet,
ProjectIdentifierEndpoint,
AddMemberToProjectEndpoint,
ProjectJoinEndpoint,
ProjectUserViewsEndpoint,
ProjectMemberUserEndpoint,
ProjectFavoritesViewSet,
ProjectDeployBoardViewSet,
ProjectDeployBoardPublicSettingsEndpoint,
ProjectMemberEndpoint,
WorkspaceProjectDeployBoardEndpoint,
LeaveProjectEndpoint,
ProjectPublicCoverImagesEndpoint,
@@ -53,7 +51,6 @@ from .workspace import (
WorkspaceUserProfileEndpoint,
WorkspaceUserProfileIssuesEndpoint,
WorkspaceLabelsEndpoint,
WorkspaceMembersEndpoint,
LeaveWorkspaceEndpoint,
)
from .state import StateViewSet
@@ -71,7 +68,7 @@ from .issue import (
WorkSpaceIssuesEndpoint,
IssueActivityEndpoint,
IssueCommentViewSet,
IssuePropertyViewSet,
IssueUserDisplayPropertyEndpoint,
LabelViewSet,
BulkDeleteIssuesEndpoint,
UserWorkSpaceIssues,
+3 -3
View File
@@ -249,11 +249,11 @@ class MagicSignInGenerateEndpoint(BaseAPIView):
## Generate a random token
token = (
"".join(random.choices(string.ascii_lowercase + string.digits, k=4))
"".join(random.choices(string.ascii_lowercase, k=4))
+ "-"
+ "".join(random.choices(string.ascii_lowercase + string.digits, k=4))
+ "".join(random.choices(string.ascii_lowercase, k=4))
+ "-"
+ "".join(random.choices(string.ascii_lowercase + string.digits, k=4))
+ "".join(random.choices(string.ascii_lowercase, k=4))
)
ri = redis_instance()
+2
View File
@@ -84,6 +84,7 @@ class BaseViewSet(TimezoneMixin, ModelViewSet, BasePaginator):
capture_exception(e)
return Response({"error": f"key {e} does not exist"}, status=status.HTTP_400_BAD_REQUEST)
print(e) if settings.DEBUG else print("Server Error")
capture_exception(e)
return Response({"error": "Something went wrong please try again later"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
@@ -161,6 +162,7 @@ class BaseAPIView(TimezoneMixin, APIView, BasePaginator):
if isinstance(e, KeyError):
return Response({"error": f"key {e} does not exist"}, status=status.HTTP_400_BAD_REQUEST)
print(e) if settings.DEBUG else print("Server Error")
capture_exception(e)
return Response({"error": "Something went wrong please try again later"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
+3 -3
View File
@@ -588,14 +588,14 @@ class CycleIssueViewSet(BaseViewSet):
)
if group_by:
grouped_results = group_results(issues_data, group_by, sub_group_by)
return Response(
group_results(issues_data, group_by, sub_group_by),
grouped_results,
status=status.HTTP_200_OK,
)
return Response(
issues_data,
status=status.HTTP_200_OK,
issues_data, status=status.HTTP_200_OK
)
def create(self, request, slug, project_id, cycle_id):
+4
View File
@@ -39,6 +39,7 @@ from plane.utils.integrations.github import get_github_repo_details
from plane.utils.importers.jira import jira_project_issue_summary
from plane.bgtasks.importer_task import service_importer
from plane.utils.html_processor import strip_tags
from plane.api.permissions import WorkSpaceAdminPermission
class ServiceIssueImportSummaryEndpoint(BaseAPIView):
@@ -119,6 +120,9 @@ class ServiceIssueImportSummaryEndpoint(BaseAPIView):
class ImportServiceEndpoint(BaseAPIView):
permission_classes = [
WorkSpaceAdminPermission,
]
def post(self, request, slug, service):
project_id = request.data.get("project_id", False)
+34 -48
View File
@@ -130,7 +130,7 @@ class IssueViewSet(BaseViewSet):
queryset=IssueReaction.objects.select_related("actor"),
)
)
)
).distinct()
@method_decorator(gzip_page)
def list(self, request, slug, project_id):
@@ -229,12 +229,16 @@ class IssueViewSet(BaseViewSet):
)
if group_by:
grouped_results = group_results(issues, group_by, sub_group_by)
return Response(
group_results(issues, group_by, sub_group_by),
grouped_results,
status=status.HTTP_200_OK,
)
return Response(issues, status=status.HTTP_200_OK)
return Response(
issues, status=status.HTTP_200_OK
)
def create(self, request, slug, project_id):
project = Project.objects.get(pk=project_id)
@@ -433,12 +437,15 @@ class UserWorkSpaceIssues(BaseAPIView):
)
if group_by:
grouped_results = group_results(issues, group_by, sub_group_by)
return Response(
group_results(issues, group_by, sub_group_by),
grouped_results,
status=status.HTTP_200_OK,
)
return Response(issues, status=status.HTTP_200_OK)
return Response(
issues, status=status.HTTP_200_OK
)
class WorkSpaceIssuesEndpoint(BaseAPIView):
@@ -597,41 +604,12 @@ class IssueCommentViewSet(BaseViewSet):
return Response(status=status.HTTP_204_NO_CONTENT)
class IssuePropertyViewSet(BaseViewSet):
serializer_class = IssuePropertySerializer
model = IssueProperty
class IssueUserDisplayPropertyEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
ProjectLitePermission,
]
filterset_fields = []
def perform_create(self, serializer):
serializer.save(
project_id=self.kwargs.get("project_id"), user=self.request.user
)
def get_queryset(self):
return self.filter_queryset(
super()
.get_queryset()
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id"))
.filter(user=self.request.user)
.filter(project__project_projectmember__member=self.request.user)
.select_related("project")
.select_related("workspace")
)
def list(self, request, slug, project_id):
queryset = self.get_queryset()
serializer = IssuePropertySerializer(queryset, many=True)
return Response(
serializer.data[0] if len(serializer.data) > 0 else [],
status=status.HTTP_200_OK,
)
def create(self, request, slug, project_id):
def post(self, request, slug, project_id):
issue_property, created = IssueProperty.objects.get_or_create(
user=request.user,
project_id=project_id,
@@ -640,16 +618,20 @@ class IssuePropertyViewSet(BaseViewSet):
if not created:
issue_property.properties = request.data.get("properties", {})
issue_property.save()
serializer = IssuePropertySerializer(issue_property)
return Response(serializer.data, status=status.HTTP_200_OK)
issue_property.properties = request.data.get("properties", {})
issue_property.save()
serializer = IssuePropertySerializer(issue_property)
return Response(serializer.data, status=status.HTTP_201_CREATED)
def get(self, request, slug, project_id):
issue_property, _ = IssueProperty.objects.get_or_create(
user=request.user, project_id=project_id
)
serializer = IssuePropertySerializer(issue_property)
return Response(serializer.data, status=status.HTTP_200_OK)
class LabelViewSet(BaseViewSet):
serializer_class = LabelSerializer
model = Label
@@ -963,8 +945,8 @@ class IssueAttachmentEndpoint(BaseAPIView):
issue_attachments = IssueAttachment.objects.filter(
issue_id=issue_id, workspace__slug=slug, project_id=project_id
)
serilaizer = IssueAttachmentSerializer(issue_attachments, many=True)
return Response(serilaizer.data, status=status.HTTP_200_OK)
serializer = IssueAttachmentSerializer(issue_attachments, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
class IssueArchiveViewSet(BaseViewSet):
@@ -1165,9 +1147,7 @@ class IssueSubscriberViewSet(BaseViewSet):
def list(self, request, slug, project_id, issue_id):
members = (
ProjectMember.objects.filter(
workspace__slug=slug, project_id=project_id
)
ProjectMember.objects.filter(workspace__slug=slug, project_id=project_id)
.annotate(
is_subscribed=Exists(
IssueSubscriber.objects.filter(
@@ -2174,9 +2154,15 @@ class IssueDraftViewSet(BaseViewSet):
## Grouping the results
group_by = request.GET.get("group_by", False)
if group_by:
return Response(group_results(issues, group_by), status=status.HTTP_200_OK)
grouped_results = group_results(issues, group_by)
return Response(
grouped_results,
status=status.HTTP_200_OK,
)
return Response(issues, status=status.HTTP_200_OK)
return Response(
issues, status=status.HTTP_200_OK
)
def create(self, request, slug, project_id):
project = Project.objects.get(pk=project_id)
+6 -4
View File
@@ -149,6 +149,9 @@ class ModuleViewSet(BaseViewSet):
if serializer.is_valid():
serializer.save()
module = Module.objects.get(pk=serializer.data["id"])
serializer = ModuleSerializer(module)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@@ -361,7 +364,6 @@ class ModuleIssueViewSet(BaseViewSet):
.values("count")
)
)
issues_data = IssueStateSerializer(issues, many=True).data
if sub_group_by and sub_group_by == group_by:
@@ -371,14 +373,14 @@ class ModuleIssueViewSet(BaseViewSet):
)
if group_by:
grouped_results = group_results(issues_data, group_by, sub_group_by)
return Response(
group_results(issues_data, group_by, sub_group_by),
grouped_results,
status=status.HTTP_200_OK,
)
return Response(
issues_data,
status=status.HTTP_200_OK,
issues_data, status=status.HTTP_200_OK
)
def create(self, request, slug, project_id, module_id):
+133 -74
View File
@@ -69,6 +69,7 @@ from plane.db.models import (
ModuleMember,
Inbox,
ProjectDeployBoard,
IssueProperty,
)
from plane.bgtasks.project_invitation_task import project_invitation
@@ -201,6 +202,11 @@ class ProjectViewSet(BaseViewSet):
project_member = ProjectMember.objects.create(
project_id=serializer.data["id"], member=request.user, role=20
)
# Also create the issue property for the user
_ = IssueProperty.objects.create(
project_id=serializer.data["id"],
user=request.user,
)
if serializer.data["project_lead"] is not None and str(
serializer.data["project_lead"]
@@ -210,6 +216,11 @@ class ProjectViewSet(BaseViewSet):
member_id=serializer.data["project_lead"],
role=20,
)
# Also create the issue property for the user
IssueProperty.objects.create(
project_id=serializer.data["id"],
user_id=serializer.data["project_lead"],
)
# Default states
states = [
@@ -262,12 +273,9 @@ class ProjectViewSet(BaseViewSet):
]
)
data = serializer.data
# Additional fields of the member
data["sort_order"] = project_member.sort_order
data["member_role"] = project_member.role
data["is_member"] = True
return Response(data, status=status.HTTP_201_CREATED)
project = self.get_queryset().filter(pk=serializer.data["id"]).first()
serializer = ProjectListSerializer(project)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(
serializer.errors,
status=status.HTTP_400_BAD_REQUEST,
@@ -317,6 +325,8 @@ class ProjectViewSet(BaseViewSet):
color="#ff7700",
)
project = self.get_queryset().filter(pk=serializer.data["id"]).first()
serializer = ProjectListSerializer(project)
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@@ -393,6 +403,8 @@ class InviteProjectEndpoint(BaseAPIView):
member=user, project_id=project_id, role=role
)
_ = IssueProperty.objects.create(user=user, project_id=project_id)
return Response(
ProjectMemberSerializer(project_member).data, status=status.HTTP_200_OK
)
@@ -428,6 +440,18 @@ class UserProjectInvitationsViewset(BaseViewSet):
]
)
IssueProperty.objects.bulk_create(
[
ProjectMember(
project=invitation.project,
workspace=invitation.project.workspace,
user=request.user,
created_by=request.user,
)
for invitation in project_invitations
]
)
# Delete joined project invites
project_invitations.delete()
@@ -458,6 +482,83 @@ class ProjectMemberViewSet(BaseViewSet):
.select_related("workspace", "workspace__owner")
)
def create(self, request, slug, project_id):
members = request.data.get("members", [])
# get the project
project = Project.objects.get(pk=project_id, workspace__slug=slug)
if not len(members):
return Response(
{"error": "Atleast one member is required"},
status=status.HTTP_400_BAD_REQUEST,
)
bulk_project_members = []
bulk_issue_props = []
project_members = (
ProjectMember.objects.filter(
workspace__slug=slug,
member_id__in=[member.get("member_id") for member in members],
)
.values("member_id", "sort_order")
.order_by("sort_order")
)
for member in members:
sort_order = [
project_member.get("sort_order")
for project_member in project_members
if str(project_member.get("member_id")) == str(member.get("member_id"))
]
bulk_project_members.append(
ProjectMember(
member_id=member.get("member_id"),
role=member.get("role", 10),
project_id=project_id,
workspace_id=project.workspace_id,
sort_order=sort_order[0] - 10000 if len(sort_order) else 65535,
)
)
bulk_issue_props.append(
IssueProperty(
user_id=member.get("member_id"),
project_id=project_id,
workspace_id=project.workspace_id,
)
)
project_members = ProjectMember.objects.bulk_create(
bulk_project_members,
batch_size=10,
ignore_conflicts=True,
)
_ = IssueProperty.objects.bulk_create(
bulk_issue_props, batch_size=10, ignore_conflicts=True
)
serializer = ProjectMemberSerializer(project_members, many=True)
return Response(serializer.data, status=status.HTTP_201_CREATED)
def list(self, request, slug, project_id):
project_member = ProjectMember.objects.get(
member=request.user, workspace__slug=slug, project_id=project_id
)
project_members = ProjectMember.objects.filter(
project_id=project_id,
workspace__slug=slug,
member__is_bot=False,
).select_related("project", "member", "workspace")
if project_member.role > 10:
serializer = ProjectMemberAdminSerializer(project_members, many=True)
else:
serializer = ProjectMemberSerializer(project_members, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
def partial_update(self, request, slug, project_id, pk):
project_member = ProjectMember.objects.get(
pk=pk, workspace__slug=slug, project_id=project_id
@@ -543,59 +644,6 @@ class ProjectMemberViewSet(BaseViewSet):
return Response(status=status.HTTP_204_NO_CONTENT)
class AddMemberToProjectEndpoint(BaseAPIView):
permission_classes = [
ProjectBasePermission,
]
def post(self, request, slug, project_id):
members = request.data.get("members", [])
# get the project
project = Project.objects.get(pk=project_id, workspace__slug=slug)
if not len(members):
return Response(
{"error": "Atleast one member is required"},
status=status.HTTP_400_BAD_REQUEST,
)
bulk_project_members = []
project_members = (
ProjectMember.objects.filter(
workspace__slug=slug,
member_id__in=[member.get("member_id") for member in members],
)
.values("member_id", "sort_order")
.order_by("sort_order")
)
for member in members:
sort_order = [
project_member.get("sort_order")
for project_member in project_members
if str(project_member.get("member_id")) == str(member.get("member_id"))
]
bulk_project_members.append(
ProjectMember(
member_id=member.get("member_id"),
role=member.get("role", 10),
project_id=project_id,
workspace_id=project.workspace_id,
sort_order=sort_order[0] - 10000 if len(sort_order) else 65535,
)
)
project_members = ProjectMember.objects.bulk_create(
bulk_project_members,
batch_size=10,
ignore_conflicts=True,
)
serializer = ProjectMemberSerializer(project_members, many=True)
return Response(serializer.data, status=status.HTTP_201_CREATED)
class AddTeamToProjectEndpoint(BaseAPIView):
permission_classes = [
ProjectBasePermission,
@@ -614,6 +662,7 @@ class AddTeamToProjectEndpoint(BaseAPIView):
workspace = Workspace.objects.get(slug=slug)
project_members = []
issue_props = []
for member in team_members:
project_members.append(
ProjectMember(
@@ -623,11 +672,23 @@ class AddTeamToProjectEndpoint(BaseAPIView):
created_by=request.user,
)
)
issue_props.append(
IssueProperty(
project_id=project_id,
user_id=member,
workspace=workspace,
created_by=request.user,
)
)
ProjectMember.objects.bulk_create(
project_members, batch_size=10, ignore_conflicts=True
)
_ = IssueProperty.objects.bulk_create(
issue_props, batch_size=10, ignore_conflicts=True
)
serializer = ProjectMemberSerializer(project_members, many=True)
return Response(serializer.data, status=status.HTTP_201_CREATED)
@@ -743,6 +804,19 @@ class ProjectJoinEndpoint(BaseAPIView):
ignore_conflicts=True,
)
IssueProperty.objects.bulk_create(
[
IssueProperty(
project_id=project_id,
user=request.user,
workspace=workspace,
created_by=request.user,
)
for project_id in project_ids
],
ignore_conflicts=True,
)
return Response(
{"message": "Projects joined successfully"},
status=status.HTTP_201_CREATED,
@@ -869,21 +943,6 @@ class ProjectDeployBoardViewSet(BaseViewSet):
return Response(serializer.data, status=status.HTTP_200_OK)
class ProjectMemberEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
]
def get(self, request, slug, project_id):
project_members = ProjectMember.objects.filter(
project_id=project_id,
workspace__slug=slug,
member__is_bot=False,
).select_related("project", "member", "workspace")
serializer = ProjectMemberSerializer(project_members, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
class ProjectDeployBoardPublicSettingsEndpoint(BaseAPIView):
permission_classes = [
AllowAny,
+6 -10
View File
@@ -93,7 +93,6 @@ class GlobalViewIssuesViewSet(BaseViewSet):
)
)
@method_decorator(gzip_page)
def list(self, request, slug):
filters = issue_filters(request.query_params, "GET")
@@ -117,9 +116,7 @@ class GlobalViewIssuesViewSet(BaseViewSet):
.values("count")
)
.annotate(
attachment_count=IssueAttachment.objects.filter(
issue=OuterRef("id")
)
attachment_count=IssueAttachment.objects.filter(issue=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
@@ -129,9 +126,7 @@ class GlobalViewIssuesViewSet(BaseViewSet):
# Priority Ordering
if order_by_param == "priority" or order_by_param == "-priority":
priority_order = (
priority_order
if order_by_param == "priority"
else priority_order[::-1]
priority_order if order_by_param == "priority" else priority_order[::-1]
)
issue_queryset = issue_queryset.annotate(
priority_order=Case(
@@ -183,7 +178,6 @@ class GlobalViewIssuesViewSet(BaseViewSet):
)
else:
issue_queryset = issue_queryset.order_by(order_by_param)
issues = IssueLiteSerializer(issue_queryset, many=True).data
## Grouping the results
@@ -194,10 +188,12 @@ class GlobalViewIssuesViewSet(BaseViewSet):
{"error": "Group by and sub group by cannot be same"},
status=status.HTTP_400_BAD_REQUEST,
)
if group_by:
grouped_results = group_results(issues, group_by, sub_group_by)
return Response(
group_results(issues, group_by, sub_group_by), status=status.HTTP_200_OK
grouped_results,
status=status.HTTP_200_OK,
)
return Response(issues, status=status.HTTP_200_OK)
+28 -17
View File
@@ -472,7 +472,7 @@ class WorkSpaceMemberViewSet(BaseViewSet):
model = WorkspaceMember
permission_classes = [
WorkSpaceAdminPermission,
WorkspaceEntityPermission,
]
search_fields = [
@@ -489,6 +489,25 @@ class WorkSpaceMemberViewSet(BaseViewSet):
.select_related("member")
)
def list(self, request, slug):
workspace_member = WorkspaceMember.objects.get(
member=request.user, workspace__slug=slug
)
workspace_members = WorkspaceMember.objects.filter(
workspace__slug=slug,
member__is_bot=False,
).select_related("workspace", "member")
if workspace_member.role > 10:
serializer = WorkspaceMemberAdminSerializer(workspace_members, many=True)
else:
serializer = WorkSpaceMemberSerializer(
workspace_members,
many=True,
)
return Response(serializer.data, status=status.HTTP_200_OK)
def partial_update(self, request, slug, pk):
workspace_member = WorkspaceMember.objects.get(pk=pk, workspace__slug=slug)
if request.user.id == workspace_member.member_id:
@@ -1228,9 +1247,15 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView):
## Grouping the results
group_by = request.GET.get("group_by", False)
if group_by:
return Response(group_results(issues, group_by), status=status.HTTP_200_OK)
grouped_results = group_results(issues, group_by)
return Response(
grouped_results,
status=status.HTTP_200_OK,
)
return Response(issues, status=status.HTTP_200_OK)
return Response(
issues, status=status.HTTP_200_OK
)
class WorkspaceLabelsEndpoint(BaseAPIView):
@@ -1246,20 +1271,6 @@ class WorkspaceLabelsEndpoint(BaseAPIView):
return Response(labels, status=status.HTTP_200_OK)
class WorkspaceMembersEndpoint(BaseAPIView):
permission_classes = [
WorkspaceEntityPermission,
]
def get(self, request, slug):
workspace_members = WorkspaceMember.objects.filter(
workspace__slug=slug,
member__is_bot=False,
).select_related("workspace", "member")
serialzier = WorkSpaceMemberSerializer(workspace_members, many=True)
return Response(serialzier.data, status=status.HTTP_200_OK)
class LeaveWorkspaceEndpoint(BaseAPIView):
permission_classes = [
WorkspaceEntityPermission,
+2 -2
View File
@@ -106,14 +106,14 @@ def upload_to_s3(zip_file, workspace_id, token_id, slug):
)
s3.upload_fileobj(
zip_file,
settings.AWS_PUBLIC_STORAGE_BUCKET_NAME,
settings.AWS_S3_BUCKET_NAME,
file_name,
ExtraArgs={"ACL": "public-read", "ContentType": "application/zip"},
)
presigned_url = s3.generate_presigned_url(
"get_object",
Params={"Bucket": settings.AWS_PUBLIC_STORAGE_BUCKET_NAME, "Key": file_name},
Params={"Bucket": settings.AWS_S3_BUCKET_NAME, "Key": file_name},
ExpiresIn=expires_in,
)
@@ -44,6 +44,6 @@ def delete_old_s3_link():
if settings.DOCKERIZED and settings.USE_MINIO:
s3.delete_object(Bucket=settings.AWS_STORAGE_BUCKET_NAME, Key=file_name)
else:
s3.delete_object(Bucket=settings.AWS_PUBLIC_STORAGE_BUCKET_NAME, Key=file_name)
s3.delete_object(Bucket=settings.AWS_S3_BUCKET_NAME, Key=file_name)
ExporterHistory.objects.filter(id=exporter_id).update(url=None)
+15
View File
@@ -25,6 +25,7 @@ from plane.db.models import (
WorkspaceIntegration,
Label,
User,
IssueProperty,
)
from .workspace_invitation_task import workspace_invitation
from plane.bgtasks.user_welcome_task import send_welcome_slack
@@ -103,6 +104,20 @@ def service_importer(service, importer_id):
ignore_conflicts=True,
)
IssueProperty.objects.bulk_create(
[
IssueProperty(
project_id=importer.project_id,
workspace_id=importer.workspace_id,
user=user,
created_by=importer.created_by,
)
for user in workspace_users
],
batch_size=100,
ignore_conflicts=True,
)
# Check if sync config is on for github importers
if service == "github" and importer.config.get("sync", False):
name = importer.metadata.get("name", False)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,102 @@
# Python imports
import json
# Django imports
from django.utils import timezone
# Module imports
from plane.db.models import IssueSubscriber, Project, IssueAssignee, Issue, Notification
# Third Party imports
from celery import shared_task
@shared_task
def notifications(type, issue_id, project_id, actor_id, subscriber, issue_activities_created):
issue_activities_created = (
json.loads(issue_activities_created) if issue_activities_created is not None else None
)
if type not in [
"cycle.activity.created",
"cycle.activity.deleted",
"module.activity.created",
"module.activity.deleted",
"issue_reaction.activity.created",
"issue_reaction.activity.deleted",
"comment_reaction.activity.created",
"comment_reaction.activity.deleted",
"issue_vote.activity.created",
"issue_vote.activity.deleted",
"issue_draft.activity.created",
"issue_draft.activity.updated",
"issue_draft.activity.deleted",
]:
# Create Notifications
bulk_notifications = []
issue_subscribers = list(
IssueSubscriber.objects.filter(project_id=project_id, issue_id=issue_id)
.exclude(subscriber_id=actor_id)
.values_list("subscriber", flat=True)
)
issue_assignees = list(
IssueAssignee.objects.filter(project_id=project_id, issue_id=issue_id)
.exclude(assignee_id=actor_id)
.values_list("assignee", flat=True)
)
issue_subscribers = issue_subscribers + issue_assignees
issue = Issue.objects.filter(pk=issue_id).first()
if subscriber:
# add the user to issue subscriber
try:
_ = IssueSubscriber.objects.get_or_create(
issue_id=issue_id, subscriber_id=actor_id
)
except Exception as e:
pass
project = Project.objects.get(pk=project_id)
for subscriber in list(set(issue_subscribers)):
for issue_activity in issue_activities_created:
bulk_notifications.append(
Notification(
workspace=project.workspace,
sender="in_app:issue_activities",
triggered_by_id=actor_id,
receiver_id=subscriber,
entity_identifier=issue_id,
entity_name="issue",
project=project,
title=issue_activity.get("comment"),
data={
"issue": {
"id": str(issue_id),
"name": str(issue.name),
"identifier": str(issue.project.identifier),
"sequence_id": issue.sequence_id,
"state_name": issue.state.name,
"state_group": issue.state.group,
},
"issue_activity": {
"id": str(issue_activity.get("id")),
"verb": str(issue_activity.get("verb")),
"field": str(issue_activity.get("field")),
"actor": str(issue_activity.get("actor_id")),
"new_value": str(issue_activity.get("new_value")),
"old_value": str(issue_activity.get("old_value")),
"issue_comment": str(
issue_activity.get("issue_comment").comment_stripped
if issue_activity.get("issue_comment") is not None
else ""
),
},
},
)
)
# Bulk create notifications
Notification.objects.bulk_create(bulk_notifications, batch_size=100)
@@ -0,0 +1,21 @@
# Generated by Django 4.2.5 on 2023-10-18 12:04
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import plane.db.models.issue
class Migration(migrations.Migration):
dependencies = [
('db', '0045_issueactivity_epoch_workspacemember_issue_props_and_more'),
]
operations = [
migrations.AlterField(
model_name='issueproperty',
name='properties',
field=models.JSONField(default=plane.db.models.issue.get_default_properties),
),
]
@@ -1,38 +0,0 @@
# Generated by Django 4.2.3 on 2023-09-21 14:16
import boto3
from django.db import migrations
from django.conf import settings
def move_s3_objects(apps, schema_editor):
IssueAttachment = apps.get_model("db", "IssueAttachment")
# Your source and destination bucket names
source_bucket = settings.AWS_PUBLIC_STORAGE_BUCKET_NAME
destination_bucket = settings.AWS_PRIVATE_STORAGE_BUCKET_NAME
s3_client = boto3.client(
"s3",
aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
)
for key in IssueAttachment.objects.values_list("asset", flat=True):
try:
copy_source = {"Bucket": source_bucket, "Key": key}
s3_client.copy_object(
Bucket=destination_bucket, CopySource=copy_source, Key=key
)
except Exception as e:
pass
class Migration(migrations.Migration):
dependencies = [
("db", "0045_issueactivity_epoch_workspacemember_issue_props_and_more"),
]
operations = [
migrations.RunPython(move_s3_objects),
]
+1 -2
View File
@@ -8,7 +8,7 @@ from django.conf import settings
# Module import
from . import BaseModel
from plane.settings.storage import PublicS3Storage
def get_upload_path(instance, filename):
if instance.workspace_id is not None:
@@ -32,7 +32,6 @@ class FileAsset(BaseModel):
validators=[
file_size,
],
storage=PublicS3Storage(),
)
workspace = models.ForeignKey(
"db.Workspace", on_delete=models.CASCADE, null=True, related_name="assets"
+30 -8
View File
@@ -14,7 +14,25 @@ from django.core.exceptions import ValidationError
# Module imports
from . import ProjectBaseModel
from plane.utils.html_processor import strip_tags
from plane.settings.storage import PrivateS3Storage
def get_default_properties():
return {
"assignee": True,
"start_date": True,
"due_date": True,
"labels": True,
"key": True,
"priority": True,
"state": True,
"sub_issue_count": True,
"link": True,
"attachment_count": True,
"estimate": True,
"created_on": True,
"updated_on": True,
}
# TODO: Handle identifiers for Bulk Inserts - nk
class IssueManager(models.Manager):
@@ -39,7 +57,7 @@ class Issue(ProjectBaseModel):
("high", "High"),
("medium", "Medium"),
("low", "Low"),
("none", "None")
("none", "None"),
)
parent = models.ForeignKey(
"self",
@@ -186,7 +204,7 @@ class IssueRelation(ProjectBaseModel):
("relates_to", "Relates To"),
("blocked_by", "Blocked By"),
)
issue = models.ForeignKey(
Issue, related_name="issue_relation", on_delete=models.CASCADE
)
@@ -208,7 +226,7 @@ class IssueRelation(ProjectBaseModel):
ordering = ("-created_at",)
def __str__(self):
return f"{self.issue.name} {self.related_issue.name}"
return f"{self.issue.name} {self.related_issue.name}"
class IssueAssignee(ProjectBaseModel):
@@ -267,7 +285,6 @@ class IssueAttachment(ProjectBaseModel):
validators=[
file_size,
],
storage=PrivateS3Storage(),
)
issue = models.ForeignKey(
"db.Issue", on_delete=models.CASCADE, related_name="issue_attachment"
@@ -328,7 +345,9 @@ class IssueComment(ProjectBaseModel):
comment_json = models.JSONField(blank=True, default=dict)
comment_html = models.TextField(blank=True, default="<p></p>")
attachments = ArrayField(models.URLField(), size=10, blank=True, default=list)
issue = models.ForeignKey(Issue, on_delete=models.CASCADE, related_name="issue_comments")
issue = models.ForeignKey(
Issue, on_delete=models.CASCADE, related_name="issue_comments"
)
# System can also create comment
actor = models.ForeignKey(
settings.AUTH_USER_MODEL,
@@ -368,7 +387,7 @@ class IssueProperty(ProjectBaseModel):
on_delete=models.CASCADE,
related_name="issue_property_user",
)
properties = models.JSONField(default=dict)
properties = models.JSONField(default=get_default_properties)
class Meta:
verbose_name = "Issue Property"
@@ -516,7 +535,10 @@ class IssueVote(ProjectBaseModel):
)
class Meta:
unique_together = ["issue", "actor",]
unique_together = [
"issue",
"actor",
]
verbose_name = "Issue Vote"
verbose_name_plural = "Issue Votes"
db_table = "issue_votes"
-1
View File
@@ -36,7 +36,6 @@ INSTALLED_APPS = [
"corsheaders",
"taggit",
"django_celery_beat",
"storages",
]
MIDDLEWARE = [
-24
View File
@@ -119,29 +119,5 @@ GITHUB_ACCESS_TOKEN = os.environ.get("GITHUB_ACCESS_TOKEN", False)
ENABLE_SIGNUP = os.environ.get("ENABLE_SIGNUP", "1") == "1"
# Storage Settings
STORAGES = {
"staticfiles": {
"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
},
}
STORAGES["default"] = {"BACKEND": "storages.backends.s3boto3.S3Boto3Storage"}
# Common AWS settings
AWS_ACCESS_KEY_ID = os.environ.get("AWS_ACCESS_KEY_ID")
AWS_SECRET_ACCESS_KEY = os.environ.get("AWS_SECRET_ACCESS_KEY")
AWS_REGION_NAME = os.environ.get("AWS_REGION")
AWS_S3_ADDRESSING_STYLE = os.environ.get("AWS_S3_ADDRESSING_STYLE")
AWS_S3_FILE_OVERWRITE = False
# Public S3 bucket settings
AWS_PUBLIC_STORAGE_BUCKET_NAME = os.environ.get("AWS_PUBLIC_STORAGE_BUCKET_NAME")
AWS_PUBLIC_DEFAULT_ACL = "public-read"
PUBLIC_FILE_STORAGE = "storages.backends.s3boto3.S3Boto3Storage"
# Private S3 bucket settings
AWS_PRIVATE_STORAGE_BUCKET_NAME = os.environ.get("AWS_PRIVATE_STORAGE_BUCKET_NAME")
AWS_S3_PRIVATE_FILE_OVERWRITE = False
AWS_PRIVATE_DEFAULT_ACL = "private"
PRIVATE_FILE_STORAGE = "storages.backends.s3boto3.S3Boto3Storage"
# End Storage Settings
# Unsplash Access key
UNSPLASH_ACCESS_KEY = os.environ.get("UNSPLASH_ACCESS_KEY")
+118 -37
View File
@@ -14,19 +14,21 @@ from .common import * # noqa
# Database
DEBUG = int(os.environ.get("DEBUG", 0)) == 1
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": "plane",
"USER": os.environ.get("PGUSER", ""),
"PASSWORD": os.environ.get("PGPASSWORD", ""),
"HOST": os.environ.get("PGHOST", ""),
if bool(os.environ.get("DATABASE_URL")):
# Parse database configuration from $DATABASE_URL
DATABASES["default"] = dj_database_url.config()
else:
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": os.environ.get("POSTGRES_DB"),
"USER": os.environ.get("POSTGRES_USER"),
"PASSWORD": os.environ.get("POSTGRES_PASSWORD"),
"HOST": os.environ.get("POSTGRES_HOST"),
}
}
}
# Parse database configuration from $DATABASE_URL
DATABASES["default"] = dj_database_url.config()
SITE_ID = 1
# Set the variable true if running in docker environment
@@ -90,33 +92,113 @@ if bool(os.environ.get("SENTRY_DSN", False)):
profiles_sample_rate=1.0,
)
# Storage Settings
STORAGES = {
"staticfiles": {
"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
},
}
STORAGES["default"] = {"BACKEND": "storages.backends.s3boto3.S3Boto3Storage"}
# Common AWS settings
AWS_ACCESS_KEY_ID = os.environ.get("AWS_ACCESS_KEY_ID")
AWS_SECRET_ACCESS_KEY = os.environ.get("AWS_SECRET_ACCESS_KEY")
AWS_REGION_NAME = os.environ.get("AWS_REGION")
AWS_S3_ADDRESSING_STYLE = os.environ.get("AWS_S3_ADDRESSING_STYLE")
AWS_S3_FILE_OVERWRITE = False
# Public S3 bucket settings
AWS_PUBLIC_STORAGE_BUCKET_NAME = os.environ.get("AWS_PUBLIC_STORAGE_BUCKET_NAME")
AWS_PUBLIC_DEFAULT_ACL = "public-read"
AWS_S3_PUBLIC_OBJECT_PARAMETERS = {
"CacheControl": "max-age=86400",
}
PUBLIC_FILE_STORAGE = "storages.backends.s3boto3.S3Boto3Storage"
if DOCKERIZED and USE_MINIO:
INSTALLED_APPS += ("storages",)
STORAGES["default"] = {"BACKEND": "storages.backends.s3boto3.S3Boto3Storage"}
# The AWS access key to use.
AWS_ACCESS_KEY_ID = os.environ.get("AWS_ACCESS_KEY_ID", "access-key")
# The AWS secret access key to use.
AWS_SECRET_ACCESS_KEY = os.environ.get("AWS_SECRET_ACCESS_KEY", "secret-key")
# The name of the bucket to store files in.
AWS_STORAGE_BUCKET_NAME = os.environ.get("AWS_S3_BUCKET_NAME", "uploads")
# The full URL to the S3 endpoint. Leave blank to use the default region URL.
AWS_S3_ENDPOINT_URL = os.environ.get(
"AWS_S3_ENDPOINT_URL", "http://plane-minio:9000"
)
# Default permissions
AWS_DEFAULT_ACL = "public-read"
AWS_QUERYSTRING_AUTH = False
AWS_S3_FILE_OVERWRITE = False
# Private S3 bucket settings
AWS_PRIVATE_STORAGE_BUCKET_NAME = os.environ.get("AWS_PRIVATE_STORAGE_BUCKET_NAME")
AWS_S3_PRIVATE_FILE_OVERWRITE = False
AWS_PRIVATE_DEFAULT_ACL = "private"
PRIVATE_FILE_STORAGE = "storages.backends.s3boto3.S3Boto3Storage"
# End Storage Settings
# Custom Domain settings
parsed_url = urlparse(os.environ.get("WEB_URL", "http://localhost"))
AWS_S3_CUSTOM_DOMAIN = f"{parsed_url.netloc}/{AWS_STORAGE_BUCKET_NAME}"
AWS_S3_URL_PROTOCOL = f"{parsed_url.scheme}:"
else:
# The AWS region to connect to.
AWS_REGION = os.environ.get("AWS_REGION", "")
# The AWS access key to use.
AWS_ACCESS_KEY_ID = os.environ.get("AWS_ACCESS_KEY_ID", "")
# The AWS secret access key to use.
AWS_SECRET_ACCESS_KEY = os.environ.get("AWS_SECRET_ACCESS_KEY", "")
# The optional AWS session token to use.
# AWS_SESSION_TOKEN = ""
# The name of the bucket to store files in.
AWS_S3_BUCKET_NAME = os.environ.get("AWS_S3_BUCKET_NAME")
# How to construct S3 URLs ("auto", "path", "virtual").
AWS_S3_ADDRESSING_STYLE = "auto"
# The full URL to the S3 endpoint. Leave blank to use the default region URL.
AWS_S3_ENDPOINT_URL = os.environ.get("AWS_S3_ENDPOINT_URL", "")
# A prefix to be applied to every stored file. This will be joined to every filename using the "/" separator.
AWS_S3_KEY_PREFIX = ""
# Whether to enable authentication for stored files. If True, then generated URLs will include an authentication
# token valid for `AWS_S3_MAX_AGE_SECONDS`. If False, then generated URLs will not include an authentication token,
# and their permissions will be set to "public-read".
AWS_S3_BUCKET_AUTH = False
# How long generated URLs are valid for. This affects the expiry of authentication tokens if `AWS_S3_BUCKET_AUTH`
# is True. It also affects the "Cache-Control" header of the files.
# Important: Changing this setting will not affect existing files.
AWS_S3_MAX_AGE_SECONDS = 60 * 60 # 1 hours.
# A URL prefix to be used for generated URLs. This is useful if your bucket is served through a CDN. This setting
# cannot be used with `AWS_S3_BUCKET_AUTH`.
AWS_S3_PUBLIC_URL = ""
# If True, then files will be stored with reduced redundancy. Check the S3 documentation and make sure you
# understand the consequences before enabling.
# Important: Changing this setting will not affect existing files.
AWS_S3_REDUCED_REDUNDANCY = False
# The Content-Disposition header used when the file is downloaded. This can be a string, or a function taking a
# single `name` argument.
# Important: Changing this setting will not affect existing files.
AWS_S3_CONTENT_DISPOSITION = ""
# The Content-Language header used when the file is downloaded. This can be a string, or a function taking a
# single `name` argument.
# Important: Changing this setting will not affect existing files.
AWS_S3_CONTENT_LANGUAGE = ""
# A mapping of custom metadata for each file. Each value can be a string, or a function taking a
# single `name` argument.
# Important: Changing this setting will not affect existing files.
AWS_S3_METADATA = {}
# If True, then files will be stored using AES256 server-side encryption.
# If this is a string value (e.g., "aws:kms"), that encryption type will be used.
# Otherwise, server-side encryption is not be enabled.
# Important: Changing this setting will not affect existing files.
AWS_S3_ENCRYPT_KEY = False
# The AWS S3 KMS encryption key ID (the `SSEKMSKeyId` parameter) is set from this string if present.
# This is only relevant if AWS S3 KMS server-side encryption is enabled (above).
# AWS_S3_KMS_ENCRYPTION_KEY_ID = ""
# If True, then text files will be stored using gzip content encoding. Files will only be gzipped if their
# compressed size is smaller than their uncompressed size.
# Important: Changing this setting will not affect existing files.
AWS_S3_GZIP = True
# The signature version to use for S3 requests.
AWS_S3_SIGNATURE_VERSION = None
# If True, then files with the same name will overwrite each other. By default it's set to False to have
# extra characters appended.
AWS_S3_FILE_OVERWRITE = False
STORAGES["default"] = {
"BACKEND": "django_s3_storage.storage.S3Storage",
}
# AWS Settings End
# Enable Connection Pooling (if desired)
# DATABASES['default']['ENGINE'] = 'django_postgrespool'
@@ -198,4 +280,3 @@ SCOUT_NAME = "Plane"
# Unsplash Access key
UNSPLASH_ACCESS_KEY = os.environ.get("UNSPLASH_ACCESS_KEY")
+17 -14
View File
@@ -1,5 +1,8 @@
"""Self hosted settings and globals."""
from urllib.parse import urlparse
import dj_database_url
from urllib.parse import urlparse
from .common import * # noqa
@@ -52,33 +55,33 @@ CORS_ALLOW_HEADERS = [
CORS_ALLOW_CREDENTIALS = True
CORS_ALLOW_ALL_ORIGINS = True
# Storage Settings
STORAGES = {
"staticfiles": {
"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
},
}
INSTALLED_APPS += ("storages",)
STORAGES["default"] = {"BACKEND": "storages.backends.s3boto3.S3Boto3Storage"}
# Common AWS settings
# The AWS access key to use.
AWS_ACCESS_KEY_ID = os.environ.get("AWS_ACCESS_KEY_ID", "access-key")
# The AWS secret access key to use.
AWS_SECRET_ACCESS_KEY = os.environ.get("AWS_SECRET_ACCESS_KEY", "secret-key")
AWS_REGION_NAME = os.environ.get("AWS_REGION")
AWS_S3_ADDRESSING_STYLE = os.environ.get("AWS_S3_ADDRESSING_STYLE")
# The name of the bucket to store files in.
AWS_STORAGE_BUCKET_NAME = os.environ.get("AWS_S3_BUCKET_NAME", "uploads")
# The full URL to the S3 endpoint. Leave blank to use the default region URL.
AWS_S3_ENDPOINT_URL = os.environ.get(
"AWS_S3_ENDPOINT_URL", "http://plane-minio:9000"
)
# Default permissions
AWS_DEFAULT_ACL = "public-read"
AWS_QUERYSTRING_AUTH = False
AWS_S3_FILE_OVERWRITE = False
AWS_DEFAULT_ACL = None
# Public S3 bucket settings
AWS_PUBLIC_STORAGE_BUCKET_NAME = os.environ.get("AWS_PUBLIC_STORAGE_BUCKET_NAME")
AWS_PUBLIC_DEFAULT_ACL = "public-read"
PUBLIC_FILE_STORAGE = "plane.settings.storage.PublicS3Storage"
# Private S3 bucket settings
AWS_PRIVATE_STORAGE_BUCKET_NAME = os.environ.get("AWS_PRIVATE_STORAGE_BUCKET_NAME")
AWS_PRIVATE_DEFAULT_ACL = "private"
PRIVATE_FILE_STORAGE = "plane.settings.storage.PrivateS3Storage"
## End Storage settings
# Custom Domain settings
parsed_url = urlparse(os.environ.get("WEB_URL", "http://localhost"))
AWS_S3_CUSTOM_DOMAIN = f"{parsed_url.netloc}/{AWS_STORAGE_BUCKET_NAME}"
AWS_S3_URL_PROTOCOL = f"{parsed_url.scheme}:"
# Honor the 'X-Forwarded-Proto' header for request.is_secure()
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
+84 -25
View File
@@ -1,8 +1,10 @@
"""Production settings and globals."""
from urllib.parse import urlparse
import ssl
import certifi
import dj_database_url
from urllib.parse import urlparse
import sentry_sdk
from sentry_sdk.integrations.django import DjangoIntegration
@@ -68,34 +70,91 @@ sentry_sdk.init(
profiles_sample_rate=1.0,
)
# The AWS region to connect to.
AWS_REGION = os.environ.get("AWS_REGION")
# Storage Settings
STORAGES = {
"staticfiles": {
"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
},
}
STORAGES["default"] = {"BACKEND": "storages.backends.s3boto3.S3Boto3Storage"}
# Common AWS settings
# The AWS access key to use.
AWS_ACCESS_KEY_ID = os.environ.get("AWS_ACCESS_KEY_ID")
AWS_SECRET_ACCESS_KEY = os.environ.get("AWS_SECRET_ACCESS_KEY")
AWS_REGION_NAME = os.environ.get("AWS_REGION")
AWS_S3_ADDRESSING_STYLE = os.environ.get("AWS_S3_ADDRESSING_STYLE")
AWS_S3_FILE_OVERWRITE = False
# Public S3 bucket settings
AWS_PUBLIC_STORAGE_BUCKET_NAME = os.environ.get("AWS_PUBLIC_STORAGE_BUCKET_NAME")
AWS_PUBLIC_DEFAULT_ACL = "public-read"
AWS_S3_PUBLIC_OBJECT_PARAMETERS = {
"CacheControl": "max-age=86400",
}
PUBLIC_FILE_STORAGE = "storages.backends.s3boto3.S3Boto3Storage"
# Private S3 bucket settings
AWS_PRIVATE_STORAGE_BUCKET_NAME = os.environ.get("AWS_PRIVATE_STORAGE_BUCKET_NAME")
AWS_S3_PRIVATE_FILE_OVERWRITE = False
AWS_PRIVATE_DEFAULT_ACL = "private"
PRIVATE_FILE_STORAGE = "storages.backends.s3boto3.S3Boto3Storage"
# End Storage settings
# The AWS secret access key to use.
AWS_SECRET_ACCESS_KEY = os.environ.get("AWS_SECRET_ACCESS_KEY")
# The optional AWS session token to use.
# AWS_SESSION_TOKEN = ""
# The name of the bucket to store files in.
AWS_S3_BUCKET_NAME = os.environ.get("AWS_S3_BUCKET_NAME")
# How to construct S3 URLs ("auto", "path", "virtual").
AWS_S3_ADDRESSING_STYLE = "auto"
# The full URL to the S3 endpoint. Leave blank to use the default region URL.
AWS_S3_ENDPOINT_URL = os.environ.get("AWS_S3_ENDPOINT_URL", "")
# A prefix to be applied to every stored file. This will be joined to every filename using the "/" separator.
AWS_S3_KEY_PREFIX = ""
# Whether to enable authentication for stored files. If True, then generated URLs will include an authentication
# token valid for `AWS_S3_MAX_AGE_SECONDS`. If False, then generated URLs will not include an authentication token,
# and their permissions will be set to "public-read".
AWS_S3_BUCKET_AUTH = False
# How long generated URLs are valid for. This affects the expiry of authentication tokens if `AWS_S3_BUCKET_AUTH`
# is True. It also affects the "Cache-Control" header of the files.
# Important: Changing this setting will not affect existing files.
AWS_S3_MAX_AGE_SECONDS = 60 * 60 # 1 hours.
# A URL prefix to be used for generated URLs. This is useful if your bucket is served through a CDN. This setting
# cannot be used with `AWS_S3_BUCKET_AUTH`.
AWS_S3_PUBLIC_URL = ""
# If True, then files will be stored with reduced redundancy. Check the S3 documentation and make sure you
# understand the consequences before enabling.
# Important: Changing this setting will not affect existing files.
AWS_S3_REDUCED_REDUNDANCY = False
# The Content-Disposition header used when the file is downloaded. This can be a string, or a function taking a
# single `name` argument.
# Important: Changing this setting will not affect existing files.
AWS_S3_CONTENT_DISPOSITION = ""
# The Content-Language header used when the file is downloaded. This can be a string, or a function taking a
# single `name` argument.
# Important: Changing this setting will not affect existing files.
AWS_S3_CONTENT_LANGUAGE = ""
# A mapping of custom metadata for each file. Each value can be a string, or a function taking a
# single `name` argument.
# Important: Changing this setting will not affect existing files.
AWS_S3_METADATA = {}
# If True, then files will be stored using AES256 server-side encryption.
# If this is a string value (e.g., "aws:kms"), that encryption type will be used.
# Otherwise, server-side encryption is not be enabled.
# Important: Changing this setting will not affect existing files.
AWS_S3_ENCRYPT_KEY = False
# The AWS S3 KMS encryption key ID (the `SSEKMSKeyId` parameter) is set from this string if present.
# This is only relevant if AWS S3 KMS server-side encryption is enabled (above).
# AWS_S3_KMS_ENCRYPTION_KEY_ID = ""
# If True, then text files will be stored using gzip content encoding. Files will only be gzipped if their
# compressed size is smaller than their uncompressed size.
# Important: Changing this setting will not affect existing files.
AWS_S3_GZIP = True
# The signature version to use for S3 requests.
AWS_S3_SIGNATURE_VERSION = None
# If True, then files with the same name will overwrite each other. By default it's set to False to have
# extra characters appended.
AWS_S3_FILE_OVERWRITE = False
# AWS Settings End
STORAGES["default"] = {
"BACKEND": "django_s3_storage.storage.S3Storage",
}
# Enable Connection Pooling (if desired)
# DATABASES['default']['ENGINE'] = 'django_postgrespool'
-27
View File
@@ -1,27 +0,0 @@
from django.conf import settings
from storages.backends.s3boto3 import S3Boto3Storage
from urllib.parse import urlparse
class PublicS3Storage(S3Boto3Storage):
"""Configuration for the Public bucket storage"""
bucket_name = settings.AWS_PUBLIC_STORAGE_BUCKET_NAME
default_acl = settings.AWS_PUBLIC_DEFAULT_ACL
querystring_auth = False
# For self hosted docker and minio
if settings.DOCKERIZED and settings.USE_MINIO:
custom_domain = f"{urlparse(settings.WEB_URL).netloc}/{bucket_name}"
url_protocol = f"{urlparse(settings.WEB_URL).scheme}:"
class PrivateS3Storage(S3Boto3Storage):
"""Configuration for the Private bucket storage"""
bucket_name = settings.AWS_PRIVATE_STORAGE_BUCKET_NAME
region_name = settings.AWS_REGION_NAME
addressing_style = settings.AWS_S3_ADDRESSING_STYLE
default_acl = settings.AWS_PRIVATE_DEFAULT_ACL
# For self hosted docker and minio
if settings.DOCKERIZED and settings.USE_MINIO:
custom_domain = f"{urlparse(settings.WEB_URL).netloc}/{bucket_name}"
url_protocol = f"{urlparse(settings.WEB_URL).scheme}:"
+50 -27
View File
@@ -1,10 +1,24 @@
import re
import uuid
from datetime import timedelta
from django.utils import timezone
# The date from pattern
pattern = re.compile(r"\d+_(weeks|months)$")
# check the valid uuids
def filter_valid_uuids(uuid_list):
valid_uuids = []
for uuid_str in uuid_list:
try:
uuid_obj = uuid.UUID(uuid_str)
valid_uuids.append(uuid_obj)
except ValueError:
# ignore the invalid uuids
pass
return valid_uuids
# Get the 2_weeks, 3_months
def string_date_filter(filter, duration, subsequent, term, date_filter, offset):
@@ -61,40 +75,41 @@ def date_filter(filter, date_term, queries):
def filter_state(params, filter, method):
if method == "GET":
states = params.get("state").split(",")
states = [item for item in params.get("state").split(",") if item != 'null']
states = filter_valid_uuids(states)
if len(states) and "" not in states:
filter["state__in"] = states
else:
if params.get("state", None) and len(params.get("state")):
if params.get("state", None) and len(params.get("state")) and params.get("state") != 'null':
filter["state__in"] = params.get("state")
return filter
def filter_state_group(params, filter, method):
if method == "GET":
state_group = params.get("state_group").split(",")
state_group = [item for item in params.get("state_group").split(",") if item != 'null']
if len(state_group) and "" not in state_group:
filter["state__group__in"] = state_group
else:
if params.get("state_group", None) and len(params.get("state_group")):
if params.get("state_group", None) and len(params.get("state_group")) and params.get("state_group") != 'null':
filter["state__group__in"] = params.get("state_group")
return filter
def filter_estimate_point(params, filter, method):
if method == "GET":
estimate_points = params.get("estimate_point").split(",")
estimate_points = [item for item in params.get("estimate_point").split(",") if item != 'null']
if len(estimate_points) and "" not in estimate_points:
filter["estimate_point__in"] = estimate_points
else:
if params.get("estimate_point", None) and len(params.get("estimate_point")):
if params.get("estimate_point", None) and len(params.get("estimate_point")) and params.get("estimate_point") != 'null':
filter["estimate_point__in"] = params.get("estimate_point")
return filter
def filter_priority(params, filter, method):
if method == "GET":
priorities = params.get("priority").split(",")
priorities = [item for item in params.get("priority").split(",") if item != 'null']
if len(priorities) and "" not in priorities:
filter["priority__in"] = priorities
return filter
@@ -102,44 +117,48 @@ def filter_priority(params, filter, method):
def filter_parent(params, filter, method):
if method == "GET":
parents = params.get("parent").split(",")
parents = [item for item in params.get("parent").split(",") if item != 'null']
parents = filter_valid_uuids(parents)
if len(parents) and "" not in parents:
filter["parent__in"] = parents
else:
if params.get("parent", None) and len(params.get("parent")):
if params.get("parent", None) and len(params.get("parent")) and params.get("parent") != 'null':
filter["parent__in"] = params.get("parent")
return filter
def filter_labels(params, filter, method):
if method == "GET":
labels = params.get("labels").split(",")
labels = [item for item in params.get("labels").split(",") if item != 'null']
labels = filter_valid_uuids(labels)
if len(labels) and "" not in labels:
filter["labels__in"] = labels
else:
if params.get("labels", None) and len(params.get("labels")):
if params.get("labels", None) and len(params.get("labels")) and params.get("labels") != 'null':
filter["labels__in"] = params.get("labels")
return filter
def filter_assignees(params, filter, method):
if method == "GET":
assignees = params.get("assignees").split(",")
assignees = [item for item in params.get("assignees").split(",") if item != 'null']
assignees = filter_valid_uuids(assignees)
if len(assignees) and "" not in assignees:
filter["assignees__in"] = assignees
else:
if params.get("assignees", None) and len(params.get("assignees")):
if params.get("assignees", None) and len(params.get("assignees")) and params.get("assignees") != 'null':
filter["assignees__in"] = params.get("assignees")
return filter
def filter_created_by(params, filter, method):
if method == "GET":
created_bys = params.get("created_by").split(",")
created_bys = [item for item in params.get("created_by").split(",") if item != 'null']
created_bys = filter_valid_uuids(created_bys)
if len(created_bys) and "" not in created_bys:
filter["created_by__in"] = created_bys
else:
if params.get("created_by", None) and len(params.get("created_by")):
if params.get("created_by", None) and len(params.get("created_by")) and params.get("created_by") != 'null':
filter["created_by__in"] = params.get("created_by")
return filter
@@ -179,7 +198,7 @@ def filter_start_date(params, filter, method):
date_filter(filter=filter, date_term="start_date", queries=start_dates)
else:
if params.get("start_date", None) and len(params.get("start_date")):
date_filter(filter=filter, date_term="start_date", queries=params.get("start_date", []))
filter["start_date"] = params.get("start_date")
return filter
@@ -190,7 +209,7 @@ def filter_target_date(params, filter, method):
date_filter(filter=filter, date_term="target_date", queries=target_dates)
else:
if params.get("target_date", None) and len(params.get("target_date")):
date_filter(filter=filter, date_term="target_date", queries=params.get("target_date", []))
filter["target_date"] = params.get("target_date")
return filter
@@ -219,44 +238,47 @@ def filter_issue_state_type(params, filter, method):
def filter_project(params, filter, method):
if method == "GET":
projects = params.get("project").split(",")
projects = [item for item in params.get("project").split(",") if item != 'null']
projects = filter_valid_uuids(projects)
if len(projects) and "" not in projects:
filter["project__in"] = projects
else:
if params.get("project", None) and len(params.get("project")):
if params.get("project", None) and len(params.get("project")) and params.get("project") != 'null':
filter["project__in"] = params.get("project")
return filter
def filter_cycle(params, filter, method):
if method == "GET":
cycles = params.get("cycle").split(",")
cycles = [item for item in params.get("cycle").split(",") if item != 'null']
cycles = filter_valid_uuids(cycles)
if len(cycles) and "" not in cycles:
filter["issue_cycle__cycle_id__in"] = cycles
else:
if params.get("cycle", None) and len(params.get("cycle")):
if params.get("cycle", None) and len(params.get("cycle")) and params.get("cycle") != 'null':
filter["issue_cycle__cycle_id__in"] = params.get("cycle")
return filter
def filter_module(params, filter, method):
if method == "GET":
modules = params.get("module").split(",")
modules = [item for item in params.get("module").split(",") if item != 'null']
modules = filter_valid_uuids(modules)
if len(modules) and "" not in modules:
filter["issue_module__module_id__in"] = modules
else:
if params.get("module", None) and len(params.get("module")):
if params.get("module", None) and len(params.get("module")) and params.get("module") != 'null':
filter["issue_module__module_id__in"] = params.get("module")
return filter
def filter_inbox_status(params, filter, method):
if method == "GET":
status = params.get("inbox_status").split(",")
status = [item for item in params.get("inbox_status").split(",") if item != 'null']
if len(status) and "" not in status:
filter["issue_inbox__status__in"] = status
else:
if params.get("inbox_status", None) and len(params.get("inbox_status")):
if params.get("inbox_status", None) and len(params.get("inbox_status")) and params.get("inbox_status") != 'null':
filter["issue_inbox__status__in"] = params.get("inbox_status")
return filter
@@ -275,11 +297,12 @@ def filter_sub_issue_toggle(params, filter, method):
def filter_subscribed_issues(params, filter, method):
if method == "GET":
subscribers = params.get("subscriber").split(",")
subscribers = [item for item in params.get("subscriber").split(",") if item != 'null']
subscribers = filter_valid_uuids(subscribers)
if len(subscribers) and "" not in subscribers:
filter["issue_subscribers__subscriber_id__in"] = subscribers
else:
if params.get("subscriber", None) and len(params.get("subscriber")):
if params.get("subscriber", None) and len(params.get("subscriber")) and params.get("subscriber") != 'null':
filter["issue_subscribers__subscriber_id__in"] = params.get("subscriber")
return filter
+2 -10
View File
@@ -111,14 +111,7 @@ services:
createbuckets:
image: minio/mc
entrypoint: >
/bin/sh -c "
/usr/bin/mc config host add plane-minio http://plane-minio:9000 \$AWS_ACCESS_KEY_ID \$AWS_SECRET_ACCESS_KEY;
/usr/bin/mc mb plane-minio/\$AWS_PUBLIC_STORAGE_BUCKET_NAME;
/usr/bin/mc anonymous set download plane-minio/\$AWS_PUBLIC_STORAGE_BUCKET_NAME;
/usr/bin/mc config host add plane-minio http://plane-minio:9000 \$AWS_ACCESS_KEY_ID \$AWS_SECRET_ACCESS_KEY;
/usr/bin/mc mb plane-minio/\$AWS_PRIVATE_STORAGE_BUCKET_NAME;
/usr/bin/mc anonymous set none plane-minio/\$AWS_PRIVATE_STORAGE_BUCKET_NAME; exit 0;
"
/bin/sh -c " /usr/bin/mc config host add plane-minio http://plane-minio:9000 \$AWS_ACCESS_KEY_ID \$AWS_SECRET_ACCESS_KEY; /usr/bin/mc mb plane-minio/\$AWS_S3_BUCKET_NAME; /usr/bin/mc anonymous set download plane-minio/\$AWS_S3_BUCKET_NAME; exit 0; "
env_file:
- .env
depends_on:
@@ -135,8 +128,7 @@ services:
- ${NGINX_PORT}:80
environment:
FILE_SIZE_LIMIT: ${FILE_SIZE_LIMIT:-5242880}
PUBLIC_BUCKET_NAME: ${AWS_PUBLIC_STORAGE_BUCKET_NAME:-uploads}
PRIVATE_BUCKET_NAME: ${AWS_PRIVATE_STORAGE_BUCKET_NAME:-uploads-private}
BUCKET_NAME: ${AWS_S3_BUCKET_NAME:-uploads}
depends_on:
- web
- api
+2 -6
View File
@@ -29,12 +29,8 @@ http {
proxy_pass http://space:3000/spaces/;
}
location /${PUBLIC_BUCKET_NAME}/ {
proxy_pass http://plane-minio:9000/${PUBLIC_BUCKET_NAME}/;
}
location /${PRIVATE_BUCKET_NAME}/ {
proxy_pass http://plane-minio:9000/${PRIVATE_BUCKET_NAME}/;
location /${BUCKET_NAME}/ {
proxy_pass http://plane-minio:9000/uploads/;
}
}
}
+1 -1
View File
@@ -27,7 +27,7 @@
"prettier": "latest",
"prettier-plugin-tailwindcss": "^0.5.4",
"tailwindcss": "^3.3.3",
"turbo": "^1.10.14"
"turbo": "^1.10.16"
},
"resolutions": {
"@types/react": "18.2.0"
@@ -23,8 +23,8 @@ export const ImageResizer = ({ editor }: { editor: Editor }) => {
origin={false}
edge={false}
throttleDrag={0}
keepRatio={true}
resizable={true}
keepRatio
resizable
throttleResize={0}
onResize={({ target, width, height, delta }: any) => {
delta[0] && (target!.style.width = `${width}px`);
@@ -33,7 +33,7 @@ export const ImageResizer = ({ editor }: { editor: Editor }) => {
onResizeEnd={() => {
updateMediaSize();
}}
scalable={true}
scalable
renderDirections={["w", "e"]}
onScale={({ target, transform }: any) => {
target!.style.transform = transform;
+41 -30
View File
@@ -1,18 +1,23 @@
import { useEditor as useCustomEditor, Editor } from "@tiptap/react";
import { useImperativeHandle, useRef, MutableRefObject } from "react";
import { useDebouncedCallback } from "use-debounce";
import { DeleteImage } from '../../types/delete-image';
import {
useImperativeHandle,
useRef,
MutableRefObject,
useEffect,
} from "react";
import { DeleteImage } from "../../types/delete-image";
import { CoreEditorProps } from "../props";
import { CoreEditorExtensions } from "../extensions";
import { EditorProps } from '@tiptap/pm/view';
import { EditorProps } from "@tiptap/pm/view";
import { getTrimmedHTML } from "../../lib/utils";
import { UploadImage } from "../../types/upload-image";
const DEBOUNCE_DELAY = 1500;
import { useInitializedContent } from "./useInitializedContent";
interface CustomEditorProps {
uploadFile: UploadImage;
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void;
setIsSubmitting?: (
isSubmitting: "submitting" | "submitted" | "saved",
) => void;
setShouldShowAlert?: (showAlert: boolean) => void;
value: string;
deleteFile: DeleteImage;
@@ -23,25 +28,37 @@ interface CustomEditorProps {
forwardedRef?: any;
}
export const useEditor = ({ uploadFile, deleteFile, editorProps = {}, value, extensions = [], onChange, setIsSubmitting, debouncedUpdatesEnabled, forwardedRef, setShouldShowAlert, }: CustomEditorProps) => {
const editor = useCustomEditor({
editorProps: {
...CoreEditorProps(uploadFile, setIsSubmitting),
...editorProps,
},
extensions: [...CoreEditorExtensions(deleteFile), ...extensions],
content: (typeof value === "string" && value.trim() !== "") ? value : "<p></p>",
onUpdate: async ({ editor }) => {
// for instant feedback loop
setIsSubmitting?.("submitting");
setShouldShowAlert?.(true);
if (debouncedUpdatesEnabled) {
debouncedUpdates({ onChange: onChange, editor });
} else {
export const useEditor = ({
uploadFile,
deleteFile,
editorProps = {},
value,
extensions = [],
onChange,
setIsSubmitting,
forwardedRef,
setShouldShowAlert,
}: CustomEditorProps) => {
const editor = useCustomEditor(
{
editorProps: {
...CoreEditorProps(uploadFile, setIsSubmitting),
...editorProps,
},
extensions: [...CoreEditorExtensions(deleteFile), ...extensions],
content:
typeof value === "string" && value.trim() !== "" ? value : "<p></p>",
onUpdate: async ({ editor }) => {
// for instant feedback loop
setIsSubmitting?.("submitting");
setShouldShowAlert?.(true);
onChange?.(editor.getJSON(), getTrimmedHTML(editor.getHTML()));
}
},
},
});
[],
);
useInitializedContent(editor, value);
const editorRef: MutableRefObject<Editor | null> = useRef(null);
editorRef.current = editor;
@@ -55,12 +72,6 @@ export const useEditor = ({ uploadFile, deleteFile, editorProps = {}, value, ext
},
}));
const debouncedUpdates = useDebouncedCallback(async ({ onChange, editor }) => {
if (onChange) {
onChange(editor.getJSON(), getTrimmedHTML(editor.getHTML()));
}
}, DEBOUNCE_DELAY);
if (!editor) {
return null;
}
@@ -0,0 +1,19 @@
import { Editor } from "@tiptap/react";
import { useEffect, useRef } from "react";
export const useInitializedContent = (editor: Editor | null, value: string) => {
const hasInitializedContent = useRef(false);
useEffect(() => {
if (editor) {
const cleanedValue =
typeof value === "string" && value.trim() !== "" ? value : "<p></p>";
if (cleanedValue !== "<p></p>" && !hasInitializedContent.current) {
editor.commands.setContent(cleanedValue);
hasInitializedContent.current = true;
} else if (cleanedValue === "<p></p>" && hasInitializedContent.current) {
hasInitializedContent.current = false;
}
}
}, [value, editor]);
};
@@ -1,8 +1,13 @@
import { useEditor as useCustomEditor, Editor } from "@tiptap/react";
import { useImperativeHandle, useRef, MutableRefObject } from "react";
import {
useImperativeHandle,
useRef,
MutableRefObject,
useEffect,
} from "react";
import { CoreReadOnlyEditorExtensions } from "../../ui/read-only/extensions";
import { CoreReadOnlyEditorProps } from "../../ui/read-only/props";
import { EditorProps } from '@tiptap/pm/view';
import { EditorProps } from "@tiptap/pm/view";
interface CustomReadOnlyEditorProps {
value: string;
@@ -11,10 +16,16 @@ interface CustomReadOnlyEditorProps {
editorProps?: EditorProps;
}
export const useReadOnlyEditor = ({ value, forwardedRef, extensions = [], editorProps = {} }: CustomReadOnlyEditorProps) => {
export const useReadOnlyEditor = ({
value,
forwardedRef,
extensions = [],
editorProps = {},
}: CustomReadOnlyEditorProps) => {
const editor = useCustomEditor({
editable: false,
content: (typeof value === "string" && value.trim() !== "") ? value : "<p></p>",
content:
typeof value === "string" && value.trim() !== "" ? value : "<p></p>",
editorProps: {
...CoreReadOnlyEditorProps,
...editorProps,
@@ -22,6 +33,14 @@ export const useReadOnlyEditor = ({ value, forwardedRef, extensions = [], editor
extensions: [...CoreReadOnlyEditorExtensions, ...extensions],
});
const hasIntiliazedContent = useRef(false);
useEffect(() => {
if (editor && !value && !hasIntiliazedContent.current) {
editor.commands.setContent(value);
hasIntiliazedContent.current = true;
}
}, [value]);
const editorRef: MutableRefObject<Editor | null> = useRef(null);
editorRef.current = editor;
@@ -34,7 +53,6 @@ export const useReadOnlyEditor = ({ value, forwardedRef, extensions = [], editor
},
}));
if (!editor) {
return null;
}
@@ -16,7 +16,7 @@ const TrackImageDeletionPlugin = (deleteImage: DeleteImage): Plugin =>
new Plugin({
key: deleteKey,
appendTransaction: (transactions: readonly Transaction[], oldState: EditorState, newState: EditorState) => {
const newImageSources = new Set();
const newImageSources = new Set<string>();
newState.doc.descendants((node) => {
if (node.type.name === IMAGE_NODE_TYPE) {
newImageSources.add(node.attrs.src);
@@ -1,9 +0,0 @@
import ListItem from '@tiptap/extension-list-item'
export const CustomListItem = ListItem.extend({
addKeyboardShortcuts() {
return {
'Shift-Enter': () => this.editor.chain().focus().splitListItem('listItem').run(),
}
},
})
@@ -1,16 +1,25 @@
import { Extension } from '@tiptap/core';
import { Extension } from "@tiptap/core";
export const EnterKeyExtension = (onEnterKeyPress?: () => void) => Extension.create({
name: 'enterKey',
export const EnterKeyExtension = (onEnterKeyPress?: () => void) =>
Extension.create({
name: "enterKey",
addKeyboardShortcuts() {
return {
'Enter': () => {
if (onEnterKeyPress) {
onEnterKeyPress();
}
return true;
},
}
},
});
addKeyboardShortcuts() {
return {
Enter: () => {
if (onEnterKeyPress) {
onEnterKeyPress();
}
return true;
},
"Shift-Enter": ({ editor }) =>
editor.commands.first(({ commands }) => [
() => commands.newlineInCode(),
() => commands.splitListItem("listItem"),
() => commands.createParagraphNear(),
() => commands.liftEmptyBlock(),
() => commands.splitBlock(),
]),
};
},
});
@@ -1,7 +1,5 @@
import { CustomListItem } from "./custom-list-extension";
import { EnterKeyExtension } from "./enter-key-extension";
export const LiteTextEditorExtensions = (onEnterKeyPress?: () => void) => [
CustomListItem,
EnterKeyExtension(onEnterKeyPress),
];
@@ -213,7 +213,9 @@ module.exports = {
},
},
}),
screens: {
"3xl": "1792px",
},
// scale down font sizes to 90% of default
fontSize: {
xs: "0.675rem",
+3 -3
View File
@@ -10,13 +10,13 @@
"dist/**"
],
"scripts": {
"build": "tsup src/index.tsx --format esm,cjs --dts --external react",
"dev": "tsup src/index.tsx --format esm,cjs --watch --dts --external react",
"build": "tsup src/index.ts --format esm,cjs --dts --external react",
"dev": "tsup src/index.ts --format esm,cjs --watch --dts --external react",
"lint": "eslint src/",
"clean": "rm -rf .turbo && rm -rf node_modules && rm -rf dist"
},
"devDependencies": {
"@types/react-color" : "^3.0.9",
"@types/react-color": "^3.0.9",
"@types/node": "^20.5.2",
"@types/react": "18.2.0",
"@types/react-dom": "18.2.0",
+85
View File
@@ -0,0 +1,85 @@
import React from "react";
// ui
import { Tooltip } from "../tooltip";
// types
import { TAvatarSize, getSizeInfo, isAValidNumber } from "./avatar";
type Props = {
/**
* The children of the avatar group.
* These should ideally should be `Avatar` components
*/
children: React.ReactNode;
/**
* The maximum number of avatars to display.
* If the number of children exceeds this value, the additional avatars will be replaced by a count of the remaining avatars.
* @default 2
*/
max?: number;
/**
* Whether to show the tooltip or not
* @default true
*/
showTooltip?: boolean;
/**
* The size of the avatars
* Possible values: "sm", "md", "base", "lg"
* @default "md"
*/
size?: TAvatarSize;
};
export const AvatarGroup: React.FC<Props> = (props) => {
const { children, max = 2, showTooltip = true, size = "md" } = props;
// calculate total length of avatars inside the group
const totalAvatars = React.Children.toArray(children).length;
// slice the children to the maximum number of avatars
const avatars = React.Children.toArray(children).slice(0, max);
// assign the necessary props from the AvatarGroup component to the Avatar components
const avatarsWithUpdatedProps = avatars.map((avatar) => {
const updatedProps: Partial<Props> = {
showTooltip,
size,
};
return React.cloneElement(avatar as React.ReactElement, updatedProps);
});
// get size details based on the size prop
const sizeInfo = getSizeInfo(size);
return (
<div className={`flex ${sizeInfo.spacing}`}>
{avatarsWithUpdatedProps.map((avatar, index) => (
<div key={index} className="ring-1 ring-custom-border-200 rounded-full">
{avatar}
</div>
))}
{max < totalAvatars && (
<Tooltip
tooltipContent={`${totalAvatars} total`}
disabled={!showTooltip}
>
<div
className={`${
!isAValidNumber(size) ? sizeInfo.avatarSize : ""
} ring-1 ring-custom-border-200 bg-custom-primary-500 text-white rounded-full grid place-items-center text-[9px]`}
style={
isAValidNumber(size)
? {
width: `${size}px`,
height: `${size}px`,
}
: {}
}
>
+{totalAvatars - max}
</div>
</Tooltip>
)}
</div>
);
};
+168
View File
@@ -0,0 +1,168 @@
import React from "react";
// ui
import { Tooltip } from "../tooltip";
export type TAvatarSize = "sm" | "md" | "base" | "lg" | number;
type Props = {
/**
* The name of the avatar which will be displayed on the tooltip
*/
name?: string;
/**
* The background color if the avatar image fails to load
*/
fallbackBackgroundColor?: string;
/**
* The text to display if the avatar image fails to load
*/
fallbackText?: string;
/**
* The text color if the avatar image fails to load
*/
fallbackTextColor?: string;
/**
* Whether to show the tooltip or not
* @default true
*/
showTooltip?: boolean;
/**
* The size of the avatars
* Possible values: "sm", "md", "base", "lg"
* @default "md"
*/
size?: TAvatarSize;
/**
* The shape of the avatar
* Possible values: "circle", "square"
* @default "circle"
*/
shape?: "circle" | "square";
/**
* The source of the avatar image
*/
src?: string;
};
/**
* Get the size details based on the size prop
* @param size The size of the avatar
* @returns The size details
*/
export const getSizeInfo = (size: TAvatarSize) => {
switch (size) {
case "sm":
return {
avatarSize: "h-4 w-4",
fontSize: "text-xs",
spacing: "-space-x-1",
};
case "md":
return {
avatarSize: "h-5 w-5",
fontSize: "text-xs",
spacing: "-space-x-1",
};
case "base":
return {
avatarSize: "h-6 w-6",
fontSize: "text-sm",
spacing: "-space-x-1.5",
};
case "lg":
return {
avatarSize: "h-7 w-7",
fontSize: "text-sm",
spacing: "-space-x-1.5",
};
default:
return {
avatarSize: "h-5 w-5",
fontSize: "text-xs",
spacing: "-space-x-1",
};
}
};
/**
* Get the border radius based on the shape prop
* @param shape The shape of the avatar
* @returns The border radius
*/
export const getBorderRadius = (shape: "circle" | "square") => {
switch (shape) {
case "circle":
return "rounded-full";
case "square":
return "rounded-md";
default:
return "rounded-full";
}
};
/**
* Check if the value is a valid number
* @param value The value to check
* @returns Whether the value is a valid number or not
*/
export const isAValidNumber = (value: any) => {
return typeof value === "number" && !isNaN(value);
};
export const Avatar: React.FC<Props> = (props) => {
const {
name,
fallbackBackgroundColor,
fallbackText,
fallbackTextColor,
showTooltip = true,
size = "md",
shape = "circle",
src,
} = props;
// get size details based on the size prop
const sizeInfo = getSizeInfo(size);
return (
<Tooltip
tooltipContent={fallbackText ?? name ?? "?"}
disabled={!showTooltip}
>
<div
className={`${
!isAValidNumber(size) ? sizeInfo.avatarSize : ""
} overflow-hidden grid place-items-center ${getBorderRadius(shape)}`}
style={
isAValidNumber(size)
? {
height: `${size}px`,
width: `${size}px`,
}
: {}
}
>
{src ? (
<img
src={src}
className={`h-full w-full ${getBorderRadius(shape)}`}
alt={name}
/>
) : (
<div
className={`${
sizeInfo.fontSize
} grid place-items-center h-full w-full ${getBorderRadius(shape)}`}
style={{
backgroundColor:
fallbackBackgroundColor ?? "rgba(var(--color-primary-500))",
color: fallbackTextColor ?? "#ffffff",
}}
>
{name ? name[0].toUpperCase() : fallbackText ?? "?"}
</div>
)}
</div>
</Tooltip>
);
};
+2
View File
@@ -0,0 +1,2 @@
export * from "./avatar-group";
export * from "./avatar";
+1
View File
@@ -1,3 +1,4 @@
// FIXME: fix this!!!
import { Placement } from "@blueprintjs/popover2";
export interface IDropdownProps {
@@ -11,12 +11,14 @@ export interface InputColorPickerProps {
value: string | undefined;
onChange: (value: string) => void;
name: string;
className: string;
className?: string;
style?: React.CSSProperties;
placeholder: string;
}
export const InputColorPicker: React.FC<InputColorPickerProps> = (props) => {
const { value, hasError, onChange, name, className, placeholder } = props;
const { value, hasError, onChange, name, className, style, placeholder } =
props;
const [referenceElement, setReferenceElement] =
React.useState<HTMLButtonElement | null>(null);
@@ -32,12 +34,12 @@ export const InputColorPicker: React.FC<InputColorPickerProps> = (props) => {
onChange(hex);
};
const handleInputChange = (value: any) => {
onChange(value);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
onChange(e.target.value);
};
return (
<div className="flex items-center justify-between rounded border border-custom-border-200 px-1">
<div className="relative">
<Input
id={name}
name={name}
@@ -46,10 +48,14 @@ export const InputColorPicker: React.FC<InputColorPickerProps> = (props) => {
onChange={handleInputChange}
hasError={hasError}
placeholder={placeholder}
className={`border-none ${className}`}
className={`border-[0.5px] border-custom-border-200 ${className}`}
style={style}
/>
<Popover as="div">
<Popover
as="div"
className="absolute top-1/2 -translate-y-1/2 right-1 z-10"
>
{({ open }) => {
if (open) {
}
@@ -60,26 +66,26 @@ export const InputColorPicker: React.FC<InputColorPickerProps> = (props) => {
ref={setReferenceElement}
variant="neutral-primary"
size="sm"
className="border-none !p-1.5"
className="border-none !bg-transparent"
>
{value && value !== "" ? (
<span
className="h-3.5 w-3.5 rounded"
style={{
backgroundColor: `${value}`,
}}
/>
) : (
<svg
width={14}
height={14}
viewBox="0 0 14 14"
stroke="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M0.8125 13.7508C0.65 13.7508 0.515625 13.6977 0.409375 13.5914C0.303125 13.4852 0.25 13.3508 0.25 13.1883V10.8258C0.25 10.7508 0.2625 10.682 0.2875 10.6195C0.3125 10.557 0.35625 10.4945 0.41875 10.432L7.31875 3.53203L6.34375 2.55703C6.24375 2.45703 6.19688 2.32891 6.20312 2.17266C6.20938 2.01641 6.2625 1.88828 6.3625 1.78828C6.4625 1.68828 6.59063 1.63828 6.74688 1.63828C6.90313 1.63828 7.03125 1.68828 7.13125 1.78828L8.4625 3.13828L11.125 0.475781C11.2625 0.338281 11.4094 0.269531 11.5656 0.269531C11.7219 0.269531 11.8688 0.338281 12.0063 0.475781L13.525 1.99453C13.6625 2.13203 13.7313 2.27891 13.7313 2.43516C13.7313 2.59141 13.6625 2.73828 13.525 2.87578L10.8625 5.53828L12.2125 6.88828C12.3125 6.98828 12.3625 7.11328 12.3625 7.26328C12.3625 7.41328 12.3125 7.53828 12.2125 7.63828C12.1125 7.73828 11.9844 7.78828 11.8281 7.78828C11.6719 7.78828 11.5438 7.73828 11.4438 7.63828L10.4688 6.68203L3.56875 13.582C3.50625 13.6445 3.44375 13.6883 3.38125 13.7133C3.31875 13.7383 3.25 13.7508 3.175 13.7508H0.8125ZM1.375 12.6258H3.00625L9.6625 5.96953L8.03125 4.33828L1.375 10.9945V12.6258ZM10.0563 4.75078L12.3813 2.42578L11.575 1.61953L9.25 3.94453L10.0563 4.75078Z" />
</svg>
)}
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
className="lucide lucide-palette"
>
<circle cx="13.5" cy="6.5" r=".5" />
<circle cx="17.5" cy="10.5" r=".5" />
<circle cx="8.5" cy="7.5" r=".5" />
<circle cx="6.5" cy="12.5" r=".5" />
<path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.926 0 1.648-.746 1.648-1.688 0-.437-.18-.835-.437-1.125-.29-.289-.438-.652-.438-1.125a1.64 1.64 0 0 1 1.668-1.668h1.996c3.051 0 5.555-2.503 5.555-5.554C21.965 6.012 17.461 2 12 2z" />
</svg>
</Button>
</Popover.Button>
<Transition
@@ -0,0 +1,20 @@
import * as React from "react";
import { ISvgIcons } from "../type";
export const CircleDotFullIcon: React.FC<ISvgIcons> = ({
className = "text-current",
...rest
}) => (
<svg
viewBox="0 0 24 24"
className={`${className} stroke-2`}
stroke="currentColor"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...rest}
>
<circle cx="8.33333" cy="8.33333" r="5.33333" stroke-linecap="round" />
<circle cx="8.33333" cy="8.33333" r="4.33333" fill="currentColor" />
</svg>
);
@@ -1,6 +1,6 @@
import * as React from "react";
import { ISvgIcons } from "./type";
import { ISvgIcons } from "../type";
export const ContrastIcon: React.FC<ISvgIcons> = ({
className = "text-current",
@@ -0,0 +1,33 @@
import * as React from "react";
import { ContrastIcon } from "./contrast-icon";
import { CircleDotFullIcon } from "./circle-dot-full-icon";
import { CircleDotDashed, Circle } from "lucide-react";
import { CYCLE_GROUP_COLORS, ICycleGroupIcon } from "./helper";
const iconComponents = {
current: ContrastIcon,
upcoming: CircleDotDashed,
completed: CircleDotFullIcon,
draft: Circle,
};
export const CycleGroupIcon: React.FC<ICycleGroupIcon> = ({
className = "",
color,
cycleGroup,
height = "12px",
width = "12px",
}) => {
const CycleIconComponent = iconComponents[cycleGroup] || ContrastIcon;
return (
<CycleIconComponent
height={height}
width={width}
color={color ?? CYCLE_GROUP_COLORS[cycleGroup]}
className={`flex-shrink-0 ${className}`}
/>
);
};
@@ -1,6 +1,6 @@
import * as React from "react";
import { ISvgIcons } from "./type";
import { ISvgIcons } from "../type";
export const DoubleCircleIcon: React.FC<ISvgIcons> = ({
className = "text-current",
+18
View File
@@ -0,0 +1,18 @@
export interface ICycleGroupIcon {
className?: string;
color?: string;
cycleGroup: TCycleGroups;
height?: string;
width?: string;
}
export type TCycleGroups = "current" | "upcoming" | "completed" | "draft";
export const CYCLE_GROUP_COLORS: {
[key in TCycleGroups]: string;
} = {
current: "#F59E0B",
upcoming: "#3F76FF",
completed: "#16A34A",
draft: "#525252",
};
+5
View File
@@ -0,0 +1,5 @@
export * from "./double-circle-icon";
export * from "./circle-dot-full-icon";
export * from "./contrast-icon";
export * from "./circle-dot-full-icon";
export * from "./cycle-group-icon";
+1 -2
View File
@@ -1,5 +1,4 @@
export * from "./user-group-icon";
export * from "./contrast-icon";
export * from "./dice-icon";
export * from "./layers-icon";
export * from "./photo-filter-icon";
@@ -7,7 +6,6 @@ export * from "./archive-icon";
export * from "./admin-profile-icon";
export * from "./create-icon";
export * from "./subscribe-icon";
export * from "./double-circle-icon";
export * from "./external-link-icon";
export * from "./copy-icon";
export * from "./layer-stack";
@@ -20,6 +18,7 @@ export * from "./blocked-icon";
export * from "./blocker-icon";
export * from "./related-icon";
export * from "./module";
export * from "./cycle";
export * from "./github-icon";
export * from "./discord-icon";
export * from "./transfer-icon";
@@ -1,9 +1,10 @@
export * from "./avatar";
export * from "./breadcrumbs";
export * from "./button";
export * from "./dropdowns";
export * from "./form-fields";
export * from "./icons";
export * from "./progress";
export * from "./spinners";
export * from "./loader";
export * from "./tooltip";
export * from "./icons";
export * from "./breadcrumbs";
export * from "./dropdowns";
export * from "./loader";
@@ -0,0 +1,102 @@
import React, { Children } from "react";
interface ICircularProgressIndicator {
size: number;
percentage: number;
strokeWidth?: number;
strokeColor?: string;
children?: React.ReactNode;
}
export const CircularProgressIndicator: React.FC<ICircularProgressIndicator> = (
props
) => {
const { size = 40, percentage = 25, strokeWidth = 6, children } = props;
const sqSize = size;
const radius = (size - strokeWidth) / 2;
const viewBox = `0 0 ${sqSize} ${sqSize}`;
const dashArray = radius * Math.PI * 2;
const dashOffset = dashArray - (dashArray * percentage) / 100;
return (
<div className="relative">
<svg width={size} height={size} viewBox={viewBox} fill="none">
<circle
className="fill-none stroke-custom-background-80"
cx={size / 2}
cy={size / 2}
r={radius}
strokeWidth={`${strokeWidth}px`}
style={{ filter: "url(#filter0_bi_377_19141)" }}
/>
<defs>
<filter
id="filter0_bi_377_19141"
x="-3.57544"
y="-3.57422"
width="45.2227"
height="45.2227"
filterUnits="userSpaceOnUse"
color-interpolation-filters="sRGB"
>
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feGaussianBlur in="BackgroundImageFix" stdDeviation="2" />
<feComposite
in2="SourceAlpha"
operator="in"
result="effect1_backgroundBlur_377_19141"
/>
<feBlend
mode="normal"
in="SourceGraphic"
in2="effect1_backgroundBlur_377_19141"
result="shape"
/>
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset dx="1" dy="1" />
<feGaussianBlur stdDeviation="2" />
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
<feColorMatrix
type="matrix"
values="0 0 0 0 0.63125 0 0 0 0 0.6625 0 0 0 0 0.75 0 0 0 0.35 0"
/>
<feBlend
mode="normal"
in2="shape"
result="effect2_innerShadow_377_19141"
/>
</filter>
</defs>
<circle
className="stroke-custom-primary-100 fill-none "
cx={size / 2}
cy={size / 2}
r={radius}
strokeWidth={`${strokeWidth}px`}
transform={`rotate(-90 ${size / 2} ${size / 2})`}
style={{
strokeDasharray: dashArray,
strokeDashoffset: dashOffset,
}}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
<div
className="absolute"
style={{
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
}}
>
{children}
</div>
</div>
);
};
+1
View File
@@ -1,3 +1,4 @@
export * from "./radial-progress";
export * from "./progress-bar";
export * from "./linear-progress-indicator";
export * from "./circular-progress-indicator";
+3
View File
@@ -8,6 +8,9 @@ const nextConfig = {
experimental: {
outputFileTracingRoot: path.join(__dirname, "../"),
},
images: {
unoptimized: true,
},
output: "standalone",
};
+4 -3
View File
@@ -7,7 +7,8 @@
"develop": "next dev -p 4000",
"build": "next build",
"start": "next start -p 4000",
"lint": "next lint"
"lint": "next lint",
"export": "next export"
},
"dependencies": {
"@blueprintjs/core": "^4.16.3",
@@ -16,8 +17,8 @@
"@emotion/styled": "^11.11.0",
"@headlessui/react": "^1.7.13",
"@mui/material": "^5.14.1",
"@plane/ui" : "*",
"@plane/lite-text-editor" : "*",
"@plane/ui": "*",
"@plane/lite-text-editor": "*",
"@plane/rich-text-editor": "*",
"axios": "^1.3.4",
"clsx": "^2.0.0",
@@ -144,7 +144,7 @@ export const CommandModal: React.FC<Props> = (props) => {
} else {
updatedAssignees.push(assignee);
}
updateIssue({ assignees_list: updatedAssignees });
updateIssue({ assignees: updatedAssignees });
};
const redirect = (path: string) => {
@@ -9,7 +9,7 @@ import useProjectMembers from "hooks/use-project-members";
// constants
import { ISSUE_DETAILS, PROJECT_ISSUES_ACTIVITY } from "constants/fetch-keys";
// ui
import { Avatar } from "components/ui";
import { Avatar } from "@plane/ui";
// icons
import { Check } from "lucide-react";
// types
@@ -31,13 +31,13 @@ export const ChangeIssueAssignee: FC<Props> = ({ setIsPaletteOpen, issue, user }
const { members } = useProjectMembers(workspaceSlug as string, projectId as string);
const options =
members?.map(({ member }: any) => ({
members?.map(({ member }) => ({
value: member.id,
query: member.display_name,
content: (
<>
<div className="flex items-center gap-2">
<Avatar user={member} />
<Avatar name={member.display_name} src={member.avatar} showTooltip={false} />
{member.display_name}
</div>
{issue.assignees.includes(member.id) && (
@@ -79,7 +79,7 @@ export const ChangeIssueAssignee: FC<Props> = ({ setIsPaletteOpen, issue, user }
);
const handleIssueAssignees = (assignee: string) => {
const updatedAssignees = issue.assignees_list ?? [];
const updatedAssignees = issue.assignees ?? [];
if (updatedAssignees.includes(assignee)) {
updatedAssignees.splice(updatedAssignees.indexOf(assignee), 1);
@@ -87,7 +87,7 @@ export const ChangeIssueAssignee: FC<Props> = ({ setIsPaletteOpen, issue, user }
updatedAssignees.push(assignee);
}
updateIssue({ assignees_list: updatedAssignees });
updateIssue({ assignees: updatedAssignees });
setIsPaletteOpen(false);
};
+4 -8
View File
@@ -1,18 +1,14 @@
import React from "react";
// icons
import { PriorityIcon, StateGroupIcon } from "@plane/ui";
import { X } from "lucide-react";
// ui
import { Avatar } from "components/ui";
import { Avatar, PriorityIcon, StateGroupIcon } from "@plane/ui";
// helpers
import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper";
// helpers
import { renderShortDateWithYearFormat } from "helpers/date-time.helper";
// types
import { IIssueFilterOptions, IIssueLabels, IState, IUserLite, TStateGroups } from "types";
// constants
import { STATE_GROUP_COLORS } from "constants/state";
import { X } from "lucide-react";
type Props = {
filters: Partial<IIssueFilterOptions>;
@@ -149,7 +145,7 @@ export const FiltersList: React.FC<Props> = ({ filters, setFilters, clearAllFilt
key={memberId}
className="inline-flex items-center gap-x-1 rounded-full bg-custom-background-90 px-1"
>
<Avatar user={member} />
<Avatar name={member?.display_name} src={member?.avatar} showTooltip={false} />
<span>{member?.display_name}</span>
<span
className="cursor-pointer"
@@ -173,7 +169,7 @@ export const FiltersList: React.FC<Props> = ({ filters, setFilters, clearAllFilt
key={`${memberId}-${key}`}
className="inline-flex items-center gap-x-1 rounded-full bg-custom-background-90 px-1 capitalize"
>
<Avatar user={member} />
<Avatar name={member?.display_name} src={member?.avatar} />
<span>{member?.display_name}</span>
<span
className="cursor-pointer"
-1
View File
@@ -1,4 +1,3 @@
export * from "./date-filter-modal";
export * from "./date-filter-select";
export * from "./filters-list";
export * from "./workspace-filters-list";
@@ -1,347 +0,0 @@
import { FC } from "react";
// icons
import { X } from "lucide-react";
import { PriorityIcon, StateGroupIcon } from "@plane/ui";
// ui
import { Avatar } from "components/ui";
// helpers
import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper";
// helpers
import { renderShortDateWithYearFormat } from "helpers/date-time.helper";
// types
import { IIssueLabels, IProject, IUserLite, IWorkspaceIssueFilterOptions, TStateGroups } from "types";
// constants
import { STATE_GROUP_COLORS } from "constants/state";
type Props = {
filters: Partial<IWorkspaceIssueFilterOptions>;
setFilters: (updatedFilter: Partial<IWorkspaceIssueFilterOptions>) => void;
clearAllFilters: (...args: any) => void;
labels: IIssueLabels[] | undefined;
members: IUserLite[] | undefined;
stateGroup: string[] | undefined;
project?: IProject[] | undefined;
};
export const WorkspaceFiltersList: FC<Props> = (props) => {
const { filters, setFilters, clearAllFilters, labels, members, project } = props;
if (!filters) return <></>;
const nullFilters = Object.keys(filters).filter((key) => filters[key as keyof IWorkspaceIssueFilterOptions] === null);
return (
<div className="flex flex-1 flex-wrap items-center gap-2 text-xs">
{Object.keys(filters).map((filterKey) => {
const key = filterKey as keyof typeof filters;
if (filters[key] === null || (filters[key]?.length ?? 0) <= 0) return null;
return (
<div
key={key}
className="flex items-center gap-x-2 rounded-full border border-custom-border-200 bg-custom-background-80 px-2 py-1"
>
<span className="capitalize text-custom-text-200">
{key === "target_date" ? "Due Date" : replaceUnderscoreIfSnakeCase(key)}:
</span>
{filters[key] === null || (filters[key]?.length ?? 0) <= 0 ? (
<span className="inline-flex items-center px-2 py-0.5 font-medium">None</span>
) : Array.isArray(filters[key]) ? (
<div className="space-x-2">
<div className="flex flex-wrap items-center gap-1">
{key === "state_group"
? filters.state_group?.map((stateGroup) => {
const group = stateGroup as TStateGroups;
return (
<p
key={group}
className="inline-flex items-center gap-x-1 rounded-full px-2 py-0.5 capitalize"
style={{
color: STATE_GROUP_COLORS[group],
backgroundColor: `${STATE_GROUP_COLORS[group]}20`,
}}
>
<span>
<StateGroupIcon stateGroup={group} color={undefined} />
</span>
<span>{group}</span>
<span
className="cursor-pointer"
onClick={() =>
setFilters({
state_group: filters.state_group?.filter((g) => g !== group),
})
}
>
<X className="h-3 w-3" />
</span>
</p>
);
})
: key === "priority"
? filters.priority?.map((priority: any) => (
<p
key={priority}
className={`inline-flex items-center gap-x-1 rounded-full px-2 py-0.5 capitalize ${
priority === "urgent"
? "bg-red-500/20 text-red-500"
: priority === "high"
? "bg-orange-500/20 text-orange-500"
: priority === "medium"
? "bg-yellow-500/20 text-yellow-500"
: priority === "low"
? "bg-green-500/20 text-green-500"
: "bg-custom-background-90 text-custom-text-200"
}`}
>
<span>
<PriorityIcon priority={priority} />
</span>
<span>{priority === "null" ? "None" : priority}</span>
<span
className="cursor-pointer"
onClick={() =>
setFilters({
priority: filters.priority?.filter((p: any) => p !== priority),
})
}
>
<X className="h-3 w-3" />
</span>
</p>
))
: key === "assignees"
? filters.assignees?.map((memberId: string) => {
const member = members?.find((m) => m.id === memberId);
return (
<div
key={memberId}
className="inline-flex items-center gap-x-1 rounded-full bg-custom-background-90 px-1"
>
<Avatar user={member} />
<span>{member?.display_name}</span>
<span
className="cursor-pointer"
onClick={() =>
setFilters({
assignees: filters.assignees?.filter((p: any) => p !== memberId),
})
}
>
<X className="h-3 w-3" />
</span>
</div>
);
})
: key === "subscriber"
? filters.subscriber?.map((memberId: string) => {
const member = members?.find((m) => m.id === memberId);
return (
<div
key={memberId}
className="inline-flex items-center gap-x-1 rounded-full bg-custom-background-90 px-1"
>
<Avatar user={member} />
<span>{member?.display_name}</span>
<span
className="cursor-pointer"
onClick={() =>
setFilters({
assignees: filters.assignees?.filter((p: any) => p !== memberId),
})
}
>
<X className="h-3 w-3" />
</span>
</div>
);
})
: key === "created_by"
? filters.created_by?.map((memberId: string) => {
const member = members?.find((m) => m.id === memberId);
return (
<div
key={`${memberId}-${key}`}
className="inline-flex items-center gap-x-1 rounded-full bg-custom-background-90 px-1 capitalize"
>
<Avatar user={member} />
<span>{member?.display_name}</span>
<span
className="cursor-pointer"
onClick={() =>
setFilters({
created_by: filters.created_by?.filter((p: any) => p !== memberId),
})
}
>
<X className="h-3 w-3" />
</span>
</div>
);
})
: key === "labels"
? filters.labels?.map((labelId: string) => {
const label = labels?.find((l) => l.id === labelId);
if (!label) return null;
const color = label.color !== "" ? label.color : "#0f172a";
return (
<div
className="inline-flex items-center gap-x-1 rounded-full px-2 py-0.5"
style={{
color: color,
backgroundColor: `${color}20`, // add 20% opacity
}}
key={labelId}
>
<div
className="h-1.5 w-1.5 rounded-full"
style={{
backgroundColor: color,
}}
/>
<span>{label.name}</span>
<span
className="cursor-pointer"
onClick={() =>
setFilters({
labels: filters.labels?.filter((l: any) => l !== labelId),
})
}
>
<X
className="h-3 w-3"
style={{
color: color,
}}
/>
</span>
</div>
);
})
: key === "start_date"
? filters.start_date?.map((date: string) => {
if (filters.start_date && filters.start_date.length <= 0) return null;
const splitDate = date.split(";");
return (
<div
key={date}
className="inline-flex items-center gap-x-1 rounded-full border border-custom-border-200 bg-custom-background-100 px-1 py-0.5"
>
<div className="h-1.5 w-1.5 rounded-full" />
<span className="capitalize">
{splitDate[1]} {renderShortDateWithYearFormat(splitDate[0])}
</span>
<span
className="cursor-pointer"
onClick={() =>
setFilters({
start_date: filters.start_date?.filter((d: any) => d !== date),
})
}
>
<X className="h-3 w-3" />
</span>
</div>
);
})
: key === "target_date"
? filters.target_date?.map((date: string) => {
if (filters.target_date && filters.target_date.length <= 0) return null;
const splitDate = date.split(";");
return (
<div
key={date}
className="inline-flex items-center gap-x-1 rounded-full border border-custom-border-200 bg-custom-background-100 px-1 py-0.5"
>
<div className="h-1.5 w-1.5 rounded-full" />
<span className="capitalize">
{splitDate[1]} {renderShortDateWithYearFormat(splitDate[0])}
</span>
<span
className="cursor-pointer"
onClick={() =>
setFilters({
target_date: filters.target_date?.filter((d: any) => d !== date),
})
}
>
<X className="h-3 w-3" />
</span>
</div>
);
})
: key === "project"
? filters.project?.map((projectId) => {
const currentProject = project?.find((p) => p.id === projectId);
return (
<p
key={currentProject?.id}
className="inline-flex items-center gap-x-1 rounded-full px-2 py-0.5 capitalize"
>
<span>{currentProject?.name}</span>
<span
className="cursor-pointer"
onClick={() =>
setFilters({
project: filters.project?.filter((p) => p !== projectId),
})
}
>
<X className="h-3 w-3" />
</span>
</p>
);
})
: (filters[key] as any)?.join(", ")}
<button
type="button"
onClick={() =>
setFilters({
[key]: null,
})
}
>
<X className="h-3 w-3" />
</button>
</div>
</div>
) : (
<div className="flex items-center gap-x-1 capitalize">
{filters[key as keyof typeof filters]}
<button
type="button"
onClick={() =>
setFilters({
[key]: null,
})
}
>
<X className="h-3 w-3" />
</button>
</div>
)}
</div>
);
})}
{Object.keys(filters).length > 0 && nullFilters.length !== Object.keys(filters).length && (
<button
type="button"
onClick={clearAllFilters}
className="flex items-center gap-x-1 rounded-full border border-custom-border-200 bg-custom-background-80 px-3 py-1.5 text-xs"
>
<span>Clear all filters</span>
<X className="h-3 w-3" />
</button>
)}
</div>
);
};
+53 -48
View File
@@ -15,58 +15,63 @@ type Props = {
export const LinksList: React.FC<Props> = ({ links, handleDeleteLink, handleEditLink, userAuth }) => {
const isNotAllowed = userAuth.isGuest || userAuth.isViewer;
return (
<>
{links.map((link) => (
<div key={link.id} className="relative">
{!isNotAllowed && (
<div className="absolute top-1.5 right-1.5 z-[1] flex items-center gap-1">
<button
type="button"
className="grid h-7 w-7 place-items-center rounded bg-custom-background-90 p-1 outline-none hover:bg-custom-background-80"
onClick={() => handleEditLink(link)}
>
<Pencil className="text-custom-text-200" />
</button>
<a
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="grid h-7 w-7 place-items-center rounded bg-custom-background-90 p-1 outline-none hover:bg-custom-background-80"
>
<ExternalLinkIcon className="h-4 w-4 text-custom-text-200" />
</a>
<button
type="button"
className="grid h-7 w-7 place-items-center rounded bg-custom-background-90 p-1 text-red-500 outline-none duration-300 hover:bg-red-500/20"
onClick={() => handleDeleteLink(link.id)}
>
<Trash2 className="h-4 w-4" />
</button>
<div key={link.id} className="relative flex flex-col rounded-md bg-custom-background-90 p-2.5">
<div className="flex items-start justify-between gap-2 w-full">
<div className="flex items-start gap-2">
<span className="py-1">
<LinkIcon className="h-3 w-3 flex-shrink-0" />
</span>
<span className="text-xs break-all">{link.title && link.title !== "" ? link.title : link.url}</span>
</div>
)}
<a
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="relative flex gap-2 rounded-md bg-custom-background-90 p-2"
>
<div className="mt-0.5">
<LinkIcon className="h-3.5 w-3.5" />
</div>
<div>
<h5 className="w-4/5 break-words">{link.title ?? link.url}</h5>
<p className="mt-0.5 text-custom-text-200">
Added {timeAgo(link.created_at)}
<br />
by{" "}
{link.created_by_detail.is_bot
? link.created_by_detail.first_name + " Bot"
: link.created_by_detail.display_name}
</p>
</div>
</a>
{!isNotAllowed && (
<div className="flex items-center gap-2 flex-shrink-0 z-[1]">
<button
type="button"
className="flex items-center justify-center p-1 hover:bg-custom-background-80"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleEditLink(link);
}}
>
<Pencil className="h-3 w-3 text-custom-text-200 stroke-[1.5]" />
</button>
<a
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center p-1 hover:bg-custom-background-80"
>
<ExternalLinkIcon className="h-3 w-3 text-custom-text-200 stroke-[1.5]" />
</a>
<button
type="button"
className="flex items-center justify-center p-1 hover:bg-custom-background-80"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleDeleteLink(link.id);
}}
>
<Trash2 className="h-3 w-3" />
</button>
</div>
)}
</div>
<div className="px-5">
<p className="text-xs mt-0.5 text-custom-text-300 stroke-[1.5]">
Added {timeAgo(link.created_at)}
<br />
by{" "}
{link.created_by_detail.is_bot
? link.created_by_detail.first_name + " Bot"
: link.created_by_detail.display_name}
</p>
</div>
</div>
))}
</>
@@ -1,14 +1,18 @@
import React from "react";
import Image from "next/image";
// headless ui
import { Tab } from "@headlessui/react";
// hooks
import useLocalStorage from "hooks/use-local-storage";
import useIssuesView from "hooks/use-issues-view";
// images
import emptyLabel from "public/empty-state/empty_label.svg";
import emptyMembers from "public/empty-state/empty_members.svg";
// components
import { SingleProgressStats } from "components/core";
// ui
import { Avatar } from "components/ui";
import { Avatar, StateGroupIcon } from "@plane/ui";
// types
import {
IModule,
@@ -17,9 +21,7 @@ import {
TLabelsDistribution,
TStateGroups,
} from "types";
// constants
import { STATE_GROUP_COLORS } from "constants/state";
// types
type Props = {
distribution: {
assignees: TAssigneesDistribution[];
@@ -33,6 +35,7 @@ type Props = {
module?: IModule;
roundedTab?: boolean;
noBackground?: boolean;
isPeekView?: boolean;
};
export const SidebarProgressStats: React.FC<Props> = ({
@@ -42,6 +45,7 @@ export const SidebarProgressStats: React.FC<Props> = ({
module,
roundedTab,
noBackground,
isPeekView = false,
}) => {
const { filters, setFilters } = useIssuesView();
@@ -55,7 +59,6 @@ export const SidebarProgressStats: React.FC<Props> = ({
return 1;
case "States":
return 2;
default:
return 0;
}
@@ -72,7 +75,6 @@ export const SidebarProgressStats: React.FC<Props> = ({
return setTab("Labels");
case 2:
return setTab("States");
default:
return setTab("Assignees");
}
@@ -82,15 +84,17 @@ export const SidebarProgressStats: React.FC<Props> = ({
as="div"
className={`flex w-full items-center gap-2 justify-between rounded-md ${
noBackground ? "" : "bg-custom-background-90"
} px-1 py-1.5
${module ? "text-xs" : "text-sm"} `}
} p-0.5
${module ? "text-xs" : "text-sm"}`}
>
<Tab
className={({ selected }) =>
`w-full ${
roundedTab ? "rounded-3xl border border-custom-border-200" : "rounded"
} px-3 py-1 text-custom-text-100 ${
selected ? " bg-custom-primary text-white" : " hover:bg-custom-background-80"
selected
? "bg-custom-background-100 text-custom-text-300 shadow-custom-shadow-2xs"
: "text-custom-text-400 hover:text-custom-text-300"
}`
}
>
@@ -101,7 +105,9 @@ export const SidebarProgressStats: React.FC<Props> = ({
`w-full ${
roundedTab ? "rounded-3xl border border-custom-border-200" : "rounded"
} px-3 py-1 text-custom-text-100 ${
selected ? " bg-custom-primary text-white" : " hover:bg-custom-background-80"
selected
? "bg-custom-background-100 text-custom-text-300 shadow-custom-shadow-2xs"
: "text-custom-text-400 hover:text-custom-text-300"
}`
}
>
@@ -112,113 +118,118 @@ export const SidebarProgressStats: React.FC<Props> = ({
`w-full ${
roundedTab ? "rounded-3xl border border-custom-border-200" : "rounded"
} px-3 py-1 text-custom-text-100 ${
selected ? " bg-custom-primary text-white" : " hover:bg-custom-background-80"
selected
? "bg-custom-background-100 text-custom-text-300 shadow-custom-shadow-2xs"
: "text-custom-text-400 hover:text-custom-text-300"
}`
}
>
States
</Tab>
</Tab.List>
<Tab.Panels className="flex w-full items-center justify-between pt-1 text-custom-text-200">
<Tab.Panel as="div" className="w-full space-y-1">
{distribution.assignees.map((assignee, index) => {
if (assignee.assignee_id)
return (
<SingleProgressStats
key={assignee.assignee_id}
title={
<div className="flex items-center gap-2">
<Avatar
user={{
id: assignee.assignee_id,
avatar: assignee.avatar ?? "",
first_name: assignee.first_name ?? "",
last_name: assignee.last_name ?? "",
display_name: assignee.display_name ?? "",
}}
/>
<span>{assignee.display_name}</span>
</div>
}
completed={assignee.completed_issues}
total={assignee.total_issues}
onClick={() => {
if (filters?.assignees?.includes(assignee.assignee_id ?? ""))
setFilters({
assignees: filters?.assignees?.filter((a) => a !== assignee.assignee_id),
});
else
setFilters({
assignees: [...(filters?.assignees ?? []), assignee.assignee_id ?? ""],
});
}}
selected={filters?.assignees?.includes(assignee.assignee_id ?? "")}
/>
);
else
return (
<SingleProgressStats
key={`unassigned-${index}`}
title={
<div className="flex items-center gap-2">
<div className="h-5 w-5 rounded-full border-2 border-custom-border-200 bg-custom-background-80">
<img
src="/user.png"
height="100%"
width="100%"
className="rounded-full"
alt="User"
/>
<Tab.Panels className="flex w-full items-center justify-between text-custom-text-200">
<Tab.Panel as="div" className="flex flex-col gap-1.5 pt-3.5 w-full h-44 overflow-y-auto">
{distribution.assignees.length > 0 ? (
distribution.assignees.map((assignee, index) => {
if (assignee.assignee_id)
return (
<SingleProgressStats
key={assignee.assignee_id}
title={
<div className="flex items-center gap-2">
<Avatar name={assignee.display_name ?? undefined} src={assignee?.avatar ?? undefined} />
<span>{assignee.display_name}</span>
</div>
<span>No assignee</span>
</div>
}
completed={assignee.completed_issues}
total={assignee.total_issues}
/>
);
})}
</Tab.Panel>
<Tab.Panel as="div" className="w-full space-y-1">
{distribution.labels.map((label, index) => (
<SingleProgressStats
key={label.label_id ?? `no-label-${index}`}
title={
<div className="flex items-center gap-2">
<span
className="block h-3 w-3 rounded-full"
style={{
backgroundColor: label.color ?? "transparent",
}}
}
completed={assignee.completed_issues}
total={assignee.total_issues}
{...(!isPeekView && {
onClick: () => {
if (filters?.assignees?.includes(assignee.assignee_id ?? ""))
setFilters({
assignees: filters?.assignees?.filter((a) => a !== assignee.assignee_id),
});
else
setFilters({
assignees: [...(filters?.assignees ?? []), assignee.assignee_id ?? ""],
});
},
selected: filters?.assignees?.includes(assignee.assignee_id ?? ""),
})}
/>
<span className="text-xs">{label.label_name ?? "No labels"}</span>
</div>
}
completed={label.completed_issues}
total={label.total_issues}
onClick={() => {
if (filters.labels?.includes(label.label_id ?? ""))
setFilters({
labels: filters?.labels?.filter((l) => l !== label.label_id),
});
else setFilters({ labels: [...(filters?.labels ?? []), label.label_id ?? ""] });
}}
selected={filters?.labels?.includes(label.label_id ?? "")}
/>
))}
);
else
return (
<SingleProgressStats
key={`unassigned-${index}`}
title={
<div className="flex items-center gap-2">
<div className="h-4 w-4 rounded-full border-2 border-custom-border-200 bg-custom-background-80">
<img src="/user.png" height="100%" width="100%" className="rounded-full" alt="User" />
</div>
<span>No assignee</span>
</div>
}
completed={assignee.completed_issues}
total={assignee.total_issues}
/>
);
})
) : (
<div className="flex flex-col items-center justify-center gap-2 h-full">
<div className="flex items-center justify-center h-20 w-20 bg-custom-background-80 rounded-full">
<Image src={emptyMembers} className="h-12 w-12" alt="empty members" />
</div>
<h6 className="text-base text-custom-text-300">No assignees yet</h6>
</div>
)}
</Tab.Panel>
<Tab.Panel as="div" className="w-full space-y-1">
<Tab.Panel as="div" className="flex flex-col gap-1.5 pt-3.5 w-full h-44 overflow-y-auto">
{distribution.labels.length > 0 ? (
distribution.labels.map((label, index) => (
<SingleProgressStats
key={label.label_id ?? `no-label-${index}`}
title={
<div className="flex items-center gap-2">
<span
className="block h-3 w-3 rounded-full"
style={{
backgroundColor: label.color ?? "transparent",
}}
/>
<span className="text-xs">{label.label_name ?? "No labels"}</span>
</div>
}
completed={label.completed_issues}
total={label.total_issues}
{...(!isPeekView && {
onClick: () => {
if (filters.labels?.includes(label.label_id ?? ""))
setFilters({
labels: filters?.labels?.filter((l) => l !== label.label_id),
});
else setFilters({ labels: [...(filters?.labels ?? []), label.label_id ?? ""] });
},
selected: filters?.labels?.includes(label.label_id ?? ""),
})}
/>
))
) : (
<div className="flex flex-col items-center justify-center gap-2 h-full">
<div className="flex items-center justify-center h-20 w-20 bg-custom-background-80 rounded-full">
<Image src={emptyLabel} className="h-12 w-12" alt="empty label" />
</div>
<h6 className="text-base text-custom-text-300">No labels yet</h6>
</div>
)}
</Tab.Panel>
<Tab.Panel as="div" className="flex flex-col gap-1.5 pt-3.5 w-full h-44 overflow-y-auto">
{Object.keys(groupedIssues).map((group, index) => (
<SingleProgressStats
key={index}
title={
<div className="flex items-center gap-2">
<span
className="block h-3 w-3 rounded-full "
style={{
backgroundColor: STATE_GROUP_COLORS[group as TStateGroups],
}}
/>
<StateGroupIcon stateGroup={group as TStateGroups} />
<span className="text-xs capitalize">{group}</span>
</div>
}
@@ -1,6 +1,6 @@
import React from "react";
import { ProgressBar } from "@plane/ui";
import { CircularProgressIndicator } from "@plane/ui";
type TSingleProgressStatsProps = {
title: any;
@@ -27,7 +27,7 @@ export const SingleProgressStats: React.FC<TSingleProgressStatsProps> = ({
<div className="flex w-1/2 items-center justify-end gap-1 px-2">
<div className="flex h-5 items-center justify-center gap-1">
<span className="h-4 w-4">
<ProgressBar value={completed} maxValue={total} />
<CircularProgressIndicator percentage={(completed / total) * 100} size={14} strokeWidth={2} />
</span>
<span className="w-8 text-right">
{isNaN(Math.floor((completed / total) * 100)) ? "0" : Math.floor((completed / total) * 100)}%
@@ -1,27 +1,40 @@
import { FC } from "react";
import { useTheme } from "next-themes";
import { observer } from "mobx-react-lite";
import { Controller, useForm } from "react-hook-form";
import { useTheme } from "next-themes";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
// ui
import { Button, InputColorPicker } from "@plane/ui";
// types
import { IUserTheme } from "types";
// mobx react lite
import { observer } from "mobx-react-lite";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
type Props = {};
const inputRules = {
required: "Background color is required",
minLength: {
value: 7,
message: "Enter a valid hex code of 6 characters",
},
maxLength: {
value: 7,
message: "Enter a valid hex code of 6 characters",
},
pattern: {
value: /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/,
message: "Enter a valid hex code of 6 characters",
},
};
export const CustomThemeSelector: FC<Props> = observer(() => {
export const CustomThemeSelector: React.FC = observer(() => {
const { user: userStore } = useMobxStore();
const userTheme = userStore?.currentUser?.theme;
// hooks
const { setTheme } = useTheme();
const {
control,
formState: { errors, isSubmitting },
handleSubmit,
control,
watch,
} = useForm<IUserTheme>({
defaultValues: {
background: userTheme?.background !== "" ? userTheme?.background : "#0d101b",
@@ -51,100 +64,151 @@ export const CustomThemeSelector: FC<Props> = observer(() => {
return userStore.updateCurrentUser({ theme: payload });
};
const handleValueChange = (val: string | undefined, onChange: any) => {
let hex = val;
// prepend a hashtag if it doesn't exist
if (val && val[0] !== "#") hex = `#${val}`;
onChange(hex);
};
return (
<form onSubmit={handleSubmit(handleUpdateTheme)}>
<div className="space-y-5">
<h3 className="text-lg font-semibold text-custom-text-100">Customize your theme</h3>
<div className="space-y-4">
<div className="grid grid-cols-1 gap-x-6 gap-y-4 sm:grid-cols-2 md:grid-cols-3">
<div className="grid grid-cols-1 gap-x-8 gap-y-4 sm:grid-cols-2 md:grid-cols-3">
<div className="flex flex-col items-start gap-2">
<h3 className="text-left text-sm font-medium text-custom-text-200">Background color</h3>
<Controller
control={control}
name="background"
render={({ field: { value, onChange } }) => (
<InputColorPicker
name="background"
value={value}
onChange={onChange}
className=""
placeholder="#ffffff"
hasError={Boolean(errors?.background)}
/>
)}
/>
<div className="w-full">
<Controller
control={control}
name="background"
rules={inputRules}
render={({ field: { value, onChange } }) => (
<InputColorPicker
name="background"
value={value}
onChange={(val) => handleValueChange(val, onChange)}
placeholder="#0d101b"
className="w-full"
style={{
backgroundColor: value,
color: watch("text"),
}}
hasError={Boolean(errors?.background)}
/>
)}
/>
{errors.background && <p className="text-xs text-red-500 mt-1">{errors.background.message}</p>}
</div>
</div>
<div className="flex flex-col items-start gap-2">
<h3 className="text-left text-sm font-medium text-custom-text-200">Text color</h3>
<Controller
control={control}
name="text"
render={({ field: { value, onChange } }) => (
<InputColorPicker
name="text"
value={value}
onChange={onChange}
className=""
placeholder="#ffffff"
hasError={Boolean(errors?.text)}
/>
)}
/>
<div className="w-full">
<Controller
control={control}
name="text"
rules={inputRules}
render={({ field: { value, onChange } }) => (
<InputColorPicker
name="text"
value={value}
onChange={(val) => handleValueChange(val, onChange)}
placeholder="#c5c5c5"
className="w-full"
style={{
backgroundColor: watch("background"),
color: value,
}}
hasError={Boolean(errors?.text)}
/>
)}
/>
{errors.text && <p className="text-xs text-red-500 mt-1">{errors.text.message}</p>}
</div>
</div>
<div className="flex flex-col items-start gap-2">
<h3 className="text-left text-sm font-medium text-custom-text-200">Primary(Theme) color</h3>
<Controller
control={control}
name="primary"
render={({ field: { value, onChange } }) => (
<InputColorPicker
name="primary"
value={value}
onChange={onChange}
className=""
placeholder="#ffffff"
hasError={Boolean(errors?.primary)}
/>
)}
/>
<div className="w-full">
<Controller
control={control}
name="primary"
rules={inputRules}
render={({ field: { value, onChange } }) => (
<InputColorPicker
name="primary"
value={value}
onChange={(val) => handleValueChange(val, onChange)}
placeholder="#3f76ff"
className="w-full"
style={{
backgroundColor: value,
color: watch("text"),
}}
hasError={Boolean(errors?.primary)}
/>
)}
/>
{errors.primary && <p className="text-xs text-red-500 mt-1">{errors.primary.message}</p>}
</div>
</div>
<div className="flex flex-col items-start gap-2">
<h3 className="text-left text-sm font-medium text-custom-text-200">Sidebar background color</h3>
<Controller
control={control}
name="sidebarBackground"
render={({ field: { value, onChange } }) => (
<InputColorPicker
name="sidebarBackground"
value={value}
onChange={onChange}
className=""
placeholder="#ffffff"
hasError={Boolean(errors?.sidebarBackground)}
/>
<div className="w-full">
<Controller
control={control}
name="sidebarBackground"
rules={inputRules}
render={({ field: { value, onChange } }) => (
<InputColorPicker
name="sidebarBackground"
value={value}
onChange={(val) => handleValueChange(val, onChange)}
placeholder="#0d101b"
className="w-full"
style={{
backgroundColor: value,
color: watch("sidebarText"),
}}
hasError={Boolean(errors?.sidebarBackground)}
/>
)}
/>
{errors.sidebarBackground && (
<p className="text-xs text-red-500 mt-1">{errors.sidebarBackground.message}</p>
)}
/>
</div>
</div>
<div className="flex flex-col items-start gap-2">
<h3 className="text-left text-sm font-medium text-custom-text-200">Sidebar text color</h3>
<Controller
control={control}
name="sidebarText"
render={({ field: { value, onChange } }) => (
<InputColorPicker
name="sidebarText"
value={value}
onChange={onChange}
className=""
placeholder="#ffffff"
hasError={Boolean(errors?.sidebarText)}
/>
)}
/>
<div className="w-full">
<Controller
control={control}
name="sidebarText"
rules={inputRules}
render={({ field: { value, onChange } }) => (
<InputColorPicker
name="sidebarText"
value={value}
onChange={(val) => handleValueChange(val, onChange)}
placeholder="#c5c5c5"
className="w-full"
style={{
backgroundColor: watch("sidebarBackground"),
color: value,
}}
hasError={Boolean(errors?.sidebarText)}
/>
)}
/>
{errors.sidebarText && <p className="text-xs text-red-500 mt-1">{errors.sidebarText.message}</p>}
</div>
</div>
</div>
</div>
@@ -198,8 +198,7 @@ export const InlineCreateIssueFormWrapper: React.FC<Props> = (props) => {
if (onSuccess) await onSuccess(res);
if (formData.assignees_list?.some((assignee) => assignee === user?.id))
mutate(USER_ISSUE(workspaceSlug as string));
if (formData.assignees?.some((assignee) => assignee === user?.id)) mutate(USER_ISSUE(workspaceSlug as string));
if (formData.parent && formData.parent !== "") mutate(SUB_ISSUES(formData.parent));
})
+14 -12
View File
@@ -1,16 +1,14 @@
import { MouseEvent } from "react";
import Link from "next/link";
import { useRouter } from "next/router";
import useSWR, { mutate } from "swr";
// services
import { CycleService } from "services/cycle.service";
import useSWR from "swr";
// hooks
import useToast from "hooks/use-toast";
import { useMobxStore } from "lib/mobx/store-provider";
// ui
import { AssigneesList } from "components/ui/avatar";
import { SingleProgressStats } from "components/core";
import {
AvatarGroup,
Loader,
Tooltip,
LinearProgressIndicator,
@@ -19,6 +17,7 @@ import {
LayersIcon,
StateGroupIcon,
PriorityIcon,
Avatar,
} from "@plane/ui";
// components
import ProgressChart from "components/core/sidebar/progress-chart";
@@ -31,9 +30,7 @@ import { AlarmClock, AlertTriangle, ArrowRight, CalendarDays, Star, Target } fro
import { getDateRangeStatus, renderShortDateWithYearFormat, findHowManyDaysLeft } from "helpers/date-time.helper";
import { truncateText } from "helpers/string.helper";
// types
import { ICycle, IIssue } from "types";
// fetch-keys
import { CURRENT_CYCLE_LIST, CYCLES_LIST, CYCLE_ISSUES_WITH_PARAMS } from "constants/fetch-keys";
import { ICycle } from "types";
const stateGroups = [
{
@@ -69,9 +66,6 @@ interface IActiveCycleDetails {
}
export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = (props) => {
// services
const cycleService = new CycleService();
const router = useRouter();
const { workspaceSlug, projectId } = props;
@@ -306,7 +300,11 @@ export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = (props) => {
{cycle.assignees.length > 0 && (
<div className="flex items-center gap-1 text-custom-text-200">
<AssigneesList users={cycle.assignees} length={4} />
<AvatarGroup>
{cycle.assignees.map((assignee) => (
<Avatar key={assignee.id} name={assignee.display_name} src={assignee.avatar} />
))}
</AvatarGroup>
</div>
)}
</div>
@@ -406,7 +404,11 @@ export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = (props) => {
<div className={`flex items-center gap-2 text-custom-text-200`}>
{issue.assignees && issue.assignees.length > 0 && Array.isArray(issue.assignees) ? (
<div className="-my-0.5 flex items-center justify-center gap-2">
<AssigneesList users={issue.assignee_details} length={3} showLength={false} />
<AvatarGroup showTooltip={false}>
{issue.assignee_details.map((assignee: any) => (
<Avatar key={assignee.id} name={assignee.display_name} src={assignee.avatar} />
))}
</AvatarGroup>
</div>
) : (
""
+7 -28
View File
@@ -1,16 +1,14 @@
import React, { Fragment } from "react";
// headless ui
import { Tab } from "@headlessui/react";
// hooks
import useLocalStorage from "hooks/use-local-storage";
// components
import { SingleProgressStats } from "components/core";
// ui
import { Avatar } from "components/ui";
import { Avatar } from "@plane/ui";
// types
import { ICycle } from "types";
// types
type Props = {
cycle: ICycle;
};
@@ -71,10 +69,7 @@ export const ActiveCycleProgressStats: React.FC<Props> = ({ cycle }) => {
</Tab.List>
{cycle.total_issues > 0 ? (
<Tab.Panels as={Fragment}>
<Tab.Panel
as="div"
className="w-full gap-1 overflow-y-scroll items-center text-custom-text-200 p-4"
>
<Tab.Panel as="div" className="w-full gap-1 overflow-y-scroll items-center text-custom-text-200 p-4">
{cycle.distribution.assignees.map((assignee, index) => {
if (assignee.assignee_id)
return (
@@ -82,15 +77,8 @@ export const ActiveCycleProgressStats: React.FC<Props> = ({ cycle }) => {
key={assignee.assignee_id}
title={
<div className="flex items-center gap-2">
<Avatar
user={{
id: assignee.assignee_id,
avatar: assignee.avatar ?? "",
first_name: assignee.first_name ?? "",
last_name: assignee.last_name ?? "",
display_name: assignee.display_name ?? "",
}}
/>
<Avatar name={assignee?.display_name ?? undefined} src={assignee?.avatar ?? undefined} />
<span>{assignee.display_name}</span>
</div>
}
@@ -105,13 +93,7 @@ export const ActiveCycleProgressStats: React.FC<Props> = ({ cycle }) => {
title={
<div className="flex items-center gap-2">
<div className="h-5 w-5 rounded-full border-2 border-custom-border-200 bg-custom-background-80">
<img
src="/user.png"
height="100%"
width="100%"
className="rounded-full"
alt="User"
/>
<img src="/user.png" height="100%" width="100%" className="rounded-full" alt="User" />
</div>
<span>No assignee</span>
</div>
@@ -122,10 +104,7 @@ export const ActiveCycleProgressStats: React.FC<Props> = ({ cycle }) => {
);
})}
</Tab.Panel>
<Tab.Panel
as="div"
className="w-full gap-1 overflow-y-scroll items-center text-custom-text-200 p-4"
>
<Tab.Panel as="div" className="w-full gap-1 overflow-y-scroll items-center text-custom-text-200 p-4">
{cycle.distribution.labels.map((label, index) => (
<SingleProgressStats
key={label.label_id ?? `no-label-${index}`}
@@ -0,0 +1,55 @@
import React, { useEffect } from "react";
import { useRouter } from "next/router";
// mobx
import { observer } from "mobx-react-lite";
import { useMobxStore } from "lib/mobx/store-provider";
// components
import { CycleDetailsSidebar } from "./sidebar";
type Props = {
projectId: string;
workspaceSlug: string;
};
export const CyclePeekOverview: React.FC<Props> = observer(({ projectId, workspaceSlug }) => {
const router = useRouter();
const { peekCycle } = router.query;
const ref = React.useRef(null);
const { cycle: cycleStore } = useMobxStore();
const { fetchCycleWithId } = cycleStore;
const handleClose = () => {
delete router.query.peekCycle;
router.push({
pathname: router.pathname,
query: { ...router.query },
});
};
useEffect(() => {
if (!peekCycle) return;
fetchCycleWithId(workspaceSlug, projectId, peekCycle.toString());
}, [fetchCycleWithId, peekCycle, projectId, workspaceSlug]);
return (
<>
{peekCycle && (
<div
ref={ref}
className="flex flex-col gap-3.5 h-full w-[24rem] z-10 overflow-y-auto border-l border-custom-border-100 bg-custom-sidebar-background-100 px-6 py-3.5 duration-300 flex-shrink-0"
style={{
boxShadow:
"0px 1px 4px 0px rgba(0, 0, 0, 0.06), 0px 2px 4px 0px rgba(16, 24, 40, 0.06), 0px 1px 8px -1px rgba(16, 24, 40, 0.06)",
}}
>
<CycleDetailsSidebar cycleId={peekCycle?.toString() ?? ""} handleClose={handleClose} />
</div>
)}
</>
);
});
+171 -316
View File
@@ -1,64 +1,28 @@
import { FC, MouseEvent, useState } from "react";
// next imports
import { useRouter } from "next/router";
import Link from "next/link";
// headless ui
import { Disclosure, Transition } from "@headlessui/react";
// hooks
import useToast from "hooks/use-toast";
// components
import { SingleProgressStats } from "components/core";
import { CycleCreateUpdateModal, CycleDeleteModal } from "components/cycles";
// ui
import { AssigneesList } from "components/ui/avatar";
import { CustomMenu, Tooltip, LinearProgressIndicator, ContrastIcon, RunningIcon } from "@plane/ui";
import { Avatar, AvatarGroup, CustomMenu, Tooltip, LayersIcon, CycleGroupIcon } from "@plane/ui";
// icons
import {
AlarmClock,
AlertTriangle,
ArrowRight,
CalendarDays,
ChevronDown,
LinkIcon,
Pencil,
Star,
Target,
Trash2,
} from "lucide-react";
import { Info, LinkIcon, Pencil, Star, Trash2 } from "lucide-react";
// helpers
import { getDateRangeStatus, renderShortDateWithYearFormat, findHowManyDaysLeft } from "helpers/date-time.helper";
import { copyTextToClipboard, truncateText } from "helpers/string.helper";
import {
getDateRangeStatus,
findHowManyDaysLeft,
renderShortDate,
renderShortMonthDate,
} from "helpers/date-time.helper";
import { copyTextToClipboard } from "helpers/string.helper";
// types
import { ICycle } from "types";
// store
import { useMobxStore } from "lib/mobx/store-provider";
const stateGroups = [
{
key: "backlog_issues",
title: "Backlog",
color: "#dee2e6",
},
{
key: "unstarted_issues",
title: "Unstarted",
color: "#26b5ce",
},
{
key: "started_issues",
title: "Started",
color: "#f7ae59",
},
{
key: "cancelled_issues",
title: "Cancelled",
color: "#d687ff",
},
{
key: "completed_issues",
title: "Completed",
color: "#09a953",
},
];
// constants
import { CYCLE_STATUS } from "constants/cycle";
export interface ICyclesBoardCard {
workspaceSlug: string;
@@ -81,7 +45,34 @@ export const CyclesBoardCard: FC<ICyclesBoardCard> = (props) => {
const endDate = new Date(cycle.end_date ?? "");
const startDate = new Date(cycle.start_date ?? "");
const handleCopyText = () => {
const router = useRouter();
const currentCycle = CYCLE_STATUS.find((status) => status.value === cycleStatus);
const areYearsEqual = startDate.getFullYear() === endDate.getFullYear();
const cycleTotalIssues =
cycle.backlog_issues +
cycle.unstarted_issues +
cycle.started_issues +
cycle.completed_issues +
cycle.cancelled_issues;
const completionPercentage = (cycle.completed_issues / cycleTotalIssues) * 100;
const issueCount = cycle
? cycleTotalIssues === 0
? "0 Issue"
: cycleTotalIssues === cycle.completed_issues
? cycleTotalIssues > 1
? `${cycleTotalIssues} Issues`
: `${cycleTotalIssues} Issue`
: `${cycle.completed_issues}/${cycleTotalIssues} Issues`
: "0 Issue";
const handleCopyText = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
const originURL = typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
copyTextToClipboard(`${originURL}/${workspaceSlug}/projects/${projectId}/cycles/${cycle.id}`).then(() => {
@@ -93,21 +84,6 @@ export const CyclesBoardCard: FC<ICyclesBoardCard> = (props) => {
});
};
const progressIndicatorData = stateGroups.map((group, index) => ({
id: index,
name: group.title,
value: cycle.total_issues > 0 ? ((cycle[group.key as keyof ICycle] as number) / cycle.total_issues) * 100 : 0,
color: group.color,
}));
const groupedIssues: any = {
backlog: cycle.backlog_issues,
unstarted: cycle.unstarted_issues,
started: cycle.started_issues,
completed: cycle.completed_issues,
cancelled: cycle.cancelled_issues,
};
const handleAddToFavorites = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
if (!workspaceSlug || !projectId) return;
@@ -134,6 +110,29 @@ export const CyclesBoardCard: FC<ICyclesBoardCard> = (props) => {
});
};
const handleEditCycle = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
setUpdateModal(true);
};
const handleDeleteCycle = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
setDeleteModal(true);
};
const openCycleOverview = (e: MouseEvent<HTMLButtonElement>) => {
const { query } = router;
e.preventDefault();
e.stopPropagation();
router.push({
pathname: router.pathname,
query: { ...query, peekCycle: cycle.id },
});
};
return (
<div>
<CycleCreateUpdateModal
@@ -152,267 +151,123 @@ export const CyclesBoardCard: FC<ICyclesBoardCard> = (props) => {
projectId={projectId}
/>
<div className="flex flex-col rounded-[10px] bg-custom-background-100 border border-custom-border-200 text-xs shadow">
<Link href={`/${workspaceSlug}/projects/${projectId}/cycles/${cycle.id}`}>
<a className="w-full">
<div className="flex h-full flex-col gap-4 rounded-b-[10px] p-4">
<div className="flex items-center justify-between gap-1">
<span className="flex items-center gap-1">
<span className="h-5 w-5">
<ContrastIcon
className="h-5 w-5"
color={`${
cycleStatus === "current"
? "#09A953"
: cycleStatus === "upcoming"
? "#F7AE59"
: cycleStatus === "completed"
? "#3F76FF"
: cycleStatus === "draft"
? "rgb(var(--color-text-200))"
: ""
}`}
/>
</span>
<Tooltip tooltipContent={cycle.name} className="break-words" position="top-left">
<h3 className="break-words text-lg font-semibold">{truncateText(cycle.name, 15)}</h3>
</Tooltip>
<Link href={`/${workspaceSlug}/projects/${projectId}/cycles/${cycle.id}`}>
<a className="flex flex-col justify-between p-4 h-44 w-full min-w-[250px] text-sm rounded bg-custom-background-100 border border-custom-border-100 hover:shadow-md">
<div>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-3">
<span className="flex-shrink-0">
<CycleGroupIcon cycleGroup={cycleStatus} className="h-3.5 w-3.5" />
</span>
<span className="flex items-center gap-1 capitalize">
<Tooltip tooltipContent={cycle.name} position="top">
<span className="text-base font-medium truncate">{cycle.name}</span>
</Tooltip>
</div>
<div className="flex items-center gap-2">
{currentCycle && (
<span
className={`rounded-full px-1.5 py-0.5
${
cycleStatus === "current"
? "bg-green-600/5 text-green-600"
: cycleStatus === "upcoming"
? "bg-orange-300/5 text-orange-300"
: cycleStatus === "completed"
? "bg-blue-500/5 text-blue-500"
: cycleStatus === "draft"
? "bg-neutral-400/5 text-neutral-400"
: ""
}`}
className="flex items-center justify-center text-xs text-center h-6 w-20 rounded-sm"
style={{
color: currentCycle.color,
backgroundColor: `${currentCycle.color}20`,
}}
>
{cycleStatus === "current" ? (
<span className="flex gap-1 whitespace-nowrap">
<RunningIcon className="h-4 w-4" />
{findHowManyDaysLeft(cycle.end_date ?? new Date())} Days Left
</span>
) : cycleStatus === "upcoming" ? (
<span className="flex gap-1 whitespace-nowrap">
<AlarmClock className="h-4 w-4" />
{findHowManyDaysLeft(cycle.start_date ?? new Date())} Days Left
</span>
) : cycleStatus === "completed" ? (
<span className="flex gap-1 whitespace-nowrap">
{cycle.total_issues - cycle.completed_issues > 0 && (
<Tooltip
tooltipContent={`${cycle.total_issues - cycle.completed_issues} more pending ${
cycle.total_issues - cycle.completed_issues === 1 ? "issue" : "issues"
}`}
>
<span>
<AlertTriangle className="h-3.5 w-3.5" />
</span>
</Tooltip>
)}{" "}
Completed
</span>
) : (
cycleStatus
)}
{currentCycle.value === "current"
? `${findHowManyDaysLeft(cycle.end_date ?? new Date())} ${currentCycle.label}`
: `${currentCycle.label}`}
</span>
{cycle.is_favorite ? (
<button onClick={handleRemoveFromFavorites}>
<Star className="h-4 w-4 text-orange-400" fill="#f6ad55" />
</button>
) : (
<button onClick={handleAddToFavorites}>
<Star className="h-4 w-4 " color="rgb(var(--color-text-200))" />
</button>
)}
</span>
</div>
<div className="flex h-4 items-center justify-start gap-5 text-custom-text-200">
{cycleStatus !== "draft" && (
<>
<div className="flex items-start gap-1">
<CalendarDays className="h-4 w-4" />
<span>{renderShortDateWithYearFormat(startDate)}</span>
</div>
<ArrowRight className="h-4 w-4" />
<div className="flex items-start gap-1">
<Target className="h-4 w-4" />
<span>{renderShortDateWithYearFormat(endDate)}</span>
</div>
</>
)}
</div>
<div className="flex justify-between items-end">
<div className="flex flex-col gap-2 text-xs text-custom-text-200">
<div className="flex items-center gap-2">
<div className="w-16">Creator:</div>
<div className="flex items-center gap-2.5 text-custom-text-200">
{cycle.owned_by.avatar && cycle.owned_by.avatar !== "" ? (
<img
src={cycle.owned_by.avatar}
height={16}
width={16}
className="rounded-full"
alt={cycle.owned_by.display_name}
/>
) : (
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-orange-300 capitalize text-white">
{cycle.owned_by.display_name.charAt(0)}
</span>
)}
<span className="text-custom-text-200">{cycle.owned_by.display_name}</span>
</div>
</div>
<div className="flex h-5 items-center gap-2">
<div className="w-16">Members:</div>
{cycle.assignees.length > 0 ? (
<div className="flex items-center gap-1 text-custom-text-200">
<AssigneesList users={cycle.assignees} length={4} />
</div>
) : (
"No members"
)}
</div>
</div>
<div className="flex items-center">
{!isCompleted && (
<button
onClick={(e) => {
e.preventDefault();
setUpdateModal(true);
}}
className="cursor-pointer rounded p-1 text-custom-text-200 duration-300 hover:bg-custom-background-80"
>
<Pencil className="h-4 w-4" />
</button>
)}
<CustomMenu width="auto" verticalEllipsis>
{!isCompleted && (
<CustomMenu.MenuItem
onClick={(e) => {
e.preventDefault();
setDeleteModal(true);
}}
>
<span className="flex items-center justify-start gap-2">
<Trash2 className="h-4 w-4" />
<span>Delete cycle</span>
</span>
</CustomMenu.MenuItem>
)}
<CustomMenu.MenuItem
onClick={(e) => {
e.preventDefault();
handleCopyText();
}}
>
<span className="flex items-center justify-start gap-2">
<LinkIcon className="h-4 w-4" />
<span>Copy cycle link</span>
</span>
</CustomMenu.MenuItem>
</CustomMenu>
</div>
<button onClick={openCycleOverview}>
<Info className="h-4 w-4 text-custom-text-400" />
</button>
</div>
</div>
</a>
</Link>
</div>
<div className="flex h-full flex-col rounded-b-[10px]">
<Disclosure>
{({ open }) => (
<div
className={`flex h-full w-full flex-col rounded-b-[10px] border-t border-custom-border-200 bg-custom-background-80 text-custom-text-200 ${
open ? "" : "flex-row"
}`}
>
<div className="flex w-full items-center gap-2 px-4 py-1">
<span>Progress</span>
<Tooltip
tooltipContent={
<div className="flex w-56 flex-col">
{Object.keys(groupedIssues).map((group, index) => (
<SingleProgressStats
key={index}
title={
<div className="flex items-center gap-2">
<span
className="block h-3 w-3 rounded-full "
style={{
backgroundColor: stateGroups[index].color,
}}
/>
<span className="text-xs capitalize">{group}</span>
</div>
}
completed={groupedIssues[group]}
total={cycle.total_issues}
/>
))}
</div>
}
position="bottom"
>
<div className="flex w-full items-center">
<LinearProgressIndicator data={progressIndicatorData} noTooltip={true} />
</div>
</Tooltip>
<Disclosure.Button>
<span className="p-1">
<ChevronDown className={`h-3 w-3 ${open ? "rotate-180 transform" : ""}`} aria-hidden="true" />
</span>
</Disclosure.Button>
</div>
<Transition show={open}>
<Disclosure.Panel>
<div className="overflow-hidden rounded-b-md bg-custom-background-80 py-3 shadow">
<div className="col-span-2 space-y-3 px-4">
<div className="space-y-3 text-xs">
{stateGroups.map((group) => (
<div key={group.key} className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<span
className="block h-2 w-2 rounded-full"
style={{
backgroundColor: group.color,
}}
/>
<h6 className="text-xs">{group.title}</h6>
</div>
<div>
<span>
{cycle[group.key as keyof ICycle] as number}{" "}
<span className="text-custom-text-200">
-{" "}
{cycle.total_issues > 0
? `${Math.round(
((cycle[group.key as keyof ICycle] as number) / cycle.total_issues) * 100
)}%`
: "0%"}
</span>
</span>
</div>
</div>
))}
</div>
</div>
</div>
</Disclosure.Panel>
</Transition>
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5 text-custom-text-200">
<LayersIcon className="h-4 w-4 text-custom-text-300" />
<span className="text-xs text-custom-text-300">{issueCount}</span>
</div>
)}
</Disclosure>
</div>
</div>
{cycle.assignees.length > 0 && (
<Tooltip tooltipContent={`${cycle.assignees.length} Members`}>
<div className="flex items-center gap-1 cursor-default">
<AvatarGroup showTooltip={false}>
{cycle.assignees.map((assignee) => (
<Avatar key={assignee.id} name={assignee.display_name} src={assignee.avatar} />
))}
</AvatarGroup>
</div>
</Tooltip>
)}
</div>
<Tooltip
tooltipContent={isNaN(completionPercentage) ? "0" : `${completionPercentage.toFixed(0)}%`}
position="top-left"
>
<div className="flex items-center w-full">
<div
className="bar relative h-1.5 w-full rounded bg-custom-background-90"
style={{
boxShadow: "1px 1px 4px 0px rgba(161, 169, 191, 0.35) inset",
}}
>
<div
className="absolute top-0 left-0 h-1.5 rounded bg-blue-600 duration-300"
style={{
width: `${isNaN(completionPercentage) ? 0 : completionPercentage.toFixed(0)}%`,
}}
/>
</div>
</div>
</Tooltip>
<div className="flex items-center justify-between">
<span className="text-xs text-custom-text-300">
{areYearsEqual ? renderShortDate(startDate, "_ _") : renderShortMonthDate(startDate, "_ _")} -{" "}
{areYearsEqual ? renderShortDate(endDate, "_ _") : renderShortMonthDate(endDate, "_ _")}
</span>
<div className="flex items-center gap-1.5 z-10">
{cycle.is_favorite ? (
<button type="button" onClick={handleRemoveFromFavorites}>
<Star className="h-3.5 w-3.5 text-amber-500 fill-current" />
</button>
) : (
<button type="button" onClick={handleAddToFavorites}>
<Star className="h-3.5 w-3.5 text-custom-text-200" />
</button>
)}
<CustomMenu width="auto" ellipsis className="z-10">
{!isCompleted && (
<>
<CustomMenu.MenuItem onClick={handleEditCycle}>
<span className="flex items-center justify-start gap-2">
<Pencil className="h-3 w-3" />
<span>Edit cycle</span>
</span>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={handleDeleteCycle}>
<span className="flex items-center justify-start gap-2">
<Trash2 className="h-3 w-3" />
<span>Delete module</span>
</span>
</CustomMenu.MenuItem>
</>
)}
<CustomMenu.MenuItem onClick={handleCopyText}>
<span className="flex items-center justify-start gap-2">
<LinkIcon className="h-3 w-3" />
<span>Copy cycle link</span>
</span>
</CustomMenu.MenuItem>
</CustomMenu>
</div>
</div>
</div>
</a>
</Link>
</div>
);
};
+24 -9
View File
@@ -2,26 +2,41 @@ import { FC } from "react";
// types
import { ICycle } from "types";
// components
import { CyclesBoardCard } from "components/cycles";
import { CyclePeekOverview, CyclesBoardCard } from "components/cycles";
export interface ICyclesBoard {
cycles: ICycle[];
filter: string;
workspaceSlug: string;
projectId: string;
peekCycle: string;
}
export const CyclesBoard: FC<ICyclesBoard> = (props) => {
const { cycles, filter, workspaceSlug, projectId } = props;
const { cycles, filter, workspaceSlug, projectId, peekCycle } = props;
return (
<div className="grid grid-cols-1 gap-9 lg:grid-cols-2 xl:grid-cols-3">
<>
{cycles.length > 0 ? (
<>
{cycles.map((cycle) => (
<CyclesBoardCard key={cycle.id} workspaceSlug={workspaceSlug} projectId={projectId} cycle={cycle} />
))}
</>
<div className="h-full w-full">
<div className="flex justify-between h-full w-full">
<div
className={`grid grid-cols-1 gap-6 p-8 h-full w-full overflow-y-auto ${
peekCycle
? "lg:grid-cols-1 xl:grid-cols-2 3xl:grid-cols-3"
: "lg:grid-cols-2 xl:grid-cols-3 3xl:grid-cols-4"
} auto-rows-max transition-all `}
>
{cycles.map((cycle) => (
<CyclesBoardCard key={cycle.id} workspaceSlug={workspaceSlug} projectId={projectId} cycle={cycle} />
))}
</div>
<CyclePeekOverview
projectId={projectId?.toString() ?? ""}
workspaceSlug={workspaceSlug?.toString() ?? ""}
/>
</div>
</div>
) : (
<div className="h-full grid place-items-center text-center">
<div className="space-y-2">
@@ -50,6 +65,6 @@ export const CyclesBoard: FC<ICyclesBoard> = (props) => {
</div>
</div>
)}
</div>
</>
);
};
+167 -267
View File
@@ -1,29 +1,29 @@
import { FC, MouseEvent, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/router";
// stores
import { useMobxStore } from "lib/mobx/store-provider";
// hooks
import useToast from "hooks/use-toast";
// components
import { CycleCreateUpdateModal, CycleDeleteModal } from "components/cycles";
// ui
import { CustomMenu, RadialProgressBar, Tooltip, LinearProgressIndicator, ContrastIcon, RunningIcon } from "@plane/ui";
import { CustomMenu, Tooltip, CircularProgressIndicator, CycleGroupIcon, AvatarGroup, Avatar } from "@plane/ui";
// icons
import {
AlarmClock,
AlertTriangle,
ArrowRight,
CalendarDays,
LinkIcon,
Pencil,
Star,
Target,
Trash2,
} from "lucide-react";
import { Check, Info, LinkIcon, Pencil, Star, Trash2, User2 } from "lucide-react";
// helpers
import { getDateRangeStatus, renderShortDateWithYearFormat, findHowManyDaysLeft } from "helpers/date-time.helper";
import {
getDateRangeStatus,
findHowManyDaysLeft,
renderShortDate,
renderShortMonthDate,
} from "helpers/date-time.helper";
import { copyTextToClipboard } from "helpers/string.helper";
// types
import { ICycle } from "types";
import { useMobxStore } from "lib/mobx/store-provider";
// constants
import { CYCLE_STATUS } from "constants/cycle";
type TCyclesListItem = {
cycle: ICycle;
@@ -35,34 +35,6 @@ type TCyclesListItem = {
projectId: string;
};
const stateGroups = [
{
key: "backlog_issues",
title: "Backlog",
color: "#dee2e6",
},
{
key: "unstarted_issues",
title: "Unstarted",
color: "#26b5ce",
},
{
key: "started_issues",
title: "Started",
color: "#f7ae59",
},
{
key: "cancelled_issues",
title: "Cancelled",
color: "#d687ff",
},
{
key: "completed_issues",
title: "Completed",
color: "#09a953",
},
];
export const CyclesListItem: FC<TCyclesListItem> = (props) => {
const { cycle, workspaceSlug, projectId } = props;
// store
@@ -78,7 +50,28 @@ export const CyclesListItem: FC<TCyclesListItem> = (props) => {
const endDate = new Date(cycle.end_date ?? "");
const startDate = new Date(cycle.start_date ?? "");
const handleCopyText = () => {
const router = useRouter();
const cycleTotalIssues =
cycle.backlog_issues +
cycle.unstarted_issues +
cycle.started_issues +
cycle.completed_issues +
cycle.cancelled_issues;
const renderDate = cycle.start_date || cycle.end_date;
const areYearsEqual = startDate.getFullYear() === endDate.getFullYear();
const completionPercentage = (cycle.completed_issues / cycleTotalIssues) * 100;
const progress = isNaN(completionPercentage) ? 0 : Math.floor(completionPercentage);
const currentCycle = CYCLE_STATUS.find((status) => status.value === cycleStatus);
const handleCopyText = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
const originURL = typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
copyTextToClipboard(`${originURL}/${workspaceSlug}/projects/${projectId}/cycles/${cycle.id}`).then(() => {
@@ -90,13 +83,6 @@ export const CyclesListItem: FC<TCyclesListItem> = (props) => {
});
};
const progressIndicatorData = stateGroups.map((group, index) => ({
id: index,
name: group.title,
value: cycle.total_issues > 0 ? ((cycle[group.key as keyof ICycle] as number) / cycle.total_issues) * 100 : 0,
color: group.color,
}));
const handleAddToFavorites = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
if (!workspaceSlug || !projectId) return;
@@ -123,224 +109,31 @@ export const CyclesListItem: FC<TCyclesListItem> = (props) => {
});
};
const handleEditCycle = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
setUpdateModal(true);
};
const handleDeleteCycle = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
setDeleteModal(true);
};
const openCycleOverview = (e: MouseEvent<HTMLButtonElement>) => {
const { query } = router;
e.preventDefault();
e.stopPropagation();
router.push({
pathname: router.pathname,
query: { ...query, peekCycle: cycle.id },
});
};
return (
<>
<div className="relative flex items-center gap-1 hover:bg-custom-background-80 transition-all rounded px-2 pl-3">
<div className="w-full text-xs py-3">
<Link href={`/${workspaceSlug}/projects/${projectId}/cycles/${cycle.id}`}>
<a className="w-full h-full relative overflow-hidden flex items-center gap-2">
{/* left content */}
<div className="relative flex items-center gap-2 overflow-hidden">
{/* cycle state */}
<div className="flex-shrink-0">
<ContrastIcon
className="h-5 w-5"
color={`${
cycleStatus === "current"
? "#09A953"
: cycleStatus === "upcoming"
? "#F7AE59"
: cycleStatus === "completed"
? "#3F76FF"
: cycleStatus === "draft"
? "rgb(var(--color-text-200))"
: ""
}`}
/>
</div>
{/* cycle title and description */}
<div className="max-w-xl">
<Tooltip tooltipContent={cycle.name} className="break-words" position="top-left">
<div className="text-base font-semibold line-clamp-1 pr-5 overflow-hidden break-words">
{cycle.name}
</div>
</Tooltip>
{cycle.description && (
<div className="mt-1 text-custom-text-200 break-words w-full line-clamp-2">{cycle.description}</div>
)}
</div>
</div>
{/* right content */}
<div className="ml-auto flex-shrink-0 relative flex items-center gap-3 p-2">
{/* cycle status */}
<div
className={`rounded-full px-2 py-1
${
cycleStatus === "current"
? "bg-green-600/10 text-green-600"
: cycleStatus === "upcoming"
? "bg-orange-300/10 text-orange-300"
: cycleStatus === "completed"
? "bg-blue-500/10 text-blue-500"
: cycleStatus === "draft"
? "bg-neutral-400/10 text-neutral-400"
: ""
}`}
>
{cycleStatus === "current" ? (
<span className="flex items-center gap-1 whitespace-nowrap">
<RunningIcon className="h-3.5 w-3.5" />
{findHowManyDaysLeft(cycle.end_date ?? new Date())} days left
</span>
) : cycleStatus === "upcoming" ? (
<span className="flex items-center gap-1">
<AlarmClock className="h-3.5 w-3.5" />
{findHowManyDaysLeft(cycle.start_date ?? new Date())} days left
</span>
) : cycleStatus === "completed" ? (
<span className="flex items-center gap-1">
{cycle.total_issues - cycle.completed_issues > 0 && (
<Tooltip
tooltipContent={`${cycle.total_issues - cycle.completed_issues} more pending ${
cycle.total_issues - cycle.completed_issues === 1 ? "issue" : "issues"
}`}
>
<span>
<AlertTriangle className="h-3.5 w-3.5" />
</span>
</Tooltip>
)}{" "}
Completed
</span>
) : (
cycleStatus
)}
</div>
{/* cycle start_date and target_date */}
{cycleStatus !== "draft" && (
<div className="flex items-center justify-start gap-2 text-custom-text-200">
<div className="flex items-start gap-1 whitespace-nowrap">
<CalendarDays className="h-4 w-4" />
<span>{renderShortDateWithYearFormat(startDate)}</span>
</div>
<ArrowRight className="h-4 w-4" />
<div className="flex items-start gap-1 whitespace-nowrap">
<Target className="h-4 w-4" />
<span>{renderShortDateWithYearFormat(endDate)}</span>
</div>
</div>
)}
{/* cycle created by */}
<div className="flex items-center text-custom-text-200">
{cycle.owned_by.avatar && cycle.owned_by.avatar !== "" ? (
<img
src={cycle.owned_by.avatar}
height={16}
width={16}
className="rounded-full"
alt={cycle.owned_by.display_name}
/>
) : (
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-orange-300 capitalize text-white">
{cycle.owned_by.display_name.charAt(0)}
</span>
)}
</div>
{/* cycle progress */}
<Tooltip
position="top-right"
tooltipContent={
<div className="flex w-80 items-center gap-2 px-4 py-1">
<span>Progress</span>
<LinearProgressIndicator data={progressIndicatorData} />
</div>
}
>
<span
className={`rounded-md px-1.5 py-1
${
cycleStatus === "current"
? "border border-green-600 bg-green-600/5 text-green-600"
: cycleStatus === "upcoming"
? "border border-orange-300 bg-orange-300/5 text-orange-300"
: cycleStatus === "completed"
? "border border-blue-500 bg-blue-500/5 text-blue-500"
: cycleStatus === "draft"
? "border border-neutral-400 bg-neutral-400/5 text-neutral-400"
: ""
}`}
>
{cycleStatus === "current" ? (
<span className="flex gap-1 whitespace-nowrap">
{cycle.total_issues > 0 ? (
<>
<RadialProgressBar progress={(cycle.completed_issues / cycle.total_issues) * 100} />
<span>{Math.floor((cycle.completed_issues / cycle.total_issues) * 100)} %</span>
</>
) : (
<span className="normal-case">No issues present</span>
)}
</span>
) : cycleStatus === "upcoming" ? (
<span className="flex gap-1">
<RadialProgressBar progress={100} /> Yet to start
</span>
) : cycleStatus === "completed" ? (
<span className="flex gap-1">
<RadialProgressBar progress={100} />
<span>{100} %</span>
</span>
) : (
<span className="flex gap-1">
<RadialProgressBar progress={(cycle.total_issues / cycle.completed_issues) * 100} />
{cycleStatus}
</span>
)}
</span>
</Tooltip>
{/* cycle favorite */}
{cycle.is_favorite ? (
<button type="button" onClick={handleRemoveFromFavorites}>
<Star className="h-4 w-4 text-orange-400" fill="#f6ad55" />
</button>
) : (
<button type="button" onClick={handleAddToFavorites}>
<Star className="h-4 w-4 " color="rgb(var(--color-text-200))" />
</button>
)}
</div>
</a>
</Link>
</div>
<div className="flex-shrink-0">
<CustomMenu width="auto" verticalEllipsis>
{!isCompleted && (
<CustomMenu.MenuItem onClick={() => setUpdateModal(true)}>
<span className="flex items-center justify-start gap-2">
<Pencil className="h-4 w-4" />
<span>Edit Cycle</span>
</span>
</CustomMenu.MenuItem>
)}
{!isCompleted && (
<CustomMenu.MenuItem onClick={() => setDeleteModal(true)}>
<span className="flex items-center justify-start gap-2">
<Trash2 className="h-4 w-4" />
<span>Delete cycle</span>
</span>
</CustomMenu.MenuItem>
)}
<CustomMenu.MenuItem onClick={handleCopyText}>
<span className="flex items-center justify-start gap-2">
<LinkIcon className="h-4 w-4" />
<span>Copy cycle link</span>
</span>
</CustomMenu.MenuItem>
</CustomMenu>
</div>
</div>
<CycleCreateUpdateModal
data={cycle}
isOpen={updateModal}
@@ -348,7 +141,6 @@ export const CyclesListItem: FC<TCyclesListItem> = (props) => {
workspaceSlug={workspaceSlug}
projectId={projectId}
/>
<CycleDeleteModal
cycle={cycle}
isOpen={deleteModal}
@@ -356,6 +148,114 @@ export const CyclesListItem: FC<TCyclesListItem> = (props) => {
workspaceSlug={workspaceSlug}
projectId={projectId}
/>
<Link href={`/${workspaceSlug}/projects/${projectId}/cycles/${cycle.id}`}>
<a className="group flex items-center justify-between gap-5 px-10 py-6 h-16 w-full text-sm bg-custom-background-100 border-b border-custom-border-100 hover:bg-custom-background-90">
<div className="flex items-center gap-3 w-full truncate">
<div className="flex items-center gap-4 truncate">
<span className="flex-shrink-0">
<CircularProgressIndicator size={38} percentage={progress}>
{isCompleted ? (
<span className="text-sm text-custom-primary-100">{`!`}</span>
) : progress === 100 ? (
<Check className="h-3 w-3 text-custom-primary-100 stroke-[2]" />
) : (
<span className="text-xs text-custom-text-300">{`${progress}%`}</span>
)}
</CircularProgressIndicator>
</span>
<div className="flex items-center gap-2.5">
<span className="flex-shrink-0">
<CycleGroupIcon cycleGroup={cycleStatus} className="h-3.5 w-3.5" />
</span>
<Tooltip tooltipContent={cycle.name} position="top">
<span className="text-base font-medium truncate">{cycle.name}</span>
</Tooltip>
</div>
</div>
<button onClick={openCycleOverview} className="flex-shrink-0 hidden group-hover:flex z-10">
<Info className="h-4 w-4 text-custom-text-400" />
</button>
</div>
<div className="flex items-center gap-2.5 justify-end w-full md:w-auto md:flex-shrink-0 ">
<div className="flex items-center justify-center">
{currentCycle && (
<span
className="flex items-center justify-center text-xs text-center h-6 w-20 rounded-sm"
style={{
color: currentCycle.color,
backgroundColor: `${currentCycle.color}20`,
}}
>
{currentCycle.value === "current"
? `${findHowManyDaysLeft(cycle.end_date ?? new Date())} ${currentCycle.label}`
: `${currentCycle.label}`}
</span>
)}
</div>
{renderDate && (
<span className="flex items-center justify-center gap-2 w-28 text-xs text-custom-text-300">
{areYearsEqual ? renderShortDate(startDate, "_ _") : renderShortMonthDate(startDate, "_ _")}
{" - "}
{areYearsEqual ? renderShortDate(endDate, "_ _") : renderShortMonthDate(endDate, "_ _")}
</span>
)}
<Tooltip tooltipContent={`${cycle.assignees.length} Members`}>
<div className="flex items-center justify-center gap-1 cursor-default w-16">
{cycle.assignees.length > 0 ? (
<AvatarGroup showTooltip={false}>
{cycle.assignees.map((assignee) => (
<Avatar key={assignee.id} name={assignee.display_name} src={assignee.avatar} />
))}
</AvatarGroup>
) : (
<span className="flex items-end justify-center h-5 w-5 bg-custom-background-80 rounded-full border border-dashed border-custom-text-400">
<User2 className="h-4 w-4 text-custom-text-400" />
</span>
)}
</div>
</Tooltip>
{cycle.is_favorite ? (
<button type="button" onClick={handleRemoveFromFavorites}>
<Star className="h-3.5 w-3.5 text-amber-500 fill-current" />
</button>
) : (
<button type="button" onClick={handleAddToFavorites}>
<Star className="h-3.5 w-3.5 text-custom-text-200" />
</button>
)}
<CustomMenu width="auto" ellipsis className="z-10">
{!isCompleted && (
<>
<CustomMenu.MenuItem onClick={handleEditCycle}>
<span className="flex items-center justify-start gap-2">
<Pencil className="h-3 w-3" />
<span>Edit cycle</span>
</span>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={handleDeleteCycle}>
<span className="flex items-center justify-start gap-2">
<Trash2 className="h-3 w-3" />
<span>Delete module</span>
</span>
</CustomMenu.MenuItem>
</>
)}
<CustomMenu.MenuItem onClick={handleCopyText}>
<span className="flex items-center justify-start gap-2">
<LinkIcon className="h-3 w-3" />
<span>Copy cycle link</span>
</span>
</CustomMenu.MenuItem>
</CustomMenu>
</div>
</a>
</Link>
</>
);
};
+14 -9
View File
@@ -1,6 +1,7 @@
import { FC } from "react";
// components
import { CyclesListItem } from "./cycles-list-item";
import { CyclePeekOverview, CyclesListItem } from "components/cycles";
// ui
import { Loader } from "@plane/ui";
// types
@@ -17,18 +18,22 @@ export const CyclesList: FC<ICyclesList> = (props) => {
const { cycles, filter, workspaceSlug, projectId } = props;
return (
<div>
<>
{cycles ? (
<>
{cycles.length > 0 ? (
<div className="divide-y divide-custom-border-200">
{cycles.map((cycle) => (
<div className="hover:bg-custom-background-80" key={cycle.id}>
<div className="flex flex-col border-custom-border-200">
<div className="h-full overflow-y-auto">
<div className="flex justify-between h-full w-full">
<div className="flex flex-col h-full w-full overflow-y-auto">
{cycles.map((cycle) => (
<CyclesListItem cycle={cycle} workspaceSlug={workspaceSlug} projectId={projectId} />
</div>
))}
</div>
))}
<CyclePeekOverview
projectId={projectId?.toString() ?? ""}
workspaceSlug={workspaceSlug?.toString() ?? ""}
/>
</div>
</div>
) : (
<div className="h-full grid place-items-center text-center">
@@ -68,6 +73,6 @@ export const CyclesList: FC<ICyclesList> = (props) => {
<Loader.Item height="50px" />
</Loader>
)}
</div>
</>
);
};
+15 -8
View File
@@ -15,16 +15,17 @@ export interface ICyclesView {
layout: TCycleLayout;
workspaceSlug: string;
projectId: string;
peekCycle: string;
}
export const CyclesView: FC<ICyclesView> = observer((props) => {
const { filter, layout, workspaceSlug, projectId } = props;
const { filter, layout, workspaceSlug, projectId, peekCycle } = props;
// store
const { cycle: cycleStore } = useMobxStore();
// api call to fetch cycles list
const { isLoading } = useSWR(
useSWR(
workspaceSlug && projectId && filter ? `CYCLES_LIST_${projectId}_${filter}` : null,
workspaceSlug && projectId && filter ? () => cycleStore.fetchCycles(workspaceSlug, projectId, filter) : null
);
@@ -35,10 +36,10 @@ export const CyclesView: FC<ICyclesView> = observer((props) => {
<>
{layout === "list" && (
<>
{!isLoading ? (
{cyclesList ? (
<CyclesList cycles={cyclesList} filter={filter} workspaceSlug={workspaceSlug} projectId={projectId} />
) : (
<Loader className="space-y-4">
<Loader className="space-y-4 p-8">
<Loader.Item height="50px" />
<Loader.Item height="50px" />
<Loader.Item height="50px" />
@@ -49,10 +50,16 @@ export const CyclesView: FC<ICyclesView> = observer((props) => {
{layout === "board" && (
<>
{!isLoading ? (
<CyclesBoard cycles={cyclesList} filter={filter} workspaceSlug={workspaceSlug} projectId={projectId} />
{cyclesList ? (
<CyclesBoard
cycles={cyclesList}
filter={filter}
workspaceSlug={workspaceSlug}
projectId={projectId}
peekCycle={peekCycle}
/>
) : (
<Loader className="grid grid-cols-1 gap-9 md:grid-cols-2 lg:grid-cols-3">
<Loader className="grid grid-cols-1 gap-9 md:grid-cols-2 lg:grid-cols-3 p-8">
<Loader.Item height="200px" />
<Loader.Item height="200px" />
<Loader.Item height="200px" />
@@ -63,7 +70,7 @@ export const CyclesView: FC<ICyclesView> = observer((props) => {
{layout === "gantt" && (
<>
{!isLoading ? (
{cyclesList ? (
<CyclesListGanttChartView cycles={cyclesList} workspaceSlug={workspaceSlug} />
) : (
<Loader className="space-y-4">
+2 -2
View File
@@ -7,8 +7,6 @@ export * from "./form";
export * from "./modal";
export * from "./select";
export * from "./sidebar";
export * from "./single-cycle-card";
export * from "./single-cycle-list";
export * from "./transfer-issues-modal";
export * from "./transfer-issues";
export * from "./cycles-list";
@@ -17,3 +15,5 @@ export * from "./cycles-board";
export * from "./cycles-board-card";
export * from "./cycles-gantt";
export * from "./delete-modal";
export * from "./cycle-peek-overview";
export * from "./cycles-list-item";
+249 -305
View File
@@ -16,35 +16,28 @@ import ProgressChart from "components/core/sidebar/progress-chart";
import { CycleDeleteModal } from "components/cycles/delete-modal";
// ui
import { CustomRangeDatePicker } from "components/ui";
import { CustomMenu, Loader, ProgressBar } from "@plane/ui";
import { Avatar, CustomMenu, Loader, LayersIcon } from "@plane/ui";
// icons
import {
CalendarDays,
ChevronDown,
File,
MoveRight,
LinkIcon,
PieChart,
Trash2,
UserCircle2,
AlertCircle,
} from "lucide-react";
import { ChevronDown, LinkIcon, Trash2, UserCircle2, AlertCircle, ChevronRight, MoveRight } from "lucide-react";
// helpers
import { capitalizeFirstLetter, copyUrlToClipboard } from "helpers/string.helper";
import { copyUrlToClipboard } from "helpers/string.helper";
import {
findHowManyDaysLeft,
getDateRangeStatus,
isDateGreaterThanToday,
renderDateFormat,
renderShortDateWithYearFormat,
renderShortDate,
renderShortMonthDate,
} from "helpers/date-time.helper";
// types
import { ICycle } from "types";
// fetch-keys
import { CYCLE_DETAILS } from "constants/fetch-keys";
import { CYCLE_STATUS } from "constants/cycle";
type Props = {
isOpen: boolean;
cycleId: string;
handleClose: () => void;
};
// services
@@ -52,12 +45,12 @@ const cycleService = new CycleService();
// TODO: refactor the whole component
export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
const { isOpen, cycleId } = props;
const { cycleId, handleClose } = props;
const [cycleDeleteModal, setCycleDeleteModal] = useState(false);
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { workspaceSlug, projectId, peekCycle } = router.query;
const { user: userStore, cycle: cycleDetailsStore } = useMobxStore();
@@ -144,7 +137,7 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
});
if (isDateValidForExistingCycle) {
await submitChanges({
submitChanges({
start_date: renderDateFormat(`${watch("start_date")}`),
end_date: renderDateFormat(`${watch("end_date")}`),
});
@@ -218,7 +211,7 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
});
if (isDateValidForExistingCycle) {
await submitChanges({
submitChanges({
start_date: renderDateFormat(`${watch("start_date")}`),
end_date: renderDateFormat(`${watch("end_date")}`),
});
@@ -280,6 +273,22 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
if (!cycleDetails) return null;
const endDate = new Date(cycleDetails.end_date ?? "");
const startDate = new Date(cycleDetails.start_date ?? "");
const areYearsEqual = startDate.getFullYear() === endDate.getFullYear();
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}`;
return (
<>
{cycleDetails && workspaceSlug && projectId && (
@@ -291,327 +300,262 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
projectId={projectId.toString()}
/>
)}
<div
className={`fixed top-[66px] ${
isOpen ? "right-0" : "-right-[24rem]"
} h-full w-[24rem] overflow-y-auto border-l border-custom-border-200 bg-custom-sidebar-background-100 pt-5 pb-10 duration-300`}
>
{cycleDetails ? (
<>
<div className="flex flex-col items-start justify-center">
<div className="flex gap-2.5 px-5 text-sm">
<div className="flex items-center">
<span className="flex items-center rounded border-[0.5px] border-custom-border-200 bg-custom-background-90 px-2 py-1 text-center text-xs capitalize">
{capitalizeFirstLetter(cycleStatus)}
</span>
</div>
<div className="relative flex h-full w-52 items-center gap-2">
<Popover className="flex h-full items-center justify-center rounded-lg">
{({}) => (
<>
<Popover.Button
disabled={isCompleted ?? false}
className={`group flex h-full items-center gap-2 whitespace-nowrap rounded border-[0.5px] border-custom-border-200 bg-custom-background-90 px-2 py-1 text-xs ${
cycleDetails.start_date ? "" : "text-custom-text-200"
}`}
>
<CalendarDays className="h-3 w-3" />
<span>
{renderShortDateWithYearFormat(
new Date(`${watch("start_date") ? watch("start_date") : cycleDetails?.start_date}`),
"Start date"
)}
</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 top-10 -right-5 z-20 transform overflow-hidden">
<CustomRangeDatePicker
value={watch("start_date") ? watch("start_date") : cycleDetails?.start_date}
onChange={(val) => {
if (val) {
handleStartDateChange(val);
}
}}
startDate={watch("start_date") ? `${watch("start_date")}` : null}
endDate={watch("end_date") ? `${watch("end_date")}` : null}
maxDate={new Date(`${watch("end_date")}`)}
selectsStart
/>
</Popover.Panel>
</Transition>
</>
)}
</Popover>
<span>
<MoveRight className="h-3 w-3 text-custom-text-200" />
</span>
<Popover className="flex h-full items-center justify-center rounded-lg">
{({}) => (
<>
<Popover.Button
disabled={isCompleted ?? false}
className={`group flex items-center gap-2 whitespace-nowrap rounded border-[0.5px] border-custom-border-200 bg-custom-background-90 px-2 py-1 text-xs ${
cycleDetails.end_date ? "" : "text-custom-text-200"
}`}
>
<CalendarDays className="h-3 w-3" />
{cycleDetails ? (
<>
<div className="flex items-center justify-between w-full">
<div>
{peekCycle && (
<button
className="flex items-center justify-center h-5 w-5 rounded-full bg-custom-border-300"
onClick={() => handleClose()}
>
<ChevronRight className="h-3 w-3 text-white stroke-2" />
</button>
)}
</div>
<div className="flex items-center gap-3.5">
<button onClick={handleCopyText}>
<LinkIcon className="h-3 w-3 text-custom-text-300" />
</button>
{!isCompleted && (
<CustomMenu width="lg" ellipsis>
<CustomMenu.MenuItem onClick={() => setCycleDeleteModal(true)}>
<span className="flex items-center justify-start gap-2">
<Trash2 className="h-4 w-4" />
<span>Delete</span>
</span>
</CustomMenu.MenuItem>
</CustomMenu>
)}
</div>
</div>
<span>
{renderShortDateWithYearFormat(
new Date(`${watch("end_date") ? watch("end_date") : cycleDetails?.end_date}`),
"End date"
)}
</span>
</Popover.Button>
<div className="flex flex-col gap-3">
<h4 className="text-xl font-semibold break-words w-full text-custom-text-100">{cycleDetails.name}</h4>
<div className="flex items-center gap-5">
{currentCycle && (
<span
className="flex items-center justify-center text-xs text-center h-6 w-20 rounded-sm"
style={{
color: currentCycle.color,
backgroundColor: `${currentCycle.color}20`,
}}
>
{currentCycle.value === "current"
? `${findHowManyDaysLeft(cycleDetails.end_date ?? new Date())} ${currentCycle.label}`
: `${currentCycle.label}`}
</span>
)}
<div className="relative flex h-full w-52 items-center gap-2.5">
<Popover className="flex h-full items-center justify-center rounded-lg">
<Popover.Button
disabled={isCompleted ?? false}
className="text-sm text-custom-text-300 font-medium cursor-default"
>
{areYearsEqual ? renderShortDate(startDate, "_ _") : renderShortMonthDate(startDate, "_ _")}
</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 top-10 -right-5 z-20 transform overflow-hidden">
<CustomRangeDatePicker
value={watch("end_date") ? watch("end_date") : cycleDetails?.end_date}
onChange={(val) => {
if (val) {
handleEndDateChange(val);
}
}}
startDate={watch("start_date") ? `${watch("start_date")}` : null}
endDate={watch("end_date") ? `${watch("end_date")}` : null}
minDate={new Date(`${watch("start_date")}`)}
selectsEnd
/>
</Popover.Panel>
</Transition>
</>
)}
</Popover>
</div>
<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 top-10 -right-5 z-20 transform overflow-hidden">
<CustomRangeDatePicker
value={watch("start_date") ? watch("start_date") : cycleDetails?.start_date}
onChange={(val) => {
if (val) {
handleStartDateChange(val);
}
}}
startDate={watch("start_date") ? `${watch("start_date")}` : null}
endDate={watch("end_date") ? `${watch("end_date")}` : null}
maxDate={new Date(`${watch("end_date")}`)}
selectsStart
/>
</Popover.Panel>
</Transition>
</Popover>
<MoveRight className="h-4 w-4 text-custom-text-300" />
<Popover className="flex h-full items-center justify-center rounded-lg">
{({}) => (
<>
<Popover.Button
disabled={isCompleted ?? false}
className="text-sm text-custom-text-300 font-medium cursor-default"
>
{areYearsEqual ? renderShortDate(endDate, "_ _") : renderShortMonthDate(endDate, "_ _")}
</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 top-10 -right-5 z-20 transform overflow-hidden">
<CustomRangeDatePicker
value={watch("end_date") ? watch("end_date") : cycleDetails?.end_date}
onChange={(val) => {
if (val) {
handleEndDateChange(val);
}
}}
startDate={watch("start_date") ? `${watch("start_date")}` : null}
endDate={watch("end_date") ? `${watch("end_date")}` : null}
minDate={new Date(`${watch("start_date")}`)}
selectsEnd
/>
</Popover.Panel>
</Transition>
</>
)}
</Popover>
</div>
</div>
</div>
<div className="flex w-full flex-col gap-6 px-6 py-6">
<div className="flex w-full flex-col items-start justify-start gap-2">
<div className="flex w-full items-start justify-between gap-2">
<div className="max-w-[300px]">
<h4 className="text-xl font-semibold text-custom-text-100 break-words w-full">
{cycleDetails.name}
</h4>
</div>
<CustomMenu width="lg" ellipsis>
{!isCompleted && (
<CustomMenu.MenuItem onClick={() => setCycleDeleteModal(true)}>
<span className="flex items-center justify-start gap-2">
<Trash2 className="h-4 w-4" />
<span>Delete</span>
</span>
</CustomMenu.MenuItem>
)}
<CustomMenu.MenuItem onClick={handleCopyText}>
<span className="flex items-center justify-start gap-2">
<LinkIcon className="h-4 w-4" />
<span>Copy link</span>
</span>
</CustomMenu.MenuItem>
</CustomMenu>
</div>
{cycleDetails.description && (
<span className="whitespace-normal text-sm leading-5 py-2.5 text-custom-text-200 break-words w-full">
{cycleDetails.description}
</span>
)}
<span className="whitespace-normal text-sm leading-5 text-custom-text-200 break-words w-full">
{cycleDetails.description}
</span>
</div>
<div className="flex flex-col gap-4 text-sm">
<div className="flex items-center justify-start gap-1">
<div className="flex w-40 items-center justify-start gap-2 text-custom-text-200">
<UserCircle2 className="h-5 w-5" />
<span>Lead</span>
</div>
<div className="flex items-center gap-2.5">
{cycleDetails.owned_by.avatar && cycleDetails.owned_by.avatar !== "" ? (
<img
src={cycleDetails.owned_by.avatar}
height={12}
width={12}
className="rounded-full"
alt={cycleDetails.owned_by.display_name}
/>
) : (
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-gray-800 capitalize text-white">
{cycleDetails.owned_by.display_name.charAt(0)}
</span>
)}
<span className="text-custom-text-200">{cycleDetails.owned_by.display_name}</span>
</div>
</div>
<div className="flex items-center justify-start gap-1">
<div className="flex w-40 items-center justify-start gap-2 text-custom-text-200">
<PieChart className="h-5 w-5" />
<span>Progress</span>
</div>
<div className="flex items-center gap-2.5 text-custom-text-200">
<span className="h-4 w-4">
<ProgressBar value={cycleDetails.completed_issues} maxValue={cycleDetails.total_issues} />
</span>
{cycleDetails.completed_issues}/{cycleDetails.total_issues}
</div>
</div>
<div className="flex flex-col gap-5 pt-2.5 pb-6">
<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">
<UserCircle2 className="h-4 w-4" />
<span className="text-base">Lead</span>
</div>
<div className="flex items-center w-1/2 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>
</div>
</div>
</div>
<div className="flex w-full flex-col items-center justify-start gap-2 border-t border-custom-border-200 p-6">
<Disclosure defaultOpen>
<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">
<LayersIcon className="h-4 w-4" />
<span className="text-base">Issues</span>
</div>
<div className="flex items-center w-1/2">
<span className="text-sm text-custom-text-300 px-1.5">{issueCount}</span>
</div>
</div>
</div>
<div className="flex flex-col">
<div className="flex w-full flex-col items-center justify-start gap-2 border-t border-custom-border-200 py-5 px-1.5">
<Disclosure>
{({ open }) => (
<div className={`relative flex h-full w-full flex-col ${open ? "" : "flex-row"}`}>
<div className="flex w-full items-center justify-between gap-2 ">
<div className="flex w-full items-center justify-between gap-2">
<div className="flex items-center justify-start gap-2 text-sm">
<span className="font-medium text-custom-text-200">Progress</span>
{!open && progressPercentage ? (
<span className="rounded bg-[#09A953]/10 px-1.5 py-0.5 text-xs text-[#09A953]">
</div>
<div className="flex items-center gap-2.5">
{progressPercentage ? (
<span className="flex items-center justify-center h-5 w-9 rounded text-xs font-medium text-amber-500 bg-amber-50">
{progressPercentage ? `${progressPercentage}%` : ""}
</span>
) : (
""
)}
{isStartValid && isEndValid ? (
<Disclosure.Button className="p-1.5">
<ChevronDown
className={`h-3 w-3 ${open ? "rotate-180 transform" : ""}`}
aria-hidden="true"
/>
</Disclosure.Button>
) : (
<div className="flex items-center gap-1">
<AlertCircle height={14} width={14} className="text-custom-text-200" />
<span className="text-xs italic text-custom-text-200">
Invalid date. Please enter valid date.
</span>
</div>
)}
</div>
{isStartValid && isEndValid ? (
<Disclosure.Button>
<ChevronDown className={`h-3 w-3 ${open ? "rotate-180 transform" : ""}`} aria-hidden="true" />
</Disclosure.Button>
) : (
<div className="flex items-center gap-1">
<AlertCircle className="h-3.5 w-3.5 text-custom-text-200" />
<span className="text-xs italic text-custom-text-200">
{cycleStatus === "upcoming"
? "Cycle is yet to start."
: "Invalid date. Please enter valid date."}
</span>
</div>
)}
</div>
<Transition show={open}>
<Disclosure.Panel>
{isStartValid && isEndValid ? (
<div className=" h-full w-full py-4">
<div className="flex items-start justify-between gap-4 py-2 text-xs">
<div className="flex items-center gap-1">
<span>
<File className="h-3 w-3 text-custom-text-200" />
</span>
<span>
Pending Issues -{" "}
{cycleDetails.total_issues -
(cycleDetails.completed_issues + cycleDetails.cancelled_issues)}
</span>
<div className="flex flex-col gap-3">
{isStartValid && isEndValid ? (
<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">
<div className="flex items-center justify-center gap-1">
<span className="h-2.5 w-2.5 rounded-full bg-[#A9BBD0]" />
<span>Ideal</span>
</div>
<div className="flex items-center justify-center gap-1">
<span className="h-2.5 w-2.5 rounded-full bg-[#4C8FFF]" />
<span>Current</span>
</div>
</div>
</div>
<div className="flex items-center gap-3 text-custom-text-100">
<div className="flex items-center justify-center gap-1">
<span className="h-2.5 w-2.5 rounded-full bg-[#A9BBD0]" />
<span>Ideal</span>
</div>
<div className="flex items-center justify-center gap-1">
<span className="h-2.5 w-2.5 rounded-full bg-[#4C8FFF]" />
<span>Current</span>
</div>
<div className="relative h-40 w-80">
<ProgressChart
distribution={cycleDetails.distribution.completion_chart}
startDate={cycleDetails.start_date ?? ""}
endDate={cycleDetails.end_date ?? ""}
totalIssues={cycleDetails.total_issues}
/>
</div>
</div>
<div className="relative">
<ProgressChart
distribution={cycleDetails.distribution.completion_chart}
startDate={cycleDetails.start_date ?? ""}
endDate={cycleDetails.end_date ?? ""}
) : (
""
)}
{cycleDetails.total_issues > 0 && (
<div className="h-full w-full pt-5 border-t border-custom-border-200">
<SidebarProgressStats
distribution={cycleDetails.distribution}
groupedIssues={{
backlog: cycleDetails.backlog_issues,
unstarted: cycleDetails.unstarted_issues,
started: cycleDetails.started_issues,
completed: cycleDetails.completed_issues,
cancelled: cycleDetails.cancelled_issues,
}}
totalIssues={cycleDetails.total_issues}
isPeekView={Boolean(peekCycle)}
/>
</div>
</div>
) : (
""
)}
</Disclosure.Panel>
</Transition>
</div>
)}
</Disclosure>
</div>
<div className="flex w-full flex-col items-center justify-start gap-2 border-t border-custom-border-200 p-6">
<Disclosure defaultOpen>
{({ open }) => (
<div className={`relative flex h-full w-full flex-col ${open ? "" : "flex-row"}`}>
<div className="flex w-full items-center justify-between gap-2">
<div className="flex items-center justify-start gap-2 text-sm">
<span className="font-medium text-custom-text-200">Other Information</span>
</div>
{cycleDetails.total_issues > 0 ? (
<Disclosure.Button>
<ChevronDown className={`h-3 w-3 ${open ? "rotate-180 transform" : ""}`} aria-hidden="true" />
</Disclosure.Button>
) : (
<div className="flex items-center gap-1">
<AlertCircle className="h-3.5 w-3.5 text-custom-text-200" />
<span className="text-xs italic text-custom-text-200">
No issues found. Please add issue.
</span>
)}
</div>
)}
</div>
<Transition show={open}>
<Disclosure.Panel>
{cycleDetails.total_issues > 0 ? (
<div className="h-full w-full py-4">
<SidebarProgressStats
distribution={cycleDetails.distribution}
groupedIssues={{
backlog: cycleDetails.backlog_issues,
unstarted: cycleDetails.unstarted_issues,
started: cycleDetails.started_issues,
completed: cycleDetails.completed_issues,
cancelled: cycleDetails.cancelled_issues,
}}
totalIssues={cycleDetails.total_issues}
/>
</div>
) : (
""
)}
</Disclosure.Panel>
</Transition>
</div>
)}
</Disclosure>
</div>
</>
) : (
<Loader className="px-5">
<div className="space-y-2">
<Loader.Item height="15px" width="50%" />
<Loader.Item height="15px" width="30%" />
</div>
<div className="mt-8 space-y-3">
<Loader.Item height="30px" />
<Loader.Item height="30px" />
<Loader.Item height="30px" />
</div>
</Loader>
)}
</div>
</div>
</>
) : (
<Loader className="px-5">
<div className="space-y-2">
<Loader.Item height="15px" width="50%" />
<Loader.Item height="15px" width="30%" />
</div>
<div className="mt-8 space-y-3">
<Loader.Item height="30px" />
<Loader.Item height="30px" />
<Loader.Item height="30px" />
</div>
</Loader>
)}
</>
);
});
-389
View File
@@ -1,389 +0,0 @@
import React from "react";
import Link from "next/link";
import { useRouter } from "next/router";
// headless ui
import { Disclosure, Transition } from "@headlessui/react";
// hooks
import useToast from "hooks/use-toast";
// components
import { SingleProgressStats } from "components/core";
// ui
import { CustomMenu, Tooltip, LinearProgressIndicator, ContrastIcon, RunningIcon } from "@plane/ui";
import { AssigneesList } from "components/ui/avatar";
// icons
import {
AlarmClock,
AlertTriangle,
ArrowRight,
CalendarDays,
ChevronDown,
LinkIcon,
Pencil,
Star,
Target,
Trash2,
} from "lucide-react";
// helpers
import { getDateRangeStatus, renderShortDateWithYearFormat, findHowManyDaysLeft } from "helpers/date-time.helper";
import { copyTextToClipboard, truncateText } from "helpers/string.helper";
// types
import { ICycle } from "types";
type TSingleStatProps = {
cycle: ICycle;
handleEditCycle: () => void;
handleDeleteCycle: () => void;
handleAddToFavorites: () => void;
handleRemoveFromFavorites: () => void;
};
const stateGroups = [
{
key: "backlog_issues",
title: "Backlog",
color: "#dee2e6",
},
{
key: "unstarted_issues",
title: "Unstarted",
color: "#26b5ce",
},
{
key: "started_issues",
title: "Started",
color: "#f7ae59",
},
{
key: "cancelled_issues",
title: "Cancelled",
color: "#d687ff",
},
{
key: "completed_issues",
title: "Completed",
color: "#09a953",
},
];
export const SingleCycleCard: React.FC<TSingleStatProps> = ({
cycle,
handleEditCycle,
handleDeleteCycle,
handleAddToFavorites,
handleRemoveFromFavorites,
}) => {
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { setToastAlert } = useToast();
const cycleStatus = getDateRangeStatus(cycle.start_date, cycle.end_date);
const isCompleted = cycleStatus === "completed";
const endDate = new Date(cycle.end_date ?? "");
const startDate = new Date(cycle.start_date ?? "");
const handleCopyText = () => {
const originURL = typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
copyTextToClipboard(`${originURL}/${workspaceSlug}/projects/${projectId}/cycles/${cycle.id}`).then(() => {
setToastAlert({
type: "success",
title: "Link Copied!",
message: "Cycle link copied to clipboard.",
});
});
};
const progressIndicatorData = stateGroups.map((group, index) => ({
id: index,
name: group.title,
value: cycle.total_issues > 0 ? ((cycle[group.key as keyof ICycle] as number) / cycle.total_issues) * 100 : 0,
color: group.color,
}));
const groupedIssues: any = {
backlog: cycle.backlog_issues,
unstarted: cycle.unstarted_issues,
started: cycle.started_issues,
completed: cycle.completed_issues,
cancelled: cycle.cancelled_issues,
};
return (
<div>
<div className="flex flex-col rounded-[10px] bg-custom-background-100 border border-custom-border-200 text-xs shadow">
<Link href={`/${workspaceSlug}/projects/${projectId}/cycles/${cycle.id}`}>
<a className="w-full">
<div className="flex h-full flex-col gap-4 rounded-b-[10px] p-4">
<div className="flex items-center justify-between gap-1">
<span className="flex items-center gap-1">
<span className="h-5 w-5">
<ContrastIcon
className="h-5 w-5"
color={`${
cycleStatus === "current"
? "#09A953"
: cycleStatus === "upcoming"
? "#F7AE59"
: cycleStatus === "completed"
? "#3F76FF"
: cycleStatus === "draft"
? "rgb(var(--color-text-200))"
: ""
}`}
/>
</span>
<Tooltip tooltipContent={cycle.name} className="break-words" position="top-left">
<h3 className="break-words text-lg font-semibold">{truncateText(cycle.name, 15)}</h3>
</Tooltip>
</span>
<span className="flex items-center gap-1 capitalize">
<span
className={`rounded-full px-1.5 py-0.5
${
cycleStatus === "current"
? "bg-green-600/5 text-green-600"
: cycleStatus === "upcoming"
? "bg-orange-300/5 text-orange-300"
: cycleStatus === "completed"
? "bg-blue-500/5 text-blue-500"
: cycleStatus === "draft"
? "bg-neutral-400/5 text-neutral-400"
: ""
}`}
>
{cycleStatus === "current" ? (
<span className="flex gap-1 whitespace-nowrap">
<RunningIcon className="h-4 w-4" />
{findHowManyDaysLeft(cycle.end_date ?? new Date())} Days Left
</span>
) : cycleStatus === "upcoming" ? (
<span className="flex gap-1 whitespace-nowrap">
<AlarmClock className="h-4 w-4" />
{findHowManyDaysLeft(cycle.start_date ?? new Date())} Days Left
</span>
) : cycleStatus === "completed" ? (
<span className="flex gap-1 whitespace-nowrap">
{cycle.total_issues - cycle.completed_issues > 0 && (
<Tooltip
tooltipContent={`${cycle.total_issues - cycle.completed_issues} more pending ${
cycle.total_issues - cycle.completed_issues === 1 ? "issue" : "issues"
}`}
>
<span>
<AlertTriangle className="h-3.5 w-3.5" />
</span>
</Tooltip>
)}{" "}
Completed
</span>
) : (
cycleStatus
)}
</span>
{cycle.is_favorite ? (
<button
onClick={(e) => {
e.preventDefault();
handleRemoveFromFavorites();
}}
>
<Star className="h-4 w-4 text-orange-400" fill="#f6ad55" />
</button>
) : (
<button
onClick={(e) => {
e.preventDefault();
handleAddToFavorites();
}}
>
<Star className="h-4 w-4 " color="rgb(var(--color-text-200))" />
</button>
)}
</span>
</div>
<div className="flex h-4 items-center justify-start gap-5 text-custom-text-200">
{cycleStatus !== "draft" && (
<>
<div className="flex items-start gap-1">
<CalendarDays className="h-4 w-4" />
<span>{renderShortDateWithYearFormat(startDate)}</span>
</div>
<ArrowRight className="h-4 w-4" />
<div className="flex items-start gap-1">
<Target className="h-4 w-4" />
<span>{renderShortDateWithYearFormat(endDate)}</span>
</div>
</>
)}
</div>
<div className="flex justify-between items-end">
<div className="flex flex-col gap-2 text-xs text-custom-text-200">
<div className="flex items-center gap-2">
<div className="w-16">Creator:</div>
<div className="flex items-center gap-2.5 text-custom-text-200">
{cycle.owned_by.avatar && cycle.owned_by.avatar !== "" ? (
<img
src={cycle.owned_by.avatar}
height={16}
width={16}
className="rounded-full"
alt={cycle.owned_by.display_name}
/>
) : (
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-orange-300 capitalize text-white">
{cycle.owned_by.display_name.charAt(0)}
</span>
)}
<span className="text-custom-text-200">{cycle.owned_by.display_name}</span>
</div>
</div>
<div className="flex h-5 items-center gap-2">
<div className="w-16">Members:</div>
{cycle.assignees.length > 0 ? (
<div className="flex items-center gap-1 text-custom-text-200">
<AssigneesList users={cycle.assignees} length={4} />
</div>
) : (
"No members"
)}
</div>
</div>
<div className="flex items-center">
{!isCompleted && (
<button
onClick={(e) => {
e.preventDefault();
handleEditCycle();
}}
className="cursor-pointer rounded p-1 text-custom-text-200 duration-300 hover:bg-custom-background-80"
>
<Pencil className="h-4 w-4" />
</button>
)}
<CustomMenu width="auto" verticalEllipsis>
{!isCompleted && (
<CustomMenu.MenuItem
onClick={(e) => {
e.preventDefault();
handleDeleteCycle();
}}
>
<span className="flex items-center justify-start gap-2">
<Trash2 className="h-4 w-4" />
<span>Delete cycle</span>
</span>
</CustomMenu.MenuItem>
)}
<CustomMenu.MenuItem
onClick={(e) => {
e.preventDefault();
handleCopyText();
}}
>
<span className="flex items-center justify-start gap-2">
<LinkIcon className="h-4 w-4" />
<span>Copy cycle link</span>
</span>
</CustomMenu.MenuItem>
</CustomMenu>
</div>
</div>
</div>
</a>
</Link>
<div className="flex h-full flex-col rounded-b-[10px]">
<Disclosure>
{({ open }) => (
<div
className={`flex h-full w-full flex-col rounded-b-[10px] border-t border-custom-border-200 bg-custom-background-80 text-custom-text-200 ${
open ? "" : "flex-row"
}`}
>
<div className="flex w-full items-center gap-2 px-4 py-1">
<span>Progress</span>
<Tooltip
tooltipContent={
<div className="flex w-56 flex-col">
{Object.keys(groupedIssues).map((group, index) => (
<SingleProgressStats
key={index}
title={
<div className="flex items-center gap-2">
<span
className="block h-3 w-3 rounded-full "
style={{
backgroundColor: stateGroups[index].color,
}}
/>
<span className="text-xs capitalize">{group}</span>
</div>
}
completed={groupedIssues[group]}
total={cycle.total_issues}
/>
))}
</div>
}
position="bottom"
>
<div className="flex w-full items-center">
<LinearProgressIndicator data={progressIndicatorData} noTooltip={true} />
</div>
</Tooltip>
<Disclosure.Button>
<span className="p-1">
<ChevronDown className={`h-3 w-3 ${open ? "rotate-180 transform" : ""}`} aria-hidden="true" />
</span>
</Disclosure.Button>
</div>
<Transition show={open}>
<Disclosure.Panel>
<div className="overflow-hidden rounded-b-md bg-custom-background-80 py-3 shadow">
<div className="col-span-2 space-y-3 px-4">
<div className="space-y-3 text-xs">
{stateGroups.map((group) => (
<div key={group.key} className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<span
className="block h-2 w-2 rounded-full"
style={{
backgroundColor: group.color,
}}
/>
<h6 className="text-xs">{group.title}</h6>
</div>
<div>
<span>
{cycle[group.key as keyof ICycle] as number}{" "}
<span className="text-custom-text-200">
-{" "}
{cycle.total_issues > 0
? `${Math.round(
((cycle[group.key as keyof ICycle] as number) / cycle.total_issues) * 100
)}%`
: "0%"}
</span>
</span>
</div>
</div>
))}
</div>
</div>
</div>
</Disclosure.Panel>
</Transition>
</div>
)}
</Disclosure>
</div>
</div>
</div>
);
};
-369
View File
@@ -1,369 +0,0 @@
import React, { useEffect, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/router";
// hooks
import useToast from "hooks/use-toast";
// ui
import { CustomMenu, Tooltip, LinearProgressIndicator, ContrastIcon, RunningIcon } from "@plane/ui";
// icons
import {
AlarmClock,
AlertTriangle,
ArrowRight,
CalendarDays,
LinkIcon,
Pencil,
Star,
Target,
Trash2,
} from "lucide-react";
// helpers
import { getDateRangeStatus, renderShortDateWithYearFormat, findHowManyDaysLeft } from "helpers/date-time.helper";
import { copyTextToClipboard, truncateText } from "helpers/string.helper";
// types
import { ICycle } from "types";
type TSingleStatProps = {
cycle: ICycle;
handleEditCycle: () => void;
handleDeleteCycle: () => void;
handleAddToFavorites: () => void;
handleRemoveFromFavorites: () => void;
};
const stateGroups = [
{
key: "backlog_issues",
title: "Backlog",
color: "#dee2e6",
},
{
key: "unstarted_issues",
title: "Unstarted",
color: "#26b5ce",
},
{
key: "started_issues",
title: "Started",
color: "#f7ae59",
},
{
key: "cancelled_issues",
title: "Cancelled",
color: "#d687ff",
},
{
key: "completed_issues",
title: "Completed",
color: "#09a953",
},
];
type progress = {
progress: number;
};
function RadialProgressBar({ progress }: progress) {
const [circumference, setCircumference] = useState(0);
useEffect(() => {
const radius = 40;
const circumference = 2 * Math.PI * radius;
setCircumference(circumference);
}, []);
const progressOffset = ((100 - progress) / 100) * circumference;
return (
<div className="relative h-4 w-4">
<svg className="absolute top-0 left-0" viewBox="0 0 100 100">
<circle
className={"stroke-current opacity-10"}
cx="50"
cy="50"
r="40"
strokeWidth="12"
fill="none"
strokeDasharray={`${circumference} ${circumference}`}
/>
<circle
className={`stroke-current`}
cx="50"
cy="50"
r="40"
strokeWidth="12"
fill="none"
strokeDasharray={`${circumference} ${circumference}`}
strokeDashoffset={progressOffset}
transform="rotate(-90 50 50)"
/>
</svg>
</div>
);
}
export const SingleCycleList: React.FC<TSingleStatProps> = ({
cycle,
handleEditCycle,
handleDeleteCycle,
handleAddToFavorites,
handleRemoveFromFavorites,
}) => {
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { setToastAlert } = useToast();
const cycleStatus = getDateRangeStatus(cycle.start_date, cycle.end_date);
const isCompleted = cycleStatus === "completed";
const endDate = new Date(cycle.end_date ?? "");
const startDate = new Date(cycle.start_date ?? "");
const handleCopyText = () => {
const originURL = typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
copyTextToClipboard(`${originURL}/${workspaceSlug}/projects/${projectId}/cycles/${cycle.id}`).then(() => {
setToastAlert({
type: "success",
title: "Link Copied!",
message: "Cycle link copied to clipboard.",
});
});
};
const progressIndicatorData = stateGroups.map((group, index) => ({
id: index,
name: group.title,
value: cycle.total_issues > 0 ? ((cycle[group.key as keyof ICycle] as number) / cycle.total_issues) * 100 : 0,
color: group.color,
}));
const completedIssues = cycle.completed_issues + cycle.cancelled_issues;
const percentage = cycle.total_issues > 0 ? (completedIssues / cycle.total_issues) * 100 : 0;
return (
<div>
<div className="flex flex-col text-xs hover:bg-custom-background-80">
<Link href={`/${workspaceSlug}/projects/${projectId}/cycles/${cycle.id}`}>
<a className="w-full">
<div className="flex h-full flex-col gap-4 rounded-b-[10px] p-4">
<div className="flex items-center justify-between gap-1">
<div className="flex items-start gap-2">
<ContrastIcon
className="mt-1 h-5 w-5"
color={`${
cycleStatus === "current"
? "#09A953"
: cycleStatus === "upcoming"
? "#F7AE59"
: cycleStatus === "completed"
? "#3F76FF"
: cycleStatus === "draft"
? "rgb(var(--color-text-200))"
: ""
}`}
/>
<div className="max-w-2xl">
<Tooltip tooltipContent={cycle.name} className="break-words" position="top-left">
<h3 className="break-words w-full text-base font-semibold">{truncateText(cycle.name, 60)}</h3>
</Tooltip>
<p className="mt-2 text-custom-text-200 break-words w-full">{cycle.description}</p>
</div>
</div>
<div className="flex-shrink-0 flex items-center gap-4">
<span
className={`rounded-full px-1.5 py-0.5
${
cycleStatus === "current"
? "bg-green-600/5 text-green-600"
: cycleStatus === "upcoming"
? "bg-orange-300/5 text-orange-300"
: cycleStatus === "completed"
? "bg-blue-500/5 text-blue-500"
: cycleStatus === "draft"
? "bg-neutral-400/5 text-neutral-400"
: ""
}`}
>
{cycleStatus === "current" ? (
<span className="flex gap-1 whitespace-nowrap">
<RunningIcon className="h-4 w-4" />
{findHowManyDaysLeft(cycle.end_date ?? new Date())} days left
</span>
) : cycleStatus === "upcoming" ? (
<span className="flex gap-1">
<AlarmClock className="h-4 w-4" />
{findHowManyDaysLeft(cycle.start_date ?? new Date())} days left
</span>
) : cycleStatus === "completed" ? (
<span className="flex items-center gap-1">
{cycle.total_issues - cycle.completed_issues > 0 && (
<Tooltip
tooltipContent={`${cycle.total_issues - cycle.completed_issues} more pending ${
cycle.total_issues - cycle.completed_issues === 1 ? "issue" : "issues"
}`}
>
<span>
<AlertTriangle className="h-3.5 w-3.5" />
</span>
</Tooltip>
)}{" "}
Completed
</span>
) : (
cycleStatus
)}
</span>
{cycleStatus !== "draft" && (
<div className="flex items-center justify-start gap-2 text-custom-text-200">
<div className="flex items-start gap-1 whitespace-nowrap">
<CalendarDays className="h-4 w-4" />
<span>{renderShortDateWithYearFormat(startDate)}</span>
</div>
<ArrowRight className="h-4 w-4" />
<div className="flex items-start gap-1 whitespace-nowrap">
<Target className="h-4 w-4" />
<span>{renderShortDateWithYearFormat(endDate)}</span>
</div>
</div>
)}
<div className="flex items-center gap-2.5 text-custom-text-200">
{cycle.owned_by.avatar && cycle.owned_by.avatar !== "" ? (
<img
src={cycle.owned_by.avatar}
height={16}
width={16}
className="rounded-full"
alt={cycle.owned_by.display_name}
/>
) : (
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-orange-300 capitalize text-white">
{cycle.owned_by.display_name.charAt(0)}
</span>
)}
</div>
<Tooltip
position="top-right"
tooltipContent={
<div className="flex w-80 items-center gap-2 px-4 py-1">
<span>Progress</span>
<LinearProgressIndicator data={progressIndicatorData} />
</div>
}
>
<span
className={`rounded-md px-1.5 py-1
${
cycleStatus === "current"
? "border border-green-600 bg-green-600/5 text-green-600"
: cycleStatus === "upcoming"
? "border border-orange-300 bg-orange-300/5 text-orange-300"
: cycleStatus === "completed"
? "border border-blue-500 bg-blue-500/5 text-blue-500"
: cycleStatus === "draft"
? "border border-neutral-400 bg-neutral-400/5 text-neutral-400"
: ""
}`}
>
{cycleStatus === "current" ? (
<span className="flex gap-1 whitespace-nowrap">
{cycle.total_issues > 0 ? (
<>
<RadialProgressBar progress={(cycle.completed_issues / cycle.total_issues) * 100} />
<span>{Math.floor((cycle.completed_issues / cycle.total_issues) * 100)} %</span>
</>
) : (
<span className="normal-case">No issues present</span>
)}
</span>
) : cycleStatus === "upcoming" ? (
<span className="flex gap-1">
<RadialProgressBar progress={100} /> Yet to start
</span>
) : cycleStatus === "completed" ? (
<span className="flex gap-1">
<RadialProgressBar progress={100} />
<span>{Math.round(percentage)} %</span>
</span>
) : (
<span className="flex gap-1">
<RadialProgressBar progress={(cycle.total_issues / cycle.completed_issues) * 100} />
{cycleStatus}
</span>
)}
</span>
</Tooltip>
{cycle.is_favorite ? (
<button
onClick={(e) => {
e.preventDefault();
handleRemoveFromFavorites();
}}
>
<Star className="h-4 w-4 text-orange-400" fill="#f6ad55" />
</button>
) : (
<button
onClick={(e) => {
e.preventDefault();
handleAddToFavorites();
}}
>
<Star className="h-4 w-4 " color="rgb(var(--color-text-200))" />
</button>
)}
<div className="flex items-center">
<CustomMenu width="auto" verticalEllipsis>
{!isCompleted && (
<CustomMenu.MenuItem
onClick={(e) => {
e.preventDefault();
handleEditCycle();
}}
>
<span className="flex items-center justify-start gap-2">
<Pencil className="h-4 w-4" />
<span>Edit Cycle</span>
</span>
</CustomMenu.MenuItem>
)}
{!isCompleted && (
<CustomMenu.MenuItem
onClick={(e) => {
e.preventDefault();
handleDeleteCycle();
}}
>
<span className="flex items-center justify-start gap-2">
<Trash2 className="h-4 w-4" />
<span>Delete cycle</span>
</span>
</CustomMenu.MenuItem>
)}
<CustomMenu.MenuItem
onClick={(e) => {
e.preventDefault();
handleCopyText();
}}
>
<span className="flex items-center justify-start gap-2">
<LinkIcon className="h-4 w-4" />
<span>Copy cycle link</span>
</span>
</CustomMenu.MenuItem>
</CustomMenu>
</div>
</div>
</div>
</div>
</a>
</Link>
</div>
</div>
);
};
@@ -1,15 +1,11 @@
import React, { useEffect } from "react";
import { useRouter } from "next/router";
import { mutate } from "swr";
// react-hook-form
import { Controller, useForm } from "react-hook-form";
// headless ui
import { Dialog, Transition } from "@headlessui/react";
// services
import { ProjectEstimateService } from "services/project";
// store
import { observer } from "mobx-react-lite";
import { useMobxStore } from "lib/mobx/store-provider";
// hooks
import useToast from "hooks/use-toast";
// ui
@@ -17,29 +13,15 @@ import { Button, Input, TextArea } from "@plane/ui";
// helpers
import { checkDuplicates } from "helpers/array.helper";
// types
import { IUser, IEstimate, IEstimateFormData } from "types";
// fetch-keys
import { ESTIMATES_LIST, ESTIMATE_DETAILS } from "constants/fetch-keys";
import { IEstimate, IEstimateFormData } from "types";
type Props = {
isOpen: boolean;
handleClose: () => void;
data?: IEstimate;
user: IUser | undefined;
};
type FormValues = {
name: string;
description: string;
value1: string;
value2: string;
value3: string;
value4: string;
value5: string;
value6: string;
};
const defaultValues: Partial<FormValues> = {
const defaultValues = {
name: "",
description: "",
value1: "",
@@ -50,10 +32,18 @@ const defaultValues: Partial<FormValues> = {
value6: "",
};
// services
const projectEstimateService = new ProjectEstimateService();
type FormValues = typeof defaultValues;
export const CreateUpdateEstimateModal: React.FC<Props> = observer((props) => {
const { handleClose, data, isOpen } = props;
// router
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
// store
const { projectEstimates: projectEstimatesStore } = useMobxStore();
export const CreateUpdateEstimateModal: React.FC<Props> = ({ handleClose, data, isOpen, user }) => {
const {
formState: { errors, isSubmitting },
handleSubmit,
@@ -68,71 +58,47 @@ export const CreateUpdateEstimateModal: React.FC<Props> = ({ handleClose, data,
reset();
};
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { setToastAlert } = useToast();
const createEstimate = async (payload: IEstimateFormData) => {
if (!workspaceSlug || !projectId) return;
await projectEstimateService
.createEstimate(workspaceSlug as string, projectId as string, payload, user)
await projectEstimatesStore
.createEstimate(workspaceSlug.toString(), projectId.toString(), payload)
.then(() => {
mutate(ESTIMATES_LIST(projectId as string));
onClose();
})
.catch((err) => {
if (err.status === 400)
setToastAlert({
type: "error",
title: "Error!",
message: "Estimate with that name already exists. Please try again with another name.",
});
else
setToastAlert({
type: "error",
title: "Error!",
message: "Estimate could not be created. Please try again.",
});
const error = err?.error;
const errorString = Array.isArray(error) ? error[0] : error;
setToastAlert({
type: "error",
title: "Error!",
message:
errorString ?? err.status === 400
? "Estimate with that name already exists. Please try again with another name."
: "Estimate could not be created. Please try again.",
});
});
};
const updateEstimate = async (payload: IEstimateFormData) => {
if (!workspaceSlug || !projectId || !data) return;
mutate<IEstimate[]>(
ESTIMATES_LIST(projectId.toString()),
(prevData) =>
prevData?.map((p) => {
if (p.id === data.id)
return {
...p,
name: payload.estimate.name,
description: payload.estimate.description,
points: p.points.map((point, index) => ({
...point,
value: payload.estimate_points[index].value,
})),
};
return p;
}),
false
);
await projectEstimateService
.patchEstimate(workspaceSlug as string, projectId as string, data?.id as string, payload, user)
await projectEstimatesStore
.updateEstimate(workspaceSlug.toString(), projectId.toString(), data.id, payload)
.then(() => {
mutate(ESTIMATES_LIST(projectId.toString()));
mutate(ESTIMATE_DETAILS(data.id));
handleClose();
})
.catch(() => {
.catch((err) => {
const error = err?.error;
const errorString = Array.isArray(error) ? error[0] : error;
setToastAlert({
type: "error",
title: "Error!",
message: "Estimate could not be updated. Please try again.",
message: errorString ?? "Estimate could not be updated. Please try again.",
});
});
@@ -291,151 +257,38 @@ export const CreateUpdateEstimateModal: React.FC<Props> = ({ handleClose, data,
)}
/>
</div>
{/* list of all the points */}
{/* since they are all the same, we can use a loop to render them */}
<div className="grid grid-cols-3 gap-3">
<div className="flex items-center">
<span className="flex h-full items-center rounded-lg bg-custom-background-80">
<span className="rounded-lg px-2 text-sm text-custom-text-200">1</span>
<span className="rounded-r-lg bg-custom-background-100">
<Controller
control={control}
name="value1"
render={({ field: { value, onChange, ref } }) => (
<Input
id="value1"
name="value1"
type="text"
value={value}
onChange={onChange}
ref={ref}
hasError={Boolean(errors.value1)}
placeholder="Point 1"
className="rounded-l-none w-full"
{Array(6)
.fill(0)
.map((_, i) => (
<div className="flex items-center">
<span className="flex h-full items-center rounded-lg bg-custom-background-80">
<span className="rounded-lg px-2 text-sm text-custom-text-200">{i + 1}</span>
<span className="rounded-r-lg bg-custom-background-100">
<Controller
control={control}
name={`value${i + 1}` as keyof FormValues}
render={({ field: { value, onChange, ref } }) => (
<Input
ref={ref}
type="text"
value={value}
onChange={onChange}
id={`value${i + 1}`}
name={`value${i + 1}`}
placeholder={`Point ${i + 1}`}
className="rounded-l-none w-full"
hasError={Boolean(errors[`value${i + 1}` as keyof FormValues])}
/>
)}
/>
)}
/>
</span>
</span>
</div>
<div className="flex items-center">
<span className="flex h-full items-center rounded-lg bg-custom-background-80">
<span className="rounded-lg px-2 text-sm text-custom-text-200">2</span>
<span className="rounded-r-lg bg-custom-background-100">
<Controller
control={control}
name="value2"
render={({ field: { value, onChange, ref } }) => (
<Input
id="value2"
name="value2"
type="text"
value={value}
onChange={onChange}
ref={ref}
hasError={Boolean(errors.value2)}
placeholder="Point 2"
className="rounded-l-none w-full"
/>
)}
/>
</span>
</span>
</div>
<div className="flex items-center">
<span className="flex h-full items-center rounded-lg bg-custom-background-80">
<span className="rounded-lg px-2 text-sm text-custom-text-200">3</span>
<span className="rounded-r-lg bg-custom-background-100">
<Controller
control={control}
name="value3"
render={({ field: { value, onChange, ref } }) => (
<Input
id="value3"
name="value3"
type="text"
value={value}
onChange={onChange}
ref={ref}
hasError={Boolean(errors.value3)}
placeholder="Point 3"
className="rounded-l-none w-full"
/>
)}
/>
</span>
</span>
</div>
<div className="flex items-center">
<span className="flex h-full items-center rounded-lg bg-custom-background-80">
<span className="rounded-lg px-2 text-sm text-custom-text-200">4</span>
<span className="rounded-r-lg bg-custom-background-100">
<Controller
control={control}
name="value4"
render={({ field: { value, onChange, ref } }) => (
<Input
id="value4"
name="value4"
type="text"
value={value}
onChange={onChange}
ref={ref}
hasError={Boolean(errors.value4)}
placeholder="Point 4"
className="rounded-l-none w-full"
/>
)}
/>
</span>
</span>
</div>
<div className="flex items-center">
<span className="flex h-full items-center rounded-lg bg-custom-background-80">
<span className="rounded-lg px-2 text-sm text-custom-text-200">5</span>
<span className="rounded-r-lg bg-custom-background-100">
<Controller
control={control}
name="value5"
render={({ field: { value, onChange, ref } }) => (
<Input
id="value5"
name="value5"
type="text"
value={value}
onChange={onChange}
ref={ref}
hasError={Boolean(errors.value5)}
placeholder="Point 5"
className="rounded-l-none w-full"
/>
)}
/>
</span>
</span>
</div>
<div className="flex items-center">
<span className="flex h-full items-center rounded-lg bg-custom-background-80">
<span className="rounded-lg px-2 text-sm text-custom-text-200">6</span>
<span className="rounded-r-lg bg-custom-background-100">
<Controller
control={control}
name="value6"
render={({ field: { value, onChange, ref } }) => (
<Input
id="value6"
name="value6"
type="text"
value={value}
onChange={onChange}
ref={ref}
hasError={Boolean(errors.value6)}
placeholder="Point 6"
className="rounded-l-none w-full"
/>
)}
/>
</span>
</span>
</div>
</span>
</span>
</div>
))}
</div>
</div>
<div className="mt-5 flex justify-end gap-2">
@@ -461,4 +314,4 @@ export const CreateUpdateEstimateModal: React.FC<Props> = ({ handleClose, data,
</Transition.Root>
</>
);
};
});
@@ -1,10 +1,13 @@
import React, { useEffect, useState } from "react";
// headless ui
import { useRouter } from "next/router";
import { Dialog, Transition } from "@headlessui/react";
// store
import { observer } from "mobx-react-lite";
import { useMobxStore } from "lib/mobx/store-provider";
// hooks
import useToast from "hooks/use-toast";
// types
import { IEstimate } from "types";
// icons
import { AlertTriangle } from "lucide-react";
// ui
@@ -12,14 +15,43 @@ import { Button } from "@plane/ui";
type Props = {
isOpen: boolean;
data: IEstimate | null;
handleClose: () => void;
data: IEstimate;
handleDelete: () => void;
};
export const DeleteEstimateModal: React.FC<Props> = ({ isOpen, handleClose, data, handleDelete }) => {
export const DeleteEstimateModal: React.FC<Props> = observer((props) => {
const { isOpen, handleClose, data } = props;
// router
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
// store
const { projectEstimates: projectEstimatesStore } = useMobxStore();
// states
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
// hooks
const { setToastAlert } = useToast();
const handleEstimateDelete = () => {
if (!workspaceSlug || !projectId) return;
const estimateId = data?.id!;
projectEstimatesStore.deleteEstimate(workspaceSlug.toString(), projectId.toString(), estimateId).catch((err) => {
const error = err?.error;
const errorString = Array.isArray(error) ? error[0] : error;
setToastAlert({
type: "error",
title: "Error!",
message: errorString ?? "Estimate could not be deleted. Please try again",
});
});
};
useEffect(() => {
setIsDeleteLoading(false);
}, [isOpen]);
@@ -68,7 +100,7 @@ export const DeleteEstimateModal: React.FC<Props> = ({ isOpen, handleClose, data
<span>
<p className="break-words text-sm leading-7 text-custom-text-200">
Are you sure you want to delete estimate-{" "}
<span className="break-words font-medium text-custom-text-100">{data.name}</span>
<span className="break-words font-medium text-custom-text-100">{data?.name}</span>
{""}? All of the data related to the estiamte will be permanently removed. This action cannot be
undone.
</p>
@@ -81,7 +113,7 @@ export const DeleteEstimateModal: React.FC<Props> = ({ isOpen, handleClose, data
variant="danger"
onClick={() => {
setIsDeleteLoading(true);
handleDelete();
handleEstimateDelete();
}}
loading={isDeleteLoading}
>
@@ -96,4 +128,4 @@ export const DeleteEstimateModal: React.FC<Props> = ({ isOpen, handleClose, data
</Dialog>
</Transition.Root>
);
};
});
@@ -1,14 +1,12 @@
import React, { useState } from "react";
import React from "react";
import { useRouter } from "next/router";
// services
import { ProjectService } from "services/project";
// store
import { observer } from "mobx-react-lite";
import { useMobxStore } from "lib/mobx/store-provider";
// hooks
import useToast from "hooks/use-toast";
import useProjectDetails from "hooks/use-project-details";
// components
import { DeleteEstimateModal } from "components/estimates";
// ui
import { Button, CustomMenu } from "@plane/ui";
//icons
@@ -16,48 +14,46 @@ import { Pencil, Trash2 } from "lucide-react";
// helpers
import { orderArrayBy } from "helpers/array.helper";
// types
import { IUser, IEstimate } from "types";
import { IEstimate } from "types";
type Props = {
user: IUser | undefined;
estimate: IEstimate;
editEstimate: (estimate: IEstimate) => void;
handleEstimateDelete: (estimateId: string) => void;
deleteEstimate: (estimateId: string) => void;
};
// services
const projectService = new ProjectService();
export const SingleEstimate: React.FC<Props> = ({ user, estimate, editEstimate, handleEstimateDelete }) => {
const [isDeleteEstimateModalOpen, setIsDeleteEstimateModalOpen] = useState(false);
export const EstimateListItem: React.FC<Props> = observer((props) => {
const { estimate, editEstimate, deleteEstimate } = props;
// router
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
// store
const { project: projectStore } = useMobxStore();
const { setToastAlert } = useToast();
const { projectDetails, mutateProjectDetails } = useProjectDetails();
// derived values
const projectDetails = projectStore.project_details?.[projectId?.toString()!];
const handleUseEstimate = async () => {
if (!workspaceSlug || !projectId) return;
const payload = {
estimate: estimate.id,
};
await projectStore
.updateProject(workspaceSlug.toString(), projectId.toString(), {
estimate: estimate.id,
})
.catch((err) => {
const error = err?.error;
const errorString = Array.isArray(error) ? error[0] : error;
mutateProjectDetails((prevData: any) => {
if (!prevData) return prevData;
return { ...prevData, estimate: estimate.id };
}, false);
await projectService.updateProject(workspaceSlug as string, projectId as string, payload, user).catch(() => {
setToastAlert({
type: "error",
title: "Error!",
message: "Estimate points could not be used. Please try again.",
setToastAlert({
type: "error",
title: "Error!",
message: errorString ?? "Estimate points could not be used. Please try again.",
});
});
});
};
return (
@@ -76,7 +72,7 @@ export const SingleEstimate: React.FC<Props> = ({ user, estimate, editEstimate,
</p>
</div>
<div className="flex items-center gap-2">
{projectDetails?.estimate !== estimate.id && estimate.points.length > 0 && (
{projectDetails?.estimate !== estimate?.id && estimate?.points?.length > 0 && (
<Button variant="neutral-primary" onClick={handleUseEstimate}>
Use
</Button>
@@ -95,7 +91,7 @@ export const SingleEstimate: React.FC<Props> = ({ user, estimate, editEstimate,
{projectDetails?.estimate !== estimate.id && (
<CustomMenu.MenuItem
onClick={() => {
setIsDeleteEstimateModalOpen(true);
deleteEstimate(estimate.id);
}}
>
<div className="flex items-center justify-start gap-2">
@@ -107,7 +103,7 @@ export const SingleEstimate: React.FC<Props> = ({ user, estimate, editEstimate,
</CustomMenu>
</div>
</div>
{estimate.points.length > 0 ? (
{estimate?.points?.length > 0 ? (
<div className="flex text-xs text-custom-text-200">
Estimate points (
<span className="flex gap-1">
@@ -126,16 +122,6 @@ export const SingleEstimate: React.FC<Props> = ({ user, estimate, editEstimate,
</div>
)}
</div>
<DeleteEstimateModal
isOpen={isDeleteEstimateModalOpen}
handleClose={() => setIsDeleteEstimateModalOpen(false)}
data={estimate}
handleDelete={() => {
handleEstimateDelete(estimate.id);
setIsDeleteEstimateModalOpen(false);
}}
/>
</>
);
};
});
+139
View File
@@ -0,0 +1,139 @@
import React, { useState } from "react";
import { useRouter } from "next/router";
// store
import { observer } from "mobx-react-lite";
import { useMobxStore } from "lib/mobx/store-provider";
// components
import { CreateUpdateEstimateModal, DeleteEstimateModal, EstimateListItem } from "components/estimates";
//hooks
import useToast from "hooks/use-toast";
// ui
import { Button, Loader } from "@plane/ui";
import { EmptyState } from "components/common";
// icons
import { Plus } from "lucide-react";
// images
import emptyEstimate from "public/empty-state/estimate.svg";
// types
import { IEstimate } from "types";
export const EstimatesList: React.FC = observer(() => {
// router
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
// store
const { project: projectStore } = useMobxStore();
// states
const [estimateFormOpen, setEstimateFormOpen] = useState(false);
const [estimateToDelete, setEstimateToDelete] = useState<string | null>(null);
const [estimateToUpdate, setEstimateToUpdate] = useState<IEstimate | undefined>();
// hooks
const { setToastAlert } = useToast();
// derived values
const estimatesList = projectStore.projectEstimates;
const projectDetails = projectStore.project_details?.[projectId?.toString()!];
const editEstimate = (estimate: IEstimate) => {
setEstimateFormOpen(true);
setEstimateToUpdate(estimate);
};
const disableEstimates = () => {
if (!workspaceSlug || !projectId) return;
projectStore.updateProject(workspaceSlug.toString(), projectId.toString(), { estimate: null }).catch((err) => {
const error = err?.error;
const errorString = Array.isArray(error) ? error[0] : error;
setToastAlert({
type: "error",
title: "Error!",
message: errorString ?? "Estimate could not be disabled. Please try again",
});
});
};
return (
<>
<CreateUpdateEstimateModal
isOpen={estimateFormOpen}
data={estimateToUpdate}
handleClose={() => {
setEstimateFormOpen(false);
setEstimateToUpdate(undefined);
}}
/>
<DeleteEstimateModal
isOpen={!!estimateToDelete}
handleClose={() => setEstimateToDelete(null)}
data={projectStore.getProjectEstimateById(estimateToDelete!)}
/>
<section className="flex items-center justify-between py-3.5 border-b border-custom-border-200">
<h3 className="text-xl font-medium">Estimates</h3>
<div className="col-span-12 space-y-5 sm:col-span-7">
<div className="flex items-center gap-2">
<Button
variant="primary"
onClick={() => {
setEstimateFormOpen(true);
setEstimateToUpdate(undefined);
}}
>
Add Estimate
</Button>
{projectDetails?.estimate && (
<Button variant="neutral-primary" onClick={disableEstimates}>
Disable Estimates
</Button>
)}
</div>
</div>
</section>
{estimatesList ? (
estimatesList.length > 0 ? (
<section className="h-full bg-custom-background-100 overflow-y-auto">
{estimatesList.map((estimate) => (
<EstimateListItem
key={estimate.id}
estimate={estimate}
editEstimate={(estimate) => editEstimate(estimate)}
deleteEstimate={(estimateId) => setEstimateToDelete(estimateId)}
/>
))}
</section>
) : (
<div className="h-full w-full overflow-y-auto">
<EmptyState
title="No estimates yet"
description="Estimates help you communicate the complexity of an issue."
image={emptyEstimate}
primaryButton={{
icon: <Plus className="h-4 w-4" />,
text: "Add Estimate",
onClick: () => {
setEstimateFormOpen(true);
setEstimateToUpdate(undefined);
},
}}
/>
</div>
)
) : (
<Loader className="mt-5 space-y-5">
<Loader.Item height="40px" />
<Loader.Item height="40px" />
<Loader.Item height="40px" />
<Loader.Item height="40px" />
</Loader>
)}
</>
);
});
+1 -1
View File
@@ -1,4 +1,4 @@
export * from "./create-update-estimate-modal";
export * from "./delete-estimate-modal";
export * from "./estimate-select";
export * from "./single-estimate";
export * from "./estimate-list-item";
+1 -1
View File
@@ -128,7 +128,7 @@ export const Exporter: React.FC<Props> = observer((props) => {
value={value ?? []}
onChange={(val: string[]) => onChange(val)}
options={options}
input={true}
input
label={
value && value.length > 0
? projects &&
+1 -1
View File
@@ -150,7 +150,7 @@ const IntegrationGuide = () => {
</>
{provider && (
<Exporter
isOpen={true}
isOpen
handleClose={() => handleCsvClose()}
data={null}
user={user}
+1 -48
View File
@@ -1,6 +1,4 @@
import { FC, useEffect, useState } from "react";
// next
import { useRouter } from "next/router";
// icons
// components
import { GanttChartBlocks } from "components/gantt-chart";
@@ -13,7 +11,7 @@ import { MonthChartView } from "./month";
// import { QuarterChartView } from "./quarter";
// import { YearChartView } from "./year";
// icons
import { Expand, PlusIcon, Shrink } from "lucide-react";
import { Expand, Shrink } from "lucide-react";
// views
import {
// generateHourChart,
@@ -28,7 +26,6 @@ import {
// getNumberOfDaysBetweenTwoDatesInYear,
getMonthChartItemPositionWidthInMonth,
} from "../views";
// import { GanttInlineCreateIssueForm } from "components/core/views/gantt-chart-view/inline-create-issue-form";
// types
import { ChartDataType, IBlockUpdateData, IGanttBlock, TGanttViews } from "../types";
// data
@@ -65,15 +62,9 @@ export const ChartViewRoot: FC<ChartViewRootProps> = ({
enableReorder,
bottomSpacing,
}) => {
// router
const router = useRouter();
const { cycleId, moduleId } = router.query;
const isCyclePage = router.pathname.split("/")[4] === "cycles" && !cycleId;
const isModulePage = router.pathname.split("/")[4] === "modules" && !moduleId;
// states
const [itemsContainerWidth, setItemsContainerWidth] = useState<number>(0);
const [fullScreenMode, setFullScreenMode] = useState<boolean>(false);
const [isCreateIssueFormOpen, setIsCreateIssueFormOpen] = useState(false);
const [chartBlocks, setChartBlocks] = useState<IGanttBlock[] | null>(null); // blocks state management starts
// hooks
const { currentView, currentViewData, renderView, dispatch, allViews, updateScrollLeft } = useChart();
@@ -297,44 +288,6 @@ export const ChartViewRoot: FC<ChartViewRootProps> = ({
SidebarBlockRender={SidebarBlockRender}
enableReorder={enableReorder}
/>
{chartBlocks && !(isCyclePage || isModulePage) && (
<div className="pl-2.5 py-3">
{/* <GanttInlineCreateIssueForm
isOpen={isCreateIssueFormOpen}
handleClose={() => setIsCreateIssueFormOpen(false)}
onSuccess={() => {
const ganttSidebar = document.getElementById(`gantt-sidebar-${cycleId}`);
const timeoutId = setTimeout(() => {
if (ganttSidebar)
ganttSidebar.scrollBy({
top: ganttSidebar.scrollHeight,
left: 0,
behavior: "smooth",
});
clearTimeout(timeoutId);
}, 10);
}}
prePopulatedData={{
start_date: new Date(Date.now()).toISOString().split("T")[0],
target_date: new Date(Date.now() + 86400000).toISOString().split("T")[0],
...(cycleId && { cycle: cycleId.toString() }),
...(moduleId && { module: moduleId.toString() }),
}}
/> */}
{!isCreateIssueFormOpen && (
<button
type="button"
onClick={() => setIsCreateIssueFormOpen(true)}
className="flex items-center gap-x-[6px] text-custom-primary-100 px-2 pl-[1.875rem] py-1 rounded-md"
>
<PlusIcon className="h-4 w-4" />
<span className="text-sm font-medium text-custom-primary-100">New Issue</span>
</button>
)}
</div>
)}
</div>
<div
className="relative flex h-full w-full flex-1 flex-col overflow-hidden overflow-x-auto horizontal-scroll-enable"
@@ -0,0 +1,158 @@
import { useRouter } from "next/router";
import { DragDropContext, Draggable, DropResult } from "react-beautiful-dnd";
import StrictModeDroppable from "components/dnd/StrictModeDroppable";
import { MoreVertical } from "lucide-react";
// hooks
import { useChart } from "./hooks";
// ui
import { Loader } from "@plane/ui";
// helpers
import { findTotalDaysInRange } from "helpers/date-time.helper";
// types
import { IBlockUpdateData, IGanttBlock } from "./types";
type Props = {
title: string;
blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void;
blocks: IGanttBlock[] | null;
SidebarBlockRender: React.FC<any>;
enableReorder: boolean;
};
export const GanttSidebar: React.FC<Props> = (props) => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { title, blockUpdateHandler, blocks, SidebarBlockRender, enableReorder } = props;
const router = useRouter();
const { cycleId } = router.query;
const { activeBlock, dispatch } = useChart();
// update the active block on hover
const updateActiveBlock = (block: IGanttBlock | null) => {
dispatch({
type: "PARTIAL_UPDATE",
payload: {
activeBlock: block,
},
});
};
const handleOrderChange = (result: DropResult) => {
if (!blocks) return;
const { source, destination } = result;
// return if dropped outside the list
if (!destination) return;
// return if dropped on the same index
if (source.index === destination.index) return;
let updatedSortOrder = blocks[source.index].sort_order;
// update the sort order to the lowest if dropped at the top
if (destination.index === 0) updatedSortOrder = blocks[0].sort_order - 1000;
// update the sort order to the highest if dropped at the bottom
else if (destination.index === blocks.length - 1) updatedSortOrder = blocks[blocks.length - 1].sort_order + 1000;
// update the sort order to the average of the two adjacent blocks if dropped in between
else {
const destinationSortingOrder = blocks[destination.index].sort_order;
const relativeDestinationSortingOrder =
source.index < destination.index
? blocks[destination.index + 1].sort_order
: blocks[destination.index - 1].sort_order;
updatedSortOrder = (destinationSortingOrder + relativeDestinationSortingOrder) / 2;
}
// extract the element from the source index and insert it at the destination index without updating the entire array
const removedElement = blocks.splice(source.index, 1)[0];
blocks.splice(destination.index, 0, removedElement);
// call the block update handler with the updated sort order, new and old index
blockUpdateHandler(removedElement.data, {
sort_order: {
destinationIndex: destination.index,
newSortOrder: updatedSortOrder,
sourceIndex: source.index,
},
});
};
return (
<DragDropContext onDragEnd={handleOrderChange}>
<StrictModeDroppable droppableId="gantt-sidebar">
{(droppableProvided) => (
<div
id={`gantt-sidebar-${cycleId}`}
className="max-h-full overflow-y-auto pl-2.5 mt-3"
ref={droppableProvided.innerRef}
{...droppableProvided.droppableProps}
>
<>
{blocks ? (
blocks.map((block, index) => {
const duration = findTotalDaysInRange(block.start_date ?? "", block.target_date ?? "", true);
return (
<Draggable
key={`sidebar-block-${block.id}`}
draggableId={`sidebar-block-${block.id}`}
index={index}
isDragDisabled={!enableReorder}
>
{(provided, snapshot) => (
<div
className={`h-11 ${snapshot.isDragging ? "bg-custom-background-80 rounded" : ""}`}
onMouseEnter={() => updateActiveBlock(block)}
onMouseLeave={() => updateActiveBlock(null)}
ref={provided.innerRef}
{...provided.draggableProps}
>
<div
id={`sidebar-block-${block.id}`}
className={`group h-full w-full flex items-center gap-2 rounded-l px-2 pr-4 ${
activeBlock?.id === block.id ? "bg-custom-background-80" : ""
}`}
>
{enableReorder && (
<button
type="button"
className="rounded p-0.5 text-custom-sidebar-text-200 flex flex-shrink-0 opacity-0 group-hover:opacity-100"
{...provided.dragHandleProps}
>
<MoreVertical className="h-3.5 w-3.5" />
<MoreVertical className="h-3.5 w-3.5 -ml-5" />
</button>
)}
<div className="flex-grow truncate h-full flex items-center justify-between gap-2">
<div className="flex-grow truncate">
<SidebarBlockRender data={block.data} />
</div>
<div className="flex-shrink-0 text-sm text-custom-text-200">
{duration} day{duration > 1 ? "s" : ""}
</div>
</div>
</div>
</div>
)}
</Draggable>
);
})
) : (
<Loader className="pr-2 space-y-3">
<Loader.Item height="34px" />
<Loader.Item height="34px" />
<Loader.Item height="34px" />
<Loader.Item height="34px" />
</Loader>
)}
{droppableProvided.placeholder}
</>
</div>
)}
</StrictModeDroppable>
</DragDropContext>
);
};
@@ -0,0 +1,158 @@
import { useRouter } from "next/router";
import { DragDropContext, Draggable, DropResult } from "react-beautiful-dnd";
import StrictModeDroppable from "components/dnd/StrictModeDroppable";
import { MoreVertical } from "lucide-react";
// hooks
import { useChart } from "./hooks";
// ui
import { Loader } from "@plane/ui";
// helpers
import { findTotalDaysInRange } from "helpers/date-time.helper";
// types
import { IBlockUpdateData, IGanttBlock } from "./types";
type Props = {
title: string;
blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void;
blocks: IGanttBlock[] | null;
SidebarBlockRender: React.FC<any>;
enableReorder: boolean;
};
export const GanttSidebar: React.FC<Props> = (props) => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { title, blockUpdateHandler, blocks, SidebarBlockRender, enableReorder } = props;
const router = useRouter();
const { cycleId } = router.query;
const { activeBlock, dispatch } = useChart();
// update the active block on hover
const updateActiveBlock = (block: IGanttBlock | null) => {
dispatch({
type: "PARTIAL_UPDATE",
payload: {
activeBlock: block,
},
});
};
const handleOrderChange = (result: DropResult) => {
if (!blocks) return;
const { source, destination } = result;
// return if dropped outside the list
if (!destination) return;
// return if dropped on the same index
if (source.index === destination.index) return;
let updatedSortOrder = blocks[source.index].sort_order;
// update the sort order to the lowest if dropped at the top
if (destination.index === 0) updatedSortOrder = blocks[0].sort_order - 1000;
// update the sort order to the highest if dropped at the bottom
else if (destination.index === blocks.length - 1) updatedSortOrder = blocks[blocks.length - 1].sort_order + 1000;
// update the sort order to the average of the two adjacent blocks if dropped in between
else {
const destinationSortingOrder = blocks[destination.index].sort_order;
const relativeDestinationSortingOrder =
source.index < destination.index
? blocks[destination.index + 1].sort_order
: blocks[destination.index - 1].sort_order;
updatedSortOrder = (destinationSortingOrder + relativeDestinationSortingOrder) / 2;
}
// extract the element from the source index and insert it at the destination index without updating the entire array
const removedElement = blocks.splice(source.index, 1)[0];
blocks.splice(destination.index, 0, removedElement);
// call the block update handler with the updated sort order, new and old index
blockUpdateHandler(removedElement.data, {
sort_order: {
destinationIndex: destination.index,
newSortOrder: updatedSortOrder,
sourceIndex: source.index,
},
});
};
return (
<DragDropContext onDragEnd={handleOrderChange}>
<StrictModeDroppable droppableId="gantt-sidebar">
{(droppableProvided) => (
<div
id={`gantt-sidebar-${cycleId}`}
className="max-h-full overflow-y-auto pl-2.5 mt-3"
ref={droppableProvided.innerRef}
{...droppableProvided.droppableProps}
>
<>
{blocks ? (
blocks.map((block, index) => {
const duration = findTotalDaysInRange(block.start_date ?? "", block.target_date ?? "", true);
return (
<Draggable
key={`sidebar-block-${block.id}`}
draggableId={`sidebar-block-${block.id}`}
index={index}
isDragDisabled={!enableReorder}
>
{(provided, snapshot) => (
<div
className={`h-11 ${snapshot.isDragging ? "bg-custom-background-80 rounded" : ""}`}
onMouseEnter={() => updateActiveBlock(block)}
onMouseLeave={() => updateActiveBlock(null)}
ref={provided.innerRef}
{...provided.draggableProps}
>
<div
id={`sidebar-block-${block.id}`}
className={`group h-full w-full flex items-center gap-2 rounded-l px-2 pr-4 ${
activeBlock?.id === block.id ? "bg-custom-background-80" : ""
}`}
>
{enableReorder && (
<button
type="button"
className="rounded p-0.5 text-custom-sidebar-text-200 flex flex-shrink-0 opacity-0 group-hover:opacity-100"
{...provided.dragHandleProps}
>
<MoreVertical className="h-3.5 w-3.5" />
<MoreVertical className="h-3.5 w-3.5 -ml-5" />
</button>
)}
<div className="flex-grow truncate h-full flex items-center justify-between gap-2">
<div className="flex-grow truncate">
<SidebarBlockRender data={block.data} />
</div>
<div className="flex-shrink-0 text-sm text-custom-text-200">
{duration} day{duration > 1 ? "s" : ""}
</div>
</div>
</div>
</div>
)}
</Draggable>
);
})
) : (
<Loader className="pr-2 space-y-3">
<Loader.Item height="34px" />
<Loader.Item height="34px" />
<Loader.Item height="34px" />
<Loader.Item height="34px" />
</Loader>
)}
{droppableProvided.placeholder}
</>
</div>
)}
</StrictModeDroppable>
</DragDropContext>
);
};

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