forked from github/plane
Compare commits
16 Commits
feat/issue
...
chore/issu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb28a5fd59 | ||
|
|
57d0dade1f | ||
|
|
eda4da8aed | ||
|
|
759a604cb8 | ||
|
|
6659cfc8b0 | ||
|
|
a53b428bbd | ||
|
|
4e0e02522f | ||
|
|
f983d787b4 | ||
|
|
87abf3ccb1 | ||
|
|
d0f6ca3bac | ||
|
|
af73bbe718 | ||
|
|
9033ceb03c | ||
|
|
9bac7cb036 | ||
|
|
32d08570e7 | ||
|
|
1b1ed37405 | ||
|
|
42d38f7531 |
54
.env.example
54
.env.example
@@ -1,38 +1,3 @@
|
||||
# Frontend
|
||||
# Extra image domains that need to be added for Next Image
|
||||
NEXT_PUBLIC_EXTRA_IMAGE_DOMAINS=
|
||||
# Google Client ID for Google OAuth
|
||||
NEXT_PUBLIC_GOOGLE_CLIENTID=""
|
||||
# Github ID for Github OAuth
|
||||
NEXT_PUBLIC_GITHUB_ID=""
|
||||
# Github App Name for GitHub Integration
|
||||
NEXT_PUBLIC_GITHUB_APP_NAME=""
|
||||
# Sentry DSN for error monitoring
|
||||
NEXT_PUBLIC_SENTRY_DSN=""
|
||||
# Enable/Disable OAUTH - default 0 for selfhosted instance
|
||||
NEXT_PUBLIC_ENABLE_OAUTH=0
|
||||
# Enable/Disable sentry
|
||||
NEXT_PUBLIC_ENABLE_SENTRY=0
|
||||
# Enable/Disable session recording
|
||||
NEXT_PUBLIC_ENABLE_SESSION_RECORDER=0
|
||||
# Enable/Disable event tracking
|
||||
NEXT_PUBLIC_TRACK_EVENTS=0
|
||||
# Slack for Slack Integration
|
||||
NEXT_PUBLIC_SLACK_CLIENT_ID=""
|
||||
# For Telemetry, set it to "app.plane.so"
|
||||
NEXT_PUBLIC_PLAUSIBLE_DOMAIN=""
|
||||
# public boards deploy url
|
||||
NEXT_PUBLIC_DEPLOY_URL=""
|
||||
# plane deploy using nginx
|
||||
NEXT_PUBLIC_DEPLOY_WITH_NGINX=1
|
||||
|
||||
# Backend
|
||||
# Debug value for api server use it as 0 for production use
|
||||
DEBUG=0
|
||||
|
||||
# Error logs
|
||||
SENTRY_DSN=""
|
||||
|
||||
# Database Settings
|
||||
PGUSER="plane"
|
||||
PGPASSWORD="plane"
|
||||
@@ -45,15 +10,6 @@ REDIS_HOST="plane-redis"
|
||||
REDIS_PORT="6379"
|
||||
REDIS_URL="redis://${REDIS_HOST}:6379/"
|
||||
|
||||
# Email Settings
|
||||
EMAIL_HOST=""
|
||||
EMAIL_HOST_USER=""
|
||||
EMAIL_HOST_PASSWORD=""
|
||||
EMAIL_PORT=587
|
||||
EMAIL_FROM="Team Plane <team@mailer.plane.so>"
|
||||
EMAIL_USE_TLS="1"
|
||||
EMAIL_USE_SSL="0"
|
||||
|
||||
# AWS Settings
|
||||
AWS_REGION=""
|
||||
AWS_ACCESS_KEY_ID="access-key"
|
||||
@@ -69,9 +25,6 @@ OPENAI_API_BASE="https://api.openai.com/v1" # change if using a custom endpoint
|
||||
OPENAI_API_KEY="sk-" # add your openai key here
|
||||
GPT_ENGINE="gpt-3.5-turbo" # use "gpt-4" if you have access
|
||||
|
||||
# Github
|
||||
GITHUB_CLIENT_SECRET="" # For fetching release notes
|
||||
|
||||
# Settings related to Docker
|
||||
DOCKERIZED=1
|
||||
# set to 1 If using the pre-configured minio setup
|
||||
@@ -80,10 +33,3 @@ USE_MINIO=1
|
||||
# Nginx Configuration
|
||||
NGINX_PORT=80
|
||||
|
||||
# Default Creds
|
||||
DEFAULT_EMAIL="captain@plane.so"
|
||||
DEFAULT_PASSWORD="password123"
|
||||
|
||||
# SignUps
|
||||
ENABLE_SIGNUP="1"
|
||||
# Auto generated and Required that will be generated from setup.sh
|
||||
|
||||
60
apiserver/.env.example
Normal file
60
apiserver/.env.example
Normal file
@@ -0,0 +1,60 @@
|
||||
# Backend
|
||||
# Debug value for api server use it as 0 for production use
|
||||
DEBUG=0
|
||||
|
||||
# Error logs
|
||||
SENTRY_DSN=""
|
||||
|
||||
# Database Settings
|
||||
PGUSER="plane"
|
||||
PGPASSWORD="plane"
|
||||
PGHOST="plane-db"
|
||||
PGDATABASE="plane"
|
||||
DATABASE_URL=postgresql://${PGUSER}:${PGPASSWORD}@${PGHOST}/${PGDATABASE}
|
||||
|
||||
# Redis Settings
|
||||
REDIS_HOST="plane-redis"
|
||||
REDIS_PORT="6379"
|
||||
REDIS_URL="redis://${REDIS_HOST}:6379/"
|
||||
|
||||
# Email Settings
|
||||
EMAIL_HOST=""
|
||||
EMAIL_HOST_USER=""
|
||||
EMAIL_HOST_PASSWORD=""
|
||||
EMAIL_PORT=587
|
||||
EMAIL_FROM="Team Plane <team@mailer.plane.so>"
|
||||
EMAIL_USE_TLS="1"
|
||||
EMAIL_USE_SSL="0"
|
||||
|
||||
# AWS Settings
|
||||
AWS_REGION=""
|
||||
AWS_ACCESS_KEY_ID="access-key"
|
||||
AWS_SECRET_ACCESS_KEY="secret-key"
|
||||
AWS_S3_ENDPOINT_URL="http://plane-minio:9000"
|
||||
# Changing this requires change in the nginx.conf for uploads if using minio setup
|
||||
AWS_S3_BUCKET_NAME="uploads"
|
||||
# Maximum file upload limit
|
||||
FILE_SIZE_LIMIT=5242880
|
||||
|
||||
# GPT settings
|
||||
OPENAI_API_BASE="https://api.openai.com/v1" # change if using a custom endpoint
|
||||
OPENAI_API_KEY="sk-" # add your openai key here
|
||||
GPT_ENGINE="gpt-3.5-turbo" # use "gpt-4" if you have access
|
||||
|
||||
# Github
|
||||
GITHUB_CLIENT_SECRET="" # For fetching release notes
|
||||
|
||||
# Settings related to Docker
|
||||
DOCKERIZED=1
|
||||
# set to 1 If using the pre-configured minio setup
|
||||
USE_MINIO=1
|
||||
|
||||
# Nginx Configuration
|
||||
NGINX_PORT=80
|
||||
|
||||
# Default Creds
|
||||
DEFAULT_EMAIL="captain@plane.so"
|
||||
DEFAULT_PASSWORD="password123"
|
||||
|
||||
# SignUps
|
||||
ENABLE_SIGNUP="1"
|
||||
@@ -49,6 +49,7 @@ class IssueFlatSerializer(BaseSerializer):
|
||||
"target_date",
|
||||
"sequence_id",
|
||||
"sort_order",
|
||||
"is_draft",
|
||||
]
|
||||
|
||||
|
||||
@@ -292,12 +293,12 @@ class IssueLabelSerializer(BaseSerializer):
|
||||
|
||||
|
||||
class IssueRelationSerializer(BaseSerializer):
|
||||
related_issue_detail = IssueProjectLiteSerializer(read_only=True, source="related_issue")
|
||||
issue_detail = IssueProjectLiteSerializer(read_only=True, source="related_issue")
|
||||
|
||||
class Meta:
|
||||
model = IssueRelation
|
||||
fields = [
|
||||
"related_issue_detail",
|
||||
"issue_detail",
|
||||
"relation_type",
|
||||
"related_issue",
|
||||
"issue",
|
||||
|
||||
@@ -1038,6 +1038,7 @@ urlpatterns = [
|
||||
IssueDraftViewSet.as_view(
|
||||
{
|
||||
"get": "list",
|
||||
"post": "create",
|
||||
}
|
||||
),
|
||||
name="project-issue-draft",
|
||||
@@ -1047,6 +1048,7 @@ urlpatterns = [
|
||||
IssueDraftViewSet.as_view(
|
||||
{
|
||||
"get": "retrieve",
|
||||
"patch": "partial_update",
|
||||
"delete": "destroy",
|
||||
}
|
||||
),
|
||||
|
||||
@@ -517,6 +517,7 @@ class CycleIssueViewSet(BaseViewSet):
|
||||
try:
|
||||
order_by = request.GET.get("order_by", "created_at")
|
||||
group_by = request.GET.get("group_by", False)
|
||||
sub_group_by = request.GET.get("sub_group_by", False)
|
||||
filters = issue_filters(request.query_params, "GET")
|
||||
issues = (
|
||||
Issue.issue_objects.filter(issue_cycle__cycle_id=cycle_id)
|
||||
@@ -555,9 +556,15 @@ class CycleIssueViewSet(BaseViewSet):
|
||||
|
||||
issues_data = IssueStateSerializer(issues, many=True).data
|
||||
|
||||
if sub_group_by and sub_group_by == group_by:
|
||||
return Response(
|
||||
{"error": "Group by and sub group by cannot be same"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
if group_by:
|
||||
return Response(
|
||||
group_results(issues_data, group_by),
|
||||
group_results(issues_data, group_by, sub_group_by),
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@ from plane.api.serializers import (
|
||||
CommentReactionSerializer,
|
||||
IssueVoteSerializer,
|
||||
IssueRelationSerializer,
|
||||
RelatedIssueSerializer,
|
||||
IssuePublicSerializer,
|
||||
)
|
||||
from plane.api.permissions import (
|
||||
@@ -269,9 +270,16 @@ class IssueViewSet(BaseViewSet):
|
||||
|
||||
## Grouping the results
|
||||
group_by = request.GET.get("group_by", False)
|
||||
sub_group_by = request.GET.get("sub_group_by", False)
|
||||
if sub_group_by and sub_group_by == group_by:
|
||||
return Response(
|
||||
{"error": "Group by and sub group by cannot be same"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
if group_by:
|
||||
return Response(
|
||||
group_results(issues, group_by), status=status.HTTP_200_OK
|
||||
group_results(issues, group_by, sub_group_by), status=status.HTTP_200_OK
|
||||
)
|
||||
|
||||
return Response(issues, status=status.HTTP_200_OK)
|
||||
@@ -446,9 +454,16 @@ class UserWorkSpaceIssues(BaseAPIView):
|
||||
|
||||
## Grouping the results
|
||||
group_by = request.GET.get("group_by", False)
|
||||
sub_group_by = request.GET.get("sub_group_by", False)
|
||||
if sub_group_by and sub_group_by == group_by:
|
||||
return Response(
|
||||
{"error": "Group by and sub group by cannot be same"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
if group_by:
|
||||
return Response(
|
||||
group_results(issues, group_by), status=status.HTTP_200_OK
|
||||
group_results(issues, group_by, sub_group_by), status=status.HTTP_200_OK
|
||||
)
|
||||
|
||||
return Response(issues, status=status.HTTP_200_OK)
|
||||
@@ -494,7 +509,7 @@ class IssueActivityEndpoint(BaseAPIView):
|
||||
issue_activities = (
|
||||
IssueActivity.objects.filter(issue_id=issue_id)
|
||||
.filter(
|
||||
~Q(field__in=["comment", "vote", "reaction"]),
|
||||
~Q(field__in=["comment", "vote", "reaction", "draft"]),
|
||||
project__project_projectmember__member=self.request.user,
|
||||
)
|
||||
.select_related("actor", "workspace", "issue", "project")
|
||||
@@ -2071,9 +2086,10 @@ class IssueRelationViewSet(BaseViewSet):
|
||||
def create(self, request, slug, project_id, issue_id):
|
||||
try:
|
||||
related_list = request.data.get("related_list", [])
|
||||
relation = request.data.get("relation", None)
|
||||
project = Project.objects.get(pk=project_id)
|
||||
|
||||
issueRelation = IssueRelation.objects.bulk_create(
|
||||
issue_relation = IssueRelation.objects.bulk_create(
|
||||
[
|
||||
IssueRelation(
|
||||
issue_id=related_issue["issue"],
|
||||
@@ -2098,11 +2114,17 @@ class IssueRelationViewSet(BaseViewSet):
|
||||
project_id=str(project_id),
|
||||
current_instance=None,
|
||||
)
|
||||
|
||||
return Response(
|
||||
IssueRelationSerializer(issueRelation, many=True).data,
|
||||
status=status.HTTP_201_CREATED,
|
||||
)
|
||||
|
||||
if relation == "blocking":
|
||||
return Response(
|
||||
RelatedIssueSerializer(issue_relation, many=True).data,
|
||||
status=status.HTTP_201_CREATED,
|
||||
)
|
||||
else:
|
||||
return Response(
|
||||
IssueRelationSerializer(issue_relation, many=True).data,
|
||||
status=status.HTTP_201_CREATED,
|
||||
)
|
||||
except IntegrityError as e:
|
||||
if "already exists" in str(e):
|
||||
return Response(
|
||||
@@ -2344,6 +2366,47 @@ class IssueDraftViewSet(BaseViewSet):
|
||||
serializer_class = IssueFlatSerializer
|
||||
model = Issue
|
||||
|
||||
|
||||
def perform_update(self, serializer):
|
||||
requested_data = json.dumps(self.request.data, cls=DjangoJSONEncoder)
|
||||
current_instance = (
|
||||
self.get_queryset().filter(pk=self.kwargs.get("pk", None)).first()
|
||||
)
|
||||
if current_instance is not None:
|
||||
issue_activity.delay(
|
||||
type="issue_draft.activity.updated",
|
||||
requested_data=requested_data,
|
||||
actor_id=str(self.request.user.id),
|
||||
issue_id=str(self.kwargs.get("pk", None)),
|
||||
project_id=str(self.kwargs.get("project_id", None)),
|
||||
current_instance=json.dumps(
|
||||
IssueSerializer(current_instance).data, cls=DjangoJSONEncoder
|
||||
),
|
||||
)
|
||||
|
||||
return super().perform_update(serializer)
|
||||
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
current_instance = (
|
||||
self.get_queryset().filter(pk=self.kwargs.get("pk", None)).first()
|
||||
)
|
||||
if current_instance is not None:
|
||||
issue_activity.delay(
|
||||
type="issue_draft.activity.deleted",
|
||||
requested_data=json.dumps(
|
||||
{"issue_id": str(self.kwargs.get("pk", None))}
|
||||
),
|
||||
actor_id=str(self.request.user.id),
|
||||
issue_id=str(self.kwargs.get("pk", None)),
|
||||
project_id=str(self.kwargs.get("project_id", None)),
|
||||
current_instance=json.dumps(
|
||||
IssueSerializer(current_instance).data, cls=DjangoJSONEncoder
|
||||
),
|
||||
)
|
||||
return super().perform_destroy(instance)
|
||||
|
||||
|
||||
def get_queryset(self):
|
||||
return (
|
||||
Issue.objects.annotate(
|
||||
@@ -2369,6 +2432,7 @@ class IssueDraftViewSet(BaseViewSet):
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@method_decorator(gzip_page)
|
||||
def list(self, request, slug, project_id):
|
||||
try:
|
||||
@@ -2478,6 +2542,40 @@ class IssueDraftViewSet(BaseViewSet):
|
||||
)
|
||||
|
||||
|
||||
def create(self, request, slug, project_id):
|
||||
try:
|
||||
project = Project.objects.get(pk=project_id)
|
||||
|
||||
serializer = IssueCreateSerializer(
|
||||
data=request.data,
|
||||
context={
|
||||
"project_id": project_id,
|
||||
"workspace_id": project.workspace_id,
|
||||
"default_assignee_id": project.default_assignee_id,
|
||||
},
|
||||
)
|
||||
|
||||
if serializer.is_valid():
|
||||
serializer.save(is_draft=True)
|
||||
|
||||
# Track the issue
|
||||
issue_activity.delay(
|
||||
type="issue_draft.activity.created",
|
||||
requested_data=json.dumps(self.request.data, cls=DjangoJSONEncoder),
|
||||
actor_id=str(request.user.id),
|
||||
issue_id=str(serializer.data.get("id", None)),
|
||||
project_id=str(project_id),
|
||||
current_instance=None,
|
||||
)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
except Project.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Project was not found"}, status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
|
||||
|
||||
def retrieve(self, request, slug, project_id, pk=None):
|
||||
try:
|
||||
issue = Issue.objects.get(
|
||||
|
||||
@@ -308,6 +308,7 @@ class ModuleIssueViewSet(BaseViewSet):
|
||||
try:
|
||||
order_by = request.GET.get("order_by", "created_at")
|
||||
group_by = request.GET.get("group_by", False)
|
||||
sub_group_by = request.GET.get("sub_group_by", False)
|
||||
filters = issue_filters(request.query_params, "GET")
|
||||
issues = (
|
||||
Issue.issue_objects.filter(issue_module__module_id=module_id)
|
||||
@@ -346,9 +347,15 @@ class ModuleIssueViewSet(BaseViewSet):
|
||||
|
||||
issues_data = IssueStateSerializer(issues, many=True).data
|
||||
|
||||
if sub_group_by and sub_group_by == group_by:
|
||||
return Response(
|
||||
{"error": "Group by and sub group by cannot be same"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
if group_by:
|
||||
return Response(
|
||||
group_results(issues_data, group_by),
|
||||
group_results(issues_data, group_by, sub_group_by),
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
|
||||
@@ -396,16 +396,16 @@ def track_assignees(
|
||||
def create_issue_activity(
|
||||
requested_data, current_instance, issue_id, project, actor, issue_activities
|
||||
):
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
issue_id=issue_id,
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"created the issue",
|
||||
verb="created",
|
||||
actor=actor,
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
issue_id=issue_id,
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"created the issue",
|
||||
verb="created",
|
||||
actor=actor,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def track_estimate_points(
|
||||
@@ -518,11 +518,6 @@ def update_issue_activity(
|
||||
"closed_to": track_closed_to,
|
||||
}
|
||||
|
||||
requested_data = json.loads(requested_data) if requested_data is not None else None
|
||||
current_instance = (
|
||||
json.loads(current_instance) if current_instance is not None else None
|
||||
)
|
||||
|
||||
for key in requested_data:
|
||||
func = ISSUE_ACTIVITY_MAPPER.get(key, None)
|
||||
if func is not None:
|
||||
@@ -1053,6 +1048,25 @@ def create_issue_relation_activity(
|
||||
)
|
||||
if current_instance is None and requested_data.get("related_list") is not None:
|
||||
for issue_relation in requested_data.get("related_list"):
|
||||
if issue_relation.get("relation_type") == "blocked_by":
|
||||
relation_type = "blocking"
|
||||
else:
|
||||
relation_type = issue_relation.get("relation_type")
|
||||
issue = Issue.objects.get(pk=issue_relation.get("issue"))
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
issue_id=issue_relation.get("related_issue"),
|
||||
actor=actor,
|
||||
verb="created",
|
||||
old_value="",
|
||||
new_value=f"{project.identifier}-{issue.sequence_id}",
|
||||
field=relation_type,
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f'added {relation_type} relation',
|
||||
old_identifier=issue_relation.get("issue"),
|
||||
)
|
||||
)
|
||||
issue = Issue.objects.get(pk=issue_relation.get("related_issue"))
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
@@ -1065,7 +1079,7 @@ def create_issue_relation_activity(
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f'added {issue_relation.get("relation_type")} relation',
|
||||
old_identifier=issue_relation.get("issue"),
|
||||
old_identifier=issue_relation.get("related_issue"),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1078,23 +1092,105 @@ def delete_issue_relation_activity(
|
||||
json.loads(current_instance) if current_instance is not None else None
|
||||
)
|
||||
if current_instance is not None and requested_data.get("related_list") is None:
|
||||
issue = Issue.objects.get(pk=current_instance.get("issue"))
|
||||
if current_instance.get("relation_type") == "blocked_by":
|
||||
relation_type = "blocking"
|
||||
else:
|
||||
relation_type = current_instance.get("relation_type")
|
||||
issue = Issue.objects.get(pk=current_instance.get("issue"))
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
issue_id=current_instance.get("related_issue"),
|
||||
actor=actor,
|
||||
verb="deleted",
|
||||
old_value="",
|
||||
new_value=f"{project.identifier}-{issue.sequence_id}",
|
||||
field=relation_type,
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f'deleted {relation_type} relation',
|
||||
old_identifier=current_instance.get("issue"),
|
||||
)
|
||||
)
|
||||
issue = Issue.objects.get(pk=current_instance.get("related_issue"))
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
issue_id=current_instance.get("issue"),
|
||||
actor=actor,
|
||||
verb="deleted",
|
||||
old_value="",
|
||||
new_value=f"{project.identifier}-{issue.sequence_id}",
|
||||
field=f'{current_instance.get("relation_type")}',
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f'deleted {current_instance.get("relation_type")} relation',
|
||||
old_identifier=current_instance.get("related_issue"),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def create_draft_issue_activity(
|
||||
requested_data, current_instance, issue_id, project, actor, issue_activities
|
||||
):
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
issue_id=current_instance.get("issue"),
|
||||
actor=actor,
|
||||
verb="deleted",
|
||||
old_value=f"{project.identifier}-{issue.sequence_id}",
|
||||
new_value="",
|
||||
field=f'{current_instance.get("relation_type")}',
|
||||
issue_id=issue_id,
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f'deleted the {current_instance.get("relation_type")} relation',
|
||||
old_identifier=current_instance.get("issue"),
|
||||
comment=f"drafted the issue",
|
||||
field="draft",
|
||||
verb="created",
|
||||
actor=actor,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def update_draft_issue_activity(
|
||||
requested_data, current_instance, issue_id, project, actor, issue_activities
|
||||
):
|
||||
requested_data = json.loads(requested_data) if requested_data is not None else None
|
||||
current_instance = (
|
||||
json.loads(current_instance) if current_instance is not None else None
|
||||
)
|
||||
if requested_data.get("is_draft") is not None and requested_data.get("is_draft") == False:
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
issue_id=issue_id,
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"created the issue",
|
||||
verb="updated",
|
||||
actor=actor,
|
||||
)
|
||||
)
|
||||
else:
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
issue_id=issue_id,
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"updated the draft issue",
|
||||
field="draft",
|
||||
verb="updated",
|
||||
actor=actor,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
|
||||
def delete_draft_issue_activity(
|
||||
requested_data, current_instance, issue_id, project, actor, issue_activities
|
||||
):
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"deleted the draft issue",
|
||||
field="draft",
|
||||
verb="deleted",
|
||||
actor=actor,
|
||||
)
|
||||
)
|
||||
|
||||
# Receive message from room group
|
||||
@shared_task
|
||||
def issue_activity(
|
||||
@@ -1166,6 +1262,9 @@ def issue_activity(
|
||||
"comment_reaction.activity.deleted": delete_comment_reaction_activity,
|
||||
"issue_vote.activity.created": create_issue_vote_activity,
|
||||
"issue_vote.activity.deleted": delete_issue_vote_activity,
|
||||
"issue_draft.activity.created": create_draft_issue_activity,
|
||||
"issue_draft.activity.updated": update_draft_issue_activity,
|
||||
"issue_draft.activity.deleted": delete_draft_issue_activity,
|
||||
}
|
||||
|
||||
func = ACTIVITY_MAPPER.get(type)
|
||||
|
||||
23
apiserver/plane/db/migrations/0045_auto_20230915_0655.py
Normal file
23
apiserver/plane/db/migrations/0045_auto_20230915_0655.py
Normal file
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 4.2.3 on 2023-09-15 06:55
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
def update_issue_activity(apps, schema_editor):
|
||||
IssueActivityModel = apps.get_model("db", "IssueActivity")
|
||||
updated_issue_activity = []
|
||||
for obj in IssueActivityModel.objects.all():
|
||||
if obj.field == "blocks":
|
||||
obj.field = "blocked_by"
|
||||
updated_issue_activity.append(obj)
|
||||
IssueActivityModel.objects.bulk_update(updated_issue_activity, ["field"], batch_size=100)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('db', '0044_auto_20230913_0709'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(update_issue_activity),
|
||||
]
|
||||
@@ -25,13 +25,26 @@ ROLE_CHOICES = (
|
||||
|
||||
def get_default_props():
|
||||
return {
|
||||
"filters": {"type": None},
|
||||
"orderBy": "-created_at",
|
||||
"collapsed": True,
|
||||
"issueView": "list",
|
||||
"filterIssue": None,
|
||||
"groupByProperty": None,
|
||||
"showEmptyGroups": True,
|
||||
"filters": {
|
||||
"priority": None,
|
||||
"state": None,
|
||||
"state_group": None,
|
||||
"assignees": None,
|
||||
"created_by": None,
|
||||
"labels": None,
|
||||
"start_date": None,
|
||||
"target_date": None,
|
||||
"subscriber": None,
|
||||
},
|
||||
"display_filters": {
|
||||
"group_by": None,
|
||||
"order_by": '-created_at',
|
||||
"type": None,
|
||||
"sub_issue": True,
|
||||
"show_empty_groups": True,
|
||||
"layout": "list",
|
||||
"calendar_date_range": "",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -16,26 +16,41 @@ ROLE_CHOICES = (
|
||||
|
||||
def get_default_props():
|
||||
return {
|
||||
"filters": {"type": None},
|
||||
"groupByProperty": None,
|
||||
"issueView": "list",
|
||||
"orderBy": "-created_at",
|
||||
"properties": {
|
||||
"filters": {
|
||||
"priority": None,
|
||||
"state": None,
|
||||
"state_group": None,
|
||||
"assignees": None,
|
||||
"created_by": None,
|
||||
"labels": None,
|
||||
"start_date": None,
|
||||
"target_date": None,
|
||||
"subscriber": None,
|
||||
},
|
||||
"display_filters": {
|
||||
"group_by": None,
|
||||
"order_by": '-created_at',
|
||||
"type": None,
|
||||
"sub_issue": True,
|
||||
"show_empty_groups": True,
|
||||
"layout": "list",
|
||||
"calendar_date_range": "",
|
||||
},
|
||||
"display_properties": {
|
||||
"assignee": True,
|
||||
"attachment_count": True,
|
||||
"created_on": True,
|
||||
"due_date": True,
|
||||
"estimate": True,
|
||||
"key": True,
|
||||
"labels": True,
|
||||
"link": True,
|
||||
"priority": True,
|
||||
"start_date": True,
|
||||
"state": True,
|
||||
"sub_issue_count": True,
|
||||
"attachment_count": True,
|
||||
"link": True,
|
||||
"estimate": True,
|
||||
"created_on": True,
|
||||
"updated_on": True,
|
||||
"start_date": True,
|
||||
},
|
||||
"showEmptyGroups": True,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ def resolve_keys(group_keys, value):
|
||||
return value
|
||||
|
||||
|
||||
def group_results(results_data, group_by):
|
||||
def group_results(results_data, group_by, sub_group_by=False):
|
||||
"""group results data into certain group_by
|
||||
|
||||
Args:
|
||||
@@ -25,38 +25,140 @@ def group_results(results_data, group_by):
|
||||
Returns:
|
||||
obj: grouped results
|
||||
"""
|
||||
response_dict = dict()
|
||||
if sub_group_by:
|
||||
main_responsive_dict = dict()
|
||||
|
||||
if group_by == "priority":
|
||||
response_dict = {
|
||||
"urgent": [],
|
||||
"high": [],
|
||||
"medium": [],
|
||||
"low": [],
|
||||
"None": [],
|
||||
}
|
||||
if sub_group_by == "priority":
|
||||
main_responsive_dict = {
|
||||
"urgent": {},
|
||||
"high": {},
|
||||
"medium": {},
|
||||
"low": {},
|
||||
"none": {},
|
||||
}
|
||||
|
||||
for value in results_data:
|
||||
group_attribute = resolve_keys(group_by, value)
|
||||
if isinstance(group_attribute, list):
|
||||
if len(group_attribute):
|
||||
for attrib in group_attribute:
|
||||
if str(attrib) in response_dict:
|
||||
response_dict[str(attrib)].append(value)
|
||||
else:
|
||||
response_dict[str(attrib)] = []
|
||||
response_dict[str(attrib)].append(value)
|
||||
else:
|
||||
if str(None) in response_dict:
|
||||
response_dict[str(None)].append(value)
|
||||
for value in results_data:
|
||||
main_group_attribute = resolve_keys(sub_group_by, value)
|
||||
group_attribute = resolve_keys(group_by, value)
|
||||
if isinstance(main_group_attribute, list) and not isinstance(group_attribute, list):
|
||||
if len(main_group_attribute):
|
||||
for attrib in main_group_attribute:
|
||||
if str(attrib) not in main_responsive_dict:
|
||||
main_responsive_dict[str(attrib)] = {}
|
||||
if str(group_attribute) in main_responsive_dict[str(attrib)]:
|
||||
main_responsive_dict[str(attrib)][str(group_attribute)].append(value)
|
||||
else:
|
||||
main_responsive_dict[str(attrib)][str(group_attribute)] = []
|
||||
main_responsive_dict[str(attrib)][str(group_attribute)].append(value)
|
||||
else:
|
||||
response_dict[str(None)] = []
|
||||
response_dict[str(None)].append(value)
|
||||
else:
|
||||
if str(group_attribute) in response_dict:
|
||||
response_dict[str(group_attribute)].append(value)
|
||||
else:
|
||||
response_dict[str(group_attribute)] = []
|
||||
response_dict[str(group_attribute)].append(value)
|
||||
if str(None) not in main_responsive_dict:
|
||||
main_responsive_dict[str(None)] = {}
|
||||
|
||||
return response_dict
|
||||
if str(group_attribute) in main_responsive_dict[str(None)]:
|
||||
main_responsive_dict[str(None)][str(group_attribute)].append(value)
|
||||
else:
|
||||
main_responsive_dict[str(None)][str(group_attribute)] = []
|
||||
main_responsive_dict[str(None)][str(group_attribute)].append(value)
|
||||
|
||||
elif isinstance(group_attribute, list) and not isinstance(main_group_attribute, list):
|
||||
if str(main_group_attribute) not in main_responsive_dict:
|
||||
main_responsive_dict[str(main_group_attribute)] = {}
|
||||
if len(group_attribute):
|
||||
for attrib in group_attribute:
|
||||
if str(attrib) in main_responsive_dict[str(main_group_attribute)]:
|
||||
main_responsive_dict[str(main_group_attribute)][str(attrib)].append(value)
|
||||
else:
|
||||
main_responsive_dict[str(main_group_attribute)][str(attrib)] = []
|
||||
main_responsive_dict[str(main_group_attribute)][str(attrib)].append(value)
|
||||
else:
|
||||
if str(None) in main_responsive_dict[str(main_group_attribute)]:
|
||||
main_responsive_dict[str(main_group_attribute)][str(None)].append(value)
|
||||
else:
|
||||
main_responsive_dict[str(main_group_attribute)][str(None)] = []
|
||||
main_responsive_dict[str(main_group_attribute)][str(None)].append(value)
|
||||
|
||||
elif isinstance(group_attribute, list) and isinstance(main_group_attribute, list):
|
||||
if len(main_group_attribute):
|
||||
for main_attrib in main_group_attribute:
|
||||
if str(main_attrib) not in main_responsive_dict:
|
||||
main_responsive_dict[str(main_attrib)] = {}
|
||||
if len(group_attribute):
|
||||
for attrib in group_attribute:
|
||||
if str(attrib) in main_responsive_dict[str(main_attrib)]:
|
||||
main_responsive_dict[str(main_attrib)][str(attrib)].append(value)
|
||||
else:
|
||||
main_responsive_dict[str(main_attrib)][str(attrib)] = []
|
||||
main_responsive_dict[str(main_attrib)][str(attrib)].append(value)
|
||||
else:
|
||||
if str(None) in main_responsive_dict[str(main_attrib)]:
|
||||
main_responsive_dict[str(main_attrib)][str(None)].append(value)
|
||||
else:
|
||||
main_responsive_dict[str(main_attrib)][str(None)] = []
|
||||
main_responsive_dict[str(main_attrib)][str(None)].append(value)
|
||||
else:
|
||||
if str(None) not in main_responsive_dict:
|
||||
main_responsive_dict[str(None)] = {}
|
||||
if len(group_attribute):
|
||||
for attrib in group_attribute:
|
||||
if str(attrib) in main_responsive_dict[str(None)]:
|
||||
main_responsive_dict[str(None)][str(attrib)].append(value)
|
||||
else:
|
||||
main_responsive_dict[str(None)][str(attrib)] = []
|
||||
main_responsive_dict[str(None)][str(attrib)].append(value)
|
||||
else:
|
||||
if str(None) in main_responsive_dict[str(None)]:
|
||||
main_responsive_dict[str(None)][str(None)].append(value)
|
||||
else:
|
||||
main_responsive_dict[str(None)][str(None)] = []
|
||||
main_responsive_dict[str(None)][str(None)].append(value)
|
||||
else:
|
||||
main_group_attribute = resolve_keys(sub_group_by, value)
|
||||
group_attribute = resolve_keys(group_by, value)
|
||||
|
||||
if str(main_group_attribute) not in main_responsive_dict:
|
||||
main_responsive_dict[str(main_group_attribute)] = {}
|
||||
|
||||
if str(group_attribute) in main_responsive_dict[str(main_group_attribute)]:
|
||||
main_responsive_dict[str(main_group_attribute)][str(group_attribute)].append(value)
|
||||
else:
|
||||
main_responsive_dict[str(main_group_attribute)][str(group_attribute)] = []
|
||||
main_responsive_dict[str(main_group_attribute)][str(group_attribute)].append(value)
|
||||
|
||||
return main_responsive_dict
|
||||
|
||||
else:
|
||||
response_dict = dict()
|
||||
|
||||
if group_by == "priority":
|
||||
response_dict = {
|
||||
"urgent": [],
|
||||
"high": [],
|
||||
"medium": [],
|
||||
"low": [],
|
||||
"none": [],
|
||||
}
|
||||
|
||||
for value in results_data:
|
||||
group_attribute = resolve_keys(group_by, value)
|
||||
if isinstance(group_attribute, list):
|
||||
if len(group_attribute):
|
||||
for attrib in group_attribute:
|
||||
if str(attrib) in response_dict:
|
||||
response_dict[str(attrib)].append(value)
|
||||
else:
|
||||
response_dict[str(attrib)] = []
|
||||
response_dict[str(attrib)].append(value)
|
||||
else:
|
||||
if str(None) in response_dict:
|
||||
response_dict[str(None)].append(value)
|
||||
else:
|
||||
response_dict[str(None)] = []
|
||||
response_dict[str(None)].append(value)
|
||||
else:
|
||||
if str(group_attribute) in response_dict:
|
||||
response_dict[str(group_attribute)].append(value)
|
||||
else:
|
||||
response_dict[str(group_attribute)] = []
|
||||
response_dict[str(group_attribute)].append(value)
|
||||
|
||||
return response_dict
|
||||
|
||||
@@ -1,37 +1,5 @@
|
||||
version: "3.8"
|
||||
|
||||
x-api-and-worker-env: &api-and-worker-env
|
||||
DEBUG: ${DEBUG}
|
||||
SENTRY_DSN: ${SENTRY_DSN}
|
||||
DJANGO_SETTINGS_MODULE: plane.settings.production
|
||||
DATABASE_URL: postgres://${PGUSER}:${PGPASSWORD}@${PGHOST}:5432/${PGDATABASE}
|
||||
REDIS_URL: redis://plane-redis:6379/
|
||||
EMAIL_HOST: ${EMAIL_HOST}
|
||||
EMAIL_HOST_USER: ${EMAIL_HOST_USER}
|
||||
EMAIL_HOST_PASSWORD: ${EMAIL_HOST_PASSWORD}
|
||||
EMAIL_PORT: ${EMAIL_PORT}
|
||||
EMAIL_FROM: ${EMAIL_FROM}
|
||||
EMAIL_USE_TLS: ${EMAIL_USE_TLS}
|
||||
EMAIL_USE_SSL: ${EMAIL_USE_SSL}
|
||||
AWS_REGION: ${AWS_REGION}
|
||||
AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID}
|
||||
AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY}
|
||||
AWS_S3_BUCKET_NAME: ${AWS_S3_BUCKET_NAME}
|
||||
AWS_S3_ENDPOINT_URL: ${AWS_S3_ENDPOINT_URL}
|
||||
FILE_SIZE_LIMIT: ${FILE_SIZE_LIMIT}
|
||||
WEB_URL: ${WEB_URL}
|
||||
GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET}
|
||||
DISABLE_COLLECTSTATIC: 1
|
||||
DOCKERIZED: 1
|
||||
OPENAI_API_BASE: ${OPENAI_API_BASE}
|
||||
OPENAI_API_KEY: ${OPENAI_API_KEY}
|
||||
GPT_ENGINE: ${GPT_ENGINE}
|
||||
SECRET_KEY: ${SECRET_KEY}
|
||||
DEFAULT_EMAIL: ${DEFAULT_EMAIL}
|
||||
DEFAULT_PASSWORD: ${DEFAULT_PASSWORD}
|
||||
USE_MINIO: ${USE_MINIO}
|
||||
ENABLE_SIGNUP: ${ENABLE_SIGNUP}
|
||||
|
||||
services:
|
||||
plane-web:
|
||||
container_name: planefrontend
|
||||
@@ -40,23 +8,8 @@ services:
|
||||
dockerfile: ./web/Dockerfile.web
|
||||
args:
|
||||
DOCKER_BUILDKIT: 1
|
||||
NEXT_PUBLIC_API_BASE_URL: http://localhost:8000
|
||||
NEXT_PUBLIC_DEPLOY_URL: http://localhost/spaces
|
||||
restart: always
|
||||
command: /usr/local/bin/start.sh web/server.js web
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
NEXT_PUBLIC_API_BASE_URL: ${NEXT_PUBLIC_API_BASE_URL}
|
||||
NEXT_PUBLIC_DEPLOY_URL: ${NEXT_PUBLIC_DEPLOY_URL}
|
||||
NEXT_PUBLIC_GOOGLE_CLIENTID: "0"
|
||||
NEXT_PUBLIC_GITHUB_APP_NAME: "0"
|
||||
NEXT_PUBLIC_GITHUB_ID: "0"
|
||||
NEXT_PUBLIC_SENTRY_DSN: "0"
|
||||
NEXT_PUBLIC_ENABLE_OAUTH: "0"
|
||||
NEXT_PUBLIC_ENABLE_SENTRY: "0"
|
||||
NEXT_PUBLIC_ENABLE_SESSION_RECORDER: "0"
|
||||
NEXT_PUBLIC_TRACK_EVENTS: "0"
|
||||
depends_on:
|
||||
- plane-api
|
||||
- plane-worker
|
||||
@@ -68,14 +21,8 @@ services:
|
||||
dockerfile: ./space/Dockerfile.space
|
||||
args:
|
||||
DOCKER_BUILDKIT: 1
|
||||
NEXT_PUBLIC_DEPLOY_WITH_NGINX: 1
|
||||
NEXT_PUBLIC_API_BASE_URL: http://localhost:8000
|
||||
restart: always
|
||||
command: /usr/local/bin/start.sh space/server.js space
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- NEXT_PUBLIC_API_BASE_URL=${NEXT_PUBLIC_API_BASE_URL}
|
||||
depends_on:
|
||||
- plane-api
|
||||
- plane-worker
|
||||
@@ -91,9 +38,7 @@ services:
|
||||
restart: always
|
||||
command: ./bin/takeoff
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
<<: *api-and-worker-env
|
||||
- ./apiserver/.env
|
||||
depends_on:
|
||||
- plane-db
|
||||
- plane-redis
|
||||
@@ -108,9 +53,7 @@ services:
|
||||
restart: always
|
||||
command: ./bin/worker
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
<<: *api-and-worker-env
|
||||
- ./apiserver/.env
|
||||
depends_on:
|
||||
- plane-api
|
||||
- plane-db
|
||||
@@ -126,9 +69,7 @@ services:
|
||||
restart: always
|
||||
command: ./bin/beat
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
<<: *api-and-worker-env
|
||||
- ./apiserver/.env
|
||||
depends_on:
|
||||
- plane-api
|
||||
- plane-db
|
||||
@@ -163,8 +104,6 @@ services:
|
||||
command: server /export --console-address ":9090"
|
||||
volumes:
|
||||
- uploads:/export
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
MINIO_ROOT_USER: ${AWS_ACCESS_KEY_ID}
|
||||
MINIO_ROOT_PASSWORD: ${AWS_SECRET_ACCESS_KEY}
|
||||
@@ -187,8 +126,6 @@ services:
|
||||
restart: always
|
||||
ports:
|
||||
- ${NGINX_PORT}:80
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
FILE_SIZE_LIMIT: ${FILE_SIZE_LIMIT:-5242880}
|
||||
BUCKET_NAME: ${AWS_S3_BUCKET_NAME:-uploads}
|
||||
|
||||
@@ -1,30 +1,29 @@
|
||||
events { }
|
||||
|
||||
|
||||
http {
|
||||
sendfile on;
|
||||
sendfile on;
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
root /www/data/;
|
||||
access_log /var/log/nginx/access.log;
|
||||
server {
|
||||
listen 80;
|
||||
root /www/data/;
|
||||
access_log /var/log/nginx/access.log;
|
||||
|
||||
client_max_body_size ${FILE_SIZE_LIMIT};
|
||||
client_max_body_size ${FILE_SIZE_LIMIT};
|
||||
|
||||
location / {
|
||||
proxy_pass http://planefrontend:3000/;
|
||||
location / {
|
||||
proxy_pass http://planefrontend:3000/;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://planebackend:8000/api/;
|
||||
}
|
||||
|
||||
location /spaces/ {
|
||||
proxy_pass http://planedeploy:3000/spaces/;
|
||||
}
|
||||
|
||||
location /${BUCKET_NAME}/ {
|
||||
proxy_pass http://plane-minio:9000/uploads/;
|
||||
}
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://planebackend:8000/api/;
|
||||
}
|
||||
|
||||
location /spaces/ {
|
||||
proxy_pass http://planedeploy:3000/spaces/;
|
||||
}
|
||||
|
||||
location /${BUCKET_NAME}/ {
|
||||
proxy_pass http://plane-minio:9000/uploads/;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
#!/bin/sh
|
||||
FROM=$1
|
||||
TO=$2
|
||||
DIRECTORY=$3
|
||||
|
||||
if [ "${FROM}" = "${TO}" ]; then
|
||||
echo "Nothing to replace, the value is already set to ${TO}."
|
||||
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Only perform action if $FROM and $TO are different.
|
||||
echo "Replacing all statically built instances of $FROM with this string $TO ."
|
||||
|
||||
grep -R -la "${FROM}" $DIRECTORY/.next | xargs -I{} sed -i "s|$FROM|$TO|g" "{}"
|
||||
17
setup.sh
17
setup.sh
@@ -5,15 +5,12 @@ cp ./.env.example ./.env
|
||||
export LC_ALL=C
|
||||
export LC_CTYPE=C
|
||||
|
||||
|
||||
# Generate the NEXT_PUBLIC_API_BASE_URL with given IP
|
||||
echo -e "\nNEXT_PUBLIC_API_BASE_URL=$1" >> ./.env
|
||||
cp ./web/.env.example ./web/.env
|
||||
cp ./space/.env.example ./space/.env
|
||||
cp ./apiserver/.env.example ./apiserver/.env
|
||||
|
||||
# Generate the SECRET_KEY that will be used by django
|
||||
echo -e "SECRET_KEY=\"$(tr -dc 'a-z0-9' < /dev/urandom | head -c50)\"" >> ./.env
|
||||
|
||||
# WEB_URL for email redirection and image saving
|
||||
echo -e "WEB_URL=$1" >> ./.env
|
||||
echo -e "SECRET_KEY=\"$(tr -dc 'a-z0-9' < /dev/urandom | head -c50)\"" >> ./apiserver/.env
|
||||
|
||||
# Generate Prompt for taking tiptap auth key
|
||||
echo -e "\n\e[1;38m Instructions for generating TipTap Pro Extensions Auth Token \e[0m \n"
|
||||
@@ -21,9 +18,7 @@ echo -e "\n\e[1;38m Instructions for generating TipTap Pro Extensions Auth Token
|
||||
echo -e "\e[1;38m 1. Head over to TipTap cloud's Pro Extensions Page, https://collab.tiptap.dev/pro-extensions \e[0m"
|
||||
echo -e "\e[1;38m 2. Copy the token given to you under the first paragraph, after 'Here it is' \e[0m \n"
|
||||
|
||||
read -p $'\e[1;32m Please Enter Your TipTap Pro Extensions Authentication Token: \e[0m \e[1;36m' authToken
|
||||
|
||||
read -p $'\e[1;32m Please Enter Your TipTap Pro Extensions Authentication Token: \e[0m \e[1;36m' authToken
|
||||
|
||||
echo "@tiptap-pro:registry=https://registry.tiptap.dev/
|
||||
//registry.tiptap.dev/:_authToken=${authToken}" > .npmrc
|
||||
|
||||
//registry.tiptap.dev/:_authToken=${authToken}" > .npmrc
|
||||
@@ -1,8 +1,4 @@
|
||||
# Base url for the API requests
|
||||
NEXT_PUBLIC_API_BASE_URL=""
|
||||
# Public boards deploy URL
|
||||
NEXT_PUBLIC_DEPLOY_URL=""
|
||||
# Google Client ID for Google OAuth
|
||||
NEXT_PUBLIC_GOOGLE_CLIENTID=""
|
||||
# Flag to toggle OAuth
|
||||
NEXT_PUBLIC_ENABLE_OAUTH=1
|
||||
NEXT_PUBLIC_ENABLE_OAUTH=0
|
||||
@@ -1,7 +1,6 @@
|
||||
FROM node:18-alpine AS builder
|
||||
RUN apk add --no-cache libc6-compat
|
||||
WORKDIR /app
|
||||
ENV NEXT_PUBLIC_API_BASE_URL=http://NEXT_PUBLIC_API_BASE_URL_PLACEHOLDER
|
||||
|
||||
RUN yarn global add turbo
|
||||
COPY . .
|
||||
@@ -20,19 +19,16 @@ RUN yarn install --network-timeout 500000
|
||||
|
||||
COPY --from=builder /app/out/full/ .
|
||||
COPY turbo.json turbo.json
|
||||
COPY replace-env-vars.sh /usr/local/bin/
|
||||
USER root
|
||||
RUN chmod +x /usr/local/bin/replace-env-vars.sh
|
||||
|
||||
ARG NEXT_PUBLIC_API_BASE_URL=http://localhost:8000
|
||||
ARG NEXT_PUBLIC_API_BASE_URL=""
|
||||
ARG NEXT_PUBLIC_DEPLOY_WITH_NGINX=1
|
||||
|
||||
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL BUILT_NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL NEXT_PUBLIC_DEPLOY_WITH_NGINX=$NEXT_PUBLIC_DEPLOY_WITH_NGINX
|
||||
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
|
||||
ENV NEXT_PUBLIC_DEPLOY_WITH_NGINX=$NEXT_PUBLIC_DEPLOY_WITH_NGINX
|
||||
|
||||
RUN yarn turbo run build --filter=space
|
||||
|
||||
RUN /usr/local/bin/replace-env-vars.sh http://NEXT_PUBLIC_WEBAPP_URL_PLACEHOLDER ${NEXT_PUBLIC_API_BASE_URL} space
|
||||
|
||||
FROM node:18-alpine AS runner
|
||||
WORKDIR /app
|
||||
|
||||
@@ -48,14 +44,14 @@ COPY --from=installer --chown=captain:plane /app/space/.next/standalone ./
|
||||
COPY --from=installer --chown=captain:plane /app/space/.next ./space/.next
|
||||
COPY --from=installer --chown=captain:plane /app/space/public ./space/public
|
||||
|
||||
ARG NEXT_PUBLIC_API_BASE_URL=http://localhost:8000
|
||||
ARG NEXT_PUBLIC_API_BASE_URL=""
|
||||
ARG NEXT_PUBLIC_DEPLOY_WITH_NGINX=1
|
||||
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL BUILT_NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL NEXT_PUBLIC_DEPLOY_WITH_NGINX=$NEXT_PUBLIC_DEPLOY_WITH_NGINX
|
||||
|
||||
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
|
||||
ENV NEXT_PUBLIC_DEPLOY_WITH_NGINX=$NEXT_PUBLIC_DEPLOY_WITH_NGINX
|
||||
|
||||
USER root
|
||||
COPY replace-env-vars.sh /usr/local/bin/
|
||||
COPY start.sh /usr/local/bin/
|
||||
RUN chmod +x /usr/local/bin/replace-env-vars.sh
|
||||
RUN chmod +x /usr/local/bin/start.sh
|
||||
|
||||
USER captain
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from "react";
|
||||
import React, { useEffect } from "react";
|
||||
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/router";
|
||||
@@ -13,7 +13,7 @@ import useToast from "hooks/use-toast";
|
||||
// components
|
||||
import { EmailPasswordForm, GithubLoginButton, GoogleLoginButton, EmailCodeForm } from "components/accounts";
|
||||
// images
|
||||
const imagePrefix = process.env.NEXT_PUBLIC_DEPLOY_WITH_NGINX ? "/spaces/" : "";
|
||||
const imagePrefix = Boolean(parseInt(process.env.NEXT_PUBLIC_DEPLOY_WITH_NGINX || "0")) ? "/spaces" : "";
|
||||
|
||||
export const SignInView = observer(() => {
|
||||
const { user: userStore } = useMobxStore();
|
||||
|
||||
1
space/helpers/common.helper.ts
Normal file
1
space/helpers/common.helper.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL ? process.env.NEXT_PUBLIC_API_BASE_URL : "";
|
||||
@@ -5,7 +5,7 @@ import { useMobxStore } from "lib/mobx/store-provider";
|
||||
// components
|
||||
import { OnBoardingForm } from "components/accounts/onboarding-form";
|
||||
|
||||
const imagePrefix = process.env.NEXT_PUBLIC_DEPLOY_WITH_NGINX ? "/spaces/" : "";
|
||||
const imagePrefix = Boolean(parseInt(process.env.NEXT_PUBLIC_DEPLOY_WITH_NGINX || "0")) ? "/spaces" : "";
|
||||
|
||||
const OnBoardingPage = () => {
|
||||
const { user: userStore } = useMobxStore();
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
// services
|
||||
import APIService from "services/api.service";
|
||||
import { API_BASE_URL } from "helpers/common.helper";
|
||||
|
||||
class AuthService extends APIService {
|
||||
constructor() {
|
||||
super(process.env.NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000");
|
||||
super(API_BASE_URL);
|
||||
}
|
||||
|
||||
async emailLogin(data: any) {
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
// services
|
||||
import APIService from "services/api.service";
|
||||
|
||||
const { NEXT_PUBLIC_API_BASE_URL } = process.env;
|
||||
import { API_BASE_URL } from "helpers/common.helper";
|
||||
|
||||
interface UnSplashImage {
|
||||
id: string;
|
||||
@@ -29,7 +27,7 @@ interface UnSplashImageUrls {
|
||||
|
||||
class FileServices extends APIService {
|
||||
constructor() {
|
||||
super(NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000");
|
||||
super(API_BASE_URL);
|
||||
}
|
||||
|
||||
async uploadFile(workspaceSlug: string, file: FormData): Promise<any> {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
// services
|
||||
import APIService from "services/api.service";
|
||||
import { API_BASE_URL } from "helpers/common.helper";
|
||||
|
||||
class IssueService extends APIService {
|
||||
constructor() {
|
||||
super(process.env.NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000");
|
||||
super(API_BASE_URL);
|
||||
}
|
||||
|
||||
async getPublicIssues(workspace_slug: string, project_slug: string, params: any): Promise<any> {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
// services
|
||||
import APIService from "services/api.service";
|
||||
import { API_BASE_URL } from "helpers/common.helper";
|
||||
|
||||
class ProjectService extends APIService {
|
||||
constructor() {
|
||||
super(process.env.NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000");
|
||||
super(API_BASE_URL);
|
||||
}
|
||||
|
||||
async getProjectSettings(workspace_slug: string, project_slug: string): Promise<any> {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
// services
|
||||
import APIService from "services/api.service";
|
||||
import { API_BASE_URL } from "helpers/common.helper";
|
||||
|
||||
class UserService extends APIService {
|
||||
constructor() {
|
||||
super(process.env.NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000");
|
||||
super(API_BASE_URL);
|
||||
}
|
||||
|
||||
async currentUser(): Promise<any> {
|
||||
|
||||
4
start.sh
4
start.sh
@@ -1,9 +1,5 @@
|
||||
#!/bin/sh
|
||||
set -x
|
||||
|
||||
# Replace the statically built BUILT_NEXT_PUBLIC_API_BASE_URL with run-time NEXT_PUBLIC_API_BASE_URL
|
||||
# NOTE: if these values are the same, this will be skipped.
|
||||
/usr/local/bin/replace-env-vars.sh "$BUILT_NEXT_PUBLIC_API_BASE_URL" "$NEXT_PUBLIC_API_BASE_URL" $2
|
||||
|
||||
echo "Starting Plane Frontend.."
|
||||
node $1
|
||||
|
||||
17
turbo.json
17
turbo.json
@@ -15,17 +15,20 @@
|
||||
"NEXT_PUBLIC_UNSPLASH_ACCESS",
|
||||
"NEXT_PUBLIC_UNSPLASH_ENABLED",
|
||||
"NEXT_PUBLIC_TRACK_EVENTS",
|
||||
"TRACKER_ACCESS_KEY",
|
||||
"NEXT_PUBLIC_PLAUSIBLE_DOMAIN",
|
||||
"NEXT_PUBLIC_CRISP_ID",
|
||||
"NEXT_PUBLIC_ENABLE_SESSION_RECORDER",
|
||||
"NEXT_PUBLIC_SESSION_RECORDER_KEY",
|
||||
"NEXT_PUBLIC_EXTRA_IMAGE_DOMAINS",
|
||||
"NEXT_PUBLIC_SLACK_CLIENT_ID",
|
||||
"NEXT_PUBLIC_SLACK_CLIENT_SECRET",
|
||||
"NEXT_PUBLIC_SUPABASE_URL",
|
||||
"NEXT_PUBLIC_SUPABASE_ANON_KEY",
|
||||
"NEXT_PUBLIC_PLAUSIBLE_DOMAIN",
|
||||
"NEXT_PUBLIC_DEPLOY_WITH_NGINX"
|
||||
"NEXT_PUBLIC_DEPLOY_WITH_NGINX",
|
||||
"NEXT_PUBLIC_POSTHOG_KEY",
|
||||
"NEXT_PUBLIC_POSTHOG_HOST",
|
||||
"SLACK_OAUTH_URL",
|
||||
"SLACK_CLIENT_ID",
|
||||
"SLACK_CLIENT_SECRET",
|
||||
"JITSU_TRACKER_ACCESS_KEY",
|
||||
"JITSU_TRACKER_HOST",
|
||||
"UNSPLASH_ACCESS_KEY"
|
||||
],
|
||||
"pipeline": {
|
||||
"build": {
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
# Base url for the API requests
|
||||
NEXT_PUBLIC_API_BASE_URL=""
|
||||
# Extra image domains that need to be added for Next Image
|
||||
NEXT_PUBLIC_EXTRA_IMAGE_DOMAINS=
|
||||
# Google Client ID for Google OAuth
|
||||
@@ -23,4 +21,4 @@ NEXT_PUBLIC_SLACK_CLIENT_ID=""
|
||||
# For Telemetry, set it to "app.plane.so"
|
||||
NEXT_PUBLIC_PLAUSIBLE_DOMAIN=""
|
||||
# Public boards deploy URL
|
||||
NEXT_PUBLIC_DEPLOY_URL=""
|
||||
NEXT_PUBLIC_DEPLOY_URL="http://localhost:3000/spaces"
|
||||
@@ -2,7 +2,6 @@ FROM node:18-alpine AS builder
|
||||
RUN apk add --no-cache libc6-compat
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
ENV NEXT_PUBLIC_API_BASE_URL=http://NEXT_PUBLIC_API_BASE_URL_PLACEHOLDER
|
||||
|
||||
RUN yarn global add turbo
|
||||
COPY . .
|
||||
@@ -14,8 +13,8 @@ FROM node:18-alpine AS installer
|
||||
|
||||
RUN apk add --no-cache libc6-compat
|
||||
WORKDIR /app
|
||||
ARG NEXT_PUBLIC_API_BASE_URL=http://localhost:8000
|
||||
ARG NEXT_PUBLIC_DEPLOY_URL=http://localhost/spaces
|
||||
ARG NEXT_PUBLIC_API_BASE_URL=""
|
||||
ARG NEXT_PUBLIC_DEPLOY_URL=""
|
||||
|
||||
# First install the dependencies (as they change less often)
|
||||
COPY .gitignore .gitignore
|
||||
@@ -26,18 +25,12 @@ RUN yarn install --network-timeout 500000
|
||||
# Build the project
|
||||
COPY --from=builder /app/out/full/ .
|
||||
COPY turbo.json turbo.json
|
||||
COPY replace-env-vars.sh /usr/local/bin/
|
||||
USER root
|
||||
RUN chmod +x /usr/local/bin/replace-env-vars.sh
|
||||
|
||||
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL \
|
||||
BUILT_NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL \
|
||||
NEXT_PUBLIC_DEPLOY_URL=$NEXT_PUBLIC_DEPLOY_URL
|
||||
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
|
||||
ENV NEXT_PUBLIC_DEPLOY_URL=$NEXT_PUBLIC_DEPLOY_URL
|
||||
|
||||
RUN yarn turbo run build --filter=web
|
||||
|
||||
RUN /usr/local/bin/replace-env-vars.sh http://NEXT_PUBLIC_WEBAPP_URL_PLACEHOLDER ${NEXT_PUBLIC_API_BASE_URL} web
|
||||
|
||||
FROM node:18-alpine AS runner
|
||||
WORKDIR /app
|
||||
|
||||
@@ -52,20 +45,15 @@ COPY --from=installer /app/web/package.json .
|
||||
# Automatically leverage output traces to reduce image size
|
||||
# https://nextjs.org/docs/advanced-features/output-file-tracing
|
||||
COPY --from=installer --chown=captain:plane /app/web/.next/standalone ./
|
||||
|
||||
COPY --from=installer --chown=captain:plane /app/web/.next ./web/.next
|
||||
|
||||
ARG NEXT_PUBLIC_API_BASE_URL=http://localhost:8000
|
||||
ARG NEXT_PUBLIC_DEPLOY_URL=http://localhost/spaces
|
||||
|
||||
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL \
|
||||
BUILT_NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL \
|
||||
NEXT_PUBLIC_DEPLOY_URL=$NEXT_PUBLIC_DEPLOY_URL
|
||||
ARG NEXT_PUBLIC_API_BASE_URL=""
|
||||
ARG NEXT_PUBLIC_DEPLOY_URL=""
|
||||
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
|
||||
ENV NEXT_PUBLIC_DEPLOY_URL=$NEXT_PUBLIC_DEPLOY_URL
|
||||
|
||||
USER root
|
||||
COPY replace-env-vars.sh /usr/local/bin/
|
||||
COPY start.sh /usr/local/bin/
|
||||
RUN chmod +x /usr/local/bin/replace-env-vars.sh
|
||||
RUN chmod +x /usr/local/bin/start.sh
|
||||
|
||||
USER captain
|
||||
|
||||
@@ -3,8 +3,8 @@ import React, { useState } from "react";
|
||||
// component
|
||||
import { CustomSelect, ToggleSwitch } from "components/ui";
|
||||
import { SelectMonthModal } from "components/automation";
|
||||
// icons
|
||||
import { ChevronDownIcon } from "@heroicons/react/24/outline";
|
||||
// icon
|
||||
import { ArchiveRestore } from "lucide-react";
|
||||
// constants
|
||||
import { PROJECT_AUTOMATION_MONTHS } from "constants/project";
|
||||
// types
|
||||
@@ -28,14 +28,18 @@ export const AutoArchiveAutomation: React.FC<Props> = ({ projectDetails, handleC
|
||||
handleClose={() => setmonthModal(false)}
|
||||
handleChange={handleChange}
|
||||
/>
|
||||
<div className="flex flex-col gap-7 px-6 py-5 rounded-[10px] border border-custom-border-300 bg-custom-background-90">
|
||||
<div className="flex items-center justify-between gap-x-8 gap-y-2">
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<h4 className="text-lg font-semibold">Auto-archive closed issues</h4>
|
||||
<p className="text-sm text-custom-text-200">
|
||||
Plane will automatically archive issues that have been completed or cancelled for the
|
||||
configured time period.
|
||||
</p>
|
||||
<div className="flex flex-col gap-4 border-b border-custom-border-200 px-4 py-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex items-center justify-center p-3 rounded bg-custom-background-90">
|
||||
<ArchiveRestore className="h-4 w-4 text-custom-text-100 flex-shrink-0" />
|
||||
</div>
|
||||
<div className="">
|
||||
<h4 className="text-sm font-medium">Auto-archive closed issues</h4>
|
||||
<p className="text-sm text-custom-text-200 tracking-tight">
|
||||
Plane will auto archive issues that have been completed or canceled.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
value={projectDetails?.archive_in !== 0}
|
||||
@@ -47,40 +51,43 @@ export const AutoArchiveAutomation: React.FC<Props> = ({ projectDetails, handleC
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
{projectDetails?.archive_in !== 0 && (
|
||||
<div className="flex items-center justify-between gap-2 w-full">
|
||||
<div className="w-1/2 text-base font-medium">
|
||||
Auto-archive issues that are closed for
|
||||
</div>
|
||||
<div className="w-1/2">
|
||||
<CustomSelect
|
||||
value={projectDetails?.archive_in}
|
||||
label={`${projectDetails?.archive_in} ${
|
||||
projectDetails?.archive_in === 1 ? "Month" : "Months"
|
||||
}`}
|
||||
onChange={(val: number) => {
|
||||
handleChange({ archive_in: val });
|
||||
}}
|
||||
input
|
||||
verticalPosition="top"
|
||||
width="w-full"
|
||||
>
|
||||
<>
|
||||
{PROJECT_AUTOMATION_MONTHS.map((month) => (
|
||||
<CustomSelect.Option key={month.label} value={month.value}>
|
||||
{month.label}
|
||||
</CustomSelect.Option>
|
||||
))}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full select-none items-center rounded px-1 py-1.5 text-custom-text-200 hover:bg-custom-background-80"
|
||||
onClick={() => setmonthModal(true)}
|
||||
>
|
||||
Customize Time Range
|
||||
</button>
|
||||
</>
|
||||
</CustomSelect>
|
||||
{projectDetails?.archive_in !== 0 && (
|
||||
<div className="ml-12">
|
||||
<div className="flex items-center justify-between rounded px-5 py-4 bg-custom-background-90 border border-custom-border-200 gap-2 w-full">
|
||||
<div className="w-1/2 text-sm font-medium">
|
||||
Auto-archive issues that are closed for
|
||||
</div>
|
||||
<div className="w-1/2">
|
||||
<CustomSelect
|
||||
value={projectDetails?.archive_in}
|
||||
label={`${projectDetails?.archive_in} ${
|
||||
projectDetails?.archive_in === 1 ? "Month" : "Months"
|
||||
}`}
|
||||
onChange={(val: number) => {
|
||||
handleChange({ archive_in: val });
|
||||
}}
|
||||
input
|
||||
verticalPosition="bottom"
|
||||
width="w-full"
|
||||
>
|
||||
<>
|
||||
{PROJECT_AUTOMATION_MONTHS.map((month) => (
|
||||
<CustomSelect.Option key={month.label} value={month.value}>
|
||||
<span className="text-sm">{month.label}</span>
|
||||
</CustomSelect.Option>
|
||||
))}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full text-sm select-none items-center rounded px-1 py-1.5 text-custom-text-200 hover:bg-custom-background-80"
|
||||
onClick={() => setmonthModal(true)}
|
||||
>
|
||||
Customise Time Range
|
||||
</button>
|
||||
</>
|
||||
</CustomSelect>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -5,11 +5,12 @@ import useSWR from "swr";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
// component
|
||||
import { CustomSearchSelect, CustomSelect, ToggleSwitch } from "components/ui";
|
||||
import { CustomSearchSelect, CustomSelect, Icon, ToggleSwitch } from "components/ui";
|
||||
import { SelectMonthModal } from "components/automation";
|
||||
// icons
|
||||
import { ChevronDownIcon, Squares2X2Icon } from "@heroicons/react/24/outline";
|
||||
import { Squares2X2Icon } from "@heroicons/react/24/outline";
|
||||
import { StateGroupIcon } from "components/icons";
|
||||
import { ArchiveX } from "lucide-react";
|
||||
// services
|
||||
import stateService from "services/state.service";
|
||||
// constants
|
||||
@@ -76,14 +77,18 @@ export const AutoCloseAutomation: React.FC<Props> = ({ projectDetails, handleCha
|
||||
handleChange={handleChange}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-7 px-6 py-5 rounded-[10px] border border-custom-border-300 bg-custom-background-90">
|
||||
<div className="flex items-center justify-between gap-x-8 gap-y-2 ">
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<h4 className="text-lg font-semibold">Auto-close inactive issues</h4>
|
||||
<p className="text-sm text-custom-text-200">
|
||||
Plane will automatically close the issues that have not been updated for the
|
||||
configured time period.
|
||||
</p>
|
||||
<div className="flex flex-col gap-4 border-b border-custom-border-200 px-4 py-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex items-center justify-center p-3 rounded bg-custom-background-90">
|
||||
<ArchiveX className="h-4 w-4 text-red-500 flex-shrink-0" />
|
||||
</div>
|
||||
<div className="">
|
||||
<h4 className="text-sm font-medium">Auto-close issues</h4>
|
||||
<p className="text-sm text-custom-text-200 tracking-tight">
|
||||
Plane will automatically close issue that haven’t been completed or canceled.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
value={projectDetails?.close_in !== 0}
|
||||
@@ -95,82 +100,86 @@ export const AutoCloseAutomation: React.FC<Props> = ({ projectDetails, handleCha
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{projectDetails?.close_in !== 0 && (
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<div className="flex items-center justify-between gap-2 w-full">
|
||||
<div className="w-1/2 text-base font-medium">
|
||||
Auto-close issues that are inactive for
|
||||
<div className="ml-12">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between rounded px-5 py-4 bg-custom-background-90 border border-custom-border-200 gap-2 w-full">
|
||||
<div className="w-1/2 text-sm font-medium">
|
||||
Auto-close issues that are inactive for
|
||||
</div>
|
||||
<div className="w-1/2">
|
||||
<CustomSelect
|
||||
value={projectDetails?.close_in}
|
||||
label={`${projectDetails?.close_in} ${
|
||||
projectDetails?.close_in === 1 ? "Month" : "Months"
|
||||
}`}
|
||||
onChange={(val: number) => {
|
||||
handleChange({ close_in: val });
|
||||
}}
|
||||
input
|
||||
width="w-full"
|
||||
>
|
||||
<>
|
||||
{PROJECT_AUTOMATION_MONTHS.map((month) => (
|
||||
<CustomSelect.Option key={month.label} value={month.value}>
|
||||
{month.label}
|
||||
</CustomSelect.Option>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full select-none items-center rounded px-1 py-1.5 text-custom-text-200 hover:bg-custom-background-80"
|
||||
onClick={() => setmonthModal(true)}
|
||||
>
|
||||
Customise Time Range
|
||||
</button>
|
||||
</>
|
||||
</CustomSelect>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-1/2">
|
||||
<CustomSelect
|
||||
value={projectDetails?.close_in}
|
||||
label={`${projectDetails?.close_in} ${
|
||||
projectDetails?.close_in === 1 ? "Month" : "Months"
|
||||
}`}
|
||||
onChange={(val: number) => {
|
||||
handleChange({ close_in: val });
|
||||
}}
|
||||
input
|
||||
width="w-full"
|
||||
>
|
||||
<>
|
||||
{PROJECT_AUTOMATION_MONTHS.map((month) => (
|
||||
<CustomSelect.Option key={month.label} value={month.value}>
|
||||
{month.label}
|
||||
</CustomSelect.Option>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full select-none items-center rounded px-1 py-1.5 text-custom-text-200 hover:bg-custom-background-80"
|
||||
onClick={() => setmonthModal(true)}
|
||||
>
|
||||
Customize Time Range
|
||||
</button>
|
||||
</>
|
||||
</CustomSelect>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2 w-full">
|
||||
<div className="w-1/2 text-base font-medium">Auto-close Status</div>
|
||||
<div className="w-1/2 ">
|
||||
<CustomSearchSelect
|
||||
value={
|
||||
projectDetails?.default_state ? projectDetails?.default_state : defaultState
|
||||
}
|
||||
label={
|
||||
<div className="flex items-center gap-2">
|
||||
{selectedOption ? (
|
||||
<StateGroupIcon
|
||||
stateGroup={selectedOption.group}
|
||||
color={selectedOption.color}
|
||||
height="16px"
|
||||
width="16px"
|
||||
/>
|
||||
) : currentDefaultState ? (
|
||||
<StateGroupIcon
|
||||
stateGroup={currentDefaultState.group}
|
||||
color={currentDefaultState.color}
|
||||
height="16px"
|
||||
width="16px"
|
||||
/>
|
||||
) : (
|
||||
<Squares2X2Icon className="h-3.5 w-3.5 text-custom-text-200" />
|
||||
)}
|
||||
{selectedOption?.name
|
||||
? selectedOption.name
|
||||
: currentDefaultState?.name ?? (
|
||||
<span className="text-custom-text-200">State</span>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
onChange={(val: string) => {
|
||||
handleChange({ default_state: val });
|
||||
}}
|
||||
options={options}
|
||||
disabled={!multipleOptions}
|
||||
width="w-full"
|
||||
input
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between rounded px-5 py-4 bg-custom-background-90 border border-custom-border-200 gap-2 w-full">
|
||||
<div className="w-1/2 text-sm font-medium">Auto-close Status</div>
|
||||
<div className="w-1/2 ">
|
||||
<CustomSearchSelect
|
||||
value={
|
||||
projectDetails?.default_state ? projectDetails?.default_state : defaultState
|
||||
}
|
||||
label={
|
||||
<div className="flex items-center gap-2">
|
||||
{selectedOption ? (
|
||||
<StateGroupIcon
|
||||
stateGroup={selectedOption.group}
|
||||
color={selectedOption.color}
|
||||
height="16px"
|
||||
width="16px"
|
||||
/>
|
||||
) : currentDefaultState ? (
|
||||
<StateGroupIcon
|
||||
stateGroup={currentDefaultState.group}
|
||||
color={currentDefaultState.color}
|
||||
height="16px"
|
||||
width="16px"
|
||||
/>
|
||||
) : (
|
||||
<Squares2X2Icon className="h-3.5 w-3.5 text-custom-text-200" />
|
||||
)}
|
||||
{selectedOption?.name
|
||||
? selectedOption.name
|
||||
: currentDefaultState?.name ?? (
|
||||
<span className="text-custom-text-200">State</span>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
onChange={(val: string) => {
|
||||
handleChange({ default_state: val });
|
||||
}}
|
||||
options={options}
|
||||
disabled={!multipleOptions}
|
||||
width="w-full"
|
||||
input
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -104,7 +104,7 @@ export const SelectMonthModal: React.FC<Props> = ({
|
||||
as="h3"
|
||||
className="text-lg font-medium leading-6 text-custom-text-100"
|
||||
>
|
||||
Customize Time Range
|
||||
Customise Time Range
|
||||
</Dialog.Title>
|
||||
<div className="mt-8 flex items-center gap-2">
|
||||
<div className="flex w-full flex-col gap-1 justify-center">
|
||||
|
||||
@@ -20,6 +20,7 @@ import fileService from "services/file.service";
|
||||
import { Input, Spinner, PrimaryButton, SecondaryButton } from "components/ui";
|
||||
// hooks
|
||||
import useWorkspaceDetails from "hooks/use-workspace-details";
|
||||
import useOutsideClickDetector from "hooks/use-outside-click-detector";
|
||||
|
||||
const unsplashEnabled =
|
||||
process.env.NEXT_PUBLIC_UNSPLASH_ENABLED === "true" ||
|
||||
@@ -67,6 +68,8 @@ export const ImagePickerPopover: React.FC<Props> = ({
|
||||
fileService.getUnsplashImages(1, searchParams)
|
||||
);
|
||||
|
||||
const imagePickerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { workspaceDetails } = useWorkspaceDetails();
|
||||
|
||||
const onDrop = useCallback((acceptedFiles: File[]) => {
|
||||
@@ -116,12 +119,14 @@ export const ImagePickerPopover: React.FC<Props> = ({
|
||||
onChange(images[0].urls.regular);
|
||||
}, [value, onChange, images]);
|
||||
|
||||
useOutsideClickDetector(imagePickerRef, () => setIsOpen(false));
|
||||
|
||||
if (!unsplashEnabled) return null;
|
||||
|
||||
return (
|
||||
<Popover className="relative z-[2]" ref={ref}>
|
||||
<Popover.Button
|
||||
className="rounded-md border border-custom-border-300 bg-custom-background-100 px-2 py-1 text-xs text-custom-text-200 hover:text-custom-text-100"
|
||||
className="rounded-sm border border-custom-border-300 bg-custom-background-100 px-2 py-1 text-xs text-custom-text-200 hover:text-custom-text-100"
|
||||
onClick={() => setIsOpen((prev) => !prev)}
|
||||
disabled={disabled}
|
||||
>
|
||||
@@ -137,7 +142,10 @@ export const ImagePickerPopover: React.FC<Props> = ({
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<Popover.Panel className="absolute right-0 z-10 mt-2 rounded-md border border-custom-border-200 bg-custom-background-80 shadow-lg">
|
||||
<div className="h-96 md:h-[28rem] w-80 md:w-[36rem] flex flex-col overflow-auto rounded border border-custom-border-300 bg-custom-background-100 p-3 shadow-2xl">
|
||||
<div
|
||||
ref={imagePickerRef}
|
||||
className="h-96 md:h-[28rem] w-80 md:w-[36rem] flex flex-col overflow-auto rounded border border-custom-border-300 bg-custom-background-100 p-3 shadow-2xl"
|
||||
>
|
||||
<Tab.Group>
|
||||
<div>
|
||||
<Tab.List as="span" className="inline-block rounded bg-custom-background-80 p-1">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback } from "react";
|
||||
import React, { useCallback, useState } from "react";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
@@ -49,7 +49,7 @@ type Props = {
|
||||
};
|
||||
secondaryButton?: React.ReactNode;
|
||||
};
|
||||
handleIssueAction: (issue: IIssue, action: "copy" | "delete" | "edit") => void;
|
||||
handleIssueAction: (issue: IIssue, action: "copy" | "delete" | "edit" | "updateDraft") => void;
|
||||
handleOnDragEnd: (result: DropResult) => Promise<void>;
|
||||
openIssuesListModal: (() => void) | null;
|
||||
removeIssue: ((bridgeId: string, issueId: string) => void) | null;
|
||||
@@ -77,6 +77,8 @@ export const AllViews: React.FC<Props> = ({
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, cycleId, moduleId } = router.query;
|
||||
|
||||
const [myIssueProjectId, setMyIssueProjectId] = useState<string | null>(null);
|
||||
|
||||
const { user } = useUser();
|
||||
const { memberRole } = useProjectMyMembership();
|
||||
|
||||
@@ -90,6 +92,10 @@ export const AllViews: React.FC<Props> = ({
|
||||
);
|
||||
const states = getStatesList(stateGroups);
|
||||
|
||||
const handleMyIssueOpen = (issue: IIssue) => {
|
||||
setMyIssueProjectId(issue.project);
|
||||
};
|
||||
|
||||
const handleTrashBox = useCallback(
|
||||
(isDragging: boolean) => {
|
||||
if (isDragging && !trashBox) setTrashBox(true);
|
||||
@@ -128,6 +134,8 @@ export const AllViews: React.FC<Props> = ({
|
||||
handleIssueAction={handleIssueAction}
|
||||
openIssuesListModal={cycleId || moduleId ? openIssuesListModal : null}
|
||||
removeIssue={removeIssue}
|
||||
myIssueProjectId={myIssueProjectId}
|
||||
handleMyIssueOpen={handleMyIssueOpen}
|
||||
disableUserActions={disableUserActions}
|
||||
disableAddIssueOption={disableAddIssueOption}
|
||||
user={user}
|
||||
@@ -143,6 +151,8 @@ export const AllViews: React.FC<Props> = ({
|
||||
handleIssueAction={handleIssueAction}
|
||||
handleTrashBox={handleTrashBox}
|
||||
openIssuesListModal={cycleId || moduleId ? openIssuesListModal : null}
|
||||
myIssueProjectId={myIssueProjectId}
|
||||
handleMyIssueOpen={handleMyIssueOpen}
|
||||
removeIssue={removeIssue}
|
||||
states={states}
|
||||
user={user}
|
||||
@@ -166,7 +176,9 @@ export const AllViews: React.FC<Props> = ({
|
||||
userAuth={memberRole}
|
||||
/>
|
||||
) : (
|
||||
displayFilters?.layout === "gantt_chart" && <GanttChartView />
|
||||
displayFilters?.layout === "gantt_chart" && (
|
||||
<GanttChartView disableUserActions={disableUserActions} />
|
||||
)
|
||||
)}
|
||||
</>
|
||||
) : router.pathname.includes("archived-issues") ? (
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
//hook
|
||||
import useMyIssues from "hooks/my-issues/use-my-issues";
|
||||
import useIssuesView from "hooks/use-issues-view";
|
||||
import useProfileIssues from "hooks/use-profile-issues";
|
||||
// components
|
||||
import { SingleBoard } from "components/core/views/board-view/single-board";
|
||||
import { IssuePeekOverview } from "components/issues";
|
||||
// icons
|
||||
import { StateGroupIcon } from "components/icons";
|
||||
// helpers
|
||||
@@ -12,10 +19,12 @@ type Props = {
|
||||
disableUserActions: boolean;
|
||||
disableAddIssueOption?: boolean;
|
||||
dragDisabled: boolean;
|
||||
handleIssueAction: (issue: IIssue, action: "copy" | "delete" | "edit") => void;
|
||||
handleIssueAction: (issue: IIssue, action: "copy" | "delete" | "edit" | "updateDraft") => void;
|
||||
handleTrashBox: (isDragging: boolean) => void;
|
||||
openIssuesListModal?: (() => void) | null;
|
||||
removeIssue: ((bridgeId: string, issueId: string) => void) | null;
|
||||
myIssueProjectId?: string | null;
|
||||
handleMyIssueOpen?: (issue: IIssue) => void;
|
||||
states: IState[] | undefined;
|
||||
user: ICurrentUserResponse | undefined;
|
||||
userAuth: UserAuth;
|
||||
@@ -30,16 +39,40 @@ export const AllBoards: React.FC<Props> = ({
|
||||
handleIssueAction,
|
||||
handleTrashBox,
|
||||
openIssuesListModal,
|
||||
myIssueProjectId,
|
||||
handleMyIssueOpen,
|
||||
removeIssue,
|
||||
states,
|
||||
user,
|
||||
userAuth,
|
||||
viewProps,
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, userId } = router.query;
|
||||
|
||||
const isProfileIssue =
|
||||
router.pathname.includes("assigned") ||
|
||||
router.pathname.includes("created") ||
|
||||
router.pathname.includes("subscribed");
|
||||
|
||||
const isMyIssue = router.pathname.includes("my-issues");
|
||||
|
||||
const { mutateIssues } = useIssuesView();
|
||||
const { mutateMyIssues } = useMyIssues(workspaceSlug?.toString());
|
||||
const { mutateProfileIssues } = useProfileIssues(workspaceSlug?.toString(), userId?.toString());
|
||||
|
||||
const { displayFilters, groupedIssues } = viewProps;
|
||||
|
||||
return (
|
||||
<>
|
||||
<IssuePeekOverview
|
||||
handleMutation={() =>
|
||||
isMyIssue ? mutateMyIssues() : isProfileIssue ? mutateProfileIssues() : mutateIssues()
|
||||
}
|
||||
projectId={myIssueProjectId ? myIssueProjectId : projectId?.toString() ?? ""}
|
||||
workspaceSlug={workspaceSlug?.toString() ?? ""}
|
||||
readOnly={disableUserActions}
|
||||
/>
|
||||
{groupedIssues ? (
|
||||
<div className="horizontal-scroll-enable flex h-full gap-x-4 p-8">
|
||||
{Object.keys(groupedIssues).map((singleGroup, index) => {
|
||||
@@ -63,6 +96,7 @@ export const AllBoards: React.FC<Props> = ({
|
||||
handleIssueAction={handleIssueAction}
|
||||
handleTrashBox={handleTrashBox}
|
||||
openIssuesListModal={openIssuesListModal ?? null}
|
||||
handleMyIssueOpen={handleMyIssueOpen}
|
||||
removeIssue={removeIssue}
|
||||
user={user}
|
||||
userAuth={userAuth}
|
||||
|
||||
@@ -26,6 +26,7 @@ type Props = {
|
||||
handleIssueAction: (issue: IIssue, action: "copy" | "delete" | "edit") => void;
|
||||
handleTrashBox: (isDragging: boolean) => void;
|
||||
openIssuesListModal?: (() => void) | null;
|
||||
handleMyIssueOpen?: (issue: IIssue) => void;
|
||||
removeIssue: ((bridgeId: string, issueId: string) => void) | null;
|
||||
user: ICurrentUserResponse | undefined;
|
||||
userAuth: UserAuth;
|
||||
@@ -42,6 +43,7 @@ export const SingleBoard: React.FC<Props> = ({
|
||||
handleIssueAction,
|
||||
handleTrashBox,
|
||||
openIssuesListModal,
|
||||
handleMyIssueOpen,
|
||||
removeIssue,
|
||||
user,
|
||||
userAuth,
|
||||
@@ -50,7 +52,7 @@ export const SingleBoard: React.FC<Props> = ({
|
||||
// collapse/expand
|
||||
const [isCollapsed, setIsCollapsed] = useState(true);
|
||||
|
||||
const { displayFilters, groupedIssues, properties } = viewProps;
|
||||
const { displayFilters, groupedIssues } = viewProps;
|
||||
|
||||
const router = useRouter();
|
||||
const { cycleId, moduleId } = router.query;
|
||||
@@ -135,6 +137,7 @@ export const SingleBoard: React.FC<Props> = ({
|
||||
makeIssueCopy={() => handleIssueAction(issue, "copy")}
|
||||
handleDeleteIssue={() => handleIssueAction(issue, "delete")}
|
||||
handleTrashBox={handleTrashBox}
|
||||
handleMyIssueOpen={handleMyIssueOpen}
|
||||
removeIssue={() => {
|
||||
if (removeIssue && issue.bridge_id)
|
||||
removeIssue(issue.bridge_id, issue.id);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import { mutate } from "swr";
|
||||
@@ -58,6 +57,7 @@ type Props = {
|
||||
index: number;
|
||||
editIssue: () => void;
|
||||
makeIssueCopy: () => void;
|
||||
handleMyIssueOpen?: (issue: IIssue) => void;
|
||||
removeIssue?: (() => void) | null;
|
||||
handleDeleteIssue: (issue: IIssue) => void;
|
||||
handleTrashBox: (isDragging: boolean) => void;
|
||||
@@ -75,6 +75,7 @@ export const SingleBoardIssue: React.FC<Props> = ({
|
||||
index,
|
||||
editIssue,
|
||||
makeIssueCopy,
|
||||
handleMyIssueOpen,
|
||||
removeIssue,
|
||||
groupTitle,
|
||||
handleDeleteIssue,
|
||||
@@ -187,6 +188,17 @@ export const SingleBoardIssue: React.FC<Props> = ({
|
||||
|
||||
useOutsideClickDetector(actionSectionRef, () => setIsMenuActive(false));
|
||||
|
||||
const openPeekOverview = () => {
|
||||
const { query } = router;
|
||||
|
||||
if (handleMyIssueOpen) handleMyIssueOpen(issue);
|
||||
|
||||
router.push({
|
||||
pathname: router.pathname,
|
||||
query: { ...query, peekIssue: issue.id },
|
||||
});
|
||||
};
|
||||
|
||||
const isNotAllowed = userAuth.isGuest || userAuth.isViewer || disableUserActions;
|
||||
|
||||
return (
|
||||
@@ -286,16 +298,22 @@ export const SingleBoardIssue: React.FC<Props> = ({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<Link href={`/${workspaceSlug}/projects/${issue.project}/issues/${issue.id}`}>
|
||||
<a className="flex flex-col gap-1.5">
|
||||
{properties.key && (
|
||||
<div className="text-xs font-medium text-custom-text-200">
|
||||
{issue.project_detail.identifier}-{issue.sequence_id}
|
||||
</div>
|
||||
)}
|
||||
<h5 className="text-sm break-words line-clamp-2">{issue.name}</h5>
|
||||
</a>
|
||||
</Link>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{properties.key && (
|
||||
<div className="text-xs font-medium text-custom-text-200">
|
||||
{issue.project_detail.identifier}-{issue.sequence_id}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="text-sm text-left break-words line-clamp-2"
|
||||
onClick={openPeekOverview}
|
||||
>
|
||||
{issue.name}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`flex items-center gap-2 text-xs ${
|
||||
isDropdownActive ? "" : "overflow-x-scroll"
|
||||
|
||||
@@ -12,6 +12,7 @@ import issuesService from "services/issues.service";
|
||||
import useCalendarIssuesView from "hooks/use-calendar-issues-view";
|
||||
// components
|
||||
import { SingleCalendarDate, CalendarHeader } from "components/core";
|
||||
import { IssuePeekOverview } from "components/issues";
|
||||
// ui
|
||||
import { Spinner } from "components/ui";
|
||||
// helpers
|
||||
@@ -60,7 +61,8 @@ export const CalendarView: React.FC<Props> = ({
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, cycleId, moduleId, viewId } = router.query;
|
||||
|
||||
const { calendarIssues, params, displayFilters, setDisplayFilters } = useCalendarIssuesView();
|
||||
const { calendarIssues, mutateIssues, params, displayFilters, setDisplayFilters } =
|
||||
useCalendarIssuesView();
|
||||
|
||||
const totalDate = eachDayOfInterval({
|
||||
start: calendarDates.startDate,
|
||||
@@ -170,75 +172,85 @@ export const CalendarView: React.FC<Props> = ({
|
||||
|
||||
const isNotAllowed = userAuth.isGuest || userAuth.isViewer || disableUserActions;
|
||||
|
||||
return calendarIssues ? (
|
||||
<div className="h-full overflow-y-auto">
|
||||
<DragDropContext onDragEnd={onDragEnd}>
|
||||
<div className="h-full rounded-lg p-8 text-custom-text-200">
|
||||
<CalendarHeader
|
||||
isMonthlyView={isMonthlyView}
|
||||
setIsMonthlyView={setIsMonthlyView}
|
||||
showWeekEnds={showWeekEnds}
|
||||
setShowWeekEnds={setShowWeekEnds}
|
||||
currentDate={currentDate}
|
||||
setCurrentDate={setCurrentDate}
|
||||
changeDateRange={changeDateRange}
|
||||
/>
|
||||
return (
|
||||
<>
|
||||
<IssuePeekOverview
|
||||
handleMutation={() => mutateIssues()}
|
||||
projectId={projectId?.toString() ?? ""}
|
||||
workspaceSlug={workspaceSlug?.toString() ?? ""}
|
||||
readOnly={disableUserActions}
|
||||
/>
|
||||
{calendarIssues ? (
|
||||
<div className="h-full overflow-y-auto">
|
||||
<DragDropContext onDragEnd={onDragEnd}>
|
||||
<div className="h-full rounded-lg p-8 text-custom-text-200">
|
||||
<CalendarHeader
|
||||
isMonthlyView={isMonthlyView}
|
||||
setIsMonthlyView={setIsMonthlyView}
|
||||
showWeekEnds={showWeekEnds}
|
||||
setShowWeekEnds={setShowWeekEnds}
|
||||
currentDate={currentDate}
|
||||
setCurrentDate={setCurrentDate}
|
||||
changeDateRange={changeDateRange}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={`grid auto-rows-[minmax(36px,1fr)] rounded-lg ${
|
||||
showWeekEnds ? "grid-cols-7" : "grid-cols-5"
|
||||
}`}
|
||||
>
|
||||
{weeks.map((date, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`flex items-center justify-start gap-2 border-custom-border-200 bg-custom-background-90 p-1.5 text-base font-medium text-custom-text-200 ${
|
||||
!isMonthlyView
|
||||
? showWeekEnds
|
||||
? (index + 1) % 7 === 0
|
||||
? ""
|
||||
: "border-r"
|
||||
: (index + 1) % 5 === 0
|
||||
? ""
|
||||
: "border-r"
|
||||
: ""
|
||||
className={`grid auto-rows-[minmax(36px,1fr)] rounded-lg ${
|
||||
showWeekEnds ? "grid-cols-7" : "grid-cols-5"
|
||||
}`}
|
||||
>
|
||||
<span>
|
||||
{isMonthlyView
|
||||
? formatDate(date, "eee").substring(0, 3)
|
||||
: formatDate(date, "eee")}
|
||||
</span>
|
||||
{!isMonthlyView && <span>{formatDate(date, "d")}</span>}
|
||||
{weeks.map((date, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`flex items-center justify-start gap-2 border-custom-border-200 bg-custom-background-90 p-1.5 text-base font-medium text-custom-text-200 ${
|
||||
!isMonthlyView
|
||||
? showWeekEnds
|
||||
? (index + 1) % 7 === 0
|
||||
? ""
|
||||
: "border-r"
|
||||
: (index + 1) % 5 === 0
|
||||
? ""
|
||||
: "border-r"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<span>
|
||||
{isMonthlyView
|
||||
? formatDate(date, "eee").substring(0, 3)
|
||||
: formatDate(date, "eee")}
|
||||
</span>
|
||||
{!isMonthlyView && <span>{formatDate(date, "d")}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`grid h-full ${isMonthlyView ? "auto-rows-min" : ""} ${
|
||||
showWeekEnds ? "grid-cols-7" : "grid-cols-5"
|
||||
} `}
|
||||
>
|
||||
{currentViewDaysData.map((date, index) => (
|
||||
<SingleCalendarDate
|
||||
key={`${date}-${index}`}
|
||||
index={index}
|
||||
date={date}
|
||||
handleIssueAction={handleIssueAction}
|
||||
addIssueToDate={addIssueToDate}
|
||||
isMonthlyView={isMonthlyView}
|
||||
showWeekEnds={showWeekEnds}
|
||||
user={user}
|
||||
isNotAllowed={isNotAllowed}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
className={`grid h-full ${isMonthlyView ? "auto-rows-min" : ""} ${
|
||||
showWeekEnds ? "grid-cols-7" : "grid-cols-5"
|
||||
} `}
|
||||
>
|
||||
{currentViewDaysData.map((date, index) => (
|
||||
<SingleCalendarDate
|
||||
key={`${date}-${index}`}
|
||||
index={index}
|
||||
date={date}
|
||||
handleIssueAction={handleIssueAction}
|
||||
addIssueToDate={addIssueToDate}
|
||||
isMonthlyView={isMonthlyView}
|
||||
showWeekEnds={showWeekEnds}
|
||||
user={user}
|
||||
isNotAllowed={isNotAllowed}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</DragDropContext>
|
||||
</div>
|
||||
</DragDropContext>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React, { useCallback } from "react";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import { mutate } from "swr";
|
||||
@@ -158,6 +157,15 @@ export const SingleCalendarIssue: React.FC<Props> = ({
|
||||
? Object.values(properties).some((value) => value === true)
|
||||
: false;
|
||||
|
||||
const openPeekOverview = () => {
|
||||
const { query } = router;
|
||||
|
||||
router.push({
|
||||
pathname: router.pathname,
|
||||
query: { ...query, peekIssue: issue.id },
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
@@ -193,23 +201,27 @@ export const SingleCalendarIssue: React.FC<Props> = ({
|
||||
</CustomMenu>
|
||||
</div>
|
||||
)}
|
||||
<Link href={`/${workspaceSlug}/projects/${issue.project}/issues/${issue.id}`}>
|
||||
<a className="flex w-full cursor-pointer flex-col items-start justify-center gap-1.5">
|
||||
{properties.key && (
|
||||
<Tooltip
|
||||
tooltipHeading="Issue ID"
|
||||
tooltipContent={`${issue.project_detail?.identifier}-${issue.sequence_id}`}
|
||||
>
|
||||
<span className="flex-shrink-0 text-xs text-custom-text-200">
|
||||
{issue.project_detail?.identifier}-{issue.sequence_id}
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip position="top-left" tooltipHeading="Title" tooltipContent={issue.name}>
|
||||
<span className="text-xs text-custom-text-100">{truncateText(issue.name, 25)}</span>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full cursor-pointer flex-col items-start justify-center gap-1.5"
|
||||
onClick={openPeekOverview}
|
||||
>
|
||||
{properties.key && (
|
||||
<Tooltip
|
||||
tooltipHeading="Issue ID"
|
||||
tooltipContent={`${issue.project_detail?.identifier}-${issue.sequence_id}`}
|
||||
>
|
||||
<span className="flex-shrink-0 text-xs text-custom-text-200">
|
||||
{issue.project_detail?.identifier}-{issue.sequence_id}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</a>
|
||||
</Link>
|
||||
)}
|
||||
<Tooltip position="top-left" tooltipHeading="Title" tooltipContent={issue.name}>
|
||||
<span className="text-xs text-custom-text-100">{truncateText(issue.name, 25)}</span>
|
||||
</Tooltip>
|
||||
</button>
|
||||
|
||||
{displayProperties && (
|
||||
<div className="relative mt-1.5 w-full flex flex-wrap items-center gap-2 text-xs">
|
||||
{properties.priority && (
|
||||
|
||||
@@ -6,20 +6,24 @@ import { IssueGanttChartView } from "components/issues";
|
||||
import { ModuleIssuesGanttChartView } from "components/modules";
|
||||
import { ViewIssuesGanttChartView } from "components/views";
|
||||
|
||||
export const GanttChartView = () => {
|
||||
type Props = {
|
||||
disableUserActions: boolean;
|
||||
};
|
||||
|
||||
export const GanttChartView: React.FC<Props> = ({ disableUserActions }) => {
|
||||
const router = useRouter();
|
||||
const { cycleId, moduleId, viewId } = router.query;
|
||||
|
||||
return (
|
||||
<>
|
||||
{cycleId ? (
|
||||
<CycleIssuesGanttChartView />
|
||||
<CycleIssuesGanttChartView disableUserActions={disableUserActions} />
|
||||
) : moduleId ? (
|
||||
<ModuleIssuesGanttChartView />
|
||||
<ModuleIssuesGanttChartView disableUserActions={disableUserActions} />
|
||||
) : viewId ? (
|
||||
<ViewIssuesGanttChartView />
|
||||
<ViewIssuesGanttChartView disableUserActions={disableUserActions} />
|
||||
) : (
|
||||
<IssueGanttChartView />
|
||||
<IssueGanttChartView disableUserActions={disableUserActions} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -19,7 +19,12 @@ import useIssuesProperties from "hooks/use-issue-properties";
|
||||
import useProjectMembers from "hooks/use-project-members";
|
||||
// components
|
||||
import { FiltersList, AllViews } from "components/core";
|
||||
import { CreateUpdateIssueModal, DeleteIssueModal } from "components/issues";
|
||||
import {
|
||||
CreateUpdateIssueModal,
|
||||
DeleteIssueModal,
|
||||
IssuePeekOverview,
|
||||
CreateUpdateDraftIssueModal,
|
||||
} from "components/issues";
|
||||
import { CreateUpdateViewModal } from "components/views";
|
||||
// ui
|
||||
import { PrimaryButton, SecondaryButton } from "components/ui";
|
||||
@@ -70,6 +75,9 @@ export const IssuesView: React.FC<Props> = ({
|
||||
// trash box
|
||||
const [trashBox, setTrashBox] = useState(false);
|
||||
|
||||
// selected draft issue
|
||||
const [selectedDraftIssue, setSelectedDraftIssue] = useState<IIssue | null>(null);
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, cycleId, moduleId, viewId } = router.query;
|
||||
|
||||
@@ -106,6 +114,8 @@ export const IssuesView: React.FC<Props> = ({
|
||||
[setDeleteIssueModal, setIssueToDelete]
|
||||
);
|
||||
|
||||
const handleDraftIssueClick = (issue: any) => setSelectedDraftIssue(issue);
|
||||
|
||||
const handleOnDragEnd = useCallback(
|
||||
async (result: DropResult) => {
|
||||
setTrashBox(false);
|
||||
@@ -335,10 +345,11 @@ export const IssuesView: React.FC<Props> = ({
|
||||
);
|
||||
|
||||
const handleIssueAction = useCallback(
|
||||
(issue: IIssue, action: "copy" | "edit" | "delete") => {
|
||||
(issue: IIssue, action: "copy" | "edit" | "delete" | "updateDraft") => {
|
||||
if (action === "copy") makeIssueCopy(issue);
|
||||
else if (action === "edit") handleEditIssue(issue);
|
||||
else if (action === "delete") handleDeleteIssue(issue);
|
||||
else if (action === "updateDraft") handleDraftIssueClick(issue);
|
||||
},
|
||||
[makeIssueCopy, handleEditIssue, handleDeleteIssue]
|
||||
);
|
||||
@@ -451,6 +462,27 @@ export const IssuesView: React.FC<Props> = ({
|
||||
...preloadedData,
|
||||
}}
|
||||
/>
|
||||
<CreateUpdateDraftIssueModal
|
||||
isOpen={selectedDraftIssue !== null}
|
||||
handleClose={() => setSelectedDraftIssue(null)}
|
||||
data={
|
||||
selectedDraftIssue
|
||||
? {
|
||||
...selectedDraftIssue,
|
||||
is_draft: true,
|
||||
}
|
||||
: null
|
||||
}
|
||||
fieldsToShow={[
|
||||
"name",
|
||||
"description",
|
||||
"label",
|
||||
"assignee",
|
||||
"priority",
|
||||
"dueDate",
|
||||
"priority",
|
||||
]}
|
||||
/>
|
||||
<CreateUpdateIssueModal
|
||||
isOpen={editIssueModal && issueToEdit?.actionType !== "delete"}
|
||||
handleClose={() => setEditIssueModal(false)}
|
||||
@@ -462,6 +494,7 @@ export const IssuesView: React.FC<Props> = ({
|
||||
data={issueToDelete}
|
||||
user={user}
|
||||
/>
|
||||
|
||||
{areFiltersApplied && (
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-2 px-5 pt-3 pb-0">
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
// hooks
|
||||
import useMyIssues from "hooks/my-issues/use-my-issues";
|
||||
import useIssuesView from "hooks/use-issues-view";
|
||||
import useProfileIssues from "hooks/use-profile-issues";
|
||||
// components
|
||||
import { SingleList } from "components/core/views/list-view/single-list";
|
||||
import { IssuePeekOverview } from "components/issues";
|
||||
// types
|
||||
import { ICurrentUserResponse, IIssue, IIssueViewProps, IState, UserAuth } from "types";
|
||||
|
||||
@@ -7,8 +14,10 @@ import { ICurrentUserResponse, IIssue, IIssueViewProps, IState, UserAuth } from
|
||||
type Props = {
|
||||
states: IState[] | undefined;
|
||||
addIssueToGroup: (groupTitle: string) => void;
|
||||
handleIssueAction: (issue: IIssue, action: "copy" | "delete" | "edit") => void;
|
||||
handleIssueAction: (issue: IIssue, action: "copy" | "delete" | "edit" | "updateDraft") => void;
|
||||
openIssuesListModal?: (() => void) | null;
|
||||
myIssueProjectId?: string | null;
|
||||
handleMyIssueOpen?: (issue: IIssue) => void;
|
||||
removeIssue: ((bridgeId: string, issueId: string) => void) | null;
|
||||
disableUserActions: boolean;
|
||||
disableAddIssueOption?: boolean;
|
||||
@@ -23,16 +32,39 @@ export const AllLists: React.FC<Props> = ({
|
||||
disableUserActions,
|
||||
disableAddIssueOption = false,
|
||||
openIssuesListModal,
|
||||
handleMyIssueOpen,
|
||||
myIssueProjectId,
|
||||
removeIssue,
|
||||
states,
|
||||
user,
|
||||
userAuth,
|
||||
viewProps,
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, userId } = router.query;
|
||||
|
||||
const isProfileIssue =
|
||||
router.pathname.includes("assigned") ||
|
||||
router.pathname.includes("created") ||
|
||||
router.pathname.includes("subscribed");
|
||||
|
||||
const isMyIssue = router.pathname.includes("my-issues");
|
||||
const { mutateIssues } = useIssuesView();
|
||||
const { mutateMyIssues } = useMyIssues(workspaceSlug?.toString());
|
||||
const { mutateProfileIssues } = useProfileIssues(workspaceSlug?.toString(), userId?.toString());
|
||||
|
||||
const { displayFilters, groupedIssues } = viewProps;
|
||||
|
||||
return (
|
||||
<>
|
||||
<IssuePeekOverview
|
||||
handleMutation={() =>
|
||||
isMyIssue ? mutateMyIssues() : isProfileIssue ? mutateProfileIssues() : mutateIssues()
|
||||
}
|
||||
projectId={myIssueProjectId ? myIssueProjectId : projectId?.toString() ?? ""}
|
||||
workspaceSlug={workspaceSlug?.toString() ?? ""}
|
||||
readOnly={disableUserActions}
|
||||
/>
|
||||
{groupedIssues && (
|
||||
<div className="h-full overflow-y-auto">
|
||||
{Object.keys(groupedIssues).map((singleGroup) => {
|
||||
@@ -51,6 +83,7 @@ export const AllLists: React.FC<Props> = ({
|
||||
currentState={currentState}
|
||||
addIssueToGroup={() => addIssueToGroup(singleGroup)}
|
||||
handleIssueAction={handleIssueAction}
|
||||
handleMyIssueOpen={handleMyIssueOpen}
|
||||
openIssuesListModal={openIssuesListModal}
|
||||
removeIssue={removeIssue}
|
||||
disableUserActions={disableUserActions}
|
||||
|
||||
@@ -61,6 +61,8 @@ type Props = {
|
||||
makeIssueCopy: () => void;
|
||||
removeIssue?: (() => void) | null;
|
||||
handleDeleteIssue: (issue: IIssue) => void;
|
||||
handleDraftIssueSelect?: (issue: IIssue) => void;
|
||||
handleMyIssueOpen?: (issue: IIssue) => void;
|
||||
disableUserActions: boolean;
|
||||
user: ICurrentUserResponse | undefined;
|
||||
userAuth: UserAuth;
|
||||
@@ -76,10 +78,12 @@ export const SingleListIssue: React.FC<Props> = ({
|
||||
removeIssue,
|
||||
groupTitle,
|
||||
handleDeleteIssue,
|
||||
handleMyIssueOpen,
|
||||
disableUserActions,
|
||||
user,
|
||||
userAuth,
|
||||
viewProps,
|
||||
handleDraftIssueSelect,
|
||||
}) => {
|
||||
// context menu
|
||||
const [contextMenu, setContextMenu] = useState(false);
|
||||
@@ -88,6 +92,7 @@ export const SingleListIssue: React.FC<Props> = ({
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, cycleId, moduleId, userId } = router.query;
|
||||
const isArchivedIssues = router.pathname.includes("archived-issues");
|
||||
const isDraftIssues = router.pathname?.split("/")?.[4] === "draft-issues";
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
@@ -176,8 +181,20 @@ export const SingleListIssue: React.FC<Props> = ({
|
||||
|
||||
const issuePath = isArchivedIssues
|
||||
? `/${workspaceSlug}/projects/${issue.project}/archived-issues/${issue.id}`
|
||||
: isDraftIssues
|
||||
? `#`
|
||||
: `/${workspaceSlug}/projects/${issue.project}/issues/${issue.id}`;
|
||||
|
||||
const openPeekOverview = (issue: IIssue) => {
|
||||
const { query } = router;
|
||||
|
||||
if (handleMyIssueOpen) handleMyIssueOpen(issue);
|
||||
router.push({
|
||||
pathname: router.pathname,
|
||||
query: { ...query, peekIssue: issue.id },
|
||||
});
|
||||
};
|
||||
|
||||
const isNotAllowed =
|
||||
userAuth.isGuest || userAuth.isViewer || disableUserActions || isArchivedIssues;
|
||||
|
||||
@@ -220,23 +237,31 @@ export const SingleListIssue: React.FC<Props> = ({
|
||||
}}
|
||||
>
|
||||
<div className="flex-grow cursor-pointer min-w-[200px] whitespace-nowrap overflow-hidden overflow-ellipsis">
|
||||
<Link href={issuePath}>
|
||||
<a className="group relative flex items-center gap-2">
|
||||
{properties.key && (
|
||||
<Tooltip
|
||||
tooltipHeading="Issue ID"
|
||||
tooltipContent={`${issue.project_detail?.identifier}-${issue.sequence_id}`}
|
||||
>
|
||||
<span className="flex-shrink-0 text-xs text-custom-text-200">
|
||||
{issue.project_detail?.identifier}-{issue.sequence_id}
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip position="top-left" tooltipHeading="Title" tooltipContent={issue.name}>
|
||||
<span className="truncate text-[0.825rem] text-custom-text-100">{issue.name}</span>
|
||||
<div className="group relative flex items-center gap-2">
|
||||
{properties.key && (
|
||||
<Tooltip
|
||||
tooltipHeading="Issue ID"
|
||||
tooltipContent={`${issue.project_detail?.identifier}-${issue.sequence_id}`}
|
||||
>
|
||||
<span className="flex-shrink-0 text-xs text-custom-text-200">
|
||||
{issue.project_detail?.identifier}-{issue.sequence_id}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</a>
|
||||
</Link>
|
||||
)}
|
||||
<Tooltip position="top-left" tooltipHeading="Title" tooltipContent={issue.name}>
|
||||
<button
|
||||
type="button"
|
||||
className="truncate text-[0.825rem] text-custom-text-100"
|
||||
onClick={() => {
|
||||
if (!isDraftIssues) openPeekOverview(issue);
|
||||
|
||||
if (handleDraftIssueSelect) handleDraftIssueSelect(issue);
|
||||
}}
|
||||
>
|
||||
{issue.name}
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
|
||||
@@ -39,8 +39,9 @@ type Props = {
|
||||
currentState?: IState | null;
|
||||
groupTitle: string;
|
||||
addIssueToGroup: () => void;
|
||||
handleIssueAction: (issue: IIssue, action: "copy" | "delete" | "edit") => void;
|
||||
handleIssueAction: (issue: IIssue, action: "copy" | "delete" | "edit" | "updateDraft") => void;
|
||||
openIssuesListModal?: (() => void) | null;
|
||||
handleMyIssueOpen?: (issue: IIssue) => void;
|
||||
removeIssue: ((bridgeId: string, issueId: string) => void) | null;
|
||||
disableUserActions: boolean;
|
||||
disableAddIssueOption?: boolean;
|
||||
@@ -55,6 +56,7 @@ export const SingleList: React.FC<Props> = ({
|
||||
addIssueToGroup,
|
||||
handleIssueAction,
|
||||
openIssuesListModal,
|
||||
handleMyIssueOpen,
|
||||
removeIssue,
|
||||
disableUserActions,
|
||||
disableAddIssueOption = false,
|
||||
@@ -251,6 +253,8 @@ export const SingleList: React.FC<Props> = ({
|
||||
editIssue={() => handleIssueAction(issue, "edit")}
|
||||
makeIssueCopy={() => handleIssueAction(issue, "copy")}
|
||||
handleDeleteIssue={() => handleIssueAction(issue, "delete")}
|
||||
handleDraftIssueSelect={() => handleIssueAction(issue, "updateDraft")}
|
||||
handleMyIssueOpen={handleMyIssueOpen}
|
||||
removeIssue={() => {
|
||||
if (removeIssue !== null && issue.bridge_id)
|
||||
removeIssue(issue.bridge_id, issue.id);
|
||||
|
||||
@@ -8,11 +8,15 @@ import { updateGanttIssue } from "components/gantt-chart/hooks/block-update";
|
||||
import useProjectDetails from "hooks/use-project-details";
|
||||
// components
|
||||
import { GanttChartRoot, renderIssueBlocksStructure } from "components/gantt-chart";
|
||||
import { IssueGanttBlock, IssueGanttSidebarBlock } from "components/issues";
|
||||
import { IssueGanttBlock, IssueGanttSidebarBlock, IssuePeekOverview } from "components/issues";
|
||||
// types
|
||||
import { IIssue } from "types";
|
||||
|
||||
export const CycleIssuesGanttChartView = () => {
|
||||
type Props = {
|
||||
disableUserActions: boolean;
|
||||
};
|
||||
|
||||
export const CycleIssuesGanttChartView: React.FC<Props> = ({ disableUserActions }) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, cycleId } = router.query;
|
||||
|
||||
@@ -30,23 +34,31 @@ export const CycleIssuesGanttChartView = () => {
|
||||
const isAllowed = projectDetails?.member_role === 20 || projectDetails?.member_role === 15;
|
||||
|
||||
return (
|
||||
<div className="w-full h-full">
|
||||
<GanttChartRoot
|
||||
border={false}
|
||||
title="Issues"
|
||||
loaderTitle="Issues"
|
||||
blocks={ganttIssues ? renderIssueBlocksStructure(ganttIssues as IIssue[]) : null}
|
||||
blockUpdateHandler={(block, payload) =>
|
||||
updateGanttIssue(block, payload, mutateGanttIssues, user, workspaceSlug?.toString())
|
||||
}
|
||||
SidebarBlockRender={IssueGanttSidebarBlock}
|
||||
BlockRender={IssueGanttBlock}
|
||||
enableBlockLeftResize={isAllowed}
|
||||
enableBlockRightResize={isAllowed}
|
||||
enableBlockMove={isAllowed}
|
||||
enableReorder={displayFilters.order_by === "sort_order" && isAllowed}
|
||||
bottomSpacing
|
||||
<>
|
||||
<IssuePeekOverview
|
||||
handleMutation={() => mutateGanttIssues()}
|
||||
projectId={projectId?.toString() ?? ""}
|
||||
workspaceSlug={workspaceSlug?.toString() ?? ""}
|
||||
readOnly={disableUserActions}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full h-full">
|
||||
<GanttChartRoot
|
||||
border={false}
|
||||
title="Issues"
|
||||
loaderTitle="Issues"
|
||||
blocks={ganttIssues ? renderIssueBlocksStructure(ganttIssues as IIssue[]) : null}
|
||||
blockUpdateHandler={(block, payload) =>
|
||||
updateGanttIssue(block, payload, mutateGanttIssues, user, workspaceSlug?.toString())
|
||||
}
|
||||
SidebarBlockRender={IssueGanttSidebarBlock}
|
||||
BlockRender={IssueGanttBlock}
|
||||
enableBlockLeftResize={isAllowed}
|
||||
enableBlockRightResize={isAllowed}
|
||||
enableBlockMove={isAllowed}
|
||||
enableReorder={displayFilters.order_by === "sort_order" && isAllowed}
|
||||
bottomSpacing
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import React, { useEffect, useState, useRef } from "react";
|
||||
// headless ui
|
||||
import { Tab, Transition, Popover } from "@headlessui/react";
|
||||
// react colors
|
||||
import { TwitterPicker } from "react-color";
|
||||
// hooks
|
||||
import useOutsideClickDetector from "hooks/use-outside-click-detector";
|
||||
// types
|
||||
import { Props } from "./types";
|
||||
// emojis
|
||||
@@ -36,6 +38,8 @@ const EmojiIconPicker: React.FC<Props> = ({
|
||||
|
||||
const [recentEmojis, setRecentEmojis] = useState<string[]>([]);
|
||||
|
||||
const emojiPickerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setRecentEmojis(getRecentEmojis());
|
||||
}, []);
|
||||
@@ -44,6 +48,8 @@ const EmojiIconPicker: React.FC<Props> = ({
|
||||
if (!value || value?.length === 0) onChange(getRandomEmoji());
|
||||
}, [value, onChange]);
|
||||
|
||||
useOutsideClickDetector(emojiPickerRef, () => setIsOpen(false));
|
||||
|
||||
return (
|
||||
<Popover className="relative z-[1]">
|
||||
<Popover.Button
|
||||
@@ -63,7 +69,10 @@ const EmojiIconPicker: React.FC<Props> = ({
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<Popover.Panel className="absolute z-10 mt-2 w-[250px] rounded-[4px] border border-custom-border-200 bg-custom-background-80 shadow-lg">
|
||||
<div className="h-[230px] w-[250px] overflow-auto rounded-[4px] border border-custom-border-200 bg-custom-background-80 p-2 shadow-xl">
|
||||
<div
|
||||
ref={emojiPickerRef}
|
||||
className="h-[230px] w-[250px] overflow-auto rounded-[4px] border border-custom-border-200 bg-custom-background-80 p-2 shadow-xl"
|
||||
>
|
||||
<Tab.Group as="div" className="flex h-full w-full flex-col">
|
||||
<Tab.List className="flex-0 -mx-2 flex justify-around gap-1 p-1">
|
||||
{tabOptions.map((tab) => (
|
||||
|
||||
@@ -66,7 +66,7 @@ export const SingleEstimate: React.FC<Props> = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="gap-2 py-3">
|
||||
<div className="gap-2 p-4 border-b border-custom-border-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h6 className="flex w-[40vw] items-center gap-2 truncate text-sm font-medium">
|
||||
|
||||
@@ -83,3 +83,5 @@ export * from "./archive-icon";
|
||||
export * from "./clock-icon";
|
||||
export * from "./bell-icon";
|
||||
export * from "./single-comment-icon";
|
||||
export * from "./related-icon";
|
||||
export * from "./module-icon";
|
||||
59
web/components/icons/module-icon.tsx
Normal file
59
web/components/icons/module-icon.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import React from "react";
|
||||
|
||||
import type { Props } from "./types";
|
||||
|
||||
export const ModuleIcon: React.FC<Props> = ({
|
||||
width = "24",
|
||||
height = "24",
|
||||
className,
|
||||
color = "#F15B5B",
|
||||
}) => (
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
className={className}
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M12.6667 2H3.33333C2.59695 2 2 2.59695 2 3.33333V12.6667C2 13.403 2.59695 14 3.33333 14H12.6667C13.403 14 14 13.403 14 12.6667V3.33333C14 2.59695 13.403 2 12.6667 2Z"
|
||||
stroke="#F15B5B"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M5.84925 4.66667H4.81221C4.73039 4.66667 4.66406 4.733 4.66406 4.81482V5.85185C4.66406 5.93367 4.73039 6 4.81221 6H5.84925C5.93107 6 5.9974 5.93367 5.9974 5.85185V4.81482C5.9974 4.733 5.93107 4.66667 5.84925 4.66667Z"
|
||||
fill={color}
|
||||
stroke="#F15B5B"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M5.84925 10H4.81221C4.73039 10 4.66406 10.0663 4.66406 10.1481V11.1852C4.66406 11.267 4.73039 11.3333 4.81221 11.3333H5.84925C5.93107 11.3333 5.9974 11.267 5.9974 11.1852V10.1481C5.9974 10.0663 5.93107 10 5.84925 10Z"
|
||||
fill={color}
|
||||
stroke="#F15B5B"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M11.1852 4.66667H10.1481C10.0663 4.66667 10 4.733 10 4.81482V5.85185C10 5.93367 10.0663 6 10.1481 6H11.1852C11.267 6 11.3333 5.93367 11.3333 5.85185V4.81482C11.3333 4.733 11.267 4.66667 11.1852 4.66667Z"
|
||||
fill={color}
|
||||
stroke="#F15B5B"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M11.1852 10H10.1481C10.0663 10 10 10.0663 10 10.1481V11.1852C10 11.267 10.0663 11.3333 10.1481 11.3333H11.1852C11.267 11.3333 11.3333 11.267 11.3333 11.1852V10.1481C11.3333 10.0663 11.267 10 11.1852 10Z"
|
||||
fill={color}
|
||||
stroke="#F15B5B"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
41
web/components/icons/related-icon.tsx
Normal file
41
web/components/icons/related-icon.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import React from "react";
|
||||
|
||||
import type { Props } from "./types";
|
||||
|
||||
export const RelatedIcon: React.FC<Props> = ({
|
||||
width = "24",
|
||||
height = "24",
|
||||
color = "rgb(var(--color-text-200))",
|
||||
className,
|
||||
}) => (
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M20 13L20 6C20 5.46957 19.7893 4.96086 19.4142 4.58579C19.0391 4.21071 18.5304 4 18 4L4 4C3.46957 4 2.96086 4.21071 2.58579 4.58579C2.21071 4.96086 2 5.46957 2 6L2 20C2 20.5304 2.21071 21.0391 2.58579 21.4142C2.96086 21.7893 3.46957 22 4 22L11 22"
|
||||
stroke={color}
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M12.125 19.25L9 16.125L12.125 13"
|
||||
stroke={color}
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M20 22V18.1818C20 17.6032 19.7366 17.0482 19.2678 16.639C18.7989 16.2299 18.163 16 17.5 16H10"
|
||||
stroke={color}
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
@@ -66,6 +66,8 @@ export const SelectRepository: React.FC<Props> = ({
|
||||
content: <p>{truncateText(repo.full_name, characterLimit)}</p>,
|
||||
})) ?? [];
|
||||
|
||||
if (userRepositories.length < 1) return null;
|
||||
|
||||
return (
|
||||
<CustomSearchSelect
|
||||
value={value}
|
||||
|
||||
@@ -83,9 +83,7 @@ export const SelectChannel: React.FC<Props> = ({ integration }) => {
|
||||
{projectIntegration ? (
|
||||
<button
|
||||
type="button"
|
||||
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none ${
|
||||
slackChannelAvailabilityToggle ? "bg-green-500" : "bg-gray-200"
|
||||
}`}
|
||||
className={`relative inline-flex h-4 w-6 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none bg-gray-700`}
|
||||
role="switch"
|
||||
aria-checked
|
||||
onClick={() => {
|
||||
@@ -94,8 +92,8 @@ export const SelectChannel: React.FC<Props> = ({ integration }) => {
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
|
||||
slackChannelAvailabilityToggle ? "translate-x-5" : "translate-x-0"
|
||||
className={`self-center inline-block h-2 w-2 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
|
||||
slackChannelAvailabilityToggle ? "translate-x-3" : "translate-x-0"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
93
web/components/issues/confirm-issue-discard.tsx
Normal file
93
web/components/issues/confirm-issue-discard.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import React, { useState } from "react";
|
||||
|
||||
// headless ui
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
// ui
|
||||
import { SecondaryButton, PrimaryButton } from "components/ui";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
handleClose: () => void;
|
||||
onDiscard: () => void;
|
||||
onConfirm: () => Promise<void>;
|
||||
};
|
||||
|
||||
export const ConfirmIssueDiscard: React.FC<Props> = (props) => {
|
||||
const { isOpen, handleClose, onDiscard, onConfirm } = props;
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const onClose = () => {
|
||||
handleClose();
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
const handleDeletion = async () => {
|
||||
setIsLoading(true);
|
||||
await onConfirm();
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Transition.Root show={isOpen} as={React.Fragment}>
|
||||
<Dialog as="div" className="relative z-20" onClose={handleClose}>
|
||||
<Transition.Child
|
||||
as={React.Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 bg-custom-backdrop bg-opacity-50 transition-opacity" />
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0 z-10 overflow-y-auto">
|
||||
<div className="my-10 flex items-center justify-center p-4 text-center sm:p-0 md:my-32">
|
||||
<Transition.Child
|
||||
as={React.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 border border-custom-border-200 bg-custom-background-100 text-left shadow-xl transition-all sm:my-8 sm:w-[40rem]">
|
||||
<div className="px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
||||
<div className="sm:flex sm:items-start">
|
||||
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
|
||||
<Dialog.Title
|
||||
as="h3"
|
||||
className="text-lg font-medium leading-6 text-custom-text-100"
|
||||
>
|
||||
Draft Issue
|
||||
</Dialog.Title>
|
||||
<div className="mt-2">
|
||||
<p className="text-sm text-custom-text-200">
|
||||
Would you like to save this issue in drafts?
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between gap-2 p-4 sm:px-6">
|
||||
<div>
|
||||
<SecondaryButton onClick={onDiscard}>Discard</SecondaryButton>
|
||||
</div>
|
||||
<div className="space-x-2">
|
||||
<SecondaryButton onClick={onClose}>Cancel</SecondaryButton>
|
||||
<PrimaryButton onClick={handleDeletion} loading={isLoading}>
|
||||
{isLoading ? "Saving..." : "Save Draft"}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition.Root>
|
||||
);
|
||||
};
|
||||
580
web/components/issues/draft-issue-form.tsx
Normal file
580
web/components/issues/draft-issue-form.tsx
Normal file
@@ -0,0 +1,580 @@
|
||||
import React, { FC, useState, useEffect, useRef } from "react";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
// react-hook-form
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
// services
|
||||
import aiService from "services/ai.service";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
// components
|
||||
import { GptAssistantModal } from "components/core";
|
||||
import { ParentIssuesListModal } from "components/issues";
|
||||
import {
|
||||
IssueAssigneeSelect,
|
||||
IssueDateSelect,
|
||||
IssueEstimateSelect,
|
||||
IssueLabelSelect,
|
||||
IssuePrioritySelect,
|
||||
IssueProjectSelect,
|
||||
IssueStateSelect,
|
||||
} from "components/issues/select";
|
||||
import { CreateStateModal } from "components/states";
|
||||
import { CreateLabelModal } from "components/labels";
|
||||
// ui
|
||||
import { CustomMenu, Input, PrimaryButton, SecondaryButton, ToggleSwitch } from "components/ui";
|
||||
import { TipTapEditor } from "components/tiptap";
|
||||
// icons
|
||||
import { SparklesIcon, XMarkIcon } from "@heroicons/react/24/outline";
|
||||
// types
|
||||
import type { ICurrentUserResponse, IIssue, ISearchIssueResponse } from "types";
|
||||
|
||||
const defaultValues: Partial<IIssue> = {
|
||||
project: "",
|
||||
name: "",
|
||||
description: {
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
},
|
||||
],
|
||||
},
|
||||
description_html: "<p></p>",
|
||||
estimate_point: null,
|
||||
state: "",
|
||||
parent: null,
|
||||
priority: "none",
|
||||
assignees: [],
|
||||
assignees_list: [],
|
||||
labels: [],
|
||||
labels_list: [],
|
||||
start_date: null,
|
||||
target_date: null,
|
||||
};
|
||||
|
||||
interface IssueFormProps {
|
||||
handleFormSubmit: (formData: Partial<IIssue>) => Promise<void>;
|
||||
data?: Partial<IIssue> | null;
|
||||
prePopulatedData?: Partial<IIssue> | null;
|
||||
projectId: string;
|
||||
setActiveProject: React.Dispatch<React.SetStateAction<string | null>>;
|
||||
createMore: boolean;
|
||||
setCreateMore: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
handleClose: () => void;
|
||||
status: boolean;
|
||||
user: ICurrentUserResponse | undefined;
|
||||
fieldsToShow: (
|
||||
| "project"
|
||||
| "name"
|
||||
| "description"
|
||||
| "state"
|
||||
| "priority"
|
||||
| "assignee"
|
||||
| "label"
|
||||
| "startDate"
|
||||
| "dueDate"
|
||||
| "estimate"
|
||||
| "parent"
|
||||
| "all"
|
||||
)[];
|
||||
}
|
||||
|
||||
export const DraftIssueForm: FC<IssueFormProps> = (props) => {
|
||||
const {
|
||||
handleFormSubmit,
|
||||
data,
|
||||
prePopulatedData,
|
||||
projectId,
|
||||
setActiveProject,
|
||||
createMore,
|
||||
setCreateMore,
|
||||
handleClose,
|
||||
status,
|
||||
user,
|
||||
fieldsToShow,
|
||||
} = props;
|
||||
|
||||
const [stateModal, setStateModal] = useState(false);
|
||||
const [labelModal, setLabelModal] = useState(false);
|
||||
const [parentIssueListModalOpen, setParentIssueListModalOpen] = useState(false);
|
||||
const [selectedParentIssue, setSelectedParentIssue] = useState<ISearchIssueResponse | null>(null);
|
||||
|
||||
const [gptAssistantModal, setGptAssistantModal] = useState(false);
|
||||
const [iAmFeelingLucky, setIAmFeelingLucky] = useState(false);
|
||||
|
||||
const editorRef = useRef<any>(null);
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug } = router.query;
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const {
|
||||
register,
|
||||
formState: { errors, isSubmitting },
|
||||
handleSubmit,
|
||||
reset,
|
||||
watch,
|
||||
control,
|
||||
getValues,
|
||||
setValue,
|
||||
setFocus,
|
||||
} = useForm<IIssue>({
|
||||
defaultValues: prePopulatedData ?? defaultValues,
|
||||
reValidateMode: "onChange",
|
||||
});
|
||||
|
||||
const issueName = watch("name");
|
||||
|
||||
const onClose = () => {
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const handleCreateUpdateIssue = async (
|
||||
formData: Partial<IIssue>,
|
||||
action: "saveDraft" | "createToNewIssue" = "saveDraft"
|
||||
) => {
|
||||
await handleFormSubmit({
|
||||
...formData,
|
||||
is_draft: action === "saveDraft",
|
||||
});
|
||||
|
||||
setGptAssistantModal(false);
|
||||
|
||||
reset({
|
||||
...defaultValues,
|
||||
project: projectId,
|
||||
description: {
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
},
|
||||
],
|
||||
},
|
||||
description_html: "<p></p>",
|
||||
});
|
||||
editorRef?.current?.clearEditor();
|
||||
};
|
||||
|
||||
const handleAiAssistance = async (response: string) => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
||||
setValue("description", {});
|
||||
setValue("description_html", `${watch("description_html")}<p>${response}</p>`);
|
||||
editorRef.current?.setEditorValue(`${watch("description_html")}`);
|
||||
};
|
||||
|
||||
const handleAutoGenerateDescription = async () => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
||||
setIAmFeelingLucky(true);
|
||||
|
||||
aiService
|
||||
.createGptTask(
|
||||
workspaceSlug as string,
|
||||
projectId as string,
|
||||
{
|
||||
prompt: issueName,
|
||||
task: "Generate a proper description for this issue.",
|
||||
},
|
||||
user
|
||||
)
|
||||
.then((res) => {
|
||||
if (res.response === "")
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message:
|
||||
"Issue title isn't informative enough to generate the description. Please try with a different title.",
|
||||
});
|
||||
else handleAiAssistance(res.response_html);
|
||||
})
|
||||
.catch((err) => {
|
||||
const error = err?.data?.error;
|
||||
|
||||
if (err.status === 429)
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message:
|
||||
error ||
|
||||
"You have reached the maximum number of requests of 50 requests per month per user.",
|
||||
});
|
||||
else
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: error || "Some error occurred. Please try again.",
|
||||
});
|
||||
})
|
||||
.finally(() => setIAmFeelingLucky(false));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setFocus("name");
|
||||
|
||||
reset({
|
||||
...defaultValues,
|
||||
...(prePopulatedData ?? {}),
|
||||
...(data ?? {}),
|
||||
});
|
||||
}, [setFocus, prePopulatedData, reset, data]);
|
||||
|
||||
// update projectId in form when projectId changes
|
||||
useEffect(() => {
|
||||
reset({
|
||||
...getValues(),
|
||||
project: projectId,
|
||||
});
|
||||
}, [getValues, projectId, reset]);
|
||||
|
||||
const startDate = watch("start_date");
|
||||
const targetDate = watch("target_date");
|
||||
|
||||
const minDate = startDate ? new Date(startDate) : null;
|
||||
minDate?.setDate(minDate.getDate());
|
||||
|
||||
const maxDate = targetDate ? new Date(targetDate) : null;
|
||||
maxDate?.setDate(maxDate.getDate());
|
||||
|
||||
return (
|
||||
<>
|
||||
{projectId && (
|
||||
<>
|
||||
<CreateStateModal
|
||||
isOpen={stateModal}
|
||||
handleClose={() => setStateModal(false)}
|
||||
projectId={projectId}
|
||||
user={user}
|
||||
/>
|
||||
<CreateLabelModal
|
||||
isOpen={labelModal}
|
||||
handleClose={() => setLabelModal(false)}
|
||||
projectId={projectId}
|
||||
user={user}
|
||||
onSuccess={(response) => {
|
||||
setValue("labels", [...watch("labels"), response.id]);
|
||||
setValue("labels_list", [...watch("labels_list"), response.id]);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<form
|
||||
onSubmit={handleSubmit((formData) => handleCreateUpdateIssue(formData, "createToNewIssue"))}
|
||||
>
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-center gap-x-2">
|
||||
{(fieldsToShow.includes("all") || fieldsToShow.includes("project")) && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="project"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<IssueProjectSelect
|
||||
value={value}
|
||||
onChange={(val: string) => {
|
||||
onChange(val);
|
||||
setActiveProject(val);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<h3 className="text-xl font-semibold leading-6 text-custom-text-100">
|
||||
{status ? "Update" : "Create"} Issue
|
||||
</h3>
|
||||
</div>
|
||||
{watch("parent") &&
|
||||
(fieldsToShow.includes("all") || fieldsToShow.includes("parent")) &&
|
||||
selectedParentIssue && (
|
||||
<div className="flex w-min items-center gap-2 whitespace-nowrap rounded bg-custom-background-80 p-2 text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="block h-1.5 w-1.5 rounded-full"
|
||||
style={{
|
||||
backgroundColor: selectedParentIssue.state__color,
|
||||
}}
|
||||
/>
|
||||
<span className="flex-shrink-0 text-custom-text-200">
|
||||
{selectedParentIssue.project__identifier}-{selectedParentIssue.sequence_id}
|
||||
</span>
|
||||
<span className="truncate font-medium">
|
||||
{selectedParentIssue.name.substring(0, 50)}
|
||||
</span>
|
||||
<XMarkIcon
|
||||
className="h-3 w-3 cursor-pointer"
|
||||
onClick={() => {
|
||||
setValue("parent", null);
|
||||
setSelectedParentIssue(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-3">
|
||||
<div className="mt-2 space-y-3">
|
||||
{(fieldsToShow.includes("all") || fieldsToShow.includes("name")) && (
|
||||
<div>
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
className="resize-none text-xl"
|
||||
placeholder="Title"
|
||||
autoComplete="off"
|
||||
error={errors.name}
|
||||
register={register}
|
||||
validations={{
|
||||
required: "Title is required",
|
||||
maxLength: {
|
||||
value: 255,
|
||||
message: "Title should be less than 255 characters",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{(fieldsToShow.includes("all") || fieldsToShow.includes("description")) && (
|
||||
<div className="relative">
|
||||
<div className="flex justify-end">
|
||||
{issueName && issueName !== "" && (
|
||||
<button
|
||||
type="button"
|
||||
className={`flex items-center gap-1 rounded px-1.5 py-1 text-xs hover:bg-custom-background-90 ${
|
||||
iAmFeelingLucky ? "cursor-wait" : ""
|
||||
}`}
|
||||
onClick={handleAutoGenerateDescription}
|
||||
disabled={iAmFeelingLucky}
|
||||
>
|
||||
{iAmFeelingLucky ? (
|
||||
"Generating response..."
|
||||
) : (
|
||||
<>
|
||||
<SparklesIcon className="h-4 w-4" />I{"'"}m feeling lucky
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 rounded px-1.5 py-1 text-xs hover:bg-custom-background-90"
|
||||
onClick={() => setGptAssistantModal((prevData) => !prevData)}
|
||||
>
|
||||
<SparklesIcon className="h-4 w-4" />
|
||||
AI
|
||||
</button>
|
||||
</div>
|
||||
<Controller
|
||||
name="description_html"
|
||||
control={control}
|
||||
render={({ field: { value, onChange } }) => {
|
||||
if (!value && !watch("description_html")) return <></>;
|
||||
|
||||
return (
|
||||
<TipTapEditor
|
||||
workspaceSlug={workspaceSlug as string}
|
||||
ref={editorRef}
|
||||
debouncedUpdatesEnabled={false}
|
||||
value={
|
||||
!value ||
|
||||
value === "" ||
|
||||
(typeof value === "object" && Object.keys(value).length === 0)
|
||||
? watch("description_html")
|
||||
: value
|
||||
}
|
||||
customClassName="min-h-[150px]"
|
||||
onChange={(description: Object, description_html: string) => {
|
||||
onChange(description_html);
|
||||
setValue("description", description);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<GptAssistantModal
|
||||
isOpen={gptAssistantModal}
|
||||
handleClose={() => {
|
||||
setGptAssistantModal(false);
|
||||
// this is done so that the title do not reset after gpt popover closed
|
||||
reset(getValues());
|
||||
}}
|
||||
inset="top-2 left-0"
|
||||
content=""
|
||||
htmlContent={watch("description_html")}
|
||||
onResponse={(response) => {
|
||||
handleAiAssistance(response);
|
||||
}}
|
||||
projectId={projectId}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{(fieldsToShow.includes("all") || fieldsToShow.includes("state")) && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="state"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<IssueStateSelect
|
||||
setIsOpen={setStateModal}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
projectId={projectId}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{(fieldsToShow.includes("all") || fieldsToShow.includes("priority")) && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="priority"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<IssuePrioritySelect value={value} onChange={onChange} />
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{(fieldsToShow.includes("all") || fieldsToShow.includes("assignee")) && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="assignees"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<IssueAssigneeSelect
|
||||
projectId={projectId}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{(fieldsToShow.includes("all") || fieldsToShow.includes("label")) && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="labels"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<IssueLabelSelect
|
||||
setIsOpen={setLabelModal}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
projectId={projectId}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{(fieldsToShow.includes("all") || fieldsToShow.includes("startDate")) && (
|
||||
<div>
|
||||
<Controller
|
||||
control={control}
|
||||
name="start_date"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<IssueDateSelect
|
||||
label="Start date"
|
||||
maxDate={maxDate ?? undefined}
|
||||
onChange={onChange}
|
||||
value={value}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{(fieldsToShow.includes("all") || fieldsToShow.includes("dueDate")) && (
|
||||
<div>
|
||||
<Controller
|
||||
control={control}
|
||||
name="target_date"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<IssueDateSelect
|
||||
label="Due date"
|
||||
minDate={minDate ?? undefined}
|
||||
onChange={onChange}
|
||||
value={value}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{(fieldsToShow.includes("all") || fieldsToShow.includes("estimate")) && (
|
||||
<div>
|
||||
<Controller
|
||||
control={control}
|
||||
name="estimate_point"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<IssueEstimateSelect value={value} onChange={onChange} />
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{(fieldsToShow.includes("all") || fieldsToShow.includes("parent")) && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="parent"
|
||||
render={({ field: { onChange } }) => (
|
||||
<ParentIssuesListModal
|
||||
isOpen={parentIssueListModalOpen}
|
||||
handleClose={() => setParentIssueListModalOpen(false)}
|
||||
onChange={(issue) => {
|
||||
onChange(issue.id);
|
||||
setSelectedParentIssue(issue);
|
||||
}}
|
||||
projectId={projectId}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{(fieldsToShow.includes("all") || fieldsToShow.includes("parent")) && (
|
||||
<CustomMenu ellipsis>
|
||||
{watch("parent") ? (
|
||||
<>
|
||||
<CustomMenu.MenuItem
|
||||
renderAs="button"
|
||||
onClick={() => setParentIssueListModalOpen(true)}
|
||||
>
|
||||
Change parent issue
|
||||
</CustomMenu.MenuItem>
|
||||
<CustomMenu.MenuItem
|
||||
renderAs="button"
|
||||
onClick={() => setValue("parent", null)}
|
||||
>
|
||||
Remove parent issue
|
||||
</CustomMenu.MenuItem>
|
||||
</>
|
||||
) : (
|
||||
<CustomMenu.MenuItem
|
||||
renderAs="button"
|
||||
onClick={() => setParentIssueListModalOpen(true)}
|
||||
>
|
||||
Select Parent Issue
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
</CustomMenu>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="-mx-5 mt-5 flex items-center justify-between gap-2 border-t border-custom-border-200 px-5 pt-5">
|
||||
<div
|
||||
className="flex cursor-pointer items-center gap-1"
|
||||
onClick={() => setCreateMore((prevData) => !prevData)}
|
||||
>
|
||||
<span className="text-xs">Create more</span>
|
||||
<ToggleSwitch value={createMore} onChange={() => {}} size="md" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<SecondaryButton onClick={onClose}>Discard</SecondaryButton>
|
||||
<SecondaryButton
|
||||
loading={isSubmitting}
|
||||
onClick={handleSubmit((formData) => handleCreateUpdateIssue(formData, "saveDraft"))}
|
||||
>
|
||||
{isSubmitting ? "Saving..." : "Save Draft"}
|
||||
</SecondaryButton>
|
||||
{data && (
|
||||
<PrimaryButton type="submit" loading={isSubmitting}>
|
||||
{isSubmitting ? "Saving..." : "Add Issue"}
|
||||
</PrimaryButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
};
|
||||
285
web/components/issues/draft-issue-modal.tsx
Normal file
285
web/components/issues/draft-issue-modal.tsx
Normal file
@@ -0,0 +1,285 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import { mutate } from "swr";
|
||||
|
||||
// headless ui
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
// services
|
||||
import issuesService from "services/issues.service";
|
||||
// hooks
|
||||
import useUser from "hooks/use-user";
|
||||
import useIssuesView from "hooks/use-issues-view";
|
||||
import useCalendarIssuesView from "hooks/use-calendar-issues-view";
|
||||
import useToast from "hooks/use-toast";
|
||||
import useSpreadsheetIssuesView from "hooks/use-spreadsheet-issues-view";
|
||||
import useProjects from "hooks/use-projects";
|
||||
import useMyIssues from "hooks/my-issues/use-my-issues";
|
||||
// components
|
||||
import { DraftIssueForm } from "components/issues";
|
||||
// types
|
||||
import type { IIssue } from "types";
|
||||
// fetch-keys
|
||||
import {
|
||||
PROJECT_ISSUES_DETAILS,
|
||||
USER_ISSUE,
|
||||
SUB_ISSUES,
|
||||
PROJECT_ISSUES_LIST_WITH_PARAMS,
|
||||
CYCLE_ISSUES_WITH_PARAMS,
|
||||
MODULE_ISSUES_WITH_PARAMS,
|
||||
VIEW_ISSUES,
|
||||
PROJECT_DRAFT_ISSUES_LIST_WITH_PARAMS,
|
||||
} from "constants/fetch-keys";
|
||||
|
||||
interface IssuesModalProps {
|
||||
data?: IIssue | null;
|
||||
handleClose: () => void;
|
||||
isOpen: boolean;
|
||||
isUpdatingSingleIssue?: boolean;
|
||||
prePopulateData?: Partial<IIssue>;
|
||||
fieldsToShow?: (
|
||||
| "project"
|
||||
| "name"
|
||||
| "description"
|
||||
| "state"
|
||||
| "priority"
|
||||
| "assignee"
|
||||
| "label"
|
||||
| "startDate"
|
||||
| "dueDate"
|
||||
| "estimate"
|
||||
| "parent"
|
||||
| "all"
|
||||
)[];
|
||||
onSubmit?: (data: Partial<IIssue>) => Promise<void> | void;
|
||||
}
|
||||
|
||||
export const CreateUpdateDraftIssueModal: React.FC<IssuesModalProps> = ({
|
||||
data,
|
||||
handleClose,
|
||||
isOpen,
|
||||
isUpdatingSingleIssue = false,
|
||||
prePopulateData,
|
||||
fieldsToShow = ["all"],
|
||||
onSubmit,
|
||||
}) => {
|
||||
// states
|
||||
const [createMore, setCreateMore] = useState(false);
|
||||
const [activeProject, setActiveProject] = useState<string | null>(null);
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, cycleId, moduleId, viewId } = router.query;
|
||||
|
||||
const { displayFilters, params } = useIssuesView();
|
||||
const { params: calendarParams } = useCalendarIssuesView();
|
||||
const { ...viewGanttParams } = params;
|
||||
const { params: spreadsheetParams } = useSpreadsheetIssuesView();
|
||||
|
||||
const { user } = useUser();
|
||||
const { projects } = useProjects();
|
||||
|
||||
const { groupedIssues, mutateMyIssues } = useMyIssues(workspaceSlug?.toString());
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
if (cycleId) prePopulateData = { ...prePopulateData, cycle: cycleId as string };
|
||||
if (moduleId) prePopulateData = { ...prePopulateData, module: moduleId as string };
|
||||
if (router.asPath.includes("my-issues") || router.asPath.includes("assigned"))
|
||||
prePopulateData = {
|
||||
...prePopulateData,
|
||||
assignees: [...(prePopulateData?.assignees ?? []), user?.id ?? ""],
|
||||
};
|
||||
|
||||
const onClose = () => {
|
||||
handleClose();
|
||||
setActiveProject(null);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// if modal is closed, reset active project to null
|
||||
// and return to avoid activeProject being set to some other project
|
||||
if (!isOpen) {
|
||||
setActiveProject(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// if data is present, set active project to the project of the
|
||||
// issue. This has more priority than the project in the url.
|
||||
if (data && data.project) {
|
||||
setActiveProject(data.project);
|
||||
return;
|
||||
}
|
||||
|
||||
// if data is not present, set active project to the project
|
||||
// in the url. This has the least priority.
|
||||
if (projects && projects.length > 0 && !activeProject)
|
||||
setActiveProject(projects?.find((p) => p.id === projectId)?.id ?? projects?.[0].id ?? null);
|
||||
}, [activeProject, data, projectId, projects, isOpen]);
|
||||
|
||||
const calendarFetchKey = cycleId
|
||||
? CYCLE_ISSUES_WITH_PARAMS(cycleId.toString(), calendarParams)
|
||||
: moduleId
|
||||
? MODULE_ISSUES_WITH_PARAMS(moduleId.toString(), calendarParams)
|
||||
: viewId
|
||||
? VIEW_ISSUES(viewId.toString(), calendarParams)
|
||||
: PROJECT_ISSUES_LIST_WITH_PARAMS(activeProject?.toString() ?? "", calendarParams);
|
||||
|
||||
const spreadsheetFetchKey = cycleId
|
||||
? CYCLE_ISSUES_WITH_PARAMS(cycleId.toString(), spreadsheetParams)
|
||||
: moduleId
|
||||
? MODULE_ISSUES_WITH_PARAMS(moduleId.toString(), spreadsheetParams)
|
||||
: viewId
|
||||
? VIEW_ISSUES(viewId.toString(), spreadsheetParams)
|
||||
: PROJECT_ISSUES_LIST_WITH_PARAMS(activeProject?.toString() ?? "", spreadsheetParams);
|
||||
|
||||
const ganttFetchKey = cycleId
|
||||
? CYCLE_ISSUES_WITH_PARAMS(cycleId.toString())
|
||||
: moduleId
|
||||
? MODULE_ISSUES_WITH_PARAMS(moduleId.toString())
|
||||
: viewId
|
||||
? VIEW_ISSUES(viewId.toString(), viewGanttParams)
|
||||
: PROJECT_ISSUES_LIST_WITH_PARAMS(activeProject?.toString() ?? "");
|
||||
|
||||
const createIssue = async (payload: Partial<IIssue>) => {
|
||||
if (!workspaceSlug || !activeProject || !user) return;
|
||||
|
||||
await issuesService
|
||||
.createDraftIssue(workspaceSlug as string, activeProject ?? "", payload, user)
|
||||
.then(async () => {
|
||||
mutate(PROJECT_ISSUES_LIST_WITH_PARAMS(activeProject ?? "", params));
|
||||
mutate(PROJECT_DRAFT_ISSUES_LIST_WITH_PARAMS(activeProject ?? "", params));
|
||||
|
||||
if (displayFilters.layout === "calendar") mutate(calendarFetchKey);
|
||||
if (displayFilters.layout === "gantt_chart")
|
||||
mutate(ganttFetchKey, {
|
||||
start_target_date: true,
|
||||
order_by: "sort_order",
|
||||
});
|
||||
if (displayFilters.layout === "spreadsheet") mutate(spreadsheetFetchKey);
|
||||
if (groupedIssues) mutateMyIssues();
|
||||
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
title: "Success!",
|
||||
message: "Issue created successfully.",
|
||||
});
|
||||
|
||||
if (payload.assignees_list?.some((assignee) => assignee === user?.id))
|
||||
mutate(USER_ISSUE(workspaceSlug as string));
|
||||
|
||||
if (payload.parent && payload.parent !== "") mutate(SUB_ISSUES(payload.parent));
|
||||
})
|
||||
.catch(() => {
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: "Issue could not be created. Please try again.",
|
||||
});
|
||||
});
|
||||
|
||||
if (!createMore) onClose();
|
||||
};
|
||||
|
||||
const updateIssue = async (payload: Partial<IIssue>) => {
|
||||
if (!user) return;
|
||||
|
||||
await issuesService
|
||||
.updateDraftIssue(workspaceSlug as string, activeProject ?? "", data?.id ?? "", payload, user)
|
||||
.then((res) => {
|
||||
if (isUpdatingSingleIssue) {
|
||||
mutate<IIssue>(PROJECT_ISSUES_DETAILS, (prevData) => ({ ...prevData, ...res }), false);
|
||||
} else {
|
||||
if (displayFilters.layout === "calendar") mutate(calendarFetchKey);
|
||||
if (displayFilters.layout === "spreadsheet") mutate(spreadsheetFetchKey);
|
||||
if (payload.parent) mutate(SUB_ISSUES(payload.parent.toString()));
|
||||
mutate(PROJECT_ISSUES_LIST_WITH_PARAMS(activeProject ?? "", params));
|
||||
mutate(PROJECT_DRAFT_ISSUES_LIST_WITH_PARAMS(activeProject ?? "", params));
|
||||
}
|
||||
|
||||
if (!createMore) onClose();
|
||||
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
title: "Success!",
|
||||
message: "Issue updated successfully.",
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: "Issue could not be updated. Please try again.",
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handleFormSubmit = async (formData: Partial<IIssue>) => {
|
||||
if (!workspaceSlug || !activeProject) return;
|
||||
|
||||
const payload: Partial<IIssue> = {
|
||||
...formData,
|
||||
assignees_list: formData.assignees ?? [],
|
||||
labels_list: formData.labels ?? [],
|
||||
description: formData.description ?? "",
|
||||
description_html: formData.description_html ?? "<p></p>",
|
||||
};
|
||||
|
||||
if (!data) await createIssue(payload);
|
||||
else await updateIssue(payload);
|
||||
|
||||
if (onSubmit) await onSubmit(payload);
|
||||
};
|
||||
|
||||
if (!projects || projects.length === 0) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Transition.Root show={isOpen} as={React.Fragment}>
|
||||
<Dialog as="div" className="relative z-20" onClose={onClose}>
|
||||
<Transition.Child
|
||||
as={React.Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 bg-custom-backdrop bg-opacity-50 transition-opacity" />
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0 z-10 overflow-y-auto">
|
||||
<div className="my-10 flex items-center justify-center p-4 text-center sm:p-0 md:my-20">
|
||||
<Transition.Child
|
||||
as={React.Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
enterTo="opacity-100 translate-y-0 sm:scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
>
|
||||
<Dialog.Panel className="relative transform rounded-lg border border-custom-border-200 bg-custom-background-100 p-5 text-left shadow-xl transition-all sm:w-full sm:max-w-2xl">
|
||||
<DraftIssueForm
|
||||
handleFormSubmit={handleFormSubmit}
|
||||
prePopulatedData={prePopulateData}
|
||||
data={data}
|
||||
createMore={createMore}
|
||||
setCreateMore={setCreateMore}
|
||||
handleClose={onClose}
|
||||
projectId={activeProject ?? ""}
|
||||
setActiveProject={setActiveProject}
|
||||
status={data ? true : false}
|
||||
user={user}
|
||||
fieldsToShow={fieldsToShow}
|
||||
/>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition.Root>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -8,6 +8,7 @@ import { Controller, useForm } from "react-hook-form";
|
||||
import aiService from "services/ai.service";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
import useLocalStorage from "hooks/use-local-storage";
|
||||
// components
|
||||
import { GptAssistantModal } from "components/core";
|
||||
import { ParentIssuesListModal } from "components/issues";
|
||||
@@ -23,14 +24,7 @@ import {
|
||||
import { CreateStateModal } from "components/states";
|
||||
import { CreateLabelModal } from "components/labels";
|
||||
// ui
|
||||
import {
|
||||
CustomMenu,
|
||||
Input,
|
||||
Loader,
|
||||
PrimaryButton,
|
||||
SecondaryButton,
|
||||
ToggleSwitch,
|
||||
} from "components/ui";
|
||||
import { CustomMenu, Input, PrimaryButton, SecondaryButton, ToggleSwitch } from "components/ui";
|
||||
import { TipTapEditor } from "components/tiptap";
|
||||
// icons
|
||||
import { SparklesIcon, XMarkIcon } from "@heroicons/react/24/outline";
|
||||
@@ -69,8 +63,11 @@ export interface IssueFormProps {
|
||||
createMore: boolean;
|
||||
setCreateMore: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
handleClose: () => void;
|
||||
handleDiscardClose: () => void;
|
||||
status: boolean;
|
||||
user: ICurrentUserResponse | undefined;
|
||||
setIsConfirmDiscardOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
handleFormDirty: (payload: Partial<IIssue> | null) => void;
|
||||
fieldsToShow: (
|
||||
| "project"
|
||||
| "name"
|
||||
@@ -87,18 +84,21 @@ export interface IssueFormProps {
|
||||
)[];
|
||||
}
|
||||
|
||||
export const IssueForm: FC<IssueFormProps> = ({
|
||||
handleFormSubmit,
|
||||
initialData,
|
||||
projectId,
|
||||
setActiveProject,
|
||||
createMore,
|
||||
setCreateMore,
|
||||
handleClose,
|
||||
status,
|
||||
user,
|
||||
fieldsToShow,
|
||||
}) => {
|
||||
export const IssueForm: FC<IssueFormProps> = (props) => {
|
||||
const {
|
||||
handleFormSubmit,
|
||||
initialData,
|
||||
projectId,
|
||||
setActiveProject,
|
||||
createMore,
|
||||
setCreateMore,
|
||||
handleDiscardClose,
|
||||
status,
|
||||
user,
|
||||
fieldsToShow,
|
||||
handleFormDirty,
|
||||
} = props;
|
||||
|
||||
const [stateModal, setStateModal] = useState(false);
|
||||
const [labelModal, setLabelModal] = useState(false);
|
||||
const [parentIssueListModalOpen, setParentIssueListModalOpen] = useState(false);
|
||||
@@ -107,6 +107,8 @@ export const IssueForm: FC<IssueFormProps> = ({
|
||||
const [gptAssistantModal, setGptAssistantModal] = useState(false);
|
||||
const [iAmFeelingLucky, setIAmFeelingLucky] = useState(false);
|
||||
|
||||
const { setValue: setValueInLocalStorage } = useLocalStorage<any>("draftedIssue", null);
|
||||
|
||||
const editorRef = useRef<any>(null);
|
||||
|
||||
const router = useRouter();
|
||||
@@ -116,7 +118,7 @@ export const IssueForm: FC<IssueFormProps> = ({
|
||||
|
||||
const {
|
||||
register,
|
||||
formState: { errors, isSubmitting },
|
||||
formState: { errors, isSubmitting, isDirty },
|
||||
handleSubmit,
|
||||
reset,
|
||||
watch,
|
||||
@@ -131,6 +133,17 @@ export const IssueForm: FC<IssueFormProps> = ({
|
||||
|
||||
const issueName = watch("name");
|
||||
|
||||
const payload = {
|
||||
name: getValues("name"),
|
||||
description: getValues("description"),
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isDirty) handleFormDirty(payload);
|
||||
else handleFormDirty(null);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [JSON.stringify(payload), isDirty]);
|
||||
|
||||
const handleCreateUpdateIssue = async (formData: Partial<IIssue>) => {
|
||||
await handleFormSubmit(formData);
|
||||
|
||||
@@ -550,7 +563,15 @@ export const IssueForm: FC<IssueFormProps> = ({
|
||||
<ToggleSwitch value={createMore} onChange={() => {}} size="md" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<SecondaryButton onClick={handleClose}>Discard</SecondaryButton>
|
||||
<SecondaryButton
|
||||
onClick={() => {
|
||||
const data = JSON.stringify(getValues());
|
||||
setValueInLocalStorage(data);
|
||||
handleDiscardClose();
|
||||
}}
|
||||
>
|
||||
Discard
|
||||
</SecondaryButton>
|
||||
<PrimaryButton type="submit" loading={isSubmitting}>
|
||||
{status
|
||||
? isSubmitting
|
||||
|
||||
@@ -11,13 +11,21 @@ import { IIssue } from "types";
|
||||
|
||||
export const IssueGanttBlock = ({ data }: { data: IIssue }) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug } = router.query;
|
||||
|
||||
const openPeekOverview = () => {
|
||||
const { query } = router;
|
||||
|
||||
router.push({
|
||||
pathname: router.pathname,
|
||||
query: { ...query, peekIssue: data.id },
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center relative h-full w-full rounded cursor-pointer"
|
||||
style={{ backgroundColor: data?.state_detail?.color }}
|
||||
onClick={() => router.push(`/${workspaceSlug}/projects/${data?.project}/issues/${data?.id}`)}
|
||||
onClick={openPeekOverview}
|
||||
>
|
||||
<div className="absolute top-0 left-0 h-full w-full bg-custom-background-100/50" />
|
||||
<Tooltip
|
||||
@@ -43,14 +51,22 @@ export const IssueGanttBlock = ({ data }: { data: IIssue }) => {
|
||||
// rendering issues on gantt sidebar
|
||||
export const IssueGanttSidebarBlock = ({ data }: { data: IIssue }) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug } = router.query;
|
||||
|
||||
const duration = findTotalDaysInRange(data?.start_date ?? "", data?.target_date ?? "", true);
|
||||
|
||||
const openPeekOverview = () => {
|
||||
const { query } = router;
|
||||
|
||||
router.push({
|
||||
pathname: router.pathname,
|
||||
query: { ...query, peekIssue: data.id },
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative w-full flex items-center gap-2 h-full cursor-pointer"
|
||||
onClick={() => router.push(`/${workspaceSlug}/projects/${data?.project}/issues/${data?.id}`)}
|
||||
onClick={openPeekOverview}
|
||||
>
|
||||
<StateGroupIcon stateGroup={data?.state_detail?.group} color={data?.state_detail?.color} />
|
||||
<div className="text-xs text-custom-text-300 flex-shrink-0">
|
||||
|
||||
@@ -8,11 +8,15 @@ import { updateGanttIssue } from "components/gantt-chart/hooks/block-update";
|
||||
import useProjectDetails from "hooks/use-project-details";
|
||||
// components
|
||||
import { GanttChartRoot, renderIssueBlocksStructure } from "components/gantt-chart";
|
||||
import { IssueGanttBlock, IssueGanttSidebarBlock } from "components/issues";
|
||||
import { IssueGanttBlock, IssueGanttSidebarBlock, IssuePeekOverview } from "components/issues";
|
||||
// types
|
||||
import { IIssue } from "types";
|
||||
|
||||
export const IssueGanttChartView = () => {
|
||||
type Props = {
|
||||
disableUserActions: boolean;
|
||||
};
|
||||
|
||||
export const IssueGanttChartView: React.FC<Props> = ({ disableUserActions }) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
|
||||
@@ -29,23 +33,31 @@ export const IssueGanttChartView = () => {
|
||||
const isAllowed = projectDetails?.member_role === 20 || projectDetails?.member_role === 15;
|
||||
|
||||
return (
|
||||
<div className="w-full h-full">
|
||||
<GanttChartRoot
|
||||
border={false}
|
||||
title="Issues"
|
||||
loaderTitle="Issues"
|
||||
blocks={ganttIssues ? renderIssueBlocksStructure(ganttIssues as IIssue[]) : null}
|
||||
blockUpdateHandler={(block, payload) =>
|
||||
updateGanttIssue(block, payload, mutateGanttIssues, user, workspaceSlug?.toString())
|
||||
}
|
||||
BlockRender={IssueGanttBlock}
|
||||
SidebarBlockRender={IssueGanttSidebarBlock}
|
||||
enableBlockLeftResize={isAllowed}
|
||||
enableBlockRightResize={isAllowed}
|
||||
enableBlockMove={isAllowed}
|
||||
enableReorder={displayFilters.order_by === "sort_order" && isAllowed}
|
||||
bottomSpacing
|
||||
<>
|
||||
<IssuePeekOverview
|
||||
handleMutation={() => mutateGanttIssues()}
|
||||
projectId={projectId?.toString() ?? ""}
|
||||
workspaceSlug={workspaceSlug?.toString() ?? ""}
|
||||
readOnly={disableUserActions}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full h-full">
|
||||
<GanttChartRoot
|
||||
border={false}
|
||||
title="Issues"
|
||||
loaderTitle="Issues"
|
||||
blocks={ganttIssues ? renderIssueBlocksStructure(ganttIssues as IIssue[]) : null}
|
||||
blockUpdateHandler={(block, payload) =>
|
||||
updateGanttIssue(block, payload, mutateGanttIssues, user, workspaceSlug?.toString())
|
||||
}
|
||||
BlockRender={IssueGanttBlock}
|
||||
SidebarBlockRender={IssueGanttSidebarBlock}
|
||||
enableBlockLeftResize={isAllowed}
|
||||
enableBlockRightResize={isAllowed}
|
||||
enableBlockMove={isAllowed}
|
||||
enableReorder={displayFilters.order_by === "sort_order" && isAllowed}
|
||||
bottomSpacing
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -16,3 +16,6 @@ export * from "./sub-issues-list";
|
||||
export * from "./label";
|
||||
export * from "./issue-reaction";
|
||||
export * from "./peek-overview";
|
||||
export * from "./confirm-issue-discard";
|
||||
export * from "./draft-issue-form";
|
||||
export * from "./draft-issue-modal";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState, useCallback } from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
@@ -20,7 +20,7 @@ import useSpreadsheetIssuesView from "hooks/use-spreadsheet-issues-view";
|
||||
import useProjects from "hooks/use-projects";
|
||||
import useMyIssues from "hooks/my-issues/use-my-issues";
|
||||
// components
|
||||
import { IssueForm } from "components/issues";
|
||||
import { IssueForm, ConfirmIssueDiscard } from "components/issues";
|
||||
// types
|
||||
import type { IIssue } from "types";
|
||||
// fetch-keys
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
MODULE_DETAILS,
|
||||
VIEW_ISSUES,
|
||||
INBOX_ISSUES,
|
||||
PROJECT_DRAFT_ISSUES_LIST_WITH_PARAMS,
|
||||
} from "constants/fetch-keys";
|
||||
// constants
|
||||
import { INBOX_ISSUE_SOURCE } from "constants/inbox";
|
||||
@@ -73,6 +74,8 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
|
||||
}) => {
|
||||
// states
|
||||
const [createMore, setCreateMore] = useState(false);
|
||||
const [formDirtyState, setFormDirtyState] = useState<any>(null);
|
||||
const [showConfirmDiscard, setShowConfirmDiscard] = useState(false);
|
||||
const [activeProject, setActiveProject] = useState<string | null>(null);
|
||||
|
||||
const router = useRouter();
|
||||
@@ -80,7 +83,7 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
|
||||
|
||||
const { displayFilters, params } = useIssuesView();
|
||||
const { params: calendarParams } = useCalendarIssuesView();
|
||||
const { order_by, group_by, ...viewGanttParams } = params;
|
||||
const { ...viewGanttParams } = params;
|
||||
const { params: inboxParams } = useInboxView();
|
||||
const { params: spreadsheetParams } = useSpreadsheetIssuesView();
|
||||
|
||||
@@ -99,10 +102,23 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
|
||||
assignees: [...(prePopulateData?.assignees ?? []), user?.id ?? ""],
|
||||
};
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
const onClose = () => {
|
||||
if (formDirtyState !== null) {
|
||||
setShowConfirmDiscard(true);
|
||||
} else {
|
||||
handleClose();
|
||||
setActiveProject(null);
|
||||
}
|
||||
};
|
||||
|
||||
const onDiscardClose = () => {
|
||||
handleClose();
|
||||
setActiveProject(null);
|
||||
}, [handleClose]);
|
||||
};
|
||||
|
||||
const handleFormDirty = (data: any) => {
|
||||
setFormDirtyState(data);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// if modal is closed, reset active project to null
|
||||
@@ -275,10 +291,50 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
|
||||
});
|
||||
});
|
||||
|
||||
if (!createMore) onClose();
|
||||
if (!createMore) onDiscardClose();
|
||||
};
|
||||
|
||||
const createDraftIssue = async () => {
|
||||
if (!workspaceSlug || !activeProject || !user) return;
|
||||
|
||||
const payload: Partial<IIssue> = {
|
||||
...formDirtyState,
|
||||
};
|
||||
|
||||
await issuesService
|
||||
.createDraftIssue(workspaceSlug as string, activeProject ?? "", payload, user)
|
||||
.then(() => {
|
||||
mutate(PROJECT_DRAFT_ISSUES_LIST_WITH_PARAMS(activeProject ?? "", params));
|
||||
if (groupedIssues) mutateMyIssues();
|
||||
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
title: "Success!",
|
||||
message: "Draft Issue created successfully.",
|
||||
});
|
||||
|
||||
handleClose();
|
||||
setActiveProject(null);
|
||||
setFormDirtyState(null);
|
||||
setShowConfirmDiscard(false);
|
||||
|
||||
if (payload.assignees_list?.some((assignee) => assignee === user?.id))
|
||||
mutate(USER_ISSUE(workspaceSlug as string));
|
||||
|
||||
if (payload.parent && payload.parent !== "") mutate(SUB_ISSUES(payload.parent));
|
||||
})
|
||||
.catch(() => {
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: "Issue could not be created. Please try again.",
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const updateIssue = async (payload: Partial<IIssue>) => {
|
||||
if (!user) return;
|
||||
|
||||
await issuesService
|
||||
.patchIssue(workspaceSlug as string, activeProject ?? "", data?.id ?? "", payload, user)
|
||||
.then((res) => {
|
||||
@@ -294,7 +350,7 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
|
||||
if (payload.cycle && payload.cycle !== "") addIssueToCycle(res.id, payload.cycle);
|
||||
if (payload.module && payload.module !== "") addIssueToModule(res.id, payload.module);
|
||||
|
||||
if (!createMore) onClose();
|
||||
if (!createMore) onDiscardClose();
|
||||
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
@@ -331,49 +387,66 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
|
||||
if (!projects || projects.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Transition.Root show={isOpen} as={React.Fragment}>
|
||||
<Dialog as="div" className="relative z-20" onClose={onClose}>
|
||||
<Transition.Child
|
||||
as={React.Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 bg-custom-backdrop bg-opacity-50 transition-opacity" />
|
||||
</Transition.Child>
|
||||
<>
|
||||
<ConfirmIssueDiscard
|
||||
isOpen={showConfirmDiscard}
|
||||
handleClose={() => setShowConfirmDiscard(false)}
|
||||
onConfirm={createDraftIssue}
|
||||
onDiscard={() => {
|
||||
handleClose();
|
||||
setActiveProject(null);
|
||||
setFormDirtyState(null);
|
||||
setShowConfirmDiscard(false);
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="fixed inset-0 z-10 overflow-y-auto">
|
||||
<div className="my-10 flex items-center justify-center p-4 text-center sm:p-0 md:my-20">
|
||||
<Transition.Child
|
||||
as={React.Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
enterTo="opacity-100 translate-y-0 sm:scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
>
|
||||
<Dialog.Panel className="relative transform rounded-lg border border-custom-border-200 bg-custom-background-100 p-5 text-left shadow-xl transition-all sm:w-full sm:max-w-2xl">
|
||||
<IssueForm
|
||||
handleFormSubmit={handleFormSubmit}
|
||||
initialData={data ?? prePopulateData}
|
||||
createMore={createMore}
|
||||
setCreateMore={setCreateMore}
|
||||
handleClose={onClose}
|
||||
projectId={activeProject ?? ""}
|
||||
setActiveProject={setActiveProject}
|
||||
status={data ? true : false}
|
||||
user={user}
|
||||
fieldsToShow={fieldsToShow}
|
||||
/>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
<Transition.Root show={isOpen} as={React.Fragment}>
|
||||
<Dialog as="div" className="relative z-20" onClose={onClose}>
|
||||
<Transition.Child
|
||||
as={React.Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 bg-custom-backdrop bg-opacity-50 transition-opacity" />
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0 z-10 overflow-y-auto">
|
||||
<div className="my-10 flex items-center justify-center p-4 text-center sm:p-0 md:my-20">
|
||||
<Transition.Child
|
||||
as={React.Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
enterTo="opacity-100 translate-y-0 sm:scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
>
|
||||
<Dialog.Panel className="relative transform rounded-lg border border-custom-border-200 bg-custom-background-100 p-5 text-left shadow-xl transition-all sm:w-full sm:max-w-2xl">
|
||||
<IssueForm
|
||||
handleFormSubmit={handleFormSubmit}
|
||||
initialData={data ?? prePopulateData}
|
||||
createMore={createMore}
|
||||
setCreateMore={setCreateMore}
|
||||
handleClose={onClose}
|
||||
handleDiscardClose={onDiscardClose}
|
||||
setIsConfirmDiscardOpen={setShowConfirmDiscard}
|
||||
projectId={activeProject ?? ""}
|
||||
setActiveProject={setActiveProject}
|
||||
status={data ? true : false}
|
||||
user={user}
|
||||
fieldsToShow={fieldsToShow}
|
||||
handleFormDirty={handleFormDirty}
|
||||
/>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition.Root>
|
||||
</Dialog>
|
||||
</Transition.Root>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -57,7 +57,7 @@ export const MyIssuesView: React.FC<Props> = ({
|
||||
const { user } = useUserAuth();
|
||||
|
||||
const { groupedIssues, mutateMyIssues, isEmpty, params } = useMyIssues(workspaceSlug?.toString());
|
||||
const { filters, setFilters, displayFilters, setDisplayFilters, properties } = useMyIssuesFilters(
|
||||
const { filters, setFilters, displayFilters, properties } = useMyIssuesFilters(
|
||||
workspaceSlug?.toString()
|
||||
);
|
||||
|
||||
@@ -205,7 +205,7 @@ export const MyIssuesView: React.FC<Props> = ({
|
||||
);
|
||||
|
||||
const handleIssueAction = useCallback(
|
||||
(issue: IIssue, action: "copy" | "edit" | "delete") => {
|
||||
(issue: IIssue, action: "copy" | "edit" | "delete" | "updateDraft") => {
|
||||
if (action === "copy") makeIssueCopy(issue);
|
||||
else if (action === "edit") handleEditIssue(issue);
|
||||
else if (action === "delete") handleDeleteIssue(issue);
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import React, { useState } from "react";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
// react-hook-form
|
||||
import { UseFormWatch } from "react-hook-form";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
import useUser from "hooks/use-user";
|
||||
// services
|
||||
import issuesService from "services/issues.service";
|
||||
// components
|
||||
import { ExistingIssuesListModal } from "components/core";
|
||||
// icons
|
||||
@@ -29,10 +31,11 @@ export const SidebarBlockedSelect: React.FC<Props> = ({
|
||||
}) => {
|
||||
const [isBlockedModalOpen, setIsBlockedModalOpen] = useState(false);
|
||||
|
||||
const { user } = useUser();
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug } = router.query;
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
|
||||
const handleClose = () => {
|
||||
setIsBlockedModalOpen(false);
|
||||
@@ -62,21 +65,39 @@ export const SidebarBlockedSelect: React.FC<Props> = ({
|
||||
},
|
||||
}));
|
||||
|
||||
const newBlocked = [...watch("blocked_issues"), ...selectedIssues];
|
||||
if (!user) return;
|
||||
|
||||
issuesService
|
||||
.createIssueRelation(workspaceSlug as string, projectId as string, issueId as string, user, {
|
||||
related_list: [
|
||||
...selectedIssues.map((issue) => ({
|
||||
issue: issueId as string,
|
||||
relation_type: "blocked_by" as const,
|
||||
related_issue_detail: issue.blocked_issue_detail,
|
||||
related_issue: issue.blocked_issue_detail.id,
|
||||
})),
|
||||
],
|
||||
})
|
||||
.then((response) => {
|
||||
submitChanges({
|
||||
related_issues: [
|
||||
...watch("related_issues")?.filter((i) => i.relation_type !== "blocked_by"),
|
||||
...response,
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
submitChanges({
|
||||
blocked_issues: newBlocked,
|
||||
blocks_list: newBlocked.map((i) => i.blocked_issue_detail?.id ?? ""),
|
||||
});
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const blockedByIssue = watch("related_issues")?.filter((i) => i.relation_type === "blocked_by");
|
||||
|
||||
return (
|
||||
<>
|
||||
<ExistingIssuesListModal
|
||||
isOpen={isBlockedModalOpen}
|
||||
handleClose={() => setIsBlockedModalOpen(false)}
|
||||
searchParams={{ blocker_blocked_by: true, issue_id: issueId }}
|
||||
searchParams={{ issue_relation: true, issue_id: issueId }}
|
||||
handleOnSubmit={onSubmit}
|
||||
workspaceLevelToggle
|
||||
/>
|
||||
@@ -87,33 +108,42 @@ export const SidebarBlockedSelect: React.FC<Props> = ({
|
||||
</div>
|
||||
<div className="space-y-1 sm:basis-1/2">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{watch("blocked_issues") && watch("blocked_issues").length > 0
|
||||
? watch("blocked_issues").map((issue) => (
|
||||
{blockedByIssue && blockedByIssue.length > 0
|
||||
? blockedByIssue.map((relation) => (
|
||||
<div
|
||||
key={issue.blocked_issue_detail?.id}
|
||||
key={relation.related_issue_detail?.id}
|
||||
className="group flex cursor-pointer items-center gap-1 rounded-2xl border border-custom-border-200 px-1.5 py-0.5 text-xs text-red-500 duration-300 hover:border-red-500/20 hover:bg-red-500/20"
|
||||
>
|
||||
<a
|
||||
href={`/${workspaceSlug}/projects/${issue.blocked_issue_detail?.project_detail.id}/issues/${issue.blocked_issue_detail?.id}`}
|
||||
href={`/${workspaceSlug}/projects/${relation.related_issue_detail?.project_detail.id}/issues/${relation.related_issue_detail?.id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<BlockedIcon height={10} width={10} />
|
||||
{`${issue.blocked_issue_detail?.project_detail.identifier}-${issue.blocked_issue_detail?.sequence_id}`}
|
||||
{`${relation.related_issue_detail?.project_detail.identifier}-${relation.related_issue_detail?.sequence_id}`}
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
className="opacity-0 duration-300 group-hover:opacity-100"
|
||||
onClick={() => {
|
||||
const updatedBlocked = watch("blocked_issues").filter(
|
||||
(i) => i.blocked_issue_detail?.id !== issue.blocked_issue_detail?.id
|
||||
const updatedRelations = watch("related_issues")?.filter(
|
||||
(i) => i.id !== relation.id
|
||||
);
|
||||
|
||||
submitChanges({
|
||||
blocked_issues: updatedBlocked,
|
||||
blocks_list: updatedBlocked.map((i) => i.blocked_issue_detail?.id ?? ""),
|
||||
related_issues: updatedRelations,
|
||||
});
|
||||
|
||||
if (!user) return;
|
||||
|
||||
issuesService.deleteIssueRelation(
|
||||
workspaceSlug as string,
|
||||
projectId as string,
|
||||
issueId as string,
|
||||
relation.id,
|
||||
user
|
||||
);
|
||||
}}
|
||||
>
|
||||
<XMarkIcon className="h-2 w-2" />
|
||||
|
||||
@@ -6,8 +6,11 @@ import { useRouter } from "next/router";
|
||||
import { UseFormWatch } from "react-hook-form";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
import useUser from "hooks/use-user";
|
||||
// components
|
||||
import { ExistingIssuesListModal } from "components/core";
|
||||
// services
|
||||
import issuesService from "services/issues.service";
|
||||
// icons
|
||||
import { XMarkIcon } from "@heroicons/react/24/outline";
|
||||
import { BlockerIcon } from "components/icons";
|
||||
@@ -29,15 +32,19 @@ export const SidebarBlockerSelect: React.FC<Props> = ({
|
||||
}) => {
|
||||
const [isBlockerModalOpen, setIsBlockerModalOpen] = useState(false);
|
||||
|
||||
const { user } = useUser();
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug } = router.query;
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
|
||||
const handleClose = () => {
|
||||
setIsBlockerModalOpen(false);
|
||||
};
|
||||
|
||||
const blockerIssue =
|
||||
watch("issue_relations")?.filter((i) => i.relation_type === "blocked_by") || [];
|
||||
|
||||
const onSubmit = async (data: ISearchIssueResponse[]) => {
|
||||
if (data.length === 0) {
|
||||
setToastAlert({
|
||||
@@ -62,12 +69,33 @@ export const SidebarBlockerSelect: React.FC<Props> = ({
|
||||
},
|
||||
}));
|
||||
|
||||
const newBlockers = [...watch("blocker_issues"), ...selectedIssues];
|
||||
if (!user) return;
|
||||
|
||||
issuesService
|
||||
.createIssueRelation(workspaceSlug as string, projectId as string, issueId as string, user, {
|
||||
related_list: [
|
||||
...selectedIssues.map((issue) => ({
|
||||
issue: issue.blocker_issue_detail.id,
|
||||
relation_type: "blocked_by" as const,
|
||||
related_issue: issueId as string,
|
||||
related_issue_detail: issue.blocker_issue_detail,
|
||||
})),
|
||||
],
|
||||
})
|
||||
.then((response) => {
|
||||
submitChanges({
|
||||
issue_relations: [
|
||||
...blockerIssue,
|
||||
...(response ?? []).map((i: any) => ({
|
||||
id: i.id,
|
||||
relation_type: i.relation_type,
|
||||
issue_detail: i.related_issue_detail,
|
||||
issue: i.related_issue,
|
||||
})),
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
submitChanges({
|
||||
blocker_issues: newBlockers,
|
||||
blockers_list: newBlockers.map((i) => i.blocker_issue_detail?.id ?? ""),
|
||||
});
|
||||
handleClose();
|
||||
};
|
||||
|
||||
@@ -76,7 +104,7 @@ export const SidebarBlockerSelect: React.FC<Props> = ({
|
||||
<ExistingIssuesListModal
|
||||
isOpen={isBlockerModalOpen}
|
||||
handleClose={() => setIsBlockerModalOpen(false)}
|
||||
searchParams={{ blocker_blocked_by: true, issue_id: issueId }}
|
||||
searchParams={{ issue_relation: true, issue_id: issueId }}
|
||||
handleOnSubmit={onSubmit}
|
||||
workspaceLevelToggle
|
||||
/>
|
||||
@@ -87,35 +115,42 @@ export const SidebarBlockerSelect: React.FC<Props> = ({
|
||||
</div>
|
||||
<div className="space-y-1 sm:basis-1/2">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{watch("blocker_issues") && watch("blocker_issues").length > 0
|
||||
? watch("blocker_issues").map((issue) => (
|
||||
{blockerIssue && blockerIssue.length > 0
|
||||
? blockerIssue.map((relation) => (
|
||||
<div
|
||||
key={issue.blocker_issue_detail?.id}
|
||||
key={relation.issue_detail?.id}
|
||||
className="group flex cursor-pointer items-center gap-1 rounded-2xl border border-custom-border-200 px-1.5 py-0.5 text-xs text-yellow-500 duration-300 hover:border-yellow-500/20 hover:bg-yellow-500/20"
|
||||
>
|
||||
<a
|
||||
href={`/${workspaceSlug}/projects/${issue.blocker_issue_detail?.project_detail.id}/issues/${issue.blocker_issue_detail?.id}`}
|
||||
href={`/${workspaceSlug}/projects/${relation.issue_detail?.project_detail.id}/issues/${relation.issue_detail?.id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<BlockerIcon height={10} width={10} />
|
||||
{`${issue.blocker_issue_detail?.project_detail.identifier}-${issue.blocker_issue_detail?.sequence_id}`}
|
||||
{`${relation.issue_detail?.project_detail.identifier}-${relation.issue_detail?.sequence_id}`}
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
className="opacity-0 duration-300 group-hover:opacity-100"
|
||||
onClick={() => {
|
||||
const updatedBlockers = watch("blocker_issues").filter(
|
||||
(i) => i.blocker_issue_detail?.id !== issue.blocker_issue_detail?.id
|
||||
const updatedBlockers = blockerIssue.filter(
|
||||
(i) => i.issue_detail?.id !== relation.issue_detail?.id
|
||||
);
|
||||
|
||||
submitChanges({
|
||||
blocker_issues: updatedBlockers,
|
||||
blockers_list: updatedBlockers.map(
|
||||
(i) => i.blocker_issue_detail?.id ?? ""
|
||||
),
|
||||
issue_relations: updatedBlockers,
|
||||
});
|
||||
|
||||
if (!user) return;
|
||||
|
||||
issuesService.deleteIssueRelation(
|
||||
workspaceSlug as string,
|
||||
projectId as string,
|
||||
relation.issue_detail?.id as string,
|
||||
relation.id,
|
||||
user
|
||||
);
|
||||
}}
|
||||
>
|
||||
<XMarkIcon className="h-2 w-2" />
|
||||
|
||||
172
web/components/issues/sidebar-select/duplicate.tsx
Normal file
172
web/components/issues/sidebar-select/duplicate.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
import React, { useState } from "react";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
// react-hook-form
|
||||
import { UseFormWatch } from "react-hook-form";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
import useUser from "hooks/use-user";
|
||||
// icons
|
||||
import { X, CopyPlus } from "lucide-react";
|
||||
// components
|
||||
import { BlockerIcon } from "components/icons";
|
||||
import { ExistingIssuesListModal } from "components/core";
|
||||
// services
|
||||
import issuesService from "services/issues.service";
|
||||
// types
|
||||
import { BlockeIssueDetail, IIssue, ISearchIssueResponse } from "types";
|
||||
|
||||
type Props = {
|
||||
issueId?: string;
|
||||
submitChanges: (formData: Partial<IIssue>) => void;
|
||||
watch: UseFormWatch<IIssue>;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export const SidebarDuplicateSelect: React.FC<Props> = (props) => {
|
||||
const { issueId, submitChanges, watch, disabled = false } = props;
|
||||
|
||||
const [isDuplicateModalOpen, setIsDuplicateModalOpen] = useState(false);
|
||||
|
||||
const { user } = useUser();
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
|
||||
const handleClose = () => {
|
||||
setIsDuplicateModalOpen(false);
|
||||
};
|
||||
|
||||
const onSubmit = async (data: ISearchIssueResponse[]) => {
|
||||
if (data.length === 0) {
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: "Please select at least one issue.",
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedIssues: { blocker_issue_detail: BlockeIssueDetail }[] = data.map((i) => ({
|
||||
blocker_issue_detail: {
|
||||
id: i.id,
|
||||
name: i.name,
|
||||
sequence_id: i.sequence_id,
|
||||
project_detail: {
|
||||
id: i.project_id,
|
||||
identifier: i.project__identifier,
|
||||
name: i.project__name,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
if (!user) return;
|
||||
|
||||
issuesService
|
||||
.createIssueRelation(workspaceSlug as string, projectId as string, issueId as string, user, {
|
||||
related_list: [
|
||||
...selectedIssues.map((issue) => ({
|
||||
issue: issueId as string,
|
||||
related_issue_detail: issue.blocker_issue_detail,
|
||||
related_issue: issue.blocker_issue_detail.id,
|
||||
relation_type: "duplicate" as const,
|
||||
})),
|
||||
],
|
||||
})
|
||||
.then((response) => {
|
||||
submitChanges({
|
||||
related_issues: [...watch("related_issues"), ...(response ?? [])],
|
||||
});
|
||||
});
|
||||
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const duplicateIssuesRelation = [
|
||||
...(watch("related_issues")?.filter((i) => i.relation_type === "duplicate") ?? []),
|
||||
...(watch("issue_relations") ?? [])
|
||||
?.filter((i) => i.relation_type === "duplicate")
|
||||
.map((i) => ({
|
||||
...i,
|
||||
related_issue_detail: i.issue_detail,
|
||||
related_issue: i.issue_detail?.id,
|
||||
})),
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<ExistingIssuesListModal
|
||||
isOpen={isDuplicateModalOpen}
|
||||
handleClose={() => setIsDuplicateModalOpen(false)}
|
||||
searchParams={{ issue_relation: true, issue_id: issueId }}
|
||||
handleOnSubmit={onSubmit}
|
||||
workspaceLevelToggle
|
||||
/>
|
||||
<div className="flex flex-wrap items-start py-2">
|
||||
<div className="flex items-center gap-x-2 text-sm text-custom-text-200 sm:basis-1/2">
|
||||
<CopyPlus className="h-4 w-4 flex-shrink-0" />
|
||||
<p>Duplicate</p>
|
||||
</div>
|
||||
<div className="space-y-1 sm:basis-1/2">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{duplicateIssuesRelation && duplicateIssuesRelation.length > 0
|
||||
? duplicateIssuesRelation.map((relation) => (
|
||||
<div
|
||||
key={relation.related_issue_detail?.id}
|
||||
className="group flex cursor-pointer items-center gap-1 rounded-2xl border border-custom-border-200 px-1.5 py-0.5 text-xs text-yellow-500 duration-300 hover:border-yellow-500/20 hover:bg-yellow-500/20"
|
||||
>
|
||||
<a
|
||||
href={`/${workspaceSlug}/projects/${relation.related_issue_detail?.project_detail.id}/issues/${relation.related_issue_detail?.id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<BlockerIcon height={10} width={10} />
|
||||
{`${relation.related_issue_detail?.project_detail.identifier}-${relation.related_issue_detail?.sequence_id}`}
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
className="opacity-0 duration-300 group-hover:opacity-100"
|
||||
onClick={() => {
|
||||
const updatedBlockers = duplicateIssuesRelation.filter(
|
||||
(i) => i.related_issue_detail?.id !== relation.related_issue_detail?.id
|
||||
);
|
||||
|
||||
submitChanges({
|
||||
related_issues: updatedBlockers,
|
||||
});
|
||||
|
||||
if (!user) return;
|
||||
|
||||
issuesService.deleteIssueRelation(
|
||||
workspaceSlug as string,
|
||||
projectId as string,
|
||||
issueId as string,
|
||||
relation.id,
|
||||
user
|
||||
);
|
||||
}}
|
||||
>
|
||||
<X className="h-2 w-2" />
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
: null}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={`bg-custom-background-80 text-xs text-custom-text-200 rounded px-2.5 py-0.5 ${
|
||||
disabled ? "cursor-not-allowed" : "cursor-pointer hover:bg-custom-background-80"
|
||||
}`}
|
||||
onClick={() => setIsDuplicateModalOpen(true)}
|
||||
disabled={disabled}
|
||||
>
|
||||
Select issues
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -8,3 +8,5 @@ export * from "./module";
|
||||
export * from "./parent";
|
||||
export * from "./priority";
|
||||
export * from "./state";
|
||||
export * from "./duplicate";
|
||||
export * from "./relates-to";
|
||||
|
||||
172
web/components/issues/sidebar-select/relates-to.tsx
Normal file
172
web/components/issues/sidebar-select/relates-to.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
import React, { useState } from "react";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
// react-hook-form
|
||||
import { UseFormWatch } from "react-hook-form";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
import useUser from "hooks/use-user";
|
||||
// icons
|
||||
import { X } from "lucide-react";
|
||||
import { BlockerIcon, RelatedIcon } from "components/icons";
|
||||
// components
|
||||
import { ExistingIssuesListModal } from "components/core";
|
||||
// services
|
||||
import issuesService from "services/issues.service";
|
||||
// types
|
||||
import { BlockeIssueDetail, IIssue, ISearchIssueResponse } from "types";
|
||||
|
||||
type Props = {
|
||||
issueId?: string;
|
||||
submitChanges: (formData: Partial<IIssue>) => void;
|
||||
watch: UseFormWatch<IIssue>;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export const SidebarRelatesSelect: React.FC<Props> = (props) => {
|
||||
const { issueId, submitChanges, watch, disabled = false } = props;
|
||||
|
||||
const [isRelatesToModalOpen, setIsRelatesToModalOpen] = useState(false);
|
||||
|
||||
const { user } = useUser();
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
|
||||
const handleClose = () => {
|
||||
setIsRelatesToModalOpen(false);
|
||||
};
|
||||
|
||||
const onSubmit = async (data: ISearchIssueResponse[]) => {
|
||||
if (data.length === 0) {
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: "Please select at least one issue.",
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedIssues: { blocker_issue_detail: BlockeIssueDetail }[] = data.map((i) => ({
|
||||
blocker_issue_detail: {
|
||||
id: i.id,
|
||||
name: i.name,
|
||||
sequence_id: i.sequence_id,
|
||||
project_detail: {
|
||||
id: i.project_id,
|
||||
identifier: i.project__identifier,
|
||||
name: i.project__name,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
if (!user) return;
|
||||
|
||||
issuesService
|
||||
.createIssueRelation(workspaceSlug as string, projectId as string, issueId as string, user, {
|
||||
related_list: [
|
||||
...selectedIssues.map((issue) => ({
|
||||
issue: issueId as string,
|
||||
related_issue_detail: issue.blocker_issue_detail,
|
||||
related_issue: issue.blocker_issue_detail.id,
|
||||
relation_type: "relates_to" as const,
|
||||
})),
|
||||
],
|
||||
})
|
||||
.then((response) => {
|
||||
submitChanges({
|
||||
related_issues: [...watch("related_issues"), ...(response ?? [])],
|
||||
});
|
||||
});
|
||||
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const relatedToIssueRelation = [
|
||||
...(watch("related_issues")?.filter((i) => i.relation_type === "relates_to") ?? []),
|
||||
...(watch("issue_relations") ?? [])
|
||||
?.filter((i) => i.relation_type === "relates_to")
|
||||
.map((i) => ({
|
||||
...i,
|
||||
related_issue_detail: i.issue_detail,
|
||||
related_issue: i.issue_detail?.id,
|
||||
})),
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<ExistingIssuesListModal
|
||||
isOpen={isRelatesToModalOpen}
|
||||
handleClose={() => setIsRelatesToModalOpen(false)}
|
||||
searchParams={{ issue_relation: true, issue_id: issueId }}
|
||||
handleOnSubmit={onSubmit}
|
||||
workspaceLevelToggle
|
||||
/>
|
||||
<div className="flex flex-wrap items-start py-2">
|
||||
<div className="flex items-center gap-x-2 text-sm text-custom-text-200 sm:basis-1/2">
|
||||
<RelatedIcon className="h-4 w-4 flex-shrink-0" />
|
||||
<p>Relates to</p>
|
||||
</div>
|
||||
<div className="space-y-1 sm:basis-1/2">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{relatedToIssueRelation && relatedToIssueRelation.length > 0
|
||||
? relatedToIssueRelation.map((relation) => (
|
||||
<div
|
||||
key={relation.related_issue_detail?.id}
|
||||
className="group flex cursor-pointer items-center gap-1 rounded-2xl border border-custom-border-200 px-1.5 py-0.5 text-xs text-yellow-500 duration-300 hover:border-yellow-500/20 hover:bg-yellow-500/20"
|
||||
>
|
||||
<a
|
||||
href={`/${workspaceSlug}/projects/${relation.related_issue_detail?.project_detail.id}/issues/${relation.related_issue_detail?.id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<BlockerIcon height={10} width={10} />
|
||||
{`${relation.related_issue_detail?.project_detail.identifier}-${relation.related_issue_detail?.sequence_id}`}
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
className="opacity-0 duration-300 group-hover:opacity-100"
|
||||
onClick={() => {
|
||||
const updatedBlockers = relatedToIssueRelation.filter(
|
||||
(i) => i.related_issue_detail?.id !== relation.related_issue_detail?.id
|
||||
);
|
||||
|
||||
submitChanges({
|
||||
related_issues: updatedBlockers,
|
||||
});
|
||||
|
||||
if (!user) return;
|
||||
|
||||
issuesService.deleteIssueRelation(
|
||||
workspaceSlug as string,
|
||||
projectId as string,
|
||||
issueId as string,
|
||||
relation.id,
|
||||
user
|
||||
);
|
||||
}}
|
||||
>
|
||||
<X className="h-2 w-2" />
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
: null}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={`bg-custom-background-80 text-xs text-custom-text-200 rounded px-2.5 py-0.5 ${
|
||||
disabled ? "cursor-not-allowed" : "cursor-pointer hover:bg-custom-background-80"
|
||||
}`}
|
||||
onClick={() => setIsRelatesToModalOpen(true)}
|
||||
disabled={disabled}
|
||||
>
|
||||
Select issues
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -30,6 +30,8 @@ import {
|
||||
SidebarStateSelect,
|
||||
SidebarEstimateSelect,
|
||||
SidebarLabelSelect,
|
||||
SidebarDuplicateSelect,
|
||||
SidebarRelatesSelect,
|
||||
} from "components/issues";
|
||||
// ui
|
||||
import { CustomDatePicker, Icon } from "components/ui";
|
||||
@@ -76,6 +78,8 @@ type Props = {
|
||||
| "delete"
|
||||
| "all"
|
||||
| "subscribe"
|
||||
| "duplicate"
|
||||
| "relates_to"
|
||||
)[];
|
||||
uneditable?: boolean;
|
||||
};
|
||||
@@ -464,7 +468,19 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
|
||||
{(fieldsToShow.includes("all") || fieldsToShow.includes("blocker")) && (
|
||||
<SidebarBlockerSelect
|
||||
issueId={issueId as string}
|
||||
submitChanges={submitChanges}
|
||||
submitChanges={(data: any) => {
|
||||
mutate<IIssue>(
|
||||
ISSUE_DETAILS(issueId as string),
|
||||
(prevData) => {
|
||||
if (!prevData) return prevData;
|
||||
return {
|
||||
...prevData,
|
||||
...data,
|
||||
};
|
||||
},
|
||||
false
|
||||
);
|
||||
}}
|
||||
watch={watchIssue}
|
||||
disabled={memberRole.isGuest || memberRole.isViewer || uneditable}
|
||||
/>
|
||||
@@ -472,7 +488,59 @@ export const IssueDetailsSidebar: React.FC<Props> = ({
|
||||
{(fieldsToShow.includes("all") || fieldsToShow.includes("blocked")) && (
|
||||
<SidebarBlockedSelect
|
||||
issueId={issueId as string}
|
||||
submitChanges={submitChanges}
|
||||
submitChanges={(data: any) => {
|
||||
mutate<IIssue>(
|
||||
ISSUE_DETAILS(issueId as string),
|
||||
(prevData) => {
|
||||
if (!prevData) return prevData;
|
||||
return {
|
||||
...prevData,
|
||||
...data,
|
||||
};
|
||||
},
|
||||
false
|
||||
);
|
||||
}}
|
||||
watch={watchIssue}
|
||||
disabled={memberRole.isGuest || memberRole.isViewer || uneditable}
|
||||
/>
|
||||
)}
|
||||
{(fieldsToShow.includes("all") || fieldsToShow.includes("duplicate")) && (
|
||||
<SidebarDuplicateSelect
|
||||
issueId={issueId as string}
|
||||
submitChanges={(data: any) => {
|
||||
mutate<IIssue>(
|
||||
ISSUE_DETAILS(issueId as string),
|
||||
(prevData) => {
|
||||
if (!prevData) return prevData;
|
||||
return {
|
||||
...prevData,
|
||||
...data,
|
||||
};
|
||||
},
|
||||
false
|
||||
);
|
||||
}}
|
||||
watch={watchIssue}
|
||||
disabled={memberRole.isGuest || memberRole.isViewer || uneditable}
|
||||
/>
|
||||
)}
|
||||
{(fieldsToShow.includes("all") || fieldsToShow.includes("relates_to")) && (
|
||||
<SidebarRelatesSelect
|
||||
issueId={issueId as string}
|
||||
submitChanges={(data: any) => {
|
||||
mutate<IIssue>(
|
||||
ISSUE_DETAILS(issueId as string),
|
||||
(prevData) => {
|
||||
if (!prevData) return prevData;
|
||||
return {
|
||||
...prevData,
|
||||
...data,
|
||||
};
|
||||
},
|
||||
false
|
||||
);
|
||||
}}
|
||||
watch={watchIssue}
|
||||
disabled={memberRole.isGuest || memberRole.isViewer || uneditable}
|
||||
/>
|
||||
|
||||
@@ -17,7 +17,7 @@ import issuesService from "services/issues.service";
|
||||
// ui
|
||||
import { Input, PrimaryButton, SecondaryButton } from "components/ui";
|
||||
// icons
|
||||
import { ChevronDownIcon } from "@heroicons/react/24/outline";
|
||||
import { Component } from "lucide-react";
|
||||
// types
|
||||
import { IIssueLabels } from "types";
|
||||
// fetch-keys
|
||||
@@ -132,7 +132,7 @@ export const CreateUpdateLabelInline = forwardRef<HTMLDivElement, Props>(
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex scroll-m-8 items-center gap-2 rounded-[10px] border border-custom-border-200 bg-custom-background-100 p-5 ${
|
||||
className={`flex scroll-m-8 items-center gap-2 rounded border border-custom-border-200 bg-custom-background-100 px-3.5 py-2 ${
|
||||
labelForm ? "" : "hidden"
|
||||
}`}
|
||||
ref={ref}
|
||||
@@ -146,18 +146,12 @@ export const CreateUpdateLabelInline = forwardRef<HTMLDivElement, Props>(
|
||||
open ? "text-custom-text-100" : "text-custom-text-200"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className="h-5 w-5 rounded"
|
||||
<Component
|
||||
className="h-4 w-4 text-custom-text-100 flex-shrink-0"
|
||||
style={{
|
||||
backgroundColor: watch("color"),
|
||||
color: watch("color"),
|
||||
}}
|
||||
/>
|
||||
<ChevronDownIcon
|
||||
className={`ml-2 h-5 w-5 group-hover:text-custom-text-200 ${
|
||||
open ? "text-gray-600" : "text-gray-400"
|
||||
}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Popover.Button>
|
||||
|
||||
<Transition
|
||||
|
||||
@@ -13,12 +13,12 @@ import { CustomMenu } from "components/ui";
|
||||
// icons
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
RectangleGroupIcon,
|
||||
XMarkIcon,
|
||||
PlusIcon,
|
||||
PencilIcon,
|
||||
TrashIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { Component, X } from "lucide-react";
|
||||
// types
|
||||
import { ICurrentUserResponse, IIssueLabels } from "types";
|
||||
// fetch-keys
|
||||
@@ -76,20 +76,18 @@ export const SingleLabelGroup: React.FC<Props> = ({
|
||||
return (
|
||||
<Disclosure
|
||||
as="div"
|
||||
className="rounded-[10px] border border-custom-border-200 bg-custom-background-100 p-5 text-custom-text-100"
|
||||
className="rounded border border-custom-border-200 bg-custom-background-100 px-3.5 py-3 text-custom-text-100"
|
||||
defaultOpen
|
||||
>
|
||||
{({ open }) => (
|
||||
<>
|
||||
<div className="flex cursor-pointer items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>
|
||||
<RectangleGroupIcon className="h-4 w-4" />
|
||||
</span>
|
||||
<Component className="h-4 w-4 text-custom-text-100 flex-shrink-0" />
|
||||
<h6>{label.name}</h6>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<CustomMenu ellipsis>
|
||||
<CustomMenu ellipsis buttonClassName="!text-custom-sidebar-text-400">
|
||||
<CustomMenu.MenuItem onClick={() => addLabelToGroup(label)}>
|
||||
<span className="flex items-center justify-start gap-2">
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
@@ -112,7 +110,9 @@ export const SingleLabelGroup: React.FC<Props> = ({
|
||||
<Disclosure.Button>
|
||||
<span>
|
||||
<ChevronDownIcon
|
||||
className={`h-4 w-4 text-custom-text-100 ${!open ? "rotate-90 transform" : ""}`}
|
||||
className={`h-4 w-4 text-custom-sidebar-text-400 ${
|
||||
!open ? "rotate-90 transform" : ""
|
||||
}`}
|
||||
/>
|
||||
</span>
|
||||
</Disclosure.Button>
|
||||
@@ -128,15 +128,15 @@ export const SingleLabelGroup: React.FC<Props> = ({
|
||||
leaveTo="transform opacity-0"
|
||||
>
|
||||
<Disclosure.Panel>
|
||||
<div className="mt-3 ml-6 space-y-3">
|
||||
<div className="mt-2.5 ml-6">
|
||||
{labelChildren.map((child) => (
|
||||
<div
|
||||
key={child.id}
|
||||
className="group flex items-center justify-between rounded-md border border-custom-border-200 p-2 text-sm"
|
||||
className="group flex items-center justify-between border-b border-custom-border-200 px-4 py-2.5 text-sm last:border-0"
|
||||
>
|
||||
<h5 className="flex items-center gap-3">
|
||||
<span
|
||||
className="h-2.5 w-2.5 flex-shrink-0 rounded-full"
|
||||
className="h-3.5 w-3.5 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor:
|
||||
child.color && child.color !== "" ? child.color : "#000000",
|
||||
@@ -144,27 +144,38 @@ export const SingleLabelGroup: React.FC<Props> = ({
|
||||
/>
|
||||
{child.name}
|
||||
</h5>
|
||||
<div className="pointer-events-none opacity-0 group-hover:pointer-events-auto group-hover:opacity-100">
|
||||
<CustomMenu ellipsis>
|
||||
<CustomMenu.MenuItem onClick={() => removeFromGroup(child)}>
|
||||
<span className="flex items-center justify-start gap-2">
|
||||
<XMarkIcon className="h-4 w-4" />
|
||||
<span>Remove from group</span>
|
||||
</span>
|
||||
</CustomMenu.MenuItem>
|
||||
<CustomMenu.MenuItem onClick={() => editLabel(child)}>
|
||||
<span className="flex items-center justify-start gap-2">
|
||||
<PencilIcon className="h-4 w-4" />
|
||||
<span>Edit label</span>
|
||||
</span>
|
||||
</CustomMenu.MenuItem>
|
||||
<CustomMenu.MenuItem onClick={handleLabelDelete}>
|
||||
<span className="flex items-center justify-start gap-2">
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
<span>Delete label</span>
|
||||
</span>
|
||||
</CustomMenu.MenuItem>
|
||||
</CustomMenu>
|
||||
<div className="flex items-center gap-3.5 pointer-events-none opacity-0 group-hover:pointer-events-auto group-hover:opacity-100">
|
||||
<div className="h-4 w-4">
|
||||
<CustomMenu
|
||||
customButton={
|
||||
<div className="h-4 w-4">
|
||||
<Component className="h-4 w-4 leading-4 text-custom-sidebar-text-400 flex-shrink-0" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<CustomMenu.MenuItem onClick={() => removeFromGroup(child)}>
|
||||
<span className="flex items-center justify-start gap-2">
|
||||
<XMarkIcon className="h-4 w-4" />
|
||||
<span>Remove from group</span>
|
||||
</span>
|
||||
</CustomMenu.MenuItem>
|
||||
<CustomMenu.MenuItem onClick={() => editLabel(child)}>
|
||||
<span className="flex items-center justify-start gap-2">
|
||||
<PencilIcon className="h-4 w-4" />
|
||||
<span>Edit label</span>
|
||||
</span>
|
||||
</CustomMenu.MenuItem>
|
||||
</CustomMenu>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<button
|
||||
className="flex items-center justify-start gap-2"
|
||||
onClick={handleLabelDelete}
|
||||
>
|
||||
<X className="h-[18px] w-[18px] text-custom-sidebar-text-400 flex-shrink-0" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -5,7 +5,8 @@ import { CustomMenu } from "components/ui";
|
||||
// types
|
||||
import { IIssueLabels } from "types";
|
||||
//icons
|
||||
import { RectangleGroupIcon, PencilIcon, TrashIcon } from "@heroicons/react/24/outline";
|
||||
import { RectangleGroupIcon, PencilIcon } from "@heroicons/react/24/outline";
|
||||
import { Component, X } from "lucide-react";
|
||||
|
||||
type Props = {
|
||||
label: IIssueLabels;
|
||||
@@ -20,8 +21,8 @@ export const SingleLabel: React.FC<Props> = ({
|
||||
editLabel,
|
||||
handleLabelDelete,
|
||||
}) => (
|
||||
<div className="gap-2 space-y-3 divide-y divide-custom-border-200 rounded-[10px] border border-custom-border-200 bg-custom-background-100 p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="gap-2 space-y-3 divide-y divide-custom-border-200 rounded border border-custom-border-200 bg-custom-background-100 px-4 py-2.5">
|
||||
<div className="group flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
className="h-3.5 w-3.5 flex-shrink-0 rounded-full"
|
||||
@@ -31,26 +32,36 @@ export const SingleLabel: React.FC<Props> = ({
|
||||
/>
|
||||
<h6 className="text-sm">{label.name}</h6>
|
||||
</div>
|
||||
<CustomMenu ellipsis>
|
||||
<CustomMenu.MenuItem onClick={() => addLabelToGroup(label)}>
|
||||
<span className="flex items-center justify-start gap-2">
|
||||
<RectangleGroupIcon className="h-4 w-4" />
|
||||
<span>Convert to group</span>
|
||||
</span>
|
||||
</CustomMenu.MenuItem>
|
||||
<CustomMenu.MenuItem onClick={() => editLabel(label)}>
|
||||
<span className="flex items-center justify-start gap-2">
|
||||
<PencilIcon className="h-4 w-4" />
|
||||
<span>Edit label</span>
|
||||
</span>
|
||||
</CustomMenu.MenuItem>
|
||||
<CustomMenu.MenuItem onClick={handleLabelDelete}>
|
||||
<span className="flex items-center justify-start gap-2">
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
<span>Delete label</span>
|
||||
</span>
|
||||
</CustomMenu.MenuItem>
|
||||
</CustomMenu>
|
||||
<div className="flex items-center gap-3.5 pointer-events-none opacity-0 group-hover:pointer-events-auto group-hover:opacity-100">
|
||||
<div className="h-4 w-4">
|
||||
<CustomMenu
|
||||
customButton={
|
||||
<div className="h-4 w-4">
|
||||
<Component className="h-4 w-4 leading-4 text-custom-sidebar-text-400 flex-shrink-0" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<CustomMenu.MenuItem onClick={() => addLabelToGroup(label)}>
|
||||
<span className="flex items-center justify-start gap-2">
|
||||
<RectangleGroupIcon className="h-4 w-4" />
|
||||
<span>Convert to group</span>
|
||||
</span>
|
||||
</CustomMenu.MenuItem>
|
||||
<CustomMenu.MenuItem onClick={() => editLabel(label)}>
|
||||
<span className="flex items-center justify-start gap-2">
|
||||
<PencilIcon className="h-4 w-4" />
|
||||
<span>Edit label</span>
|
||||
</span>
|
||||
</CustomMenu.MenuItem>
|
||||
</CustomMenu>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<button className="flex items-center justify-start gap-2" onClick={handleLabelDelete}>
|
||||
<X className="h-[18px] w-[18px] text-custom-sidebar-text-400 flex-shrink-0" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { FC } from "react";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
// hooks
|
||||
@@ -10,13 +8,13 @@ import { updateGanttIssue } from "components/gantt-chart/hooks/block-update";
|
||||
import useProjectDetails from "hooks/use-project-details";
|
||||
// components
|
||||
import { GanttChartRoot, renderIssueBlocksStructure } from "components/gantt-chart";
|
||||
import { IssueGanttBlock, IssueGanttSidebarBlock } from "components/issues";
|
||||
import { IssueGanttBlock, IssueGanttSidebarBlock, IssuePeekOverview } from "components/issues";
|
||||
// types
|
||||
import { IIssue } from "types";
|
||||
|
||||
type Props = {};
|
||||
type Props = { disableUserActions: boolean };
|
||||
|
||||
export const ModuleIssuesGanttChartView: FC<Props> = ({}) => {
|
||||
export const ModuleIssuesGanttChartView: React.FC<Props> = ({ disableUserActions }) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, moduleId } = router.query;
|
||||
|
||||
@@ -34,23 +32,31 @@ export const ModuleIssuesGanttChartView: FC<Props> = ({}) => {
|
||||
const isAllowed = projectDetails?.member_role === 20 || projectDetails?.member_role === 15;
|
||||
|
||||
return (
|
||||
<div className="w-full h-full">
|
||||
<GanttChartRoot
|
||||
border={false}
|
||||
title="Issues"
|
||||
loaderTitle="Issues"
|
||||
blocks={ganttIssues ? renderIssueBlocksStructure(ganttIssues as IIssue[]) : null}
|
||||
blockUpdateHandler={(block, payload) =>
|
||||
updateGanttIssue(block, payload, mutateGanttIssues, user, workspaceSlug?.toString())
|
||||
}
|
||||
SidebarBlockRender={IssueGanttSidebarBlock}
|
||||
BlockRender={IssueGanttBlock}
|
||||
enableBlockLeftResize={isAllowed}
|
||||
enableBlockRightResize={isAllowed}
|
||||
enableBlockMove={isAllowed}
|
||||
enableReorder={displayFilters.order_by === "sort_order" && isAllowed}
|
||||
bottomSpacing
|
||||
<>
|
||||
<IssuePeekOverview
|
||||
handleMutation={() => mutateGanttIssues()}
|
||||
projectId={projectId?.toString() ?? ""}
|
||||
workspaceSlug={workspaceSlug?.toString() ?? ""}
|
||||
readOnly={disableUserActions}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full h-full">
|
||||
<GanttChartRoot
|
||||
border={false}
|
||||
title="Issues"
|
||||
loaderTitle="Issues"
|
||||
blocks={ganttIssues ? renderIssueBlocksStructure(ganttIssues as IIssue[]) : null}
|
||||
blockUpdateHandler={(block, payload) =>
|
||||
updateGanttIssue(block, payload, mutateGanttIssues, user, workspaceSlug?.toString())
|
||||
}
|
||||
SidebarBlockRender={IssueGanttSidebarBlock}
|
||||
BlockRender={IssueGanttBlock}
|
||||
enableBlockLeftResize={isAllowed}
|
||||
enableBlockRightResize={isAllowed}
|
||||
enableBlockMove={isAllowed}
|
||||
enableReorder={displayFilters.order_by === "sort_order" && isAllowed}
|
||||
bottomSpacing
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -204,7 +204,7 @@ export const ProfileIssuesView = () => {
|
||||
);
|
||||
|
||||
const handleIssueAction = useCallback(
|
||||
(issue: IIssue, action: "copy" | "edit" | "delete") => {
|
||||
(issue: IIssue, action: "copy" | "edit" | "delete" | "updateDraft") => {
|
||||
if (action === "copy") makeIssueCopy(issue);
|
||||
else if (action === "edit") handleEditIssue(issue);
|
||||
else if (action === "delete") handleDeleteIssue(issue);
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
export * from "./create-project-modal";
|
||||
export * from "./delete-project-modal";
|
||||
export * from "./sidebar-list";
|
||||
export * from "./settings-header";
|
||||
export * from "./settings-sidebar";
|
||||
export * from "./single-integration-card";
|
||||
export * from "./single-project-card";
|
||||
export * from "./single-sidebar-project";
|
||||
export * from "./confirm-project-leave-modal";
|
||||
export * from "./member-select";
|
||||
|
||||
74
web/components/project/member-select.tsx
Normal file
74
web/components/project/member-select.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import React from "react";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import useSWR from "swr";
|
||||
|
||||
// services
|
||||
import projectService from "services/project.service";
|
||||
// ui
|
||||
import { Avatar, CustomSearchSelect } from "components/ui";
|
||||
// fetch-keys
|
||||
import { PROJECT_MEMBERS } from "constants/fetch-keys";
|
||||
|
||||
type Props = {
|
||||
value: any;
|
||||
onChange: (val: string) => void;
|
||||
};
|
||||
|
||||
export const MemberSelect: React.FC<Props> = ({ value, onChange }) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
|
||||
const { data: members } = useSWR(
|
||||
workspaceSlug && projectId ? PROJECT_MEMBERS(projectId as string) : null,
|
||||
workspaceSlug && projectId
|
||||
? () => projectService.projectMembers(workspaceSlug as string, projectId as string)
|
||||
: null
|
||||
);
|
||||
|
||||
const options = members?.map((member) => ({
|
||||
value: member.member.id,
|
||||
query: member.member.display_name,
|
||||
content: (
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar user={member.member} />
|
||||
{member.member.display_name}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const selectedOption = members?.find((m) => m.member.id === value)?.member;
|
||||
|
||||
return (
|
||||
<CustomSearchSelect
|
||||
value={value}
|
||||
label={
|
||||
<div className="flex items-center gap-2">
|
||||
{selectedOption && <Avatar user={selectedOption} />}
|
||||
{selectedOption ? (
|
||||
selectedOption?.display_name
|
||||
) : (
|
||||
<span className="text-sm py-0.5 text-custom-text-200">Select</span>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
buttonClassName="!px-3 !py-2"
|
||||
options={
|
||||
options &&
|
||||
options && [
|
||||
...options,
|
||||
{
|
||||
value: "none",
|
||||
query: "none",
|
||||
content: <div className="flex items-center gap-2">None</div>,
|
||||
},
|
||||
]
|
||||
}
|
||||
maxHeight="md"
|
||||
position="right"
|
||||
width="w-full"
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -63,7 +63,11 @@ export const PublishProjectModal: React.FC<Props> = observer(() => {
|
||||
const [isUnpublishing, setIsUnpublishing] = useState(false);
|
||||
const [isUpdateRequired, setIsUpdateRequired] = useState(false);
|
||||
|
||||
const plane_deploy_url = process.env.NEXT_PUBLIC_DEPLOY_URL ?? "http://localhost:4000";
|
||||
let plane_deploy_url = process.env.NEXT_PUBLIC_DEPLOY_URL;
|
||||
|
||||
if (typeof window !== 'undefined' && !plane_deploy_url) {
|
||||
plane_deploy_url= window.location.protocol + "//" + window.location.host + "/spaces";
|
||||
}
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug } = router.query;
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import SettingsNavbar from "layouts/settings-navbar";
|
||||
|
||||
export const SettingsHeader = () => (
|
||||
<div className="mb-8 space-y-6">
|
||||
<div>
|
||||
<h3 className="text-2xl font-semibold">Project Settings</h3>
|
||||
<p className="mt-1 text-sm text-custom-text-200">
|
||||
This information will be displayed to every member of the project.
|
||||
</p>
|
||||
</div>
|
||||
<SettingsNavbar />
|
||||
</div>
|
||||
);
|
||||
72
web/components/project/settings-sidebar.tsx
Normal file
72
web/components/project/settings-sidebar.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import React from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import Link from "next/link";
|
||||
|
||||
export const SettingsSidebar = () => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
|
||||
const projectLinks: Array<{
|
||||
label: string;
|
||||
href: string;
|
||||
}> = [
|
||||
{
|
||||
label: "General",
|
||||
href: `/${workspaceSlug}/projects/${projectId}/settings`,
|
||||
},
|
||||
{
|
||||
label: "Members",
|
||||
href: `/${workspaceSlug}/projects/${projectId}/settings/members`,
|
||||
},
|
||||
{
|
||||
label: "Features",
|
||||
href: `/${workspaceSlug}/projects/${projectId}/settings/features`,
|
||||
},
|
||||
{
|
||||
label: "States",
|
||||
href: `/${workspaceSlug}/projects/${projectId}/settings/states`,
|
||||
},
|
||||
{
|
||||
label: "Labels",
|
||||
href: `/${workspaceSlug}/projects/${projectId}/settings/labels`,
|
||||
},
|
||||
{
|
||||
label: "Integrations",
|
||||
href: `/${workspaceSlug}/projects/${projectId}/settings/integrations`,
|
||||
},
|
||||
{
|
||||
label: "Estimates",
|
||||
href: `/${workspaceSlug}/projects/${projectId}/settings/estimates`,
|
||||
},
|
||||
{
|
||||
label: "Automations",
|
||||
href: `/${workspaceSlug}/projects/${projectId}/settings/automations`,
|
||||
},
|
||||
];
|
||||
return (
|
||||
<div className="flex flex-col gap-2 w-80 px-9">
|
||||
<span className="text-xs text-custom-sidebar-text-400 font-semibold">SETTINGS</span>
|
||||
<div className="flex flex-col gap-1 w-full">
|
||||
{projectLinks.map((link) => (
|
||||
<Link key={link.href} href={link.href}>
|
||||
<a>
|
||||
<div
|
||||
className={`px-4 py-2 text-sm font-medium rounded-md ${
|
||||
(
|
||||
link.label === "Import"
|
||||
? router.asPath.includes(link.href)
|
||||
: router.asPath === link.href
|
||||
)
|
||||
? "bg-custom-primary-100/10 text-custom-primary-100"
|
||||
: "text-custom-sidebar-text-200 hover:bg-custom-sidebar-background-80 focus:bg-custom-sidebar-background-80"
|
||||
}`}
|
||||
>
|
||||
{link.label}
|
||||
</div>
|
||||
</a>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -30,8 +30,7 @@ const integrationDetails: { [key: string]: any } = {
|
||||
},
|
||||
slack: {
|
||||
logo: SlackLogo,
|
||||
description:
|
||||
"Connect your slack channel to this project to get regular updates. Control which notification you want to receive.",
|
||||
description: "Get regular updates and control which notification you want to receive.",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -93,19 +92,19 @@ export const SingleIntegration: React.FC<Props> = ({ integration }) => {
|
||||
return (
|
||||
<>
|
||||
{integration && (
|
||||
<div className="flex items-center justify-between gap-2 rounded-[10px] border border-custom-border-200 bg-custom-background-100 p-5">
|
||||
<div className="flex items-center justify-between gap-2 border-b border-custom-border-200 bg-custom-background-100 px-4 py-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="h-12 w-12 flex-shrink-0">
|
||||
<div className="h-10 w-10 flex-shrink-0">
|
||||
<Image
|
||||
src={integrationDetails[integration.integration_detail.provider].logo}
|
||||
alt={`${integration.integration_detail.title} Logo`}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="flex items-center gap-4 text-xl font-semibold">
|
||||
<h3 className="flex items-center gap-4 text-sm font-medium">
|
||||
{integration.integration_detail.title}
|
||||
</h3>
|
||||
<p className="text-sm text-custom-text-200">
|
||||
<p className="text-sm text-custom-text-200 tracking-tight">
|
||||
{integrationDetails[integration.integration_detail.provider].description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
PhotoFilterOutlined,
|
||||
SettingsOutlined,
|
||||
} from "@mui/icons-material";
|
||||
import { PenSquare } from "lucide-react";
|
||||
// helpers
|
||||
import { renderEmoji } from "helpers/emoji.helper";
|
||||
// types
|
||||
@@ -288,6 +289,16 @@ export const SingleSidebarProject: React.FC<Props> = observer((props) => {
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() =>
|
||||
router.push(`/${workspaceSlug}/projects/${project?.id}/draft-issues`)
|
||||
}
|
||||
>
|
||||
<div className="flex items-center justify-start gap-2">
|
||||
<PenSquare className="!text-base !leading-4 w-[14px] h-[14px] text-custom-text-300" />
|
||||
<span>Draft Issues</span>
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() => router.push(`/${workspaceSlug}/projects/${project?.id}/settings`)}
|
||||
>
|
||||
|
||||
@@ -9,13 +9,10 @@ import stateService from "services/state.service";
|
||||
// ui
|
||||
import { Tooltip } from "components/ui";
|
||||
// icons
|
||||
import {
|
||||
ArrowDownIcon,
|
||||
ArrowUpIcon,
|
||||
PencilSquareIcon,
|
||||
TrashIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { ArrowDownIcon, ArrowUpIcon } from "@heroicons/react/24/outline";
|
||||
import { StateGroupIcon } from "components/icons";
|
||||
import { Pencil, X } from "lucide-react";
|
||||
|
||||
// helpers
|
||||
import { addSpaceIfCamelCase } from "helpers/string.helper";
|
||||
import { groupBy, orderArrayBy } from "helpers/array.helper";
|
||||
@@ -160,15 +157,15 @@ export const SingleState: React.FC<Props> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="group flex items-center justify-between gap-2 border-custom-border-200 bg-custom-background-100 p-5 first:rounded-t-[10px] last:rounded-b-[10px]">
|
||||
<div className="group flex items-center justify-between gap-2 rounded border border-custom-border-200 bg-custom-background-100 px-4 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<StateGroupIcon stateGroup={state.group} color={state.color} height="20px" width="20px" />
|
||||
<StateGroupIcon stateGroup={state.group} color={state.color} height="16px" width="16px" />
|
||||
<div>
|
||||
<h6 className="text-sm">{addSpaceIfCamelCase(state.name)}</h6>
|
||||
<h6 className="text-sm font-medium">{addSpaceIfCamelCase(state.name)}</h6>
|
||||
<p className="text-xs text-custom-text-200">{state.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="group flex items-center gap-2.5">
|
||||
{index !== 0 && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -192,37 +189,43 @@ export const SingleState: React.FC<Props> = ({
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="hidden text-xs text-custom-text-200 group-hover:inline-block"
|
||||
className="hidden text-xs text-custom-sidebar-text-400 group-hover:inline-block"
|
||||
onClick={handleMakeDefault}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Set as default
|
||||
Mark as default
|
||||
</button>
|
||||
)}
|
||||
<div className=" items-center gap-2.5 hidden group-hover:flex">
|
||||
<button
|
||||
type="button"
|
||||
className="grid place-items-center group-hover:opacity-100 opacity-0"
|
||||
onClick={handleEditState}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5 text-custom-text-200" />
|
||||
</button>
|
||||
|
||||
<button type="button" className="grid place-items-center" onClick={handleEditState}>
|
||||
<PencilSquareIcon className="h-4 w-4 text-custom-text-200" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`${
|
||||
state.default || groupLength === 1 ? "cursor-not-allowed" : ""
|
||||
} grid place-items-center`}
|
||||
onClick={handleDeleteState}
|
||||
disabled={state.default || groupLength === 1}
|
||||
>
|
||||
{state.default ? (
|
||||
<Tooltip tooltipContent="Cannot delete the default state.">
|
||||
<TrashIcon className="h-4 w-4 text-red-500" />
|
||||
</Tooltip>
|
||||
) : groupLength === 1 ? (
|
||||
<Tooltip tooltipContent="Cannot have an empty group.">
|
||||
<TrashIcon className="h-4 w-4 text-red-500" />
|
||||
</Tooltip>
|
||||
) : (
|
||||
<TrashIcon className="h-4 w-4 text-red-500" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`group-hover:opacity-100 opacity-0 ${
|
||||
state.default || groupLength === 1 ? "cursor-not-allowed" : ""
|
||||
} grid place-items-center`}
|
||||
onClick={handleDeleteState}
|
||||
disabled={state.default || groupLength === 1}
|
||||
>
|
||||
{state.default ? (
|
||||
<Tooltip tooltipContent="Cannot delete the default state.">
|
||||
<X className="h-3.5 w-3.5 text-red-500" />
|
||||
</Tooltip>
|
||||
) : groupLength === 1 ? (
|
||||
<Tooltip tooltipContent="Cannot have an empty group.">
|
||||
<X className="h-3.5 w-3.5 text-red-500" />
|
||||
</Tooltip>
|
||||
) : (
|
||||
<X className="h-3.5 w-3.5 text-red-500" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -6,8 +6,8 @@ type Props = {
|
||||
};
|
||||
|
||||
export const IntegrationAndImportExportBanner: React.FC<Props> = ({ bannerName, description }) => (
|
||||
<div className="flex flex-col items-start gap-3">
|
||||
<h3 className="text-2xl font-semibold">{bannerName}</h3>
|
||||
<div className="flex flex-col items-start gap-3 py-3.5 border-b border-custom-border-200">
|
||||
<h3 className="text-xl font-medium">{bannerName}</h3>
|
||||
{description && (
|
||||
<div className="flex items-center gap-3 rounded-[10px] border border-custom-primary/75 bg-custom-primary/5 p-4 text-sm text-custom-text-100">
|
||||
<ExclamationIcon height={24} width={24} className="fill-current text-custom-text-100" />
|
||||
|
||||
@@ -18,24 +18,24 @@ export const ToggleSwitch: React.FC<Props> = (props) => {
|
||||
disabled={disabled}
|
||||
onChange={onChange}
|
||||
className={`relative flex-shrink-0 inline-flex ${
|
||||
size === "sm" ? "h-3.5 w-6" : size === "md" ? "h-4 w-7" : "h-6 w-11"
|
||||
} flex-shrink-0 cursor-pointer rounded-full border-2 border-custom-border-200 transition-colors duration-200 ease-in-out focus:outline-none ${
|
||||
value ? "bg-green-500" : "bg-custom-background-80"
|
||||
size === "sm" ? "h-4 w-6" : size === "md" ? "h-5 w-8" : "h-6 w-10"
|
||||
} flex-shrink-0 cursor-pointer rounded-full border border-custom-border-200 transition-colors duration-200 ease-in-out focus:outline-none ${
|
||||
value ? "bg-custom-primary-100" : "bg-gray-700"
|
||||
} ${className || ""}`}
|
||||
>
|
||||
<span className="sr-only">{label}</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`inline-block ${
|
||||
size === "sm" ? "h-2.5 w-2.5" : size === "md" ? "h-3 w-3" : "h-5 w-5"
|
||||
className={`self-center inline-block ${
|
||||
size === "sm" ? "h-2 w-2" : size === "md" ? "h-3 w-3" : "h-4 w-4"
|
||||
} transform rounded-full shadow ring-0 transition duration-200 ease-in-out ${
|
||||
value
|
||||
? (size === "sm"
|
||||
? "translate-x-2.5"
|
||||
: size === "md"
|
||||
? "translate-x-3"
|
||||
: size === "md"
|
||||
? "translate-x-4"
|
||||
: "translate-x-5") + " bg-white"
|
||||
: "translate-x-0 bg-custom-background-90"
|
||||
: "translate-x-1 bg-custom-background-90"
|
||||
}`}
|
||||
/>
|
||||
</Switch>
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { FC } from "react";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
// hooks
|
||||
@@ -9,13 +7,13 @@ import { updateGanttIssue } from "components/gantt-chart/hooks/block-update";
|
||||
import useProjectDetails from "hooks/use-project-details";
|
||||
// components
|
||||
import { GanttChartRoot, renderIssueBlocksStructure } from "components/gantt-chart";
|
||||
import { IssueGanttBlock, IssueGanttSidebarBlock } from "components/issues";
|
||||
import { IssueGanttBlock, IssueGanttSidebarBlock, IssuePeekOverview } from "components/issues";
|
||||
// types
|
||||
import { IIssue } from "types";
|
||||
|
||||
type Props = {};
|
||||
type Props = { disableUserActions: boolean };
|
||||
|
||||
export const ViewIssuesGanttChartView: FC<Props> = ({}) => {
|
||||
export const ViewIssuesGanttChartView: React.FC<Props> = ({ disableUserActions }) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, viewId } = router.query;
|
||||
|
||||
@@ -31,22 +29,30 @@ export const ViewIssuesGanttChartView: FC<Props> = ({}) => {
|
||||
const isAllowed = projectDetails?.member_role === 20 || projectDetails?.member_role === 15;
|
||||
|
||||
return (
|
||||
<div className="w-full h-full">
|
||||
<GanttChartRoot
|
||||
border={false}
|
||||
title="Issues"
|
||||
loaderTitle="Issues"
|
||||
blocks={ganttIssues ? renderIssueBlocksStructure(ganttIssues as IIssue[]) : null}
|
||||
blockUpdateHandler={(block, payload) =>
|
||||
updateGanttIssue(block, payload, mutateGanttIssues, user, workspaceSlug?.toString())
|
||||
}
|
||||
SidebarBlockRender={IssueGanttSidebarBlock}
|
||||
BlockRender={IssueGanttBlock}
|
||||
enableBlockLeftResize={isAllowed}
|
||||
enableBlockRightResize={isAllowed}
|
||||
enableBlockMove={isAllowed}
|
||||
enableReorder={isAllowed}
|
||||
<>
|
||||
<IssuePeekOverview
|
||||
handleMutation={() => mutateGanttIssues()}
|
||||
projectId={projectId?.toString() ?? ""}
|
||||
workspaceSlug={workspaceSlug?.toString() ?? ""}
|
||||
readOnly={disableUserActions}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full h-full">
|
||||
<GanttChartRoot
|
||||
border={false}
|
||||
title="Issues"
|
||||
loaderTitle="Issues"
|
||||
blocks={ganttIssues ? renderIssueBlocksStructure(ganttIssues as IIssue[]) : null}
|
||||
blockUpdateHandler={(block, payload) =>
|
||||
updateGanttIssue(block, payload, mutateGanttIssues, user, workspaceSlug?.toString())
|
||||
}
|
||||
SidebarBlockRender={IssueGanttSidebarBlock}
|
||||
BlockRender={IssueGanttBlock}
|
||||
enableBlockLeftResize={isAllowed}
|
||||
enableBlockRightResize={isAllowed}
|
||||
enableBlockMove={isAllowed}
|
||||
enableReorder={isAllowed}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,9 +4,21 @@ import React, { useState } from "react";
|
||||
// next
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
// swr
|
||||
import { mutate } from "swr";
|
||||
|
||||
// react hook forms
|
||||
import { Control, Controller, useWatch } from "react-hook-form";
|
||||
|
||||
// services
|
||||
import issuesService from "services/issues.service";
|
||||
|
||||
// hooks
|
||||
import useUser from "hooks/use-user";
|
||||
|
||||
// fetch keys
|
||||
import { ISSUE_DETAILS } from "constants/fetch-keys";
|
||||
|
||||
// icons
|
||||
import { BlockedIcon, BlockerIcon } from "components/icons";
|
||||
import { ChevronDown, PlayIcon, User, X, CalendarDays, LayoutGrid, Users } from "lucide-react";
|
||||
@@ -26,6 +38,7 @@ import {
|
||||
EstimateSelect,
|
||||
ParentSelect,
|
||||
BlockerSelect,
|
||||
BlockedSelect,
|
||||
} from "components/web-view";
|
||||
|
||||
// types
|
||||
@@ -39,15 +52,16 @@ type Props = {
|
||||
export const IssuePropertiesDetail: React.FC<Props> = (props) => {
|
||||
const { control, submitChanges } = props;
|
||||
|
||||
const blockerIssue = useWatch({
|
||||
control,
|
||||
name: "blocker_issues",
|
||||
});
|
||||
const blockerIssue =
|
||||
useWatch({
|
||||
control,
|
||||
name: "issue_relations",
|
||||
})?.filter((i) => i.relation_type === "blocked_by") || [];
|
||||
|
||||
const blockedIssue = useWatch({
|
||||
control,
|
||||
name: "blocked_issues",
|
||||
});
|
||||
name: "related_issues",
|
||||
})?.filter((i) => i.relation_type === "blocked_by");
|
||||
|
||||
const startDate = useWatch({
|
||||
control,
|
||||
@@ -55,12 +69,28 @@ export const IssuePropertiesDetail: React.FC<Props> = (props) => {
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug } = router.query;
|
||||
const { workspaceSlug, projectId, issueId } = router.query;
|
||||
|
||||
const { user } = useUser();
|
||||
|
||||
const [isViewAllOpen, setIsViewAllOpen] = useState(false);
|
||||
|
||||
const { isEstimateActive } = useEstimateOption();
|
||||
|
||||
const handleMutation = (data: any) => {
|
||||
mutate<IIssue>(
|
||||
ISSUE_DETAILS(issueId as string),
|
||||
(prevData) => {
|
||||
if (!prevData) return prevData;
|
||||
return {
|
||||
...prevData,
|
||||
...data,
|
||||
};
|
||||
},
|
||||
false
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Label>Details</Label>
|
||||
@@ -188,51 +218,80 @@ export const IssuePropertiesDetail: React.FC<Props> = (props) => {
|
||||
<span className="text-sm text-custom-text-400">Blocking</span>
|
||||
</div>
|
||||
<div>
|
||||
<Controller
|
||||
control={control}
|
||||
name="blocker_issues"
|
||||
render={({ field: { value } }) => (
|
||||
<BlockerSelect
|
||||
value={value}
|
||||
onChange={(val) =>
|
||||
submitChanges({
|
||||
blocker_issues: val,
|
||||
blockers_list: val?.map((i: any) => i.blocker_issue_detail?.id ?? ""),
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<BlockerSelect
|
||||
value={null}
|
||||
onChange={(val) => {
|
||||
if (!user || !workspaceSlug || !projectId || !issueId) return;
|
||||
|
||||
issuesService
|
||||
.createIssueRelation(
|
||||
workspaceSlug as string,
|
||||
projectId as string,
|
||||
issueId as string,
|
||||
user,
|
||||
{
|
||||
related_list: [
|
||||
...val.map((issue: any) => ({
|
||||
issue: issue.blocker_issue_detail.id,
|
||||
relation_type: "blocked_by" as const,
|
||||
related_issue: issueId as string,
|
||||
related_issue_detail: issue.blocker_issue_detail,
|
||||
})),
|
||||
],
|
||||
}
|
||||
)
|
||||
.then((response) => {
|
||||
handleMutation({
|
||||
issue_relations: [
|
||||
...blockerIssue,
|
||||
...(response ?? []).map((i: any) => ({
|
||||
id: i.id,
|
||||
relation_type: i.relation_type,
|
||||
issue_detail: i.related_issue_detail,
|
||||
issue: i.related_issue,
|
||||
})),
|
||||
],
|
||||
});
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{blockerIssue &&
|
||||
blockerIssue.map((issue) => (
|
||||
<div
|
||||
key={issue.blocker_issue_detail?.id}
|
||||
key={issue.issue_detail?.id}
|
||||
className="group inline-flex mr-1 cursor-pointer items-center gap-1 rounded-2xl border border-custom-border-200 px-1.5 py-0.5 text-xs text-yellow-500 duration-300 hover:border-yellow-500/20 hover:bg-yellow-500/20"
|
||||
>
|
||||
<a
|
||||
href={`/${workspaceSlug}/projects/${issue.blocker_issue_detail?.project_detail.id}/issues/${issue.blocker_issue_detail?.id}`}
|
||||
href={`/${workspaceSlug}/projects/${issue.issue_detail?.project_detail.id}/issues/${issue.issue_detail?.id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<BlockerIcon height={10} width={10} />
|
||||
{`${issue.blocker_issue_detail?.project_detail.identifier}-${issue.blocker_issue_detail?.sequence_id}`}
|
||||
{`${issue.issue_detail?.project_detail.identifier}-${issue.issue_detail?.sequence_id}`}
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
className="duration-300"
|
||||
onClick={() => {
|
||||
const updatedBlockers = blockerIssue.filter(
|
||||
(i) => i.blocker_issue_detail?.id !== issue.blocker_issue_detail?.id
|
||||
(i) => i.issue_detail?.id !== issue.issue_detail?.id
|
||||
);
|
||||
|
||||
submitChanges({
|
||||
blocker_issues: updatedBlockers,
|
||||
blockers_list: updatedBlockers.map(
|
||||
(i) => i.blocker_issue_detail?.id ?? ""
|
||||
),
|
||||
if (!user) return;
|
||||
|
||||
issuesService.deleteIssueRelation(
|
||||
workspaceSlug as string,
|
||||
projectId as string,
|
||||
issueId as string,
|
||||
issue.id,
|
||||
user
|
||||
);
|
||||
|
||||
handleMutation({
|
||||
issue_relations: updatedBlockers,
|
||||
});
|
||||
}}
|
||||
>
|
||||
@@ -250,49 +309,80 @@ export const IssuePropertiesDetail: React.FC<Props> = (props) => {
|
||||
<span className="text-sm text-custom-text-400">Blocked by</span>
|
||||
</div>
|
||||
<div>
|
||||
<Controller
|
||||
control={control}
|
||||
name="blocked_issues"
|
||||
render={({ field: { value } }) => (
|
||||
<BlockerSelect
|
||||
value={value}
|
||||
onChange={(val) =>
|
||||
submitChanges({
|
||||
blocked_issues: val,
|
||||
blocks_list: val?.map((i: any) => i.blocker_issue_detail?.id ?? ""),
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<BlockedSelect
|
||||
value={null}
|
||||
onChange={(val) => {
|
||||
if (!user || !workspaceSlug || !projectId || !issueId) return;
|
||||
|
||||
issuesService
|
||||
.createIssueRelation(
|
||||
workspaceSlug as string,
|
||||
projectId as string,
|
||||
issueId as string,
|
||||
user,
|
||||
{
|
||||
related_list: [
|
||||
...val.map((issue: any) => ({
|
||||
issue: issue.blocked_issue_detail.id,
|
||||
relation_type: "blocked_by" as const,
|
||||
related_issue: issueId as string,
|
||||
related_issue_detail: issue.blocked_issue_detail,
|
||||
})),
|
||||
],
|
||||
}
|
||||
)
|
||||
.then((response) => {
|
||||
handleMutation({
|
||||
related_issues: [
|
||||
...blockedIssue,
|
||||
...(response ?? []).map((i: any) => ({
|
||||
id: i.id,
|
||||
relation_type: i.relation_type,
|
||||
issue_detail: i.related_issue_detail,
|
||||
issue: i.related_issue,
|
||||
})),
|
||||
],
|
||||
});
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{blockedIssue &&
|
||||
blockedIssue.map((issue) => (
|
||||
<div
|
||||
key={issue.blocked_issue_detail?.id}
|
||||
key={issue.related_issue_detail?.id}
|
||||
className="group inline-flex mr-1 cursor-pointer items-center gap-1 rounded-2xl border border-custom-border-200 px-1.5 py-0.5 text-xs text-red-500 duration-300 hover:border-red-500/20 hover:bg-red-500/20"
|
||||
>
|
||||
<a
|
||||
href={`/${workspaceSlug}/projects/${issue.blocked_issue_detail?.project_detail.id}/issues/${issue.blocked_issue_detail?.id}`}
|
||||
href={`/${workspaceSlug}/projects/${issue.related_issue_detail?.project_detail.id}/issues/${issue.related_issue_detail?.id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<BlockedIcon height={10} width={10} />
|
||||
{`${issue?.blocked_issue_detail?.project_detail?.identifier}-${issue?.blocked_issue_detail?.sequence_id}`}
|
||||
{`${issue?.related_issue_detail?.project_detail?.identifier}-${issue?.related_issue_detail?.sequence_id}`}
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
className="duration-300"
|
||||
onClick={() => {
|
||||
const updatedBlocked = blockedIssue.filter(
|
||||
(i) => i.blocked_issue_detail?.id !== issue.blocked_issue_detail?.id
|
||||
(i) => i.related_issue_detail?.id !== issue.related_issue_detail?.id
|
||||
);
|
||||
|
||||
submitChanges({
|
||||
blocked_issues: updatedBlocked,
|
||||
blocks_list: updatedBlocked.map((i) => i.blocked_issue_detail?.id ?? ""),
|
||||
if (!user) return;
|
||||
|
||||
issuesService.deleteIssueRelation(
|
||||
workspaceSlug as string,
|
||||
projectId as string,
|
||||
issueId as string,
|
||||
issue.id,
|
||||
user
|
||||
);
|
||||
|
||||
handleMutation({
|
||||
related_issues: updatedBlocked,
|
||||
});
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -66,7 +66,7 @@ export const BlockedSelect: React.FC<Props> = (props) => {
|
||||
<ExistingIssuesListModal
|
||||
isOpen={isBlockedModalOpen}
|
||||
handleClose={() => setIsBlockedModalOpen(false)}
|
||||
searchParams={{ blocker_blocked_by: true, issue_id: issueId!.toString() }}
|
||||
searchParams={{ issue_relation: true, issue_id: issueId!.toString() }}
|
||||
handleOnSubmit={onSubmit}
|
||||
workspaceLevelToggle
|
||||
/>
|
||||
|
||||
@@ -66,7 +66,7 @@ export const BlockerSelect: React.FC<Props> = (props) => {
|
||||
<ExistingIssuesListModal
|
||||
isOpen={isBlockerModalOpen}
|
||||
handleClose={() => setIsBlockerModalOpen(false)}
|
||||
searchParams={{ blocker_blocked_by: true, issue_id: issueId!.toString() }}
|
||||
searchParams={{ issue_relation: true, issue_id: issueId!.toString() }}
|
||||
handleOnSubmit={onSubmit}
|
||||
workspaceLevelToggle
|
||||
/>
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { Fragment } from "react";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
import Link from "next/link";
|
||||
|
||||
// headless ui
|
||||
import { Menu, Transition } from "@headlessui/react";
|
||||
// next-themes
|
||||
@@ -63,8 +61,6 @@ export const WorkspaceSidebarDropdown = () => {
|
||||
|
||||
const { user, mutateUser } = useUser();
|
||||
|
||||
const { collapsed: sidebarCollapse } = useThemeHook();
|
||||
|
||||
const { setTheme } = useTheme();
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
@@ -155,7 +151,7 @@ export const WorkspaceSidebarDropdown = () => {
|
||||
{workspaces.length > 0 ? (
|
||||
workspaces.map((workspace) => (
|
||||
<Menu.Item key={workspace.id}>
|
||||
{({ active }) => (
|
||||
{() => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleWorkspaceNavigation(workspace)}
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import React from "react";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
// hooks
|
||||
import useTheme from "hooks/use-theme";
|
||||
// components
|
||||
import { NotificationPopover } from "components/notifications";
|
||||
// ui
|
||||
import { Tooltip } from "components/ui";
|
||||
// icons
|
||||
import {
|
||||
|
||||
@@ -1,47 +1,142 @@
|
||||
import React from "react";
|
||||
import React, { useState } from "react";
|
||||
|
||||
// ui
|
||||
import { Icon } from "components/ui";
|
||||
import { ChevronDown, PenSquare } from "lucide-react";
|
||||
// headless ui
|
||||
import { Menu, Transition } from "@headlessui/react";
|
||||
// hooks
|
||||
import useLocalStorage from "hooks/use-local-storage";
|
||||
// components
|
||||
import { CreateUpdateDraftIssueModal } from "components/issues";
|
||||
// mobx store
|
||||
import { useMobxStore } from "lib/mobx/store-provider";
|
||||
|
||||
export const WorkspaceSidebarQuickAction = () => {
|
||||
const store: any = useMobxStore();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center justify-between w-full cursor-pointer px-4 mt-4 ${
|
||||
store?.theme?.sidebarCollapsed ? "flex-col gap-1" : "gap-2"
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
className={`flex items-center gap-2 flex-grow rounded flex-shrink-0 py-1.5 ${
|
||||
store?.theme?.sidebarCollapsed
|
||||
? "px-2 hover:bg-custom-sidebar-background-80"
|
||||
: "px-3 shadow border-[0.5px] border-custom-border-300"
|
||||
}`}
|
||||
onClick={() => {
|
||||
const e = new KeyboardEvent("keydown", { key: "c" });
|
||||
document.dispatchEvent(e);
|
||||
}}
|
||||
>
|
||||
<Icon iconName="edit_square" className="!text-lg !leading-4 text-custom-sidebar-text-300" />
|
||||
{!store?.theme?.sidebarCollapsed && <span className="text-sm font-medium">New Issue</span>}
|
||||
</button>
|
||||
const [isDraftIssueModalOpen, setIsDraftIssueModalOpen] = useState(false);
|
||||
|
||||
<button
|
||||
className={`flex items-center justify-center rounded flex-shrink-0 p-2 ${
|
||||
store?.theme?.sidebarCollapsed
|
||||
? "hover:bg-custom-sidebar-background-80"
|
||||
: "shadow border-[0.5px] border-custom-border-300"
|
||||
}`}
|
||||
onClick={() => {
|
||||
const e = new KeyboardEvent("keydown", { key: "k", ctrlKey: true, metaKey: true });
|
||||
document.dispatchEvent(e);
|
||||
const { storedValue, clearValue } = useLocalStorage<any>("draftedIssue", null);
|
||||
|
||||
return (
|
||||
<>
|
||||
<CreateUpdateDraftIssueModal
|
||||
isOpen={isDraftIssueModalOpen}
|
||||
handleClose={() => setIsDraftIssueModalOpen(false)}
|
||||
prePopulateData={storedValue ? JSON.parse(storedValue) : {}}
|
||||
onSubmit={() => {
|
||||
localStorage.removeItem("draftedIssue");
|
||||
clearValue();
|
||||
setIsDraftIssueModalOpen(false);
|
||||
}}
|
||||
fieldsToShow={[
|
||||
"name",
|
||||
"description",
|
||||
"label",
|
||||
"assignee",
|
||||
"priority",
|
||||
"dueDate",
|
||||
"priority",
|
||||
]}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={`relative flex items-center justify-between w-full cursor-pointer px-4 mt-4 ${
|
||||
store?.theme?.sidebarCollapsed ? "flex-col gap-1" : "gap-2"
|
||||
}`}
|
||||
>
|
||||
<Icon iconName="search" className="!text-lg !leading-4 text-custom-sidebar-text-300" />
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
className={`flex items-center justify-between w-full rounded cursor-pointer px-4 gap-1 ${
|
||||
store?.theme?.sidebarCollapsed
|
||||
? "px-2 hover:bg-custom-sidebar-background-80"
|
||||
: "px-3 shadow border-[0.5px] border-custom-border-300"
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 flex-grow rounded flex-shrink-0 py-1.5"
|
||||
onClick={() => {
|
||||
const e = new KeyboardEvent("keydown", { key: "c" });
|
||||
document.dispatchEvent(e);
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
iconName="edit_square"
|
||||
className="!text-lg !leading-4 text-custom-sidebar-text-300"
|
||||
/>
|
||||
{!store?.theme?.sidebarCollapsed && (
|
||||
<span className="text-sm font-medium">New Issue</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{storedValue && <div className="h-8 w-0.5 bg-custom-sidebar-background-80" />}
|
||||
|
||||
{storedValue && (
|
||||
<div className="relative">
|
||||
<Menu as={React.Fragment}>
|
||||
{({ open }) => (
|
||||
<>
|
||||
<div>
|
||||
<Menu.Button
|
||||
type="button"
|
||||
className={`flex items-center justify-center rounded flex-shrink-0 p-2 ${
|
||||
open ? "rotate-180 pl-0" : "rotate-0 pr-0"
|
||||
}`}
|
||||
>
|
||||
<ChevronDown
|
||||
size={16}
|
||||
className="!text-custom-sidebar-text-300 transform transition-transform duration-300"
|
||||
/>
|
||||
</Menu.Button>
|
||||
</div>
|
||||
<Transition
|
||||
as={React.Fragment}
|
||||
enter="transition ease-out duration-100"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<Menu.Items className="absolute -right-4 mt-1 w-52 bg-custom-background-300">
|
||||
<div className="px-1 py-1 ">
|
||||
<Menu.Item>
|
||||
<button
|
||||
onClick={() => setIsDraftIssueModalOpen(true)}
|
||||
className="w-full flex text-sm items-center rounded flex-shrink-0 py-[10px] px-3 bg-custom-background-100 shadow border-[0.5px] border-custom-border-300 text-custom-text-300"
|
||||
>
|
||||
<PenSquare
|
||||
size={16}
|
||||
className="!text-lg !leading-4 text-custom-sidebar-text-300 mx-2"
|
||||
/>
|
||||
Last Drafted Issue
|
||||
</button>
|
||||
</Menu.Item>
|
||||
</div>
|
||||
</Menu.Items>
|
||||
</Transition>
|
||||
</>
|
||||
)}
|
||||
</Menu>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
className={`flex items-center justify-center rounded flex-shrink-0 p-2 ${
|
||||
store?.theme?.sidebarCollapsed
|
||||
? "hover:bg-custom-sidebar-background-80"
|
||||
: "shadow border-[0.5px] border-custom-border-300"
|
||||
}`}
|
||||
onClick={() => {
|
||||
const e = new KeyboardEvent("keydown", { key: "k", ctrlKey: true, metaKey: true });
|
||||
document.dispatchEvent(e);
|
||||
}}
|
||||
>
|
||||
<Icon iconName="search" className="!text-lg !leading-4 text-custom-sidebar-text-300" />
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -52,7 +52,7 @@ const SingleInvitation: React.FC<Props> = ({
|
||||
? "bg-custom-background-80 text-custom-text-200"
|
||||
: "bg-custom-primary text-white"
|
||||
} text-sm px-4 py-2 border border-custom-border-200 rounded-3xl`}
|
||||
onClick={(e) => {
|
||||
onClick={() => {
|
||||
handleInvitation(
|
||||
invitation,
|
||||
invitationsRespond.includes(invitation.id) ? "withdraw" : "accepted"
|
||||
|
||||
@@ -140,6 +140,15 @@ export const PROJECT_ARCHIVED_ISSUES_LIST_WITH_PARAMS = (projectId: string, para
|
||||
|
||||
return `PROJECT_ARCHIVED_ISSUES_LIST_WITH_PARAMS_${projectId.toUpperCase()}_${paramsKey}`;
|
||||
};
|
||||
|
||||
export const PROJECT_DRAFT_ISSUES_LIST_WITH_PARAMS = (projectId: string, params?: any) => {
|
||||
if (!params) return `PROJECT_DRAFT_ISSUES_LIST_WITH_PARAMS${projectId.toUpperCase()}`;
|
||||
|
||||
const paramsKey = paramsToKey(params);
|
||||
|
||||
return `PROJECT_DRAFT_ISSUES_LIST_WITH_PARAMS${projectId.toUpperCase()}_${paramsKey}`;
|
||||
};
|
||||
|
||||
export const PROJECT_ISSUES_DETAILS = (issueId: string) =>
|
||||
`PROJECT_ISSUES_DETAILS_${issueId.toUpperCase()}`;
|
||||
export const PROJECT_ISSUES_PROPERTIES = (projectId: string) =>
|
||||
|
||||
@@ -16,3 +16,7 @@ export const debounce = (func: any, wait: number, immediate: boolean = false) =>
|
||||
if (callNow) func(...args);
|
||||
};
|
||||
};
|
||||
|
||||
export const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL
|
||||
? process.env.NEXT_PUBLIC_API_BASE_URL
|
||||
: "";
|
||||
|
||||
@@ -44,7 +44,7 @@ const useCalendarIssuesView = () => {
|
||||
target_date: displayFilters?.calendar_date_range,
|
||||
};
|
||||
|
||||
const { data: projectCalendarIssues } = useSWR(
|
||||
const { data: projectCalendarIssues, mutate: mutateProjectCalendarIssues } = useSWR(
|
||||
workspaceSlug && projectId
|
||||
? PROJECT_ISSUES_LIST_WITH_PARAMS(projectId.toString(), params)
|
||||
: null,
|
||||
@@ -54,7 +54,7 @@ const useCalendarIssuesView = () => {
|
||||
: null
|
||||
);
|
||||
|
||||
const { data: cycleCalendarIssues } = useSWR(
|
||||
const { data: cycleCalendarIssues, mutate: mutateCycleCalendarIssues } = useSWR(
|
||||
workspaceSlug && projectId && cycleId
|
||||
? CYCLE_ISSUES_WITH_PARAMS(cycleId.toString(), params)
|
||||
: null,
|
||||
@@ -69,7 +69,7 @@ const useCalendarIssuesView = () => {
|
||||
: null
|
||||
);
|
||||
|
||||
const { data: moduleCalendarIssues } = useSWR(
|
||||
const { data: moduleCalendarIssues, mutate: mutateModuleCalendarIssues } = useSWR(
|
||||
workspaceSlug && projectId && moduleId
|
||||
? MODULE_ISSUES_WITH_PARAMS(moduleId.toString(), params)
|
||||
: null,
|
||||
@@ -84,7 +84,7 @@ const useCalendarIssuesView = () => {
|
||||
: null
|
||||
);
|
||||
|
||||
const { data: viewCalendarIssues } = useSWR(
|
||||
const { data: viewCalendarIssues, mutate: mutateViewCalendarIssues } = useSWR(
|
||||
workspaceSlug && projectId && viewId && params ? VIEW_ISSUES(viewId.toString(), params) : null,
|
||||
workspaceSlug && projectId && viewId && params
|
||||
? () =>
|
||||
@@ -104,6 +104,13 @@ const useCalendarIssuesView = () => {
|
||||
displayFilters,
|
||||
setDisplayFilters,
|
||||
calendarIssues: calendarIssues ?? [],
|
||||
mutateIssues: cycleId
|
||||
? mutateCycleCalendarIssues
|
||||
: moduleId
|
||||
? mutateModuleCalendarIssues
|
||||
: viewId
|
||||
? mutateViewCalendarIssues
|
||||
: mutateProjectCalendarIssues,
|
||||
filters,
|
||||
setFilters,
|
||||
params,
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
CYCLE_ISSUES_WITH_PARAMS,
|
||||
MODULE_ISSUES_WITH_PARAMS,
|
||||
PROJECT_ARCHIVED_ISSUES_LIST_WITH_PARAMS,
|
||||
PROJECT_DRAFT_ISSUES_LIST_WITH_PARAMS,
|
||||
PROJECT_ISSUES_LIST_WITH_PARAMS,
|
||||
STATES_LIST,
|
||||
VIEW_ISSUES,
|
||||
@@ -38,6 +39,7 @@ const useIssuesView = () => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, cycleId, moduleId, viewId, archivedIssueId } = router.query;
|
||||
const isArchivedIssues = router.pathname.includes("archived-issues");
|
||||
const isDraftIssues = router.pathname.includes("draft-issues");
|
||||
|
||||
const params: any = {
|
||||
order_by: displayFilters?.order_by,
|
||||
@@ -72,6 +74,15 @@ const useIssuesView = () => {
|
||||
: null
|
||||
);
|
||||
|
||||
const { data: draftIssues, mutate: mutateDraftIssues } = useSWR(
|
||||
workspaceSlug && projectId && params && isDraftIssues && !archivedIssueId
|
||||
? PROJECT_DRAFT_ISSUES_LIST_WITH_PARAMS(projectId as string, params)
|
||||
: null,
|
||||
workspaceSlug && projectId && params && isDraftIssues && !archivedIssueId
|
||||
? () => issuesService.getDraftIssues(workspaceSlug as string, projectId as string, params)
|
||||
: null
|
||||
);
|
||||
|
||||
const { data: cycleIssues, mutate: mutateCycleIssues } = useSWR(
|
||||
workspaceSlug && projectId && cycleId && params
|
||||
? CYCLE_ISSUES_WITH_PARAMS(cycleId as string, params)
|
||||
@@ -151,6 +162,8 @@ const useIssuesView = () => {
|
||||
? viewIssues
|
||||
: isArchivedIssues
|
||||
? projectArchivedIssues
|
||||
: isDraftIssues
|
||||
? draftIssues
|
||||
: projectIssues;
|
||||
|
||||
if (Array.isArray(issuesToGroup)) return { allIssues: issuesToGroup };
|
||||
@@ -169,6 +182,8 @@ const useIssuesView = () => {
|
||||
moduleId,
|
||||
viewId,
|
||||
isArchivedIssues,
|
||||
isDraftIssues,
|
||||
draftIssues,
|
||||
emptyStatesObject,
|
||||
]);
|
||||
|
||||
@@ -191,6 +206,8 @@ const useIssuesView = () => {
|
||||
? mutateViewIssues
|
||||
: isArchivedIssues
|
||||
? mutateProjectArchivedIssues
|
||||
: isDraftIssues
|
||||
? mutateDraftIssues
|
||||
: mutateProjectIssues,
|
||||
filters,
|
||||
setFilters,
|
||||
|
||||
@@ -17,7 +17,11 @@ type Props = {
|
||||
};
|
||||
|
||||
const { NEXT_PUBLIC_DEPLOY_URL } = process.env;
|
||||
const plane_deploy_url = NEXT_PUBLIC_DEPLOY_URL ? NEXT_PUBLIC_DEPLOY_URL : "http://localhost:3001";
|
||||
let plane_deploy_url = NEXT_PUBLIC_DEPLOY_URL
|
||||
|
||||
if (typeof window !== 'undefined' && !plane_deploy_url) {
|
||||
plane_deploy_url= window.location.protocol + "//" + window.location.host + "/spaces";
|
||||
}
|
||||
|
||||
const Header: React.FC<Props> = ({ breadcrumbs, left, right, setToggleSidebar, noHeader }) => {
|
||||
const { projectDetails } = useProjectDetails();
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
import { convertCookieStringToObject } from "./cookie";
|
||||
// types
|
||||
import type { IProjectMember, IUser, IWorkspace, IWorkspaceMember } from "types";
|
||||
// helper
|
||||
import { API_BASE_URL } from "helpers/common.helper";
|
||||
|
||||
export const requiredAuth = async (cookie?: string) => {
|
||||
const cookies = convertCookieStringToObject(cookie);
|
||||
@@ -9,12 +11,10 @@ export const requiredAuth = async (cookie?: string) => {
|
||||
|
||||
if (!token) return null;
|
||||
|
||||
const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL || "https://api.plane.so";
|
||||
|
||||
let user: IUser | null = null;
|
||||
|
||||
try {
|
||||
const data = await fetch(`${baseUrl}/api/users/me/`, {
|
||||
const data = await fetch(`${API_BASE_URL}/api/users/me/`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
@@ -41,13 +41,11 @@ export const requiredAdmin = async (workspaceSlug: string, projectId: string, co
|
||||
const cookies = convertCookieStringToObject(cookie);
|
||||
const token = cookies?.accessToken;
|
||||
|
||||
const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL || "https://api.plane.so";
|
||||
|
||||
let memberDetail: IProjectMember | null = null;
|
||||
|
||||
try {
|
||||
const data = await fetch(
|
||||
`${baseUrl}/api/workspaces/${workspaceSlug}/projects/${projectId}/project-members/me/`,
|
||||
`${API_BASE_URL}/api/workspaces/${workspaceSlug}/projects/${projectId}/project-members/me/`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
@@ -75,17 +73,18 @@ export const requiredWorkspaceAdmin = async (workspaceSlug: string, cookie?: str
|
||||
const cookies = convertCookieStringToObject(cookie);
|
||||
const token = cookies?.accessToken;
|
||||
|
||||
const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL || "https://api.plane.so";
|
||||
|
||||
let memberDetail: IWorkspaceMember | null = null;
|
||||
|
||||
try {
|
||||
const data = await fetch(`${baseUrl}/api/workspaces/${workspaceSlug}/workspace-members/me/`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
})
|
||||
const data = await fetch(
|
||||
`${API_BASE_URL}/api/workspaces/${workspaceSlug}/workspace-members/me/`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
}
|
||||
)
|
||||
.then((res) => res.json())
|
||||
.then((data) => data);
|
||||
|
||||
@@ -119,13 +118,11 @@ export const homePageRedirect = async (cookie?: string) => {
|
||||
|
||||
let workspaces: IWorkspace[] = [];
|
||||
|
||||
const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL || "https://api.plane.so";
|
||||
|
||||
const cookies = convertCookieStringToObject(cookie);
|
||||
const token = cookies?.accessToken;
|
||||
|
||||
try {
|
||||
const data = await fetch(`${baseUrl}/api/users/me/workspaces/`, {
|
||||
const data = await fetch(`${API_BASE_URL}/api/users/me/workspaces/`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
@@ -166,7 +163,7 @@ export const homePageRedirect = async (cookie?: string) => {
|
||||
};
|
||||
}
|
||||
|
||||
const invitations = await fetch(`${baseUrl}/api/users/me/invitations/workspaces/`, {
|
||||
const invitations = await fetch(`${API_BASE_URL}/api/users/me/invitations/workspaces/`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
|
||||
@@ -3,8 +3,6 @@ import { useEffect } from "react";
|
||||
import { useTheme } from "next-themes";
|
||||
// mobx store
|
||||
import { useMobxStore } from "lib/mobx/store-provider";
|
||||
// theme helpers
|
||||
import { applyTheme, unsetCustomCssVariables } from "helpers/theme.helper";
|
||||
|
||||
const MobxStoreInit = () => {
|
||||
const store: any = useMobxStore();
|
||||
|
||||
@@ -2,6 +2,7 @@ require("dotenv").config({ path: ".env" });
|
||||
|
||||
const { withSentryConfig } = require("@sentry/nextjs");
|
||||
const path = require("path");
|
||||
|
||||
const extraImageDomains = (process.env.NEXT_PUBLIC_EXTRA_IMAGE_DOMAINS ?? "")
|
||||
.split(",")
|
||||
.filter((domain) => domain.length > 0);
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
"js-cookie": "^3.0.1",
|
||||
"lodash.debounce": "^4.0.8",
|
||||
"lowlight": "^2.9.0",
|
||||
"lucide-react": "^0.263.1",
|
||||
"lucide-react": "^0.269.0",
|
||||
"mobx": "^6.10.0",
|
||||
"mobx-react-lite": "^4.0.3",
|
||||
"next": "12.3.2",
|
||||
@@ -97,8 +97,8 @@
|
||||
"eslint-config-custom": "*",
|
||||
"eslint-config-next": "12.2.2",
|
||||
"prettier": "^2.8.7",
|
||||
"tsconfig": "*",
|
||||
"tailwind-config-custom": "*",
|
||||
"tsconfig": "*",
|
||||
"typescript": "4.7.4"
|
||||
},
|
||||
"resolutions": {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user