Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b9975dfa24 | |||
| 380ad340a6 |
@@ -21,8 +21,6 @@ NEXT_PUBLIC_TRACK_EVENTS=0
|
||||
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=""
|
||||
|
||||
# Backend
|
||||
# Debug value for api server use it as 0 for production use
|
||||
|
||||
+1
-3
@@ -70,6 +70,4 @@ package-lock.json
|
||||
# lock files
|
||||
package-lock.json
|
||||
pnpm-lock.yaml
|
||||
pnpm-workspace.yaml
|
||||
|
||||
.npmrc
|
||||
pnpm-workspace.yaml
|
||||
+3
-10
@@ -5,11 +5,9 @@ WORKDIR /app
|
||||
ENV NEXT_PUBLIC_API_BASE_URL=http://NEXT_PUBLIC_API_BASE_URL_PLACEHOLDER
|
||||
|
||||
RUN yarn global add turbo
|
||||
RUN apk add tree
|
||||
COPY . .
|
||||
|
||||
RUN turbo prune --scope=app --scope=plane-deploy --docker
|
||||
CMD tree -I node_modules/
|
||||
RUN turbo prune --scope=app --docker
|
||||
|
||||
# Add lockfile and package.json's of isolated subworkspace
|
||||
FROM node:18-alpine AS installer
|
||||
@@ -23,14 +21,14 @@ COPY --from=builder /app/out/json/ .
|
||||
COPY --from=builder /app/out/yarn.lock ./yarn.lock
|
||||
RUN yarn install
|
||||
|
||||
# # Build the project
|
||||
# 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
|
||||
|
||||
RUN yarn turbo run build
|
||||
RUN yarn turbo run build --filter=app
|
||||
|
||||
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL \
|
||||
BUILT_NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
|
||||
@@ -98,16 +96,11 @@ RUN adduser --system --uid 1001 captain
|
||||
|
||||
COPY --from=installer /app/apps/app/next.config.js .
|
||||
COPY --from=installer /app/apps/app/package.json .
|
||||
COPY --from=installer /app/apps/space/next.config.js .
|
||||
COPY --from=installer /app/apps/space/package.json .
|
||||
|
||||
COPY --from=installer --chown=captain:plane /app/apps/app/.next/standalone ./
|
||||
|
||||
COPY --from=installer --chown=captain:plane /app/apps/app/.next/static ./apps/app/.next/static
|
||||
|
||||
COPY --from=installer --chown=captain:plane /app/apps/space/.next/standalone ./
|
||||
COPY --from=installer --chown=captain:plane /app/apps/space/.next ./apps/space/.next
|
||||
|
||||
ENV NEXT_TELEMETRY_DISABLED 1
|
||||
|
||||
# RUN rm /etc/nginx/conf.d/default.conf
|
||||
|
||||
@@ -61,16 +61,6 @@ chmod +x setup.sh
|
||||
|
||||
> If running in a cloud env replace localhost with public facing IP address of the VM
|
||||
|
||||
- Setup Tiptap Pro
|
||||
|
||||
Visit [Tiptap Pro](https://collab.tiptap.dev/pro-extensions) and signup (it is free).
|
||||
|
||||
Create a **`.npmrc`** file, copy the following and replace your registry token generated from Tiptap Pro.
|
||||
|
||||
```
|
||||
@tiptap-pro:registry=https://registry.tiptap.dev/
|
||||
//registry.tiptap.dev/:_authToken=YOUR_REGISTRY_TOKEN
|
||||
```
|
||||
- Run Docker compose up
|
||||
|
||||
```bash
|
||||
|
||||
@@ -18,8 +18,6 @@ from .project import (
|
||||
ProjectFavoriteSerializer,
|
||||
ProjectLiteSerializer,
|
||||
ProjectMemberLiteSerializer,
|
||||
ProjectDeployBoardSerializer,
|
||||
ProjectMemberAdminSerializer,
|
||||
)
|
||||
from .state import StateSerializer, StateLiteSerializer
|
||||
from .view import IssueViewSerializer, IssueViewFavoriteSerializer
|
||||
@@ -43,7 +41,6 @@ from .issue import (
|
||||
IssueSubscriberSerializer,
|
||||
IssueReactionSerializer,
|
||||
CommentReactionSerializer,
|
||||
IssueVoteSerializer,
|
||||
)
|
||||
|
||||
from .module import (
|
||||
@@ -81,5 +78,3 @@ from .inbox import InboxSerializer, InboxIssueSerializer, IssueStateInboxSeriali
|
||||
from .analytic import AnalyticViewSerializer
|
||||
|
||||
from .notification import NotificationSerializer
|
||||
|
||||
from .exporter import ExporterHistorySerializer
|
||||
|
||||
@@ -41,7 +41,6 @@ class CycleSerializer(BaseSerializer):
|
||||
{
|
||||
"avatar": assignee.avatar,
|
||||
"first_name": assignee.first_name,
|
||||
"display_name": assignee.display_name,
|
||||
"id": assignee.id,
|
||||
}
|
||||
for issue_cycle in obj.issue_cycle.all()
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
# Module imports
|
||||
from .base import BaseSerializer
|
||||
from plane.db.models import ExporterHistory
|
||||
from .user import UserLiteSerializer
|
||||
|
||||
|
||||
class ExporterHistorySerializer(BaseSerializer):
|
||||
initiated_by_detail = UserLiteSerializer(source="initiated_by", read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = ExporterHistory
|
||||
fields = [
|
||||
"id",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"project",
|
||||
"provider",
|
||||
"status",
|
||||
"url",
|
||||
"initiated_by",
|
||||
"initiated_by_detail",
|
||||
"token",
|
||||
"created_by",
|
||||
"updated_by",
|
||||
]
|
||||
read_only_fields = fields
|
||||
@@ -31,7 +31,6 @@ from plane.db.models import (
|
||||
IssueAttachment,
|
||||
IssueReaction,
|
||||
CommentReaction,
|
||||
IssueVote,
|
||||
)
|
||||
|
||||
|
||||
@@ -112,11 +111,6 @@ class IssueCreateSerializer(BaseSerializer):
|
||||
"updated_at",
|
||||
]
|
||||
|
||||
def validate(self, data):
|
||||
if data.get("start_date", None) is not None and data.get("target_date", None) is not None and data.get("start_date", None) > data.get("target_date", None):
|
||||
raise serializers.ValidationError("Start date cannot exceed target date")
|
||||
return data
|
||||
|
||||
def create(self, validated_data):
|
||||
blockers = validated_data.pop("blockers_list", None)
|
||||
assignees = validated_data.pop("assignees_list", None)
|
||||
@@ -555,14 +549,6 @@ class CommentReactionSerializer(BaseSerializer):
|
||||
|
||||
|
||||
|
||||
class IssueVoteSerializer(BaseSerializer):
|
||||
|
||||
class Meta:
|
||||
model = IssueVote
|
||||
fields = ["issue", "vote", "workspace_id", "project_id", "actor"]
|
||||
read_only_fields = fields
|
||||
|
||||
|
||||
class IssueCommentSerializer(BaseSerializer):
|
||||
actor_detail = UserLiteSerializer(read_only=True, source="actor")
|
||||
issue_detail = IssueFlatSerializer(read_only=True, source="issue")
|
||||
@@ -582,7 +568,6 @@ class IssueCommentSerializer(BaseSerializer):
|
||||
"updated_by",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"access",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ from plane.db.models import (
|
||||
ProjectMemberInvite,
|
||||
ProjectIdentifier,
|
||||
ProjectFavorite,
|
||||
ProjectDeployBoard,
|
||||
)
|
||||
|
||||
|
||||
@@ -81,15 +80,7 @@ class ProjectSerializer(BaseSerializer):
|
||||
class ProjectLiteSerializer(BaseSerializer):
|
||||
class Meta:
|
||||
model = Project
|
||||
fields = [
|
||||
"id",
|
||||
"identifier",
|
||||
"name",
|
||||
"cover_image",
|
||||
"icon_prop",
|
||||
"emoji",
|
||||
"description",
|
||||
]
|
||||
fields = ["id", "identifier", "name"]
|
||||
read_only_fields = fields
|
||||
|
||||
|
||||
@@ -103,8 +94,6 @@ class ProjectDetailSerializer(BaseSerializer):
|
||||
total_modules = serializers.IntegerField(read_only=True)
|
||||
is_member = serializers.BooleanField(read_only=True)
|
||||
sort_order = serializers.FloatField(read_only=True)
|
||||
member_role = serializers.IntegerField(read_only=True)
|
||||
is_deployed = serializers.BooleanField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Project
|
||||
@@ -126,6 +115,7 @@ class ProjectMemberAdminSerializer(BaseSerializer):
|
||||
project = ProjectLiteSerializer(read_only=True)
|
||||
member = UserAdminLiteSerializer(read_only=True)
|
||||
|
||||
|
||||
class Meta:
|
||||
model = ProjectMember
|
||||
fields = "__all__"
|
||||
@@ -158,6 +148,8 @@ class ProjectFavoriteSerializer(BaseSerializer):
|
||||
]
|
||||
|
||||
|
||||
|
||||
|
||||
class ProjectMemberLiteSerializer(BaseSerializer):
|
||||
member = UserLiteSerializer(read_only=True)
|
||||
is_subscribed = serializers.BooleanField(read_only=True)
|
||||
@@ -166,16 +158,3 @@ class ProjectMemberLiteSerializer(BaseSerializer):
|
||||
model = ProjectMember
|
||||
fields = ["member", "id", "is_subscribed"]
|
||||
read_only_fields = fields
|
||||
|
||||
|
||||
class ProjectDeployBoardSerializer(BaseSerializer):
|
||||
project_details = ProjectLiteSerializer(read_only=True, source="project")
|
||||
workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace")
|
||||
|
||||
class Meta:
|
||||
model = ProjectDeployBoard
|
||||
fields = "__all__"
|
||||
read_only_fields = [
|
||||
"workspace",
|
||||
"project" "anchor",
|
||||
]
|
||||
|
||||
@@ -86,7 +86,6 @@ from plane.api.views import (
|
||||
IssueAttachmentEndpoint,
|
||||
IssueArchiveViewSet,
|
||||
IssueSubscriberViewSet,
|
||||
IssueCommentPublicViewSet,
|
||||
IssueReactionViewSet,
|
||||
CommentReactionViewSet,
|
||||
ExportIssuesEndpoint,
|
||||
@@ -166,15 +165,6 @@ from plane.api.views import (
|
||||
NotificationViewSet,
|
||||
UnreadNotificationEndpoint,
|
||||
## End Notification
|
||||
# Public Boards
|
||||
ProjectDeployBoardViewSet,
|
||||
ProjectDeployBoardIssuesPublicEndpoint,
|
||||
ProjectDeployBoardPublicSettingsEndpoint,
|
||||
IssueReactionPublicViewSet,
|
||||
CommentReactionPublicViewSet,
|
||||
InboxIssuePublicViewSet,
|
||||
IssueVotePublicViewSet,
|
||||
## End Public Boards
|
||||
)
|
||||
|
||||
|
||||
@@ -1491,128 +1481,4 @@ urlpatterns = [
|
||||
name="unread-notifications",
|
||||
),
|
||||
## End Notification
|
||||
# Public Boards
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/project-deploy-boards/",
|
||||
ProjectDeployBoardViewSet.as_view(
|
||||
{
|
||||
"get": "list",
|
||||
"post": "create",
|
||||
}
|
||||
),
|
||||
name="project-deploy-board",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/project-deploy-boards/<uuid:pk>/",
|
||||
ProjectDeployBoardViewSet.as_view(
|
||||
{
|
||||
"get": "retrieve",
|
||||
"patch": "partial_update",
|
||||
"delete": "destroy",
|
||||
}
|
||||
),
|
||||
name="project-deploy-board",
|
||||
),
|
||||
path(
|
||||
"public/workspaces/<str:slug>/project-boards/<uuid:project_id>/settings/",
|
||||
ProjectDeployBoardPublicSettingsEndpoint.as_view(),
|
||||
name="project-deploy-board-settings",
|
||||
),
|
||||
path(
|
||||
"public/workspaces/<str:slug>/project-boards/<uuid:project_id>/issues/",
|
||||
ProjectDeployBoardIssuesPublicEndpoint.as_view(),
|
||||
name="project-deploy-board",
|
||||
),
|
||||
path(
|
||||
"public/workspaces/<str:slug>/project-boards/<uuid:project_id>/issues/<uuid:issue_id>/comments/",
|
||||
IssueCommentPublicViewSet.as_view(
|
||||
{
|
||||
"get": "list",
|
||||
"post": "create",
|
||||
}
|
||||
),
|
||||
name="issue-comments-project-board",
|
||||
),
|
||||
path(
|
||||
"public/workspaces/<str:slug>/project-boards/<uuid:project_id>/issues/<uuid:issue_id>/comments/<uuid:pk>/",
|
||||
IssueCommentPublicViewSet.as_view(
|
||||
{
|
||||
"get": "retrieve",
|
||||
"patch": "partial_update",
|
||||
"delete": "destroy",
|
||||
}
|
||||
),
|
||||
name="issue-comments-project-board",
|
||||
),
|
||||
path(
|
||||
"public/workspaces/<str:slug>/project-boards/<uuid:project_id>/issues/<uuid:issue_id>/reactions/",
|
||||
IssueReactionPublicViewSet.as_view(
|
||||
{
|
||||
"get": "list",
|
||||
"post": "create",
|
||||
}
|
||||
),
|
||||
name="issue-reactions-project-board",
|
||||
),
|
||||
path(
|
||||
"public/workspaces/<str:slug>/project-boards/<uuid:project_id>/issues/<uuid:issue_id>/reactions/<str:reaction_code>/",
|
||||
IssueReactionPublicViewSet.as_view(
|
||||
{
|
||||
"delete": "destroy",
|
||||
}
|
||||
),
|
||||
name="issue-reactions-project-board",
|
||||
),
|
||||
path(
|
||||
"public/workspaces/<str:slug>/project-boards/<uuid:project_id>/comments/<uuid:comment_id>/reactions/",
|
||||
CommentReactionPublicViewSet.as_view(
|
||||
{
|
||||
"get": "list",
|
||||
"post": "create",
|
||||
}
|
||||
),
|
||||
name="comment-reactions-project-board",
|
||||
),
|
||||
path(
|
||||
"public/workspaces/<str:slug>/project-boards/<uuid:project_id>/comments/<uuid:comment_id>/reactions/<str:reaction_code>/",
|
||||
CommentReactionPublicViewSet.as_view(
|
||||
{
|
||||
"delete": "destroy",
|
||||
}
|
||||
),
|
||||
name="comment-reactions-project-board",
|
||||
),
|
||||
path(
|
||||
"public/workspaces/<str:slug>/project-boards/<uuid:project_id>/inboxes/<uuid:inbox_id>/inbox-issues/",
|
||||
InboxIssuePublicViewSet.as_view(
|
||||
{
|
||||
"get": "list",
|
||||
"post": "create",
|
||||
}
|
||||
),
|
||||
name="inbox-issue",
|
||||
),
|
||||
path(
|
||||
"public/workspaces/<str:slug>/project-boards/<uuid:project_id>/inboxes/<uuid:inbox_id>/inbox-issues/<uuid:pk>/",
|
||||
InboxIssuePublicViewSet.as_view(
|
||||
{
|
||||
"get": "retrieve",
|
||||
"patch": "partial_update",
|
||||
"delete": "destroy",
|
||||
}
|
||||
),
|
||||
name="inbox-issue",
|
||||
),
|
||||
path(
|
||||
"public/workspaces/<str:slug>/project-boards/<uuid:project_id>/issues/<uuid:issue_id>/votes/",
|
||||
IssueVotePublicViewSet.as_view(
|
||||
{
|
||||
"get": "list",
|
||||
"post": "create",
|
||||
"delete": "destroy",
|
||||
}
|
||||
),
|
||||
name="issue-vote-project-board",
|
||||
),
|
||||
## End Public Boards
|
||||
]
|
||||
|
||||
@@ -12,9 +12,6 @@ from .project import (
|
||||
ProjectUserViewsEndpoint,
|
||||
ProjectMemberUserEndpoint,
|
||||
ProjectFavoritesViewSet,
|
||||
ProjectDeployBoardIssuesPublicEndpoint,
|
||||
ProjectDeployBoardViewSet,
|
||||
ProjectDeployBoardPublicSettingsEndpoint,
|
||||
ProjectMemberEndpoint,
|
||||
)
|
||||
from .user import (
|
||||
@@ -78,12 +75,9 @@ from .issue import (
|
||||
IssueAttachmentEndpoint,
|
||||
IssueArchiveViewSet,
|
||||
IssueSubscriberViewSet,
|
||||
IssueCommentPublicViewSet,
|
||||
CommentReactionViewSet,
|
||||
IssueReactionViewSet,
|
||||
IssueReactionPublicViewSet,
|
||||
CommentReactionPublicViewSet,
|
||||
IssueVotePublicViewSet,
|
||||
ExportIssuesEndpoint
|
||||
)
|
||||
|
||||
from .auth_extended import (
|
||||
@@ -151,7 +145,7 @@ from .estimate import (
|
||||
|
||||
from .release import ReleaseNotesEndpoint
|
||||
|
||||
from .inbox import InboxViewSet, InboxIssueViewSet, InboxIssuePublicViewSet
|
||||
from .inbox import InboxViewSet, InboxIssueViewSet
|
||||
|
||||
from .analytic import (
|
||||
AnalyticsEndpoint,
|
||||
@@ -161,8 +155,4 @@ from .analytic import (
|
||||
DefaultAnalyticsEndpoint,
|
||||
)
|
||||
|
||||
from .notification import NotificationViewSet, UnreadNotificationEndpoint
|
||||
|
||||
from .exporter import (
|
||||
ExportIssuesEndpoint,
|
||||
)
|
||||
from .notification import NotificationViewSet, UnreadNotificationEndpoint
|
||||
@@ -243,21 +243,21 @@ class DefaultAnalyticsEndpoint(BaseAPIView):
|
||||
)
|
||||
most_issue_created_user = (
|
||||
queryset.exclude(created_by=None)
|
||||
.values("created_by__first_name", "created_by__last_name", "created_by__avatar", "created_by__display_name", "created_by__id")
|
||||
.values("created_by__first_name", "created_by__last_name", "created_by__avatar", "created_by__display_name")
|
||||
.annotate(count=Count("id"))
|
||||
.order_by("-count")
|
||||
)[:5]
|
||||
|
||||
most_issue_closed_user = (
|
||||
queryset.filter(completed_at__isnull=False, assignees__isnull=False)
|
||||
.values("assignees__first_name", "assignees__last_name", "assignees__avatar", "assignees__display_name", "assignees__id")
|
||||
.values("assignees__first_name", "assignees__last_name", "assignees__avatar", "assignees__display_name")
|
||||
.annotate(count=Count("id"))
|
||||
.order_by("-count")
|
||||
)[:5]
|
||||
|
||||
pending_issue_user = (
|
||||
queryset.filter(completed_at__isnull=True)
|
||||
.values("assignees__first_name", "assignees__last_name", "assignees__avatar", "assignees__display_name", "assignees__id")
|
||||
.values("assignees__first_name", "assignees__last_name", "assignees__avatar", "assignees__display_name")
|
||||
.annotate(count=Count("id"))
|
||||
.order_by("-count")
|
||||
)
|
||||
|
||||
@@ -18,21 +18,10 @@ class FileAssetEndpoint(BaseAPIView):
|
||||
"""
|
||||
|
||||
def get(self, request, workspace_id, asset_key):
|
||||
try:
|
||||
asset_key = str(workspace_id) + "/" + asset_key
|
||||
files = FileAsset.objects.filter(asset=asset_key)
|
||||
if files.exists():
|
||||
serializer = FileAssetSerializer(files, context={"request": request}, many=True)
|
||||
return Response({"data": serializer.data, "status": True}, status=status.HTTP_200_OK)
|
||||
else:
|
||||
return Response({"error": "Asset key does not exist", "status": False}, status=status.HTTP_200_OK)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
asset_key = str(workspace_id) + "/" + asset_key
|
||||
files = FileAsset.objects.filter(asset=asset_key)
|
||||
serializer = FileAssetSerializer(files, context={"request": request}, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
def post(self, request, slug):
|
||||
try:
|
||||
@@ -79,16 +68,11 @@ class UserAssetsEndpoint(BaseAPIView):
|
||||
def get(self, request, asset_key):
|
||||
try:
|
||||
files = FileAsset.objects.filter(asset=asset_key, created_by=request.user)
|
||||
if files.exists():
|
||||
serializer = FileAssetSerializer(files, context={"request": request})
|
||||
return Response({"data": serializer.data, "status": True}, status=status.HTTP_200_OK)
|
||||
else:
|
||||
return Response({"error": "Asset key does not exist", "status": False}, status=status.HTTP_200_OK)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
serializer = FileAssetSerializer(files, context={"request": request})
|
||||
return Response(serializer.data)
|
||||
except FileAsset.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
{"error": "File Asset does not exist"}, status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
|
||||
def post(self, request):
|
||||
|
||||
@@ -165,9 +165,6 @@ class CycleViewSet(BaseViewSet):
|
||||
try:
|
||||
queryset = self.get_queryset()
|
||||
cycle_view = request.GET.get("cycle_view", "all")
|
||||
order_by = request.GET.get("order_by", "sort_order")
|
||||
|
||||
queryset = queryset.order_by(order_by)
|
||||
|
||||
# All Cycles
|
||||
if cycle_view == "all":
|
||||
@@ -373,8 +370,7 @@ class CycleViewSet(BaseViewSet):
|
||||
.annotate(last_name=F("assignees__last_name"))
|
||||
.annotate(assignee_id=F("assignees__id"))
|
||||
.annotate(avatar=F("assignees__avatar"))
|
||||
.annotate(display_name=F("assignees__display_name"))
|
||||
.values("first_name", "last_name", "assignee_id", "avatar", "display_name")
|
||||
.values("first_name", "last_name", "assignee_id", "avatar")
|
||||
.annotate(total_issues=Count("assignee_id"))
|
||||
.annotate(
|
||||
completed_issues=Count(
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
# Third Party imports
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
from sentry_sdk import capture_exception
|
||||
|
||||
# Module imports
|
||||
from . import BaseAPIView
|
||||
from plane.api.permissions import WorkSpaceAdminPermission
|
||||
from plane.bgtasks.export_task import issue_export_task
|
||||
from plane.db.models import Project, ExporterHistory, Workspace
|
||||
|
||||
from plane.api.serializers import ExporterHistorySerializer
|
||||
|
||||
|
||||
class ExportIssuesEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
WorkSpaceAdminPermission,
|
||||
]
|
||||
model = ExporterHistory
|
||||
serializer_class = ExporterHistorySerializer
|
||||
|
||||
def post(self, request, slug):
|
||||
try:
|
||||
# Get the workspace
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
|
||||
provider = request.data.get("provider", False)
|
||||
multiple = request.data.get("multiple", False)
|
||||
project_ids = request.data.get("project", [])
|
||||
|
||||
if provider in ["csv", "xlsx", "json"]:
|
||||
if not project_ids:
|
||||
project_ids = Project.objects.filter(
|
||||
workspace__slug=slug
|
||||
).values_list("id", flat=True)
|
||||
project_ids = [str(project_id) for project_id in project_ids]
|
||||
|
||||
exporter = ExporterHistory.objects.create(
|
||||
workspace=workspace,
|
||||
project=project_ids,
|
||||
initiated_by=request.user,
|
||||
provider=provider,
|
||||
)
|
||||
|
||||
issue_export_task.delay(
|
||||
provider=exporter.provider,
|
||||
workspace_id=workspace.id,
|
||||
project_ids=project_ids,
|
||||
token_id=exporter.token,
|
||||
multiple=multiple,
|
||||
slug=slug,
|
||||
)
|
||||
return Response(
|
||||
{
|
||||
"message": f"Once the export is ready you will be able to download it"
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
else:
|
||||
return Response(
|
||||
{"error": f"Provider '{provider}' not found."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
except Workspace.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Workspace does not exists"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
def get(self, request, slug):
|
||||
try:
|
||||
exporter_history = ExporterHistory.objects.filter(
|
||||
workspace__slug=slug
|
||||
).select_related("workspace","initiated_by")
|
||||
|
||||
if request.GET.get("per_page", False) and request.GET.get("cursor", False):
|
||||
return self.paginate(
|
||||
request=request,
|
||||
queryset=exporter_history,
|
||||
on_results=lambda exporter_history: ExporterHistorySerializer(
|
||||
exporter_history, many=True
|
||||
).data,
|
||||
)
|
||||
else:
|
||||
return Response(
|
||||
{"error": "per_page and cursor are required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
@@ -15,6 +15,7 @@ from sentry_sdk import capture_exception
|
||||
from .base import BaseViewSet
|
||||
from plane.api.permissions import ProjectBasePermission, ProjectLitePermission
|
||||
from plane.db.models import (
|
||||
Project,
|
||||
Inbox,
|
||||
InboxIssue,
|
||||
Issue,
|
||||
@@ -22,7 +23,6 @@ from plane.db.models import (
|
||||
IssueLink,
|
||||
IssueAttachment,
|
||||
ProjectMember,
|
||||
ProjectDeployBoard,
|
||||
)
|
||||
from plane.api.serializers import (
|
||||
IssueSerializer,
|
||||
@@ -377,269 +377,4 @@ class InboxIssueViewSet(BaseViewSet):
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
class InboxIssuePublicViewSet(BaseViewSet):
|
||||
serializer_class = InboxIssueSerializer
|
||||
model = InboxIssue
|
||||
|
||||
filterset_fields = [
|
||||
"status",
|
||||
]
|
||||
|
||||
def get_queryset(self):
|
||||
project_deploy_board = ProjectDeployBoard.objects.get(workspace__slug=self.kwargs.get("slug"), project_id=self.kwargs.get("project_id"))
|
||||
if project_deploy_board is not None:
|
||||
return self.filter_queryset(
|
||||
super()
|
||||
.get_queryset()
|
||||
.filter(
|
||||
Q(snoozed_till__gte=timezone.now()) | Q(snoozed_till__isnull=True),
|
||||
project_id=self.kwargs.get("project_id"),
|
||||
workspace__slug=self.kwargs.get("slug"),
|
||||
inbox_id=self.kwargs.get("inbox_id"),
|
||||
)
|
||||
.select_related("issue", "workspace", "project")
|
||||
)
|
||||
else:
|
||||
return InboxIssue.objects.none()
|
||||
|
||||
def list(self, request, slug, project_id, inbox_id):
|
||||
try:
|
||||
project_deploy_board = ProjectDeployBoard.objects.get(workspace__slug=slug, project_id=project_id)
|
||||
if project_deploy_board.inbox is None:
|
||||
return Response({"error": "Inbox is not enabled for this Project Board"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
filters = issue_filters(request.query_params, "GET")
|
||||
issues = (
|
||||
Issue.objects.filter(
|
||||
issue_inbox__inbox_id=inbox_id,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
)
|
||||
.filter(**filters)
|
||||
.annotate(bridge_id=F("issue_inbox__id"))
|
||||
.select_related("workspace", "project", "state", "parent")
|
||||
.prefetch_related("assignees", "labels")
|
||||
.order_by("issue_inbox__snoozed_till", "issue_inbox__status")
|
||||
.annotate(
|
||||
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
attachment_count=IssueAttachment.objects.filter(
|
||||
issue=OuterRef("id")
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_inbox",
|
||||
queryset=InboxIssue.objects.only(
|
||||
"status", "duplicate_to", "snoozed_till", "source"
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
issues_data = IssueStateInboxSerializer(issues, many=True).data
|
||||
return Response(
|
||||
issues_data,
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
except ProjectDeployBoard.DoesNotExist:
|
||||
return Response({"error": "Project Deploy Board does not exist"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
def create(self, request, slug, project_id, inbox_id):
|
||||
try:
|
||||
project_deploy_board = ProjectDeployBoard.objects.get(workspace__slug=slug, project_id=project_id)
|
||||
if project_deploy_board.inbox is None:
|
||||
return Response({"error": "Inbox is not enabled for this Project Board"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
if not request.data.get("issue", {}).get("name", False):
|
||||
return Response(
|
||||
{"error": "Name is required"}, status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
# Check for valid priority
|
||||
if not request.data.get("issue", {}).get("priority", None) in [
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"urgent",
|
||||
None,
|
||||
]:
|
||||
return Response(
|
||||
{"error": "Invalid priority"}, status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
# Create or get state
|
||||
state, _ = State.objects.get_or_create(
|
||||
name="Triage",
|
||||
group="backlog",
|
||||
description="Default state for managing all Inbox Issues",
|
||||
project_id=project_id,
|
||||
color="#ff7700",
|
||||
)
|
||||
|
||||
# create an issue
|
||||
issue = Issue.objects.create(
|
||||
name=request.data.get("issue", {}).get("name"),
|
||||
description=request.data.get("issue", {}).get("description", {}),
|
||||
description_html=request.data.get("issue", {}).get(
|
||||
"description_html", "<p></p>"
|
||||
),
|
||||
priority=request.data.get("issue", {}).get("priority", "low"),
|
||||
project_id=project_id,
|
||||
state=state,
|
||||
)
|
||||
|
||||
# Create an Issue Activity
|
||||
issue_activity.delay(
|
||||
type="issue.activity.created",
|
||||
requested_data=json.dumps(request.data, cls=DjangoJSONEncoder),
|
||||
actor_id=str(request.user.id),
|
||||
issue_id=str(issue.id),
|
||||
project_id=str(project_id),
|
||||
current_instance=None,
|
||||
)
|
||||
# create an inbox issue
|
||||
InboxIssue.objects.create(
|
||||
inbox_id=inbox_id,
|
||||
project_id=project_id,
|
||||
issue=issue,
|
||||
source=request.data.get("source", "in-app"),
|
||||
)
|
||||
|
||||
serializer = IssueStateInboxSerializer(issue)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
def partial_update(self, request, slug, project_id, inbox_id, pk):
|
||||
try:
|
||||
project_deploy_board = ProjectDeployBoard.objects.get(workspace__slug=slug, project_id=project_id)
|
||||
if project_deploy_board.inbox is None:
|
||||
return Response({"error": "Inbox is not enabled for this Project Board"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
inbox_issue = InboxIssue.objects.get(
|
||||
pk=pk, workspace__slug=slug, project_id=project_id, inbox_id=inbox_id
|
||||
)
|
||||
# Get the project member
|
||||
if str(inbox_issue.created_by_id) != str(request.user.id):
|
||||
return Response({"error": "You cannot edit inbox issues"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Get issue data
|
||||
issue_data = request.data.pop("issue", False)
|
||||
|
||||
|
||||
issue = Issue.objects.get(
|
||||
pk=inbox_issue.issue_id, workspace__slug=slug, project_id=project_id
|
||||
)
|
||||
# viewers and guests since only viewers and guests
|
||||
issue_data = {
|
||||
"name": issue_data.get("name", issue.name),
|
||||
"description_html": issue_data.get("description_html", issue.description_html),
|
||||
"description": issue_data.get("description", issue.description)
|
||||
}
|
||||
|
||||
issue_serializer = IssueCreateSerializer(
|
||||
issue, data=issue_data, partial=True
|
||||
)
|
||||
|
||||
if issue_serializer.is_valid():
|
||||
current_instance = issue
|
||||
# Log all the updates
|
||||
requested_data = json.dumps(issue_data, cls=DjangoJSONEncoder)
|
||||
if issue is not None:
|
||||
issue_activity.delay(
|
||||
type="issue.activity.updated",
|
||||
requested_data=requested_data,
|
||||
actor_id=str(request.user.id),
|
||||
issue_id=str(issue.id),
|
||||
project_id=str(project_id),
|
||||
current_instance=json.dumps(
|
||||
IssueSerializer(current_instance).data,
|
||||
cls=DjangoJSONEncoder,
|
||||
),
|
||||
)
|
||||
issue_serializer.save()
|
||||
return Response(issue_serializer.data, status=status.HTTP_200_OK)
|
||||
return Response(issue_serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
except InboxIssue.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Inbox Issue does not exist"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
def retrieve(self, request, slug, project_id, inbox_id, pk):
|
||||
try:
|
||||
project_deploy_board = ProjectDeployBoard.objects.get(workspace__slug=slug, project_id=project_id)
|
||||
if project_deploy_board.inbox is None:
|
||||
return Response({"error": "Inbox is not enabled for this Project Board"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
inbox_issue = InboxIssue.objects.get(
|
||||
pk=pk, workspace__slug=slug, project_id=project_id, inbox_id=inbox_id
|
||||
)
|
||||
issue = Issue.objects.get(
|
||||
pk=inbox_issue.issue_id, workspace__slug=slug, project_id=project_id
|
||||
)
|
||||
serializer = IssueStateInboxSerializer(issue)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
def destroy(self, request, slug, project_id, inbox_id, pk):
|
||||
try:
|
||||
project_deploy_board = ProjectDeployBoard.objects.get(workspace__slug=slug, project_id=project_id)
|
||||
if project_deploy_board.inbox is None:
|
||||
return Response({"error": "Inbox is not enabled for this Project Board"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
inbox_issue = InboxIssue.objects.get(
|
||||
pk=pk, workspace__slug=slug, project_id=project_id, inbox_id=inbox_id
|
||||
)
|
||||
|
||||
if str(inbox_issue.created_by_id) != str(request.user.id):
|
||||
return Response({"error": "You cannot delete inbox issue"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
inbox_issue.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
except InboxIssue.DoesNotExist:
|
||||
return Response({"error": "Inbox Issue does not exists"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
)
|
||||
@@ -20,17 +20,6 @@ class SlackProjectSyncViewSet(BaseViewSet):
|
||||
serializer_class = SlackProjectSyncSerializer
|
||||
model = SlackProjectSync
|
||||
|
||||
def get_queryset(self):
|
||||
return (
|
||||
super()
|
||||
.get_queryset()
|
||||
.filter(
|
||||
workspace__slug=self.kwargs.get("slug"),
|
||||
project_id=self.kwargs.get("project_id"),
|
||||
)
|
||||
.filter(project__project_projectmember__member=self.request.user)
|
||||
)
|
||||
|
||||
def create(self, request, slug, project_id, workspace_integration_id):
|
||||
try:
|
||||
serializer = SlackProjectSyncSerializer(data=request.data)
|
||||
@@ -56,10 +45,7 @@ class SlackProjectSyncViewSet(BaseViewSet):
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
except IntegrityError:
|
||||
return Response(
|
||||
{"error": "Slack is already enabled for the project"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
return Response({"error": "Slack is already enabled for the project"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
except WorkspaceIntegration.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Workspace Integration does not exist"},
|
||||
|
||||
@@ -48,7 +48,6 @@ from plane.api.serializers import (
|
||||
ProjectMemberLiteSerializer,
|
||||
IssueReactionSerializer,
|
||||
CommentReactionSerializer,
|
||||
IssueVoteSerializer,
|
||||
)
|
||||
from plane.api.permissions import (
|
||||
WorkspaceEntityPermission,
|
||||
@@ -71,12 +70,11 @@ from plane.db.models import (
|
||||
ProjectMember,
|
||||
IssueReaction,
|
||||
CommentReaction,
|
||||
ProjectDeployBoard,
|
||||
IssueVote,
|
||||
)
|
||||
from plane.bgtasks.issue_activites_task import issue_activity
|
||||
from plane.utils.grouper import group_results
|
||||
from plane.utils.issue_filters import issue_filters
|
||||
from plane.bgtasks.project_issue_export import issue_export_task
|
||||
|
||||
|
||||
class IssueViewSet(BaseViewSet):
|
||||
@@ -171,6 +169,7 @@ class IssueViewSet(BaseViewSet):
|
||||
def list(self, request, slug, project_id):
|
||||
try:
|
||||
filters = issue_filters(request.query_params, "GET")
|
||||
print(filters)
|
||||
|
||||
# Custom ordering for priority and state
|
||||
priority_order = ["urgent", "high", "medium", "low", None]
|
||||
@@ -363,14 +362,8 @@ class UserWorkSpaceIssues(BaseAPIView):
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_reactions",
|
||||
queryset=IssueReaction.objects.select_related("actor"),
|
||||
)
|
||||
)
|
||||
.filter(**filters)
|
||||
).distinct()
|
||||
)
|
||||
|
||||
# Priority Ordering
|
||||
if order_by_param == "priority" or order_by_param == "-priority":
|
||||
@@ -751,25 +744,21 @@ class SubIssuesEndpoint(BaseAPIView):
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_reactions",
|
||||
queryset=IssueReaction.objects.select_related("actor"),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
state_distribution = (
|
||||
State.objects.filter(
|
||||
workspace__slug=slug, state_issue__parent_id=issue_id
|
||||
State.objects.filter(~Q(name="Triage"), workspace__slug=slug)
|
||||
.annotate(
|
||||
state_count=Count(
|
||||
"state_issue",
|
||||
filter=Q(state_issue__parent_id=issue_id),
|
||||
)
|
||||
)
|
||||
.annotate(state_group=F("group"))
|
||||
.values("state_group")
|
||||
.annotate(state_count=Count("state_group"))
|
||||
.order_by("state_group")
|
||||
.order_by("group")
|
||||
.values("group", "state_count")
|
||||
)
|
||||
|
||||
result = {item["state_group"]: item["state_count"] for item in state_distribution}
|
||||
result = {item["group"]: item["state_count"] for item in state_distribution}
|
||||
|
||||
serializer = IssueLiteSerializer(
|
||||
sub_issues,
|
||||
@@ -1459,374 +1448,6 @@ class CommentReactionViewSet(BaseViewSet):
|
||||
)
|
||||
|
||||
|
||||
class IssueCommentPublicViewSet(BaseViewSet):
|
||||
serializer_class = IssueCommentSerializer
|
||||
model = IssueComment
|
||||
|
||||
filterset_fields = [
|
||||
"issue__id",
|
||||
"workspace__id",
|
||||
]
|
||||
|
||||
def get_queryset(self):
|
||||
project_deploy_board = ProjectDeployBoard.objects.get(
|
||||
workspace__slug=self.kwargs.get("slug"),
|
||||
project_id=self.kwargs.get("project_id"),
|
||||
)
|
||||
if project_deploy_board.comments:
|
||||
return self.filter_queryset(
|
||||
super()
|
||||
.get_queryset()
|
||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.filter(issue_id=self.kwargs.get("issue_id"))
|
||||
.select_related("project")
|
||||
.select_related("workspace")
|
||||
.select_related("issue")
|
||||
.distinct()
|
||||
)
|
||||
else:
|
||||
return IssueComment.objects.none()
|
||||
|
||||
def create(self, request, slug, project_id, issue_id):
|
||||
try:
|
||||
project_deploy_board = ProjectDeployBoard.objects.get(
|
||||
workspace__slug=slug, project_id=project_id
|
||||
)
|
||||
|
||||
if not project_deploy_board.comments:
|
||||
return Response(
|
||||
{"error": "Comments are not enabled for this project"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
access = (
|
||||
"INTERNAL"
|
||||
if ProjectMember.objects.filter(
|
||||
project_id=project_id, member=request.user
|
||||
).exists()
|
||||
else "EXTERNAL"
|
||||
)
|
||||
|
||||
serializer = IssueCommentSerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
serializer.save(
|
||||
project_id=project_id,
|
||||
issue_id=issue_id,
|
||||
actor=request.user,
|
||||
access=access,
|
||||
)
|
||||
issue_activity.delay(
|
||||
type="comment.activity.created",
|
||||
requested_data=json.dumps(serializer.data, cls=DjangoJSONEncoder),
|
||||
actor_id=str(request.user.id),
|
||||
issue_id=str(issue_id),
|
||||
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 Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
def partial_update(self, request, slug, project_id, issue_id, pk):
|
||||
try:
|
||||
project_deploy_board = ProjectDeployBoard.objects.get(
|
||||
workspace__slug=slug, project_id=project_id
|
||||
)
|
||||
|
||||
if not project_deploy_board.comments:
|
||||
return Response(
|
||||
{"error": "Comments are not enabled for this project"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
comment = IssueComment.objects.get(
|
||||
workspace__slug=slug, pk=pk, actor=request.user
|
||||
)
|
||||
serializer = IssueCommentSerializer(
|
||||
comment, data=request.data, partial=True
|
||||
)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
issue_activity.delay(
|
||||
type="comment.activity.updated",
|
||||
requested_data=json.dumps(request.data, cls=DjangoJSONEncoder),
|
||||
actor_id=str(request.user.id),
|
||||
issue_id=str(issue_id),
|
||||
project_id=str(project_id),
|
||||
current_instance=json.dumps(
|
||||
IssueCommentSerializer(comment).data,
|
||||
cls=DjangoJSONEncoder,
|
||||
),
|
||||
)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
except (IssueComment.DoesNotExist, ProjectDeployBoard.DoesNotExist):
|
||||
return Response(
|
||||
{"error": "IssueComent Does not exists"},
|
||||
status=status.HTTP_400_BAD_REQUEST,)
|
||||
|
||||
def destroy(self, request, slug, project_id, issue_id, pk):
|
||||
try:
|
||||
project_deploy_board = ProjectDeployBoard.objects.get(
|
||||
workspace__slug=slug, project_id=project_id
|
||||
)
|
||||
|
||||
if not project_deploy_board.comments:
|
||||
return Response(
|
||||
{"error": "Comments are not enabled for this project"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
comment = IssueComment.objects.get(
|
||||
workspace__slug=slug, pk=pk, project_id=project_id, actor=request.user
|
||||
)
|
||||
issue_activity.delay(
|
||||
type="comment.activity.deleted",
|
||||
requested_data=json.dumps({"comment_id": str(pk)}),
|
||||
actor_id=str(request.user.id),
|
||||
issue_id=str(issue_id),
|
||||
project_id=str(project_id),
|
||||
current_instance=json.dumps(
|
||||
IssueCommentSerializer(comment).data,
|
||||
cls=DjangoJSONEncoder,
|
||||
),
|
||||
)
|
||||
comment.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
except (IssueComment.DoesNotExist, ProjectDeployBoard.DoesNotExist):
|
||||
return Response(
|
||||
{"error": "IssueComent Does not exists"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
class IssueReactionPublicViewSet(BaseViewSet):
|
||||
serializer_class = IssueReactionSerializer
|
||||
model = IssueReaction
|
||||
|
||||
def get_queryset(self):
|
||||
project_deploy_board = ProjectDeployBoard.objects.get(
|
||||
workspace__slug=self.kwargs.get("slug"),
|
||||
project_id=self.kwargs.get("project_id"),
|
||||
)
|
||||
if project_deploy_board.reactions:
|
||||
return (
|
||||
super()
|
||||
.get_queryset()
|
||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.filter(project_id=self.kwargs.get("project_id"))
|
||||
.filter(issue_id=self.kwargs.get("issue_id"))
|
||||
.order_by("-created_at")
|
||||
.distinct()
|
||||
)
|
||||
else:
|
||||
return IssueReaction.objects.none()
|
||||
|
||||
def create(self, request, slug, project_id, issue_id):
|
||||
try:
|
||||
project_deploy_board = ProjectDeployBoard.objects.get(
|
||||
workspace__slug=slug, project_id=project_id
|
||||
)
|
||||
|
||||
if not project_deploy_board.reactions:
|
||||
return Response(
|
||||
{"error": "Reactions are not enabled for this project board"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
serializer = IssueReactionSerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
serializer.save(
|
||||
project_id=project_id, issue_id=issue_id, actor=request.user
|
||||
)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
except ProjectDeployBoard.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Project board does not exist"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
def destroy(self, request, slug, project_id, issue_id, reaction_code):
|
||||
try:
|
||||
project_deploy_board = ProjectDeployBoard.objects.get(
|
||||
workspace__slug=slug, project_id=project_id
|
||||
)
|
||||
|
||||
if not project_deploy_board.reactions:
|
||||
return Response(
|
||||
{"error": "Reactions are not enabled for this project board"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
issue_reaction = IssueReaction.objects.get(
|
||||
workspace__slug=slug,
|
||||
issue_id=issue_id,
|
||||
reaction=reaction_code,
|
||||
actor=request.user,
|
||||
)
|
||||
issue_reaction.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
except IssueReaction.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Issue reaction does not exist"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
class CommentReactionPublicViewSet(BaseViewSet):
|
||||
serializer_class = CommentReactionSerializer
|
||||
model = CommentReaction
|
||||
|
||||
def get_queryset(self):
|
||||
project_deploy_board = ProjectDeployBoard.objects.get(
|
||||
workspace__slug=self.kwargs.get("slug"),
|
||||
project_id=self.kwargs.get("project_id"),
|
||||
)
|
||||
if project_deploy_board.reactions:
|
||||
return (
|
||||
super()
|
||||
.get_queryset()
|
||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.filter(project_id=self.kwargs.get("project_id"))
|
||||
.filter(comment_id=self.kwargs.get("comment_id"))
|
||||
.order_by("-created_at")
|
||||
.distinct()
|
||||
)
|
||||
else:
|
||||
return CommentReaction.objects.none()
|
||||
|
||||
def create(self, request, slug, project_id, comment_id):
|
||||
try:
|
||||
project_deploy_board = ProjectDeployBoard.objects.get(
|
||||
workspace__slug=slug, project_id=project_id
|
||||
)
|
||||
|
||||
if not project_deploy_board.reactions:
|
||||
return Response(
|
||||
{"error": "Reactions are not enabled for this board"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
serializer = CommentReactionSerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
serializer.save(
|
||||
project_id=project_id, comment_id=comment_id, actor=request.user
|
||||
)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
except ProjectDeployBoard.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Project board does not exist"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
def destroy(self, request, slug, project_id, comment_id, reaction_code):
|
||||
try:
|
||||
project_deploy_board = ProjectDeployBoard.objects.get(
|
||||
workspace__slug=slug, project_id=project_id
|
||||
)
|
||||
if not project_deploy_board.reactions:
|
||||
return Response(
|
||||
{"error": "Reactions are not enabled for this board"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
comment_reaction = CommentReaction.objects.get(
|
||||
project_id=project_id,
|
||||
workspace__slug=slug,
|
||||
comment_id=comment_id,
|
||||
reaction=reaction_code,
|
||||
actor=request.user,
|
||||
)
|
||||
comment_reaction.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
except CommentReaction.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Comment reaction does not exist"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
class IssueVotePublicViewSet(BaseViewSet):
|
||||
model = IssueVote
|
||||
serializer_class = IssueVoteSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
return (
|
||||
super()
|
||||
.get_queryset()
|
||||
.filter(issue_id=self.kwargs.get("issue_id"))
|
||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.filter(project_id=self.kwargs.get("project_id"))
|
||||
)
|
||||
|
||||
def create(self, request, slug, project_id, issue_id):
|
||||
try:
|
||||
issue_vote, _ = IssueVote.objects.get_or_create(
|
||||
actor_id=request.user.id,
|
||||
project_id=project_id,
|
||||
issue_id=issue_id,
|
||||
vote=request.data.get("vote", 1),
|
||||
)
|
||||
serializer = IssueVoteSerializer(issue_vote)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
def destroy(self, request, slug, project_id, issue_id):
|
||||
try:
|
||||
issue_vote = IssueVote.objects.get(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
issue_id=issue_id,
|
||||
actor_id=request.user.id,
|
||||
)
|
||||
issue_vote.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
class ExportIssuesEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
|
||||
@@ -53,8 +53,6 @@ class ModuleViewSet(BaseViewSet):
|
||||
)
|
||||
|
||||
def get_queryset(self):
|
||||
order_by = self.request.GET.get("order_by", "sort_order")
|
||||
|
||||
subquery = ModuleFavorite.objects.filter(
|
||||
user=self.request.user,
|
||||
module_id=OuterRef("pk"),
|
||||
@@ -108,7 +106,7 @@ class ModuleViewSet(BaseViewSet):
|
||||
filter=Q(issue_module__issue__state__group="backlog"),
|
||||
)
|
||||
)
|
||||
.order_by(order_by, "name")
|
||||
.order_by("-is_favorite", "name")
|
||||
)
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
@@ -175,9 +173,8 @@ class ModuleViewSet(BaseViewSet):
|
||||
.annotate(first_name=F("assignees__first_name"))
|
||||
.annotate(last_name=F("assignees__last_name"))
|
||||
.annotate(assignee_id=F("assignees__id"))
|
||||
.annotate(display_name=F("assignees__display_name"))
|
||||
.annotate(avatar=F("assignees__avatar"))
|
||||
.values("first_name", "last_name", "assignee_id", "avatar", "display_name")
|
||||
.values("first_name", "last_name", "assignee_id", "avatar")
|
||||
.annotate(total_issues=Count("assignee_id"))
|
||||
.annotate(
|
||||
completed_issues=Count(
|
||||
|
||||
@@ -5,21 +5,7 @@ from datetime import datetime
|
||||
# Django imports
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import IntegrityError
|
||||
from django.db.models import (
|
||||
Q,
|
||||
Exists,
|
||||
OuterRef,
|
||||
Func,
|
||||
F,
|
||||
Max,
|
||||
CharField,
|
||||
Func,
|
||||
Subquery,
|
||||
Prefetch,
|
||||
When,
|
||||
Case,
|
||||
Value,
|
||||
)
|
||||
from django.db.models import Q, Exists, OuterRef, Func, F, Min, Subquery
|
||||
from django.core.validators import validate_email
|
||||
from django.conf import settings
|
||||
|
||||
@@ -27,7 +13,6 @@ from django.conf import settings
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
from rest_framework import serializers
|
||||
from rest_framework.permissions import AllowAny
|
||||
from sentry_sdk import capture_exception
|
||||
|
||||
# Module imports
|
||||
@@ -38,16 +23,9 @@ from plane.api.serializers import (
|
||||
ProjectDetailSerializer,
|
||||
ProjectMemberInviteSerializer,
|
||||
ProjectFavoriteSerializer,
|
||||
IssueLiteSerializer,
|
||||
ProjectDeployBoardSerializer,
|
||||
ProjectMemberAdminSerializer,
|
||||
)
|
||||
|
||||
from plane.api.permissions import (
|
||||
ProjectBasePermission,
|
||||
ProjectEntityPermission,
|
||||
ProjectMemberPermission,
|
||||
)
|
||||
from plane.api.permissions import ProjectBasePermission, ProjectEntityPermission
|
||||
|
||||
from plane.db.models import (
|
||||
Project,
|
||||
@@ -70,17 +48,9 @@ from plane.db.models import (
|
||||
IssueAssignee,
|
||||
ModuleMember,
|
||||
Inbox,
|
||||
ProjectDeployBoard,
|
||||
Issue,
|
||||
IssueReaction,
|
||||
IssueLink,
|
||||
IssueAttachment,
|
||||
Label,
|
||||
)
|
||||
|
||||
from plane.bgtasks.project_invitation_task import project_invitation
|
||||
from plane.utils.grouper import group_results
|
||||
from plane.utils.issue_filters import issue_filters
|
||||
|
||||
|
||||
class ProjectViewSet(BaseViewSet):
|
||||
@@ -122,9 +92,7 @@ class ProjectViewSet(BaseViewSet):
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
total_members=ProjectMember.objects.filter(
|
||||
project_id=OuterRef("id"), member__is_bot=False
|
||||
)
|
||||
total_members=ProjectMember.objects.filter(project_id=OuterRef("id"))
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
@@ -141,20 +109,6 @@ class ProjectViewSet(BaseViewSet):
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
member_role=ProjectMember.objects.filter(
|
||||
project_id=OuterRef("pk"),
|
||||
member_id=self.request.user.id,
|
||||
).values("role")
|
||||
)
|
||||
.annotate(
|
||||
is_deployed=Exists(
|
||||
ProjectDeployBoard.objects.filter(
|
||||
project_id=OuterRef("pk"),
|
||||
workspace__slug=self.kwargs.get("slug"),
|
||||
)
|
||||
)
|
||||
)
|
||||
.distinct()
|
||||
)
|
||||
|
||||
@@ -226,9 +180,7 @@ class ProjectViewSet(BaseViewSet):
|
||||
project_id=serializer.data["id"], member=request.user, role=20
|
||||
)
|
||||
|
||||
if serializer.data["project_lead"] is not None and str(
|
||||
serializer.data["project_lead"]
|
||||
) != str(request.user.id):
|
||||
if serializer.data["project_lead"] is not None:
|
||||
ProjectMember.objects.create(
|
||||
project_id=serializer.data["id"],
|
||||
member_id=serializer.data["project_lead"],
|
||||
@@ -395,9 +347,7 @@ class InviteProjectEndpoint(BaseAPIView):
|
||||
validate_email(email)
|
||||
# Check if user is already a member of workspace
|
||||
if ProjectMember.objects.filter(
|
||||
project_id=project_id,
|
||||
member__email=email,
|
||||
member__is_bot=False,
|
||||
project_id=project_id, member__email=email
|
||||
).exists():
|
||||
return Response(
|
||||
{"error": "User is already member of workspace"},
|
||||
@@ -501,7 +451,7 @@ class UserProjectInvitationsViewset(BaseViewSet):
|
||||
|
||||
|
||||
class ProjectMemberViewSet(BaseViewSet):
|
||||
serializer_class = ProjectMemberAdminSerializer
|
||||
serializer_class = ProjectMemberSerializer
|
||||
model = ProjectMember
|
||||
permission_classes = [
|
||||
ProjectBasePermission,
|
||||
@@ -1036,63 +986,6 @@ class ProjectFavoritesViewSet(BaseViewSet):
|
||||
)
|
||||
|
||||
|
||||
class ProjectDeployBoardViewSet(BaseViewSet):
|
||||
permission_classes = [
|
||||
ProjectMemberPermission,
|
||||
]
|
||||
serializer_class = ProjectDeployBoardSerializer
|
||||
model = ProjectDeployBoard
|
||||
|
||||
def get_queryset(self):
|
||||
return (
|
||||
super()
|
||||
.get_queryset()
|
||||
.filter(
|
||||
workspace__slug=self.kwargs.get("slug"),
|
||||
project_id=self.kwargs.get("project_id"),
|
||||
)
|
||||
.select_related("project")
|
||||
)
|
||||
|
||||
def create(self, request, slug, project_id):
|
||||
try:
|
||||
comments = request.data.get("comments", False)
|
||||
reactions = request.data.get("reactions", False)
|
||||
inbox = request.data.get("inbox", None)
|
||||
votes = request.data.get("votes", False)
|
||||
views = request.data.get(
|
||||
"views",
|
||||
{
|
||||
"list": True,
|
||||
"kanban": True,
|
||||
"calendar": True,
|
||||
"gantt": True,
|
||||
"spreadsheet": True,
|
||||
},
|
||||
)
|
||||
|
||||
project_deploy_board, _ = ProjectDeployBoard.objects.get_or_create(
|
||||
anchor=f"{slug}/{project_id}",
|
||||
project_id=project_id,
|
||||
)
|
||||
project_deploy_board.comments = comments
|
||||
project_deploy_board.reactions = reactions
|
||||
project_deploy_board.inbox = inbox
|
||||
project_deploy_board.votes = votes
|
||||
project_deploy_board.views = views
|
||||
|
||||
project_deploy_board.save()
|
||||
|
||||
serializer = ProjectDeployBoardSerializer(project_deploy_board)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
class ProjectMemberEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
ProjectEntityPermission,
|
||||
@@ -1101,9 +994,7 @@ class ProjectMemberEndpoint(BaseAPIView):
|
||||
def get(self, request, slug, project_id):
|
||||
try:
|
||||
project_members = ProjectMember.objects.filter(
|
||||
project_id=project_id,
|
||||
workspace__slug=slug,
|
||||
member__is_bot=False,
|
||||
project_id=project_id, workspace__slug=slug
|
||||
).select_related("project", "member")
|
||||
serializer = ProjectMemberSerializer(project_members, many=True)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
@@ -1113,176 +1004,3 @@ class ProjectMemberEndpoint(BaseAPIView):
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
class ProjectDeployBoardPublicSettingsEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
AllowAny,
|
||||
]
|
||||
|
||||
def get(self, request, slug, project_id):
|
||||
try:
|
||||
project_deploy_board = ProjectDeployBoard.objects.get(
|
||||
workspace__slug=slug, project_id=project_id
|
||||
)
|
||||
serializer = ProjectDeployBoardSerializer(project_deploy_board)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
except ProjectDeployBoard.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Project Deploy Board does not exists"},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
class ProjectDeployBoardIssuesPublicEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
AllowAny,
|
||||
]
|
||||
|
||||
def get(self, request, slug, project_id):
|
||||
try:
|
||||
project_deploy_board = ProjectDeployBoard.objects.get(
|
||||
workspace__slug=slug, project_id=project_id
|
||||
)
|
||||
|
||||
filters = issue_filters(request.query_params, "GET")
|
||||
|
||||
# Custom ordering for priority and state
|
||||
priority_order = ["urgent", "high", "medium", "low", None]
|
||||
state_order = ["backlog", "unstarted", "started", "completed", "cancelled"]
|
||||
|
||||
order_by_param = request.GET.get("order_by", "-created_at")
|
||||
|
||||
issue_queryset = (
|
||||
Issue.issue_objects.annotate(
|
||||
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.filter(project_id=project_id)
|
||||
.filter(workspace__slug=slug)
|
||||
.select_related("project", "workspace", "state", "parent")
|
||||
.prefetch_related("assignees", "labels")
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_reactions",
|
||||
queryset=IssueReaction.objects.select_related("actor"),
|
||||
)
|
||||
)
|
||||
.filter(**filters)
|
||||
.annotate(cycle_id=F("issue_cycle__cycle_id"))
|
||||
.annotate(module_id=F("issue_module__module_id"))
|
||||
.annotate(
|
||||
link_count=IssueLink.objects.filter(issue=OuterRef("id"))
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
attachment_count=IssueAttachment.objects.filter(
|
||||
issue=OuterRef("id")
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
)
|
||||
|
||||
# Priority Ordering
|
||||
if order_by_param == "priority" or order_by_param == "-priority":
|
||||
priority_order = (
|
||||
priority_order
|
||||
if order_by_param == "priority"
|
||||
else priority_order[::-1]
|
||||
)
|
||||
issue_queryset = issue_queryset.annotate(
|
||||
priority_order=Case(
|
||||
*[
|
||||
When(priority=p, then=Value(i))
|
||||
for i, p in enumerate(priority_order)
|
||||
],
|
||||
output_field=CharField(),
|
||||
)
|
||||
).order_by("priority_order")
|
||||
|
||||
# State Ordering
|
||||
elif order_by_param in [
|
||||
"state__name",
|
||||
"state__group",
|
||||
"-state__name",
|
||||
"-state__group",
|
||||
]:
|
||||
state_order = (
|
||||
state_order
|
||||
if order_by_param in ["state__name", "state__group"]
|
||||
else state_order[::-1]
|
||||
)
|
||||
issue_queryset = issue_queryset.annotate(
|
||||
state_order=Case(
|
||||
*[
|
||||
When(state__group=state_group, then=Value(i))
|
||||
for i, state_group in enumerate(state_order)
|
||||
],
|
||||
default=Value(len(state_order)),
|
||||
output_field=CharField(),
|
||||
)
|
||||
).order_by("state_order")
|
||||
# assignee and label ordering
|
||||
elif order_by_param in [
|
||||
"labels__name",
|
||||
"-labels__name",
|
||||
"assignees__first_name",
|
||||
"-assignees__first_name",
|
||||
]:
|
||||
issue_queryset = issue_queryset.annotate(
|
||||
max_values=Max(
|
||||
order_by_param[1::]
|
||||
if order_by_param.startswith("-")
|
||||
else order_by_param
|
||||
)
|
||||
).order_by(
|
||||
"-max_values" if order_by_param.startswith("-") else "max_values"
|
||||
)
|
||||
else:
|
||||
issue_queryset = issue_queryset.order_by(order_by_param)
|
||||
|
||||
issues = IssueLiteSerializer(issue_queryset, many=True).data
|
||||
|
||||
states = State.objects.filter(
|
||||
workspace__slug=slug, project_id=project_id
|
||||
).values("name", "group", "color", "id")
|
||||
|
||||
labels = Label.objects.filter(
|
||||
workspace__slug=slug, project_id=project_id
|
||||
).values("id", "name", "color", "parent")
|
||||
|
||||
## Grouping the results
|
||||
group_by = request.GET.get("group_by", False)
|
||||
if group_by:
|
||||
issues = group_results(issues, group_by)
|
||||
|
||||
return Response(
|
||||
{
|
||||
"issues": issues,
|
||||
"states": states,
|
||||
"labels": labels,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
except ProjectDeployBoard.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Board does not exists"}, status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
@@ -19,7 +19,6 @@ from plane.db.models import (
|
||||
IssueView,
|
||||
Issue,
|
||||
IssueViewFavorite,
|
||||
IssueReaction,
|
||||
)
|
||||
from plane.utils.issue_filters import issue_filters
|
||||
|
||||
@@ -78,12 +77,6 @@ class ViewIssuesEndpoint(BaseAPIView):
|
||||
.select_related("parent")
|
||||
.prefetch_related("assignees")
|
||||
.prefetch_related("labels")
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"issue_reactions",
|
||||
queryset=IssueReaction.objects.select_related("actor"),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
serializer = IssueLiteSerializer(issues, many=True)
|
||||
|
||||
@@ -47,7 +47,7 @@ from plane.api.serializers import (
|
||||
WorkspaceThemeSerializer,
|
||||
IssueActivitySerializer,
|
||||
IssueLiteSerializer,
|
||||
WorkspaceMemberAdminSerializer,
|
||||
WorkspaceMemberAdminSerializer
|
||||
)
|
||||
from plane.api.views.base import BaseAPIView
|
||||
from . import BaseViewSet
|
||||
@@ -107,9 +107,7 @@ class WorkSpaceViewSet(BaseViewSet):
|
||||
|
||||
def get_queryset(self):
|
||||
member_count = (
|
||||
WorkspaceMember.objects.filter(
|
||||
workspace=OuterRef("id"), member__is_bot=False
|
||||
)
|
||||
WorkspaceMember.objects.filter(workspace=OuterRef("id"))
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
@@ -194,9 +192,7 @@ class UserWorkSpacesEndpoint(BaseAPIView):
|
||||
def get(self, request):
|
||||
try:
|
||||
member_count = (
|
||||
WorkspaceMember.objects.filter(
|
||||
workspace=OuterRef("id"), member__is_bot=False
|
||||
)
|
||||
WorkspaceMember.objects.filter(workspace=OuterRef("id"))
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
@@ -629,9 +625,7 @@ class WorkSpaceMemberViewSet(BaseViewSet):
|
||||
if (
|
||||
workspace_member.role == 20
|
||||
and WorkspaceMember.objects.filter(
|
||||
workspace__slug=slug,
|
||||
role=20,
|
||||
member__is_bot=False,
|
||||
workspace__slug=slug, role=20
|
||||
).count()
|
||||
== 1
|
||||
):
|
||||
@@ -994,11 +988,11 @@ class UserWorkspaceDashboardEndpoint(BaseAPIView):
|
||||
|
||||
upcoming_issues = Issue.issue_objects.filter(
|
||||
~Q(state__group__in=["completed", "cancelled"]),
|
||||
start_date__gte=timezone.now(),
|
||||
target_date__gte=timezone.now(),
|
||||
workspace__slug=slug,
|
||||
assignees__in=[request.user],
|
||||
completed_at__isnull=True,
|
||||
).values("id", "name", "workspace__slug", "project_id", "start_date")
|
||||
).values("id", "name", "workspace__slug", "project_id", "target_date")
|
||||
|
||||
return Response(
|
||||
{
|
||||
@@ -1083,7 +1077,6 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView):
|
||||
.filter(**filters)
|
||||
.values("priority")
|
||||
.annotate(priority_count=Count("priority"))
|
||||
.filter(priority_count__gte=1)
|
||||
.annotate(
|
||||
priority_order=Case(
|
||||
*[
|
||||
@@ -1462,8 +1455,7 @@ class WorkspaceMembersEndpoint(BaseAPIView):
|
||||
def get(self, request, slug):
|
||||
try:
|
||||
workspace_members = WorkspaceMember.objects.filter(
|
||||
workspace__slug=slug,
|
||||
member__is_bot=False,
|
||||
workspace__slug=slug
|
||||
).select_related("workspace", "member")
|
||||
serialzier = WorkSpaceMemberSerializer(workspace_members, many=True)
|
||||
return Response(serialzier.data, status=status.HTTP_200_OK)
|
||||
|
||||
@@ -51,12 +51,12 @@ def analytic_export_task(email, data, slug):
|
||||
segmented = segment
|
||||
|
||||
assignee_details = {}
|
||||
if x_axis in ["assignees__id"] or segment in ["assignees__id"]:
|
||||
if x_axis in ["assignees__display_name"] or segment in ["assignees__display_name"]:
|
||||
assignee_details = (
|
||||
Issue.issue_objects.filter(workspace__slug=slug, **filters, assignees__avatar__isnull=False)
|
||||
.order_by("assignees__id")
|
||||
.distinct("assignees__id")
|
||||
.values("assignees__avatar", "assignees__display_name", "assignees__first_name", "assignees__last_name", "assignees__id")
|
||||
.values("assignees__avatar", "assignees__display_name", "assignees__first_name", "assignees__last_name")
|
||||
)
|
||||
|
||||
if segment:
|
||||
@@ -93,19 +93,19 @@ def analytic_export_task(email, data, slug):
|
||||
else:
|
||||
generated_row.append("0")
|
||||
# x-axis replacement for names
|
||||
if x_axis in ["assignees__id"]:
|
||||
assignee = [user for user in assignee_details if str(user.get("assignees__id")) == str(item)]
|
||||
if x_axis in ["assignees__display_name"]:
|
||||
assignee = [user for user in assignee_details if str(user.get("assignees__display_name")) == str(item)]
|
||||
if len(assignee):
|
||||
generated_row[0] = str(assignee[0].get("assignees__first_name")) + " " + str(assignee[0].get("assignees__last_name"))
|
||||
rows.append(tuple(generated_row))
|
||||
|
||||
# If segment is ["assignees__display_name"] then replace segment_zero rows with first and last names
|
||||
if segmented in ["assignees__id"]:
|
||||
if segmented in ["assignees__display_name"]:
|
||||
for index, segm in enumerate(row_zero[2:]):
|
||||
# find the name of the user
|
||||
assignee = [user for user in assignee_details if str(user.get("assignees__id")) == str(segm)]
|
||||
assignee = [user for user in assignee_details if str(user.get("assignees__display_name")) == str(segm)]
|
||||
if len(assignee):
|
||||
row_zero[index + 2] = str(assignee[0].get("assignees__first_name")) + " " + str(assignee[0].get("assignees__last_name"))
|
||||
row_zero[index] = str(assignee[0].get("assignees__first_name")) + " " + str(assignee[0].get("assignees__last_name"))
|
||||
|
||||
rows = [tuple(row_zero)] + rows
|
||||
csv_buffer = io.StringIO()
|
||||
@@ -141,8 +141,8 @@ def analytic_export_task(email, data, slug):
|
||||
else distribution.get(item)[0].get("estimate "),
|
||||
]
|
||||
# x-axis replacement to names
|
||||
if x_axis in ["assignees__id"]:
|
||||
assignee = [user for user in assignee_details if str(user.get("assignees__id")) == str(item)]
|
||||
if x_axis in ["assignees__display_name"]:
|
||||
assignee = [user for user in assignee_details if str(user.get("assignees__display_name")) == str(item)]
|
||||
if len(assignee):
|
||||
row[0] = str(assignee[0].get("assignees__first_name")) + " " + str(assignee[0].get("assignees__last_name"))
|
||||
|
||||
|
||||
@@ -1,346 +0,0 @@
|
||||
# Python imports
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
import boto3
|
||||
import zipfile
|
||||
|
||||
# Django imports
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
from sentry_sdk import capture_exception
|
||||
from botocore.client import Config
|
||||
from openpyxl import Workbook
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import Issue, ExporterHistory
|
||||
|
||||
|
||||
def dateTimeConverter(time):
|
||||
if time:
|
||||
return time.strftime("%a, %d %b %Y %I:%M:%S %Z%z")
|
||||
|
||||
def dateConverter(time):
|
||||
if time:
|
||||
return time.strftime("%a, %d %b %Y")
|
||||
|
||||
def create_csv_file(data):
|
||||
csv_buffer = io.StringIO()
|
||||
csv_writer = csv.writer(csv_buffer, delimiter=",", quoting=csv.QUOTE_ALL)
|
||||
|
||||
for row in data:
|
||||
csv_writer.writerow(row)
|
||||
|
||||
csv_buffer.seek(0)
|
||||
return csv_buffer.getvalue()
|
||||
|
||||
|
||||
def create_json_file(data):
|
||||
return json.dumps(data)
|
||||
|
||||
|
||||
def create_xlsx_file(data):
|
||||
workbook = Workbook()
|
||||
sheet = workbook.active
|
||||
|
||||
for row in data:
|
||||
sheet.append(row)
|
||||
|
||||
xlsx_buffer = io.BytesIO()
|
||||
workbook.save(xlsx_buffer)
|
||||
xlsx_buffer.seek(0)
|
||||
return xlsx_buffer.getvalue()
|
||||
|
||||
|
||||
def create_zip_file(files):
|
||||
zip_buffer = io.BytesIO()
|
||||
with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zipf:
|
||||
for filename, file_content in files:
|
||||
zipf.writestr(filename, file_content)
|
||||
|
||||
zip_buffer.seek(0)
|
||||
return zip_buffer
|
||||
|
||||
|
||||
def upload_to_s3(zip_file, workspace_id, token_id, slug):
|
||||
s3 = boto3.client(
|
||||
"s3",
|
||||
region_name=settings.AWS_REGION,
|
||||
aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
|
||||
aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
|
||||
config=Config(signature_version="s3v4"),
|
||||
)
|
||||
file_name = f"{workspace_id}/export-{slug}-{token_id[:6]}-{timezone.now()}.zip"
|
||||
|
||||
s3.upload_fileobj(
|
||||
zip_file,
|
||||
settings.AWS_S3_BUCKET_NAME,
|
||||
file_name,
|
||||
ExtraArgs={"ACL": "public-read", "ContentType": "application/zip"},
|
||||
)
|
||||
|
||||
expires_in = 7 * 24 * 60 * 60
|
||||
presigned_url = s3.generate_presigned_url(
|
||||
"get_object",
|
||||
Params={"Bucket": settings.AWS_S3_BUCKET_NAME, "Key": file_name},
|
||||
ExpiresIn=expires_in,
|
||||
)
|
||||
|
||||
exporter_instance = ExporterHistory.objects.get(token=token_id)
|
||||
|
||||
if presigned_url:
|
||||
exporter_instance.url = presigned_url
|
||||
exporter_instance.status = "completed"
|
||||
exporter_instance.key = file_name
|
||||
else:
|
||||
exporter_instance.status = "failed"
|
||||
|
||||
exporter_instance.save(update_fields=["status", "url","key"])
|
||||
|
||||
|
||||
def generate_table_row(issue):
|
||||
return [
|
||||
f"""{issue["project__identifier"]}-{issue["sequence_id"]}""",
|
||||
issue["project__name"],
|
||||
issue["name"],
|
||||
issue["description_stripped"],
|
||||
issue["state__name"],
|
||||
issue["priority"],
|
||||
f"{issue['created_by__first_name']} {issue['created_by__last_name']}"
|
||||
if issue["created_by__first_name"] and issue["created_by__last_name"]
|
||||
else "",
|
||||
f"{issue['assignees__first_name']} {issue['assignees__last_name']}"
|
||||
if issue["assignees__first_name"] and issue["assignees__last_name"]
|
||||
else "",
|
||||
issue["labels__name"],
|
||||
issue["issue_cycle__cycle__name"],
|
||||
dateConverter(issue["issue_cycle__cycle__start_date"]),
|
||||
dateConverter(issue["issue_cycle__cycle__end_date"]),
|
||||
issue["issue_module__module__name"],
|
||||
dateConverter(issue["issue_module__module__start_date"]),
|
||||
dateConverter(issue["issue_module__module__target_date"]),
|
||||
dateTimeConverter(issue["created_at"]),
|
||||
dateTimeConverter(issue["updated_at"]),
|
||||
dateTimeConverter(issue["completed_at"]),
|
||||
dateTimeConverter(issue["archived_at"]),
|
||||
]
|
||||
|
||||
|
||||
def generate_json_row(issue):
|
||||
return {
|
||||
"ID": f"""{issue["project__identifier"]}-{issue["sequence_id"]}""",
|
||||
"Project": issue["project__name"],
|
||||
"Name": issue["name"],
|
||||
"Description": issue["description_stripped"],
|
||||
"State": issue["state__name"],
|
||||
"Priority": issue["priority"],
|
||||
"Created By": f"{issue['created_by__first_name']} {issue['created_by__last_name']}"
|
||||
if issue["created_by__first_name"] and issue["created_by__last_name"]
|
||||
else "",
|
||||
"Assignee": f"{issue['assignees__first_name']} {issue['assignees__last_name']}"
|
||||
if issue["assignees__first_name"] and issue["assignees__last_name"]
|
||||
else "",
|
||||
"Labels": issue["labels__name"],
|
||||
"Cycle Name": issue["issue_cycle__cycle__name"],
|
||||
"Cycle Start Date": dateConverter(issue["issue_cycle__cycle__start_date"]),
|
||||
"Cycle End Date": dateConverter(issue["issue_cycle__cycle__end_date"]),
|
||||
"Module Name": issue["issue_module__module__name"],
|
||||
"Module Start Date": dateConverter(issue["issue_module__module__start_date"]),
|
||||
"Module Target Date": dateConverter(issue["issue_module__module__target_date"]),
|
||||
"Created At": dateTimeConverter(issue["created_at"]),
|
||||
"Updated At": dateTimeConverter(issue["updated_at"]),
|
||||
"Completed At": dateTimeConverter(issue["completed_at"]),
|
||||
"Archived At": dateTimeConverter(issue["archived_at"]),
|
||||
}
|
||||
|
||||
|
||||
def update_json_row(rows, row):
|
||||
matched_index = next(
|
||||
(
|
||||
index
|
||||
for index, existing_row in enumerate(rows)
|
||||
if existing_row["ID"] == row["ID"]
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
if matched_index is not None:
|
||||
existing_assignees, existing_labels = (
|
||||
rows[matched_index]["Assignee"],
|
||||
rows[matched_index]["Labels"],
|
||||
)
|
||||
assignee, label = row["Assignee"], row["Labels"]
|
||||
|
||||
if assignee is not None and assignee not in existing_assignees:
|
||||
rows[matched_index]["Assignee"] += f", {assignee}"
|
||||
if label is not None and label not in existing_labels:
|
||||
rows[matched_index]["Labels"] += f", {label}"
|
||||
else:
|
||||
rows.append(row)
|
||||
|
||||
|
||||
def update_table_row(rows, row):
|
||||
matched_index = next(
|
||||
(index for index, existing_row in enumerate(rows) if existing_row[0] == row[0]),
|
||||
None,
|
||||
)
|
||||
|
||||
if matched_index is not None:
|
||||
existing_assignees, existing_labels = rows[matched_index][7:9]
|
||||
assignee, label = row[7:9]
|
||||
|
||||
if assignee is not None and assignee not in existing_assignees:
|
||||
rows[matched_index][7] += f", {assignee}"
|
||||
if label is not None and label not in existing_labels:
|
||||
rows[matched_index][8] += f", {label}"
|
||||
else:
|
||||
rows.append(row)
|
||||
|
||||
|
||||
def generate_csv(header, project_id, issues, files):
|
||||
"""
|
||||
Generate CSV export for all the passed issues.
|
||||
"""
|
||||
rows = [
|
||||
header,
|
||||
]
|
||||
for issue in issues:
|
||||
row = generate_table_row(issue)
|
||||
update_table_row(rows, row)
|
||||
csv_file = create_csv_file(rows)
|
||||
files.append((f"{project_id}.csv", csv_file))
|
||||
|
||||
|
||||
def generate_json(header, project_id, issues, files):
|
||||
rows = []
|
||||
for issue in issues:
|
||||
row = generate_json_row(issue)
|
||||
update_json_row(rows, row)
|
||||
json_file = create_json_file(rows)
|
||||
files.append((f"{project_id}.json", json_file))
|
||||
|
||||
|
||||
def generate_xlsx(header, project_id, issues, files):
|
||||
rows = [header]
|
||||
for issue in issues:
|
||||
row = generate_table_row(issue)
|
||||
update_table_row(rows, row)
|
||||
xlsx_file = create_xlsx_file(rows)
|
||||
files.append((f"{project_id}.xlsx", xlsx_file))
|
||||
|
||||
|
||||
@shared_task
|
||||
def issue_export_task(provider, workspace_id, project_ids, token_id, multiple, slug):
|
||||
try:
|
||||
exporter_instance = ExporterHistory.objects.get(token=token_id)
|
||||
exporter_instance.status = "processing"
|
||||
exporter_instance.save(update_fields=["status"])
|
||||
|
||||
workspace_issues = (
|
||||
(
|
||||
Issue.objects.filter(
|
||||
workspace__id=workspace_id, project_id__in=project_ids
|
||||
)
|
||||
.select_related("project", "workspace", "state", "parent", "created_by")
|
||||
.prefetch_related(
|
||||
"assignees", "labels", "issue_cycle__cycle", "issue_module__module"
|
||||
)
|
||||
.values(
|
||||
"id",
|
||||
"project__identifier",
|
||||
"project__name",
|
||||
"project__id",
|
||||
"sequence_id",
|
||||
"name",
|
||||
"description_stripped",
|
||||
"priority",
|
||||
"state__name",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"completed_at",
|
||||
"archived_at",
|
||||
"issue_cycle__cycle__name",
|
||||
"issue_cycle__cycle__start_date",
|
||||
"issue_cycle__cycle__end_date",
|
||||
"issue_module__module__name",
|
||||
"issue_module__module__start_date",
|
||||
"issue_module__module__target_date",
|
||||
"created_by__first_name",
|
||||
"created_by__last_name",
|
||||
"assignees__first_name",
|
||||
"assignees__last_name",
|
||||
"labels__name",
|
||||
)
|
||||
)
|
||||
.order_by("project__identifier","sequence_id")
|
||||
.distinct()
|
||||
)
|
||||
# CSV header
|
||||
header = [
|
||||
"ID",
|
||||
"Project",
|
||||
"Name",
|
||||
"Description",
|
||||
"State",
|
||||
"Priority",
|
||||
"Created By",
|
||||
"Assignee",
|
||||
"Labels",
|
||||
"Cycle Name",
|
||||
"Cycle Start Date",
|
||||
"Cycle End Date",
|
||||
"Module Name",
|
||||
"Module Start Date",
|
||||
"Module Target Date",
|
||||
"Created At",
|
||||
"Updated At",
|
||||
"Completed At",
|
||||
"Archived At",
|
||||
]
|
||||
|
||||
EXPORTER_MAPPER = {
|
||||
"csv": generate_csv,
|
||||
"json": generate_json,
|
||||
"xlsx": generate_xlsx,
|
||||
}
|
||||
|
||||
files = []
|
||||
if multiple:
|
||||
for project_id in project_ids:
|
||||
issues = workspace_issues.filter(project__id=project_id)
|
||||
exporter = EXPORTER_MAPPER.get(provider)
|
||||
if exporter is not None:
|
||||
exporter(
|
||||
header,
|
||||
project_id,
|
||||
issues,
|
||||
files,
|
||||
)
|
||||
|
||||
else:
|
||||
exporter = EXPORTER_MAPPER.get(provider)
|
||||
if exporter is not None:
|
||||
exporter(
|
||||
header,
|
||||
workspace_id,
|
||||
workspace_issues,
|
||||
files,
|
||||
)
|
||||
|
||||
zip_buffer = create_zip_file(files)
|
||||
upload_to_s3(zip_buffer, workspace_id, token_id, slug)
|
||||
|
||||
except Exception as e:
|
||||
exporter_instance = ExporterHistory.objects.get(token=token_id)
|
||||
exporter_instance.status = "failed"
|
||||
exporter_instance.reason = str(e)
|
||||
exporter_instance.save(update_fields=["status", "reason"])
|
||||
|
||||
# Print logs if in DEBUG mode
|
||||
if settings.DEBUG:
|
||||
print(e)
|
||||
capture_exception(e)
|
||||
return
|
||||
@@ -1,38 +0,0 @@
|
||||
# Python imports
|
||||
import boto3
|
||||
from datetime import timedelta
|
||||
|
||||
# Django imports
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
from django.db.models import Q
|
||||
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
from botocore.client import Config
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import ExporterHistory
|
||||
|
||||
|
||||
@shared_task
|
||||
def delete_old_s3_link():
|
||||
# Get a list of keys and IDs to process
|
||||
expired_exporter_history = ExporterHistory.objects.filter(
|
||||
Q(url__isnull=False) & Q(created_at__lte=timezone.now() - timedelta(days=8))
|
||||
).values_list("key", "id")
|
||||
|
||||
s3 = boto3.client(
|
||||
"s3",
|
||||
region_name="ap-south-1",
|
||||
aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
|
||||
aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
|
||||
config=Config(signature_version="s3v4"),
|
||||
)
|
||||
|
||||
for file_name, exporter_id in expired_exporter_history:
|
||||
# Delete object from S3
|
||||
if file_name:
|
||||
s3.delete_object(Bucket=settings.AWS_S3_BUCKET_NAME, Key=file_name)
|
||||
|
||||
ExporterHistory.objects.filter(id=exporter_id).update(url=None)
|
||||
@@ -184,24 +184,19 @@ def track_description(
|
||||
if current_instance.get("description_html") != requested_data.get(
|
||||
"description_html"
|
||||
):
|
||||
last_activity = IssueActivity.objects.filter(issue_id=issue_id).order_by("-created_at").first()
|
||||
if(last_activity is not None and last_activity.field == "description" and actor.id == last_activity.actor_id):
|
||||
last_activity.created_at = timezone.now()
|
||||
last_activity.save(update_fields=["created_at"])
|
||||
else:
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
issue_id=issue_id,
|
||||
actor=actor,
|
||||
verb="updated",
|
||||
old_value=current_instance.get("description_html"),
|
||||
new_value=requested_data.get("description_html"),
|
||||
field="description",
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"updated the description to {requested_data.get('description_html')}",
|
||||
)
|
||||
)
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
issue_id=issue_id,
|
||||
actor=actor,
|
||||
verb="updated",
|
||||
old_value=current_instance.get("description_html"),
|
||||
new_value=requested_data.get("description_html"),
|
||||
field="description",
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"updated the description to {requested_data.get('description_html')}",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# Track changes in issue target date
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
# Python imports
|
||||
import csv
|
||||
import io
|
||||
|
||||
# Django imports
|
||||
from django.core.mail import EmailMultiAlternatives
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.html import strip_tags
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
from sentry_sdk import capture_exception
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import Issue
|
||||
|
||||
@shared_task
|
||||
def issue_export_task(email, data, slug, exporter_name):
|
||||
try:
|
||||
|
||||
project_ids = data.get("project_id", [])
|
||||
issues_filter = {"workspace__slug": slug}
|
||||
|
||||
if project_ids:
|
||||
issues_filter["project_id__in"] = project_ids
|
||||
|
||||
issues = (
|
||||
Issue.objects.filter(**issues_filter)
|
||||
.select_related("project", "workspace", "state", "parent", "created_by")
|
||||
.prefetch_related(
|
||||
"assignees", "labels", "issue_cycle__cycle", "issue_module__module"
|
||||
)
|
||||
.values_list(
|
||||
"project__identifier",
|
||||
"sequence_id",
|
||||
"name",
|
||||
"description_stripped",
|
||||
"priority",
|
||||
"start_date",
|
||||
"target_date",
|
||||
"state__name",
|
||||
"project__name",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"completed_at",
|
||||
"archived_at",
|
||||
"issue_cycle__cycle__name",
|
||||
"issue_cycle__cycle__start_date",
|
||||
"issue_cycle__cycle__end_date",
|
||||
"issue_module__module__name",
|
||||
"issue_module__module__start_date",
|
||||
"issue_module__module__target_date",
|
||||
"created_by__first_name",
|
||||
"created_by__last_name",
|
||||
"assignees__first_name",
|
||||
"assignees__last_name",
|
||||
"labels__name",
|
||||
)
|
||||
)
|
||||
|
||||
# CSV header
|
||||
header = [
|
||||
"Issue ID",
|
||||
"Project",
|
||||
"Name",
|
||||
"Description",
|
||||
"State",
|
||||
"Priority",
|
||||
"Created By",
|
||||
"Assignee",
|
||||
"Labels",
|
||||
"Cycle Name",
|
||||
"Cycle Start Date",
|
||||
"Cycle End Date",
|
||||
"Module Name",
|
||||
"Module Start Date",
|
||||
"Module Target Date",
|
||||
"Created At"
|
||||
"Updated At"
|
||||
"Completed At"
|
||||
"Archived At"
|
||||
]
|
||||
|
||||
# Prepare the CSV data
|
||||
rows = [header]
|
||||
|
||||
# Write data for each issue
|
||||
for issue in issues:
|
||||
(
|
||||
project_identifier,
|
||||
sequence_id,
|
||||
name,
|
||||
description,
|
||||
priority,
|
||||
start_date,
|
||||
target_date,
|
||||
state_name,
|
||||
project_name,
|
||||
created_at,
|
||||
updated_at,
|
||||
completed_at,
|
||||
archived_at,
|
||||
cycle_name,
|
||||
cycle_start_date,
|
||||
cycle_end_date,
|
||||
module_name,
|
||||
module_start_date,
|
||||
module_target_date,
|
||||
created_by_first_name,
|
||||
created_by_last_name,
|
||||
assignees_first_names,
|
||||
assignees_last_names,
|
||||
labels_names,
|
||||
) = issue
|
||||
|
||||
created_by_fullname = (
|
||||
f"{created_by_first_name} {created_by_last_name}"
|
||||
if created_by_first_name and created_by_last_name
|
||||
else ""
|
||||
)
|
||||
|
||||
assignees_names = ""
|
||||
if assignees_first_names and assignees_last_names:
|
||||
assignees_names = ", ".join(
|
||||
[
|
||||
f"{assignees_first_name} {assignees_last_name}"
|
||||
for assignees_first_name, assignees_last_name in zip(
|
||||
assignees_first_names, assignees_last_names
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
labels_names = ", ".join(labels_names) if labels_names else ""
|
||||
|
||||
row = [
|
||||
f"{project_identifier}-{sequence_id}",
|
||||
project_name,
|
||||
name,
|
||||
description,
|
||||
state_name,
|
||||
priority,
|
||||
created_by_fullname,
|
||||
assignees_names,
|
||||
labels_names,
|
||||
cycle_name,
|
||||
cycle_start_date,
|
||||
cycle_end_date,
|
||||
module_name,
|
||||
module_start_date,
|
||||
module_target_date,
|
||||
start_date,
|
||||
target_date,
|
||||
created_at,
|
||||
updated_at,
|
||||
completed_at,
|
||||
archived_at,
|
||||
]
|
||||
rows.append(row)
|
||||
|
||||
# Create CSV file in-memory
|
||||
csv_buffer = io.StringIO()
|
||||
writer = csv.writer(csv_buffer, delimiter=",", quoting=csv.QUOTE_ALL)
|
||||
|
||||
# Write CSV data to the buffer
|
||||
for row in rows:
|
||||
writer.writerow(row)
|
||||
|
||||
subject = "Your Issue Export is ready"
|
||||
|
||||
context = {
|
||||
"username": exporter_name,
|
||||
}
|
||||
|
||||
html_content = render_to_string("emails/exports/issues.html", context)
|
||||
text_content = strip_tags(html_content)
|
||||
|
||||
csv_buffer.seek(0)
|
||||
msg = EmailMultiAlternatives(
|
||||
subject, text_content, settings.EMAIL_FROM, [email]
|
||||
)
|
||||
msg.attach(f"{slug}-issues-{timezone.now().date()}.csv", csv_buffer.read(), "text/csv")
|
||||
msg.send(fail_silently=False)
|
||||
|
||||
except Exception as e:
|
||||
# Print logs if in DEBUG mode
|
||||
if settings.DEBUG:
|
||||
print(e)
|
||||
capture_exception(e)
|
||||
return
|
||||
@@ -20,10 +20,6 @@ app.conf.beat_schedule = {
|
||||
"task": "plane.bgtasks.issue_automation_task.archive_and_close_old_issues",
|
||||
"schedule": crontab(hour=0, minute=0),
|
||||
},
|
||||
"check-every-day-to-delete_exporter_history": {
|
||||
"task": "plane.bgtasks.exporter_expired_task.delete_old_s3_link",
|
||||
"schedule": crontab(hour=0, minute=0),
|
||||
},
|
||||
}
|
||||
|
||||
# Load task modules from all registered Django app configs.
|
||||
|
||||
@@ -1,243 +0,0 @@
|
||||
# Generated by Django 4.2.3 on 2023-08-14 07:12
|
||||
|
||||
from django.conf import settings
|
||||
import django.contrib.postgres.fields
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import plane.db.models.exporter
|
||||
import plane.db.models.project
|
||||
import uuid
|
||||
import random
|
||||
import string
|
||||
|
||||
def generate_display_name(apps, schema_editor):
|
||||
UserModel = apps.get_model("db", "User")
|
||||
updated_users = []
|
||||
for obj in UserModel.objects.all():
|
||||
obj.display_name = (
|
||||
obj.email.split("@")[0]
|
||||
if len(obj.email.split("@"))
|
||||
else "".join(random.choice(string.ascii_letters) for _ in range(6))
|
||||
)
|
||||
updated_users.append(obj)
|
||||
UserModel.objects.bulk_update(updated_users, ["display_name"], batch_size=100)
|
||||
|
||||
|
||||
def rectify_field_issue_activity(apps, schema_editor):
|
||||
Model = apps.get_model("db", "IssueActivity")
|
||||
updated_activity = []
|
||||
for obj in Model.objects.filter(field="assignee"):
|
||||
obj.field = "assignees"
|
||||
updated_activity.append(obj)
|
||||
|
||||
Model.objects.bulk_update(updated_activity, ["field"], batch_size=100)
|
||||
|
||||
|
||||
def update_assignee_issue_activity(apps, schema_editor):
|
||||
Model = apps.get_model("db", "IssueActivity")
|
||||
updated_activity = []
|
||||
|
||||
# Get all the users
|
||||
User = apps.get_model("db", "User")
|
||||
users = User.objects.values("id", "email", "display_name")
|
||||
|
||||
for obj in Model.objects.filter(field="assignees"):
|
||||
if bool(obj.new_value) and not bool(obj.old_value):
|
||||
# Get user from list
|
||||
assigned_user = [
|
||||
user for user in users if user.get("email") == obj.new_value
|
||||
]
|
||||
if assigned_user:
|
||||
obj.new_value = assigned_user[0].get("display_name")
|
||||
obj.new_identifier = assigned_user[0].get("id")
|
||||
# Update the comment
|
||||
words = obj.comment.split()
|
||||
words[-1] = assigned_user[0].get("display_name")
|
||||
obj.comment = " ".join(words)
|
||||
|
||||
if bool(obj.old_value) and not bool(obj.new_value):
|
||||
# Get user from list
|
||||
assigned_user = [
|
||||
user for user in users if user.get("email") == obj.old_value
|
||||
]
|
||||
if assigned_user:
|
||||
obj.old_value = assigned_user[0].get("display_name")
|
||||
obj.old_identifier = assigned_user[0].get("id")
|
||||
# Update the comment
|
||||
words = obj.comment.split()
|
||||
words[-1] = assigned_user[0].get("display_name")
|
||||
obj.comment = " ".join(words)
|
||||
|
||||
updated_activity.append(obj)
|
||||
|
||||
Model.objects.bulk_update(
|
||||
updated_activity,
|
||||
["old_value", "new_value", "old_identifier", "new_identifier", "comment"],
|
||||
batch_size=200,
|
||||
)
|
||||
|
||||
|
||||
def update_name_activity(apps, schema_editor):
|
||||
Model = apps.get_model("db", "IssueActivity")
|
||||
update_activity = []
|
||||
for obj in Model.objects.filter(field="name"):
|
||||
obj.comment = obj.comment.replace("start date", "name")
|
||||
update_activity.append(obj)
|
||||
|
||||
Model.objects.bulk_update(update_activity, ["comment"], batch_size=1000)
|
||||
|
||||
|
||||
def random_cycle_order(apps, schema_editor):
|
||||
CycleModel = apps.get_model("db", "Cycle")
|
||||
updated_cycles = []
|
||||
for obj in CycleModel.objects.all():
|
||||
obj.sort_order = random.randint(1, 65536)
|
||||
updated_cycles.append(obj)
|
||||
CycleModel.objects.bulk_update(updated_cycles, ["sort_order"], batch_size=100)
|
||||
|
||||
|
||||
def random_module_order(apps, schema_editor):
|
||||
ModuleModel = apps.get_model("db", "Module")
|
||||
updated_modules = []
|
||||
for obj in ModuleModel.objects.all():
|
||||
obj.sort_order = random.randint(1, 65536)
|
||||
updated_modules.append(obj)
|
||||
ModuleModel.objects.bulk_update(updated_modules, ["sort_order"], batch_size=100)
|
||||
|
||||
|
||||
def update_user_issue_properties(apps, schema_editor):
|
||||
IssuePropertyModel = apps.get_model("db", "IssueProperty")
|
||||
updated_issue_properties = []
|
||||
for obj in IssuePropertyModel.objects.all():
|
||||
obj.properties["start_date"] = True
|
||||
updated_issue_properties.append(obj)
|
||||
IssuePropertyModel.objects.bulk_update(
|
||||
updated_issue_properties, ["properties"], batch_size=100
|
||||
)
|
||||
|
||||
|
||||
def workspace_member_properties(apps, schema_editor):
|
||||
WorkspaceMemberModel = apps.get_model("db", "WorkspaceMember")
|
||||
updated_workspace_members = []
|
||||
for obj in WorkspaceMemberModel.objects.all():
|
||||
obj.view_props["properties"]["start_date"] = True
|
||||
obj.default_props["properties"]["start_date"] = True
|
||||
updated_workspace_members.append(obj)
|
||||
|
||||
WorkspaceMemberModel.objects.bulk_update(
|
||||
updated_workspace_members, ["view_props", "default_props"], batch_size=100
|
||||
)
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('db', '0040_projectmember_preferences_user_cover_image_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='cycle',
|
||||
name='sort_order',
|
||||
field=models.FloatField(default=65535),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='issuecomment',
|
||||
name='access',
|
||||
field=models.CharField(choices=[('INTERNAL', 'INTERNAL'), ('EXTERNAL', 'EXTERNAL')], default='INTERNAL', max_length=100),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='module',
|
||||
name='sort_order',
|
||||
field=models.FloatField(default=65535),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='user',
|
||||
name='display_name',
|
||||
field=models.CharField(default='', max_length=255),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ExporterHistory',
|
||||
fields=[
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')),
|
||||
('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
|
||||
('project', django.contrib.postgres.fields.ArrayField(base_field=models.UUIDField(default=uuid.uuid4), blank=True, null=True, size=None)),
|
||||
('provider', models.CharField(choices=[('json', 'json'), ('csv', 'csv'), ('xlsx', 'xlsx')], max_length=50)),
|
||||
('status', models.CharField(choices=[('queued', 'Queued'), ('processing', 'Processing'), ('completed', 'Completed'), ('failed', 'Failed')], default='queued', max_length=50)),
|
||||
('reason', models.TextField(blank=True)),
|
||||
('key', models.TextField(blank=True)),
|
||||
('url', models.URLField(blank=True, max_length=800, null=True)),
|
||||
('token', models.CharField(default=plane.db.models.exporter.generate_token, max_length=255, unique=True)),
|
||||
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
|
||||
('initiated_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_exporters', to=settings.AUTH_USER_MODEL)),
|
||||
('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')),
|
||||
('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_exporters', to='db.workspace')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Exporter',
|
||||
'verbose_name_plural': 'Exporters',
|
||||
'db_table': 'exporters',
|
||||
'ordering': ('-created_at',),
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ProjectDeployBoard',
|
||||
fields=[
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')),
|
||||
('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
|
||||
('anchor', models.CharField(db_index=True, default=plane.db.models.project.get_anchor, max_length=255, unique=True)),
|
||||
('comments', models.BooleanField(default=False)),
|
||||
('reactions', models.BooleanField(default=False)),
|
||||
('votes', models.BooleanField(default=False)),
|
||||
('views', models.JSONField(default=plane.db.models.project.get_default_views)),
|
||||
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
|
||||
('inbox', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='bord_inbox', to='db.inbox')),
|
||||
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project')),
|
||||
('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')),
|
||||
('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Project Deploy Board',
|
||||
'verbose_name_plural': 'Project Deploy Boards',
|
||||
'db_table': 'project_deploy_boards',
|
||||
'ordering': ('-created_at',),
|
||||
'unique_together': {('project', 'anchor')},
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='IssueVote',
|
||||
fields=[
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')),
|
||||
('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
|
||||
('vote', models.IntegerField(choices=[(-1, 'DOWNVOTE'), (1, 'UPVOTE')])),
|
||||
('actor', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='votes', to=settings.AUTH_USER_MODEL)),
|
||||
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
|
||||
('issue', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='votes', to='db.issue')),
|
||||
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project')),
|
||||
('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')),
|
||||
('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Issue Vote',
|
||||
'verbose_name_plural': 'Issue Votes',
|
||||
'db_table': 'issue_votes',
|
||||
'ordering': ('-created_at',),
|
||||
'unique_together': {('issue', 'actor')},
|
||||
},
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='modulelink',
|
||||
name='title',
|
||||
field=models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
migrations.RunPython(generate_display_name),
|
||||
migrations.RunPython(rectify_field_issue_activity),
|
||||
migrations.RunPython(update_assignee_issue_activity),
|
||||
migrations.RunPython(update_name_activity),
|
||||
migrations.RunPython(random_cycle_order),
|
||||
migrations.RunPython(random_module_order),
|
||||
migrations.RunPython(update_user_issue_properties),
|
||||
migrations.RunPython(workspace_member_properties),
|
||||
]
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
# Generated by Django 4.2.3 on 2023-08-04 09:12
|
||||
import string
|
||||
import random
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
def generate_display_name(apps, schema_editor):
|
||||
UserModel = apps.get_model("db", "User")
|
||||
updated_users = []
|
||||
for obj in UserModel.objects.all():
|
||||
obj.display_name = (
|
||||
obj.email.split("@")[0]
|
||||
if len(obj.email.split("@"))
|
||||
else "".join(random.choice(string.ascii_letters) for _ in range(6))
|
||||
)
|
||||
updated_users.append(obj)
|
||||
UserModel.objects.bulk_update(updated_users, ["display_name"], batch_size=100)
|
||||
|
||||
|
||||
def rectify_field_issue_activity(apps, schema_editor):
|
||||
Model = apps.get_model("db", "IssueActivity")
|
||||
updated_activity = []
|
||||
for obj in Model.objects.filter(field="assignee"):
|
||||
obj.field = "assignees"
|
||||
updated_activity.append(obj)
|
||||
|
||||
Model.objects.bulk_update(updated_activity, ["field"], batch_size=100)
|
||||
|
||||
|
||||
def update_assignee_issue_activity(apps, schema_editor):
|
||||
Model = apps.get_model("db", "IssueActivity")
|
||||
updated_activity = []
|
||||
|
||||
# Get all the users
|
||||
User = apps.get_model("db", "User")
|
||||
users = User.objects.values("id", "email", "display_name")
|
||||
|
||||
for obj in Model.objects.filter(field="assignees"):
|
||||
if bool(obj.new_value) and not bool(obj.old_value):
|
||||
# Get user from list
|
||||
assigned_user = [
|
||||
user for user in users if user.get("email") == obj.new_value
|
||||
]
|
||||
if assigned_user:
|
||||
obj.new_value = assigned_user[0].get("display_name")
|
||||
obj.new_identifier = assigned_user[0].get("id")
|
||||
# Update the comment
|
||||
words = obj.comment.split()
|
||||
words[-1] = assigned_user[0].get("display_name")
|
||||
obj.comment = " ".join(words)
|
||||
|
||||
if bool(obj.old_value) and not bool(obj.new_value):
|
||||
# Get user from list
|
||||
assigned_user = [
|
||||
user for user in users if user.get("email") == obj.old_value
|
||||
]
|
||||
if assigned_user:
|
||||
obj.old_value = assigned_user[0].get("display_name")
|
||||
obj.old_identifier = assigned_user[0].get("id")
|
||||
# Update the comment
|
||||
words = obj.comment.split()
|
||||
words[-1] = assigned_user[0].get("display_name")
|
||||
obj.comment = " ".join(words)
|
||||
|
||||
updated_activity.append(obj)
|
||||
|
||||
Model.objects.bulk_update(
|
||||
updated_activity,
|
||||
["old_value", "new_value", "old_identifier", "new_identifier", "comment"],
|
||||
batch_size=200,
|
||||
)
|
||||
|
||||
|
||||
def update_name_activity(apps, schema_editor):
|
||||
Model = apps.get_model("db", "IssueActivity")
|
||||
update_activity = []
|
||||
for obj in Model.objects.filter(field="name"):
|
||||
obj.comment = obj.comment.replace("start date", "name")
|
||||
update_activity.append(obj)
|
||||
|
||||
Model.objects.bulk_update(update_activity, ["comment"], batch_size=1000)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("db", "0040_projectmember_preferences_user_cover_image_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="display_name",
|
||||
field=models.CharField(default="", max_length=255),
|
||||
),
|
||||
migrations.RunPython(generate_display_name),
|
||||
migrations.RunPython(rectify_field_issue_activity),
|
||||
migrations.RunPython(update_assignee_issue_activity),
|
||||
migrations.RunPython(update_name_activity),
|
||||
]
|
||||
@@ -18,7 +18,6 @@ from .project import (
|
||||
ProjectMemberInvite,
|
||||
ProjectIdentifier,
|
||||
ProjectFavorite,
|
||||
ProjectDeployBoard,
|
||||
)
|
||||
|
||||
from .issue import (
|
||||
@@ -37,7 +36,6 @@ from .issue import (
|
||||
IssueSubscriber,
|
||||
IssueReaction,
|
||||
CommentReaction,
|
||||
IssueVote,
|
||||
)
|
||||
|
||||
from .asset import FileAsset
|
||||
@@ -74,6 +72,4 @@ from .inbox import Inbox, InboxIssue
|
||||
|
||||
from .analytic import AnalyticView
|
||||
|
||||
from .notification import Notification
|
||||
|
||||
from .exporter import ExporterHistory
|
||||
from .notification import Notification
|
||||
@@ -17,7 +17,6 @@ class Cycle(ProjectBaseModel):
|
||||
related_name="owned_by_cycle",
|
||||
)
|
||||
view_props = models.JSONField(default=dict)
|
||||
sort_order = models.FloatField(default=65535)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Cycle"
|
||||
@@ -25,17 +24,6 @@ class Cycle(ProjectBaseModel):
|
||||
db_table = "cycles"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if self._state.adding:
|
||||
smallest_sort_order = Cycle.objects.filter(
|
||||
project=self.project
|
||||
).aggregate(smallest=models.Min("sort_order"))["smallest"]
|
||||
|
||||
if smallest_sort_order is not None:
|
||||
self.sort_order = smallest_sort_order - 10000
|
||||
|
||||
super(Cycle, self).save(*args, **kwargs)
|
||||
|
||||
def __str__(self):
|
||||
"""Return name of the cycle"""
|
||||
return f"{self.name} <{self.project.name}>"
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
import uuid
|
||||
|
||||
# Python imports
|
||||
from uuid import uuid4
|
||||
|
||||
# Django imports
|
||||
from django.db import models
|
||||
from django.conf import settings
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
|
||||
# Module imports
|
||||
from . import BaseModel
|
||||
|
||||
def generate_token():
|
||||
return uuid4().hex
|
||||
|
||||
class ExporterHistory(BaseModel):
|
||||
workspace = models.ForeignKey(
|
||||
"db.WorkSpace", on_delete=models.CASCADE, related_name="workspace_exporters"
|
||||
)
|
||||
project = ArrayField(models.UUIDField(default=uuid.uuid4), blank=True, null=True)
|
||||
provider = models.CharField(
|
||||
max_length=50,
|
||||
choices=(
|
||||
("json", "json"),
|
||||
("csv", "csv"),
|
||||
("xlsx", "xlsx"),
|
||||
),
|
||||
)
|
||||
status = models.CharField(
|
||||
max_length=50,
|
||||
choices=(
|
||||
("queued", "Queued"),
|
||||
("processing", "Processing"),
|
||||
("completed", "Completed"),
|
||||
("failed", "Failed"),
|
||||
),
|
||||
default="queued",
|
||||
)
|
||||
reason = models.TextField(blank=True)
|
||||
key = models.TextField(blank=True)
|
||||
url = models.URLField(max_length=800, blank=True, null=True)
|
||||
token = models.CharField(max_length=255, default=generate_token, unique=True)
|
||||
initiated_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="workspace_exporters"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Exporter"
|
||||
verbose_name_plural = "Exporters"
|
||||
db_table = "exporters"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def __str__(self):
|
||||
"""Return name of the service"""
|
||||
return f"{self.provider} <{self.workspace.name}>"
|
||||
@@ -108,7 +108,11 @@ class Issue(ProjectBaseModel):
|
||||
~models.Q(name="Triage"), project=self.project
|
||||
).first()
|
||||
self.state = random_state
|
||||
if random_state.group == "started":
|
||||
self.start_date = timezone.now().date()
|
||||
else:
|
||||
if default_state.group == "started":
|
||||
self.start_date = timezone.now().date()
|
||||
self.state = default_state
|
||||
except ImportError:
|
||||
pass
|
||||
@@ -123,6 +127,8 @@ class Issue(ProjectBaseModel):
|
||||
PageBlock.objects.filter(issue_id=self.id).filter().update(
|
||||
completed_at=timezone.now()
|
||||
)
|
||||
elif self.state.group == "started":
|
||||
self.start_date = timezone.now().date()
|
||||
else:
|
||||
PageBlock.objects.filter(issue_id=self.id).filter().update(
|
||||
completed_at=None
|
||||
@@ -147,6 +153,9 @@ class Issue(ProjectBaseModel):
|
||||
if largest_sort_order is not None:
|
||||
self.sort_order = largest_sort_order + 10000
|
||||
|
||||
# If adding it to started state
|
||||
if self.state.group == "started":
|
||||
self.start_date = timezone.now().date()
|
||||
# Strip the html tags using html parser
|
||||
self.description_stripped = (
|
||||
None
|
||||
@@ -301,14 +310,6 @@ class IssueComment(ProjectBaseModel):
|
||||
related_name="comments",
|
||||
null=True,
|
||||
)
|
||||
access = models.CharField(
|
||||
choices=(
|
||||
("INTERNAL", "INTERNAL"),
|
||||
("EXTERNAL", "EXTERNAL"),
|
||||
),
|
||||
default="INTERNAL",
|
||||
max_length=100,
|
||||
)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.comment_stripped = (
|
||||
@@ -424,14 +425,13 @@ class IssueSubscriber(ProjectBaseModel):
|
||||
|
||||
|
||||
class IssueReaction(ProjectBaseModel):
|
||||
|
||||
actor = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="issue_reactions",
|
||||
)
|
||||
issue = models.ForeignKey(
|
||||
Issue, on_delete=models.CASCADE, related_name="issue_reactions"
|
||||
)
|
||||
issue = models.ForeignKey(Issue, on_delete=models.CASCADE, related_name="issue_reactions")
|
||||
reaction = models.CharField(max_length=20)
|
||||
|
||||
class Meta:
|
||||
@@ -446,14 +446,13 @@ class IssueReaction(ProjectBaseModel):
|
||||
|
||||
|
||||
class CommentReaction(ProjectBaseModel):
|
||||
|
||||
actor = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="comment_reactions",
|
||||
)
|
||||
comment = models.ForeignKey(
|
||||
IssueComment, on_delete=models.CASCADE, related_name="comment_reactions"
|
||||
)
|
||||
comment = models.ForeignKey(IssueComment, on_delete=models.CASCADE, related_name="comment_reactions")
|
||||
reaction = models.CharField(max_length=20)
|
||||
|
||||
class Meta:
|
||||
@@ -467,27 +466,6 @@ class CommentReaction(ProjectBaseModel):
|
||||
return f"{self.issue.name} {self.actor.email}"
|
||||
|
||||
|
||||
class IssueVote(ProjectBaseModel):
|
||||
issue = models.ForeignKey(Issue, on_delete=models.CASCADE, related_name="votes")
|
||||
actor = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="votes"
|
||||
)
|
||||
vote = models.IntegerField(
|
||||
choices=(
|
||||
(-1, "DOWNVOTE"),
|
||||
(1, "UPVOTE"),
|
||||
)
|
||||
)
|
||||
class Meta:
|
||||
unique_together = ["issue", "actor"]
|
||||
verbose_name = "Issue Vote"
|
||||
verbose_name_plural = "Issue Votes"
|
||||
db_table = "issue_votes"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.issue.name} {self.actor.email}"
|
||||
|
||||
|
||||
# TODO: Find a better method to save the model
|
||||
@receiver(post_save, sender=Issue)
|
||||
|
||||
@@ -40,7 +40,6 @@ class Module(ProjectBaseModel):
|
||||
through_fields=("module", "member"),
|
||||
)
|
||||
view_props = models.JSONField(default=dict)
|
||||
sort_order = models.FloatField(default=65535)
|
||||
|
||||
class Meta:
|
||||
unique_together = ["name", "project"]
|
||||
@@ -49,17 +48,6 @@ class Module(ProjectBaseModel):
|
||||
db_table = "modules"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if self._state.adding:
|
||||
smallest_sort_order = Module.objects.filter(
|
||||
project=self.project
|
||||
).aggregate(smallest=models.Min("sort_order"))["smallest"]
|
||||
|
||||
if smallest_sort_order is not None:
|
||||
self.sort_order = smallest_sort_order - 10000
|
||||
|
||||
super(Module, self).save(*args, **kwargs)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name} {self.start_date} {self.target_date}"
|
||||
|
||||
@@ -98,7 +86,7 @@ class ModuleIssue(ProjectBaseModel):
|
||||
|
||||
|
||||
class ModuleLink(ProjectBaseModel):
|
||||
title = models.CharField(max_length=255, blank=True, null=True)
|
||||
title = models.CharField(max_length=255, null=True)
|
||||
url = models.URLField()
|
||||
module = models.ForeignKey(
|
||||
Module, on_delete=models.CASCADE, related_name="link_module"
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
# Python imports
|
||||
from uuid import uuid4
|
||||
|
||||
# Django imports
|
||||
from django.db import models
|
||||
from django.conf import settings
|
||||
@@ -34,9 +31,12 @@ def get_default_props():
|
||||
"showEmptyGroups": True,
|
||||
}
|
||||
|
||||
|
||||
def get_default_preferences():
|
||||
return {"pages": {"block_display": True}}
|
||||
return {
|
||||
"pages": {
|
||||
"block_display": True
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class Project(BaseModel):
|
||||
@@ -157,6 +157,7 @@ class ProjectMember(ProjectBaseModel):
|
||||
preferences = models.JSONField(default=get_default_preferences)
|
||||
sort_order = models.FloatField(default=65535)
|
||||
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if self._state.adding:
|
||||
smallest_sort_order = ProjectMember.objects.filter(
|
||||
@@ -216,41 +217,3 @@ class ProjectFavorite(ProjectBaseModel):
|
||||
def __str__(self):
|
||||
"""Return user of the project"""
|
||||
return f"{self.user.email} <{self.project.name}>"
|
||||
|
||||
|
||||
def get_anchor():
|
||||
return uuid4().hex
|
||||
|
||||
|
||||
def get_default_views():
|
||||
return {
|
||||
"list": True,
|
||||
"kanban": True,
|
||||
"calendar": True,
|
||||
"gantt": True,
|
||||
"spreadsheet": True,
|
||||
}
|
||||
|
||||
|
||||
class ProjectDeployBoard(ProjectBaseModel):
|
||||
anchor = models.CharField(
|
||||
max_length=255, default=get_anchor, unique=True, db_index=True
|
||||
)
|
||||
comments = models.BooleanField(default=False)
|
||||
reactions = models.BooleanField(default=False)
|
||||
inbox = models.ForeignKey(
|
||||
"db.Inbox", related_name="bord_inbox", on_delete=models.SET_NULL, null=True
|
||||
)
|
||||
votes = models.BooleanField(default=False)
|
||||
views = models.JSONField(default=get_default_views)
|
||||
|
||||
class Meta:
|
||||
unique_together = ["project", "anchor"]
|
||||
verbose_name = "Project Deploy Board"
|
||||
verbose_name_plural = "Project Deploy Boards"
|
||||
db_table = "project_deploy_boards"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def __str__(self):
|
||||
"""Return project and anchor"""
|
||||
return f"{self.anchor} <{self.project.name}>"
|
||||
|
||||
@@ -33,7 +33,6 @@ def get_default_props():
|
||||
"estimate": True,
|
||||
"created_on": True,
|
||||
"updated_on": True,
|
||||
"start_date": True,
|
||||
},
|
||||
"showEmptyGroups": True,
|
||||
}
|
||||
|
||||
@@ -214,4 +214,4 @@ SIMPLE_JWT = {
|
||||
CELERY_TIMEZONE = TIME_ZONE
|
||||
CELERY_TASK_SERIALIZER = 'json'
|
||||
CELERY_ACCEPT_CONTENT = ['application/json']
|
||||
CELERY_IMPORTS = ("plane.bgtasks.issue_automation_task","plane.bgtasks.exporter_expired_task")
|
||||
CELERY_IMPORTS = ("plane.bgtasks.issue_automation_task",)
|
||||
|
||||
@@ -124,11 +124,10 @@ def filter_created_at(params, filter, method):
|
||||
else:
|
||||
if params.get("created_at", None) and len(params.get("created_at")):
|
||||
for query in params.get("created_at"):
|
||||
created_at_query = query.split(";")
|
||||
if len(created_at_query) == 2 and "after" in created_at_query:
|
||||
filter["created_at__date__gte"] = created_at_query[0]
|
||||
if query.get("timeline", "after") == "after":
|
||||
filter["created_at__date__gte"] = query.get("datetime")
|
||||
else:
|
||||
filter["created_at__date__lte"] = created_at_query[0]
|
||||
filter["created_at__date__lte"] = query.get("datetime")
|
||||
return filter
|
||||
|
||||
|
||||
@@ -145,11 +144,10 @@ def filter_updated_at(params, filter, method):
|
||||
else:
|
||||
if params.get("updated_at", None) and len(params.get("updated_at")):
|
||||
for query in params.get("updated_at"):
|
||||
updated_at_query = query.split(";")
|
||||
if len(updated_at_query) == 2 and "after" in updated_at_query:
|
||||
filter["updated_at__date__gte"] = updated_at_query[0]
|
||||
if query.get("timeline", "after") == "after":
|
||||
filter["updated_at__date__gte"] = query.get("datetime")
|
||||
else:
|
||||
filter["updated_at__date__lte"] = updated_at_query[0]
|
||||
filter["updated_at__date__lte"] = query.get("datetime")
|
||||
return filter
|
||||
|
||||
|
||||
@@ -166,11 +164,10 @@ def filter_start_date(params, filter, method):
|
||||
else:
|
||||
if params.get("start_date", None) and len(params.get("start_date")):
|
||||
for query in params.get("start_date"):
|
||||
start_date_query = query.split(";")
|
||||
if len(start_date_query) == 2 and "after" in start_date_query:
|
||||
filter["start_date__gte"] = start_date_query[0]
|
||||
if query.get("timeline", "after") == "after":
|
||||
filter["start_date__gte"] = query.get("datetime")
|
||||
else:
|
||||
filter["start_date__lte"] = start_date_query[0]
|
||||
filter["start_date__lte"] = query.get("datetime")
|
||||
return filter
|
||||
|
||||
|
||||
@@ -187,11 +184,10 @@ def filter_target_date(params, filter, method):
|
||||
else:
|
||||
if params.get("target_date", None) and len(params.get("target_date")):
|
||||
for query in params.get("target_date"):
|
||||
target_date_query = query.split(";")
|
||||
if len(target_date_query) == 2 and "after" in target_date_query:
|
||||
filter["target_date__gt"] = target_date_query[0]
|
||||
if query.get("timeline", "after") == "after":
|
||||
filter["target_date__gt"] = query.get("datetime")
|
||||
else:
|
||||
filter["target_date__lt"] = target_date_query[0]
|
||||
filter["target_date__lt"] = query.get("datetime")
|
||||
|
||||
return filter
|
||||
|
||||
@@ -209,11 +205,10 @@ def filter_completed_at(params, filter, method):
|
||||
else:
|
||||
if params.get("completed_at", None) and len(params.get("completed_at")):
|
||||
for query in params.get("completed_at"):
|
||||
completed_at_query = query.split(";")
|
||||
if len(completed_at_query) == 2 and "after" in completed_at_query:
|
||||
filter["completed_at__date__gte"] = completed_at_query[0]
|
||||
if query.get("timeline", "after") == "after":
|
||||
filter["completed_at__date__gte"] = query.get("datetime")
|
||||
else:
|
||||
filter["completed_at__lte"] = completed_at_query[0]
|
||||
filter["completed_at__lte"] = query.get("datetime")
|
||||
return filter
|
||||
|
||||
|
||||
@@ -297,16 +292,9 @@ def filter_subscribed_issues(params, filter, method):
|
||||
return filter
|
||||
|
||||
|
||||
def filter_start_target_date_issues(params, filter, method):
|
||||
start_target_date = params.get("start_target_date", "false")
|
||||
if start_target_date == "true":
|
||||
filter["target_date__isnull"] = False
|
||||
filter["start_date__isnull"] = False
|
||||
return filter
|
||||
|
||||
|
||||
def issue_filters(query_params, method):
|
||||
filter = dict()
|
||||
print(query_params)
|
||||
|
||||
ISSUE_FILTER = {
|
||||
"state": filter_state,
|
||||
@@ -330,7 +318,6 @@ def issue_filters(query_params, method):
|
||||
"inbox_status": filter_inbox_status,
|
||||
"sub_issue": filter_sub_issue_toggle,
|
||||
"subscriber": filter_subscribed_issues,
|
||||
"start_target_date": filter_start_target_date_issues,
|
||||
}
|
||||
|
||||
for key, value in ISSUE_FILTER.items():
|
||||
|
||||
@@ -32,5 +32,4 @@ celery==5.3.1
|
||||
django_celery_beat==2.5.0
|
||||
psycopg-binary==3.1.9
|
||||
psycopg-c==3.1.9
|
||||
scout-apm==2.26.1
|
||||
openpyxl==3.1.2
|
||||
scout-apm==2.26.1
|
||||
@@ -33,8 +33,8 @@ RUN yarn turbo run build --filter=app
|
||||
|
||||
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL \
|
||||
BUILT_NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
|
||||
ENV NEXT_PUBLIC_DEPLOY_URL=${NEXT_PUBLIC_DEPLOY_URL}
|
||||
RUN /usr/local/bin/replace-env-vars.sh http://NEXT_PUBLIC_WEBAPP_URL_PLACEHOLDER ${NEXT_PUBLIC_API_BASE_URL} app
|
||||
|
||||
RUN /usr/local/bin/replace-env-vars.sh http://NEXT_PUBLIC_WEBAPP_URL_PLACEHOLDER ${NEXT_PUBLIC_API_BASE_URL}
|
||||
|
||||
FROM node:18-alpine AS runner
|
||||
WORKDIR /app
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
// ui
|
||||
import { ProfileEmptyState } from "components/ui";
|
||||
// image
|
||||
import emptyUsers from "public/empty-state/empty_users.svg";
|
||||
|
||||
type Props = {
|
||||
users: {
|
||||
avatar: string | null;
|
||||
@@ -10,29 +5,18 @@ type Props = {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
count: number;
|
||||
id: string;
|
||||
}[];
|
||||
title: string;
|
||||
emptyStateMessage: string;
|
||||
workspaceSlug: string;
|
||||
};
|
||||
|
||||
export const AnalyticsLeaderboard: React.FC<Props> = ({
|
||||
users,
|
||||
title,
|
||||
emptyStateMessage,
|
||||
workspaceSlug,
|
||||
}) => (
|
||||
export const AnalyticsLeaderboard: React.FC<Props> = ({ users, title }) => (
|
||||
<div className="p-3 border border-custom-border-200 rounded-[10px]">
|
||||
<h6 className="text-base font-medium">{title}</h6>
|
||||
{users.length > 0 ? (
|
||||
<div className="mt-3 space-y-3">
|
||||
{users.map((user) => (
|
||||
<a
|
||||
<div
|
||||
key={user.display_name ?? "None"}
|
||||
href={`/${workspaceSlug}/profile/${user.id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-start justify-between gap-4 text-xs"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -54,13 +38,11 @@ export const AnalyticsLeaderboard: React.FC<Props> = ({
|
||||
</span>
|
||||
</div>
|
||||
<span className="flex-shrink-0">{user.count}</span>
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-7 py-4">
|
||||
<ProfileEmptyState title="No Data yet" description={emptyStateMessage} image={emptyUsers} />
|
||||
</div>
|
||||
<div className="text-custom-text-200 text-center text-sm py-8">No matching data found.</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -60,11 +60,8 @@ export const ScopeAndDemand: React.FC<Props> = ({ fullScreen = true }) => {
|
||||
lastName: user?.created_by__last_name,
|
||||
display_name: user?.created_by__display_name,
|
||||
count: user?.count,
|
||||
id: user?.created_by__id,
|
||||
}))}
|
||||
title="Most issues created"
|
||||
emptyStateMessage="Co-workers and the number issues created by them appears here."
|
||||
workspaceSlug={workspaceSlug?.toString() ?? ""}
|
||||
/>
|
||||
<AnalyticsLeaderboard
|
||||
users={defaultAnalytics.most_issue_closed_user?.map((user) => ({
|
||||
@@ -73,11 +70,8 @@ export const ScopeAndDemand: React.FC<Props> = ({ fullScreen = true }) => {
|
||||
lastName: user?.assignees__last_name,
|
||||
display_name: user?.assignees__display_name,
|
||||
count: user?.count,
|
||||
id: user?.assignees__id,
|
||||
}))}
|
||||
title="Most issues closed"
|
||||
emptyStateMessage="Co-workers and the number issues closed by them appears here."
|
||||
workspaceSlug={workspaceSlug?.toString() ?? ""}
|
||||
/>
|
||||
<div className={fullScreen ? "md:col-span-2" : ""}>
|
||||
<AnalyticsYearWiseIssues defaultAnalytics={defaultAnalytics} />
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
// ui
|
||||
import { BarGraph, ProfileEmptyState } from "components/ui";
|
||||
// image
|
||||
import emptyBarGraph from "public/empty-state/empty_bar_graph.svg";
|
||||
import { BarGraph } from "components/ui";
|
||||
// types
|
||||
import { IDefaultAnalyticsResponse } from "types";
|
||||
|
||||
@@ -72,12 +70,8 @@ export const AnalyticsScope: React.FC<Props> = ({ defaultAnalytics }) => (
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="px-7 py-4">
|
||||
<ProfileEmptyState
|
||||
title="No Data yet"
|
||||
description="Analysis of pending issues by co-workers appears here."
|
||||
image={emptyBarGraph}
|
||||
/>
|
||||
<div className="text-custom-text-200 text-center text-sm py-8">
|
||||
No matching data found.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
// ui
|
||||
import { LineGraph, ProfileEmptyState } from "components/ui";
|
||||
// image
|
||||
import emptyGraph from "public/empty-state/empty_graph.svg";
|
||||
import { LineGraph } from "components/ui";
|
||||
// types
|
||||
import { IDefaultAnalyticsResponse } from "types";
|
||||
// constants
|
||||
@@ -50,13 +48,7 @@ export const AnalyticsYearWiseIssues: React.FC<Props> = ({ defaultAnalytics }) =
|
||||
enableArea
|
||||
/>
|
||||
) : (
|
||||
<div className="px-7 py-4">
|
||||
<ProfileEmptyState
|
||||
title="No Data yet"
|
||||
description="Close issues to view analysis of the same in the form of a graph."
|
||||
image={emptyGraph}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-custom-text-200 text-center text-sm py-8">No matching data found.</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -7,20 +7,12 @@ import { useTheme } from "next-themes";
|
||||
import { SettingIcon } from "components/icons";
|
||||
import userService from "services/user.service";
|
||||
import useUser from "hooks/use-user";
|
||||
// helper
|
||||
import { unsetCustomCssVariables } from "helpers/theme.helper";
|
||||
// mobx react lite
|
||||
import { observer } from "mobx-react-lite";
|
||||
// mobx store
|
||||
import { useMobxStore } from "lib/mobx/store-provider";
|
||||
|
||||
type Props = {
|
||||
setIsPaletteOpen: Dispatch<SetStateAction<boolean>>;
|
||||
};
|
||||
|
||||
export const ChangeInterfaceTheme: React.FC<Props> = observer(({ setIsPaletteOpen }) => {
|
||||
const store: any = useMobxStore();
|
||||
|
||||
export const ChangeInterfaceTheme: React.FC<Props> = ({ setIsPaletteOpen }) => {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
const { setTheme } = useTheme();
|
||||
@@ -29,11 +21,27 @@ export const ChangeInterfaceTheme: React.FC<Props> = observer(({ setIsPaletteOpe
|
||||
|
||||
const updateUserTheme = (newTheme: string) => {
|
||||
if (!user) return;
|
||||
|
||||
setTheme(newTheme);
|
||||
return store.user
|
||||
.updateCurrentUserSettings({ theme: { ...user.theme, theme: newTheme } })
|
||||
.then((response: any) => response)
|
||||
.catch((error: any) => error);
|
||||
|
||||
mutateUser((prevData) => {
|
||||
if (!prevData) return prevData;
|
||||
|
||||
return {
|
||||
...prevData,
|
||||
theme: {
|
||||
...prevData.theme,
|
||||
theme: newTheme,
|
||||
},
|
||||
};
|
||||
}, false);
|
||||
|
||||
userService.updateUser({
|
||||
theme: {
|
||||
...user.theme,
|
||||
theme: newTheme,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// useEffect only runs on the client, so now we can safely show the UI
|
||||
@@ -62,4 +70,4 @@ export const ChangeInterfaceTheme: React.FC<Props> = observer(({ setIsPaletteOpe
|
||||
))}
|
||||
</>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -354,8 +354,8 @@ export const CommandK: React.FC<Props> = ({ deleteIssue, isPaletteOpen, setIsPal
|
||||
<Command.Item
|
||||
key={item.id}
|
||||
onSelect={() => {
|
||||
setIsPaletteOpen(false);
|
||||
router.push(currentSection.path(item));
|
||||
setIsPaletteOpen(false);
|
||||
}}
|
||||
value={`${key}-${item?.name}`}
|
||||
className="focus:outline-none"
|
||||
@@ -379,7 +379,6 @@ export const CommandK: React.FC<Props> = ({ deleteIssue, isPaletteOpen, setIsPal
|
||||
<Command.Group heading="Issue actions">
|
||||
<Command.Item
|
||||
onSelect={() => {
|
||||
setIsPaletteOpen(false);
|
||||
setPlaceholder("Change state...");
|
||||
setSearchTerm("");
|
||||
setPages([...pages, "change-issue-state"]);
|
||||
@@ -461,7 +460,6 @@ export const CommandK: React.FC<Props> = ({ deleteIssue, isPaletteOpen, setIsPal
|
||||
<Command.Group heading="Issue">
|
||||
<Command.Item
|
||||
onSelect={() => {
|
||||
setIsPaletteOpen(false);
|
||||
const e = new KeyboardEvent("keydown", {
|
||||
key: "c",
|
||||
});
|
||||
@@ -481,7 +479,6 @@ export const CommandK: React.FC<Props> = ({ deleteIssue, isPaletteOpen, setIsPal
|
||||
<Command.Group heading="Project">
|
||||
<Command.Item
|
||||
onSelect={() => {
|
||||
setIsPaletteOpen(false);
|
||||
const e = new KeyboardEvent("keydown", {
|
||||
key: "p",
|
||||
});
|
||||
@@ -503,7 +500,6 @@ export const CommandK: React.FC<Props> = ({ deleteIssue, isPaletteOpen, setIsPal
|
||||
<Command.Group heading="Cycle">
|
||||
<Command.Item
|
||||
onSelect={() => {
|
||||
setIsPaletteOpen(false);
|
||||
const e = new KeyboardEvent("keydown", {
|
||||
key: "q",
|
||||
});
|
||||
@@ -521,7 +517,6 @@ export const CommandK: React.FC<Props> = ({ deleteIssue, isPaletteOpen, setIsPal
|
||||
<Command.Group heading="Module">
|
||||
<Command.Item
|
||||
onSelect={() => {
|
||||
setIsPaletteOpen(false);
|
||||
const e = new KeyboardEvent("keydown", {
|
||||
key: "m",
|
||||
});
|
||||
@@ -539,7 +534,6 @@ export const CommandK: React.FC<Props> = ({ deleteIssue, isPaletteOpen, setIsPal
|
||||
<Command.Group heading="View">
|
||||
<Command.Item
|
||||
onSelect={() => {
|
||||
setIsPaletteOpen(false);
|
||||
const e = new KeyboardEvent("keydown", {
|
||||
key: "v",
|
||||
});
|
||||
@@ -557,7 +551,6 @@ export const CommandK: React.FC<Props> = ({ deleteIssue, isPaletteOpen, setIsPal
|
||||
<Command.Group heading="Page">
|
||||
<Command.Item
|
||||
onSelect={() => {
|
||||
setIsPaletteOpen(false);
|
||||
const e = new KeyboardEvent("keydown", {
|
||||
key: "d",
|
||||
});
|
||||
@@ -575,12 +568,11 @@ export const CommandK: React.FC<Props> = ({ deleteIssue, isPaletteOpen, setIsPal
|
||||
{projectDetails && projectDetails.inbox_view && (
|
||||
<Command.Group heading="Inbox">
|
||||
<Command.Item
|
||||
onSelect={() => {
|
||||
setIsPaletteOpen(false);
|
||||
onSelect={() =>
|
||||
redirect(
|
||||
`/${workspaceSlug}/projects/${projectId}/inbox/${inboxList?.[0]?.id}`
|
||||
);
|
||||
}}
|
||||
)
|
||||
}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-custom-text-200">
|
||||
@@ -739,21 +731,12 @@ export const CommandK: React.FC<Props> = ({ deleteIssue, isPaletteOpen, setIsPal
|
||||
</div>
|
||||
</Command.Item>
|
||||
<Command.Item
|
||||
onSelect={() => redirect(`/${workspaceSlug}/settings/imports`)}
|
||||
onSelect={() => redirect(`/${workspaceSlug}/settings/import-export`)}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-custom-text-200">
|
||||
<SettingIcon className="h-4 w-4 text-custom-text-200" />
|
||||
Import
|
||||
</div>
|
||||
</Command.Item>
|
||||
<Command.Item
|
||||
onSelect={() => redirect(`/${workspaceSlug}/settings/exports`)}
|
||||
className="focus:outline-none"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-custom-text-200">
|
||||
<SettingIcon className="h-4 w-4 text-custom-text-200" />
|
||||
Export
|
||||
Import/Export
|
||||
</div>
|
||||
</Command.Item>
|
||||
</>
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import useSWR from "swr";
|
||||
|
||||
// hooks
|
||||
import useTheme from "hooks/use-theme";
|
||||
import useToast from "hooks/use-toast";
|
||||
import useUser from "hooks/use-user";
|
||||
// components
|
||||
@@ -23,10 +26,8 @@ import inboxService from "services/inbox.service";
|
||||
import { INBOX_LIST, ISSUE_DETAILS } from "constants/fetch-keys";
|
||||
// mobx store
|
||||
import { useMobxStore } from "lib/mobx/store-provider";
|
||||
import { observable } from "mobx";
|
||||
import { observer } from "mobx-react-lite";
|
||||
|
||||
export const CommandPalette: React.FC = observer(() => {
|
||||
export const CommandPalette: React.FC = () => {
|
||||
const store: any = useMobxStore();
|
||||
|
||||
const [isPaletteOpen, setIsPaletteOpen] = useState(false);
|
||||
@@ -46,12 +47,13 @@ export const CommandPalette: React.FC = observer(() => {
|
||||
const { user } = useUser();
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
const { toggleCollapsed } = useTheme();
|
||||
|
||||
const { data: issueDetails } = useSWR(
|
||||
workspaceSlug && projectId && issueId ? ISSUE_DETAILS(issueId as string) : null,
|
||||
workspaceSlug && projectId && issueId
|
||||
? () =>
|
||||
issuesService.retrieve(workspaceSlug as string, projectId as string, issueId as string)
|
||||
issuesService.retrieve(workspaceSlug as string, projectId as string, issueId as string)
|
||||
: null
|
||||
);
|
||||
|
||||
@@ -76,52 +78,55 @@ export const CommandPalette: React.FC = observer(() => {
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
const { key, ctrlKey, metaKey, altKey, shiftKey } = e;
|
||||
if (!key) return;
|
||||
|
||||
const keyPressed = key.toLowerCase();
|
||||
const cmdClicked = ctrlKey || metaKey;
|
||||
// if on input, textarea or editor, don't do anything
|
||||
if (
|
||||
e.target instanceof HTMLTextAreaElement ||
|
||||
e.target instanceof HTMLInputElement ||
|
||||
(e.target as Element).classList?.contains("ProseMirror")
|
||||
(e.target as Element).classList?.contains("remirror-editor")
|
||||
)
|
||||
return;
|
||||
|
||||
if (cmdClicked) {
|
||||
if (keyPressed === "k") {
|
||||
e.preventDefault();
|
||||
setIsPaletteOpen(true);
|
||||
} else if (keyPressed === "c" && altKey) {
|
||||
e.preventDefault();
|
||||
copyIssueUrlToClipboard();
|
||||
} else if (keyPressed === "b") {
|
||||
e.preventDefault();
|
||||
store.theme.setSidebarCollapsed(!store?.theme?.sidebarCollapsed);
|
||||
}
|
||||
} else {
|
||||
if (keyPressed === "c") {
|
||||
setIsIssueModalOpen(true);
|
||||
} else if (keyPressed === "p") {
|
||||
setIsProjectModalOpen(true);
|
||||
} else if (keyPressed === "v") {
|
||||
setIsCreateViewModalOpen(true);
|
||||
} else if (keyPressed === "d") {
|
||||
setIsCreateUpdatePageModalOpen(true);
|
||||
} else if (keyPressed === "h") {
|
||||
setIsShortcutsModalOpen(true);
|
||||
} else if (keyPressed === "q") {
|
||||
setIsCreateCycleModalOpen(true);
|
||||
} else if (keyPressed === "m") {
|
||||
setIsCreateModuleModalOpen(true);
|
||||
} else if (keyPressed === "backspace" || keyPressed === "delete") {
|
||||
e.preventDefault();
|
||||
setIsBulkDeleteIssuesModalOpen(true);
|
||||
}
|
||||
const { key, ctrlKey, metaKey, altKey, shiftKey } = e;
|
||||
|
||||
if (!key) return;
|
||||
|
||||
const keyPressed = key.toLowerCase();
|
||||
|
||||
const cmdClicked = ctrlKey || metaKey;
|
||||
|
||||
if (cmdClicked) {
|
||||
if (keyPressed === "k") {
|
||||
e.preventDefault();
|
||||
setIsPaletteOpen(true);
|
||||
} else if (keyPressed === "c" && altKey) {
|
||||
e.preventDefault();
|
||||
copyIssueUrlToClipboard();
|
||||
} else if (keyPressed === "b") {
|
||||
e.preventDefault();
|
||||
toggleCollapsed();
|
||||
}
|
||||
} else {
|
||||
if (keyPressed === "c") {
|
||||
setIsIssueModalOpen(true);
|
||||
} else if (keyPressed === "p") {
|
||||
setIsProjectModalOpen(true);
|
||||
} else if (keyPressed === "v") {
|
||||
setIsCreateViewModalOpen(true);
|
||||
} else if (keyPressed === "d") {
|
||||
setIsCreateUpdatePageModalOpen(true);
|
||||
} else if (keyPressed === "h") {
|
||||
setIsShortcutsModalOpen(true);
|
||||
} else if (keyPressed === "q") {
|
||||
setIsCreateCycleModalOpen(true);
|
||||
} else if (keyPressed === "m") {
|
||||
setIsCreateModuleModalOpen(true);
|
||||
} else if (keyPressed === "backspace" || keyPressed === "delete") {
|
||||
e.preventDefault();
|
||||
setIsBulkDeleteIssuesModalOpen(true);
|
||||
}
|
||||
}
|
||||
},
|
||||
[copyIssueUrlToClipboard]
|
||||
[copyIssueUrlToClipboard, toggleCollapsed]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -196,4 +201,4 @@ export const CommandPalette: React.FC = observer(() => {
|
||||
/>
|
||||
</>
|
||||
);
|
||||
})
|
||||
};
|
||||
|
||||
@@ -35,22 +35,6 @@ const IssueLink = ({ activity }: { activity: IIssueActivity }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const UserLink = ({ activity }: { activity: IIssueActivity }) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug } = router.query;
|
||||
|
||||
return (
|
||||
<a
|
||||
href={`/${workspaceSlug}/profile/${activity.new_identifier ?? activity.old_identifier}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium text-custom-text-100 inline-flex items-center hover:underline"
|
||||
>
|
||||
{activity.new_value && activity.new_value !== "" ? activity.new_value : activity.old_value}
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
const activityDetails: {
|
||||
[key: string]: {
|
||||
message: (activity: IIssueActivity, showIssue: boolean) => React.ReactNode;
|
||||
@@ -62,7 +46,8 @@ const activityDetails: {
|
||||
if (activity.old_value === "")
|
||||
return (
|
||||
<>
|
||||
added a new assignee <UserLink activity={activity} />
|
||||
added a new assignee{" "}
|
||||
<span className="font-medium text-custom-text-100">{activity.new_value}</span>
|
||||
{showIssue && (
|
||||
<>
|
||||
{" "}
|
||||
@@ -75,7 +60,8 @@ const activityDetails: {
|
||||
else
|
||||
return (
|
||||
<>
|
||||
removed the assignee <UserLink activity={activity} />
|
||||
removed the assignee{" "}
|
||||
<span className="font-medium text-custom-text-100">{activity.old_value}</span>
|
||||
{showIssue && (
|
||||
<>
|
||||
{" "}
|
||||
|
||||
@@ -113,51 +113,49 @@ export const IssuesFilterView: React.FC = () => {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{issueView !== "gantt_chart" && (
|
||||
<SelectFilters
|
||||
filters={filters}
|
||||
onSelect={(option) => {
|
||||
const key = option.key as keyof typeof filters;
|
||||
<SelectFilters
|
||||
filters={filters}
|
||||
onSelect={(option) => {
|
||||
const key = option.key as keyof typeof filters;
|
||||
|
||||
if (key === "target_date") {
|
||||
const valueExists = checkIfArraysHaveSameElements(
|
||||
filters.target_date ?? [],
|
||||
option.value
|
||||
if (key === "target_date") {
|
||||
const valueExists = checkIfArraysHaveSameElements(
|
||||
filters.target_date ?? [],
|
||||
option.value
|
||||
);
|
||||
|
||||
setFilters({
|
||||
target_date: valueExists ? null : option.value,
|
||||
});
|
||||
} else {
|
||||
const valueExists = filters[key]?.includes(option.value);
|
||||
|
||||
if (valueExists)
|
||||
setFilters(
|
||||
{
|
||||
[option.key]: ((filters[key] ?? []) as any[])?.filter(
|
||||
(val) => val !== option.value
|
||||
),
|
||||
},
|
||||
!Boolean(viewId)
|
||||
);
|
||||
|
||||
setFilters({
|
||||
target_date: valueExists ? null : option.value,
|
||||
});
|
||||
} else {
|
||||
const valueExists = filters[key]?.includes(option.value);
|
||||
|
||||
if (valueExists)
|
||||
setFilters(
|
||||
{
|
||||
[option.key]: ((filters[key] ?? []) as any[])?.filter(
|
||||
(val) => val !== option.value
|
||||
),
|
||||
},
|
||||
!Boolean(viewId)
|
||||
);
|
||||
else
|
||||
setFilters(
|
||||
{
|
||||
[option.key]: [...((filters[key] ?? []) as any[]), option.value],
|
||||
},
|
||||
!Boolean(viewId)
|
||||
);
|
||||
}
|
||||
}}
|
||||
direction="left"
|
||||
height="rg"
|
||||
/>
|
||||
)}
|
||||
else
|
||||
setFilters(
|
||||
{
|
||||
[option.key]: [...((filters[key] ?? []) as any[]), option.value],
|
||||
},
|
||||
!Boolean(viewId)
|
||||
);
|
||||
}
|
||||
}}
|
||||
direction="left"
|
||||
height="rg"
|
||||
/>
|
||||
<Popover className="relative">
|
||||
{({ open }) => (
|
||||
<>
|
||||
<Popover.Button
|
||||
className={`group flex items-center gap-2 rounded-md border border-custom-border-200 px-3 py-1.5 text-xs hover:bg-custom-sidebar-background-90 hover:text-custom-sidebar-text-100 focus:outline-none duration-300 ${
|
||||
className={`group flex items-center gap-2 rounded-md border border-custom-sidebar-border-200 bg-transparent px-3 py-1.5 text-xs hover:bg-custom-sidebar-background-90 hover:text-custom-sidebar-text-100 focus:outline-none duration-300 ${
|
||||
open
|
||||
? "bg-custom-sidebar-background-90 text-custom-sidebar-text-100"
|
||||
: "text-custom-sidebar-text-200"
|
||||
@@ -179,9 +177,8 @@ export const IssuesFilterView: React.FC = () => {
|
||||
<Popover.Panel className="absolute right-0 z-30 mt-1 w-screen max-w-xs transform rounded-lg border border-custom-border-200 bg-custom-background-90 p-3 shadow-lg">
|
||||
<div className="relative divide-y-2 divide-custom-border-200">
|
||||
<div className="space-y-4 pb-3 text-xs">
|
||||
{issueView !== "calendar" &&
|
||||
issueView !== "spreadsheet" &&
|
||||
issueView !== "gantt_chart" && (
|
||||
{issueView !== "calendar" && issueView !== "spreadsheet" && (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-custom-text-200">Group by</h4>
|
||||
<div className="w-28">
|
||||
@@ -209,34 +206,34 @@ export const IssuesFilterView: React.FC = () => {
|
||||
</CustomMenu>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{issueView !== "calendar" && issueView !== "spreadsheet" && (
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-custom-text-200">Order by</h4>
|
||||
<div className="w-28">
|
||||
<CustomMenu
|
||||
label={
|
||||
ORDER_BY_OPTIONS.find((option) => option.key === orderBy)?.name ??
|
||||
"Select"
|
||||
}
|
||||
className="!w-full"
|
||||
buttonClassName="w-full"
|
||||
>
|
||||
{ORDER_BY_OPTIONS.map((option) =>
|
||||
groupByProperty === "priority" && option.key === "priority" ? null : (
|
||||
<CustomMenu.MenuItem
|
||||
key={option.key}
|
||||
onClick={() => {
|
||||
setOrderBy(option.key);
|
||||
}}
|
||||
>
|
||||
{option.name}
|
||||
</CustomMenu.MenuItem>
|
||||
)
|
||||
)}
|
||||
</CustomMenu>
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-custom-text-200">Order by</h4>
|
||||
<div className="w-28">
|
||||
<CustomMenu
|
||||
label={
|
||||
ORDER_BY_OPTIONS.find((option) => option.key === orderBy)?.name ??
|
||||
"Select"
|
||||
}
|
||||
className="!w-full"
|
||||
buttonClassName="w-full"
|
||||
>
|
||||
{ORDER_BY_OPTIONS.map((option) =>
|
||||
groupByProperty === "priority" &&
|
||||
option.key === "priority" ? null : (
|
||||
<CustomMenu.MenuItem
|
||||
key={option.key}
|
||||
onClick={() => {
|
||||
setOrderBy(option.key);
|
||||
}}
|
||||
>
|
||||
{option.name}
|
||||
</CustomMenu.MenuItem>
|
||||
)
|
||||
)}
|
||||
</CustomMenu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-custom-text-200">Issue type</h4>
|
||||
@@ -266,19 +263,16 @@ export const IssuesFilterView: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{issueView !== "calendar" && issueView !== "spreadsheet" && (
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-custom-text-200">Show sub-issues</h4>
|
||||
<div className="w-28">
|
||||
<ToggleSwitch
|
||||
value={showSubIssues}
|
||||
onChange={() => setShowSubIssues(!showSubIssues)}
|
||||
/>
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-custom-text-200">Show sub-issues</h4>
|
||||
<div className="w-28">
|
||||
<ToggleSwitch
|
||||
value={showSubIssues}
|
||||
onChange={() => setShowSubIssues(!showSubIssues)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{issueView !== "calendar" &&
|
||||
issueView !== "spreadsheet" &&
|
||||
issueView !== "gantt_chart" && (
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-custom-text-200">Show empty states</h4>
|
||||
<div className="w-28">
|
||||
@@ -288,10 +282,6 @@ export const IssuesFilterView: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{issueView !== "calendar" &&
|
||||
issueView !== "spreadsheet" &&
|
||||
issueView !== "gantt_chart" && (
|
||||
<div className="relative flex justify-end gap-x-3">
|
||||
<button type="button" onClick={() => resetFilterToDefault()}>
|
||||
Reset to default
|
||||
@@ -304,48 +294,47 @@ export const IssuesFilterView: React.FC = () => {
|
||||
Set as default
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{issueView !== "gantt_chart" && (
|
||||
<div className="space-y-2 py-3">
|
||||
<h4 className="text-sm text-custom-text-200">Display Properties</h4>
|
||||
<div className="flex flex-wrap items-center gap-2 text-custom-text-200">
|
||||
{Object.keys(properties).map((key) => {
|
||||
if (key === "estimate" && !isEstimateActive) return null;
|
||||
<div className="space-y-2 py-3">
|
||||
<h4 className="text-sm text-custom-text-200">Display Properties</h4>
|
||||
<div className="flex flex-wrap items-center gap-2 text-custom-text-200">
|
||||
{Object.keys(properties).map((key) => {
|
||||
if (key === "estimate" && !isEstimateActive) return null;
|
||||
|
||||
if (
|
||||
issueView === "spreadsheet" &&
|
||||
(key === "attachment_count" ||
|
||||
key === "link" ||
|
||||
key === "sub_issue_count")
|
||||
)
|
||||
return null;
|
||||
if (
|
||||
issueView === "spreadsheet" &&
|
||||
(key === "attachment_count" ||
|
||||
key === "link" ||
|
||||
key === "sub_issue_count")
|
||||
)
|
||||
return null;
|
||||
|
||||
if (
|
||||
issueView !== "spreadsheet" &&
|
||||
(key === "created_on" || key === "updated_on")
|
||||
)
|
||||
return null;
|
||||
if (
|
||||
issueView !== "spreadsheet" &&
|
||||
(key === "created_on" || key === "updated_on")
|
||||
)
|
||||
return null;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
className={`rounded border px-2 py-1 text-xs capitalize ${
|
||||
properties[key as keyof Properties]
|
||||
? "border-custom-primary bg-custom-primary text-white"
|
||||
: "border-custom-border-200"
|
||||
}`}
|
||||
onClick={() => setProperties(key as keyof Properties)}
|
||||
>
|
||||
{key === "key" ? "ID" : replaceUnderscoreIfSnakeCase(key)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
className={`rounded border px-2 py-1 text-xs capitalize ${
|
||||
properties[key as keyof Properties]
|
||||
? "border-custom-primary bg-custom-primary text-white"
|
||||
: "border-custom-border-200"
|
||||
}`}
|
||||
onClick={() => setProperties(key as keyof Properties)}
|
||||
>
|
||||
{key === "key" ? "ID" : replaceUnderscoreIfSnakeCase(key)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Popover.Panel>
|
||||
</Transition>
|
||||
|
||||
@@ -27,8 +27,8 @@ const unsplashEnabled =
|
||||
|
||||
const tabOptions = [
|
||||
{
|
||||
key: "images",
|
||||
title: "Images",
|
||||
key: "unsplash",
|
||||
title: "Unsplash",
|
||||
},
|
||||
{
|
||||
key: "upload",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useEffect, useState, forwardRef, useRef } from "react";
|
||||
import { useEffect, useState, forwardRef, useRef } from "react";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
// react-hook-form
|
||||
import { useForm } from "react-hook-form";
|
||||
@@ -14,7 +15,6 @@ import useUserAuth from "hooks/use-user-auth";
|
||||
import { Input, PrimaryButton, SecondaryButton } from "components/ui";
|
||||
|
||||
import { IIssue, IPageBlock } from "types";
|
||||
import Tiptap, { ITiptapRichTextEditor } from "components/tiptap";
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
handleClose: () => void;
|
||||
@@ -32,11 +32,17 @@ type FormData = {
|
||||
task: string;
|
||||
};
|
||||
|
||||
const TiptapEditor = React.forwardRef<ITiptapRichTextEditor, ITiptapRichTextEditor>(
|
||||
(props, ref) => <Tiptap {...props} forwardedRef={ref} />
|
||||
const RemirrorRichTextEditor = dynamic(() => import("components/rich-text-editor"), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
import { IRemirrorRichTextEditor } from "components/rich-text-editor";
|
||||
|
||||
const WrappedRemirrorRichTextEditor = forwardRef<IRemirrorRichTextEditor, IRemirrorRichTextEditor>(
|
||||
(props, ref) => <RemirrorRichTextEditor {...props} forwardedRef={ref} />
|
||||
);
|
||||
|
||||
TiptapEditor.displayName = "TiptapEditor";
|
||||
WrappedRemirrorRichTextEditor.displayName = "WrappedRemirrorRichTextEditor";
|
||||
|
||||
export const GptAssistantModal: React.FC<Props> = ({
|
||||
isOpen,
|
||||
@@ -140,15 +146,15 @@ export const GptAssistantModal: React.FC<Props> = ({
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`absolute ${inset} z-20 w-full space-y-4 rounded-[10px] border border-custom-border-200 bg-custom-background-100 p-4 shadow ${isOpen ? "block" : "hidden"
|
||||
}`}
|
||||
className={`absolute ${inset} z-20 w-full space-y-4 rounded-[10px] border border-custom-border-200 bg-custom-background-100 p-4 shadow ${
|
||||
isOpen ? "block" : "hidden"
|
||||
}`}
|
||||
>
|
||||
{((content && content !== "") || (htmlContent && htmlContent !== "<p></p>")) && (
|
||||
<div className="text-sm">
|
||||
<div className="remirror-section text-sm">
|
||||
Content:
|
||||
<TiptapEditor
|
||||
workspaceSlug={workspaceSlug as string}
|
||||
value={htmlContent ?? `<p>${content}</p>`}
|
||||
<WrappedRemirrorRichTextEditor
|
||||
value={htmlContent ?? <p>{content}</p>}
|
||||
customClassName="-m-3"
|
||||
noBorder
|
||||
borderOnFocus={false}
|
||||
@@ -160,8 +166,7 @@ export const GptAssistantModal: React.FC<Props> = ({
|
||||
{response !== "" && (
|
||||
<div className="page-block-section text-sm">
|
||||
Response:
|
||||
<Tiptap
|
||||
workspaceSlug={workspaceSlug as string}
|
||||
<RemirrorRichTextEditor
|
||||
value={`<p>${response}</p>`}
|
||||
customClassName="-mx-3 -my-3"
|
||||
noBorder
|
||||
@@ -180,10 +185,11 @@ export const GptAssistantModal: React.FC<Props> = ({
|
||||
type="text"
|
||||
name="task"
|
||||
register={register}
|
||||
placeholder={`${content && content !== ""
|
||||
placeholder={`${
|
||||
content && content !== ""
|
||||
? "Tell AI what action to perform on this content..."
|
||||
: "Ask AI anything..."
|
||||
}`}
|
||||
}`}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<div className={`flex gap-2 ${response === "" ? "justify-end" : "justify-between"}`}>
|
||||
@@ -219,8 +225,8 @@ export const GptAssistantModal: React.FC<Props> = ({
|
||||
{isSubmitting
|
||||
? "Generating response..."
|
||||
: response === ""
|
||||
? "Generate response"
|
||||
: "Generate again"}
|
||||
? "Generate response"
|
||||
: "Generate again"}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -131,10 +131,10 @@ export const ImageUploadModal: React.FC<Props> = ({
|
||||
Upload Image
|
||||
</Dialog.Title>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
{...getRootProps()}
|
||||
className={`relative grid h-80 w-80 cursor-pointer place-items-center rounded-lg p-12 text-center focus:outline-none focus:ring-2 focus:ring-custom-primary focus:ring-offset-2 ${
|
||||
className={`relative grid h-80 w-full cursor-pointer place-items-center rounded-lg p-12 text-center focus:outline-none focus:ring-2 focus:ring-custom-primary focus:ring-offset-2 ${
|
||||
(image === null && isDragActive) || !value
|
||||
? "border-2 border-dashed border-custom-border-200 hover:bg-custom-background-90"
|
||||
: ""
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect } from "react";
|
||||
import React from "react";
|
||||
|
||||
// react-hook-form
|
||||
import { useForm } from "react-hook-form";
|
||||
@@ -7,15 +7,12 @@ import { Dialog, Transition } from "@headlessui/react";
|
||||
// ui
|
||||
import { Input, PrimaryButton, SecondaryButton } from "components/ui";
|
||||
// types
|
||||
import type { IIssueLink, linkDetails, ModuleLink } from "types";
|
||||
import type { IIssueLink, ModuleLink } from "types";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
handleClose: () => void;
|
||||
data?: linkDetails | null;
|
||||
status: boolean;
|
||||
createIssueLink: (formData: IIssueLink | ModuleLink) => Promise<void>;
|
||||
updateIssueLink: (formData: IIssueLink | ModuleLink, linkId: string) => Promise<void>;
|
||||
onFormSubmit: (formData: IIssueLink | ModuleLink) => Promise<void>;
|
||||
};
|
||||
|
||||
const defaultValues: IIssueLink | ModuleLink = {
|
||||
@@ -23,14 +20,7 @@ const defaultValues: IIssueLink | ModuleLink = {
|
||||
url: "",
|
||||
};
|
||||
|
||||
export const LinkModal: React.FC<Props> = ({
|
||||
isOpen,
|
||||
handleClose,
|
||||
createIssueLink,
|
||||
updateIssueLink,
|
||||
status,
|
||||
data,
|
||||
}) => {
|
||||
export const LinkModal: React.FC<Props> = ({ isOpen, handleClose, onFormSubmit }) => {
|
||||
const {
|
||||
register,
|
||||
formState: { errors, isSubmitting },
|
||||
@@ -40,6 +30,11 @@ export const LinkModal: React.FC<Props> = ({
|
||||
defaultValues,
|
||||
});
|
||||
|
||||
const onSubmit = async (formData: IIssueLink | ModuleLink) => {
|
||||
await onFormSubmit({ title: formData.title, url: formData.url });
|
||||
onClose();
|
||||
};
|
||||
|
||||
const onClose = () => {
|
||||
handleClose();
|
||||
const timeout = setTimeout(() => {
|
||||
@@ -48,27 +43,6 @@ export const LinkModal: React.FC<Props> = ({
|
||||
}, 500);
|
||||
};
|
||||
|
||||
const handleFormSubmit = async (formData: IIssueLink | ModuleLink) => {
|
||||
if (!data) await createIssueLink({ title: formData.title, url: formData.url });
|
||||
else await updateIssueLink({ title: formData.title, url: formData.url }, data.id);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleCreateUpdatePage = async (formData: IIssueLink | ModuleLink) => {
|
||||
await handleFormSubmit(formData);
|
||||
|
||||
reset({
|
||||
...defaultValues,
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
reset({
|
||||
...defaultValues,
|
||||
...data,
|
||||
});
|
||||
}, [data, reset]);
|
||||
|
||||
return (
|
||||
<Transition.Root show={isOpen} as={React.Fragment}>
|
||||
<Dialog as="div" className="relative z-20" onClose={onClose}>
|
||||
@@ -96,14 +70,14 @@ export const LinkModal: React.FC<Props> = ({
|
||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
>
|
||||
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-custom-background-100 border border-custom-border-200 px-5 py-8 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-2xl sm:p-6">
|
||||
<form onSubmit={handleSubmit(handleCreateUpdatePage)}>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div>
|
||||
<div className="space-y-5">
|
||||
<Dialog.Title
|
||||
as="h3"
|
||||
className="text-lg font-medium leading-6 text-custom-text-100"
|
||||
>
|
||||
{status ? "Update Link" : "Add Link"}
|
||||
Add Link
|
||||
</Dialog.Title>
|
||||
<div className="mt-2 space-y-3">
|
||||
<div>
|
||||
@@ -139,13 +113,7 @@ export const LinkModal: React.FC<Props> = ({
|
||||
<div className="mt-5 flex justify-end gap-2">
|
||||
<SecondaryButton onClick={onClose}>Cancel</SecondaryButton>
|
||||
<PrimaryButton type="submit" loading={isSubmitting}>
|
||||
{status
|
||||
? isSubmitting
|
||||
? "Updating Link..."
|
||||
: "Update Link"
|
||||
: isSubmitting
|
||||
? "Adding Link..."
|
||||
: "Add Link"}
|
||||
{isSubmitting ? "Adding Link..." : "Add Link"}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -1,24 +1,25 @@
|
||||
// icons
|
||||
import { ArrowTopRightOnSquareIcon, LinkIcon, TrashIcon } from "@heroicons/react/24/outline";
|
||||
import { Icon } from "components/ui";
|
||||
// helpers
|
||||
import { timeAgo } from "helpers/date-time.helper";
|
||||
// types
|
||||
import { linkDetails, UserAuth } from "types";
|
||||
import { IUserLite, UserAuth } from "types";
|
||||
|
||||
type Props = {
|
||||
links: linkDetails[];
|
||||
links: {
|
||||
id: string;
|
||||
created_at: Date;
|
||||
created_by: string;
|
||||
created_by_detail: IUserLite;
|
||||
metadata: any;
|
||||
title: string;
|
||||
url: string;
|
||||
}[];
|
||||
handleDeleteLink: (linkId: string) => void;
|
||||
handleEditLink: (link: linkDetails) => void;
|
||||
userAuth: UserAuth;
|
||||
};
|
||||
|
||||
export const LinksList: React.FC<Props> = ({
|
||||
links,
|
||||
handleDeleteLink,
|
||||
handleEditLink,
|
||||
userAuth,
|
||||
}) => {
|
||||
export const LinksList: React.FC<Props> = ({ links, handleDeleteLink, userAuth }) => {
|
||||
const isNotAllowed = userAuth.isGuest || userAuth.isViewer;
|
||||
|
||||
return (
|
||||
@@ -27,13 +28,6 @@ export const LinksList: React.FC<Props> = ({
|
||||
<div key={link.id} className="relative">
|
||||
{!isNotAllowed && (
|
||||
<div className="absolute top-1.5 right-1.5 z-[1] flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
className="grid h-7 w-7 place-items-center rounded bg-custom-background-90 p-1 outline-none hover:bg-custom-background-80"
|
||||
onClick={() => handleEditLink(link)}
|
||||
>
|
||||
<Icon iconName="edit" className="text-custom-text-200" />
|
||||
</button>
|
||||
<a
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
|
||||
@@ -21,21 +21,13 @@ import { ICustomTheme } from "types";
|
||||
|
||||
type Props = {
|
||||
name: keyof ICustomTheme;
|
||||
position?: "left" | "right";
|
||||
watch: UseFormWatch<any>;
|
||||
setValue: UseFormSetValue<any>;
|
||||
error: FieldError | Merge<FieldError, FieldErrorsImpl<any>> | undefined;
|
||||
register: UseFormRegister<any>;
|
||||
};
|
||||
|
||||
export const ColorPickerInput: React.FC<Props> = ({
|
||||
name,
|
||||
position = "left",
|
||||
watch,
|
||||
setValue,
|
||||
error,
|
||||
register,
|
||||
}) => {
|
||||
export const ColorPickerInput: React.FC<Props> = ({ name, watch, setValue, error, register }) => {
|
||||
const handleColorChange = (newColor: ColorResult) => {
|
||||
const { hex } = newColor;
|
||||
setValue(name, hex);
|
||||
@@ -112,11 +104,7 @@ export const ColorPickerInput: React.FC<Props> = ({
|
||||
leaveFrom="opacity-100 translate-y-0"
|
||||
leaveTo="opacity-0 translate-y-1"
|
||||
>
|
||||
<Popover.Panel
|
||||
className={`absolute bottom-8 z-20 mt-1 max-w-xs px-2 sm:px-0 ${
|
||||
position === "right" ? "left-0" : "right-0"
|
||||
}`}
|
||||
>
|
||||
<Popover.Panel className="absolute bottom-8 right-0 z-20 mt-1 max-w-xs px-2 sm:px-0">
|
||||
<SketchPicker color={watch(name)} onChange={handleColorChange} />
|
||||
</Popover.Panel>
|
||||
</Transition>
|
||||
|
||||
@@ -83,7 +83,6 @@ export const CustomThemeSelector: React.FC<Props> = observer(({ preLoadedData })
|
||||
</h3>
|
||||
<ColorPickerInput
|
||||
name="background"
|
||||
position="right"
|
||||
error={errors.background}
|
||||
watch={watch}
|
||||
setValue={setValue}
|
||||
@@ -121,7 +120,6 @@ export const CustomThemeSelector: React.FC<Props> = observer(({ preLoadedData })
|
||||
</h3>
|
||||
<ColorPickerInput
|
||||
name="sidebarBackground"
|
||||
position="right"
|
||||
error={errors.sidebarBackground}
|
||||
watch={watch}
|
||||
setValue={setValue}
|
||||
|
||||
@@ -10,7 +10,7 @@ import projectService from "services/project.service";
|
||||
// hooks
|
||||
import useProjects from "hooks/use-projects";
|
||||
// component
|
||||
import { Avatar, Icon } from "components/ui";
|
||||
import { Avatar } from "components/ui";
|
||||
// icons
|
||||
import { ArrowsPointingInIcon, ArrowsPointingOutIcon, PlusIcon } from "@heroicons/react/24/outline";
|
||||
import { getPriorityIcon, getStateGroupIcon } from "components/icons";
|
||||
@@ -140,7 +140,7 @@ export const BoardHeader: React.FC<Props> = ({
|
||||
>
|
||||
<div className={`flex items-center ${isCollapsed ? "gap-1" : "flex-col gap-2"}`}>
|
||||
<div
|
||||
className={`flex cursor-pointer items-center gap-x-2 max-w-[316px] ${
|
||||
className={`flex cursor-pointer items-center gap-x-3 max-w-[316px] ${
|
||||
!isCollapsed ? "mb-2 flex-col gap-y-2 py-2" : ""
|
||||
}`}
|
||||
>
|
||||
@@ -155,7 +155,11 @@ export const BoardHeader: React.FC<Props> = ({
|
||||
>
|
||||
{getGroupTitle()}
|
||||
</h2>
|
||||
<span className={`${isCollapsed ? "ml-0.5" : ""} py-1 text-center text-sm`}>
|
||||
<span
|
||||
className={`${
|
||||
isCollapsed ? "ml-0.5" : ""
|
||||
} min-w-[2.5rem] rounded-full bg-custom-background-80 py-1 text-center text-xs`}
|
||||
>
|
||||
{groupedIssues?.[groupTitle].length ?? 0}
|
||||
</span>
|
||||
</div>
|
||||
@@ -170,12 +174,9 @@ export const BoardHeader: React.FC<Props> = ({
|
||||
}}
|
||||
>
|
||||
{isCollapsed ? (
|
||||
<Icon
|
||||
iconName="close_fullscreen"
|
||||
className="text-base font-medium text-custom-text-900"
|
||||
/>
|
||||
<ArrowsPointingInIcon className="h-4 w-4" />
|
||||
) : (
|
||||
<Icon iconName="open_in_full" className="text-base font-medium text-custom-text-900" />
|
||||
<ArrowsPointingOutIcon className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
{!disableUserActions && selectedGroup !== "created_by" && (
|
||||
|
||||
@@ -86,10 +86,8 @@ export const SingleBoardIssue: React.FC<Props> = ({
|
||||
}) => {
|
||||
// context menu
|
||||
const [contextMenu, setContextMenu] = useState(false);
|
||||
const [contextMenuPosition, setContextMenuPosition] = useState<React.MouseEvent | null>(null);
|
||||
|
||||
const [contextMenuPosition, setContextMenuPosition] = useState({ x: 0, y: 0 });
|
||||
const [isMenuActive, setIsMenuActive] = useState(false);
|
||||
const [isDropdownActive, setIsDropdownActive] = useState(false);
|
||||
|
||||
const actionSectionRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
@@ -127,7 +125,7 @@ export const SingleBoardIssue: React.FC<Props> = ({
|
||||
);
|
||||
} else {
|
||||
mutateIssues(
|
||||
(prevData: any) =>
|
||||
(prevData) =>
|
||||
handleIssuesMutation(
|
||||
formData,
|
||||
groupTitle ?? "",
|
||||
@@ -202,7 +200,7 @@ export const SingleBoardIssue: React.FC<Props> = ({
|
||||
return (
|
||||
<>
|
||||
<ContextMenu
|
||||
clickEvent={contextMenuPosition}
|
||||
position={contextMenuPosition}
|
||||
title="Quick actions"
|
||||
isOpen={contextMenu}
|
||||
setIsOpen={setContextMenu}
|
||||
@@ -234,7 +232,7 @@ export const SingleBoardIssue: React.FC<Props> = ({
|
||||
</a>
|
||||
</ContextMenu>
|
||||
<div
|
||||
className={`mb-3 rounded bg-custom-background-100 shadow ${
|
||||
className={`mb-3 rounded bg-custom-background-90 shadow ${
|
||||
snapshot.isDragging ? "border-2 border-custom-primary shadow-lg" : ""
|
||||
}`}
|
||||
ref={provided.innerRef}
|
||||
@@ -244,10 +242,10 @@ export const SingleBoardIssue: React.FC<Props> = ({
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
setContextMenu(true);
|
||||
setContextMenuPosition(e);
|
||||
setContextMenuPosition({ x: e.pageX, y: e.pageY });
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col justify-between gap-1.5 group/card relative select-none px-3.5 py-3 h-[118px]">
|
||||
<div className="group/card relative select-none p-3.5">
|
||||
{!isNotAllowed && (
|
||||
<div
|
||||
ref={actionSectionRef}
|
||||
@@ -297,20 +295,16 @@ export const SingleBoardIssue: React.FC<Props> = ({
|
||||
</div>
|
||||
)}
|
||||
<Link href={`/${workspaceSlug}/projects/${issue.project}/issues/${issue.id}`}>
|
||||
<a className="flex flex-col gap-1.5">
|
||||
<a>
|
||||
{properties.key && (
|
||||
<div className="text-xs font-medium text-custom-text-200">
|
||||
<div className="mb-2.5 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>
|
||||
<h5 className="text-sm break-words line-clamp-3">{issue.name}</h5>
|
||||
</a>
|
||||
</Link>
|
||||
<div
|
||||
className={`flex items-center gap-2 text-xs ${
|
||||
isDropdownActive ? "" : "overflow-x-scroll"
|
||||
}`}
|
||||
>
|
||||
<div className="relative mt-2.5 flex flex-wrap items-center gap-2 text-xs">
|
||||
{properties.priority && (
|
||||
<ViewPrioritySelect
|
||||
issue={issue}
|
||||
@@ -333,8 +327,6 @@ export const SingleBoardIssue: React.FC<Props> = ({
|
||||
<ViewStartDateSelect
|
||||
issue={issue}
|
||||
partialUpdateIssue={partialUpdateIssue}
|
||||
handleOnOpen={() => setIsDropdownActive(true)}
|
||||
handleOnClose={() => setIsDropdownActive(false)}
|
||||
user={user}
|
||||
isNotAllowed={isNotAllowed}
|
||||
/>
|
||||
@@ -343,8 +335,6 @@ export const SingleBoardIssue: React.FC<Props> = ({
|
||||
<ViewDueDateSelect
|
||||
issue={issue}
|
||||
partialUpdateIssue={partialUpdateIssue}
|
||||
handleOnOpen={() => setIsDropdownActive(true)}
|
||||
handleOnClose={() => setIsDropdownActive(false)}
|
||||
user={user}
|
||||
isNotAllowed={isNotAllowed}
|
||||
/>
|
||||
@@ -357,7 +347,6 @@ export const SingleBoardIssue: React.FC<Props> = ({
|
||||
issue={issue}
|
||||
partialUpdateIssue={partialUpdateIssue}
|
||||
isNotAllowed={isNotAllowed}
|
||||
customButton
|
||||
user={user}
|
||||
selfPositioned
|
||||
/>
|
||||
|
||||
@@ -33,7 +33,7 @@ import {
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { LayerDiagonalIcon } from "components/icons";
|
||||
// helpers
|
||||
import { copyTextToClipboard } from "helpers/string.helper";
|
||||
import { copyTextToClipboard, truncateText } from "helpers/string.helper";
|
||||
import { handleIssuesMutation } from "constants/issue";
|
||||
// types
|
||||
import { ICurrentUserResponse, IIssue, IIssueViewProps, ISubIssueResponse, UserAuth } from "types";
|
||||
@@ -71,7 +71,7 @@ export const SingleListIssue: React.FC<Props> = ({
|
||||
}) => {
|
||||
// context menu
|
||||
const [contextMenu, setContextMenu] = useState(false);
|
||||
const [contextMenuPosition, setContextMenuPosition] = useState<React.MouseEvent | null>(null);
|
||||
const [contextMenuPosition, setContextMenuPosition] = useState({ x: 0, y: 0 });
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, cycleId, moduleId } = router.query;
|
||||
@@ -108,7 +108,7 @@ export const SingleListIssue: React.FC<Props> = ({
|
||||
);
|
||||
} else {
|
||||
mutateIssues(
|
||||
(prevData: any) =>
|
||||
(prevData) =>
|
||||
handleIssuesMutation(
|
||||
formData,
|
||||
groupTitle ?? "",
|
||||
@@ -167,7 +167,7 @@ export const SingleListIssue: React.FC<Props> = ({
|
||||
return (
|
||||
<>
|
||||
<ContextMenu
|
||||
clickEvent={contextMenuPosition}
|
||||
position={contextMenuPosition}
|
||||
title="Quick actions"
|
||||
isOpen={contextMenu}
|
||||
setIsOpen={setContextMenu}
|
||||
@@ -199,7 +199,7 @@ export const SingleListIssue: React.FC<Props> = ({
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
setContextMenu(true);
|
||||
setContextMenuPosition(e);
|
||||
setContextMenuPosition({ x: e.pageX, y: e.pageY });
|
||||
}}
|
||||
>
|
||||
<div className="flex-grow cursor-pointer min-w-[200px] whitespace-nowrap overflow-hidden overflow-ellipsis">
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
ViewEstimateSelect,
|
||||
ViewIssueLabel,
|
||||
ViewPrioritySelect,
|
||||
ViewStartDateSelect,
|
||||
ViewStateSelect,
|
||||
} from "components/issues";
|
||||
import { Popover2 } from "@blueprintjs/popover2";
|
||||
@@ -316,19 +315,6 @@ export const SingleSpreadsheetIssue: React.FC<Props> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{properties.start_date && (
|
||||
<div className="flex items-center text-xs text-custom-text-200 text-center p-2 group-hover:bg-custom-background-80 border-custom-border-200">
|
||||
<ViewStartDateSelect
|
||||
issue={issue}
|
||||
partialUpdateIssue={partialUpdateIssue}
|
||||
tooltipPosition={tooltipPosition}
|
||||
noBorder
|
||||
user={user}
|
||||
isNotAllowed={isNotAllowed}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{properties.due_date && (
|
||||
<div className="flex items-center text-xs text-custom-text-200 text-center p-2 group-hover:bg-custom-background-80 border-custom-border-200">
|
||||
<ViewDueDateSelect
|
||||
|
||||
@@ -1,28 +1,21 @@
|
||||
import { FC } from "react";
|
||||
|
||||
// next imports
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import { KeyedMutator } from "swr";
|
||||
|
||||
// services
|
||||
import cyclesService from "services/cycles.service";
|
||||
// hooks
|
||||
import useUser from "hooks/use-user";
|
||||
// components
|
||||
import { CycleGanttBlock, GanttChartRoot, IBlockUpdateData } from "components/gantt-chart";
|
||||
import { GanttChartRoot } from "components/gantt-chart";
|
||||
// ui
|
||||
import { Tooltip } from "components/ui";
|
||||
// types
|
||||
import { ICycle } from "types";
|
||||
|
||||
type Props = {
|
||||
cycles: ICycle[];
|
||||
mutateCycles: KeyedMutator<ICycle[]>;
|
||||
};
|
||||
|
||||
export const CyclesListGanttChartView: FC<Props> = ({ cycles, mutateCycles }) => {
|
||||
export const CyclesListGanttChartView: FC<Props> = ({ cycles }) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug } = router.query;
|
||||
|
||||
const { user } = useUser();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
|
||||
// rendering issues on gantt sidebar
|
||||
const GanttSidebarBlockView = ({ data }: any) => (
|
||||
@@ -35,63 +28,53 @@ export const CyclesListGanttChartView: FC<Props> = ({ cycles, mutateCycles }) =>
|
||||
</div>
|
||||
);
|
||||
|
||||
const handleCycleUpdate = (cycle: ICycle, payload: IBlockUpdateData) => {
|
||||
if (!workspaceSlug || !user) return;
|
||||
// rendering issues on gantt card
|
||||
const GanttBlockView = ({ data }: { data: ICycle }) => (
|
||||
<Link href={`/${workspaceSlug}/projects/${projectId}/cycles/${data?.id}`}>
|
||||
<a className="relative flex items-center w-full h-full overflow-hidden shadow-sm">
|
||||
<div
|
||||
className="flex-shrink-0 w-[4px] h-full"
|
||||
style={{ backgroundColor: "rgb(var(--color-primary-100))" }}
|
||||
/>
|
||||
<Tooltip tooltipContent={data?.name} className={`z-[999999]`}>
|
||||
<div className="text-custom-text-100 text-[15px] whitespace-nowrap py-[4px] px-2.5 overflow-hidden w-full">
|
||||
{data?.name}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</a>
|
||||
</Link>
|
||||
);
|
||||
|
||||
mutateCycles((prevData: any) => {
|
||||
if (!prevData) return prevData;
|
||||
|
||||
const newList = prevData.map((p: any) => ({
|
||||
...p,
|
||||
...(p.id === cycle.id
|
||||
? {
|
||||
start_date: payload.start_date ? payload.start_date : p.start_date,
|
||||
target_date: payload.target_date ? payload.target_date : p.end_date,
|
||||
sort_order: payload.sort_order ? payload.sort_order.newSortOrder : p.sort_order,
|
||||
}
|
||||
: {}),
|
||||
}));
|
||||
|
||||
if (payload.sort_order) {
|
||||
const removedElement = newList.splice(payload.sort_order.sourceIndex, 1)[0];
|
||||
newList.splice(payload.sort_order.destinationIndex, 0, removedElement);
|
||||
}
|
||||
|
||||
return newList;
|
||||
}, false);
|
||||
|
||||
const newPayload: any = { ...payload };
|
||||
|
||||
if (newPayload.sort_order && payload.sort_order)
|
||||
newPayload.sort_order = payload.sort_order.newSortOrder;
|
||||
|
||||
cyclesService.patchCycle(workspaceSlug.toString(), cycle.project, cycle.id, newPayload, user);
|
||||
// handle gantt issue start date and target date
|
||||
const handleUpdateDates = async (data: any) => {
|
||||
const payload = {
|
||||
id: data?.id,
|
||||
start_date: data?.start_date,
|
||||
target_date: data?.target_date,
|
||||
};
|
||||
};
|
||||
|
||||
const blockFormat = (blocks: ICycle[]) =>
|
||||
const blockFormat = (blocks: any) =>
|
||||
blocks && blocks.length > 0
|
||||
? blocks
|
||||
.filter((b) => b.start_date && b.end_date)
|
||||
.map((block) => ({
|
||||
data: block,
|
||||
id: block.id,
|
||||
sort_order: block.sort_order,
|
||||
start_date: new Date(block.start_date ?? ""),
|
||||
target_date: new Date(block.end_date ?? ""),
|
||||
}))
|
||||
? blocks.map((_block: any) => {
|
||||
if (_block?.start_date && _block.target_date) console.log("_block", _block);
|
||||
return {
|
||||
start_date: new Date(_block.created_at),
|
||||
target_date: new Date(_block.updated_at),
|
||||
data: _block,
|
||||
};
|
||||
})
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div className="w-full h-full overflow-y-auto">
|
||||
<GanttChartRoot
|
||||
title="Cycles"
|
||||
title={"Cycles"}
|
||||
loaderTitle="Cycles"
|
||||
blocks={cycles ? blockFormat(cycles) : null}
|
||||
blockUpdateHandler={(block, payload) => handleCycleUpdate(block, payload)}
|
||||
blockUpdateHandler={handleUpdateDates}
|
||||
sidebarBlockRender={(data: any) => <GanttSidebarBlockView data={data} />}
|
||||
blockRender={(data: any) => <CycleGanttBlock cycle={data as ICycle} />}
|
||||
enableLeftDrag={false}
|
||||
enableRightDrag={false}
|
||||
blockRender={(data: any) => <GanttBlockView data={data} />}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -17,7 +17,7 @@ export const AllCyclesList: React.FC<Props> = ({ viewType }) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
|
||||
const { data: allCyclesList, mutate } = useSWR(
|
||||
const { data: allCyclesList } = useSWR(
|
||||
workspaceSlug && projectId ? CYCLES_LIST(projectId.toString()) : null,
|
||||
workspaceSlug && projectId
|
||||
? () =>
|
||||
@@ -25,5 +25,5 @@ export const AllCyclesList: React.FC<Props> = ({ viewType }) => {
|
||||
: null
|
||||
);
|
||||
|
||||
return <CyclesView cycles={allCyclesList} mutateCycles={mutate} viewType={viewType} />;
|
||||
return <CyclesView cycles={allCyclesList} viewType={viewType} />;
|
||||
};
|
||||
|
||||
@@ -17,7 +17,7 @@ export const CompletedCyclesList: React.FC<Props> = ({ viewType }) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
|
||||
const { data: completedCyclesList, mutate } = useSWR(
|
||||
const { data: completedCyclesList } = useSWR(
|
||||
workspaceSlug && projectId ? COMPLETED_CYCLES_LIST(projectId.toString()) : null,
|
||||
workspaceSlug && projectId
|
||||
? () =>
|
||||
@@ -29,5 +29,5 @@ export const CompletedCyclesList: React.FC<Props> = ({ viewType }) => {
|
||||
: null
|
||||
);
|
||||
|
||||
return <CyclesView cycles={completedCyclesList} mutateCycles={mutate} viewType={viewType} />;
|
||||
return <CyclesView cycles={completedCyclesList} viewType={viewType} />;
|
||||
};
|
||||
|
||||
@@ -17,7 +17,7 @@ export const DraftCyclesList: React.FC<Props> = ({ viewType }) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
|
||||
const { data: draftCyclesList, mutate } = useSWR(
|
||||
const { data: draftCyclesList } = useSWR(
|
||||
workspaceSlug && projectId ? DRAFT_CYCLES_LIST(projectId.toString()) : null,
|
||||
workspaceSlug && projectId
|
||||
? () =>
|
||||
@@ -25,5 +25,5 @@ export const DraftCyclesList: React.FC<Props> = ({ viewType }) => {
|
||||
: null
|
||||
);
|
||||
|
||||
return <CyclesView cycles={draftCyclesList} mutateCycles={mutate} viewType={viewType} />;
|
||||
return <CyclesView cycles={draftCyclesList} viewType={viewType} />;
|
||||
};
|
||||
|
||||
@@ -17,7 +17,7 @@ export const UpcomingCyclesList: React.FC<Props> = ({ viewType }) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
|
||||
const { data: upcomingCyclesList, mutate } = useSWR(
|
||||
const { data: upcomingCyclesList } = useSWR(
|
||||
workspaceSlug && projectId ? UPCOMING_CYCLES_LIST(projectId.toString()) : null,
|
||||
workspaceSlug && projectId
|
||||
? () =>
|
||||
@@ -29,5 +29,5 @@ export const UpcomingCyclesList: React.FC<Props> = ({ viewType }) => {
|
||||
: null
|
||||
);
|
||||
|
||||
return <CyclesView cycles={upcomingCyclesList} mutateCycles={mutate} viewType={viewType} />;
|
||||
return <CyclesView cycles={upcomingCyclesList} viewType={viewType} />;
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useState } from "react";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import { KeyedMutator, mutate } from "swr";
|
||||
import { mutate } from "swr";
|
||||
|
||||
// services
|
||||
import cyclesService from "services/cycles.service";
|
||||
@@ -35,11 +35,10 @@ import {
|
||||
|
||||
type Props = {
|
||||
cycles: ICycle[] | undefined;
|
||||
mutateCycles: KeyedMutator<ICycle[]>;
|
||||
viewType: string | null;
|
||||
};
|
||||
|
||||
export const CyclesView: React.FC<Props> = ({ cycles, mutateCycles, viewType }) => {
|
||||
export const CyclesView: React.FC<Props> = ({ cycles, viewType }) => {
|
||||
const [createUpdateCycleModal, setCreateUpdateCycleModal] = useState(false);
|
||||
const [selectedCycleToUpdate, setSelectedCycleToUpdate] = useState<ICycle | null>(null);
|
||||
|
||||
@@ -203,7 +202,7 @@ export const CyclesView: React.FC<Props> = ({ cycles, mutateCycles, viewType })
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<CyclesListGanttChartView cycles={cycles ?? []} mutateCycles={mutateCycles} />
|
||||
<CyclesListGanttChartView cycles={cycles ?? []} />
|
||||
)
|
||||
) : (
|
||||
<div className="h-full grid place-items-center text-center">
|
||||
|
||||
@@ -1,27 +1,20 @@
|
||||
import { FC } from "react";
|
||||
// next imports
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
// hooks
|
||||
import useIssuesView from "hooks/use-issues-view";
|
||||
import useUser from "hooks/use-user";
|
||||
import useGanttChartCycleIssues from "hooks/gantt-chart/cycle-issues-view";
|
||||
import { updateGanttIssue } from "components/gantt-chart/hooks/block-update";
|
||||
// components
|
||||
import {
|
||||
GanttChartRoot,
|
||||
IssueGanttBlock,
|
||||
renderIssueBlocksStructure,
|
||||
} from "components/gantt-chart";
|
||||
// types
|
||||
import { IIssue } from "types";
|
||||
import { GanttChartRoot } from "components/gantt-chart";
|
||||
// ui
|
||||
import { Tooltip } from "components/ui";
|
||||
// hooks
|
||||
import useGanttChartCycleIssues from "hooks/gantt-chart/cycle-issues-view";
|
||||
|
||||
export const CycleIssuesGanttChartView = () => {
|
||||
type Props = {};
|
||||
|
||||
export const CycleIssuesGanttChartView: FC<Props> = ({}) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, cycleId } = router.query;
|
||||
|
||||
const { orderBy } = useIssuesView();
|
||||
|
||||
const { user } = useUser();
|
||||
|
||||
const { ganttIssues, mutateGanttIssues } = useGanttChartCycleIssues(
|
||||
workspaceSlug as string,
|
||||
projectId as string,
|
||||
@@ -39,18 +32,77 @@ export const CycleIssuesGanttChartView = () => {
|
||||
</div>
|
||||
);
|
||||
|
||||
// rendering issues on gantt card
|
||||
const GanttBlockView = ({ data }: any) => (
|
||||
<Link href={`/${workspaceSlug}/projects/${projectId}/issues/${data?.id}`}>
|
||||
<a className="relative flex items-center w-full h-full overflow-hidden shadow-sm">
|
||||
<div
|
||||
className="flex-shrink-0 w-[4px] h-full"
|
||||
style={{ backgroundColor: data?.state_detail?.color || "rgb(var(--color-primary-100))" }}
|
||||
/>
|
||||
<Tooltip tooltipContent={data?.name} className={`z-[999999]`}>
|
||||
<div className="text-custom-text-100 text-[15px] whitespace-nowrap py-[4px] px-2.5 overflow-hidden w-full">
|
||||
{data?.name}
|
||||
</div>
|
||||
</Tooltip>
|
||||
{data.infoToggle && (
|
||||
<Tooltip
|
||||
tooltipContent={`No due-date set, rendered according to last updated date.`}
|
||||
className={`z-[999999]`}
|
||||
>
|
||||
<div className="flex-shrink-0 mx-2 w-[18px] h-[18px] overflow-hidden flex justify-center items-center">
|
||||
<span className="material-symbols-rounded text-custom-text-200 text-[18px]">
|
||||
info
|
||||
</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
</a>
|
||||
</Link>
|
||||
);
|
||||
|
||||
// handle gantt issue start date and target date
|
||||
const handleUpdateDates = async (data: any) => {
|
||||
const payload = {
|
||||
id: data?.id,
|
||||
start_date: data?.start_date,
|
||||
target_date: data?.target_date,
|
||||
};
|
||||
|
||||
console.log("payload", payload);
|
||||
};
|
||||
|
||||
const blockFormat = (blocks: any) =>
|
||||
blocks && blocks.length > 0
|
||||
? blocks.map((_block: any) => {
|
||||
let startDate = new Date(_block.created_at);
|
||||
let targetDate = new Date(_block.updated_at);
|
||||
let infoToggle = true;
|
||||
|
||||
if (_block?.start_date && _block.target_date) {
|
||||
startDate = _block?.start_date;
|
||||
targetDate = _block.target_date;
|
||||
infoToggle = false;
|
||||
}
|
||||
|
||||
return {
|
||||
start_date: new Date(startDate),
|
||||
target_date: new Date(targetDate),
|
||||
infoToggle: infoToggle,
|
||||
data: _block,
|
||||
};
|
||||
})
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div className="w-full h-full p-3">
|
||||
<GanttChartRoot
|
||||
title="Cycles"
|
||||
loaderTitle="Cycles"
|
||||
blocks={ganttIssues ? renderIssueBlocksStructure(ganttIssues as IIssue[]) : null}
|
||||
blockUpdateHandler={(block, payload) =>
|
||||
updateGanttIssue(block, payload, mutateGanttIssues, user, workspaceSlug?.toString())
|
||||
}
|
||||
blocks={ganttIssues ? blockFormat(ganttIssues) : null}
|
||||
blockUpdateHandler={handleUpdateDates}
|
||||
sidebarBlockRender={(data: any) => <GanttSidebarBlockView data={data} />}
|
||||
blockRender={(data: any) => <IssueGanttBlock issue={data as IIssue} />}
|
||||
enableReorder={orderBy === "sort_order"}
|
||||
blockRender={(data: any) => <GanttBlockView data={data} />}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -45,6 +45,7 @@ type TSingleStatProps = {
|
||||
handleDeleteCycle: () => void;
|
||||
handleAddToFavorites: () => void;
|
||||
handleRemoveFromFavorites: () => void;
|
||||
isCompleted?: boolean;
|
||||
};
|
||||
|
||||
const stateGroups = [
|
||||
@@ -81,6 +82,7 @@ export const SingleCycleCard: React.FC<TSingleStatProps> = ({
|
||||
handleDeleteCycle,
|
||||
handleAddToFavorites,
|
||||
handleRemoveFromFavorites,
|
||||
isCompleted = false,
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
@@ -88,7 +90,6 @@ export const SingleCycleCard: React.FC<TSingleStatProps> = ({
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const cycleStatus = getDateRangeStatus(cycle.start_date, cycle.end_date);
|
||||
const isCompleted = cycleStatus === "completed";
|
||||
const endDate = new Date(cycle.end_date ?? "");
|
||||
const startDate = new Date(cycle.start_date ?? "");
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ type TSingleStatProps = {
|
||||
handleDeleteCycle: () => void;
|
||||
handleAddToFavorites: () => void;
|
||||
handleRemoveFromFavorites: () => void;
|
||||
isCompleted?: boolean;
|
||||
};
|
||||
|
||||
const stateGroups = [
|
||||
@@ -112,6 +113,7 @@ export const SingleCycleList: React.FC<TSingleStatProps> = ({
|
||||
handleDeleteCycle,
|
||||
handleAddToFavorites,
|
||||
handleRemoveFromFavorites,
|
||||
isCompleted = false,
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
@@ -119,7 +121,6 @@ export const SingleCycleList: React.FC<TSingleStatProps> = ({
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const cycleStatus = getDateRangeStatus(cycle.start_date, cycle.end_date);
|
||||
const isCompleted = cycleStatus === "completed";
|
||||
const endDate = new Date(cycle.end_date ?? "");
|
||||
const startDate = new Date(cycle.start_date ?? "");
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ export const SingleEstimate: React.FC<Props> = ({
|
||||
estimate: estimate.id,
|
||||
};
|
||||
|
||||
mutateProjectDetails((prevData: any) => {
|
||||
mutateProjectDetails((prevData) => {
|
||||
if (!prevData) return prevData;
|
||||
|
||||
return { ...prevData, estimate: estimate.id };
|
||||
|
||||
@@ -1,185 +0,0 @@
|
||||
import React, { useState } from "react";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
// headless ui
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
// services
|
||||
import CSVIntegrationService from "services/integration/csv.services";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
// ui
|
||||
import { SecondaryButton, PrimaryButton, CustomSearchSelect } from "components/ui";
|
||||
// types
|
||||
import { ICurrentUserResponse, IImporterService } from "types";
|
||||
// fetch-keys
|
||||
import useProjects from "hooks/use-projects";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
handleClose: () => void;
|
||||
data: IImporterService | null;
|
||||
user: ICurrentUserResponse | undefined;
|
||||
provider: string | string[];
|
||||
mutateServices: () => void;
|
||||
};
|
||||
|
||||
export const Exporter: React.FC<Props> = ({
|
||||
isOpen,
|
||||
handleClose,
|
||||
user,
|
||||
provider,
|
||||
mutateServices,
|
||||
}) => {
|
||||
const [exportLoading, setExportLoading] = useState(false);
|
||||
const router = useRouter();
|
||||
const { workspaceSlug } = router.query;
|
||||
const { projects } = useProjects();
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const options = projects?.map((project) => ({
|
||||
value: project.id,
|
||||
query: project.name + project.identifier,
|
||||
content: (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-custom-text-200 text-[0.65rem]">{project.identifier}</span>
|
||||
{project.name}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const [value, setValue] = React.useState<string[]>([]);
|
||||
const [multiple, setMultiple] = React.useState<boolean>(false);
|
||||
const onChange = (val: any) => {
|
||||
setValue(val);
|
||||
};
|
||||
const ExportCSVToMail = async () => {
|
||||
setExportLoading(true);
|
||||
if (workspaceSlug && user && typeof provider === "string") {
|
||||
const payload = {
|
||||
provider: provider,
|
||||
project: value,
|
||||
multiple: multiple,
|
||||
};
|
||||
await CSVIntegrationService.exportCSVService(workspaceSlug as string, payload, user)
|
||||
.then(() => {
|
||||
mutateServices();
|
||||
router.push(`/${workspaceSlug}/settings/exports`);
|
||||
setExportLoading(false);
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
title: "Export Successful",
|
||||
message: `You will be able to download the exported ${
|
||||
provider === "csv"
|
||||
? "CSV"
|
||||
: provider === "xlsx"
|
||||
? "Excel"
|
||||
: provider === "json"
|
||||
? "JSON"
|
||||
: ""
|
||||
} from the previous export.`,
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
setExportLoading(false);
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: "Export was unsuccessful. Please try again.",
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
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-20 overflow-y-auto">
|
||||
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
|
||||
<Transition.Child
|
||||
as={React.Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
enterTo="opacity-100 translate-y-0 sm:scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
>
|
||||
<Dialog.Panel className="relative transform rounded-lg border border-custom-border-200 bg-custom-background-100 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-2xl">
|
||||
<div className="flex flex-col gap-6 gap-y-4 p-6">
|
||||
<div className="flex w-full items-center justify-start gap-6">
|
||||
<span className="flex items-center justify-start">
|
||||
<h3 className="text-xl font-medium 2xl:text-2xl">
|
||||
Export to{" "}
|
||||
{provider === "csv"
|
||||
? "CSV"
|
||||
: provider === "xlsx"
|
||||
? "Excel"
|
||||
: provider === "json"
|
||||
? "JSON"
|
||||
: ""}
|
||||
</h3>
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<CustomSearchSelect
|
||||
value={value ?? []}
|
||||
onChange={(val: string[]) => onChange(val)}
|
||||
options={options}
|
||||
input={true}
|
||||
label={
|
||||
value && value.length > 0
|
||||
? projects &&
|
||||
projects
|
||||
.filter((p) => value.includes(p.id))
|
||||
.map((p) => p.identifier)
|
||||
.join(", ")
|
||||
: "All projects"
|
||||
}
|
||||
optionsClassName="min-w-full"
|
||||
multiple
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
onClick={() => setMultiple(!multiple)}
|
||||
className="flex items-center gap-2 max-w-min cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={multiple}
|
||||
onChange={() => setMultiple(!multiple)}
|
||||
/>
|
||||
<div className="text-sm whitespace-nowrap">
|
||||
Export the data into separate files
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<SecondaryButton onClick={handleClose}>Cancel</SecondaryButton>
|
||||
<PrimaryButton
|
||||
onClick={ExportCSVToMail}
|
||||
disabled={exportLoading}
|
||||
loading={exportLoading}
|
||||
>
|
||||
{exportLoading ? "Exporting..." : "Export"}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition.Root>
|
||||
);
|
||||
};
|
||||
@@ -1,171 +0,0 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import useSWR, { mutate } from "swr";
|
||||
|
||||
// hooks
|
||||
import useUserAuth from "hooks/use-user-auth";
|
||||
// services
|
||||
import IntegrationService from "services/integration";
|
||||
// components
|
||||
import { Exporter, SingleExport } from "components/exporter";
|
||||
// ui
|
||||
import { Icon, Loader, PrimaryButton } from "components/ui";
|
||||
// icons
|
||||
import { ArrowPathIcon } from "@heroicons/react/24/outline";
|
||||
// fetch-keys
|
||||
import { EXPORT_SERVICES_LIST } from "constants/fetch-keys";
|
||||
// constants
|
||||
import { EXPORTERS_LIST } from "constants/workspace";
|
||||
|
||||
const IntegrationGuide = () => {
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const per_page = 10;
|
||||
const [cursor, setCursor] = useState<string | undefined>(`10:0:0`);
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, provider } = router.query;
|
||||
|
||||
const { user } = useUserAuth();
|
||||
|
||||
const { data: exporterServices } = useSWR(
|
||||
workspaceSlug && cursor
|
||||
? EXPORT_SERVICES_LIST(workspaceSlug as string, cursor, `${per_page}`)
|
||||
: null,
|
||||
workspaceSlug && cursor
|
||||
? () => IntegrationService.getExportsServicesList(workspaceSlug as string, cursor, per_page)
|
||||
: null
|
||||
);
|
||||
|
||||
const handleCsvClose = () => {
|
||||
router.replace(`/plane/settings/exports`);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="h-full space-y-2">
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
{EXPORTERS_LIST.map((service) => (
|
||||
<div
|
||||
key={service.provider}
|
||||
className="rounded-[10px] border border-custom-border-200 bg-custom-background-100 p-4"
|
||||
>
|
||||
<div className="flex items-center gap-4 whitespace-nowrap">
|
||||
<div className="relative h-10 w-10 flex-shrink-0">
|
||||
<Image
|
||||
src={service.logo}
|
||||
layout="fill"
|
||||
objectFit="cover"
|
||||
alt={`${service.title} Logo`}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<h3>{service.title}</h3>
|
||||
<p className="text-sm text-custom-text-200">{service.description}</p>
|
||||
</div>
|
||||
<div className="flex-shrink-0">
|
||||
<Link href={`/${workspaceSlug}/settings/exports?provider=${service.provider}`}>
|
||||
<a>
|
||||
<PrimaryButton>
|
||||
<span className="capitalize">{service.type}</span> now
|
||||
</PrimaryButton>
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="rounded-[10px] border border-custom-border-200 bg-custom-background-100 p-4">
|
||||
<h3 className="mb-2 flex gap-2 text-lg font-medium justify-between">
|
||||
<div className="flex gap-2">
|
||||
<div className="">Previous Exports</div>
|
||||
<button
|
||||
type="button"
|
||||
className="flex flex-shrink-0 items-center gap-1 rounded bg-custom-background-80 py-1 px-1.5 text-xs outline-none"
|
||||
onClick={() => {
|
||||
setRefreshing(true);
|
||||
mutate(
|
||||
EXPORT_SERVICES_LIST(workspaceSlug as string, `${cursor}`, `${per_page}`)
|
||||
).then(() => setRefreshing(false));
|
||||
}}
|
||||
>
|
||||
<ArrowPathIcon className={`h-3 w-3 ${refreshing ? "animate-spin" : ""}`} />{" "}
|
||||
{refreshing ? "Refreshing..." : "Refresh status"}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex gap-2 items-center text-xs">
|
||||
<button
|
||||
disabled={!exporterServices?.prev_page_results}
|
||||
onClick={() =>
|
||||
exporterServices?.prev_page_results && setCursor(exporterServices?.prev_cursor)
|
||||
}
|
||||
className={`flex items-center border border-custom-primary-100 text-custom-primary-100 px-1 rounded ${
|
||||
exporterServices?.prev_page_results
|
||||
? "cursor-pointer hover:bg-custom-primary-100 hover:text-white"
|
||||
: "cursor-not-allowed opacity-75"
|
||||
}`}
|
||||
>
|
||||
<Icon iconName="keyboard_arrow_left" className="!text-lg" />
|
||||
<div className="pr-1">Prev</div>
|
||||
</button>
|
||||
<button
|
||||
disabled={!exporterServices?.next_page_results}
|
||||
onClick={() =>
|
||||
exporterServices?.next_page_results && setCursor(exporterServices?.next_cursor)
|
||||
}
|
||||
className={`flex items-center border border-custom-primary-100 text-custom-primary-100 px-1 rounded ${
|
||||
exporterServices?.next_page_results
|
||||
? "cursor-pointer hover:bg-custom-primary-100 hover:text-white"
|
||||
: "cursor-not-allowed opacity-75"
|
||||
}`}
|
||||
>
|
||||
<div className="pl-1">Next</div>
|
||||
<Icon iconName="keyboard_arrow_right" className="!text-lg" />
|
||||
</button>
|
||||
</div>
|
||||
</h3>
|
||||
{exporterServices && exporterServices?.results ? (
|
||||
exporterServices?.results?.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<div className="divide-y divide-custom-border-200">
|
||||
{exporterServices?.results.map((service) => (
|
||||
<SingleExport key={service.id} service={service} refreshing={refreshing} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="py-2 text-sm text-custom-text-200">No previous export available.</p>
|
||||
)
|
||||
) : (
|
||||
<Loader className="mt-6 grid grid-cols-1 gap-3">
|
||||
<Loader.Item height="40px" width="100%" />
|
||||
<Loader.Item height="40px" width="100%" />
|
||||
<Loader.Item height="40px" width="100%" />
|
||||
<Loader.Item height="40px" width="100%" />
|
||||
</Loader>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
{provider && (
|
||||
<Exporter
|
||||
isOpen={true}
|
||||
handleClose={() => handleCsvClose()}
|
||||
data={null}
|
||||
user={user}
|
||||
provider={provider}
|
||||
mutateServices={() =>
|
||||
mutate(EXPORT_SERVICES_LIST(workspaceSlug as string, `${cursor}`, `${per_page}`))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default IntegrationGuide;
|
||||
@@ -1,4 +0,0 @@
|
||||
//layout
|
||||
export * from "./single-export";
|
||||
// csv
|
||||
export * from "./export-modal";
|
||||
@@ -1,81 +0,0 @@
|
||||
import React from "react";
|
||||
// next imports
|
||||
import Link from "next/link";
|
||||
// ui
|
||||
import { PrimaryButton } from "components/ui"; // icons
|
||||
// helpers
|
||||
import { renderShortDateWithYearFormat } from "helpers/date-time.helper";
|
||||
// types
|
||||
import { IExportData } from "types";
|
||||
|
||||
type Props = {
|
||||
service: IExportData;
|
||||
refreshing: boolean;
|
||||
};
|
||||
|
||||
export const SingleExport: React.FC<Props> = ({ service, refreshing }) => {
|
||||
const provider = service.provider;
|
||||
const [isLoading, setIsLoading] = React.useState(false);
|
||||
|
||||
const checkExpiry = (inputDateString: string) => {
|
||||
const currentDate = new Date();
|
||||
const expiryDate = new Date(inputDateString);
|
||||
expiryDate.setDate(expiryDate.getDate() + 7);
|
||||
return expiryDate > currentDate;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-2 py-3">
|
||||
<div>
|
||||
<h4 className="flex items-center gap-2 text-sm">
|
||||
<span>
|
||||
Export to{" "}
|
||||
<span className="font-medium">
|
||||
{provider === "csv"
|
||||
? "CSV"
|
||||
: provider === "xlsx"
|
||||
? "Excel"
|
||||
: provider === "json"
|
||||
? "JSON"
|
||||
: ""}
|
||||
</span>{" "}
|
||||
</span>
|
||||
<span
|
||||
className={`rounded px-2 py-0.5 text-xs capitalize ${
|
||||
service.status === "completed"
|
||||
? "bg-green-500/20 text-green-500"
|
||||
: service.status === "processing"
|
||||
? "bg-yellow-500/20 text-yellow-500"
|
||||
: service.status === "failed"
|
||||
? "bg-red-500/20 text-red-500"
|
||||
: service.status === "expired"
|
||||
? "bg-orange-500/20 text-orange-500"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{refreshing ? "Refreshing..." : service.status}
|
||||
</span>
|
||||
</h4>
|
||||
<div className="mt-2 flex items-center gap-2 text-xs text-custom-text-200">
|
||||
<span>{renderShortDateWithYearFormat(service.created_at)}</span>|
|
||||
<span>Exported by {service?.initiated_by_detail?.display_name}</span>
|
||||
</div>
|
||||
</div>
|
||||
{checkExpiry(service.created_at) ? (
|
||||
<>
|
||||
{service.status == "completed" && (
|
||||
<div>
|
||||
<Link href={service?.url}>
|
||||
<PrimaryButton className="w-full text-center">
|
||||
{isLoading ? "Downloading..." : "Download"}
|
||||
</PrimaryButton>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="text-xs text-red-500">Expired</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,103 +0,0 @@
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
// ui
|
||||
import { Tooltip } from "components/ui";
|
||||
// helpers
|
||||
import { renderShortDate } from "helpers/date-time.helper";
|
||||
// types
|
||||
import { ICycle, IIssue, IModule } from "types";
|
||||
// constants
|
||||
import { MODULE_STATUS } from "constants/module";
|
||||
|
||||
export const IssueGanttBlock = ({ issue }: { issue: IIssue }) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug } = router.query;
|
||||
|
||||
return (
|
||||
<Link href={`/${workspaceSlug}/projects/${issue.project}/issues/${issue.id}`}>
|
||||
<a className="relative flex items-center w-full h-full shadow-sm transition-all duration-300">
|
||||
<div
|
||||
className="flex-shrink-0 w-0.5 h-full"
|
||||
style={{ backgroundColor: issue.state_detail?.color }}
|
||||
/>
|
||||
<Tooltip
|
||||
tooltipContent={
|
||||
<div className="space-y-1">
|
||||
<h5>{issue.name}</h5>
|
||||
<div>
|
||||
{renderShortDate(issue.start_date ?? "")} to{" "}
|
||||
{renderShortDate(issue.target_date ?? "")}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
position="top-left"
|
||||
>
|
||||
<div className="text-custom-text-100 text-sm truncate py-1 px-2.5 w-full">
|
||||
{issue.name}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</a>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
export const CycleGanttBlock = ({ cycle }: { cycle: ICycle }) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug } = router.query;
|
||||
|
||||
return (
|
||||
<Link href={`/${workspaceSlug}/projects/${cycle.project}/cycles/${cycle.id}`}>
|
||||
<a className="relative flex items-center w-full h-full shadow-sm transition-all duration-300">
|
||||
<div className="flex-shrink-0 w-0.5 h-full bg-custom-primary-100" />
|
||||
<Tooltip
|
||||
tooltipContent={
|
||||
<div className="space-y-1">
|
||||
<h5>{cycle.name}</h5>
|
||||
<div>
|
||||
{renderShortDate(cycle.start_date ?? "")} to {renderShortDate(cycle.end_date ?? "")}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
position="top-left"
|
||||
>
|
||||
<div className="text-custom-text-100 text-sm truncate py-1 px-2.5 w-full">
|
||||
{cycle.name}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</a>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
export const ModuleGanttBlock = ({ module }: { module: IModule }) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug } = router.query;
|
||||
|
||||
return (
|
||||
<Link href={`/${workspaceSlug}/projects/${module.project}/modules/${module.id}`}>
|
||||
<a className="relative flex items-center w-full h-full shadow-sm transition-all duration-300">
|
||||
<div
|
||||
className="flex-shrink-0 w-0.5 h-full"
|
||||
style={{ backgroundColor: MODULE_STATUS.find((s) => s.value === module.status)?.color }}
|
||||
/>
|
||||
<Tooltip
|
||||
tooltipContent={
|
||||
<div className="space-y-1">
|
||||
<h5>{module.name}</h5>
|
||||
<div>
|
||||
{renderShortDate(module.start_date ?? "")} to{" "}
|
||||
{renderShortDate(module.target_date ?? "")}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
position="top-left"
|
||||
>
|
||||
<div className="text-custom-text-100 text-sm truncate py-1 px-2.5 w-full">
|
||||
{module.name}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</a>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
@@ -1,178 +0,0 @@
|
||||
import { FC } from "react";
|
||||
|
||||
// react-beautiful-dnd
|
||||
import { DragDropContext, Draggable, DropResult } from "react-beautiful-dnd";
|
||||
import StrictModeDroppable from "components/dnd/StrictModeDroppable";
|
||||
// helpers
|
||||
import { ChartDraggable } from "../helpers/draggable";
|
||||
import { renderDateFormat } from "helpers/date-time.helper";
|
||||
// types
|
||||
import { IBlockUpdateData, IGanttBlock } from "../types";
|
||||
|
||||
export const GanttChartBlocks: FC<{
|
||||
itemsContainerWidth: number;
|
||||
blocks: IGanttBlock[] | null;
|
||||
sidebarBlockRender: FC;
|
||||
blockRender: FC;
|
||||
blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void;
|
||||
enableLeftDrag: boolean;
|
||||
enableRightDrag: boolean;
|
||||
enableReorder: boolean;
|
||||
}> = ({
|
||||
itemsContainerWidth,
|
||||
blocks,
|
||||
sidebarBlockRender,
|
||||
blockRender,
|
||||
blockUpdateHandler,
|
||||
enableLeftDrag,
|
||||
enableRightDrag,
|
||||
enableReorder,
|
||||
}) => {
|
||||
const handleChartBlockPosition = (
|
||||
block: IGanttBlock,
|
||||
totalBlockShifts: number,
|
||||
dragDirection: "left" | "right"
|
||||
) => {
|
||||
let updatedDate = new Date();
|
||||
|
||||
if (dragDirection === "left") {
|
||||
const originalDate = new Date(block.start_date);
|
||||
|
||||
const currentDay = originalDate.getDate();
|
||||
updatedDate = new Date(originalDate);
|
||||
|
||||
updatedDate.setDate(currentDay - totalBlockShifts);
|
||||
} else {
|
||||
const originalDate = new Date(block.target_date);
|
||||
|
||||
const currentDay = originalDate.getDate();
|
||||
updatedDate = new Date(originalDate);
|
||||
|
||||
updatedDate.setDate(currentDay + totalBlockShifts);
|
||||
}
|
||||
|
||||
blockUpdateHandler(block.data, {
|
||||
[dragDirection === "left" ? "start_date" : "target_date"]: renderDateFormat(updatedDate),
|
||||
});
|
||||
};
|
||||
|
||||
const handleOrderChange = (result: DropResult) => {
|
||||
if (!blocks) return;
|
||||
|
||||
const { source, destination, draggableId } = result;
|
||||
|
||||
if (!destination) return;
|
||||
|
||||
if (source.index === destination.index && document) {
|
||||
// const draggedBlock = document.querySelector(`#${draggableId}`) as HTMLElement;
|
||||
// const blockStyles = window.getComputedStyle(draggedBlock);
|
||||
|
||||
// console.log(blockStyles.marginLeft);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
let updatedSortOrder = blocks[source.index].sort_order;
|
||||
|
||||
if (destination.index === 0) updatedSortOrder = blocks[0].sort_order - 1000;
|
||||
else if (destination.index === blocks.length - 1)
|
||||
updatedSortOrder = blocks[blocks.length - 1].sort_order + 1000;
|
||||
else {
|
||||
const destinationSortingOrder = blocks[destination.index].sort_order;
|
||||
const relativeDestinationSortingOrder =
|
||||
source.index < destination.index
|
||||
? blocks[destination.index + 1].sort_order
|
||||
: blocks[destination.index - 1].sort_order;
|
||||
|
||||
updatedSortOrder = (destinationSortingOrder + relativeDestinationSortingOrder) / 2;
|
||||
}
|
||||
|
||||
const removedElement = blocks.splice(source.index, 1)[0];
|
||||
blocks.splice(destination.index, 0, removedElement);
|
||||
|
||||
blockUpdateHandler(removedElement.data, {
|
||||
sort_order: {
|
||||
destinationIndex: destination.index,
|
||||
newSortOrder: updatedSortOrder,
|
||||
sourceIndex: source.index,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative z-[5] mt-[72px] h-full overflow-hidden overflow-y-auto"
|
||||
style={{ width: `${itemsContainerWidth}px` }}
|
||||
>
|
||||
<DragDropContext onDragEnd={handleOrderChange}>
|
||||
<StrictModeDroppable droppableId="gantt">
|
||||
{(droppableProvided, droppableSnapshot) => (
|
||||
<div
|
||||
className="w-full space-y-2"
|
||||
ref={droppableProvided.innerRef}
|
||||
{...droppableProvided.droppableProps}
|
||||
>
|
||||
<>
|
||||
{blocks &&
|
||||
blocks.length > 0 &&
|
||||
blocks.map(
|
||||
(block, index: number) =>
|
||||
block.start_date &&
|
||||
block.target_date && (
|
||||
<Draggable
|
||||
key={`block-${block.id}`}
|
||||
draggableId={`block-${block.id}`}
|
||||
index={index}
|
||||
isDragDisabled={!enableReorder}
|
||||
>
|
||||
{(provided) => (
|
||||
<div
|
||||
className={
|
||||
droppableSnapshot.isDraggingOver ? "bg-custom-border-100/10" : ""
|
||||
}
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
>
|
||||
<ChartDraggable
|
||||
block={block}
|
||||
handleBlock={(...args) => handleChartBlockPosition(block, ...args)}
|
||||
enableLeftDrag={enableLeftDrag}
|
||||
enableRightDrag={enableRightDrag}
|
||||
provided={provided}
|
||||
>
|
||||
<div
|
||||
className="rounded shadow-sm bg-custom-background-80 overflow-hidden h-9 flex items-center transition-all"
|
||||
style={{
|
||||
width: `${block.position?.width}px`,
|
||||
}}
|
||||
>
|
||||
{blockRender({
|
||||
...block.data,
|
||||
})}
|
||||
</div>
|
||||
</ChartDraggable>
|
||||
</div>
|
||||
)}
|
||||
</Draggable>
|
||||
)
|
||||
)}
|
||||
{droppableProvided.placeholder}
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
</StrictModeDroppable>
|
||||
</DragDropContext>
|
||||
|
||||
{/* sidebar */}
|
||||
{/* <div className="fixed top-0 bottom-0 w-[300px] flex-shrink-0 divide-y divide-custom-border-200 border-r border-custom-border-200 overflow-y-auto">
|
||||
{blocks &&
|
||||
blocks.length > 0 &&
|
||||
blocks.map((block: any, _idx: number) => (
|
||||
<div className="relative h-[40px] bg-custom-background-100" key={`sidebar-blocks-${_idx}`}>
|
||||
{sidebarBlockRender(block?.data)}
|
||||
</div>
|
||||
))}
|
||||
</div> */}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from "./block";
|
||||
export * from "./blocks-display";
|
||||
@@ -0,0 +1,82 @@
|
||||
import { FC, useEffect, useState } from "react";
|
||||
// helpers
|
||||
import { ChartDraggable } from "../helpers/draggable";
|
||||
// data
|
||||
import { datePreview } from "../data";
|
||||
|
||||
export const GanttChartBlocks: FC<{
|
||||
itemsContainerWidth: number;
|
||||
blocks: null | any[];
|
||||
sidebarBlockRender: FC;
|
||||
blockRender: FC;
|
||||
}> = ({ itemsContainerWidth, blocks, sidebarBlockRender, blockRender }) => {
|
||||
const handleChartBlockPosition = (block: any) => {
|
||||
// setChartBlocks((prevData: any) =>
|
||||
// prevData.map((_block: any) => (_block?.data?.id == block?.data?.id ? block : _block))
|
||||
// );
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative z-[5] mt-[58px] h-full w-[4000px] divide-x divide-gray-300 overflow-hidden overflow-y-auto"
|
||||
style={{ width: `${itemsContainerWidth}px` }}
|
||||
>
|
||||
<div className="w-full">
|
||||
{blocks &&
|
||||
blocks.length > 0 &&
|
||||
blocks.map((block: any, _idx: number) => (
|
||||
<>
|
||||
{block.start_date && block.target_date && (
|
||||
<ChartDraggable
|
||||
className="relative flex h-[40px] items-center"
|
||||
key={`blocks-${_idx}`}
|
||||
block={block}
|
||||
handleBlock={handleChartBlockPosition}
|
||||
>
|
||||
<div
|
||||
className="relative group inline-flex cursor-pointer items-center font-medium transition-all"
|
||||
style={{ marginLeft: `${block?.position?.marginLeft}px` }}
|
||||
>
|
||||
<div className="flex-shrink-0 relative w-0 h-0 flex items-center invisible group-hover:visible whitespace-nowrap">
|
||||
<div className="absolute right-0 mr-[5px] rounded-sm bg-custom-background-90 px-2 py-0.5 text-xs font-medium">
|
||||
{block?.start_date ? datePreview(block?.start_date) : "-"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="rounded shadow-sm bg-custom-background-100 overflow-hidden relative flex items-center h-[34px] border border-custom-border-200"
|
||||
style={{
|
||||
width: `${block?.position?.width}px`,
|
||||
}}
|
||||
>
|
||||
{blockRender({
|
||||
...block?.data,
|
||||
infoToggle: block?.infoToggle ? true : false,
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex-shrink-0 relative w-0 h-0 flex items-center invisible group-hover:visible whitespace-nowrap">
|
||||
<div className="absolute left-0 ml-[5px] mr-[5px] rounded-sm bg-custom-background-90 px-2 py-0.5 text-xs font-medium">
|
||||
{block?.target_date ? datePreview(block?.target_date) : "-"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ChartDraggable>
|
||||
)}
|
||||
</>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* sidebar */}
|
||||
{/* <div className="fixed top-0 bottom-0 w-[300px] flex-shrink-0 divide-y divide-custom-border-200 border-r border-custom-border-200 overflow-y-auto">
|
||||
{blocks &&
|
||||
blocks.length > 0 &&
|
||||
blocks.map((block: any, _idx: number) => (
|
||||
<div className="relative h-[40px] bg-custom-background-100" key={`sidebar-blocks-${_idx}`}>
|
||||
{sidebarBlockRender(block?.data)}
|
||||
</div>
|
||||
))}
|
||||
</div> */}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -25,7 +25,7 @@ export const BiWeekChartView: FC<any> = () => {
|
||||
<div
|
||||
key={`sub-title-${_idxRoot}-${_idx}`}
|
||||
className="relative flex h-full flex-col overflow-hidden whitespace-nowrap"
|
||||
style={{ width: `${currentViewData?.data.width}px` }}
|
||||
style={{ width: `${currentViewData.data.width}px` }}
|
||||
>
|
||||
<div
|
||||
className={`flex-shrink-0 border-b py-1 text-center text-sm capitalize font-medium ${
|
||||
|
||||
@@ -25,7 +25,7 @@ export const DayChartView: FC<any> = () => {
|
||||
<div
|
||||
key={`sub-title-${_idxRoot}-${_idx}`}
|
||||
className="relative flex h-full flex-col overflow-hidden whitespace-nowrap"
|
||||
style={{ width: `${currentViewData?.data.width}px` }}
|
||||
style={{ width: `${currentViewData.data.width}px` }}
|
||||
>
|
||||
<div
|
||||
className={`flex-shrink-0 border-b py-1 text-center text-sm capitalize font-medium ${
|
||||
|
||||
@@ -25,7 +25,7 @@ export const HourChartView: FC<any> = () => {
|
||||
<div
|
||||
key={`sub-title-${_idxRoot}-${_idx}`}
|
||||
className="relative flex h-full flex-col overflow-hidden whitespace-nowrap"
|
||||
style={{ width: `${currentViewData?.data.width}px` }}
|
||||
style={{ width: `${currentViewData.data.width}px` }}
|
||||
>
|
||||
<div
|
||||
className={`flex-shrink-0 border-b py-1 text-center text-sm capitalize font-medium ${
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { FC, useEffect, useState } from "react";
|
||||
// icons
|
||||
import { ArrowsPointingInIcon, ArrowsPointingOutIcon } from "@heroicons/react/20/solid";
|
||||
import {
|
||||
Bars4Icon,
|
||||
XMarkIcon,
|
||||
ArrowsPointingInIcon,
|
||||
ArrowsPointingOutIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
// components
|
||||
import { GanttChartBlocks } from "components/gantt-chart";
|
||||
import { GanttChartBlocks } from "../blocks";
|
||||
// import { HourChartView } from "./hours";
|
||||
// import { DayChartView } from "./day";
|
||||
// import { WeekChartView } from "./week";
|
||||
@@ -25,9 +30,9 @@ import {
|
||||
getMonthChartItemPositionWidthInMonth,
|
||||
} from "../views";
|
||||
// types
|
||||
import { ChartDataType, IBlockUpdateData, IGanttBlock } from "../types";
|
||||
import { ChartDataType } from "../types";
|
||||
// data
|
||||
import { currentViewDataWithView } from "../data";
|
||||
import { datePreview, currentViewDataWithView } from "../data";
|
||||
// context
|
||||
import { useChart } from "../hooks";
|
||||
|
||||
@@ -35,13 +40,10 @@ type ChartViewRootProps = {
|
||||
border: boolean;
|
||||
title: null | string;
|
||||
loaderTitle: string;
|
||||
blocks: IGanttBlock[] | null;
|
||||
blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void;
|
||||
blocks: any;
|
||||
blockUpdateHandler: (data: any) => void;
|
||||
sidebarBlockRender: FC<any>;
|
||||
blockRender: FC<any>;
|
||||
enableLeftDrag: boolean;
|
||||
enableRightDrag: boolean;
|
||||
enableReorder: boolean;
|
||||
};
|
||||
|
||||
export const ChartViewRoot: FC<ChartViewRootProps> = ({
|
||||
@@ -52,9 +54,6 @@ export const ChartViewRoot: FC<ChartViewRootProps> = ({
|
||||
blockUpdateHandler,
|
||||
sidebarBlockRender,
|
||||
blockRender,
|
||||
enableLeftDrag,
|
||||
enableRightDrag,
|
||||
enableReorder,
|
||||
}) => {
|
||||
const { currentView, currentViewData, renderView, dispatch, allViews } = useChart();
|
||||
|
||||
@@ -63,13 +62,13 @@ export const ChartViewRoot: FC<ChartViewRootProps> = ({
|
||||
const [blocksSidebarView, setBlocksSidebarView] = useState<boolean>(false);
|
||||
|
||||
// blocks state management starts
|
||||
const [chartBlocks, setChartBlocks] = useState<IGanttBlock[] | null>(null);
|
||||
const [chartBlocks, setChartBlocks] = useState<any[] | null>(null);
|
||||
|
||||
const renderBlockStructure = (view: any, blocks: IGanttBlock[]) =>
|
||||
const renderBlockStructure = (view: any, blocks: any) =>
|
||||
blocks && blocks.length > 0
|
||||
? blocks.map((block: any) => ({
|
||||
...block,
|
||||
position: getMonthChartItemPositionWidthInMonth(view, block),
|
||||
? blocks.map((_block: any) => ({
|
||||
..._block,
|
||||
position: getMonthChartItemPositionWidthInMonth(view, _block),
|
||||
}))
|
||||
: [];
|
||||
|
||||
@@ -155,14 +154,13 @@ export const ChartViewRoot: FC<ChartViewRootProps> = ({
|
||||
|
||||
const updatingCurrentLeftScrollPosition = (width: number) => {
|
||||
const scrollContainer = document.getElementById("scroll-container") as HTMLElement;
|
||||
scrollContainer.scrollLeft = width + scrollContainer?.scrollLeft;
|
||||
setItemsContainerWidth(width + scrollContainer?.scrollLeft);
|
||||
scrollContainer.scrollLeft = width + scrollContainer.scrollLeft;
|
||||
setItemsContainerWidth(width + scrollContainer.scrollLeft);
|
||||
};
|
||||
|
||||
const handleScrollToCurrentSelectedDate = (currentState: ChartDataType, date: Date) => {
|
||||
const scrollContainer = document.getElementById("scroll-container") as HTMLElement;
|
||||
|
||||
const clientVisibleWidth: number = scrollContainer?.clientWidth;
|
||||
const clientVisibleWidth: number = scrollContainer.clientWidth;
|
||||
let scrollWidth: number = 0;
|
||||
let daysDifference: number = 0;
|
||||
|
||||
@@ -191,9 +189,9 @@ export const ChartViewRoot: FC<ChartViewRootProps> = ({
|
||||
const onScroll = () => {
|
||||
const scrollContainer = document.getElementById("scroll-container") as HTMLElement;
|
||||
|
||||
const scrollWidth: number = scrollContainer?.scrollWidth;
|
||||
const clientVisibleWidth: number = scrollContainer?.clientWidth;
|
||||
const currentScrollPosition: number = scrollContainer?.scrollLeft;
|
||||
const scrollWidth: number = scrollContainer.scrollWidth;
|
||||
const clientVisibleWidth: number = scrollContainer.clientWidth;
|
||||
const currentScrollPosition: number = scrollContainer.scrollLeft;
|
||||
|
||||
const approxRangeLeft: number =
|
||||
scrollWidth >= clientVisibleWidth + 1000 ? 1000 : scrollWidth - clientVisibleWidth;
|
||||
@@ -209,7 +207,6 @@ export const ChartViewRoot: FC<ChartViewRootProps> = ({
|
||||
const scrollContainer = document.getElementById("scroll-container") as HTMLElement;
|
||||
|
||||
scrollContainer.addEventListener("scroll", onScroll);
|
||||
|
||||
return () => {
|
||||
scrollContainer.removeEventListener("scroll", onScroll);
|
||||
};
|
||||
@@ -245,7 +242,7 @@ export const ChartViewRoot: FC<ChartViewRootProps> = ({
|
||||
</div> */}
|
||||
|
||||
{/* chart header */}
|
||||
<div className="flex w-full flex-shrink-0 flex-wrap items-center gap-2 whitespace-nowrap p-2">
|
||||
<div className="flex w-full flex-shrink-0 flex-wrap items-center gap-5 gap-y-3 whitespace-nowrap p-2">
|
||||
{/* <div
|
||||
className="transition-all border border-custom-border-200 w-[30px] h-[30px] flex justify-center items-center cursor-pointer rounded-sm hover:bg-custom-background-80"
|
||||
onClick={() => setBlocksSidebarView(() => !blocksSidebarView)}
|
||||
@@ -304,8 +301,8 @@ export const ChartViewRoot: FC<ChartViewRootProps> = ({
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="transition-all border border-custom-border-200 p-1 flex justify-center items-center cursor-pointer rounded-sm hover:bg-custom-background-80"
|
||||
onClick={() => setFullScreenMode((prevData) => !prevData)}
|
||||
className="transition-all border border-custom-border-200 w-[30px] h-[30px] flex justify-center items-center cursor-pointer rounded-sm hover:bg-custom-background-80"
|
||||
onClick={() => setFullScreenMode(() => !fullScreenMode)}
|
||||
>
|
||||
{fullScreenMode ? (
|
||||
<ArrowsPointingInIcon className="h-4 w-4" />
|
||||
@@ -328,10 +325,6 @@ export const ChartViewRoot: FC<ChartViewRootProps> = ({
|
||||
blocks={chartBlocks}
|
||||
sidebarBlockRender={sidebarBlockRender}
|
||||
blockRender={blockRender}
|
||||
blockUpdateHandler={blockUpdateHandler}
|
||||
enableLeftDrag={enableLeftDrag}
|
||||
enableRightDrag={enableRightDrag}
|
||||
enableReorder={enableReorder}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,55 +1,48 @@
|
||||
import { FC } from "react";
|
||||
|
||||
// hooks
|
||||
// context
|
||||
import { useChart } from "../hooks";
|
||||
// types
|
||||
import { IMonthBlock } from "../views";
|
||||
|
||||
export const MonthChartView: FC<any> = () => {
|
||||
const { currentViewData, renderView } = useChart();
|
||||
|
||||
const monthBlocks: IMonthBlock[] = renderView;
|
||||
const { currentView, currentViewData, renderView, dispatch, allViews } = useChart();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="absolute flex h-full flex-grow divide-x divide-custom-border-100/50">
|
||||
{monthBlocks &&
|
||||
monthBlocks.length > 0 &&
|
||||
monthBlocks.map((block, _idxRoot) => (
|
||||
<div key={`month-${block?.month}-${block?.year}`} className="relative flex flex-col">
|
||||
<div className="absolute flex h-full flex-grow divide-x divide-custom-border-200">
|
||||
{renderView &&
|
||||
renderView.length > 0 &&
|
||||
renderView.map((_itemRoot: any, _idxRoot: any) => (
|
||||
<div key={`title-${_idxRoot}`} className="relative flex flex-col">
|
||||
<div className="relative border-b border-custom-border-200">
|
||||
<div className="sticky left-0 inline-flex whitespace-nowrap px-2 py-1 text-sm font-medium capitalize">
|
||||
{block?.title}
|
||||
{_itemRoot?.title}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex h-full w-full divide-x divide-custom-border-100/50">
|
||||
{block?.children &&
|
||||
block?.children.length > 0 &&
|
||||
block?.children.map((monthDay, _idx) => (
|
||||
<div className="flex h-full w-full divide-x divide-custom-border-200">
|
||||
{_itemRoot.children &&
|
||||
_itemRoot.children.length > 0 &&
|
||||
_itemRoot.children.map((_item: any, _idx: any) => (
|
||||
<div
|
||||
key={`sub-title-${_idxRoot}-${_idx}`}
|
||||
className="relative flex h-full flex-col overflow-hidden whitespace-nowrap"
|
||||
style={{ width: `${currentViewData?.data.width}px` }}
|
||||
style={{ width: `${currentViewData.data.width}px` }}
|
||||
>
|
||||
<div
|
||||
className={`flex-shrink-0 border-b py-1 text-center text-sm capitalize font-medium ${
|
||||
monthDay?.today
|
||||
? `text-red-500 border-red-500`
|
||||
: `border-custom-border-200`
|
||||
_item?.today ? `text-red-500 border-red-500` : `border-custom-border-200`
|
||||
}`}
|
||||
>
|
||||
<div>{monthDay?.title}</div>
|
||||
<div>{_item.title}</div>
|
||||
</div>
|
||||
<div
|
||||
className={`relative h-full w-full flex-1 flex justify-center ${
|
||||
["sat", "sun"].includes(monthDay?.dayData?.shortTitle || "")
|
||||
["sat", "sun"].includes(_item?.dayData?.shortTitle || "")
|
||||
? `bg-custom-background-90`
|
||||
: ``
|
||||
}`}
|
||||
>
|
||||
{monthDay?.today && (
|
||||
<div className="absolute top-0 bottom-0 w-[1px] bg-red-500" />
|
||||
{_item?.today && (
|
||||
<div className="absolute top-0 bottom-0 border border-red-500"> </div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -25,7 +25,7 @@ export const QuarterChartView: FC<any> = () => {
|
||||
<div
|
||||
key={`sub-title-${_idxRoot}-${_idx}`}
|
||||
className="relative flex h-full flex-col overflow-hidden whitespace-nowrap"
|
||||
style={{ width: `${currentViewData?.data.width}px` }}
|
||||
style={{ width: `${currentViewData.data.width}px` }}
|
||||
>
|
||||
<div
|
||||
className={`flex-shrink-0 border-b py-1 text-center text-sm capitalize font-medium ${
|
||||
|
||||
@@ -25,7 +25,7 @@ export const WeekChartView: FC<any> = () => {
|
||||
<div
|
||||
key={`sub-title-${_idxRoot}-${_idx}`}
|
||||
className="relative flex h-full flex-col overflow-hidden whitespace-nowrap"
|
||||
style={{ width: `${currentViewData?.data.width}px` }}
|
||||
style={{ width: `${currentViewData.data.width}px` }}
|
||||
>
|
||||
<div
|
||||
className={`flex-shrink-0 border-b py-1 text-center text-sm capitalize font-medium ${
|
||||
|
||||
@@ -25,7 +25,7 @@ export const YearChartView: FC<any> = () => {
|
||||
<div
|
||||
key={`sub-title-${_idxRoot}-${_idx}`}
|
||||
className="relative flex h-full flex-col overflow-hidden whitespace-nowrap"
|
||||
style={{ width: `${currentViewData?.data.width}px` }}
|
||||
style={{ width: `${currentViewData.data.width}px` }}
|
||||
>
|
||||
<div
|
||||
className={`flex-shrink-0 border-b py-1 text-center text-sm capitalize font-medium ${
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
// types
|
||||
import { IIssue } from "types";
|
||||
import { IGanttBlock } from "components/gantt-chart";
|
||||
|
||||
export const renderIssueBlocksStructure = (blocks: IIssue[]): IGanttBlock[] =>
|
||||
blocks && blocks.length > 0
|
||||
? blocks.map((block) => ({
|
||||
data: block,
|
||||
id: block.id,
|
||||
sort_order: block.sort_order,
|
||||
start_date: new Date(block.start_date ?? ""),
|
||||
target_date: new Date(block.target_date ?? ""),
|
||||
}))
|
||||
: [];
|
||||
@@ -1,205 +1,138 @@
|
||||
import React, { useRef, useState } from "react";
|
||||
import { useState, useRef } from "react";
|
||||
|
||||
// react-beautiful-dnd
|
||||
import { DraggableProvided } from "react-beautiful-dnd";
|
||||
import { useChart } from "../hooks";
|
||||
// types
|
||||
import { IGanttBlock } from "../types";
|
||||
export const ChartDraggable = ({ children, block, handleBlock, className }: any) => {
|
||||
const [dragging, setDragging] = useState(false);
|
||||
|
||||
type Props = {
|
||||
children: any;
|
||||
block: IGanttBlock;
|
||||
handleBlock: (totalBlockShifts: number, dragDirection: "left" | "right") => void;
|
||||
enableLeftDrag: boolean;
|
||||
enableRightDrag: boolean;
|
||||
provided: DraggableProvided;
|
||||
};
|
||||
const [chartBlockPositionLeft, setChartBlockPositionLeft] = useState(0);
|
||||
const [blockPositionLeft, setBlockPositionLeft] = useState(0);
|
||||
const [dragBlockOffsetX, setDragBlockOffsetX] = useState(0);
|
||||
|
||||
export const ChartDraggable: React.FC<Props> = ({
|
||||
children,
|
||||
block,
|
||||
handleBlock,
|
||||
enableLeftDrag = true,
|
||||
enableRightDrag = true,
|
||||
provided,
|
||||
}) => {
|
||||
const [isLeftResizing, setIsLeftResizing] = useState(false);
|
||||
const [isRightResizing, setIsRightResizing] = useState(false);
|
||||
const handleMouseDown = (event: any) => {
|
||||
const chartBlockPositionLeft: number = block.position.marginLeft;
|
||||
const blockPositionLeft: number = event.target.getBoundingClientRect().left;
|
||||
const dragBlockOffsetX: number = event.clientX - event.target.getBoundingClientRect().left;
|
||||
|
||||
const parentDivRef = useRef<HTMLDivElement>(null);
|
||||
const resizableRef = useRef<HTMLDivElement>(null);
|
||||
console.log("--------------------");
|
||||
console.log("chartBlockPositionLeft", chartBlockPositionLeft);
|
||||
console.log("blockPositionLeft", blockPositionLeft);
|
||||
console.log("dragBlockOffsetX", dragBlockOffsetX);
|
||||
console.log("-->");
|
||||
|
||||
const { currentViewData } = useChart();
|
||||
|
||||
const checkScrollEnd = (e: MouseEvent): number => {
|
||||
let delWidth = 0;
|
||||
|
||||
const scrollContainer = document.querySelector("#scroll-container") as HTMLElement;
|
||||
const appSidebar = document.querySelector("#app-sidebar") as HTMLElement;
|
||||
|
||||
const posFromLeft = e.clientX;
|
||||
// manually scroll to left if reached the left end while dragging
|
||||
if (posFromLeft - appSidebar.clientWidth <= 70) {
|
||||
if (e.movementX > 0) return 0;
|
||||
|
||||
delWidth = -5;
|
||||
|
||||
scrollContainer.scrollBy(delWidth, 0);
|
||||
} else delWidth = e.movementX;
|
||||
|
||||
// manually scroll to right if reached the right end while dragging
|
||||
const posFromRight = window.innerWidth - e.clientX;
|
||||
if (posFromRight <= 70) {
|
||||
if (e.movementX < 0) return 0;
|
||||
|
||||
delWidth = 5;
|
||||
|
||||
scrollContainer.scrollBy(delWidth, 0);
|
||||
} else delWidth = e.movementX;
|
||||
|
||||
return delWidth;
|
||||
setDragging(true);
|
||||
setChartBlockPositionLeft(chartBlockPositionLeft);
|
||||
setBlockPositionLeft(blockPositionLeft);
|
||||
setDragBlockOffsetX(dragBlockOffsetX);
|
||||
};
|
||||
|
||||
const handleLeftDrag = () => {
|
||||
if (!currentViewData || !resizableRef.current || !parentDivRef.current || !block.position)
|
||||
return;
|
||||
const handleMouseMove = (event: any) => {
|
||||
if (!dragging) return;
|
||||
|
||||
const resizableDiv = resizableRef.current;
|
||||
const parentDiv = parentDivRef.current;
|
||||
|
||||
const columnWidth = currentViewData.data.width;
|
||||
|
||||
const blockInitialWidth =
|
||||
resizableDiv.clientWidth ?? parseInt(block.position.width.toString(), 10);
|
||||
|
||||
let initialWidth = resizableDiv.clientWidth ?? parseInt(block.position.width.toString(), 10);
|
||||
let initialMarginLeft = parseInt(parentDiv.style.marginLeft);
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!window) return;
|
||||
|
||||
let delWidth = 0;
|
||||
|
||||
delWidth = checkScrollEnd(e);
|
||||
|
||||
// calculate new width and update the initialMarginLeft using -=
|
||||
const newWidth = Math.round((initialWidth -= delWidth) / columnWidth) * columnWidth;
|
||||
// calculate new marginLeft and update the initial marginLeft to the newly calculated one
|
||||
const newMarginLeft = initialMarginLeft - (newWidth - (block.position?.width ?? 0));
|
||||
initialMarginLeft = newMarginLeft;
|
||||
|
||||
// block needs to be at least 1 column wide
|
||||
if (newWidth < columnWidth) return;
|
||||
|
||||
resizableDiv.style.width = `${newWidth}px`;
|
||||
parentDiv.style.marginLeft = `${newMarginLeft}px`;
|
||||
|
||||
if (block.position) {
|
||||
block.position.width = newWidth;
|
||||
block.position.marginLeft = newMarginLeft;
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
|
||||
const totalBlockShifts = Math.ceil(
|
||||
(resizableDiv.clientWidth - blockInitialWidth) / columnWidth
|
||||
);
|
||||
|
||||
handleBlock(totalBlockShifts, "left");
|
||||
};
|
||||
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
const currentBlockPosition = event.clientX - dragBlockOffsetX;
|
||||
console.log("currentBlockPosition", currentBlockPosition);
|
||||
if (currentBlockPosition <= blockPositionLeft) {
|
||||
const updatedPosition = chartBlockPositionLeft - (blockPositionLeft - currentBlockPosition);
|
||||
console.log("updatedPosition", updatedPosition);
|
||||
handleBlock({ ...block, position: { ...block.position, marginLeft: updatedPosition } });
|
||||
} else {
|
||||
const updatedPosition = chartBlockPositionLeft + (blockPositionLeft - currentBlockPosition);
|
||||
console.log("updatedPosition", updatedPosition);
|
||||
handleBlock({ ...block, position: { ...block.position, marginLeft: updatedPosition } });
|
||||
}
|
||||
console.log("--------------------");
|
||||
};
|
||||
|
||||
const handleRightDrag = () => {
|
||||
if (!currentViewData || !resizableRef.current || !parentDivRef.current || !block.position)
|
||||
return;
|
||||
|
||||
const resizableDiv = resizableRef.current;
|
||||
|
||||
const columnWidth = currentViewData.data.width;
|
||||
|
||||
const blockInitialWidth =
|
||||
resizableDiv.clientWidth ?? parseInt(block.position.width.toString(), 10);
|
||||
|
||||
let initialWidth = resizableDiv.clientWidth ?? parseInt(block.position.width.toString(), 10);
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!window) return;
|
||||
|
||||
let delWidth = 0;
|
||||
|
||||
delWidth = checkScrollEnd(e);
|
||||
|
||||
// calculate new width and update the initialMarginLeft using +=
|
||||
const newWidth = Math.round((initialWidth += delWidth) / columnWidth) * columnWidth;
|
||||
|
||||
// block needs to be at least 1 column wide
|
||||
if (newWidth < columnWidth) return;
|
||||
|
||||
resizableDiv.style.width = `${Math.max(newWidth, 80)}px`;
|
||||
if (block.position) block.position.width = Math.max(newWidth, 80);
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
|
||||
const totalBlockShifts = Math.ceil(
|
||||
(resizableDiv.clientWidth - blockInitialWidth) / columnWidth
|
||||
);
|
||||
|
||||
handleBlock(totalBlockShifts, "right");
|
||||
};
|
||||
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
const handleMouseUp = () => {
|
||||
setDragging(false);
|
||||
setChartBlockPositionLeft(0);
|
||||
setBlockPositionLeft(0);
|
||||
setDragBlockOffsetX(0);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
id={`block-${block.id}`}
|
||||
ref={parentDivRef}
|
||||
className="relative group inline-flex cursor-pointer items-center font-medium transition-all"
|
||||
style={{
|
||||
marginLeft: `${block.position?.marginLeft}px`,
|
||||
}}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
className={`${className ? className : ``}`}
|
||||
>
|
||||
{enableLeftDrag && (
|
||||
<>
|
||||
<div
|
||||
onMouseDown={handleLeftDrag}
|
||||
onMouseEnter={() => setIsLeftResizing(true)}
|
||||
onMouseLeave={() => setIsLeftResizing(false)}
|
||||
className="absolute top-1/2 -left-2.5 -translate-y-1/2 z-[1] w-6 h-10 bg-brand-backdrop rounded-md cursor-col-resize"
|
||||
/>
|
||||
<div
|
||||
className={`absolute top-1/2 -translate-y-1/2 w-1 h-4/5 rounded-sm bg-custom-background-80 transition-all duration-300 ${
|
||||
isLeftResizing ? "-left-2.5" : "left-1"
|
||||
}`}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{React.cloneElement(children, { ref: resizableRef, ...provided.dragHandleProps })}
|
||||
{enableRightDrag && (
|
||||
<>
|
||||
<div
|
||||
onMouseDown={handleRightDrag}
|
||||
onMouseEnter={() => setIsRightResizing(true)}
|
||||
onMouseLeave={() => setIsRightResizing(false)}
|
||||
className="absolute top-1/2 -right-2.5 -translate-y-1/2 z-[1] w-6 h-6 bg-brand-backdrop rounded-md cursor-col-resize"
|
||||
/>
|
||||
<div
|
||||
className={`absolute top-1/2 -translate-y-1/2 w-1 h-4/5 rounded-sm bg-custom-background-80 transition-all duration-300 ${
|
||||
isRightResizing ? "-right-2.5" : "right-1"
|
||||
}`}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// import { useState } from "react";
|
||||
|
||||
// export const ChartDraggable = ({ children, id, className = "", style }: any) => {
|
||||
// const [dragging, setDragging] = useState(false);
|
||||
|
||||
// const [chartBlockPositionLeft, setChartBlockPositionLeft] = useState(0);
|
||||
// const [blockPositionLeft, setBlockPositionLeft] = useState(0);
|
||||
// const [dragBlockOffsetX, setDragBlockOffsetX] = useState(0);
|
||||
|
||||
// const handleDragStart = (event: any) => {
|
||||
// // event.dataTransfer.setData("text/plain", event.target.id);
|
||||
|
||||
// const chartBlockPositionLeft: number = parseInt(event.target.style.left.slice(0, -2));
|
||||
// const blockPositionLeft: number = event.target.getBoundingClientRect().left;
|
||||
// const dragBlockOffsetX: number = event.clientX - event.target.getBoundingClientRect().left;
|
||||
|
||||
// console.log("chartBlockPositionLeft", chartBlockPositionLeft);
|
||||
// console.log("blockPositionLeft", blockPositionLeft);
|
||||
// console.log("dragBlockOffsetX", dragBlockOffsetX);
|
||||
// console.log("--------------------");
|
||||
|
||||
// setDragging(true);
|
||||
// setChartBlockPositionLeft(chartBlockPositionLeft);
|
||||
// setBlockPositionLeft(blockPositionLeft);
|
||||
// setDragBlockOffsetX(dragBlockOffsetX);
|
||||
// };
|
||||
|
||||
// const handleDragEnd = () => {
|
||||
// setDragging(false);
|
||||
// setChartBlockPositionLeft(0);
|
||||
// setBlockPositionLeft(0);
|
||||
// setDragBlockOffsetX(0);
|
||||
// };
|
||||
|
||||
// const handleDragOver = (event: any) => {
|
||||
// event.preventDefault();
|
||||
// if (dragging) {
|
||||
// const scrollContainer = document.getElementById(`block-parent-${id}`) as HTMLElement;
|
||||
// const currentBlockPosition = event.clientX - dragBlockOffsetX;
|
||||
// console.log('currentBlockPosition')
|
||||
// if (currentBlockPosition <= blockPositionLeft) {
|
||||
// const updatedPosition = chartBlockPositionLeft - (blockPositionLeft - currentBlockPosition);
|
||||
// console.log("updatedPosition", updatedPosition);
|
||||
// if (scrollContainer) scrollContainer.style.left = `${updatedPosition}px`;
|
||||
// } else {
|
||||
// const updatedPosition = chartBlockPositionLeft + (blockPositionLeft - currentBlockPosition);
|
||||
// console.log("updatedPosition", updatedPosition);
|
||||
// if (scrollContainer) scrollContainer.style.left = `${updatedPosition}px`;
|
||||
// }
|
||||
// console.log("--------------------");
|
||||
// }
|
||||
// };
|
||||
|
||||
// const handleDrop = (event: any) => {
|
||||
// event.preventDefault();
|
||||
// setDragging(false);
|
||||
// setChartBlockPositionLeft(0);
|
||||
// setBlockPositionLeft(0);
|
||||
// setDragBlockOffsetX(0);
|
||||
// };
|
||||
|
||||
// return (
|
||||
// <div
|
||||
// id={id}
|
||||
// draggable
|
||||
// onDragStart={handleDragStart}
|
||||
// onDragEnd={handleDragEnd}
|
||||
// onDragOver={handleDragOver}
|
||||
// onDrop={handleDrop}
|
||||
// className={`${className} ${dragging ? "dragging" : ""}`}
|
||||
// style={style}
|
||||
// >
|
||||
// {children}
|
||||
// </div>
|
||||
// );
|
||||
// };
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./block-structure";
|
||||
@@ -1,41 +0,0 @@
|
||||
import { KeyedMutator } from "swr";
|
||||
|
||||
// services
|
||||
import issuesService from "services/issues.service";
|
||||
// types
|
||||
import { ICurrentUserResponse, IIssue } from "types";
|
||||
import { IBlockUpdateData } from "../types";
|
||||
|
||||
export const updateGanttIssue = (
|
||||
issue: IIssue,
|
||||
payload: IBlockUpdateData,
|
||||
mutate: KeyedMutator<any>,
|
||||
user: ICurrentUserResponse | undefined,
|
||||
workspaceSlug: string | undefined
|
||||
) => {
|
||||
if (!issue || !workspaceSlug || !user) return;
|
||||
|
||||
mutate((prevData: any) => {
|
||||
if (!prevData) return prevData;
|
||||
|
||||
const newList = prevData.map((p: any) => ({
|
||||
...p,
|
||||
...(p.id === issue.id ? payload : {}),
|
||||
}));
|
||||
|
||||
if (payload.sort_order) {
|
||||
const removedElement = newList.splice(payload.sort_order.sourceIndex, 1)[0];
|
||||
removedElement.sort_order = payload.sort_order.newSortOrder;
|
||||
newList.splice(payload.sort_order.destinationIndex, 0, removedElement);
|
||||
}
|
||||
|
||||
return newList;
|
||||
}, false);
|
||||
|
||||
const newPayload: any = { ...payload };
|
||||
|
||||
if (newPayload.sort_order && payload.sort_order)
|
||||
newPayload.sort_order = payload.sort_order.newSortOrder;
|
||||
|
||||
issuesService.patchIssue(workspaceSlug, issue.project, issue.id, newPayload, user);
|
||||
};
|
||||
@@ -1,5 +1 @@
|
||||
export * from "./blocks";
|
||||
export * from "./helpers";
|
||||
export * from "./hooks";
|
||||
export * from "./root";
|
||||
export * from "./types";
|
||||
|
||||
@@ -3,20 +3,15 @@ import { FC } from "react";
|
||||
import { ChartViewRoot } from "./chart";
|
||||
// context
|
||||
import { ChartContextProvider } from "./contexts";
|
||||
// types
|
||||
import { IBlockUpdateData, IGanttBlock } from "./types";
|
||||
|
||||
type GanttChartRootProps = {
|
||||
border?: boolean;
|
||||
title: null | string;
|
||||
loaderTitle: string;
|
||||
blocks: IGanttBlock[] | null;
|
||||
blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void;
|
||||
blocks: any;
|
||||
blockUpdateHandler: (data: any) => void;
|
||||
sidebarBlockRender: FC<any>;
|
||||
blockRender: FC<any>;
|
||||
enableLeftDrag?: boolean;
|
||||
enableRightDrag?: boolean;
|
||||
enableReorder?: boolean;
|
||||
};
|
||||
|
||||
export const GanttChartRoot: FC<GanttChartRootProps> = ({
|
||||
@@ -27,9 +22,6 @@ export const GanttChartRoot: FC<GanttChartRootProps> = ({
|
||||
blockUpdateHandler,
|
||||
sidebarBlockRender,
|
||||
blockRender,
|
||||
enableLeftDrag = true,
|
||||
enableRightDrag = true,
|
||||
enableReorder = true,
|
||||
}) => (
|
||||
<ChartContextProvider>
|
||||
<ChartViewRoot
|
||||
@@ -40,9 +32,6 @@ export const GanttChartRoot: FC<GanttChartRootProps> = ({
|
||||
blockUpdateHandler={blockUpdateHandler}
|
||||
sidebarBlockRender={sidebarBlockRender}
|
||||
blockRender={blockRender}
|
||||
enableLeftDrag={enableLeftDrag}
|
||||
enableRightDrag={enableRightDrag}
|
||||
enableReorder={enableReorder}
|
||||
/>
|
||||
</ChartContextProvider>
|
||||
);
|
||||
|
||||
@@ -5,32 +5,10 @@ export type allViewsType = {
|
||||
data: Object | null;
|
||||
};
|
||||
|
||||
export interface IGanttBlock {
|
||||
data: any;
|
||||
id: string;
|
||||
position?: {
|
||||
marginLeft: number;
|
||||
width: number;
|
||||
};
|
||||
sort_order: number;
|
||||
start_date: Date;
|
||||
target_date: Date;
|
||||
}
|
||||
|
||||
export interface IBlockUpdateData {
|
||||
sort_order?: {
|
||||
destinationIndex: number;
|
||||
newSortOrder: number;
|
||||
sourceIndex: number;
|
||||
};
|
||||
start_date?: string;
|
||||
target_date?: string;
|
||||
}
|
||||
|
||||
export interface ChartContextData {
|
||||
allViews: allViewsType[];
|
||||
currentView: "hours" | "day" | "week" | "bi_week" | "month" | "quarter" | "year";
|
||||
currentViewData: ChartDataType | undefined;
|
||||
currentViewData: any;
|
||||
renderView: any;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// types
|
||||
import { ChartDataType, IGanttBlock } from "../types";
|
||||
import { ChartDataType } from "../types";
|
||||
// data
|
||||
import { weeks, months } from "../data";
|
||||
// helpers
|
||||
@@ -19,35 +19,7 @@ type GetAllDaysInMonthInMonthViewType = {
|
||||
active: boolean;
|
||||
today: boolean;
|
||||
};
|
||||
|
||||
interface IMonthChild {
|
||||
active: boolean;
|
||||
date: Date;
|
||||
day: number;
|
||||
dayData: {
|
||||
key: number;
|
||||
shortTitle: string;
|
||||
title: string;
|
||||
};
|
||||
title: string;
|
||||
today: boolean;
|
||||
weekNumber: number;
|
||||
}
|
||||
|
||||
export interface IMonthBlock {
|
||||
children: IMonthChild[];
|
||||
month: number;
|
||||
monthData: {
|
||||
key: number;
|
||||
shortTitle: string;
|
||||
title: string;
|
||||
};
|
||||
title: string;
|
||||
year: number;
|
||||
}
|
||||
[];
|
||||
|
||||
const getAllDaysInMonthInMonthView = (month: number, year: number): IMonthChild[] => {
|
||||
const getAllDaysInMonthInMonthView = (month: number, year: number) => {
|
||||
const day: GetAllDaysInMonthInMonthViewType[] = [];
|
||||
const numberOfDaysInMonth = getNumberOfDaysInMonth(month, year);
|
||||
const currentDate = new Date();
|
||||
@@ -73,7 +45,7 @@ const getAllDaysInMonthInMonthView = (month: number, year: number): IMonthChild[
|
||||
return day;
|
||||
};
|
||||
|
||||
const generateMonthDataByMonthAndYearInMonthView = (month: number, year: number): IMonthBlock => {
|
||||
const generateMonthDataByMonthAndYearInMonthView = (month: number, year: number) => {
|
||||
const currentMonth: number = month;
|
||||
const currentYear: number = year;
|
||||
|
||||
@@ -190,11 +162,7 @@ export const getNumberOfDaysBetweenTwoDatesInMonth = (startDate: Date, endDate:
|
||||
return daysDifference;
|
||||
};
|
||||
|
||||
// calc item scroll position and width
|
||||
export const getMonthChartItemPositionWidthInMonth = (
|
||||
chartData: ChartDataType,
|
||||
itemData: IGanttBlock
|
||||
) => {
|
||||
export const getMonthChartItemPositionWidthInMonth = (chartData: ChartDataType, itemData: any) => {
|
||||
let scrollPosition: number = 0;
|
||||
let scrollWidth: number = 0;
|
||||
|
||||
|
||||
@@ -100,6 +100,8 @@ export const generateYearChart = (yearPayload: ChartDataType, side: null | "left
|
||||
.map((monthData: any) => monthData.children.length)
|
||||
.reduce((partialSum: number, a: number) => partialSum + a, 0) * yearPayload.data.width;
|
||||
|
||||
console.log("scrollWidth", scrollWidth);
|
||||
|
||||
return { state: renderState, payload: renderPayload, scrollWidth: scrollWidth };
|
||||
};
|
||||
|
||||
|
||||
@@ -72,8 +72,8 @@ export const InboxActionHeader = () => {
|
||||
false
|
||||
);
|
||||
mutateInboxIssues(
|
||||
(prevData: any) =>
|
||||
(prevData ?? []).map((i: any) =>
|
||||
(prevData) =>
|
||||
(prevData ?? []).map((i) =>
|
||||
i.bridge_id === inboxIssueId
|
||||
? { ...i, issue_inbox: [{ ...i.issue_inbox[0], ...data }] }
|
||||
: i
|
||||
|
||||
@@ -293,7 +293,6 @@ export const InboxMainContent: React.FC = () => {
|
||||
</div>
|
||||
<div>
|
||||
<IssueDescriptionForm
|
||||
workspaceSlug={workspaceSlug as string}
|
||||
issue={{
|
||||
name: issueDetails.name,
|
||||
description: issueDetails.description,
|
||||
|
||||
@@ -163,7 +163,7 @@ export const GithubImporterRoot: React.FC<Props> = ({ user }) => {
|
||||
|
||||
await GithubIntegrationService.createGithubServiceImport(workspaceSlug as string, payload, user)
|
||||
.then(() => {
|
||||
router.push(`/${workspaceSlug}/settings/imports`);
|
||||
router.push(`/${workspaceSlug}/settings/import-export`);
|
||||
mutate(IMPORTER_SERVICES_LIST(workspaceSlug as string));
|
||||
})
|
||||
.catch(() =>
|
||||
@@ -178,7 +178,7 @@ export const GithubImporterRoot: React.FC<Props> = ({ user }) => {
|
||||
return (
|
||||
<form onSubmit={handleSubmit(createGithubImporterService)}>
|
||||
<div className="space-y-2">
|
||||
<Link href={`/${workspaceSlug}/settings/imports`}>
|
||||
<Link href={`/${workspaceSlug}/settings/import-export`}>
|
||||
<div className="inline-flex cursor-pointer items-center gap-2 text-sm font-medium text-custom-text-200 hover:text-custom-text-100">
|
||||
<ArrowLeftIcon className="h-3 w-3" />
|
||||
<div>Cancel import & go back</div>
|
||||
|
||||
@@ -58,7 +58,7 @@ const IntegrationGuide = () => {
|
||||
user={user}
|
||||
/>
|
||||
<div className="h-full space-y-2">
|
||||
{(!provider || provider === "csv") && (
|
||||
{!provider && (
|
||||
<>
|
||||
<div className="mb-5 flex items-center gap-2">
|
||||
<div className="h-full w-full space-y-1">
|
||||
@@ -100,7 +100,7 @@ const IntegrationGuide = () => {
|
||||
</div>
|
||||
<div className="flex-shrink-0">
|
||||
<Link
|
||||
href={`/${workspaceSlug}/settings/imports?provider=${service.provider}`}
|
||||
href={`/${workspaceSlug}/settings/import-export?provider=${service.provider}`}
|
||||
>
|
||||
<a>
|
||||
<PrimaryButton>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user