Merge pull request #222 from makeplane/stage-release

dev: promote to production (v0.2-dev)
This commit is contained in:
Vamsi Kurama 2023-02-01 00:15:34 +05:30 committed by GitHub
commit 2e9b77cbdc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
389 changed files with 16918 additions and 23556 deletions

10
.eslintrc.js Normal file
View File

@ -0,0 +1,10 @@
module.exports = {
root: true,
// This tells ESLint to load the config from the package `config`
// extends: ["custom"],
settings: {
next: {
rootDir: ["apps/*/"],
},
},
};

View File

@ -1,3 +0,0 @@
{
"extends": "next/core-web-vitals"
}

View File

@ -49,7 +49,7 @@ USER root
RUN apk --update --no-cache add "bash~=5.1"
COPY ./bin ./bin/
RUN chmod +x ./bin/channel-worker ./bin/takeoff ./bin/worker
RUN chmod +x ./bin/takeoff ./bin/worker
USER captain

View File

@ -1,3 +1,2 @@
web: gunicorn -w 4 -k uvicorn.workers.UvicornWorker plane.asgi:application --bind 0.0.0.0:$PORT --config gunicorn.config.py --max-requests 10000 --max-requests-jitter 1000 --access-logfile -
worker: python manage.py rqworker
channel-worker: python manage.py runworker issue-activites
worker: python manage.py rqworker

View File

@ -1,5 +1,5 @@
# All the python scripts that are used for back migrations
from plane.db.models import ProjectIdentifier
from plane.db.models import Issue, IssueComment
# Update description and description html values for old descriptions
@ -40,3 +40,21 @@ def update_comments():
except Exception as e:
print(e)
print("Failed")
def update_project_identifiers():
try:
project_identifiers = ProjectIdentifier.objects.filter(workspace_id=None).select_related("project", "project__workspace")
updated_identifiers = []
for identifier in project_identifiers:
identifier.workspace_id = identifier.project.workspace_id
updated_identifiers.append(identifier)
ProjectIdentifier.objects.bulk_update(
updated_identifiers, ["workspace_id"], batch_size=50
)
print("Success")
except Exception as e:
print(e)
print("Failed")

View File

@ -1,6 +0,0 @@
#!/bin/bash
set -e
python manage.py wait_for_db
python manage.py migrate
python manage.py runworker issue-activites

View File

@ -1 +0,0 @@
from .issue_consumer import IssueConsumer

View File

@ -1,547 +0,0 @@
from channels.generic.websocket import SyncConsumer
import json
from plane.db.models import IssueActivity, Project, User, Issue, State, Label
class IssueConsumer(SyncConsumer):
# Track Chnages in name
def track_name(
self,
requested_data,
current_instance,
issue_id,
project,
actor,
issue_activities,
):
if current_instance.get("name") != requested_data.get("name"):
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value=current_instance.get("name"),
new_value=requested_data.get("name"),
field="name",
project=project,
workspace=project.workspace,
comment=f"{actor.email} updated the start date to {requested_data.get('name')}",
)
)
# Track changes in parent issue
def track_parent(
self,
requested_data,
current_instance,
issue_id,
project,
actor,
issue_activities,
):
if current_instance.get("parent") != requested_data.get("parent"):
if requested_data.get("parent") == None:
old_parent = Issue.objects.get(pk=current_instance.get("parent"))
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value=f"{project.identifier}-{old_parent.sequence_id}",
new_value=None,
field="parent",
project=project,
workspace=project.workspace,
comment=f"{actor.email} updated the parent issue to None",
old_identifier=old_parent.id,
new_identifier=None,
)
)
else:
new_parent = Issue.objects.get(pk=requested_data.get("parent"))
old_parent = Issue.objects.filter(
pk=current_instance.get("parent")
).first()
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value=f"{project.identifier}-{old_parent.sequence_id}"
if old_parent is not None
else None,
new_value=f"{project.identifier}-{new_parent.sequence_id}",
field="parent",
project=project,
workspace=project.workspace,
comment=f"{actor.email} updated the parent issue to {new_parent.name}",
old_identifier=old_parent.id
if old_parent is not None
else None,
new_identifier=new_parent.id,
)
)
# Track changes in priority
def track_priority(
self,
requested_data,
current_instance,
issue_id,
project,
actor,
issue_activities,
):
if current_instance.get("priority") != requested_data.get("priority"):
if requested_data.get("priority") == None:
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value=current_instance.get("parent"),
new_value=requested_data.get("parent"),
field="priority",
project=project,
workspace=project.workspace,
comment=f"{actor.email} updated the priority to None",
)
)
else:
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value=current_instance.get("priority"),
new_value=requested_data.get("priority"),
field="priority",
project=project,
workspace=project.workspace,
comment=f"{actor.email} updated the priority to {requested_data.get('priority')}",
)
)
# Track chnages in state of the issue
def track_state(
self,
requested_data,
current_instance,
issue_id,
project,
actor,
issue_activities,
):
if current_instance.get("state") != requested_data.get("state"):
new_state = State.objects.get(pk=requested_data.get("state", None))
old_state = State.objects.get(pk=current_instance.get("state", None))
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value=old_state.name,
new_value=new_state.name,
field="state",
project=project,
workspace=project.workspace,
comment=f"{actor.email} updated the state to {new_state.name}",
old_identifier=old_state.id,
new_identifier=new_state.id,
)
)
# Track issue description
def track_description(
self,
requested_data,
current_instance,
issue_id,
project,
actor,
issue_activities,
):
if current_instance.get("description_html") != requested_data.get("description_html"):
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value=current_instance.get("description_html"),
new_value=requested_data.get("description_html"),
field="description",
project=project,
workspace=project.workspace,
comment=f"{actor.email} updated the description to {requested_data.get('description_html')}",
)
)
# Track changes in issue target date
def track_target_date(
self,
requested_data,
current_instance,
issue_id,
project,
actor,
issue_activities,
):
if current_instance.get("target_date") != requested_data.get("target_date"):
if requested_data.get("target_date") == None:
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value=current_instance.get("target_date"),
new_value=requested_data.get("target_date"),
field="target_date",
project=project,
workspace=project.workspace,
comment=f"{actor.email} updated the target date to None",
)
)
else:
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value=current_instance.get("target_date"),
new_value=requested_data.get("target_date"),
field="target_date",
project=project,
workspace=project.workspace,
comment=f"{actor.email} updated the target date to {requested_data.get('target_date')}",
)
)
# Track changes in issue start date
def track_start_date(
self,
requested_data,
current_instance,
issue_id,
project,
actor,
issue_activities,
):
if current_instance.get("start_date") != requested_data.get("start_date"):
if requested_data.get("start_date") == None:
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value=current_instance.get("start_date"),
new_value=requested_data.get("start_date"),
field="start_date",
project=project,
workspace=project.workspace,
comment=f"{actor.email} updated the start date to None",
)
)
else:
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value=current_instance.get("start_date"),
new_value=requested_data.get("start_date"),
field="start_date",
project=project,
workspace=project.workspace,
comment=f"{actor.email} updated the start date to {requested_data.get('start_date')}",
)
)
# Track changes in issue labels
def track_labels(
self,
requested_data,
current_instance,
issue_id,
project,
actor,
issue_activities,
):
# Label Addition
if len(requested_data.get("labels_list")) > len(current_instance.get("labels")):
for label in requested_data.get("labels_list"):
if label not in current_instance.get("labels"):
label = Label.objects.get(pk=label)
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value="",
new_value=label.name,
field="labels",
project=project,
workspace=project.workspace,
comment=f"{actor.email} added label {label.name}",
new_identifier=label.id,
old_identifier=None,
)
)
# Label Removal
if len(requested_data.get("labels_list")) < len(current_instance.get("labels")):
for label in current_instance.get("labels"):
if label not in requested_data.get("labels_list"):
label = Label.objects.get(pk=label)
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value=label.name,
new_value="",
field="labels",
project=project,
workspace=project.workspace,
comment=f"{actor.email} removed label {label.name}",
old_identifier=label.id,
new_identifier=None,
)
)
# Track changes in issue assignees
def track_assignees(
self,
requested_data,
current_instance,
issue_id,
project,
actor,
issue_activities,
):
# Assignee Addition
if len(requested_data.get("assignees_list")) > len(
current_instance.get("assignees")
):
for assignee in requested_data.get("assignees_list"):
if assignee not in current_instance.get("assignees"):
assignee = User.objects.get(pk=assignee)
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value="",
new_value=assignee.email,
field="assignees",
project=project,
workspace=project.workspace,
comment=f"{actor.email} added assignee {assignee.email}",
new_identifier=actor.id,
)
)
# Assignee Removal
if len(requested_data.get("assignees_list")) < len(
current_instance.get("assignees")
):
for assignee in current_instance.get("assignees"):
if assignee not in requested_data.get("assignees_list"):
assignee = User.objects.get(pk=assignee)
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value=assignee.email,
new_value="",
field="assignee",
project=project,
workspace=project.workspace,
comment=f"{actor.email} removed assignee {assignee.email}",
old_identifier=actor.id,
)
)
# Track changes in blocking issues
def track_blocks(
self,
requested_data,
current_instance,
issue_id,
project,
actor,
issue_activities,
):
if len(requested_data.get("blocks_list")) > len(
current_instance.get("blocked_issues")
):
for block in requested_data.get("blocks_list"):
if (
len(
[
blocked
for blocked in current_instance.get("blocked_issues")
if blocked.get("block") == block
]
)
== 0
):
issue = Issue.objects.get(pk=block)
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value="",
new_value=f"{project.identifier}-{issue.sequence_id}",
field="blocks",
project=project,
workspace=project.workspace,
comment=f"{actor.email} added blocking issue {project.identifier}-{issue.sequence_id}",
new_identifier=issue.id,
)
)
# Blocked Issue Removal
if len(requested_data.get("blocks_list")) < len(
current_instance.get("blocked_issues")
):
for blocked in current_instance.get("blocked_issues"):
if blocked.get("block") not in requested_data.get("blocks_list"):
issue = Issue.objects.get(pk=blocked.get("block"))
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value=f"{project.identifier}-{issue.sequence_id}",
new_value="",
field="blocks",
project=project,
workspace=project.workspace,
comment=f"{actor.email} removed blocking issue {project.identifier}-{issue.sequence_id}",
old_identifier=issue.id,
)
)
# Track changes in blocked_by issues
def track_blockings(
self,
requested_data,
current_instance,
issue_id,
project,
actor,
issue_activities,
):
if len(requested_data.get("blockers_list")) > len(
current_instance.get("blocker_issues")
):
for block in requested_data.get("blockers_list"):
if (
len(
[
blocked
for blocked in current_instance.get("blocker_issues")
if blocked.get("blocked_by") == block
]
)
== 0
):
issue = Issue.objects.get(pk=block)
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value="",
new_value=f"{project.identifier}-{issue.sequence_id}",
field="blocking",
project=project,
workspace=project.workspace,
comment=f"{actor.email} added blocked by issue {project.identifier}-{issue.sequence_id}",
new_identifier=issue.id,
)
)
# Blocked Issue Removal
if len(requested_data.get("blockers_list")) < len(
current_instance.get("blocker_issues")
):
for blocked in current_instance.get("blocker_issues"):
if blocked.get("blocked_by") not in requested_data.get("blockers_list"):
issue = Issue.objects.get(pk=blocked.get("blocked_by"))
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value=f"{project.identifier}-{issue.sequence_id}",
new_value="",
field="blocking",
project=project,
workspace=project.workspace,
comment=f"{actor.email} removed blocked by issue {project.identifier}-{issue.sequence_id}",
old_identifier=issue.id,
)
)
# Receive message from room group
def issue_activity(self, event):
issue_activities = []
# Remove event type:
event.pop("type")
requested_data = json.loads(event.get("requested_data"))
current_instance = json.loads(event.get("current_instance"))
issue_id = event.get("issue_id")
actor_id = event.get("actor_id")
project_id = event.get("project_id")
actor = User.objects.get(pk=actor_id)
project = Project.objects.get(pk=project_id)
ISSUE_ACTIVITY_MAPPER = {
"name": self.track_name,
"parent": self.track_parent,
"priority": self.track_priority,
"state": self.track_state,
"description": self.track_description,
"target_date": self.track_target_date,
"start_date": self.track_start_date,
"labels_list": self.track_labels,
"assignees_list": self.track_assignees,
"blocks_list": self.track_blocks,
"blockers_list": self.track_blockings,
}
for key in requested_data:
func = ISSUE_ACTIVITY_MAPPER.get(key, None)
if func is not None:
func(
requested_data,
current_instance,
issue_id,
project,
actor,
issue_activities,
)
# Save all the values to database
IssueActivity.objects.bulk_create(issue_activities)

View File

@ -4,6 +4,12 @@ from rest_framework.permissions import BasePermission, SAFE_METHODS
# Module import
from plane.db.models import WorkspaceMember, ProjectMember
# Permission Mappings
Admin = 20
Member = 15
Viewer = 10
Guest = 5
class ProjectBasePermission(BasePermission):
def has_permission(self, request, view):
@ -13,16 +19,24 @@ class ProjectBasePermission(BasePermission):
## Safe Methods -> Handle the filtering logic in queryset
if request.method in SAFE_METHODS:
return True
return WorkspaceMember.objects.filter(
workspace__slug=view.workspace_slug, member=request.user
).exists()
## Only workspace owners or admins can create the projects
if request.method == "POST":
return WorkspaceMember.objects.filter(
workspace=view.workspace, member=request.user, role__in=[15, 20]
workspace__slug=view.workspace_slug,
member=request.user,
role__in=[Admin, Member],
).exists()
## Only Project Admins can update project attributes
return ProjectMember.objects.filter(
workspace=view.workspace, member=request.user, role=20
workspace__slug=view.workspace_slug,
member=request.user,
role=Admin,
project_id=view.project_id,
).exists()
@ -34,16 +48,23 @@ class ProjectMemberPermission(BasePermission):
## Safe Methods -> Handle the filtering logic in queryset
if request.method in SAFE_METHODS:
return True
return ProjectMember.objects.filter(
workspace__slug=view.workspace_slug, member=request.user
).exists()
## Only workspace owners or admins can create the projects
if request.method == "POST":
return WorkspaceMember.objects.filter(
workspace=view.workspace, member=request.user, role__in=[15, 20]
workspace__slug=view.workspace_slug,
member=request.user,
role__in=[Admin, Member],
).exists()
## Only Project Admins can update project attributes
return ProjectMember.objects.filter(
workspace=view.workspace, member=request.user, role__in=[15, 20]
workspace__slug=view.workspace_slug,
member=request.user,
role__in=[Admin, Member],
project_id=view.project_id,
).exists()
@ -52,12 +73,19 @@ class ProjectEntityPermission(BasePermission):
if request.user.is_anonymous:
return False
## Safe Methods -> Handle the filtering logic in queryset
if request.method in SAFE_METHODS:
return True
## Only workspace owners or admins can create the projects
return ProjectMember.objects.filter(
workspace__slug=view.workspace_slug,
member=request.user,
project_id=view.project_id,
).exists()
## Only project members or admins can create and edit the project attributes
return ProjectMember.objects.filter(
workspace=view.workspace, member=request.user, role__in=[15, 20]
workspace__slug=view.workspace_slug,
member=request.user,
role__in=[Admin, Member],
project_id=view.project_id,
).exists()

View File

@ -2,7 +2,15 @@
from rest_framework.permissions import BasePermission, SAFE_METHODS
# Module imports
from plane.db.models import WorkspaceMember, ProjectMember
from plane.db.models import WorkspaceMember
# Permission Mappings
Owner = 20
Admin = 15
Member = 10
Guest = 5
# TODO: Move the below logic to python match - python v3.10
@ -22,13 +30,15 @@ class WorkSpaceBasePermission(BasePermission):
# allow only admins and owners to update the workspace settings
if request.method in ["PUT", "PATCH"]:
return WorkspaceMember.objects.filter(
member=request.user, workspace=view.workspace, role__in=[15, 20]
member=request.user,
workspace__slug=view.workspace_slug,
role__in=[Owner, Admin],
).exists()
# allow only owner to delete the workspace
if request.method == "DELETE":
return WorkspaceMember.objects.filter(
member=request.user, workspace=view.workspace, role=20
member=request.user, workspace__slug=view.workspace_slug, role=Owner
).exists()
@ -39,5 +49,7 @@ class WorkSpaceAdminPermission(BasePermission):
return False
return WorkspaceMember.objects.filter(
member=request.user, workspace=view.workspace, role__in=[15, 20]
member=request.user,
workspace__slug=view.workspace_slug,
role__in=[Owner, Admin],
).exists()

View File

@ -29,7 +29,6 @@ from .issue import (
IssueCommentSerializer,
TimeLineIssueSerializer,
IssuePropertySerializer,
IssueLabelSerializer,
BlockerIssueSerializer,
BlockedIssueSerializer,
IssueAssigneeSerializer,
@ -39,4 +38,6 @@ from .issue import (
IssueStateSerializer,
)
from .module import ModuleWriteSerializer, ModuleSerializer, ModuleIssueSerializer
from .module import ModuleWriteSerializer, ModuleSerializer, ModuleIssueSerializer
from .api_token import APITokenSerializer

View File

@ -0,0 +1,8 @@
from .base import BaseSerializer
from plane.db.models import APIToken
class APITokenSerializer(BaseSerializer):
class Meta:
model = APIToken
fields = "__all__"

View File

@ -1,3 +1,6 @@
# Third party imports
from rest_framework import serializers
# Module imports
from .base import BaseSerializer
from .user import UserLiteSerializer
@ -22,6 +25,7 @@ class CycleSerializer(BaseSerializer):
class CycleIssueSerializer(BaseSerializer):
issue_detail = IssueStateSerializer(read_only=True, source="issue")
sub_issues_count = serializers.IntegerField(read_only=True)
class Meta:
model = CycleIssue

View File

@ -432,6 +432,7 @@ class IssueSerializer(BaseSerializer):
blocker_issues = BlockerIssueSerializer(read_only=True, many=True)
issue_cycle = IssueCycleDetailSerializer(read_only=True)
issue_module = IssueModuleDetailSerializer(read_only=True)
sub_issues_count = serializers.IntegerField(read_only=True)
class Meta:
model = Issue

View File

@ -93,7 +93,7 @@ class ModuleWriteSerializer(BaseSerializer):
links = validated_data.pop("links_list", None)
if members is not None:
ModuleIssue.objects.filter(module=instance).delete()
ModuleMember.objects.filter(module=instance).delete()
ModuleMember.objects.bulk_create(
[
ModuleMember(
@ -150,6 +150,7 @@ class ModuleIssueSerializer(BaseSerializer):
module_detail = ModuleFlatSerializer(read_only=True, source="module")
issue_detail = IssueStateSerializer(read_only=True, source="issue")
sub_issues_count = serializers.IntegerField(read_only=True)
class Meta:
model = ModuleIssue
@ -200,4 +201,4 @@ class ModuleSerializer(BaseSerializer):
"updated_by",
"created_at",
"updated_at",
]
]

View File

@ -4,73 +4,98 @@ from django.urls import path
# Create your urls here.
from plane.api.views import (
# Authentication
SignUpEndpoint,
SignInEndpoint,
SignOutEndpoint,
MagicSignInEndpoint,
MagicSignInGenerateEndpoint,
OauthEndpoint,
## End Authentication
# Auth Extended
ForgotPasswordEndpoint,
PeopleEndpoint,
UserEndpoint,
VerifyEmailEndpoint,
ResetPasswordEndpoint,
RequestEmailVerificationEndpoint,
OauthEndpoint,
ChangePasswordEndpoint,
)
from plane.api.views import (
UserWorkspaceInvitationsEndpoint,
## End Auth Extender
# User
UserEndpoint,
UpdateUserOnBoardedEndpoint,
## End User
# Workspaces
WorkSpaceViewSet,
UserWorkspaceInvitationsEndpoint,
UserWorkSpacesEndpoint,
InviteWorkspaceEndpoint,
JoinWorkspaceEndpoint,
WorkSpaceMemberViewSet,
WorkspaceInvitationsViewset,
UserWorkspaceInvitationsEndpoint,
WorkspaceMemberUserEndpoint,
WorkspaceMemberUserViewsEndpoint,
WorkSpaceAvailabilityCheckEndpoint,
TeamMemberViewSet,
AddTeamToProjectEndpoint,
UserLastProjectWithWorkspaceEndpoint,
UserWorkspaceInvitationEndpoint,
## End Workspaces
# File Assets
FileAssetEndpoint,
## End File Assets
# Projects
ProjectViewSet,
InviteProjectEndpoint,
ProjectMemberViewSet,
ProjectMemberInvitationsViewset,
StateViewSet,
ShortCutViewSet,
ViewViewSet,
CycleViewSet,
FileAssetEndpoint,
ProjectMemberUserEndpoint,
AddMemberToProjectEndpoint,
ProjectJoinEndpoint,
UserProjectInvitationsViewset,
ProjectIdentifierEndpoint,
## End Projects
# Issues
IssueViewSet,
WorkSpaceIssuesEndpoint,
IssueActivityEndpoint,
IssueCommentViewSet,
TeamMemberViewSet,
TimeLineIssueViewSet,
CycleIssueViewSet,
IssuePropertyViewSet,
UpdateUserOnBoardedEndpoint,
UserWorkspaceInvitationEndpoint,
UserProjectInvitationsViewset,
ProjectIdentifierEndpoint,
LabelViewSet,
AddMemberToProjectEndpoint,
ProjectJoinEndpoint,
UserWorkSpaceIssues,
BulkDeleteIssuesEndpoint,
ProjectUserViewsEndpoint,
TimeLineIssueViewSet,
IssuePropertyViewSet,
LabelViewSet,
SubIssuesEndpoint,
## End Issues
# States
StateViewSet,
## End States
# Shortcuts
ShortCutViewSet,
## End Shortcuts
# Views
ViewViewSet,
## End Views
# Cycles
CycleViewSet,
CycleIssueViewSet,
## End Cycles
# Modules
ModuleViewSet,
ModuleIssueViewSet,
UserLastProjectWithWorkspaceEndpoint,
UserWorkSpaceIssues,
ProjectMemberUserEndpoint,
WorkspaceMemberUserEndpoint,
WorkspaceMemberUserViewsEndpoint,
WorkSpaceAvailabilityCheckEndpoint,
## End Modules
# Api Tokens
ApiTokenEndpoint,
## End Api Tokens
)
from plane.api.views.project import AddTeamToProjectEndpoint
urlpatterns = [
# Social Auth
path("social-auth/", OauthEndpoint.as_view(), name="oauth"),
# Auth
path("sign-in/", SignInEndpoint.as_view(), name="sign-in"),
path("sign-up/", SignUpEndpoint.as_view(), name="sign-up"),
path("sign-out/", SignOutEndpoint.as_view(), name="sign-out"),
# Magic Sign In/Up
path(
@ -95,8 +120,6 @@ urlpatterns = [
ForgotPasswordEndpoint.as_view(),
name="forgot-password",
),
# List Users
path("users/", PeopleEndpoint.as_view()),
# User Profile
path(
"users/me/",
@ -521,6 +544,11 @@ urlpatterns = [
UserWorkSpaceIssues.as_view(),
name="workspace-issues",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/sub-issues/",
SubIssuesEndpoint.as_view(),
name="sub-issues",
),
## End Issues
## Issue Activity
path(
@ -654,9 +682,8 @@ urlpatterns = [
name="project-module-issues",
),
## End Modules
# path(
# "issues/<int:pk>/all/",
# IssueViewSet.as_view({"get": "list_issue_history_comments"}),
# name="Issue history and comments",
# ),
# API Tokens
path("api-tokens/", ApiTokenEndpoint.as_view(), name="api-token"),
path("api-tokens/<uuid:pk>/", ApiTokenEndpoint.as_view(), name="api-token"),
## End API Tokens
]

View File

@ -13,7 +13,6 @@ from .project import (
ProjectMemberUserEndpoint,
)
from .people import (
PeopleEndpoint,
UserEndpoint,
UpdateUserOnBoardedEndpoint,
)
@ -52,6 +51,7 @@ from .issue import (
LabelViewSet,
BulkDeleteIssuesEndpoint,
UserWorkSpaceIssues,
SubIssuesEndpoint,
)
from .auth_extended import (
@ -64,6 +64,7 @@ from .auth_extended import (
from .authentication import (
SignUpEndpoint,
SignInEndpoint,
SignOutEndpoint,
MagicSignInEndpoint,
@ -71,3 +72,5 @@ from .authentication import (
)
from .module import ModuleViewSet, ModuleIssueViewSet
from .api_token import ApiTokenEndpoint

View File

@ -0,0 +1,62 @@
# Python import
from uuid import uuid4
# Third party
from rest_framework.response import Response
from rest_framework import status
from sentry_sdk import capture_exception
# Module import
from .base import BaseAPIView
from plane.db.models import APIToken
from plane.api.serializers import APITokenSerializer
class ApiTokenEndpoint(BaseAPIView):
def post(self, request):
try:
label = request.data.get("label", str(uuid4().hex))
api_token = APIToken.objects.create(
label=label,
user=request.user,
)
serializer = APITokenSerializer(api_token)
return Response(serializer.data, status=status.HTTP_201_CREATED)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
def get(self, request):
try:
api_tokens = APIToken.objects.filter(user=request.user)
serializer = APITokenSerializer(api_tokens, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
def delete(self, request, pk):
try:
api_token = APIToken.objects.get(pk=pk)
api_token.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
except APIToken.DoesNotExist:
return Response(
{"error": "Token does not exists"}, status=status.HTTP_400_BAD_REQUEST
)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)

View File

@ -6,7 +6,7 @@ from sentry_sdk import capture_exception
# Module imports
from .base import BaseAPIView
from plane.db.models import FileAsset, Workspace
from plane.db.models import FileAsset
from plane.api.serializers import FileAssetSerializer
@ -18,8 +18,8 @@ class FileAssetEndpoint(BaseAPIView):
A viewset for viewing and editing task instances.
"""
def get(self, request):
files = FileAsset.objects.all()
def get(self, request, slug):
files = FileAsset.objects.filter(workspace__slug=slug)
serializer = FileAssetSerializer(files, context={"request": request}, many=True)
return Response(serializer.data)

View File

@ -1,6 +1,7 @@
# Django imports
from django.urls import resolve
from django.conf import settings
# Third part imports
from rest_framework import status
from rest_framework.viewsets import ModelViewSet
@ -39,32 +40,23 @@ class BaseViewSet(ModelViewSet, BasePaginator):
return self.model.objects.all()
except Exception as e:
print(e)
raise APIException(
"Please check the view", status.HTTP_400_BAD_REQUEST
)
raise APIException("Please check the view", status.HTTP_400_BAD_REQUEST)
def dispatch(self, request, *args, **kwargs):
response = super().dispatch(request, *args, **kwargs)
if settings.DEBUG:
from django.db import connection
print(f'# of Queries: {len(connection.queries)}')
print(
f"{request.method} - {request.get_full_path()} of Queries: {len(connection.queries)}"
)
return response
@property
def workspace_slug(self):
return self.kwargs.get("slug", None)
@property
def workspace(self):
if self.workspace_slug:
try:
return Workspace.objects.get(slug=self.workspace_slug)
except Workspace.DoesNotExist:
raise NotFound(detail="Workspace does not exist")
else:
return None
@property
def project_id(self):
project_id = self.kwargs.get("project_id", None)
@ -74,16 +66,6 @@ class BaseViewSet(ModelViewSet, BasePaginator):
if resolve(self.request.path_info).url_name == "project":
return self.kwargs.get("pk", None)
@property
def project(self):
if self.project_id:
try:
return Project.objects.get(pk=self.project_id)
except Project.DoesNotExist:
raise NotFound(detail="Project does not exist")
else:
return None
class BaseAPIView(APIView, BasePaginator):
@ -110,33 +92,16 @@ class BaseAPIView(APIView, BasePaginator):
if settings.DEBUG:
from django.db import connection
print(f'# of Queries: {len(connection.queries)}')
print(
f"{request.method} - {request.get_full_path()} of Queries: {len(connection.queries)}"
)
return response
@property
def workspace_slug(self):
return self.kwargs.get("slug", None)
@property
def workspace(self):
if self.workspace_slug:
try:
return Workspace.objects.get(slug=self.workspace_slug)
except Workspace.DoesNotExist:
raise NotFound(detail="Workspace does not exist")
else:
return None
@property
def project_id(self):
return self.kwargs.get("project_id", None)
@property
def project(self):
if self.project_id:
try:
return Project.objects.get(pk=self.project_id)
except Project.DoesNotExist:
raise NotFound(detail="Project does not exist")
else:
return None

View File

@ -1,3 +1,6 @@
# Django imports
from django.db.models import OuterRef, Func, F
# Third party imports
from rest_framework.response import Response
from rest_framework import status
@ -32,6 +35,7 @@ class CycleViewSet(BaseViewSet):
.filter(project__project_projectmember__member=self.request.user)
.select_related("project")
.select_related("workspace")
.select_related("owned_by")
.distinct()
)
@ -55,6 +59,12 @@ class CycleIssueViewSet(BaseViewSet):
return self.filter_queryset(
super()
.get_queryset()
.annotate(
sub_issues_count=Issue.objects.filter(parent=OuterRef("issue_id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id"))
.filter(project__project_projectmember__member=self.request.user)
@ -62,8 +72,8 @@ class CycleIssueViewSet(BaseViewSet):
.select_related("project")
.select_related("workspace")
.select_related("cycle")
.select_related("issue")
.select_related("issue__state")
.select_related("issue", "issue__state", "issue__project")
.prefetch_related("issue__assignees", "issue__labels")
.distinct()
)

View File

@ -3,16 +3,13 @@ import json
from itertools import groupby, chain
# Django imports
from django.db.models import Prefetch
from django.db.models import Count, Sum
from django.db.models import Prefetch, OuterRef, Func, F
from django.core.serializers.json import DjangoJSONEncoder
# Third Party imports
from rest_framework.response import Response
from rest_framework import status
from sentry_sdk import capture_exception
from channels.layers import get_channel_layer
from asgiref.sync import async_to_sync
# Module imports
from . import BaseViewSet, BaseAPIView
@ -43,6 +40,7 @@ from plane.db.models import (
CycleIssue,
ModuleIssue,
)
from plane.bgtasks.issue_activites_task import issue_activity
class IssueViewSet(BaseViewSet):
@ -73,12 +71,12 @@ class IssueViewSet(BaseViewSet):
def perform_update(self, serializer):
requested_data = json.dumps(self.request.data, cls=DjangoJSONEncoder)
current_instance = Issue.objects.filter(pk=self.kwargs.get("pk", None)).first()
current_instance = (
self.get_queryset().filter(pk=self.kwargs.get("pk", None)).first()
)
if current_instance is not None:
channel_layer = get_channel_layer()
async_to_sync(channel_layer.send)(
"issue-activites",
issue_activity.delay(
{
"type": "issue.activity",
"requested_data": requested_data,
@ -94,9 +92,16 @@ class IssueViewSet(BaseViewSet):
return super().perform_update(serializer)
def get_queryset(self):
return (
super()
.get_queryset()
.annotate(
sub_issues_count=Issue.objects.filter(parent=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.filter(project_id=self.kwargs.get("project_id"))
.filter(workspace__slug=self.kwargs.get("slug"))
.select_related("project")
@ -126,7 +131,9 @@ class IssueViewSet(BaseViewSet):
.prefetch_related(
Prefetch(
"issue_module",
queryset=ModuleIssue.objects.select_related("module", "issue"),
queryset=ModuleIssue.objects.select_related(
"module", "issue"
).prefetch_related("module__members"),
),
)
)
@ -162,13 +169,22 @@ class IssueViewSet(BaseViewSet):
return Response(issue_dict, status=status.HTTP_200_OK)
return self.paginate(
request=request,
queryset=issue_queryset,
on_results=lambda issues: IssueSerializer(issues, many=True).data,
return Response(
{
"next_cursor": str(0),
"prev_cursor": str(0),
"next_page_results": False,
"prev_page_results": False,
"count": issue_queryset.count(),
"total_pages": 1,
"extra_stats": {},
"results": IssueSerializer(issue_queryset, many=True).data,
},
status=status.HTTP_200_OK,
)
except Exception as e:
print(e)
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
@ -207,8 +223,48 @@ class IssueViewSet(BaseViewSet):
class UserWorkSpaceIssues(BaseAPIView):
def get(self, request, slug):
try:
issues = Issue.objects.filter(
assignees__in=[request.user], workspace__slug=slug
issues = (
Issue.objects.filter(assignees__in=[request.user], workspace__slug=slug)
.annotate(
sub_issues_count=Issue.objects.filter(parent=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.select_related("project")
.select_related("workspace")
.select_related("state")
.select_related("parent")
.prefetch_related("assignees")
.prefetch_related("labels")
.prefetch_related(
Prefetch(
"blocked_issues",
queryset=IssueBlocker.objects.select_related(
"blocked_by", "block"
),
)
)
.prefetch_related(
Prefetch(
"blocker_issues",
queryset=IssueBlocker.objects.select_related(
"block", "blocked_by"
),
)
)
.prefetch_related(
Prefetch(
"issue_cycle",
queryset=CycleIssue.objects.select_related("cycle", "issue"),
),
)
.prefetch_related(
Prefetch(
"issue_module",
queryset=ModuleIssue.objects.select_related("module", "issue"),
),
)
)
serializer = IssueSerializer(issues, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
@ -468,3 +524,62 @@ class BulkDeleteIssuesEndpoint(BaseAPIView):
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class SubIssuesEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
]
def get(self, request, slug, project_id, issue_id):
try:
sub_issues = (
Issue.objects.filter(
parent_id=issue_id, workspace__slug=slug, project_id=project_id
)
.select_related("project")
.select_related("workspace")
.select_related("state")
.select_related("parent")
.prefetch_related("assignees")
.prefetch_related("labels")
.prefetch_related(
Prefetch(
"blocked_issues",
queryset=IssueBlocker.objects.select_related(
"blocked_by", "block"
),
)
)
.prefetch_related(
Prefetch(
"blocker_issues",
queryset=IssueBlocker.objects.select_related(
"block", "blocked_by"
),
)
)
.prefetch_related(
Prefetch(
"issue_cycle",
queryset=CycleIssue.objects.select_related("cycle", "issue"),
),
)
.prefetch_related(
Prefetch(
"issue_module",
queryset=ModuleIssue.objects.select_related("module", "issue"),
),
)
)
serializer = IssueSerializer(sub_issues, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
except Exception as e:
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)

View File

@ -1,6 +1,6 @@
# Django Imports
from django.db import IntegrityError
from django.db.models import Prefetch
from django.db.models import Prefetch, F, OuterRef, Func
# Third party imports
from rest_framework.response import Response
@ -15,7 +15,13 @@ from plane.api.serializers import (
ModuleIssueSerializer,
)
from plane.api.permissions import ProjectEntityPermission
from plane.db.models import Module, ModuleIssue, Project, Issue, ModuleLink
from plane.db.models import (
Module,
ModuleIssue,
Project,
Issue,
ModuleLink,
)
class ModuleViewSet(BaseViewSet):
@ -45,13 +51,15 @@ class ModuleViewSet(BaseViewSet):
.prefetch_related(
Prefetch(
"issue_module",
queryset=ModuleIssue.objects.select_related("module", "issue"),
queryset=ModuleIssue.objects.select_related(
"module", "issue", "issue__state", "issue__project"
).prefetch_related("issue__assignees", "issue__labels"),
)
)
.prefetch_related(
Prefetch(
"link_module",
queryset=ModuleLink.objects.select_related("module"),
queryset=ModuleLink.objects.select_related("module", "created_by"),
)
)
)
@ -110,6 +118,12 @@ class ModuleIssueViewSet(BaseViewSet):
return self.filter_queryset(
super()
.get_queryset()
.annotate(
sub_issues_count=Issue.objects.filter(parent=OuterRef("issue"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id"))
.filter(module_id=self.kwargs.get("module_id"))
@ -117,7 +131,9 @@ class ModuleIssueViewSet(BaseViewSet):
.select_related("project")
.select_related("workspace")
.select_related("module")
.select_related("issue")
.select_related("issue", "issue__state", "issue__project")
.prefetch_related("issue__assignees", "issue__labels")
.prefetch_related("module__members")
.distinct()
)
@ -164,4 +180,4 @@ class ModuleIssueViewSet(BaseViewSet):
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
)

View File

@ -223,8 +223,8 @@ class OauthEndpoint(BaseAPIView):
username=username,
email=email,
mobile_number=mobile_number,
first_name=data["first_name"],
last_name=data["last_name"],
first_name=data.get("first_name", ""),
last_name=data.get("last_name", ""),
is_email_verified=email_verified,
is_password_autoset=True,
)

View File

@ -7,48 +7,11 @@ from sentry_sdk import capture_exception
# Module imports
from plane.api.serializers import (
UserSerializer,
WorkSpaceSerializer,
)
from plane.api.views.base import BaseViewSet, BaseAPIView
from plane.db.models import User, Workspace
class PeopleEndpoint(BaseAPIView):
filterset_fields = ("date_joined",)
search_fields = (
"^first_name",
"^last_name",
"^email",
"^username",
)
def get(self, request):
try:
users = User.objects.all().order_by("-date_joined")
if (
request.GET.get("search", None) is not None
and len(request.GET.get("search")) < 3
):
return Response(
{"message": "Search term must be at least 3 characters long"},
status=status.HTTP_400_BAD_REQUEST,
)
return self.paginate(
request=request,
queryset=self.filter_queryset(users),
on_results=lambda data: UserSerializer(data, many=True).data,
)
except Exception as e:
capture_exception(e)
return Response(
{"message": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
class UserEndpoint(BaseViewSet):
serializer_class = UserSerializer
model = User

View File

@ -67,7 +67,9 @@ class ProjectViewSet(BaseViewSet):
.get_queryset()
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(Q(project_projectmember__member=self.request.user) | Q(network=2))
.select_related("workspace", "workspace__owner")
.select_related(
"workspace", "workspace__owner", "default_assignee", "project_lead"
)
.distinct()
)
@ -294,7 +296,7 @@ class UserProjectInvitationsViewset(BaseViewSet):
super()
.get_queryset()
.filter(email=self.request.user.email)
.select_related("workspace")
.select_related("workspace", "workspace__owner", "project")
)
def create(self, request):
@ -349,6 +351,7 @@ class ProjectMemberViewSet(BaseViewSet):
.filter(project_id=self.kwargs.get("project_id"))
.select_related("project")
.select_related("member")
.select_related("workspace", "workspace__owner")
)
@ -481,6 +484,7 @@ class ProjectMemberInvitationsViewset(BaseViewSet):
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(project_id=self.kwargs.get("project_id"))
.select_related("project")
.select_related("workspace", "workspace__owner")
)
@ -496,7 +500,12 @@ class ProjectMemberInviteDetailViewSet(BaseViewSet):
]
def get_queryset(self):
return self.filter_queryset(super().get_queryset().select_related("project"))
return self.filter_queryset(
super()
.get_queryset()
.select_related("project")
.select_related("workspace", "workspace__owner")
)
class ProjectIdentifierEndpoint(BaseAPIView):

View File

@ -10,7 +10,7 @@ from django.utils import timezone
from django.core.exceptions import ValidationError
from django.core.validators import validate_email
from django.contrib.sites.shortcuts import get_current_site
from django.db.models import CharField, Count
from django.db.models import CharField, Count, OuterRef, Func, F
from django.db.models.functions import Cast
# Third party modules
@ -111,6 +111,14 @@ class UserWorkSpacesEndpoint(BaseAPIView):
def get(self, request):
try:
member_count = (
WorkspaceMember.objects.filter(workspace=OuterRef("id"))
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
workspace = (
Workspace.objects.prefetch_related(
Prefetch("workspace_member", queryset=WorkspaceMember.objects.all())
@ -119,7 +127,7 @@ class UserWorkSpacesEndpoint(BaseAPIView):
workspace_member__member=request.user,
)
.select_related("owner")
).annotate(total_members=Count("workspace_member"))
).annotate(total_members=member_count)
serializer = WorkSpaceSerializer(self.filter_queryset(workspace), many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
@ -176,7 +184,7 @@ class InviteWorkspaceEndpoint(BaseAPIView):
workspace_members = WorkspaceMember.objects.filter(
workspace_id=workspace.id,
member__email__in=[email.get("email") for email in emails],
)
).select_related("member", "workspace", "workspace__owner")
if len(workspace_members):
return Response(
@ -339,7 +347,7 @@ class WorkspaceInvitationsViewset(BaseViewSet):
super()
.get_queryset()
.filter(workspace__slug=self.kwargs.get("slug"))
.select_related("workspace")
.select_related("workspace", "workspace__owner")
)
@ -353,7 +361,7 @@ class UserWorkspaceInvitationsEndpoint(BaseViewSet):
super()
.get_queryset()
.filter(email=self.request.user.email)
.select_related("workspace")
.select_related("workspace", "workspace__owner")
)
def create(self, request):
@ -524,7 +532,7 @@ class UserLastProjectWithWorkspaceEndpoint(BaseAPIView):
project_member = ProjectMember.objects.filter(
workspace_id=last_workspace_id, member=request.user
).select_related("workspace", "project", "member")
).select_related("workspace", "project", "member", "workspace__owner")
project_member_serializer = ProjectMemberSerializer(
project_member, many=True

View File

@ -1,6 +1,6 @@
import os
from channels.routing import ProtocolTypeRouter, ChannelNameRouter
from channels.routing import ProtocolTypeRouter
from django.core.asgi import get_asgi_application
django_asgi_app = get_asgi_application()
@ -10,15 +10,9 @@ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "plane.settings.production")
# Initialize Django ASGI application early to ensure the AppRegistry
# is populated before importing code that may import ORM models.
from plane.api.consumers import IssueConsumer
application = ProtocolTypeRouter(
{
"http": get_asgi_application(),
"channel": ChannelNameRouter(
{
"issue-activites": IssueConsumer.as_asgi(),
}
),
}
)

View File

@ -0,0 +1,553 @@
# Python imports
import json
# Third Party imports
from django_rq import job
from sentry_sdk import capture_exception
# Module imports
from plane.db.models import User, Issue, Project, Label, IssueActivity, State
# Track Chnages in name
def track_name(
requested_data,
current_instance,
issue_id,
project,
actor,
issue_activities,
):
if current_instance.get("name") != requested_data.get("name"):
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value=current_instance.get("name"),
new_value=requested_data.get("name"),
field="name",
project=project,
workspace=project.workspace,
comment=f"{actor.email} updated the start date to {requested_data.get('name')}",
)
)
# Track changes in parent issue
def track_parent(
requested_data,
current_instance,
issue_id,
project,
actor,
issue_activities,
):
if current_instance.get("parent") != requested_data.get("parent"):
if requested_data.get("parent") == None:
old_parent = Issue.objects.get(pk=current_instance.get("parent"))
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value=f"{project.identifier}-{old_parent.sequence_id}",
new_value=None,
field="parent",
project=project,
workspace=project.workspace,
comment=f"{actor.email} updated the parent issue to None",
old_identifier=old_parent.id,
new_identifier=None,
)
)
else:
new_parent = Issue.objects.get(pk=requested_data.get("parent"))
old_parent = Issue.objects.filter(pk=current_instance.get("parent")).first()
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value=f"{project.identifier}-{old_parent.sequence_id}"
if old_parent is not None
else None,
new_value=f"{project.identifier}-{new_parent.sequence_id}",
field="parent",
project=project,
workspace=project.workspace,
comment=f"{actor.email} updated the parent issue to {new_parent.name}",
old_identifier=old_parent.id if old_parent is not None else None,
new_identifier=new_parent.id,
)
)
# Track changes in priority
def track_priority(
requested_data,
current_instance,
issue_id,
project,
actor,
issue_activities,
):
if current_instance.get("priority") != requested_data.get("priority"):
if requested_data.get("priority") == None:
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value=current_instance.get("priority"),
new_value=None,
field="priority",
project=project,
workspace=project.workspace,
comment=f"{actor.email} updated the priority to None",
)
)
else:
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value=current_instance.get("priority"),
new_value=requested_data.get("priority"),
field="priority",
project=project,
workspace=project.workspace,
comment=f"{actor.email} updated the priority to {requested_data.get('priority')}",
)
)
# Track chnages in state of the issue
def track_state(
requested_data,
current_instance,
issue_id,
project,
actor,
issue_activities,
):
if current_instance.get("state") != requested_data.get("state"):
new_state = State.objects.get(pk=requested_data.get("state", None))
old_state = State.objects.get(pk=current_instance.get("state", None))
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value=old_state.name,
new_value=new_state.name,
field="state",
project=project,
workspace=project.workspace,
comment=f"{actor.email} updated the state to {new_state.name}",
old_identifier=old_state.id,
new_identifier=new_state.id,
)
)
# Track issue description
def track_description(
requested_data,
current_instance,
issue_id,
project,
actor,
issue_activities,
):
if current_instance.get("description_html") != requested_data.get(
"description_html"
):
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value=current_instance.get("description_html"),
new_value=requested_data.get("description_html"),
field="description",
project=project,
workspace=project.workspace,
comment=f"{actor.email} updated the description to {requested_data.get('description_html')}",
)
)
# Track changes in issue target date
def track_target_date(
requested_data,
current_instance,
issue_id,
project,
actor,
issue_activities,
):
if current_instance.get("target_date") != requested_data.get("target_date"):
if requested_data.get("target_date") == None:
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value=current_instance.get("target_date"),
new_value=requested_data.get("target_date"),
field="target_date",
project=project,
workspace=project.workspace,
comment=f"{actor.email} updated the target date to None",
)
)
else:
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value=current_instance.get("target_date"),
new_value=requested_data.get("target_date"),
field="target_date",
project=project,
workspace=project.workspace,
comment=f"{actor.email} updated the target date to {requested_data.get('target_date')}",
)
)
# Track changes in issue start date
def track_start_date(
requested_data,
current_instance,
issue_id,
project,
actor,
issue_activities,
):
if current_instance.get("start_date") != requested_data.get("start_date"):
if requested_data.get("start_date") == None:
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value=current_instance.get("start_date"),
new_value=requested_data.get("start_date"),
field="start_date",
project=project,
workspace=project.workspace,
comment=f"{actor.email} updated the start date to None",
)
)
else:
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value=current_instance.get("start_date"),
new_value=requested_data.get("start_date"),
field="start_date",
project=project,
workspace=project.workspace,
comment=f"{actor.email} updated the start date to {requested_data.get('start_date')}",
)
)
# Track changes in issue labels
def track_labels(
requested_data,
current_instance,
issue_id,
project,
actor,
issue_activities,
):
# Label Addition
if len(requested_data.get("labels_list")) > len(current_instance.get("labels")):
for label in requested_data.get("labels_list"):
if label not in current_instance.get("labels"):
label = Label.objects.get(pk=label)
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value="",
new_value=label.name,
field="labels",
project=project,
workspace=project.workspace,
comment=f"{actor.email} added label {label.name}",
new_identifier=label.id,
old_identifier=None,
)
)
# Label Removal
if len(requested_data.get("labels_list")) < len(current_instance.get("labels")):
for label in current_instance.get("labels"):
if label not in requested_data.get("labels_list"):
label = Label.objects.get(pk=label)
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value=label.name,
new_value="",
field="labels",
project=project,
workspace=project.workspace,
comment=f"{actor.email} removed label {label.name}",
old_identifier=label.id,
new_identifier=None,
)
)
# Track changes in issue assignees
def track_assignees(
requested_data,
current_instance,
issue_id,
project,
actor,
issue_activities,
):
# Assignee Addition
if len(requested_data.get("assignees_list")) > len(
current_instance.get("assignees")
):
for assignee in requested_data.get("assignees_list"):
if assignee not in current_instance.get("assignees"):
assignee = User.objects.get(pk=assignee)
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value="",
new_value=assignee.email,
field="assignees",
project=project,
workspace=project.workspace,
comment=f"{actor.email} added assignee {assignee.email}",
new_identifier=actor.id,
)
)
# Assignee Removal
if len(requested_data.get("assignees_list")) < len(
current_instance.get("assignees")
):
for assignee in current_instance.get("assignees"):
if assignee not in requested_data.get("assignees_list"):
assignee = User.objects.get(pk=assignee)
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value=assignee.email,
new_value="",
field="assignee",
project=project,
workspace=project.workspace,
comment=f"{actor.email} removed assignee {assignee.email}",
old_identifier=actor.id,
)
)
# Track changes in blocking issues
def track_blocks(
requested_data,
current_instance,
issue_id,
project,
actor,
issue_activities,
):
if len(requested_data.get("blocks_list")) > len(
current_instance.get("blocked_issues")
):
for block in requested_data.get("blocks_list"):
if (
len(
[
blocked
for blocked in current_instance.get("blocked_issues")
if blocked.get("block") == block
]
)
== 0
):
issue = Issue.objects.get(pk=block)
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value="",
new_value=f"{project.identifier}-{issue.sequence_id}",
field="blocks",
project=project,
workspace=project.workspace,
comment=f"{actor.email} added blocking issue {project.identifier}-{issue.sequence_id}",
new_identifier=issue.id,
)
)
# Blocked Issue Removal
if len(requested_data.get("blocks_list")) < len(
current_instance.get("blocked_issues")
):
for blocked in current_instance.get("blocked_issues"):
if blocked.get("block") not in requested_data.get("blocks_list"):
issue = Issue.objects.get(pk=blocked.get("block"))
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value=f"{project.identifier}-{issue.sequence_id}",
new_value="",
field="blocks",
project=project,
workspace=project.workspace,
comment=f"{actor.email} removed blocking issue {project.identifier}-{issue.sequence_id}",
old_identifier=issue.id,
)
)
# Track changes in blocked_by issues
def track_blockings(
requested_data,
current_instance,
issue_id,
project,
actor,
issue_activities,
):
if len(requested_data.get("blockers_list")) > len(
current_instance.get("blocker_issues")
):
for block in requested_data.get("blockers_list"):
if (
len(
[
blocked
for blocked in current_instance.get("blocker_issues")
if blocked.get("blocked_by") == block
]
)
== 0
):
issue = Issue.objects.get(pk=block)
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value="",
new_value=f"{project.identifier}-{issue.sequence_id}",
field="blocking",
project=project,
workspace=project.workspace,
comment=f"{actor.email} added blocked by issue {project.identifier}-{issue.sequence_id}",
new_identifier=issue.id,
)
)
# Blocked Issue Removal
if len(requested_data.get("blockers_list")) < len(
current_instance.get("blocker_issues")
):
for blocked in current_instance.get("blocker_issues"):
if blocked.get("blocked_by") not in requested_data.get("blockers_list"):
issue = Issue.objects.get(pk=blocked.get("blocked_by"))
issue_activities.append(
IssueActivity(
issue_id=issue_id,
actor=actor,
verb="updated",
old_value=f"{project.identifier}-{issue.sequence_id}",
new_value="",
field="blocking",
project=project,
workspace=project.workspace,
comment=f"{actor.email} removed blocked by issue {project.identifier}-{issue.sequence_id}",
old_identifier=issue.id,
)
)
# Receive message from room group
@job("default")
def issue_activity(event):
try:
issue_activities = []
requested_data = json.loads(event.get("requested_data"))
current_instance = json.loads(event.get("current_instance"))
issue_id = event.get("issue_id")
actor_id = event.get("actor_id")
project_id = event.get("project_id")
actor = User.objects.get(pk=actor_id)
project = Project.objects.get(pk=project_id)
ISSUE_ACTIVITY_MAPPER = {
"name": track_name,
"parent": track_parent,
"priority": track_priority,
"state": track_state,
"description": track_description,
"target_date": track_target_date,
"start_date": track_start_date,
"labels_list": track_labels,
"assignees_list": track_assignees,
"blocks_list": track_blocks,
"blockers_list": track_blockings,
}
for key in requested_data:
func = ISSUE_ACTIVITY_MAPPER.get(key, None)
if func is not None:
func(
requested_data,
current_instance,
issue_id,
project,
actor,
issue_activities,
)
# Save all the values to database
_ = IssueActivity.objects.bulk_create(issue_activities)
return
except Exception as e:
capture_exception(e)
return

View File

@ -1,52 +1,5 @@
from django.apps import AppConfig
from fieldsignals import post_save_changed
class DbConfig(AppConfig):
name = "plane.db"
# def ready(self):
# post_save_changed.connect(
# self.model_activity,
# sender=self.get_model("Issue"),
# )
# def model_activity(self, sender, instance, changed_fields, **kwargs):
# verb = "created" if instance._state.adding else "changed"
# import inspect
# for frame_record in inspect.stack():
# if frame_record[3] == "get_response":
# request = frame_record[0].f_locals["request"]
# REQUEST_METHOD = request.method
# if REQUEST_METHOD == "POST":
# self.get_model("IssueActivity").objects.create(
# issue=instance, project=instance.project, actor=instance.created_by
# )
# elif REQUEST_METHOD == "PATCH":
# try:
# del changed_fields["updated_at"]
# del changed_fields["updated_by"]
# except KeyError as e:
# pass
# for field_name, (old, new) in changed_fields.items():
# field = field_name
# old_value = old
# new_value = new
# self.get_model("IssueActivity").objects.create(
# issue=instance,
# verb=verb,
# field=field,
# old_value=old_value,
# new_value=new_value,
# project=instance.project,
# actor=instance.updated_by,
# )

View File

@ -0,0 +1,57 @@
# Generated by Django 3.2.16 on 2023-01-29 19:49
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import plane.db.models.api_token
import uuid
class Migration(migrations.Migration):
dependencies = [
('db', '0017_alter_workspace_unique_together'),
]
operations = [
migrations.AddField(
model_name='user',
name='is_bot',
field=models.BooleanField(default=False),
),
migrations.AlterField(
model_name='issue',
name='description',
field=models.JSONField(blank=True, null=True),
),
migrations.AlterField(
model_name='issue',
name='description_html',
field=models.TextField(blank=True, null=True),
),
migrations.AlterField(
model_name='issue',
name='description_stripped',
field=models.TextField(blank=True, null=True),
),
migrations.CreateModel(
name='APIToken',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')),
('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
('token', models.CharField(default=plane.db.models.api_token.generate_token, max_length=255, unique=True)),
('label', models.CharField(default=plane.db.models.api_token.generate_label_token, max_length=255)),
('user_type', models.PositiveSmallIntegerField(choices=[(0, 'Human'), (1, 'Bot')], default=0)),
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='apitoken_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='apitoken_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bot_tokens', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'API Token',
'verbose_name_plural': 'API Tokems',
'db_table': 'api_tokens',
'ordering': ('-created_at',),
},
),
]

View File

@ -0,0 +1,23 @@
# Generated by Django 3.2.16 on 2023-01-30 19:19
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('db', '0018_auto_20230130_0119'),
]
operations = [
migrations.AlterField(
model_name='issueactivity',
name='new_value',
field=models.TextField(blank=True, null=True, verbose_name='New Value'),
),
migrations.AlterField(
model_name='issueactivity',
name='old_value',
field=models.TextField(blank=True, null=True, verbose_name='Old Value'),
),
]

View File

@ -38,3 +38,5 @@ from .shortcut import Shortcut
from .view import View
from .module import Module, ModuleMember, ModuleIssue, ModuleLink
from .api_token import APIToken

View File

@ -0,0 +1,39 @@
# Python imports
from uuid import uuid4
# Django imports
from django.db import models
from django.conf import settings
from .base import BaseModel
def generate_label_token():
return uuid4().hex
def generate_token():
return uuid4().hex + uuid4().hex
class APIToken(BaseModel):
token = models.CharField(max_length=255, unique=True, default=generate_token)
label = models.CharField(max_length=255, default=generate_label_token)
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="bot_tokens",
)
user_type = models.PositiveSmallIntegerField(
choices=((0, "Human"), (1, "Bot")), default=0
)
class Meta:
verbose_name = "API Token"
verbose_name_plural = "API Tokems"
db_table = "api_tokens"
ordering = ("-created_at",)
def __str__(self):
return str(self.user.name)

View File

@ -32,9 +32,9 @@ class Issue(ProjectBaseModel):
related_name="state_issue",
)
name = models.CharField(max_length=255, verbose_name="Issue Name")
description = models.JSONField(blank=True)
description_html = models.TextField(blank=True)
description_stripped = models.TextField(blank=True)
description = models.JSONField(blank=True, null=True)
description_html = models.TextField(blank=True, null=True)
description_stripped = models.TextField(blank=True, null=True)
priority = models.CharField(
max_length=30,
choices=PRIORITY_CHOICES,
@ -84,10 +84,12 @@ class Issue(ProjectBaseModel):
)
except ImportError:
pass
# Strip the html tags using html parser
self.description_stripped = (
strip_tags(self.description_html) if self.description_html != "" else ""
None
if (self.description_html == "" or self.description_html is None)
else strip_tags(self.description_html)
)
super(Issue, self).save(*args, **kwargs)
@ -143,12 +145,8 @@ class IssueActivity(ProjectBaseModel):
field = models.CharField(
max_length=255, verbose_name="Field Name", blank=True, null=True
)
old_value = models.CharField(
max_length=255, verbose_name="Old Value", blank=True, null=True
)
new_value = models.CharField(
max_length=255, verbose_name="New Value", blank=True, null=True
)
old_value = models.TextField(verbose_name="Old Value", blank=True, null=True)
new_value = models.TextField(verbose_name="New Value", blank=True, null=True)
comment = models.TextField(verbose_name="Comment", blank=True)
attachments = ArrayField(models.URLField(), size=10, blank=True, default=list)
@ -211,10 +209,11 @@ class IssueComment(ProjectBaseModel):
)
def save(self, *args, **kwargs):
self.comment_stripped = strip_tags(self.comment_html) if self.comment_html != "" else ""
self.comment_stripped = (
strip_tags(self.comment_html) if self.comment_html != "" else ""
)
return super(IssueComment, self).save(*args, **kwargs)
class Meta:
verbose_name = "Issue Comment"
verbose_name_plural = "Issue Comments"

View File

@ -68,6 +68,7 @@ class User(AbstractBaseUser, PermissionsMixin):
last_workspace_id = models.UUIDField(null=True)
my_issues_prop = models.JSONField(null=True)
role = models.CharField(max_length=300, null=True, blank=True)
is_bot = models.BooleanField(default=False)
USERNAME_FIELD = "email"
@ -101,7 +102,7 @@ class User(AbstractBaseUser, PermissionsMixin):
@receiver(post_save, sender=User)
def send_welcome_email(sender, instance, created, **kwargs):
try:
if created:
if created and not instance.is_bot:
first_name = instance.first_name.capitalize()
to_email = instance.email
from_email_string = f"Team Plane <team@mailer.plane.so>"

View File

@ -34,9 +34,7 @@ INSTALLED_APPS = [
"rest_framework_simplejwt.token_blacklist",
"corsheaders",
"taggit",
"fieldsignals",
"django_rq",
"channels",
]
MIDDLEWARE = [

View File

@ -66,11 +66,3 @@ RQ_QUEUES = {
WEB_URL = "http://localhost:3000"
CHANNEL_LAYERS = {
"default": {
"BACKEND": "channels_redis.core.RedisChannelLayer",
"CONFIG": {
"hosts": [(REDIS_HOST, REDIS_PORT)],
},
},
}

View File

@ -1,11 +1,8 @@
"""Production settings and globals."""
import ssl
from typing import Optional
from urllib.parse import urlparse
import dj_database_url
from urllib.parse import urlparse
from redis.asyncio.connection import Connection, RedisSSLContext
import sentry_sdk
from sentry_sdk.integrations.django import DjangoIntegration
@ -186,64 +183,10 @@ RQ_QUEUES = {
}
class CustomSSLConnection(Connection):
def __init__(
self,
ssl_context: Optional[str] = None,
**kwargs,
):
super().__init__(**kwargs)
self.ssl_context = RedisSSLContext(ssl_context)
class RedisSSLContext:
__slots__ = ("context",)
def __init__(
self,
ssl_context,
):
self.context = ssl_context
def get(self):
return self.context
url = urlparse(os.environ.get("REDIS_URL"))
DOCKERIZED = os.environ.get("DOCKERIZED", False) # Set the variable true if running in docker-compose environment
if not DOCKERIZED:
ssl_context = ssl.SSLContext()
ssl_context.check_hostname = False
CHANNEL_LAYERS = {
"default": {
"BACKEND": "channels_redis.core.RedisChannelLayer",
"CONFIG": {
"hosts": [
{
"host": url.hostname,
"port": url.port,
"username": url.username,
"password": url.password,
"connection_class": CustomSSLConnection,
"ssl_context": ssl_context,
}
],
},
},
}
else:
CHANNEL_LAYERS = {
"default": {
"BACKEND": "channels_redis.core.RedisChannelLayer",
"CONFIG": {
"hosts": [(os.environ.get("REDIS_URL"))],
},
},
}
DOCKERIZED = os.environ.get(
"DOCKERIZED", False
) # Set the variable true if running in docker-compose environment
WEB_URL = os.environ.get("WEB_URL")

View File

@ -1,11 +1,8 @@
"""Production settings and globals."""
import ssl
from typing import Optional
from urllib.parse import urlparse
import dj_database_url
from urllib.parse import urlparse
from redis.asyncio.connection import Connection, RedisSSLContext
import sentry_sdk
from sentry_sdk.integrations.django import DjangoIntegration
@ -14,7 +11,7 @@ from sentry_sdk.integrations.redis import RedisIntegration
from .common import * # noqa
# Database
DEBUG = False
DEBUG = True
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql_psycopg2",
@ -186,52 +183,5 @@ RQ_QUEUES = {
}
}
class CustomSSLConnection(Connection):
def __init__(
self,
ssl_context: Optional[str] = None,
**kwargs,
):
super().__init__(**kwargs)
self.ssl_context = RedisSSLContext(ssl_context)
class RedisSSLContext:
__slots__ = (
"context",
)
def __init__(
self,
ssl_context,
):
self.context = ssl_context
def get(self):
return self.context
url = urlparse(os.environ.get("REDIS_URL"))
ssl_context = ssl.SSLContext()
ssl_context.check_hostname = False
CHANNEL_LAYERS = {
'default': {
'BACKEND': 'channels_redis.core.RedisChannelLayer',
'CONFIG': {
'hosts': [
{
'host': url.hostname,
'port': url.port,
'username': url.username,
'password': url.password,
'connection_class': CustomSSLConnection,
'ssl_context': ssl_context,
}
],
}
},
}
WEB_URL = os.environ.get("WEB_URL")

View File

@ -6,7 +6,7 @@ django-taggit==2.1.0
psycopg2==2.9.3
django-oauth-toolkit==2.0.0
mistune==2.0.3
djangorestframework==3.13.1
djangorestframework==3.14.0
redis==4.2.2
django-nested-admin==3.4.0
django-cors-headers==3.11.0
@ -16,16 +16,13 @@ faker==13.4.0
django-filter==21.1
jsonmodels==2.5.0
djangorestframework-simplejwt==5.1.0
sentry-sdk==1.5.12
sentry-sdk==1.13.0
django-s3-storage==0.13.6
django-crum==0.7.9
django-guardian==2.4.0
django-fieldsignals==0.7.0
dj_rest_auth==2.2.5
google-auth==2.9.1
google-api-python-client==2.55.0
django-rq==2.5.1
django-redis==5.2.0
channels==4.0.0
channels-redis==4.0.0
uvicorn==0.20.0

1
apps/app/.eslintrc.js Normal file
View File

@ -0,0 +1 @@
module.exports = require("config/.eslintrc");

View File

@ -14,7 +14,7 @@ ENV PATH="${PATH}:./pnpm"
COPY ./apps ./apps
COPY ./package.json ./package.json
COPY ./.eslintrc.json ./.eslintrc.json
COPY ./.eslintrc.js ./.eslintrc.js
COPY ./turbo.json ./turbo.json
COPY ./pnpm-workspace.yaml ./pnpm-workspace.yaml
COPY ./pnpm-lock.yaml ./pnpm-lock.yaml

View File

@ -1,28 +1,30 @@
import React, { useState } from "react";
// react hook form
import { useForm } from "react-hook-form";
// ui
import { Button, Input } from "ui";
import authenticationService from "lib/services/authentication.service";
// icons
import { CheckCircleIcon } from "@heroicons/react/20/solid";
import { Button, Input } from "components/ui";
// services
import authenticationService from "services/authentication.service";
import useToast from "hooks/use-toast";
// icons
// types
type SignIn = {
type EmailCodeFormValues = {
email: string;
key?: string;
token?: string;
};
const EmailCodeForm = ({ onSuccess }: any) => {
export const EmailCodeForm = ({ onSuccess }: any) => {
const [codeSent, setCodeSent] = useState(false);
const { setToastAlert } = useToast();
const {
register,
handleSubmit,
setError,
setValue,
formState: { errors, isSubmitting, dirtyFields, isValid, isDirty },
} = useForm<SignIn>({
formState: { errors, isSubmitting, isValid, isDirty },
} = useForm<EmailCodeFormValues>({
defaultValues: {
email: "",
key: "",
@ -32,9 +34,8 @@ const EmailCodeForm = ({ onSuccess }: any) => {
reValidateMode: "onChange",
});
const onSubmit = ({ email }: SignIn) => {
const onSubmit = ({ email }: EmailCodeFormValues) => {
console.log(email);
authenticationService
.emailCode({ email })
.then((res) => {
@ -46,15 +47,20 @@ const EmailCodeForm = ({ onSuccess }: any) => {
});
};
const handleSignin = (formData: SignIn) => {
const handleSignin = (formData: EmailCodeFormValues) => {
authenticationService
.magicSignIn(formData)
.then(async (response) => {
await onSuccess(response);
.then((response) => {
onSuccess(response);
})
.catch((error) => {
console.log(error);
setError("token" as keyof SignIn, {
setToastAlert({
title: "Oops!",
type: "error",
message: "Enter the correct code to sign in",
});
setError("token" as keyof EmailCodeFormValues, {
type: "manual",
message: error.error,
});
@ -127,5 +133,3 @@ const EmailCodeForm = ({ onSuccess }: any) => {
</>
);
};
export default EmailCodeForm;

View File

@ -1,29 +1,28 @@
import React from "react";
// next
import Link from "next/link";
import { useRouter } from "next/router";
// react hook form
import { useForm } from "react-hook-form";
// ui
import { Button, Input } from "ui";
import authenticationService from "lib/services/authentication.service";
import { Button, Input } from "components/ui";
import authenticationService from "services/authentication.service";
import useToast from "hooks/use-toast";
// types
type SignIn = {
type EmailPasswordFormValues = {
email: string;
password?: string;
medium?: string;
};
const EmailPasswordForm = ({ onSuccess }: any) => {
export const EmailPasswordForm = ({ onSuccess }: any) => {
const { setToastAlert } = useToast();
const {
register,
handleSubmit,
setError,
setValue,
getValues,
formState: { errors, isSubmitting, dirtyFields, isValid, isDirty },
} = useForm<SignIn>({
formState: { errors, isSubmitting, isValid, isDirty },
} = useForm<EmailPasswordFormValues>({
defaultValues: {
email: "",
password: "",
@ -33,19 +32,24 @@ const EmailPasswordForm = ({ onSuccess }: any) => {
reValidateMode: "onChange",
});
const onSubmit = (formData: SignIn) => {
const onSubmit = (formData: EmailPasswordFormValues) => {
authenticationService
.emailLogin(formData)
.then(async (response) => {
await onSuccess(response);
.then((response) => {
onSuccess(response);
})
.catch((error) => {
console.log(error);
setToastAlert({
title: "Oops!",
type: "error",
message: "Enter the correct email address and password to sign in",
});
if (!error?.response?.data) return;
Object.keys(error.response.data).forEach((key) => {
const err = error.response.data[key];
console.log("err", err);
setError(key as keyof SignIn, {
setError(key as keyof EmailPasswordFormValues, {
type: "manual",
message: Array.isArray(err) ? err.join(", ") : err,
});
@ -85,8 +89,8 @@ const EmailPasswordForm = ({ onSuccess }: any) => {
placeholder="Enter your password"
/>
</div>
<div className="flex items-center justify-between mt-2">
<div className="text-sm ml-auto">
<div className="mt-2 flex items-center justify-between">
<div className="ml-auto text-sm">
<Link href={"/forgot-password"}>
<a className="font-medium text-theme hover:text-indigo-500">Forgot your password?</a>
</Link>
@ -105,5 +109,3 @@ const EmailPasswordForm = ({ onSuccess }: any) => {
</>
);
};
export default EmailPasswordForm;

View File

@ -0,0 +1,46 @@
import { useState, FC } from "react";
import { KeyIcon } from "@heroicons/react/24/outline";
// components
import { EmailCodeForm, EmailPasswordForm } from "components/account";
export interface EmailSignInFormProps {
handleSuccess: () => void;
}
export const EmailSignInForm: FC<EmailSignInFormProps> = (props) => {
const { handleSuccess } = props;
// states
const [useCode, setUseCode] = useState(true);
return (
<>
{useCode ? (
<EmailCodeForm onSuccess={handleSuccess} />
) : (
<EmailPasswordForm onSuccess={handleSuccess} />
)}
<div className="mt-6">
<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-300" />
</div>
<div className="relative flex justify-center text-sm">
<span className="bg-white px-2 text-gray-500">or</span>
</div>
</div>
{/* <div className="mt-6 flex w-full flex-col items-stretch gap-y-2">
<button
type="button"
className="flex w-full items-center rounded border border-gray-300 px-3 py-2 text-sm duration-300 hover:bg-gray-100"
onClick={() => setUseCode((prev) => !prev)}
>
<KeyIcon className="h-[25px] w-[25px]" />
<span className="w-full text-center font-medium">
{useCode ? "Continue with Password" : "Continue with Code"}
</span>
</button>
</div> */}
</div>
</>
);
};

View File

@ -0,0 +1,51 @@
import { useEffect, useState, FC } from "react";
import Link from "next/link";
import Image from "next/image";
import { useRouter } from "next/router";
// images
import githubImage from "/public/logos/github.png";
const { NEXT_PUBLIC_GITHUB_ID } = process.env;
export interface GithubLoginButtonProps {
handleSignIn: React.Dispatch<string>;
}
export const GithubLoginButton: FC<GithubLoginButtonProps> = (props) => {
const { handleSignIn } = props;
// router
const {
query: { code },
} = useRouter();
// states
const [loginCallBackURL, setLoginCallBackURL] = useState(undefined);
useEffect(() => {
if (code) {
handleSignIn(code.toString());
}
}, [code, handleSignIn]);
useEffect(() => {
const origin =
typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
setLoginCallBackURL(`${origin}/signin` as any);
}, []);
return (
<Link
href={`https://github.com/login/oauth/authorize?client_id=${NEXT_PUBLIC_GITHUB_ID}&redirect_uri=${loginCallBackURL}`}
>
<button className="flex w-full items-center rounded bg-black px-3 py-2 text-sm text-white opacity-90 duration-300 hover:opacity-100">
<Image
src={githubImage}
height={25}
width={25}
className="flex-shrink-0"
alt="GitHub Logo"
/>
<span className="w-full text-center font-medium">Continue with GitHub</span>
</button>
</Link>
);
};

View File

@ -4,12 +4,13 @@ import Script from "next/script";
export interface IGoogleLoginButton {
text?: string;
onSuccess?: (res: any) => void;
onFailure?: (res: any) => void;
handleSignIn: React.Dispatch<any>;
styles?: CSSProperties;
}
export const GoogleLoginButton: FC<IGoogleLoginButton> = (props) => {
const { handleSignIn } = props;
const googleSignInButton = useRef<HTMLDivElement>(null);
const [gsiScriptLoaded, setGsiScriptLoaded] = useState(false);
@ -17,7 +18,7 @@ export const GoogleLoginButton: FC<IGoogleLoginButton> = (props) => {
if (!googleSignInButton.current || gsiScriptLoaded) return;
window?.google?.accounts.id.initialize({
client_id: process.env.NEXT_PUBLIC_GOOGLE_CLIENTID || "",
callback: props.onSuccess as any,
callback: handleSignIn,
});
window?.google?.accounts.id.renderButton(
googleSignInButton.current,
@ -32,7 +33,7 @@ export const GoogleLoginButton: FC<IGoogleLoginButton> = (props) => {
);
window?.google?.accounts.id.prompt(); // also display the One Tap dialog
setGsiScriptLoaded(true);
}, [props.onSuccess, gsiScriptLoaded]);
}, [handleSignIn, gsiScriptLoaded]);
useEffect(() => {
if (window?.google?.accounts?.id) {
@ -46,7 +47,7 @@ export const GoogleLoginButton: FC<IGoogleLoginButton> = (props) => {
return (
<>
<Script src="https://accounts.google.com/gsi/client" async defer onLoad={loadScript} />
<div className="w-full" id="googleSignInButton" ref={googleSignInButton}></div>
<div className="w-full" id="googleSignInButton" ref={googleSignInButton} />
</>
);
};

View File

@ -0,0 +1,5 @@
export * from "./google-login";
export * from "./email-code-form";
export * from "./email-password-form";
export * from "./github-login-button";
export * from "./email-signin-form";

View File

@ -1,6 +1,6 @@
import * as React from "react";
import { useRouter } from "next/router";
import Link from "next/link";
// icons
import { ArrowLeftIcon } from "@heroicons/react/24/outline";
@ -32,29 +32,27 @@ type BreadcrumbItemProps = {
icon?: any;
};
const BreadcrumbItem: React.FC<BreadcrumbItemProps> = ({ title, link, icon }) => {
return (
<>
{link ? (
<Link href={link}>
<a className="border-r-2 border-gray-300 px-3 text-sm">
<p className={`${icon ? "flex items-center gap-2" : ""}`}>
{icon ?? null}
{title}
</p>
</a>
</Link>
) : (
<div className="px-3 text-sm">
const BreadcrumbItem: React.FC<BreadcrumbItemProps> = ({ title, link, icon }) => (
<>
{link ? (
<Link href={link}>
<a className="border-r-2 border-gray-300 px-3 text-sm">
<p className={`${icon ? "flex items-center gap-2" : ""}`}>
{icon}
{icon ?? null}
{title}
</p>
</div>
)}
</>
);
};
</a>
</Link>
) : (
<div className="px-3 text-sm">
<p className={`${icon ? "flex items-center gap-2" : ""}`}>
{icon}
{title}
</p>
</div>
)}
</>
);
Breadcrumbs.BreadcrumbItem = BreadcrumbItem;

View File

@ -1,40 +1,39 @@
// TODO: Refactor this component: into a different file, use this file to export the components
import React, { useState, useCallback, useEffect } from "react";
// next
import { useRouter } from "next/router";
// swr
import useSWR from "swr";
// hooks
import useTheme from "lib/hooks/useTheme";
import useToast from "lib/hooks/useToast";
import useUser from "lib/hooks/useUser";
// services
import userService from "lib/services/user.service";
// components
import ShortcutsModal from "components/command-palette/shortcuts";
import { CreateProjectModal } from "components/project";
import CreateUpdateIssuesModal from "components/project/issues/create-update-issue-modal";
import CreateUpdateCycleModal from "components/project/cycles/create-update-cycle-modal";
import CreateUpdateModuleModal from "components/project/modules/create-update-module-modal";
import BulkDeleteIssuesModal from "components/common/bulk-delete-issues-modal";
// headless ui
import { Combobox, Dialog, Transition } from "@headlessui/react";
// constants
import { USER_ISSUE } from "constants/fetch-keys";
// ui
import { Button } from "ui";
// icons
import {
FolderIcon,
RectangleStackIcon,
ClipboardDocumentListIcon,
MagnifyingGlassIcon,
} from "@heroicons/react/24/outline";
import useTheme from "hooks/use-theme";
import useToast from "hooks/use-toast";
import useUser from "hooks/use-user";
// services
import userService from "services/user.service";
// components
import ShortcutsModal from "components/command-palette/shortcuts";
import { CreateProjectModal } from "components/project";
import { CreateUpdateIssueModal } from "components/issues/modal";
import CreateUpdateCycleModal from "components/project/cycles/create-update-cycle-modal";
import CreateUpdateModuleModal from "components/project/modules/create-update-module-modal";
import BulkDeleteIssuesModal from "components/common/bulk-delete-issues-modal";
// headless ui
// helpers
import { copyTextToClipboard } from "helpers/string.helper";
// types
import { IIssue } from "types";
// common
import { classNames, copyTextToClipboard } from "constants/common";
// ui
import { Button } from "components/ui";
// icons
// fetch-keys
import { USER_ISSUE } from "constants/fetch-keys";
const CommandPalette: React.FC = () => {
const [query, setQuery] = useState("");
@ -98,49 +97,56 @@ const CommandPalette: React.FC = () => {
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === "/") {
e.preventDefault();
setIsPaletteOpen(true);
} else if ((e.ctrlKey || e.metaKey) && e.key === "i") {
e.preventDefault();
setIsIssueModalOpen(true);
} else if ((e.ctrlKey || e.metaKey) && e.key === "p") {
e.preventDefault();
setIsProjectModalOpen(true);
} else if ((e.ctrlKey || e.metaKey) && e.key === "b") {
e.preventDefault();
toggleCollapsed();
} else if ((e.ctrlKey || e.metaKey) && e.key === "h") {
e.preventDefault();
setIsShortcutsModalOpen(true);
} else if ((e.ctrlKey || e.metaKey) && e.key === "q") {
e.preventDefault();
setIsCreateCycleModalOpen(true);
} else if ((e.ctrlKey || e.metaKey) && e.key === "m") {
e.preventDefault();
setIsCreateModuleModalOpen(true);
} else if ((e.ctrlKey || e.metaKey) && e.key === "d") {
e.preventDefault();
setIsBulkDeleteIssuesModalOpen(true);
} else if ((e.ctrlKey || e.metaKey) && e.altKey && e.key === "c") {
e.preventDefault();
if (
!(e.target instanceof HTMLTextAreaElement) &&
!(e.target instanceof HTMLInputElement) &&
!(e.target as Element).classList?.contains("remirror-editor")
) {
if ((e.ctrlKey || e.metaKey) && e.key === "k") {
e.preventDefault();
setIsPaletteOpen(true);
} else if (e.ctrlKey && e.key === "c") {
console.log("Text copied");
} else if (e.key === "c") {
e.preventDefault();
setIsIssueModalOpen(true);
} else if (e.key === "p") {
e.preventDefault();
setIsProjectModalOpen(true);
} else if ((e.ctrlKey || e.metaKey) && e.key === "b") {
e.preventDefault();
toggleCollapsed();
} else if (e.key === "h") {
e.preventDefault();
setIsShortcutsModalOpen(true);
} else if (e.key === "q") {
e.preventDefault();
setIsCreateCycleModalOpen(true);
} else if (e.key === "m") {
e.preventDefault();
setIsCreateModuleModalOpen(true);
} else if (e.key === "Delete") {
e.preventDefault();
setIsBulkDeleteIssuesModalOpen(true);
} else if ((e.ctrlKey || e.metaKey) && e.altKey && e.key === "c") {
e.preventDefault();
if (!router.query.issueId) return;
if (!router.query.issueId) return;
const url = new URL(window.location.href);
copyTextToClipboard(url.href)
.then(() => {
setToastAlert({
type: "success",
title: "Copied to clipboard",
const url = new URL(window.location.href);
copyTextToClipboard(url.href)
.then(() => {
setToastAlert({
type: "success",
title: "Copied to clipboard",
});
})
.catch(() => {
setToastAlert({
type: "error",
title: "Some error occurred",
});
});
})
.catch(() => {
setToastAlert({
type: "error",
title: "Some error occurred",
});
});
}
}
},
[toggleCollapsed, setToastAlert, router]
@ -173,10 +179,9 @@ const CommandPalette: React.FC = () => {
/>
</>
)}
<CreateUpdateIssuesModal
<CreateUpdateIssueModal
isOpen={isIssueModalOpen}
setIsOpen={setIsIssueModalOpen}
projectId={projectId as string}
handleClose={() => setIsIssueModalOpen(false)}
/>
<BulkDeleteIssuesModal
isOpen={isBulkDeleteIssuesModalOpen}
@ -188,7 +193,7 @@ const CommandPalette: React.FC = () => {
afterLeave={() => setQuery("")}
appear
>
<Dialog as="div" className="relative z-10" onClose={handleCommandPaletteClose}>
<Dialog as="div" className="relative z-20" onClose={handleCommandPaletteClose}>
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
@ -201,7 +206,7 @@ const CommandPalette: React.FC = () => {
<div className="fixed inset-0 bg-gray-500 bg-opacity-25 transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-10 overflow-y-auto p-4 sm:p-6 md:p-20">
<div className="fixed inset-0 z-20 overflow-y-auto p-4 sm:p-6 md:p-20">
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
@ -228,7 +233,7 @@ const CommandPalette: React.FC = () => {
className="h-12 w-full border-0 bg-transparent pl-11 pr-4 text-gray-900 placeholder-gray-500 outline-none focus:ring-0 sm:text-sm"
placeholder="Search..."
autoComplete="off"
onChange={(event) => setQuery(event.target.value)}
onChange={(e) => setQuery(e.target.value)}
/>
</div>
@ -255,10 +260,9 @@ const CommandPalette: React.FC = () => {
url: `/${workspaceSlug}/projects/${issue.project}/issues/${issue.id}`,
}}
className={({ active }) =>
classNames(
"flex cursor-pointer select-none items-center justify-between rounded-md px-3 py-2",
`flex cursor-pointer select-none items-center justify-between rounded-md px-3 py-2 ${
active ? "bg-gray-500 bg-opacity-5 text-gray-900" : ""
)
}`
}
>
{({ active }) => (
@ -307,19 +311,17 @@ const CommandPalette: React.FC = () => {
onClick: action.onClick,
}}
className={({ active }) =>
classNames(
"flex cursor-default select-none items-center rounded-md px-3 py-2",
`flex cursor-default select-none items-center rounded-md px-3 py-2 ${
active ? "bg-gray-500 bg-opacity-5 text-gray-900" : ""
)
}`
}
>
{({ active }) => (
<>
<action.icon
className={classNames(
"h-6 w-6 flex-none text-gray-900 text-opacity-40",
className={`h-6 w-6 flex-none text-gray-900 text-opacity-40 ${
active ? "text-opacity-100" : ""
)}
}`}
aria-hidden="true"
/>
<span className="ml-3 flex-auto truncate">{action.name}</span>

View File

@ -4,7 +4,7 @@ import { Dialog, Transition } from "@headlessui/react";
// icons
import { XMarkIcon } from "@heroicons/react/20/solid";
// ui
import { Input } from "ui";
import { Input } from "components/ui";
type Props = {
isOpen: boolean;
@ -15,7 +15,7 @@ const shortcuts = [
{
title: "Navigation",
shortcuts: [
{ keys: "ctrl,/", description: "To open navigator" },
{ keys: "ctrl,cmd,k", description: "To open navigator" },
{ keys: "↑", description: "Move up" },
{ keys: "↓", description: "Move down" },
{ keys: "←", description: "Move left" },
@ -27,14 +27,14 @@ const shortcuts = [
{
title: "Common",
shortcuts: [
{ keys: "ctrl,p", description: "To create project" },
{ keys: "ctrl,i", description: "To create issue" },
{ keys: "ctrl,q", description: "To create cycle" },
{ keys: "ctrl,m", description: "To create module" },
{ keys: "ctrl,d", description: "To bulk delete issues" },
{ keys: "ctrl,h", description: "To open shortcuts guide" },
{ keys: "p", description: "To create project" },
{ keys: "c", description: "To create issue" },
{ keys: "q", description: "To create cycle" },
{ keys: "m", description: "To create module" },
{ keys: "Delete", description: "To bulk delete issues" },
{ keys: "h", description: "To open shortcuts guide" },
{
keys: "ctrl,alt,c",
keys: "ctrl,cmd,alt,c",
description: "To copy issue url when on issue detail page.",
},
],
@ -52,7 +52,7 @@ const ShortcutsModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
return (
<Transition.Root show={isOpen} as={React.Fragment}>
<Dialog as="div" className="relative z-10" onClose={setIsOpen}>
<Dialog as="div" className="relative z-20" onClose={setIsOpen}>
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
@ -65,7 +65,7 @@ const ShortcutsModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-10 overflow-y-auto">
<div className="fixed inset-0 z-20 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={React.Fragment}
@ -79,10 +79,10 @@ const ShortcutsModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-white text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg">
<div className="bg-white p-5">
<div className="sm:flex sm:items-start">
<div className="flex flex-col gap-y-4 text-center sm:text-left w-full">
<div className="flex w-full flex-col gap-y-4 text-center sm:text-left">
<Dialog.Title
as="h3"
className="text-lg font-medium leading-6 text-gray-900 flex justify-between"
className="flex justify-between text-lg font-medium leading-6 text-gray-900"
>
<span>Keyboard Shortcuts</span>
<span>
@ -103,11 +103,11 @@ const ShortcutsModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
onChange={(e) => setQuery(e.target.value)}
/>
</div>
<div className="flex flex-col gap-y-3 w-full">
<div className="flex w-full flex-col gap-y-3">
{filteredShortcuts.length > 0 ? (
filteredShortcuts.map(({ title, shortcuts }) => (
<div key={title} className="w-full flex flex-col">
<p className="font-medium mb-4">{title}</p>
<div key={title} className="flex w-full flex-col">
<p className="mb-4 font-medium">{title}</p>
<div className="flex flex-col gap-y-3">
{shortcuts.map(({ keys, description }, index) => (
<div key={index} className="flex justify-between">
@ -115,7 +115,7 @@ const ShortcutsModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
<div className="flex items-center gap-x-1">
{keys.split(",").map((key, index) => (
<span key={index} className="flex items-center gap-1">
<kbd className="bg-gray-200 text-sm px-1 rounded">
<kbd className="rounded bg-gray-200 px-1 text-sm">
{key}
</kbd>
</span>

View File

@ -0,0 +1,109 @@
import React from "react";
// react-beautiful-dnd
import { DraggableProvided } from "react-beautiful-dnd";
// icons
import {
ArrowsPointingInIcon,
ArrowsPointingOutIcon,
EllipsisHorizontalIcon,
PlusIcon,
} from "@heroicons/react/24/outline";
// helpers
import { addSpaceIfCamelCase } from "helpers/string.helper";
// types
import { IIssue, NestedKeyOf } from "types";
type Props = {
isCollapsed: boolean;
setIsCollapsed: React.Dispatch<React.SetStateAction<boolean>>;
groupedByIssues: {
[key: string]: IIssue[];
};
selectedGroup: NestedKeyOf<IIssue> | null;
groupTitle: string;
createdBy: string | null;
bgColor: string;
addIssueToState: () => void;
provided?: DraggableProvided;
};
const BoardHeader: React.FC<Props> = ({
isCollapsed,
setIsCollapsed,
provided,
groupedByIssues,
selectedGroup,
groupTitle,
createdBy,
bgColor,
addIssueToState,
}) => (
<div
className={`flex justify-between p-3 pb-0 ${
!isCollapsed ? "flex-col rounded-md border bg-gray-50" : ""
}`}
>
<div className={`flex items-center ${!isCollapsed ? "flex-col gap-2" : "gap-1"}`}>
{provided && (
<button
type="button"
{...provided.dragHandleProps}
className={`grid h-7 w-7 place-items-center rounded p-1 outline-none duration-300 hover:bg-gray-200 ${
!isCollapsed ? "" : "rotate-90"
} ${selectedGroup !== "state_detail.name" ? "hidden" : ""}`}
>
<EllipsisHorizontalIcon className="h-4 w-4 text-gray-600" />
<EllipsisHorizontalIcon className="mt-[-0.7rem] h-4 w-4 text-gray-600" />
</button>
)}
<div
className={`flex cursor-pointer items-center gap-x-1 rounded-md bg-slate-900 px-2 ${
!isCollapsed ? "mb-2 flex-col gap-y-2 py-2" : ""
}`}
style={{
border: `2px solid ${bgColor}`,
backgroundColor: `${bgColor}20`,
}}
>
<h2
className={`text-[0.9rem] font-medium capitalize`}
style={{
writingMode: !isCollapsed ? "vertical-rl" : "horizontal-tb",
}}
>
{groupTitle === null || groupTitle === "null"
? "None"
: createdBy
? createdBy
: addSpaceIfCamelCase(groupTitle)}
</h2>
<span className="ml-0.5 text-sm text-gray-500">{groupedByIssues[groupTitle].length}</span>
</div>
</div>
<div className={`flex items-center ${!isCollapsed ? "flex-col pb-2" : ""}`}>
<button
type="button"
className="grid h-7 w-7 place-items-center rounded p-1 outline-none duration-300 hover:bg-gray-200"
onClick={() => {
setIsCollapsed((prevData) => !prevData);
}}
>
{isCollapsed ? (
<ArrowsPointingInIcon className="h-4 w-4" />
) : (
<ArrowsPointingOutIcon className="h-4 w-4" />
)}
</button>
<button
type="button"
className="grid h-7 w-7 place-items-center rounded p-1 outline-none duration-300 hover:bg-gray-200"
onClick={addIssueToState}
>
<PlusIcon className="h-4 w-4" />
</button>
</div>
</div>
);
export default BoardHeader;

View File

@ -1,5 +1,3 @@
const SingleBoard = () => {
return <></>;
};
const SingleBoard = () => <></>;
export default SingleBoard;

View File

@ -1,72 +1,68 @@
import React from "react";
// next
import Link from "next/link";
import Image from "next/image";
import { useRouter } from "next/router";
// swr
import useSWR from "swr";
import useSWR, { mutate } from "swr";
// react-beautiful-dnd
import { DraggableStateSnapshot } from "react-beautiful-dnd";
// react-datepicker
import DatePicker from "react-datepicker";
import "react-datepicker/dist/react-datepicker.css";
// headless ui
import { Listbox, Transition } from "@headlessui/react";
// constants
import { PRIORITIES } from "constants/";
import { PROJECT_ISSUES_LIST, STATE_LIST } from "constants/fetch-keys";
import { getPriorityIcon } from "constants/global";
// services
import issuesService from "lib/services/issues.service";
import stateService from "lib/services/state.service";
import projectService from "lib/services/project.service";
// icons
import { TrashIcon } from "@heroicons/react/24/outline";
import { CalendarDaysIcon } from "@heroicons/react/20/solid";
import User from "public/user.png";
// services
import issuesService from "services/issues.service";
import stateService from "services/state.service";
import projectService from "services/project.service";
// components
import { AssigneesList, CustomDatePicker } from "components/ui";
// helpers
import { renderShortNumericDateFormat, findHowManyDaysLeft } from "helpers/date-time.helper";
import { addSpaceIfCamelCase } from "helpers/string.helper";
// types
import { IIssue, IssueResponse, IWorkspaceMember, Properties } from "types";
import { IIssue, IUserLite, IWorkspaceMember, Properties, UserAuth } from "types";
// common
import { PRIORITIES } from "constants/";
import {
addSpaceIfCamelCase,
classNames,
findHowManyDaysLeft,
renderShortNumericDateFormat,
} from "constants/common";
import { PROJECT_DETAILS } from "constants/fetch-keys";
STATE_LIST,
PROJECT_DETAILS,
CYCLE_ISSUES,
MODULE_ISSUES,
PROJECT_ISSUES_LIST,
} from "constants/fetch-keys";
import { getPriorityIcon } from "constants/global";
type Props = {
type?: string;
typeId?: string;
issue: IIssue;
properties: Properties;
snapshot?: DraggableStateSnapshot;
assignees: {
avatar: string | undefined;
first_name: string | undefined;
email: string | undefined;
}[];
assignees: Partial<IUserLite>[] | (Partial<IUserLite> | undefined)[];
people: IWorkspaceMember[] | undefined;
handleDeleteIssue?: React.Dispatch<React.SetStateAction<string | undefined>>;
partialUpdateIssue: (formData: Partial<IIssue>, childIssueId: string) => void;
userAuth: UserAuth;
};
const SingleBoardIssue: React.FC<Props> = ({
type,
typeId,
issue,
properties,
snapshot,
assignees,
people,
handleDeleteIssue,
partialUpdateIssue,
userAuth,
}) => {
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { data: issues } = useSWR<IssueResponse>(
workspaceSlug && projectId
? PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string)
: null,
workspaceSlug && projectId
? () => issuesService.getIssues(workspaceSlug as string, projectId as string)
: null
);
const { data: states } = useSWR(
workspaceSlug && projectId ? STATE_LIST(projectId as string) : null,
workspaceSlug && projectId
@ -81,7 +77,25 @@ const SingleBoardIssue: React.FC<Props> = ({
: null
);
const totalChildren = issues?.results.filter((i) => i.parent === issue.id).length;
const partialUpdateIssue = (formData: Partial<IIssue>) => {
if (!workspaceSlug || !projectId) return;
issuesService
.patchIssue(workspaceSlug as string, projectId as string, issue.id, formData)
.then((res) => {
if (typeId) {
mutate(CYCLE_ISSUES(typeId ?? ""));
mutate(MODULE_ISSUES(typeId ?? ""));
}
mutate(PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string));
})
.catch((error) => {
console.log(error);
});
};
const isNotAllowed = userAuth.isGuest || userAuth.isViewer;
return (
<div
@ -90,7 +104,7 @@ const SingleBoardIssue: React.FC<Props> = ({
}`}
>
<div className="group/card relative select-none p-2">
{handleDeleteIssue && (
{handleDeleteIssue && !isNotAllowed && (
<div className="absolute top-1.5 right-1.5 z-10 opacity-0 group-hover/card:opacity-100">
<button
type="button"
@ -122,15 +136,18 @@ const SingleBoardIssue: React.FC<Props> = ({
as="div"
value={issue.priority}
onChange={(data: string) => {
partialUpdateIssue({ priority: data }, issue.id);
partialUpdateIssue({ priority: data });
}}
className="group relative flex-shrink-0"
disabled={isNotAllowed}
>
{({ open }) => (
<>
<div>
<Listbox.Button
className={`grid cursor-pointer place-items-center rounded px-2 py-1 capitalize shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 ${
className={`grid ${
isNotAllowed ? "cursor-not-allowed" : "cursor-pointer"
} place-items-center rounded px-2 py-1 capitalize shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 ${
issue.priority === "urgent"
? "bg-red-100 text-red-600"
: issue.priority === "high"
@ -157,10 +174,9 @@ const SingleBoardIssue: React.FC<Props> = ({
<Listbox.Option
key={priority}
className={({ active }) =>
classNames(
active ? "bg-indigo-50" : "bg-white",
"flex cursor-pointer select-none items-center gap-2 px-3 py-2 capitalize"
)
`flex cursor-pointer select-none items-center gap-2 px-3 py-2 capitalize ${
active ? "bg-indigo-50" : "bg-white"
}`
}
value={priority}
>
@ -180,20 +196,25 @@ const SingleBoardIssue: React.FC<Props> = ({
as="div"
value={issue.state}
onChange={(data: string) => {
partialUpdateIssue({ state: data }, issue.id);
partialUpdateIssue({ state: data });
}}
className="group relative flex-shrink-0"
disabled={isNotAllowed}
>
{({ open }) => (
<>
<div>
<Listbox.Button className="flex cursor-pointer items-center gap-1 rounded border px-2 py-1 text-xs shadow-sm duration-300 hover:bg-gray-100 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500">
<Listbox.Button
className={`flex ${
isNotAllowed ? "cursor-not-allowed" : "cursor-pointer"
} items-center gap-1 rounded border px-2 py-1 text-xs shadow-sm duration-300 hover:bg-gray-100 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500`}
>
<span
className="h-1.5 w-1.5 flex-shrink-0 rounded-full"
style={{
backgroundColor: issue.state_detail.color,
}}
></span>
/>
{addSpaceIfCamelCase(issue.state_detail.name)}
</Listbox.Button>
@ -209,10 +230,9 @@ const SingleBoardIssue: React.FC<Props> = ({
<Listbox.Option
key={state.id}
className={({ active }) =>
classNames(
active ? "bg-indigo-50" : "bg-white",
"flex cursor-pointer select-none items-center gap-2 px-3 py-2"
)
`flex cursor-pointer select-none items-center gap-2 px-3 py-2 ${
active ? "bg-indigo-50" : "bg-white"
}`
}
value={state.id}
>
@ -221,24 +241,25 @@ const SingleBoardIssue: React.FC<Props> = ({
style={{
backgroundColor: state.color,
}}
></span>
/>
{addSpaceIfCamelCase(state.name)}
</Listbox.Option>
))}
</Listbox.Options>
</Transition>
</div>
{/* <div className="absolute bottom-full right-0 mb-2 z-10 hidden group-hover:block p-2 bg-white shadow-md rounded-md whitespace-nowrap">
<h5 className="font-medium mb-1">State</h5>
<div>{issue.state_detail.name}</div>
</div> */}
</>
)}
</Listbox>
)}
{/* {properties.cycle && !typeId && (
<div className="flex flex-shrink-0 items-center gap-1 rounded border px-2 py-1 text-xs shadow-sm duration-300 hover:bg-gray-100 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500">
{issue.issue_cycle ? issue.issue_cycle.cycle_detail.name : "None"}
</div>
)} */}
{properties.due_date && (
<div
className={`group flex flex-shrink-0 cursor-pointer items-center gap-1 rounded border px-2 py-1 text-xs shadow-sm duration-300 hover:bg-gray-100 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 ${
className={`group relative ${
issue.target_date === null
? ""
: issue.target_date < new Date().toISOString()
@ -246,13 +267,42 @@ const SingleBoardIssue: React.FC<Props> = ({
: findHowManyDaysLeft(issue.target_date) <= 3 && "text-orange-400"
}`}
>
<CalendarDaysIcon className="h-4 w-4" />
{issue.target_date ? renderShortNumericDateFormat(issue.target_date) : "N/A"}
<CustomDatePicker
placeholder="N/A"
value={issue?.target_date}
onChange={(val: Date) => {
partialUpdateIssue({
target_date: val
? `${val.getFullYear()}-${val.getMonth() + 1}-${val.getDate()}`
: null,
});
}}
className={issue?.target_date ? "w-[6.5rem]" : "w-[3rem] text-center"}
/>
{/* <DatePicker
placeholderText="N/A"
value={
issue?.target_date ? `${renderShortNumericDateFormat(issue.target_date)}` : "N/A"
}
selected={issue?.target_date ? new Date(issue.target_date) : null}
onChange={(val: Date) => {
partialUpdateIssue({
target_date: val
? `${val.getFullYear()}-${val.getMonth() + 1}-${val.getDate()}`
: null,
});
}}
dateFormat="dd-MM-yyyy"
className={`cursor-pointer rounded-md border px-2 py-[3px] text-xs shadow-sm duration-300 hover:bg-gray-100 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 ${
issue?.target_date ? "w-[4.5rem]" : "w-[3rem] text-center"
}`}
isClearable
/> */}
</div>
)}
{properties.sub_issue_count && (
<div className="flex flex-shrink-0 items-center gap-1 rounded border px-2 py-1 text-xs shadow-sm duration-300 hover:bg-gray-100 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500">
{totalChildren} {totalChildren === 1 ? "sub-issue" : "sub-issues"}
{issue.sub_issues_count} {issue.sub_issues_count === 1 ? "sub-issue" : "sub-issues"}
</div>
)}
{properties.assignee && (
@ -261,126 +311,86 @@ const SingleBoardIssue: React.FC<Props> = ({
value={issue.assignees}
onChange={(data: any) => {
const newData = issue.assignees ?? [];
if (newData.includes(data)) {
newData.splice(newData.indexOf(data), 1);
} else {
newData.push(data);
}
partialUpdateIssue({ assignees_list: newData }, issue.id);
if (newData.includes(data)) newData.splice(newData.indexOf(data), 1);
else newData.push(data);
partialUpdateIssue({ assignees_list: newData });
}}
className="group relative flex-shrink-0"
disabled={isNotAllowed}
>
{({ open }) => (
<>
<div>
<Listbox.Button>
<div className="flex cursor-pointer items-center gap-1 text-xs">
{assignees.length > 0 ? (
assignees.map((assignee, index: number) => (
<div
key={index}
className={`relative z-[1] h-5 w-5 rounded-full ${
index !== 0 ? "-ml-2.5" : ""
}`}
>
{assignee.avatar && assignee.avatar !== "" ? (
<div className="h-5 w-5 rounded-full border-2 border-white bg-white">
<Image
src={assignee.avatar}
height="100%"
width="100%"
className="rounded-full"
alt={assignee?.first_name}
priority={false}
loading="lazy"
/>
</div>
) : (
<div className="grid h-5 w-5 place-items-center rounded-full border-2 border-white bg-gray-700 capitalize text-white">
{assignee.first_name && assignee.first_name !== ""
? assignee.first_name.charAt(0)
: assignee?.email?.charAt(0)}
</div>
)}
</div>
))
) : (
<div className="h-5 w-5 rounded-full border-2 border-white bg-white">
<Image
src={User}
height="100%"
width="100%"
className="rounded-full"
alt="No user"
priority={false}
loading="lazy"
/>
</div>
)}
</div>
</Listbox.Button>
<Transition
show={open}
as={React.Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
<div>
<Listbox.Button>
<div
className={`flex ${
isNotAllowed ? "cursor-not-allowed" : "cursor-pointer"
} items-center gap-1 text-xs`}
>
<Listbox.Options className="absolute left-0 z-20 mt-1 max-h-28 overflow-auto rounded-md bg-white py-1 text-xs shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
{people?.map((person) => (
<Listbox.Option
key={person.id}
className={({ active }) =>
classNames(
active ? "bg-indigo-50" : "bg-white",
"cursor-pointer select-none p-2"
)
}
value={person.member.id}
<AssigneesList users={assignees} length={3} />
</div>
</Listbox.Button>
<Transition
show={open}
as={React.Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Listbox.Options className="absolute left-0 z-20 mt-1 max-h-28 overflow-auto rounded-md bg-white py-1 text-xs shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
{people?.map((person) => (
<Listbox.Option
key={person.id}
className={({ active }) =>
`cursor-pointer select-none p-2 ${active ? "bg-indigo-50" : "bg-white"}`
}
value={person.member.id}
>
<div
className={`flex items-center gap-x-1 ${
assignees.includes({
id: person.member.last_name,
first_name: person.member.first_name,
last_name: person.member.last_name,
email: person.member.email,
avatar: person.member.avatar,
})
? "font-medium"
: "font-normal"
}`}
>
<div
className={`flex items-center gap-x-1 ${
assignees.includes({
avatar: person.member.avatar,
first_name: person.member.first_name,
email: person.member.email,
})
? "font-medium"
: "font-normal"
}`}
>
{person.member.avatar && person.member.avatar !== "" ? (
<div className="relative h-4 w-4">
<Image
src={person.member.avatar}
alt="avatar"
className="rounded-full"
layout="fill"
objectFit="cover"
priority={false}
loading="lazy"
/>
</div>
) : (
<div className="grid h-4 w-4 place-items-center rounded-full bg-gray-700 capitalize text-white">
{person.member.first_name && person.member.first_name !== ""
? person.member.first_name.charAt(0)
: person.member.email.charAt(0)}
</div>
)}
<p>
{person.member.avatar && person.member.avatar !== "" ? (
<div className="relative h-4 w-4">
<Image
src={person.member.avatar}
alt="avatar"
className="rounded-full"
layout="fill"
objectFit="cover"
priority={false}
loading="lazy"
/>
</div>
) : (
<div className="grid h-4 w-4 place-items-center rounded-full bg-gray-700 capitalize text-white">
{person.member.first_name && person.member.first_name !== ""
? person.member.first_name
: person.member.email}
</p>
</div>
</Listbox.Option>
))}
</Listbox.Options>
</Transition>
</div>
</>
? person.member.first_name.charAt(0)
: person.member.email.charAt(0)}
</div>
)}
<p>
{person.member.first_name && person.member.first_name !== ""
? person.member.first_name
: person.member.email}
</p>
</div>
</Listbox.Option>
))}
</Listbox.Options>
</Transition>
</div>
)}
</Listbox>
)}

View File

@ -7,27 +7,24 @@ import useSWR, { mutate } from "swr";
// react hook form
import { SubmitHandler, useForm } from "react-hook-form";
// services
import issuesServices from "lib/services/issues.service";
import projectService from "lib/services/project.service";
// hooks
import useToast from "lib/hooks/useToast";
// headless ui
import { Combobox, Dialog, Transition } from "@headlessui/react";
// ui
import { Button } from "ui";
// icons
import { FolderIcon, MagnifyingGlassIcon } from "@heroicons/react/24/outline";
import issuesServices from "services/issues.service";
import projectService from "services/project.service";
// hooks
import useToast from "hooks/use-toast";
// headless ui
// ui
import { Button } from "components/ui";
// icons
import { LayerDiagonalIcon } from "components/icons";
// types
import { IIssue, IssueResponse } from "types";
// fetch keys
import { PROJECT_ISSUES_LIST, PROJECT_DETAILS } from "constants/fetch-keys";
// common
import { classNames } from "constants/common";
import { LayerDiagonalIcon } from "ui/icons";
type FormInput = {
issue_ids: string[];
cycleId: string;
delete_issue_ids: string[];
};
type Props = {
@ -62,13 +59,28 @@ const BulkDeleteIssuesModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
const { setToastAlert } = useToast();
const { register, handleSubmit, reset } = useForm<FormInput>();
const {
handleSubmit,
watch,
reset,
setValue,
formState: { isSubmitting },
} = useForm<FormInput>({
defaultValues: {
delete_issue_ids: [],
},
});
const filteredIssues: IIssue[] =
query === ""
? issues?.results ?? []
: issues?.results.filter((issue) => issue.name.toLowerCase().includes(query.toLowerCase())) ??
[];
: issues?.results.filter(
(issue) =>
issue.name.toLowerCase().includes(query.toLowerCase()) ||
`${issue.project_detail.identifier}-${issue.sequence_id}`
.toLowerCase()
.includes(query.toLowerCase())
) ?? [];
const handleClose = () => {
setIsOpen(false);
@ -77,7 +89,7 @@ const BulkDeleteIssuesModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
};
const handleDelete: SubmitHandler<FormInput> = async (data) => {
if (!data.issue_ids || data.issue_ids.length === 0) {
if (!data.delete_issue_ids || data.delete_issue_ids.length === 0) {
setToastAlert({
title: "Error",
type: "error",
@ -86,32 +98,34 @@ const BulkDeleteIssuesModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
return;
}
if (!Array.isArray(data.issue_ids)) data.issue_ids = [data.issue_ids];
if (!Array.isArray(data.delete_issue_ids)) data.delete_issue_ids = [data.delete_issue_ids];
if (workspaceSlug && projectId) {
await issuesServices
.bulkDeleteIssues(workspaceSlug as string, projectId as string, data)
.bulkDeleteIssues(workspaceSlug as string, projectId as string, {
issue_ids: data.delete_issue_ids,
})
.then((res) => {
setToastAlert({
title: "Success",
type: "success",
message: res.message,
});
mutate<IssueResponse>(
PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string),
(prevData) => {
return {
...(prevData as IssueResponse),
count: (prevData?.results ?? []).filter(
(p) => !data.issue_ids.some((id) => p.id === id)
).length,
results: (prevData?.results ?? []).filter(
(p) => !data.issue_ids.some((id) => p.id === id)
),
};
},
(prevData) => ({
...(prevData as IssueResponse),
count: (prevData?.results ?? []).filter(
(p) => !data.delete_issue_ids.some((id) => p.id === id)
).length,
results: (prevData?.results ?? []).filter(
(p) => !data.delete_issue_ids.some((id) => p.id === id)
),
}),
false
);
handleClose();
})
.catch((e) => {
console.log(e);
@ -120,140 +134,126 @@ const BulkDeleteIssuesModal: React.FC<Props> = ({ isOpen, setIsOpen }) => {
};
return (
<>
<Transition.Root show={isOpen} as={React.Fragment} afterLeave={() => setQuery("")} appear>
<Dialog as="div" className="relative z-10" onClose={handleClose}>
<Transition.Root show={isOpen} as={React.Fragment} afterLeave={() => setQuery("")} appear>
<Dialog as="div" className="relative z-20" onClose={handleClose}>
<div className="fixed inset-0 z-20 overflow-y-auto p-4 sm:p-6 md:p-20">
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<div className="fixed inset-0 bg-gray-500 bg-opacity-25 transition-opacity" />
</Transition.Child>
<Dialog.Panel className="relative mx-auto max-w-2xl transform divide-y divide-gray-500 divide-opacity-10 rounded-xl bg-white shadow-2xl ring-1 ring-black ring-opacity-5 transition-all">
<form>
<Combobox
onChange={(val: string) => {
const selectedIssues = watch("delete_issue_ids");
if (selectedIssues.includes(val))
setValue(
"delete_issue_ids",
selectedIssues.filter((i) => i !== val)
);
else {
const newToDelete = selectedIssues;
newToDelete.push(val);
<div className="fixed inset-0 z-10 overflow-y-auto p-4 sm:p-6 md:p-20">
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<Dialog.Panel className="relative mx-auto max-w-2xl transform divide-y divide-gray-500 divide-opacity-10 rounded-xl bg-white bg-opacity-80 shadow-2xl ring-1 ring-black ring-opacity-5 backdrop-blur backdrop-filter transition-all">
<form>
<Combobox>
<div className="relative m-1">
<MagnifyingGlassIcon
className="pointer-events-none absolute top-3.5 left-4 h-5 w-5 text-gray-900 text-opacity-40"
aria-hidden="true"
/>
<Combobox.Input
className="h-12 w-full border-0 bg-transparent pl-11 pr-4 text-gray-900 placeholder-gray-500 outline-none focus:ring-0 sm:text-sm"
placeholder="Search..."
onChange={(event) => setQuery(event.target.value)}
/>
</div>
setValue("delete_issue_ids", newToDelete);
}
}}
>
<div className="relative m-1">
<MagnifyingGlassIcon
className="pointer-events-none absolute top-3.5 left-4 h-5 w-5 text-gray-900 text-opacity-40"
aria-hidden="true"
/>
<input
type="text"
className="h-12 w-full border-0 bg-transparent pl-11 pr-4 text-gray-900 placeholder-gray-500 outline-none focus:ring-0 sm:text-sm"
placeholder="Search..."
onChange={(event) => setQuery(event.target.value)}
/>
</div>
<Combobox.Options
static
className="max-h-80 scroll-py-2 divide-y divide-gray-500 divide-opacity-10 overflow-y-auto"
>
{filteredIssues.length > 0 ? (
<li className="p-2">
{query === "" && (
<h2 className="mt-4 mb-2 px-3 text-xs font-semibold text-gray-900">
Select issues to delete
</h2>
)}
<ul className="text-sm text-gray-700">
{filteredIssues.map((issue) => (
<Combobox.Option
key={issue.id}
as="label"
htmlFor={`issue-${issue.id}`}
value={{
name: issue.name,
url: `/${workspaceSlug}/projects/${issue.project}/issues/${issue.id}`,
}}
className={({ active }) =>
classNames(
"flex cursor-pointer select-none items-center justify-between rounded-md px-3 py-2",
active ? "bg-gray-900 bg-opacity-5 text-gray-900" : ""
)
}
>
<div className="flex items-center gap-2">
<input
type="checkbox"
{...register("issue_ids")}
id={`issue-${issue.id}`}
value={issue.id}
/>
<span
className="block h-1.5 w-1.5 flex-shrink-0 rounded-full"
style={{
backgroundColor: issue.state_detail.color,
}}
/>
<span className="flex-shrink-0 text-xs text-gray-500">
{projectDetails?.identifier}-{issue.sequence_id}
</span>
<span>{issue.name}</span>
</div>
</Combobox.Option>
))}
</ul>
</li>
) : (
<div className="flex flex-col items-center justify-center gap-4 px-3 py-8 text-center">
<LayerDiagonalIcon height="56" width="56" />
<h3 className="text-gray-500">
No issues found. Create a new issue with{" "}
<pre className="inline rounded bg-gray-100 px-2 py-1">
Ctrl/Command + I
</pre>
.
</h3>
</div>
)}
</Combobox.Options>
{query !== "" && filteredIssues.length === 0 && (
<div className="py-14 px-6 text-center sm:px-14">
<FolderIcon
className="mx-auto h-6 w-6 text-gray-900 text-opacity-40"
aria-hidden="true"
/>
<p className="mt-4 text-sm text-gray-900">
We couldn{"'"}t find any issue with that term. Please try again.
</p>
<Combobox.Options
static
className="max-h-80 scroll-py-2 divide-y divide-gray-500 divide-opacity-10 overflow-y-auto"
>
{filteredIssues.length > 0 ? (
<li className="p-2">
{query === "" && (
<h2 className="mt-4 mb-2 px-3 text-xs font-semibold text-gray-900">
Select issues to delete
</h2>
)}
<ul className="text-sm text-gray-700">
{filteredIssues.map((issue) => (
<Combobox.Option
key={issue.id}
as="div"
value={issue.id}
className={({ active }) =>
`flex cursor-pointer select-none items-center justify-between rounded-md px-3 py-2 ${
active ? "bg-gray-900 bg-opacity-5 text-gray-900" : ""
}`
}
>
<div className="flex items-center gap-2">
<input
type="checkbox"
checked={watch("delete_issue_ids").includes(issue.id)}
readOnly
/>
<span
className="block h-1.5 w-1.5 flex-shrink-0 rounded-full"
style={{
backgroundColor: issue.state_detail.color,
}}
/>
<span className="flex-shrink-0 text-xs text-gray-500">
{projectDetails?.identifier}-{issue.sequence_id}
</span>
<span>{issue.name}</span>
</div>
</Combobox.Option>
))}
</ul>
</li>
) : (
<div className="flex flex-col items-center justify-center gap-4 px-3 py-8 text-center">
<LayerDiagonalIcon height="56" width="56" />
<h3 className="text-gray-500">
No issues found. Create a new issue with{" "}
<pre className="inline rounded bg-gray-100 px-2 py-1">C</pre>.
</h3>
</div>
)}
</Combobox>
</Combobox.Options>
</Combobox>
{filteredIssues.length > 0 && (
<div className="flex items-center justify-end gap-2 p-3">
<Button type="button" theme="secondary" size="sm" onClick={handleClose}>
Close
</Button>
<Button onClick={handleSubmit(handleDelete)} theme="danger" size="sm">
Delete selected issues
</Button>
</div>
)}
</form>
</Dialog.Panel>
</Transition.Child>
</div>
</Dialog>
</Transition.Root>
</>
{filteredIssues.length > 0 && (
<div className="flex items-center justify-end gap-2 p-3">
<Button type="button" theme="secondary" size="sm" onClick={handleClose}>
Close
</Button>
<Button
onClick={handleSubmit(handleDelete)}
theme="danger"
size="sm"
disabled={isSubmitting}
>
{isSubmitting ? "Deleting..." : "Delete selected issues"}
</Button>
</div>
)}
</form>
</Dialog.Panel>
</Transition.Child>
</div>
</Dialog>
</Transition.Root>
);
};

View File

@ -6,22 +6,19 @@ import useSWR from "swr";
// react-hook-form
import { Controller, SubmitHandler, useForm } from "react-hook-form";
// hooks
import useToast from "lib/hooks/useToast";
// services
import projectService from "lib/services/project.service";
// headless ui
import { Combobox, Dialog, Transition } from "@headlessui/react";
// ui
import { Button } from "ui";
// icons
import { MagnifyingGlassIcon, RectangleStackIcon } from "@heroicons/react/24/outline";
import useToast from "hooks/use-toast";
// services
import projectService from "services/project.service";
// headless ui
// ui
import { Button } from "components/ui";
import { LayerDiagonalIcon } from "components/icons";
// types
import { IIssue } from "types";
// fetch-keys
import { PROJECT_DETAILS } from "constants/fetch-keys";
// common
import { classNames } from "constants/common";
import { LayerDiagonalIcon } from "ui/icons";
type FormInput = {
issues: string[];
@ -32,7 +29,7 @@ type Props = {
handleClose: () => void;
type: string;
issues: IIssue[];
handleOnSubmit: (data: FormInput) => void;
handleOnSubmit: any;
};
const ExistingIssuesListModal: React.FC<Props> = ({
@ -73,18 +70,25 @@ const ExistingIssuesListModal: React.FC<Props> = ({
},
});
const onSubmit: SubmitHandler<FormInput> = (data) => {
const onSubmit: SubmitHandler<FormInput> = async (data) => {
if (!data.issues || data.issues.length === 0) {
setToastAlert({
title: "Error",
type: "error",
message: "Please select atleast one issue",
});
return;
}
handleOnSubmit(data);
await handleOnSubmit(data);
handleClose();
setToastAlert({
title: "Success",
type: "success",
message: `Issue${data.issues.length > 1 ? "s" : ""} added successfully`,
});
};
const filteredIssues: IIssue[] =
@ -149,38 +153,35 @@ const ExistingIssuesListModal: React.FC<Props> = ({
</h2>
)}
<ul className="text-sm text-gray-700">
{filteredIssues.map((issue) => {
return (
<Combobox.Option
key={issue.id}
as="label"
htmlFor={`issue-${issue.id}`}
value={issue.id}
className={({ active }) =>
classNames(
"flex w-full cursor-pointer select-none items-center gap-2 rounded-md px-3 py-2",
active ? "bg-gray-900 bg-opacity-5 text-gray-900" : ""
)
}
>
{({ selected }) => (
<>
<input type="checkbox" checked={selected} readOnly />
<span
className="block h-1.5 w-1.5 flex-shrink-0 rounded-full"
style={{
backgroundColor: issue.state_detail.color,
}}
/>
<span className="flex-shrink-0 text-xs text-gray-500">
{projectDetails?.identifier}-{issue.sequence_id}
</span>
{issue.name}
</>
)}
</Combobox.Option>
);
})}
{filteredIssues.map((issue) => (
<Combobox.Option
key={issue.id}
as="label"
htmlFor={`issue-${issue.id}`}
value={issue.id}
className={({ active }) =>
`flex w-full cursor-pointer select-none items-center gap-2 rounded-md px-3 py-2 ${
active ? "bg-gray-900 bg-opacity-5 text-gray-900" : ""
}`
}
>
{({ selected }) => (
<>
<input type="checkbox" checked={selected} readOnly />
<span
className="block h-1.5 w-1.5 flex-shrink-0 rounded-full"
style={{
backgroundColor: issue.state_detail.color,
}}
/>
<span className="flex-shrink-0 text-xs text-gray-500">
{projectDetails?.identifier}-{issue.sequence_id}
</span>
{issue.name}
</>
)}
</Combobox.Option>
))}
</ul>
</li>
) : (
@ -188,10 +189,7 @@ const ExistingIssuesListModal: React.FC<Props> = ({
<LayerDiagonalIcon height="56" width="56" />
<h3 className="text-gray-500">
No issues found. Create a new issue with{" "}
<pre className="inline rounded bg-gray-100 px-2 py-1">
Ctrl/Command + I
</pre>
.
<pre className="inline rounded bg-gray-100 px-2 py-1">C</pre>.
</h3>
</div>
)}

View File

@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useState } from "react";
import React, { useCallback, useState } from "react";
import NextImage from "next/image";
import { useRouter } from "next/router";
@ -8,11 +8,11 @@ import { useDropzone } from "react-dropzone";
import { Transition, Dialog } from "@headlessui/react";
// services
import fileServices from "lib/services/file.service";
import fileServices from "services/file.service";
// icon
import { UserCircleIcon } from "ui/icons";
import { UserCircleIcon } from "components/icons";
// ui
import { Button } from "ui";
import { Button } from "components/ui";
type TImageUploadModalProps = {
value?: string | null;
@ -70,7 +70,7 @@ export const ImageUploadModal: React.FC<TImageUploadModalProps> = (props) => {
return (
<Transition.Root show={isOpen} as={React.Fragment}>
<Dialog as="div" className="relative z-10" onClose={handleClose}>
<Dialog as="div" className="relative z-30" onClose={handleClose}>
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
@ -83,7 +83,7 @@ export const ImageUploadModal: React.FC<TImageUploadModalProps> = (props) => {
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-10 overflow-y-auto">
<div className="fixed inset-0 z-30 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4 text-center sm:p-0">
<Transition.Child
as={React.Fragment}
@ -103,13 +103,13 @@ export const ImageUploadModal: React.FC<TImageUploadModalProps> = (props) => {
<div className="flex items-center gap-3">
<div
{...getRootProps()}
className={`relative block w-full rounded-lg p-12 text-center focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 ${
className={`relative block h-80 w-full rounded-lg p-12 text-center focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 ${
(image === null && isDragActive) || !value
? "border-2 border-dashed border-gray-300 hover:border-gray-400"
: ""
}`}
>
{value && value !== "" ? (
{image !== null || (value && value !== null && value !== "") ? (
<>
<button
type="button"
@ -121,7 +121,7 @@ export const ImageUploadModal: React.FC<TImageUploadModalProps> = (props) => {
<NextImage
layout="fill"
objectFit="cover"
src={image ? URL.createObjectURL(image) : value}
src={image ? URL.createObjectURL(image) : value ? value : ""}
alt="image"
/>
</>

View File

@ -1,59 +1,106 @@
// next
import React, { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/router";
import useSWR from "swr";
import useSWR, { mutate } from "swr";
// react-datepicker
import DatePicker from "react-datepicker";
import "react-datepicker/dist/react-datepicker.css";
// services
import issuesService from "lib/services/issues.service";
import issuesService from "services/issues.service";
import workspaceService from "services/workspace.service";
import stateService from "services/state.service";
// headless ui
import { Listbox, Transition } from "@headlessui/react";
// ui
import { CustomMenu } from "ui";
import { CustomMenu, CustomSelect, AssigneesList, Avatar, CustomDatePicker } from "components/ui";
// components
import ConfirmIssueDeletion from "components/project/issues/confirm-issue-deletion";
// icons
import { CalendarDaysIcon } from "@heroicons/react/24/outline";
// helpers
import { renderShortNumericDateFormat, findHowManyDaysLeft } from "helpers/date-time.helper";
import { addSpaceIfCamelCase } from "helpers/string.helper";
// types
import { IIssue, IssueResponse, Properties } from "types";
import { IIssue, IWorkspaceMember, Properties, UserAuth } from "types";
// fetch-keys
import { PROJECT_ISSUES_LIST } from "constants/fetch-keys";
// common
import {
addSpaceIfCamelCase,
findHowManyDaysLeft,
renderShortNumericDateFormat,
} from "constants/common";
CYCLE_ISSUES,
MODULE_ISSUES,
PROJECT_ISSUES_LIST,
STATE_LIST,
WORKSPACE_MEMBERS,
} from "constants/fetch-keys";
// constants
import { getPriorityIcon } from "constants/global";
import { PRIORITIES } from "constants/";
type Props = {
type?: string;
typeId?: string;
issue: IIssue;
properties: Properties;
editIssue: () => void;
handleDeleteIssue: () => void;
removeIssue: () => void;
removeIssue?: () => void;
userAuth: UserAuth;
};
const SingleListIssue: React.FC<Props> = ({
type,
typeId,
issue,
properties,
editIssue,
handleDeleteIssue,
removeIssue,
userAuth,
}) => {
const router = useRouter();
let { workspaceSlug, projectId } = router.query;
const [deleteIssue, setDeleteIssue] = useState<IIssue | undefined>();
const { data: issues } = useSWR<IssueResponse>(
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { data: states } = useSWR(
workspaceSlug && projectId ? STATE_LIST(projectId as string) : null,
workspaceSlug && projectId
? PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string)
: null,
workspaceSlug && projectId
? () => issuesService.getIssues(workspaceSlug as string, projectId as string)
? () => stateService.getStates(workspaceSlug as string, projectId as string)
: null
);
const totalChildren = issues?.results.filter((i) => i.parent === issue.id).length;
const { data: people } = useSWR<IWorkspaceMember[]>(
workspaceSlug ? WORKSPACE_MEMBERS : null,
workspaceSlug ? () => workspaceService.workspaceMembers(workspaceSlug as string) : null
);
const partialUpdateIssue = (formData: Partial<IIssue>) => {
if (!workspaceSlug || !projectId) return;
issuesService
.patchIssue(workspaceSlug as string, projectId as string, issue.id, formData)
.then((res) => {
if (typeId) {
mutate(CYCLE_ISSUES(typeId ?? ""));
mutate(MODULE_ISSUES(typeId ?? ""));
}
mutate(PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string));
})
.catch((error) => {
console.log(error);
});
};
const isNotAllowed = userAuth.isGuest || userAuth.isViewer;
return (
<>
<div key={issue.id} className="flex items-center justify-between gap-2 px-4 py-3 text-sm">
<ConfirmIssueDeletion
handleClose={() => setDeleteIssue(undefined)}
isOpen={!!deleteIssue}
data={deleteIssue}
/>
<div className="flex items-center justify-between gap-2 px-4 py-3 text-sm">
<div className="flex items-center gap-2">
<span
className={`block h-1.5 w-1.5 flex-shrink-0 rounded-full`}
@ -74,59 +121,130 @@ const SingleListIssue: React.FC<Props> = ({
</div>
<div className="flex flex-shrink-0 flex-wrap items-center gap-x-1 gap-y-2 text-xs">
{properties.priority && (
<div
className={`group relative flex flex-shrink-0 cursor-pointer items-center gap-1 rounded px-2 py-1 text-xs capitalize shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 ${
issue.priority === "urgent"
? "bg-red-100 text-red-600"
: issue.priority === "high"
? "bg-orange-100 text-orange-500"
: issue.priority === "medium"
? "bg-yellow-100 text-yellow-500"
: issue.priority === "low"
? "bg-green-100 text-green-500"
: "bg-gray-100"
}`}
<Listbox
as="div"
value={issue.priority}
onChange={(data: string) => {
partialUpdateIssue({ priority: data });
}}
className="group relative flex-shrink-0"
disabled={isNotAllowed}
>
{/* {getPriorityIcon(issue.priority ?? "")} */}
{issue.priority ?? "None"}
<div className="absolute bottom-full right-0 z-10 mb-2 hidden whitespace-nowrap rounded-md bg-white p-2 shadow-md group-hover:block">
<h5 className="mb-1 font-medium text-gray-900">Priority</h5>
<div
className={`capitalize ${
issue.priority === "urgent"
? "text-red-600"
: issue.priority === "high"
? "text-orange-500"
: issue.priority === "medium"
? "text-yellow-500"
: issue.priority === "low"
? "text-green-500"
: ""
}`}
>
{issue.priority ?? "None"}
</div>
</div>
</div>
{({ open }) => (
<>
<div>
<Listbox.Button
className={`flex ${
isNotAllowed ? "cursor-not-allowed" : "cursor-pointer"
} items-center gap-x-2 rounded px-2 py-0.5 capitalize shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 ${
issue.priority === "urgent"
? "bg-red-100 text-red-600"
: issue.priority === "high"
? "bg-orange-100 text-orange-500"
: issue.priority === "medium"
? "bg-yellow-100 text-yellow-500"
: issue.priority === "low"
? "bg-green-100 text-green-500"
: "bg-gray-100"
}`}
>
{getPriorityIcon(
issue.priority && issue.priority !== "" ? issue.priority ?? "" : "None",
"text-sm"
)}
</Listbox.Button>
<Transition
show={open}
as={React.Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Listbox.Options className="absolute right-0 z-10 mt-1 max-h-48 w-36 overflow-auto rounded-md bg-white py-1 text-xs shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
{PRIORITIES?.map((priority) => (
<Listbox.Option
key={priority}
className={({ active }) =>
`flex cursor-pointer select-none items-center gap-x-2 px-3 py-2 capitalize ${
active ? "bg-indigo-50" : "bg-white"
}`
}
value={priority}
>
{getPriorityIcon(priority, "text-sm")}
{priority ?? "None"}
</Listbox.Option>
))}
</Listbox.Options>
</Transition>
</div>
<div className="absolute bottom-full right-0 z-10 mb-2 hidden whitespace-nowrap rounded-md bg-white p-2 shadow-md group-hover:block">
<h5 className="mb-1 font-medium text-gray-900">Priority</h5>
<div
className={`capitalize ${
issue.priority === "urgent"
? "text-red-600"
: issue.priority === "high"
? "text-orange-500"
: issue.priority === "medium"
? "text-yellow-500"
: issue.priority === "low"
? "text-green-500"
: ""
}`}
>
{issue.priority ?? "None"}
</div>
</div>
</>
)}
</Listbox>
)}
{properties.state && (
<div className="group relative flex flex-shrink-0 cursor-pointer items-center gap-1 rounded border px-2 py-1 text-xs shadow-sm duration-300 hover:bg-gray-100 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500">
<span
className="h-1.5 w-1.5 flex-shrink-0 rounded-full"
style={{
backgroundColor: issue?.state_detail?.color,
}}
></span>
{addSpaceIfCamelCase(issue?.state_detail.name)}
<div className="absolute bottom-full right-0 z-10 mb-2 hidden whitespace-nowrap rounded-md bg-white p-2 shadow-md group-hover:block">
<h5 className="mb-1 font-medium">State</h5>
<div>{issue?.state_detail.name}</div>
</div>
</div>
<CustomSelect
label={
<>
<span
className="h-1.5 w-1.5 flex-shrink-0 rounded-full"
style={{
backgroundColor: issue.state_detail.color,
}}
/>
{addSpaceIfCamelCase(issue.state_detail.name)}
</>
}
value={issue.state}
onChange={(data: string) => {
partialUpdateIssue({ state: data });
}}
maxHeight="md"
noChevron
disabled={isNotAllowed}
>
{states?.map((state) => (
<CustomSelect.Option key={state.id} value={state.id}>
<>
<span
className="h-1.5 w-1.5 flex-shrink-0 rounded-full"
style={{
backgroundColor: state.color,
}}
/>
{addSpaceIfCamelCase(state.name)}
</>
</CustomSelect.Option>
))}
</CustomSelect>
)}
{/* {properties.cycle && !typeId && (
<div className="flex flex-shrink-0 items-center gap-1 rounded border px-2 py-1 text-xs shadow-sm duration-300 hover:bg-gray-100 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500">
{issue.issue_cycle ? issue.issue_cycle.cycle_detail.name : "None"}
</div>
)} */}
{properties.due_date && (
<div
className={`group group relative flex flex-shrink-0 cursor-pointer items-center gap-1 rounded border px-2 py-1 text-xs shadow-sm duration-300 hover:bg-gray-100 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 ${
className={`group relative ${
issue.target_date === null
? ""
: issue.target_date < new Date().toISOString()
@ -134,8 +252,37 @@ const SingleListIssue: React.FC<Props> = ({
: findHowManyDaysLeft(issue.target_date) <= 3 && "text-orange-400"
}`}
>
<CalendarDaysIcon className="h-4 w-4" />
{issue.target_date ? renderShortNumericDateFormat(issue.target_date) : "N/A"}
<CustomDatePicker
placeholder="N/A"
value={issue?.target_date}
onChange={(val: Date) => {
partialUpdateIssue({
target_date: val
? `${val.getFullYear()}-${val.getMonth() + 1}-${val.getDate()}`
: null,
});
}}
className={issue?.target_date ? "w-[6.5rem]" : "w-[3rem] text-center"}
/>
{/* <DatePicker
placeholderText="N/A"
value={
issue?.target_date ? `${renderShortNumericDateFormat(issue.target_date)}` : "N/A"
}
selected={issue?.target_date ? new Date(issue.target_date) : null}
onChange={(val: Date) => {
partialUpdateIssue({
target_date: val
? `${val.getFullYear()}-${val.getMonth() + 1}-${val.getDate()}`
: null,
});
}}
dateFormat="dd-MM-yyyy"
className={`cursor-pointer rounded-md border px-2 py-[3px] text-xs shadow-sm duration-300 hover:bg-gray-100 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 ${
issue?.target_date ? "w-[4.5rem]" : "w-[3rem] text-center"
}`}
isClearable
/> */}
<div className="absolute bottom-full right-0 z-10 mb-2 hidden whitespace-nowrap rounded-md bg-white p-2 shadow-md group-hover:block">
<h5 className="mb-1 font-medium text-gray-900">Due date</h5>
<div>{renderShortNumericDateFormat(issue.target_date ?? "")}</div>
@ -150,18 +297,93 @@ const SingleListIssue: React.FC<Props> = ({
</div>
</div>
)}
{properties.sub_issue_count && projectId && (
<div className="flex flex-shrink-0 items-center gap-1 rounded border px-2 py-1 text-xs shadow-sm duration-300 hover:bg-gray-100 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500">
{totalChildren} {totalChildren === 1 ? "sub-issue" : "sub-issues"}
{properties.sub_issue_count && (
<div className="flex flex-shrink-0 items-center gap-1 rounded border px-2 py-1 text-xs shadow-sm">
{issue.sub_issues_count} {issue.sub_issues_count === 1 ? "sub-issue" : "sub-issues"}
</div>
)}
{type && (
{properties.assignee && (
<Listbox
as="div"
value={issue.assignees}
onChange={(data: any) => {
const newData = issue.assignees ?? [];
if (newData.includes(data)) newData.splice(newData.indexOf(data), 1);
else newData.push(data);
partialUpdateIssue({ assignees_list: newData });
}}
className="group relative flex-shrink-0"
disabled={isNotAllowed}
>
{({ open }) => (
<>
<div>
<Listbox.Button>
<div
className={`flex ${
isNotAllowed ? "cursor-not-allowed" : "cursor-pointer"
} items-center gap-1 text-xs`}
>
<AssigneesList userIds={issue.assignees ?? []} />
</div>
</Listbox.Button>
<Transition
show={open}
as={React.Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Listbox.Options className="absolute right-0 z-10 mt-1 max-h-48 overflow-auto rounded-md bg-white py-1 text-xs shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
{people?.map((person) => (
<Listbox.Option
key={person.id}
className={({ active, selected }) =>
`flex items-center gap-x-1 cursor-pointer select-none p-2 ${
active ? "bg-indigo-50" : ""
} ${
selected || issue.assignees?.includes(person.member.id)
? "bg-indigo-50 font-medium"
: "font-normal"
}`
}
value={person.member.id}
>
<Avatar user={person.member} />
<p>
{person.member.first_name && person.member.first_name !== ""
? person.member.first_name
: person.member.email}
</p>
</Listbox.Option>
))}
</Listbox.Options>
</Transition>
</div>
<div className="absolute bottom-full right-0 z-10 mb-2 hidden whitespace-nowrap rounded-md bg-white p-2 shadow-md group-hover:block">
<h5 className="mb-1 font-medium">Assigned to</h5>
<div>
{issue.assignee_details?.length > 0
? issue.assignee_details.map((assignee) => assignee.first_name).join(", ")
: "No one"}
</div>
</div>
</>
)}
</Listbox>
)}
{type && !isNotAllowed && (
<CustomMenu width="auto" ellipsis>
<CustomMenu.MenuItem onClick={() => editIssue()}>Edit</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={() => removeIssue()}>
<>Remove from {type}</>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={() => handleDeleteIssue()}>
<CustomMenu.MenuItem onClick={editIssue}>Edit</CustomMenu.MenuItem>
{type !== "issue" && (
<CustomMenu.MenuItem onClick={removeIssue}>
<>Remove from {type}</>
</CustomMenu.MenuItem>
)}
<CustomMenu.MenuItem onClick={() => setDeleteIssue(issue)}>
Delete permanently
</CustomMenu.MenuItem>
</CustomMenu>

View File

@ -5,9 +5,9 @@ import { useRouter } from "next/router";
// layouts
import DefaultLayout from "layouts/default-layout";
// hooks
import useUser from "lib/hooks/useUser";
import useUser from "hooks/use-user";
// icons
import { LockIcon } from "ui/icons";
import { LockIcon } from "components/icons";
type TNotAuthorizedViewProps = {
actionButton?: React.ReactNode;

View File

@ -1,60 +1,84 @@
import React from "react";
import { useRouter } from "next/router";
// hooks
import useIssuesProperties from "lib/hooks/useIssuesProperties";
import useIssuesProperties from "hooks/use-issue-properties";
import useIssueView from "hooks/use-issue-view";
// headless ui
import { Popover, Transition } from "@headlessui/react";
// ui
import { CustomMenu } from "ui";
import { CustomMenu } from "components/ui";
// icons
import { ChevronDownIcon } from "@heroicons/react/24/outline";
import { ChevronDownIcon, ListBulletIcon } from "@heroicons/react/24/outline";
import { Squares2X2Icon } from "@heroicons/react/20/solid";
// helpers
import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper";
// types
import { IIssue, NestedKeyOf, Properties } from "types";
import { IIssue, Properties } from "types";
// common
import { classNames, replaceUnderscoreIfSnakeCase } from "constants/common";
// constants
import { filterIssueOptions, groupByOptions, orderByOptions } from "constants/";
type Props = {
groupByProperty: NestedKeyOf<IIssue> | null;
setGroupByProperty: (property: NestedKeyOf<IIssue> | null) => void;
orderBy: NestedKeyOf<IIssue> | null;
setOrderBy: (property: NestedKeyOf<IIssue> | null) => void;
filterIssue: "activeIssue" | "backlogIssue" | null;
setFilterIssue: (property: "activeIssue" | "backlogIssue" | null) => void;
resetFilterToDefault: () => void;
setNewFilterDefaultView: () => void;
issues?: IIssue[];
};
const View: React.FC<Props> = ({
groupByProperty,
setGroupByProperty,
orderBy,
setOrderBy,
filterIssue,
setFilterIssue,
resetFilterToDefault,
setNewFilterDefaultView,
}) => {
const View: React.FC<Props> = ({ issues }) => {
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const {
issueView,
setIssueViewToList,
setIssueViewToKanban,
groupByProperty,
setGroupByProperty,
setOrderBy,
setFilterIssue,
orderBy,
filterIssue,
resetFilterToDefault,
setNewFilterDefaultView,
} = useIssueView(issues ?? []);
const [properties, setProperties] = useIssuesProperties(
workspaceSlug as string,
projectId as string
);
return (
<>
<div className="flex items-center gap-2">
<div className="flex items-center gap-x-1">
{issues && (
<>
<button
type="button"
className={`grid h-7 w-7 place-items-center rounded p-1 outline-none duration-300 hover:bg-gray-200 ${
issueView === "list" ? "bg-gray-200" : ""
}`}
onClick={() => setIssueViewToList()}
>
<ListBulletIcon className="h-4 w-4" />
</button>
<button
type="button"
className={`grid h-7 w-7 place-items-center rounded p-1 outline-none duration-300 hover:bg-gray-200 ${
issueView === "kanban" ? "bg-gray-200" : ""
}`}
onClick={() => setIssueViewToKanban()}
>
<Squares2X2Icon className="h-4 w-4" />
</button>
</>
)}
</div>
<Popover className="relative">
{({ open }) => (
<>
<Popover.Button
className={classNames(
open ? "bg-gray-100 text-gray-900" : "text-gray-500",
"group flex items-center gap-2 rounded-md border bg-transparent p-2 text-xs font-medium hover:bg-gray-100 hover:text-gray-900 focus:outline-none"
)}
className={`group flex items-center gap-2 rounded-md border bg-transparent p-2 text-xs font-medium hover:bg-gray-100 hover:text-gray-900 focus:outline-none ${
open ? "bg-gray-100 text-gray-900" : "text-gray-500"
}`}
>
<span>View</span>
<ChevronDownIcon className="h-4 w-4" aria-hidden="true" />
@ -71,82 +95,85 @@ const View: React.FC<Props> = ({
>
<Popover.Panel className="absolute right-0 z-20 mt-1 w-screen max-w-xs transform overflow-hidden rounded-lg bg-white p-3 shadow-lg">
<div className="relative divide-y-2">
<div className="space-y-4 pb-3">
<div className="flex items-center justify-between">
<h4 className="text-sm text-gray-600">Group by</h4>
<CustomMenu
label={
groupByOptions.find((option) => option.key === groupByProperty)?.name ??
"Select"
}
width="lg"
>
{groupByOptions.map((option) => (
<CustomMenu.MenuItem
key={option.key}
onClick={() => setGroupByProperty(option.key)}
>
{option.name}
</CustomMenu.MenuItem>
))}
</CustomMenu>
</div>
<div className="flex items-center justify-between">
<h4 className="text-sm text-gray-600">Order by</h4>
<CustomMenu
label={
orderByOptions.find((option) => option.key === orderBy)?.name ?? "Select"
}
width="lg"
>
{orderByOptions.map((option) =>
groupByProperty === "priority" && option.key === "priority" ? null : (
{issues && (
<div className="space-y-4 pb-3">
<div className="flex items-center justify-between">
<h4 className="text-sm text-gray-600">Group by</h4>
<CustomMenu
label={
groupByOptions.find((option) => option.key === groupByProperty)?.name ??
"Select"
}
width="lg"
>
{groupByOptions.map((option) => (
<CustomMenu.MenuItem
key={option.key}
onClick={() => setOrderBy(option.key)}
onClick={() => setGroupByProperty(option.key)}
>
{option.name}
</CustomMenu.MenuItem>
)
)}
</CustomMenu>
))}
</CustomMenu>
</div>
<div className="flex items-center justify-between">
<h4 className="text-sm text-gray-600">Order by</h4>
<CustomMenu
label={
orderByOptions.find((option) => option.key === orderBy)?.name ??
"Select"
}
width="lg"
>
{orderByOptions.map((option) =>
groupByProperty === "priority" && option.key === "priority" ? null : (
<CustomMenu.MenuItem
key={option.key}
onClick={() => setOrderBy(option.key)}
>
{option.name}
</CustomMenu.MenuItem>
)
)}
</CustomMenu>
</div>
<div className="flex items-center justify-between">
<h4 className="text-sm text-gray-600">Issue type</h4>
<CustomMenu
label={
filterIssueOptions.find((option) => option.key === filterIssue)?.name ??
"Select"
}
width="lg"
>
{filterIssueOptions.map((option) => (
<CustomMenu.MenuItem
key={option.key}
onClick={() => setFilterIssue(option.key)}
>
{option.name}
</CustomMenu.MenuItem>
))}
</CustomMenu>
</div>
<div className="relative flex justify-end gap-x-3">
<button
type="button"
className="text-xs"
onClick={() => resetFilterToDefault()}
>
Reset to default
</button>
<button
type="button"
className="text-xs font-medium text-theme"
onClick={() => setNewFilterDefaultView()}
>
Set as default
</button>
</div>
</div>
<div className="flex items-center justify-between">
<h4 className="text-sm text-gray-600">Issue type</h4>
<CustomMenu
label={
filterIssueOptions.find((option) => option.key === filterIssue)?.name ??
"Select"
}
width="lg"
>
{filterIssueOptions.map((option) => (
<CustomMenu.MenuItem
key={option.key}
onClick={() => setFilterIssue(option.key)}
>
{option.name}
</CustomMenu.MenuItem>
))}
</CustomMenu>
</div>
<div className="relative flex justify-end gap-x-3">
<button
type="button"
className="text-xs"
onClick={() => resetFilterToDefault()}
>
Reset to default
</button>
<button
type="button"
className="text-xs font-medium text-theme"
onClick={() => setNewFilterDefaultView()}
>
Set as default
</button>
</div>
</div>
)}
<div className="space-y-2 py-3">
<h4 className="text-sm text-gray-600">Display Properties</h4>
<div className="flex flex-wrap items-center gap-2">
@ -172,7 +199,7 @@ const View: React.FC<Props> = ({
</>
)}
</Popover>
</>
</div>
);
};

View File

@ -0,0 +1,135 @@
import { FC } from "react";
import { Controller, useForm } from "react-hook-form";
// components
import { Button, Input, TextArea, CustomSelect } from "components/ui";
// types
import type { ICycle } from "types";
const defaultValues: Partial<ICycle> = {
name: "",
description: "",
status: "draft",
start_date: new Date().toString(),
end_date: new Date().toString(),
};
export interface CycleFormProps {
handleFormSubmit: (values: Partial<ICycle>) => void;
handleFormCancel?: () => void;
initialData?: Partial<ICycle>;
}
export const CycleForm: FC<CycleFormProps> = (props) => {
const { handleFormSubmit, handleFormCancel = () => {}, initialData = null } = props;
// form handler
const {
register,
formState: { errors, isSubmitting },
handleSubmit,
control,
} = useForm<ICycle>({
defaultValues: initialData || defaultValues,
});
return (
<form onSubmit={handleSubmit(handleFormSubmit)}>
<div className="space-y-5">
<div className="space-y-3">
<div>
<Input
id="name"
label="Name"
name="name"
type="name"
placeholder="Enter name"
autoComplete="off"
error={errors.name}
register={register}
validations={{
required: "Name is required",
}}
/>
</div>
<div>
<TextArea
id="description"
name="description"
label="Description"
placeholder="Enter description"
error={errors.description}
register={register}
/>
</div>
<div>
<h6 className="text-gray-500">Status</h6>
<Controller
name="status"
control={control}
render={({ field }) => (
<CustomSelect
{...field}
label={<span className="capitalize">{field.value ?? "Select Status"}</span>}
input
>
{[
{ label: "Draft", value: "draft" },
{ label: "Started", value: "started" },
{ label: "Completed", value: "completed" },
].map((item) => (
<CustomSelect.Option key={item.value} value={item.value}>
{item.label}
</CustomSelect.Option>
))}
</CustomSelect>
)}
/>
</div>
<div className="flex gap-x-2">
<div className="w-full">
<Input
id="start_date"
label="Start Date"
name="start_date"
type="date"
placeholder="Enter start date"
error={errors.start_date}
register={register}
validations={{
required: "Start date is required",
}}
/>
</div>
<div className="w-full">
<Input
id="end_date"
label="End Date"
name="end_date"
type="date"
placeholder="Enter end date"
error={errors.end_date}
register={register}
validations={{
required: "End date is required",
}}
/>
</div>
</div>
</div>
</div>
<div className="mt-5 flex justify-end gap-2">
<Button theme="secondary" onClick={handleFormCancel}>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting}>
{initialData
? isSubmitting
? "Updating Cycle..."
: "Update Cycle"
: isSubmitting
? "Creating Cycle..."
: "Create Cycle"}
</Button>
</div>
</form>
);
};

View File

@ -0,0 +1,3 @@
export * from "./modal";
export * from "./select";
export * from "./form";

View File

@ -0,0 +1,112 @@
import { Fragment } from "react";
import { mutate } from "swr";
import { Dialog, Transition } from "@headlessui/react";
// services
import cycleService from "services/cycles.service";
// components
import { CycleForm } from "components/cycles";
// helpers
import { renderDateFormat } from "helpers/date-time.helper";
// types
import type { ICycle } from "types";
// fetch keys
import { CYCLE_LIST } from "constants/fetch-keys";
export interface CycleModalProps {
isOpen: boolean;
handleClose: () => void;
projectId: string;
workspaceSlug: string;
initialData?: ICycle;
}
export const CycleModal: React.FC<CycleModalProps> = (props) => {
const { isOpen, handleClose, initialData, projectId, workspaceSlug } = props;
const createCycle = (payload: Partial<ICycle>) => {
cycleService
.createCycle(workspaceSlug as string, projectId, payload)
.then((res) => {
mutate(CYCLE_LIST(projectId));
handleClose();
})
.catch((err) => {
// TODO: Handle this ERROR.
// Object.keys(err).map((key) => {
// setError(key as keyof typeof defaultValues, {
// message: err[key].join(", "),
// });
// });
});
};
const updateCycle = (cycleId: string, payload: Partial<ICycle>) => {
cycleService
.updateCycle(workspaceSlug, projectId, cycleId, payload)
.then((res) => {
mutate(CYCLE_LIST(projectId));
handleClose();
})
.catch((err) => {
// TODO: Handle this ERROR.
// Object.keys(err).map((key) => {
// setError(key as keyof typeof defaultValues, {
// message: err[key].join(", "),
// });
// });
});
};
const handleFormSubmit = (formValues: Partial<ICycle>) => {
if (workspaceSlug && projectId) {
const payload = {
...formValues,
start_date: formValues.start_date ? renderDateFormat(formValues.start_date) : null,
end_date: formValues.end_date ? renderDateFormat(formValues.end_date) : null,
};
if (initialData) {
updateCycle(initialData.id, payload);
} else {
createCycle(payload);
}
}
};
return (
<Transition.Root show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-20" onClose={handleClose}>
<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-gray-500 bg-opacity-75 transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-20 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4 text-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 overflow-hidden rounded-lg bg-white px-5 py-8 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-2xl sm:p-6">
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-gray-900">
{initialData ? "Update" : "Create"} Cycle
</Dialog.Title>
<CycleForm handleFormSubmit={handleFormSubmit} handleFormCancel={handleClose} />
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
};

View File

@ -0,0 +1,132 @@
import React, { useState } from "react";
import { useRouter } from "next/router";
import useSWR from "swr";
// headless ui
import { Listbox, Transition } from "@headlessui/react";
// icons
import { PlusIcon } from "@heroicons/react/24/outline";
import { CyclesIcon } from "components/icons";
// services
import cycleServices from "services/cycles.service";
// components
import { CycleModal } from "components/cycles";
// fetch-keys
import { CYCLE_LIST } from "constants/fetch-keys";
export type IssueCycleSelectProps = {
projectId: string;
value: any;
onChange: (value: any) => void;
multiple?: boolean;
};
export const CycleSelect: React.FC<IssueCycleSelectProps> = ({
projectId,
value,
onChange,
multiple = false,
}) => {
// states
const [isCycleModalActive, setCycleModalActive] = useState(false);
const router = useRouter();
const { workspaceSlug } = router.query;
const { data: cycles } = useSWR(
workspaceSlug && projectId ? CYCLE_LIST(projectId) : null,
workspaceSlug && projectId
? () => cycleServices.getCycles(workspaceSlug as string, projectId)
: null
);
const options = cycles?.map((cycle) => ({ value: cycle.id, display: cycle.name }));
const openCycleModal = () => {
setCycleModalActive(true);
};
const closeCycleModal = () => {
setCycleModalActive(false);
};
return (
<>
<CycleModal
isOpen={isCycleModalActive}
handleClose={closeCycleModal}
projectId={projectId}
workspaceSlug={workspaceSlug as string}
/>
<Listbox as="div" className="relative" value={value} onChange={onChange} multiple={multiple}>
{({ open }) => (
<>
<Listbox.Button
className={`flex cursor-pointer items-center gap-1 rounded-md border px-2 py-1 text-xs shadow-sm duration-300 hover:bg-gray-100 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500`}
>
<CyclesIcon className="h-3 w-3 text-gray-500" />
<div className="flex items-center gap-2 truncate">
{cycles?.find((c) => c.id === value)?.name ?? "Cycles"}
</div>
</Listbox.Button>
<Transition
show={open}
as={React.Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Listbox.Options
className={`absolute mt-1 max-h-32 min-w-[8rem] overflow-y-auto whitespace-nowrap bg-white shadow-lg text-xs z-10 rounded-md py-1 ring-1 ring-black ring-opacity-5 focus:outline-none`}
>
<div className="py-1">
{options ? (
options.length > 0 ? (
options.map((option) => (
<Listbox.Option
key={option.value}
className={({ selected, active }) =>
`${
selected ||
(Array.isArray(value)
? value.includes(option.value)
: value === option.value)
? "bg-indigo-50 font-medium"
: ""
} ${
active ? "bg-indigo-50" : ""
} relative cursor-pointer select-none p-2 text-gray-900`
}
value={option.value}
>
<span className={` flex items-center gap-2 truncate`}>
{option.display}
</span>
</Listbox.Option>
))
) : (
<p className="text-center text-sm text-gray-500">No options</p>
)
) : (
<p className="text-center text-sm text-gray-500">Loading...</p>
)}
<button
type="button"
className="relative w-full flex select-none items-center gap-x-2 p-2 text-gray-400 hover:bg-indigo-50 hover:text-gray-900"
onClick={openCycleModal}
>
<PlusIcon className="h-4 w-4 text-gray-400" aria-hidden="true" />
<span>Create cycle</span>
</button>
</div>
</Listbox.Options>
</Transition>
</>
)}
</Listbox>
</>
);
};

View File

@ -1,7 +1,6 @@
import React, { useState, useEffect } from "react";
// react beautiful dnd
import { Droppable } from "react-beautiful-dnd";
import type { DroppableProps } from "react-beautiful-dnd";
import { Droppable, DroppableProps } from "react-beautiful-dnd";
const StrictModeDroppable = ({ children, ...props }: DroppableProps) => {
const [enabled, setEnabled] = useState(false);

View File

@ -1,16 +1,15 @@
import React, { useEffect, useState, useRef } from "react";
// headless ui
import { Tab, Transition, Popover } from "@headlessui/react";
// hooks
import useOutsideClickDetector from "lib/hooks/useOutsideClickDetector";
// common
import { getRandomEmoji } from "constants/common";
// emoji
// types
import { Props } from "./types";
// emojis
import emojis from "./emojis.json";
// helpers
import { getRecentEmojis, saveRecentEmoji } from "./helpers";
// types
import { Props } from "./types";
import { getRandomEmoji } from "helpers/functions.helper";
// hooks
import useOutsideClickDetector from "hooks/use-outside-click-detector";
const tabOptions = [
{
@ -43,7 +42,7 @@ const EmojiIconPicker: React.FC<Props> = ({ label, value, onChange }) => {
}, [value, onChange]);
return (
<Popover className="relative" ref={ref}>
<Popover className="relative z-[1]" ref={ref}>
<Popover.Button
className="rounded-md border border-gray-300 p-2 outline-none sm:text-sm"
onClick={() => setIsOpen((prev) => !prev)}

View File

@ -2,8 +2,7 @@ import React from "react";
import type { Props } from "./types";
export const AttachmentIcon: React.FC<Props> = ({ width, height, className }) => {
return (
export const AttachmentIcon: React.FC<Props> = ({ width, height, className }) => (
<svg
width={width}
height={height}
@ -18,4 +17,3 @@ export const AttachmentIcon: React.FC<Props> = ({ width, height, className }) =>
/>
</svg>
);
};

View File

@ -2,8 +2,7 @@ import React from "react";
import type { Props } from "./types";
export const BlockedIcon: React.FC<Props> = ({ width = "24", height = "24", className }) => {
return (
export const BlockedIcon: React.FC<Props> = ({ width = "24", height = "24", className }) => (
<svg
width={width}
height={height}
@ -24,4 +23,3 @@ export const BlockedIcon: React.FC<Props> = ({ width = "24", height = "24", clas
/>
</svg>
);
};

View File

@ -2,8 +2,7 @@ import React from "react";
import type { Props } from "./types";
export const BlockerIcon: React.FC<Props> = ({ width = "24", height = "24", className }) => {
return (
export const BlockerIcon: React.FC<Props> = ({ width = "24", height = "24", className }) => (
<svg
width={width}
height={height}
@ -24,4 +23,3 @@ export const BlockerIcon: React.FC<Props> = ({ width = "24", height = "24", clas
/>
</svg>
);
};

View File

@ -2,8 +2,7 @@ import React from "react";
import type { Props } from "./types";
export const BoltIcon: React.FC<Props> = ({ width = "24", height = "24", className }) => {
return (
export const BoltIcon: React.FC<Props> = ({ width = "24", height = "24", className }) => (
<svg
width={width}
height={height}
@ -15,4 +14,3 @@ export const BoltIcon: React.FC<Props> = ({ width = "24", height = "24", classNa
<path d="M10.6002 21C10.4169 21 10.2752 20.9417 10.1752 20.825C10.0752 20.7083 10.0419 20.5583 10.0752 20.375L11.0002 13.95H7.3502C7.16686 13.95 7.03353 13.8667 6.9502 13.7C6.86686 13.5333 6.86686 13.375 6.9502 13.225L12.8752 3.325C12.9252 3.24167 13.0085 3.16667 13.1252 3.1C13.2419 3.03333 13.3585 3 13.4752 3C13.6585 3 13.8002 3.05833 13.9002 3.175C14.0002 3.29167 14.0335 3.44167 14.0002 3.625L13.0752 10.025H16.6752C16.8585 10.025 16.996 10.1083 17.0877 10.275C17.1794 10.4417 17.1835 10.6 17.1002 10.75L11.2002 20.675C11.1502 20.7583 11.0669 20.8333 10.9502 20.9C10.8335 20.9667 10.7169 21 10.6002 21V21Z" />
</svg>
);
};

View File

@ -2,8 +2,7 @@ import React from "react";
import type { Props } from "./types";
export const CalendarMonthIcon: React.FC<Props> = ({ width = "24", height = "24", className }) => {
return (
export const CalendarMonthIcon: React.FC<Props> = ({ width = "24", height = "24", className }) => (
<svg
width={width}
height={height}
@ -18,4 +17,3 @@ export const CalendarMonthIcon: React.FC<Props> = ({ width = "24", height = "24"
/>
</svg>
);
};

View File

@ -2,8 +2,7 @@ import React from "react";
import type { Props } from "./types";
export const CancelIcon: React.FC<Props> = ({ width, height, className }) => {
return (
export const CancelIcon: React.FC<Props> = ({ width, height, className }) => (
<svg
width={width}
height={height}
@ -15,4 +14,3 @@ export const CancelIcon: React.FC<Props> = ({ width, height, className }) => {
<path d="M7.725 16.275C7.875 16.425 8.05 16.5 8.25 16.5C8.45 16.5 8.625 16.425 8.775 16.275L12 13.05L15.25 16.3C15.3833 16.4333 15.5542 16.4958 15.7625 16.4875C15.9708 16.4792 16.1417 16.4083 16.275 16.275C16.425 16.125 16.5 15.95 16.5 15.75C16.5 15.55 16.425 15.375 16.275 15.225L13.05 12L16.3 8.75C16.4333 8.61667 16.4958 8.44583 16.4875 8.2375C16.4792 8.02917 16.4083 7.85833 16.275 7.725C16.125 7.575 15.95 7.5 15.75 7.5C15.55 7.5 15.375 7.575 15.225 7.725L12 10.95L8.75 7.7C8.61667 7.56667 8.44583 7.50417 8.2375 7.5125C8.02917 7.52083 7.85833 7.59167 7.725 7.725C7.575 7.875 7.5 8.05 7.5 8.25C7.5 8.45 7.575 8.625 7.725 8.775L10.95 12L7.7 15.25C7.56667 15.3833 7.50417 15.5542 7.5125 15.7625C7.52083 15.9708 7.59167 16.1417 7.725 16.275ZM12 22C10.5833 22 9.26667 21.7458 8.05 21.2375C6.83333 20.7292 5.775 20.025 4.875 19.125C3.975 18.225 3.27083 17.1667 2.7625 15.95C2.25417 14.7333 2 13.4167 2 12C2 10.6 2.25417 9.29167 2.7625 8.075C3.27083 6.85833 3.975 5.8 4.875 4.9C5.775 4 6.83333 3.29167 8.05 2.775C9.26667 2.25833 10.5833 2 12 2C13.4 2 14.7083 2.25833 15.925 2.775C17.1417 3.29167 18.2 4 19.1 4.9C20 5.8 20.7083 6.85833 21.225 8.075C21.7417 9.29167 22 10.6 22 12C22 13.4167 21.7417 14.7333 21.225 15.95C20.7083 17.1667 20 18.225 19.1 19.125C18.2 20.025 17.1417 20.7292 15.925 21.2375C14.7083 21.7458 13.4 22 12 22ZM12 20.5C14.3333 20.5 16.3333 19.6667 18 18C19.6667 16.3333 20.5 14.3333 20.5 12C20.5 9.66667 19.6667 7.66667 18 6C16.3333 4.33333 14.3333 3.5 12 3.5C9.66667 3.5 7.66667 4.33333 6 6C4.33333 7.66667 3.5 9.66667 3.5 12C3.5 14.3333 4.33333 16.3333 6 18C7.66667 19.6667 9.66667 20.5 12 20.5Z" />
</svg>
);
};

View File

@ -2,8 +2,7 @@ import React from "react";
import type { Props } from "./types";
export const ClipboardIcon: React.FC<Props> = ({ width = "24", height = "24", className }) => {
return (
export const ClipboardIcon: React.FC<Props> = ({ width = "24", height = "24", className }) => (
<svg
width={width}
height={height}
@ -18,4 +17,3 @@ export const ClipboardIcon: React.FC<Props> = ({ width = "24", height = "24", cl
/>
</svg>
);
};

View File

@ -2,8 +2,7 @@ import React from "react";
import type { Props } from "./types";
export const CommentIcon: React.FC<Props> = ({ width = "24", height = "24", className }) => {
return (
export const CommentIcon: React.FC<Props> = ({ width = "24", height = "24", className }) => (
<svg
width={width}
height={height}
@ -15,4 +14,3 @@ export const CommentIcon: React.FC<Props> = ({ width = "24", height = "24", clas
<path d="M2 16.1V3.05C2 2.81667 2.10833 2.58333 2.325 2.35C2.54167 2.11667 2.76667 2 3 2H15.975C16.225 2 16.4583 2.1125 16.675 2.3375C16.8917 2.5625 17 2.8 17 3.05V11.95C17 12.1833 16.8917 12.4167 16.675 12.65C16.4583 12.8833 16.225 13 15.975 13H6L2.65 16.35C2.53333 16.4667 2.39583 16.4958 2.2375 16.4375C2.07917 16.3792 2 16.2667 2 16.1ZM3.5 3.5V11.5V3.5ZM7.025 18C6.79167 18 6.5625 17.8833 6.3375 17.65C6.1125 17.4167 6 17.1833 6 16.95V14.5H18.5V6H21C21.2333 6 21.4583 6.11667 21.675 6.35C21.8917 6.58333 22 6.825 22 7.075V21.075C22 21.2417 21.9208 21.3542 21.7625 21.4125C21.6042 21.4708 21.4667 21.4417 21.35 21.325L18.025 18H7.025ZM15.5 3.5H3.5V11.5H15.5V3.5Z" />
</svg>
);
};

View File

@ -7,8 +7,7 @@ export const CompletedCycleIcon: React.FC<Props> = ({
height = "24",
className,
color = "black",
}) => {
return (
}) => (
<svg xmlns="http://www.w3.org/2000/svg" height={height} width={width} className={className}>
<path
d="m21.65 36.6-6.9-6.85 2.1-2.1 4.8 4.7 9.2-9.2 2.1 2.15ZM6 44V7h6.25V4h3.25v3h17V4h3.25v3H42v37Zm3-3h30V19.5H9Zm0-24.5h30V10H9Zm0 0V10v6.5Z"
@ -16,4 +15,3 @@ export const CompletedCycleIcon: React.FC<Props> = ({
/>
</svg>
);
};

View File

@ -7,8 +7,7 @@ export const CurrentCycleIcon: React.FC<Props> = ({
height = "24",
className,
color = "black",
}) => {
return (
}) => (
<svg xmlns="http://www.w3.org/2000/svg" height={height} width={width} className={className}>
<path
d="M15.3 28.3q-.85 0-1.425-.575-.575-.575-.575-1.425 0-.85.575-1.425.575-.575 1.425-.575.85 0 1.425.575.575.575.575 1.425 0 .85-.575 1.425-.575.575-1.425.575Zm8.85 0q-.85 0-1.425-.575-.575-.575-.575-1.425 0-.85.575-1.425.575-.575 1.425-.575.85 0 1.425.575.575.575.575 1.425 0 .85-.575 1.425-.575.575-1.425.575Zm8.5 0q-.85 0-1.425-.575-.575-.575-.575-1.425 0-.85.575-1.425.575-.575 1.425-.575.85 0 1.425.575.575.575.575 1.425 0 .85-.575 1.425-.575.575-1.425.575ZM6 44V7h6.25V4h3.25v3h17V4h3.25v3H42v37Zm3-3h30V19.5H9Zm0-24.5h30V10H9Zm0 0V10v6.5Z"
@ -16,4 +15,3 @@ export const CurrentCycleIcon: React.FC<Props> = ({
/>
</svg>
);
};

View File

@ -7,8 +7,7 @@ export const CyclesIcon: React.FC<Props> = ({
height = "24",
className,
color = "black",
}) => {
return (
}) => (
<svg
width={width}
height={height}
@ -33,4 +32,3 @@ export const CyclesIcon: React.FC<Props> = ({
/>
</svg>
);
};

View File

@ -2,8 +2,7 @@ import React from "react";
import type { Props } from "./types";
export const DiscordIcon: React.FC<Props> = ({ width = "24", height = "24", className }) => {
return (
export const DiscordIcon: React.FC<Props> = ({ width = "24", height = "24", className }) => (
<svg
width={width}
height={height}
@ -12,7 +11,7 @@ export const DiscordIcon: React.FC<Props> = ({ width = "24", height = "24", clas
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<g clip-path="url(#clip0_282_229)">
<g clipPath="url(#clip0_282_229)">
<path d="M16.9312 3.64157C15.6346 3.04643 14.2662 2.62206 12.8604 2.37907C12.8476 2.37657 12.8343 2.37821 12.8225 2.38375C12.8106 2.38929 12.8009 2.39845 12.7946 2.4099C12.6196 2.7224 12.4246 3.1299 12.2879 3.45157C10.7724 3.22139 9.23088 3.22139 7.7154 3.45157C7.5633 3.09515 7.39165 2.7474 7.20123 2.4099C7.19467 2.39871 7.18486 2.38977 7.1731 2.38426C7.16135 2.37876 7.1482 2.37695 7.1354 2.37907C5.72944 2.62155 4.36101 3.04595 3.06457 3.64157C3.05359 3.64617 3.04429 3.65402 3.0379 3.66407C0.444567 7.53823 -0.266266 11.3166 0.0829005 15.0474C0.0837487 15.0567 0.0864772 15.0656 0.0909192 15.0738C0.0953611 15.082 0.101423 15.0892 0.108734 15.0949C1.6184 16.2134 3.30716 17.0672 5.1029 17.6199C5.11556 17.6236 5.12903 17.6233 5.14153 17.6191C5.15403 17.615 5.16497 17.6071 5.1729 17.5966C5.55895 17.072 5.90069 16.5162 6.19457 15.9349C6.19866 15.9269 6.20103 15.9182 6.2015 15.9093C6.20198 15.9003 6.20056 15.8914 6.19733 15.8831C6.1941 15.8747 6.18914 15.8671 6.18278 15.8609C6.17641 15.8546 6.16878 15.8497 6.1604 15.8466C5.62159 15.6404 5.09995 15.3918 4.6004 15.1032C4.59124 15.0979 4.58354 15.0905 4.57797 15.0815C4.5724 15.0725 4.56914 15.0622 4.56848 15.0517C4.56782 15.0411 4.56978 15.0306 4.57418 15.021C4.57859 15.0113 4.58531 15.003 4.59373 14.9966C4.69893 14.9179 4.80229 14.8367 4.90373 14.7532C4.91261 14.746 4.92331 14.7414 4.93464 14.74C4.94597 14.7385 4.95748 14.7402 4.9679 14.7449C8.24123 16.2391 11.7846 16.2391 15.0196 14.7449C15.0301 14.74 15.0418 14.7382 15.0533 14.7397C15.0648 14.7412 15.0756 14.7459 15.0846 14.7532C15.1846 14.8349 15.2896 14.9182 15.3954 14.9966C15.4037 15.0029 15.4104 15.0111 15.4148 15.0205C15.4193 15.03 15.4213 15.0404 15.4208 15.0508C15.4203 15.0612 15.4173 15.0714 15.412 15.0804C15.4067 15.0894 15.3993 15.0969 15.3904 15.1024C14.892 15.3937 14.3699 15.6424 13.8296 15.8457C13.8212 15.849 13.8135 15.8539 13.8071 15.8603C13.8008 15.8666 13.7958 15.8743 13.7926 15.8827C13.7894 15.8911 13.788 15.9001 13.7884 15.9091C13.7889 15.9181 13.7913 15.9269 13.7954 15.9349C14.0954 16.5166 14.4387 17.0699 14.8162 17.5957C14.824 17.6064 14.8349 17.6145 14.8475 17.6186C14.86 17.6228 14.8736 17.623 14.8862 17.6191C16.685 17.0681 18.3765 16.2142 19.8879 15.0941C19.8953 15.0889 19.9014 15.0822 19.906 15.0744C19.9106 15.0667 19.9135 15.058 19.9146 15.0491C20.3312 10.7349 19.2162 6.9874 16.9571 3.66573C16.9518 3.65453 16.9426 3.64564 16.9312 3.64073V3.64157ZM6.68373 12.7749C5.6979 12.7749 4.88623 11.8707 4.88623 10.7591C4.88623 9.64823 5.6829 8.74323 6.68373 8.74323C7.69207 8.74323 8.49707 9.65657 8.48123 10.7599C8.48123 11.8707 7.68457 12.7749 6.68373 12.7749ZM13.3296 12.7749C12.3437 12.7749 11.5321 11.8707 11.5321 10.7591C11.5321 9.64823 12.3279 8.74323 13.3296 8.74323C14.3379 8.74323 15.1429 9.65657 15.1271 10.7599C15.1271 11.8707 14.3387 12.7749 13.3296 12.7749Z" />
</g>
<defs>
@ -22,4 +21,3 @@ export const DiscordIcon: React.FC<Props> = ({ width = "24", height = "24", clas
</defs>
</svg>
);
};

View File

@ -2,8 +2,7 @@ import React from "react";
import type { Props } from "./types";
export const DocumentIcon: React.FC<Props> = ({ width = "24", height = "24", className }) => {
return (
export const DocumentIcon: React.FC<Props> = ({ width = "24", height = "24", className }) => (
<svg
width={width}
height={height}
@ -15,4 +14,3 @@ export const DocumentIcon: React.FC<Props> = ({ width = "24", height = "24", cla
<path d="M7.27051 14.792H12.7288C12.9094 14.792 13.0587 14.733 13.1768 14.6149C13.2948 14.4969 13.3538 14.3475 13.3538 14.167C13.3538 13.9864 13.2948 13.8371 13.1768 13.7191C13.0587 13.601 12.9094 13.542 12.7288 13.542H7.27051C7.08995 13.542 6.94065 13.601 6.82259 13.7191C6.70454 13.8371 6.64551 13.9864 6.64551 14.167C6.64551 14.3475 6.70454 14.4969 6.82259 14.6149C6.94065 14.733 7.08995 14.792 7.27051 14.792ZM7.27051 11.2503H12.7288C12.9094 11.2503 13.0587 11.1913 13.1768 11.0732C13.2948 10.9552 13.3538 10.8059 13.3538 10.6253C13.3538 10.4448 13.2948 10.2955 13.1768 10.1774C13.0587 10.0594 12.9094 10.0003 12.7288 10.0003H7.27051C7.08995 10.0003 6.94065 10.0594 6.82259 10.1774C6.70454 10.2955 6.64551 10.4448 6.64551 10.6253C6.64551 10.8059 6.70454 10.9552 6.82259 11.0732C6.94065 11.1913 7.08995 11.2503 7.27051 11.2503ZM4.58301 18.3337C4.24967 18.3337 3.95801 18.2087 3.70801 17.9587C3.45801 17.7087 3.33301 17.417 3.33301 17.0837V2.91699C3.33301 2.58366 3.45801 2.29199 3.70801 2.04199C3.95801 1.79199 4.24967 1.66699 4.58301 1.66699H11.583C11.7497 1.66699 11.9129 1.70171 12.0726 1.77116C12.2323 1.8406 12.3677 1.93088 12.4788 2.04199L16.2913 5.85449C16.4025 5.9656 16.4927 6.10102 16.5622 6.26074C16.6316 6.42046 16.6663 6.58366 16.6663 6.75033V17.0837C16.6663 17.417 16.5413 17.7087 16.2913 17.9587C16.0413 18.2087 15.7497 18.3337 15.4163 18.3337H4.58301ZM11.4788 6.16699V2.91699H4.58301V17.0837H15.4163V6.79199H12.1038C11.9233 6.79199 11.774 6.73296 11.6559 6.61491C11.5379 6.49685 11.4788 6.34755 11.4788 6.16699ZM4.58301 2.91699V6.79199V2.91699V17.0837V2.91699Z" />
</svg>
);
};

View File

@ -2,8 +2,7 @@ import React from "react";
import type { Props } from "./types";
export const EditIcon: React.FC<Props> = ({ width, height, className }) => {
return (
export const EditIcon: React.FC<Props> = ({ width, height, className }) => (
<svg
width={width}
height={height}
@ -18,4 +17,3 @@ export const EditIcon: React.FC<Props> = ({ width, height, className }) => {
/>
</svg>
);
};

View File

@ -2,8 +2,7 @@ import React from "react";
import type { Props } from "./types";
export const EllipsisHorizontalIcon: React.FC<Props> = ({ width, height, className }) => {
return (
export const EllipsisHorizontalIcon: React.FC<Props> = ({ width, height, className }) => (
<svg
width={width}
height={height}
@ -18,4 +17,3 @@ export const EllipsisHorizontalIcon: React.FC<Props> = ({ width, height, classNa
/>
</svg>
);
};

View File

@ -2,8 +2,7 @@ import React from "react";
import type { Props } from "./types";
export const GithubIcon: React.FC<Props> = ({ width = "24", height = "24", className }) => {
return (
export const GithubIcon: React.FC<Props> = ({ width = "24", height = "24", className }) => (
<svg
width={width}
height={height}
@ -12,10 +11,10 @@ export const GithubIcon: React.FC<Props> = ({ width = "24", height = "24", class
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<g clip-path="url(#clip0_282_232)">
<g clipPath="url(#clip0_282_232)">
<path
fill-rule="evenodd"
clip-rule="evenodd"
fillRule="evenodd"
clipRule="evenodd"
d="M10 0C4.475 0 0 4.475 0 10C0 14.425 2.8625 18.1625 6.8375 19.4875C7.3375 19.575 7.525 19.275 7.525 19.0125C7.525 18.775 7.5125 17.9875 7.5125 17.15C5 17.6125 4.35 16.5375 4.15 15.975C4.0375 15.6875 3.55 14.8 3.125 14.5625C2.775 14.375 2.275 13.9125 3.1125 13.9C3.9 13.8875 4.4625 14.625 4.65 14.925C5.55 16.4375 6.9875 16.0125 7.5625 15.75C7.65 15.1 7.9125 14.6625 8.2 14.4125C5.975 14.1625 3.65 13.3 3.65 9.475C3.65 8.3875 4.0375 7.4875 4.675 6.7875C4.575 6.5375 4.225 5.5125 4.775 4.1375C4.775 4.1375 5.6125 3.875 7.525 5.1625C8.325 4.9375 9.175 4.825 10.025 4.825C10.875 4.825 11.725 4.9375 12.525 5.1625C14.4375 3.8625 15.275 4.1375 15.275 4.1375C15.825 5.5125 15.475 6.5375 15.375 6.7875C16.0125 7.4875 16.4 8.375 16.4 9.475C16.4 13.3125 14.0625 14.1625 11.8375 14.4125C12.2 14.725 12.5125 15.325 12.5125 16.2625C12.5125 17.6 12.5 18.675 12.5 19.0125C12.5 19.275 12.6875 19.5875 13.1875 19.4875C15.1726 18.8173 16.8976 17.5414 18.1197 15.8395C19.3418 14.1375 19.9994 12.0952 20 10C20 4.475 15.525 0 10 0Z"
fill="#858E96"
/>
@ -27,4 +26,3 @@ export const GithubIcon: React.FC<Props> = ({ width = "24", height = "24", class
</defs>
</svg>
);
};

View File

@ -2,8 +2,7 @@ import React from "react";
import type { Props } from "./types";
export const HeartbeatIcon: React.FC<Props> = ({ width = "24", height = "24", className }) => {
return (
export const HeartbeatIcon: React.FC<Props> = ({ width = "24", height = "24", className }) => (
<svg
width={width}
height={height}
@ -21,4 +20,3 @@ export const HeartbeatIcon: React.FC<Props> = ({ width = "24", height = "24", cl
/>
</svg>
);
};

View File

@ -7,8 +7,7 @@ export const LayerDiagonalIcon: React.FC<Props> = ({
height = "24",
className,
color = "black",
}) => {
return (
}) => (
<svg
width={width}
height={height}
@ -23,4 +22,3 @@ export const LayerDiagonalIcon: React.FC<Props> = ({
/>
</svg>
);
};

View File

@ -2,8 +2,7 @@ import React from "react";
import type { Props } from "./types";
export const LockIcon: React.FC<Props> = ({ width = "24", height = "24", className }) => {
return (
export const LockIcon: React.FC<Props> = ({ width = "24", height = "24", className }) => (
<svg
width={width}
height={height}
@ -15,4 +14,3 @@ export const LockIcon: React.FC<Props> = ({ width = "24", height = "24", classNa
<path d="M6 22C5.58333 22 5.22917 21.8542 4.9375 21.5625C4.64583 21.2708 4.5 20.9167 4.5 20.5V9.65C4.5 9.23333 4.64583 8.87917 4.9375 8.5875C5.22917 8.29583 5.58333 8.15 6 8.15H7.75V5.75C7.75 4.43333 8.2125 3.3125 9.1375 2.3875C10.0625 1.4625 11.1833 1 12.5 1C13.8167 1 14.9375 1.4625 15.8625 2.3875C16.7875 3.3125 17.25 4.43333 17.25 5.75V8.15H19C19.4167 8.15 19.7708 8.29583 20.0625 8.5875C20.3542 8.87917 20.5 9.23333 20.5 9.65V20.5C20.5 20.9167 20.3542 21.2708 20.0625 21.5625C19.7708 21.8542 19.4167 22 19 22H6ZM6 20.5H19V9.65H6V20.5ZM12.5 17C13.0333 17 13.4875 16.8167 13.8625 16.45C14.2375 16.0833 14.425 15.6417 14.425 15.125C14.425 14.625 14.2375 14.1708 13.8625 13.7625C13.4875 13.3542 13.0333 13.15 12.5 13.15C11.9667 13.15 11.5125 13.3542 11.1375 13.7625C10.7625 14.1708 10.575 14.625 10.575 15.125C10.575 15.6417 10.7625 16.0833 11.1375 16.45C11.5125 16.8167 11.9667 17 12.5 17ZM9.25 8.15H15.75V5.75C15.75 4.85 15.4333 4.08333 14.8 3.45C14.1667 2.81667 13.4 2.5 12.5 2.5C11.6 2.5 10.8333 2.81667 10.2 3.45C9.56667 4.08333 9.25 4.85 9.25 5.75V8.15ZM6 20.5V9.65V20.5Z" />
</svg>
);
};

View File

@ -2,8 +2,7 @@ import React from "react";
import type { Props } from "./types";
export const MenuIcon: React.FC<Props> = ({ width = "24", height = "24", className }) => {
return (
export const MenuIcon: React.FC<Props> = ({ width = "24", height = "24", className }) => (
<svg
width={width}
height={height}
@ -18,4 +17,3 @@ export const MenuIcon: React.FC<Props> = ({ width = "24", height = "24", classNa
/>
</svg>
);
};

View File

@ -2,8 +2,7 @@ import React from "react";
import type { Props } from "./types";
export const PlusIcon: React.FC<Props> = ({ width = "24", height = "24", className }) => {
return (
export const PlusIcon: React.FC<Props> = ({ width = "24", height = "24", className }) => (
<svg
width={width}
height={height}
@ -18,4 +17,3 @@ export const PlusIcon: React.FC<Props> = ({ width = "24", height = "24", classNa
/>
</svg>
);
};

View File

@ -6,8 +6,7 @@ export const QuestionMarkCircleIcon: React.FC<Props> = ({
width = "24",
height = "24",
className,
}) => {
return (
}) => (
<svg
width={width}
height={height}
@ -19,4 +18,3 @@ export const QuestionMarkCircleIcon: React.FC<Props> = ({
<path d="M12.1 17.825C12.3667 17.825 12.5917 17.7333 12.775 17.55C12.9583 17.3667 13.05 17.1417 13.05 16.875C13.05 16.6083 12.9583 16.3833 12.775 16.2C12.5917 16.0167 12.3667 15.925 12.1 15.925C11.8333 15.925 11.6083 16.0167 11.425 16.2C11.2417 16.3833 11.15 16.6083 11.15 16.875C11.15 17.1417 11.2417 17.3667 11.425 17.55C11.6083 17.7333 11.8333 17.825 12.1 17.825ZM12.075 7.5C12.6417 7.5 13.1 7.65417 13.45 7.9625C13.8 8.27083 13.975 8.66667 13.975 9.15C13.975 9.48333 13.875 9.8125 13.675 10.1375C13.475 10.4625 13.15 10.8167 12.7 11.2C12.2667 11.5833 11.9208 11.9875 11.6625 12.4125C11.4042 12.8375 11.275 13.225 11.275 13.575C11.275 13.7583 11.3458 13.9042 11.4875 14.0125C11.6292 14.1208 11.7917 14.175 11.975 14.175C12.175 14.175 12.3417 14.1083 12.475 13.975C12.6083 13.8417 12.6917 13.675 12.725 13.475C12.775 13.1417 12.8875 12.8458 13.0625 12.5875C13.2375 12.3292 13.5083 12.05 13.875 11.75C14.375 11.3333 14.7375 10.9167 14.9625 10.5C15.1875 10.0833 15.3 9.61667 15.3 9.1C15.3 8.21667 15.0125 7.50833 14.4375 6.975C13.8625 6.44167 13.1 6.175 12.15 6.175C11.5167 6.175 10.9333 6.3 10.4 6.55C9.86667 6.8 9.425 7.16667 9.075 7.65C8.94167 7.83333 8.8875 8.02083 8.9125 8.2125C8.9375 8.40417 9.01667 8.55 9.15 8.65C9.33333 8.78333 9.52917 8.825 9.7375 8.775C9.94583 8.725 10.1167 8.60833 10.25 8.425C10.4667 8.125 10.7292 7.89583 11.0375 7.7375C11.3458 7.57917 11.6917 7.5 12.075 7.5ZM12 22C10.6 22 9.29167 21.7458 8.075 21.2375C6.85833 20.7292 5.8 20.025 4.9 19.125C4 18.225 3.29167 17.1667 2.775 15.95C2.25833 14.7333 2 13.4167 2 12C2 10.6 2.25833 9.29167 2.775 8.075C3.29167 6.85833 4 5.8 4.9 4.9C5.8 4 6.85833 3.29167 8.075 2.775C9.29167 2.25833 10.6 2 12 2C13.3833 2 14.6833 2.25833 15.9 2.775C17.1167 3.29167 18.175 4 19.075 4.9C19.975 5.8 20.6875 6.85833 21.2125 8.075C21.7375 9.29167 22 10.6 22 12C22 13.4167 21.7375 14.7333 21.2125 15.95C20.6875 17.1667 19.975 18.225 19.075 19.125C18.175 20.025 17.1167 20.7292 15.9 21.2375C14.6833 21.7458 13.3833 22 12 22ZM12 20.5C14.35 20.5 16.3542 19.6667 18.0125 18C19.6708 16.3333 20.5 14.3333 20.5 12C20.5 9.66667 19.6708 7.66667 18.0125 6C16.3542 4.33333 14.35 3.5 12 3.5C9.61667 3.5 7.60417 4.33333 5.9625 6C4.32083 7.66667 3.5 9.66667 3.5 12C3.5 14.3333 4.32083 16.3333 5.9625 18C7.60417 19.6667 9.61667 20.5 12 20.5Z" />
</svg>
);
};

View File

@ -2,8 +2,7 @@ import React from "react";
import type { Props } from "./types";
export const SettingIcon: React.FC<Props> = ({ width = "24", height = "24", className }) => {
return (
export const SettingIcon: React.FC<Props> = ({ width = "24", height = "24", className }) => (
<svg
width={width}
height={height}
@ -18,4 +17,3 @@ export const SettingIcon: React.FC<Props> = ({ width = "24", height = "24", clas
/>
</svg>
);
};

View File

@ -2,8 +2,7 @@ import React from "react";
import type { Props } from "./types";
export const SignalCellularIcon: React.FC<Props> = ({ width = "24", height = "24", className }) => {
return (
export const SignalCellularIcon: React.FC<Props> = ({ width = "24", height = "24", className }) => (
<svg
width={width}
height={height}
@ -18,4 +17,3 @@ export const SignalCellularIcon: React.FC<Props> = ({ width = "24", height = "24
/>
</svg>
);
};

View File

@ -7,8 +7,7 @@ export const TagIcon: React.FC<Props> = ({
height = "24",
className,
color = "black",
}) => {
return (
}) => (
<svg
width={width}
height={height}
@ -23,4 +22,3 @@ export const TagIcon: React.FC<Props> = ({
/>
</svg>
);
};

View File

@ -2,8 +2,7 @@ import React from "react";
import type { Props } from "./types";
export const TuneIcon: React.FC<Props> = ({ width = "24", height = "24", className }) => {
return (
export const TuneIcon: React.FC<Props> = ({ width = "24", height = "24", className }) => (
<svg
width={width}
height={height}
@ -18,4 +17,3 @@ export const TuneIcon: React.FC<Props> = ({ width = "24", height = "24", classNa
/>
</svg>
);
};

View File

@ -7,8 +7,7 @@ export const UpcomingCycleIcon: React.FC<Props> = ({
height = "24",
className,
color = "black",
}) => {
return (
}) => (
<svg xmlns="http://www.w3.org/2000/svg" height={height} width={width} className={className}>
<path
d="M28.3 44v-3H39V19.5H9v11H6V10q0-1.2.9-2.1Q7.8 7 9 7h3.25V4h3.25v3h17V4h3.25v3H39q1.2 0 2.1.9.9.9.9 2.1v31q0 1.2-.9 2.1-.9.9-2.1.9ZM16 47.3l-2.1-2.1 5.65-5.7H2.5v-3h17.05l-5.65-5.7 2.1-2.1 9.3 9.3ZM9 16.5h30V10H9Zm0 0V10v6.5Z"
@ -16,4 +15,3 @@ export const UpcomingCycleIcon: React.FC<Props> = ({
/>
</svg>
);
};

View File

@ -2,8 +2,7 @@ import React from "react";
import type { Props } from "./types";
export const UserGroupIcon: React.FC<Props> = ({ width = "24", height = "24", className }) => {
return (
export const UserGroupIcon: React.FC<Props> = ({ width = "24", height = "24", className }) => (
<svg
width={width}
height={height}
@ -18,4 +17,3 @@ export const UserGroupIcon: React.FC<Props> = ({ width = "24", height = "24", cl
/>
</svg>
);
};

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