From 7e334203f115e23d317aebf370791289ccc65986 Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal <65252264+aaryan610@users.noreply.github.com> Date: Fri, 11 Oct 2024 20:13:38 +0530 Subject: [PATCH] [WEB-310] dev: private bucket implementation (#5793) * chore: migrations and backmigration to move attachments to file asset * chore: move attachments to file assets * chore: update migration file to include created by and updated by and size * chore: remove uninmport errors * chore: make size as float field * fix: file asset uploads * chore: asset uploads migration changes * chore: v2 assets endpoint * chore: remove unused imports * chore: issue attachments * chore: issue attachments * chore: workspace logo endpoints * chore: private bucket changes * chore: user asset endpoint * chore: add logo_url validation * chore: cover image urlk * chore: change asset max length * chore: pages endpoint * chore: store the storage_metadata only when none * chore: attachment asset apis * chore: update create private bucket * chore: make bucket private * chore: fix response of user uploads * fix: response of user uploads * fix: job to fix file asset uploads * fix: user asset endpoints * chore: avatar for user profile * chore: external apis user url endpoint * chore: upload workspace and user asset actions updated * chore: analytics endpoint * fix: analytics export * chore: avatar urls * chore: update user avatar instances * chore: avatar urls for assignees and creators * chore: bucket permission script * fix: all user avatr instances in the web app * chore: update project cover image logic * fix: issue attachment endpoint * chore: patch endpoint for issue attachment * chore: attachments * chore: change attachment storage class * chore: update issue attachment endpoints * fix: issue attachment * chore: update issue attachment implementation * chore: page asset endpoints * fix: web build errors * chore: attachments * chore: page asset urls * chore: comment and issue asset endpoints * chore: asset endpoints * chore: attachment endpoints * chore: bulk asset endpoint * chore: restore endpoint * chore: project assets endpoints * chore: asset url * chore: add delete asset endpoints * chore: fix asset upload endpoint * chore: update patch endpoints * chore: update patch endpoint * chore: update editor image handling * chore: asset restore endpoints * chore: avatar url for space assets * chore: space app assets migration * fix: space app urls * chore: space endpoints * fix: old editor images rendering logic * fix: issue archive and attachment activity * chore: asset deletes * chore: attachment delete * fix: issue attachment * fix: issue attachment get * chore: cover image url for projects * chore: remove duplicate py file * fix: url check function * chore: chore project cover asset delete * fix: migrations * chore: delete migration files * chore: update bucket * fix: build errors * chore: add asset url in intake attachment * chore: project cover fix * chore: update next.config * chore: delete old workspace logos * chore: workspace assets * chore: asset get for space * chore: update project modal * chore: remove unused imports * fix: space app editor helper * chore: update rich-text read-only editor * chore: create multiple column for entity identifiers * chore: update migrations * chore: remove entity identifier * fix: issue assets * chore: update maximum file size logic * chore: update editor max file size logic * fix: close modal after removing workspace logo * chore: update uploaded asstes' status post issue creation * chore: added file size limit to the space app * dev: add file size limit restriction on all endpoints * fix: remove old workspace logo and user avatar --------- Co-authored-by: pablohashescobar --- .../admin-sidebar/sidebar-dropdown.tsx | 10 +- admin/helpers/file.helper.ts | 15 + admin/helpers/string.helper.ts | 21 + apiserver/plane/api/serializers/__init__.py | 1 - apiserver/plane/api/serializers/issue.py | 7 +- apiserver/plane/api/serializers/project.py | 5 + apiserver/plane/api/serializers/user.py | 1 + apiserver/plane/api/views/cycle.py | 63 +- apiserver/plane/api/views/issue.py | 17 +- apiserver/plane/api/views/module.py | 7 +- apiserver/plane/app/serializers/base.py | 103 ++- apiserver/plane/app/serializers/issue.py | 12 +- apiserver/plane/app/serializers/project.py | 3 + apiserver/plane/app/serializers/user.py | 5 + apiserver/plane/app/serializers/workspace.py | 2 + apiserver/plane/app/urls/asset.py | 52 ++ apiserver/plane/app/urls/issue.py | 13 + apiserver/plane/app/views/__init__.py | 16 +- apiserver/plane/app/views/analytic/base.py | 69 +- apiserver/plane/app/views/asset/v2.py | 691 ++++++++++++++++++ apiserver/plane/app/views/cycle/archive.py | 32 +- apiserver/plane/app/views/cycle/base.py | 103 ++- apiserver/plane/app/views/cycle/issue.py | 7 +- apiserver/plane/app/views/dashboard/base.py | 24 +- apiserver/plane/app/views/inbox/base.py | 7 +- apiserver/plane/app/views/issue/archive.py | 13 +- apiserver/plane/app/views/issue/attachment.py | 196 ++++- apiserver/plane/app/views/issue/base.py | 26 +- apiserver/plane/app/views/issue/relation.py | 7 +- apiserver/plane/app/views/issue/sub_issue.py | 7 +- apiserver/plane/app/views/module/archive.py | 55 +- apiserver/plane/app/views/module/base.py | 51 +- apiserver/plane/app/views/module/issue.py | 7 +- apiserver/plane/app/views/page/base.py | 5 +- apiserver/plane/app/views/project/base.py | 27 +- apiserver/plane/app/views/view/base.py | 7 +- apiserver/plane/app/views/workspace/user.py | 11 +- .../plane/bgtasks/analytic_plot_export.py | 2 +- .../plane/bgtasks/email_notification_task.py | 6 +- apiserver/plane/bgtasks/file_asset_task.py | 25 +- apiserver/plane/celery.py | 2 +- .../db/management/commands/create_bucket.py | 71 +- .../db/management/commands/update_bucket.py | 209 ++++++ ..._comment_fileasset_entity_type_and_more.py | 179 +++++ .../db/migrations/0079_auto_20241009_0619.py | 64 ++ apiserver/plane/db/models/__init__.py | 1 - apiserver/plane/db/models/asset.py | 81 +- apiserver/plane/db/models/integration/base.py | 2 +- apiserver/plane/db/models/project.py | 21 +- apiserver/plane/db/models/user.py | 45 +- apiserver/plane/db/models/workspace.py | 20 +- .../plane/license/api/serializers/admin.py | 1 + apiserver/plane/settings/common.py | 61 +- apiserver/plane/settings/storage.py | 154 ++++ apiserver/plane/space/serializer/issue.py | 4 +- apiserver/plane/space/serializer/user.py | 1 + apiserver/plane/space/urls/__init__.py | 2 + apiserver/plane/space/urls/asset.py | 32 + apiserver/plane/space/utils/grouper.py | 58 +- apiserver/plane/space/views/__init__.py | 6 + apiserver/plane/space/views/asset.py | 280 +++++++ apiserver/plane/space/views/inbox.py | 7 +- apiserver/plane/space/views/issue.py | 49 +- .../collaborative-read-only-editor.tsx | 2 + .../editors/document/read-only-editor.tsx | 5 +- .../editors/read-only-editor-wrapper.tsx | 4 +- .../custom-image/components/image-block.tsx | 6 +- .../custom-image/components/image-node.tsx | 3 + .../components/image-uploader.tsx | 45 +- .../extensions/custom-image/custom-image.ts | 22 +- .../custom-image/read-only-custom-image.ts | 15 +- .../editor/src/core/extensions/extensions.tsx | 230 +++--- .../src/core/extensions/image/extension.tsx | 24 +- .../core/extensions/image/read-only-image.tsx | 45 +- .../core/extensions/read-only-extensions.tsx | 181 ++--- packages/editor/src/core/hooks/use-editor.ts | 7 +- .../editor/src/core/hooks/use-file-upload.ts | 82 ++- .../use-read-only-collaborative-editor.ts | 2 + .../src/core/hooks/use-read-only-editor.ts | 9 +- .../src/core/plugins/image/delete-image.ts | 5 +- .../src/core/plugins/image/restore-image.ts | 5 +- .../core/plugins/image/utils/validate-file.ts | 25 +- .../editor/src/core/types/collaboration.ts | 1 + packages/editor/src/core/types/config.ts | 8 + packages/editor/src/core/types/editor.ts | 1 + packages/editor/src/core/types/image.ts | 4 +- packages/types/src/analytics.d.ts | 6 +- packages/types/src/current-user/accounts.d.ts | 17 - packages/types/src/current-user/index.ts | 2 - packages/types/src/current-user/user.d.ts | 30 - packages/types/src/cycle/cycle.d.ts | 2 +- packages/types/src/enums.ts | 11 + packages/types/src/file.d.ts | 32 + packages/types/src/index.d.ts | 1 + packages/types/src/integration.d.ts | 1 - packages/types/src/issues/activity/base.d.ts | 2 +- packages/types/src/issues/issue.d.ts | 2 +- .../types/src/issues/issue_attachment.d.ts | 11 +- packages/types/src/module/modules.d.ts | 2 +- packages/types/src/project/projects.d.ts | 4 +- packages/types/src/users.d.ts | 36 +- packages/types/src/workspace.d.ts | 5 +- .../components/editor/lite-text-editor.tsx | 20 +- .../editor/lite-text-read-only-editor.tsx | 10 +- .../editor/rich-text-read-only-editor.tsx | 10 +- .../components/issues/navbar/user-avatar.tsx | 3 +- .../peek-overview/comment/add-comment.tsx | 26 +- .../comment/comment-detail-card.tsx | 22 +- .../issues/peek-overview/issue-details.tsx | 1 + space/core/constants/common.ts | 1 + space/core/services/api.service.ts | 6 +- space/core/services/file-upload.service.ts | 33 + space/core/services/file.service.ts | 164 ++--- space/core/services/issue.service.ts | 4 +- space/core/store/issue-detail.store.ts | 29 +- space/core/store/user.store.ts | 2 +- space/core/types/issue.d.ts | 2 +- space/helpers/editor.helper.ts | 83 +++ space/helpers/file.helper.ts | 52 ++ space/helpers/string.helper.ts | 22 + web/app/profile/page.tsx | 80 +- web/app/profile/sidebar.tsx | 9 +- .../pages/editor/ai/ask-pi-menu.tsx | 6 +- web/ce/components/pages/editor/ai/menu.tsx | 10 +- web/ce/components/projects/create/root.tsx | 16 +- web/ce/hooks/use-file-size.ts | 17 + .../custom-analytics/graph/index.tsx | 5 +- .../scope-and-demand/leaderboard.tsx | 11 +- .../scope-and-demand/scope-and-demand.tsx | 4 +- .../analytics/scope-and-demand/scope.tsx | 13 +- .../actions/issue-actions/change-assignee.tsx | 11 +- .../common/applied-filters/members.tsx | 11 +- .../components/common/filters/created-by.tsx | 11 +- .../components/core/image-picker-popover.tsx | 72 +- .../core/modals/gpt-assistant-popover.tsx | 22 +- .../core/modals/user-image-upload-modal.tsx | 94 +-- .../modals/workspace-image-upload-modal.tsx | 104 +-- .../cycles/active-cycle/cycle-stats.tsx | 9 +- .../components/cycles/active-cycle/header.tsx | 79 -- .../components/cycles/active-cycle/index.ts | 6 +- .../components/cycles/active-cycle/stats.tsx | 144 ---- .../upcoming-cycles-list-item.tsx | 137 ---- .../active-cycle/upcoming-cycles-list.tsx | 64 -- .../analytics-sidebar/progress-stats.tsx | 9 +- .../cycles/analytics-sidebar/root.tsx | 2 +- .../analytics-sidebar/sidebar-details.tsx | 12 +- .../cycles/board/cycles-board-card.tsx | 262 ------- .../cycles/board/cycles-board-map.tsx | 25 - web/core/components/cycles/board/index.ts | 3 - web/core/components/cycles/board/root.tsx | 62 -- web/core/components/cycles/index.ts | 1 - .../cycles/list/cycle-list-item-action.tsx | 7 +- .../widgets/issue-panels/issue-list-item.tsx | 25 +- .../dashboard/widgets/recent-activity.tsx | 5 +- .../collaborators-list.tsx | 4 +- .../dashboard/widgets/recent-projects.tsx | 14 +- .../components/dropdowns/member/avatar.tsx | 16 +- .../dropdowns/member/member-options.tsx | 20 +- .../lite-text-editor/lite-text-editor.tsx | 24 +- .../lite-text-read-only-editor.tsx | 12 +- .../rich-text-editor/rich-text-editor.tsx | 25 +- .../rich-text-read-only-editor.tsx | 12 +- .../components/inbox/content/issue-root.tsx | 2 + .../inbox-filter/applied-filters/member.tsx | 11 +- .../inbox/inbox-filter/filters/members.tsx | 13 +- .../modals/create-edit-modal/create-root.tsx | 15 +- .../create-edit-modal/issue-description.tsx | 36 +- .../integration/github/single-user-select.tsx | 11 +- .../integration/jira/import-users.tsx | 12 +- .../issues/attachment/attachment-detail.tsx | 15 +- .../attachment/attachment-item-list.tsx | 65 +- .../attachment/attachment-list-item.tsx | 17 +- .../issues/attachment/attachment-upload.tsx | 30 +- .../components/issues/attachment/root.tsx | 6 +- .../components/issues/description-input.tsx | 23 + .../attachments/helper.tsx | 5 +- .../attachments/quick-action-button.tsx | 74 +- .../issue-activity/comments/comment-block.tsx | 11 +- .../issue-activity/comments/comment-card.tsx | 16 +- .../comments/comment-create.tsx | 41 +- .../issue-detail/issue-activity/root.tsx | 38 +- .../issues/issue-detail/main-content.tsx | 2 - .../components/issues/issue-detail/root.tsx | 6 +- .../filters/applied-filters/members.tsx | 13 +- .../filters/header/filters/assignee.tsx | 19 +- .../filters/header/filters/created-by.tsx | 12 +- .../filters/header/filters/mentions.tsx | 19 +- .../components/issues/issue-layouts/utils.tsx | 12 +- .../components/issues/issue-modal/base.tsx | 22 + .../components/description-editor.tsx | 27 + .../issues/issue-modal/draft-issue-layout.tsx | 3 + .../components/issues/issue-modal/form.tsx | 4 + .../components/issues/peek-overview/index.ts | 1 - .../peek-overview/issue-attachments.tsx | 113 --- .../components/issues/peek-overview/root.tsx | 14 +- .../analytics-sidebar/progress-stats.tsx | 9 +- .../modules/applied-filters/members.tsx | 13 +- .../modules/dropdowns/filters/lead.tsx | 19 +- .../modules/dropdowns/filters/members.tsx | 19 +- .../components/onboarding/profile-setup.tsx | 34 +- .../onboarding/switch-account-dropdown.tsx | 5 +- .../dropdowns/edit-information-popover.tsx | 7 +- .../components/pages/editor/editor-body.tsx | 48 +- .../pages/list/block-item-action.tsx | 3 +- web/core/components/pages/version/editor.tsx | 6 + .../pages/version/sidebar-list-item.tsx | 3 +- .../profile/activity/activity-list.tsx | 15 +- .../activity/profile-activity-list.tsx | 15 +- .../components/profile/overview/activity.tsx | 13 +- web/core/components/profile/sidebar.tsx | 27 +- .../project/applied-filters/members.tsx | 9 +- web/core/components/project/card.tsx | 17 +- .../project/create-project-modal.tsx | 20 +- web/core/components/project/create/header.tsx | 14 +- .../project/dropdowns/filters/lead.tsx | 19 +- .../project/dropdowns/filters/members.tsx | 19 +- web/core/components/project/form.tsx | 23 +- web/core/components/project/member-select.tsx | 12 +- .../project/send-project-invitation-modal.tsx | 8 +- .../project/settings/member-columns.tsx | 26 +- .../sidebar/notification-card/item.tsx | 3 +- web/core/components/workspace/logo.tsx | 11 +- .../workspace/settings/member-columns.tsx | 29 +- .../workspace/settings/workspace-details.tsx | 66 +- .../components/workspace/sidebar/dropdown.tsx | 15 +- web/core/constants/common.ts | 2 +- web/core/hooks/store/use-mention.ts | 15 +- web/core/services/file-upload.service.ts | 33 + web/core/services/file.service.ts | 313 ++++---- .../issue/issue_attachment.service.ts | 53 +- .../services/issue/issue_comment.service.ts | 24 +- .../services/page/project-page.service.ts | 5 + .../issue/issue-details/attachment.store.ts | 8 +- .../store/issue/issue-details/issue.store.ts | 4 +- .../store/issue/issue-details/root.store.ts | 4 +- web/core/store/workspace/index.ts | 17 + web/ee/hooks/use-file-size.ts | 1 + web/helpers/editor.helper.ts | 113 ++- web/helpers/file.helper.ts | 55 ++ web/helpers/string.helper.ts | 8 +- web/next.config.js | 13 +- 241 files changed, 5326 insertions(+), 2518 deletions(-) create mode 100644 admin/helpers/file.helper.ts create mode 100644 admin/helpers/string.helper.ts create mode 100644 apiserver/plane/app/views/asset/v2.py create mode 100644 apiserver/plane/db/management/commands/update_bucket.py create mode 100644 apiserver/plane/db/migrations/0078_fileasset_comment_fileasset_entity_type_and_more.py create mode 100644 apiserver/plane/db/migrations/0079_auto_20241009_0619.py create mode 100644 apiserver/plane/settings/storage.py create mode 100644 apiserver/plane/space/urls/asset.py create mode 100644 apiserver/plane/space/views/asset.py delete mode 100644 packages/types/src/current-user/accounts.d.ts delete mode 100644 packages/types/src/current-user/user.d.ts create mode 100644 packages/types/src/file.d.ts create mode 100644 space/core/constants/common.ts create mode 100644 space/core/services/file-upload.service.ts create mode 100644 space/helpers/editor.helper.ts create mode 100644 space/helpers/file.helper.ts create mode 100644 web/ce/hooks/use-file-size.ts delete mode 100644 web/core/components/cycles/active-cycle/header.tsx delete mode 100644 web/core/components/cycles/active-cycle/stats.tsx delete mode 100644 web/core/components/cycles/active-cycle/upcoming-cycles-list-item.tsx delete mode 100644 web/core/components/cycles/active-cycle/upcoming-cycles-list.tsx delete mode 100644 web/core/components/cycles/board/cycles-board-card.tsx delete mode 100644 web/core/components/cycles/board/cycles-board-map.tsx delete mode 100644 web/core/components/cycles/board/index.ts delete mode 100644 web/core/components/cycles/board/root.tsx delete mode 100644 web/core/components/issues/peek-overview/issue-attachments.tsx create mode 100644 web/core/services/file-upload.service.ts create mode 100644 web/ee/hooks/use-file-size.ts diff --git a/admin/core/components/admin-sidebar/sidebar-dropdown.tsx b/admin/core/components/admin-sidebar/sidebar-dropdown.tsx index b5a7b4f15..e0741f7c4 100644 --- a/admin/core/components/admin-sidebar/sidebar-dropdown.tsx +++ b/admin/core/components/admin-sidebar/sidebar-dropdown.tsx @@ -5,11 +5,13 @@ import { observer } from "mobx-react"; import { useTheme as useNextTheme } from "next-themes"; import { LogOut, UserCog2, Palette } from "lucide-react"; import { Menu, Transition } from "@headlessui/react"; +// plane ui import { Avatar } from "@plane/ui"; -// hooks -import { API_BASE_URL, cn } from "@/helpers/common.helper"; -import { useTheme, useUser } from "@/hooks/store"; // helpers +import { API_BASE_URL, cn } from "@/helpers/common.helper"; +import { getFileURL } from "@/helpers/file.helper"; +// hooks +import { useTheme, useUser } from "@/hooks/store"; // services import { AuthService } from "@/services/auth.service"; @@ -122,7 +124,7 @@ export const SidebarDropdown = observer(() => { { + if (!path) return undefined; + const isValidURL = checkURLValidity(path); + if (isValidURL) return path; + return `${API_BASE_URL}${path}`; +}; diff --git a/admin/helpers/string.helper.ts b/admin/helpers/string.helper.ts new file mode 100644 index 000000000..a48508118 --- /dev/null +++ b/admin/helpers/string.helper.ts @@ -0,0 +1,21 @@ +/** + * @description + * This function test whether a URL is valid or not. + * + * It accepts URLs with or without the protocol. + * @param {string} url + * @returns {boolean} + * @example + * checkURLValidity("https://example.com") => true + * checkURLValidity("example.com") => true + * checkURLValidity("example") => false + */ +export const checkURLValidity = (url: string): boolean => { + if (!url) return false; + + // regex to support complex query parameters and fragments + const urlPattern = + /^(https?:\/\/)?((([a-z\d-]+\.)*[a-z\d-]+\.[a-z]{2,6})|(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}))(:\d+)?(\/[\w.-]*)*(\?[^#\s]*)?(#[\w-]*)?$/i; + + return urlPattern.test(url); +}; diff --git a/apiserver/plane/api/serializers/__init__.py b/apiserver/plane/api/serializers/__init__.py index 72c5f8da9..263be85b8 100644 --- a/apiserver/plane/api/serializers/__init__.py +++ b/apiserver/plane/api/serializers/__init__.py @@ -5,7 +5,6 @@ from .issue import ( IssueSerializer, LabelSerializer, IssueLinkSerializer, - IssueAttachmentSerializer, IssueCommentSerializer, IssueAttachmentSerializer, IssueActivitySerializer, diff --git a/apiserver/plane/api/serializers/issue.py b/apiserver/plane/api/serializers/issue.py index ab054ae51..905157339 100644 --- a/apiserver/plane/api/serializers/issue.py +++ b/apiserver/plane/api/serializers/issue.py @@ -11,7 +11,7 @@ from plane.db.models import ( IssueType, IssueActivity, IssueAssignee, - IssueAttachment, + FileAsset, IssueComment, IssueLabel, IssueLink, @@ -31,6 +31,7 @@ from .user import UserLiteSerializer from django.core.exceptions import ValidationError from django.core.validators import URLValidator + class IssueSerializer(BaseSerializer): assignees = serializers.ListField( child=serializers.PrimaryKeyRelatedField( @@ -315,7 +316,7 @@ class IssueLinkSerializer(BaseSerializer): "created_at", "updated_at", ] - + def validate_url(self, value): # Check URL format validate_url = URLValidator() @@ -359,7 +360,7 @@ class IssueLinkSerializer(BaseSerializer): class IssueAttachmentSerializer(BaseSerializer): class Meta: - model = IssueAttachment + model = FileAsset fields = "__all__" read_only_fields = [ "id", diff --git a/apiserver/plane/api/serializers/project.py b/apiserver/plane/api/serializers/project.py index d1fea2023..591a1203d 100644 --- a/apiserver/plane/api/serializers/project.py +++ b/apiserver/plane/api/serializers/project.py @@ -19,6 +19,7 @@ class ProjectSerializer(BaseSerializer): sort_order = serializers.FloatField(read_only=True) member_role = serializers.IntegerField(read_only=True) is_deployed = serializers.BooleanField(read_only=True) + cover_image_url = serializers.CharField(read_only=True) class Meta: model = Project @@ -32,6 +33,7 @@ class ProjectSerializer(BaseSerializer): "created_by", "updated_by", "deleted_at", + "cover_image_url", ] def validate(self, data): @@ -87,6 +89,8 @@ class ProjectSerializer(BaseSerializer): class ProjectLiteSerializer(BaseSerializer): + cover_image_url = serializers.CharField(read_only=True) + class Meta: model = Project fields = [ @@ -97,5 +101,6 @@ class ProjectLiteSerializer(BaseSerializer): "icon_prop", "emoji", "description", + "cover_image_url", ] read_only_fields = fields diff --git a/apiserver/plane/api/serializers/user.py b/apiserver/plane/api/serializers/user.py index e853b90c2..b266d7d54 100644 --- a/apiserver/plane/api/serializers/user.py +++ b/apiserver/plane/api/serializers/user.py @@ -13,6 +13,7 @@ class UserLiteSerializer(BaseSerializer): "last_name", "email", "avatar", + "avatar_url", "display_name", "email", ] diff --git a/apiserver/plane/api/views/cycle.py b/apiserver/plane/api/views/cycle.py index 48e7f6d1f..5fa959b26 100644 --- a/apiserver/plane/api/views/cycle.py +++ b/apiserver/plane/api/views/cycle.py @@ -13,8 +13,12 @@ from django.db.models import ( Q, Sum, FloatField, + Case, + When, + Value, ) -from django.db.models.functions import Cast +from django.db.models.functions import Cast, Concat +from django.db import models # Third party imports from rest_framework import status @@ -32,7 +36,7 @@ from plane.db.models import ( CycleIssue, Issue, Project, - IssueAttachment, + FileAsset, IssueLink, ProjectMember, UserFavorite, @@ -641,8 +645,9 @@ class CycleIssueAPIEndpoint(BaseAPIView): .values("count") ) .annotate( - attachment_count=IssueAttachment.objects.filter( - issue=OuterRef("id") + attachment_count=FileAsset.objects.filter( + issue_id=OuterRef("id"), + entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, ) .order_by() .annotate(count=Func(F("id"), function="Count")) @@ -883,7 +888,27 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView): .annotate(display_name=F("assignees__display_name")) .annotate(assignee_id=F("assignees__id")) .annotate(avatar=F("assignees__avatar")) - .values("display_name", "assignee_id", "avatar") + .annotate( + avatar_url=Case( + # If `avatar_asset` exists, use it to generate the asset URL + When( + assignees__avatar_asset__isnull=False, + then=Concat( + Value("/api/assets/v2/static/"), + "assignees__avatar_asset", # Assuming avatar_asset has an id or relevant field + Value("/"), + ), + ), + # If `avatar_asset` is None, fall back to using `avatar` field directly + When( + assignees__avatar_asset__isnull=True, + then="assignees__avatar", + ), + default=Value(None), + output_field=models.CharField(), + ) + ) + .values("display_name", "assignee_id", "avatar", "avatar_url") .annotate( total_estimates=Sum( Cast("estimate_point__value", FloatField()) @@ -920,7 +945,8 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView): if item["assignee_id"] else None ), - "avatar": item["avatar"], + "avatar": item.get("avatar", None), + "avatar_url": item.get("avatar_url", None), "total_estimates": item["total_estimates"], "completed_estimates": item["completed_estimates"], "pending_estimates": item["pending_estimates"], @@ -998,7 +1024,27 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView): .annotate(display_name=F("assignees__display_name")) .annotate(assignee_id=F("assignees__id")) .annotate(avatar=F("assignees__avatar")) - .values("display_name", "assignee_id", "avatar") + .annotate( + avatar_url=Case( + # If `avatar_asset` exists, use it to generate the asset URL + When( + assignees__avatar_asset__isnull=False, + then=Concat( + Value("/api/assets/v2/static/"), + "assignees__avatar_asset", # Assuming avatar_asset has an id or relevant field + Value("/"), + ), + ), + # If `avatar_asset` is None, fall back to using `avatar` field directly + When( + assignees__avatar_asset__isnull=True, + then="assignees__avatar", + ), + default=Value(None), + output_field=models.CharField(), + ) + ) + .values("display_name", "assignee_id", "avatar_url") .annotate( total_issues=Count( "id", @@ -1037,7 +1083,8 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView): "assignee_id": ( str(item["assignee_id"]) if item["assignee_id"] else None ), - "avatar": item["avatar"], + "avatar": item.get("avatar", None), + "avatar_url": item.get("avatar_url", None), "total_issues": item["total_issues"], "completed_issues": item["completed_issues"], "pending_issues": item["pending_issues"], diff --git a/apiserver/plane/api/views/issue.py b/apiserver/plane/api/views/issue.py index 1cd8ed1b4..77f0fad62 100644 --- a/apiserver/plane/api/views/issue.py +++ b/apiserver/plane/api/views/issue.py @@ -42,7 +42,7 @@ from plane.bgtasks.issue_activities_task import issue_activity from plane.db.models import ( Issue, IssueActivity, - IssueAttachment, + FileAsset, IssueComment, IssueLink, Label, @@ -210,8 +210,9 @@ class IssueAPIEndpoint(BaseAPIView): .values("count") ) .annotate( - attachment_count=IssueAttachment.objects.filter( - issue=OuterRef("id") + attachment_count=FileAsset.objects.filter( + issue_id=OuterRef("id"), + entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, ) .order_by() .annotate(count=Func(F("id"), function="Count")) @@ -1062,7 +1063,7 @@ class IssueAttachmentEndpoint(BaseAPIView): permission_classes = [ ProjectEntityPermission, ] - model = IssueAttachment + model = FileAsset parser_classes = (MultiPartParser, FormParser) def post(self, request, slug, project_id, issue_id): @@ -1070,7 +1071,7 @@ class IssueAttachmentEndpoint(BaseAPIView): if ( request.data.get("external_id") and request.data.get("external_source") - and IssueAttachment.objects.filter( + and FileAsset.objects.filter( project_id=project_id, workspace__slug=slug, issue_id=issue_id, @@ -1078,7 +1079,7 @@ class IssueAttachmentEndpoint(BaseAPIView): external_id=request.data.get("external_id"), ).exists() ): - issue_attachment = IssueAttachment.objects.filter( + issue_attachment = FileAsset.objects.filter( workspace__slug=slug, project_id=project_id, external_id=request.data.get("external_id"), @@ -1112,7 +1113,7 @@ class IssueAttachmentEndpoint(BaseAPIView): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) def delete(self, request, slug, project_id, issue_id, pk): - issue_attachment = IssueAttachment.objects.get(pk=pk) + issue_attachment = FileAsset.objects.get(pk=pk) issue_attachment.asset.delete(save=False) issue_attachment.delete() issue_activity.delay( @@ -1130,7 +1131,7 @@ class IssueAttachmentEndpoint(BaseAPIView): return Response(status=status.HTTP_204_NO_CONTENT) def get(self, request, slug, project_id, issue_id): - issue_attachments = IssueAttachment.objects.filter( + issue_attachments = FileAsset.objects.filter( issue_id=issue_id, workspace__slug=slug, project_id=project_id ) serializer = IssueAttachmentSerializer(issue_attachments, many=True) diff --git a/apiserver/plane/api/views/module.py b/apiserver/plane/api/views/module.py index 67ccf13a9..45407df80 100644 --- a/apiserver/plane/api/views/module.py +++ b/apiserver/plane/api/views/module.py @@ -21,7 +21,7 @@ from plane.app.permissions import ProjectEntityPermission from plane.bgtasks.issue_activities_task import issue_activity from plane.db.models import ( Issue, - IssueAttachment, + FileAsset, IssueLink, Module, ModuleIssue, @@ -393,8 +393,9 @@ class ModuleIssueAPIEndpoint(BaseAPIView): .values("count") ) .annotate( - attachment_count=IssueAttachment.objects.filter( - issue=OuterRef("id") + attachment_count=FileAsset.objects.filter( + issue_id=OuterRef("id"), + entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, ) .order_by() .annotate(count=Func(F("id"), function="Count")) diff --git a/apiserver/plane/app/serializers/base.py b/apiserver/plane/app/serializers/base.py index 6693ba931..f84d349a6 100644 --- a/apiserver/plane/app/serializers/base.py +++ b/apiserver/plane/app/serializers/base.py @@ -49,48 +49,46 @@ class DynamicBaseSerializer(BaseSerializer): allowed.append(list(item.keys())[0]) for field in allowed: - if field not in self.fields: - from . import ( - WorkspaceLiteSerializer, - ProjectLiteSerializer, - UserLiteSerializer, - StateLiteSerializer, - IssueSerializer, - LabelSerializer, - CycleIssueSerializer, - IssueLiteSerializer, - IssueRelationSerializer, - InboxIssueLiteSerializer, - IssueReactionLiteSerializer, - IssueAttachmentLiteSerializer, - IssueLinkLiteSerializer, - ) + from . import ( + WorkspaceLiteSerializer, + ProjectLiteSerializer, + UserLiteSerializer, + StateLiteSerializer, + IssueSerializer, + LabelSerializer, + CycleIssueSerializer, + IssueLiteSerializer, + IssueRelationSerializer, + InboxIssueLiteSerializer, + IssueReactionLiteSerializer, + IssueLinkLiteSerializer, + ) - # Expansion mapper - expansion = { - "user": UserLiteSerializer, - "workspace": WorkspaceLiteSerializer, - "project": ProjectLiteSerializer, - "default_assignee": UserLiteSerializer, - "project_lead": UserLiteSerializer, - "state": StateLiteSerializer, - "created_by": UserLiteSerializer, - "issue": IssueSerializer, - "actor": UserLiteSerializer, - "owned_by": UserLiteSerializer, - "members": UserLiteSerializer, - "assignees": UserLiteSerializer, - "labels": LabelSerializer, - "issue_cycle": CycleIssueSerializer, - "parent": IssueLiteSerializer, - "issue_relation": IssueRelationSerializer, - "issue_inbox": InboxIssueLiteSerializer, - "issue_reactions": IssueReactionLiteSerializer, - "issue_attachment": IssueAttachmentLiteSerializer, - "issue_link": IssueLinkLiteSerializer, - "sub_issues": IssueLiteSerializer, - } + # Expansion mapper + expansion = { + "user": UserLiteSerializer, + "workspace": WorkspaceLiteSerializer, + "project": ProjectLiteSerializer, + "default_assignee": UserLiteSerializer, + "project_lead": UserLiteSerializer, + "state": StateLiteSerializer, + "created_by": UserLiteSerializer, + "issue": IssueSerializer, + "actor": UserLiteSerializer, + "owned_by": UserLiteSerializer, + "members": UserLiteSerializer, + "assignees": UserLiteSerializer, + "labels": LabelSerializer, + "issue_cycle": CycleIssueSerializer, + "parent": IssueLiteSerializer, + "issue_relation": IssueRelationSerializer, + "issue_inbox": InboxIssueLiteSerializer, + "issue_reactions": IssueReactionLiteSerializer, + "issue_link": IssueLinkLiteSerializer, + "sub_issues": IssueLiteSerializer, + } + if field not in self.fields and field in expansion: self.fields[field] = expansion[field]( many=( True @@ -178,4 +176,29 @@ class DynamicBaseSerializer(BaseSerializer): instance, f"{expand}_id", None ) + # Check if issue_attachments is in fields or expand + if ( + "issue_attachments" in self.fields + or "issue_attachments" in self.expand + ): + # Import the model here to avoid circular imports + from plane.db.models import FileAsset + + issue_id = getattr(instance, "id", None) + + if issue_id: + # Fetch related issue_attachments + issue_attachments = FileAsset.objects.filter( + issue_id=issue_id, + entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, + ) + # Serialize issue_attachments and add them to the response + response["issue_attachments"] = ( + IssueAttachmentLiteSerializer( + issue_attachments, many=True + ).data + ) + else: + response["issue_attachments"] = [] + return response diff --git a/apiserver/plane/app/serializers/issue.py b/apiserver/plane/app/serializers/issue.py index 4cdf94402..2323c248a 100644 --- a/apiserver/plane/app/serializers/issue.py +++ b/apiserver/plane/app/serializers/issue.py @@ -27,7 +27,7 @@ from plane.db.models import ( Module, ModuleIssue, IssueLink, - IssueAttachment, + FileAsset, IssueReaction, CommentReaction, IssueVote, @@ -498,8 +498,11 @@ class IssueLinkLiteSerializer(BaseSerializer): class IssueAttachmentSerializer(BaseSerializer): + + asset_url = serializers.CharField(read_only=True) + class Meta: - model = IssueAttachment + model = FileAsset fields = "__all__" read_only_fields = [ "created_by", @@ -514,14 +517,15 @@ class IssueAttachmentSerializer(BaseSerializer): class IssueAttachmentLiteSerializer(DynamicBaseSerializer): class Meta: - model = IssueAttachment + model = FileAsset fields = [ "id", "asset", "attributes", - "issue_id", + # "issue_id", "updated_at", "updated_by", + "asset_url", ] read_only_fields = fields diff --git a/apiserver/plane/app/serializers/project.py b/apiserver/plane/app/serializers/project.py index 948608f79..24bc5464e 100644 --- a/apiserver/plane/app/serializers/project.py +++ b/apiserver/plane/app/serializers/project.py @@ -95,6 +95,7 @@ class ProjectLiteSerializer(BaseSerializer): "identifier", "name", "cover_image", + "cover_image_url", "logo_props", "description", ] @@ -117,6 +118,7 @@ class ProjectListSerializer(DynamicBaseSerializer): member_role = serializers.IntegerField(read_only=True) anchor = serializers.CharField(read_only=True) members = serializers.SerializerMethodField() + cover_image_url = serializers.CharField(read_only=True) def get_members(self, obj): project_members = getattr(obj, "members_list", None) @@ -128,6 +130,7 @@ class ProjectListSerializer(DynamicBaseSerializer): "member_id": member.member_id, "member__display_name": member.member.display_name, "member__avatar": member.member.avatar, + "member__avatar_url": member.member.avatar_url, } for member in project_members ] diff --git a/apiserver/plane/app/serializers/user.py b/apiserver/plane/app/serializers/user.py index f99214874..993f74c82 100644 --- a/apiserver/plane/app/serializers/user.py +++ b/apiserver/plane/app/serializers/user.py @@ -56,12 +56,15 @@ class UserSerializer(BaseSerializer): class UserMeSerializer(BaseSerializer): + class Meta: model = User fields = [ "id", "avatar", "cover_image", + "avatar_url", + "cover_image_url", "date_joined", "display_name", "email", @@ -156,6 +159,7 @@ class UserLiteSerializer(BaseSerializer): "first_name", "last_name", "avatar", + "avatar_url", "is_bot", "display_name", ] @@ -173,6 +177,7 @@ class UserAdminLiteSerializer(BaseSerializer): "first_name", "last_name", "avatar", + "avatar_url", "is_bot", "display_name", "email", diff --git a/apiserver/plane/app/serializers/workspace.py b/apiserver/plane/app/serializers/workspace.py index 96ee7dce3..abfbd001e 100644 --- a/apiserver/plane/app/serializers/workspace.py +++ b/apiserver/plane/app/serializers/workspace.py @@ -22,6 +22,7 @@ class WorkSpaceSerializer(DynamicBaseSerializer): owner = UserLiteSerializer(read_only=True) total_members = serializers.IntegerField(read_only=True) total_issues = serializers.IntegerField(read_only=True) + logo_url = serializers.CharField(read_only=True) def validate_slug(self, value): # Check if the slug is restricted @@ -39,6 +40,7 @@ class WorkSpaceSerializer(DynamicBaseSerializer): "created_at", "updated_at", "owner", + "logo_url", ] diff --git a/apiserver/plane/app/urls/asset.py b/apiserver/plane/app/urls/asset.py index 2d84b93e0..eed379fd7 100644 --- a/apiserver/plane/app/urls/asset.py +++ b/apiserver/plane/app/urls/asset.py @@ -5,6 +5,13 @@ from plane.app.views import ( FileAssetEndpoint, UserAssetsEndpoint, FileAssetViewSet, + # V2 Endpoints + WorkspaceFileAssetEndpoint, + UserAssetsV2Endpoint, + StaticFileAssetEndpoint, + AssetRestoreEndpoint, + ProjectAssetEndpoint, + ProjectBulkAssetEndpoint, ) @@ -38,4 +45,49 @@ urlpatterns = [ ), name="file-assets-restore", ), + # V2 Endpoints + path( + "assets/v2/workspaces//", + WorkspaceFileAssetEndpoint.as_view(), + name="workspace-file-assets", + ), + path( + "assets/v2/workspaces///", + WorkspaceFileAssetEndpoint.as_view(), + name="workspace-file-assets", + ), + path( + "assets/v2/user-assets/", + UserAssetsV2Endpoint.as_view(), + name="user-file-assets", + ), + path( + "assets/v2/user-assets//", + UserAssetsV2Endpoint.as_view(), + name="user-file-assets", + ), + path( + "assets/v2/workspaces//restore//", + AssetRestoreEndpoint.as_view(), + name="asset-restore", + ), + path( + "assets/v2/static//", + StaticFileAssetEndpoint.as_view(), + name="static-file-asset", + ), + path( + "assets/v2/workspaces//projects//", + ProjectAssetEndpoint.as_view(), + name="bulk-asset-update", + ), + path( + "assets/v2/workspaces//projects///", + ProjectAssetEndpoint.as_view(), + name="bulk-asset-update", + ), + path( + "assets/v2/workspaces//projects///bulk/", + ProjectBulkAssetEndpoint.as_view(), + ), ] diff --git a/apiserver/plane/app/urls/issue.py b/apiserver/plane/app/urls/issue.py index e6007862f..23330e8e1 100644 --- a/apiserver/plane/app/urls/issue.py +++ b/apiserver/plane/app/urls/issue.py @@ -21,6 +21,7 @@ from plane.app.views import ( BulkArchiveIssuesEndpoint, DeletedIssuesListViewSet, IssuePaginatedViewSet, + IssueAttachmentV2Endpoint, ) urlpatterns = [ @@ -132,6 +133,18 @@ urlpatterns = [ IssueAttachmentEndpoint.as_view(), name="project-issue-attachments", ), + # V2 Attachments + path( + "assets/v2/workspaces//projects//issues//attachments/", + IssueAttachmentV2Endpoint.as_view(), + name="project-issue-attachments", + ), + path( + "assets/v2/workspaces//projects//issues//attachments//", + IssueAttachmentV2Endpoint.as_view(), + name="project-issue-attachments", + ), + ## Export Issues path( "workspaces//export-issues/", ExportIssuesEndpoint.as_view(), diff --git a/apiserver/plane/app/views/__init__.py b/apiserver/plane/app/views/__init__.py index 872b511a0..606d05e0d 100644 --- a/apiserver/plane/app/views/__init__.py +++ b/apiserver/plane/app/views/__init__.py @@ -110,7 +110,19 @@ from .cycle.archive import ( CycleArchiveUnarchiveEndpoint, ) -from .asset.base import FileAssetEndpoint, UserAssetsEndpoint, FileAssetViewSet +from .asset.base import ( + FileAssetEndpoint, + UserAssetsEndpoint, + FileAssetViewSet, +) +from .asset.v2 import ( + WorkspaceFileAssetEndpoint, + UserAssetsV2Endpoint, + StaticFileAssetEndpoint, + AssetRestoreEndpoint, + ProjectAssetEndpoint, + ProjectBulkAssetEndpoint, +) from .issue.base import ( IssueListEndpoint, IssueViewSet, @@ -128,6 +140,8 @@ from .issue.archive import IssueArchiveViewSet, BulkArchiveIssuesEndpoint from .issue.attachment import ( IssueAttachmentEndpoint, + # V2 + IssueAttachmentV2Endpoint, ) from .issue.comment import ( diff --git a/apiserver/plane/app/views/analytic/base.py b/apiserver/plane/app/views/analytic/base.py index 65ba1469c..2bf0d4e1a 100644 --- a/apiserver/plane/app/views/analytic/base.py +++ b/apiserver/plane/app/views/analytic/base.py @@ -2,6 +2,9 @@ from django.db.models import Count, F, Sum from django.db.models.functions import ExtractMonth from django.utils import timezone +from django.db.models.functions import Concat +from django.db.models import Case, When, Value +from django.db import models # Third party imports from rest_framework import status @@ -120,12 +123,12 @@ class AnalyticsEndpoint(BaseAPIView): Issue.issue_objects.filter( workspace__slug=slug, **filters, - assignees__avatar__isnull=False, + assignees__avatar_url__isnull=False, ) .order_by("assignees__id") .distinct("assignees__id") .values( - "assignees__avatar", + "assignees__avatar_url", "assignees__display_name", "assignees__first_name", "assignees__last_name", @@ -355,7 +358,6 @@ class DefaultAnalyticsEndpoint(BaseAPIView): user_details = [ "created_by__first_name", "created_by__last_name", - "created_by__avatar", "created_by__display_name", "created_by__id", ] @@ -364,13 +366,32 @@ class DefaultAnalyticsEndpoint(BaseAPIView): base_issues.exclude(created_by=None) .values(*user_details) .annotate(count=Count("id")) + .annotate( + created_by__avatar_url=Case( + # If `avatar_asset` exists, use it to generate the asset URL + When( + created_by__avatar_asset__isnull=False, + then=Concat( + Value("/api/assets/v2/static/"), + "created_by__avatar_asset", # Assuming avatar_asset has an id or relevant field + Value("/"), + ), + ), + # If `avatar_asset` is None, fall back to using `avatar` field directly + When( + created_by__avatar_asset__isnull=True, + then="created_by__avatar", + ), + default=Value(None), + output_field=models.CharField(), + ) + ) .order_by("-count")[:5] ) user_assignee_details = [ "assignees__first_name", "assignees__last_name", - "assignees__avatar", "assignees__display_name", "assignees__id", ] @@ -379,6 +400,26 @@ class DefaultAnalyticsEndpoint(BaseAPIView): base_issues.filter(completed_at__isnull=False) .exclude(assignees=None) .values(*user_assignee_details) + .annotate( + assignees__avatar_url=Case( + # If `avatar_asset` exists, use it to generate the asset URL + When( + assignees__avatar_asset__isnull=False, + then=Concat( + Value("/api/assets/v2/static/"), + "assignees__avatar_asset", # Assuming avatar_asset has an id or relevant field + Value("/"), + ), + ), + # If `avatar_asset` is None, fall back to using `avatar` field directly + When( + assignees__avatar_asset__isnull=True, + then="assignees__avatar", + ), + default=Value(None), + output_field=models.CharField(), + ) + ) .annotate(count=Count("id")) .order_by("-count")[:5] ) @@ -387,6 +428,26 @@ class DefaultAnalyticsEndpoint(BaseAPIView): base_issues.filter(completed_at__isnull=True) .values(*user_assignee_details) .annotate(count=Count("id")) + .annotate( + assignees__avatar_url=Case( + # If `avatar_asset` exists, use it to generate the asset URL + When( + assignees__avatar_asset__isnull=False, + then=Concat( + Value("/api/assets/v2/static/"), + "assignees__avatar_asset", # Assuming avatar_asset has an id or relevant field + Value("/"), + ), + ), + # If `avatar_asset` is None, fall back to using `avatar` field directly + When( + assignees__avatar_asset__isnull=True, + then="assignees__avatar", + ), + default=Value(None), + output_field=models.CharField(), + ) + ) .order_by("-count") ) diff --git a/apiserver/plane/app/views/asset/v2.py b/apiserver/plane/app/views/asset/v2.py new file mode 100644 index 000000000..dfb5a2331 --- /dev/null +++ b/apiserver/plane/app/views/asset/v2.py @@ -0,0 +1,691 @@ +# Python imports +import uuid + +# Django imports +from django.conf import settings +from django.http import HttpResponseRedirect +from django.utils import timezone + +# Third party imports +from rest_framework import status +from rest_framework.response import Response +from rest_framework.permissions import AllowAny + +# Module imports +from ..base import BaseAPIView +from plane.db.models import ( + FileAsset, + Workspace, + Project, + User, +) +from plane.settings.storage import S3Storage +from plane.app.permissions import allow_permission, ROLE + + +class UserAssetsV2Endpoint(BaseAPIView): + """This endpoint is used to upload user profile images.""" + + def asset_delete(self, asset_id): + asset = FileAsset.objects.filter(id=asset_id).first() + if asset is None: + return + asset.is_deleted = True + asset.deleted_at = timezone.now() + asset.save() + return + + def entity_asset_save(self, asset_id, entity_type, asset): + # User Avatar + if entity_type == FileAsset.EntityTypeContext.USER_AVATAR: + user = User.objects.get(id=asset.user_id) + user.avatar = "" + # Delete the previous avatar + if user.avatar_asset_id: + self.asset_delete(user.avatar_asset_id) + # Save the new avatar + user.avatar_asset_id = asset_id + user.save() + return + # User Cover + if entity_type == FileAsset.EntityTypeContext.USER_COVER: + user = User.objects.get(id=asset.user_id) + user.cover_image = None + # Delete the previous cover image + if user.cover_image_asset_id: + self.asset_delete(user.cover_image_asset_id) + # Save the new cover image + user.cover_image_asset_id = asset_id + user.save() + return + return + + def entity_asset_delete(self, entity_type, asset): + # User Avatar + if entity_type == FileAsset.EntityTypeContext.USER_AVATAR: + user = User.objects.get(id=asset.user_id) + user.avatar_asset_id = None + user.save() + return + # User Cover + if entity_type == FileAsset.EntityTypeContext.USER_COVER: + user = User.objects.get(id=asset.user_id) + user.cover_image_asset_id = None + user.save() + return + return + + def post(self, request): + # get the asset key + name = request.data.get("name") + type = request.data.get("type", "image/jpeg") + size = int(request.data.get("size", settings.FILE_SIZE_LIMIT)) + entity_type = request.data.get("entity_type", False) + + # Check if the entity type is allowed + if not entity_type or entity_type not in ["USER_AVATAR", "USER_COVER"]: + return Response( + { + "error": "Invalid entity type.", + "status": False, + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Check if the file type is allowed + allowed_types = ["image/jpeg", "image/png", "image/webp", "image/jpg"] + if type not in allowed_types: + return Response( + { + "error": "Invalid file type. Only JPEG and PNG files are allowed.", + "status": False, + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Get the size limit + size_limit = min(settings.FILE_SIZE_LIMIT, size) + + # asset key + asset_key = f"{uuid.uuid4().hex}-{name}" + + # Create a File Asset + asset = FileAsset.objects.create( + attributes={ + "name": name, + "type": type, + "size": size_limit, + }, + asset=asset_key, + size=size_limit, + user=request.user, + created_by=request.user, + entity_type=entity_type, + ) + + # Get the presigned URL + storage = S3Storage(request=request) + # Generate a presigned URL to share an S3 object + presigned_url = storage.generate_presigned_post( + object_name=asset_key, + file_type=type, + file_size=size_limit, + ) + # Return the presigned URL + return Response( + { + "upload_data": presigned_url, + "asset_id": str(asset.id), + "asset_url": asset.asset_url, + }, + status=status.HTTP_200_OK, + ) + + def patch(self, request, asset_id): + # get the asset id + asset = FileAsset.objects.get(id=asset_id, user_id=request.user.id) + storage = S3Storage(request=request) + # get the storage metadata + asset.is_uploaded = True + # get the storage metadata + if asset.storage_metadata is None: + asset.storage_metadata = storage.get_object_metadata( + object_name=asset.asset.name + ) + # get the entity and save the asset id for the request field + self.entity_asset_save(asset_id, asset.entity_type, asset) + # update the attributes + asset.attributes = request.data.get("attributes", asset.attributes) + # save the asset + asset.save() + return Response(status=status.HTTP_204_NO_CONTENT) + + def delete(self, request, asset_id): + asset = FileAsset.objects.get(id=asset_id, user_id=request.user.id) + asset.is_deleted = True + asset.deleted_at = timezone.now() + # get the entity and save the asset id for the request field + self.entity_asset_delete(asset.entity_type, asset) + asset.save() + return Response(status=status.HTTP_204_NO_CONTENT) + + +class WorkspaceFileAssetEndpoint(BaseAPIView): + """This endpoint is used to upload cover images/logos etc for workspace, projects and users.""" + + def get_entity_id_field(self, entity_type, entity_id): + if entity_type == FileAsset.EntityTypeContext.WORKSPACE_LOGO: + return { + "workspace_id": entity_id, + } + + if entity_type == FileAsset.EntityTypeContext.PROJECT_COVER: + return { + "project_id": entity_id, + } + + if entity_type in [ + FileAsset.EntityTypeContext.USER_AVATAR, + FileAsset.EntityTypeContext.USER_COVER, + ]: + return { + "user_id": entity_id, + } + + if entity_type in [ + FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, + FileAsset.EntityTypeContext.ISSUE_DESCRIPTION, + ]: + return { + "issue_id": entity_id, + } + + if entity_type == FileAsset.EntityTypeContext.PAGE_DESCRIPTION: + return { + "page_id": entity_id, + } + + if entity_type == FileAsset.EntityTypeContext.COMMENT_DESCRIPTION: + return { + "comment_id": entity_id, + } + return {} + + def asset_delete(self, asset_id): + asset = FileAsset.objects.filter(id=asset_id).first() + # Check if the asset exists + if asset is None: + return + # Mark the asset as deleted + asset.is_deleted = True + asset.deleted_at = timezone.now() + asset.save() + return + + def entity_asset_save(self, asset_id, entity_type, asset): + # Workspace Logo + if entity_type == FileAsset.EntityTypeContext.WORKSPACE_LOGO: + workspace = Workspace.objects.filter(id=asset.workspace_id).first() + if workspace is None: + return + # Delete the previous logo + if workspace.logo_asset_id: + self.asset_delete(workspace.logo_asset_id) + # Save the new logo + workspace.logo = "" + workspace.logo_asset_id = asset_id + workspace.save() + return + + # Project Cover + elif entity_type == FileAsset.EntityTypeContext.PROJECT_COVER: + project = Project.objects.filter(id=asset.workspace_id).first() + if project is None: + return + # Delete the previous cover image + if project.cover_image_asset_id: + self.asset_delete(project.cover_image_asset_id) + # Save the new cover image + project.cover_image = "" + project.cover_image_asset_id = asset_id + project.save() + return + else: + return + + def entity_asset_delete(self, entity_type, asset): + # Workspace Logo + if entity_type == FileAsset.EntityTypeContext.WORKSPACE_LOGO: + workspace = Workspace.objects.get(id=asset.workspace_id) + if workspace is None: + return + workspace.logo_asset_id = None + workspace.save() + return + # Project Cover + elif entity_type == FileAsset.EntityTypeContext.PROJECT_COVER: + project = Project.objects.filter(id=asset.project_id).first() + if project is None: + return + project.cover_image_asset_id = None + project.save() + return + else: + return + + def post(self, request, slug): + name = request.data.get("name") + type = request.data.get("type", "image/jpeg") + size = int(request.data.get("size", settings.FILE_SIZE_LIMIT)) + entity_type = request.data.get("entity_type") + entity_identifier = request.data.get("entity_identifier", False) + + # Check if the entity type is allowed + if entity_type not in FileAsset.EntityTypeContext.values: + return Response( + { + "error": "Invalid entity type.", + "status": False, + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Check if the file type is allowed + allowed_types = ["image/jpeg", "image/png", "image/webp", "image/jpg"] + if type not in allowed_types: + return Response( + { + "error": "Invalid file type. Only JPEG and PNG files are allowed.", + "status": False, + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Get the size limit + size_limit = min(settings.FILE_SIZE_LIMIT, size) + + # Get the workspace + workspace = Workspace.objects.get(slug=slug) + + # asset key + asset_key = f"{workspace.id}/{uuid.uuid4().hex}-{name}" + + # Create a File Asset + asset = FileAsset.objects.create( + attributes={ + "name": name, + "type": type, + "size": size_limit, + }, + asset=asset_key, + size=size_limit, + workspace=workspace, + created_by=request.user, + entity_type=entity_type, + **self.get_entity_id_field(entity_type, entity_identifier), + ) + + # Get the presigned URL + storage = S3Storage(request=request) + # Generate a presigned URL to share an S3 object + presigned_url = storage.generate_presigned_post( + object_name=asset_key, + file_type=type, + file_size=size_limit, + ) + # Return the presigned URL + return Response( + { + "upload_data": presigned_url, + "asset_id": str(asset.id), + "asset_url": asset.asset_url, + }, + status=status.HTTP_200_OK, + ) + + def patch(self, request, slug, asset_id): + # get the asset id + asset = FileAsset.objects.get(id=asset_id, workspace__slug=slug) + storage = S3Storage(request=request) + # get the storage metadata + asset.is_uploaded = True + # get the storage metadata + if asset.storage_metadata is None: + asset.storage_metadata = storage.get_object_metadata( + object_name=asset.asset.name + ) + # get the entity and save the asset id for the request field + self.entity_asset_save(asset_id, asset.entity_type, asset) + # update the attributes + asset.attributes = request.data.get("attributes", asset.attributes) + # save the asset + asset.save() + return Response(status=status.HTTP_204_NO_CONTENT) + + def delete(self, request, slug, asset_id): + asset = FileAsset.objects.get(id=asset_id, workspace__slug=slug) + asset.is_deleted = True + asset.deleted_at = timezone.now() + # get the entity and save the asset id for the request field + self.entity_asset_delete(asset.entity_type, asset) + asset.save() + return Response(status=status.HTTP_204_NO_CONTENT) + + def get(self, request, slug, asset_id): + # get the asset id + asset = FileAsset.objects.get(id=asset_id, workspace__slug=slug) + + # Check if the asset is uploaded + if not asset.is_uploaded: + return Response( + { + "error": "The requested asset could not be found.", + }, + status=status.HTTP_404_NOT_FOUND, + ) + + # Get the presigned URL + storage = S3Storage(request=request) + # Generate a presigned URL to share an S3 object + signed_url = storage.generate_presigned_url( + object_name=asset.asset.name, + ) + # Redirect to the signed URL + return HttpResponseRedirect(signed_url) + + +class StaticFileAssetEndpoint(BaseAPIView): + """This endpoint is used to get the signed URL for a static asset.""" + + permission_classes = [ + AllowAny, + ] + + def get(self, request, asset_id): + # get the asset id + asset = FileAsset.objects.get(id=asset_id) + + # Check if the asset is uploaded + if not asset.is_uploaded: + return Response( + { + "error": "The requested asset could not be found.", + }, + status=status.HTTP_404_NOT_FOUND, + ) + + # Check if the entity type is allowed + if asset.entity_type not in [ + FileAsset.EntityTypeContext.USER_AVATAR, + FileAsset.EntityTypeContext.USER_COVER, + FileAsset.EntityTypeContext.WORKSPACE_LOGO, + FileAsset.EntityTypeContext.PROJECT_COVER, + ]: + return Response( + { + "error": "Invalid entity type.", + "status": False, + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Get the presigned URL + storage = S3Storage(request=request) + # Generate a presigned URL to share an S3 object + signed_url = storage.generate_presigned_url( + object_name=asset.asset.name, + ) + # Redirect to the signed URL + return HttpResponseRedirect(signed_url) + + +class AssetRestoreEndpoint(BaseAPIView): + """Endpoint to restore a deleted assets.""" + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE") + def post(self, request, slug, asset_id): + asset = FileAsset.all_objects.get(id=asset_id, workspace__slug=slug) + asset.is_deleted = False + asset.deleted_at = None + asset.save() + return Response(status=status.HTTP_204_NO_CONTENT) + + +class ProjectAssetEndpoint(BaseAPIView): + """This endpoint is used to upload cover images/logos etc for workspace, projects and users.""" + + def get_entity_id_fiekd(self, entity_type, entity_id): + if entity_type == FileAsset.EntityTypeContext.WORKSPACE_LOGO: + return { + "workspace_id": entity_id, + } + + if entity_type == FileAsset.EntityTypeContext.PROJECT_COVER: + return { + "project_id": entity_id, + } + + if entity_type in [ + FileAsset.EntityTypeContext.USER_AVATAR, + FileAsset.EntityTypeContext.USER_COVER, + ]: + return { + "user_id": entity_id, + } + + if entity_type in [ + FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, + FileAsset.EntityTypeContext.ISSUE_DESCRIPTION, + ]: + return { + "issue_id": entity_id, + } + + if entity_type == FileAsset.EntityTypeContext.PAGE_DESCRIPTION: + return { + "page_id": entity_id, + } + + if entity_type == FileAsset.EntityTypeContext.COMMENT_DESCRIPTION: + return { + "comment_id": entity_id, + } + return {} + + @allow_permission( + [ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], + ) + def post(self, request, slug, project_id): + name = request.data.get("name") + type = request.data.get("type", "image/jpeg") + size = int(request.data.get("size", settings.FILE_SIZE_LIMIT)) + entity_type = request.data.get("entity_type", "") + entity_identifier = request.data.get("entity_identifier") + + # Check if the entity type is allowed + if entity_type not in FileAsset.EntityTypeContext.values: + return Response( + { + "error": "Invalid entity type.", + "status": False, + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Check if the file type is allowed + allowed_types = ["image/jpeg", "image/png", "image/webp"] + if type not in allowed_types: + return Response( + { + "error": "Invalid file type. Only JPEG and PNG files are allowed.", + "status": False, + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Get the size limit + size_limit = min(settings.FILE_SIZE_LIMIT, size) + + # Get the workspace + workspace = Workspace.objects.get(slug=slug) + + # asset key + asset_key = f"{workspace.id}/{uuid.uuid4().hex}-{name}" + + # Create a File Asset + asset = FileAsset.objects.create( + attributes={ + "name": name, + "type": type, + "size": size_limit, + }, + asset=asset_key, + size=size_limit, + workspace=workspace, + created_by=request.user, + entity_type=entity_type, + project_id=project_id, + **self.get_entity_id_fiekd(entity_type, entity_identifier), + ) + + # Get the presigned URL + storage = S3Storage(request=request) + # Generate a presigned URL to share an S3 object + presigned_url = storage.generate_presigned_post( + object_name=asset_key, + file_type=type, + file_size=size_limit, + ) + # Return the presigned URL + return Response( + { + "upload_data": presigned_url, + "asset_id": str(asset.id), + "asset_url": asset.asset_url, + }, + status=status.HTTP_200_OK, + ) + + @allow_permission( + [ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], + ) + def patch(self, request, slug, project_id, pk): + # get the asset id + asset = FileAsset.objects.get( + id=pk, + ) + storage = S3Storage(request=request) + # get the storage metadata + asset.is_uploaded = True + # get the storage metadata + if asset.storage_metadata is None: + asset.storage_metadata = storage.get_object_metadata( + object_name=asset.asset.name + ) + + # update the attributes + asset.attributes = request.data.get("attributes", asset.attributes) + # save the asset + asset.save() + return Response(status=status.HTTP_204_NO_CONTENT) + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) + def delete(self, request, slug, project_id, pk): + # Get the asset + asset = FileAsset.objects.get( + id=pk, + workspace__slug=slug, + project_id=project_id, + ) + # Check deleted assets + asset.is_deleted = True + asset.deleted_at = timezone.now() + # Save the asset + asset.save() + return Response(status=status.HTTP_204_NO_CONTENT) + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) + def get(self, request, slug, project_id, pk): + # get the asset id + asset = FileAsset.objects.get( + workspace__slug=slug, + project_id=project_id, + pk=pk, + ) + + # Check if the asset is uploaded + if not asset.is_uploaded: + return Response( + { + "error": "The requested asset could not be found.", + }, + status=status.HTTP_404_NOT_FOUND, + ) + + # Get the presigned URL + storage = S3Storage(request=request) + # Generate a presigned URL to share an S3 object + signed_url = storage.generate_presigned_url( + object_name=asset.asset.name, + ) + # Redirect to the signed URL + return HttpResponseRedirect(signed_url) + + +class ProjectBulkAssetEndpoint(BaseAPIView): + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) + def post(self, request, slug, project_id, entity_id): + asset_ids = request.data.get("asset_ids", []) + + # Check if the asset ids are provided + if not asset_ids: + return Response( + { + "error": "No asset ids provided.", + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + # get the asset id + assets = FileAsset.objects.filter( + id__in=asset_ids, + workspace__slug=slug, + ) + + # Get the first asset + asset = assets.first() + + if not asset: + return Response( + { + "error": "The requested asset could not be found.", + }, + status=status.HTTP_404_NOT_FOUND, + ) + + # Check if the asset is uploaded + if asset.entity_type == FileAsset.EntityTypeContext.PROJECT_COVER: + assets.update( + project_id=project_id, + ) + + if asset.entity_type == FileAsset.EntityTypeContext.ISSUE_DESCRIPTION: + assets.update( + issue_id=entity_id, + ) + + if ( + asset.entity_type + == FileAsset.EntityTypeContext.COMMENT_DESCRIPTION + ): + assets.update( + comment_id=entity_id, + ) + + if asset.entity_type == FileAsset.EntityTypeContext.PAGE_DESCRIPTION: + assets.update( + page_id=entity_id, + ) + + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apiserver/plane/app/views/cycle/archive.py b/apiserver/plane/app/views/cycle/archive.py index 9d7f79b0e..a03a9fddd 100644 --- a/apiserver/plane/app/views/cycle/archive.py +++ b/apiserver/plane/app/views/cycle/archive.py @@ -1,6 +1,7 @@ # Django imports from django.contrib.postgres.aggregates import ArrayAgg from django.contrib.postgres.fields import ArrayField +from django.db import models from django.db.models import ( Case, CharField, @@ -18,7 +19,7 @@ from django.db.models import ( Sum, FloatField, ) -from django.db.models.functions import Coalesce, Cast +from django.db.models.functions import Coalesce, Cast, Concat from django.utils import timezone # Third party imports @@ -139,7 +140,7 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView): Prefetch( "issue_cycle__issue__assignees", queryset=User.objects.only( - "avatar", "first_name", "id" + "avatar_asset", "first_name", "id" ).distinct(), ) ) @@ -400,8 +401,27 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView): ) .annotate(display_name=F("assignees__display_name")) .annotate(assignee_id=F("assignees__id")) - .annotate(avatar=F("assignees__avatar")) - .values("display_name", "assignee_id", "avatar") + .annotate( + avatar_url=Case( + # If `avatar_asset` exists, use it to generate the asset URL + When( + assignees__avatar_asset__isnull=False, + then=Concat( + Value("/api/assets/v2/static/"), + "assignees__avatar_asset", # Assuming avatar_asset has an id or relevant field + Value("/"), + ), + ), + # If `avatar_asset` is None, fall back to using `avatar` field directly + When( + assignees__avatar_asset__isnull=True, + then="assignees__avatar", + ), + default=Value(None), + output_field=models.CharField(), + ) + ) + .values("display_name", "assignee_id", "avatar_url") .annotate( total_estimates=Sum( Cast("estimate_point__value", FloatField()) @@ -494,13 +514,13 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView): .annotate(first_name=F("assignees__first_name")) .annotate(last_name=F("assignees__last_name")) .annotate(assignee_id=F("assignees__id")) - .annotate(avatar=F("assignees__avatar")) + .annotate(avatar_url=F("assignees__avatar_url")) .annotate(display_name=F("assignees__display_name")) .values( "first_name", "last_name", "assignee_id", - "avatar", + "avatar_url", "display_name", ) .annotate( diff --git a/apiserver/plane/app/views/cycle/base.py b/apiserver/plane/app/views/cycle/base.py index 3a372d36d..9ad6ecb36 100644 --- a/apiserver/plane/app/views/cycle/base.py +++ b/apiserver/plane/app/views/cycle/base.py @@ -20,7 +20,8 @@ from django.db.models import ( Sum, FloatField, ) -from django.db.models.functions import Coalesce, Cast +from django.db import models +from django.db.models.functions import Coalesce, Cast, Concat from django.utils import timezone from django.core.serializers.json import DjangoJSONEncoder @@ -81,7 +82,7 @@ class CycleViewSet(BaseViewSet): Prefetch( "issue_cycle__issue__assignees", queryset=User.objects.only( - "avatar", "first_name", "id" + "avatar_asset", "first_name", "id" ).distinct(), ) ) @@ -667,8 +668,27 @@ class TransferCycleIssueEndpoint(BaseAPIView): ) .annotate(display_name=F("assignees__display_name")) .annotate(assignee_id=F("assignees__id")) - .annotate(avatar=F("assignees__avatar")) - .values("display_name", "assignee_id", "avatar") + .annotate( + avatar_url=Case( + # If `avatar_asset` exists, use it to generate the asset URL + When( + assignees__avatar_asset__isnull=False, + then=Concat( + Value("/api/assets/v2/static/"), + "assignees__avatar_asset", # Assuming avatar_asset has an id or relevant field + Value("/"), + ), + ), + # If `avatar_asset` is None, fall back to using `avatar` field directly + When( + assignees__avatar_asset__isnull=True, + then="assignees__avatar", + ), + default=Value(None), + output_field=models.CharField(), + ) + ) + .values("display_name", "assignee_id", "avatar_url") .annotate( total_estimates=Sum( Cast("estimate_point__value", FloatField()) @@ -705,7 +725,8 @@ class TransferCycleIssueEndpoint(BaseAPIView): if item["assignee_id"] else None ), - "avatar": item["avatar"], + "avatar": item.get("avatar"), + "avatar_url": item.get("avatar_url"), "total_estimates": item["total_estimates"], "completed_estimates": item["completed_estimates"], "pending_estimates": item["pending_estimates"], @@ -782,8 +803,27 @@ class TransferCycleIssueEndpoint(BaseAPIView): ) .annotate(display_name=F("assignees__display_name")) .annotate(assignee_id=F("assignees__id")) - .annotate(avatar=F("assignees__avatar")) - .values("display_name", "assignee_id", "avatar") + .annotate( + avatar_url=Case( + # If `avatar_asset` exists, use it to generate the asset URL + When( + assignees__avatar_asset__isnull=False, + then=Concat( + Value("/api/assets/v2/static/"), + "assignees__avatar_asset", # Assuming avatar_asset has an id or relevant field + Value("/"), + ), + ), + # If `avatar_asset` is None, fall back to using `avatar` field directly + When( + assignees__avatar_asset__isnull=True, + then="assignees__avatar", + ), + default=Value(None), + output_field=models.CharField(), + ) + ) + .values("display_name", "assignee_id", "avatar_url") .annotate( total_issues=Count( "id", @@ -822,7 +862,8 @@ class TransferCycleIssueEndpoint(BaseAPIView): "assignee_id": ( str(item["assignee_id"]) if item["assignee_id"] else None ), - "avatar": item["avatar"], + "avatar": item.get("avatar"), + "avatar_url": item.get("avatar_url"), "total_issues": item["total_issues"], "completed_issues": item["completed_issues"], "pending_issues": item["pending_issues"], @@ -1201,8 +1242,27 @@ class CycleAnalyticsEndpoint(BaseAPIView): ) .annotate(display_name=F("assignees__display_name")) .annotate(assignee_id=F("assignees__id")) - .annotate(avatar=F("assignees__avatar")) - .values("display_name", "assignee_id", "avatar") + .annotate( + avatar_url=Case( + # If `avatar_asset` exists, use it to generate the asset URL + When( + assignees__avatar_asset__isnull=False, + then=Concat( + Value("/api/assets/v2/static/"), + "assignees__avatar_asset", # Assuming avatar_asset has an id or relevant field + Value("/"), + ), + ), + # If `avatar_asset` is None, fall back to using `avatar` field directly + When( + assignees__avatar_asset__isnull=True, + then="assignees__avatar", + ), + default=Value(None), + output_field=models.CharField(), + ) + ) + .values("display_name", "assignee_id", "avatar_url") .annotate( total_estimates=Sum( Cast("estimate_point__value", FloatField()) @@ -1285,8 +1345,27 @@ class CycleAnalyticsEndpoint(BaseAPIView): ) .annotate(display_name=F("assignees__display_name")) .annotate(assignee_id=F("assignees__id")) - .annotate(avatar=F("assignees__avatar")) - .values("display_name", "assignee_id", "avatar") + .annotate( + avatar_url=Case( + # If `avatar_asset` exists, use it to generate the asset URL + When( + assignees__avatar_asset__isnull=False, + then=Concat( + Value("/api/assets/v2/static/"), + "assignees__avatar_asset", # Assuming avatar_asset has an id or relevant field + Value("/"), + ), + ), + # If `avatar_asset` is None, fall back to using `avatar` field directly + When( + assignees__avatar_asset__isnull=True, + then="assignees__avatar", + ), + default=Value(None), + output_field=models.CharField(), + ) + ) + .values("display_name", "assignee_id", "avatar_url") .annotate( total_issues=Count( "assignee_id", diff --git a/apiserver/plane/app/views/cycle/issue.py b/apiserver/plane/app/views/cycle/issue.py index 6be2c9ea9..9e1585b54 100644 --- a/apiserver/plane/app/views/cycle/issue.py +++ b/apiserver/plane/app/views/cycle/issue.py @@ -22,7 +22,7 @@ from plane.db.models import ( Cycle, CycleIssue, Issue, - IssueAttachment, + FileAsset, IssueLink, ) from plane.utils.grouper import ( @@ -110,8 +110,9 @@ class CycleIssueViewSet(BaseViewSet): .values("count") ) .annotate( - attachment_count=IssueAttachment.objects.filter( - issue=OuterRef("id") + attachment_count=FileAsset.objects.filter( + entity_identifier=OuterRef("id"), + entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, ) .order_by() .annotate(count=Func(F("id"), function="Count")) diff --git a/apiserver/plane/app/views/dashboard/base.py b/apiserver/plane/app/views/dashboard/base.py index 6a72fed28..19ff8dfb4 100644 --- a/apiserver/plane/app/views/dashboard/base.py +++ b/apiserver/plane/app/views/dashboard/base.py @@ -36,7 +36,7 @@ from plane.db.models import ( DashboardWidget, Issue, IssueActivity, - IssueAttachment, + FileAsset, IssueLink, IssueRelation, Project, @@ -56,7 +56,8 @@ def dashboard_overview_stats(self, request, slug): project__project_projectmember__member=request.user, workspace__slug=slug, assignees__in=[request.user], - ).filter( + ) + .filter( Q( project__project_projectmember__role=5, project__guest_view_all_features=True, @@ -83,7 +84,8 @@ def dashboard_overview_stats(self, request, slug): project__project_projectmember__member=request.user, workspace__slug=slug, assignees__in=[request.user], - ).filter( + ) + .filter( Q( project__project_projectmember__role=5, project__guest_view_all_features=True, @@ -108,7 +110,8 @@ def dashboard_overview_stats(self, request, slug): project__project_projectmember__is_active=True, project__project_projectmember__member=request.user, created_by_id=request.user.id, - ).filter( + ) + .filter( Q( project__project_projectmember__role=5, project__guest_view_all_features=True, @@ -134,7 +137,8 @@ def dashboard_overview_stats(self, request, slug): project__project_projectmember__member=request.user, assignees__in=[request.user], state__group="completed", - ).filter( + ) + .filter( Q( project__project_projectmember__role=5, project__guest_view_all_features=True, @@ -195,8 +199,9 @@ def dashboard_assigned_issues(self, request, slug): .values("count") ) .annotate( - attachment_count=IssueAttachment.objects.filter( - issue=OuterRef("id") + attachment_count=FileAsset.objects.filter( + issue_id=OuterRef("id"), + entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, ) .order_by() .annotate(count=Func(F("id"), function="Count")) @@ -358,8 +363,9 @@ def dashboard_created_issues(self, request, slug): .values("count") ) .annotate( - attachment_count=IssueAttachment.objects.filter( - issue=OuterRef("id") + attachment_count=FileAsset.objects.filter( + issue_id=OuterRef("id"), + entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, ) .order_by() .annotate(count=Func(F("id"), function="Count")) diff --git a/apiserver/plane/app/views/inbox/base.py b/apiserver/plane/app/views/inbox/base.py index 4a32d9930..81e8ad2f9 100644 --- a/apiserver/plane/app/views/inbox/base.py +++ b/apiserver/plane/app/views/inbox/base.py @@ -23,7 +23,7 @@ from plane.db.models import ( Issue, State, IssueLink, - IssueAttachment, + FileAsset, Project, ProjectMember, ) @@ -120,8 +120,9 @@ class InboxIssueViewSet(BaseViewSet): .values("count") ) .annotate( - attachment_count=IssueAttachment.objects.filter( - issue=OuterRef("id") + attachment_count=FileAsset.objects.filter( + issue_id=OuterRef("id"), + entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, ) .order_by() .annotate(count=Func(F("id"), function="Count")) diff --git a/apiserver/plane/app/views/issue/archive.py b/apiserver/plane/app/views/issue/archive.py index 4817ea90e..83fc5d0c2 100644 --- a/apiserver/plane/app/views/issue/archive.py +++ b/apiserver/plane/app/views/issue/archive.py @@ -30,7 +30,7 @@ from plane.app.serializers import ( from plane.bgtasks.issue_activities_task import issue_activity from plane.db.models import ( Issue, - IssueAttachment, + FileAsset, IssueLink, IssueSubscriber, IssueReaction, @@ -79,8 +79,9 @@ class IssueArchiveViewSet(BaseViewSet): .values("count") ) .annotate( - attachment_count=IssueAttachment.objects.filter( - issue=OuterRef("id") + attachment_count=FileAsset.objects.filter( + issue_id=OuterRef("id"), + entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, ) .order_by() .annotate(count=Func(F("id"), function="Count")) @@ -236,12 +237,6 @@ class IssueArchiveViewSet(BaseViewSet): ), ) ) - .prefetch_related( - Prefetch( - "issue_attachment", - queryset=IssueAttachment.objects.select_related("issue"), - ) - ) .prefetch_related( Prefetch( "issue_link", diff --git a/apiserver/plane/app/views/issue/attachment.py b/apiserver/plane/app/views/issue/attachment.py index 434c72d1d..816bc9793 100644 --- a/apiserver/plane/app/views/issue/attachment.py +++ b/apiserver/plane/app/views/issue/attachment.py @@ -1,9 +1,12 @@ # Python imports import json +import uuid # Django imports from django.utils import timezone from django.core.serializers.json import DjangoJSONEncoder +from django.conf import settings +from django.http import HttpResponseRedirect # Third Party imports from rest_framework.response import Response @@ -13,21 +16,28 @@ from rest_framework.parsers import MultiPartParser, FormParser # Module imports from .. import BaseAPIView from plane.app.serializers import IssueAttachmentSerializer -from plane.db.models import IssueAttachment +from plane.db.models import FileAsset, Workspace from plane.bgtasks.issue_activities_task import issue_activity from plane.app.permissions import allow_permission, ROLE +from plane.settings.storage import S3Storage class IssueAttachmentEndpoint(BaseAPIView): serializer_class = IssueAttachmentSerializer - model = IssueAttachment + model = FileAsset parser_classes = (MultiPartParser, FormParser) @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) def post(self, request, slug, project_id, issue_id): serializer = IssueAttachmentSerializer(data=request.data) + workspace = Workspace.objects.get(slug=slug) if serializer.is_valid(): - serializer.save(project_id=project_id, issue_id=issue_id) + serializer.save( + project_id=project_id, + issue_id=issue_id, + workspace_id=workspace.id, + entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, + ) issue_activity.delay( type="attachment.activity.created", requested_data=None, @@ -45,9 +55,9 @@ class IssueAttachmentEndpoint(BaseAPIView): return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - @allow_permission([ROLE.ADMIN], creator=True, model=IssueAttachment) + @allow_permission([ROLE.ADMIN], creator=True, model=FileAsset) def delete(self, request, slug, project_id, issue_id, pk): - issue_attachment = IssueAttachment.objects.get(pk=pk) + issue_attachment = FileAsset.objects.get(pk=pk) issue_attachment.asset.delete(save=False) issue_attachment.delete() issue_activity.delay( @@ -72,8 +82,182 @@ class IssueAttachmentEndpoint(BaseAPIView): ] ) def get(self, request, slug, project_id, issue_id): - issue_attachments = IssueAttachment.objects.filter( + issue_attachments = FileAsset.objects.filter( issue_id=issue_id, workspace__slug=slug, project_id=project_id ) serializer = IssueAttachmentSerializer(issue_attachments, many=True) return Response(serializer.data, status=status.HTTP_200_OK) + + +class IssueAttachmentV2Endpoint(BaseAPIView): + + serializer_class = IssueAttachmentSerializer + model = FileAsset + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) + def post(self, request, slug, project_id, issue_id): + name = request.data.get("name") + type = request.data.get("type", False) + size = int(request.data.get("size", settings.FILE_SIZE_LIMIT)) + + if not type or type not in settings.ATTACHMENT_MIME_TYPES: + return Response( + { + "error": "Invalid file type.", + "status": False, + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Get the workspace + workspace = Workspace.objects.get(slug=slug) + + # asset key + asset_key = f"{workspace.id}/{uuid.uuid4().hex}-{name}" + + # Get the size limit + size_limit = min(size, settings.FILE_SIZE_LIMIT) + + # Create a File Asset + asset = FileAsset.objects.create( + attributes={ + "name": name, + "type": type, + "size": size_limit, + }, + asset=asset_key, + size=size_limit, + workspace_id=workspace.id, + created_by=request.user, + issue_id=issue_id, + project_id=project_id, + entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, + ) + + # Get the presigned URL + storage = S3Storage(request=request) + # Generate a presigned URL to share an S3 object + presigned_url = storage.generate_presigned_post( + object_name=asset_key, + file_type=type, + file_size=size_limit, + ) + # Return the presigned URL + return Response( + { + "upload_data": presigned_url, + "asset_id": str(asset.id), + "attachment": IssueAttachmentSerializer(asset).data, + "asset_url": asset.asset_url, + }, + status=status.HTTP_200_OK, + ) + + @allow_permission([ROLE.ADMIN], creator=True, model=FileAsset) + def delete(self, request, slug, project_id, issue_id, pk): + issue_attachment = FileAsset.objects.get( + pk=pk, workspace__slug=slug, project_id=project_id + ) + issue_attachment.is_deleted = True + issue_attachment.deleted_at = timezone.now() + issue_attachment.save() + + issue_activity.delay( + type="attachment.activity.deleted", + requested_data=None, + actor_id=str(self.request.user.id), + issue_id=str(issue_id), + project_id=str(project_id), + current_instance=None, + epoch=int(timezone.now().timestamp()), + notification=True, + origin=request.META.get("HTTP_ORIGIN"), + ) + + return Response(status=status.HTTP_204_NO_CONTENT) + + @allow_permission( + [ + ROLE.ADMIN, + ROLE.MEMBER, + ROLE.GUEST, + ] + ) + def get(self, request, slug, project_id, issue_id, pk=None): + if pk: + # Get the asset + asset = FileAsset.objects.get( + id=pk, workspace__slug=slug, project_id=project_id + ) + + # Check if the asset is uploaded + if not asset.is_uploaded: + return Response( + { + "error": "The asset is not uploaded.", + "status": False, + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + storage = S3Storage(request=request) + presigned_url = storage.generate_presigned_url( + object_name=asset.asset.name, + disposition="attachment", + filename=asset.attributes.get("name"), + ) + return HttpResponseRedirect(presigned_url) + + # Get all the attachments + issue_attachments = FileAsset.objects.filter( + issue_id=issue_id, + entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, + workspace__slug=slug, + project_id=project_id, + is_uploaded=True, + ) + # Serialize the attachments + serializer = IssueAttachmentSerializer(issue_attachments, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + + @allow_permission( + [ + ROLE.ADMIN, + ROLE.MEMBER, + ROLE.GUEST, + ] + ) + def patch(self, request, slug, project_id, issue_id, pk): + issue_attachment = FileAsset.objects.get( + pk=pk, workspace__slug=slug, project_id=project_id + ) + serializer = IssueAttachmentSerializer(issue_attachment) + + # Send this activity only if the attachment is not uploaded before + if not issue_attachment.is_uploaded: + issue_activity.delay( + type="attachment.activity.created", + requested_data=None, + actor_id=str(self.request.user.id), + issue_id=str(self.kwargs.get("issue_id", None)), + project_id=str(self.kwargs.get("project_id", None)), + current_instance=json.dumps( + serializer.data, + cls=DjangoJSONEncoder, + ), + epoch=int(timezone.now().timestamp()), + notification=True, + origin=request.META.get("HTTP_ORIGIN"), + ) + + # Update the attachment + issue_attachment.is_uploaded = True + + # Get the storage metadata + if issue_attachment.storage_metadata is None: + storage = S3Storage(request=request) + issue_attachment.storage_metadata = storage.get_object_metadata( + issue_attachment.asset.name + ) + issue_attachment.save() + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apiserver/plane/app/views/issue/base.py b/apiserver/plane/app/views/issue/base.py index eca14018f..c6a45f846 100644 --- a/apiserver/plane/app/views/issue/base.py +++ b/apiserver/plane/app/views/issue/base.py @@ -35,7 +35,7 @@ from plane.app.serializers import ( from plane.bgtasks.issue_activities_task import issue_activity from plane.db.models import ( Issue, - IssueAttachment, + FileAsset, IssueLink, IssueUserProperty, IssueReaction, @@ -91,8 +91,9 @@ class IssueListEndpoint(BaseAPIView): .values("count") ) .annotate( - attachment_count=IssueAttachment.objects.filter( - issue=OuterRef("id") + attachment_count=FileAsset.objects.filter( + issue_id=OuterRef("id"), + entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, ) .order_by() .annotate(count=Func(F("id"), function="Count")) @@ -214,8 +215,9 @@ class IssueViewSet(BaseViewSet): .values("count") ) .annotate( - attachment_count=IssueAttachment.objects.filter( - issue=OuterRef("id") + attachment_count=FileAsset.objects.filter( + issue_id=OuterRef("id"), + entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, ) .order_by() .annotate(count=Func(F("id"), function="Count")) @@ -500,12 +502,6 @@ class IssueViewSet(BaseViewSet): ), ) ) - .prefetch_related( - Prefetch( - "issue_attachment", - queryset=IssueAttachment.objects.select_related("issue"), - ) - ) .prefetch_related( Prefetch( "issue_link", @@ -759,14 +755,6 @@ class IssuePaginatedViewSet(BaseViewSet): .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") - ) .annotate( sub_issues_count=Issue.issue_objects.filter( parent=OuterRef("id") diff --git a/apiserver/plane/app/views/issue/relation.py b/apiserver/plane/app/views/issue/relation.py index e69614747..f3cd418ec 100644 --- a/apiserver/plane/app/views/issue/relation.py +++ b/apiserver/plane/app/views/issue/relation.py @@ -24,7 +24,7 @@ from plane.db.models import ( Project, IssueRelation, Issue, - IssueAttachment, + FileAsset, IssueLink, ) from plane.bgtasks.issue_activities_task import issue_activity @@ -91,8 +91,9 @@ class IssueRelationViewSet(BaseViewSet): .values("count") ) .annotate( - attachment_count=IssueAttachment.objects.filter( - issue=OuterRef("id") + attachment_count=FileAsset.objects.filter( + issue_id=OuterRef("id"), + entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, ) .order_by() .annotate(count=Func(F("id"), function="Count")) diff --git a/apiserver/plane/app/views/issue/sub_issue.py b/apiserver/plane/app/views/issue/sub_issue.py index 9496f1751..19a3db151 100644 --- a/apiserver/plane/app/views/issue/sub_issue.py +++ b/apiserver/plane/app/views/issue/sub_issue.py @@ -28,7 +28,7 @@ from plane.app.permissions import ProjectEntityPermission from plane.db.models import ( Issue, IssueLink, - IssueAttachment, + FileAsset, ) from plane.bgtasks.issue_activities_task import issue_activity from plane.utils.user_timezone_converter import user_timezone_converter @@ -56,8 +56,9 @@ class SubIssuesEndpoint(BaseAPIView): .values("count") ) .annotate( - attachment_count=IssueAttachment.objects.filter( - issue=OuterRef("id") + attachment_count=FileAsset.objects.filter( + issue_id=OuterRef("id"), + entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, ) .order_by() .annotate(count=Func(F("id"), function="Count")) diff --git a/apiserver/plane/app/views/module/archive.py b/apiserver/plane/app/views/module/archive.py index b38d83487..f9d23cb4e 100644 --- a/apiserver/plane/app/views/module/archive.py +++ b/apiserver/plane/app/views/module/archive.py @@ -14,9 +14,12 @@ from django.db.models import ( Value, Sum, FloatField, + Case, + When, ) -from django.db.models.functions import Coalesce, Cast +from django.db.models.functions import Coalesce, Cast, Concat from django.utils import timezone +from django.db import models # Third party imports from rest_framework import status @@ -364,12 +367,31 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView): .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")) + .annotate( + avatar_url=Case( + # If `avatar_asset` exists, use it to generate the asset URL + When( + assignees__avatar_asset__isnull=False, + then=Concat( + Value("/api/assets/v2/static/"), + "assignees__avatar_asset", # Assuming avatar_asset has an id or relevant field + Value("/"), + ), + ), + # If `avatar_asset` is None, fall back to using `avatar` field directly + When( + assignees__avatar_asset__isnull=True, + then="assignees__avatar", + ), + default=Value(None), + output_field=models.CharField(), + ) + ) .values( "first_name", "last_name", "assignee_id", - "avatar", + "avatar_url", "display_name", ) .annotate( @@ -437,7 +459,9 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView): ) .order_by("label_name") ) - data["estimate_distribution"]["assignees"] = assignee_distribution + data["estimate_distribution"][ + "assignees" + ] = assignee_distribution data["estimate_distribution"]["labels"] = label_distribution if modules and modules.start_date and modules.target_date: @@ -461,12 +485,31 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView): .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")) + .annotate( + avatar_url=Case( + # If `avatar_asset` exists, use it to generate the asset URL + When( + assignees__avatar_asset__isnull=False, + then=Concat( + Value("/api/assets/v2/static/"), + "assignees__avatar_asset", # Assuming avatar_asset has an id or relevant field + Value("/"), + ), + ), + # If `avatar_asset` is None, fall back to using `avatar` field directly + When( + assignees__avatar_asset__isnull=True, + then="assignees__avatar", + ), + default=Value(None), + output_field=models.CharField(), + ) + ) .values( "first_name", "last_name", "assignee_id", - "avatar", + "avatar_url", "display_name", ) .annotate( diff --git a/apiserver/plane/app/views/module/base.py b/apiserver/plane/app/views/module/base.py index eafb63b0e..ba1ae840f 100644 --- a/apiserver/plane/app/views/module/base.py +++ b/apiserver/plane/app/views/module/base.py @@ -18,8 +18,11 @@ from django.db.models import ( Value, Sum, FloatField, + Case, + When, ) -from django.db.models.functions import Coalesce, Cast +from django.db import models +from django.db.models.functions import Coalesce, Cast, Concat from django.core.serializers.json import DjangoJSONEncoder from django.utils import timezone @@ -481,12 +484,31 @@ class ModuleViewSet(BaseViewSet): .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")) + .annotate( + avatar_url=Case( + # If `avatar_asset` exists, use it to generate the asset URL + When( + assignees__avatar_asset__isnull=False, + then=Concat( + Value("/api/assets/v2/static/"), + "assignees__avatar_asset", # Assuming avatar_asset has an id or relevant field + Value("/"), + ), + ), + # If `avatar_asset` is None, fall back to using `avatar` field directly + When( + assignees__avatar_asset__isnull=True, + then="assignees__avatar", + ), + default=Value(None), + output_field=models.CharField(), + ) + ) .values( "first_name", "last_name", "assignee_id", - "avatar", + "avatar_url", "display_name", ) .annotate( @@ -578,12 +600,31 @@ class ModuleViewSet(BaseViewSet): .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")) + .annotate( + avatar_url=Case( + # If `avatar_asset` exists, use it to generate the asset URL + When( + assignees__avatar_asset__isnull=False, + then=Concat( + Value("/api/assets/v2/static/"), + "assignees__avatar_asset", # Assuming avatar_asset has an id or relevant field + Value("/"), + ), + ), + # If `avatar_asset` is None, fall back to using `avatar` field directly + When( + assignees__avatar_asset__isnull=True, + then="assignees__avatar", + ), + default=Value(None), + output_field=models.CharField(), + ) + ) .values( "first_name", "last_name", "assignee_id", - "avatar", + "avatar_url", "display_name", ) .annotate( diff --git a/apiserver/plane/app/views/module/issue.py b/apiserver/plane/app/views/module/issue.py index eb63890d2..8edced252 100644 --- a/apiserver/plane/app/views/module/issue.py +++ b/apiserver/plane/app/views/module/issue.py @@ -24,7 +24,7 @@ from plane.app.serializers import ( from plane.bgtasks.issue_activities_task import issue_activity from plane.db.models import ( Issue, - IssueAttachment, + FileAsset, IssueLink, ModuleIssue, Project, @@ -73,8 +73,9 @@ class ModuleIssueViewSet(BaseViewSet): .values("count") ) .annotate( - attachment_count=IssueAttachment.objects.filter( - issue=OuterRef("id") + attachment_count=FileAsset.objects.filter( + issue_id=OuterRef("id"), + entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, ) .order_by() .annotate(count=Func(F("id"), function="Count")) diff --git a/apiserver/plane/app/views/page/base.py b/apiserver/plane/app/views/page/base.py index bb4814e47..5e56cc703 100644 --- a/apiserver/plane/app/views/page/base.py +++ b/apiserver/plane/app/views/page/base.py @@ -18,7 +18,7 @@ from django.db.models.functions import Coalesce from rest_framework import status from rest_framework.response import Response - +# Module imports from plane.app.permissions import allow_permission, ROLE from plane.app.serializers import ( PageLogSerializer, @@ -35,10 +35,7 @@ from plane.db.models import ( Project, ) from plane.utils.error_codes import ERROR_CODES - -# Module imports from ..base import BaseAPIView, BaseViewSet - from plane.bgtasks.page_transaction_task import page_transaction from plane.bgtasks.page_version_task import page_version from plane.bgtasks.recent_visited_task import recent_visited_task diff --git a/apiserver/plane/app/views/project/base.py b/apiserver/plane/app/views/project/base.py index 3ca034467..4afc747c3 100644 --- a/apiserver/plane/app/views/project/base.py +++ b/apiserver/plane/app/views/project/base.py @@ -53,6 +53,7 @@ from plane.db.models import ( from plane.utils.cache import cache_response from plane.bgtasks.webhook_task import model_activity from plane.bgtasks.recent_visited_task import recent_visited_task +from plane.utils.exception_logger import log_exception class ProjectViewSet(BaseViewSet): @@ -720,18 +721,22 @@ class ProjectPublicCoverImagesEndpoint(BaseAPIView): "Prefix": "static/project-cover/", } - response = s3.list_objects_v2(**params) - # Extracting file keys from the response - if "Contents" in response: - for content in response["Contents"]: - if not content["Key"].endswith( - "/" - ): # This line ensures we're only getting files, not "sub-folders" - files.append( - f"https://{settings.AWS_STORAGE_BUCKET_NAME}.s3.{settings.AWS_REGION}.amazonaws.com/{content['Key']}" - ) + try: + response = s3.list_objects_v2(**params) + # Extracting file keys from the response + if "Contents" in response: + for content in response["Contents"]: + if not content["Key"].endswith( + "/" + ): # This line ensures we're only getting files, not "sub-folders" + files.append( + f"https://{settings.AWS_STORAGE_BUCKET_NAME}.s3.{settings.AWS_REGION}.amazonaws.com/{content['Key']}" + ) - return Response(files, status=status.HTTP_200_OK) + return Response(files, status=status.HTTP_200_OK) + except Exception as e: + log_exception(e) + return Response([], status=status.HTTP_200_OK) class DeployBoardViewSet(BaseViewSet): diff --git a/apiserver/plane/app/views/view/base.py b/apiserver/plane/app/views/view/base.py index e48871e3b..2579eb1da 100644 --- a/apiserver/plane/app/views/view/base.py +++ b/apiserver/plane/app/views/view/base.py @@ -29,7 +29,7 @@ from plane.app.serializers import ( ) from plane.db.models import ( Issue, - IssueAttachment, + FileAsset, IssueLink, IssueView, Workspace, @@ -213,8 +213,9 @@ class WorkspaceViewIssuesViewSet(BaseViewSet): .values("count") ) .annotate( - attachment_count=IssueAttachment.objects.filter( - issue=OuterRef("id") + attachment_count=FileAsset.objects.filter( + issue_id=OuterRef("id"), + entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, ) .order_by() .annotate(count=Func(F("id"), function="Count")) diff --git a/apiserver/plane/app/views/workspace/user.py b/apiserver/plane/app/views/workspace/user.py index 15e2d01da..fa0bec019 100644 --- a/apiserver/plane/app/views/workspace/user.py +++ b/apiserver/plane/app/views/workspace/user.py @@ -40,7 +40,7 @@ from plane.db.models import ( CycleIssue, Issue, IssueActivity, - IssueAttachment, + FileAsset, IssueLink, IssueSubscriber, Project, @@ -128,8 +128,9 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView): .values("count") ) .annotate( - attachment_count=IssueAttachment.objects.filter( - issue=OuterRef("id") + attachment_count=FileAsset.objects.filter( + entity_identifier=OuterRef("id"), + entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, ) .order_by() .annotate(count=Func(F("id"), function="Count")) @@ -359,8 +360,8 @@ class WorkspaceUserProfileEndpoint(BaseAPIView): "email": user_data.email, "first_name": user_data.first_name, "last_name": user_data.last_name, - "avatar": user_data.avatar, - "cover_image": user_data.cover_image, + "avatar_url": user_data.avatar_url, + "cover_image_url": user_data.cover_image_url, "date_joined": user_data.date_joined, "user_timezone": user_data.user_timezone, "display_name": user_data.display_name, diff --git a/apiserver/plane/bgtasks/analytic_plot_export.py b/apiserver/plane/bgtasks/analytic_plot_export.py index e6788df79..0de64603d 100644 --- a/apiserver/plane/bgtasks/analytic_plot_export.py +++ b/apiserver/plane/bgtasks/analytic_plot_export.py @@ -89,7 +89,7 @@ def get_assignee_details(slug, filters): .distinct("assignees__id") .order_by("assignees__id") .values( - "assignees__avatar", + "assignees__avatar_url", "assignees__display_name", "assignees__first_name", "assignees__last_name", diff --git a/apiserver/plane/bgtasks/email_notification_task.py b/apiserver/plane/bgtasks/email_notification_task.py index 11ec91eb4..f2db0de59 100644 --- a/apiserver/plane/bgtasks/email_notification_task.py +++ b/apiserver/plane/bgtasks/email_notification_task.py @@ -224,7 +224,7 @@ def send_email_notification( { "actor_comments": comment, "actor_detail": { - "avatar_url": actor.avatar, + "avatar_url": f"{base_api}{actor.avatar_url}", "first_name": actor.first_name, "last_name": actor.last_name, }, @@ -241,7 +241,7 @@ def send_email_notification( { "actor_comments": mention, "actor_detail": { - "avatar_url": actor.avatar, + "avatar_url": f"{base_api}{actor.avatar_url}", "first_name": actor.first_name, "last_name": actor.last_name, }, @@ -257,7 +257,7 @@ def send_email_notification( template_data.append( { "actor_detail": { - "avatar_url": actor.avatar, + "avatar_url": f"{base_api}{actor.avatar_url}", "first_name": actor.first_name, "last_name": actor.last_name, }, diff --git a/apiserver/plane/bgtasks/file_asset_task.py b/apiserver/plane/bgtasks/file_asset_task.py index e372355ef..e05ed6d37 100644 --- a/apiserver/plane/bgtasks/file_asset_task.py +++ b/apiserver/plane/bgtasks/file_asset_task.py @@ -1,4 +1,5 @@ # Python imports +import os from datetime import timedelta # Django imports @@ -13,16 +14,14 @@ from plane.db.models import FileAsset @shared_task -def delete_file_asset(): - # file assets to delete - file_assets_to_delete = FileAsset.objects.filter( - Q(is_deleted=True) - & Q(updated_at__lte=timezone.now() - timedelta(days=7)) - ) - - # Delete the file from storage and the file object from the database - for file_asset in file_assets_to_delete: - # Delete the file from storage - file_asset.asset.delete(save=False) - # Delete the file object - file_asset.delete() +def delete_unuploaded_file_asset(): + """This task deletes unuploaded file assets older than a certain number of days.""" + FileAsset.objects.filter( + Q( + created_at__lt=timezone.now() + - timedelta( + days=int(os.environ.get("UNUPLOADED_ASSET_DELETE_DAYS", "7")) + ) + ) + & Q(is_uploaded=False) + ).delete() diff --git a/apiserver/plane/celery.py b/apiserver/plane/celery.py index 459cb8ed6..865ef95fa 100644 --- a/apiserver/plane/celery.py +++ b/apiserver/plane/celery.py @@ -25,7 +25,7 @@ app.conf.beat_schedule = { "schedule": crontab(hour=0, minute=0), }, "check-every-day-to-delete-file-asset": { - "task": "plane.bgtasks.file_asset_task.delete_file_asset", + "task": "plane.bgtasks.file_asset_task.delete_unuploaded_file_asset", "schedule": crontab(hour=0, minute=0), }, "check-every-five-minutes-to-send-email-notifications": { diff --git a/apiserver/plane/db/management/commands/create_bucket.py b/apiserver/plane/db/management/commands/create_bucket.py index bdd0b7014..9313b6b1c 100644 --- a/apiserver/plane/db/management/commands/create_bucket.py +++ b/apiserver/plane/db/management/commands/create_bucket.py @@ -1,67 +1,45 @@ # Python imports +import os import boto3 -import json from botocore.exceptions import ClientError # Django imports from django.core.management import BaseCommand -from django.conf import settings class Command(BaseCommand): help = "Create the default bucket for the instance" - def set_bucket_public_policy(self, s3_client, bucket_name): - public_policy = { - "Version": "2012-10-17", - "Statement": [ - { - "Effect": "Allow", - "Principal": "*", - "Action": ["s3:GetObject"], - "Resource": [f"arn:aws:s3:::{bucket_name}/*"], - } - ], - } - - try: - s3_client.put_bucket_policy( - Bucket=bucket_name, Policy=json.dumps(public_policy) - ) - self.stdout.write( - self.style.SUCCESS( - f"Public read access policy set for bucket '{bucket_name}'." - ) - ) - except ClientError as e: - self.stdout.write( - self.style.ERROR( - f"Error setting public read access policy: {e}" - ) - ) - def handle(self, *args, **options): # Create a session using the credentials from Django settings try: - session = boto3.session.Session( - aws_access_key_id=settings.AWS_ACCESS_KEY_ID, - aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY, + s3_client = boto3.client( + "s3", + endpoint_url=os.environ.get( + "AWS_S3_ENDPOINT_URL" + ), # MinIO endpoint + aws_access_key_id=os.environ.get( + "AWS_ACCESS_KEY_ID" + ), # MinIO access key + aws_secret_access_key=os.environ.get( + "AWS_SECRET_ACCESS_KEY" + ), # MinIO secret key + region_name=os.environ.get("AWS_REGION"), # MinIO region + config=boto3.session.Config(signature_version="s3v4"), ) - # Create an S3 client using the session - s3_client = session.client( - "s3", endpoint_url=settings.AWS_S3_ENDPOINT_URL - ) - bucket_name = settings.AWS_STORAGE_BUCKET_NAME - + # Get the bucket name from the environment + bucket_name = os.environ.get("AWS_S3_BUCKET_NAME") self.stdout.write(self.style.NOTICE("Checking bucket...")) - # Check if the bucket exists s3_client.head_bucket(Bucket=bucket_name) - - self.set_bucket_public_policy(s3_client, bucket_name) + # If the bucket exists, print a success message + self.stdout.write( + self.style.SUCCESS(f"Bucket '{bucket_name}' exists.") + ) + return except ClientError as e: error_code = int(e.response["Error"]["Code"]) - bucket_name = settings.AWS_STORAGE_BUCKET_NAME + bucket_name = os.environ.get("AWS_S3_BUCKET_NAME") if error_code == 404: # Bucket does not exist, create it self.stdout.write( @@ -76,13 +54,16 @@ class Command(BaseCommand): f"Bucket '{bucket_name}' created successfully." ) ) - self.set_bucket_public_policy(s3_client, bucket_name) + + # Handle the exception if the bucket creation fails except ClientError as create_error: self.stdout.write( self.style.ERROR( f"Failed to create bucket: {create_error}" ) ) + + # Handle the exception if access to the bucket is forbidden elif error_code == 403: # Access to the bucket is forbidden self.stdout.write( diff --git a/apiserver/plane/db/management/commands/update_bucket.py b/apiserver/plane/db/management/commands/update_bucket.py new file mode 100644 index 000000000..96027ac26 --- /dev/null +++ b/apiserver/plane/db/management/commands/update_bucket.py @@ -0,0 +1,209 @@ +# Python imports +import os +import boto3 +from botocore.exceptions import ClientError +import json + +# Django imports +from django.core.management import BaseCommand + + +class Command(BaseCommand): + help = "Create the default bucket for the instance" + + def get_s3_client(self): + s3_client = boto3.client( + "s3", + endpoint_url=os.environ.get( + "AWS_S3_ENDPOINT_URL" + ), # MinIO endpoint + aws_access_key_id=os.environ.get( + "AWS_ACCESS_KEY_ID" + ), # MinIO access key + aws_secret_access_key=os.environ.get( + "AWS_SECRET_ACCESS_KEY" + ), # MinIO secret key + region_name=os.environ.get("AWS_REGION"), # MinIO region + config=boto3.session.Config(signature_version="s3v4"), + ) + return s3_client + + # Check if the access key has the required permissions + def check_s3_permissions(self, bucket_name): + s3_client = self.get_s3_client() + permissions = { + "s3:GetObject": False, + "s3:ListBucket": False, + "s3:PutBucketPolicy": False, + "s3:PutObject": False, + } + + # 1. Test s3:ListBucket (attempt to list the bucket contents) + try: + s3_client.list_objects_v2(Bucket=bucket_name) + permissions["s3:ListBucket"] = True + except ClientError as e: + if e.response["Error"]["Code"] == "AccessDenied": + self.stdout.write("ListBucket permission denied.") + else: + self.stdout.write(f"Error in ListBucket: {e}") + + # 2. Test s3:GetObject (attempt to get a specific object) + try: + response = s3_client.list_objects_v2(Bucket=bucket_name) + if "Contents" in response: + test_object_key = response["Contents"][0]["Key"] + s3_client.get_object(Bucket=bucket_name, Key=test_object_key) + permissions["s3:GetObject"] = True + except ClientError as e: + if e.response["Error"]["Code"] == "AccessDenied": + self.stdout.write("GetObject permission denied.") + else: + self.stdout.write(f"Error in GetObject: {e}") + + # 3. Test s3:PutObject (attempt to upload an object) + try: + s3_client.put_object( + Bucket=bucket_name, + Key="test_permission_check.txt", + Body=b"Test", + ) + self.stdout.write("PutObject permission granted.") + permissions["s3:PutObject"] = True + # Clean up + except ClientError as e: + if e.response["Error"]["Code"] == "AccessDenied": + self.stdout.write("PutObject permission denied.") + else: + self.stdout.write(f"Error in PutObject: {e}") + + # Clean up + try: + s3_client.delete_object( + Bucket=bucket_name, Key="test_permission_check.txt" + ) + except ClientError: + self.stdout.write("Coudn't delete test object") + + # 4. Test s3:PutBucketPolicy (attempt to put a bucket policy) + try: + policy = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": "*", + "Action": "s3:GetObject", + "Resource": f"arn:aws:s3:::{bucket_name}/*", + } + ], + } + s3_client.put_bucket_policy( + Bucket=bucket_name, Policy=json.dumps(policy) + ) + permissions["s3:PutBucketPolicy"] = True + except ClientError as e: + if e.response["Error"]["Code"] == "AccessDenied": + self.stdout.write("PutBucketPolicy permission denied.") + else: + self.stdout.write(f"Error in PutBucketPolicy: {e}") + + return permissions + + def generate_bucket_policy(self, bucket_name): + s3_client = self.get_s3_client() + response = s3_client.list_objects_v2(Bucket=bucket_name) + public_object_resource = [] + if "Contents" in response: + for obj in response["Contents"]: + object_key = obj["Key"] + public_object_resource.append( + f"arn:aws:s3:::{bucket_name}/{object_key}" + ) + bucket_policy = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": "*", + "Action": "s3:GetObject", + "Resource": public_object_resource, + } + ], + } + return bucket_policy + + def make_objects_public(self, bucket_name): + # Initialize S3 client + s3_client = self.get_s3_client() + # Get the bucket policy + bucket_policy = self.generate_bucket_policy(bucket_name) + # Apply the policy to the bucket + s3_client.put_bucket_policy( + Bucket=bucket_name, Policy=json.dumps(bucket_policy) + ) + # Print a success message + self.stdout.write( + "Bucket is private, but existing objects remain public." + ) + return + + def handle(self, *args, **options): + # Create a session using the credentials from Django settings + try: + # Check if the bucket exists + s3_client = self.get_s3_client() + # Get the bucket name from the environment + bucket_name = os.environ.get("AWS_S3_BUCKET_NAME") + self.stdout.write(self.style.NOTICE("Checking bucket...")) + # Check if the bucket exists + s3_client.head_bucket(Bucket=bucket_name) + + # If the bucket exists, print a success message + self.stdout.write( + self.style.SUCCESS(f"Bucket '{bucket_name}' exists.") + ) + + # Check the permissions of the access key + permissions = self.check_s3_permissions(bucket_name) + + if all(permissions.values()): + self.stdout.write( + self.style.SUCCESS( + "Access key has the required permissions." + ) + ) + # Making the existing objects public + self.make_objects_public(bucket_name) + + # If the access key does not have PutBucketPolicy permission + # write the bucket policy to a file + if ( + all( + { + k: v + for k, v in permissions.items() + if k != "s3:PutBucketPolicy" + }.values() + ) + and not permissions["s3:PutBucketPolicy"] + ): + self.stdout.write( + self.style.WARNING( + "Access key does not have PutBucketPolicy permission." + ) + ) + # Writing to a file + with open("permissions.json", "w") as f: + f.write( + json.dumps(self.generate_bucket_policy(bucket_name)) + ) + self.stdout.write( + self.style.WARNING( + "Permissions have been written to permissions.json." + ) + ) + return + except Exception as ex: + # Handle any other exception + self.stdout.write(self.style.ERROR(f"An error occurred: {ex}")) diff --git a/apiserver/plane/db/migrations/0078_fileasset_comment_fileasset_entity_type_and_more.py b/apiserver/plane/db/migrations/0078_fileasset_comment_fileasset_entity_type_and_more.py new file mode 100644 index 000000000..3839f4e73 --- /dev/null +++ b/apiserver/plane/db/migrations/0078_fileasset_comment_fileasset_entity_type_and_more.py @@ -0,0 +1,179 @@ +# Generated by Django 4.2.15 on 2024-10-09 06:19 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import plane.db.models.asset + + +class Migration(migrations.Migration): + + dependencies = [ + ( + "db", + "0077_draftissue_cycle_user_timezone_project_user_timezone_and_more", + ), + ] + + operations = [ + migrations.AddField( + model_name="fileasset", + name="comment", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="assets", + to="db.issuecomment", + ), + ), + migrations.AddField( + model_name="fileasset", + name="entity_type", + field=models.CharField( + blank=True, + choices=[ + ("ISSUE_ATTACHMENT", "Issue Attachment"), + ("ISSUE_DESCRIPTION", "Issue Description"), + ("COMMENT_DESCRIPTION", "Comment Description"), + ("PAGE_DESCRIPTION", "Page Description"), + ("USER_COVER", "User Cover"), + ("USER_AVATAR", "User Avatar"), + ("WORKSPACE_LOGO", "Workspace Logo"), + ("PROJECT_COVER", "Project Cover"), + ], + max_length=255, + null=True, + ), + ), + migrations.AddField( + model_name="fileasset", + name="external_id", + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AddField( + model_name="fileasset", + name="external_source", + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AddField( + model_name="fileasset", + name="is_uploaded", + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="fileasset", + name="issue", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="assets", + to="db.issue", + ), + ), + migrations.AddField( + model_name="fileasset", + name="page", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="assets", + to="db.page", + ), + ), + migrations.AddField( + model_name="fileasset", + name="project", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="assets", + to="db.project", + ), + ), + migrations.AddField( + model_name="fileasset", + name="size", + field=models.FloatField(default=0), + ), + migrations.AddField( + model_name="fileasset", + name="storage_metadata", + field=models.JSONField(blank=True, default=dict, null=True), + ), + migrations.AddField( + model_name="fileasset", + name="user", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="assets", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AddField( + model_name="project", + name="cover_image_asset", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="project_cover_image", + to="db.fileasset", + ), + ), + migrations.AddField( + model_name="user", + name="avatar_asset", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="user_avatar", + to="db.fileasset", + ), + ), + migrations.AddField( + model_name="user", + name="cover_image_asset", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="user_cover_image", + to="db.fileasset", + ), + ), + migrations.AddField( + model_name="workspace", + name="logo_asset", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="workspace_logo", + to="db.fileasset", + ), + ), + migrations.AlterField( + model_name="fileasset", + name="asset", + field=models.FileField( + max_length=800, upload_to=plane.db.models.asset.get_upload_path + ), + ), + migrations.AlterField( + model_name="integration", + name="avatar_url", + field=models.TextField(blank=True, null=True), + ), + migrations.AlterField( + model_name="project", + name="cover_image", + field=models.TextField(blank=True, null=True), + ), + migrations.AlterField( + model_name="workspace", + name="logo", + field=models.TextField(blank=True, null=True, verbose_name="Logo"), + ), + ] diff --git a/apiserver/plane/db/migrations/0079_auto_20241009_0619.py b/apiserver/plane/db/migrations/0079_auto_20241009_0619.py new file mode 100644 index 000000000..e3fc904a7 --- /dev/null +++ b/apiserver/plane/db/migrations/0079_auto_20241009_0619.py @@ -0,0 +1,64 @@ +# Generated by Django 4.2.15 on 2024-10-09 06:19 + +from django.db import migrations + + +def move_attachment_to_fileasset(apps, schema_editor): + FileAsset = apps.get_model("db", "FileAsset") + IssueAttachment = apps.get_model("db", "IssueAttachment") + + bulk_issue_attachment = [] + for issue_attachment in IssueAttachment.objects.values( + "issue_id", + "project_id", + "workspace_id", + "asset", + "attributes", + "external_source", + "external_id", + "deleted_at", + "created_by_id", + "updated_by_id", + ): + bulk_issue_attachment.append( + FileAsset( + issue_id=issue_attachment["issue_id"], + entity_type="ISSUE_ATTACHMENT", + project_id=issue_attachment["project_id"], + workspace_id=issue_attachment["workspace_id"], + attributes=issue_attachment["attributes"], + asset=issue_attachment["asset"], + external_source=issue_attachment["external_source"], + external_id=issue_attachment["external_id"], + deleted_at=issue_attachment["deleted_at"], + created_by_id=issue_attachment["created_by_id"], + updated_by_id=issue_attachment["updated_by_id"], + size=issue_attachment["attributes"].get("size", 0), + ) + ) + + FileAsset.objects.bulk_create(bulk_issue_attachment, batch_size=1000) + + +def mark_existing_file_uploads(apps, schema_editor): + FileAsset = apps.get_model("db", "FileAsset") + # Mark all existing file uploads as uploaded + FileAsset.objects.update(is_uploaded=True) + + +class Migration(migrations.Migration): + + dependencies = [ + ("db", "0078_fileasset_comment_fileasset_entity_type_and_more"), + ] + + operations = [ + migrations.RunPython( + move_attachment_to_fileasset, + reverse_code=migrations.RunPython.noop, + ), + migrations.RunPython( + mark_existing_file_uploads, + reverse_code=migrations.RunPython.noop, + ), + ] diff --git a/apiserver/plane/db/models/__init__.py b/apiserver/plane/db/models/__init__.py index a6fa6dddb..c9df0f608 100644 --- a/apiserver/plane/db/models/__init__.py +++ b/apiserver/plane/db/models/__init__.py @@ -24,7 +24,6 @@ from .issue import ( Issue, IssueActivity, IssueAssignee, - IssueAttachment, IssueBlocker, IssueComment, IssueLabel, diff --git a/apiserver/plane/db/models/asset.py b/apiserver/plane/db/models/asset.py index a11ba89a4..fb6662e78 100644 --- a/apiserver/plane/db/models/asset.py +++ b/apiserver/plane/db/models/asset.py @@ -5,14 +5,13 @@ from uuid import uuid4 from django.conf import settings from django.core.exceptions import ValidationError from django.db import models -from django.core.validators import FileExtensionValidator # Module import from .base import BaseModel def get_upload_path(instance, filename): - filename = filename[:50] + if instance.workspace_id is not None: return f"{instance.workspace.id}/{uuid4().hex}-{filename}" return f"user-{uuid4().hex}-{filename}" @@ -28,13 +27,26 @@ class FileAsset(BaseModel): A file asset. """ + class EntityTypeContext(models.TextChoices): + ISSUE_ATTACHMENT = "ISSUE_ATTACHMENT" + ISSUE_DESCRIPTION = "ISSUE_DESCRIPTION" + COMMENT_DESCRIPTION = "COMMENT_DESCRIPTION" + PAGE_DESCRIPTION = "PAGE_DESCRIPTION" + USER_COVER = "USER_COVER" + USER_AVATAR = "USER_AVATAR" + WORKSPACE_LOGO = "WORKSPACE_LOGO" + PROJECT_COVER = "PROJECT_COVER" + attributes = models.JSONField(default=dict) asset = models.FileField( upload_to=get_upload_path, - validators=[ - FileExtensionValidator(allowed_extensions=["jpg", "jpeg", "png"]), - file_size, - ], + max_length=800, + ) + user = models.ForeignKey( + "db.User", + on_delete=models.CASCADE, + null=True, + related_name="assets", ) workspace = models.ForeignKey( "db.Workspace", @@ -42,8 +54,43 @@ class FileAsset(BaseModel): null=True, related_name="assets", ) + project = models.ForeignKey( + "db.Project", + on_delete=models.CASCADE, + null=True, + related_name="assets", + ) + issue = models.ForeignKey( + "db.Issue", + on_delete=models.CASCADE, + null=True, + related_name="assets", + ) + comment = models.ForeignKey( + "db.IssueComment", + on_delete=models.CASCADE, + null=True, + related_name="assets", + ) + page = models.ForeignKey( + "db.Page", + on_delete=models.CASCADE, + null=True, + related_name="assets", + ) + entity_type = models.CharField( + max_length=255, + choices=EntityTypeContext.choices, + null=True, + blank=True, + ) is_deleted = models.BooleanField(default=False) is_archived = models.BooleanField(default=False) + external_id = models.CharField(max_length=255, null=True, blank=True) + external_source = models.CharField(max_length=255, null=True, blank=True) + size = models.FloatField(default=0) + is_uploaded = models.BooleanField(default=False) + storage_metadata = models.JSONField(default=dict, null=True, blank=True) class Meta: verbose_name = "File Asset" @@ -53,3 +100,25 @@ class FileAsset(BaseModel): def __str__(self): return str(self.asset) + + @property + def asset_url(self): + if ( + self.entity_type == self.EntityTypeContext.WORKSPACE_LOGO + or self.entity_type == self.EntityTypeContext.USER_AVATAR + or self.entity_type == self.EntityTypeContext.USER_COVER + or self.entity_type == self.EntityTypeContext.PROJECT_COVER + ): + return f"/api/assets/v2/static/{self.id}/" + + if self.entity_type == self.EntityTypeContext.ISSUE_ATTACHMENT: + return f"/api/assets/v2/workspaces/{self.workspace.slug}/projects/{self.project_id}/issues/{self.issue_id}/attachments/{self.id}/" + + if self.entity_type in [ + self.EntityTypeContext.ISSUE_DESCRIPTION, + self.EntityTypeContext.COMMENT_DESCRIPTION, + self.EntityTypeContext.PAGE_DESCRIPTION, + ]: + return f"/api/assets/v2/workspaces/{self.workspace.slug}/projects/{self.project_id}/{self.id}/" + + return None diff --git a/apiserver/plane/db/models/integration/base.py b/apiserver/plane/db/models/integration/base.py index 0c68adfd2..3c296895f 100644 --- a/apiserver/plane/db/models/integration/base.py +++ b/apiserver/plane/db/models/integration/base.py @@ -29,7 +29,7 @@ class Integration(AuditModel): redirect_url = models.TextField(blank=True) metadata = models.JSONField(default=dict) verified = models.BooleanField(default=False) - avatar_url = models.URLField(blank=True, null=True) + avatar_url = models.TextField(blank=True, null=True) def __str__(self): """Return provider of the integration""" diff --git a/apiserver/plane/db/models/project.py b/apiserver/plane/db/models/project.py index 3f784b399..f970b121c 100644 --- a/apiserver/plane/db/models/project.py +++ b/apiserver/plane/db/models/project.py @@ -99,7 +99,14 @@ class Project(BaseModel): is_time_tracking_enabled = models.BooleanField(default=False) is_issue_type_enabled = models.BooleanField(default=False) guest_view_all_features = models.BooleanField(default=False) - cover_image = models.URLField(blank=True, null=True, max_length=800) + cover_image = models.TextField(blank=True, null=True) + cover_image_asset = models.ForeignKey( + "db.FileAsset", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="project_cover_image", + ) estimate = models.ForeignKey( "db.Estimate", on_delete=models.SET_NULL, @@ -126,6 +133,18 @@ class Project(BaseModel): max_length=255, default="UTC", choices=TIMEZONE_CHOICES ) + @property + def cover_image_url(self): + # Return cover image url + if self.cover_image_asset: + return self.cover_image_asset.asset_url + + # Return cover image url + if self.cover_image: + return self.cover_image + + return None + def __str__(self): """Return name of the project""" return f"{self.name} <{self.workspace.name}>" diff --git a/apiserver/plane/db/models/user.py b/apiserver/plane/db/models/user.py index 2a88df8b6..d8a25c291 100644 --- a/apiserver/plane/db/models/user.py +++ b/apiserver/plane/db/models/user.py @@ -17,6 +17,7 @@ from django.dispatch import receiver from django.utils import timezone # Module imports +from plane.db.models import FileAsset from ..mixins import TimeAuditModel @@ -48,8 +49,24 @@ class User(AbstractBaseUser, PermissionsMixin): display_name = models.CharField(max_length=255, default="") first_name = models.CharField(max_length=255, blank=True) last_name = models.CharField(max_length=255, blank=True) + # avatar avatar = models.TextField(blank=True) + avatar_asset = models.ForeignKey( + FileAsset, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="user_avatar", + ) + # cover image cover_image = models.URLField(blank=True, null=True, max_length=800) + cover_image_asset = models.ForeignKey( + FileAsset, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="user_cover_image", + ) # tracking metrics date_joined = models.DateTimeField( @@ -111,6 +128,28 @@ class User(AbstractBaseUser, PermissionsMixin): def __str__(self): return f"{self.username} <{self.email}>" + @property + def avatar_url(self): + # Return the logo asset url if it exists + if self.avatar_asset: + return self.avatar_asset.asset_url + + # Return the logo url if it exists + if self.avatar: + return self.avatar + return None + + @property + def cover_image_url(self): + # Return the logo asset url if it exists + if self.cover_image_asset: + return self.cover_image_asset.asset_url + + # Return the logo url if it exists + if self.cover_image: + return self.cover_image + return None + def save(self, *args, **kwargs): self.email = self.email.lower().strip() self.mobile_number = self.mobile_number @@ -182,7 +221,11 @@ class Account(TimeAuditModel): ) provider_account_id = models.CharField(max_length=255) provider = models.CharField( - choices=(("google", "Google"), ("github", "Github"), ("gitlab", "GitLab")), + choices=( + ("google", "Google"), + ("github", "Github"), + ("gitlab", "GitLab"), + ), ) access_token = models.TextField() access_token_expired_at = models.DateTimeField(null=True) diff --git a/apiserver/plane/db/models/workspace.py b/apiserver/plane/db/models/workspace.py index 50dac6096..f316aa2b4 100644 --- a/apiserver/plane/db/models/workspace.py +++ b/apiserver/plane/db/models/workspace.py @@ -118,7 +118,14 @@ def slug_validator(value): class Workspace(BaseModel): name = models.CharField(max_length=80, verbose_name="Workspace Name") - logo = models.URLField(verbose_name="Logo", blank=True, null=True) + logo = models.TextField(verbose_name="Logo", blank=True, null=True) + logo_asset = models.ForeignKey( + "db.FileAsset", + on_delete=models.SET_NULL, + related_name="workspace_logo", + blank=True, + null=True, + ) owner = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.CASCADE, @@ -138,6 +145,17 @@ class Workspace(BaseModel): """Return name of the Workspace""" return self.name + @property + def logo_url(self): + # Return the logo asset url if it exists + if self.logo_asset: + return self.logo_asset.asset_url + + # Return the logo url if it exists + if self.logo: + return self.logo + return None + class Meta: verbose_name = "Workspace" verbose_name_plural = "Workspaces" diff --git a/apiserver/plane/license/api/serializers/admin.py b/apiserver/plane/license/api/serializers/admin.py index 848e94ef7..119460b4b 100644 --- a/apiserver/plane/license/api/serializers/admin.py +++ b/apiserver/plane/license/api/serializers/admin.py @@ -11,6 +11,7 @@ class InstanceAdminMeSerializer(BaseSerializer): fields = [ "id", "avatar", + "avatar_url", "cover_image", "date_joined", "display_name", diff --git a/apiserver/plane/settings/common.py b/apiserver/plane/settings/common.py index 5e1ea39a4..6e9c98ce1 100644 --- a/apiserver/plane/settings/common.py +++ b/apiserver/plane/settings/common.py @@ -72,7 +72,6 @@ INSTALLED_APPS = [ "rest_framework", "corsheaders", "django_celery_beat", - "storages", ] # Middlewares @@ -259,7 +258,7 @@ STORAGES = { }, } STORAGES["default"] = { - "BACKEND": "storages.backends.s3boto3.S3Boto3Storage", + "BACKEND": "plane.settings.storage.S3Storage", } AWS_ACCESS_KEY_ID = os.environ.get("AWS_ACCESS_KEY_ID", "access-key") AWS_SECRET_ACCESS_KEY = os.environ.get("AWS_SECRET_ACCESS_KEY", "secret-key") @@ -384,3 +383,61 @@ SPACE_BASE_URL = os.environ.get("SPACE_BASE_URL", None) APP_BASE_URL = os.environ.get("APP_BASE_URL") HARD_DELETE_AFTER_DAYS = int(os.environ.get("HARD_DELETE_AFTER_DAYS", 60)) + +ATTACHMENT_MIME_TYPES = [ + # Images + "image/jpeg", + "image/png", + "image/gif", + "image/svg+xml", + "image/webp", + "image/tiff", + "image/bmp", + # Documents + "application/pdf", + "application/msword", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "application/vnd.ms-excel", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "application/vnd.ms-powerpoint", + "application/vnd.openxmlformats-officedocument.presentationml.presentation", + "text/plain", + "application/rtf", + # Audio + "audio/mpeg", + "audio/wav", + "audio/ogg", + "audio/midi", + "audio/x-midi", + "audio/aac", + "audio/flac", + "audio/x-m4a", + # Video + "video/mp4", + "video/mpeg", + "video/ogg", + "video/webm", + "video/quicktime", + "video/x-msvideo", + "video/x-ms-wmv", + # Archives + "application/zip", + "application/x-rar-compressed", + "application/x-tar", + "application/gzip", + # 3D Models + "model/gltf-binary", + "model/gltf+json", + "application/octet-stream", # for .obj files, but be cautious + # Fonts + "font/ttf", + "font/otf", + "font/woff", + "font/woff2", + # Other + "text/css", + "text/javascript", + "application/json", + "text/xml", + "application/xml", +] diff --git a/apiserver/plane/settings/storage.py b/apiserver/plane/settings/storage.py new file mode 100644 index 000000000..c0d4f440d --- /dev/null +++ b/apiserver/plane/settings/storage.py @@ -0,0 +1,154 @@ +# Python imports +import os + +# Third party imports +import boto3 +from botocore.exceptions import ClientError +from urllib.parse import quote + +# Module imports +from plane.utils.exception_logger import log_exception +from storages.backends.s3boto3 import S3Boto3Storage + + +class S3Storage(S3Boto3Storage): + + def url(self, name, parameters=None, expire=None, http_method=None): + return name + + """S3 storage class to generate presigned URLs for S3 objects""" + + def __init__(self, request=None): + # Get the AWS credentials and bucket name from the environment + self.aws_access_key_id = os.environ.get("AWS_ACCESS_KEY_ID") + # Use the AWS_SECRET_ACCESS_KEY environment variable for the secret key + self.aws_secret_access_key = os.environ.get("AWS_SECRET_ACCESS_KEY") + # Use the AWS_S3_BUCKET_NAME environment variable for the bucket name + self.aws_storage_bucket_name = os.environ.get("AWS_S3_BUCKET_NAME") + # Use the AWS_REGION environment variable for the region + self.aws_region = os.environ.get("AWS_REGION") + # Use the AWS_S3_ENDPOINT_URL environment variable for the endpoint URL + self.aws_s3_endpoint_url = os.environ.get( + "AWS_S3_ENDPOINT_URL" + ) or os.environ.get("MINIO_ENDPOINT_URL") + + if os.environ.get("USE_MINIO") == "1": + # Create an S3 client for MinIO + self.s3_client = boto3.client( + "s3", + aws_access_key_id=self.aws_access_key_id, + aws_secret_access_key=self.aws_secret_access_key, + region_name=self.aws_region, + endpoint_url=f"{request.scheme}://{request.get_host()}", + config=boto3.session.Config(signature_version="s3v4"), + ) + else: + # Create an S3 client + self.s3_client = boto3.client( + "s3", + aws_access_key_id=self.aws_access_key_id, + aws_secret_access_key=self.aws_secret_access_key, + region_name=self.aws_region, + endpoint_url=self.aws_s3_endpoint_url, + config=boto3.session.Config(signature_version="s3v4"), + ) + + def generate_presigned_post( + self, object_name, file_type, file_size, expiration=3600 + ): + """Generate a presigned URL to upload an S3 object""" + fields = { + "Content-Type": file_type, + } + + conditions = [ + {"bucket": self.aws_storage_bucket_name}, + ["content-length-range", 1, file_size], + {"Content-Type": file_type}, + ] + + # Add condition for the object name (key) + if object_name.startswith("${filename}"): + conditions.append( + ["starts-with", "$key", object_name[: -len("${filename}")]] + ) + else: + fields["key"] = object_name + conditions.append({"key": object_name}) + + # Generate the presigned POST URL + try: + # Generate a presigned URL for the S3 object + response = self.s3_client.generate_presigned_post( + Bucket=self.aws_storage_bucket_name, + Key=object_name, + Fields=fields, + Conditions=conditions, + ExpiresIn=expiration, + ) + # Handle errors + except ClientError as e: + print(f"Error generating presigned POST URL: {e}") + return None + + return response + + def _get_content_disposition(self, disposition, filename=None): + """Helper method to generate Content-Disposition header value""" + if filename: + # Encode the filename to handle special characters + encoded_filename = quote(filename) + return f"{disposition}; filename*=UTF-8''{encoded_filename}" + return disposition + + def generate_presigned_url( + self, + object_name, + expiration=3600, + http_method="GET", + disposition="inline", + filename=None, + ): + content_disposition = self._get_content_disposition( + disposition, filename + ) + """Generate a presigned URL to share an S3 object""" + try: + response = self.s3_client.generate_presigned_url( + "get_object", + Params={ + "Bucket": self.aws_storage_bucket_name, + "Key": str(object_name), + "ResponseContentDisposition": content_disposition, + }, + ExpiresIn=expiration, + HttpMethod=http_method, + ) + except ClientError as e: + log_exception(e) + return None + + # The response contains the presigned URL + return response + + def get_object_metadata(self, object_name): + """Get the metadata for an S3 object""" + try: + response = self.s3_client.head_object( + Bucket=self.aws_storage_bucket_name, Key=object_name + ) + except ClientError as e: + log_exception(e) + return None + + return { + "ContentType": response.get("ContentType"), + "ContentLength": response.get("ContentLength"), + "LastModified": ( + response.get("LastModified").isoformat() + if response.get("LastModified") + else None + ), + "ETag": response.get("ETag"), + "Metadata": response.get("Metadata", {}), + } diff --git a/apiserver/plane/space/serializer/issue.py b/apiserver/plane/space/serializer/issue.py index 401e7d719..d7447681f 100644 --- a/apiserver/plane/space/serializer/issue.py +++ b/apiserver/plane/space/serializer/issue.py @@ -22,7 +22,7 @@ from plane.db.models import ( CycleIssue, ModuleIssue, IssueLink, - IssueAttachment, + FileAsset, IssueReaction, CommentReaction, IssueVote, @@ -174,7 +174,7 @@ class IssueLinkSerializer(BaseSerializer): class IssueAttachmentSerializer(BaseSerializer): class Meta: - model = IssueAttachment + model = FileAsset fields = "__all__" read_only_fields = [ "created_by", diff --git a/apiserver/plane/space/serializer/user.py b/apiserver/plane/space/serializer/user.py index e206073f7..13cd2e45e 100644 --- a/apiserver/plane/space/serializer/user.py +++ b/apiserver/plane/space/serializer/user.py @@ -13,6 +13,7 @@ class UserLiteSerializer(BaseSerializer): "first_name", "last_name", "avatar", + "avatar_url", "is_bot", "display_name", ] diff --git a/apiserver/plane/space/urls/__init__.py b/apiserver/plane/space/urls/__init__.py index 054026b00..418665a0b 100644 --- a/apiserver/plane/space/urls/__init__.py +++ b/apiserver/plane/space/urls/__init__.py @@ -1,10 +1,12 @@ from .inbox import urlpatterns as inbox_urls from .issue import urlpatterns as issue_urls from .project import urlpatterns as project_urls +from .asset import urlpatterns as asset_urls urlpatterns = [ *inbox_urls, *issue_urls, *project_urls, + *asset_urls, ] diff --git a/apiserver/plane/space/urls/asset.py b/apiserver/plane/space/urls/asset.py new file mode 100644 index 000000000..2a5c30a22 --- /dev/null +++ b/apiserver/plane/space/urls/asset.py @@ -0,0 +1,32 @@ +# Django imports +from django.urls import path + +# Module imports +from plane.space.views import ( + EntityAssetEndpoint, + AssetRestoreEndpoint, + EntityBulkAssetEndpoint, +) + +urlpatterns = [ + path( + "assets/v2/anchor//", + EntityAssetEndpoint.as_view(), + name="entity-asset", + ), + path( + "assets/v2/anchor///", + EntityAssetEndpoint.as_view(), + name="entity-asset", + ), + path( + "assets/v2/anchor//restore//", + AssetRestoreEndpoint.as_view(), + name="asset-restore", + ), + path( + "assets/v2/anchor///bulk/", + EntityBulkAssetEndpoint.as_view(), + name="entity-bulk-asset", + ), +] diff --git a/apiserver/plane/space/utils/grouper.py b/apiserver/plane/space/utils/grouper.py index 9a3cde7ad..a1eb16b9c 100644 --- a/apiserver/plane/space/utils/grouper.py +++ b/apiserver/plane/space/utils/grouper.py @@ -1,8 +1,17 @@ # Django imports from django.contrib.postgres.aggregates import ArrayAgg from django.contrib.postgres.fields import ArrayField -from django.db.models import Q, UUIDField, Value, F, Case, When, JSONField -from django.db.models.functions import Coalesce, JSONObject +from django.db.models import ( + Q, + UUIDField, + Value, + F, + Case, + When, + JSONField, + CharField, +) +from django.db.models.functions import Coalesce, JSONObject, Concat # Module imports from plane.db.models import ( @@ -16,6 +25,7 @@ from plane.db.models import ( WorkspaceMember, ) + def issue_queryset_grouper(queryset, group_by, sub_group_by): FIELD_MAPPER = { @@ -98,18 +108,26 @@ def issue_on_results(issues, group_by, sub_group_by): first_name=F("votes__actor__first_name"), last_name=F("votes__actor__last_name"), avatar=F("votes__actor__avatar"), + avatar_url=Case( + When( + votes__actor__avatar_asset__isnull=False, + then=Concat( + Value("/api/assets/v2/static/"), + F("votes__actor__avatar_asset"), + Value("/"), + ), + ), + default=F("votes__actor__avatar"), + output_field=CharField(), + ), display_name=F("votes__actor__display_name"), - ) + ), ), ), default=None, output_field=JSONField(), ), - filter=Case( - When(votes__isnull=False, then=True), - default=False, - output_field=JSONField(), - ), + filter=Q(votes__isnull=False), distinct=True, ), reaction_items=ArrayAgg( @@ -123,18 +141,30 @@ def issue_on_results(issues, group_by, sub_group_by): first_name=F("issue_reactions__actor__first_name"), last_name=F("issue_reactions__actor__last_name"), avatar=F("issue_reactions__actor__avatar"), - display_name=F("issue_reactions__actor__display_name"), + avatar_url=Case( + When( + issue_reactions__actor__avatar_asset__isnull=False, + then=Concat( + Value("/api/assets/v2/static/"), + F( + "issue_reactions__actor__avatar_asset" + ), + Value("/"), + ), + ), + default=F("issue_reactions__actor__avatar"), + output_field=CharField(), + ), + display_name=F( + "issue_reactions__actor__display_name" + ), ), ), ), default=None, output_field=JSONField(), ), - filter=Case( - When(issue_reactions__isnull=False, then=True), - default=False, - output_field=JSONField(), - ), + filter=Q(issue_reactions__isnull=False), distinct=True, ), ).values(*required_fields, "vote_items", "reaction_items") diff --git a/apiserver/plane/space/views/__init__.py b/apiserver/plane/space/views/__init__.py index f5e860d87..4210c1ec1 100644 --- a/apiserver/plane/space/views/__init__.py +++ b/apiserver/plane/space/views/__init__.py @@ -23,3 +23,9 @@ from .module import ProjectModulesEndpoint from .state import ProjectStatesEndpoint from .label import ProjectLabelsEndpoint + +from .asset import ( + EntityAssetEndpoint, + AssetRestoreEndpoint, + EntityBulkAssetEndpoint, +) diff --git a/apiserver/plane/space/views/asset.py b/apiserver/plane/space/views/asset.py new file mode 100644 index 000000000..1e8cfcaf3 --- /dev/null +++ b/apiserver/plane/space/views/asset.py @@ -0,0 +1,280 @@ +# Python imports +import uuid + +# Django imports +from django.conf import settings +from django.http import HttpResponseRedirect +from django.utils import timezone + +# Third party imports +from rest_framework import status +from rest_framework.permissions import AllowAny, IsAuthenticated +from rest_framework.response import Response + +# Module imports +from .base import BaseAPIView +from plane.db.models import DeployBoard, FileAsset +from plane.settings.storage import S3Storage + + +class EntityAssetEndpoint(BaseAPIView): + + def get_permissions(self): + if self.request.method == "GET": + permission_classes = [ + AllowAny, + ] + else: + permission_classes = [ + IsAuthenticated, + ] + return [permission() for permission in permission_classes] + + def get(self, request, anchor, pk): + # Get the deploy board + deploy_board = DeployBoard.objects.filter(anchor=anchor).first() + # Check if the project is published + if not deploy_board: + return Response( + {"error": "Requested resource could not be found."}, + status=status.HTTP_404_NOT_FOUND, + ) + + # get the asset id + asset = FileAsset.objects.get( + workspace_id=deploy_board.workspace_id, + pk=pk, + entity_type__in=[ + FileAsset.EntityTypeContext.ISSUE_DESCRIPTION, + FileAsset.EntityTypeContext.COMMENT_DESCRIPTION, + ], + ) + + # Check if the asset is uploaded + if not asset.is_uploaded: + return Response( + { + "error": "The requested asset could not be found.", + }, + status=status.HTTP_404_NOT_FOUND, + ) + + # Get the presigned URL + storage = S3Storage(request=request) + # Generate a presigned URL to share an S3 object + signed_url = storage.generate_presigned_url( + object_name=asset.asset.name, + ) + # Redirect to the signed URL + return HttpResponseRedirect(signed_url) + + def post(self, request, anchor): + # Get the deploy board + deploy_board = DeployBoard.objects.filter( + anchor=anchor, entity_name="project" + ).first() + # Check if the project is published + if not deploy_board: + return Response( + {"error": "Project is not published"}, + status=status.HTTP_404_NOT_FOUND, + ) + + # Get the asset + name = request.data.get("name") + type = request.data.get("type", "image/jpeg") + size = int(request.data.get("size", settings.FILE_SIZE_LIMIT)) + entity_type = request.data.get("entity_type", "") + entity_identifier = request.data.get("entity_identifier") + + # Check if the entity type is allowed + if entity_type not in FileAsset.EntityTypeContext.values: + return Response( + { + "error": "Invalid entity type.", + "status": False, + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Check if the file type is allowed + allowed_types = ["image/jpeg", "image/png", "image/webp"] + if type not in allowed_types: + return Response( + { + "error": "Invalid file type. Only JPEG and PNG files are allowed.", + "status": False, + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + # asset key + asset_key = f"{deploy_board.workspace_id}/{uuid.uuid4().hex}-{name}" + + # Create a File Asset + asset = FileAsset.objects.create( + attributes={ + "name": name, + "type": type, + "size": size, + }, + asset=asset_key, + size=size, + workspace=deploy_board.workspace, + created_by=request.user, + entity_type=entity_type, + project_id=deploy_board.project_id, + comment_id=entity_identifier, + ) + + # Get the presigned URL + storage = S3Storage(request=request) + # Generate a presigned URL to share an S3 object + presigned_url = storage.generate_presigned_post( + object_name=asset_key, + file_type=type, + file_size=size, + ) + # Return the presigned URL + return Response( + { + "upload_data": presigned_url, + "asset_id": str(asset.id), + "asset_url": asset.asset_url, + }, + status=status.HTTP_200_OK, + ) + + def patch(self, request, anchor, pk): + # Get the deploy board + deploy_board = DeployBoard.objects.filter( + anchor=anchor, entity_name="project" + ).first() + # Check if the project is published + if not deploy_board: + return Response( + {"error": "Project is not published"}, + status=status.HTTP_404_NOT_FOUND, + ) + + # get the asset id + asset = FileAsset.objects.get(id=pk, workspace=deploy_board.workspace) + storage = S3Storage(request=request) + # get the storage metadata + asset.is_uploaded = True + # get the storage metadata + if asset.storage_metadata is None: + asset.storage_metadata = storage.get_object_metadata( + object_name=asset.asset.name + ) + + # update the attributes + asset.attributes = request.data.get("attributes", asset.attributes) + # save the asset + asset.save() + return Response(status=status.HTTP_204_NO_CONTENT) + + def delete(self, request, anchor, pk): + # Get the deploy board + deploy_board = DeployBoard.objects.filter( + anchor=anchor, entity_name="project" + ).first() + # Check if the project is published + if not deploy_board: + return Response( + {"error": "Project is not published"}, + status=status.HTTP_404_NOT_FOUND, + ) + # Get the asset + asset = FileAsset.objects.get( + id=pk, + workspace=deploy_board.workspace, + project_id=deploy_board.project_id, + ) + # Check deleted assets + asset.is_deleted = True + asset.deleted_at = timezone.now() + # Save the asset + asset.save() + return Response(status=status.HTTP_204_NO_CONTENT) + + +class AssetRestoreEndpoint(BaseAPIView): + """Endpoint to restore a deleted assets.""" + + def post(self, request, anchor, asset_id): + # Get the deploy board + deploy_board = DeployBoard.objects.filter( + anchor=anchor, entity_name="project" + ).first() + # Check if the project is published + if not deploy_board: + return Response( + {"error": "Project is not published"}, + status=status.HTTP_404_NOT_FOUND, + ) + + # Get the asset + asset = FileAsset.all_objects.get( + id=asset_id, workspace=deploy_board.workspace + ) + asset.is_deleted = False + asset.deleted_at = None + asset.save() + return Response(status=status.HTTP_204_NO_CONTENT) + + +class EntityBulkAssetEndpoint(BaseAPIView): + """Endpoint to bulk update assets.""" + + def post(self, request, anchor, entity_id): + # Get the deploy board + deploy_board = DeployBoard.objects.filter( + anchor=anchor, entity_name="project" + ).first() + # Check if the project is published + if not deploy_board: + return Response( + {"error": "Project is not published"}, + status=status.HTTP_404_NOT_FOUND, + ) + + asset_ids = request.data.get("asset_ids", []) + + # Check if the asset ids are provided + if not asset_ids: + return Response( + { + "error": "No asset ids provided.", + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + # get the asset id + assets = FileAsset.objects.filter( + id__in=asset_ids, + workspace=deploy_board.workspace, + project_id=deploy_board.project_id, + ) + + asset = assets.first() + + # Check if the asset is uploaded + if not asset: + return Response( + { + "error": "The requested asset could not be found.", + }, + status=status.HTTP_404_NOT_FOUND, + ) + + # Check if the entity type is allowed + if ( + asset.entity_type + == FileAsset.EntityTypeContext.COMMENT_DESCRIPTION + ): + # update the attributes + assets.update( + comment_id=entity_id, + ) + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apiserver/plane/space/views/inbox.py b/apiserver/plane/space/views/inbox.py index 3358ff1d3..a538a34ca 100644 --- a/apiserver/plane/space/views/inbox.py +++ b/apiserver/plane/space/views/inbox.py @@ -17,7 +17,7 @@ from plane.db.models import ( Issue, State, IssueLink, - IssueAttachment, + FileAsset, DeployBoard, ) from plane.app.serializers import ( @@ -95,8 +95,9 @@ class InboxIssuePublicViewSet(BaseViewSet): .values("count") ) .annotate( - attachment_count=IssueAttachment.objects.filter( - issue=OuterRef("id") + attachment_count=FileAsset.objects.filter( + issue_id=OuterRef("id"), + entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, ) .order_by() .annotate(count=Func(F("id"), function="Count")) diff --git a/apiserver/plane/space/views/issue.py b/apiserver/plane/space/views/issue.py index fe7a4e13a..1c1fe7d63 100644 --- a/apiserver/plane/space/views/issue.py +++ b/apiserver/plane/space/views/issue.py @@ -19,7 +19,9 @@ from django.db.models import ( Value, OuterRef, Func, + CharField, ) +from django.db.models.functions import Concat # Third Party imports from rest_framework.response import Response @@ -59,7 +61,7 @@ from plane.db.models import ( DeployBoard, IssueVote, ProjectPublicMember, - IssueAttachment, + FileAsset, ) from plane.bgtasks.issue_activities_task import issue_activity from plane.utils.issue_filters import issue_filters @@ -112,8 +114,9 @@ class ProjectIssuesPublicEndpoint(BaseAPIView): .values("count") ) .annotate( - attachment_count=IssueAttachment.objects.filter( - issue=OuterRef("id") + attachment_count=FileAsset.objects.filter( + issue_id=OuterRef("id"), + entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, ) .order_by() .annotate(count=Func(F("id"), function="Count")) @@ -746,6 +749,26 @@ class IssueRetrievePublicEndpoint(BaseAPIView): first_name=F("votes__actor__first_name"), last_name=F("votes__actor__last_name"), avatar=F("votes__actor__avatar"), + avatar_url=Case( + When( + votes__actor__avatar_asset__isnull=False, + then=Concat( + Value( + "/api/assets/v2/static/" + ), + F( + "votes__actor__avatar_asset" + ), + Value("/"), + ), + ), + When( + votes__actor__avatar_asset__isnull=True, + then=F("votes__actor__avatar"), + ), + default=Value(None), + output_field=CharField(), + ), display_name=F( "votes__actor__display_name" ), @@ -777,6 +800,26 @@ class IssueRetrievePublicEndpoint(BaseAPIView): "issue_reactions__actor__last_name" ), avatar=F("issue_reactions__actor__avatar"), + avatar_url=Case( + When( + votes__actor__avatar_asset__isnull=False, + then=Concat( + Value( + "/api/assets/v2/static/" + ), + F( + "votes__actor__avatar_asset" + ), + Value("/"), + ), + ), + When( + votes__actor__avatar_asset__isnull=True, + then=F("votes__actor__avatar"), + ), + default=Value(None), + output_field=CharField(), + ), display_name=F( "issue_reactions__actor__display_name" ), diff --git a/packages/editor/src/core/components/editors/document/collaborative-read-only-editor.tsx b/packages/editor/src/core/components/editors/document/collaborative-read-only-editor.tsx index 35b833bf9..aa925abec 100644 --- a/packages/editor/src/core/components/editors/document/collaborative-read-only-editor.tsx +++ b/packages/editor/src/core/components/editors/document/collaborative-read-only-editor.tsx @@ -18,6 +18,7 @@ const CollaborativeDocumentReadOnlyEditor = (props: ICollaborativeDocumentReadOn displayConfig = DEFAULT_DISPLAY_CONFIG, editorClassName = "", embedHandler, + fileHandler, forwardedRef, handleEditorReady, id, @@ -38,6 +39,7 @@ const CollaborativeDocumentReadOnlyEditor = (props: ICollaborativeDocumentReadOn const { editor, hasServerConnectionFailed, hasServerSynced } = useReadOnlyCollaborativeEditor({ editorClassName, extensions, + fileHandler, forwardedRef, handleEditorReady, id, diff --git a/packages/editor/src/core/components/editors/document/read-only-editor.tsx b/packages/editor/src/core/components/editors/document/read-only-editor.tsx index 73a600e2b..8544157aa 100644 --- a/packages/editor/src/core/components/editors/document/read-only-editor.tsx +++ b/packages/editor/src/core/components/editors/document/read-only-editor.tsx @@ -10,7 +10,7 @@ import { getEditorClassNames } from "@/helpers/common"; // hooks import { useReadOnlyEditor } from "@/hooks/use-read-only-editor"; // types -import { EditorReadOnlyRefApi, IMentionHighlight, TDisplayConfig } from "@/types"; +import { EditorReadOnlyRefApi, IMentionHighlight, TDisplayConfig, TFileHandler } from "@/types"; interface IDocumentReadOnlyEditor { id: string; @@ -19,6 +19,7 @@ interface IDocumentReadOnlyEditor { displayConfig?: TDisplayConfig; editorClassName?: string; embedHandler: any; + fileHandler: Pick; tabIndex?: number; handleEditorReady?: (value: boolean) => void; mentionHandler: { @@ -33,6 +34,7 @@ const DocumentReadOnlyEditor = (props: IDocumentReadOnlyEditor) => { displayConfig = DEFAULT_DISPLAY_CONFIG, editorClassName = "", embedHandler, + fileHandler, id, forwardedRef, handleEditorReady, @@ -51,6 +53,7 @@ const DocumentReadOnlyEditor = (props: IDocumentReadOnlyEditor) => { const editor = useReadOnlyEditor({ editorClassName, extensions, + fileHandler, forwardedRef, handleEditorReady, initialValue, diff --git a/packages/editor/src/core/components/editors/read-only-editor-wrapper.tsx b/packages/editor/src/core/components/editors/read-only-editor-wrapper.tsx index fc0911bee..e06826a28 100644 --- a/packages/editor/src/core/components/editors/read-only-editor-wrapper.tsx +++ b/packages/editor/src/core/components/editors/read-only-editor-wrapper.tsx @@ -14,14 +14,16 @@ export const ReadOnlyEditorWrapper = (props: IReadOnlyEditorProps) => { containerClassName, displayConfig = DEFAULT_DISPLAY_CONFIG, editorClassName = "", + fileHandler, + forwardedRef, id, initialValue, - forwardedRef, mentionHandler, } = props; const editor = useReadOnlyEditor({ editorClassName, + fileHandler, forwardedRef, initialValue, mentionHandler, diff --git a/packages/editor/src/core/extensions/custom-image/components/image-block.tsx b/packages/editor/src/core/extensions/custom-image/components/image-block.tsx index ed60f3dab..34d604760 100644 --- a/packages/editor/src/core/extensions/custom-image/components/image-block.tsx +++ b/packages/editor/src/core/extensions/custom-image/components/image-block.tsx @@ -42,6 +42,7 @@ type CustomImageBlockProps = CustomImageNodeViewProps & { setFailedToLoadImage: (isError: boolean) => void; editorContainer: HTMLDivElement | null; setEditorContainer: (editorContainer: HTMLDivElement | null) => void; + src: string; }; export const CustomImageBlock: React.FC = (props) => { @@ -55,9 +56,10 @@ export const CustomImageBlock: React.FC = (props) => { getPos, editor, editorContainer, + src: remoteImageSrc, setEditorContainer, } = props; - const { src: remoteImageSrc, width, height, aspectRatio } = node.attrs; + const { width, height, aspectRatio } = node.attrs; // states const [size, setSize] = useState({ width: ensurePixelString(width, "35%"), @@ -206,7 +208,7 @@ export const CustomImageBlock: React.FC = (props) => { // show the image resizer only if the editor is editable, the remote image's (post upload) src is set and the initial resize is complete (but not while we're showing the preview imageFromFileSystem) const showImageResizer = editor.isEditable && remoteImageSrc && initialResizeComplete; // show the preview image from the file system if the remote image's src is not set - const displayedImageSrc = remoteImageSrc ?? imageFromFileSystem; + const displayedImageSrc = remoteImageSrc || imageFromFileSystem; return (
{ imageFromFileSystem={imageFromFileSystem} editorContainer={editorContainer} editor={editor} + // @ts-expect-error function not expected here, but will still work + src={editor?.commands?.getImageSource?.(node.attrs.src)} getPos={getPos} node={node} setEditorContainer={setEditorContainer} @@ -67,6 +69,7 @@ export const CustomImageNode = (props: CustomImageNodeViewProps) => { failedToLoadImage={failedToLoadImage} getPos={getPos} loadImageFromFileSystem={setImageFromFileSystem} + maxFileSize={editor.storage.imageComponent.maxFileSize} node={node} setIsUploaded={setIsUploaded} selected={selected} diff --git a/packages/editor/src/core/extensions/custom-image/components/image-uploader.tsx b/packages/editor/src/core/extensions/custom-image/components/image-uploader.tsx index b5c52db66..67cb4e329 100644 --- a/packages/editor/src/core/extensions/custom-image/components/image-uploader.tsx +++ b/packages/editor/src/core/extensions/custom-image/components/image-uploader.tsx @@ -10,33 +10,34 @@ import { useUploader, useDropZone, uploadFirstImageAndInsertRemaining } from "@/ import { getImageComponentImageFileMap, ImageAttributes } from "@/extensions/custom-image"; export const CustomImageUploader = (props: { - failedToLoadImage: boolean; editor: Editor; - selected: boolean; + failedToLoadImage: boolean; + getPos: () => number; loadImageFromFileSystem: (file: string) => void; - setIsUploaded: (isUploaded: boolean) => void; + maxFileSize: number; node: ProsemirrorNode & { attrs: ImageAttributes; }; + selected: boolean; + setIsUploaded: (isUploaded: boolean) => void; updateAttributes: (attrs: Record) => void; - getPos: () => number; }) => { const { - selected, - failedToLoadImage, editor, + failedToLoadImage, + getPos, loadImageFromFileSystem, + maxFileSize, node, + selected, setIsUploaded, updateAttributes, - getPos, } = props; - // ref + // refs const fileInputRef = useRef(null); - const hasTriggeredFilePickerRef = useRef(false); + // derived values const imageEntityId = node.attrs.id; - const imageComponentImageFileMap = useMemo(() => getImageComponentImageFileMap(editor), [editor]); const onUpload = useCallback( @@ -71,11 +72,17 @@ export const CustomImageUploader = (props: { [imageComponentImageFileMap, imageEntityId, updateAttributes, getPos] ); // hooks - const { uploading: isImageBeingUploaded, uploadFile } = useUploader({ onUpload, editor, loadImageFromFileSystem }); - const { draggedInside, onDrop, onDragEnter, onDragLeave } = useDropZone({ - uploader: uploadFile, + const { uploading: isImageBeingUploaded, uploadFile } = useUploader({ editor, + loadImageFromFileSystem, + maxFileSize, + onUpload, + }); + const { draggedInside, onDrop, onDragEnter, onDragLeave } = useDropZone({ + editor, + maxFileSize, pos: getPos(), + uploader: uploadFile, }); // the meta data of the image component @@ -102,11 +109,17 @@ export const CustomImageUploader = (props: { const onFileChange = useCallback( async (e: ChangeEvent) => { e.preventDefault(); - const fileList = e.target.files; - if (!fileList) { + const filesList = e.target.files; + if (!filesList) { return; } - await uploadFirstImageAndInsertRemaining(editor, fileList, getPos(), uploadFile); + await uploadFirstImageAndInsertRemaining({ + editor, + filesList, + maxFileSize, + pos: getPos(), + uploader: uploadFile, + }); }, [uploadFile, editor, getPos] ); diff --git a/packages/editor/src/core/extensions/custom-image/custom-image.ts b/packages/editor/src/core/extensions/custom-image/custom-image.ts index 939d97668..6dd5f0f19 100644 --- a/packages/editor/src/core/extensions/custom-image/custom-image.ts +++ b/packages/editor/src/core/extensions/custom-image/custom-image.ts @@ -22,6 +22,7 @@ declare module "@tiptap/core" { imageComponent: { insertImageComponent: ({ file, pos, event }: InsertImageComponentProps) => ReturnType; uploadImage: (file: File) => () => Promise | undefined; + getImageSource?: (path: string) => () => string; }; } } @@ -36,7 +37,13 @@ export interface UploadImageExtensionStorage { export type UploadEntity = ({ event: "insert" } | { event: "drop"; file: File }) & { hasOpenedFileInputOnce?: boolean }; export const CustomImageExtension = (props: TFileHandler) => { - const { upload, delete: deleteImage, restore: restoreImage } = props; + const { + getAssetSrc, + upload, + delete: deleteImage, + restore: restoreImage, + validation: { maxFileSize }, + } = props; return Image.extend, UploadImageExtensionStorage>({ name: "imageComponent", @@ -87,8 +94,7 @@ export const CustomImageExtension = (props: TFileHandler) => { }); imageSources.forEach(async (src) => { try { - const assetUrlWithWorkspaceId = new URL(src).pathname.substring(1); - await restoreImage(assetUrlWithWorkspaceId); + await restoreImage(src); } catch (error) { console.error("Error restoring image: ", error); } @@ -114,6 +120,7 @@ export const CustomImageExtension = (props: TFileHandler) => { fileMap: new Map(), deletedImageSet: new Map(), uploadInProgress: false, + maxFileSize, }; }, @@ -123,7 +130,13 @@ export const CustomImageExtension = (props: TFileHandler) => { (props: { file?: File; pos?: number; event: "insert" | "drop" }) => ({ commands }) => { // Early return if there's an invalid file being dropped - if (props?.file && !isFileValid(props.file)) { + if ( + props?.file && + !isFileValid({ + file: props.file, + maxFileSize, + }) + ) { return false; } @@ -166,6 +179,7 @@ export const CustomImageExtension = (props: TFileHandler) => { const fileUrl = await upload(file); return fileUrl; }, + getImageSource: (path: string) => () => getAssetSrc(path), }; }, diff --git a/packages/editor/src/core/extensions/custom-image/read-only-custom-image.ts b/packages/editor/src/core/extensions/custom-image/read-only-custom-image.ts index f7db8d6b0..76edacbd0 100644 --- a/packages/editor/src/core/extensions/custom-image/read-only-custom-image.ts +++ b/packages/editor/src/core/extensions/custom-image/read-only-custom-image.ts @@ -3,9 +3,13 @@ import { Image } from "@tiptap/extension-image"; import { ReactNodeViewRenderer } from "@tiptap/react"; // components import { CustomImageNode, UploadImageExtensionStorage } from "@/extensions/custom-image"; +// types +import { TFileHandler } from "@/types"; -export const CustomReadOnlyImageExtension = () => - Image.extend, UploadImageExtensionStorage>({ +export const CustomReadOnlyImageExtension = (props: Pick) => { + const { getAssetSrc } = props; + + return Image.extend, UploadImageExtensionStorage>({ name: "imageComponent", selectable: false, group: "block", @@ -51,7 +55,14 @@ export const CustomReadOnlyImageExtension = () => }; }, + addCommands() { + return { + getImageSource: (path: string) => () => getAssetSrc(path), + }; + }, + addNodeView() { return ReactNodeViewRenderer(CustomImageNode); }, }); +}; diff --git a/packages/editor/src/core/extensions/extensions.tsx b/packages/editor/src/core/extensions/extensions.tsx index ea2d63e54..dff2792d5 100644 --- a/packages/editor/src/core/extensions/extensions.tsx +++ b/packages/editor/src/core/extensions/extensions.tsx @@ -31,16 +31,11 @@ import { // helpers import { isValidHttpUrl } from "@/helpers/common"; // types -import { DeleteImage, IMentionHighlight, IMentionSuggestion, RestoreImage, UploadImage } from "@/types"; +import { IMentionHighlight, IMentionSuggestion, TFileHandler } from "@/types"; type TArguments = { enableHistory: boolean; - fileConfig: { - deleteFile: DeleteImage; - restoreFile: RestoreImage; - cancelUploadImage?: () => void; - uploadFile: UploadImage; - }; + fileHandler: TFileHandler; mentionConfig: { mentionSuggestions?: () => Promise; mentionHighlights?: () => Promise; @@ -49,125 +44,118 @@ type TArguments = { tabIndex?: number; }; -export const CoreEditorExtensions = ({ - enableHistory, - fileConfig: { deleteFile, restoreFile, cancelUploadImage, uploadFile }, - mentionConfig, - placeholder, - tabIndex, -}: TArguments) => [ - StarterKit.configure({ - bulletList: { - HTMLAttributes: { - class: "list-disc pl-7 space-y-2", +export const CoreEditorExtensions = (args: TArguments) => { + const { enableHistory, fileHandler, mentionConfig, placeholder, tabIndex } = args; + + return [ + StarterKit.configure({ + bulletList: { + HTMLAttributes: { + class: "list-disc pl-7 space-y-2", + }, }, - }, - orderedList: { - HTMLAttributes: { - class: "list-decimal pl-7 space-y-2", + orderedList: { + HTMLAttributes: { + class: "list-decimal pl-7 space-y-2", + }, }, - }, - listItem: { - HTMLAttributes: { - class: "not-prose space-y-2", + listItem: { + HTMLAttributes: { + class: "not-prose space-y-2", + }, }, - }, - code: false, - codeBlock: false, - horizontalRule: false, - blockquote: false, - dropcursor: { - class: "text-custom-text-300", - }, - ...(enableHistory ? {} : { history: false }), - }), - CustomQuoteExtension, - DropHandlerExtension(), - CustomHorizontalRule.configure({ - HTMLAttributes: { - class: "my-4 border-custom-border-400", - }, - }), - CustomKeymap, - ListKeymap({ tabIndex }), - CustomLinkExtension.configure({ - openOnClick: true, - autolink: true, - linkOnPaste: true, - protocols: ["http", "https"], - validate: (url: string) => isValidHttpUrl(url), - HTMLAttributes: { - class: - "text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer", - }, - }), - CustomTypographyExtension, - ImageExtension(deleteFile, restoreFile, cancelUploadImage).configure({ - HTMLAttributes: { - class: "rounded-md", - }, - }), - CustomImageExtension({ - delete: deleteFile, - restore: restoreFile, - upload: uploadFile, - cancel: cancelUploadImage ?? (() => {}), - }), - TiptapUnderline, - TextStyle, - TaskList.configure({ - HTMLAttributes: { - class: "not-prose pl-2 space-y-2", - }, - }), - TaskItem.configure({ - HTMLAttributes: { - class: "relative", - }, - nested: true, - }), - CustomCodeBlockExtension.configure({ - HTMLAttributes: { - class: "", - }, - }), - CustomCodeMarkPlugin, - CustomCodeInlineExtension, - Markdown.configure({ - html: true, - transformPastedText: true, - breaks: true, - }), - Table, - TableHeader, - TableCell, - TableRow, - CustomMention({ - mentionSuggestions: mentionConfig.mentionSuggestions, - mentionHighlights: mentionConfig.mentionHighlights, - readonly: false, - }), - Placeholder.configure({ - placeholder: ({ editor, node }) => { - if (node.type.name === "heading") return `Heading ${node.attrs.level}`; + code: false, + codeBlock: false, + horizontalRule: false, + blockquote: false, + dropcursor: { + class: "text-custom-text-300", + }, + ...(enableHistory ? {} : { history: false }), + }), + CustomQuoteExtension, + DropHandlerExtension(), + CustomHorizontalRule.configure({ + HTMLAttributes: { + class: "my-4 border-custom-border-400", + }, + }), + CustomKeymap, + ListKeymap({ tabIndex }), + CustomLinkExtension.configure({ + openOnClick: true, + autolink: true, + linkOnPaste: true, + protocols: ["http", "https"], + validate: (url: string) => isValidHttpUrl(url), + HTMLAttributes: { + class: + "text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer", + }, + }), + CustomTypographyExtension, + ImageExtension(fileHandler).configure({ + HTMLAttributes: { + class: "rounded-md", + }, + }), + CustomImageExtension(fileHandler), + TiptapUnderline, + TextStyle, + TaskList.configure({ + HTMLAttributes: { + class: "not-prose pl-2 space-y-2", + }, + }), + TaskItem.configure({ + HTMLAttributes: { + class: "relative", + }, + nested: true, + }), + CustomCodeBlockExtension.configure({ + HTMLAttributes: { + class: "", + }, + }), + CustomCodeMarkPlugin, + CustomCodeInlineExtension, + Markdown.configure({ + html: true, + transformPastedText: true, + breaks: true, + }), + Table, + TableHeader, + TableCell, + TableRow, + CustomMention({ + mentionSuggestions: mentionConfig.mentionSuggestions, + mentionHighlights: mentionConfig.mentionHighlights, + readonly: false, + }), + Placeholder.configure({ + placeholder: ({ editor, node }) => { + if (node.type.name === "heading") return `Heading ${node.attrs.level}`; - if (editor.storage.imageComponent.uploadInProgress) return ""; + if (editor.storage.imageComponent.uploadInProgress) return ""; - const shouldHidePlaceholder = - editor.isActive("table") || editor.isActive("codeBlock") || editor.isActive("image"); + const shouldHidePlaceholder = + editor.isActive("table") || editor.isActive("codeBlock") || editor.isActive("image"); - if (shouldHidePlaceholder) return ""; + if (shouldHidePlaceholder) return ""; - if (placeholder) { - if (typeof placeholder === "string") return placeholder; - else return placeholder(editor.isFocused, editor.getHTML()); - } + if (placeholder) { + if (typeof placeholder === "string") return placeholder; + else return placeholder(editor.isFocused, editor.getHTML()); + } - return "Press '/' for commands..."; - }, - includeChildren: true, - }), - CharacterCount, - CustomTextColorExtension, - CustomBackgroundColorExtension, -]; + return "Press '/' for commands..."; + }, + includeChildren: true, + }), + CharacterCount, + CustomTextColorExtension, + CustomBackgroundColorExtension, + ]; +}; diff --git a/packages/editor/src/core/extensions/image/extension.tsx b/packages/editor/src/core/extensions/image/extension.tsx index 1f15846a1..e430b88a8 100644 --- a/packages/editor/src/core/extensions/image/extension.tsx +++ b/packages/editor/src/core/extensions/image/extension.tsx @@ -5,12 +5,19 @@ import { insertEmptyParagraphAtNodeBoundaries } from "@/helpers/insert-empty-par // plugins import { ImageExtensionStorage, TrackImageDeletionPlugin, TrackImageRestorationPlugin } from "@/plugins/image"; // types -import { DeleteImage, RestoreImage } from "@/types"; +import { TFileHandler } from "@/types"; // extensions import { CustomImageNode } from "@/extensions"; -export const ImageExtension = (deleteImage: DeleteImage, restoreImage: RestoreImage, cancelUploadImage?: () => void) => - ImageExt.extend({ +export const ImageExtension = (fileHandler: TFileHandler) => { + const { + delete: deleteImage, + getAssetSrc, + restore: restoreImage, + validation: { maxFileSize }, + } = fileHandler; + + return ImageExt.extend({ addKeyboardShortcuts() { return { ArrowDown: insertEmptyParagraphAtNodeBoundaries("down", this.name), @@ -33,8 +40,7 @@ export const ImageExtension = (deleteImage: DeleteImage, restoreImage: RestoreIm }); imageSources.forEach(async (src) => { try { - const assetUrlWithWorkspaceId = new URL(src).pathname.substring(1); - await restoreImage(assetUrlWithWorkspaceId); + await restoreImage(src); } catch (error) { console.error("Error restoring image: ", error); } @@ -46,6 +52,7 @@ export const ImageExtension = (deleteImage: DeleteImage, restoreImage: RestoreIm return { deletedImageSet: new Map(), uploadInProgress: false, + maxFileSize, }; }, @@ -61,8 +68,15 @@ export const ImageExtension = (deleteImage: DeleteImage, restoreImage: RestoreIm }; }, + addCommands() { + return { + getImageSource: (path: string) => () => getAssetSrc(path), + }; + }, + // render custom image node addNodeView() { return ReactNodeViewRenderer(CustomImageNode); }, }); +}; diff --git a/packages/editor/src/core/extensions/image/read-only-image.tsx b/packages/editor/src/core/extensions/image/read-only-image.tsx index 1605174b3..7ba961cdb 100644 --- a/packages/editor/src/core/extensions/image/read-only-image.tsx +++ b/packages/editor/src/core/extensions/image/read-only-image.tsx @@ -2,20 +2,33 @@ import Image from "@tiptap/extension-image"; import { ReactNodeViewRenderer } from "@tiptap/react"; // extensions import { CustomImageNode } from "@/extensions"; +// types +import { TFileHandler } from "@/types"; -export const ReadOnlyImageExtension = Image.extend({ - addAttributes() { - return { - ...this.parent?.(), - width: { - default: "35%", - }, - height: { - default: null, - }, - }; - }, - addNodeView() { - return ReactNodeViewRenderer(CustomImageNode); - }, -}); +export const ReadOnlyImageExtension = (props: Pick) => { + const { getAssetSrc } = props; + + return Image.extend({ + addAttributes() { + return { + ...this.parent?.(), + width: { + default: "35%", + }, + height: { + default: null, + }, + }; + }, + + addCommands() { + return { + getImageSource: (path: string) => () => getAssetSrc(path), + }; + }, + + addNodeView() { + return ReactNodeViewRenderer(CustomImageNode); + }, + }); +}; diff --git a/packages/editor/src/core/extensions/read-only-extensions.tsx b/packages/editor/src/core/extensions/read-only-extensions.tsx index 187861512..8bfa0509a 100644 --- a/packages/editor/src/core/extensions/read-only-extensions.tsx +++ b/packages/editor/src/core/extensions/read-only-extensions.tsx @@ -27,91 +27,104 @@ import { // helpers import { isValidHttpUrl } from "@/helpers/common"; // types -import { IMentionHighlight } from "@/types"; +import { IMentionHighlight, TFileHandler } from "@/types"; -export const CoreReadOnlyEditorExtensions = (mentionConfig: { - mentionHighlights?: () => Promise; -}) => [ - StarterKit.configure({ - bulletList: { - HTMLAttributes: { - class: "list-disc pl-7 space-y-2", +type Props = { + fileHandler: Pick; + mentionConfig: { + mentionHighlights?: () => Promise; + }; +}; + +export const CoreReadOnlyEditorExtensions = (props: Props) => { + const { fileHandler, mentionConfig } = props; + + return [ + StarterKit.configure({ + bulletList: { + HTMLAttributes: { + class: "list-disc pl-7 space-y-2", + }, }, - }, - orderedList: { - HTMLAttributes: { - class: "list-decimal pl-7 space-y-2", + orderedList: { + HTMLAttributes: { + class: "list-decimal pl-7 space-y-2", + }, }, - }, - listItem: { - HTMLAttributes: { - class: "not-prose space-y-2", + listItem: { + HTMLAttributes: { + class: "not-prose space-y-2", + }, }, - }, - code: false, - codeBlock: false, - horizontalRule: false, - blockquote: false, - dropcursor: false, - gapcursor: false, - }), - CustomQuoteExtension, - CustomHorizontalRule.configure({ - HTMLAttributes: { - class: "my-4 border-custom-border-400", - }, - }), - CustomLinkExtension.configure({ - openOnClick: true, - autolink: true, - linkOnPaste: true, - protocols: ["http", "https"], - validate: (url: string) => isValidHttpUrl(url), - HTMLAttributes: { - class: - "text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer", - }, - }), - CustomTypographyExtension, - ReadOnlyImageExtension.configure({ - HTMLAttributes: { - class: "rounded-md", - }, - }), - CustomReadOnlyImageExtension(), - TiptapUnderline, - TextStyle, - TaskList.configure({ - HTMLAttributes: { - class: "not-prose pl-2 space-y-2", - }, - }), - TaskItem.configure({ - HTMLAttributes: { - class: "relative pointer-events-none", - }, - nested: true, - }), - CustomCodeBlockExtension.configure({ - HTMLAttributes: { - class: "", - }, - }), - CustomCodeInlineExtension, - Markdown.configure({ - html: true, - transformCopiedText: true, - }), - Table, - TableHeader, - TableCell, - TableRow, - CustomMention({ - mentionHighlights: mentionConfig.mentionHighlights, - readonly: true, - }), - CharacterCount, - CustomTextColorExtension, - CustomBackgroundColorExtension, - HeadingListExtension, -]; + code: false, + codeBlock: false, + horizontalRule: false, + blockquote: false, + dropcursor: false, + gapcursor: false, + }), + CustomQuoteExtension, + CustomHorizontalRule.configure({ + HTMLAttributes: { + class: "my-4 border-custom-border-400", + }, + }), + CustomLinkExtension.configure({ + openOnClick: true, + autolink: true, + linkOnPaste: true, + protocols: ["http", "https"], + validate: (url: string) => isValidHttpUrl(url), + HTMLAttributes: { + class: + "text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer", + }, + }), + CustomTypographyExtension, + ReadOnlyImageExtension({ + getAssetSrc: fileHandler.getAssetSrc, + }).configure({ + HTMLAttributes: { + class: "rounded-md", + }, + }), + CustomReadOnlyImageExtension({ + getAssetSrc: fileHandler.getAssetSrc, + }), + TiptapUnderline, + TextStyle, + TaskList.configure({ + HTMLAttributes: { + class: "not-prose pl-2 space-y-2", + }, + }), + TaskItem.configure({ + HTMLAttributes: { + class: "relative pointer-events-none", + }, + nested: true, + }), + CustomCodeBlockExtension.configure({ + HTMLAttributes: { + class: "", + }, + }), + CustomCodeInlineExtension, + Markdown.configure({ + html: true, + transformCopiedText: true, + }), + Table, + TableHeader, + TableCell, + TableRow, + CustomMention({ + mentionHighlights: mentionConfig.mentionHighlights, + readonly: true, + }), + CharacterCount, + CustomTextColorExtension, + CustomBackgroundColorExtension, + HeadingListExtension, + ]; +}; diff --git a/packages/editor/src/core/hooks/use-editor.ts b/packages/editor/src/core/hooks/use-editor.ts index 0edb6ca50..beee9c929 100644 --- a/packages/editor/src/core/hooks/use-editor.ts +++ b/packages/editor/src/core/hooks/use-editor.ts @@ -75,12 +75,7 @@ export const useEditor = (props: CustomEditorProps) => { extensions: [ ...CoreEditorExtensions({ enableHistory, - fileConfig: { - uploadFile: fileHandler.upload, - deleteFile: fileHandler.delete, - restoreFile: fileHandler.restore, - cancelUploadImage: fileHandler.cancel, - }, + fileHandler, mentionConfig: { mentionSuggestions: mentionHandler.suggestions ?? (() => Promise.resolve([])), mentionHighlights: mentionHandler.highlights, diff --git a/packages/editor/src/core/hooks/use-file-upload.ts b/packages/editor/src/core/hooks/use-file-upload.ts index f1bc8c8a1..f5f930f29 100644 --- a/packages/editor/src/core/hooks/use-file-upload.ts +++ b/packages/editor/src/core/hooks/use-file-upload.ts @@ -1,17 +1,20 @@ import { DragEvent, useCallback, useEffect, useState } from "react"; import { Editor } from "@tiptap/core"; -import { isFileValid } from "@/plugins/image"; +// extensions import { insertImagesSafely } from "@/extensions/drop"; +// plugins +import { isFileValid } from "@/plugins/image"; -export const useUploader = ({ - onUpload, - editor, - loadImageFromFileSystem, -}: { - onUpload: (url: string) => void; +type TUploaderArgs = { editor: Editor; loadImageFromFileSystem: (file: string) => void; -}) => { + maxFileSize: number; + onUpload: (url: string) => void; +}; + +export const useUploader = (args: TUploaderArgs) => { + const { editor, loadImageFromFileSystem, maxFileSize, onUpload } = args; + // states const [uploading, setUploading] = useState(false); const uploadFile = useCallback( @@ -23,7 +26,10 @@ export const useUploader = ({ setUploading(true); const fileNameTrimmed = trimFileName(file.name); const fileWithTrimmedName = new File([file], fileNameTrimmed, { type: file.type }); - const isValid = isFileValid(fileWithTrimmedName); + const isValid = isFileValid({ + file: fileWithTrimmedName, + maxFileSize, + }); if (!isValid) { setImageUploadInProgress(false); return; @@ -64,15 +70,16 @@ export const useUploader = ({ return { uploading, uploadFile }; }; -export const useDropZone = ({ - uploader, - editor, - pos, -}: { - uploader: (file: File) => Promise; +type TDropzoneArgs = { editor: Editor; + maxFileSize: number; pos: number; -}) => { + uploader: (file: File) => Promise; +}; + +export const useDropZone = (args: TDropzoneArgs) => { + const { editor, maxFileSize, pos, uploader } = args; + // states const [isDragging, setIsDragging] = useState(false); const [draggedInside, setDraggedInside] = useState(false); @@ -101,8 +108,14 @@ export const useDropZone = ({ if (e.dataTransfer.files.length === 0) { return; } - const fileList = e.dataTransfer.files; - await uploadFirstImageAndInsertRemaining(editor, fileList, pos, uploader); + const filesList = e.dataTransfer.files; + await uploadFirstImageAndInsertRemaining({ + editor, + filesList, + maxFileSize, + pos, + uploader, + }); }, [uploader, editor, pos] ); @@ -129,22 +142,33 @@ function trimFileName(fileName: string, maxLength = 100) { return fileName; } +type TMultipleImagesArgs = { + editor: Editor; + filesList: FileList; + maxFileSize: number; + pos: number; + uploader: (file: File) => Promise; +}; + // Upload the first image and insert the remaining images for uploading multiple image // post insertion of image-component -export async function uploadFirstImageAndInsertRemaining( - editor: Editor, - fileList: FileList, - pos: number, - uploaderFn: (file: File) => Promise -) { +export async function uploadFirstImageAndInsertRemaining(args: TMultipleImagesArgs) { + const { editor, filesList, maxFileSize, pos, uploader } = args; const filteredFiles: File[] = []; - for (let i = 0; i < fileList.length; i += 1) { - const item = fileList.item(i); - if (item && item.type.indexOf("image") !== -1 && isFileValid(item)) { + for (let i = 0; i < filesList.length; i += 1) { + const item = filesList.item(i); + if ( + item && + item.type.indexOf("image") !== -1 && + isFileValid({ + file: item, + maxFileSize, + }) + ) { filteredFiles.push(item); } } - if (filteredFiles.length !== fileList.length) { + if (filteredFiles.length !== filesList.length) { console.warn("Some files were not images and have been ignored."); } if (filteredFiles.length === 0) { @@ -154,7 +178,7 @@ export async function uploadFirstImageAndInsertRemaining( // Upload the first image const firstFile = filteredFiles[0]; - uploaderFn(firstFile); + uploader(firstFile); // Insert the remaining images const remainingFiles = filteredFiles.slice(1); diff --git a/packages/editor/src/core/hooks/use-read-only-collaborative-editor.ts b/packages/editor/src/core/hooks/use-read-only-collaborative-editor.ts index 1aff29aa7..9fa73c3ec 100644 --- a/packages/editor/src/core/hooks/use-read-only-collaborative-editor.ts +++ b/packages/editor/src/core/hooks/use-read-only-collaborative-editor.ts @@ -14,6 +14,7 @@ export const useReadOnlyCollaborativeEditor = (props: TReadOnlyCollaborativeEdit editorClassName, editorProps = {}, extensions, + fileHandler, forwardedRef, handleEditorReady, id, @@ -74,6 +75,7 @@ export const useReadOnlyCollaborativeEditor = (props: TReadOnlyCollaborativeEdit document: provider.document, }), ], + fileHandler, forwardedRef, handleEditorReady, mentionHandler, diff --git a/packages/editor/src/core/hooks/use-read-only-editor.ts b/packages/editor/src/core/hooks/use-read-only-editor.ts index 6d1ed6fa9..23ce023ad 100644 --- a/packages/editor/src/core/hooks/use-read-only-editor.ts +++ b/packages/editor/src/core/hooks/use-read-only-editor.ts @@ -11,7 +11,7 @@ import { IMarking, scrollSummary } from "@/helpers/scroll-to-node"; // props import { CoreReadOnlyEditorProps } from "@/props"; // types -import { EditorReadOnlyRefApi, IMentionHighlight } from "@/types"; +import { EditorReadOnlyRefApi, IMentionHighlight, TFileHandler } from "@/types"; interface CustomReadOnlyEditorProps { initialValue?: string; @@ -19,6 +19,7 @@ interface CustomReadOnlyEditorProps { forwardedRef?: MutableRefObject; extensions?: any; editorProps?: EditorProps; + fileHandler: Pick; handleEditorReady?: (value: boolean) => void; mentionHandler: { highlights: () => Promise; @@ -33,6 +34,7 @@ export const useReadOnlyEditor = (props: CustomReadOnlyEditorProps) => { forwardedRef, extensions = [], editorProps = {}, + fileHandler, handleEditorReady, mentionHandler, provider, @@ -52,7 +54,10 @@ export const useReadOnlyEditor = (props: CustomReadOnlyEditorProps) => { }, extensions: [ ...CoreReadOnlyEditorExtensions({ - mentionHighlights: mentionHandler.highlights, + mentionConfig: { + mentionHighlights: mentionHandler.highlights, + }, + fileHandler, }), ...extensions, ], diff --git a/packages/editor/src/core/plugins/image/delete-image.ts b/packages/editor/src/core/plugins/image/delete-image.ts index 21c8cd24f..bcede7707 100644 --- a/packages/editor/src/core/plugins/image/delete-image.ts +++ b/packages/editor/src/core/plugins/image/delete-image.ts @@ -47,10 +47,9 @@ export const TrackImageDeletionPlugin = (editor: Editor, deleteImage: DeleteImag }); async function onNodeDeleted(src: string, deleteImage: DeleteImage): Promise { + if (!src) return; try { - if (!src) return; - const assetUrlWithWorkspaceId = new URL(src).pathname.substring(1); - await deleteImage(assetUrlWithWorkspaceId); + await deleteImage(src); } catch (error) { console.error("Error deleting image: ", error); } diff --git a/packages/editor/src/core/plugins/image/restore-image.ts b/packages/editor/src/core/plugins/image/restore-image.ts index d722e53a6..4d7279fff 100644 --- a/packages/editor/src/core/plugins/image/restore-image.ts +++ b/packages/editor/src/core/plugins/image/restore-image.ts @@ -48,10 +48,9 @@ export const TrackImageRestorationPlugin = (editor: Editor, restoreImage: Restor }); async function onNodeRestored(src: string, restoreImage: RestoreImage): Promise { + if (!src) return; try { - if (!src) return; - const assetUrlWithWorkspaceId = new URL(src).pathname.substring(1); - await restoreImage(assetUrlWithWorkspaceId); + await restoreImage(src); } catch (error) { console.error("Error restoring image: ", error); throw error; diff --git a/packages/editor/src/core/plugins/image/utils/validate-file.ts b/packages/editor/src/core/plugins/image/utils/validate-file.ts index c86e99335..db88f3f73 100644 --- a/packages/editor/src/core/plugins/image/utils/validate-file.ts +++ b/packages/editor/src/core/plugins/image/utils/validate-file.ts @@ -1,25 +1,26 @@ -export function isFileValid(file: File, showAlert = true): boolean { +type TArgs = { + file: File; + maxFileSize: number; +}; + +export const isFileValid = (args: TArgs): boolean => { + const { file, maxFileSize } = args; + if (!file) { - if (showAlert) { - alert("No file selected. Please select a file to upload."); - } + alert("No file selected. Please select a file to upload."); return false; } const allowedTypes = ["image/jpeg", "image/jpg", "image/png", "image/webp"]; if (!allowedTypes.includes(file.type)) { - if (showAlert) { - alert("Invalid file type. Please select a JPEG, JPG, PNG, or WEBP image file."); - } + alert("Invalid file type. Please select a JPEG, JPG, PNG, or WEBP image file."); return false; } - if (file.size > 5 * 1024 * 1024) { - if (showAlert) { - alert("File size too large. Please select a file smaller than 5MB."); - } + if (file.size > maxFileSize) { + alert(`File size too large. Please select a file smaller than ${maxFileSize / 1024 / 1024}MB.`); return false; } return true; -} +}; diff --git a/packages/editor/src/core/types/collaboration.ts b/packages/editor/src/core/types/collaboration.ts index 4b706a7f9..60721a5a6 100644 --- a/packages/editor/src/core/types/collaboration.ts +++ b/packages/editor/src/core/types/collaboration.ts @@ -44,5 +44,6 @@ export type TCollaborativeEditorProps = TCollaborativeEditorHookProps & { }; export type TReadOnlyCollaborativeEditorProps = TCollaborativeEditorHookProps & { + fileHandler: Pick; forwardedRef?: React.MutableRefObject; }; diff --git a/packages/editor/src/core/types/config.ts b/packages/editor/src/core/types/config.ts index 93d612e59..67043ef9a 100644 --- a/packages/editor/src/core/types/config.ts +++ b/packages/editor/src/core/types/config.ts @@ -1,10 +1,18 @@ import { DeleteImage, RestoreImage, UploadImage } from "@/types"; export type TFileHandler = { + getAssetSrc: (path: string) => string; cancel: () => void; delete: DeleteImage; upload: UploadImage; restore: RestoreImage; + validation: { + /** + * @description max file size in bytes + * @example enter 5242880( 5* 1024 * 1024) for 5MB + */ + maxFileSize: number; + }; }; export type TEditorFontStyle = "sans-serif" | "serif" | "monospace"; diff --git a/packages/editor/src/core/types/editor.ts b/packages/editor/src/core/types/editor.ts index c833cb749..31b315c1c 100644 --- a/packages/editor/src/core/types/editor.ts +++ b/packages/editor/src/core/types/editor.ts @@ -108,6 +108,7 @@ export interface IReadOnlyEditorProps { containerClassName?: string; displayConfig?: TDisplayConfig; editorClassName?: string; + fileHandler: Pick; forwardedRef?: React.MutableRefObject; id: string; initialValue: string; diff --git a/packages/editor/src/core/types/image.ts b/packages/editor/src/core/types/image.ts index c1b174a48..5c707bf33 100644 --- a/packages/editor/src/core/types/image.ts +++ b/packages/editor/src/core/types/image.ts @@ -1,5 +1,5 @@ -export type DeleteImage = (assetUrlWithWorkspaceId: string) => Promise; +export type DeleteImage = (assetUrlWithWorkspaceId: string) => Promise; -export type RestoreImage = (assetUrlWithWorkspaceId: string) => Promise; +export type RestoreImage = (assetUrlWithWorkspaceId: string) => Promise; export type UploadImage = (file: File) => Promise; diff --git a/packages/types/src/analytics.d.ts b/packages/types/src/analytics.d.ts index 2fb7ad51a..ec417e73f 100644 --- a/packages/types/src/analytics.d.ts +++ b/packages/types/src/analytics.d.ts @@ -20,7 +20,7 @@ export interface IAnalyticsData { } export interface IAnalyticsAssigneeDetails { - assignees__avatar: string | null; + assignees__avatar_url: string | null; assignees__display_name: string | null; assignees__first_name: string; assignees__id: string | null; @@ -87,7 +87,7 @@ export interface IExportAnalyticsFormData { } export interface IDefaultAnalyticsUser { - assignees__avatar: string | null; + assignees__avatar_url: string | null; assignees__first_name: string; assignees__last_name: string; assignees__display_name: string; @@ -99,7 +99,7 @@ export interface IDefaultAnalyticsResponse { issue_completed_month_wise: { month: number; count: number }[]; most_issue_closed_user: IDefaultAnalyticsUser[]; most_issue_created_user: { - created_by__avatar: string | null; + created_by__avatar_url: string | null; created_by__first_name: string; created_by__last_name: string; created_by__display_name: string; diff --git a/packages/types/src/current-user/accounts.d.ts b/packages/types/src/current-user/accounts.d.ts deleted file mode 100644 index d328f0529..000000000 --- a/packages/types/src/current-user/accounts.d.ts +++ /dev/null @@ -1,17 +0,0 @@ -export type TCurrentUserAccount = { - id: string | undefined; - - user: string | undefined; - - provider_account_id: string | undefined; - provider: "google" | "github" | "gitlab" | string | undefined; - access_token: string | undefined; - access_token_expired_at: Date | undefined; - refresh_token: string | undefined; - refresh_token_expired_at: Date | undefined; - last_connected_at: Date | undefined; - metadata: object | undefined; - - created_at: Date | undefined; - updated_at: Date | undefined; -}; diff --git a/packages/types/src/current-user/index.ts b/packages/types/src/current-user/index.ts index 43a43b9cd..aeb49bbab 100644 --- a/packages/types/src/current-user/index.ts +++ b/packages/types/src/current-user/index.ts @@ -1,3 +1 @@ -export * from "./user"; export * from "./profile"; -export * from "./accounts"; diff --git a/packages/types/src/current-user/user.d.ts b/packages/types/src/current-user/user.d.ts deleted file mode 100644 index 9bc67b6cf..000000000 --- a/packages/types/src/current-user/user.d.ts +++ /dev/null @@ -1,30 +0,0 @@ -export type TCurrentUser = { - id: string | undefined; - avatar: string | undefined; - cover_image: string | undefined; - date_joined: Date | undefined; - display_name: string | undefined; - email: string | undefined; - first_name: string | undefined; - last_name: string | undefined; - is_active: boolean; - is_bot: boolean; - is_email_verified: boolean; - is_managed: boolean; - mobile_number: string | undefined; - user_timezone: string | undefined; - username: string | undefined; - is_password_autoset: boolean; -}; - -export type TCurrentUserSettings = { - id: string | undefined; - email: string | undefined; - workspace: { - last_workspace_id: string | undefined; - last_workspace_slug: string | undefined; - fallback_workspace_id: string | undefined; - fallback_workspace_slug: string | undefined; - invites: number | undefined; - }; -}; diff --git a/packages/types/src/cycle/cycle.d.ts b/packages/types/src/cycle/cycle.d.ts index fdcffb52b..1c2fa273a 100644 --- a/packages/types/src/cycle/cycle.d.ts +++ b/packages/types/src/cycle/cycle.d.ts @@ -20,7 +20,7 @@ export type TCycleEstimateDistributionBase = { export type TCycleAssigneesDistribution = { assignee_id: string | null; - avatar: string | null; + avatar_url: string | null; first_name: string | null; last_name: string | null; display_name: string | null; diff --git a/packages/types/src/enums.ts b/packages/types/src/enums.ts index 914ebb0c3..da6d07f18 100644 --- a/packages/types/src/enums.ts +++ b/packages/types/src/enums.ts @@ -48,3 +48,14 @@ export enum ENotificationFilterType { ASSIGNED = "assigned", SUBSCRIBED = "subscribed", } + +export enum EFileAssetType { + COMMENT_DESCRIPTION = "COMMENT_DESCRIPTION", + ISSUE_ATTACHMENT = "ISSUE_ATTACHMENT", + ISSUE_DESCRIPTION = "ISSUE_DESCRIPTION", + PAGE_DESCRIPTION = "PAGE_DESCRIPTION", + PROJECT_COVER = "PROJECT_COVER", + USER_AVATAR = "USER_AVATAR", + USER_COVER = "USER_COVER", + WORKSPACE_LOGO = "WORKSPACE_LOGO", +} \ No newline at end of file diff --git a/packages/types/src/file.d.ts b/packages/types/src/file.d.ts new file mode 100644 index 000000000..8bcaade6c --- /dev/null +++ b/packages/types/src/file.d.ts @@ -0,0 +1,32 @@ +import { EFileAssetType } from "./enums" + +export type TFileMetaDataLite = { + name: string; + // file size in bytes + size: number; + type: string; +} + +export type TFileEntityInfo = { + entity_identifier: string; + entity_type: EFileAssetType; +} + +export type TFileMetaData = TFileMetaDataLite & TFileEntityInfo; + +export type TFileSignedURLResponse = { + asset_id: string; + asset_url: string; + upload_data: { + url: string; + fields: { + "Content-Type": string; + key: string; + "x-amz-algorithm": string; + "x-amz-credential": string; + "x-amz-date": string; + policy: string; + "x-amz-signature": string; + }; + }; +}; \ No newline at end of file diff --git a/packages/types/src/index.d.ts b/packages/types/src/index.d.ts index 4559e79c8..d637b0102 100644 --- a/packages/types/src/index.d.ts +++ b/packages/types/src/index.d.ts @@ -29,4 +29,5 @@ export * from "./pragmatic"; export * from "./publish"; export * from "./workspace-notifications"; export * from "./favorite"; +export * from "./file"; export * from "./workspace-draft-issues/base"; diff --git a/packages/types/src/integration.d.ts b/packages/types/src/integration.d.ts index bb76f9fc0..e2561bd18 100644 --- a/packages/types/src/integration.d.ts +++ b/packages/types/src/integration.d.ts @@ -1,7 +1,6 @@ // All the app integrations that are available export interface IAppIntegration { author: string; - author: ""; avatar_url: string | null; created_at: string; created_by: string | null; diff --git a/packages/types/src/issues/activity/base.d.ts b/packages/types/src/issues/activity/base.d.ts index 82b881fd9..63f365d89 100644 --- a/packages/types/src/issues/activity/base.d.ts +++ b/packages/types/src/issues/activity/base.d.ts @@ -40,7 +40,7 @@ export type TIssueActivityUserDetail = { id: string; first_name: string; last_name: string; - avatar: string; + avatar_url: string; is_bot: boolean; display_name: string; }; diff --git a/packages/types/src/issues/issue.d.ts b/packages/types/src/issues/issue.d.ts index 1584a3d16..aacc28023 100644 --- a/packages/types/src/issues/issue.d.ts +++ b/packages/types/src/issues/issue.d.ts @@ -45,7 +45,7 @@ export type TIssue = TBaseIssue & { is_subscribed?: boolean; parent?: Partial; issue_reactions?: TIssueReaction[]; - issue_attachment?: TIssueAttachment[]; + issue_attachments?: TIssueAttachment[]; issue_link?: TIssueLink[]; // tempId is used for optimistic updates. It is not a part of the API response. tempId?: string; diff --git a/packages/types/src/issues/issue_attachment.d.ts b/packages/types/src/issues/issue_attachment.d.ts index 7c3819e00..2238fa4c7 100644 --- a/packages/types/src/issues/issue_attachment.d.ts +++ b/packages/types/src/issues/issue_attachment.d.ts @@ -1,17 +1,22 @@ +import { TFileSignedURLResponse } from "../file"; + export type TIssueAttachment = { id: string; attributes: { name: string; size: number; }; - asset: string; + asset_url: string; issue_id: string; - - //need + // required updated_at: string; updated_by: string; }; +export type TIssueAttachmentUploadResponse = TFileSignedURLResponse & { + attachment: TIssueAttachment +}; + export type TIssueAttachmentMap = { [issue_id: string]: TIssueAttachment; }; diff --git a/packages/types/src/module/modules.d.ts b/packages/types/src/module/modules.d.ts index 6a5a09231..fa77a6a41 100644 --- a/packages/types/src/module/modules.d.ts +++ b/packages/types/src/module/modules.d.ts @@ -26,7 +26,7 @@ export type TModuleEstimateDistributionBase = { export type TModuleAssigneesDistribution = { assignee_id: string | null; - avatar: string | null; + avatar_url: string | null; first_name: string | null; last_name: string | null; display_name: string | null; diff --git a/packages/types/src/project/projects.d.ts b/packages/types/src/project/projects.d.ts index a46f490f1..26685e7ba 100644 --- a/packages/types/src/project/projects.d.ts +++ b/packages/types/src/project/projects.d.ts @@ -18,7 +18,7 @@ export interface IProject { close_in: number; created_at: Date; created_by: string; - cover_image: string | null; + cover_image_url: string; cycle_view: boolean; issue_views_view: boolean; module_view: boolean; @@ -75,7 +75,7 @@ export interface IProjectMap { export interface IProjectMemberLite { id: string; - member__avatar: string; + member__avatar_url: string; member__display_name: string; member_id: string; } diff --git a/packages/types/src/users.d.ts b/packages/types/src/users.d.ts index 4d5db28f9..0440ff05f 100644 --- a/packages/types/src/users.d.ts +++ b/packages/types/src/users.d.ts @@ -3,17 +3,21 @@ import { TUserPermissions } from "./enums"; type TLoginMediums = "email" | "magic-code" | "github" | "gitlab" | "google"; -export interface IUser { - id: string; - avatar: string | null; - cover_image: string | null; - date_joined: string; + +export interface IUserLite { + avatar_url: string; display_name: string; - email: string; + email?: string; first_name: string; - last_name: string; - is_active: boolean; + id: string; is_bot: boolean; + last_name: string; +} +export interface IUser extends IUserLite { + cover_image_url: string | null; + date_joined: string; + email: string; + is_active: boolean; is_email_verified: boolean; is_password_autoset: boolean; is_tour_completed: boolean; @@ -86,15 +90,6 @@ export interface IUserTheme { sidebarBackground: string | undefined; } -export interface IUserLite { - avatar: string; - display_name: string; - email?: string; - first_name: string; - id: string; - is_bot: boolean; - last_name: string; -} export interface IUserMemberLite extends IUserLite { email?: string; @@ -158,13 +153,8 @@ export interface IUserProfileProjectSegregation { id: string; pending_issues: number; }[]; - user_data: { - avatar: string; - cover_image: string | null; + user_data: Pick & { date_joined: Date; - display_name: string; - first_name: string; - last_name: string; user_timezone: string; }; } diff --git a/packages/types/src/workspace.d.ts b/packages/types/src/workspace.d.ts index f72f52463..fb466e3c6 100644 --- a/packages/types/src/workspace.d.ts +++ b/packages/types/src/workspace.d.ts @@ -14,8 +14,7 @@ export interface IWorkspace { readonly updated_at: Date; name: string; url: string; - logo: string | null; - slug: string; + logo_url: string | null; readonly total_members: number; readonly slug: string; readonly created_by: string; @@ -71,7 +70,7 @@ export interface IWorkspaceMember { member: IUserLite; role: TUserPermissions; created_at?: string; - avatar?: string; + avatar_url?: string; email?: string; first_name?: string; last_name?: string; diff --git a/space/core/components/editor/lite-text-editor.tsx b/space/core/components/editor/lite-text-editor.tsx index 86b143f9d..4cd6d82e8 100644 --- a/space/core/components/editor/lite-text-editor.tsx +++ b/space/core/components/editor/lite-text-editor.tsx @@ -5,26 +5,27 @@ import { EditorRefApi, ILiteTextEditor, LiteTextEditorWithRef, TNonColorEditorCo import { IssueCommentToolbar } from "@/components/editor"; // helpers import { cn } from "@/helpers/common.helper"; +import { getEditorFileHandlers } from "@/helpers/editor.helper"; import { isCommentEmpty } from "@/helpers/string.helper"; // hooks import { useMention } from "@/hooks/use-mention"; -// services -import fileService from "@/services/file.service"; interface LiteTextEditorWrapperProps extends Omit { - workspaceSlug: string; + anchor: string; workspaceId: string; isSubmitting?: boolean; showSubmitButton?: boolean; + uploadFile: (file: File) => Promise; } export const LiteTextEditor = React.forwardRef((props, ref) => { const { + anchor, containerClassName, - workspaceSlug, workspaceId, isSubmitting = false, showSubmitButton = true, + uploadFile, ...rest } = props; // use-mention @@ -39,12 +40,11 @@ export const LiteTextEditor = React.forwardRef ; +type LiteTextReadOnlyEditorWrapperProps = Omit & { + anchor: string; +}; export const LiteTextReadOnlyEditor = React.forwardRef( - ({ ...props }, ref) => { + ({ anchor, ...props }, ref) => { const { mentionHighlights } = useMention(); return ( ; +type RichTextReadOnlyEditorWrapperProps = Omit & { + anchor: string; +}; export const RichTextReadOnlyEditor = React.forwardRef( - ({ ...props }, ref) => { + ({ anchor, ...props }, ref) => { const { mentionHighlights } = useMention(); return ( { > = observer((props) => { const { anchor } = props; + // states + const [uploadedAssetIds, setUploadAssetIds] = useState([]); // refs const editorRef = useRef(null); // store hooks - const { peekId: issueId, addIssueComment } = useIssueDetails(); + const { peekId: issueId, addIssueComment, uploadCommentAsset } = useIssueDetails(); const { data: currentUser } = useUser(); - const { workspaceSlug, workspace: workspaceID } = usePublish(anchor); + const { workspace: workspaceID } = usePublish(anchor); // form info const { handleSubmit, @@ -44,9 +49,15 @@ export const AddComment: React.FC = observer((props) => { if (!anchor || !issueId || isSubmitting || !formData.comment_html) return; await addIssueComment(anchor, issueId, formData) - .then(() => { + .then(async (res) => { reset(defaultValues); editorRef.current?.clearEditor(); + if (uploadedAssetIds.length > 0) { + await fileService.updateBulkAssetsUploadStatus(anchor, res.id, { + asset_ids: uploadedAssetIds, + }); + setUploadAssetIds([]); + } }) .catch(() => setToast({ @@ -69,8 +80,8 @@ export const AddComment: React.FC = observer((props) => { onEnterKeyPress={(e) => { if (currentUser) handleSubmit(onSubmit)(e); }} + anchor={anchor} workspaceId={workspaceID?.toString() ?? ""} - workspaceSlug={workspaceSlug?.toString() ?? ""} ref={editorRef} id="peek-overview-add-comment" initialValue={ @@ -81,6 +92,11 @@ export const AddComment: React.FC = observer((props) => { onChange={(comment_json, comment_html) => onChange(comment_html)} isSubmitting={isSubmitting} placeholder="Add Comment..." + uploadFile={async (file) => { + const { asset_id } = await uploadCommentAsset(file, anchor); + setUploadAssetIds((prev) => [...prev, asset_id]); + return asset_id; + }} /> )} /> diff --git a/space/core/components/issues/peek-overview/comment/comment-detail-card.tsx b/space/core/components/issues/peek-overview/comment/comment-detail-card.tsx index 47b506b96..1b228dfb3 100644 --- a/space/core/components/issues/peek-overview/comment/comment-detail-card.tsx +++ b/space/core/components/issues/peek-overview/comment/comment-detail-card.tsx @@ -9,6 +9,7 @@ import { LiteTextEditor, LiteTextReadOnlyEditor } from "@/components/editor"; import { CommentReactions } from "@/components/issues/peek-overview"; // helpers import { timeAgo } from "@/helpers/date-time.helper"; +import { getFileURL } from "@/helpers/file.helper"; // hooks import { useIssueDetails, usePublish, useUser } from "@/hooks/store"; import useIsInIframe from "@/hooks/use-is-in-iframe"; @@ -23,9 +24,9 @@ type Props = { export const CommentCard: React.FC = observer((props) => { const { anchor, comment } = props; // store hooks - const { peekId, deleteIssueComment, updateIssueComment } = useIssueDetails(); + const { peekId, deleteIssueComment, updateIssueComment, uploadCommentAsset } = useIssueDetails(); const { data: currentUser } = useUser(); - const { workspaceSlug, workspace: workspaceID } = usePublish(anchor); + const { workspace: workspaceID } = usePublish(anchor); const isInIframe = useIsInIframe(); // states @@ -58,10 +59,10 @@ export const CommentCard: React.FC = observer((props) => { return (
- {comment.actor_detail.avatar && comment.actor_detail.avatar !== "" ? ( + {comment.actor_detail.avatar_url && comment.actor_detail.avatar_url !== "" ? ( // eslint-disable-next-line @next/next/no-img-element { = observer((props) => { name="comment_html" render={({ field: { onChange, value } }) => ( = observer((props) => { onChange={(comment_json, comment_html) => onChange(comment_html)} isSubmitting={isSubmitting} showSubmitButton={false} + uploadFile={async (file) => { + const { asset_id } = await uploadCommentAsset(file, anchor, comment.id); + return asset_id; + }} /> )} /> @@ -133,7 +138,12 @@ export const CommentCard: React.FC = observer((props) => {
- +
diff --git a/space/core/components/issues/peek-overview/issue-details.tsx b/space/core/components/issues/peek-overview/issue-details.tsx index b47bfad68..36bad2fad 100644 --- a/space/core/components/issues/peek-overview/issue-details.tsx +++ b/space/core/components/issues/peek-overview/issue-details.tsx @@ -26,6 +26,7 @@ export const PeekOverviewIssueDetails: React.FC = observer((props) => {

{issueDetails.name}

{description !== "" && description !== "

" && ( { + this.cancelSource = axios.CancelToken.source(); + return this.post(url, data, { + headers: { + "Content-Type": "multipart/form-data", + }, + cancelToken: this.cancelSource.token, + }) + .then((response) => response?.data) + .catch((error) => { + if (axios.isCancel(error)) { + console.log(error.message); + } else { + throw error?.response?.data; + } + }); + } + + cancelUpload() { + this.cancelSource.cancel("Upload canceled"); + } +} diff --git a/space/core/services/file.service.ts b/space/core/services/file.service.ts index 9fe06cd36..168738804 100644 --- a/space/core/services/file.service.ts +++ b/space/core/services/file.service.ts @@ -1,106 +1,100 @@ -import axios from "axios"; +// plane types +import { TFileEntityInfo, TFileSignedURLResponse } from "@plane/types"; // helpers import { API_BASE_URL } from "@/helpers/common.helper"; +import { generateFileUploadPayload, getAssetIdFromUrl, getFileMetaDataForUpload } from "@/helpers/file.helper"; // services import { APIService } from "@/services/api.service"; +import { FileUploadService } from "@/services/file-upload.service"; -class FileService extends APIService { +export class FileService extends APIService { private cancelSource: any; + fileUploadService: FileUploadService; constructor() { super(API_BASE_URL); - this.uploadFile = this.uploadFile.bind(this); - this.deleteImage = this.deleteImage.bind(this); - this.restoreImage = this.restoreImage.bind(this); this.cancelUpload = this.cancelUpload.bind(this); + // services + this.fileUploadService = new FileUploadService(); } - async uploadFile(workspaceSlug: string, file: FormData): Promise { - this.cancelSource = axios.CancelToken.source(); - return this.post(`/api/workspaces/${workspaceSlug}/file-assets/`, file, { - headers: { - "Content-Type": "multipart/form-data", - }, - cancelToken: this.cancelSource.token, - }) + private async updateAssetUploadStatus(anchor: string, assetId: string): Promise { + return this.patch(`/api/public/assets/v2/anchor/${anchor}/${assetId}/`) .then((response) => response?.data) .catch((error) => { - if (axios.isCancel(error)) { - console.log(error.message); - } else { - console.log(error); - throw error?.response?.data; - } + throw error?.response?.data; + }); + } + + async updateBulkAssetsUploadStatus( + anchor: string, + entityId: string, + data: { + asset_ids: string[]; + } + ): Promise { + return this.post(`/api/public/assets/v2/anchor/${anchor}/${entityId}/bulk/`, data) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async uploadAsset(anchor: string, data: TFileEntityInfo, file: File): Promise { + const fileMetaData = getFileMetaDataForUpload(file); + return this.post(`/api/public/assets/v2/anchor/${anchor}/`, { + ...data, + ...fileMetaData, + }) + .then(async (response) => { + const signedURLResponse: TFileSignedURLResponse = response?.data; + const fileUploadPayload = generateFileUploadPayload(signedURLResponse, file); + await this.fileUploadService.uploadFile(signedURLResponse.upload_data.url, fileUploadPayload); + await this.updateAssetUploadStatus(anchor, signedURLResponse.asset_id); + return signedURLResponse; + }) + .catch((error) => { + throw error?.response?.data; + }); + } + + async deleteNewAsset(assetPath: string): Promise { + return this.delete(assetPath) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async deleteOldEditorAsset(workspaceId: string, src: string): Promise { + const assetKey = getAssetIdFromUrl(src); + return this.delete(`/api/workspaces/file-assets/${workspaceId}/${assetKey}/`) + .then((response) => response?.status) + .catch((error) => { + throw error?.response?.data; + }); + } + + async restoreNewAsset(workspaceSlug: string, src: string): Promise { + // remove the last slash and get the asset id + const assetId = getAssetIdFromUrl(src); + return this.post(`/api/public/assets/v2/workspaces/${workspaceSlug}/restore/${assetId}/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async restoreOldEditorAsset(workspaceId: string, src: string): Promise { + const assetKey = getAssetIdFromUrl(src); + return this.post(`/api/workspaces/file-assets/${workspaceId}/${assetKey}/restore/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; }); } cancelUpload() { this.cancelSource.cancel("Upload cancelled"); } - - getUploadFileFunction(workspaceSlug: string): (file: File) => Promise { - return async (file: File) => { - const formData = new FormData(); - formData.append("asset", file); - formData.append("attributes", JSON.stringify({})); - - const data = await this.uploadFile(workspaceSlug, formData); - return data.asset; - }; - } - - getDeleteImageFunction(workspaceId: string) { - return async (src: string) => { - try { - const assetUrlWithWorkspaceId = `${workspaceId}/${this.extractAssetIdFromUrl(src, workspaceId)}`; - const data = await this.deleteImage(assetUrlWithWorkspaceId); - return data; - } catch (e) { - console.error(e); - } - }; - } - - getRestoreImageFunction(workspaceId: string) { - return async (src: string) => { - try { - const assetUrlWithWorkspaceId = `${workspaceId}/${this.extractAssetIdFromUrl(src, workspaceId)}`; - const data = await this.restoreImage(assetUrlWithWorkspaceId); - return data; - } catch (e) { - console.error(e); - } - }; - } - - extractAssetIdFromUrl(src: string, workspaceId: string): string { - const indexWhereAssetIdStarts = src.indexOf(workspaceId) + workspaceId.length + 1; - if (indexWhereAssetIdStarts === -1) { - throw new Error("Workspace ID not found in source string"); - } - const assetUrl = src.substring(indexWhereAssetIdStarts); - return assetUrl; - } - - async deleteImage(assetUrlWithWorkspaceId: string): Promise { - return this.delete(`/api/workspaces/file-assets/${assetUrlWithWorkspaceId}/`) - .then((response) => response?.status) - .catch((error) => { - throw error?.response?.data; - }); - } - - async restoreImage(assetUrlWithWorkspaceId: string): Promise { - return this.post(`/api/workspaces/file-assets/${assetUrlWithWorkspaceId}/restore/`, { - "Content-Type": "application/json", - }) - .then((response) => response?.status) - .catch((error) => { - throw error?.response?.data; - }); - } } - -const fileService = new FileService(); - -export default fileService; diff --git a/space/core/services/issue.service.ts b/space/core/services/issue.service.ts index 2f19b4f08..b5ecb8077 100644 --- a/space/core/services/issue.service.ts +++ b/space/core/services/issue.service.ts @@ -2,7 +2,7 @@ import { API_BASE_URL } from "@/helpers/common.helper"; // services import { APIService } from "@/services/api.service"; // types -import { TIssuesResponse, IIssue } from "@/types/issue"; +import { Comment, TIssuesResponse, IIssue } from "@/types/issue"; class IssueService extends APIService { constructor() { @@ -83,7 +83,7 @@ class IssueService extends APIService { }); } - async createIssueComment(anchor: string, issueID: string, data: any): Promise { + async createIssueComment(anchor: string, issueID: string, data: any): Promise { return this.post(`/api/public/anchor/${anchor}/issues/${issueID}/comments/`, data) .then((response) => response?.data) .catch((error) => { diff --git a/space/core/store/issue-detail.store.ts b/space/core/store/issue-detail.store.ts index 8b4710b17..ee8a3031e 100644 --- a/space/core/store/issue-detail.store.ts +++ b/space/core/store/issue-detail.store.ts @@ -3,12 +3,16 @@ import set from "lodash/set"; import { makeObservable, observable, action, runInAction } from "mobx"; import { computedFn } from "mobx-utils"; import { v4 as uuidv4 } from "uuid"; +// plane types +import { TFileSignedURLResponse } from "@plane/types"; +import { EFileAssetType } from "@plane/types/src/enums"; // services +import { FileService } from "@/services/file.service"; import IssueService from "@/services/issue.service"; // store import { CoreRootStore } from "@/store/root.store"; // types -import { IIssue, IPeekMode, IVote } from "@/types/issue"; +import { Comment, IIssue, IPeekMode, IVote } from "@/types/issue"; export interface IIssueDetailStore { loader: boolean; @@ -28,9 +32,10 @@ export interface IIssueDetailStore { // issue actions fetchIssueDetails: (anchor: string, issueID: string) => void; // comment actions - addIssueComment: (anchor: string, issueID: string, data: any) => Promise; + addIssueComment: (anchor: string, issueID: string, data: any) => Promise; updateIssueComment: (anchor: string, issueID: string, commentID: string, data: any) => Promise; deleteIssueComment: (anchor: string, issueID: string, commentID: string) => void; + uploadCommentAsset: (file: File, anchor: string, commentID?: string) => Promise; addCommentReaction: (anchor: string, issueID: string, commentID: string, reactionHex: string) => void; removeCommentReaction: (anchor: string, issueID: string, commentID: string, reactionHex: string) => void; // reaction actions @@ -54,6 +59,7 @@ export class IssueDetailStore implements IIssueDetailStore { rootStore: CoreRootStore; // services issueService: IssueService; + fileService: FileService; constructor(_rootStore: CoreRootStore) { makeObservable(this, { @@ -72,6 +78,7 @@ export class IssueDetailStore implements IIssueDetailStore { addIssueComment: action, updateIssueComment: action, deleteIssueComment: action, + uploadCommentAsset: action, addCommentReaction: action, removeCommentReaction: action, // reaction actions @@ -83,6 +90,7 @@ export class IssueDetailStore implements IIssueDetailStore { }); this.rootStore = _rootStore; this.issueService = new IssueService(); + this.fileService = new FileService(); } setPeekId = (issueID: string | null) => { @@ -220,6 +228,23 @@ export class IssueDetailStore implements IIssueDetailStore { } }; + uploadCommentAsset = async (file: File, anchor: string, commentID?: string) => { + try { + const res = await this.fileService.uploadAsset( + anchor, + { + entity_identifier: commentID ?? "", + entity_type: EFileAssetType.COMMENT_DESCRIPTION, + }, + file + ); + return res; + } catch (error) { + console.log("Error in uploading comment asset:", error); + throw new Error("Asset upload failed. Please try again later."); + } + }; + addCommentReaction = async (anchor: string, issueID: string, commentID: string, reactionHex: string) => { const newReaction = { id: uuidv4(), diff --git a/space/core/store/user.store.ts b/space/core/store/user.store.ts index 33b2cbe60..6616b10b0 100644 --- a/space/core/store/user.store.ts +++ b/space/core/store/user.store.ts @@ -79,7 +79,7 @@ export class UserStore implements IUserStore { first_name: this.data?.first_name, last_name: this.data?.last_name, display_name: this.data?.display_name, - avatar: this.data?.avatar || undefined, + avatar_url: this.data?.avatar_url || undefined, is_bot: false, }; } diff --git a/space/core/types/issue.d.ts b/space/core/types/issue.d.ts index 79c6257d5..3041a188d 100644 --- a/space/core/types/issue.d.ts +++ b/space/core/types/issue.d.ts @@ -139,7 +139,7 @@ export interface IIssueReaction { } export interface ActorDetail { - avatar?: string; + avatar_url?: string; display_name?: string; first_name?: string; is_bot?: boolean; diff --git a/space/helpers/editor.helper.ts b/space/helpers/editor.helper.ts new file mode 100644 index 000000000..bf4faabdb --- /dev/null +++ b/space/helpers/editor.helper.ts @@ -0,0 +1,83 @@ +// plane editor +import { TFileHandler } from "@plane/editor"; +// constants +import { MAX_FILE_SIZE } from "@/constants/common"; +// helpers +import { getFileURL } from "@/helpers/file.helper"; +import { checkURLValidity } from "@/helpers/string.helper"; +// services +import { FileService } from "@/services/file.service"; +const fileService = new FileService(); + +/** + * @description generate the file source using assetId + * @param {string} anchor + */ +export const getEditorAssetSrc = (anchor: string, assetId: string): string | undefined => { + const url = getFileURL(`/api/public/assets/v2/anchor/${anchor}/${assetId}/`); + return url; +}; + +type TArgs = { + anchor: string; + uploadFile: (file: File) => Promise; + workspaceId: string; +}; + +/** + * @description this function returns the file handler required by the editors + * @param {TArgs} args + */ +export const getEditorFileHandlers = (args: TArgs): TFileHandler => { + const { anchor, uploadFile, workspaceId } = args; + + return { + getAssetSrc: (path) => { + if (!path) return ""; + if (checkURLValidity(path)) { + return path; + } else { + return getEditorAssetSrc(anchor, path) ?? ""; + } + }, + upload: uploadFile, + delete: async (src: string) => { + if (checkURLValidity(src)) { + await fileService.deleteOldEditorAsset(workspaceId, src); + } else { + await fileService.deleteNewAsset(getEditorAssetSrc(anchor, src) ?? ""); + } + }, + restore: async (src: string) => { + if (checkURLValidity(src)) { + await fileService.restoreOldEditorAsset(workspaceId, src); + } else { + await fileService.restoreNewAsset(anchor, src); + } + }, + cancel: fileService.cancelUpload, + validation: { + maxFileSize: MAX_FILE_SIZE, + }, + }; +}; + +/** + * @description this function returns the file handler required by the read-only editors + */ +export const getReadOnlyEditorFileHandlers = ( + args: Pick +): { getAssetSrc: TFileHandler["getAssetSrc"] } => { + const { anchor } = args; + + return { + getAssetSrc: (path) => { + if (!path) return ""; + if (checkURLValidity(path)) { + return path; + } else { + return getEditorAssetSrc(anchor, path) ?? ""; + } + }, + }; +}; diff --git a/space/helpers/file.helper.ts b/space/helpers/file.helper.ts new file mode 100644 index 000000000..fef071ed3 --- /dev/null +++ b/space/helpers/file.helper.ts @@ -0,0 +1,52 @@ +// plane types +import { TFileMetaDataLite, TFileSignedURLResponse } from "@plane/types"; +// helpers +import { API_BASE_URL } from "@/helpers/common.helper"; +import { checkURLValidity } from "@/helpers/string.helper"; + +/** + * @description from the provided signed URL response, generate a payload to be used to upload the file + * @param {TFileSignedURLResponse} signedURLResponse + * @param {File} file + * @returns {FormData} file upload request payload + */ +export const generateFileUploadPayload = (signedURLResponse: TFileSignedURLResponse, file: File): FormData => { + const formData = new FormData(); + Object.entries(signedURLResponse.upload_data.fields).forEach(([key, value]) => formData.append(key, value)); + formData.append("file", file); + return formData; +}; + +/** + * @description combine the file path with the base URL + * @param {string} path + * @returns {string} final URL with the base URL + */ +export const getFileURL = (path: string): string | undefined => { + if (!path) return undefined; + const isValidURL = checkURLValidity(path); + if (isValidURL) return path; + return `${API_BASE_URL}${path}`; +}; + +/** + * @description returns the necessary file meta data to upload a file + * @param {File} file + * @returns {TFileMetaDataLite} payload with file info + */ +export const getFileMetaDataForUpload = (file: File): TFileMetaDataLite => ({ + name: file.name, + size: file.size, + type: file.type, +}); + +/** + * @description this function returns the assetId from the asset source + * @param {string} src + * @returns {string} assetId + */ +export const getAssetIdFromUrl = (src: string): string => { + const sourcePaths = src.split("/"); + const assetUrl = sourcePaths[sourcePaths.length - 1]; + return assetUrl; +}; diff --git a/space/helpers/string.helper.ts b/space/helpers/string.helper.ts index 5c704c44c..dc838596a 100644 --- a/space/helpers/string.helper.ts +++ b/space/helpers/string.helper.ts @@ -78,3 +78,25 @@ export const isCommentEmpty = (comment: string | undefined): boolean => { export const replaceUnderscoreIfSnakeCase = (str: string) => str.replace(/_/g, " "); export const capitalizeFirstLetter = (str: string) => str.charAt(0).toUpperCase() + str.slice(1); + +/** + * @description + * This function test whether a URL is valid or not. + * + * It accepts URLs with or without the protocol. + * @param {string} url + * @returns {boolean} + * @example + * checkURLValidity("https://example.com") => true + * checkURLValidity("example.com") => true + * checkURLValidity("example") => false + */ +export const checkURLValidity = (url: string): boolean => { + if (!url) return false; + + // regex to support complex query parameters and fragments + const urlPattern = + /^(https?:\/\/)?((([a-z\d-]+\.)*[a-z\d-]+\.[a-z]{2,6})|(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}))(:\d+)?(\/[\w.-]*)*(\?[^#\s]*)?(#[\w-]*)?$/i; + + return urlPattern.test(url); +}; diff --git a/web/app/profile/page.tsx b/web/app/profile/page.tsx index 428be8272..1aa11d3d7 100644 --- a/web/app/profile/page.tsx +++ b/web/app/profile/page.tsx @@ -15,14 +15,14 @@ import { ProfileSettingContentWrapper } from "@/components/profile"; // constants import { TIME_ZONES } from "@/constants/timezones"; import { USER_ROLES } from "@/constants/workspace"; +// helpers +import { getFileURL } from "@/helpers/file.helper"; // hooks import { useUser } from "@/hooks/store"; -// services -import { FileService } from "@/services/file.service"; const defaultValues: Partial = { - avatar: "", - cover_image: "", + avatar_url: "", + cover_image_url: "", first_name: "", last_name: "", display_name: "", @@ -31,12 +31,9 @@ const defaultValues: Partial = { user_timezone: "Asia/Kolkata", }; -const fileService = new FileService(); - const ProfileSettingsPage = observer(() => { // states const [isLoading, setIsLoading] = useState(false); - const [isRemoving, setIsRemoving] = useState(false); const [isImageUploadModalOpen, setIsImageUploadModalOpen] = useState(false); const [deactivateAccountModal, setDeactivateAccountModal] = useState(false); // form info @@ -48,6 +45,9 @@ const ProfileSettingsPage = observer(() => { setValue, formState: { errors }, } = useForm({ defaultValues }); + // derived values + const userAvatar = watch("avatar_url"); + const userCover = watch("cover_image_url"); // store hooks const { data: currentUser, updateCurrentUser } = useUser(); @@ -60,8 +60,8 @@ const ProfileSettingsPage = observer(() => { const payload: Partial = { first_name: formData.first_name, last_name: formData.last_name, - avatar: formData.avatar, - cover_image: formData.cover_image, + avatar_url: formData.avatar_url, + cover_image_url: formData.cover_image_url, role: formData.role, display_name: formData?.display_name, user_timezone: formData.user_timezone, @@ -81,35 +81,30 @@ const ProfileSettingsPage = observer(() => { }); }; - const handleDelete = (url: string | null | undefined, updateUser: boolean = false) => { + const handleDelete = async (url: string | null | undefined) => { if (!url) return; - setIsRemoving(true); - - fileService.deleteUserFile(url).then(() => { - if (updateUser) - updateCurrentUser({ avatar: "" }) - .then(() => { - setToast({ - type: TOAST_TYPE.SUCCESS, - title: "Success!", - message: "Profile picture deleted successfully.", - }); - setIsRemoving(false); - setValue("avatar", ""); - }) - .catch(() => { - setToast({ - type: TOAST_TYPE.ERROR, - title: "Error!", - message: "There was some error in deleting your profile picture. Please try again.", - }); - }) - .finally(() => { - setIsRemoving(false); - setIsImageUploadModalOpen(false); - }); - }); + await updateCurrentUser({ + avatar_url: "", + }) + .then(() => { + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Success!", + message: "Profile picture deleted successfully.", + }); + setValue("avatar_url", ""); + }) + .catch(() => { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: "There was some error in deleting your profile picture. Please try again.", + }); + }) + .finally(() => { + setIsImageUploadModalOpen(false); + }); }; const timeZoneOptions = TIME_ZONES.map((timeZone) => ({ @@ -131,13 +126,12 @@ const ProfileSettingsPage = observer(() => { ( setIsImageUploadModalOpen(false)} - isRemoving={isRemoving} - handleDelete={() => handleDelete(currentUser?.avatar, true)} + handleRemove={async () => await handleDelete(currentUser?.avatar_url)} onSuccess={(url) => { onChange(url); handleSubmit(onSubmit)(); @@ -152,7 +146,7 @@ const ProfileSettingsPage = observer(() => {
{currentUser?.first_name @@ -160,14 +154,14 @@ const ProfileSettingsPage = observer(() => {
)} @@ -218,6 +230,8 @@ export const GptAssistantPopover: React.FC = (props) => { id="ai-assistant-response" initialValue={`

${response}

`} ref={responseRef} + workspaceSlug={workspaceSlug} + projectId={projectId} />
)} diff --git a/web/core/components/core/modals/user-image-upload-modal.tsx b/web/core/components/core/modals/user-image-upload-modal.tsx index 7e033053f..ad7a4daac 100644 --- a/web/core/components/core/modals/user-image-upload-modal.tsx +++ b/web/core/components/core/modals/user-image-upload-modal.tsx @@ -5,34 +5,33 @@ import { observer } from "mobx-react"; import { useDropzone } from "react-dropzone"; import { UserCircle2 } from "lucide-react"; import { Transition, Dialog } from "@headlessui/react"; +// plane types +import { EFileAssetType } from "@plane/types/src/enums"; // hooks import { Button, TOAST_TYPE, setToast } from "@plane/ui"; // constants -import { MAX_FILE_SIZE } from "@/constants/common"; -// hooks -import { useInstance } from "@/hooks/store"; +import { MAX_STATIC_FILE_SIZE } from "@/constants/common"; +// helpers +import { getAssetIdFromUrl, getFileURL } from "@/helpers/file.helper"; +import { checkURLValidity } from "@/helpers/string.helper"; // services import { FileService } from "@/services/file.service"; +const fileService = new FileService(); type Props = { - handleDelete?: () => void; + handleRemove: () => Promise; isOpen: boolean; - isRemoving: boolean; onClose: () => void; onSuccess: (url: string) => void; value: string | null; }; -// services -const fileService = new FileService(); - export const UserImageUploadModal: React.FC = observer((props) => { - const { value, onSuccess, isOpen, onClose, isRemoving, handleDelete } = props; + const { handleRemove, isOpen, onClose, onSuccess, value } = props; // states const [image, setImage] = useState(null); + const [isRemoving, setIsRemoving] = useState(false); const [isImageUploading, setIsImageUploading] = useState(false); - // store hooks - const { config } = useInstance(); const onDrop = (acceptedFiles: File[]) => setImage(acceptedFiles[0]); @@ -41,7 +40,7 @@ export const UserImageUploadModal: React.FC = observer((props) => { accept: { "image/*": [".png", ".jpg", ".jpeg", ".webp"], }, - maxSize: config?.file_size_limit ?? MAX_FILE_SIZE, + maxSize: MAX_STATIC_FILE_SIZE, multiple: false, }); @@ -53,31 +52,46 @@ export const UserImageUploadModal: React.FC = observer((props) => { const handleSubmit = async () => { if (!image) return; - setIsImageUploading(true); - const formData = new FormData(); - formData.append("asset", image); - formData.append("attributes", JSON.stringify({})); + try { + const { asset_url } = await fileService.uploadUserAsset( + { + entity_identifier: "", + entity_type: EFileAssetType.USER_AVATAR, + }, + image + ); + onSuccess(asset_url); + setImage(null); + } catch (error) { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: error?.toString() ?? "Something went wrong. Please try again.", + }); + throw new Error("Error in uploading file."); + } finally { + setIsImageUploading(false); + } + }; - fileService - .uploadUserFile(formData) - .then((res) => { - const imageUrl = res.asset; - - onSuccess(imageUrl); - setImage(null); - - if (value) fileService.deleteUserFile(value); - }) - .catch((err) => - setToast({ - type: TOAST_TYPE.ERROR, - title: "Error!", - message: err?.error ?? "Something went wrong. Please try again.", - }) - ) - .finally(() => setIsImageUploading(false)); + const handleImageRemove = async () => { + if (!value) return; + setIsRemoving(true); + try { + if (checkURLValidity(value)) { + await fileService.deleteOldUserAsset(value); + } else { + const assetId = getAssetIdFromUrl(value); + await fileService.deleteUserAsset(assetId); + } + await handleRemove(); + } catch (error) { + console.log("Error in uploading user asset:", error); + } finally { + setIsRemoving(false); + } }; return ( @@ -130,7 +144,7 @@ export const UserImageUploadModal: React.FC = observer((props) => { Edit image @@ -158,11 +172,9 @@ export const UserImageUploadModal: React.FC = observer((props) => {

File formats supported- .jpeg, .jpg, .png, .webp

- {handleDelete && ( - - )} +
diff --git a/web/core/components/core/modals/workspace-image-upload-modal.tsx b/web/core/components/core/modals/workspace-image-upload-modal.tsx index 614fe5f41..cc7248a62 100644 --- a/web/core/components/core/modals/workspace-image-upload-modal.tsx +++ b/web/core/components/core/modals/workspace-image-upload-modal.tsx @@ -1,23 +1,27 @@ "use client"; import React, { useState } from "react"; import { observer } from "mobx-react"; -import { useParams, usePathname } from "next/navigation"; +import { useParams } from "next/navigation"; import { useDropzone } from "react-dropzone"; import { UserCircle2 } from "lucide-react"; import { Transition, Dialog } from "@headlessui/react"; +// plane types +import { EFileAssetType } from "@plane/types/src/enums"; // hooks -import { Button, TOAST_TYPE, setToast } from "@plane/ui"; +import { Button } from "@plane/ui"; // constants -import { MAX_FILE_SIZE } from "@/constants/common"; +import { MAX_STATIC_FILE_SIZE } from "@/constants/common"; +// helpers +import { getAssetIdFromUrl, getFileURL } from "@/helpers/file.helper"; +import { checkURLValidity } from "@/helpers/string.helper"; // hooks -import { useWorkspace, useInstance } from "@/hooks/store"; +import { useWorkspace } from "@/hooks/store"; // services import { FileService } from "@/services/file.service"; type Props = { - handleRemove?: () => void; + handleRemove: () => Promise; isOpen: boolean; - isRemoving: boolean; onClose: () => void; onSuccess: (url: string) => void; value: string | null; @@ -27,16 +31,15 @@ type Props = { const fileService = new FileService(); export const WorkspaceImageUploadModal: React.FC = observer((props) => { - const { value, onSuccess, isOpen, onClose, isRemoving, handleRemove } = props; + const { handleRemove, isOpen, onClose, onSuccess, value } = props; // states const [image, setImage] = useState(null); + const [isRemoving, setIsRemoving] = useState(false); const [isImageUploading, setIsImageUploading] = useState(false); // router const { workspaceSlug } = useParams(); - const pathname = usePathname(); // store hooks - const { config } = useInstance(); - const { currentWorkspace } = useWorkspace(); + const { currentWorkspace, updateWorkspaceLogo } = useWorkspace(); const onDrop = (acceptedFiles: File[]) => setImage(acceptedFiles[0]); @@ -45,45 +48,58 @@ export const WorkspaceImageUploadModal: React.FC = observer((props) => { accept: { "image/*": [".png", ".jpg", ".jpeg", ".webp"], }, - maxSize: config?.file_size_limit ?? MAX_FILE_SIZE, + maxSize: MAX_STATIC_FILE_SIZE, multiple: false, }); const handleClose = () => { - setImage(null); setIsImageUploading(false); onClose(); + setTimeout(() => { + setImage(null); + }, 300); }; const handleSubmit = async () => { - if (!image || (!workspaceSlug && pathname !== "/onboarding")) return; - + if (!image || !workspaceSlug || !currentWorkspace) return; setIsImageUploading(true); - const formData = new FormData(); - formData.append("asset", image); - formData.append("attributes", JSON.stringify({})); + try { + const { asset_url } = await fileService.uploadWorkspaceAsset( + workspaceSlug.toString(), + { + entity_identifier: currentWorkspace.id, + entity_type: EFileAssetType.WORKSPACE_LOGO, + }, + image + ); + updateWorkspaceLogo(workspaceSlug.toString(), asset_url); + onSuccess(asset_url); + } catch (error) { + console.log("error", error); + throw new Error("Error in uploading file."); + } finally { + setIsImageUploading(false); + } + }; - if (!workspaceSlug) return; - - fileService - .uploadFile(workspaceSlug.toString(), formData) - .then((res) => { - const imageUrl = res.asset; - - onSuccess(imageUrl); - setImage(null); - - if (value && currentWorkspace) fileService.deleteFile(currentWorkspace.id, value); - }) - .catch((err) => - setToast({ - type: TOAST_TYPE.ERROR, - title: "Error!", - message: err?.error ?? "Something went wrong. Please try again.", - }) - ) - .finally(() => setIsImageUploading(false)); + const handleImageRemove = async () => { + if (!workspaceSlug || !value) return; + setIsRemoving(true); + try { + if (checkURLValidity(value)) { + await fileService.deleteOldWorkspaceAsset(currentWorkspace?.id ?? "", value); + } else { + const assetId = getAssetIdFromUrl(value); + await fileService.deleteWorkspaceAsset(workspaceSlug.toString(), assetId); + } + await handleRemove(); + handleClose(); + } catch (error) { + console.log("Error in removing workspace asset:", error); + } finally { + setIsRemoving(false); + } }; return ( @@ -115,7 +131,7 @@ export const WorkspaceImageUploadModal: React.FC = observer((props) => {
- Upload Image + Upload image
@@ -136,7 +152,7 @@ export const WorkspaceImageUploadModal: React.FC = observer((props) => { Edit image @@ -164,11 +180,9 @@ export const WorkspaceImageUploadModal: React.FC = observer((props) => {

File formats supported- .jpeg, .jpg, .png, .webp

- {handleRemove && ( - - )} +
diff --git a/web/core/components/cycles/active-cycle/cycle-stats.tsx b/web/core/components/cycles/active-cycle/cycle-stats.tsx index 1ee86a620..8e1d82349 100644 --- a/web/core/components/cycles/active-cycle/cycle-stats.tsx +++ b/web/core/components/cycles/active-cycle/cycle-stats.tsx @@ -17,15 +17,17 @@ import { EmptyState } from "@/components/empty-state"; // constants import { EmptyStateType } from "@/constants/empty-state"; import { EIssuesStoreType } from "@/constants/issue"; -// helper +// helpers import { cn } from "@/helpers/common.helper"; import { renderFormattedDate, renderFormattedDateWithoutYear } from "@/helpers/date-time.helper"; +import { getFileURL } from "@/helpers/file.helper"; // hooks import { useIssueDetail, useIssues } from "@/hooks/store"; import { useIntersectionObserver } from "@/hooks/use-intersection-observer"; import useLocalStorage from "@/hooks/use-local-storage"; // plane web components import { IssueIdentifier } from "@/plane-web/components/issues"; +// store import { ActiveCycleIssueDetails } from "@/store/issue/cycle"; export type ActiveCycleStatsProps = { @@ -250,7 +252,10 @@ export const ActiveCycleStats: FC = observer((props) => { key={assignee.assignee_id} title={
- + {assignee.display_name}
diff --git a/web/core/components/cycles/active-cycle/header.tsx b/web/core/components/cycles/active-cycle/header.tsx deleted file mode 100644 index 73d36f992..000000000 --- a/web/core/components/cycles/active-cycle/header.tsx +++ /dev/null @@ -1,79 +0,0 @@ -"use client"; - -import { FC } from "react"; -import Link from "next/link"; -// types -import { ICycle, TCycleGroups } from "@plane/types"; -// ui -import { Tooltip, CycleGroupIcon, getButtonStyling, Avatar, AvatarGroup } from "@plane/ui"; -// helpers -import { renderFormattedDate, findHowManyDaysLeft } from "@/helpers/date-time.helper"; -import { truncateText } from "@/helpers/string.helper"; -// hooks -import { useMember } from "@/hooks/store"; - -export type ActiveCycleHeaderProps = { - cycle: ICycle; - workspaceSlug: string; - projectId: string; -}; - -export const ActiveCycleHeader: FC = (props) => { - const { cycle, workspaceSlug, projectId } = props; - // store - const { getUserDetails } = useMember(); - const cycleOwnerDetails = cycle && cycle.owned_by_id ? getUserDetails(cycle.owned_by_id) : undefined; - - const daysLeft = findHowManyDaysLeft(cycle.end_date) ?? 0; - const currentCycleStatus = cycle.status?.toLocaleLowerCase() as TCycleGroups | undefined; - - const cycleAssignee = (cycle.distribution?.assignees ?? []).filter((assignee) => assignee.display_name); - - return ( -
-
- - -

{truncateText(cycle.name, 70)}

-
- - - {`${daysLeft} ${daysLeft > 1 ? "days" : "day"} left`} - - -
-
-
-
- - {cycleAssignee.length > 0 && ( - - - {cycleAssignee.map((member) => ( - - ))} - - - )} -
-
- - View Cycle - -
-
- ); -}; diff --git a/web/core/components/cycles/active-cycle/index.ts b/web/core/components/cycles/active-cycle/index.ts index c21978252..bf5f3e9b4 100644 --- a/web/core/components/cycles/active-cycle/index.ts +++ b/web/core/components/cycles/active-cycle/index.ts @@ -1,7 +1,3 @@ -export * from "./header"; -export * from "./stats"; -export * from "./upcoming-cycles-list-item"; -export * from "./upcoming-cycles-list"; export * from "./cycle-stats"; -export * from "./progress"; export * from "./productivity"; +export * from "./progress"; diff --git a/web/core/components/cycles/active-cycle/stats.tsx b/web/core/components/cycles/active-cycle/stats.tsx deleted file mode 100644 index 1a91fdfc8..000000000 --- a/web/core/components/cycles/active-cycle/stats.tsx +++ /dev/null @@ -1,144 +0,0 @@ -"use client"; - -import React, { Fragment } from "react"; -import { Tab } from "@headlessui/react"; -import { ICycle } from "@plane/types"; -// hooks -import { Avatar } from "@plane/ui"; -import { SingleProgressStats } from "@/components/core"; -import useLocalStorage from "@/hooks/use-local-storage"; -// components -// ui -// types - -type Props = { - cycle: ICycle; -}; - -export const ActiveCycleProgressStats: React.FC = ({ cycle }) => { - const { storedValue: tab, setValue: setTab } = useLocalStorage("activeCycleTab", "Assignees"); - - const currentValue = (tab: string | null) => { - switch (tab) { - case "Assignees": - return 0; - case "Labels": - return 1; - - default: - return 0; - } - }; - - return ( - { - switch (i) { - case 0: - return setTab("Assignees"); - case 1: - return setTab("Labels"); - - default: - return setTab("Assignees"); - } - }} - > - - - `rounded-3xl border border-custom-border-200 px-3 py-1 text-custom-text-100 ${ - selected ? " bg-custom-primary text-white" : " hover:bg-custom-background-80" - }` - } - > - Assignees - - - `rounded-3xl border border-custom-border-200 px-3 py-1 text-custom-text-100 ${ - selected ? " bg-custom-primary text-white" : " hover:bg-custom-background-80" - }` - } - > - Labels - - - {cycle && cycle.total_issues > 0 ? ( - - - {cycle.distribution?.assignees?.map((assignee, index) => { - if (assignee.assignee_id) - return ( - - - - {assignee?.display_name ?? ""} -
- } - completed={assignee.completed_issues} - total={assignee.total_issues} - /> - ); - else - return ( - -
- User -
- No assignee -
- } - completed={assignee.completed_issues} - total={assignee.total_issues} - /> - ); - })} - - - - {cycle.distribution?.labels?.map((label, index) => ( - - - {label.label_name ?? "No labels"} -
- } - completed={label.completed_issues} - total={label.total_issues} - /> - ))} - - - ) : ( -
- There are no issues present in this cycle. -
- )} - - ); -}; diff --git a/web/core/components/cycles/active-cycle/upcoming-cycles-list-item.tsx b/web/core/components/cycles/active-cycle/upcoming-cycles-list-item.tsx deleted file mode 100644 index 4cdadac97..000000000 --- a/web/core/components/cycles/active-cycle/upcoming-cycles-list-item.tsx +++ /dev/null @@ -1,137 +0,0 @@ -"use client"; - -import { useRef } from "react"; -import { observer } from "mobx-react"; -import Link from "next/link"; -import { useParams } from "next/navigation"; -import { Users } from "lucide-react"; -// ui -import { Avatar, AvatarGroup, FavoriteStar, setPromiseToast } from "@plane/ui"; -// components -import { CycleQuickActions } from "@/components/cycles"; -// constants -import { CYCLE_FAVORITED, CYCLE_UNFAVORITED } from "@/constants/event-tracker"; -// helpers -import { renderFormattedDate } from "@/helpers/date-time.helper"; -// hooks -import { useCycle, useEventTracker, useMember } from "@/hooks/store"; - -type Props = { - cycleId: string; -}; - -export const UpcomingCycleListItem: React.FC = observer((props) => { - const { cycleId } = props; - // refs - const parentRef = useRef(null); - // router - const { workspaceSlug, projectId } = useParams(); - // store hooks - const { captureEvent } = useEventTracker(); - const { addCycleToFavorites, getCycleById, removeCycleFromFavorites } = useCycle(); - const { getUserDetails } = useMember(); - // derived values - const cycle = getCycleById(cycleId); - - const handleAddToFavorites = (e: React.MouseEvent) => { - e.preventDefault(); - if (!workspaceSlug || !projectId) return; - - const addToFavoritePromise = addCycleToFavorites(workspaceSlug?.toString(), projectId.toString(), cycleId).then( - () => { - captureEvent(CYCLE_FAVORITED, { - cycle_id: cycleId, - element: "List layout", - state: "SUCCESS", - }); - } - ); - - setPromiseToast(addToFavoritePromise, { - loading: "Adding cycle to favorites...", - success: { - title: "Success!", - message: () => "Cycle added to favorites.", - }, - error: { - title: "Error!", - message: () => "Couldn't add the cycle to favorites. Please try again.", - }, - }); - }; - - const handleRemoveFromFavorites = (e: React.MouseEvent) => { - e.preventDefault(); - if (!workspaceSlug || !projectId) return; - - const removeFromFavoritePromise = removeCycleFromFavorites( - workspaceSlug?.toString(), - projectId.toString(), - cycleId - ).then(() => { - captureEvent(CYCLE_UNFAVORITED, { - cycle_id: cycleId, - element: "List layout", - state: "SUCCESS", - }); - }); - - setPromiseToast(removeFromFavoritePromise, { - loading: "Removing cycle from favorites...", - success: { - title: "Success!", - message: () => "Cycle removed from favorites.", - }, - error: { - title: "Error!", - message: () => "Couldn't remove the cycle from favorites. Please try again.", - }, - }); - }; - - if (!cycle) return null; - - return ( - -
{cycle.name}
-
- {cycle.start_date && cycle.end_date && ( -
- {renderFormattedDate(cycle.start_date)} - {renderFormattedDate(cycle.end_date)} -
- )} - {cycle.assignee_ids && cycle.assignee_ids?.length > 0 ? ( - - {cycle.assignee_ids?.map((assigneeId) => { - const member = getUserDetails(assigneeId); - return ; - })} - - ) : ( - - )} - - { - if (cycle.is_favorite) handleRemoveFromFavorites(e); - else handleAddToFavorites(e); - }} - selected={!!cycle.is_favorite} - /> - - {workspaceSlug && projectId && ( - - )} -
- - ); -}); diff --git a/web/core/components/cycles/active-cycle/upcoming-cycles-list.tsx b/web/core/components/cycles/active-cycle/upcoming-cycles-list.tsx deleted file mode 100644 index 221ffab0b..000000000 --- a/web/core/components/cycles/active-cycle/upcoming-cycles-list.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import { FC } from "react"; -import { observer } from "mobx-react"; -import Image from "next/image"; -import { useTheme } from "next-themes"; -// components -import { UpcomingCycleListItem } from "@/components/cycles"; -// hooks -import { useCycle } from "@/hooks/store"; - -type Props = { - handleEmptyStateAction: () => void; -}; - -export const UpcomingCyclesList: FC = observer((props) => { - const { handleEmptyStateAction } = props; - // store hooks - const { currentProjectUpcomingCycleIds } = useCycle(); - - // theme - const { resolvedTheme } = useTheme(); - - const resolvedEmptyStatePath = `/empty-state/active-cycle/cycle-${resolvedTheme === "light" ? "light" : "dark"}.webp`; - - if (!currentProjectUpcomingCycleIds) return null; - - return ( -
-
- Next cycles -
- {currentProjectUpcomingCycleIds.length > 0 ? ( -
- {currentProjectUpcomingCycleIds.map((cycleId) => ( - - ))} -
- ) : ( -
-
-
- button image -
-
No upcoming cycles
-

- Create new cycles to find them here or check -
- {"'"}All{"'"} cycles tab to see all cycles or{" "} - -

-
-
- )} -
- ); -}); diff --git a/web/core/components/cycles/analytics-sidebar/progress-stats.tsx b/web/core/components/cycles/analytics-sidebar/progress-stats.tsx index e98871b40..069ccb2ff 100644 --- a/web/core/components/cycles/analytics-sidebar/progress-stats.tsx +++ b/web/core/components/cycles/analytics-sidebar/progress-stats.tsx @@ -17,6 +17,7 @@ import { Avatar, StateGroupIcon } from "@plane/ui"; import { SingleProgressStats } from "@/components/core"; // helpers import { cn } from "@/helpers/common.helper"; +import { getFileURL } from "@/helpers/file.helper"; // hooks import { useProjectState } from "@/hooks/store"; import useLocalStorage from "@/hooks/use-local-storage"; @@ -28,7 +29,7 @@ import emptyMembers from "@/public/empty-state/empty_members.svg"; type TAssigneeData = { id: string | undefined; title: string | undefined; - avatar: string | undefined; + avatar_url: string | undefined; completed: number; total: number; }[]; @@ -82,7 +83,7 @@ export const AssigneeStatComponent = observer((props: TAssigneeStatComponent) => key={assignee?.id} title={
- + {assignee?.title ?? ""}
} @@ -277,14 +278,14 @@ export const CycleProgressStats: FC = observer((props) => { ? (currentDistribution?.assignees || []).map((assignee) => ({ id: assignee?.assignee_id || undefined, title: assignee?.display_name || undefined, - avatar: assignee?.avatar || undefined, + avatar_url: assignee?.avatar_url || undefined, completed: assignee.completed_issues, total: assignee.total_issues, })) : (currentEstimateDistribution?.assignees || []).map((assignee) => ({ id: assignee?.assignee_id || undefined, title: assignee?.display_name || undefined, - avatar: assignee?.avatar || undefined, + avatar_url: assignee?.avatar_url || undefined, completed: assignee.completed_estimates, total: assignee.total_estimates, })); diff --git a/web/core/components/cycles/analytics-sidebar/root.tsx b/web/core/components/cycles/analytics-sidebar/root.tsx index 8bea7edfe..fd8c984a6 100644 --- a/web/core/components/cycles/analytics-sidebar/root.tsx +++ b/web/core/components/cycles/analytics-sidebar/root.tsx @@ -7,8 +7,8 @@ import { useParams } from "next/navigation"; import { Loader } from "@plane/ui"; // components import { CycleAnalyticsProgress, CycleSidebarHeader, CycleSidebarDetails } from "@/components/cycles"; -import useCyclesDetails from "../active-cycle/use-cycles-details"; // hooks +import useCyclesDetails from "../active-cycle/use-cycles-details"; type Props = { handleClose: () => void; diff --git a/web/core/components/cycles/analytics-sidebar/sidebar-details.tsx b/web/core/components/cycles/analytics-sidebar/sidebar-details.tsx index 708b9c217..c60b5dae9 100644 --- a/web/core/components/cycles/analytics-sidebar/sidebar-details.tsx +++ b/web/core/components/cycles/analytics-sidebar/sidebar-details.tsx @@ -3,13 +3,15 @@ import React, { FC } from "react"; import isEmpty from "lodash/isEmpty"; import { observer } from "mobx-react"; import { LayersIcon, SquareUser, Users } from "lucide-react"; -// types +// plane types import { ICycle } from "@plane/types"; -// ui +// plane ui import { Avatar, AvatarGroup, TextArea } from "@plane/ui"; +// helpers +import { getFileURL } from "@/helpers/file.helper"; // hooks import { useMember, useProjectEstimates } from "@/hooks/store"; -// plane web +// plane web constants import { EEstimateSystem } from "@/plane-web/constants/estimates"; type Props = { @@ -72,7 +74,7 @@ export const CycleSidebarDetails: FC = observer((props) => {
- + {cycleOwnerDetails?.display_name}
@@ -94,7 +96,7 @@ export const CycleSidebarDetails: FC = observer((props) => { ); diff --git a/web/core/components/cycles/board/cycles-board-card.tsx b/web/core/components/cycles/board/cycles-board-card.tsx deleted file mode 100644 index 1f755089a..000000000 --- a/web/core/components/cycles/board/cycles-board-card.tsx +++ /dev/null @@ -1,262 +0,0 @@ -"use client"; - -import { FC, MouseEvent, useRef } from "react"; -import { observer } from "mobx-react"; -import Link from "next/link"; -import { usePathname, useSearchParams } from "next/navigation"; -import { CalendarCheck2, CalendarClock, Info, MoveRight } from "lucide-react"; -// types -import type { TCycleGroups } from "@plane/types"; -// ui -import { Avatar, AvatarGroup, Tooltip, LayersIcon, CycleGroupIcon, setPromiseToast, FavoriteStar } from "@plane/ui"; -// components -import { CycleQuickActions } from "@/components/cycles"; -// constants -import { CYCLE_STATUS } from "@/constants/cycle"; -import { CYCLE_FAVORITED, CYCLE_UNFAVORITED } from "@/constants/event-tracker"; -// helpers -import { findHowManyDaysLeft, getDate, renderFormattedDate } from "@/helpers/date-time.helper"; -import { generateQueryParams } from "@/helpers/router.helper"; -// hooks -import { useEventTracker, useCycle, useMember, useUserPermissions } from "@/hooks/store"; -import { useAppRouter } from "@/hooks/use-app-router"; -import { usePlatformOS } from "@/hooks/use-platform-os"; -import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions"; - -export interface ICyclesBoardCard { - workspaceSlug: string; - projectId: string; - cycleId: string; -} - -export const CyclesBoardCard: FC = observer((props) => { - const { cycleId, workspaceSlug, projectId } = props; - // refs - const parentRef = useRef(null); - // router - const router = useAppRouter(); - const searchParams = useSearchParams(); - const pathname = usePathname(); - // store - const { captureEvent } = useEventTracker(); - const { allowPermissions } = useUserPermissions(); - - const { addCycleToFavorites, removeCycleFromFavorites, getCycleById } = useCycle(); - const { getUserDetails } = useMember(); - // computed - const cycleDetails = getCycleById(cycleId); - // hooks - const { isMobile } = usePlatformOS(); - - if (!cycleDetails) return null; - - const cycleStatus = cycleDetails.status?.toLocaleLowerCase(); - // const isCompleted = cycleStatus === "completed"; - const endDate = getDate(cycleDetails.end_date); - const startDate = getDate(cycleDetails.start_date); - const isDateValid = cycleDetails.start_date || cycleDetails.end_date; - - const isEditingAllowed = allowPermissions( - [EUserPermissions.ADMIN, EUserPermissions.MEMBER], - EUserPermissionsLevel.PROJECT - ); - - const currentCycle = CYCLE_STATUS.find((status) => status.value === cycleStatus); - - const cycleTotalIssues = - cycleDetails.backlog_issues + - cycleDetails.unstarted_issues + - cycleDetails.started_issues + - cycleDetails.completed_issues + - cycleDetails.cancelled_issues; - - const completionPercentage = (cycleDetails.completed_issues / cycleTotalIssues) * 100; - - const issueCount = cycleDetails - ? cycleTotalIssues === 0 - ? "0 Issue" - : cycleTotalIssues === cycleDetails.completed_issues - ? `${cycleTotalIssues} Issue${cycleTotalIssues > 1 ? "s" : ""}` - : `${cycleDetails.completed_issues}/${cycleTotalIssues} Issues` - : "0 Issue"; - - const handleAddToFavorites = (e: MouseEvent) => { - e.preventDefault(); - if (!workspaceSlug || !projectId) return; - - const addToFavoritePromise = addCycleToFavorites(workspaceSlug?.toString(), projectId.toString(), cycleId).then( - () => { - captureEvent(CYCLE_FAVORITED, { - cycle_id: cycleId, - element: "Grid layout", - state: "SUCCESS", - }); - } - ); - - setPromiseToast(addToFavoritePromise, { - loading: "Adding cycle to favorites...", - success: { - title: "Success!", - message: () => "Cycle added to favorites.", - }, - error: { - title: "Error!", - message: () => "Couldn't add the cycle to favorites. Please try again.", - }, - }); - }; - - const handleRemoveFromFavorites = (e: MouseEvent) => { - e.preventDefault(); - if (!workspaceSlug || !projectId) return; - - const removeFromFavoritePromise = removeCycleFromFavorites( - workspaceSlug?.toString(), - projectId.toString(), - cycleId - ).then(() => { - captureEvent(CYCLE_UNFAVORITED, { - cycle_id: cycleId, - element: "Grid layout", - state: "SUCCESS", - }); - }); - - setPromiseToast(removeFromFavoritePromise, { - loading: "Removing cycle from favorites...", - success: { - title: "Success!", - message: () => "Cycle removed from favorites.", - }, - error: { - title: "Error!", - message: () => "Couldn't remove the cycle from favorites. Please try again.", - }, - }); - }; - - const openCycleOverview = (e: MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - - const query = generateQueryParams(searchParams, ["peekCycle"]); - if (searchParams.has("peekCycle") && searchParams.get("peekCycle") === cycleId) { - router.push(`${pathname}?${query}`); - } else { - router.push(`${pathname}?${query && `${query}&`}peekCycle=${cycleId}`); - } - }; - - const daysLeft = findHowManyDaysLeft(cycleDetails.end_date) ?? 0; - - return ( -
- -
-
-
- - - - - {cycleDetails.name} - -
-
- {currentCycle && ( - - {currentCycle.value === "current" - ? `${daysLeft} ${daysLeft > 1 ? "days" : "day"} left` - : `${currentCycle.label}`} - - )} - -
-
- -
-
-
- - {issueCount} -
- {cycleDetails.assignee_ids && cycleDetails.assignee_ids.length > 0 && ( - -
- - {cycleDetails.assignee_ids.map((assigne_id) => { - const member = getUserDetails(assigne_id); - return ; - })} - -
-
- )} -
- - -
-
-
-
-
- - -
- {isDateValid && ( -
- - {renderFormattedDate(startDate)} - - - {renderFormattedDate(endDate)} -
- )} -
-
-
- -
- {isEditingAllowed && ( - { - if (cycleDetails.is_favorite) handleRemoveFromFavorites(e); - else handleAddToFavorites(e); - }} - selected={!!cycleDetails.is_favorite} - /> - )} - - -
-
- ); -}); diff --git a/web/core/components/cycles/board/cycles-board-map.tsx b/web/core/components/cycles/board/cycles-board-map.tsx deleted file mode 100644 index 3e83ca755..000000000 --- a/web/core/components/cycles/board/cycles-board-map.tsx +++ /dev/null @@ -1,25 +0,0 @@ -// components -import { CyclesBoardCard } from "@/components/cycles"; - -type Props = { - cycleIds: string[]; - peekCycle: string | undefined; - projectId: string; - workspaceSlug: string; -}; - -export const CyclesBoardMap: React.FC = (props) => { - const { cycleIds, peekCycle, projectId, workspaceSlug } = props; - - return ( -
- {cycleIds.map((cycleId) => ( - - ))} -
- ); -}; diff --git a/web/core/components/cycles/board/index.ts b/web/core/components/cycles/board/index.ts deleted file mode 100644 index 2e6933d99..000000000 --- a/web/core/components/cycles/board/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from "./cycles-board-card"; -export * from "./cycles-board-map"; -export * from "./root"; diff --git a/web/core/components/cycles/board/root.tsx b/web/core/components/cycles/board/root.tsx deleted file mode 100644 index 1d4684fe5..000000000 --- a/web/core/components/cycles/board/root.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import { FC } from "react"; -import { observer } from "mobx-react"; -import { ChevronRight } from "lucide-react"; -import { Disclosure } from "@headlessui/react"; -// components -import { CyclePeekOverview, CyclesBoardMap } from "@/components/cycles"; -// helpers -import { cn } from "@/helpers/common.helper"; - -export interface ICyclesBoard { - completedCycleIds: string[]; - cycleIds: string[]; - workspaceSlug: string; - projectId: string; - peekCycle: string | undefined; -} - -export const CyclesBoard: FC = observer((props) => { - const { completedCycleIds, cycleIds, workspaceSlug, projectId, peekCycle } = props; - - return ( -
-
-
- {cycleIds.length > 0 && ( - - )} - {completedCycleIds.length !== 0 && ( - - - {({ open }) => ( - <> - Completed cycles ({completedCycleIds.length}) - - - )} - - - - - - )} -
- -
-
- ); -}); diff --git a/web/core/components/cycles/index.ts b/web/core/components/cycles/index.ts index f286b39e6..679ab7238 100644 --- a/web/core/components/cycles/index.ts +++ b/web/core/components/cycles/index.ts @@ -1,6 +1,5 @@ export * from "./active-cycle"; export * from "./applied-filters"; -export * from "./board/"; export * from "./dropdowns"; export * from "./gantt-chart"; export * from "./list"; diff --git a/web/core/components/cycles/list/cycle-list-item-action.tsx b/web/core/components/cycles/list/cycle-list-item-action.tsx index 5782881b1..eab9c5018 100644 --- a/web/core/components/cycles/list/cycle-list-item-action.tsx +++ b/web/core/components/cycles/list/cycle-list-item-action.tsx @@ -18,12 +18,15 @@ import { CYCLE_STATUS } from "@/constants/cycle"; import { CYCLE_FAVORITED, CYCLE_UNFAVORITED } from "@/constants/event-tracker"; // helpers import { findHowManyDaysLeft, getDate, renderFormattedPayloadDate } from "@/helpers/date-time.helper"; +import { getFileURL } from "@/helpers/file.helper"; // hooks import { generateQueryParams } from "@/helpers/router.helper"; import { useCycle, useEventTracker, useMember, useUserPermissions } from "@/hooks/store"; import { useAppRouter } from "@/hooks/use-app-router"; import { usePlatformOS } from "@/hooks/use-platform-os"; +// plane web constants import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions"; +// services import { CycleService } from "@/services/cycle.service"; const cycleService = new CycleService(); @@ -260,7 +263,9 @@ export const CycleListItemAction: FC = observer((props) => { {cycleDetails.assignee_ids?.map((assignee_id) => { const member = getUserDetails(assignee_id); - return ; + return ( + + ); })} ) : ( diff --git a/web/core/components/dashboard/widgets/issue-panels/issue-list-item.tsx b/web/core/components/dashboard/widgets/issue-panels/issue-list-item.tsx index c3dc06b1e..ba311ee56 100644 --- a/web/core/components/dashboard/widgets/issue-panels/issue-list-item.tsx +++ b/web/core/components/dashboard/widgets/issue-panels/issue-list-item.tsx @@ -8,6 +8,7 @@ import { TIssue, TWidgetIssue } from "@plane/types"; import { Avatar, AvatarGroup, ControlLink, PriorityIcon } from "@plane/ui"; // helpers import { findTotalDaysInRange, getDate, renderFormattedDate } from "@/helpers/date-time.helper"; +import { getFileURL } from "@/helpers/file.helper"; // hooks import { useIssueDetail, useMember, useProject } from "@/hooks/store"; // plane web components @@ -128,12 +129,12 @@ export const AssignedOverdueIssueListItem: React.FC = observ ? blockedByIssues.length > 1 ? `${blockedByIssues.length} blockers` : blockedByIssueProjectDetails && ( - - ) + + ) : "-"}
@@ -223,7 +224,9 @@ export const CreatedUpcomingIssueListItem: React.FC = observ if (!userDetails) return null; - return ; + return ( + + ); })} ) : ( @@ -281,7 +284,9 @@ export const CreatedOverdueIssueListItem: React.FC = observe if (!userDetails) return null; - return ; + return ( + + ); })} ) : ( @@ -334,7 +339,9 @@ export const CreatedCompletedIssueListItem: React.FC = obser if (!userDetails) return null; - return ; + return ( + + ); })} ) : ( diff --git a/web/core/components/dashboard/widgets/recent-activity.tsx b/web/core/components/dashboard/widgets/recent-activity.tsx index bc81f57c9..dd21815cc 100644 --- a/web/core/components/dashboard/widgets/recent-activity.tsx +++ b/web/core/components/dashboard/widgets/recent-activity.tsx @@ -13,6 +13,7 @@ import { RecentActivityEmptyState, WidgetLoader, WidgetProps } from "@/component // helpers import { cn } from "@/helpers/common.helper"; import { calculateTimeAgo } from "@/helpers/date-time.helper"; +import { getFileURL } from "@/helpers/file.helper"; // hooks import { useDashboard, useUser } from "@/hooks/store"; @@ -54,9 +55,9 @@ export const RecentActivityWidget: React.FC = observer((props) => { ) - ) : activity.actor_detail.avatar && activity.actor_detail.avatar !== "" ? ( + ) : activity.actor_detail.avatar_url && activity.actor_detail.avatar_url !== "" ? ( = observer((prop
= observer((props) => {
{projectDetails.members?.map((member) => ( - + ))}
diff --git a/web/core/components/dropdowns/member/avatar.tsx b/web/core/components/dropdowns/member/avatar.tsx index 50e3ae599..0a7a92d43 100644 --- a/web/core/components/dropdowns/member/avatar.tsx +++ b/web/core/components/dropdowns/member/avatar.tsx @@ -1,10 +1,11 @@ "use client"; import { observer } from "mobx-react"; -// icons import { LucideIcon, Users } from "lucide-react"; -// ui +// plane ui import { Avatar, AvatarGroup } from "@plane/ui"; +// helpers +import { getFileURL } from "@/helpers/file.helper"; // hooks import { useMember } from "@/hooks/store"; @@ -27,14 +28,21 @@ export const ButtonAvatars: React.FC = observer((props) => { const userDetails = getUserDetails(userId); if (!userDetails) return; - return ; + return ; })} ); } else { if (userIds) { const userDetails = getUserDetails(userIds); - return ; + return ( + + ); } } diff --git a/web/core/components/dropdowns/member/member-options.tsx b/web/core/components/dropdowns/member/member-options.tsx index bf14e14a6..cc34d25cc 100644 --- a/web/core/components/dropdowns/member/member-options.tsx +++ b/web/core/components/dropdowns/member/member-options.tsx @@ -8,15 +8,17 @@ import { createPortal } from "react-dom"; import { usePopper } from "react-popper"; import { Check, Search } from "lucide-react"; import { Combobox } from "@headlessui/react"; -//components -import { cn } from "@plane/editor"; +// plane ui import { Avatar } from "@plane/ui"; -//store +// helpers +import { cn } from "@/helpers/common.helper"; +import { getFileURL } from "@/helpers/file.helper"; +// hooks import { useUser, useMember } from "@/hooks/store"; import { usePlatformOS } from "@/hooks/use-platform-os"; interface Props { - className? : string; + className?: string; optionsClassName?: string; projectId?: string; referenceElement: HTMLButtonElement | null; @@ -25,7 +27,7 @@ interface Props { } export const MemberOptions = observer((props: Props) => { - const { projectId, referenceElement, placement, isOpen, optionsClassName="" } = props; + const { projectId, referenceElement, placement, isOpen, optionsClassName = "" } = props; // states const [query, setQuery] = useState(""); const [popperElement, setPopperElement] = useState(null); @@ -82,7 +84,7 @@ export const MemberOptions = observer((props: Props) => { query: `${userDetails?.display_name} ${userDetails?.first_name} ${userDetails?.last_name}`, content: (
- + {currentUser?.id === userId ? "You" : userDetails?.display_name}
), @@ -95,8 +97,10 @@ export const MemberOptions = observer((props: Props) => { return createPortal(
{ workspaceSlug: string; @@ -24,10 +25,9 @@ interface LiteTextEditorWrapperProps extends Omit Promise; } -const fileService = new FileService(); - export const LiteTextEditor = React.forwardRef((props, ref) => { const { containerClassName, @@ -40,6 +40,7 @@ export const LiteTextEditor = React.forwardRef ; +type LiteTextReadOnlyEditorWrapperProps = Omit & { + workspaceSlug: string; + projectId: string; +}; export const LiteTextReadOnlyEditor = React.forwardRef( - ({ ...props }, ref) => { + ({ workspaceSlug, projectId, ...props }, ref) => { // store hooks const { data: currentUser } = useUser(); const { mentionHighlights } = useMention({ @@ -19,6 +23,10 @@ export const LiteTextReadOnlyEditor = React.forwardRef { workspaceSlug: string; workspaceId: string; projectId: string; + uploadFile: (file: File) => Promise; } -const fileService = new FileService(); - export const RichTextEditor = forwardRef((props, ref) => { - const { containerClassName, workspaceSlug, workspaceId, projectId, ...rest } = props; + const { containerClassName, workspaceSlug, workspaceId, projectId, uploadFile, ...rest } = props; // store hooks const { data: currentUser } = useUser(); const { @@ -36,16 +36,19 @@ export const RichTextEditor = forwardRef; +type RichTextReadOnlyEditorWrapperProps = Omit & { + workspaceSlug: string; + projectId?: string; +}; export const RichTextReadOnlyEditor = React.forwardRef( - ({ ...props }, ref) => { + ({ workspaceSlug, projectId, ...props }, ref) => { const { mentionHighlights } = useMention({}); return ( return (
- +
{optionDetail?.display_name}
= observer((props: Props) => { key={`members-${member.id}`} isChecked={filterValue?.includes(member.id) ? true : false} onClick={() => handleInboxIssueFilters(filterKey, handleFilterValue(member.id))} - icon={} + icon={ + + } title={currentUser?.id === member.id ? "You" : member?.display_name} /> ); diff --git a/web/core/components/inbox/modals/create-edit-modal/create-root.tsx b/web/core/components/inbox/modals/create-edit-modal/create-root.tsx index b4dfd6904..7df5203f2 100644 --- a/web/core/components/inbox/modals/create-edit-modal/create-root.tsx +++ b/web/core/components/inbox/modals/create-edit-modal/create-root.tsx @@ -25,6 +25,9 @@ import { useEventTracker, useProjectInbox, useWorkspace } from "@/hooks/store"; import { useAppRouter } from "@/hooks/use-app-router"; import useKeypress from "@/hooks/use-keypress"; import { usePlatformOS } from "@/hooks/use-platform-os"; +// services +import { FileService } from "@/services/file.service"; +const fileService = new FileService(); type TInboxIssueCreateRoot = { workspaceSlug: string; @@ -46,6 +49,9 @@ export const defaultIssueData: Partial = { export const InboxIssueCreateRoot: FC = observer((props) => { const { workspaceSlug, projectId, handleModalClose } = props; + // states + const [uploadedAssetIds, setUploadedAssetIds] = useState([]); + // router const router = useAppRouter(); const pathname = usePathname(); // refs @@ -112,7 +118,13 @@ export const InboxIssueCreateRoot: FC = observer((props) setFormSubmitting(true); await createInboxIssue(workspaceSlug, projectId, payload) - .then((res) => { + .then(async (res) => { + if (uploadedAssetIds.length > 0) { + await fileService.updateBulkProjectAssetsUploadStatus(workspaceSlug, projectId, res?.id ?? "", { + asset_ids: uploadedAssetIds, + }); + setUploadedAssetIds([]); + } if (!createMore) { router.push(`/${workspaceSlug}/projects/${projectId}/inbox/?currentTab=open&inboxIssueId=${res?.issue?.id}`); handleModalClose(); @@ -177,6 +189,7 @@ export const InboxIssueCreateRoot: FC = observer((props) editorRef={descriptionEditorRef} containerClassName="border-[0.5px] border-custom-border-200 py-3 min-h-[150px]" onEnterKeyPress={() => submitBtnRef?.current?.click()} + onAssetUpload={(assetId) => setUploadedAssetIds((prev) => [...prev, assetId])} />
diff --git a/web/core/components/inbox/modals/create-edit-modal/issue-description.tsx b/web/core/components/inbox/modals/create-edit-modal/issue-description.tsx index 4daface0b..b9bad6c11 100644 --- a/web/core/components/inbox/modals/create-edit-modal/issue-description.tsx +++ b/web/core/components/inbox/modals/create-edit-modal/issue-description.tsx @@ -6,6 +6,7 @@ import { observer } from "mobx-react"; import { EditorRefApi } from "@plane/editor"; // types import { TIssue } from "@plane/types"; +import { EFileAssetType } from "@plane/types/src/enums"; // ui import { Loader } from "@plane/ui"; // components @@ -18,6 +19,9 @@ import { getTabIndex } from "@/helpers/tab-indices.helper"; // hooks import { useProjectInbox } from "@/hooks/store"; import { usePlatformOS } from "@/hooks/use-platform-os"; +// services +import { FileService } from "@/services/file.service"; +const fileService = new FileService(); type TInboxIssueDescription = { containerClassName?: string; @@ -28,12 +32,22 @@ type TInboxIssueDescription = { handleData: (issueKey: keyof Partial, issueValue: Partial[keyof Partial]) => void; editorRef: RefObject; onEnterKeyPress?: (e?: any) => void; + onAssetUpload?: (assetId: string) => void; }; // TODO: have to implement GPT Assistance export const InboxIssueDescription: FC = observer((props) => { - const { containerClassName, workspaceSlug, projectId, workspaceId, data, handleData, editorRef, onEnterKeyPress } = - props; + const { + containerClassName, + workspaceSlug, + projectId, + workspaceId, + data, + handleData, + editorRef, + onEnterKeyPress, + onAssetUpload, + } = props; // hooks const { loader } = useProjectInbox(); const { isMobile } = usePlatformOS(); @@ -61,6 +75,24 @@ export const InboxIssueDescription: FC = observer((props containerClassName={containerClassName} onEnterKeyPress={onEnterKeyPress} tabIndex={getIndex("description_html")} + uploadFile={async (file) => { + try { + const { asset_id } = await fileService.uploadProjectAsset( + workspaceSlug, + projectId, + { + entity_identifier: data.id ?? "", + entity_type: EFileAssetType.ISSUE_DESCRIPTION, + }, + file + ); + onAssetUpload?.(asset_id); + return asset_id; + } catch (error) { + console.log("Error in uploading issue asset:", error); + throw new Error("Asset upload failed. Please try again later."); + } + }} /> ); }); diff --git a/web/core/components/integration/github/single-user-select.tsx b/web/core/components/integration/github/single-user-select.tsx index a936db630..c2ecda03e 100644 --- a/web/core/components/integration/github/single-user-select.tsx +++ b/web/core/components/integration/github/single-user-select.tsx @@ -2,15 +2,18 @@ import { useParams } from "next/navigation"; import useSWR from "swr"; +// plane types import { IGithubRepoCollaborator } from "@plane/types"; -// services +// plane ui import { Avatar, CustomSelect, CustomSearchSelect, Input } from "@plane/ui"; +// constants import { WORKSPACE_MEMBERS } from "@/constants/fetch-keys"; +// helpers +import { getFileURL } from "@/helpers/file.helper"; +// plane web services import { WorkspaceService } from "@/plane-web/services"; -// ui // types import { IUserDetails } from "./root"; -// fetch-keys type Props = { collaborator: IGithubRepoCollaborator; @@ -53,7 +56,7 @@ export const SingleUserSelect: React.FC = ({ collaborator, index, users, query: member.member?.display_name ?? "", content: (
- + {member.member?.display_name}
), diff --git a/web/core/components/integration/jira/import-users.tsx b/web/core/components/integration/jira/import-users.tsx index 3b7a7cd73..6bffece7f 100644 --- a/web/core/components/integration/jira/import-users.tsx +++ b/web/core/components/integration/jira/import-users.tsx @@ -4,14 +4,16 @@ import { FC } from "react"; import { useParams } from "next/navigation"; import { useFormContext, useFieldArray, Controller } from "react-hook-form"; import useSWR from "swr"; +// plane types import { IJiraImporterForm } from "@plane/types"; -// services +// plane ui import { Avatar, CustomSelect, CustomSearchSelect, Input, ToggleSwitch } from "@plane/ui"; +// constants import { WORKSPACE_MEMBERS } from "@/constants/fetch-keys"; +// helpers +import { getFileURL } from "@/helpers/file.helper"; +// plane web services import { WorkspaceService } from "@/plane-web/services"; -// ui -// types -// fetch keys const workspaceService = new WorkspaceService(); @@ -42,7 +44,7 @@ export const JiraImportUsers: FC = () => { query: member.member.display_name ?? "", content: (
- + {member.member.display_name}
), diff --git a/web/core/components/issues/attachment/attachment-detail.tsx b/web/core/components/issues/attachment/attachment-detail.tsx index 255b955bb..04d5641af 100644 --- a/web/core/components/issues/attachment/attachment-detail.tsx +++ b/web/core/components/issues/attachment/attachment-detail.tsx @@ -13,6 +13,7 @@ import { IssueAttachmentDeleteModal } from "@/components/issues"; // helpers import { convertBytesToSize, getFileExtension, getFileName } from "@/helpers/attachment.helper"; import { renderFormattedDate } from "@/helpers/date-time.helper"; +import { getFileURL } from "@/helpers/file.helper"; import { truncateText } from "@/helpers/string.helper"; // hooks import { useIssueDetail, useMember } from "@/hooks/store"; @@ -40,6 +41,10 @@ export const IssueAttachmentsDetail: FC = observer((pro const [isDeleteIssueAttachmentModalOpen, setIsDeleteIssueAttachmentModalOpen] = useState(false); // derived values const attachment = attachmentId ? getAttachmentById(attachmentId) : undefined; + const fileName = getFileName(attachment?.attributes.name ?? ""); + const fileExtension = getFileExtension(attachment?.asset_url ?? ""); + const fileIcon = getFileIcon(fileExtension, 28); + const fileURL = getFileURL(attachment?.asset_url ?? ""); // hooks const { isMobile } = usePlatformOS(); @@ -56,13 +61,13 @@ export const IssueAttachmentsDetail: FC = observer((pro /> )}
- +
-
{getFileIcon(getFileExtension(attachment.asset), 28)}
+
{fileIcon}
- - {truncateText(`${getFileName(attachment.attributes.name)}`, 10)} + + {truncateText(`${fileName}`, 10)} = observer((pro
- {getFileExtension(attachment.asset).toUpperCase()} + {fileExtension.toUpperCase()} {convertBytesToSize(attachment.attributes.size)}
diff --git a/web/core/components/issues/attachment/attachment-item-list.tsx b/web/core/components/issues/attachment/attachment-item-list.tsx index a0126b251..f1af2884c 100644 --- a/web/core/components/issues/attachment/attachment-item-list.tsx +++ b/web/core/components/issues/attachment/attachment-item-list.tsx @@ -3,10 +3,10 @@ import { observer } from "mobx-react"; import { FileRejection, useDropzone } from "react-dropzone"; import { UploadCloud } from "lucide-react"; // hooks -import {TOAST_TYPE, setToast } from "@plane/ui"; -import { MAX_FILE_SIZE } from "@/constants/common"; -import { generateFileName } from "@/helpers/attachment.helper"; -import { useInstance, useIssueDetail } from "@/hooks/store"; +import { TOAST_TYPE, setToast } from "@plane/ui"; +import { useIssueDetail } from "@/hooks/store"; +// plane web hooks +import { useFileSize } from "@/plane-web/hooks/use-file-size"; // components import { IssueAttachmentsListItem } from "./attachment-list-item"; // types @@ -24,66 +24,57 @@ type TIssueAttachmentItemList = { export const IssueAttachmentItemList: FC = observer((props) => { const { workspaceSlug, issueId, handleAttachmentOperations, disabled } = props; + // states const [isLoading, setIsLoading] = useState(false); - // store hooks - const { config } = useInstance(); const { attachment: { getAttachmentsByIssueId }, attachmentDeleteModalId, toggleDeleteAttachmentModal, } = useIssueDetail(); + // file size + const { maxFileSize } = useFileSize(); // derived values const issueAttachments = getAttachmentsByIssueId(issueId); const onDrop = useCallback( - (acceptedFiles: File[], rejectedFiles:FileRejection[] ) => { - const totalAttachedFiles = acceptedFiles.length + rejectedFiles.length; + (acceptedFiles: File[], rejectedFiles: FileRejection[]) => { + const totalAttachedFiles = acceptedFiles.length + rejectedFiles.length; - if(rejectedFiles.length===0){ + if (rejectedFiles.length === 0) { const currentFile: File = acceptedFiles[0]; if (!currentFile || !workspaceSlug) return; - const uploadedFile: File = new File([currentFile], generateFileName(currentFile.name), { - type: currentFile.type, - }); - const formData = new FormData(); - formData.append("asset", uploadedFile); - formData.append( - "attributes", - JSON.stringify({ - name: uploadedFile.name, - size: uploadedFile.size, - }) - ); setIsLoading(true); - handleAttachmentOperations.create(formData) - .catch(()=>{ - setToast({ - type: TOAST_TYPE.ERROR, - title: "Error!", - message: "File could not be attached. Try uploading again.", + handleAttachmentOperations + .create(currentFile) + .catch(() => { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: "File could not be attached. Try uploading again.", + }); }) - }) - .finally(() => setIsLoading(false)); + .finally(() => setIsLoading(false)); return; } setToast({ - type: TOAST_TYPE.ERROR, - title: "Error!", - message: (totalAttachedFiles>1)? - "Only one file can be uploaded at a time." : - "File must be 5MB or less.", - }) + type: TOAST_TYPE.ERROR, + title: "Error!", + message: + totalAttachedFiles > 1 + ? "Only one file can be uploaded at a time." + : `File must be of ${maxFileSize / 1024 / 1024}MB or less in size.`, + }); return; }, - [handleAttachmentOperations, workspaceSlug] + [handleAttachmentOperations, maxFileSize, workspaceSlug] ); const { getRootProps, getInputProps, isDragActive } = useDropzone({ onDrop, - maxSize: config?.file_size_limit ?? MAX_FILE_SIZE, + maxSize: maxFileSize, multiple: false, disabled: isLoading || disabled, }); diff --git a/web/core/components/issues/attachment/attachment-list-item.tsx b/web/core/components/issues/attachment/attachment-list-item.tsx index 28cff6995..e3adc5f82 100644 --- a/web/core/components/issues/attachment/attachment-list-item.tsx +++ b/web/core/components/issues/attachment/attachment-list-item.tsx @@ -11,6 +11,7 @@ import { getFileIcon } from "@/components/icons"; // helpers import { convertBytesToSize, getFileExtension, getFileName } from "@/helpers/attachment.helper"; import { renderFormattedDate } from "@/helpers/date-time.helper"; +import { getFileURL } from "@/helpers/file.helper"; // hooks import { useIssueDetail, useMember } from "@/hooks/store"; import { usePlatformOS } from "@/hooks/use-platform-os"; @@ -29,9 +30,12 @@ export const IssueAttachmentsListItem: FC = observer( attachment: { getAttachmentById }, toggleDeleteAttachmentModal, } = useIssueDetail(); - // derived values const attachment = attachmentId ? getAttachmentById(attachmentId) : undefined; + const fileName = getFileName(attachment?.attributes.name ?? ""); + const fileExtension = getFileExtension(attachment?.asset_url ?? ""); + const fileIcon = getFileIcon(fileExtension, 18); + const fileURL = getFileURL(attachment?.asset_url ?? ""); // hooks const { isMobile } = usePlatformOS(); @@ -43,17 +47,14 @@ export const IssueAttachmentsListItem: FC = observer( onClick={(e) => { e.preventDefault(); e.stopPropagation(); - window.open(attachment.asset, "_blank"); + window.open(fileURL, "_blank"); }} >
-
{getFileIcon(getFileExtension(attachment.asset), 18)}
- -

{`${getFileName(attachment.attributes.name)}.${getFileExtension(attachment.asset)}`}

+
{fileIcon}
+ +

{`${fileName}.${fileExtension}`}

{convertBytesToSize(attachment.attributes.size)} diff --git a/web/core/components/issues/attachment/attachment-upload.tsx b/web/core/components/issues/attachment/attachment-upload.tsx index 4be4cf11b..a2f526900 100644 --- a/web/core/components/issues/attachment/attachment-upload.tsx +++ b/web/core/components/issues/attachment/attachment-upload.tsx @@ -1,12 +1,8 @@ import { useCallback, useState } from "react"; import { observer } from "mobx-react"; import { useDropzone } from "react-dropzone"; -// constants -import { MAX_FILE_SIZE } from "@/constants/common"; -// helpers -import { generateFileName } from "@/helpers/attachment.helper"; -// hooks -import { useInstance } from "@/hooks/store"; +// plane web hooks +import { useFileSize } from "@/plane-web/hooks/use-file-size"; // types import { TAttachmentOperations } from "./root"; @@ -20,43 +16,29 @@ type Props = { export const IssueAttachmentUpload: React.FC = observer((props) => { const { workspaceSlug, disabled = false, handleAttachmentOperations } = props; - // store hooks - const { config } = useInstance(); // states const [isLoading, setIsLoading] = useState(false); + // file size + const { maxFileSize } = useFileSize(); const onDrop = useCallback( (acceptedFiles: File[]) => { const currentFile: File = acceptedFiles[0]; if (!currentFile || !workspaceSlug) return; - const uploadedFile: File = new File([currentFile], generateFileName(currentFile.name), { - type: currentFile.type, - }); - const formData = new FormData(); - formData.append("asset", uploadedFile); - formData.append( - "attributes", - JSON.stringify({ - name: uploadedFile.name, - size: uploadedFile.size, - }) - ); setIsLoading(true); - handleAttachmentOperations.create(formData).finally(() => setIsLoading(false)); + handleAttachmentOperations.create(currentFile).finally(() => setIsLoading(false)); }, [handleAttachmentOperations, workspaceSlug] ); const { getRootProps, getInputProps, isDragActive, isDragReject, fileRejections } = useDropzone({ onDrop, - maxSize: config?.file_size_limit ?? MAX_FILE_SIZE, + maxSize: maxFileSize, multiple: false, disabled: isLoading || disabled, }); - const maxFileSize = config?.file_size_limit ?? MAX_FILE_SIZE; - const fileError = fileRejections.length > 0 ? `Invalid file type or size (max ${maxFileSize / 1024 / 1024} MB)` : null; diff --git a/web/core/components/issues/attachment/root.tsx b/web/core/components/issues/attachment/root.tsx index f1bec92e8..e7874cc64 100644 --- a/web/core/components/issues/attachment/root.tsx +++ b/web/core/components/issues/attachment/root.tsx @@ -17,7 +17,7 @@ export type TIssueAttachmentRoot = { }; export type TAttachmentOperations = { - create: (data: FormData) => Promise; + create: (file: File) => Promise; remove: (linkId: string) => Promise; }; @@ -30,11 +30,11 @@ export const IssueAttachmentRoot: FC = (props) => { const handleAttachmentOperations: TAttachmentOperations = useMemo( () => ({ - create: async (data: FormData) => { + create: async (file: File) => { try { if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing required fields"); - const attachmentUploadPromise = createAttachment(workspaceSlug, projectId, issueId, data); + const attachmentUploadPromise = createAttachment(workspaceSlug, projectId, issueId, file); setPromiseToast(attachmentUploadPromise, { loading: "Uploading attachment...", success: { diff --git a/web/core/components/issues/description-input.tsx b/web/core/components/issues/description-input.tsx index 56819d006..8c18618c5 100644 --- a/web/core/components/issues/description-input.tsx +++ b/web/core/components/issues/description-input.tsx @@ -6,6 +6,7 @@ import { observer } from "mobx-react"; import { Controller, useForm } from "react-hook-form"; // types import { TIssue } from "@plane/types"; +import { EFileAssetType } from "@plane/types/src/enums"; // ui import { Loader } from "@plane/ui"; // components @@ -15,6 +16,9 @@ import { TIssueOperations } from "@/components/issues/issue-detail"; import { getDescriptionPlaceholder } from "@/helpers/issue.helper"; // hooks import { useWorkspace } from "@/hooks/store"; +// services +import { FileService } from "@/services/file.service"; +const fileService = new FileService(); export type IssueDescriptionInputProps = { containerClassName?: string; @@ -115,12 +119,31 @@ export const IssueDescriptionInput: FC = observer((p placeholder ? placeholder : (isFocused, value) => getDescriptionPlaceholder(isFocused, value) } containerClassName={containerClassName} + uploadFile={async (file) => { + try { + const { asset_id } = await fileService.uploadProjectAsset( + workspaceSlug, + projectId, + { + entity_identifier: issueId, + entity_type: EFileAssetType.ISSUE_DESCRIPTION, + }, + file + ); + return asset_id; + } catch (error) { + console.log("Error in uploading issue asset:", error); + throw new Error("Asset upload failed. Please try again later."); + } + }} /> ) : ( ) } diff --git a/web/core/components/issues/issue-detail-widgets/attachments/helper.tsx b/web/core/components/issues/issue-detail-widgets/attachments/helper.tsx index 539c9ea18..b452dc3ad 100644 --- a/web/core/components/issues/issue-detail-widgets/attachments/helper.tsx +++ b/web/core/components/issues/issue-detail-widgets/attachments/helper.tsx @@ -16,11 +16,12 @@ export const useAttachmentOperations = ( const handleAttachmentOperations: TAttachmentOperations = useMemo( () => ({ - create: async (data: FormData) => { + create: async (file: File) => { + console.log("creating attachment...", file); try { if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing required fields"); - const attachmentUploadPromise = createAttachment(workspaceSlug, projectId, issueId, data); + const attachmentUploadPromise = createAttachment(workspaceSlug, projectId, issueId, file); setPromiseToast(attachmentUploadPromise, { loading: "Uploading attachment...", success: { diff --git a/web/core/components/issues/issue-detail-widgets/attachments/quick-action-button.tsx b/web/core/components/issues/issue-detail-widgets/attachments/quick-action-button.tsx index 01923b210..cfdb3c695 100644 --- a/web/core/components/issues/issue-detail-widgets/attachments/quick-action-button.tsx +++ b/web/core/components/issues/issue-detail-widgets/attachments/quick-action-button.tsx @@ -1,15 +1,15 @@ "use client"; + import React, { FC, useCallback, useState } from "react"; import { observer } from "mobx-react"; import { FileRejection, useDropzone } from "react-dropzone"; import { Plus } from "lucide-react"; -import {TOAST_TYPE, setToast } from "@plane/ui"; -// constants -import { MAX_FILE_SIZE } from "@/constants/common"; -// helper -import { generateFileName } from "@/helpers/attachment.helper"; +// plane ui +import { TOAST_TYPE, setToast } from "@plane/ui"; // hooks -import { useInstance, useIssueDetail } from "@/hooks/store"; +import { useIssueDetail } from "@/hooks/store"; +// plane web hooks +import { useFileSize } from "@/plane-web/hooks/use-file-size"; import { useAttachmentOperations } from "./helper"; @@ -26,65 +26,53 @@ export const IssueAttachmentActionButton: FC = observer((props) => { // state const [isLoading, setIsLoading] = useState(false); // store hooks - const { config } = useInstance(); const { setLastWidgetAction } = useIssueDetail(); - + // file size + const { maxFileSize } = useFileSize(); // operations const handleAttachmentOperations = useAttachmentOperations(workspaceSlug, projectId, issueId); - // handlers const onDrop = useCallback( - (acceptedFiles: File[], rejectedFiles:FileRejection[] ) => { + (acceptedFiles: File[], rejectedFiles: FileRejection[]) => { const totalAttachedFiles = acceptedFiles.length + rejectedFiles.length; - if(rejectedFiles.length===0){ + if (rejectedFiles.length === 0) { const currentFile: File = acceptedFiles[0]; if (!currentFile || !workspaceSlug) return; - const uploadedFile: File = new File([currentFile], generateFileName(currentFile.name), { - type: currentFile.type, - }); - const formData = new FormData(); - formData.append("asset", uploadedFile); - formData.append( - "attributes", - JSON.stringify({ - name: uploadedFile.name, - size: uploadedFile.size, - }) - ); setIsLoading(true); - handleAttachmentOperations.create(formData) - .catch(()=>{ - setToast({ - type: TOAST_TYPE.ERROR, - title: "Error!", - message: "File could not be attached. Try uploading again.", + handleAttachmentOperations + .create(currentFile) + .catch(() => { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: "File could not be attached. Try uploading again.", + }); }) - }) - .finally(() => { - setLastWidgetAction("attachments"); - setIsLoading(false); - }); - return; + .finally(() => { + setLastWidgetAction("attachments"); + setIsLoading(false); + }); + return; } setToast({ type: TOAST_TYPE.ERROR, title: "Error!", - message: (totalAttachedFiles>1)? - "Only one file can be uploaded at a time." : - "File must be 5MB or less.", - }) + message: + totalAttachedFiles > 1 + ? "Only one file can be uploaded at a time." + : `File must be of ${maxFileSize / 1024 / 1024}MB or less in size.`, + }); return; }, - [handleAttachmentOperations, workspaceSlug] + [handleAttachmentOperations, maxFileSize, workspaceSlug] ); - const { getRootProps, getInputProps } = useDropzone({ onDrop, - maxSize: config?.file_size_limit ?? MAX_FILE_SIZE, + maxSize: maxFileSize, multiple: false, disabled: isLoading || disabled, }); @@ -95,4 +83,4 @@ export const IssueAttachmentActionButton: FC = observer((props) => { {customButton ? customButton : } ); -}); \ No newline at end of file +}); diff --git a/web/core/components/issues/issue-detail/issue-activity/comments/comment-block.tsx b/web/core/components/issues/issue-detail/issue-activity/comments/comment-block.tsx index 2fb7116ff..8b9a3eff0 100644 --- a/web/core/components/issues/issue-detail/issue-activity/comments/comment-block.tsx +++ b/web/core/components/issues/issue-detail/issue-activity/comments/comment-block.tsx @@ -1,10 +1,11 @@ import { FC, ReactNode } from "react"; import { observer } from "mobx-react"; import { MessageCircle } from "lucide-react"; -// hooks -import { calculateTimeAgo } from "@/helpers/date-time.helper"; -import { useIssueDetail } from "@/hooks/store"; // helpers +import { calculateTimeAgo } from "@/helpers/date-time.helper"; +import { getFileURL } from "@/helpers/file.helper"; +// hooks +import { useIssueDetail } from "@/hooks/store"; type TIssueCommentBlock = { commentId: string; @@ -27,9 +28,9 @@ export const IssueCommentBlock: FC = observer((props) => {
- {comment.actor_detail.avatar && comment.actor_detail.avatar !== "" ? ( + {comment.actor_detail.avatar_url && comment.actor_detail.avatar_url !== "" ? ( { = observer((props) => { }; useEffect(() => { - isEditing && setFocus("comment_html"); + if (isEditing) { + setFocus("comment_html"); + } }, [isEditing, setFocus]); const commentHTML = watch("comment_html"); @@ -155,6 +157,10 @@ export const IssueCommentCard: FC = observer((props) => { } }} showSubmitButton={false} + uploadFile={async (file) => { + const { asset_id } = await activityOperations.uploadCommentAsset(file, comment.id); + return asset_id; + }} />
@@ -189,7 +195,13 @@ export const IssueCommentCard: FC = observer((props) => { )}
)} - + = (props) => { const { workspaceSlug, projectId, issueId, activityOperations, showAccessSpecifier = false } = props; + // states + const [uploadedAssetIds, setUploadedAssetIds] = useState([]); // refs - const editorRef = useRef(null); + const editorRef = useRef(null); // store hooks const workspaceStore = useWorkspace(); const { peekIssue } = useIssueDetail(); @@ -44,13 +51,24 @@ export const IssueCommentCreate: FC = (props) => { }, }); - const onSubmit = async (formData: Partial) => - await activityOperations.createComment(formData).finally(() => { - reset({ - comment_html: "

", + const onSubmit = async (formData: Partial) => { + await activityOperations + .createComment(formData) + .then(async (res) => { + if (uploadedAssetIds.length > 0) { + await fileService.updateBulkProjectAssetsUploadStatus(workspaceSlug, projectId, res.id, { + asset_ids: uploadedAssetIds, + }); + setUploadedAssetIds([]); + } + }) + .finally(() => { + reset({ + comment_html: "

", + }); + editorRef.current?.clearEditor(); }); - editorRef.current?.clearEditor(); - }); + }; const commentHTML = watch("comment_html"); const isEmpty = isCommentEmpty(commentHTML); @@ -92,6 +110,11 @@ export const IssueCommentCreate: FC = (props) => { handleAccessChange={onAccessChange} showAccessSpecifier={showAccessSpecifier} isSubmitting={isSubmitting} + uploadFile={async (file) => { + const { asset_id } = await activityOperations.uploadCommentAsset(file); + setUploadedAssetIds((prev) => [...prev, asset_id]); + return asset_id; + }} /> )} /> diff --git a/web/core/components/issues/issue-detail/issue-activity/root.tsx b/web/core/components/issues/issue-detail/issue-activity/root.tsx index 8bb15b973..60f9e59c1 100644 --- a/web/core/components/issues/issue-detail/issue-activity/root.tsx +++ b/web/core/components/issues/issue-detail/issue-activity/root.tsx @@ -1,9 +1,10 @@ "use client"; -import { FC, Fragment, useMemo, useState } from "react"; +import { FC, useMemo, useState } from "react"; import { observer } from "mobx-react"; // types -import { TIssueComment } from "@plane/types"; +import { TFileSignedURLResponse, TIssueComment } from "@plane/types"; +import { EFileAssetType } from "@plane/types/src/enums"; // ui import { TOAST_TYPE, setToast } from "@plane/ui"; // components @@ -15,6 +16,9 @@ import { useIssueDetail, useProject } from "@/hooks/store"; import { ActivityFilterRoot, IssueActivityWorklogCreateButton } from "@/plane-web/components/issues/worklog"; // plane web constants import { TActivityFilters, defaultActivityFilters } from "@/plane-web/constants/issues"; +// services +import { FileService } from "@/services/file.service"; +const fileService = new FileService(); type TIssueActivity = { workspaceSlug: string; @@ -25,9 +29,10 @@ type TIssueActivity = { }; export type TActivityOperations = { - createComment: (data: Partial) => Promise; + createComment: (data: Partial) => Promise; updateComment: (commentId: string, data: Partial) => Promise; removeComment: (commentId: string) => Promise; + uploadCommentAsset: (file: File, commentId?: string) => Promise; }; export const IssueActivity: FC = observer((props) => { @@ -51,15 +56,16 @@ export const IssueActivity: FC = observer((props) => { const activityOperations: TActivityOperations = useMemo( () => ({ - createComment: async (data: Partial) => { + createComment: async (data) => { try { if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing fields"); - await createComment(workspaceSlug, projectId, issueId, data); + const comment = await createComment(workspaceSlug, projectId, issueId, data); setToast({ title: "Success!", type: TOAST_TYPE.SUCCESS, message: "Comment created successfully.", }); + return comment; } catch (error) { setToast({ title: "Error!", @@ -68,7 +74,7 @@ export const IssueActivity: FC = observer((props) => { }); } }, - updateComment: async (commentId: string, data: Partial) => { + updateComment: async (commentId, data) => { try { if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing fields"); await updateComment(workspaceSlug, projectId, issueId, commentId, data); @@ -85,7 +91,7 @@ export const IssueActivity: FC = observer((props) => { }); } }, - removeComment: async (commentId: string) => { + removeComment: async (commentId) => { try { if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing fields"); await removeComment(workspaceSlug, projectId, issueId, commentId); @@ -102,6 +108,24 @@ export const IssueActivity: FC = observer((props) => { }); } }, + uploadCommentAsset: async (file, commentId) => { + try { + if (!workspaceSlug || !projectId) throw new Error("Missing fields"); + const res = await fileService.uploadProjectAsset( + workspaceSlug, + projectId, + { + entity_identifier: commentId ?? "", + entity_type: EFileAssetType.COMMENT_DESCRIPTION, + }, + file + ); + return res; + } catch (error) { + console.log("Error in uploading comment asset:", error); + throw new Error("Asset upload failed. Please try again later."); + } + }, }), [workspaceSlug, projectId, issueId, createComment, updateComment, removeComment] ); diff --git a/web/core/components/issues/issue-detail/main-content.tsx b/web/core/components/issues/issue-detail/main-content.tsx index 2bb16f515..ffd930de4 100644 --- a/web/core/components/issues/issue-detail/main-content.tsx +++ b/web/core/components/issues/issue-detail/main-content.tsx @@ -83,7 +83,6 @@ export const IssueMainContent: React.FC = observer((props) => { containerClassName="-ml-3" /> - {/* {issue?.description_html === issueDescription && ( */} = observer((props) => { setIsSubmitting={(value) => setIsSubmitting(value)} containerClassName="-ml-3 border-none" /> - {/* )} */} {currentUser && ( = observer((props) => { try { await fetchIssue(workspaceSlug, projectId, issueId); } catch (error) { - console.error("Error fetching the parent issue"); + console.error("Error fetching the parent issue:", error); } }, update: async (workspaceSlug: string, projectId: string, issueId: string, data: Partial) => { @@ -101,6 +101,7 @@ export const IssueDetailRoot: FC = observer((props) => { path: pathname, }); } catch (error) { + console.log("Error in updating issue:", error); captureIssueEvent({ eventName: ISSUE_UPDATED, payload: { state: "FAILED", element: "Issue detail page" }, @@ -132,6 +133,7 @@ export const IssueDetailRoot: FC = observer((props) => { path: pathname, }); } catch (error) { + console.log("Error in deleting issue:", error); setToast({ title: "Error!", type: TOAST_TYPE.ERROR, @@ -153,6 +155,7 @@ export const IssueDetailRoot: FC = observer((props) => { path: pathname, }); } catch (error) { + console.log("Error in archiving issue:", error); captureIssueEvent({ eventName: ISSUE_ARCHIVED, payload: { id: issueId, state: "FAILED", element: "Issue details page" }, @@ -318,6 +321,7 @@ export const IssueDetailRoot: FC = observer((props) => { archiveIssue, removeArchivedIssue, addIssueToCycle, + addCycleToIssue, removeIssueFromCycle, changeModulesInIssue, removeIssueFromModule, diff --git a/web/core/components/issues/issue-layouts/filters/applied-filters/members.tsx b/web/core/components/issues/issue-layouts/filters/applied-filters/members.tsx index 7f71abe7d..ed0b6a154 100644 --- a/web/core/components/issues/issue-layouts/filters/applied-filters/members.tsx +++ b/web/core/components/issues/issue-layouts/filters/applied-filters/members.tsx @@ -2,9 +2,11 @@ import { observer } from "mobx-react"; import { X } from "lucide-react"; -// ui +// plane ui import { Avatar } from "@plane/ui"; -// types +// helpers +import { getFileURL } from "@/helpers/file.helper"; +// hooks import { useMember } from "@/hooks/store"; type Props = { @@ -29,7 +31,12 @@ export const AppliedMembersFilters: React.FC = observer((props) => { return (
- + {memberDetails.display_name} {editable && ( } + workspaceSlug={workspaceSlug} + projectId={projectId} /> )}
diff --git a/web/core/components/issues/issue-modal/draft-issue-layout.tsx b/web/core/components/issues/issue-modal/draft-issue-layout.tsx index b542539bb..bca5de1d0 100644 --- a/web/core/components/issues/issue-modal/draft-issue-layout.tsx +++ b/web/core/components/issues/issue-modal/draft-issue-layout.tsx @@ -23,6 +23,7 @@ export interface DraftIssueProps { data?: Partial; issueTitleRef: React.MutableRefObject; isCreateMoreToggleEnabled: boolean; + onAssetUpload: (assetId: string) => void; onCreateMoreToggleChange: (value: boolean) => void; onChange: (formData: Partial | null) => void; onClose: (saveDraftIssueInLocalStorage?: boolean) => void; @@ -37,6 +38,7 @@ export const DraftIssueLayout: React.FC = observer((props) => { changesMade, data, issueTitleRef, + onAssetUpload, onChange, onClose, onSubmit, @@ -153,6 +155,7 @@ export const DraftIssueLayout: React.FC = observer((props) => { onCreateMoreToggleChange={onCreateMoreToggleChange} data={data} issueTitleRef={issueTitleRef} + onAssetUpload={onAssetUpload} onChange={onChange} onClose={handleClose} onSubmit={onSubmit} diff --git a/web/core/components/issues/issue-modal/form.tsx b/web/core/components/issues/issue-modal/form.tsx index 215c34099..08eeb9d42 100644 --- a/web/core/components/issues/issue-modal/form.tsx +++ b/web/core/components/issues/issue-modal/form.tsx @@ -53,6 +53,7 @@ export interface IssueFormProps { data?: Partial; issueTitleRef: React.MutableRefObject; isCreateMoreToggleEnabled: boolean; + onAssetUpload: (assetId: string) => void; onCreateMoreToggleChange: (value: boolean) => void; onChange?: (formData: Partial | null) => void; onClose: () => void; @@ -66,6 +67,7 @@ export const IssueFormRoot: FC = observer((props) => { const { data, issueTitleRef, + onAssetUpload, onChange, onClose, onSubmit, @@ -319,6 +321,7 @@ export const IssueFormRoot: FC = observer((props) => { = observer((props) => { } setGptAssistantModal={setGptAssistantModal} handleGptAssistantClose={() => reset(getValues())} + onAssetUpload={onAssetUpload} onClose={onClose} />
diff --git a/web/core/components/issues/peek-overview/index.ts b/web/core/components/issues/peek-overview/index.ts index 9cd51648b..3e0d56558 100644 --- a/web/core/components/issues/peek-overview/index.ts +++ b/web/core/components/issues/peek-overview/index.ts @@ -1,5 +1,4 @@ export * from "./header"; -export * from "./issue-attachments"; export * from "./issue-detail"; export * from "./properties"; export * from "./root"; diff --git a/web/core/components/issues/peek-overview/issue-attachments.tsx b/web/core/components/issues/peek-overview/issue-attachments.tsx deleted file mode 100644 index 8ffcdc277..000000000 --- a/web/core/components/issues/peek-overview/issue-attachments.tsx +++ /dev/null @@ -1,113 +0,0 @@ -"use client"; - -import { useMemo } from "react"; -// hooks -import { TOAST_TYPE, setPromiseToast, setToast } from "@plane/ui"; -import { IssueAttachmentUpload, IssueAttachmentsList, TAttachmentOperations } from "@/components/issues"; -import { useEventTracker, useIssueDetail } from "@/hooks/store"; -// components -// ui - -type Props = { - disabled: boolean; - issueId: string; - projectId: string; - workspaceSlug: string; -}; - -export const PeekOverviewIssueAttachments: React.FC = (props) => { - const { disabled, issueId, projectId, workspaceSlug } = props; - // store hooks - const { captureIssueEvent } = useEventTracker(); - const { - attachment: { createAttachment, removeAttachment }, - } = useIssueDetail(); - - const handleAttachmentOperations: TAttachmentOperations = useMemo( - () => ({ - create: async (data: FormData) => { - try { - const attachmentUploadPromise = createAttachment(workspaceSlug, projectId, issueId, data); - setPromiseToast(attachmentUploadPromise, { - loading: "Uploading attachment...", - success: { - title: "Attachment uploaded", - message: () => "The attachment has been successfully uploaded", - }, - error: { - title: "Attachment not uploaded", - message: () => "The attachment could not be uploaded", - }, - }); - - const res = await attachmentUploadPromise; - captureIssueEvent({ - eventName: "Issue attachment added", - payload: { id: issueId, state: "SUCCESS", element: "Issue detail page" }, - updates: { - changed_property: "attachment", - change_details: res.id, - }, - }); - } catch (error) { - captureIssueEvent({ - eventName: "Issue attachment added", - payload: { id: issueId, state: "FAILED", element: "Issue detail page" }, - }); - } - }, - remove: async (attachmentId: string) => { - try { - if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing required fields"); - await removeAttachment(workspaceSlug, projectId, issueId, attachmentId); - setToast({ - message: "The attachment has been successfully removed", - type: TOAST_TYPE.SUCCESS, - title: "Attachment removed", - }); - captureIssueEvent({ - eventName: "Issue attachment deleted", - payload: { id: issueId, state: "SUCCESS", element: "Issue detail page" }, - updates: { - changed_property: "attachment", - change_details: "", - }, - }); - } catch (error) { - captureIssueEvent({ - eventName: "Issue attachment deleted", - payload: { id: issueId, state: "FAILED", element: "Issue detail page" }, - updates: { - changed_property: "attachment", - change_details: "", - }, - }); - setToast({ - message: "The Attachment could not be removed", - type: TOAST_TYPE.ERROR, - title: "Attachment not removed", - }); - } - }, - }), - [workspaceSlug, projectId, issueId, captureIssueEvent, createAttachment, removeAttachment] - ); - - return ( -
-
Attachments
-
- - -
-
- ); -}; diff --git a/web/core/components/issues/peek-overview/root.tsx b/web/core/components/issues/peek-overview/root.tsx index 7ac5cd50c..49f178287 100644 --- a/web/core/components/issues/peek-overview/root.tsx +++ b/web/core/components/issues/peek-overview/root.tsx @@ -3,7 +3,9 @@ import { FC, useEffect, useState, useMemo } from "react"; import { observer } from "mobx-react"; import { usePathname } from "next/navigation"; +// plane types import { TIssue } from "@plane/types"; +// plane ui import { TOAST_TYPE, setPromiseToast, setToast } from "@plane/ui"; // components import { IssueView, TIssueOperations } from "@/components/issues"; @@ -13,6 +15,7 @@ import { EIssuesStoreType } from "@/constants/issue"; // hooks import { useEventTracker, useIssueDetail, useIssues, useUserPermissions } from "@/hooks/store"; import { useIssuesStore } from "@/hooks/use-issue-layout-store"; +// plane web constants import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions"; interface IIssuePeekOverview { @@ -46,12 +49,12 @@ export const IssuePeekOverview: FC = observer((props) => { const removeRoutePeekId = () => { setPeekIssue(undefined); - if (embedIssue) embedRemoveCurrentNotification && embedRemoveCurrentNotification(); + if (embedIssue) embedRemoveCurrentNotification?.(); }; const issueOperations: TIssueOperations = useMemo( () => ({ - fetch: async (workspaceSlug: string, projectId: string, issueId: string, loader = true) => { + fetch: async (workspaceSlug: string, projectId: string, issueId: string) => { try { setError(false); await fetchIssue( @@ -67,8 +70,8 @@ export const IssuePeekOverview: FC = observer((props) => { } }, update: async (workspaceSlug: string, projectId: string, issueId: string, data: Partial) => { - issues?.updateIssue && - (await issues + if (issues?.updateIssue) { + await issues .updateIssue(workspaceSlug, projectId, issueId, data) .then(async () => { fetchActivities(workspaceSlug, projectId, issueId); @@ -93,7 +96,8 @@ export const IssuePeekOverview: FC = observer((props) => { type: TOAST_TYPE.ERROR, message: "Issue update failed", }); - })); + }); + } }, remove: async (workspaceSlug: string, projectId: string, issueId: string) => { try { diff --git a/web/core/components/modules/analytics-sidebar/progress-stats.tsx b/web/core/components/modules/analytics-sidebar/progress-stats.tsx index 712928f14..3fc7849a4 100644 --- a/web/core/components/modules/analytics-sidebar/progress-stats.tsx +++ b/web/core/components/modules/analytics-sidebar/progress-stats.tsx @@ -17,6 +17,7 @@ import { Avatar, StateGroupIcon } from "@plane/ui"; import { SingleProgressStats } from "@/components/core"; // helpers import { cn } from "@/helpers/common.helper"; +import { getFileURL } from "@/helpers/file.helper"; // hooks import { useProjectState } from "@/hooks/store"; import useLocalStorage from "@/hooks/use-local-storage"; @@ -28,7 +29,7 @@ import emptyMembers from "@/public/empty-state/empty_members.svg"; type TAssigneeData = { id: string | undefined; title: string | undefined; - avatar: string | undefined; + avatar_url: string | undefined; completed: number; total: number; }[]; @@ -82,7 +83,7 @@ export const AssigneeStatComponent = observer((props: TAssigneeStatComponent) => key={assignee?.id} title={
- + {assignee?.title ?? ""}
} @@ -277,14 +278,14 @@ export const ModuleProgressStats: FC = observer((props) => ? (currentDistribution?.assignees || []).map((assignee) => ({ id: assignee?.assignee_id || undefined, title: assignee?.display_name || undefined, - avatar: assignee?.avatar || undefined, + avatar_url: assignee?.avatar_url || undefined, completed: assignee.completed_issues, total: assignee.total_issues, })) : (currentEstimateDistribution?.assignees || []).map((assignee) => ({ id: assignee?.assignee_id || undefined, title: assignee?.display_name || undefined, - avatar: assignee?.avatar || undefined, + avatar_url: assignee?.avatar_url || undefined, completed: assignee.completed_estimates, total: assignee.total_estimates, })); diff --git a/web/core/components/modules/applied-filters/members.tsx b/web/core/components/modules/applied-filters/members.tsx index 69f7d0004..ccb8c90c9 100644 --- a/web/core/components/modules/applied-filters/members.tsx +++ b/web/core/components/modules/applied-filters/members.tsx @@ -2,9 +2,11 @@ import { observer } from "mobx-react"; import { X } from "lucide-react"; -// ui +// plane ui import { Avatar } from "@plane/ui"; -// types +// helpers +import { getFileURL } from "@/helpers/file.helper"; +// hooks import { useMember } from "@/hooks/store"; type Props = { @@ -29,7 +31,12 @@ export const AppliedMembersFilters: React.FC = observer((props) => { return (
- + {memberDetails.display_name} {editable && (