Compare commits
105 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cd9cebc7af | |||
| b77ddf9c95 | |||
| 4133798a81 | |||
| 761aefa73b | |||
| 561fb9815b | |||
| 2cc67f6498 | |||
| fa403ef4ae | |||
| eee6658cc2 | |||
| 68b438ab1a | |||
| 3ce0aa8ebc | |||
| b406a70e72 | |||
| b02417120b | |||
| d040394826 | |||
| f7682c57ba | |||
| 9bb6254515 | |||
| ae052f1890 | |||
| cfc7049343 | |||
| 41e55dff85 | |||
| 0bccb63a9f | |||
| 2eb956e97e | |||
| d470adf262 | |||
| cebc8bdc8d | |||
| 64b5ba196f | |||
| 8d5018318d | |||
| 0fbdc0b157 | |||
| 2f39181eb7 | |||
| d825dc5579 | |||
| 1f8117c987 | |||
| 125e9090ea | |||
| 02ac4cee22 | |||
| 93164755e2 | |||
| 6344f6f562 | |||
| b67e30fd9c | |||
| 93fec2c678 | |||
| c3c6ba9e34 | |||
| d74ec7bda9 | |||
| e593a8d4bd | |||
| abb8782c44 | |||
| 0afd72db95 | |||
| 65295f6c6f | |||
| 5b6b43fb83 | |||
| f8497125db | |||
| b24622e5ef | |||
| 10dface85d | |||
| 2b6debaa3e | |||
| 1750ba344b | |||
| 550473bb02 | |||
| fde978861c | |||
| f44d142f2c | |||
| 1ded8f486f | |||
| 2c43a15515 | |||
| 0979acc1a4 | |||
| 9003c58d89 | |||
| 55e2f00ffe | |||
| 08382f88b4 | |||
| 72419447ec | |||
| b554087b1f | |||
| 07717e9a93 | |||
| df46a45afc | |||
| e1ae0d3b56 | |||
| daa8f7d79b | |||
| d2cdaaccb9 | |||
| 6774eddb66 | |||
| 8ccc1b3fcc | |||
| 5e76e03a55 | |||
| 77fb50faa4 | |||
| 5ddfee12bc | |||
| f7a596c113 | |||
| f73239be92 | |||
| dc2438b2d3 | |||
| ddd3301d17 | |||
| 816f00d956 | |||
| 70e2509d52 | |||
| 1a9faa025a | |||
| feba1cc4d0 | |||
| 079a5b28d8 | |||
| c6eea9c7a9 | |||
| 5f5790ebf9 | |||
| a3d99100ee | |||
| ac6d2b0139 | |||
| 7becec4ee9 | |||
| cd5e5b96da | |||
| ad4cdcc512 | |||
| 6617049983 | |||
| abec46a725 | |||
| 88e987b902 | |||
| 785a6e8871 | |||
| 762ef422ca | |||
| e2b5657c3e | |||
| dbb53a663e | |||
| 5c964d144a | |||
| 289e81d6eb | |||
| def10af1e2 | |||
| edaeae1b69 | |||
| e06ee25800 | |||
| be86a7d38e | |||
| 0a1483c482 | |||
| 11abd3cadf | |||
| 085cd1960e | |||
| 2769a73898 | |||
| 8373f20944 | |||
| 9b4aebc385 | |||
| 9828d2332a | |||
| 9f69fe6060 | |||
| d9339b8f8e |
@@ -21,6 +21,8 @@ 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
-1
@@ -4,7 +4,7 @@ module.exports = {
|
||||
extends: ["custom"],
|
||||
settings: {
|
||||
next: {
|
||||
rootDir: ["apps/*"],
|
||||
rootDir: ["web/", "deploy/"],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
+3
-1
@@ -70,4 +70,6 @@ package-lock.json
|
||||
# lock files
|
||||
package-lock.json
|
||||
pnpm-lock.yaml
|
||||
pnpm-workspace.yaml
|
||||
pnpm-workspace.yaml
|
||||
|
||||
.npmrc
|
||||
|
||||
+10
-3
@@ -5,9 +5,11 @@ 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 --docker
|
||||
RUN turbo prune --scope=app --scope=plane-deploy --docker
|
||||
CMD tree -I node_modules/
|
||||
|
||||
# Add lockfile and package.json's of isolated subworkspace
|
||||
FROM node:18-alpine AS installer
|
||||
@@ -21,14 +23,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 --filter=app
|
||||
RUN yarn turbo run build
|
||||
|
||||
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL \
|
||||
BUILT_NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
|
||||
@@ -96,11 +98,16 @@ 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,6 +61,16 @@ 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,6 +18,8 @@ from .project import (
|
||||
ProjectFavoriteSerializer,
|
||||
ProjectLiteSerializer,
|
||||
ProjectMemberLiteSerializer,
|
||||
ProjectDeployBoardSerializer,
|
||||
ProjectMemberAdminSerializer,
|
||||
)
|
||||
from .state import StateSerializer, StateLiteSerializer
|
||||
from .view import IssueViewSerializer, IssueViewFavoriteSerializer
|
||||
@@ -41,6 +43,7 @@ from .issue import (
|
||||
IssueSubscriberSerializer,
|
||||
IssueReactionSerializer,
|
||||
CommentReactionSerializer,
|
||||
IssueVoteSerializer,
|
||||
)
|
||||
|
||||
from .module import (
|
||||
@@ -78,3 +81,5 @@ from .inbox import InboxSerializer, InboxIssueSerializer, IssueStateInboxSeriali
|
||||
from .analytic import AnalyticViewSerializer
|
||||
|
||||
from .notification import NotificationSerializer
|
||||
|
||||
from .exporter import ExporterHistorySerializer
|
||||
|
||||
@@ -14,6 +14,11 @@ from plane.db.models import Cycle, CycleIssue, CycleFavorite
|
||||
|
||||
class CycleWriteSerializer(BaseSerializer):
|
||||
|
||||
def validate(self, data):
|
||||
if data.get("start_date", None) is not None and data.get("end_date", None) is not None and data.get("start_date", None) > data.get("end_date", None):
|
||||
raise serializers.ValidationError("Start date cannot exceed end date")
|
||||
return data
|
||||
|
||||
class Meta:
|
||||
model = Cycle
|
||||
fields = "__all__"
|
||||
@@ -35,12 +40,18 @@ class CycleSerializer(BaseSerializer):
|
||||
started_estimates = serializers.IntegerField(read_only=True)
|
||||
workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace")
|
||||
project_detail = ProjectLiteSerializer(read_only=True, source="project")
|
||||
|
||||
def validate(self, data):
|
||||
if data.get("start_date", None) is not None and data.get("end_date", None) is not None and data.get("start_date", None) > data.get("end_date", None):
|
||||
raise serializers.ValidationError("Start date cannot exceed end date")
|
||||
return data
|
||||
|
||||
def get_assignees(self, obj):
|
||||
members = [
|
||||
{
|
||||
"avatar": assignee.avatar,
|
||||
"first_name": assignee.first_name,
|
||||
"display_name": assignee.display_name,
|
||||
"id": assignee.id,
|
||||
}
|
||||
for issue_cycle in obj.issue_cycle.all()
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
# 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,6 +31,7 @@ from plane.db.models import (
|
||||
IssueAttachment,
|
||||
IssueReaction,
|
||||
CommentReaction,
|
||||
IssueVote,
|
||||
)
|
||||
|
||||
|
||||
@@ -111,6 +112,11 @@ 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)
|
||||
@@ -549,6 +555,14 @@ 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")
|
||||
@@ -568,6 +582,7 @@ class IssueCommentSerializer(BaseSerializer):
|
||||
"updated_by",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"access",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -40,6 +40,11 @@ class ModuleWriteSerializer(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):
|
||||
members = validated_data.pop("members_list", None)
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ from plane.db.models import (
|
||||
ProjectMemberInvite,
|
||||
ProjectIdentifier,
|
||||
ProjectFavorite,
|
||||
ProjectDeployBoard,
|
||||
)
|
||||
|
||||
|
||||
@@ -80,7 +81,15 @@ class ProjectSerializer(BaseSerializer):
|
||||
class ProjectLiteSerializer(BaseSerializer):
|
||||
class Meta:
|
||||
model = Project
|
||||
fields = ["id", "identifier", "name"]
|
||||
fields = [
|
||||
"id",
|
||||
"identifier",
|
||||
"name",
|
||||
"cover_image",
|
||||
"icon_prop",
|
||||
"emoji",
|
||||
"description",
|
||||
]
|
||||
read_only_fields = fields
|
||||
|
||||
|
||||
@@ -94,6 +103,8 @@ 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
|
||||
@@ -115,7 +126,6 @@ class ProjectMemberAdminSerializer(BaseSerializer):
|
||||
project = ProjectLiteSerializer(read_only=True)
|
||||
member = UserAdminLiteSerializer(read_only=True)
|
||||
|
||||
|
||||
class Meta:
|
||||
model = ProjectMember
|
||||
fields = "__all__"
|
||||
@@ -148,8 +158,6 @@ class ProjectFavoriteSerializer(BaseSerializer):
|
||||
]
|
||||
|
||||
|
||||
|
||||
|
||||
class ProjectMemberLiteSerializer(BaseSerializer):
|
||||
member = UserLiteSerializer(read_only=True)
|
||||
is_subscribed = serializers.BooleanField(read_only=True)
|
||||
@@ -158,3 +166,16 @@ 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,6 +86,7 @@ from plane.api.views import (
|
||||
IssueAttachmentEndpoint,
|
||||
IssueArchiveViewSet,
|
||||
IssueSubscriberViewSet,
|
||||
IssueCommentPublicViewSet,
|
||||
IssueReactionViewSet,
|
||||
CommentReactionViewSet,
|
||||
ExportIssuesEndpoint,
|
||||
@@ -165,6 +166,15 @@ from plane.api.views import (
|
||||
NotificationViewSet,
|
||||
UnreadNotificationEndpoint,
|
||||
## End Notification
|
||||
# Public Boards
|
||||
ProjectDeployBoardViewSet,
|
||||
ProjectDeployBoardIssuesPublicEndpoint,
|
||||
ProjectDeployBoardPublicSettingsEndpoint,
|
||||
IssueReactionPublicViewSet,
|
||||
CommentReactionPublicViewSet,
|
||||
InboxIssuePublicViewSet,
|
||||
IssueVotePublicViewSet,
|
||||
## End Public Boards
|
||||
)
|
||||
|
||||
|
||||
@@ -1481,4 +1491,128 @@ 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,6 +12,9 @@ from .project import (
|
||||
ProjectUserViewsEndpoint,
|
||||
ProjectMemberUserEndpoint,
|
||||
ProjectFavoritesViewSet,
|
||||
ProjectDeployBoardIssuesPublicEndpoint,
|
||||
ProjectDeployBoardViewSet,
|
||||
ProjectDeployBoardPublicSettingsEndpoint,
|
||||
ProjectMemberEndpoint,
|
||||
)
|
||||
from .user import (
|
||||
@@ -75,9 +78,12 @@ from .issue import (
|
||||
IssueAttachmentEndpoint,
|
||||
IssueArchiveViewSet,
|
||||
IssueSubscriberViewSet,
|
||||
IssueCommentPublicViewSet,
|
||||
CommentReactionViewSet,
|
||||
IssueReactionViewSet,
|
||||
ExportIssuesEndpoint
|
||||
IssueReactionPublicViewSet,
|
||||
CommentReactionPublicViewSet,
|
||||
IssueVotePublicViewSet,
|
||||
)
|
||||
|
||||
from .auth_extended import (
|
||||
@@ -145,7 +151,7 @@ from .estimate import (
|
||||
|
||||
from .release import ReleaseNotesEndpoint
|
||||
|
||||
from .inbox import InboxViewSet, InboxIssueViewSet
|
||||
from .inbox import InboxViewSet, InboxIssueViewSet, InboxIssuePublicViewSet
|
||||
|
||||
from .analytic import (
|
||||
AnalyticsEndpoint,
|
||||
@@ -155,4 +161,8 @@ from .analytic import (
|
||||
DefaultAnalyticsEndpoint,
|
||||
)
|
||||
|
||||
from .notification import NotificationViewSet, UnreadNotificationEndpoint
|
||||
from .notification import NotificationViewSet, UnreadNotificationEndpoint
|
||||
|
||||
from .exporter import (
|
||||
ExportIssuesEndpoint,
|
||||
)
|
||||
@@ -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")
|
||||
.values("created_by__first_name", "created_by__last_name", "created_by__avatar", "created_by__display_name", "created_by__id")
|
||||
.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")
|
||||
.values("assignees__first_name", "assignees__last_name", "assignees__avatar", "assignees__display_name", "assignees__id")
|
||||
.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")
|
||||
.values("assignees__first_name", "assignees__last_name", "assignees__avatar", "assignees__display_name", "assignees__id")
|
||||
.annotate(count=Count("id"))
|
||||
.order_by("-count")
|
||||
)
|
||||
|
||||
@@ -165,6 +165,9 @@ 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":
|
||||
@@ -370,7 +373,8 @@ class CycleViewSet(BaseViewSet):
|
||||
.annotate(last_name=F("assignees__last_name"))
|
||||
.annotate(assignee_id=F("assignees__id"))
|
||||
.annotate(avatar=F("assignees__avatar"))
|
||||
.values("first_name", "last_name", "assignee_id", "avatar")
|
||||
.annotate(display_name=F("assignees__display_name"))
|
||||
.values("first_name", "last_name", "assignee_id", "avatar", "display_name")
|
||||
.annotate(total_issues=Count("assignee_id"))
|
||||
.annotate(
|
||||
completed_issues=Count(
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
# 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,7 +15,6 @@ 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,
|
||||
@@ -23,6 +22,7 @@ from plane.db.models import (
|
||||
IssueLink,
|
||||
IssueAttachment,
|
||||
ProjectMember,
|
||||
ProjectDeployBoard,
|
||||
)
|
||||
from plane.api.serializers import (
|
||||
IssueSerializer,
|
||||
@@ -377,4 +377,269 @@ 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,6 +20,17 @@ 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)
|
||||
@@ -45,7 +56,10 @@ 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,6 +48,7 @@ from plane.api.serializers import (
|
||||
ProjectMemberLiteSerializer,
|
||||
IssueReactionSerializer,
|
||||
CommentReactionSerializer,
|
||||
IssueVoteSerializer,
|
||||
)
|
||||
from plane.api.permissions import (
|
||||
WorkspaceEntityPermission,
|
||||
@@ -70,11 +71,12 @@ 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):
|
||||
@@ -169,7 +171,6 @@ 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]
|
||||
@@ -362,8 +363,14 @@ 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":
|
||||
@@ -744,21 +751,25 @@ 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(~Q(name="Triage"), workspace__slug=slug)
|
||||
.annotate(
|
||||
state_count=Count(
|
||||
"state_issue",
|
||||
filter=Q(state_issue__parent_id=issue_id),
|
||||
)
|
||||
State.objects.filter(
|
||||
workspace__slug=slug, state_issue__parent_id=issue_id
|
||||
)
|
||||
.order_by("group")
|
||||
.values("group", "state_count")
|
||||
.annotate(state_group=F("group"))
|
||||
.values("state_group")
|
||||
.annotate(state_count=Count("state_group"))
|
||||
.order_by("state_group")
|
||||
)
|
||||
|
||||
result = {item["group"]: item["state_count"] for item in state_distribution}
|
||||
result = {item["state_group"]: item["state_count"] for item in state_distribution}
|
||||
|
||||
serializer = IssueLiteSerializer(
|
||||
sub_issues,
|
||||
@@ -1448,6 +1459,374 @@ 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,6 +53,8 @@ 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"),
|
||||
@@ -106,7 +108,7 @@ class ModuleViewSet(BaseViewSet):
|
||||
filter=Q(issue_module__issue__state__group="backlog"),
|
||||
)
|
||||
)
|
||||
.order_by("-is_favorite", "name")
|
||||
.order_by(order_by, "name")
|
||||
)
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
@@ -173,8 +175,9 @@ 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")
|
||||
.values("first_name", "last_name", "assignee_id", "avatar", "display_name")
|
||||
.annotate(total_issues=Count("assignee_id"))
|
||||
.annotate(
|
||||
completed_issues=Count(
|
||||
|
||||
@@ -5,7 +5,21 @@ 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, Min, Subquery
|
||||
from django.db.models import (
|
||||
Q,
|
||||
Exists,
|
||||
OuterRef,
|
||||
Func,
|
||||
F,
|
||||
Max,
|
||||
CharField,
|
||||
Func,
|
||||
Subquery,
|
||||
Prefetch,
|
||||
When,
|
||||
Case,
|
||||
Value,
|
||||
)
|
||||
from django.core.validators import validate_email
|
||||
from django.conf import settings
|
||||
|
||||
@@ -13,6 +27,7 @@ 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
|
||||
@@ -23,9 +38,16 @@ from plane.api.serializers import (
|
||||
ProjectDetailSerializer,
|
||||
ProjectMemberInviteSerializer,
|
||||
ProjectFavoriteSerializer,
|
||||
IssueLiteSerializer,
|
||||
ProjectDeployBoardSerializer,
|
||||
ProjectMemberAdminSerializer,
|
||||
)
|
||||
|
||||
from plane.api.permissions import ProjectBasePermission, ProjectEntityPermission
|
||||
from plane.api.permissions import (
|
||||
ProjectBasePermission,
|
||||
ProjectEntityPermission,
|
||||
ProjectMemberPermission,
|
||||
)
|
||||
|
||||
from plane.db.models import (
|
||||
Project,
|
||||
@@ -48,9 +70,17 @@ 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):
|
||||
@@ -92,7 +122,9 @@ class ProjectViewSet(BaseViewSet):
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
total_members=ProjectMember.objects.filter(project_id=OuterRef("id"))
|
||||
total_members=ProjectMember.objects.filter(
|
||||
project_id=OuterRef("id"), member__is_bot=False
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
@@ -109,6 +141,20 @@ 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()
|
||||
)
|
||||
|
||||
@@ -180,7 +226,9 @@ class ProjectViewSet(BaseViewSet):
|
||||
project_id=serializer.data["id"], member=request.user, role=20
|
||||
)
|
||||
|
||||
if serializer.data["project_lead"] is not None:
|
||||
if serializer.data["project_lead"] is not None and str(
|
||||
serializer.data["project_lead"]
|
||||
) != str(request.user.id):
|
||||
ProjectMember.objects.create(
|
||||
project_id=serializer.data["id"],
|
||||
member_id=serializer.data["project_lead"],
|
||||
@@ -347,7 +395,9 @@ 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
|
||||
project_id=project_id,
|
||||
member__email=email,
|
||||
member__is_bot=False,
|
||||
).exists():
|
||||
return Response(
|
||||
{"error": "User is already member of workspace"},
|
||||
@@ -451,7 +501,7 @@ class UserProjectInvitationsViewset(BaseViewSet):
|
||||
|
||||
|
||||
class ProjectMemberViewSet(BaseViewSet):
|
||||
serializer_class = ProjectMemberSerializer
|
||||
serializer_class = ProjectMemberAdminSerializer
|
||||
model = ProjectMember
|
||||
permission_classes = [
|
||||
ProjectBasePermission,
|
||||
@@ -986,6 +1036,63 @@ 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,
|
||||
@@ -994,7 +1101,9 @@ class ProjectMemberEndpoint(BaseAPIView):
|
||||
def get(self, request, slug, project_id):
|
||||
try:
|
||||
project_members = ProjectMember.objects.filter(
|
||||
project_id=project_id, workspace__slug=slug
|
||||
project_id=project_id,
|
||||
workspace__slug=slug,
|
||||
member__is_bot=False,
|
||||
).select_related("project", "member")
|
||||
serializer = ProjectMemberSerializer(project_members, many=True)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
@@ -1004,3 +1113,176 @@ 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,6 +19,7 @@ from plane.db.models import (
|
||||
IssueView,
|
||||
Issue,
|
||||
IssueViewFavorite,
|
||||
IssueReaction,
|
||||
)
|
||||
from plane.utils.issue_filters import issue_filters
|
||||
|
||||
@@ -77,6 +78,12 @@ 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,7 +107,9 @@ class WorkSpaceViewSet(BaseViewSet):
|
||||
|
||||
def get_queryset(self):
|
||||
member_count = (
|
||||
WorkspaceMember.objects.filter(workspace=OuterRef("id"))
|
||||
WorkspaceMember.objects.filter(
|
||||
workspace=OuterRef("id"), member__is_bot=False
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
@@ -192,7 +194,9 @@ class UserWorkSpacesEndpoint(BaseAPIView):
|
||||
def get(self, request):
|
||||
try:
|
||||
member_count = (
|
||||
WorkspaceMember.objects.filter(workspace=OuterRef("id"))
|
||||
WorkspaceMember.objects.filter(
|
||||
workspace=OuterRef("id"), member__is_bot=False
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
@@ -625,7 +629,9 @@ class WorkSpaceMemberViewSet(BaseViewSet):
|
||||
if (
|
||||
workspace_member.role == 20
|
||||
and WorkspaceMember.objects.filter(
|
||||
workspace__slug=slug, role=20
|
||||
workspace__slug=slug,
|
||||
role=20,
|
||||
member__is_bot=False,
|
||||
).count()
|
||||
== 1
|
||||
):
|
||||
@@ -988,11 +994,11 @@ class UserWorkspaceDashboardEndpoint(BaseAPIView):
|
||||
|
||||
upcoming_issues = Issue.issue_objects.filter(
|
||||
~Q(state__group__in=["completed", "cancelled"]),
|
||||
target_date__gte=timezone.now(),
|
||||
start_date__gte=timezone.now(),
|
||||
workspace__slug=slug,
|
||||
assignees__in=[request.user],
|
||||
completed_at__isnull=True,
|
||||
).values("id", "name", "workspace__slug", "project_id", "target_date")
|
||||
).values("id", "name", "workspace__slug", "project_id", "start_date")
|
||||
|
||||
return Response(
|
||||
{
|
||||
@@ -1077,6 +1083,7 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView):
|
||||
.filter(**filters)
|
||||
.values("priority")
|
||||
.annotate(priority_count=Count("priority"))
|
||||
.filter(priority_count__gte=1)
|
||||
.annotate(
|
||||
priority_order=Case(
|
||||
*[
|
||||
@@ -1455,7 +1462,8 @@ class WorkspaceMembersEndpoint(BaseAPIView):
|
||||
def get(self, request, slug):
|
||||
try:
|
||||
workspace_members = WorkspaceMember.objects.filter(
|
||||
workspace__slug=slug
|
||||
workspace__slug=slug,
|
||||
member__is_bot=False,
|
||||
).select_related("workspace", "member")
|
||||
serialzier = WorkSpaceMemberSerializer(workspace_members, many=True)
|
||||
return Response(serialzier.data, status=status.HTTP_200_OK)
|
||||
|
||||
@@ -51,12 +51,12 @@ def analytic_export_task(email, data, slug):
|
||||
segmented = segment
|
||||
|
||||
assignee_details = {}
|
||||
if x_axis in ["assignees__display_name"] or segment in ["assignees__display_name"]:
|
||||
if x_axis in ["assignees__id"] or segment in ["assignees__id"]:
|
||||
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")
|
||||
.values("assignees__avatar", "assignees__display_name", "assignees__first_name", "assignees__last_name", "assignees__id")
|
||||
)
|
||||
|
||||
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__display_name"]:
|
||||
assignee = [user for user in assignee_details if str(user.get("assignees__display_name")) == str(item)]
|
||||
if x_axis in ["assignees__id"]:
|
||||
assignee = [user for user in assignee_details if str(user.get("assignees__id")) == 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__display_name"]:
|
||||
if segmented in ["assignees__id"]:
|
||||
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__display_name")) == str(segm)]
|
||||
assignee = [user for user in assignee_details if str(user.get("assignees__id")) == str(segm)]
|
||||
if len(assignee):
|
||||
row_zero[index] = str(assignee[0].get("assignees__first_name")) + " " + str(assignee[0].get("assignees__last_name"))
|
||||
row_zero[index + 2] = 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__display_name"]:
|
||||
assignee = [user for user in assignee_details if str(user.get("assignees__display_name")) == str(item)]
|
||||
if x_axis in ["assignees__id"]:
|
||||
assignee = [user for user in assignee_details if str(user.get("assignees__id")) == str(item)]
|
||||
if len(assignee):
|
||||
row[0] = str(assignee[0].get("assignees__first_name")) + " " + str(assignee[0].get("assignees__last_name"))
|
||||
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
# 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
|
||||
@@ -0,0 +1,38 @@
|
||||
# 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,19 +184,24 @@ def track_description(
|
||||
if current_instance.get("description_html") != requested_data.get(
|
||||
"description_html"
|
||||
):
|
||||
issue_activities.append(
|
||||
IssueActivity(
|
||||
issue_id=issue_id,
|
||||
actor=actor,
|
||||
verb="updated",
|
||||
old_value=current_instance.get("description_html"),
|
||||
new_value=requested_data.get("description_html"),
|
||||
field="description",
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
comment=f"updated the description to {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')}",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# Track changes in issue target date
|
||||
|
||||
@@ -1,191 +0,0 @@
|
||||
# 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,6 +20,10 @@ 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.
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
# 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
@@ -1,101 +0,0 @@
|
||||
# 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,6 +18,7 @@ from .project import (
|
||||
ProjectMemberInvite,
|
||||
ProjectIdentifier,
|
||||
ProjectFavorite,
|
||||
ProjectDeployBoard,
|
||||
)
|
||||
|
||||
from .issue import (
|
||||
@@ -36,6 +37,7 @@ from .issue import (
|
||||
IssueSubscriber,
|
||||
IssueReaction,
|
||||
CommentReaction,
|
||||
IssueVote,
|
||||
)
|
||||
|
||||
from .asset import FileAsset
|
||||
@@ -72,4 +74,6 @@ from .inbox import Inbox, InboxIssue
|
||||
|
||||
from .analytic import AnalyticView
|
||||
|
||||
from .notification import Notification
|
||||
from .notification import Notification
|
||||
|
||||
from .exporter import ExporterHistory
|
||||
@@ -17,6 +17,7 @@ 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"
|
||||
@@ -24,6 +25,17 @@ 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}>"
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
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,11 +108,7 @@ 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
|
||||
@@ -127,8 +123,6 @@ 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
|
||||
@@ -153,9 +147,6 @@ 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
|
||||
@@ -310,6 +301,14 @@ 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 = (
|
||||
@@ -425,13 +424,14 @@ 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,13 +446,14 @@ 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:
|
||||
@@ -466,6 +467,27 @@ 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,6 +40,7 @@ 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"]
|
||||
@@ -48,6 +49,17 @@ 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}"
|
||||
|
||||
@@ -86,7 +98,7 @@ class ModuleIssue(ProjectBaseModel):
|
||||
|
||||
|
||||
class ModuleLink(ProjectBaseModel):
|
||||
title = models.CharField(max_length=255, null=True)
|
||||
title = models.CharField(max_length=255, blank=True, null=True)
|
||||
url = models.URLField()
|
||||
module = models.ForeignKey(
|
||||
Module, on_delete=models.CASCADE, related_name="link_module"
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
# Python imports
|
||||
from uuid import uuid4
|
||||
|
||||
# Django imports
|
||||
from django.db import models
|
||||
from django.conf import settings
|
||||
@@ -31,12 +34,9 @@ def get_default_props():
|
||||
"showEmptyGroups": True,
|
||||
}
|
||||
|
||||
|
||||
def get_default_preferences():
|
||||
return {
|
||||
"pages": {
|
||||
"block_display": True
|
||||
}
|
||||
}
|
||||
return {"pages": {"block_display": True}}
|
||||
|
||||
|
||||
class Project(BaseModel):
|
||||
@@ -157,7 +157,6 @@ 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(
|
||||
@@ -217,3 +216,41 @@ 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,6 +33,7 @@ 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",)
|
||||
CELERY_IMPORTS = ("plane.bgtasks.issue_automation_task","plane.bgtasks.exporter_expired_task")
|
||||
|
||||
@@ -124,10 +124,11 @@ 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"):
|
||||
if query.get("timeline", "after") == "after":
|
||||
filter["created_at__date__gte"] = query.get("datetime")
|
||||
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]
|
||||
else:
|
||||
filter["created_at__date__lte"] = query.get("datetime")
|
||||
filter["created_at__date__lte"] = created_at_query[0]
|
||||
return filter
|
||||
|
||||
|
||||
@@ -144,10 +145,11 @@ 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"):
|
||||
if query.get("timeline", "after") == "after":
|
||||
filter["updated_at__date__gte"] = query.get("datetime")
|
||||
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]
|
||||
else:
|
||||
filter["updated_at__date__lte"] = query.get("datetime")
|
||||
filter["updated_at__date__lte"] = updated_at_query[0]
|
||||
return filter
|
||||
|
||||
|
||||
@@ -164,10 +166,11 @@ 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"):
|
||||
if query.get("timeline", "after") == "after":
|
||||
filter["start_date__gte"] = query.get("datetime")
|
||||
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]
|
||||
else:
|
||||
filter["start_date__lte"] = query.get("datetime")
|
||||
filter["start_date__lte"] = start_date_query[0]
|
||||
return filter
|
||||
|
||||
|
||||
@@ -184,10 +187,11 @@ 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"):
|
||||
if query.get("timeline", "after") == "after":
|
||||
filter["target_date__gt"] = query.get("datetime")
|
||||
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]
|
||||
else:
|
||||
filter["target_date__lt"] = query.get("datetime")
|
||||
filter["target_date__lt"] = target_date_query[0]
|
||||
|
||||
return filter
|
||||
|
||||
@@ -205,10 +209,11 @@ 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"):
|
||||
if query.get("timeline", "after") == "after":
|
||||
filter["completed_at__date__gte"] = query.get("datetime")
|
||||
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]
|
||||
else:
|
||||
filter["completed_at__lte"] = query.get("datetime")
|
||||
filter["completed_at__lte"] = completed_at_query[0]
|
||||
return filter
|
||||
|
||||
|
||||
@@ -292,9 +297,16 @@ 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,
|
||||
@@ -318,6 +330,7 @@ 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,4 +32,5 @@ 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
|
||||
scout-apm==2.26.1
|
||||
openpyxl==3.1.2
|
||||
@@ -1,85 +0,0 @@
|
||||
{
|
||||
"name": "Plane",
|
||||
"description": "Plane helps you track your issues, epics, and product roadmaps.",
|
||||
"repository": "http://github.com/makeplane/plane",
|
||||
"logo": "https://avatars.githubusercontent.com/u/115727700?s=200&v=4",
|
||||
"website": "https://plane.so/",
|
||||
"success_url": "/",
|
||||
"stack": "heroku-22",
|
||||
"keywords": [
|
||||
"plane",
|
||||
"project management",
|
||||
"django",
|
||||
"next"
|
||||
],
|
||||
"addons": [
|
||||
"heroku-postgresql:mini",
|
||||
"heroku-redis:mini"
|
||||
],
|
||||
"buildpacks": [
|
||||
{
|
||||
"url": "https://github.com/heroku/heroku-buildpack-python.git"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/heroku/heroku-buildpack-nodejs#v176"
|
||||
}
|
||||
],
|
||||
"env": {
|
||||
"EMAIL_HOST": {
|
||||
"description": "Email host to send emails from",
|
||||
"value": ""
|
||||
},
|
||||
"EMAIL_HOST_USER": {
|
||||
"description": "Email host to send emails from",
|
||||
"value": ""
|
||||
},
|
||||
"EMAIL_HOST_PASSWORD": {
|
||||
"description": "Email host to send emails from",
|
||||
"value": ""
|
||||
},
|
||||
"EMAIL_FROM": {
|
||||
"description": "Email Sender",
|
||||
"value": ""
|
||||
},
|
||||
"EMAIL_PORT": {
|
||||
"description": "The default Email PORT to use",
|
||||
"value": "587"
|
||||
},
|
||||
"AWS_REGION": {
|
||||
"description": "AWS Region to use for S3",
|
||||
"value": "false"
|
||||
},
|
||||
"AWS_ACCESS_KEY_ID": {
|
||||
"description": "AWS Access Key ID to use for S3",
|
||||
"value": ""
|
||||
},
|
||||
"AWS_SECRET_ACCESS_KEY": {
|
||||
"description": "AWS Secret Access Key to use for S3",
|
||||
"value": ""
|
||||
},
|
||||
"AWS_S3_BUCKET_NAME": {
|
||||
"description": "AWS Bucket Name to use for S3",
|
||||
"value": ""
|
||||
},
|
||||
"SENTRY_DSN": {
|
||||
"description": "",
|
||||
"value": ""
|
||||
},
|
||||
"WEB_URL": {
|
||||
"description": "Web URL for Plane this will be used for redirections in the emails",
|
||||
"value": ""
|
||||
},
|
||||
"GITHUB_CLIENT_SECRET": {
|
||||
"description": "Github Client Secret",
|
||||
"value": ""
|
||||
},
|
||||
"NEXT_PUBLIC_API_BASE_URL": {
|
||||
"description": "Next Public API Base URL",
|
||||
"value": ""
|
||||
},
|
||||
"SECRET_KEY": {
|
||||
"description": "Django Secret Key",
|
||||
"value": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
extends: ["custom"],
|
||||
rules: {
|
||||
"@next/next/no-img-element": "off",
|
||||
},
|
||||
};
|
||||
@@ -1,11 +0,0 @@
|
||||
FROM node:18-alpine
|
||||
RUN apk add --no-cache libc6-compat
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
COPY . .
|
||||
RUN yarn global add turbo
|
||||
RUN yarn install
|
||||
EXPOSE 3000
|
||||
CMD ["yarn","dev"]
|
||||
@@ -1,81 +0,0 @@
|
||||
import { FC } from "react";
|
||||
// next imports
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
// components
|
||||
import { GanttChartRoot } from "components/gantt-chart";
|
||||
// ui
|
||||
import { Tooltip } from "components/ui";
|
||||
// types
|
||||
import { ICycle } from "types";
|
||||
|
||||
type Props = {
|
||||
cycles: ICycle[];
|
||||
};
|
||||
|
||||
export const CyclesListGanttChartView: FC<Props> = ({ cycles }) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
|
||||
// rendering issues on gantt sidebar
|
||||
const GanttSidebarBlockView = ({ data }: any) => (
|
||||
<div className="relative flex w-full h-full items-center p-1 overflow-hidden gap-1">
|
||||
<div
|
||||
className="rounded-sm flex-shrink-0 w-[10px] h-[10px] flex justify-center items-center"
|
||||
style={{ backgroundColor: "rgb(var(--color-primary-100))" }}
|
||||
/>
|
||||
<div className="text-custom-text-100 text-sm">{data?.name}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// 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>
|
||||
);
|
||||
|
||||
// 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: any) =>
|
||||
blocks && blocks.length > 0
|
||||
? 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"}
|
||||
loaderTitle="Cycles"
|
||||
blocks={cycles ? blockFormat(cycles) : null}
|
||||
blockUpdateHandler={handleUpdateDates}
|
||||
sidebarBlockRender={(data: any) => <GanttSidebarBlockView data={data} />}
|
||||
blockRender={(data: any) => <GanttBlockView data={data} />}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,109 +0,0 @@
|
||||
import { FC } from "react";
|
||||
// next imports
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
// components
|
||||
import { GanttChartRoot } from "components/gantt-chart";
|
||||
// ui
|
||||
import { Tooltip } from "components/ui";
|
||||
// hooks
|
||||
import useGanttChartCycleIssues from "hooks/gantt-chart/cycle-issues-view";
|
||||
|
||||
type Props = {};
|
||||
|
||||
export const CycleIssuesGanttChartView: FC<Props> = ({}) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, cycleId } = router.query;
|
||||
|
||||
const { ganttIssues, mutateGanttIssues } = useGanttChartCycleIssues(
|
||||
workspaceSlug as string,
|
||||
projectId as string,
|
||||
cycleId as string
|
||||
);
|
||||
|
||||
// rendering issues on gantt sidebar
|
||||
const GanttSidebarBlockView = ({ data }: any) => (
|
||||
<div className="relative flex w-full h-full items-center p-1 overflow-hidden gap-1">
|
||||
<div
|
||||
className="rounded-sm flex-shrink-0 w-[10px] h-[10px] flex justify-center items-center"
|
||||
style={{ backgroundColor: data?.state_detail?.color || "rgb(var(--color-primary-100))" }}
|
||||
/>
|
||||
<div className="text-custom-text-100 text-sm">{data?.name}</div>
|
||||
</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 ? blockFormat(ganttIssues) : null}
|
||||
blockUpdateHandler={handleUpdateDates}
|
||||
sidebarBlockRender={(data: any) => <GanttSidebarBlockView data={data} />}
|
||||
blockRender={(data: any) => <GanttBlockView data={data} />}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,82 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,138 +0,0 @@
|
||||
import { useState, useRef } from "react";
|
||||
|
||||
export const ChartDraggable = ({ children, block, handleBlock, className }: any) => {
|
||||
const [dragging, setDragging] = useState(false);
|
||||
|
||||
const [chartBlockPositionLeft, setChartBlockPositionLeft] = useState(0);
|
||||
const [blockPositionLeft, setBlockPositionLeft] = useState(0);
|
||||
const [dragBlockOffsetX, setDragBlockOffsetX] = useState(0);
|
||||
|
||||
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;
|
||||
|
||||
console.log("--------------------");
|
||||
console.log("chartBlockPositionLeft", chartBlockPositionLeft);
|
||||
console.log("blockPositionLeft", blockPositionLeft);
|
||||
console.log("dragBlockOffsetX", dragBlockOffsetX);
|
||||
console.log("-->");
|
||||
|
||||
setDragging(true);
|
||||
setChartBlockPositionLeft(chartBlockPositionLeft);
|
||||
setBlockPositionLeft(blockPositionLeft);
|
||||
setDragBlockOffsetX(dragBlockOffsetX);
|
||||
};
|
||||
|
||||
const handleMouseMove = (event: any) => {
|
||||
if (!dragging) return;
|
||||
|
||||
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 handleMouseUp = () => {
|
||||
setDragging(false);
|
||||
setChartBlockPositionLeft(0);
|
||||
setBlockPositionLeft(0);
|
||||
setDragBlockOffsetX(0);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
className={`${className ? className : ``}`}
|
||||
>
|
||||
{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 "./root";
|
||||
@@ -1,107 +0,0 @@
|
||||
import { FC } from "react";
|
||||
// next imports
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
// components
|
||||
import { GanttChartRoot } from "components/gantt-chart";
|
||||
// ui
|
||||
import { Tooltip } from "components/ui";
|
||||
// hooks
|
||||
import useGanttChartIssues from "hooks/gantt-chart/issue-view";
|
||||
|
||||
type Props = {};
|
||||
|
||||
export const IssueGanttChartView: FC<Props> = ({}) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
|
||||
const { ganttIssues, mutateGanttIssues } = useGanttChartIssues(
|
||||
workspaceSlug as string,
|
||||
projectId as string
|
||||
);
|
||||
|
||||
// rendering issues on gantt sidebar
|
||||
const GanttSidebarBlockView = ({ data }: any) => (
|
||||
<div className="relative flex w-full h-full items-center p-1 overflow-hidden gap-1">
|
||||
<div
|
||||
className="rounded-sm flex-shrink-0 w-[10px] h-[10px] flex justify-center items-center"
|
||||
style={{ backgroundColor: data?.state_detail?.color || "#rgb(var(--color-primary-100))" }}
|
||||
/>
|
||||
<div className="text-custom-text-100 text-sm">{data?.name}</div>
|
||||
</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 font-normal">
|
||||
<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,
|
||||
};
|
||||
};
|
||||
|
||||
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">
|
||||
<GanttChartRoot
|
||||
border={false}
|
||||
title="Issues"
|
||||
loaderTitle="Issues"
|
||||
blocks={ganttIssues ? blockFormat(ganttIssues) : null}
|
||||
blockUpdateHandler={handleUpdateDates}
|
||||
sidebarBlockRender={(data: any) => <GanttSidebarBlockView data={data} />}
|
||||
blockRender={(data: any) => <GanttBlockView data={data} />}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,201 +0,0 @@
|
||||
import React, { forwardRef, useEffect } from "react";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import { mutate } from "swr";
|
||||
|
||||
// react-hook-form
|
||||
import { Controller, SubmitHandler, useForm } from "react-hook-form";
|
||||
// hooks
|
||||
import useUserAuth from "hooks/use-user-auth";
|
||||
// react-color
|
||||
import { TwitterPicker } from "react-color";
|
||||
// headless ui
|
||||
import { Popover, Transition } from "@headlessui/react";
|
||||
// services
|
||||
import issuesService from "services/issues.service";
|
||||
// ui
|
||||
import { Input, PrimaryButton, SecondaryButton } from "components/ui";
|
||||
// icons
|
||||
import { ChevronDownIcon } from "@heroicons/react/24/outline";
|
||||
// types
|
||||
import { IIssueLabels } from "types";
|
||||
// fetch-keys
|
||||
import { PROJECT_ISSUE_LABELS } from "constants/fetch-keys";
|
||||
|
||||
type Props = {
|
||||
labelForm: boolean;
|
||||
setLabelForm: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
isUpdating: boolean;
|
||||
labelToUpdate: IIssueLabels | null;
|
||||
};
|
||||
|
||||
const defaultValues: Partial<IIssueLabels> = {
|
||||
name: "",
|
||||
color: "rgb(var(--color-text-200))",
|
||||
};
|
||||
|
||||
type Ref = HTMLDivElement;
|
||||
|
||||
export const CreateUpdateLabelInline = forwardRef<Ref, Props>(function CreateUpdateLabelInline(
|
||||
{ labelForm, setLabelForm, isUpdating, labelToUpdate },
|
||||
ref
|
||||
) {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
|
||||
const { user } = useUserAuth();
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
control,
|
||||
register,
|
||||
reset,
|
||||
formState: { errors, isSubmitting },
|
||||
watch,
|
||||
setValue,
|
||||
} = useForm<IIssueLabels>({
|
||||
defaultValues,
|
||||
});
|
||||
|
||||
const handleLabelCreate: SubmitHandler<IIssueLabels> = async (formData) => {
|
||||
if (!workspaceSlug || !projectId || isSubmitting) return;
|
||||
|
||||
await issuesService
|
||||
.createIssueLabel(workspaceSlug as string, projectId as string, formData, user)
|
||||
.then((res) => {
|
||||
mutate<IIssueLabels[]>(
|
||||
PROJECT_ISSUE_LABELS(projectId as string),
|
||||
(prevData) => [res, ...(prevData ?? [])],
|
||||
false
|
||||
);
|
||||
reset(defaultValues);
|
||||
setLabelForm(false);
|
||||
});
|
||||
};
|
||||
|
||||
const handleLabelUpdate: SubmitHandler<IIssueLabels> = async (formData) => {
|
||||
if (!workspaceSlug || !projectId || isSubmitting) return;
|
||||
|
||||
await issuesService
|
||||
.patchIssueLabel(
|
||||
workspaceSlug as string,
|
||||
projectId as string,
|
||||
labelToUpdate?.id ?? "",
|
||||
formData,
|
||||
user
|
||||
)
|
||||
.then(() => {
|
||||
reset(defaultValues);
|
||||
mutate<IIssueLabels[]>(
|
||||
PROJECT_ISSUE_LABELS(projectId as string),
|
||||
(prevData) =>
|
||||
prevData?.map((p) => (p.id === labelToUpdate?.id ? { ...p, ...formData } : p)),
|
||||
false
|
||||
);
|
||||
setLabelForm(false);
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!labelForm && isUpdating) return;
|
||||
|
||||
reset();
|
||||
}, [labelForm, isUpdating, reset]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!labelToUpdate) return;
|
||||
|
||||
setValue(
|
||||
"color",
|
||||
labelToUpdate.color && labelToUpdate.color !== "" ? labelToUpdate.color : "#000"
|
||||
);
|
||||
setValue("name", labelToUpdate.name);
|
||||
}, [labelToUpdate, setValue]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex scroll-m-8 items-center gap-2 rounded-[10px] border border-custom-border-200 bg-custom-background-100 p-5 ${
|
||||
labelForm ? "" : "hidden"
|
||||
}`}
|
||||
ref={ref}
|
||||
>
|
||||
<div className="flex-shrink-0">
|
||||
<Popover className="relative z-10 flex h-full w-full items-center justify-center">
|
||||
{({ open }) => (
|
||||
<>
|
||||
<Popover.Button
|
||||
className={`group inline-flex items-center text-base font-medium focus:outline-none ${
|
||||
open ? "text-custom-text-100" : "text-custom-text-200"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className="h-5 w-5 rounded"
|
||||
style={{
|
||||
backgroundColor: watch("color"),
|
||||
}}
|
||||
/>
|
||||
<ChevronDownIcon
|
||||
className={`ml-2 h-5 w-5 group-hover:text-custom-text-200 ${
|
||||
open ? "text-gray-600" : "text-gray-400"
|
||||
}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Popover.Button>
|
||||
|
||||
<Transition
|
||||
as={React.Fragment}
|
||||
enter="transition ease-out duration-200"
|
||||
enterFrom="opacity-0 translate-y-1"
|
||||
enterTo="opacity-100 translate-y-0"
|
||||
leave="transition ease-in duration-150"
|
||||
leaveFrom="opacity-100 translate-y-0"
|
||||
leaveTo="opacity-0 translate-y-1"
|
||||
>
|
||||
<Popover.Panel className="absolute top-full left-0 z-20 mt-3 w-screen max-w-xs px-2 sm:px-0">
|
||||
<Controller
|
||||
name="color"
|
||||
control={control}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<TwitterPicker color={value} onChange={(value) => onChange(value.hex)} />
|
||||
)}
|
||||
/>
|
||||
</Popover.Panel>
|
||||
</Transition>
|
||||
</>
|
||||
)}
|
||||
</Popover>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col justify-center">
|
||||
<Input
|
||||
type="text"
|
||||
id="labelName"
|
||||
name="name"
|
||||
register={register}
|
||||
placeholder="Label title"
|
||||
validations={{
|
||||
required: "Label title is required",
|
||||
}}
|
||||
error={errors.name}
|
||||
/>
|
||||
</div>
|
||||
<SecondaryButton
|
||||
onClick={() => {
|
||||
reset();
|
||||
setLabelForm(false);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</SecondaryButton>
|
||||
{isUpdating ? (
|
||||
<PrimaryButton onClick={handleSubmit(handleLabelUpdate)} loading={isSubmitting}>
|
||||
{isSubmitting ? "Updating" : "Update"}
|
||||
</PrimaryButton>
|
||||
) : (
|
||||
<PrimaryButton onClick={handleSubmit(handleLabelCreate)} loading={isSubmitting}>
|
||||
{isSubmitting ? "Adding" : "Add"}
|
||||
</PrimaryButton>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -1,109 +0,0 @@
|
||||
import { FC } from "react";
|
||||
// next imports
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
// components
|
||||
import { GanttChartRoot } from "components/gantt-chart";
|
||||
// ui
|
||||
import { Tooltip } from "components/ui";
|
||||
// hooks
|
||||
import useGanttChartModuleIssues from "hooks/gantt-chart/module-issues-view";
|
||||
|
||||
type Props = {};
|
||||
|
||||
export const ModuleIssuesGanttChartView: FC<Props> = ({}) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, moduleId } = router.query;
|
||||
|
||||
const { ganttIssues, mutateGanttIssues } = useGanttChartModuleIssues(
|
||||
workspaceSlug as string,
|
||||
projectId as string,
|
||||
moduleId as string
|
||||
);
|
||||
|
||||
// rendering issues on gantt sidebar
|
||||
const GanttSidebarBlockView = ({ data }: any) => (
|
||||
<div className="relative flex w-full h-full items-center p-1 overflow-hidden gap-1">
|
||||
<div
|
||||
className="rounded-sm flex-shrink-0 w-[10px] h-[10px] flex justify-center items-center"
|
||||
style={{ backgroundColor: data?.state_detail?.color || "rgb(var(--color-primary-100))" }}
|
||||
/>
|
||||
<div className="text-custom-text-100 text-sm">{data?.name}</div>
|
||||
</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="Modules"
|
||||
loaderTitle="Modules"
|
||||
blocks={ganttIssues ? blockFormat(ganttIssues) : null}
|
||||
blockUpdateHandler={handleUpdateDates}
|
||||
sidebarBlockRender={(data: any) => <GanttSidebarBlockView data={data} />}
|
||||
blockRender={(data: any) => <GanttBlockView data={data} />}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,85 +0,0 @@
|
||||
import { FC } from "react";
|
||||
// next imports
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
// components
|
||||
import { GanttChartRoot } from "components/gantt-chart";
|
||||
// ui
|
||||
import { Tooltip } from "components/ui";
|
||||
// types
|
||||
import { IModule } from "types";
|
||||
// constants
|
||||
import { MODULE_STATUS } from "constants/module";
|
||||
|
||||
type Props = {
|
||||
modules: IModule[];
|
||||
};
|
||||
|
||||
export const ModulesListGanttChartView: FC<Props> = ({ modules }) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
|
||||
// rendering issues on gantt sidebar
|
||||
const GanttSidebarBlockView = ({ data }: any) => (
|
||||
<div className="relative flex w-full h-full items-center p-1 overflow-hidden gap-1">
|
||||
<div
|
||||
className="rounded-sm flex-shrink-0 w-[10px] h-[10px] flex justify-center items-center"
|
||||
style={{
|
||||
backgroundColor: MODULE_STATUS.find((s) => s.value === data.status)?.color,
|
||||
}}
|
||||
/>
|
||||
<div className="text-custom-text-100 text-sm">{data?.name}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// rendering issues on gantt card
|
||||
const GanttBlockView = ({ data }: { data: IModule }) => (
|
||||
<Link href={`/${workspaceSlug}/projects/${projectId}/modules/${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: MODULE_STATUS.find((s) => s.value === data.status)?.color }}
|
||||
/>
|
||||
<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>
|
||||
);
|
||||
|
||||
// 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: any) =>
|
||||
blocks && blocks.length > 0
|
||||
? 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="Modules"
|
||||
loaderTitle="Modules"
|
||||
blocks={modules ? blockFormat(modules) : null}
|
||||
blockUpdateHandler={handleUpdateDates}
|
||||
sidebarBlockRender={(data: any) => <GanttSidebarBlockView data={data} />}
|
||||
blockRender={(data: any) => <GanttBlockView data={data} />}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,313 +0,0 @@
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import { mutate } from "swr";
|
||||
|
||||
// react-beautiful-dnd
|
||||
import { DraggableProvided, DraggableStateSnapshot } from "react-beautiful-dnd";
|
||||
// headless ui
|
||||
import { Disclosure, Transition } from "@headlessui/react";
|
||||
// services
|
||||
import projectService from "services/project.service";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
// ui
|
||||
import { CustomMenu, Tooltip } from "components/ui";
|
||||
// icons
|
||||
import { EllipsisVerticalIcon, LinkIcon, StarIcon, TrashIcon } from "@heroicons/react/24/outline";
|
||||
import {
|
||||
ArchiveOutlined,
|
||||
ArticleOutlined,
|
||||
ContrastOutlined,
|
||||
DatasetOutlined,
|
||||
ExpandMoreOutlined,
|
||||
FilterNoneOutlined,
|
||||
PhotoFilterOutlined,
|
||||
SettingsOutlined,
|
||||
} from "@mui/icons-material";
|
||||
// helpers
|
||||
import { truncateText } from "helpers/string.helper";
|
||||
import { renderEmoji } from "helpers/emoji.helper";
|
||||
// types
|
||||
import { IProject } from "types";
|
||||
// fetch-keys
|
||||
import { PROJECTS_LIST } from "constants/fetch-keys";
|
||||
|
||||
type Props = {
|
||||
project: IProject;
|
||||
sidebarCollapse: boolean;
|
||||
provided?: DraggableProvided;
|
||||
snapshot?: DraggableStateSnapshot;
|
||||
handleDeleteProject: () => void;
|
||||
handleCopyText: () => void;
|
||||
shortContextMenu?: boolean;
|
||||
};
|
||||
|
||||
const navigation = (workspaceSlug: string, projectId: string) => [
|
||||
{
|
||||
name: "Issues",
|
||||
href: `/${workspaceSlug}/projects/${projectId}/issues`,
|
||||
Icon: FilterNoneOutlined,
|
||||
},
|
||||
{
|
||||
name: "Cycles",
|
||||
href: `/${workspaceSlug}/projects/${projectId}/cycles`,
|
||||
Icon: ContrastOutlined,
|
||||
},
|
||||
{
|
||||
name: "Modules",
|
||||
href: `/${workspaceSlug}/projects/${projectId}/modules`,
|
||||
Icon: DatasetOutlined,
|
||||
},
|
||||
{
|
||||
name: "Views",
|
||||
href: `/${workspaceSlug}/projects/${projectId}/views`,
|
||||
Icon: PhotoFilterOutlined,
|
||||
},
|
||||
{
|
||||
name: "Pages",
|
||||
href: `/${workspaceSlug}/projects/${projectId}/pages`,
|
||||
Icon: ArticleOutlined,
|
||||
},
|
||||
{
|
||||
name: "Settings",
|
||||
href: `/${workspaceSlug}/projects/${projectId}/settings`,
|
||||
Icon: SettingsOutlined,
|
||||
},
|
||||
];
|
||||
|
||||
export const SingleSidebarProject: React.FC<Props> = ({
|
||||
project,
|
||||
sidebarCollapse,
|
||||
provided,
|
||||
snapshot,
|
||||
handleDeleteProject,
|
||||
handleCopyText,
|
||||
shortContextMenu = false,
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const handleAddToFavorites = () => {
|
||||
if (!workspaceSlug) return;
|
||||
|
||||
mutate<IProject[]>(
|
||||
PROJECTS_LIST(workspaceSlug as string, { is_favorite: "all" }),
|
||||
(prevData) =>
|
||||
(prevData ?? []).map((p) => (p.id === project.id ? { ...p, is_favorite: true } : p)),
|
||||
false
|
||||
);
|
||||
|
||||
projectService
|
||||
.addProjectToFavorites(workspaceSlug as string, {
|
||||
project: project.id,
|
||||
})
|
||||
.catch(() =>
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: "Couldn't remove the project from favorites. Please try again.",
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const handleRemoveFromFavorites = () => {
|
||||
if (!workspaceSlug) return;
|
||||
|
||||
mutate<IProject[]>(
|
||||
PROJECTS_LIST(workspaceSlug as string, { is_favorite: "all" }),
|
||||
(prevData) =>
|
||||
(prevData ?? []).map((p) => (p.id === project.id ? { ...p, is_favorite: false } : p)),
|
||||
false
|
||||
);
|
||||
|
||||
projectService.removeProjectFromFavorites(workspaceSlug as string, project.id).catch(() =>
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: "Couldn't remove the project from favorites. Please try again.",
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Disclosure key={project.id} defaultOpen={projectId === project.id}>
|
||||
{({ open }) => (
|
||||
<>
|
||||
<div
|
||||
className={`group relative text-custom-sidebar-text-10 px-2 py-1 w-full flex items-center hover:bg-custom-sidebar-background-80 rounded-md ${
|
||||
snapshot?.isDragging ? "opacity-60" : ""
|
||||
}`}
|
||||
>
|
||||
{provided && (
|
||||
<Tooltip
|
||||
tooltipContent={
|
||||
project.sort_order === null
|
||||
? "Join the project to rearrange"
|
||||
: "Drag to rearrange"
|
||||
}
|
||||
position="top-right"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={`absolute top-1/2 -translate-y-1/2 -left-4 hidden rounded p-0.5 ${
|
||||
sidebarCollapse ? "" : "group-hover:!flex"
|
||||
} ${project.sort_order === null ? "opacity-60 cursor-not-allowed" : ""}`}
|
||||
{...provided?.dragHandleProps}
|
||||
>
|
||||
<EllipsisVerticalIcon className="h-4" />
|
||||
<EllipsisVerticalIcon className="-ml-5 h-4" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip
|
||||
tooltipContent={`${project.name}`}
|
||||
position="right"
|
||||
className="ml-2"
|
||||
disabled={!sidebarCollapse}
|
||||
>
|
||||
<Disclosure.Button
|
||||
as="div"
|
||||
className={`flex items-center flex-grow truncate cursor-pointer select-none text-left text-sm font-medium ${
|
||||
sidebarCollapse ? "justify-center" : `justify-between`
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`flex items-center flex-grow w-full truncate gap-x-2 ${
|
||||
sidebarCollapse ? "justify-center" : ""
|
||||
}`}
|
||||
>
|
||||
{project.emoji ? (
|
||||
<span className="grid h-7 w-7 flex-shrink-0 place-items-center rounded uppercase">
|
||||
{renderEmoji(project.emoji)}
|
||||
</span>
|
||||
) : project.icon_prop ? (
|
||||
<div className="h-7 w-7 flex-shrink-0 grid place-items-center">
|
||||
{renderEmoji(project.icon_prop)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="grid h-7 w-7 flex-shrink-0 place-items-center rounded bg-gray-700 uppercase text-white">
|
||||
{project?.name.charAt(0)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{!sidebarCollapse && (
|
||||
<p className={`truncate ${open ? "" : "text-custom-sidebar-text-200"}`}>
|
||||
{project.name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{!sidebarCollapse && (
|
||||
<ExpandMoreOutlined
|
||||
fontSize="small"
|
||||
className={`flex-shrink-0 ${
|
||||
open ? "rotate-180" : ""
|
||||
} !hidden group-hover:!block text-custom-sidebar-text-200 duration-300`}
|
||||
/>
|
||||
)}
|
||||
</Disclosure.Button>
|
||||
</Tooltip>
|
||||
|
||||
{!sidebarCollapse && (
|
||||
<CustomMenu className="hidden group-hover:block flex-shrink-0" ellipsis>
|
||||
{!shortContextMenu && (
|
||||
<CustomMenu.MenuItem onClick={handleDeleteProject}>
|
||||
<span className="flex items-center justify-start gap-2 ">
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
<span>Delete project</span>
|
||||
</span>
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
{!project.is_favorite && (
|
||||
<CustomMenu.MenuItem onClick={handleAddToFavorites}>
|
||||
<span className="flex items-center justify-start gap-2">
|
||||
<StarIcon className="h-4 w-4" />
|
||||
<span>Add to favorites</span>
|
||||
</span>
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
{project.is_favorite && (
|
||||
<CustomMenu.MenuItem onClick={handleRemoveFromFavorites}>
|
||||
<span className="flex items-center justify-start gap-2">
|
||||
<StarIcon className="h-4 w-4" />
|
||||
<span>Remove from favorites</span>
|
||||
</span>
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
<CustomMenu.MenuItem onClick={handleCopyText}>
|
||||
<span className="flex items-center justify-start gap-2">
|
||||
<LinkIcon className="h-4 w-4" />
|
||||
<span>Copy project link</span>
|
||||
</span>
|
||||
</CustomMenu.MenuItem>
|
||||
{project.archive_in > 0 && (
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() =>
|
||||
router.push(`/${workspaceSlug}/projects/${project?.id}/archived-issues/`)
|
||||
}
|
||||
>
|
||||
<div className="flex items-center justify-start gap-2">
|
||||
<ArchiveOutlined fontSize="small" />
|
||||
<span>Archived Issues</span>
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
</CustomMenu>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Transition
|
||||
enter="transition duration-100 ease-out"
|
||||
enterFrom="transform scale-95 opacity-0"
|
||||
enterTo="transform scale-100 opacity-100"
|
||||
leave="transition duration-75 ease-out"
|
||||
leaveFrom="transform scale-100 opacity-100"
|
||||
leaveTo="transform scale-95 opacity-0"
|
||||
>
|
||||
<Disclosure.Panel className={`space-y-2 mt-1 ${sidebarCollapse ? "" : "ml-[2.25rem]"}`}>
|
||||
{navigation(workspaceSlug as string, project?.id).map((item) => {
|
||||
if (
|
||||
(item.name === "Cycles" && !project.cycle_view) ||
|
||||
(item.name === "Modules" && !project.module_view) ||
|
||||
(item.name === "Views" && !project.issue_views_view) ||
|
||||
(item.name === "Pages" && !project.page_view)
|
||||
)
|
||||
return;
|
||||
|
||||
return (
|
||||
<Link key={item.name} href={item.href}>
|
||||
<a className="block w-full">
|
||||
<Tooltip
|
||||
tooltipContent={`${project?.name}: ${item.name}`}
|
||||
position="right"
|
||||
className="ml-2"
|
||||
disabled={!sidebarCollapse}
|
||||
>
|
||||
<div
|
||||
className={`group flex items-center rounded-md px-2 py-1.5 gap-2.5 text-xs font-medium outline-none ${
|
||||
router.asPath.includes(item.href)
|
||||
? "bg-custom-primary-100/10 text-custom-primary-100"
|
||||
: "text-custom-sidebar-text-200 hover:bg-custom-sidebar-background-80 focus:bg-custom-sidebar-background-80"
|
||||
} ${sidebarCollapse ? "justify-center" : ""}`}
|
||||
>
|
||||
<item.Icon
|
||||
sx={{
|
||||
fontSize: 18,
|
||||
}}
|
||||
/>
|
||||
{!sidebarCollapse && item.name}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</a>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</Disclosure.Panel>
|
||||
</Transition>
|
||||
</>
|
||||
)}
|
||||
</Disclosure>
|
||||
);
|
||||
};
|
||||
@@ -1,236 +0,0 @@
|
||||
import { useCallback, useState, useImperativeHandle } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import { InvalidContentHandler } from "remirror";
|
||||
import {
|
||||
BoldExtension,
|
||||
ItalicExtension,
|
||||
CalloutExtension,
|
||||
PlaceholderExtension,
|
||||
CodeBlockExtension,
|
||||
CodeExtension,
|
||||
HistoryExtension,
|
||||
LinkExtension,
|
||||
UnderlineExtension,
|
||||
HeadingExtension,
|
||||
OrderedListExtension,
|
||||
ListItemExtension,
|
||||
BulletListExtension,
|
||||
ImageExtension,
|
||||
DropCursorExtension,
|
||||
StrikeExtension,
|
||||
MentionAtomExtension,
|
||||
FontSizeExtension,
|
||||
} from "remirror/extensions";
|
||||
import {
|
||||
Remirror,
|
||||
useRemirror,
|
||||
EditorComponent,
|
||||
OnChangeJSON,
|
||||
OnChangeHTML,
|
||||
FloatingToolbar,
|
||||
FloatingWrapper,
|
||||
} from "@remirror/react";
|
||||
import { TableExtension } from "@remirror/extension-react-tables";
|
||||
// tlds
|
||||
import tlds from "tlds";
|
||||
// services
|
||||
import fileService from "services/file.service";
|
||||
// components
|
||||
import { CustomFloatingToolbar } from "./toolbar/float-tool-tip";
|
||||
import { MentionAutoComplete } from "./mention-autocomplete";
|
||||
|
||||
export interface IRemirrorRichTextEditor {
|
||||
placeholder?: string;
|
||||
mentions?: any[];
|
||||
tags?: any[];
|
||||
onBlur?: (jsonValue: any, htmlValue: any) => void;
|
||||
onJSONChange?: (jsonValue: any) => void;
|
||||
onHTMLChange?: (htmlValue: any) => void;
|
||||
value?: any;
|
||||
showToolbar?: boolean;
|
||||
editable?: boolean;
|
||||
customClassName?: string;
|
||||
gptOption?: boolean;
|
||||
noBorder?: boolean;
|
||||
borderOnFocus?: boolean;
|
||||
forwardedRef?: any;
|
||||
}
|
||||
|
||||
const RemirrorRichTextEditor: React.FC<IRemirrorRichTextEditor> = (props) => {
|
||||
const {
|
||||
placeholder,
|
||||
mentions = [],
|
||||
tags = [],
|
||||
onBlur = () => {},
|
||||
onJSONChange = () => {},
|
||||
onHTMLChange = () => {},
|
||||
value = "",
|
||||
showToolbar = true,
|
||||
editable = true,
|
||||
customClassName,
|
||||
gptOption = false,
|
||||
noBorder = false,
|
||||
borderOnFocus = true,
|
||||
forwardedRef,
|
||||
} = props;
|
||||
|
||||
const [disableToolbar, setDisableToolbar] = useState(false);
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug } = router.query;
|
||||
|
||||
// remirror error handler
|
||||
const onError: InvalidContentHandler = useCallback(
|
||||
({ json, invalidContent, transformers }: any) =>
|
||||
// Automatically remove all invalid nodes and marks.
|
||||
transformers.remove(json, invalidContent),
|
||||
[]
|
||||
);
|
||||
|
||||
const uploadImageHandler = (value: any): any => {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("asset", value[0].file);
|
||||
formData.append("attributes", JSON.stringify({}));
|
||||
|
||||
return [
|
||||
() =>
|
||||
new Promise(async (resolve, reject) => {
|
||||
const imageUrl = await fileService
|
||||
.uploadFile(workspaceSlug as string, formData)
|
||||
.then((response) => response.asset);
|
||||
|
||||
resolve({
|
||||
align: "left",
|
||||
alt: "Not Found",
|
||||
height: "100%",
|
||||
width: "35%",
|
||||
src: imageUrl,
|
||||
});
|
||||
}),
|
||||
];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
// remirror manager
|
||||
const { manager, state } = useRemirror({
|
||||
extensions: () => [
|
||||
new BoldExtension(),
|
||||
new ItalicExtension(),
|
||||
new UnderlineExtension(),
|
||||
new HeadingExtension({ levels: [1, 2, 3] }),
|
||||
new FontSizeExtension({ defaultSize: "16", unit: "px" }),
|
||||
new OrderedListExtension(),
|
||||
new ListItemExtension(),
|
||||
new BulletListExtension({ enableSpine: true }),
|
||||
new CalloutExtension({ defaultType: "warn" }),
|
||||
new CodeBlockExtension(),
|
||||
new CodeExtension(),
|
||||
new PlaceholderExtension({
|
||||
placeholder: placeholder || "Enter text...",
|
||||
emptyNodeClass: "empty-node",
|
||||
}),
|
||||
new HistoryExtension(),
|
||||
new LinkExtension({
|
||||
autoLink: true,
|
||||
autoLinkAllowedTLDs: tlds,
|
||||
selectTextOnClick: true,
|
||||
defaultTarget: "_blank",
|
||||
}),
|
||||
new ImageExtension({
|
||||
enableResizing: true,
|
||||
uploadHandler: uploadImageHandler,
|
||||
createPlaceholder() {
|
||||
const div = document.createElement("div");
|
||||
div.className =
|
||||
"w-[35%] aspect-video bg-custom-background-80 text-custom-text-200 animate-pulse";
|
||||
return div;
|
||||
},
|
||||
}),
|
||||
new DropCursorExtension(),
|
||||
new StrikeExtension(),
|
||||
new MentionAtomExtension({
|
||||
matchers: [
|
||||
{ name: "at", char: "@" },
|
||||
{ name: "tag", char: "#" },
|
||||
],
|
||||
}),
|
||||
new TableExtension(),
|
||||
],
|
||||
content: value,
|
||||
selection: "start",
|
||||
stringHandler: "html",
|
||||
onError,
|
||||
});
|
||||
|
||||
useImperativeHandle(forwardedRef, () => ({
|
||||
clearEditor: () => {
|
||||
manager.view.updateState(manager.createState({ content: "", selection: "start" }));
|
||||
},
|
||||
setEditorValue: (value: any) => {
|
||||
manager.view.updateState(
|
||||
manager.createState({
|
||||
content: value,
|
||||
selection: "end",
|
||||
})
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<Remirror
|
||||
manager={manager}
|
||||
initialContent={state}
|
||||
classNames={[
|
||||
`p-3 relative focus:outline-none rounded-md focus:border-custom-border-200 ${
|
||||
noBorder ? "" : "border border-custom-border-200"
|
||||
} ${
|
||||
borderOnFocus ? "focus:border border-custom-border-200" : "focus:border-0"
|
||||
} ${customClassName}`,
|
||||
]}
|
||||
editable={editable}
|
||||
onBlur={(event) => {
|
||||
const html = event.helpers.getHTML();
|
||||
const json = event.helpers.getJSON();
|
||||
|
||||
setDisableToolbar(true);
|
||||
|
||||
onBlur(json, html);
|
||||
}}
|
||||
onFocus={() => setDisableToolbar(false)}
|
||||
>
|
||||
<div className="prose prose-brand max-w-full prose-p:my-1">
|
||||
<EditorComponent />
|
||||
</div>
|
||||
|
||||
{editable && !disableToolbar && (
|
||||
<FloatingWrapper
|
||||
positioner="always"
|
||||
renderOutsideEditor
|
||||
floatingLabel="Custom Floating Toolbar"
|
||||
>
|
||||
<FloatingToolbar className="z-50 overflow-hidden rounded">
|
||||
<CustomFloatingToolbar
|
||||
gptOption={gptOption}
|
||||
editorState={state}
|
||||
setDisableToolbar={setDisableToolbar}
|
||||
/>
|
||||
</FloatingToolbar>
|
||||
</FloatingWrapper>
|
||||
)}
|
||||
|
||||
<MentionAutoComplete mentions={mentions} tags={tags} />
|
||||
{<OnChangeJSON onChange={onJSONChange} />}
|
||||
{<OnChangeHTML onChange={onHTMLChange} />}
|
||||
</Remirror>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
RemirrorRichTextEditor.displayName = "RemirrorRichTextEditor";
|
||||
|
||||
export default RemirrorRichTextEditor;
|
||||
@@ -1,64 +0,0 @@
|
||||
import { useState, useEffect, FC } from "react";
|
||||
// remirror imports
|
||||
import { cx } from "@remirror/core";
|
||||
import { useMentionAtom, MentionAtomNodeAttributes, FloatingWrapper } from "@remirror/react";
|
||||
|
||||
// export const;
|
||||
|
||||
export interface IMentionAutoComplete {
|
||||
mentions?: any[];
|
||||
tags?: any[];
|
||||
}
|
||||
|
||||
export const MentionAutoComplete: FC<IMentionAutoComplete> = (props) => {
|
||||
const { mentions = [], tags = [] } = props;
|
||||
// states
|
||||
const [options, setOptions] = useState<MentionAtomNodeAttributes[]>([]);
|
||||
|
||||
const { state, getMenuProps, getItemProps, indexIsHovered, indexIsSelected } = useMentionAtom({
|
||||
items: options,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
const searchTerm = state.query.full.toLowerCase();
|
||||
let filteredOptions: MentionAtomNodeAttributes[] = [];
|
||||
|
||||
if (state.name === "tag") {
|
||||
filteredOptions = tags.filter((tag) => tag?.label.toLowerCase().includes(searchTerm));
|
||||
} else if (state.name === "at") {
|
||||
filteredOptions = mentions.filter((user) => user?.label.toLowerCase().includes(searchTerm));
|
||||
}
|
||||
|
||||
filteredOptions = filteredOptions.sort().slice(0, 5);
|
||||
setOptions(filteredOptions);
|
||||
}, [state, mentions, tags]);
|
||||
|
||||
const enabled = Boolean(state);
|
||||
return (
|
||||
<FloatingWrapper positioner="cursor" enabled={enabled} placement="bottom-start">
|
||||
<div {...getMenuProps()} className="suggestions">
|
||||
{enabled &&
|
||||
options.map((user, index) => {
|
||||
const isHighlighted = indexIsSelected(index);
|
||||
const isHovered = indexIsHovered(index);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={user.id}
|
||||
className={cx("suggestion", isHighlighted && "highlighted", isHovered && "hovered")}
|
||||
{...getItemProps({
|
||||
item: user,
|
||||
index,
|
||||
})}
|
||||
>
|
||||
{user.label}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</FloatingWrapper>
|
||||
);
|
||||
};
|
||||
@@ -1,145 +0,0 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { TableExtension } from "@remirror/extension-react-tables";
|
||||
import {
|
||||
EditorComponent,
|
||||
ReactComponentExtension,
|
||||
Remirror,
|
||||
TableComponents,
|
||||
tableControllerPluginKey,
|
||||
ThemeProvider,
|
||||
useCommands,
|
||||
useRemirror,
|
||||
useRemirrorContext,
|
||||
} from "@remirror/react";
|
||||
import type { AnyExtension } from "remirror";
|
||||
|
||||
const CommandMenu: React.FC = () => {
|
||||
const { createTable, ...commands } = useCommands();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p>commands:</p>
|
||||
<p
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyItems: "flex-start",
|
||||
alignItems: "flex-start",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
data-testid="btn-3-3"
|
||||
onClick={() => createTable({ rowsCount: 3, columnsCount: 3, withHeaderRow: false })}
|
||||
>
|
||||
insert a 3*3 table
|
||||
</button>
|
||||
<button
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
data-testid="btn-3-3-headers"
|
||||
onClick={() => createTable({ rowsCount: 3, columnsCount: 3, withHeaderRow: true })}
|
||||
>
|
||||
insert a 3*3 table with headers
|
||||
</button>
|
||||
<button
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
data-testid="btn-4-10"
|
||||
onClick={() => createTable({ rowsCount: 10, columnsCount: 4, withHeaderRow: false })}
|
||||
>
|
||||
insert a 4*10 table
|
||||
</button>
|
||||
<button
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
data-testid="btn-3-30"
|
||||
onClick={() => createTable({ rowsCount: 30, columnsCount: 3, withHeaderRow: false })}
|
||||
>
|
||||
insert a 3*30 table
|
||||
</button>
|
||||
<button
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
data-testid="btn-8-100"
|
||||
onClick={() => createTable({ rowsCount: 100, columnsCount: 8, withHeaderRow: false })}
|
||||
>
|
||||
insert a 8*100 table
|
||||
</button>
|
||||
<button
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => commands.addTableColumnAfter()}
|
||||
>
|
||||
add a column after the current one
|
||||
</button>
|
||||
<button
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => commands.addTableRowBefore()}
|
||||
>
|
||||
add a row before the current one
|
||||
</button>
|
||||
<button
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => commands.deleteTable()}
|
||||
>
|
||||
delete the table
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ProsemirrorDocData: React.FC = () => {
|
||||
const ctx = useRemirrorContext({ autoUpdate: false });
|
||||
const [jsonPluginState, setJsonPluginState] = useState("");
|
||||
const [jsonDoc, setJsonDoc] = useState("");
|
||||
const { addHandler, view } = ctx;
|
||||
|
||||
useEffect(() => {
|
||||
addHandler("updated", () => {
|
||||
setJsonDoc(JSON.stringify(view.state.doc.toJSON(), null, 2));
|
||||
|
||||
const pluginStateValues = tableControllerPluginKey.getState(view.state)?.values;
|
||||
setJsonPluginState(
|
||||
JSON.stringify({ ...pluginStateValues, tableNodeResult: "hidden" }, null, 2)
|
||||
);
|
||||
});
|
||||
}, [addHandler, view]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p>tableControllerPluginKey.getState(view.state)</p>
|
||||
<pre style={{ fontSize: "12px", lineHeight: "12px" }}>
|
||||
<code>{jsonPluginState}</code>
|
||||
</pre>
|
||||
<p>view.state.doc.toJSON()</p>
|
||||
<pre style={{ fontSize: "12px", lineHeight: "12px" }}>
|
||||
<code>{jsonDoc}</code>
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Table = ({
|
||||
children,
|
||||
extensions,
|
||||
}: {
|
||||
children?: React.ReactElement;
|
||||
extensions: () => AnyExtension[];
|
||||
}): JSX.Element => {
|
||||
const { manager, state } = useRemirror({ extensions });
|
||||
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<Remirror manager={manager} initialContent={state}>
|
||||
<EditorComponent />
|
||||
<TableComponents />
|
||||
<CommandMenu />
|
||||
<ProsemirrorDocData />
|
||||
{children}
|
||||
</Remirror>
|
||||
</ThemeProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const Basic = (): JSX.Element => <Table extensions={defaultExtensions} />;
|
||||
|
||||
const defaultExtensions = () => [new ReactComponentExtension(), new TableExtension()];
|
||||
|
||||
export default Basic;
|
||||
@@ -1,316 +0,0 @@
|
||||
import React, {
|
||||
ChangeEvent,
|
||||
HTMLProps,
|
||||
KeyboardEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
import { createMarkPositioner, LinkExtension, ShortcutHandlerProps } from "remirror/extensions";
|
||||
// buttons
|
||||
import {
|
||||
ToggleBoldButton,
|
||||
ToggleItalicButton,
|
||||
ToggleUnderlineButton,
|
||||
ToggleStrikeButton,
|
||||
ToggleOrderedListButton,
|
||||
ToggleBulletListButton,
|
||||
ToggleCodeButton,
|
||||
ToggleHeadingButton,
|
||||
useActive,
|
||||
CommandButton,
|
||||
useAttrs,
|
||||
useChainedCommands,
|
||||
useCurrentSelection,
|
||||
useExtensionEvent,
|
||||
useUpdateReason,
|
||||
} from "@remirror/react";
|
||||
import { EditorState } from "remirror";
|
||||
|
||||
type Props = {
|
||||
gptOption?: boolean;
|
||||
editorState: Readonly<EditorState>;
|
||||
setDisableToolbar: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
};
|
||||
|
||||
const useLinkShortcut = () => {
|
||||
const [linkShortcut, setLinkShortcut] = useState<ShortcutHandlerProps | undefined>();
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
|
||||
useExtensionEvent(
|
||||
LinkExtension,
|
||||
"onShortcut",
|
||||
useCallback(
|
||||
(props) => {
|
||||
if (!isEditing) {
|
||||
setIsEditing(true);
|
||||
}
|
||||
|
||||
return setLinkShortcut(props);
|
||||
},
|
||||
[isEditing]
|
||||
)
|
||||
);
|
||||
|
||||
return { linkShortcut, isEditing, setIsEditing };
|
||||
};
|
||||
|
||||
const useFloatingLinkState = () => {
|
||||
const chain = useChainedCommands();
|
||||
const { isEditing, linkShortcut, setIsEditing } = useLinkShortcut();
|
||||
const { to, empty } = useCurrentSelection();
|
||||
|
||||
const url = (useAttrs().link()?.href as string) ?? "";
|
||||
const [href, setHref] = useState<string>(url);
|
||||
|
||||
// A positioner which only shows for links.
|
||||
const linkPositioner = useMemo(() => createMarkPositioner({ type: "link" }), []);
|
||||
|
||||
const onRemove = useCallback(() => chain.removeLink().focus().run(), [chain]);
|
||||
|
||||
const updateReason = useUpdateReason();
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!isEditing) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (updateReason.doc || updateReason.selection) {
|
||||
setIsEditing(false);
|
||||
}
|
||||
}, [isEditing, setIsEditing, updateReason.doc, updateReason.selection]);
|
||||
|
||||
useEffect(() => {
|
||||
setHref(url);
|
||||
}, [url]);
|
||||
|
||||
const submitHref = useCallback(() => {
|
||||
setIsEditing(false);
|
||||
const range = linkShortcut ?? undefined;
|
||||
|
||||
if (href === "") {
|
||||
chain.removeLink();
|
||||
} else {
|
||||
chain.updateLink({ href, auto: false }, range);
|
||||
}
|
||||
|
||||
chain.focus(range?.to ?? to).run();
|
||||
}, [setIsEditing, linkShortcut, chain, href, to]);
|
||||
|
||||
const cancelHref = useCallback(() => {
|
||||
setIsEditing(false);
|
||||
}, [setIsEditing]);
|
||||
|
||||
const clickEdit = useCallback(() => {
|
||||
if (empty) {
|
||||
chain.selectLink();
|
||||
}
|
||||
|
||||
setIsEditing(true);
|
||||
}, [chain, empty, setIsEditing]);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
href,
|
||||
setHref,
|
||||
linkShortcut,
|
||||
linkPositioner,
|
||||
isEditing,
|
||||
setIsEditing,
|
||||
clickEdit,
|
||||
onRemove,
|
||||
submitHref,
|
||||
cancelHref,
|
||||
}),
|
||||
[
|
||||
href,
|
||||
linkShortcut,
|
||||
linkPositioner,
|
||||
isEditing,
|
||||
clickEdit,
|
||||
onRemove,
|
||||
submitHref,
|
||||
cancelHref,
|
||||
setIsEditing,
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
const DelayAutoFocusInput = ({
|
||||
autoFocus,
|
||||
setDisableToolbar,
|
||||
...rest
|
||||
}: HTMLProps<HTMLInputElement> & {
|
||||
setDisableToolbar: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
}) => {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoFocus) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDisableToolbar(false);
|
||||
|
||||
const frame = window.requestAnimationFrame(() => {
|
||||
inputRef.current?.focus();
|
||||
});
|
||||
|
||||
return () => {
|
||||
window.cancelAnimationFrame(frame);
|
||||
};
|
||||
}, [autoFocus, setDisableToolbar]);
|
||||
|
||||
useEffect(() => {
|
||||
setDisableToolbar(false);
|
||||
}, [setDisableToolbar]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<label htmlFor="link-input" className="text-sm">
|
||||
Add Link
|
||||
</label>
|
||||
<input
|
||||
ref={inputRef}
|
||||
{...rest}
|
||||
onKeyDown={(e) => {
|
||||
if (rest.onKeyDown) rest.onKeyDown(e);
|
||||
setDisableToolbar(false);
|
||||
}}
|
||||
className={`${rest.className} mt-1`}
|
||||
onFocus={() => {
|
||||
setDisableToolbar(false);
|
||||
}}
|
||||
onBlur={() => {
|
||||
setDisableToolbar(true);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const CustomFloatingToolbar: React.FC<Props> = ({
|
||||
gptOption,
|
||||
editorState,
|
||||
setDisableToolbar,
|
||||
}) => {
|
||||
const { isEditing, setIsEditing, clickEdit, onRemove, submitHref, href, setHref, cancelHref } =
|
||||
useFloatingLinkState();
|
||||
|
||||
const active = useActive();
|
||||
const activeLink = active.link();
|
||||
|
||||
const handleClickEdit = useCallback(() => {
|
||||
clickEdit();
|
||||
}, [clickEdit]);
|
||||
|
||||
return (
|
||||
<div className="z-[99999] flex flex-col items-center gap-y-2 divide-x divide-y divide-custom-border-200 rounded border border-custom-border-200 bg-custom-background-80 p-1 px-0.5 shadow-md">
|
||||
<div className="flex items-center gap-y-2 divide-x divide-custom-border-200">
|
||||
<div className="flex items-center gap-x-1 px-2">
|
||||
<ToggleHeadingButton
|
||||
attrs={{
|
||||
level: 1,
|
||||
}}
|
||||
/>
|
||||
<ToggleHeadingButton
|
||||
attrs={{
|
||||
level: 2,
|
||||
}}
|
||||
/>
|
||||
<ToggleHeadingButton
|
||||
attrs={{
|
||||
level: 3,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-x-1 px-2">
|
||||
<ToggleBoldButton />
|
||||
<ToggleItalicButton />
|
||||
<ToggleUnderlineButton />
|
||||
<ToggleStrikeButton />
|
||||
</div>
|
||||
<div className="flex items-center gap-x-1 px-2">
|
||||
<ToggleOrderedListButton />
|
||||
<ToggleBulletListButton />
|
||||
</div>
|
||||
{gptOption && (
|
||||
<div className="flex items-center gap-x-1 px-2">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded py-1 px-1.5 text-xs hover:bg-custom-background-90"
|
||||
onClick={() => console.log(editorState.selection.$anchor.nodeBefore)}
|
||||
>
|
||||
AI
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-x-1 px-2">
|
||||
<ToggleCodeButton />
|
||||
</div>
|
||||
{activeLink ? (
|
||||
<div className="flex items-center gap-x-1 px-2">
|
||||
<CommandButton
|
||||
commandName="openLink"
|
||||
onSelect={() => {
|
||||
window.open(href, "_blank");
|
||||
}}
|
||||
icon="externalLinkFill"
|
||||
enabled
|
||||
/>
|
||||
<CommandButton
|
||||
commandName="updateLink"
|
||||
onSelect={handleClickEdit}
|
||||
icon="pencilLine"
|
||||
enabled
|
||||
/>
|
||||
<CommandButton commandName="removeLink" onSelect={onRemove} icon="linkUnlink" enabled />
|
||||
</div>
|
||||
) : (
|
||||
<CommandButton
|
||||
commandName="updateLink"
|
||||
onSelect={() => {
|
||||
if (isEditing) {
|
||||
setIsEditing(false);
|
||||
} else {
|
||||
handleClickEdit();
|
||||
}
|
||||
}}
|
||||
icon="link"
|
||||
enabled
|
||||
active={isEditing}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isEditing && (
|
||||
<div className="p-2 w-full">
|
||||
<DelayAutoFocusInput
|
||||
autoFocus
|
||||
placeholder="Paste your link here..."
|
||||
id="link-input"
|
||||
setDisableToolbar={setDisableToolbar}
|
||||
className="w-full px-2 py-0.5"
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => setHref(e.target.value)}
|
||||
value={href}
|
||||
onKeyDown={(e: KeyboardEvent<HTMLInputElement>) => {
|
||||
const { code } = e;
|
||||
|
||||
if (code === "Enter") {
|
||||
submitHref();
|
||||
}
|
||||
|
||||
if (code === "Escape") {
|
||||
cancelHref();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,57 +0,0 @@
|
||||
// remirror
|
||||
import { useCommands, useActive } from "@remirror/react";
|
||||
// ui
|
||||
import { CustomMenu } from "components/ui";
|
||||
|
||||
const HeadingControls = () => {
|
||||
const { toggleHeading, focus } = useCommands();
|
||||
|
||||
const active = useActive();
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<CustomMenu
|
||||
width="lg"
|
||||
label={`${
|
||||
active.heading({ level: 1 })
|
||||
? "Heading 1"
|
||||
: active.heading({ level: 2 })
|
||||
? "Heading 2"
|
||||
: active.heading({ level: 3 })
|
||||
? "Heading 3"
|
||||
: "Normal text"
|
||||
}`}
|
||||
>
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() => {
|
||||
toggleHeading({ level: 1 });
|
||||
focus();
|
||||
}}
|
||||
className={`${active.heading({ level: 1 }) ? "bg-indigo-50" : ""}`}
|
||||
>
|
||||
Heading 1
|
||||
</CustomMenu.MenuItem>
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() => {
|
||||
toggleHeading({ level: 2 });
|
||||
focus();
|
||||
}}
|
||||
className={`${active.heading({ level: 2 }) ? "bg-indigo-50" : ""}`}
|
||||
>
|
||||
Heading 2
|
||||
</CustomMenu.MenuItem>
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() => {
|
||||
toggleHeading({ level: 3 });
|
||||
focus();
|
||||
}}
|
||||
className={`${active.heading({ level: 3 }) ? "bg-indigo-50" : ""}`}
|
||||
>
|
||||
Heading 3
|
||||
</CustomMenu.MenuItem>
|
||||
</CustomMenu>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeadingControls;
|
||||
@@ -1,35 +0,0 @@
|
||||
// buttons
|
||||
import {
|
||||
ToggleBoldButton,
|
||||
ToggleItalicButton,
|
||||
ToggleUnderlineButton,
|
||||
ToggleStrikeButton,
|
||||
ToggleOrderedListButton,
|
||||
ToggleBulletListButton,
|
||||
RedoButton,
|
||||
UndoButton,
|
||||
} from "@remirror/react";
|
||||
// headings
|
||||
import HeadingControls from "./heading-controls";
|
||||
|
||||
export const RichTextToolbar: React.FC = () => (
|
||||
<div className="flex items-center gap-y-2 divide-x">
|
||||
<div className="flex items-center gap-x-1 px-2">
|
||||
<RedoButton />
|
||||
<UndoButton />
|
||||
</div>
|
||||
<div className="px-2">
|
||||
<HeadingControls />
|
||||
</div>
|
||||
<div className="flex items-center gap-x-1 px-2">
|
||||
<ToggleBoldButton />
|
||||
<ToggleItalicButton />
|
||||
<ToggleUnderlineButton />
|
||||
<ToggleStrikeButton />
|
||||
</div>
|
||||
<div className="flex items-center gap-x-1 px-2">
|
||||
<ToggleOrderedListButton />
|
||||
<ToggleBulletListButton />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -1,215 +0,0 @@
|
||||
import React, {
|
||||
ChangeEvent,
|
||||
HTMLProps,
|
||||
KeyboardEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
import { createMarkPositioner, LinkExtension, ShortcutHandlerProps } from "remirror/extensions";
|
||||
import {
|
||||
CommandButton,
|
||||
FloatingToolbar,
|
||||
FloatingWrapper,
|
||||
useActive,
|
||||
useAttrs,
|
||||
useChainedCommands,
|
||||
useCurrentSelection,
|
||||
useExtensionEvent,
|
||||
useUpdateReason,
|
||||
} from "@remirror/react";
|
||||
|
||||
const useLinkShortcut = () => {
|
||||
const [linkShortcut, setLinkShortcut] = useState<ShortcutHandlerProps | undefined>();
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
|
||||
useExtensionEvent(
|
||||
LinkExtension,
|
||||
"onShortcut",
|
||||
useCallback(
|
||||
(props) => {
|
||||
if (!isEditing) {
|
||||
setIsEditing(true);
|
||||
}
|
||||
|
||||
return setLinkShortcut(props);
|
||||
},
|
||||
[isEditing]
|
||||
)
|
||||
);
|
||||
|
||||
return { linkShortcut, isEditing, setIsEditing };
|
||||
};
|
||||
|
||||
const useFloatingLinkState = () => {
|
||||
const chain = useChainedCommands();
|
||||
const { isEditing, linkShortcut, setIsEditing } = useLinkShortcut();
|
||||
const { to, empty } = useCurrentSelection();
|
||||
|
||||
const url = (useAttrs().link()?.href as string) ?? "";
|
||||
const [href, setHref] = useState<string>(url);
|
||||
|
||||
// A positioner which only shows for links.
|
||||
const linkPositioner = useMemo(() => createMarkPositioner({ type: "link" }), []);
|
||||
|
||||
const onRemove = useCallback(() => chain.removeLink().focus().run(), [chain]);
|
||||
|
||||
const updateReason = useUpdateReason();
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!isEditing) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (updateReason.doc || updateReason.selection) {
|
||||
setIsEditing(false);
|
||||
}
|
||||
}, [isEditing, setIsEditing, updateReason.doc, updateReason.selection]);
|
||||
|
||||
useEffect(() => {
|
||||
setHref(url);
|
||||
}, [url]);
|
||||
|
||||
const submitHref = useCallback(() => {
|
||||
setIsEditing(false);
|
||||
const range = linkShortcut ?? undefined;
|
||||
|
||||
if (href === "") {
|
||||
chain.removeLink();
|
||||
} else {
|
||||
chain.updateLink({ href, auto: false }, range);
|
||||
}
|
||||
|
||||
chain.focus(range?.to ?? to).run();
|
||||
}, [setIsEditing, linkShortcut, chain, href, to]);
|
||||
|
||||
const cancelHref = useCallback(() => {
|
||||
setIsEditing(false);
|
||||
}, [setIsEditing]);
|
||||
|
||||
const clickEdit = useCallback(() => {
|
||||
if (empty) {
|
||||
chain.selectLink();
|
||||
}
|
||||
|
||||
setIsEditing(true);
|
||||
}, [chain, empty, setIsEditing]);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
href,
|
||||
setHref,
|
||||
linkShortcut,
|
||||
linkPositioner,
|
||||
isEditing,
|
||||
clickEdit,
|
||||
onRemove,
|
||||
submitHref,
|
||||
cancelHref,
|
||||
}),
|
||||
[href, linkShortcut, linkPositioner, isEditing, clickEdit, onRemove, submitHref, cancelHref]
|
||||
);
|
||||
};
|
||||
|
||||
const DelayAutoFocusInput = ({ autoFocus, ...rest }: HTMLProps<HTMLInputElement>) => {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoFocus) {
|
||||
return;
|
||||
}
|
||||
|
||||
const frame = window.requestAnimationFrame(() => {
|
||||
inputRef.current?.focus();
|
||||
});
|
||||
|
||||
return () => {
|
||||
window.cancelAnimationFrame(frame);
|
||||
};
|
||||
}, [autoFocus]);
|
||||
|
||||
return <input ref={inputRef} {...rest} />;
|
||||
};
|
||||
|
||||
export const FloatingLinkToolbar = () => {
|
||||
const { isEditing, linkPositioner, clickEdit, onRemove, submitHref, href, setHref, cancelHref } =
|
||||
useFloatingLinkState();
|
||||
|
||||
const active = useActive();
|
||||
const activeLink = active.link();
|
||||
|
||||
const { empty } = useCurrentSelection();
|
||||
|
||||
const handleClickEdit = useCallback(() => {
|
||||
clickEdit();
|
||||
}, [clickEdit]);
|
||||
|
||||
const linkEditButtons = activeLink ? (
|
||||
<>
|
||||
<CommandButton
|
||||
commandName="openLink"
|
||||
onSelect={() => {
|
||||
window.open(href, "_blank");
|
||||
}}
|
||||
icon="externalLinkFill"
|
||||
enabled
|
||||
/>
|
||||
<CommandButton
|
||||
commandName="updateLink"
|
||||
onSelect={handleClickEdit}
|
||||
icon="pencilLine"
|
||||
enabled
|
||||
/>
|
||||
<CommandButton commandName="removeLink" onSelect={onRemove} icon="linkUnlink" enabled />
|
||||
</>
|
||||
) : (
|
||||
<CommandButton commandName="updateLink" onSelect={handleClickEdit} icon="link" enabled />
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{!isEditing && (
|
||||
<FloatingToolbar className="rounded bg-custom-background-80 p-1 shadow-lg">
|
||||
{linkEditButtons}
|
||||
</FloatingToolbar>
|
||||
)}
|
||||
{!isEditing && empty && (
|
||||
<FloatingToolbar
|
||||
positioner={linkPositioner}
|
||||
className="rounded bg-custom-background-80 p-1 shadow-lg"
|
||||
>
|
||||
{linkEditButtons}
|
||||
</FloatingToolbar>
|
||||
)}
|
||||
|
||||
<FloatingWrapper
|
||||
positioner="always"
|
||||
placement="bottom"
|
||||
enabled={isEditing}
|
||||
renderOutsideEditor
|
||||
>
|
||||
<DelayAutoFocusInput
|
||||
autoFocus
|
||||
placeholder="Enter link..."
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => setHref(e.target.value)}
|
||||
value={href}
|
||||
onKeyDown={(e: KeyboardEvent<HTMLInputElement>) => {
|
||||
const { code } = e;
|
||||
|
||||
if (code === "Enter") {
|
||||
submitHref();
|
||||
}
|
||||
|
||||
if (code === "Escape") {
|
||||
cancelHref();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FloatingWrapper>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,55 +0,0 @@
|
||||
import { useCommands } from "@remirror/react";
|
||||
|
||||
export const TableControls = () => {
|
||||
const { createTable, ...commands } = useCommands();
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => createTable({ rowsCount: 3, columnsCount: 3, withHeaderRow: false })}
|
||||
className="rounded p-1 hover:bg-custom-background-90"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="icon icon-tabler icon-tabler-table"
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="#2c3e50"
|
||||
fill="none"
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<rect x="4" y="4" width="16" height="16" rx="2" />
|
||||
<line x1="4" y1="10" x2="20" y2="10" />
|
||||
<line x1="10" y1="4" x2="10" y2="20" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => commands.deleteTable()}
|
||||
className="rounded p-1 hover:bg-custom-background-90"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="icon icon-tabler icon-tabler-trash"
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth="1.5"
|
||||
stroke="#2c3e50"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<line x1="4" y1="7" x2="20" y2="7" />
|
||||
<line x1="10" y1="11" x2="10" y2="17" />
|
||||
<line x1="14" y1="11" x2="14" y2="17" />
|
||||
<path d="M5 7l1 12a2 2 0 0 0 2 2h8a2 2 0 0 0 2 -2l1 -12" />
|
||||
<path d="M9 7v-3a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v3" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,19 +0,0 @@
|
||||
import { ExclamationIcon } from "components/icons";
|
||||
|
||||
type Props = {
|
||||
bannerName: string;
|
||||
};
|
||||
|
||||
export const IntegrationAndImportExportBanner: React.FC<Props> = ({ bannerName }) => (
|
||||
<div className="flex flex-col items-start gap-3">
|
||||
<h3 className="text-2xl font-semibold">{bannerName}</h3>
|
||||
<div className="flex items-center gap-3 rounded-[10px] border border-custom-primary/75 bg-custom-primary/5 p-4 text-sm text-custom-text-100">
|
||||
<ExclamationIcon height={24} width={24} className="fill-current text-custom-text-100" />
|
||||
<p className="leading-5">
|
||||
Integrations and importers are only available on the cloud version. We plan to open-source
|
||||
our SDKs in the near future so that the community can request or contribute integrations as
|
||||
needed.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -1,32 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
type IssueLabelsListProps = {
|
||||
labels?: (string | undefined)[];
|
||||
length?: number;
|
||||
showLength?: boolean;
|
||||
};
|
||||
|
||||
export const IssueLabelsList: React.FC<IssueLabelsListProps> = ({
|
||||
labels,
|
||||
length = 5,
|
||||
showLength = true,
|
||||
}) => (
|
||||
<>
|
||||
{labels && (
|
||||
<>
|
||||
{labels.slice(0, length).map((color, index) => (
|
||||
<div className={`flex h-4 w-4 rounded-full ${index ? "-ml-3.5" : ""}`}>
|
||||
<span
|
||||
className={`h-4 w-4 flex-shrink-0 rounded-full border border-custom-border-200
|
||||
`}
|
||||
style={{
|
||||
backgroundColor: color && color !== "" ? color : "#000000",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{labels.length > length ? <span>+{labels.length - length}</span> : null}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
@@ -1,109 +0,0 @@
|
||||
import { FC } from "react";
|
||||
// next imports
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
// components
|
||||
import { GanttChartRoot } from "components/gantt-chart";
|
||||
// ui
|
||||
import { Tooltip } from "components/ui";
|
||||
// hooks
|
||||
import useGanttChartViewIssues from "hooks/gantt-chart/view-issues-view";
|
||||
|
||||
type Props = {};
|
||||
|
||||
export const ViewIssuesGanttChartView: FC<Props> = ({}) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, viewId } = router.query;
|
||||
|
||||
const { ganttIssues, mutateGanttIssues } = useGanttChartViewIssues(
|
||||
workspaceSlug as string,
|
||||
projectId as string,
|
||||
viewId as string
|
||||
);
|
||||
|
||||
// rendering issues on gantt sidebar
|
||||
const GanttSidebarBlockView = ({ data }: any) => (
|
||||
<div className="relative flex w-full h-full items-center p-1 overflow-hidden gap-1">
|
||||
<div
|
||||
className="rounded-sm flex-shrink-0 w-[10px] h-[10px] flex justify-center items-center"
|
||||
style={{ backgroundColor: data?.state_detail?.color || "rgb(var(--color-primary-100))" }}
|
||||
/>
|
||||
<div className="text-custom-text-100 text-sm">{data?.name}</div>
|
||||
</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="Issue Views"
|
||||
loaderTitle="Issue Views"
|
||||
blocks={ganttIssues ? blockFormat(ganttIssues) : null}
|
||||
blockUpdateHandler={handleUpdateDates}
|
||||
sidebarBlockRender={(data: any) => <GanttSidebarBlockView data={data} />}
|
||||
blockRender={(data: any) => <GanttBlockView data={data} />}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,35 +0,0 @@
|
||||
// icons
|
||||
import { Bars3Icon } from "@heroicons/react/24/outline";
|
||||
|
||||
type Props = {
|
||||
breadcrumbs?: JSX.Element;
|
||||
left?: JSX.Element;
|
||||
right?: JSX.Element;
|
||||
setToggleSidebar: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
noHeader: boolean;
|
||||
};
|
||||
|
||||
const Header: React.FC<Props> = ({ breadcrumbs, left, right, setToggleSidebar, noHeader }) => (
|
||||
<div
|
||||
className={`relative flex w-full flex-shrink-0 flex-row z-10 items-center justify-between gap-x-2 gap-y-4 border-b border-custom-border-200 bg-custom-sidebar-background-100 px-5 py-4 ${
|
||||
noHeader ? "md:hidden" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-grow w-full whitespace-nowrap overflow-ellipsis">
|
||||
<div className="block md:hidden">
|
||||
<button
|
||||
type="button"
|
||||
className="grid h-8 w-8 place-items-center rounded border border-custom-border-200"
|
||||
onClick={() => setToggleSidebar((prevData) => !prevData)}
|
||||
>
|
||||
<Bars3Icon className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
{breadcrumbs}
|
||||
<div className="flex-shrink-0">{left}</div>
|
||||
</div>
|
||||
<div className="flex-shrink-0">{right}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default Header;
|
||||
@@ -1,3 +0,0 @@
|
||||
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M13.3704 21.7617C11.349 21.5892 9.64994 20.7655 8.27325 19.2907C6.89653 17.816 6.20817 16.0515 6.20817 13.9974C6.20817 11.8239 6.96342 9.98197 8.47392 8.47148C9.98441 6.96098 11.8264 6.20573 13.9998 6.20573C16.0539 6.20573 17.8184 6.89181 19.2932 8.26397C20.768 9.63613 21.5916 11.3402 21.7642 13.3762L20.0506 12.8449C19.7825 11.3974 19.0895 10.1969 17.9714 9.24335C16.8532 8.28984 15.5294 7.81308 13.9998 7.81308C12.2835 7.81308 10.8237 8.4147 9.62039 9.61795C8.41714 10.8212 7.81552 12.281 7.81552 13.9974C7.81552 15.5157 8.29456 16.8389 9.25262 17.9668C10.2107 19.0946 11.4089 19.793 12.8474 20.0618L13.3704 21.7617ZM13.9998 27.4557C12.1381 27.4557 10.3885 27.1024 8.75109 26.396C7.11366 25.6896 5.68932 24.7308 4.47807 23.5198C3.26682 22.3088 2.30791 20.8847 1.60135 19.2475C0.894785 17.6104 0.541504 15.8611 0.541504 13.9997C0.541504 12.1383 0.894726 10.3887 1.60117 8.75084C2.30762 7.11296 3.26634 5.68825 4.47736 4.47669C5.6884 3.26511 7.11249 2.30594 8.74964 1.59919C10.3868 0.89244 12.136 0.539062 13.9974 0.539062C15.8588 0.539062 17.6085 0.892345 19.2463 1.59891C20.8842 2.30547 22.3089 3.26438 23.5205 4.47563C24.7321 5.68688 25.6912 7.11122 26.398 8.74865C27.1047 10.3861 27.4581 12.1356 27.4581 13.9974C27.4581 14.1735 27.4545 14.3497 27.4472 14.5259C27.4399 14.702 27.4245 14.8782 27.4009 15.0544L25.8508 14.5804V13.9974C25.8508 10.6991 24.7002 7.89934 22.399 5.59816C20.0978 3.297 17.2981 2.14641 13.9998 2.14641C10.7015 2.14641 7.90178 3.297 5.6006 5.59816C3.29944 7.89934 2.14885 10.6991 2.14885 13.9974C2.14885 17.2956 3.29944 20.0954 5.6006 22.3966C7.90178 24.6977 10.7015 25.8483 13.9998 25.8483H14.5828L15.0704 27.3984C14.892 27.422 14.7136 27.4375 14.5351 27.4448C14.3567 27.452 14.1783 27.4557 13.9998 27.4557ZM25.156 27.284L19.1079 21.2223L18.0999 24.2953C18.0033 24.555 17.8256 24.679 17.567 24.6672C17.3084 24.6554 17.1437 24.5196 17.0728 24.2599L14.2831 14.94C14.2214 14.7366 14.265 14.5604 14.4139 14.4115C14.5628 14.2625 14.739 14.219 14.9424 14.2807L24.2759 17.0704C24.5266 17.1412 24.6578 17.306 24.6696 17.5646C24.6814 17.8232 24.562 17.9963 24.3113 18.0838L21.2247 19.1055L27.2864 25.1536C27.3954 25.2592 27.4499 25.3837 27.4499 25.527C27.4499 25.6704 27.3954 25.7965 27.2864 25.9055L25.9079 27.284C25.8023 27.393 25.6778 27.4474 25.5344 27.4474C25.3911 27.4474 25.265 27.393 25.156 27.284Z" fill="#9FBBFF"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.4 KiB |
@@ -1,96 +0,0 @@
|
||||
.empty-node::after {
|
||||
content: attr(data-placeholder);
|
||||
color: rgb(var(--color-text-400));
|
||||
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
top: 15px;
|
||||
margin-left: 1px;
|
||||
}
|
||||
|
||||
.ProseMirror {
|
||||
position: relative;
|
||||
word-wrap: break-word;
|
||||
white-space: pre-wrap;
|
||||
-moz-tab-size: 4;
|
||||
tab-size: 4;
|
||||
-webkit-user-select: text;
|
||||
-moz-user-select: text;
|
||||
-ms-user-select: text;
|
||||
user-select: text;
|
||||
outline: none;
|
||||
cursor: text;
|
||||
line-height: 1.2;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
color: inherit;
|
||||
-moz-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
appearance: textfield;
|
||||
-webkit-appearance: textfield;
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
|
||||
.ProseMirror-icon {
|
||||
display: inline-block;
|
||||
line-height: 0.8;
|
||||
vertical-align: -2px;
|
||||
color: #666;
|
||||
cursor: pointer;
|
||||
margin: 0 3px;
|
||||
padding: 3px 8px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid transparent;
|
||||
transition: background 50ms ease-in-out;
|
||||
-moz-user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.ProseMirror-menu-disabled.ProseMirror-icon {
|
||||
opacity: 0.3;
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.ProseMirror-icon svg {
|
||||
fill: currentColor;
|
||||
height: 1em;
|
||||
}
|
||||
|
||||
.ProseMirror-icon span {
|
||||
vertical-align: text-top;
|
||||
}
|
||||
|
||||
.remirror-editor-wrapper .remirror-editor {
|
||||
min-height: 150px;
|
||||
}
|
||||
|
||||
.issue-comments-section .remirror-editor-wrapper .remirror-editor,
|
||||
.page-block-section .remirror-editor-wrapper .remirror-editor {
|
||||
min-height: 50px;
|
||||
}
|
||||
|
||||
.remirror-section .remirror-editor-wrapper .remirror-editor {
|
||||
min-height: 0 !important;
|
||||
}
|
||||
|
||||
.remirror-editor-wrapper {
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.MuiButtonBase-root {
|
||||
border: none !important;
|
||||
border-radius: 0.25rem !important;
|
||||
padding: 0.25rem !important;
|
||||
}
|
||||
|
||||
.MuiButtonBase-root svg {
|
||||
fill: rgb(var(--color-text-100)) !important;
|
||||
}
|
||||
|
||||
.MuiButtonBase-root.Mui-selected,
|
||||
.MuiButtonBase-root:hover {
|
||||
background-color: rgb(var(--color-background-100)) !important;
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"extends": "tsconfig/nextjs.json",
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
|
||||
"exclude": ["node_modules"],
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"jsx": "preserve"
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
NEXT_PUBLIC_VERCEL_ENV=local
|
||||
@@ -1,12 +0,0 @@
|
||||
// root styles
|
||||
import "styles/globals.css";
|
||||
|
||||
const RootLayout = ({ children }: { children: React.ReactNode }) => (
|
||||
<html lang="en">
|
||||
<body className="antialiased w-100">
|
||||
<main>{children}</main>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
export default RootLayout;
|
||||
@@ -1,9 +0,0 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
|
||||
const nextConfig = {
|
||||
experimental: {
|
||||
appDir: true,
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = nextConfig;
|
||||
@@ -1 +0,0 @@
|
||||
export const init = {};
|
||||
@@ -1,4 +0,0 @@
|
||||
# Deploy the Plane image
|
||||
FROM makeplane/plane
|
||||
|
||||
LABEL maintainer="engineering@plane.so"
|
||||
@@ -38,11 +38,12 @@ services:
|
||||
container_name: planefrontend
|
||||
image: makeplane/plane-frontend:latest
|
||||
restart: always
|
||||
command: /usr/local/bin/start.sh
|
||||
command: /usr/local/bin/start.sh apps/app/server.js app
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
NEXT_PUBLIC_API_BASE_URL: ${NEXT_PUBLIC_API_BASE_URL}
|
||||
NEXT_PUBLIC_DEPLOY_URL: ${NEXT_PUBLIC_DEPLOY_URL}
|
||||
NEXT_PUBLIC_GOOGLE_CLIENTID: "0"
|
||||
NEXT_PUBLIC_GITHUB_APP_NAME: "0"
|
||||
NEXT_PUBLIC_GITHUB_ID: "0"
|
||||
|
||||
+3
-1
@@ -41,12 +41,14 @@ services:
|
||||
dockerfile: ./apps/app/Dockerfile.web
|
||||
args:
|
||||
NEXT_PUBLIC_API_BASE_URL: http://localhost:8000
|
||||
NEXT_PUBLIC_DEPLOY_URL: http://localhost/spaces
|
||||
restart: always
|
||||
command: /usr/local/bin/start.sh
|
||||
command: /usr/local/bin/start.sh apps/app/server.js app
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
NEXT_PUBLIC_API_BASE_URL: ${NEXT_PUBLIC_API_BASE_URL}
|
||||
NEXT_PUBLIC_DEPLOY_URL: ${NEXT_PUBLIC_DEPLOY_URL}
|
||||
NEXT_PUBLIC_GOOGLE_CLIENTID: "0"
|
||||
NEXT_PUBLIC_GITHUB_APP_NAME: "0"
|
||||
NEXT_PUBLIC_GITHUB_ID: "0"
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
build:
|
||||
docker:
|
||||
web: deploy/heroku/Dockerfile
|
||||
@@ -18,6 +18,11 @@ server {
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
location /space/ {
|
||||
proxy_pass http://localhost:4000/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
error_page 500 502 503 504 /50x.html;
|
||||
location = /50x.html {
|
||||
root /usr/share/nginx/html;
|
||||
|
||||
+5
-11
@@ -1,22 +1,16 @@
|
||||
{
|
||||
"name": "plane",
|
||||
"repository": "https://github.com/makeplane/plane.git",
|
||||
"license": "MIT",
|
||||
"license": "AGPL-3.0",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"apps/*",
|
||||
"web/",
|
||||
"space/",
|
||||
"packages/*"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "turbo run build",
|
||||
"dev": "turbo run dev",
|
||||
"start": "turbo run start",
|
||||
"lint": "turbo run lint",
|
||||
"clean": "turbo run clean"
|
||||
},
|
||||
"devDependencies": {
|
||||
"eslint-config-custom": "*",
|
||||
"prettier": "latest",
|
||||
"turbo": "latest"
|
||||
"prettier": "latest"
|
||||
},
|
||||
"packageManager": "yarn@1.22.19"
|
||||
}
|
||||
|
||||
+2
-1
@@ -1,6 +1,7 @@
|
||||
#!/bin/sh
|
||||
FROM=$1
|
||||
TO=$2
|
||||
DIRECTORY=$3
|
||||
|
||||
if [ "${FROM}" = "${TO}" ]; then
|
||||
echo "Nothing to replace, the value is already set to ${TO}."
|
||||
@@ -11,4 +12,4 @@ fi
|
||||
# Only perform action if $FROM and $TO are different.
|
||||
echo "Replacing all statically built instances of $FROM with this string $TO ."
|
||||
|
||||
grep -R -la "${FROM}" apps/app/.next | xargs -I{} sed -i "s|$FROM|$TO|g" "{}"
|
||||
grep -R -la "${FROM}" apps/$DIRECTORY/.next | xargs -I{} sed -i "s|$FROM|$TO|g" "{}"
|
||||
|
||||
@@ -14,3 +14,16 @@ echo -e "SECRET_KEY=\"$(tr -dc 'a-z0-9' < /dev/urandom | head -c50)\"" >> ./.en
|
||||
|
||||
# WEB_URL for email redirection and image saving
|
||||
echo -e "WEB_URL=$1" >> ./.env
|
||||
|
||||
# Generate Prompt for taking tiptap auth key
|
||||
echo -e "\n\e[1;38m Instructions for generating TipTap Pro Extensions Auth Token \e[0m \n"
|
||||
|
||||
echo -e "\e[1;38m 1. Head over to TipTap cloud's Pro Extensions Page, https://collab.tiptap.dev/pro-extensions \e[0m"
|
||||
echo -e "\e[1;38m 2. Copy the token given to you under the first paragraph, after 'Here it is' \e[0m \n"
|
||||
|
||||
read -p $'\e[1;32m Please Enter Your TipTap Pro Extensions Authentication Token: \e[0m \e[1;36m' authToken
|
||||
|
||||
|
||||
echo "@tiptap-pro:registry=https://registry.tiptap.dev/
|
||||
//registry.tiptap.dev/:_authToken=${authToken}" > .npmrc
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
NEXT_PUBLIC_API_BASE_URL=''
|
||||
@@ -0,0 +1,70 @@
|
||||
FROM node:18-alpine AS builder
|
||||
RUN apk add --no-cache libc6-compat
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
ENV NEXT_PUBLIC_API_BASE_URL=http://NEXT_PUBLIC_API_BASE_URL_PLACEHOLDER
|
||||
|
||||
RUN yarn global add turbo
|
||||
COPY . .
|
||||
|
||||
RUN turbo prune --scope=space --docker
|
||||
|
||||
# Add lockfile and package.json's of isolated subworkspace
|
||||
FROM node:18-alpine AS installer
|
||||
|
||||
RUN apk add --no-cache libc6-compat
|
||||
WORKDIR /app
|
||||
ARG NEXT_PUBLIC_API_BASE_URL=http://localhost:8000
|
||||
|
||||
# First install the dependencies (as they change less often)
|
||||
COPY .gitignore .gitignore
|
||||
COPY --from=builder /app/out/json/ .
|
||||
COPY --from=builder /app/out/yarn.lock ./yarn.lock
|
||||
RUN yarn install --network-timeout 500000
|
||||
|
||||
# Build the project
|
||||
COPY --from=builder /app/out/full/ .
|
||||
COPY turbo.json turbo.json
|
||||
COPY replace-env-vars.sh /usr/local/bin/
|
||||
USER root
|
||||
RUN chmod +x /usr/local/bin/replace-env-vars.sh
|
||||
|
||||
RUN yarn turbo run build --filter=space
|
||||
|
||||
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL \
|
||||
BUILT_NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
|
||||
|
||||
RUN /usr/local/bin/replace-env-vars.sh http://NEXT_PUBLIC_WEBAPP_URL_PLACEHOLDER ${NEXT_PUBLIC_API_BASE_URL} space
|
||||
|
||||
FROM node:18-alpine AS runner
|
||||
WORKDIR /app
|
||||
|
||||
# Don't run production as root
|
||||
RUN addgroup --system --gid 1001 plane
|
||||
RUN adduser --system --uid 1001 captain
|
||||
USER captain
|
||||
|
||||
COPY --from=installer /app/apps/space/next.config.js .
|
||||
COPY --from=installer /app/apps/space/package.json .
|
||||
|
||||
# Automatically leverage output traces to reduce image size
|
||||
# https://nextjs.org/docs/advanced-features/output-file-tracing
|
||||
COPY --from=installer --chown=captain:plane /app/apps/space/.next/standalone ./
|
||||
|
||||
COPY --from=installer --chown=captain:plane /app/apps/space/.next ./apps/space/.next
|
||||
|
||||
ARG NEXT_PUBLIC_API_BASE_URL=http://localhost:8000
|
||||
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL \
|
||||
BUILT_NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
|
||||
|
||||
USER root
|
||||
COPY replace-env-vars.sh /usr/local/bin/
|
||||
COPY start.sh /usr/local/bin/
|
||||
RUN chmod +x /usr/local/bin/replace-env-vars.sh
|
||||
RUN chmod +x /usr/local/bin/start.sh
|
||||
|
||||
USER captain
|
||||
|
||||
ENV NEXT_TELEMETRY_DISABLED 1
|
||||
|
||||
EXPOSE 3000
|
||||
@@ -0,0 +1,56 @@
|
||||
FROM node:18-alpine AS base
|
||||
|
||||
# Install dependencies only when needed
|
||||
FROM base AS deps
|
||||
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
|
||||
RUN apk add --no-cache libc6-compat
|
||||
WORKDIR /space
|
||||
|
||||
# Install dependencies based on the preferred package manager
|
||||
COPY package.json yarn.lock* ./
|
||||
RUN yarn
|
||||
|
||||
|
||||
# Rebuild the source code only when needed
|
||||
FROM base AS builder
|
||||
WORKDIR /space
|
||||
|
||||
COPY . .
|
||||
|
||||
COPY --from=deps /space/node_modules ./node_modules
|
||||
COPY --from=deps /space/yarn.lock .
|
||||
|
||||
ENV NEXT_TELEMETRY_DISABLED 1
|
||||
|
||||
RUN yarn build
|
||||
|
||||
# If using npm comment out above and use below instead
|
||||
# RUN npm run build
|
||||
|
||||
# Production image, copy all the files and run next
|
||||
FROM base AS runner
|
||||
WORKDIR /space
|
||||
|
||||
ENV NODE_ENV production
|
||||
# Uncomment the following line in case you want to disable telemetry during runtime.
|
||||
# ENV NEXT_TELEMETRY_DISABLED 1
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
|
||||
COPY --from=builder /space/public ./public
|
||||
|
||||
# Automatically leverage output traces to reduce image size
|
||||
# https://nextjs.org/docs/advanced-features/output-file-tracing
|
||||
COPY --from=builder --chown=nextjs:nodejs /space/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /space/.next/static ./.next/static
|
||||
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENV PORT 3000
|
||||
# set hostname to localhost
|
||||
ENV HOSTNAME "0.0.0.0"
|
||||
|
||||
CMD ["node", "space/server.js"]
|
||||
@@ -0,0 +1,66 @@
|
||||
// next imports
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { Metadata, ResolvingMetadata } from "next";
|
||||
// components
|
||||
import IssueNavbar from "components/issues/navbar";
|
||||
import IssueFilter from "components/issues/filters-render";
|
||||
// service
|
||||
import ProjectService from "services/project.service";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
type LayoutProps = {
|
||||
params: { workspace_slug: string; project_slug: string };
|
||||
};
|
||||
|
||||
export async function generateMetadata({ params }: LayoutProps): Promise<Metadata> {
|
||||
// read route params
|
||||
const { workspace_slug, project_slug } = params;
|
||||
const projectServiceInstance = new ProjectService();
|
||||
|
||||
try {
|
||||
const project = await projectServiceInstance?.getProjectSettingsAsync(workspace_slug, project_slug);
|
||||
|
||||
return {
|
||||
title: `${project?.project_details?.name} | ${workspace_slug}`,
|
||||
description: `${
|
||||
project?.project_details?.description || `${project?.project_details?.name} | ${workspace_slug}`
|
||||
}`,
|
||||
icons: `data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>${
|
||||
typeof project?.project_details?.emoji != "object"
|
||||
? String.fromCodePoint(parseInt(project?.project_details?.emoji))
|
||||
: "✈️"
|
||||
}</text></svg>`,
|
||||
};
|
||||
} catch (error: any) {
|
||||
if (error?.data?.error) {
|
||||
redirect(`/project-not-published`);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
const RootLayout = ({ children }: { children: React.ReactNode }) => (
|
||||
<div className="relative w-screen min-h-[500px] h-screen overflow-hidden flex flex-col">
|
||||
<div className="flex-shrink-0 h-[60px] border-b border-gray-300 relative flex items-center bg-white select-none">
|
||||
<IssueNavbar />
|
||||
</div>
|
||||
{/* <div className="flex-shrink-0 min-h-[50px] h-auto py-1.5 border-b border-gray-300 relative flex items-center shadow-md bg-white select-none">
|
||||
<IssueFilter />
|
||||
</div> */}
|
||||
<div className="w-full h-full relative bg-gray-100/50 overflow-hidden">{children}</div>
|
||||
|
||||
<div className="absolute z-[99999] bottom-[10px] right-[10px] bg-white rounded-sm shadow-lg border border-gray-100">
|
||||
<Link href="https://plane.so" className="p-1 px-2 flex items-center gap-1" target="_blank">
|
||||
<div className="w-[24px] h-[24px] relative flex justify-center items-center">
|
||||
<Image src="/plane-logo.webp" alt="plane logo" className="w-[24px] h-[24px]" height="24" width="24" />
|
||||
</div>
|
||||
<div className="text-xs">
|
||||
Powered by <b>Plane Deploy</b>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default RootLayout;
|
||||
@@ -0,0 +1,113 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
// next imports
|
||||
import { useRouter, useParams, useSearchParams } from "next/navigation";
|
||||
// mobx
|
||||
import { observer } from "mobx-react-lite";
|
||||
// components
|
||||
import { IssueListView } from "components/issues/board-views/list";
|
||||
import { IssueKanbanView } from "components/issues/board-views/kanban";
|
||||
import { IssueCalendarView } from "components/issues/board-views/calendar";
|
||||
import { IssueSpreadsheetView } from "components/issues/board-views/spreadsheet";
|
||||
import { IssueGanttView } from "components/issues/board-views/gantt";
|
||||
// mobx store
|
||||
import { RootStore } from "store/root";
|
||||
import { useMobxStore } from "lib/mobx/store-provider";
|
||||
// types
|
||||
import { TIssueBoardKeys } from "store/types";
|
||||
|
||||
const WorkspaceProjectPage = observer(() => {
|
||||
const store: RootStore = useMobxStore();
|
||||
|
||||
const router = useRouter();
|
||||
const routerParams = useParams();
|
||||
const routerSearchparams = useSearchParams();
|
||||
|
||||
const { workspace_slug, project_slug } = routerParams as { workspace_slug: string; project_slug: string };
|
||||
const board =
|
||||
routerSearchparams &&
|
||||
routerSearchparams.get("board") != null &&
|
||||
(routerSearchparams.get("board") as TIssueBoardKeys | "");
|
||||
|
||||
// updating default board view when we are in the issues page
|
||||
useEffect(() => {
|
||||
if (workspace_slug && project_slug && store?.project?.workspaceProjectSettings) {
|
||||
const workspacePRojectSettingViews = store?.project?.workspaceProjectSettings?.views;
|
||||
const userAccessViews: TIssueBoardKeys[] = [];
|
||||
|
||||
Object.keys(workspacePRojectSettingViews).filter((_key) => {
|
||||
if (_key === "list" && workspacePRojectSettingViews.list === true) userAccessViews.push(_key);
|
||||
if (_key === "kanban" && workspacePRojectSettingViews.kanban === true) userAccessViews.push(_key);
|
||||
if (_key === "calendar" && workspacePRojectSettingViews.calendar === true) userAccessViews.push(_key);
|
||||
if (_key === "spreadsheet" && workspacePRojectSettingViews.spreadsheet === true) userAccessViews.push(_key);
|
||||
if (_key === "gantt" && workspacePRojectSettingViews.gantt === true) userAccessViews.push(_key);
|
||||
});
|
||||
|
||||
if (userAccessViews && userAccessViews.length > 0) {
|
||||
if (!board) {
|
||||
store.issue.setCurrentIssueBoardView(userAccessViews[0]);
|
||||
router.replace(`/${workspace_slug}/${project_slug}?board=${userAccessViews[0]}`);
|
||||
} else {
|
||||
if (userAccessViews.includes(board)) {
|
||||
if (store.issue.currentIssueBoardView === null) store.issue.setCurrentIssueBoardView(board);
|
||||
else {
|
||||
if (board === store.issue.currentIssueBoardView)
|
||||
router.replace(`/${workspace_slug}/${project_slug}?board=${board}`);
|
||||
else {
|
||||
store.issue.setCurrentIssueBoardView(board);
|
||||
router.replace(`/${workspace_slug}/${project_slug}?board=${board}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
store.issue.setCurrentIssueBoardView(userAccessViews[0]);
|
||||
router.replace(`/${workspace_slug}/${project_slug}?board=${userAccessViews[0]}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [workspace_slug, project_slug, board, router, store?.issue, store?.project?.workspaceProjectSettings]);
|
||||
|
||||
useEffect(() => {
|
||||
if (workspace_slug && project_slug) {
|
||||
store?.project?.getProjectSettingsAsync(workspace_slug, project_slug);
|
||||
store?.issue?.getIssuesAsync(workspace_slug, project_slug);
|
||||
}
|
||||
}, [workspace_slug, project_slug, store?.project, store?.issue]);
|
||||
|
||||
return (
|
||||
<div className="relative w-full h-full overflow-hidden">
|
||||
{store?.issue?.loader && !store.issue.issues ? (
|
||||
<div className="text-sm text-center py-10 text-gray-500">Loading...</div>
|
||||
) : (
|
||||
<>
|
||||
{store?.issue?.error ? (
|
||||
<div className="text-sm text-center py-10 text-gray-500">Something went wrong.</div>
|
||||
) : (
|
||||
store?.issue?.currentIssueBoardView && (
|
||||
<>
|
||||
{store?.issue?.currentIssueBoardView === "list" && (
|
||||
<div className="relative w-full h-full overflow-y-auto">
|
||||
<div className="container mx-auto px-5 py-3">
|
||||
<IssueListView />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{store?.issue?.currentIssueBoardView === "kanban" && (
|
||||
<div className="relative w-full h-full mx-auto px-5">
|
||||
<IssueKanbanView />
|
||||
</div>
|
||||
)}
|
||||
{store?.issue?.currentIssueBoardView === "calendar" && <IssueCalendarView />}
|
||||
{store?.issue?.currentIssueBoardView === "spreadsheet" && <IssueSpreadsheetView />}
|
||||
{store?.issue?.currentIssueBoardView === "gantt" && <IssueGanttView />}
|
||||
</>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default WorkspaceProjectPage;
|
||||
+2
-4
@@ -1,9 +1,7 @@
|
||||
import React from "react";
|
||||
"use client";
|
||||
|
||||
const WorkspaceProjectPage = () => (
|
||||
<div className="relative w-screen h-screen flex justify-center items-center text-5xl">
|
||||
Plane Workspace project Space
|
||||
</div>
|
||||
<div className="relative w-screen h-screen flex justify-center items-center text-5xl">Plane Workspace Space</div>
|
||||
);
|
||||
|
||||
export default WorkspaceProjectPage;
|
||||
@@ -0,0 +1,20 @@
|
||||
"use client";
|
||||
|
||||
// root styles
|
||||
import "styles/globals.css";
|
||||
// mobx store provider
|
||||
import { MobxStoreProvider } from "lib/mobx/store-provider";
|
||||
import MobxStoreInit from "lib/mobx/store-init";
|
||||
|
||||
const RootLayout = ({ children }: { children: React.ReactNode }) => (
|
||||
<html lang="en">
|
||||
<body className="antialiased w-100">
|
||||
<MobxStoreProvider>
|
||||
<MobxStoreInit />
|
||||
<main>{children}</main>
|
||||
</MobxStoreProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
export default RootLayout;
|
||||
@@ -1,7 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
|
||||
const HomePage = () => (
|
||||
<div className="relative w-screen h-screen flex justify-center items-center text-5xl">Plane Space</div>
|
||||
<div className="relative w-screen h-screen flex justify-center items-center text-5xl">Plane Deploy</div>
|
||||
);
|
||||
|
||||
export default HomePage;
|
||||
@@ -0,0 +1,31 @@
|
||||
// next imports
|
||||
import Image from "next/image";
|
||||
|
||||
const CustomProjectNotPublishedError = () => (
|
||||
<div className="relative w-screen min-h-screen h-full flex justify-center items-center py-5">
|
||||
<div className="max-w-[700px] space-y-5">
|
||||
<div className="flex items-center flex-col gap-3 text-center">
|
||||
<div className="relative w-[240px] h-[240px]">
|
||||
<Image src={`/project-not-published.svg`} layout="fill" alt="404- Page not found" />
|
||||
</div>
|
||||
<div className="text-xl font-medium">
|
||||
Oops! The page you{`'`}re looking for isn{`'`}t live at the moment.
|
||||
</div>
|
||||
<div className="text-sm text-custom-text-200">
|
||||
If this is your project, login to your workspace to adjust its visibility settings and make it public.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center flex justify-center items-center">
|
||||
<a
|
||||
href={`https://app.plane.so/`}
|
||||
className="transition-all border border-gray-200 bg-gray-50 hover:bg-gray-100 text-gray-700 hover:text-gray-800 cursor-pointer p-1.5 px-2.5 rounded-sm text-sm font-medium hover:scale-105 select-none"
|
||||
>
|
||||
Go to your Workspace
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default CustomProjectNotPublishedError;
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from "./issue-group/backlog-state-icon";
|
||||
export * from "./issue-group/unstarted-state-icon";
|
||||
export * from "./issue-group/started-state-icon";
|
||||
export * from "./issue-group/completed-state-icon";
|
||||
export * from "./issue-group/cancelled-state-icon";
|
||||
@@ -0,0 +1,23 @@
|
||||
import React from "react";
|
||||
// types
|
||||
import type { Props } from "../types";
|
||||
// constants
|
||||
import { issueGroupColors } from "constants/data";
|
||||
|
||||
export const BacklogStateIcon: React.FC<Props> = ({
|
||||
width = "14",
|
||||
height = "14",
|
||||
className,
|
||||
color = issueGroupColors["backlog"],
|
||||
}) => (
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
className={className}
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<circle cx="10" cy="10" r="9" stroke={color} strokeLinecap="round" strokeDasharray="4 4" />
|
||||
</svg>
|
||||
);
|
||||
@@ -0,0 +1,74 @@
|
||||
import React from "react";
|
||||
// types
|
||||
import type { Props } from "../types";
|
||||
// constants
|
||||
import { issueGroupColors } from "constants/data";
|
||||
|
||||
export const CancelledStateIcon: React.FC<Props> = ({
|
||||
width = "14",
|
||||
height = "14",
|
||||
className,
|
||||
color = issueGroupColors["cancelled"],
|
||||
}) => (
|
||||
<svg width={width} height={height} className={className} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 84.36 84.36">
|
||||
<g id="Layer_2" data-name="Layer 2">
|
||||
<g id="Layer_1-2" data-name="Layer 1">
|
||||
<path
|
||||
className="cls-1"
|
||||
fill="none"
|
||||
strokeWidth={3}
|
||||
stroke={color}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M20.45,7.69a39.74,39.74,0,0,1,43.43.54"
|
||||
/>
|
||||
<path
|
||||
className="cls-1"
|
||||
fill="none"
|
||||
strokeWidth={3}
|
||||
stroke={color}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M76.67,20.45a39.76,39.76,0,0,1-.53,43.43"
|
||||
/>
|
||||
<path
|
||||
className="cls-1"
|
||||
fill="none"
|
||||
strokeWidth={3}
|
||||
stroke={color}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M63.92,76.67a39.78,39.78,0,0,1-43.44-.53"
|
||||
/>
|
||||
<path
|
||||
className="cls-1"
|
||||
fill="none"
|
||||
strokeWidth={3}
|
||||
stroke={color}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M7.69,63.92a39.75,39.75,0,0,1,.54-43.44"
|
||||
/>
|
||||
<circle className="cls-2" fill={color} cx="42.18" cy="42.18" r="31.04" />
|
||||
<path
|
||||
className="cls-3"
|
||||
fill="none"
|
||||
strokeWidth={3}
|
||||
stroke="#ffffff"
|
||||
strokeLinecap="square"
|
||||
strokeMiterlimit={10}
|
||||
d="M32.64,32.44q9.54,9.75,19.09,19.48"
|
||||
/>
|
||||
<path
|
||||
className="cls-3"
|
||||
fill="none"
|
||||
strokeWidth={3}
|
||||
stroke="#ffffff"
|
||||
strokeLinecap="square"
|
||||
strokeMiterlimit={10}
|
||||
d="M32.64,51.92,51.73,32.44"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
@@ -0,0 +1,65 @@
|
||||
import React from "react";
|
||||
// types
|
||||
import type { Props } from "../types";
|
||||
// constants
|
||||
import { issueGroupColors } from "constants/data";
|
||||
|
||||
export const CompletedStateIcon: React.FC<Props> = ({
|
||||
width = "14",
|
||||
height = "14",
|
||||
className,
|
||||
color = issueGroupColors["completed"],
|
||||
}) => (
|
||||
<svg width={width} height={height} className={className} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 84.36 84.36">
|
||||
<g id="Layer_2" data-name="Layer 2">
|
||||
<g id="Layer_1-2" data-name="Layer 1">
|
||||
<path
|
||||
className="cls-1"
|
||||
fill="none"
|
||||
strokeWidth={3}
|
||||
stroke={color}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M20.45,7.69a39.74,39.74,0,0,1,43.43.54"
|
||||
/>
|
||||
<path
|
||||
className="cls-1"
|
||||
fill="none"
|
||||
strokeWidth={3}
|
||||
stroke={color}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M76.67,20.45a39.76,39.76,0,0,1-.53,43.43"
|
||||
/>
|
||||
<path
|
||||
className="cls-1"
|
||||
fill="none"
|
||||
strokeWidth={3}
|
||||
stroke={color}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M63.92,76.67a39.78,39.78,0,0,1-43.44-.53"
|
||||
/>
|
||||
<path
|
||||
className="cls-1"
|
||||
fill="none"
|
||||
strokeWidth={3}
|
||||
stroke={color}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M7.69,63.92a39.75,39.75,0,0,1,.54-43.44"
|
||||
/>
|
||||
<circle className="cls-2" fill={color} cx="42.18" cy="42.18" r="31.04" />
|
||||
<path
|
||||
className="cls-3"
|
||||
fill="none"
|
||||
strokeWidth={3}
|
||||
stroke="#ffffff"
|
||||
strokeLinecap="square"
|
||||
strokeMiterlimit={10}
|
||||
d="M30.45,43.75l6.61,6.61L53.92,34"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
@@ -0,0 +1,73 @@
|
||||
import React from "react";
|
||||
// types
|
||||
import type { Props } from "../types";
|
||||
// constants
|
||||
import { issueGroupColors } from "constants/data";
|
||||
|
||||
export const StartedStateIcon: React.FC<Props> = ({
|
||||
width = "14",
|
||||
height = "14",
|
||||
className,
|
||||
color = issueGroupColors["started"],
|
||||
}) => (
|
||||
<svg width={width} height={height} className={className} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 83.36 83.36">
|
||||
<g id="Layer_2" data-name="Layer 2">
|
||||
<g id="Layer_1-2" data-name="Layer 1">
|
||||
<path
|
||||
className="cls-1"
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={3}
|
||||
d="M20,7.19a39.74,39.74,0,0,1,43.43.54"
|
||||
/>
|
||||
<path
|
||||
className="cls-1"
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={3}
|
||||
d="M76.17,20a39.76,39.76,0,0,1-.53,43.43"
|
||||
/>
|
||||
<path
|
||||
className="cls-1"
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={3}
|
||||
d="M63.42,76.17A39.78,39.78,0,0,1,20,75.64"
|
||||
/>
|
||||
<path
|
||||
className="cls-1"
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={3}
|
||||
d="M7.19,63.42A39.75,39.75,0,0,1,7.73,20"
|
||||
/>
|
||||
<path
|
||||
className="cls-2"
|
||||
fill={color}
|
||||
stroke={color}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={3}
|
||||
d="M42.32,41.21q9.57-14.45,19.13-28.9a35.8,35.8,0,0,0-39.09,0Z"
|
||||
/>
|
||||
<path
|
||||
className="cls-2"
|
||||
fill={color}
|
||||
stroke={color}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={3}
|
||||
d="M42.32,41.7,61.45,70.6a35.75,35.75,0,0,1-39.09,0Z"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
@@ -0,0 +1,55 @@
|
||||
import React from "react";
|
||||
// types
|
||||
import type { Props } from "../types";
|
||||
// constants
|
||||
import { issueGroupColors } from "constants/data";
|
||||
|
||||
export const UnstartedStateIcon: React.FC<Props> = ({
|
||||
width = "14",
|
||||
height = "14",
|
||||
className,
|
||||
color = issueGroupColors["unstarted"],
|
||||
}) => (
|
||||
<svg width={width} height={height} className={className} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 84.36 84.36">
|
||||
<g id="Layer_2" data-name="Layer 2">
|
||||
<g id="Layer_1-2" data-name="Layer 1">
|
||||
<path
|
||||
className="cls-1"
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={3}
|
||||
d="M20.45,7.69a39.74,39.74,0,0,1,43.43.54"
|
||||
/>
|
||||
<path
|
||||
className="cls-1"
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={3}
|
||||
d="M76.67,20.45a39.76,39.76,0,0,1-.53,43.43"
|
||||
/>
|
||||
<path
|
||||
className="cls-1"
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={3}
|
||||
d="M63.92,76.67a39.78,39.78,0,0,1-43.44-.53"
|
||||
/>
|
||||
<path
|
||||
className="cls-1"
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={3}
|
||||
d="M7.69,63.92a39.75,39.75,0,0,1,.54-43.44"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user