Compare commits

..
Author SHA1 Message Date
sriram veeraghanta 4d2bed51cc feat: Instance activate page added 2023-10-31 18:22:24 +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
Aaryan KhandelwalandGitHub d95ea463b2 chore: implement mobx in project features settings (#2533) 2023-10-26 14:58:00 +05:30
Henit ChobisaandGitHub 993b388f00 chore: added missing dependencies in packages (#2540) 2023-10-26 12:37:24 +05:30
Aaryan KhandelwalandGitHub a49f00bd39 chore: refactor and beautify issue properties (#2539)
* chore: update all issue property components

* style: issue properties
2023-10-25 19:47:58 +05:30
guru_sainathandGitHub ca2da41dd2 chore: issue comment reaction workflow and mutation update (#2537) 2023-10-25 19:24:14 +05:30
guru_sainathandGitHub a6d741e784 chore: implemented drag and drop between dates for project issues, cycle, module, and project views for calendar layout (#2535) 2023-10-25 16:09:50 +05:30
Anmol Singh BhatiaandGitHub cea39c758e chore: layout refactor (#2532)
* chore: layout refactor

* fix: profile auth issue

* chore: project setting layout refactor

* chore: workspace layout refactor

* chore: profile layout refactor

* chore: layout import refactor
2023-10-25 15:48:57 +05:30
Aaryan KhandelwalandGitHub d72d3da6de refactor: modules list page (#2521)
* refactor: modules list page

* chore: update handle favorites logic for modules

* fix: build errors
2023-10-23 19:17:42 +05:30
sriram veeraghantaandGitHub 07d548ea43 fix: cycle modal redendent component fix (#2528) 2023-10-23 18:38:01 +05:30
Anmol Singh BhatiaandGitHub 08f7ac6da7 chore: sidebar fix and ui improvement (#2527)
* fix: cycle sidebar overlapping fix

* style: issue sidebar related to and duplicate icon fix
2023-10-23 18:07:38 +05:30
Anmol Singh BhatiaandGitHub fcf9851ee4 style: command modal style (#2526) 2023-10-23 17:40:59 +05:30
Aaryan KhandelwalandGitHub f6c1dad342 fix: extend custom tailwind valiables instead of overwriting (#2525) 2023-10-23 17:40:17 +05:30
Anmol Singh BhatiaandGitHub 4c54d826ba fix: command palette fix (#2523) 2023-10-23 17:24:43 +05:30
Anmol Singh BhatiaandGitHub 1786a395dc chore: layout refactor (#2522)
* chore: pages layout refactor

* chore: view layout refactor

* chore: view layout refactor

* chore: inbox layout refactor

* chore: draft issue layout refactor

* chore: archived issue layout refactor

* chore: draft issue header layout fix

* chore: layout code refactor

* chore: code refactor

* chore: project setting layout fix
2023-10-23 16:54:26 +05:30
d7a36f5b04 dev: Self Hosting simplified with shell script (#2484)
* bug:fix recent page hiding last item on scroll #1468 (#2411)

* dev: hub compose file update (#2376) (#2444) (#2445)

* docker-compose-hub modified for envs

* bug:fix recent page hiding last item on scroll #1468 (#2411)

* wip

* fixed the AMD build on ARM

---------

Co-authored-by: Manish Gupta <59428681+manishg3@users.noreply.github.com>
Co-authored-by: pablohashescobar <nikhilschacko@gmail.com>

* selfhosting fixes

* wip

* checking selfhost

* checking selfhost

* selfhost

* selfhosting script fix

* wip

* self hosting fixes

* folder structure modified

* replica config

* self host specific version

* fix

* fix

* fixes

* docker compose modifications

* fixed install.sh

* fixed install.sh

* install.sh modified

---------

Co-authored-by: Prashant Indurkar <32466796+PrashantIndurkar@users.noreply.github.com>
Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
Co-authored-by: pablohashescobar <nikhilschacko@gmail.com>
2023-10-23 15:56:19 +05:30
Aaryan KhandelwalandGitHub edc5a38973 chore: removed material icons package (#2518) 2023-10-23 15:07:09 +05:30
Aaryan KhandelwalandGitHub 38421e8106 refactor: layout roots (#2517) 2023-10-23 15:06:28 +05:30
Aaryan KhandelwalandGitHub 05a76c5ee3 fix: gantt chart blocks drag and resize logic (#2516) 2023-10-23 15:06:02 +05:30
Aaryan KhandelwalandGitHub c739b7235d refactor: publish project modal (#2514)
* chore: add publish badge to the header

* refactor: project oublish components

* chore: remove link tag
2023-10-23 12:12:42 +05:30
263 changed files with 10345 additions and 8776 deletions
+3 -2
View File
@@ -16,7 +16,8 @@ node_modules
# Production
/build
dist
dist/
out/
# Misc
.DS_Store
@@ -74,7 +75,7 @@ pnpm-lock.yaml
pnpm-workspace.yaml
.npmrc
tmp/
## packages
dist
+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
+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/",
+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 -1
View File
@@ -71,7 +71,7 @@ from .issue import (
WorkSpaceIssuesEndpoint,
IssueActivityEndpoint,
IssueCommentViewSet,
IssuePropertyViewSet,
IssueUserDisplayPropertyEndpoint,
LabelViewSet,
BulkDeleteIssuesEndpoint,
UserWorkSpaceIssues,
+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):
+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):
+71 -7
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()
@@ -560,6 +584,7 @@ class AddMemberToProjectEndpoint(BaseAPIView):
status=status.HTTP_400_BAD_REQUEST,
)
bulk_project_members = []
bulk_issue_props = []
project_members = (
ProjectMember.objects.filter(
@@ -574,7 +599,8 @@ class AddMemberToProjectEndpoint(BaseAPIView):
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"))
if str(project_member.get("member_id"))
== str(member.get("member_id"))
]
bulk_project_members.append(
ProjectMember(
@@ -585,6 +611,13 @@ class AddMemberToProjectEndpoint(BaseAPIView):
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,
@@ -592,7 +625,12 @@ class AddMemberToProjectEndpoint(BaseAPIView):
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)
@@ -614,6 +652,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 +662,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 +794,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,
+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)
+8 -2
View File
@@ -1228,9 +1228,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):
+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),
),
]
+29 -6
View File
@@ -16,6 +16,24 @@ from . import ProjectBaseModel
from plane.utils.html_processor import strip_tags
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):
def get_queryset(self):
@@ -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):
@@ -327,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,
@@ -367,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"
@@ -515,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"
+2 -51
View File
@@ -1,10 +1,8 @@
# Django imports
from django.db import models
from django.conf import settings
# Third party imports
from .base import BaseModel
from plane.db.models import ProjectBaseModel
class Notification(BaseModel):
@@ -22,15 +20,8 @@ class Notification(BaseModel):
message_html = models.TextField(blank=True, default="<p></p>")
message_stripped = models.TextField(blank=True, null=True)
sender = models.CharField(max_length=255)
triggered_by = models.ForeignKey(
"db.User",
related_name="triggered_notifications",
on_delete=models.SET_NULL,
null=True,
)
receiver = models.ForeignKey(
"db.User", related_name="received_notifications", on_delete=models.CASCADE
)
triggered_by = models.ForeignKey("db.User", related_name="triggered_notifications", on_delete=models.SET_NULL, null=True)
receiver = models.ForeignKey("db.User", related_name="received_notifications", on_delete=models.CASCADE)
read_at = models.DateTimeField(null=True)
snoozed_till = models.DateTimeField(null=True)
archived_at = models.DateTimeField(null=True)
@@ -44,43 +35,3 @@ class Notification(BaseModel):
def __str__(self):
"""Return name of the notifications"""
return f"{self.receiver.email} <{self.workspace.name}>"
def get_default_preference():
return {
"property_change": {
"email": True,
},
"state": {
"email": True,
},
"comment": {
"email": True,
},
"mentions": {
"email": True,
},
}
class NotificationPreference(ProjectBaseModel):
created_by = models.JSONField(default=get_default_preference)
assigned = models.JSONField(default=get_default_preference)
subscribed = models.JSONField(default=get_default_preference)
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="notification_preferences",
)
class Meta:
unique_together = ["project", "user"]
verbose_name = "Notification Preference"
verbose_name_plural = "Notification Preferences"
db_table = "notification_preferences"
ordering = ("-created_at",)
def __str__(self):
"""Return name of the notifications"""
return f"{self.user.email} <{self.workspace.name}>"
+12 -11
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
@@ -278,4 +280,3 @@ SCOUT_NAME = "Plane"
# Unsplash Access key
UNSPLASH_ACCESS_KEY = os.environ.get("UNSPLASH_ACCESS_KEY")
+48 -25
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
@@ -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
-4
View File
@@ -1,4 +0,0 @@
# Deploy the Plane image
FROM makeplane/plane
LABEL maintainer="engineering@plane.so"
+168
View File
@@ -0,0 +1,168 @@
version: "3.8"
x-app-env : &app-env
environment:
- NGINX_PORT=${NGINX_PORT:-84}
- DEBUG=${DEBUG:-0}
- DJANGO_SETTINGS_MODULE=${DJANGO_SETTINGS_MODULE:-plane.settings.selfhosted}
- NEXT_PUBLIC_ENABLE_OAUTH=${NEXT_PUBLIC_ENABLE_OAUTH:-0}
- NEXT_PUBLIC_DEPLOY_URL=${NEXT_PUBLIC_DEPLOY_URL:-http://localhost/spaces}
- SENTRY_DSN=${SENTRY_DSN:-""}
- GITHUB_CLIENT_SECRET=${GITHUB_CLIENT_SECRET:-""}
- DOCKERIZED=${DOCKERIZED:-1}
#DB SETTINGS
- PGHOST=${PGHOST:-plane-db}
- PGDATABASE=${PGDATABASE:-plane}
- POSTGRES_USER=${POSTGRES_USER:-plane}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-plane}
- POSTGRES_DB=${POSTGRES_DB:-plane}
- PGDATA=${PGDATA:-/var/lib/postgresql/data}
- DATABASE_URL=${DATABASE_URL:-postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${PGHOST}/${PGDATABASE}}
# REDIS SETTINGS
- REDIS_HOST=${REDIS_HOST:-plane-redis}
- REDIS_PORT=${REDIS_PORT:-6379}
- REDIS_URL=${REDIS_URL:-redis://${REDIS_HOST}:6379/}
# EMAIL SETTINGS
- EMAIL_HOST=${EMAIL_HOST:-""}
- EMAIL_HOST_USER=${EMAIL_HOST_USER:-""}
- EMAIL_HOST_PASSWORD=${EMAIL_HOST_PASSWORD:-""}
- EMAIL_PORT=${EMAIL_PORT:-587}
- EMAIL_FROM=${EMAIL_FROM:-"Team Plane &lt;team@mailer.plane.so&gt;"}
- EMAIL_USE_TLS=${EMAIL_USE_TLS:-1}
- EMAIL_USE_SSL=${EMAIL_USE_SSL:-0}
- DEFAULT_EMAIL=${DEFAULT_EMAIL:-captain@plane.so}
- DEFAULT_PASSWORD=${DEFAULT_PASSWORD:-password123}
# OPENAI SETTINGS
- OPENAI_API_BASE=${OPENAI_API_BASE:-https://api.openai.com/v1}
- OPENAI_API_KEY=${OPENAI_API_KEY:-"sk-"}
- GPT_ENGINE=${GPT_ENGINE:-"gpt-3.5-turbo"}
# LOGIN/SIGNUP SETTINGS
- ENABLE_SIGNUP=${ENABLE_SIGNUP:-1}
- ENABLE_EMAIL_PASSWORD=${ENABLE_EMAIL_PASSWORD:-1}
- ENABLE_MAGIC_LINK_LOGIN=${ENABLE_MAGIC_LINK_LOGIN:-0}
- SECRET_KEY=${SECRET_KEY:-60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5}
# DATA STORE SETTINGS
- USE_MINIO=${USE_MINIO:-1}
- AWS_REGION=${AWS_REGION:-""}
- AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID:-"access-key"}
- AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY:-"secret-key"}
- AWS_S3_ENDPOINT_URL=${AWS_S3_ENDPOINT_URL:-http://plane-minio:9000}
- AWS_S3_BUCKET_NAME=${AWS_S3_BUCKET_NAME:-uploads}
- MINIO_ROOT_USER=${MINIO_ROOT_USER:-"access-key"}
- MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD:-"secret-key"}
- BUCKET_NAME=${BUCKET_NAME:-uploads}
- FILE_SIZE_LIMIT=${FILE_SIZE_LIMIT:-5242880}
services:
web:
<<: *app-env
platform: linux/amd64
image: makeplane/plane-frontend:${APP_RELEASE:-latest}
restart: unless-stopped
command: /usr/local/bin/start.sh web/server.js web
deploy:
replicas: ${WEB_REPLICAS:-1}
depends_on:
- api
- worker
space:
<<: *app-env
platform: linux/amd64
image: makeplane/plane-space:${APP_RELEASE:-latest}
restart: unless-stopped
command: /usr/local/bin/start.sh space/server.js space
deploy:
replicas: ${SPACE_REPLICAS:-1}
depends_on:
- api
- worker
- web
api:
<<: *app-env
platform: linux/amd64
image: makeplane/plane-backend:${APP_RELEASE:-latest}
restart: unless-stopped
command: ./bin/takeoff
deploy:
replicas: ${API_REPLICAS:-1}
depends_on:
- plane-db
- plane-redis
worker:
<<: *app-env
container_name: bgworker
platform: linux/amd64
image: makeplane/plane-backend:${APP_RELEASE:-latest}
restart: unless-stopped
command: ./bin/worker
depends_on:
- api
- plane-db
- plane-redis
beat-worker:
<<: *app-env
container_name: beatworker
platform: linux/amd64
image: makeplane/plane-backend:${APP_RELEASE:-latest}
restart: unless-stopped
command: ./bin/beat
depends_on:
- api
- plane-db
- plane-redis
plane-db:
<<: *app-env
container_name: plane-db
image: postgres:15.2-alpine
restart: unless-stopped
command: postgres -c 'max_connections=1000'
volumes:
- pgdata:/var/lib/postgresql/data
plane-redis:
<<: *app-env
container_name: plane-redis
image: redis:6.2.7-alpine
restart: unless-stopped
volumes:
- redisdata:/data
plane-minio:
<<: *app-env
container_name: plane-minio
image: minio/minio
restart: unless-stopped
command: server /export --console-address ":9090"
volumes:
- uploads:/export
createbuckets:
<<: *app-env
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_S3_BUCKET_NAME; /usr/bin/mc anonymous set download plane-minio/\$AWS_S3_BUCKET_NAME; exit 0; "
depends_on:
- plane-minio
# Comment this if you already have a reverse proxy running
proxy:
<<: *app-env
container_name: proxy
platform: linux/amd64
image: makeplane/plane-proxy:${APP_RELEASE:-latest}
ports:
- ${NGINX_PORT}:80
depends_on:
- web
- api
- space
volumes:
pgdata:
redisdata:
uploads:
+111
View File
@@ -0,0 +1,111 @@
#!/bin/bash
BRANCH=${BRANCH:-master}
SCRIPT_DIR=$PWD
PLANE_INSTALL_DIR=$PWD/plane-app
mkdir -p $PLANE_INSTALL_DIR/archive
function install(){
echo
echo "Installing on $PLANE_INSTALL_DIR"
download
}
function download(){
cd $SCRIPT_DIR
TS=$(date +%s)
if [ -f "$PLANE_INSTALL_DIR/docker-compose.yaml" ]
then
mv $PLANE_INSTALL_DIR/docker-compose.yaml $PLANE_INSTALL_DIR/archive/$TS.docker-compose.yaml
fi
curl -H 'Cache-Control: no-cache, no-store' -s -o $PLANE_INSTALL_DIR/docker-compose.yaml https://raw.githubusercontent.com/makeplane/plane/$BRANCH/deploy/selfhost/docker-compose.yml?$(date +%s)
curl -H 'Cache-Control: no-cache, no-store' -s -o $PLANE_INSTALL_DIR/variables-upgrade.env https://raw.githubusercontent.com/makeplane/plane/$BRANCH/deploy/selfhost/variables.env?$(date +%s)
if [ -f "$PLANE_INSTALL_DIR/.env" ];
then
cp $PLANE_INSTALL_DIR/.env $PLANE_INSTALL_DIR/archive/$TS.env
else
mv $PLANE_INSTALL_DIR/variables-upgrade.env $PLANE_INSTALL_DIR/.env
fi
echo ""
echo "Latest version is now available for you to use"
echo ""
echo "In case of Upgrade, your new setting file is availabe as 'variables-upgrade.env'. Please compare and set the required values in '.env 'file."
echo ""
}
function startServices(){
cd $PLANE_INSTALL_DIR
docker compose up -d
cd $SCRIPT_DIR
}
function stopServices(){
cd $PLANE_INSTALL_DIR
docker compose down
cd $SCRIPT_DIR
}
function restartServices(){
cd $PLANE_INSTALL_DIR
docker compose restart
cd $SCRIPT_DIR
}
function upgrade(){
echo "***** STOPPING SERVICES ****"
stopServices
echo
echo "***** DOWNLOADING LATEST VERSION ****"
download
echo "***** PLEASE VALIDATE AND START SERVICES ****"
}
function askForAction(){
echo
echo "Select a Action you want to perform:"
echo " 1) Install"
echo " 2) Start"
echo " 3) Stop"
echo " 4) Restart"
echo " 5) Upgrade"
echo " 6) Exit"
echo
read -p "Action [2]: " ACTION
until [[ -z "$ACTION" || "$ACTION" =~ ^[1-6]$ ]]; do
echo "$ACTION: invalid selection."
read -p "Action [2]: " ACTION
done
echo
if [ "$ACTION" == "1" ]
then
install
askForAction
elif [ "$ACTION" == "2" ] || [ "$ACTION" == "" ]
then
startServices
askForAction
elif [ "$ACTION" == "3" ]
then
stopServices
askForAction
elif [ "$ACTION" == "4" ]
then
restartServices
askForAction
elif [ "$ACTION" == "5" ]
then
upgrade
askForAction
elif [ "$ACTION" == "6" ]
then
exit 0
else
echo "INVALID ACTION SUPPLIED"
fi
}
askForAction
+63
View File
@@ -0,0 +1,63 @@
APP_RELEASE=latest
WEB_REPLICAS=1
SPACE_REPLICAS=1
API_REPLICAS=1
NGINX_PORT=80
DEBUG=0
DJANGO_SETTINGS_MODULE=plane.settings.selfhosted
NEXT_PUBLIC_ENABLE_OAUTH=0
NEXT_PUBLIC_DEPLOY_URL=http://localhost/spaces
SENTRY_DSN=""
GITHUB_CLIENT_SECRET=""
DOCKERIZED=1
#DB SETTINGS
PGHOST=plane-db
PGDATABASE=plane
POSTGRES_USER=plane
POSTGRES_PASSWORD=plane
POSTGRES_DB=plane
PGDATA=/var/lib/postgresql/data
DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${PGHOST}/${PGDATABASE}
# REDIS SETTINGS
REDIS_HOST=plane-redis
REDIS_PORT=6379
REDIS_URL=redis://${REDIS_HOST}:6379/
# EMAIL SETTINGS
EMAIL_HOST=""
EMAIL_HOST_USER=""
EMAIL_HOST_PASSWORD=""
EMAIL_PORT=587
EMAIL_FROM="Team Plane &lt;team@mailer.plane.so&gt;"
EMAIL_USE_TLS=1
EMAIL_USE_SSL=0
DEFAULT_EMAIL=captain@plane.so
DEFAULT_PASSWORD=password123
# OPENAI SETTINGS
OPENAI_API_BASE=https://api.openai.com/v1
OPENAI_API_KEY="sk-"
GPT_ENGINE="gpt-3.5-turbo"
# LOGIN/SIGNUP SETTINGS
ENABLE_SIGNUP=1
ENABLE_EMAIL_PASSWORD=1
ENABLE_MAGIC_LINK_LOGIN=0
SECRET_KEY=60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5
# DATA STORE SETTINGS
USE_MINIO=1
AWS_REGION=""
AWS_ACCESS_KEY_ID="access-key"
AWS_SECRET_ACCESS_KEY="secret-key"
AWS_S3_ENDPOINT_URL=http://plane-minio:9000
AWS_S3_BUCKET_NAME=uploads
MINIO_ROOT_USER="access-key"
MINIO_ROOT_PASSWORD="secret-key"
BUCKET_NAME=uploads
FILE_SIZE_LIMIT=5242880
-234
View File
@@ -1,234 +0,0 @@
version: "3.8"
services:
web:
container_name: web
platform: linux/amd64
image: makeplane/plane-frontend:latest
restart: always
command: /usr/local/bin/start.sh web/server.js web
environment:
- NEXT_PUBLIC_ENABLE_OAUTH=${NEXT_PUBLIC_ENABLE_OAUTH:-0}
- NEXT_PUBLIC_DEPLOY_URL=${NEXT_PUBLIC_DEPLOY_URL:-http://localhost/spaces}
depends_on:
- api
- worker
space:
container_name: space
platform: linux/amd64
image: makeplane/plane-space:latest
restart: always
command: /usr/local/bin/start.sh space/server.js space
environment:
- NEXT_PUBLIC_ENABLE_OAUTH=${NEXT_PUBLIC_ENABLE_OAUTH:-0}
depends_on:
- api
- worker
- web
api:
container_name: api
platform: linux/amd64
image: makeplane/plane-backend:latest
restart: always
command: ./bin/takeoff
environment:
- DEBUG=${DEBUG:-0}
- DJANGO_SETTINGS_MODULE=${DJANGO_SETTINGS_MODULE:-plane.settings.selfhosted}
- SENTRY_DSN=${SENTRY_DSN:-""}
- PGUSER=${PGUSER:-plane}
- PGPASSWORD=${PGPASSWORD:-plane}
- PGHOST=${PGHOST:-plane-db}
- PGDATABASE=${PGDATABASE:-plane}
- DATABASE_URL=${DATABASE_URL:-postgresql://${PGUSER}:${PGPASSWORD}@${PGHOST}/${PGDATABASE}}
- REDIS_HOST=${REDIS_HOST:-plane-redis}
- REDIS_PORT=${REDIS_PORT:-6379}
- REDIS_URL=${REDIS_URL:-redis://${REDIS_HOST}:6379/}
- EMAIL_HOST=${EMAIL_HOST:-""}
- EMAIL_HOST_USER=${EMAIL_HOST_USER:-""}
- EMAIL_HOST_PASSWORD=${EMAIL_HOST_PASSWORD:-""}
- EMAIL_PORT=${EMAIL_PORT:-587}
- EMAIL_FROM=${EMAIL_FROM:-Team Plane <team@mailer.plane.so>}
- EMAIL_USE_TLS=${EMAIL_USE_TLS:-1}
- EMAIL_USE_SSL=${EMAIL_USE_SSL:-0}
- AWS_REGION=${AWS_REGION:-""}
- AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID:-access-key}
- AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY:-secret-key}
- AWS_S3_ENDPOINT_URL=${AWS_S3_ENDPOINT_URL:-http://plane-minio:9000}
- AWS_S3_BUCKET_NAME=${AWS_S3_BUCKET_NAME:-uploads}
- FILE_SIZE_LIMIT=${FILE_SIZE_LIMIT:-5242880}
- OPENAI_API_BASE=${OPENAI_API_BASE:-https://api.openai.com/v1}
- OPENAI_API_KEY=${OPENAI_API_KEY:-sk-}
- GPT_ENGINE=${GPT_ENGINE:-gpt-3.5-turbo}
- GITHUB_CLIENT_SECRET=${GITHUB_CLIENT_SECRET:-""}
- DOCKERIZED=${DOCKERIZED:-1}
- USE_MINIO=${USE_MINIO:-1}
- NGINX_PORT=${NGINX_PORT:-80}
- DEFAULT_EMAIL=${DEFAULT_EMAIL:-captain@plane.so}
- DEFAULT_PASSWORD=${DEFAULT_PASSWORD:-password123}
- ENABLE_SIGNUP=${ENABLE_SIGNUP:-1}
- ENABLE_EMAIL_PASSWORD=${ENABLE_EMAIL_PASSWORD:-1}
- ENABLE_MAGIC_LINK_LOGIN=${ENABLE_MAGIC_LINK_LOGIN:-0}
- SECRET_KEY=${SECRET_KEY:-60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5}
depends_on:
- plane-db
- plane-redis
worker:
container_name: bgworker
platform: linux/amd64
image: makeplane/plane-backend:latest
restart: always
command: ./bin/worker
environment:
- DEBUG=${DEBUG:-0}
- DJANGO_SETTINGS_MODULE=${DJANGO_SETTINGS_MODULE:-plane.settings.selfhosted}
- SENTRY_DSN=${SENTRY_DSN:-""}
- PGUSER=${PGUSER:-plane}
- PGPASSWORD=${PGPASSWORD:-plane}
- PGHOST=${PGHOST:-plane-db}
- PGDATABASE=${PGDATABASE:-plane}
- DATABASE_URL=${DATABASE_URL:-postgresql://${PGUSER}:${PGPASSWORD}@${PGHOST}/${PGDATABASE}}
- REDIS_HOST=${REDIS_HOST:-plane-redis}
- REDIS_PORT=${REDIS_PORT:-6379}
- REDIS_URL=${REDIS_URL:-redis://${REDIS_HOST}:6379/}
- EMAIL_HOST=${EMAIL_HOST:-""}
- EMAIL_HOST_USER=${EMAIL_HOST_USER:-""}
- EMAIL_HOST_PASSWORD=${EMAIL_HOST_PASSWORD:-""}
- EMAIL_PORT=${EMAIL_PORT:-587}
- EMAIL_FROM=${EMAIL_FROM:-Team Plane <team@mailer.plane.so>}
- EMAIL_USE_TLS=${EMAIL_USE_TLS:-1}
- EMAIL_USE_SSL=${EMAIL_USE_SSL:-0}
- AWS_REGION=${AWS_REGION:-""}
- AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID:-access-key}
- AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY:-secret-key}
- AWS_S3_ENDPOINT_URL=${AWS_S3_ENDPOINT_URL:-http://plane-minio:9000}
- AWS_S3_BUCKET_NAME=${AWS_S3_BUCKET_NAME:-uploads}
- FILE_SIZE_LIMIT=${FILE_SIZE_LIMIT:-5242880}
- OPENAI_API_BASE=${OPENAI_API_BASE:-https://api.openai.com/v1}
- OPENAI_API_KEY=${OPENAI_API_KEY:-sk-}
- GPT_ENGINE=${GPT_ENGINE:-gpt-3.5-turbo}
- GITHUB_CLIENT_SECRET=${GITHUB_CLIENT_SECRET:-""}
- DOCKERIZED=${DOCKERIZED:-1}
- USE_MINIO=${USE_MINIO:-1}
- NGINX_PORT=${NGINX_PORT:-80}
- DEFAULT_EMAIL=${DEFAULT_EMAIL:-captain@plane.so}
- DEFAULT_PASSWORD=${DEFAULT_PASSWORD:-password123}
- ENABLE_SIGNUP=${ENABLE_SIGNUP:-1}
- SECRET_KEY=${SECRET_KEY:-60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5}
depends_on:
- api
- plane-db
- plane-redis
beat-worker:
container_name: beatworker
platform: linux/amd64
image: makeplane/plane-backend:latest
restart: always
command: ./bin/beat
environment:
- DEBUG=${DEBUG:-0}
- DJANGO_SETTINGS_MODULE=${DJANGO_SETTINGS_MODULE:-plane.settings.selfhosted}
- SENTRY_DSN=${SENTRY_DSN:-""}
- PGUSER=${PGUSER:-plane}
- PGPASSWORD=${PGPASSWORD:-plane}
- PGHOST=${PGHOST:-plane-db}
- PGDATABASE=${PGDATABASE:-plane}
- DATABASE_URL=${DATABASE_URL:-postgresql://${PGUSER}:${PGPASSWORD}@${PGHOST}/${PGDATABASE}}
- REDIS_HOST=${REDIS_HOST:-plane-redis}
- REDIS_PORT=${REDIS_PORT:-6379}
- REDIS_URL=${REDIS_URL:-redis://${REDIS_HOST}:6379/}
- EMAIL_HOST=${EMAIL_HOST:-""}
- EMAIL_HOST_USER=${EMAIL_HOST_USER:-""}
- EMAIL_HOST_PASSWORD=${EMAIL_HOST_PASSWORD:-""}
- EMAIL_PORT=${EMAIL_PORT:-587}
- EMAIL_FROM=${EMAIL_FROM:-Team Plane <team@mailer.plane.so>}
- EMAIL_USE_TLS=${EMAIL_USE_TLS:-1}
- EMAIL_USE_SSL=${EMAIL_USE_SSL:-0}
- AWS_REGION=${AWS_REGION:-""}
- AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID:-access-key}
- AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY:-secret-key}
- AWS_S3_ENDPOINT_URL=${AWS_S3_ENDPOINT_URL:-http://plane-minio:9000}
- AWS_S3_BUCKET_NAME=${AWS_S3_BUCKET_NAME:-uploads}
- FILE_SIZE_LIMIT=${FILE_SIZE_LIMIT:-5242880}
- OPENAI_API_BASE=${OPENAI_API_BASE:-https://api.openai.com/v1}
- OPENAI_API_KEY=${OPENAI_API_KEY:-sk-}
- GPT_ENGINE=${GPT_ENGINE:-gpt-3.5-turbo}
- GITHUB_CLIENT_SECRET=${GITHUB_CLIENT_SECRET:-""}
- DOCKERIZED=${DOCKERIZED:-1}
- USE_MINIO=${USE_MINIO:-1}
- NGINX_PORT=${NGINX_PORT:-80}
- DEFAULT_EMAIL=${DEFAULT_EMAIL:-captain@plane.so}
- DEFAULT_PASSWORD=${DEFAULT_PASSWORD:-password123}
- ENABLE_SIGNUP=${ENABLE_SIGNUP:-1}
- SECRET_KEY=${SECRET_KEY:-60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5}
depends_on:
- api
- plane-db
- plane-redis
plane-db:
container_name: plane-db
image: postgres:15.2-alpine
restart: always
command: postgres -c 'max_connections=1000'
volumes:
- pgdata:/var/lib/postgresql/data
environment:
- POSTGRES_USER=${POSTGRES_USER:-plane}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-plane}
- POSTGRES_DB=${POSTGRES_DB:-plane}
- PGDATA=${PGDATA:-/var/lib/postgresql/data}
plane-redis:
container_name: plane-redis
image: redis:6.2.7-alpine
restart: always
volumes:
- redisdata:/data
plane-minio:
container_name: plane-minio
image: minio/minio
restart: always
command: server /export --console-address ":9090"
volumes:
- uploads:/export
environment:
- MINIO_ROOT_USER=${MINIO_ROOT_USER:-access-key}
- MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD:-secret-key}
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_S3_BUCKET_NAME; /usr/bin/mc anonymous set download plane-minio/\$AWS_S3_BUCKET_NAME; exit 0; "
environment:
- AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID:-access-key}
- AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY:-secret-key}
- AWS_S3_BUCKET_NAME=${AWS_S3_BUCKET_NAME:-uploads}
depends_on:
- plane-minio
# Comment this if you already have a reverse proxy running
proxy:
container_name: proxy
platform: linux/amd64
image: makeplane/plane-proxy:latest
ports:
- ${NGINX_PORT}:80
environment:
- NGINX_PORT=${NGINX_PORT:-80}
- FILE_SIZE_LIMIT=${FILE_SIZE_LIMIT:-5242880}
- BUCKET_NAME=${AWS_S3_BUCKET_NAME:-uploads}
depends_on:
- web
- api
- space
volumes:
pgdata:
redisdata:
uploads:
+1
View File
@@ -27,6 +27,7 @@
"next-themes": "^0.2.1"
},
"dependencies": {
"react-moveable" : "^0.54.2",
"@blueprintjs/popover2": "^2.0.10",
"@tiptap/core": "^2.1.7",
"@tiptap/extension-color": "^2.1.11",
+140 -137
View File
@@ -16,143 +16,6 @@ module.exports = {
],
},
theme: {
// scale down font sizes to 90% of default
fontSize: {
xs: "0.675rem",
sm: "0.7875rem",
base: "0.9rem",
lg: "1.0125rem",
xl: "1.125rem",
"2xl": "1.35rem",
"3xl": "1.6875rem",
"4xl": "2.25rem",
"5xl": "2.7rem",
"6xl": "3.375rem",
"7xl": "4.05rem",
"8xl": "5.4rem",
"9xl": "7.2rem",
},
// scale down spacing to 90% of default
padding: {
0: "0",
0.5: "0.1125rem",
1: "0.225rem",
1.5: "0.3375rem",
2: "0.45rem",
2.5: "0.5625rem",
3: "0.675rem",
3.5: "0.7875rem",
4: "0.9rem",
5: "1.125rem",
6: "1.35rem",
7: "1.575rem",
8: "1.8rem",
9: "2.025rem",
10: "2.25rem",
11: "2.475rem",
12: "2.7rem",
16: "3.6rem",
20: "4.5rem",
24: "5.4rem",
32: "7.2rem",
40: "9rem",
48: "10.8rem",
56: "12.6rem",
64: "14.4rem",
72: "16.2rem",
80: "18rem",
96: "21.6rem",
},
margin: {
0: "0",
0.5: "0.1125rem",
1: "0.225rem",
1.5: "0.3375rem",
2: "0.45rem",
2.5: "0.5625rem",
3: "0.675rem",
3.5: "0.7875rem",
4: "0.9rem",
5: "1.125rem",
6: "1.35rem",
7: "1.575rem",
8: "1.8rem",
9: "2.025rem",
10: "2.25rem",
11: "2.475rem",
12: "2.7rem",
16: "3.6rem",
20: "4.5rem",
24: "5.4rem",
32: "7.2rem",
40: "9rem",
48: "10.8rem",
56: "12.6rem",
64: "14.4rem",
72: "16.2rem",
80: "18rem",
96: "21.6rem",
},
space: {
0: "0",
0.5: "0.1125rem",
1: "0.225rem",
1.5: "0.3375rem",
2: "0.45rem",
2.5: "0.5625rem",
3: "0.675rem",
3.5: "0.7875rem",
4: "0.9rem",
5: "1.125rem",
6: "1.35rem",
7: "1.575rem",
8: "1.8rem",
9: "2.025rem",
10: "2.25rem",
11: "2.475rem",
12: "2.7rem",
16: "3.6rem",
20: "4.5rem",
24: "5.4rem",
32: "7.2rem",
40: "9rem",
48: "10.8rem",
56: "12.6rem",
64: "14.4rem",
72: "16.2rem",
80: "18rem",
96: "21.6rem",
},
gap: {
0: "0",
0.5: "0.1125rem",
1: "0.225rem",
1.5: "0.3375rem",
2: "0.45rem",
2.5: "0.5625rem",
3: "0.675rem",
3.5: "0.7875rem",
4: "0.9rem",
5: "1.125rem",
6: "1.35rem",
7: "1.575rem",
8: "1.8rem",
9: "2.025rem",
10: "2.25rem",
11: "2.475rem",
12: "2.7rem",
16: "3.6rem",
20: "4.5rem",
24: "5.4rem",
32: "7.2rem",
40: "9rem",
48: "10.8rem",
56: "12.6rem",
64: "14.4rem",
72: "16.2rem",
80: "18rem",
96: "21.6rem",
},
extend: {
boxShadow: {
"custom-shadow-2xs": "var(--color-shadow-2xs)",
@@ -350,6 +213,146 @@ module.exports = {
},
},
}),
screens: {
"3xl": "1792px",
},
// scale down font sizes to 90% of default
fontSize: {
xs: "0.675rem",
sm: "0.7875rem",
base: "0.9rem",
lg: "1.0125rem",
xl: "1.125rem",
"2xl": "1.35rem",
"3xl": "1.6875rem",
"4xl": "2.25rem",
"5xl": "2.7rem",
"6xl": "3.375rem",
"7xl": "4.05rem",
"8xl": "5.4rem",
"9xl": "7.2rem",
},
// scale down spacing to 90% of default
padding: {
0: "0",
0.5: "0.1125rem",
1: "0.225rem",
1.5: "0.3375rem",
2: "0.45rem",
2.5: "0.5625rem",
3: "0.675rem",
3.5: "0.7875rem",
4: "0.9rem",
5: "1.125rem",
6: "1.35rem",
7: "1.575rem",
8: "1.8rem",
9: "2.025rem",
10: "2.25rem",
11: "2.475rem",
12: "2.7rem",
16: "3.6rem",
20: "4.5rem",
24: "5.4rem",
32: "7.2rem",
40: "9rem",
48: "10.8rem",
56: "12.6rem",
64: "14.4rem",
72: "16.2rem",
80: "18rem",
96: "21.6rem",
},
margin: {
0: "0",
0.5: "0.1125rem",
1: "0.225rem",
1.5: "0.3375rem",
2: "0.45rem",
2.5: "0.5625rem",
3: "0.675rem",
3.5: "0.7875rem",
4: "0.9rem",
5: "1.125rem",
6: "1.35rem",
7: "1.575rem",
8: "1.8rem",
9: "2.025rem",
10: "2.25rem",
11: "2.475rem",
12: "2.7rem",
16: "3.6rem",
20: "4.5rem",
24: "5.4rem",
32: "7.2rem",
40: "9rem",
48: "10.8rem",
56: "12.6rem",
64: "14.4rem",
72: "16.2rem",
80: "18rem",
96: "21.6rem",
},
space: {
0: "0",
0.5: "0.1125rem",
1: "0.225rem",
1.5: "0.3375rem",
2: "0.45rem",
2.5: "0.5625rem",
3: "0.675rem",
3.5: "0.7875rem",
4: "0.9rem",
5: "1.125rem",
6: "1.35rem",
7: "1.575rem",
8: "1.8rem",
9: "2.025rem",
10: "2.25rem",
11: "2.475rem",
12: "2.7rem",
16: "3.6rem",
20: "4.5rem",
24: "5.4rem",
32: "7.2rem",
40: "9rem",
48: "10.8rem",
56: "12.6rem",
64: "14.4rem",
72: "16.2rem",
80: "18rem",
96: "21.6rem",
},
gap: {
0: "0",
0.5: "0.1125rem",
1: "0.225rem",
1.5: "0.3375rem",
2: "0.45rem",
2.5: "0.5625rem",
3: "0.675rem",
3.5: "0.7875rem",
4: "0.9rem",
5: "1.125rem",
6: "1.35rem",
7: "1.575rem",
8: "1.8rem",
9: "2.025rem",
10: "2.25rem",
11: "2.475rem",
12: "2.7rem",
16: "3.6rem",
20: "4.5rem",
24: "5.4rem",
32: "7.2rem",
40: "9rem",
48: "10.8rem",
56: "12.6rem",
64: "14.4rem",
72: "16.2rem",
80: "18rem",
96: "21.6rem",
},
},
fontFamily: {
custom: ["Inter", "sans-serif"],
+1
View File
@@ -16,6 +16,7 @@
"clean": "rm -rf .turbo && rm -rf node_modules && rm -rf dist"
},
"devDependencies": {
"@types/react-color" : "^3.0.9",
"@types/node": "^20.5.2",
"@types/react": "18.2.0",
"@types/react-dom": "18.2.0",
@@ -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";
@@ -1,15 +1,11 @@
import React from "react";
// mobx
import { observer } from "mobx-react-lite";
// headless ui
import { Listbox, Transition } from "@headlessui/react";
import { MoveRight } from "lucide-react";
// hooks
import useToast from "hooks/use-toast";
// ui
import { Icon } from "components/ui";
// icons
import { East } from "@mui/icons-material";
// helpers
import { copyTextToClipboard } from "helpers/string.helper";
// store
@@ -44,7 +40,7 @@ const peekModes: {
];
export const PeekOverviewHeader: React.FC<Props> = observer((props) => {
const { handleClose, issueDetails } = props;
const { handleClose } = props;
const { issueDetails: issueDetailStore }: RootStore = useMobxStore();
@@ -68,11 +64,7 @@ export const PeekOverviewHeader: React.FC<Props> = observer((props) => {
<div className="flex items-center gap-4">
{issueDetailStore.peekMode === "side" && (
<button type="button" onClick={handleClose}>
<East
sx={{
fontSize: "14px",
}}
/>
<MoveRight className="h-3.5 w-3.5" strokeWidth={2} />
</button>
)}
<Listbox
+3
View File
@@ -8,6 +8,9 @@ const nextConfig = {
experimental: {
outputFileTracingRoot: path.join(__dirname, "../"),
},
images: {
unoptimized: true,
},
output: "standalone",
};
+4 -2
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",
@@ -15,8 +16,9 @@
"@emotion/react": "^11.11.1",
"@emotion/styled": "^11.11.0",
"@headlessui/react": "^1.7.13",
"@mui/icons-material": "^5.14.1",
"@mui/material": "^5.14.1",
"@plane/ui": "*",
"@plane/lite-text-editor": "*",
"@plane/rich-text-editor": "*",
"axios": "^1.3.4",
"clsx": "^2.0.0",
+34 -20
View File
@@ -12,10 +12,20 @@ export interface EmailPasswordFormValues {
export interface IEmailPasswordForm {
onSubmit: (formData: EmailPasswordFormValues) => Promise<void>;
buttonText?: string;
submittingButtonText?: string;
withForgetPassword?: boolean;
withSignUpLink?: boolean;
}
export const EmailPasswordForm: React.FC<IEmailPasswordForm> = (props) => {
const { onSubmit } = props;
const {
onSubmit,
buttonText = "Sign in",
submittingButtonText = "Signing in...",
withForgetPassword = false,
withSignUpLink = false,
} = props;
// router
const router = useRouter();
// form info
@@ -82,15 +92,17 @@ export const EmailPasswordForm: React.FC<IEmailPasswordForm> = (props) => {
)}
/>
</div>
<div className="text-right text-xs">
<button
type="button"
onClick={() => router.push("/accounts/forgot-password")}
className="text-custom-text-200 hover:text-custom-primary-100"
>
Forgot your password?
</button>
</div>
{withForgetPassword && (
<div className="text-right text-xs">
<button
type="button"
onClick={() => router.push("/accounts/forgot-password")}
className="text-custom-text-200 hover:text-custom-primary-100"
>
Forgot your password?
</button>
</div>
)}
<div>
<Button
variant="primary"
@@ -100,18 +112,20 @@ export const EmailPasswordForm: React.FC<IEmailPasswordForm> = (props) => {
disabled={!isValid && isDirty}
loading={isSubmitting}
>
{isSubmitting ? "Signing in..." : "Sign in"}
{isSubmitting ? submittingButtonText : buttonText}
</Button>
</div>
<div className="text-xs">
<button
type="button"
onClick={() => router.push("/accounts/sign-up")}
className="text-custom-text-200 hover:text-custom-primary-100"
>
{"Don't have an account? Sign Up"}
</button>
</div>
{withSignUpLink && (
<div className="text-xs">
<button
type="button"
onClick={() => router.push("/accounts/sign-up")}
className="text-custom-text-200 hover:text-custom-primary-100"
>
{"Don't have an account? Sign Up"}
</button>
</div>
)}
</form>
</>
);
+3 -3
View File
@@ -4,7 +4,7 @@ import { Controller, useForm } from "react-hook-form";
// ui
import { Button, Input } from "@plane/ui";
// types
type EmailPasswordFormValues = {
export type EmailPasswordSignUpFormValues = {
email: string;
password?: string;
confirm_password: string;
@@ -12,7 +12,7 @@ type EmailPasswordFormValues = {
};
type Props = {
onSubmit: (formData: EmailPasswordFormValues) => Promise<void>;
onSubmit: (formData: EmailPasswordSignUpFormValues) => Promise<void>;
};
export const EmailSignUpForm: React.FC<Props> = (props) => {
@@ -23,7 +23,7 @@ export const EmailSignUpForm: React.FC<Props> = (props) => {
control,
watch,
formState: { errors, isSubmitting, isValid, isDirty },
} = useForm<EmailPasswordFormValues>({
} = useForm<EmailPasswordSignUpFormValues>({
defaultValues: {
email: "",
password: "",
@@ -8,7 +8,7 @@ import useUser from "hooks/use-user";
// components
import { CommandModal, ShortcutsModal } from "components/command-palette";
import { BulkDeleteIssuesModal } from "components/core";
import { CreateUpdateCycleModal } from "components/cycles";
import { CycleCreateUpdateModal } from "components/cycles";
import { CreateUpdateIssueModal, DeleteIssueModal } from "components/issues";
import { CreateUpdateModuleModal } from "components/modules";
import { CreateProjectModal } from "components/project";
@@ -180,7 +180,7 @@ export const CommandPalette: FC = observer(() => {
)}
{workspaceSlug && projectId && (
<>
<CreateUpdateCycleModal
<CycleCreateUpdateModal
isOpen={isCreateCycleModalOpen}
handleClose={() => toggleCreateCycleModal(false)}
workspaceSlug={workspaceSlug.toString()}
+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,11 +1,16 @@
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 { StateGroupIcon } from "@plane/ui";
import { SingleProgressStats } from "components/core";
// ui
import { Avatar } from "components/ui";
@@ -17,9 +22,7 @@ import {
TLabelsDistribution,
TStateGroups,
} from "types";
// constants
import { STATE_GROUP_COLORS } from "constants/state";
// types
type Props = {
distribution: {
assignees: TAssigneesDistribution[];
@@ -33,6 +36,7 @@ type Props = {
module?: IModule;
roundedTab?: boolean;
noBackground?: boolean;
isPeekModuleDetails?: boolean;
};
export const SidebarProgressStats: React.FC<Props> = ({
@@ -42,6 +46,7 @@ export const SidebarProgressStats: React.FC<Props> = ({
module,
roundedTab,
noBackground,
isPeekModuleDetails = false,
}) => {
const { filters, setFilters } = useIssuesView();
@@ -55,7 +60,6 @@ export const SidebarProgressStats: React.FC<Props> = ({
return 1;
case "States":
return 2;
default:
return 0;
}
@@ -72,7 +76,6 @@ export const SidebarProgressStats: React.FC<Props> = ({
return setTab("Labels");
case 2:
return setTab("States");
default:
return setTab("Assignees");
}
@@ -82,15 +85,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 +106,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 +119,128 @@ 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
user={{
id: assignee.assignee_id,
avatar: assignee.avatar ?? "",
first_name: assignee.first_name ?? "",
last_name: assignee.last_name ?? "",
display_name: assignee.display_name ?? "",
}}
height="18px"
width="18px"
/>
<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}
{...(!isPeekModuleDetails && {
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}
{...(!isPeekModuleDetails && {
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,163 +0,0 @@
import { Fragment, useEffect, useState } from "react";
import { Dialog, Transition } from "@headlessui/react";
import { observer } from "mobx-react-lite";
// components
import { CycleForm } from "./form";
// hooks
import useToast from "hooks/use-toast";
import { useMobxStore } from "lib/mobx/store-provider";
// types
import { CycleDateCheckData, ICycle } from "types";
interface ICycleCreateEdit {
cycle?: ICycle | null;
modal: boolean;
modalClose: () => void;
onSubmit?: () => void;
workspaceSlug: string;
projectId: string;
}
export const CycleCreateEditModal: React.FC<ICycleCreateEdit> = observer((props) => {
const { modal, modalClose, cycle = null, onSubmit, workspaceSlug, projectId } = props;
const [activeProject, setActiveProject] = useState<string | null>(null);
const { project: projectStore, cycle: cycleStore } = useMobxStore();
const projects = workspaceSlug ? projectStore.projects[workspaceSlug.toString()] : undefined;
const { setToastAlert } = useToast();
const validateCycleDate = async (payload: CycleDateCheckData) => {
let status = false;
await cycleStore.validateDate(workspaceSlug as string, projectId as string, payload).then((res) => {
status = res.status;
});
return status;
};
const formSubmit = async (data: Partial<ICycle>) => {
let isDateValid: boolean = true;
if (data?.start_date && data?.end_date) {
if (cycle?.id && cycle?.start_date && cycle?.end_date)
isDateValid = await validateCycleDate({
start_date: data.start_date,
end_date: data.end_date,
cycle_id: cycle.id,
});
else
isDateValid = await validateCycleDate({
start_date: data.start_date,
end_date: data.end_date,
});
}
if (isDateValid)
if (cycle) {
try {
await cycleStore.updateCycle(workspaceSlug, projectId, cycle.id, data);
if (modalClose) modalClose();
if (onSubmit) onSubmit();
setToastAlert({
type: "success",
title: "Success!",
message: "Cycle updated successfully.",
});
} catch (error) {
console.log("error", error);
setToastAlert({
type: "error",
title: "Warning!",
message: "Something went wrong please try again later.",
});
}
} else {
try {
await cycleStore.createCycle(workspaceSlug, projectId, data);
if (modalClose) modalClose();
if (onSubmit) onSubmit();
setToastAlert({
type: "success",
title: "Success!",
message: "Cycle created successfully.",
});
} catch (error) {
console.log("error", error);
setToastAlert({
type: "error",
title: "Warning!",
message: "Something went wrong please try again later.",
});
}
}
else
setToastAlert({
type: "error",
title: "Error!",
message: "You already have a cycle on the given dates, if you want to create a draft cycle, remove the dates.",
});
};
useEffect(() => {
// if modal is closed, reset active project to null
// and return to avoid activeProject being set to some other project
if (!modal) {
setActiveProject(null);
return;
}
// if data is present, set active project to the project of the
// issue. This has more priority than the project in the url.
if (cycle && cycle.project) {
setActiveProject(cycle.project);
return;
}
// if data is not present, set active project to the project
// in the url. This has the least priority.
if (projects && projects.length > 0 && !activeProject)
setActiveProject(projects?.find((p) => p.id === projectId)?.id ?? projects?.[0].id ?? null);
}, [activeProject, cycle, projectId, projects, modal]);
return (
<Transition.Root show={modal} as={Fragment}>
<Dialog as="div" className="relative z-20" onClose={modalClose}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-custom-backdrop bg-opacity-50 transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-10 overflow-y-auto">
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="relative transform rounded-lg border border-custom-border-200 bg-custom-background-100 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-2xl p-5">
<CycleForm
handleFormSubmit={formSubmit}
handleClose={modalClose}
projectId={activeProject ?? ""}
setActiveProject={setActiveProject}
data={cycle}
/>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
});
+11 -19
View File
@@ -7,8 +7,7 @@ import { Disclosure, Transition } from "@headlessui/react";
import useToast from "hooks/use-toast";
// components
import { SingleProgressStats } from "components/core";
import { CycleCreateEditModal } from "./cycle-create-edit-modal";
import { CycleDeleteModal } from "./cycle-delete-modal";
import { CycleCreateUpdateModal, CycleDeleteModal } from "components/cycles";
// ui
import { AssigneesList } from "components/ui/avatar";
import { CustomMenu, Tooltip, LinearProgressIndicator, ContrastIcon, RunningIcon } from "@plane/ui";
@@ -69,19 +68,14 @@ export interface ICyclesBoardCard {
export const CyclesBoardCard: FC<ICyclesBoardCard> = (props) => {
const { cycle, workspaceSlug, projectId } = props;
const [updateModal, setUpdateModal] = useState(false);
const updateModalCallback = () => {};
const [deleteModal, setDeleteModal] = useState(false);
const deleteModalCallback = () => {};
// store
const { cycle: cycleStore } = useMobxStore();
// toast
const { setToastAlert } = useToast();
// states
const [updateModal, setUpdateModal] = useState(false);
const [deleteModal, setDeleteModal] = useState(false);
// computed
const cycleStatus = getDateRangeStatus(cycle.start_date, cycle.end_date);
const isCompleted = cycleStatus === "completed";
const endDate = new Date(cycle.end_date ?? "");
@@ -142,20 +136,18 @@ export const CyclesBoardCard: FC<ICyclesBoardCard> = (props) => {
return (
<div>
<CycleCreateEditModal
cycle={cycle}
modal={updateModal}
modalClose={() => setUpdateModal(false)}
onSubmit={updateModalCallback}
<CycleCreateUpdateModal
data={cycle}
isOpen={updateModal}
handleClose={() => setUpdateModal(false)}
workspaceSlug={workspaceSlug}
projectId={projectId}
/>
<CycleDeleteModal
cycle={cycle}
modal={deleteModal}
modalClose={() => setDeleteModal(false)}
onSubmit={deleteModalCallback}
isOpen={deleteModal}
handleClose={() => setDeleteModal(false)}
workspaceSlug={workspaceSlug}
projectId={projectId}
/>
+11 -19
View File
@@ -3,8 +3,7 @@ import Link from "next/link";
// hooks
import useToast from "hooks/use-toast";
// components
import { CycleCreateEditModal } from "./cycle-create-edit-modal";
import { CycleDeleteModal } from "./cycle-delete-modal";
import { CycleCreateUpdateModal, CycleDeleteModal } from "components/cycles";
// ui
import { CustomMenu, RadialProgressBar, Tooltip, LinearProgressIndicator, ContrastIcon, RunningIcon } from "@plane/ui";
// icons
@@ -66,19 +65,14 @@ const stateGroups = [
export const CyclesListItem: FC<TCyclesListItem> = (props) => {
const { cycle, workspaceSlug, projectId } = props;
const [updateModal, setUpdateModal] = useState(false);
const updateModalCallback = () => {};
const [deleteModal, setDeleteModal] = useState(false);
const deleteModalCallback = () => {};
// store
const { cycle: cycleStore } = useMobxStore();
// toast
const { setToastAlert } = useToast();
// states
const [updateModal, setUpdateModal] = useState(false);
const [deleteModal, setDeleteModal] = useState(false);
// computed
const cycleStatus = getDateRangeStatus(cycle.start_date, cycle.end_date);
const isCompleted = cycleStatus === "completed";
const endDate = new Date(cycle.end_date ?? "");
@@ -347,20 +341,18 @@ export const CyclesListItem: FC<TCyclesListItem> = (props) => {
</div>
</div>
<CycleCreateEditModal
cycle={cycle}
modal={updateModal}
modalClose={() => setUpdateModal(false)}
onSubmit={updateModalCallback}
<CycleCreateUpdateModal
data={cycle}
isOpen={updateModal}
handleClose={() => setUpdateModal(false)}
workspaceSlug={workspaceSlug}
projectId={projectId}
/>
<CycleDeleteModal
cycle={cycle}
modal={deleteModal}
modalClose={() => setDeleteModal(false)}
onSubmit={deleteModalCallback}
isOpen={deleteModal}
handleClose={() => setDeleteModal(false)}
workspaceSlug={workspaceSlug}
projectId={projectId}
/>
@@ -13,24 +13,23 @@ import { useMobxStore } from "lib/mobx/store-provider";
interface ICycleDelete {
cycle: ICycle;
modal: boolean;
modalClose: () => void;
onSubmit?: () => void;
isOpen: boolean;
handleClose: () => void;
workspaceSlug: string;
projectId: string;
}
export const CycleDeleteModal: React.FC<ICycleDelete> = observer((props) => {
const { modal, modalClose, cycle, onSubmit, workspaceSlug, projectId } = props;
const { isOpen, handleClose, cycle, workspaceSlug, projectId } = props;
// store
const { cycle: cycleStore } = useMobxStore();
// toast
const { setToastAlert } = useToast();
// states
const [loader, setLoader] = useState(false);
const formSubmit = async () => {
setLoader(true);
if (cycle?.id)
try {
await cycleStore.removeCycle(workspaceSlug, projectId, cycle?.id);
@@ -39,8 +38,7 @@ export const CycleDeleteModal: React.FC<ICycleDelete> = observer((props) => {
title: "Success!",
message: "Cycle deleted successfully.",
});
if (modalClose) modalClose();
if (onSubmit) onSubmit();
handleClose();
} catch (error) {
setToastAlert({
type: "error",
@@ -61,8 +59,8 @@ export const CycleDeleteModal: React.FC<ICycleDelete> = observer((props) => {
return (
<div>
<div>
<Transition.Root show={modal} as={Fragment}>
<Dialog as="div" className="relative z-20" onClose={modalClose}>
<Transition.Root show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-20" onClose={handleClose}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
@@ -103,7 +101,7 @@ export const CycleDeleteModal: React.FC<ICycleDelete> = observer((props) => {
</p>
</span>
<div className="flex justify-end gap-2">
<SecondaryButton onClick={modalClose}>Cancel</SecondaryButton>
<SecondaryButton onClick={handleClose}>Cancel</SecondaryButton>
<DangerButton onClick={formSubmit} loading={loader}>
{loader ? "Deleting..." : "Delete Cycle"}
</DangerButton>
+1 -1
View File
@@ -10,7 +10,7 @@ type Props = {
handleFormSubmit: (values: Partial<ICycle>) => Promise<void>;
handleClose: () => void;
projectId: string;
setActiveProject: React.Dispatch<React.SetStateAction<string | null>>;
setActiveProject: (projectId: string) => void;
data?: ICycle | null;
};
+1
View File
@@ -16,3 +16,4 @@ export * from "./cycles-list-item";
export * from "./cycles-board";
export * from "./cycles-board-card";
export * from "./cycles-gantt";
export * from "./delete-modal";
+17 -113
View File
@@ -1,5 +1,4 @@
import React, { useEffect, useState } from "react";
import { mutate } from "swr";
import React, { useState } from "react";
import { Dialog, Transition } from "@headlessui/react";
// services
import { CycleService } from "services/cycle.service";
@@ -8,20 +7,8 @@ import useToast from "hooks/use-toast";
import { useMobxStore } from "lib/mobx/store-provider";
// components
import { CycleForm } from "components/cycles";
// helper
import { getDateRangeStatus } from "helpers/date-time.helper";
// types
import type { CycleDateCheckData, ICycle, IProject, IUser } from "types";
// fetch keys
import {
COMPLETED_CYCLES_LIST,
CURRENT_CYCLE_LIST,
CYCLES_LIST,
DRAFT_CYCLES_LIST,
INCOMPLETE_CYCLES_LIST,
PROJECT_DETAILS,
UPCOMING_CYCLES_LIST,
} from "constants/fetch-keys";
import type { CycleDateCheckData, ICycle } from "types";
type CycleModalProps = {
isOpen: boolean;
@@ -34,49 +21,19 @@ type CycleModalProps = {
// services
const cycleService = new CycleService();
export const CreateUpdateCycleModal: React.FC<CycleModalProps> = (props) => {
export const CycleCreateUpdateModal: React.FC<CycleModalProps> = (props) => {
const { isOpen, handleClose, data, workspaceSlug, projectId } = props;
const [activeProject, setActiveProject] = useState<string | null>(null);
const { project: projectStore } = useMobxStore();
const projects = workspaceSlug ? projectStore.projects[workspaceSlug.toString()] : undefined;
// store
const { cycle: cycleStore } = useMobxStore();
// states
const [activeProject, setActiveProject] = useState<string>(projectId);
// toast
const { setToastAlert } = useToast();
const createCycle = async (payload: Partial<ICycle>) => {
await cycleService
.createCycle(workspaceSlug.toString(), projectId.toString(), payload, {} as IUser)
.then((res) => {
switch (getDateRangeStatus(res.start_date, res.end_date)) {
case "completed":
mutate(COMPLETED_CYCLES_LIST(projectId.toString()));
break;
case "current":
mutate(CURRENT_CYCLE_LIST(projectId.toString()));
break;
case "upcoming":
mutate(UPCOMING_CYCLES_LIST(projectId.toString()));
break;
default:
mutate(DRAFT_CYCLES_LIST(projectId.toString()));
}
mutate(INCOMPLETE_CYCLES_LIST(projectId.toString()));
mutate(CYCLES_LIST(projectId.toString()));
// update total cycles count in the project details
mutate<IProject>(
PROJECT_DETAILS(projectId.toString()),
(prevData) => {
if (!prevData) return prevData;
return {
...prevData,
total_cycles: prevData.total_cycles + 1,
};
},
false
);
const createCycle = async (payload: Partial<ICycle>) =>
cycleStore
.createCycle(workspaceSlug, projectId, payload)
.then(() => {
setToastAlert({
type: "success",
title: "Success!",
@@ -90,42 +47,11 @@ export const CreateUpdateCycleModal: React.FC<CycleModalProps> = (props) => {
message: "Error in creating cycle. Please try again.",
});
});
};
const updateCycle = async (cycleId: string, payload: Partial<ICycle>) => {
await cycleService
.updateCycle(workspaceSlug.toString(), projectId.toString(), cycleId, payload, {} as IUser)
.then((res) => {
switch (getDateRangeStatus(data?.start_date, data?.end_date)) {
case "completed":
mutate(COMPLETED_CYCLES_LIST(projectId.toString()));
break;
case "current":
mutate(CURRENT_CYCLE_LIST(projectId.toString()));
break;
case "upcoming":
mutate(UPCOMING_CYCLES_LIST(projectId.toString()));
break;
default:
mutate(DRAFT_CYCLES_LIST(projectId.toString()));
}
mutate(CYCLES_LIST(projectId.toString()));
if (getDateRangeStatus(data?.start_date, data?.end_date) != getDateRangeStatus(res.start_date, res.end_date)) {
switch (getDateRangeStatus(res.start_date, res.end_date)) {
case "completed":
mutate(COMPLETED_CYCLES_LIST(projectId.toString()));
break;
case "current":
mutate(CURRENT_CYCLE_LIST(projectId.toString()));
break;
case "upcoming":
mutate(UPCOMING_CYCLES_LIST(projectId.toString()));
break;
default:
mutate(DRAFT_CYCLES_LIST(projectId.toString()));
}
}
const updateCycle = async (cycleId: string, payload: Partial<ICycle>) =>
cycleStore
.updateCycle(workspaceSlug, projectId, cycleId, payload)
.then(() => {
setToastAlert({
type: "success",
title: "Success!",
@@ -139,7 +65,6 @@ export const CreateUpdateCycleModal: React.FC<CycleModalProps> = (props) => {
message: "Error in updating cycle. Please try again.",
});
});
};
const dateChecker = async (payload: CycleDateCheckData) => {
let status = false;
@@ -186,27 +111,6 @@ export const CreateUpdateCycleModal: React.FC<CycleModalProps> = (props) => {
});
};
useEffect(() => {
// if modal is closed, reset active project to null
// and return to avoid activeProject being set to some other project
if (!isOpen) {
setActiveProject(null);
return;
}
// if data is present, set active project to the project of the
// issue. This has more priority than the project in the url.
if (data && data.project) {
setActiveProject(data.project);
return;
}
// if data is not present, set active project to the project
// in the url. This has the least priority.
if (projects && projects.length > 0 && !activeProject)
setActiveProject(projects?.find((p) => p.id === projectId)?.id ?? projects?.[0].id ?? null);
}, [activeProject, data, projectId, projects, isOpen]);
return (
<Transition.Root show={isOpen} as={React.Fragment}>
<Dialog as="div" className="relative z-20" onClose={handleClose}>
@@ -237,7 +141,7 @@ export const CreateUpdateCycleModal: React.FC<CycleModalProps> = (props) => {
<CycleForm
handleFormSubmit={handleFormSubmit}
handleClose={handleClose}
projectId={activeProject ?? ""}
projectId={activeProject}
setActiveProject={setActiveProject}
data={data}
/>
+2 -5
View File
@@ -1,7 +1,6 @@
import React, { useState } from "react";
import { useRouter } from "next/router";
import useSWR from "swr";
import useUserAuth from "hooks/use-user-auth";
import { Listbox, Transition } from "@headlessui/react";
// icons
import { ContrastIcon } from "@plane/ui";
@@ -9,7 +8,7 @@ import { Plus } from "lucide-react";
// services
import { CycleService } from "services/cycle.service";
// components
import { CreateUpdateCycleModal } from "components/cycles";
import { CycleCreateUpdateModal } from "components/cycles";
// fetch-keys
import { CYCLES_LIST } from "constants/fetch-keys";
@@ -29,8 +28,6 @@ export const CycleSelect: React.FC<IssueCycleSelectProps> = ({ projectId, value,
const router = useRouter();
const { workspaceSlug } = router.query;
const { user } = useUserAuth();
const { data: cycles } = useSWR(
workspaceSlug && projectId ? CYCLES_LIST(projectId) : null,
workspaceSlug && projectId
@@ -51,7 +48,7 @@ export const CycleSelect: React.FC<IssueCycleSelectProps> = ({ projectId, value,
return (
<>
{workspaceSlug && projectId && (
<CreateUpdateCycleModal
<CycleCreateUpdateModal
isOpen={isCycleModalActive}
handleClose={closeCycleModal}
workspaceSlug={workspaceSlug.toString()}
+5 -6
View File
@@ -13,7 +13,7 @@ import useToast from "hooks/use-toast";
// components
import { SidebarProgressStats } from "components/core";
import ProgressChart from "components/core/sidebar/progress-chart";
import { CycleDeleteModal } from "components/cycles/cycle-delete-modal";
import { CycleDeleteModal } from "components/cycles/delete-modal";
// ui
import { CustomRangeDatePicker } from "components/ui";
import { CustomMenu, Loader, ProgressBar } from "@plane/ui";
@@ -285,17 +285,16 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
{cycleDetails && workspaceSlug && projectId && (
<CycleDeleteModal
cycle={cycleDetails}
modal={cycleDeleteModal}
modalClose={() => setCycleDeleteModal(false)}
onSubmit={() => {}}
isOpen={cycleDeleteModal}
handleClose={() => setCycleDeleteModal(false)}
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
/>
)}
<div
className={`absolute top-0 z-20 ${
className={`fixed top-[66px] ${
isOpen ? "right-0" : "-right-[24rem]"
} h-full w-[24rem] overflow-y-auto border-l border-custom-border-100 bg-custom-sidebar-background-100 pt-5 pb-10 duration-300`}
} 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 ? (
<>
@@ -0,0 +1,160 @@
import React, { useState } from "react";
import { usePopper } from "react-popper";
import { Combobox } from "@headlessui/react";
import { Check, ChevronDown, Search, Triangle } from "lucide-react";
// types
import { Tooltip } from "components/ui";
import { Placement } from "@popperjs/core";
// constants
import { IEstimatePoint } from "types";
type Props = {
value: number | null;
onChange: (value: number | null) => void;
estimatePoints: IEstimatePoint[] | undefined;
className?: string;
buttonClassName?: string;
optionsClassName?: string;
placement?: Placement;
hideDropdownArrow?: boolean;
disabled?: boolean;
};
export const EstimateSelect: React.FC<Props> = (props) => {
const {
value,
onChange,
estimatePoints,
className = "",
buttonClassName = "",
optionsClassName = "",
placement,
hideDropdownArrow = false,
disabled = false,
} = props;
const [query, setQuery] = useState("");
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
const { styles, attributes } = usePopper(referenceElement, popperElement, {
placement: placement ?? "bottom-start",
modifiers: [
{
name: "preventOverflow",
options: {
padding: 12,
},
},
],
});
const options: { value: number | null; query: string; content: any }[] | undefined = estimatePoints?.map(
(estimate) => ({
value: estimate.key,
query: estimate.value,
content: (
<div className="flex items-center gap-2">
<Triangle className="h-3 w-3" strokeWidth={2} />
{estimate.value}
</div>
),
})
);
options?.unshift({
value: null,
query: "none",
content: (
<div className="flex items-center gap-2">
<Triangle className="h-3 w-3" strokeWidth={2} />
None
</div>
),
});
const filteredOptions =
query === "" ? options : options?.filter((option) => option.query.toLowerCase().includes(query.toLowerCase()));
const selectedEstimate = estimatePoints?.find((e) => e.key === value);
const label = (
<Tooltip tooltipHeading="Estimate" tooltipContent={selectedEstimate?.value ?? "None"} position="top">
<div className="flex items-center cursor-pointer w-full gap-2 text-custom-text-200">
<Triangle className="h-3 w-3" strokeWidth={2} />
<span className="truncate">{selectedEstimate?.value ?? "None"}</span>
</div>
</Tooltip>
);
return (
<Combobox
as="div"
className={`flex-shrink-0 text-left ${className}`}
value={value}
onChange={(val) => onChange(val as number | null)}
disabled={disabled}
>
<Combobox.Button as={React.Fragment}>
<button
ref={setReferenceElement}
type="button"
className={`flex items-center justify-between gap-1 w-full text-xs px-2.5 py-1 rounded border-[0.5px] border-custom-border-300 duration-300 focus:outline-none ${
disabled ? "cursor-not-allowed text-custom-text-200" : "cursor-pointer hover:bg-custom-background-80"
} ${buttonClassName}`}
>
{label}
{!hideDropdownArrow && !disabled && <ChevronDown className="h-3 w-3" aria-hidden="true" />}
</button>
</Combobox.Button>
<Combobox.Options className="fixed z-10">
<div
className={`border border-custom-border-300 px-2 py-2.5 rounded bg-custom-background-100 text-xs shadow-custom-shadow-rg focus:outline-none w-48 whitespace-nowrap my-1 ${optionsClassName}`}
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
<div className="flex w-full items-center justify-start rounded border border-custom-border-200 bg-custom-background-90 px-2">
<Search className="h-3.5 w-3.5 text-custom-text-300" />
<Combobox.Input
className="w-full bg-transparent py-1 px-2 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search"
displayValue={(assigned: any) => assigned?.name}
/>
</div>
<div className={`mt-2 space-y-1 max-h-48 overflow-y-scroll`}>
{filteredOptions ? (
filteredOptions.length > 0 ? (
filteredOptions.map((option) => (
<Combobox.Option
key={option.value}
value={option.value}
className={({ active, selected }) =>
`flex items-center justify-between gap-2 cursor-pointer select-none truncate rounded px-1 py-1.5 ${
active ? "bg-custom-background-80" : ""
} ${selected ? "text-custom-text-100" : "text-custom-text-200"}`
}
>
{({ selected }) => (
<>
{option.content}
{selected && <Check className="h-3.5 w-3.5" />}
</>
)}
</Combobox.Option>
))
) : (
<span className="flex items-center gap-2 p-1">
<p className="text-left text-custom-text-200 ">No matching results</p>
</span>
)
) : (
<p className="text-center text-custom-text-200">Loading...</p>
)}
</div>
</div>
</Combobox.Options>
</Combobox>
);
};
@@ -1,3 +1,4 @@
export * from "./create-update-estimate-modal";
export * from "./single-estimate";
export * from "./delete-estimate-modal";
export * from "./estimate-select";
export * from "./single-estimate";
+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>
);
};
+5 -1
View File
@@ -6,6 +6,8 @@ import { MoreVertical } from "lucide-react";
import { useChart } from "./hooks";
// ui
import { Loader } from "@plane/ui";
// components
import { GanttInlineCreateIssueForm } from "components/issues";
// helpers
import { findTotalDaysInRange } from "helpers/date-time.helper";
// types
@@ -17,11 +19,12 @@ type Props = {
blocks: IGanttBlock[] | null;
SidebarBlockRender: React.FC<any>;
enableReorder: boolean;
enableQuickIssueCreate?: 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 { title, blockUpdateHandler, blocks, SidebarBlockRender, enableReorder, enableQuickIssueCreate } = props;
const router = useRouter();
const { cycleId } = router.query;
@@ -150,6 +153,7 @@ export const GanttSidebar: React.FC<Props> = (props) => {
)}
{droppableProvided.placeholder}
</>
<GanttInlineCreateIssueForm />
</div>
)}
</StrictModeDroppable>
+1 -10
View File
@@ -2,7 +2,7 @@ import * as React from "react";
import { useRouter } from "next/router";
import Link from "next/link";
import { ArrowLeft, Plus } from "lucide-react";
import { Plus } from "lucide-react";
// ui
import { Breadcrumbs, BreadcrumbItem, Button } from "@plane/ui";
// helpers
@@ -23,15 +23,6 @@ export const CyclesHeader: React.FC<ICyclesHeader> = (props) => {
className={`relative z-10 flex w-full flex-shrink-0 flex-row items-center justify-between gap-x-2 gap-y-4 border-b border-custom-border-200 bg-custom-sidebar-background-100 p-4`}
>
<div className="flex w-full flex-grow items-center gap-2 overflow-ellipsis whitespace-nowrap">
<div className="block md:hidden">
<button
type="button"
className="grid h-8 w-8 place-items-center rounded border border-custom-border-200"
onClick={() => router.back()}
>
<ArrowLeft fontSize={14} strokeWidth={2} />
</button>
</div>
<div>
<Breadcrumbs onBack={() => router.back()}>
<BreadcrumbItem
+7 -1
View File
@@ -10,6 +10,12 @@ export * from "./workspace-dashboard";
export * from "./projects";
export * from "./profile-preferences";
export * from "./cycles";
export * from "./modules";
export * from "./modules-list";
export * from "./project-settings";
export * from "./workspace-settings";
export * from "./pages";
export * from "./project-draft-issues";
export * from "./project-archived-issue-details";
export * from "./project-archived-issues";
export * from "./project-issue-details";
export * from "./user-profile";
@@ -1,51 +1,46 @@
import { Dispatch, FC, SetStateAction } from "react";
import { useRouter } from "next/router";
import Link from "next/link";
import { ArrowLeft, Plus } from "lucide-react";
// components
import { observer } from "mobx-react-lite";
import { GanttChart, LayoutGrid, List, Plus } from "lucide-react";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
// hooks
import useLocalStorage from "hooks/use-local-storage";
// ui
import { Breadcrumbs, BreadcrumbItem, Button, Tooltip } from "@plane/ui";
import { Icon } from "components/ui";
// helper
import { replaceUnderscoreIfSnakeCase, truncateText } from "helpers/string.helper";
export interface IModulesHeader {
name: string | undefined;
modulesView: string;
setModulesView: Dispatch<SetStateAction<"grid" | "gantt_chart">>;
}
const moduleViewOptions: { type: "grid" | "gantt_chart"; icon: any }[] = [
const moduleViewOptions: { type: "list" | "grid" | "gantt_chart"; icon: any }[] = [
{
type: "gantt_chart",
icon: "view_timeline",
type: "list",
icon: List,
},
{
type: "grid",
icon: "table_rows",
icon: LayoutGrid,
},
{
type: "gantt_chart",
icon: GanttChart,
},
];
export const ModulesHeader: FC<IModulesHeader> = (props) => {
const { name, modulesView, setModulesView } = props;
export const ModulesListHeader: React.FC = observer(() => {
// router
const router = useRouter();
const { workspaceSlug } = router.query;
const { workspaceSlug, projectId } = router.query;
const { project: projectStore } = useMobxStore();
const projectDetails = projectId ? projectStore.project_details[projectId.toString()] : undefined;
const { storedValue: modulesView, setValue: setModulesView } = useLocalStorage("modules_view", "grid");
return (
<div
className={`relative z-10 flex w-full flex-shrink-0 flex-row items-center justify-between gap-x-2 gap-y-4 border-b border-custom-border-200 bg-custom-sidebar-background-100 p-4`}
>
<div className="flex w-full flex-grow items-center gap-2 overflow-ellipsis whitespace-nowrap">
<div className="block md:hidden">
<button
type="button"
className="grid h-8 w-8 place-items-center rounded border border-custom-border-200"
onClick={() => router.back()}
>
<ArrowLeft fontSize={14} strokeWidth={2} />
</button>
</div>
<div>
<Breadcrumbs onBack={() => router.back()}>
<BreadcrumbItem
@@ -57,7 +52,7 @@ export const ModulesHeader: FC<IModulesHeader> = (props) => {
</Link>
}
/>
<BreadcrumbItem title={`${truncateText(name ?? "Project", 32)} Modules`} />
<BreadcrumbItem title={`${truncateText(projectDetails?.name ?? "Project", 32)} Modules`} />
</Breadcrumbs>
</div>
</div>
@@ -75,7 +70,7 @@ export const ModulesHeader: FC<IModulesHeader> = (props) => {
}`}
onClick={() => setModulesView(option.type)}
>
<Icon iconName={option.icon} className={`!text-base ${option.type === "grid" ? "rotate-90" : ""}`} />
<option.icon className="h-3.5 w-3.5" />
</button>
</Tooltip>
))}
@@ -92,4 +87,4 @@ export const ModulesHeader: FC<IModulesHeader> = (props) => {
</div>
</div>
);
};
});
+64
View File
@@ -0,0 +1,64 @@
import { FC } from "react";
import Link from "next/link";
import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
import { Plus } from "lucide-react";
// hooks
import { useMobxStore } from "lib/mobx/store-provider";
// ui
import { Breadcrumbs, BreadcrumbItem, Button } from "@plane/ui";
// helper
import { truncateText } from "helpers/string.helper";
export interface IPagesHeaderProps {
showButton?: boolean;
}
export const PagesHeader: FC<IPagesHeaderProps> = observer((props) => {
const { showButton = false } = props;
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { project: projectStore } = useMobxStore();
const projectDetails =
workspaceSlug && projectId
? projectStore.getProjectById(workspaceSlug.toString(), projectId.toString())
: undefined;
return (
<div className="relative flex w-full flex-shrink-0 flex-row z-10 items-center justify-between gap-x-2 gap-y-4 border-b border-custom-border-200 bg-custom-sidebar-background-100 p-4">
<div className="flex items-center gap-2 flex-grow w-full whitespace-nowrap overflow-ellipsis">
<div>
<Breadcrumbs onBack={() => router.back()}>
<BreadcrumbItem
link={
<Link href={`/${workspaceSlug}/projects`}>
<a className={`border-r-2 border-custom-sidebar-border-200 px-3 text-sm `}>
<p>Projects</p>
</a>
</Link>
}
/>
<BreadcrumbItem title={`${truncateText(projectDetails?.name ?? "Project", 32)} Pages`} />
</Breadcrumbs>
</div>
</div>
{showButton && (
<div className="flex items-center gap-2">
<Button
variant="primary"
prependIcon={<Plus />}
size="sm"
onClick={() => {
const e = new KeyboardEvent("keydown", { key: "d" });
document.dispatchEvent(e);
}}
>
Create Page
</Button>
</div>
)}
</div>
);
});
@@ -1,5 +1,4 @@
import { useRouter } from "next/router";
import { ArrowLeft } from "lucide-react";
// components
import { BreadcrumbItem, Breadcrumbs } from "@plane/ui";
@@ -11,15 +10,6 @@ export const ProfilePreferencesHeader = () => {
className={`relative flex w-full flex-shrink-0 flex-row z-10 items-center justify-between gap-x-2 gap-y-4 border-b border-custom-border-200 bg-custom-sidebar-background-100 p-4`}
>
<div className="flex items-center gap-2 flex-grow w-full whitespace-nowrap overflow-ellipsis">
<div className="block md:hidden">
<button
type="button"
className="grid h-8 w-8 place-items-center rounded border border-custom-border-200"
onClick={() => router.back()}
>
<ArrowLeft fontSize={14} strokeWidth={2} />
</button>
</div>
<div>
<Breadcrumbs onBack={() => router.back()}>
<BreadcrumbItem title="My Profile Preferences" />
@@ -0,0 +1,64 @@
import { FC } from "react";
import useSWR from "swr";
import Link from "next/link";
import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
// hooks
import { useMobxStore } from "lib/mobx/store-provider";
// ui
import { Breadcrumbs, BreadcrumbItem } from "@plane/ui";
// helper
import { truncateText } from "helpers/string.helper";
// types
import { IIssue } from "types";
// constants
import { ISSUE_DETAILS } from "constants/fetch-keys";
// services
import { IssueArchiveService } from "services/issue";
const issueArchiveService = new IssueArchiveService();
export const ProjectArchivedIssueDetailsHeader: FC = observer(() => {
const router = useRouter();
const { workspaceSlug, projectId, archivedIssueId } = router.query;
const { project: projectStore } = useMobxStore();
const projectDetails =
workspaceSlug && projectId
? projectStore.getProjectById(workspaceSlug.toString(), projectId.toString())
: undefined;
const { data: issueDetails } = useSWR<IIssue | undefined>(
workspaceSlug && projectId && archivedIssueId ? ISSUE_DETAILS(archivedIssueId as string) : null,
workspaceSlug && projectId && archivedIssueId
? () =>
issueArchiveService.retrieveArchivedIssue(
workspaceSlug as string,
projectId as string,
archivedIssueId as string
)
: null
);
return (
<div className="relative flex w-full flex-shrink-0 flex-row z-10 items-center justify-between gap-x-2 gap-y-4 border-b border-custom-border-200 bg-custom-sidebar-background-100 p-4">
<div className="flex items-center gap-2 flex-grow w-full whitespace-nowrap overflow-ellipsis">
<div>
<Breadcrumbs onBack={() => router.back()}>
<Link href={`/${workspaceSlug}/projects/${projectId as string}/issues`}>
<a className={`border-r-2 border-custom-sidebar-border-200 px-3 text-sm `}>
<p className="truncate">{`${truncateText(
issueDetails?.project_detail.name ?? "Project",
32
)} Issues`}</p>
</a>
</Link>
<BreadcrumbItem title={`${truncateText(projectDetails?.name ?? "Project", 32)} Archived Issues`} />
</Breadcrumbs>
</div>
</div>
</div>
);
});
@@ -0,0 +1,40 @@
import { FC } from "react";
import Link from "next/link";
import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
// hooks
import { useMobxStore } from "lib/mobx/store-provider";
// ui
import { Breadcrumbs, BreadcrumbItem } from "@plane/ui";
// helper
import { truncateText } from "helpers/string.helper";
export const ProjectArchivedIssuesHeader: FC = observer(() => {
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { project: projectStore } = useMobxStore();
const projectDetails =
workspaceSlug && projectId
? projectStore.getProjectById(workspaceSlug.toString(), projectId.toString())
: undefined;
return (
<div className="relative flex w-full flex-shrink-0 flex-row z-10 items-center justify-between gap-x-2 gap-y-4 border-b border-custom-border-200 bg-custom-sidebar-background-100 p-4">
<div className="flex items-center gap-2 flex-grow w-full whitespace-nowrap overflow-ellipsis">
<div>
<Breadcrumbs onBack={() => router.back()}>
<Link href={`/${workspaceSlug}/projects`}>
<a className={`border-r-2 border-custom-sidebar-border-200 px-3 text-sm `}>
<p>Projects</p>
</a>
</Link>
<BreadcrumbItem title={`${truncateText(projectDetails?.name ?? "Project", 32)} Archived Issues`} />
</Breadcrumbs>
</div>
</div>
</div>
);
});
@@ -0,0 +1,44 @@
import { FC } from "react";
import Link from "next/link";
import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
// hooks
import { useMobxStore } from "lib/mobx/store-provider";
// ui
import { Breadcrumbs, BreadcrumbItem } from "@plane/ui";
// helper
import { truncateText } from "helpers/string.helper";
export const ProjectDraftIssueHeader: FC = observer(() => {
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { project: projectStore } = useMobxStore();
const projectDetails =
workspaceSlug && projectId
? projectStore.getProjectById(workspaceSlug.toString(), projectId.toString())
: undefined;
return (
<div className="relative flex w-full flex-shrink-0 flex-row z-10 items-center justify-between gap-x-2 gap-y-4 border-b border-custom-border-200 bg-custom-sidebar-background-100 p-4">
<div className="flex items-center gap-2 flex-grow w-full whitespace-nowrap overflow-ellipsis">
<div>
<Breadcrumbs onBack={() => router.back()}>
<BreadcrumbItem
link={
<Link href={`/${workspaceSlug}/projects`}>
<a className={`border-r-2 border-custom-sidebar-border-200 px-3 text-sm `}>
<p>Projects</p>
</a>
</Link>
}
/>
<BreadcrumbItem title={`${truncateText(projectDetails?.name ?? "Project", 32)} Draft Issues`} />
</Breadcrumbs>
</div>
</div>
</div>
);
});
+47 -14
View File
@@ -1,21 +1,54 @@
import { useState } from "react";
import { FC, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
import { Plus } from "lucide-react";
// hooks
import { useMobxStore } from "lib/mobx/store-provider";
// ui
import { Breadcrumbs, BreadcrumbItem, Button } from "@plane/ui";
// components
import { CreateInboxIssueModal } from "components/inbox";
// ui
import { Button } from "@plane/ui";
// icons
import { Plus } from "lucide-react";
// helper
import { truncateText } from "helpers/string.helper";
export const ProjectInboxHeader = () => {
export const ProjectInboxHeader: FC = observer(() => {
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const [createIssueModal, setCreateIssueModal] = useState(false);
const { project: projectStore } = useMobxStore();
const projectDetails =
workspaceSlug && projectId
? projectStore.getProjectById(workspaceSlug.toString(), projectId.toString())
: undefined;
return (
<>
<CreateInboxIssueModal isOpen={createIssueModal} onClose={() => setCreateIssueModal(false)} />
<Button onClick={() => setCreateIssueModal(true)} size="sm" prependIcon={<Plus />}>
Add Issue
</Button>
</>
<div className="relative flex w-full flex-shrink-0 flex-row z-10 items-center justify-between gap-x-2 gap-y-4 border-b border-custom-border-200 bg-custom-sidebar-background-100 p-4">
<div className="flex items-center gap-2 flex-grow w-full whitespace-nowrap overflow-ellipsis">
<div>
<Breadcrumbs onBack={() => router.back()}>
<BreadcrumbItem
link={
<Link href={`/${workspaceSlug}/projects`}>
<a className={`border-r-2 border-custom-sidebar-border-200 px-3 text-sm `}>
<p>Projects</p>
</a>
</Link>
}
/>
<BreadcrumbItem title={`${truncateText(projectDetails?.name ?? "Project", 32)} Inbox`} />
</Breadcrumbs>
</div>
</div>
<div className="flex items-center gap-2">
<CreateInboxIssueModal isOpen={createIssueModal} onClose={() => setCreateIssueModal(false)} />
<Button variant="primary" prependIcon={<Plus />} size="sm" onClick={() => setCreateIssueModal(true)}>
Add Issue
</Button>
</div>
</div>
);
};
});
@@ -0,0 +1,58 @@
import { FC } from "react";
import useSWR from "swr";
import Link from "next/link";
import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
// ui
import { Breadcrumbs } from "@plane/ui";
// helper
import { truncateText } from "helpers/string.helper";
// services
import { IssueService } from "services/issue";
// constants
import { ISSUE_DETAILS } from "constants/fetch-keys";
// services
const issueService = new IssueService();
export const ProjectIssueDetailsHeader: FC = observer(() => {
const router = useRouter();
const { workspaceSlug, projectId, issueId } = router.query;
const { data: issueDetails } = useSWR(
workspaceSlug && projectId && issueId ? ISSUE_DETAILS(issueId as string) : null,
workspaceSlug && projectId && issueId
? () => issueService.retrieve(workspaceSlug as string, projectId as string, issueId as string)
: null
);
return (
<div className="relative flex w-full flex-shrink-0 flex-row z-10 items-center justify-between gap-x-2 gap-y-4 border-b border-custom-border-200 bg-custom-sidebar-background-100 p-4">
<div className="flex items-center gap-2 flex-grow w-full whitespace-nowrap overflow-ellipsis">
<div>
<Breadcrumbs onBack={() => router.back()}>
<Breadcrumbs.BreadcrumbItem
link={
<Link href={`/${workspaceSlug}/projects/${projectId as string}/issues`}>
<a className={`border-r-2 border-custom-sidebar-border-200 px-3 text-sm `}>
<p className="truncate">{`${truncateText(
issueDetails?.project_detail.name ?? "Project",
32
)} Issues`}</p>
</a>
</Link>
}
/>
<Breadcrumbs.BreadcrumbItem
title={`Issue ${issueDetails?.project_detail.identifier ?? "Project"}-${
issueDetails?.sequence_id ?? "..."
} Details`}
unshrinkTitle
/>
</Breadcrumbs>
</div>
</div>
</div>
);
});
+18 -4
View File
@@ -1,9 +1,9 @@
import { useCallback, useState, FC } from "react";
import { useCallback, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
import { ArrowLeft, Plus } from "lucide-react";
// hooks
import { ArrowLeft, Circle, ExternalLink, Plus } from "lucide-react";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
// components
import { DisplayFiltersSelection, FiltersDropdown, FilterSelection, LayoutSelection } from "components/issues";
@@ -17,7 +17,7 @@ import { ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "constants/issue";
// helper
import { truncateText } from "helpers/string.helper";
export const ProjectIssuesHeader: FC = observer(() => {
export const ProjectIssuesHeader: React.FC = observer(() => {
const [analyticsModal, setAnalyticsModal] = useState(false);
const router = useRouter();
@@ -93,6 +93,8 @@ export const ProjectIssuesHeader: FC = observer(() => {
const inboxDetails = projectId ? inboxStore.inboxesList?.[projectId.toString()]?.[0] : undefined;
const deployUrl = process.env.NEXT_PUBLIC_DEPLOY_URL;
return (
<>
<ProjectAnalyticsModal
@@ -125,6 +127,18 @@ export const ProjectIssuesHeader: FC = observer(() => {
<BreadcrumbItem title={`${truncateText(projectDetails?.name ?? "Project", 32)} Issues`} />
</Breadcrumbs>
</div>
{projectDetails?.is_deployed && deployUrl && (
<a
href={`${deployUrl}/${workspaceSlug}/${projectDetails?.id}`}
className="group bg-custom-primary-100/20 text-custom-primary-100 px-2.5 py-1 text-xs flex items-center gap-1.5 rounded font-medium"
target="_blank"
rel="noopener noreferrer"
>
<Circle className="h-1.5 w-1.5 fill-custom-primary-100" strokeWidth={2} />
Public
<ExternalLink className="h-3 w-3 hidden group-hover:block" strokeWidth={2} />
</a>
)}
</div>
<div className="flex items-center gap-2">
<LayoutSelection
@@ -2,7 +2,6 @@ import { FC } from "react";
import { useRouter } from "next/router";
import Link from "next/link";
import { ArrowLeft } from "lucide-react";
// ui
import { BreadcrumbItem, Breadcrumbs } from "@plane/ui";
// helper
@@ -28,15 +27,6 @@ export const ProjectSettingHeader: FC<IProjectSettingHeader> = observer((props)
className={`relative flex w-full flex-shrink-0 flex-row z-10 items-center justify-between gap-x-2 gap-y-4 border-b border-custom-border-200 bg-custom-sidebar-background-100 p-4`}
>
<div className="flex items-center gap-2 flex-grow w-full whitespace-nowrap overflow-ellipsis">
<div className="block md:hidden">
<button
type="button"
className="grid h-8 w-8 place-items-center rounded border border-custom-border-200"
onClick={() => router.back()}
>
<ArrowLeft fontSize={14} strokeWidth={2} />
</button>
</div>
<div>
<Breadcrumbs onBack={() => router.back()}>
<BreadcrumbItem
+76 -25
View File
@@ -1,11 +1,15 @@
import { useCallback } from "react";
import Link from "next/link";
import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
// components
import { DisplayFiltersSelection, FiltersDropdown, FilterSelection, LayoutSelection } from "components/issues";
// ui
import { BreadcrumbItem, Breadcrumbs, CustomMenu, PhotoFilterIcon } from "@plane/ui";
// helpers
import { truncateText } from "helpers/string.helper";
// types
import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions, TIssueLayouts } from "types";
// constants
@@ -19,6 +23,7 @@ export const ProjectViewIssuesHeader: React.FC = observer(() => {
issueFilter: issueFilterStore,
projectViewFilters: projectViewFiltersStore,
project: projectStore,
projectViews: projectViewsStore,
} = useMobxStore();
const storedFilters = viewId ? projectViewFiltersStore.storedFilters[viewId.toString()] : undefined;
@@ -82,32 +87,78 @@ export const ProjectViewIssuesHeader: React.FC = observer(() => {
[issueFilterStore, projectId, workspaceSlug]
);
const projectDetails =
workspaceSlug && projectId
? projectStore.getProjectById(workspaceSlug.toString(), projectId.toString())
: undefined;
const viewsList = projectId ? projectViewsStore.viewsList[projectId.toString()] : undefined;
const viewDetails = viewId ? projectViewsStore.viewDetails[viewId.toString()] : undefined;
return (
<div className="flex items-center gap-2">
<LayoutSelection
layouts={["list", "kanban", "calendar", "spreadsheet", "gantt_chart"]}
onChange={(layout) => handleLayoutChange(layout)}
selectedLayout={activeLayout}
/>
<FiltersDropdown title="Filters">
<FilterSelection
filters={storedFilters ?? {}}
handleFiltersUpdate={handleFiltersUpdate}
layoutDisplayFiltersOptions={activeLayout ? ISSUE_DISPLAY_FILTERS_BY_LAYOUT.issues[activeLayout] : undefined}
labels={projectStore.labels?.[projectId?.toString() ?? ""] ?? undefined}
members={projectStore.members?.[projectId?.toString() ?? ""]?.map((m) => m.member)}
states={projectStore.states?.[projectId?.toString() ?? ""] ?? undefined}
<div className="relative w-full flex items-center z-10 justify-between gap-x-2 gap-y-4 border-b border-custom-border-200 bg-custom-sidebar-background-100 p-4">
<div className="flex items-center gap-2">
<Breadcrumbs onBack={() => router.back()}>
<BreadcrumbItem
link={
<Link href={`/${workspaceSlug}/projects/${projectDetails?.id}/cycles`}>
<a className={`border-r-2 border-custom-sidebar-border-200 px-3 text-sm `}>
<p className="truncate">{`${projectDetails?.name ?? "Project"} Views`}</p>
</a>
</Link>
}
/>
</Breadcrumbs>
<CustomMenu
label={
<>
<PhotoFilterIcon height={12} width={12} />
{viewDetails?.name && truncateText(viewDetails.name, 40)}
</>
}
className="ml-1.5"
placement="bottom-start"
>
{viewsList?.map((view) => (
<CustomMenu.MenuItem
key={view.id}
onClick={() => router.push(`/${workspaceSlug}/projects/${projectId}/views/${view.id}`)}
>
{truncateText(view.name, 40)}
</CustomMenu.MenuItem>
))}
</CustomMenu>
</div>
<div className="flex items-center gap-2">
<LayoutSelection
layouts={["list", "kanban", "calendar", "spreadsheet", "gantt_chart"]}
onChange={(layout) => handleLayoutChange(layout)}
selectedLayout={activeLayout}
/>
</FiltersDropdown>
<FiltersDropdown title="Display">
<DisplayFiltersSelection
displayFilters={issueFilterStore.userDisplayFilters}
displayProperties={issueFilterStore.userDisplayProperties}
handleDisplayFiltersUpdate={handleDisplayFiltersUpdate}
handleDisplayPropertiesUpdate={handleDisplayPropertiesUpdate}
layoutDisplayFiltersOptions={activeLayout ? ISSUE_DISPLAY_FILTERS_BY_LAYOUT.issues[activeLayout] : undefined}
/>
</FiltersDropdown>
<FiltersDropdown title="Filters">
<FilterSelection
filters={storedFilters ?? {}}
handleFiltersUpdate={handleFiltersUpdate}
layoutDisplayFiltersOptions={
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_LAYOUT.issues[activeLayout] : undefined
}
labels={projectStore.labels?.[projectId?.toString() ?? ""] ?? undefined}
members={projectStore.members?.[projectId?.toString() ?? ""]?.map((m) => m.member)}
states={projectStore.states?.[projectId?.toString() ?? ""] ?? undefined}
/>
</FiltersDropdown>
<FiltersDropdown title="Display">
<DisplayFiltersSelection
displayFilters={issueFilterStore.userDisplayFilters}
displayProperties={issueFilterStore.userDisplayProperties}
handleDisplayFiltersUpdate={handleDisplayFiltersUpdate}
handleDisplayPropertiesUpdate={handleDisplayPropertiesUpdate}
layoutDisplayFiltersOptions={
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_LAYOUT.issues[activeLayout] : undefined
}
/>
</FiltersDropdown>
</div>
</div>
);
});
+18 -29
View File
@@ -1,7 +1,10 @@
import { FC, useState } from "react";
import { useState } from "react";
import { useRouter } from "next/router";
// icons
import { ArrowLeft, Link, Plus } from "lucide-react";
import Link from "next/link";
import { observer } from "mobx-react-lite";
import { Plus } from "lucide-react";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
// components
import { CreateUpdateProjectViewModal } from "components/views";
// components
@@ -11,18 +14,20 @@ import { PrimaryButton } from "components/ui";
// helpers
import { truncateText } from "helpers/string.helper";
interface IProjectViewsHeader {
title: string | undefined;
}
export const ProjectViewsHeader: FC<IProjectViewsHeader> = (props) => {
const { title } = props;
export const ProjectViewsHeader: React.FC = observer(() => {
// router
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
// states
const [createViewModal, setCreateViewModal] = useState(false);
const { project: projectStore } = useMobxStore();
const projectDetails =
workspaceSlug && projectId
? projectStore.getProjectById(workspaceSlug.toString(), projectId.toString())
: undefined;
return (
<>
{workspaceSlug && projectId && (
@@ -37,15 +42,6 @@ export const ProjectViewsHeader: FC<IProjectViewsHeader> = (props) => {
className={`relative flex w-full flex-shrink-0 flex-row z-10 items-center justify-between gap-x-2 gap-y-4 border-b border-custom-border-200 bg-custom-sidebar-background-100 p-4`}
>
<div className="flex items-center gap-2 flex-grow w-full whitespace-nowrap overflow-ellipsis">
<div className="block md:hidden">
<button
type="button"
className="grid h-8 w-8 place-items-center rounded border border-custom-border-200"
onClick={() => router.back()}
>
<ArrowLeft fontSize={14} strokeWidth={2} />
</button>
</div>
<div>
<Breadcrumbs onBack={() => router.back()}>
<BreadcrumbItem
@@ -57,20 +53,13 @@ export const ProjectViewsHeader: FC<IProjectViewsHeader> = (props) => {
</Link>
}
/>
<BreadcrumbItem title={`${truncateText(title ?? "Project", 32)} Cycles`} />
<BreadcrumbItem title={`${truncateText(projectDetails?.name ?? "Project", 32)} Views`} />
</Breadcrumbs>
</div>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 flex-shrink-0">
<div>
<PrimaryButton
type="button"
className="flex items-center gap-2"
onClick={() => {
const e = new KeyboardEvent("keydown", { key: "v" });
document.dispatchEvent(e);
}}
>
<PrimaryButton type="button" className="flex items-center gap-2" onClick={() => setCreateViewModal(true)}>
<Plus size={14} strokeWidth={2} />
Create View
</PrimaryButton>
@@ -79,4 +68,4 @@ export const ProjectViewsHeader: FC<IProjectViewsHeader> = (props) => {
</div>
</>
);
};
});
+1 -10
View File
@@ -1,5 +1,5 @@
import { useRouter } from "next/router";
import { ArrowLeft, Search, Plus } from "lucide-react";
import { Search, Plus } from "lucide-react";
// ui
import { BreadcrumbItem, Breadcrumbs, Button } from "@plane/ui";
// helper
@@ -19,15 +19,6 @@ export const ProjectsHeader = observer(() => {
className={`relative flex w-full flex-shrink-0 flex-row z-10 items-center justify-between gap-x-2 gap-y-4 border-b border-custom-border-200 bg-custom-sidebar-background-100 p-4`}
>
<div className="flex items-center gap-2 flex-grow w-full whitespace-nowrap overflow-ellipsis">
<div className="block md:hidden">
<button
type="button"
className="grid h-8 w-8 place-items-center rounded border border-custom-border-200"
onClick={() => router.back()}
>
<ArrowLeft fontSize={14} strokeWidth={2} />
</button>
</div>
<div>
<Breadcrumbs onBack={() => router.back()}>
<BreadcrumbItem
+25
View File
@@ -0,0 +1,25 @@
import { FC } from "react";
import { useRouter } from "next/router";
// ui
import { BreadcrumbItem, Breadcrumbs } from "@plane/ui";
// hooks
import { observer } from "mobx-react-lite";
export const UserProfileHeader: FC = observer(() => {
const router = useRouter();
return (
<div
className={`relative flex w-full flex-shrink-0 flex-row z-10 items-center justify-between gap-x-2 gap-y-4 border-b border-custom-border-200 bg-custom-sidebar-background-100 p-4`}
>
<div className="flex items-center gap-2 flex-grow w-full whitespace-nowrap overflow-ellipsis">
<div>
<Breadcrumbs onBack={() => router.back()}>
<BreadcrumbItem title="User Profile" unshrinkTitle />
</Breadcrumbs>
</div>
</div>
</div>
);
});
@@ -4,7 +4,6 @@ import useSWR from "swr";
import { useRouter } from "next/router";
import Link from "next/link";
import { ArrowLeft } from "lucide-react";
// ui
import { BreadcrumbItem, Breadcrumbs } from "@plane/ui";
// hooks
@@ -37,15 +36,6 @@ export const WorkspaceSettingHeader: FC<IWorkspaceSettingHeader> = observer((pro
className={`relative flex w-full flex-shrink-0 flex-row z-10 items-center justify-between gap-x-2 gap-y-4 border-b border-custom-border-200 bg-custom-sidebar-background-100 p-4`}
>
<div className="flex items-center gap-2 flex-grow w-full whitespace-nowrap overflow-ellipsis">
<div className="block md:hidden">
<button
type="button"
className="grid h-8 w-8 place-items-center rounded border border-custom-border-200"
onClick={() => router.back()}
>
<ArrowLeft fontSize={14} strokeWidth={2} />
</button>
</div>
<div>
<Breadcrumbs onBack={() => router.back()}>
<BreadcrumbItem
@@ -40,15 +40,27 @@ export const CalendarChart: React.FC<Props> = observer((props) => {
<CalendarHeader />
<CalendarWeekHeader isLoading={!issues} showWeekends={showWeekends} />
<div className="h-full w-full overflow-y-auto">
{layout === "month" ? (
{layout === "month" && (
<div className="h-full w-full grid grid-cols-1 divide-y-[0.5px] divide-custom-border-200">
{allWeeksOfActiveMonth &&
Object.values(allWeeksOfActiveMonth).map((week: ICalendarWeek, weekIndex) => (
<CalendarWeekDays key={weekIndex} week={week} issues={issues} quickActions={quickActions} />
<CalendarWeekDays
key={weekIndex}
week={week}
issues={issues}
enableQuickIssueCreate
quickActions={quickActions}
/>
))}
</div>
) : (
<CalendarWeekDays week={calendarStore.allDaysOfActiveWeek} issues={issues} quickActions={quickActions} />
)}
{layout === "week" && (
<CalendarWeekDays
week={calendarStore.allDaysOfActiveWeek}
issues={issues}
enableQuickIssueCreate
quickActions={quickActions}
/>
)}
</div>
</div>
@@ -4,7 +4,7 @@ import { Droppable } from "@hello-pangea/dnd";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
// components
import { CalendarIssueBlocks, ICalendarDate } from "components/issues";
import { CalendarIssueBlocks, ICalendarDate, CalendarInlineCreateIssueForm } from "components/issues";
// helpers
import { renderDateFormat } from "helpers/date-time.helper";
// types
@@ -17,10 +17,11 @@ type Props = {
date: ICalendarDate;
issues: IIssueGroupedStructure | null;
quickActions: (issue: IIssue) => React.ReactNode;
enableQuickIssueCreate?: boolean;
};
export const CalendarDayTile: React.FC<Props> = observer((props) => {
const { date, issues, quickActions } = props;
const { date, issues, quickActions, enableQuickIssueCreate } = props;
const { issueFilter: issueFilterStore } = useMobxStore();
@@ -29,35 +30,56 @@ export const CalendarDayTile: React.FC<Props> = observer((props) => {
const issuesList = issues ? (issues as IIssueGroupedStructure)[renderDateFormat(date.date)] : null;
return (
<Droppable droppableId={renderDateFormat(date.date)}>
{(provided, snapshot) => (
<>
<div className="group w-full h-full relative flex flex-col bg-custom-background-90">
{/* header */}
<div
className={`flex-grow p-2 space-y-1 w-full flex flex-col overflow-hidden ${
snapshot.isDraggingOver || date.date.getDay() === 0 || date.date.getDay() === 6
className={`text-xs text-right flex-shrink-0 py-1 px-2 ${
calendarLayout === "month" // if month layout, highlight current month days
? date.is_current_month
? "font-medium"
: "text-custom-text-300"
: "font-medium" // if week layout, highlight all days
} ${
date.date.getDay() === 0 || date.date.getDay() === 6
? "bg-custom-background-90"
: "bg-custom-background-100"
} ${calendarLayout === "month" ? "min-h-[9rem]" : ""}`}
{...provided.droppableProps}
ref={provided.innerRef}
}`}
>
<>
<div
className={`text-xs text-right ${
calendarLayout === "month" // if month layout, highlight current month days
? date.is_current_month
? "font-medium"
: "text-custom-text-300"
: "font-medium" // if week layout, highlight all days
}`}
>
{date.date.getDate() === 1 && MONTHS_LIST[date.date.getMonth() + 1].shortTitle + " "}
{date.date.getDate()}
</div>
<CalendarIssueBlocks issues={issuesList} quickActions={quickActions} />
{provided.placeholder}
</>
{date.date.getDate() === 1 && MONTHS_LIST[date.date.getMonth() + 1].shortTitle + " "}
{date.date.getDate()}
</div>
)}
</Droppable>
{/* content */}
<div className="w-full h-full">
<Droppable droppableId={renderDateFormat(date.date)} isDropDisabled={false}>
{(provided, snapshot) => (
<div
className={`h-full w-full overflow-y-auto select-none ${
snapshot.isDraggingOver || date.date.getDay() === 0 || date.date.getDay() === 6
? "bg-custom-background-90"
: "bg-custom-background-100"
} ${calendarLayout === "month" ? "min-h-[9rem]" : ""}`}
{...provided.droppableProps}
ref={provided.innerRef}
>
<CalendarIssueBlocks issues={issuesList} quickActions={quickActions} />
{enableQuickIssueCreate && (
<div className="py-1 px-2">
<CalendarInlineCreateIssueForm
groupId={renderDateFormat(date.date)}
prePopulatedData={{
target_date: renderDateFormat(date.date),
}}
/>
</div>
)}
{provided.placeholder}
</div>
)}
</Droppable>
</div>
</div>
</>
);
});
@@ -7,3 +7,4 @@ export * from "./header";
export * from "./issue-blocks";
export * from "./week-days";
export * from "./week-header";
export * from "./inline-create-issue-form";
@@ -0,0 +1,234 @@
import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/router";
import { Transition } from "@headlessui/react";
import { useForm } from "react-hook-form";
// store
import { observer } from "mobx-react-lite";
import { useMobxStore } from "lib/mobx/store-provider";
// hooks
import useToast from "hooks/use-toast";
import useKeypress from "hooks/use-keypress";
import useProjectDetails from "hooks/use-project-details";
import useOutsideClickDetector from "hooks/use-outside-click-detector";
// constants
import { createIssuePayload } from "constants/issue";
// icons
import { PlusIcon } from "lucide-react";
// types
import { IIssue } from "types";
type Props = {
groupId?: string;
dependencies?: any[];
prePopulatedData?: Partial<IIssue>;
onSuccess?: (data: IIssue) => Promise<void> | void;
};
const useCheckIfThereIsSpaceOnRight = (ref: React.RefObject<HTMLDivElement>, deps: any[]) => {
const [isThereSpaceOnRight, setIsThereSpaceOnRight] = useState(true);
const router = useRouter();
const { moduleId, cycleId, viewId } = router.query;
const container = document.getElementById(`calendar-view-${cycleId ?? moduleId ?? viewId}`);
useEffect(() => {
if (!ref.current) return;
const { right } = ref.current.getBoundingClientRect();
const width = right;
const innerWidth = container?.getBoundingClientRect().width ?? window.innerWidth;
if (width > innerWidth) setIsThereSpaceOnRight(false);
else setIsThereSpaceOnRight(true);
}, [ref, deps, container]);
return isThereSpaceOnRight;
};
const defaultValues: Partial<IIssue> = {
name: "",
};
const Inputs = (props: any) => {
const { register, setFocus, projectDetails } = props;
useEffect(() => {
setFocus("name");
}, [setFocus]);
return (
<>
<h4 className="text-sm font-medium leading-5 text-custom-text-400">{projectDetails?.identifier ?? "..."}</h4>
<input
type="text"
autoComplete="off"
placeholder="Issue Title"
{...register("name", {
required: "Issue title is required.",
})}
className="w-full pr-2 py-1.5 rounded-md bg-transparent text-sm font-medium leading-5 text-custom-text-200 outline-none"
/>
</>
);
};
export const CalendarInlineCreateIssueForm: React.FC<Props> = observer((props) => {
const { prePopulatedData, dependencies = [], groupId } = props;
// router
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
// store
const { workspace: workspaceStore, quickAddIssue: quickAddStore } = useMobxStore();
// ref
const ref = useRef<HTMLDivElement>(null);
// states
const [isOpen, setIsOpen] = useState(false);
const { setToastAlert } = useToast();
const { projectDetails } = useProjectDetails();
const {
reset,
handleSubmit,
register,
setFocus,
formState: { errors, isSubmitting },
} = useForm<IIssue>({ defaultValues });
const handleClose = () => {
setIsOpen(false);
};
useKeypress("Escape", handleClose);
useOutsideClickDetector(ref, handleClose);
// derived values
const workspaceDetail = workspaceStore.getWorkspaceBySlug(workspaceSlug?.toString()!);
useEffect(() => {
if (!isOpen) reset({ ...defaultValues });
}, [isOpen, reset]);
useEffect(() => {
if (!errors) return;
Object.keys(errors).forEach((key) => {
const error = errors[key as keyof IIssue];
setToastAlert({
type: "error",
title: "Error!",
message: error?.message?.toString() || "Some error occurred. Please try again.",
});
});
}, [errors, setToastAlert]);
const isSpaceOnRight = useCheckIfThereIsSpaceOnRight(ref, dependencies);
const onSubmitHandler = async (formData: IIssue) => {
if (isSubmitting || !workspaceSlug || !projectId) return;
// resetting the form so that user can add another issue quickly
reset({ ...defaultValues, ...(prePopulatedData ?? {}) });
const payload = createIssuePayload(workspaceDetail!, projectDetails!, {
...(prePopulatedData ?? {}),
...formData,
labels_list:
formData.labels_list?.length !== 0
? formData.labels_list
: prePopulatedData?.labels && prePopulatedData?.labels.toString() !== "none"
? [prePopulatedData.labels as any]
: [],
assignees_list:
formData.assignees_list?.length !== 0
? formData.assignees_list
: prePopulatedData?.assignees && prePopulatedData?.assignees.toString() !== "none"
? [prePopulatedData.assignees as any]
: [],
});
try {
quickAddStore.createIssue(
workspaceSlug.toString(),
projectId.toString(),
{
group_id: groupId ?? null,
sub_group_id: null,
},
payload
);
setToastAlert({
type: "success",
title: "Success!",
message: "Issue created successfully.",
});
} catch (err: any) {
Object.keys(err || {}).forEach((key) => {
const error = err?.[key];
const errorTitle = error ? (Array.isArray(error) ? error.join(", ") : error) : null;
setToastAlert({
type: "error",
title: "Error!",
message: errorTitle || "Some error occurred. Please try again.",
});
});
}
};
return (
<>
<Transition
show={isOpen}
enter="transition ease-in-out duration-200 transform"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="transition ease-in-out duration-200 transform"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<div
ref={ref}
className={`transition-all z-20 w-full ${
isOpen ? "opacity-100 scale-100" : "opacity-0 pointer-events-none scale-95"
}`}
>
<form
onSubmit={handleSubmit(onSubmitHandler)}
className="flex w-full px-1.5 border-[0.5px] border-custom-border-100 rounded z-50 items-center gap-x-2 bg-custom-background-100 shadow-custom-shadow-sm transition-opacity"
>
<Inputs register={register} setFocus={setFocus} projectDetails={projectDetails} />
</form>
</div>
</Transition>
{!isOpen && (
<div className="hidden group-hover:block border-[0.5px] border-custom-border-200 rounded">
<button
type="button"
className="w-full flex items-center gap-x-[6px] text-custom-primary-100 px-1 py-1.5 rounded-md"
onClick={() => setIsOpen(true)}
>
<PlusIcon className="h-4 w-4" />
<span className="text-sm font-medium text-custom-primary-100">New Issue</span>
</button>
</div>
)}
</>
);
});
@@ -17,42 +17,44 @@ export const CalendarIssueBlocks: React.FC<Props> = observer((props) => {
const { workspaceSlug } = router.query;
return (
<div className="space-y-2 h-full w-full overflow-y-auto p-0.5">
<>
{issues?.map((issue, index) => (
<Draggable key={issue.id} draggableId={issue.id} index={index}>
{(provided, snapshot) => (
<Link href={`/${workspaceSlug?.toString()}/projects/${issue.project}/issues/${issue.id}`}>
<a
className={`group/calendar-block h-8 w-full shadow-custom-shadow-2xs rounded py-1.5 px-1 flex items-center gap-1.5 border-[0.5px] border-custom-border-100 ${
snapshot.isDragging
? "shadow-custom-shadow-rg bg-custom-background-90"
: "bg-custom-background-100 hover:bg-custom-background-90"
}`}
{...provided.draggableProps}
{...provided.dragHandleProps}
ref={provided.innerRef}
>
<span
className="h-full w-0.5 rounded flex-shrink-0"
style={{
backgroundColor: issue.state_detail.color,
}}
/>
<div className="text-xs text-custom-text-300 flex-shrink-0">
{issue.project_detail.identifier}-{issue.sequence_id}
</div>
<h6 className="text-xs flex-grow truncate">{issue.name}</h6>
<div className="hidden group-hover/calendar-block:block">{quickActions(issue)}</div>
{/* <IssueQuickActions
issue={issue}
handleDelete={async () => handleIssues(issue.target_date ?? "", issue, "delete")}
handleUpdate={async (data) => handleIssues(issue.target_date ?? "", data, "update")}
/> */}
</a>
</Link>
<div
className="p-1 px-2 relative"
{...provided.draggableProps}
{...provided.dragHandleProps}
ref={provided.innerRef}
>
{issue?.tempId !== undefined && (
<div className="absolute top-0 left-0 w-full h-full animate-pulse bg-custom-background-100/20 z-[99999]" />
)}
<Link href={`/${workspaceSlug?.toString()}/projects/${issue.project}/issues/${issue.id}`}>
<a
className={`group/calendar-block h-8 w-full shadow-custom-shadow-2xs rounded py-1.5 px-1 flex items-center gap-1.5 border-[0.5px] border-custom-border-100 ${
snapshot.isDragging
? "shadow-custom-shadow-rg bg-custom-background-90"
: "bg-custom-background-100 hover:bg-custom-background-90"
}`}
>
<span
className="h-full w-0.5 rounded flex-shrink-0"
style={{
backgroundColor: issue.state_detail.color,
}}
/>
<div className="text-xs text-custom-text-300 flex-shrink-0">
{issue.project_detail.identifier}-{issue.sequence_id}
</div>
<h6 className="text-xs flex-grow truncate">{issue.name}</h6>
<div className="hidden group-hover/calendar-block:block">{quickActions(issue)}</div>
</a>
</Link>
</div>
)}
</Draggable>
))}
</div>
</>
);
});
@@ -11,12 +11,16 @@ import { IIssueGroupedStructure } from "store/issue";
import { IIssue } from "types";
export const CycleCalendarLayout: React.FC = observer(() => {
const { cycleIssue: cycleIssueStore, issueFilter: issueFilterStore, issueDetail: issueDetailStore } = useMobxStore();
const {
cycleIssue: cycleIssueStore,
issueFilter: issueFilterStore,
issueDetail: issueDetailStore,
cycleIssueCalendarView: cycleIssueCalendarViewStore,
} = useMobxStore();
const router = useRouter();
const { workspaceSlug, cycleId } = router.query;
// TODO: add drag and drop functionality
const onDragEnd = (result: DropResult) => {
if (!result) return;
@@ -26,7 +30,7 @@ export const CycleCalendarLayout: React.FC = observer(() => {
// return if dropped on the same date
if (result.destination.droppableId === result.source.droppableId) return;
// issueKanBanViewStore?.handleDragDrop(result.source, result.destination);
cycleIssueCalendarViewStore?.handleDragDrop(result.source, result.destination);
};
const issues = cycleIssueStore.getIssues;
@@ -15,12 +15,12 @@ export const ModuleCalendarLayout: React.FC = observer(() => {
moduleIssue: moduleIssueStore,
issueFilter: issueFilterStore,
issueDetail: issueDetailStore,
moduleIssueCalendarView: moduleIssueCalendarViewStore,
} = useMobxStore();
const router = useRouter();
const { workspaceSlug, moduleId } = router.query;
// TODO: add drag and drop functionality
const onDragEnd = (result: DropResult) => {
if (!result) return;
@@ -30,7 +30,7 @@ export const ModuleCalendarLayout: React.FC = observer(() => {
// return if dropped on the same date
if (result.destination.droppableId === result.source.droppableId) return;
// issueKanBanViewStore?.handleDragDrop(result.source, result.destination);
moduleIssueCalendarViewStore?.handleDragDrop(result.source, result.destination);
};
const issues = moduleIssueStore.getIssues;
@@ -11,12 +11,16 @@ import { IIssueGroupedStructure } from "store/issue";
import { IIssue } from "types";
export const CalendarLayout: React.FC = observer(() => {
const { issue: issueStore, issueFilter: issueFilterStore, issueDetail: issueDetailStore } = useMobxStore();
const {
issue: issueStore,
issueFilter: issueFilterStore,
issueDetail: issueDetailStore,
issueCalendarView: issueCalendarViewStore,
} = useMobxStore();
const router = useRouter();
const { workspaceSlug } = router.query;
// TODO: add drag and drop functionality
const onDragEnd = (result: DropResult) => {
if (!result) return;
@@ -26,7 +30,7 @@ export const CalendarLayout: React.FC = observer(() => {
// return if dropped on the same date
if (result.destination.droppableId === result.source.droppableId) return;
// issueKanBanViewStore?.handleDragDrop(result.source, result.destination);
issueCalendarViewStore?.handleDragDrop(result.source, result.destination);
};
const issues = issueStore.getIssues;
@@ -15,12 +15,12 @@ export const ProjectViewCalendarLayout: React.FC = observer(() => {
projectViewIssues: projectViewIssuesStore,
issueFilter: issueFilterStore,
issueDetail: issueDetailStore,
projectViewIssueCalendarView: projectViewIssueCalendarViewStore,
} = useMobxStore();
const router = useRouter();
const { workspaceSlug } = router.query;
// TODO: add drag and drop functionality
const onDragEnd = (result: DropResult) => {
if (!result) return;
@@ -30,7 +30,7 @@ export const ProjectViewCalendarLayout: React.FC = observer(() => {
// return if dropped on the same date
if (result.destination.droppableId === result.source.droppableId) return;
// issueKanBanViewStore?.handleDragDrop(result.source, result.destination);
projectViewIssueCalendarViewStore?.handleDragDrop(result.source, result.destination);
};
const issues = projectViewIssuesStore.getIssues;
@@ -15,10 +15,11 @@ type Props = {
issues: IIssueGroupedStructure | null;
week: ICalendarWeek | undefined;
quickActions: (issue: IIssue) => React.ReactNode;
enableQuickIssueCreate?: boolean;
};
export const CalendarWeekDays: React.FC<Props> = observer((props) => {
const { issues, week, quickActions } = props;
const { issues, week, quickActions, enableQuickIssueCreate } = props;
const { issueFilter: issueFilterStore } = useMobxStore();
@@ -37,7 +38,13 @@ export const CalendarWeekDays: React.FC<Props> = observer((props) => {
if (!showWeekends && (date.date.getDay() === 0 || date.date.getDay() === 6)) return null;
return (
<CalendarDayTile key={renderDateFormat(date.date)} date={date} issues={issues} quickActions={quickActions} />
<CalendarDayTile
key={renderDateFormat(date.date)}
date={date}
issues={issues}
quickActions={quickActions}
enableQuickIssueCreate={enableQuickIssueCreate}
/>
);
})}
</div>
@@ -4,14 +4,14 @@ import { observer } from "mobx-react-lite";
import { useMobxStore } from "lib/mobx/store-provider";
import useProjectDetails from "hooks/use-project-details";
// components
import { GanttChartRoot, renderIssueBlocksStructure } from "components/gantt-chart";
import { IssueGanttBlock, IssueGanttSidebarBlock, IssuePeekOverview } from "components/issues";
import { GanttChartRoot, IBlockUpdateData, renderIssueBlocksStructure } from "components/gantt-chart";
import { IssueGanttBlock, IssueGanttSidebarBlock } from "components/issues";
// types
import { IIssueUnGroupedStructure } from "store/issue";
export const CycleGanttLayout: React.FC = observer(() => {
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { workspaceSlug, cycleId } = router.query;
const { projectDetails } = useProjectDetails();
@@ -21,26 +21,23 @@ export const CycleGanttLayout: React.FC = observer(() => {
const issues = cycleIssueStore.getIssues;
const updateIssue = (block: any, payload: IBlockUpdateData) => {
if (!workspaceSlug || !cycleId) return;
cycleIssueStore.updateGanttIssueStructure(workspaceSlug.toString(), cycleId.toString(), block, payload);
};
const isAllowed = projectDetails?.member_role === 20 || projectDetails?.member_role === 15;
return (
<>
<IssuePeekOverview
projectId={projectId?.toString() ?? ""}
workspaceSlug={workspaceSlug?.toString() ?? ""}
readOnly={!isAllowed}
/>
<div className="w-full h-full">
<GanttChartRoot
border={false}
title="Issues"
loaderTitle="Issues"
blocks={issues ? renderIssueBlocksStructure(issues as IIssueUnGroupedStructure) : null}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
blockUpdateHandler={(block, payload) => {
// TODO: update mutation logic
// updateGanttIssue(block, payload, mutateGanttIssues, user, workspaceSlug?.toString())
}}
blockUpdateHandler={updateIssue}
BlockRender={IssueGanttBlock}
SidebarBlockRender={IssueGanttSidebarBlock}
enableBlockLeftResize={isAllowed}
@@ -3,3 +3,4 @@ export * from "./cycle-root";
export * from "./module-root";
export * from "./project-view-root";
export * from "./root";
export * from "./inline-create-issue-form";
@@ -0,0 +1,196 @@
import { useEffect, useState, useRef } from "react";
import { useRouter } from "next/router";
import { useForm } from "react-hook-form";
import { Transition } from "@headlessui/react";
import { PlusIcon } from "lucide-react";
// store
import { observer } from "mobx-react-lite";
import { useMobxStore } from "lib/mobx/store-provider";
// constants
import { createIssuePayload } from "constants/issue";
// hooks
import useToast from "hooks/use-toast";
import useKeypress from "hooks/use-keypress";
import useProjectDetails from "hooks/use-project-details";
import useOutsideClickDetector from "hooks/use-outside-click-detector";
// types
import { IIssue } from "types";
import { renderDateFormat } from "helpers/date-time.helper";
type Props = {
prePopulatedData?: Partial<IIssue>;
onSuccess?: (data: IIssue) => Promise<void> | void;
};
const defaultValues: Partial<IIssue> = {
name: "",
};
const Inputs = (props: any) => {
const { register, setFocus } = props;
useEffect(() => {
setFocus("name");
}, [setFocus]);
return (
<input
type="text"
autoComplete="off"
placeholder="Issue Title"
{...register("name", {
required: "Issue title is required.",
})}
className="w-full px-2 rounded-md bg-transparent text-sm font-medium leading-5 text-custom-text-200 outline-none"
/>
);
};
export const GanttInlineCreateIssueForm: React.FC<Props> = observer((props) => {
const { prePopulatedData } = props;
// router
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
// store
const { workspace: workspaceStore, quickAddIssue: quickAddStore } = useMobxStore();
const { projectDetails } = useProjectDetails();
const {
reset,
handleSubmit,
setFocus,
register,
formState: { errors, isSubmitting },
} = useForm<IIssue>({ defaultValues });
// ref
const ref = useRef<HTMLFormElement>(null);
// states
const [isOpen, setIsOpen] = useState(false);
const handleClose = () => setIsOpen(false);
// hooks
useKeypress("Escape", handleClose);
useOutsideClickDetector(ref, handleClose);
const { setToastAlert } = useToast();
// derived values
const workspaceDetail = workspaceStore.getWorkspaceBySlug(workspaceSlug?.toString()!);
useEffect(() => {
if (!isOpen) reset({ ...defaultValues });
}, [isOpen, reset]);
useEffect(() => {
if (!errors) return;
Object.keys(errors).forEach((key) => {
const error = errors[key as keyof IIssue];
setToastAlert({
type: "error",
title: "Error!",
message: error?.message?.toString() || "Some error occurred. Please try again.",
});
});
}, [errors, setToastAlert]);
const onSubmitHandler = async (formData: IIssue) => {
if (isSubmitting || !workspaceSlug || !projectId) return;
// resetting the form so that user can add another issue quickly
reset({ ...defaultValues, ...(prePopulatedData ?? {}) });
const payload = createIssuePayload(workspaceDetail!, projectDetails!, {
...(prePopulatedData ?? {}),
...formData,
labels_list:
formData.labels_list?.length !== 0
? formData.labels_list
: prePopulatedData?.labels && prePopulatedData?.labels.toString() !== "none"
? [prePopulatedData.labels as any]
: [],
start_date: renderDateFormat(new Date()),
target_date: renderDateFormat(new Date(new Date().getTime() + 24 * 60 * 60 * 1000)),
});
try {
quickAddStore.createIssue(
workspaceSlug.toString(),
projectId.toString(),
{
group_id: null,
sub_group_id: null,
},
payload
);
setToastAlert({
type: "success",
title: "Success!",
message: "Issue created successfully.",
});
} catch (err: any) {
Object.keys(err || {}).forEach((key) => {
const error = err?.[key];
const errorTitle = error ? (Array.isArray(error) ? error.join(", ") : error) : null;
setToastAlert({
type: "error",
title: "Error!",
message: errorTitle || "Some error occurred. Please try again.",
});
});
}
};
return (
<>
<Transition
show={isOpen}
enter="transition ease-in-out duration-200 transform"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="transition ease-in-out duration-200 transform"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<form
ref={ref}
className="flex py-3 px-4 border-[0.5px] border-custom-border-100 mr-2.5 items-center rounded gap-x-2 bg-custom-background-100 shadow-custom-shadow-sm"
onSubmit={handleSubmit(onSubmitHandler)}
>
<div className="w-[14px] h-[14px] rounded-full border border-custom-border-1000 flex-shrink-0" />
<h4 className="text-sm text-custom-text-400">{projectDetails?.identifier ?? "..."}</h4>
<Inputs register={register} setFocus={setFocus} />
</form>
</Transition>
{isOpen && (
<p className="text-xs ml-3 mt-3 italic text-custom-text-200">
Press {"'"}Enter{"'"} to add another issue
</p>
)}
{!isOpen && (
<button
type="button"
className="flex items-center gap-x-[6px] text-custom-primary-100 px-2 py-1 rounded-md"
onClick={() => setIsOpen(true)}
>
<PlusIcon className="h-4 w-4" />
<span className="text-sm font-medium text-custom-primary-100">New Issue</span>
</button>
)}
</>
);
});
@@ -4,14 +4,14 @@ import { observer } from "mobx-react-lite";
import { useMobxStore } from "lib/mobx/store-provider";
import useProjectDetails from "hooks/use-project-details";
// components
import { GanttChartRoot, renderIssueBlocksStructure } from "components/gantt-chart";
import { IssueGanttBlock, IssueGanttSidebarBlock, IssuePeekOverview } from "components/issues";
import { GanttChartRoot, IBlockUpdateData, renderIssueBlocksStructure } from "components/gantt-chart";
import { IssueGanttBlock, IssueGanttSidebarBlock } from "components/issues";
// types
import { IIssueUnGroupedStructure } from "store/issue";
export const ModuleGanttLayout: React.FC = observer(() => {
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { workspaceSlug, moduleId } = router.query;
const { projectDetails } = useProjectDetails();
@@ -21,26 +21,23 @@ export const ModuleGanttLayout: React.FC = observer(() => {
const issues = moduleIssueStore.getIssues;
const updateIssue = (block: any, payload: IBlockUpdateData) => {
if (!workspaceSlug || !moduleId) return;
moduleIssueStore.updateGanttIssueStructure(workspaceSlug.toString(), moduleId.toString(), block, payload);
};
const isAllowed = projectDetails?.member_role === 20 || projectDetails?.member_role === 15;
return (
<>
<IssuePeekOverview
projectId={projectId?.toString() ?? ""}
workspaceSlug={workspaceSlug?.toString() ?? ""}
readOnly={!isAllowed}
/>
<div className="w-full h-full">
<GanttChartRoot
border={false}
title="Issues"
loaderTitle="Issues"
blocks={issues ? renderIssueBlocksStructure(issues as IIssueUnGroupedStructure) : null}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
blockUpdateHandler={(block, payload) => {
// TODO: update mutation logic
// updateGanttIssue(block, payload, mutateGanttIssues, user, workspaceSlug?.toString())
}}
blockUpdateHandler={updateIssue}
BlockRender={IssueGanttBlock}
SidebarBlockRender={IssueGanttSidebarBlock}
enableBlockLeftResize={isAllowed}
@@ -6,14 +6,14 @@ import { useMobxStore } from "lib/mobx/store-provider";
// hooks
import useProjectDetails from "hooks/use-project-details";
// components
import { GanttChartRoot, renderIssueBlocksStructure } from "components/gantt-chart";
import { IssueGanttBlock, IssueGanttSidebarBlock, IssuePeekOverview } from "components/issues";
import { GanttChartRoot, IBlockUpdateData, renderIssueBlocksStructure } from "components/gantt-chart";
import { IssueGanttBlock, IssueGanttSidebarBlock } from "components/issues";
// types
import { IIssueUnGroupedStructure } from "store/issue";
export const ProjectViewGanttLayout: React.FC = observer(() => {
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { workspaceSlug, viewId } = router.query;
const { projectDetails } = useProjectDetails();
@@ -23,26 +23,23 @@ export const ProjectViewGanttLayout: React.FC = observer(() => {
const issues = projectViewIssuesStore.getIssues;
const updateIssue = (block: any, payload: IBlockUpdateData) => {
if (!workspaceSlug || !viewId) return;
projectViewIssuesStore.updateGanttIssueStructure(workspaceSlug.toString(), viewId.toString(), block, payload);
};
const isAllowed = projectDetails?.member_role === 20 || projectDetails?.member_role === 15;
return (
<>
<IssuePeekOverview
projectId={projectId?.toString() ?? ""}
workspaceSlug={workspaceSlug?.toString() ?? ""}
readOnly={!isAllowed}
/>
<div className="w-full h-full">
<GanttChartRoot
border={false}
title="Issues"
loaderTitle="Issues"
blocks={issues ? renderIssueBlocksStructure(issues as IIssueUnGroupedStructure) : null}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
blockUpdateHandler={(block, payload) => {
// TODO: update mutation logic
// updateGanttIssue(block, payload, mutateGanttIssues, user, workspaceSlug?.toString())
}}
blockUpdateHandler={updateIssue}
BlockRender={IssueGanttBlock}
SidebarBlockRender={IssueGanttSidebarBlock}
enableBlockLeftResize={isAllowed}
@@ -4,14 +4,14 @@ import { observer } from "mobx-react-lite";
import { useMobxStore } from "lib/mobx/store-provider";
import useProjectDetails from "hooks/use-project-details";
// components
import { GanttChartRoot, renderIssueBlocksStructure } from "components/gantt-chart";
import { IssueGanttBlock, IssueGanttSidebarBlock, IssuePeekOverview } from "components/issues";
import { GanttChartRoot, IBlockUpdateData, renderIssueBlocksStructure } from "components/gantt-chart";
import { IssueGanttBlock, IssueGanttSidebarBlock } from "components/issues";
// types
import { IIssueUnGroupedStructure } from "store/issue";
export const GanttLayout: React.FC = observer(() => {
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { workspaceSlug } = router.query;
const { projectDetails } = useProjectDetails();
@@ -21,26 +21,23 @@ export const GanttLayout: React.FC = observer(() => {
const issues = issueStore.getIssues;
const updateIssue = (block: any, payload: IBlockUpdateData) => {
if (!workspaceSlug) return;
issueStore.updateGanttIssueStructure(workspaceSlug.toString(), block, payload);
};
const isAllowed = projectDetails?.member_role === 20 || projectDetails?.member_role === 15;
return (
<>
<IssuePeekOverview
projectId={projectId?.toString() ?? ""}
workspaceSlug={workspaceSlug?.toString() ?? ""}
readOnly={!isAllowed}
/>
<div className="w-full h-full">
<GanttChartRoot
border={false}
title="Issues"
loaderTitle="Issues"
blocks={issues ? renderIssueBlocksStructure(issues as IIssueUnGroupedStructure) : null}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
blockUpdateHandler={(block, payload) => {
// TODO: update mutation logic
// updateGanttIssue(block, payload, mutateGanttIssues, user, workspaceSlug?.toString())
}}
blockUpdateHandler={updateIssue}
BlockRender={IssueGanttBlock}
SidebarBlockRender={IssueGanttSidebarBlock}
enableBlockLeftResize={isAllowed}
@@ -2,7 +2,7 @@ import { Draggable } from "@hello-pangea/dnd";
// components
import { KanBanProperties } from "./properties";
// types
import { IIssue } from "types";
import { IEstimatePoint, IIssue, IIssueLabels, IState, IUserLite } from "types";
interface IssueBlockProps {
sub_group_id: string;
@@ -18,10 +18,27 @@ interface IssueBlockProps {
) => void;
quickActions: (sub_group_by: string | null, group_by: string | null, issue: IIssue) => React.ReactNode;
displayProperties: any;
states: IState[] | null;
labels: IIssueLabels[] | null;
members: IUserLite[] | null;
estimates: IEstimatePoint[] | null;
}
export const KanbanIssueBlock: React.FC<IssueBlockProps> = (props) => {
const { sub_group_id, columnId, index, issue, isDragDisabled, handleIssues, quickActions, displayProperties } = props;
const {
sub_group_id,
columnId,
index,
issue,
isDragDisabled,
handleIssues,
quickActions,
displayProperties,
states,
labels,
members,
estimates,
} = props;
const updateIssue = (sub_group_by: string | null, group_by: string | null, issueToUpdate: IIssue) => {
if (issueToUpdate) handleIssues(sub_group_by, group_by, issueToUpdate, "update");
@@ -37,6 +54,9 @@ export const KanbanIssueBlock: React.FC<IssueBlockProps> = (props) => {
{...provided.dragHandleProps}
ref={provided.innerRef}
>
{issue.tempId !== undefined && (
<div className="absolute top-0 left-0 w-full h-full animate-pulse bg-custom-background-100/20 z-[99999]" />
)}
<div className="absolute top-3 right-3 hidden group-hover/kanban-block:block">
{quickActions(
!sub_group_id && sub_group_id === "null" ? null : sub_group_id,
@@ -54,7 +74,7 @@ export const KanbanIssueBlock: React.FC<IssueBlockProps> = (props) => {
{issue.project_detail.identifier}-{issue.sequence_id}
</div>
)}
<div className="line-clamp-2 h-[40px] text-sm font-medium text-custom-text-100">{issue.name}</div>
<div className="line-clamp-2 text-sm font-medium text-custom-text-100">{issue.name}</div>
<div>
<KanBanProperties
sub_group_id={sub_group_id}
@@ -62,6 +82,10 @@ export const KanbanIssueBlock: React.FC<IssueBlockProps> = (props) => {
issue={issue}
handleIssues={updateIssue}
display_properties={displayProperties}
states={states}
labels={labels}
members={members}
estimates={estimates}
/>
</div>
</div>
@@ -1,6 +1,6 @@
// components
import { KanbanIssueBlock } from "components/issues";
import { IIssue } from "types";
import { IEstimatePoint, IIssue, IIssueLabels, IState, IUserLite } from "types";
interface IssueBlocksListProps {
sub_group_id: string;
@@ -15,10 +15,26 @@ interface IssueBlocksListProps {
) => void;
quickActions: (sub_group_by: string | null, group_by: string | null, issue: IIssue) => React.ReactNode;
display_properties: any;
states: IState[] | null;
labels: IIssueLabels[] | null;
members: IUserLite[] | null;
estimates: IEstimatePoint[] | null;
}
export const KanbanIssueBlocksList: React.FC<IssueBlocksListProps> = (props) => {
const { sub_group_id, columnId, issues, isDragDisabled, handleIssues, quickActions, display_properties } = props;
const {
sub_group_id,
columnId,
issues,
isDragDisabled,
handleIssues,
quickActions,
display_properties,
states,
labels,
members,
estimates,
} = props;
return (
<>
@@ -35,6 +51,10 @@ export const KanbanIssueBlocksList: React.FC<IssueBlocksListProps> = (props) =>
columnId={columnId}
sub_group_id={sub_group_id}
isDragDisabled={isDragDisabled}
states={states}
labels={labels}
members={members}
estimates={estimates}
/>
))}
</>
@@ -5,9 +5,9 @@ import { Droppable } from "@hello-pangea/dnd";
import { useMobxStore } from "lib/mobx/store-provider";
// components
import { KanBanGroupByHeaderRoot } from "./headers/group-by-root";
import { KanbanIssueBlocksList } from "components/issues";
import { KanbanIssueBlocksList, BoardInlineCreateIssueForm } from "components/issues";
// types
import { IIssue } from "types";
import { IEstimatePoint, IIssue, IIssueLabels, IProject, IState, IUserLite } from "types";
// constants
import { ISSUE_STATE_GROUPS, ISSUE_PRIORITIES, getValueFromObject } from "constants/issue";
@@ -29,6 +29,12 @@ export interface IGroupByKanBan {
display_properties: any;
kanBanToggle: any;
handleKanBanToggle: any;
enableQuickIssueCreate?: boolean;
states: IState[] | null;
labels: IIssueLabels[] | null;
members: IUserLite[] | null;
priorities: any;
estimates: IEstimatePoint[] | null;
}
const GroupByKanBan: React.FC<IGroupByKanBan> = observer((props) => {
@@ -45,6 +51,12 @@ const GroupByKanBan: React.FC<IGroupByKanBan> = observer((props) => {
display_properties,
kanBanToggle,
handleKanBanToggle,
states,
labels,
members,
priorities,
estimates,
enableQuickIssueCreate,
} = props;
const verticalAlignPosition = (_list: any) =>
@@ -93,6 +105,10 @@ const GroupByKanBan: React.FC<IGroupByKanBan> = observer((props) => {
handleIssues={handleIssues}
quickActions={quickActions}
display_properties={display_properties}
states={states}
labels={labels}
members={members}
estimates={estimates}
/>
) : (
isDragDisabled && (
@@ -106,6 +122,16 @@ const GroupByKanBan: React.FC<IGroupByKanBan> = observer((props) => {
)}
</Droppable>
</div>
{enableQuickIssueCreate && (
<BoardInlineCreateIssueForm
groupId={getValueFromObject(_list, listKey) as string}
subGroupId={sub_group_id}
prePopulatedData={{
...(group_by && { [group_by]: getValueFromObject(_list, listKey) }),
...(sub_group_by && sub_group_id !== "null" && { [sub_group_by]: sub_group_id }),
}}
/>
)}
</div>
))}
</div>
@@ -128,14 +154,14 @@ export interface IKanBan {
display_properties: any;
kanBanToggle: any;
handleKanBanToggle: any;
states: any;
states: IState[] | null;
stateGroups: any;
priorities: any;
labels: any;
members: any;
projects: any;
estimates: any;
labels: IIssueLabels[] | null;
members: IUserLite[] | null;
projects: IProject[] | null;
estimates: IEstimatePoint[] | null;
enableQuickIssueCreate?: boolean;
}
export const KanBan: React.FC<IKanBan> = observer((props) => {
@@ -156,6 +182,7 @@ export const KanBan: React.FC<IKanBan> = observer((props) => {
members,
projects,
estimates,
enableQuickIssueCreate,
} = props;
const { project: projectStore, issueKanBanView: issueKanBanViewStore } = useMobxStore();
@@ -176,6 +203,12 @@ export const KanBan: React.FC<IKanBan> = observer((props) => {
display_properties={display_properties}
kanBanToggle={kanBanToggle}
handleKanBanToggle={handleKanBanToggle}
enableQuickIssueCreate={enableQuickIssueCreate}
states={states}
labels={labels}
members={members}
priorities={priorities}
estimates={estimates}
/>
)}
@@ -193,6 +226,12 @@ export const KanBan: React.FC<IKanBan> = observer((props) => {
display_properties={display_properties}
kanBanToggle={kanBanToggle}
handleKanBanToggle={handleKanBanToggle}
enableQuickIssueCreate={enableQuickIssueCreate}
states={states}
labels={labels}
members={members}
priorities={priorities}
estimates={estimates}
/>
)}
@@ -210,6 +249,12 @@ export const KanBan: React.FC<IKanBan> = observer((props) => {
display_properties={display_properties}
kanBanToggle={kanBanToggle}
handleKanBanToggle={handleKanBanToggle}
enableQuickIssueCreate={enableQuickIssueCreate}
states={states}
labels={labels}
members={members}
priorities={priorities}
estimates={estimates}
/>
)}
@@ -227,6 +272,12 @@ export const KanBan: React.FC<IKanBan> = observer((props) => {
display_properties={display_properties}
kanBanToggle={kanBanToggle}
handleKanBanToggle={handleKanBanToggle}
enableQuickIssueCreate={enableQuickIssueCreate}
states={states}
labels={labels}
members={members}
priorities={priorities}
estimates={estimates}
/>
)}
@@ -244,6 +295,12 @@ export const KanBan: React.FC<IKanBan> = observer((props) => {
display_properties={display_properties}
kanBanToggle={kanBanToggle}
handleKanBanToggle={handleKanBanToggle}
enableQuickIssueCreate={enableQuickIssueCreate}
states={states}
labels={labels}
members={members}
priorities={priorities}
estimates={estimates}
/>
)}
@@ -261,6 +318,12 @@ export const KanBan: React.FC<IKanBan> = observer((props) => {
display_properties={display_properties}
kanBanToggle={kanBanToggle}
handleKanBanToggle={handleKanBanToggle}
enableQuickIssueCreate={enableQuickIssueCreate}
states={states}
labels={labels}
members={members}
priorities={priorities}
estimates={estimates}
/>
)}
</div>
@@ -1,5 +1,4 @@
export * from "./block";
export * from "./roots";
export * from "./blocks-list";
export * from "./cycle-root";
export * from "./module-root";
export * from "./root";
export * from "./inline-create-issue-form";
@@ -0,0 +1,202 @@
import { useEffect, useState, useRef } from "react";
import { useRouter } from "next/router";
import { useForm } from "react-hook-form";
import { Transition } from "@headlessui/react";
import { PlusIcon } from "lucide-react";
// store
import { observer } from "mobx-react-lite";
import { useMobxStore } from "lib/mobx/store-provider";
// hooks
import useToast from "hooks/use-toast";
import useKeypress from "hooks/use-keypress";
import useProjectDetails from "hooks/use-project-details";
import useOutsideClickDetector from "hooks/use-outside-click-detector";
// constants
import { createIssuePayload } from "constants/issue";
// types
import { IIssue } from "types";
type Props = {
groupId?: string;
subGroupId?: string;
prePopulatedData?: Partial<IIssue>;
onSuccess?: (data: IIssue) => Promise<void> | void;
};
const defaultValues: Partial<IIssue> = {
name: "",
};
const Inputs = (props: any) => {
const { register, setFocus, projectDetails } = props;
useEffect(() => {
setFocus("name");
}, [setFocus]);
return (
<div>
<h4 className="text-sm font-medium leading-5 text-custom-text-300">{projectDetails?.identifier ?? "..."}</h4>
<input
autoComplete="off"
placeholder="Issue Title"
{...register("name", {
required: "Issue title is required.",
})}
className="w-full px-2 pl-0 py-1.5 rounded-md bg-transparent text-sm font-medium leading-5 text-custom-text-200 outline-none"
/>
</div>
);
};
export const BoardInlineCreateIssueForm: React.FC<Props> = observer((props) => {
const { prePopulatedData, groupId, subGroupId } = props;
// router
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
// store
const { workspace: workspaceStore, quickAddIssue: quickAddStore } = useMobxStore();
// ref
const ref = useRef<HTMLFormElement>(null);
// states
const [isOpen, setIsOpen] = useState(false);
const { setToastAlert } = useToast();
const { projectDetails } = useProjectDetails();
const {
reset,
handleSubmit,
register,
setFocus,
formState: { errors, isSubmitting },
} = useForm<IIssue>({ defaultValues });
const handleClose = () => {
setIsOpen(false);
};
useKeypress("Escape", handleClose);
useOutsideClickDetector(ref, handleClose);
// derived values
const workspaceDetail = workspaceStore.getWorkspaceBySlug(workspaceSlug?.toString()!);
useEffect(() => {
if (!isOpen) reset({ ...defaultValues });
}, [isOpen, reset]);
useEffect(() => {
if (!errors) return;
Object.keys(errors).forEach((key) => {
const error = errors[key as keyof IIssue];
setToastAlert({
type: "error",
title: "Error!",
message: error?.message?.toString() || "Some error occurred. Please try again.",
});
});
}, [errors, setToastAlert]);
const onSubmitHandler = async (formData: IIssue) => {
if (isSubmitting || !workspaceSlug || !projectId) return;
// resetting the form so that user can add another issue quickly
reset({ ...defaultValues, ...(prePopulatedData ?? {}) });
const payload = createIssuePayload(workspaceDetail!, projectDetails!, {
...(prePopulatedData ?? {}),
...formData,
labels_list:
formData.labels_list && formData.labels_list.length !== 0
? formData.labels_list
: prePopulatedData?.labels && prePopulatedData?.labels.toString() !== "none"
? [prePopulatedData.labels as any]
: [],
assignees_list:
formData.assignees_list && formData.assignees_list.length !== 0
? formData.assignees_list
: prePopulatedData?.assignees && prePopulatedData?.assignees.toString() !== "none"
? [prePopulatedData.assignees as any]
: [],
});
try {
quickAddStore.createIssue(
workspaceSlug.toString(),
projectId.toString(),
{
group_id: groupId ?? null,
sub_group_id: subGroupId ?? null,
},
payload
);
setToastAlert({
type: "success",
title: "Success!",
message: "Issue created successfully.",
});
} catch (err: any) {
Object.keys(err || {}).forEach((key) => {
const error = err?.[key];
const errorTitle = error ? (Array.isArray(error) ? error.join(", ") : error) : null;
setToastAlert({
type: "error",
title: "Error!",
message: errorTitle || "Some error occurred. Please try again.",
});
});
}
};
return (
<div>
<Transition
show={isOpen}
enter="transition ease-in-out duration-200 transform"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="transition ease-in-out duration-200 transform"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<form
ref={ref}
onSubmit={handleSubmit(onSubmitHandler)}
className="flex flex-col border-[0.5px] border-custom-border-100 justify-between gap-1.5 group/card relative select-none px-3.5 py-3 h-[118px] mb-3 rounded bg-custom-background-100 shadow-custom-shadow-sm"
>
<Inputs register={register} setFocus={setFocus} projectDetails={projectDetails} />
</form>
</Transition>
{isOpen && (
<p className="text-xs ml-3 italic text-custom-text-200">
Press {"'"}Enter{"'"} to add another issue
</p>
)}
{!isOpen && (
<button
type="button"
className="flex items-center gap-x-[6px] text-custom-primary-100 px-2 py-1 rounded-md"
onClick={() => setIsOpen(true)}
>
<PlusIcon className="h-4 w-4" />
<span className="text-sm font-medium text-custom-primary-100">New Issue</span>
</button>
)}
</div>
);
});
@@ -10,190 +10,196 @@ import { IssuePropertyAssignee } from "../properties/assignee";
import { IssuePropertyEstimates } from "../properties/estimates";
import { IssuePropertyDate } from "../properties/date";
import { Tooltip } from "@plane/ui";
import { IEstimatePoint, IIssue, IIssueLabels, IState, IUserLite, TIssuePriorities } from "types";
export interface IKanBanProperties {
sub_group_id: string;
columnId: string;
issue: any;
handleIssues?: (sub_group_by: string | null, group_by: string | null, issue: any) => void;
issue: IIssue;
handleIssues: (sub_group_by: string | null, group_by: string | null, issue: IIssue) => void;
display_properties: any;
states: IState[] | null;
labels: IIssueLabels[] | null;
members: IUserLite[] | null;
estimates: IEstimatePoint[] | null;
}
export const KanBanProperties: React.FC<IKanBanProperties> = observer(
({ sub_group_id, columnId: group_id, issue, handleIssues, display_properties }) => {
const handleState = (id: string) => {
if (handleIssues)
handleIssues(
!sub_group_id && sub_group_id === "null" ? null : sub_group_id,
!group_id && group_id === "null" ? null : group_id,
{ ...issue, state: id }
);
};
export const KanBanProperties: React.FC<IKanBanProperties> = observer((props) => {
const {
sub_group_id,
columnId: group_id,
issue,
handleIssues,
display_properties,
states,
labels,
members,
estimates,
} = props;
const handlePriority = (id: string) => {
if (handleIssues)
handleIssues(
!sub_group_id && sub_group_id === "null" ? null : sub_group_id,
!group_id && group_id === "null" ? null : group_id,
{ ...issue, priority: id }
);
};
const handleLabel = (ids: string[]) => {
if (handleIssues)
handleIssues(
!sub_group_id && sub_group_id === "null" ? null : sub_group_id,
!group_id && group_id === "null" ? null : group_id,
{ ...issue, labels: ids }
);
};
const handleAssignee = (ids: string[]) => {
if (handleIssues)
handleIssues(
!sub_group_id && sub_group_id === "null" ? null : sub_group_id,
!group_id && group_id === "null" ? null : group_id,
{ ...issue, assignees: ids }
);
};
const handleStartDate = (date: string) => {
if (handleIssues)
handleIssues(
!sub_group_id && sub_group_id === "null" ? null : sub_group_id,
!group_id && group_id === "null" ? null : group_id,
{ ...issue, start_date: date }
);
};
const handleTargetDate = (date: string) => {
if (handleIssues)
handleIssues(
!sub_group_id && sub_group_id === "null" ? null : sub_group_id,
!group_id && group_id === "null" ? null : group_id,
{ ...issue, target_date: date }
);
};
const handleEstimate = (id: string) => {
if (handleIssues)
handleIssues(
!sub_group_id && sub_group_id === "null" ? null : sub_group_id,
!group_id && group_id === "null" ? null : group_id,
{ ...issue, estimate_point: id }
);
};
return (
<div className="relative flex gap-2 overflow-x-auto whitespace-nowrap">
{/* basic properties */}
{/* state */}
{display_properties && display_properties?.state && (
<IssuePropertyState
value={issue?.state || null}
dropdownArrow={false}
onChange={(id: string) => handleState(id)}
disabled={false}
/>
)}
{/* priority */}
{display_properties && display_properties?.priority && (
<IssuePropertyPriority
value={issue?.priority || null}
dropdownArrow={false}
onChange={(id: string) => handlePriority(id)}
disabled={false}
/>
)}
{/* label */}
{display_properties && display_properties?.labels && (
<IssuePropertyLabels
value={issue?.labels || null}
dropdownArrow={false}
onChange={(ids: string[]) => handleLabel(ids)}
disabled={false}
/>
)}
{/* assignee */}
{display_properties && display_properties?.assignee && (
<IssuePropertyAssignee
value={issue?.assignees || null}
dropdownArrow={false}
onChange={(ids: string[]) => handleAssignee(ids)}
disabled={false}
/>
)}
{/* start date */}
{display_properties && display_properties?.start_date && (
<IssuePropertyDate
value={issue?.start_date || null}
onChange={(date: string) => handleStartDate(date)}
disabled={false}
/>
)}
{/* target/due date */}
{display_properties && display_properties?.due_date && (
<IssuePropertyDate
value={issue?.target_date || null}
onChange={(date: string) => handleTargetDate(date)}
disabled={false}
/>
)}
{/* estimates */}
{display_properties && display_properties?.estimate && (
<IssuePropertyEstimates
value={issue?.estimate_point?.toString() || null}
dropdownArrow={false}
onChange={(id: string) => handleEstimate(id)}
disabled={false}
workspaceSlug={issue?.workspace_detail?.slug || null}
projectId={issue?.project_detail?.id || null}
/>
)}
{/* extra render properties */}
{/* sub-issues */}
{display_properties && display_properties?.sub_issue_count && (
<Tooltip tooltipHeading="Sub-issue" tooltipContent={`${issue.sub_issues_count}`}>
<div className="flex-shrink-0 border border-custom-border-300 min-w-[22px] h-[22px] overflow-hidden rounded-sm flex justify-center items-center cursor-pointer">
<div className="flex-shrink-0 w-[16px] h-[16px] flex justify-center items-center">
<Layers width={10} strokeWidth={2} />
</div>
<div className="pl-0.5 pr-1 text-xs">{issue.sub_issues_count}</div>
</div>
</Tooltip>
)}
{/* attachments */}
{display_properties && display_properties?.attachment_count && (
<Tooltip tooltipHeading="Attachments" tooltipContent={`${issue.attachment_count}`}>
<div className="flex-shrink-0 border border-custom-border-300 min-w-[22px] h-[22px] overflow-hidden rounded-sm flex justify-center items-center cursor-pointer">
<div className="flex-shrink-0 w-[16px] h-[16px] flex justify-center items-center">
<Paperclip width={10} strokeWidth={2} />
</div>
<div className="pl-0.5 pr-1 text-xs">{issue.attachment_count}</div>
</div>
</Tooltip>
)}
{/* link */}
{display_properties && display_properties?.link && (
<Tooltip tooltipHeading="Links" tooltipContent={`${issue.link_count}`}>
<div className="flex-shrink-0 border border-custom-border-300 min-w-[22px] h-[22px] overflow-hidden rounded-sm flex justify-center items-center cursor-pointer">
<div className="flex-shrink-0 w-[16px] h-[16px] flex justify-center items-center">
<Link width={10} strokeWidth={2} />
</div>
<div className="pl-0.5 pr-1 text-xs">{issue.link_count}</div>
</div>
</Tooltip>
)}
</div>
const handleState = (state: IState) => {
handleIssues(
!sub_group_id && sub_group_id === "null" ? null : sub_group_id,
!group_id && group_id === "null" ? null : group_id,
{ ...issue, state: state.id }
);
}
);
};
const handlePriority = (value: TIssuePriorities) => {
handleIssues(
!sub_group_id && sub_group_id === "null" ? null : sub_group_id,
!group_id && group_id === "null" ? null : group_id,
{ ...issue, priority: value }
);
};
const handleLabel = (ids: string[]) => {
handleIssues(
!sub_group_id && sub_group_id === "null" ? null : sub_group_id,
!group_id && group_id === "null" ? null : group_id,
{ ...issue, labels_list: ids }
);
};
const handleAssignee = (ids: string[]) => {
handleIssues(
!sub_group_id && sub_group_id === "null" ? null : sub_group_id,
!group_id && group_id === "null" ? null : group_id,
{ ...issue, assignees_list: ids }
);
};
const handleStartDate = (date: string) => {
handleIssues(
!sub_group_id && sub_group_id === "null" ? null : sub_group_id,
!group_id && group_id === "null" ? null : group_id,
{ ...issue, start_date: date }
);
};
const handleTargetDate = (date: string) => {
handleIssues(
!sub_group_id && sub_group_id === "null" ? null : sub_group_id,
!group_id && group_id === "null" ? null : group_id,
{ ...issue, target_date: date }
);
};
const handleEstimate = (value: number | null) => {
handleIssues(
!sub_group_id && sub_group_id === "null" ? null : sub_group_id,
!group_id && group_id === "null" ? null : group_id,
{ ...issue, estimate_point: value }
);
};
return (
<div className="flex items-center gap-2 flex-wrap whitespace-nowrap">
{/* basic properties */}
{/* state */}
{display_properties && display_properties?.state && (
<IssuePropertyState
value={issue?.state_detail || null}
onChange={handleState}
states={states}
disabled={false}
hideDropdownArrow={true}
/>
)}
{/* priority */}
{display_properties && display_properties?.priority && (
<IssuePropertyPriority
value={issue?.priority || null}
onChange={handlePriority}
disabled={false}
hideDropdownArrow={true}
/>
)}
{/* label */}
{display_properties && display_properties?.labels && (
<IssuePropertyLabels
value={issue?.labels || null}
onChange={handleLabel}
labels={labels}
disabled={false}
hideDropdownArrow={true}
/>
)}
{/* assignee */}
{display_properties && display_properties?.assignee && (
<IssuePropertyAssignee
value={issue?.assignees || null}
hideDropdownArrow={true}
onChange={handleAssignee}
members={members}
disabled={false}
/>
)}
{/* start date */}
{display_properties && display_properties?.start_date && (
<IssuePropertyDate
value={issue?.start_date || null}
onChange={(date: string) => handleStartDate(date)}
disabled={false}
placeHolder="Start date"
/>
)}
{/* target/due date */}
{display_properties && display_properties?.due_date && (
<IssuePropertyDate
value={issue?.target_date || null}
onChange={(date: string) => handleTargetDate(date)}
disabled={false}
placeHolder="Target date"
/>
)}
{/* estimates */}
{display_properties && display_properties?.estimate && (
<IssuePropertyEstimates
value={issue?.estimate_point || null}
onChange={handleEstimate}
estimatePoints={estimates}
disabled={false}
hideDropdownArrow={true}
/>
)}
{/* extra render properties */}
{/* sub-issues */}
{display_properties && display_properties?.sub_issue_count && (
<Tooltip tooltipHeading="Sub-issues" tooltipContent={`${issue.sub_issues_count}`}>
<div className="flex-shrink-0 border-[0.5px] border-custom-border-300 overflow-hidden rounded flex justify-center items-center gap-2 px-2.5 py-1 h-5">
<Layers className="h-3 w-3 flex-shrink-0" strokeWidth={2} />
<div className="text-xs">{issue.sub_issues_count}</div>
</div>
</Tooltip>
)}
{/* attachments */}
{display_properties && display_properties?.attachment_count && (
<Tooltip tooltipHeading="Attachments" tooltipContent={`${issue.attachment_count}`}>
<div className="flex-shrink-0 border-[0.5px] border-custom-border-300 overflow-hidden rounded flex justify-center items-center gap-2 px-2.5 py-1 h-5">
<Paperclip className="h-3 w-3 flex-shrink-0" strokeWidth={2} />
<div className="text-xs">{issue.attachment_count}</div>
</div>
</Tooltip>
)}
{/* link */}
{display_properties && display_properties?.link && (
<Tooltip tooltipHeading="Links" tooltipContent={`${issue.link_count}`}>
<div className="flex-shrink-0 border-[0.5px] border-custom-border-300 overflow-hidden rounded flex justify-center items-center gap-2 px-2.5 py-1 h-5">
<Link className="h-3 w-3 flex-shrink-0" strokeWidth={2} />
<div className="text-xs">{issue.link_count}</div>
</div>
</Tooltip>
)}
</div>
);
});
@@ -5,9 +5,11 @@ import { DragDropContext } from "@hello-pangea/dnd";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
// components
import { KanBanSwimLanes } from "./swimlanes";
import { KanBan } from "./default";
import { KanBanSwimLanes } from "../swimlanes";
import { KanBan } from "../default";
import { CycleIssueQuickActions } from "components/issues";
// helpers
import { orderArrayBy } from "helpers/array.helper";
// types
import { IIssue } from "types";
// constants
@@ -25,7 +27,7 @@ export const CycleKanBanLayout: React.FC = observer(() => {
} = useMobxStore();
const router = useRouter();
const { workspaceSlug, cycleId } = router.query;
const { workspaceSlug, projectId, cycleId } = router.query;
const issues = cycleIssueStore?.getIssues;
@@ -60,12 +62,12 @@ export const CycleKanBanLayout: React.FC = observer(() => {
if (!workspaceSlug || !cycleId) return;
if (action === "update") {
cycleIssueStore.updateIssueStructure(group_by, null, issue);
cycleIssueStore.updateIssueStructure(group_by, sub_group_by, issue);
issueDetailStore.updateIssue(workspaceSlug.toString(), issue.project, issue.id, issue);
}
if (action === "delete") cycleIssueStore.deleteIssue(group_by, null, issue);
if (action === "delete") cycleIssueStore.deleteIssue(group_by, sub_group_by, issue);
if (action === "remove" && issue.bridge_id) {
cycleIssueStore.deleteIssue(group_by, null, issue);
cycleIssueStore.deleteIssue(group_by, sub_group_by, issue);
cycleIssueStore.removeIssueFromCycle(
workspaceSlug.toString(),
issue.project,
@@ -81,13 +83,18 @@ export const CycleKanBanLayout: React.FC = observer(() => {
cycleIssueKanBanViewStore.handleKanBanToggle(toggle, value);
};
const projectDetails = projectId ? projectStore.project_details[projectId.toString()] : null;
const states = projectStore?.projectStates || null;
const priorities = ISSUE_PRIORITIES || null;
const labels = projectStore?.projectLabels || null;
const members = projectStore?.projectMembers || null;
const stateGroups = ISSUE_STATE_GROUPS || null;
const projects = projectStore?.projectStates || null;
const estimates = null;
const projects = workspaceSlug ? projectStore?.projects[workspaceSlug.toString()] || null : null;
const estimates =
projectDetails?.estimate !== null
? projectStore.projectEstimates?.find((e) => e.id === projectDetails?.estimate) || null
: null;
return (
<div className={`relative min-w-full w-max min-h-full h-max bg-custom-background-90 px-3`}>
@@ -113,9 +120,9 @@ export const CycleKanBanLayout: React.FC = observer(() => {
stateGroups={stateGroups}
priorities={priorities}
labels={labels}
members={members}
members={members?.map((m) => m.member) ?? null}
projects={projects}
estimates={estimates}
estimates={estimates?.points ? orderArrayBy(estimates.points, "key") : null}
/>
) : (
<KanBanSwimLanes
@@ -138,9 +145,9 @@ export const CycleKanBanLayout: React.FC = observer(() => {
stateGroups={stateGroups}
priorities={priorities}
labels={labels}
members={members}
members={members?.map((m) => m.member) ?? null}
projects={projects}
estimates={estimates}
estimates={estimates?.points ? orderArrayBy(estimates.points, "key") : null}
/>
)}
</DragDropContext>
@@ -0,0 +1,5 @@
export * from "./cycle-root";
export * from "./module-root";
export * from "./profile-issues-root";
export * from "./project-root";
export * from "./project-view-root";
@@ -5,9 +5,11 @@ import { DragDropContext } from "@hello-pangea/dnd";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
// components
import { KanBanSwimLanes } from "./swimlanes";
import { KanBan } from "./default";
import { KanBanSwimLanes } from "../swimlanes";
import { KanBan } from "../default";
import { ModuleIssueQuickActions } from "components/issues";
// helpers
import { orderArrayBy } from "helpers/array.helper";
// types
import { IIssue } from "types";
// constants
@@ -25,7 +27,7 @@ export const ModuleKanBanLayout: React.FC = observer(() => {
} = useMobxStore();
const router = useRouter();
const { workspaceSlug, moduleId } = router.query;
const { workspaceSlug, projectId, moduleId } = router.query;
const issues = moduleIssueStore?.getIssues;
@@ -81,13 +83,18 @@ export const ModuleKanBanLayout: React.FC = observer(() => {
moduleIssueKanBanViewStore.handleKanBanToggle(toggle, value);
};
const projectDetails = projectId ? projectStore.project_details[projectId.toString()] : null;
const states = projectStore?.projectStates || null;
const priorities = ISSUE_PRIORITIES || null;
const labels = projectStore?.projectLabels || null;
const members = projectStore?.projectMembers || null;
const stateGroups = ISSUE_STATE_GROUPS || null;
const projects = projectStore?.projectStates || null;
const estimates = null;
const projects = workspaceSlug ? projectStore?.projects[workspaceSlug.toString()] || null : null;
const estimates =
projectDetails?.estimate !== null
? projectStore.projectEstimates?.find((e) => e.id === projectDetails?.estimate) || null
: null;
return (
<div className={`relative min-w-full w-max min-h-full h-max bg-custom-background-90 px-3`}>
@@ -113,9 +120,9 @@ export const ModuleKanBanLayout: React.FC = observer(() => {
stateGroups={stateGroups}
priorities={priorities}
labels={labels}
members={members}
members={members?.map((m) => m.member) ?? null}
projects={projects}
estimates={estimates}
estimates={estimates?.points ? orderArrayBy(estimates.points, "key") : null}
/>
) : (
<KanBanSwimLanes
@@ -138,9 +145,9 @@ export const ModuleKanBanLayout: React.FC = observer(() => {
stateGroups={stateGroups}
priorities={priorities}
labels={labels}
members={members}
members={members?.map((m) => m.member) ?? null}
projects={projects}
estimates={estimates}
estimates={estimates?.points ? orderArrayBy(estimates.points, "key") : null}
/>
)}
</DragDropContext>
@@ -5,8 +5,8 @@ import { DragDropContext } from "@hello-pangea/dnd";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
// components
import { KanBanSwimLanes } from "./swimlanes";
import { KanBan } from "./default";
import { KanBanSwimLanes } from "../swimlanes";
import { KanBan } from "../default";
import { ProjectIssueQuickActions } from "components/issues";
// constants
import { ISSUE_STATE_GROUPS, ISSUE_PRIORITIES } from "constants/issue";
@@ -79,7 +79,6 @@ export const ProfileIssuesKanBanLayout: FC = observer(() => {
const members = projectStore?.projectMembers || null;
const stateGroups = ISSUE_STATE_GROUPS || null;
const projects = projectStore?.workspaceProjects || null;
const estimates = null;
return (
<div className={`relative min-w-full w-max min-h-full h-max bg-custom-background-90 px-3`}>
@@ -104,9 +103,9 @@ export const ProfileIssuesKanBanLayout: FC = observer(() => {
stateGroups={stateGroups}
priorities={priorities}
labels={labels}
members={members}
members={members?.map((m) => m.member) ?? null}
projects={projects}
estimates={estimates}
estimates={null}
/>
) : (
<KanBanSwimLanes
@@ -128,9 +127,9 @@ export const ProfileIssuesKanBanLayout: FC = observer(() => {
stateGroups={stateGroups}
priorities={priorities}
labels={labels}
members={members}
members={members?.map((m) => m.member) ?? null}
projects={projects}
estimates={estimates}
estimates={null}
/>
)}
</DragDropContext>
@@ -1,13 +1,15 @@
import { FC, useCallback } from "react";
import { useCallback } from "react";
import { useRouter } from "next/router";
import { DragDropContext } from "@hello-pangea/dnd";
import { observer } from "mobx-react-lite";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
// components
import { KanBanSwimLanes } from "./swimlanes";
import { KanBan } from "./default";
import { KanBanSwimLanes } from "../swimlanes";
import { KanBan } from "../default";
import { ProjectIssueQuickActions } from "components/issues";
// helpers
import { orderArrayBy } from "helpers/array.helper";
// types
import { IIssue } from "types";
// constants
@@ -15,9 +17,9 @@ import { ISSUE_STATE_GROUPS, ISSUE_PRIORITIES } from "constants/issue";
export interface IKanBanLayout {}
export const KanBanLayout: FC = observer(() => {
export const KanBanLayout: React.FC = observer(() => {
const router = useRouter();
const { workspaceSlug } = router.query;
const { workspaceSlug, projectId } = router.query;
const {
project: projectStore,
@@ -72,13 +74,18 @@ export const KanBanLayout: FC = observer(() => {
issueKanBanViewStore.handleKanBanToggle(toggle, value);
};
const projectDetails = projectId ? projectStore.project_details[projectId.toString()] : null;
const states = projectStore?.projectStates || null;
const priorities = ISSUE_PRIORITIES || null;
const labels = projectStore?.projectLabels || null;
const members = projectStore?.projectMembers || null;
const stateGroups = ISSUE_STATE_GROUPS || null;
const projects = projectStore?.projectStates || null;
const estimates = null;
const projects = workspaceSlug ? projectStore?.projects[workspaceSlug.toString()] || null : null;
const estimates =
projectDetails?.estimate !== null
? projectStore.projectEstimates?.find((e) => e.id === projectDetails?.estimate) || null
: null;
return (
<div className={`relative min-w-full w-max min-h-full h-max bg-custom-background-90 px-3`}>
@@ -103,9 +110,10 @@ export const KanBanLayout: FC = observer(() => {
stateGroups={stateGroups}
priorities={priorities}
labels={labels}
members={members}
members={members?.map((m) => m.member) ?? null}
projects={projects}
estimates={estimates}
enableQuickIssueCreate
estimates={estimates?.points ? orderArrayBy(estimates.points, "key") : null}
/>
) : (
<KanBanSwimLanes
@@ -127,9 +135,9 @@ export const KanBanLayout: FC = observer(() => {
stateGroups={stateGroups}
priorities={priorities}
labels={labels}
members={members}
members={members?.map((m) => m.member) ?? null}
projects={projects}
estimates={estimates}
estimates={estimates?.points ? orderArrayBy(estimates.points, "key") : null}
/>
)}
</DragDropContext>
@@ -4,8 +4,8 @@ import { DragDropContext } from "@hello-pangea/dnd";
// mobx
import { observer } from "mobx-react-lite";
// components
import { KanBanSwimLanes } from "./swimlanes";
import { KanBan } from "./default";
import { KanBanSwimLanes } from "../swimlanes";
import { KanBan } from "../default";
// store
import { useMobxStore } from "lib/mobx/store-provider";
import { RootStore } from "store/root";
@@ -14,7 +14,7 @@ import { ISSUE_STATE_GROUPS, ISSUE_PRIORITIES } from "constants/issue";
export interface IViewKanBanLayout {}
export const ViewKanBanLayout: React.FC = observer(() => {
export const ProjectViewKanBanLayout: React.FC = observer(() => {
const {
project: projectStore,
issue: issueStore,
@@ -7,7 +7,7 @@ import { KanBanGroupByHeaderRoot } from "./headers/group-by-root";
import { KanBanSubGroupByHeaderRoot } from "./headers/sub-group-by-root";
import { KanBan } from "./default";
// types
import { IIssue } from "types";
import { IEstimatePoint, IIssue, IIssueLabels, IProject, IState, IUserLite } from "types";
// constants
import { ISSUE_STATE_GROUPS, ISSUE_PRIORITIES, getValueFromObject } from "constants/issue";
@@ -19,6 +19,11 @@ interface ISubGroupSwimlaneHeader {
listKey: string;
kanBanToggle: any;
handleKanBanToggle: any;
states: IState[] | null;
labels: IIssueLabels[] | null;
members: IUserLite[] | null;
projects: IProject[] | null;
estimates: IEstimatePoint[] | null;
}
const SubGroupSwimlaneHeader: React.FC<ISubGroupSwimlaneHeader> = ({
issues,
@@ -71,13 +76,13 @@ interface ISubGroupSwimlane extends ISubGroupSwimlaneHeader {
display_properties: any;
kanBanToggle: any;
handleKanBanToggle: any;
states: any;
states: IState[] | null;
stateGroups: any;
priorities: any;
labels: any;
members: any;
projects: any;
estimates: any;
labels: IIssueLabels[] | null;
members: IUserLite[] | null;
projects: IProject[] | null;
estimates: IEstimatePoint[] | null;
}
const SubGroupSwimlane: React.FC<ISubGroupSwimlane> = observer((props) => {
const {
@@ -148,6 +153,7 @@ const SubGroupSwimlane: React.FC<ISubGroupSwimlane> = observer((props) => {
members={members}
projects={projects}
estimates={estimates}
enableQuickIssueCreate
/>
</div>
)}
@@ -171,13 +177,13 @@ export interface IKanBanSwimLanes {
display_properties: any;
kanBanToggle: any;
handleKanBanToggle: any;
states: any;
states: IState[] | null;
stateGroups: any;
priorities: any;
labels: any;
members: any;
projects: any;
estimates: any;
labels: IIssueLabels[] | null;
members: IUserLite[] | null;
projects: IProject[] | null;
estimates: IEstimatePoint[] | null;
}
export const KanBanSwimLanes: React.FC<IKanBanSwimLanes> = observer((props) => {
@@ -213,6 +219,11 @@ export const KanBanSwimLanes: React.FC<IKanBanSwimLanes> = observer((props) => {
listKey={`id`}
kanBanToggle={kanBanToggle}
handleKanBanToggle={handleKanBanToggle}
states={states}
labels={labels}
members={members}
projects={projects}
estimates={estimates}
/>
)}
@@ -225,6 +236,11 @@ export const KanBanSwimLanes: React.FC<IKanBanSwimLanes> = observer((props) => {
listKey={`key`}
kanBanToggle={kanBanToggle}
handleKanBanToggle={handleKanBanToggle}
states={states}
labels={labels}
members={members}
projects={projects}
estimates={estimates}
/>
)}
@@ -237,6 +253,11 @@ export const KanBanSwimLanes: React.FC<IKanBanSwimLanes> = observer((props) => {
listKey={`key`}
kanBanToggle={kanBanToggle}
handleKanBanToggle={handleKanBanToggle}
states={states}
labels={labels}
members={members}
projects={projects}
estimates={estimates}
/>
)}
@@ -249,6 +270,11 @@ export const KanBanSwimLanes: React.FC<IKanBanSwimLanes> = observer((props) => {
listKey={`id`}
kanBanToggle={kanBanToggle}
handleKanBanToggle={handleKanBanToggle}
states={states}
labels={labels}
members={members}
projects={projects}
estimates={estimates}
/>
)}
@@ -261,6 +287,11 @@ export const KanBanSwimLanes: React.FC<IKanBanSwimLanes> = observer((props) => {
listKey={`member.id`}
kanBanToggle={kanBanToggle}
handleKanBanToggle={handleKanBanToggle}
states={states}
labels={labels}
members={members}
projects={projects}
estimates={estimates}
/>
)}
@@ -273,6 +304,11 @@ export const KanBanSwimLanes: React.FC<IKanBanSwimLanes> = observer((props) => {
listKey={`member.id`}
kanBanToggle={kanBanToggle}
handleKanBanToggle={handleKanBanToggle}
states={states}
labels={labels}
members={members}
projects={projects}
estimates={estimates}
/>
)}
</div>

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