From 91d23dabe37cbefa984092e73f72c62ed9684abe Mon Sep 17 00:00:00 2001 From: Prateek Shourya Date: Fri, 2 Aug 2024 19:52:37 +0530 Subject: [PATCH] [WEB-1185] feat: issue types and issue properties (#719) * dev: setup basic store structure for issue types and custom properties. * dev: create issue types and add back migration for existing issues * dev: fix save * dev: fix migration for issue types * dev: create page version * dev: add page versioning migrations * dev: create page version endpoints * WIP: issue types. * chore: update issue types & properties store structure. * dev: add is_default value in issue type * dev: implement create, update, list and delete for issue-types. (#584) * dev: implement create, update, list and delete for issue-types. * dev: issue properties root. * dev: rename model * dev: create issue property and related models * dev: create issue property and issue types api * dev: issue type endpoint * dev: add issue type feature flagging * dev: issue property options * dev: issue properties creation and updation. * dev: add fields to project and issue types * dev: issue property and issue types api * dev: update issue type migrations * dev: issue types enabled * dev: update folder structure and move default issue type validation * dev: update values endpoint to return arrays * dev: rename value to values * chore: API integration for issue types and properties settings. * dev: rename urls * dev: issue type values endpoint * dev: remove print logs * dev: create issue property activity task * dev: return dictionary on issue values list endpoint * fix: boolean types * dev: add issue type validation * fix: issue create with different type * dev: issue types settings and user side flow. * chore: add option to enable/ disable issue property. * chore: fix property attribute popover position * chore: add validation to remove default value for mandatory properties. * chore: add validation to disable editing property core attributes. * allow editing if no issue is attached to the issue type. * chore: update issue type loader logic. * chore:minor UI & UX improvements. * chore: add option to enable/ disable issue types. * chore: add property title dropdown to allow adding description while creating property. * chore: minor code restructuring. * chore: add `scrollIntoView` when adding new properties. * chore: update enable/ disable issue type logic. * chore: add property type icon. * chore: improve property values loader. * chore: add validation to only show issue types in projects where it's enabled. * fix: quick add. * chore: create/ update issue modal components restructuring. * fix: global issue update through modal on inital load. * chore: add validations to remove `default_value` when `is_required` is set to True. * fix: build errors. * chore: create/ update draft issue with custom properties. * chore: add logic to get issue property values while creating issue copy. * dev: remove default issue type creation * dev: issue property activity endpoint * chore: set property `is_active` to false by default. * chore: minor improvements * chore: refactor logic to render display name in member picker. * chore:minor improvement to issue types loader and data fetching. * fix: type declarations. * chore: add property options for dropdown property type. (#737) * chore: add property options for dropdown property type. * chore: components restructuring. * improvement: issue types and properties (#742) * chore: add property options for dropdown property type. * chore: components restructuring. * chore: UI improvements and minor fixes. * chore: issue types and properties store refactor. * chore: improve UX copy of properties and attributes. * chore: improve enable/ disable issue type flow. * chore: rename `Issue Extra Property` to `Issue Additional Property` * chore: update `handleCreateDataUpdate` to `updateCreateListData`. --------- Co-authored-by: pablohashescobar Co-authored-by: Satish Gandham --- apiserver/plane/api/serializers/issue.py | 16 + apiserver/plane/api/views/inbox.py | 7 + apiserver/plane/app/serializers/issue.py | 18 + apiserver/plane/app/views/issue/archive.py | 2 - apiserver/plane/app/views/project/base.py | 1 + apiserver/plane/db/models/issue_type.py | 1 + .../bgtasks/issue_property_activity_task.py | 435 ++ apiserver/plane/ee/models/__init__.py | 3 + apiserver/plane/ee/models/issue_properties.py | 5 +- apiserver/plane/ee/serializers/__init__.py | 6 + .../ee/serializers/app/issue_property.py | 61 + apiserver/plane/ee/urls/app/__init__.py | 2 + apiserver/plane/ee/urls/app/ai.py | 1 + apiserver/plane/ee/urls/app/issue_property.py | 87 + apiserver/plane/ee/utils/__init__.py | 0 .../ee/utils/issue_property_validators.py | 403 ++ apiserver/plane/ee/views/__init__.py | 2 + .../ee/views/app/issue_property/__init__.py | 5 + .../ee/views/app/issue_property/activity.py | 33 + .../plane/ee/views/app/issue_property/base.py | 124 + .../ee/views/app/issue_property/option.py | 187 + .../plane/ee/views/app/issue_property/type.py | 156 + .../ee/views/app/issue_property/value.py | 203 + apiserver/plane/space/serializer/issue.py | 20 +- apiserver/plane/space/views/inbox.py | 6 + apiserver/plane/utils/grouper.py | 3 +- packages/types/src/issues/base.d.ts | 9 +- packages/types/src/issues/issue.d.ts | 23 +- packages/types/src/project/projects.d.ts | 14 +- packages/ui/src/collapsible/collapsible.tsx | 5 +- .../ui/src/dropdowns/custom-search-select.tsx | 31 +- packages/ui/src/dropdowns/helper.tsx | 1 + packages/ui/src/emoji/icons.ts | 6 + packages/ui/src/emoji/logo.tsx | 11 +- .../ui/src/form-fields/checkbox-select.tsx | 83 + packages/ui/src/form-fields/index.ts | 1 + .../(detail)/[projectId]/settings/header.tsx | 4 +- .../[projectId]/settings/issue-types/page.tsx | 31 + .../(detail)/[projectId]/settings/sidebar.tsx | 4 +- web/ce/constants/project/settings/index.ts | 1 + web/ce/constants/project/settings/tabs.ts | 82 + web/core/components/dropdowns/date.tsx | 8 +- .../components/dropdowns/member/index.tsx | 31 +- .../components/dropdowns/member/types.d.ts | 1 + .../components/estimates/radio-select.tsx | 8 +- .../issues/issue-detail/main-content.tsx | 19 +- .../issues/issue-detail/sidebar.tsx | 10 + .../quick-action-dropdowns/all-issue.tsx | 1 + .../quick-action-dropdowns/cycle-issue.tsx | 1 + .../quick-action-dropdowns/module-issue.tsx | 1 + .../quick-action-dropdowns/project-issue.tsx | 1 + .../components/default-properties.tsx | 318 ++ .../components/description-editor.tsx | 229 + .../issues/issue-modal/components/index.ts | 5 + .../issue-modal/components/parent-tag.tsx | 56 + .../issue-modal/components/project-select.tsx | 53 + .../issue-modal/components/title-input.tsx | 57 + .../components/issues/issue-modal/form.tsx | 852 --- .../components/issues/issue-modal/index.ts | 2 - .../components/issues/issue-modal/modal.tsx | 320 +- .../components/issues/issue-update-status.tsx | 13 +- .../issues/peek-overview/issue-detail.tsx | 11 +- .../issues/peek-overview/properties.tsx | 10 + web/core/components/project/card.tsx | 2 +- web/core/constants/issue-modal.ts | 22 + web/core/constants/project.ts | 77 - .../layouts/auth-layout/project-wrapper.tsx | 11 + .../store/issue/issue-details/issue.store.ts | 1 + .../create-update/create-modal.tsx | 89 + .../issue-types/create-update/form.tsx | 134 + .../issue-types/create-update/index.ts | 3 + .../create-update/update-modal.tsx | 79 + web/ee/components/issue-types/delete/index.ts | 1 + .../components/issue-types/delete/modal.tsx | 72 + .../components/issue-types/dropdowns/index.ts | 1 + .../issue-types/dropdowns/issue-type.tsx | 69 + web/ee/components/issue-types/empty-state.tsx | 77 + .../issue-types/enable-disable/index.ts | 1 + .../issue-types/enable-disable/modal.tsx | 80 + web/ee/components/issue-types/index.ts | 10 + .../issue-types/issue-type-list-item.tsx | 98 + .../issue-types/issue-types-list.tsx | 40 + .../properties/attributes/boolean.tsx | 56 + .../properties/attributes/common/index.ts | 2 + .../common/property-multi-select.tsx | 45 + .../property-settings-configuration.tsx | 67 + .../properties/attributes/date-picker.tsx | 44 + .../properties/attributes/dropdown.tsx | 106 + .../properties/attributes/index.ts | 8 + .../properties/attributes/member-picker.tsx | 85 + .../properties/attributes/number.tsx | 57 + .../attributes/options/create-option-item.tsx | 34 + .../options/default-option-create-select.tsx | 103 + .../properties/attributes/options/index.ts | 4 + .../properties/attributes/options/option.tsx | 105 + .../properties/attributes/options/root.tsx | 132 + .../properties/attributes/text.tsx | 62 + .../issue-types/properties/dropdowns/index.ts | 4 + .../dropdowns/property-attributes.tsx | 105 + .../properties/dropdowns/property-title.tsx | 95 + .../properties/dropdowns/property-type.tsx | 80 + .../selected-attribute-properties.tsx | 102 + .../issue-types/properties/empty-state.tsx | 12 + .../issue-types/properties/index.ts | 9 + .../properties/mandatory-field-toggle.tsx | 59 + .../properties/property-create-list-item.tsx | 32 + .../properties/property-list-item.tsx | 369 ++ .../issue-types/properties/property-list.tsx | 76 + .../issue-types/properties/quick-actions.tsx | 112 + .../issue-types/properties/root.tsx | 110 + .../components/issue-types/quick-actions.tsx | 88 + web/ee/components/issue-types/root.tsx | 41 + .../values/components/boolean-input.tsx | 27 + .../values/components/date-select.tsx | 54 + .../issue-types/values/components/index.ts | 6 + .../values/components/member-select.tsx | 90 + .../values/components/number-input.tsx | 64 + .../values/components/option-select.tsx | 113 + .../values/components/text-input.tsx | 101 + .../components/issue-types/values/create.tsx | 101 + web/ee/components/issue-types/values/index.ts | 5 + web/ee/components/issue-types/values/root.tsx | 78 + .../components/issue-types/values/update.tsx | 102 + .../issue-types/values/value-select.tsx | 180 + web/ee/components/issues/index.ts | 1 + .../components/issues/issue-details/index.ts | 1 + .../issues/issue-details/issue-identifier.tsx | 41 + .../issue-modal/additional-properties.tsx | 67 + .../issues/issue-modal/draft-issue-layout.tsx | 24 +- web/ee/components/issues/issue-modal/form.tsx | 381 ++ web/ee/components/issues/issue-modal/index.ts | 2 + .../issues/issue-modal/issue-type-select.tsx | 87 + .../components/issues/issue-modal/modal.tsx | 370 ++ web/ee/constants/issue-properties.ts | 250 + web/ee/constants/project/settings/index.ts | 1 + web/ee/constants/project/settings/tabs.ts | 38 + web/ee/helpers/issue-properties.helper.ts | 79 + web/ee/hooks/store/index.ts | 1 + web/ee/hooks/store/issue-types/index.ts | 4 + .../store/issue-types/use-issue-property.ts | 24 + .../hooks/store/issue-types/use-issue-type.ts | 19 + .../store/issue-types/use-issue-types.ts | 12 + .../store/issue-types/use-property-option.ts | 26 + web/ee/services/issue-types/index.ts | 4 + .../issue-types/issue-properties.service.ts | 68 + .../issue-property-options.service.ts | 70 + .../issue-property-values.service.ts | 30 + .../issue-types/issue-types.service.ts | 57 + web/ee/store/issue-types/index.ts | 4 + .../issue-types/issue-property-option.ts | 124 + web/ee/store/issue-types/issue-property.ts | 268 + web/ee/store/issue-types/issue-type.ts | 249 + web/ee/store/issue-types/issue-types.store.ts | 235 + web/ee/store/root.store.ts | 4 + web/ee/types/index.ts | 9 +- web/ee/types/issue-types/index.ts | 5 + .../types/issue-types/issue-properties.d.ts | 46 + .../issue-property-configurations.d.ts | 76 + .../issue-types/issue-property-option.d.ts | 29 + .../issue-types/issue-property-values.d.ts | 6 + web/ee/types/issue-types/issue-types.d.ts | 21 + web/ee/types/workspace-worklog.d.ts | 4 +- web/ee/types/workspace.d.ts | 2 +- web/helpers/date-time.helper.ts | 19 +- web/helpers/emoji.helper.tsx | 6 +- web/helpers/issue-modal.helper.ts | 3 + .../issue-types/issue-type-dark.svg | 810 +++ .../issue-types/issue-type-light.svg | 192 + yarn.lock | 4667 +++++++---------- 169 files changed, 12302 insertions(+), 4226 deletions(-) create mode 100644 apiserver/plane/ee/bgtasks/issue_property_activity_task.py create mode 100644 apiserver/plane/ee/serializers/app/issue_property.py create mode 100644 apiserver/plane/ee/urls/app/issue_property.py create mode 100644 apiserver/plane/ee/utils/__init__.py create mode 100644 apiserver/plane/ee/utils/issue_property_validators.py create mode 100644 apiserver/plane/ee/views/app/issue_property/__init__.py create mode 100644 apiserver/plane/ee/views/app/issue_property/activity.py create mode 100644 apiserver/plane/ee/views/app/issue_property/base.py create mode 100644 apiserver/plane/ee/views/app/issue_property/option.py create mode 100644 apiserver/plane/ee/views/app/issue_property/type.py create mode 100644 apiserver/plane/ee/views/app/issue_property/value.py create mode 100644 packages/ui/src/form-fields/checkbox-select.tsx create mode 100644 web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/issue-types/page.tsx create mode 100644 web/ce/constants/project/settings/tabs.ts create mode 100644 web/core/components/issues/issue-modal/components/default-properties.tsx create mode 100644 web/core/components/issues/issue-modal/components/description-editor.tsx create mode 100644 web/core/components/issues/issue-modal/components/index.ts create mode 100644 web/core/components/issues/issue-modal/components/parent-tag.tsx create mode 100644 web/core/components/issues/issue-modal/components/project-select.tsx create mode 100644 web/core/components/issues/issue-modal/components/title-input.tsx delete mode 100644 web/core/components/issues/issue-modal/form.tsx create mode 100644 web/core/constants/issue-modal.ts create mode 100644 web/ee/components/issue-types/create-update/create-modal.tsx create mode 100644 web/ee/components/issue-types/create-update/form.tsx create mode 100644 web/ee/components/issue-types/create-update/index.ts create mode 100644 web/ee/components/issue-types/create-update/update-modal.tsx create mode 100644 web/ee/components/issue-types/delete/index.ts create mode 100644 web/ee/components/issue-types/delete/modal.tsx create mode 100644 web/ee/components/issue-types/dropdowns/index.ts create mode 100644 web/ee/components/issue-types/dropdowns/issue-type.tsx create mode 100644 web/ee/components/issue-types/empty-state.tsx create mode 100644 web/ee/components/issue-types/enable-disable/index.ts create mode 100644 web/ee/components/issue-types/enable-disable/modal.tsx create mode 100644 web/ee/components/issue-types/index.ts create mode 100644 web/ee/components/issue-types/issue-type-list-item.tsx create mode 100644 web/ee/components/issue-types/issue-types-list.tsx create mode 100644 web/ee/components/issue-types/properties/attributes/boolean.tsx create mode 100644 web/ee/components/issue-types/properties/attributes/common/index.ts create mode 100644 web/ee/components/issue-types/properties/attributes/common/property-multi-select.tsx create mode 100644 web/ee/components/issue-types/properties/attributes/common/property-settings-configuration.tsx create mode 100644 web/ee/components/issue-types/properties/attributes/date-picker.tsx create mode 100644 web/ee/components/issue-types/properties/attributes/dropdown.tsx create mode 100644 web/ee/components/issue-types/properties/attributes/index.ts create mode 100644 web/ee/components/issue-types/properties/attributes/member-picker.tsx create mode 100644 web/ee/components/issue-types/properties/attributes/number.tsx create mode 100644 web/ee/components/issue-types/properties/attributes/options/create-option-item.tsx create mode 100644 web/ee/components/issue-types/properties/attributes/options/default-option-create-select.tsx create mode 100644 web/ee/components/issue-types/properties/attributes/options/index.ts create mode 100644 web/ee/components/issue-types/properties/attributes/options/option.tsx create mode 100644 web/ee/components/issue-types/properties/attributes/options/root.tsx create mode 100644 web/ee/components/issue-types/properties/attributes/text.tsx create mode 100644 web/ee/components/issue-types/properties/dropdowns/index.ts create mode 100644 web/ee/components/issue-types/properties/dropdowns/property-attributes.tsx create mode 100644 web/ee/components/issue-types/properties/dropdowns/property-title.tsx create mode 100644 web/ee/components/issue-types/properties/dropdowns/property-type.tsx create mode 100644 web/ee/components/issue-types/properties/dropdowns/selected-attribute-properties.tsx create mode 100644 web/ee/components/issue-types/properties/empty-state.tsx create mode 100644 web/ee/components/issue-types/properties/index.ts create mode 100644 web/ee/components/issue-types/properties/mandatory-field-toggle.tsx create mode 100644 web/ee/components/issue-types/properties/property-create-list-item.tsx create mode 100644 web/ee/components/issue-types/properties/property-list-item.tsx create mode 100644 web/ee/components/issue-types/properties/property-list.tsx create mode 100644 web/ee/components/issue-types/properties/quick-actions.tsx create mode 100644 web/ee/components/issue-types/properties/root.tsx create mode 100644 web/ee/components/issue-types/quick-actions.tsx create mode 100644 web/ee/components/issue-types/root.tsx create mode 100644 web/ee/components/issue-types/values/components/boolean-input.tsx create mode 100644 web/ee/components/issue-types/values/components/date-select.tsx create mode 100644 web/ee/components/issue-types/values/components/index.ts create mode 100644 web/ee/components/issue-types/values/components/member-select.tsx create mode 100644 web/ee/components/issue-types/values/components/number-input.tsx create mode 100644 web/ee/components/issue-types/values/components/option-select.tsx create mode 100644 web/ee/components/issue-types/values/components/text-input.tsx create mode 100644 web/ee/components/issue-types/values/create.tsx create mode 100644 web/ee/components/issue-types/values/index.ts create mode 100644 web/ee/components/issue-types/values/root.tsx create mode 100644 web/ee/components/issue-types/values/update.tsx create mode 100644 web/ee/components/issue-types/values/value-select.tsx create mode 100644 web/ee/components/issues/issue-details/index.ts create mode 100644 web/ee/components/issues/issue-details/issue-identifier.tsx create mode 100644 web/ee/components/issues/issue-modal/additional-properties.tsx rename web/{core => ee}/components/issues/issue-modal/draft-issue-layout.tsx (84%) create mode 100644 web/ee/components/issues/issue-modal/form.tsx create mode 100644 web/ee/components/issues/issue-modal/index.ts create mode 100644 web/ee/components/issues/issue-modal/issue-type-select.tsx create mode 100644 web/ee/components/issues/issue-modal/modal.tsx create mode 100644 web/ee/constants/issue-properties.ts create mode 100644 web/ee/constants/project/settings/tabs.ts create mode 100644 web/ee/helpers/issue-properties.helper.ts create mode 100644 web/ee/hooks/store/issue-types/index.ts create mode 100644 web/ee/hooks/store/issue-types/use-issue-property.ts create mode 100644 web/ee/hooks/store/issue-types/use-issue-type.ts create mode 100644 web/ee/hooks/store/issue-types/use-issue-types.ts create mode 100644 web/ee/hooks/store/issue-types/use-property-option.ts create mode 100644 web/ee/services/issue-types/index.ts create mode 100644 web/ee/services/issue-types/issue-properties.service.ts create mode 100644 web/ee/services/issue-types/issue-property-options.service.ts create mode 100644 web/ee/services/issue-types/issue-property-values.service.ts create mode 100644 web/ee/services/issue-types/issue-types.service.ts create mode 100644 web/ee/store/issue-types/index.ts create mode 100644 web/ee/store/issue-types/issue-property-option.ts create mode 100644 web/ee/store/issue-types/issue-property.ts create mode 100644 web/ee/store/issue-types/issue-type.ts create mode 100644 web/ee/store/issue-types/issue-types.store.ts create mode 100644 web/ee/types/issue-types/index.ts create mode 100644 web/ee/types/issue-types/issue-properties.d.ts create mode 100644 web/ee/types/issue-types/issue-property-configurations.d.ts create mode 100644 web/ee/types/issue-types/issue-property-option.d.ts create mode 100644 web/ee/types/issue-types/issue-property-values.d.ts create mode 100644 web/ee/types/issue-types/issue-types.d.ts create mode 100644 web/helpers/issue-modal.helper.ts create mode 100644 web/public/empty-state/issue-types/issue-type-dark.svg create mode 100644 web/public/empty-state/issue-types/issue-type-light.svg diff --git a/apiserver/plane/api/serializers/issue.py b/apiserver/plane/api/serializers/issue.py index fd89a3e05e..e7dc85bc1e 100644 --- a/apiserver/plane/api/serializers/issue.py +++ b/apiserver/plane/api/serializers/issue.py @@ -11,6 +11,7 @@ from rest_framework import serializers # Module imports from plane.db.models import ( Issue, + IssueType, IssueActivity, IssueAssignee, IssueAttachment, @@ -129,9 +130,24 @@ class IssueSerializer(BaseSerializer): workspace_id = self.context["workspace_id"] default_assignee_id = self.context["default_assignee_id"] + issue_type_id = validated_data.get("type_id") + + if issue_type_id: + # Check if issue type is valid + issue_type = IssueType.objects.filter( + project_id=project_id, id=issue_type_id, is_active=True + ).first() + else: + # Get default issue type + issue_type = IssueType.objects.filter( + project_id=project_id, + is_default=True, + ).first() + issue = Issue.objects.create( **validated_data, project_id=project_id, + type=issue_type, ) # Issue Audit Users diff --git a/apiserver/plane/api/views/inbox.py b/apiserver/plane/api/views/inbox.py index e4e9b765dd..100b2492a5 100644 --- a/apiserver/plane/api/views/inbox.py +++ b/apiserver/plane/api/views/inbox.py @@ -19,6 +19,7 @@ from plane.app.permissions import ProjectLitePermission from plane.bgtasks.issue_activities_task import issue_activity from plane.db.models import ( Inbox, + IssueType, InboxIssue, Issue, Project, @@ -145,6 +146,11 @@ class InboxIssueAPIEndpoint(BaseAPIView): is_triage=True, ) + # Get the issue type + issue_type = IssueType.objects.filter( + project_id=project_id, is_default=True + ).first() + # create an issue issue = Issue.objects.create( name=request.data.get("issue", {}).get("name"), @@ -155,6 +161,7 @@ class InboxIssueAPIEndpoint(BaseAPIView): priority=request.data.get("issue", {}).get("priority", "none"), project_id=project_id, state=state, + type=issue_type, ) # create an inbox issue diff --git a/apiserver/plane/app/serializers/issue.py b/apiserver/plane/app/serializers/issue.py index e6cdc8e854..cc59593e12 100644 --- a/apiserver/plane/app/serializers/issue.py +++ b/apiserver/plane/app/serializers/issue.py @@ -33,6 +33,7 @@ from plane.db.models import ( IssueVote, IssueRelation, State, + IssueType, ) @@ -79,6 +80,12 @@ class IssueCreateSerializer(BaseSerializer): required=False, allow_null=True, ) + type_id = serializers.PrimaryKeyRelatedField( + source="type", + queryset=IssueType.objects.all(), + required=False, + allow_null=True, + ) parent_id = serializers.PrimaryKeyRelatedField( source="parent", queryset=Issue.objects.all(), @@ -135,10 +142,20 @@ class IssueCreateSerializer(BaseSerializer): workspace_id = self.context["workspace_id"] default_assignee_id = self.context["default_assignee_id"] + issue_type = validated_data.pop("type", None) + + if not issue_type: + # Get default issue type + issue_type = IssueType.objects.filter( + project_id=project_id, is_default=True + ).first() + issue_type = issue_type + # Create Issue issue = Issue.objects.create( **validated_data, project_id=project_id, + type=issue_type, ) # Issue Audit Users @@ -701,6 +718,7 @@ class IssueSerializer(DynamicBaseSerializer): "link_count", "is_draft", "archived_at", + "type_id", ] read_only_fields = fields diff --git a/apiserver/plane/app/views/issue/archive.py b/apiserver/plane/app/views/issue/archive.py index 9be4cf79b3..b9a3d023bd 100644 --- a/apiserver/plane/app/views/issue/archive.py +++ b/apiserver/plane/app/views/issue/archive.py @@ -50,7 +50,6 @@ from plane.utils.paginator import ( ) - class IssueArchiveViewSet(BaseViewSet): permission_classes = [ ProjectEntityPermission, @@ -318,4 +317,3 @@ class IssueArchiveViewSet(BaseViewSet): issue.save() return Response(status=status.HTTP_204_NO_CONTENT) - diff --git a/apiserver/plane/app/views/project/base.py b/apiserver/plane/app/views/project/base.py index bca8236a98..6553d3c520 100644 --- a/apiserver/plane/app/views/project/base.py +++ b/apiserver/plane/app/views/project/base.py @@ -47,6 +47,7 @@ from plane.db.models import ( ProjectMember, State, Workspace, + IssueType, ) from plane.utils.cache import cache_response from plane.bgtasks.webhook_task import model_activity diff --git a/apiserver/plane/db/models/issue_type.py b/apiserver/plane/db/models/issue_type.py index 0b61a57727..b7d178df94 100644 --- a/apiserver/plane/db/models/issue_type.py +++ b/apiserver/plane/db/models/issue_type.py @@ -42,4 +42,5 @@ class IssueType(WorkspaceBaseModel): # If there are issue types, set the sort order to the largest + 10000 if largest_sort_order is not None: self.sort_order = largest_sort_order + 10000 + super(IssueType, self).save(*args, **kwargs) diff --git a/apiserver/plane/ee/bgtasks/issue_property_activity_task.py b/apiserver/plane/ee/bgtasks/issue_property_activity_task.py new file mode 100644 index 0000000000..7390270201 --- /dev/null +++ b/apiserver/plane/ee/bgtasks/issue_property_activity_task.py @@ -0,0 +1,435 @@ +# Third party imports +from celery import shared_task + +# Module imports +from plane.ee.models import ( + IssuePropertyActivity, + PropertyTypeEnum, + IssueProperty, +) +from plane.db.models import Issue + + +def track_property_text( + bulk_property_activity, + property, + existing_value, + requested_value, + issue_id, + user_id, + epoch, +): + """ + This function is used to track the text property changes. + """ + # Case 1: If the existing value is empty and the requested value is not empty + if not existing_value and requested_value: + bulk_property_activity.append( + IssuePropertyActivity( + workspace_id=property.workspace_id, + project_id=property.project_id, + property=property, + issue_id=issue_id, + old_value="", + new_value=requested_value[0], + action="created", + actor_id=user_id, + epoch=epoch, + comment=f"added the {property.property_type}", + ) + ) + return + + # Case 2: If the existing value is not empty and the requested value is empty + if ( + existing_value + and requested_value + and existing_value[0] != requested_value[0] + ): + bulk_property_activity.append( + IssuePropertyActivity( + workspace_id=property.workspace_id, + project_id=property.project_id, + property=property, + issue_id=issue_id, + old_value=existing_value[0], + new_value=requested_value[0], + action="updated", + actor_id=user_id, + epoch=epoch, + comment=f"updated the {property.property_type}", + ) + ) + return + + # Case 3: If the existing value is not empty and the requested value is empty + if existing_value and not requested_value: + bulk_property_activity.append( + IssuePropertyActivity( + workspace_id=property.workspace_id, + project_id=property.project_id, + property=property, + issue_id=issue_id, + old_value=existing_value[0], + new_value="", + action="deleted", + actor_id=user_id, + epoch=epoch, + comment=f"deleted the {property.property_type}", + ) + ) + return + + +def handle_multi_properties( + bulk_property_activity, + property, + existing_value, + requested_value, + issue_id, + user_id, + epoch, +): + if property.is_multi: + added_values = set(requested_value) - set(existing_value) + removed_values = set(existing_value) - set(requested_value) + + # Create the added values + for value in added_values: + bulk_property_activity.append( + IssuePropertyActivity( + workspace_id=property.workspace_id, + project_id=property.project_id, + property=property, + issue_id=issue_id, + old_value="", + new_value=value, + action="created", + actor_id=user_id, + epoch=epoch, + comment=f"added the {property.property_type}", + ) + ) + + # Create the removed values + for value in removed_values: + bulk_property_activity.append( + IssuePropertyActivity( + workspace_id=property.workspace_id, + project_id=property.project_id, + property=property, + issue_id=issue_id, + old_value=value, + new_value="", + action="deleted", + actor_id=user_id, + epoch=epoch, + comment=f"removed the {property.property_type}", + ) + ) + else: + # Case 1: If the existing value is empty and the requested value is not empty + if not existing_value and requested_value: + bulk_property_activity.append( + IssuePropertyActivity( + workspace_id=property.workspace_id, + project_id=property.project_id, + property=property, + issue_id=issue_id, + old_value="", + new_value=requested_value[0], + action="created", + actor_id=user_id, + epoch=epoch, + comment=f"added the {property.property_type}", + ) + ) + return + + # Case 2: If the existing value is not empty and the requested value is not empty + if ( + existing_value + and requested_value + and existing_value[0] != requested_value[0] + ): + bulk_property_activity.append( + IssuePropertyActivity( + workspace_id=property.workspace_id, + project_id=property.project_id, + property=property, + issue_id=issue_id, + old_value=existing_value[0], + new_value=requested_value[0], + action="updated", + actor_id=user_id, + epoch=epoch, + comment=f"added the {property.property_type}", + ) + ) + return + + # Case 3: If the existing value is not empty and the requested value is empty + if existing_value and not requested_value: + bulk_property_activity.append( + IssuePropertyActivity( + workspace_id=property.workspace_id, + project_id=property.project_id, + property=property, + issue_id=issue_id, + old_value=existing_value[0], + new_value="", + action="deleted", + actor_id=user_id, + epoch=epoch, + comment=f"deleted the {property.property_type}", + ) + ) + return + + +def track_property_datetime( + bulk_property_activity, + property, + existing_value, + requested_value, + issue_id, + user_id, + epoch, +): + """ + This function is used to track the datetime property changes. + """ + handle_multi_properties( + bulk_property_activity=bulk_property_activity, + property=property, + existing_value=existing_value, + requested_value=requested_value, + issue_id=issue_id, + user_id=user_id, + epoch=epoch, + ) + + +def track_property_decimal( + bulk_property_activity, + property, + existing_value, + requested_value, + issue_id, + user_id, + epoch, +): + """ + This function is used to track the decimal property changes. + """ + handle_multi_properties( + bulk_property_activity=bulk_property_activity, + property=property, + existing_value=existing_value, + requested_value=requested_value, + issue_id=issue_id, + user_id=user_id, + epoch=epoch, + ) + + +def track_property_option( + bulk_property_activity, + property, + existing_value, + requested_value, + issue_id, + user_id, + epoch, +): + """ + This function is used to track the option property changes. + """ + handle_multi_properties( + bulk_property_activity=bulk_property_activity, + property=property, + existing_value=existing_value, + requested_value=requested_value, + issue_id=issue_id, + user_id=user_id, + epoch=epoch, + ) + + +def track_property_boolean( + bulk_property_activity, + property, + existing_value, + requested_value, + issue_id, + user_id, + epoch, +): + """ + This function is used to track the boolean property changes. + """ + handle_multi_properties( + bulk_property_activity=bulk_property_activity, + property=property, + existing_value=existing_value, + requested_value=requested_value, + issue_id=issue_id, + user_id=user_id, + epoch=epoch, + ) + + +def track_property_relation( + bulk_property_activity, + property, + existing_value, + requested_value, + issue_id, + user_id, + epoch, +): + """ + This function is used to track the relation property changes. + """ + handle_multi_properties( + bulk_property_activity=bulk_property_activity, + property=property, + existing_value=existing_value, + requested_value=requested_value, + issue_id=issue_id, + user_id=user_id, + epoch=epoch, + ) + + +def track_property_email( + bulk_property_activity, + property, + existing_value, + requested_value, + issue_id, + user_id, + epoch, +): + """ + This function is used to track the email property changes. + """ + handle_multi_properties( + bulk_property_activity=bulk_property_activity, + property=property, + existing_value=existing_value, + requested_value=requested_value, + issue_id=issue_id, + user_id=user_id, + epoch=epoch, + ) + + +def track_property_url( + bulk_property_activity, + property, + existing_value, + requested_value, + issue_id, + user_id, + epoch, +): + """ + This function is used to track the url property changes. + """ + handle_multi_properties( + bulk_property_activity=bulk_property_activity, + property=property, + existing_value=existing_value, + requested_value=requested_value, + issue_id=issue_id, + user_id=user_id, + epoch=epoch, + ) + + +def track_property_file( + bulk_property_activity, + property, + existing_value, + requested_value, + issue_id, + user_id, + epoch, +): + """ + This function is used to track the file property changes. + """ + handle_multi_properties( + bulk_property_activity=bulk_property_activity, + property=property, + existing_value=existing_value, + requested_value=requested_value, + issue_id=issue_id, + user_id=user_id, + epoch=epoch, + ) + + +@shared_task +def issue_property_activity( + existing_values, + requested_values, + issue_id, + user_id, + epoch, +): + """ + This function is used to create an activity for the issue property changes. + """ + + # Get the issue + issue = Issue.objects.get(id=issue_id) + + # Get the issue type + properties = IssueProperty.objects.filter( + workspace_id=issue.workspace_id, + project_id=issue.project_id, + issue_type_id=issue.type_id, + ) + + # Define the property mapper + ACTIVITY_MAPPER = { + PropertyTypeEnum.TEXT: track_property_text, + PropertyTypeEnum.DATETIME: track_property_datetime, + PropertyTypeEnum.DECIMAL: track_property_decimal, + PropertyTypeEnum.OPTION: track_property_option, + PropertyTypeEnum.BOOLEAN: track_property_boolean, + PropertyTypeEnum.RELATION: track_property_relation, + PropertyTypeEnum.EMAIL: track_property_email, + PropertyTypeEnum.URL: track_property_url, + PropertyTypeEnum.FILE: track_property_file, + } + bulk_property_activity = [] + # Loop through the properties + for property in properties: + # Get the existing value + existing_value = existing_values.get(str(property.id), []) + + # Get the requested value + requested_value = requested_values.get( + str(property.id), existing_value + ) + + # Get the activity mapper + func = ACTIVITY_MAPPER.get(property.property_type) + + # Call the function + if func: + func( + bulk_property_activity=bulk_property_activity, + property=property, + existing_value=existing_value, + requested_value=requested_value, + issue_id=issue_id, + user_id=user_id, + epoch=epoch, + ) + + # Create the bulk activity + IssuePropertyActivity.objects.bulk_create(bulk_property_activity) diff --git a/apiserver/plane/ee/models/__init__.py b/apiserver/plane/ee/models/__init__.py index ed9f3521f8..267e83a768 100644 --- a/apiserver/plane/ee/models/__init__.py +++ b/apiserver/plane/ee/models/__init__.py @@ -3,7 +3,10 @@ from .issue_properties import ( IssuePropertyOption, IssuePropertyValue, IssuePropertyActivity, + PropertyTypeEnum, + RelationTypeEnum, ) + from .issue import IssueWorkLog from .project import ProjectState, ProjectAttribute from .workspace import WorkspaceFeature diff --git a/apiserver/plane/ee/models/issue_properties.py b/apiserver/plane/ee/models/issue_properties.py index b4f46b4347..03b19f17e2 100644 --- a/apiserver/plane/ee/models/issue_properties.py +++ b/apiserver/plane/ee/models/issue_properties.py @@ -10,7 +10,6 @@ from plane.db.models import WorkspaceBaseModel class PropertyTypeEnum(models.TextChoices): - TEXT = "TEXT", "Text" DATETIME = "DATETIME", "Datetime" DECIMAL = "DECIMAL", "Decimal" @@ -83,7 +82,6 @@ class IssueProperty(WorkspaceBaseModel): class IssuePropertyOption(WorkspaceBaseModel): - name = models.CharField(max_length=255) sort_order = models.FloatField(default=65535) property = models.ForeignKey( @@ -117,7 +115,7 @@ class IssuePropertyOption(WorkspaceBaseModel): if self._state.adding: # Get the maximum sequence value from the database last_id = IssuePropertyOption.objects.filter( - project=self.project + project=self.project, property=self.property ).aggregate(largest=models.Max("sort_order"))["largest"] # if last_id is not None if last_id is not None: @@ -158,7 +156,6 @@ class IssuePropertyValue(WorkspaceBaseModel): class IssuePropertyActivity(WorkspaceBaseModel): - old_value = models.TextField(blank=True) new_value = models.TextField(blank=True) old_identifier = models.UUIDField(blank=True, null=True) diff --git a/apiserver/plane/ee/serializers/__init__.py b/apiserver/plane/ee/serializers/__init__.py index 65e969ad0e..86534de50d 100644 --- a/apiserver/plane/ee/serializers/__init__.py +++ b/apiserver/plane/ee/serializers/__init__.py @@ -7,6 +7,12 @@ from plane.app.serializers import ( from .app.issue import IssueLiteSerializer from .app.active_cycle import WorkspaceActiveCycleSerializer from .app.page import WorkspacePageSerializer, WorkspacePageDetailSerializer +from .app.issue_property import ( + IssueTypeSerializer, + IssuePropertySerializer, + IssuePropertyOptionSerializer, + IssuePropertyActivitySerializer, +) from .app.worklog import IssueWorkLogSerializer from .app.exporter import ExporterHistorySerializer diff --git a/apiserver/plane/ee/serializers/app/issue_property.py b/apiserver/plane/ee/serializers/app/issue_property.py new file mode 100644 index 0000000000..e236b27941 --- /dev/null +++ b/apiserver/plane/ee/serializers/app/issue_property.py @@ -0,0 +1,61 @@ +# Third party imports +from rest_framework import serializers + +# Module imports +from plane.ee.serializers import BaseSerializer +from plane.db.models import IssueType +from plane.ee.models import ( + IssueProperty, + IssuePropertyOption, + IssuePropertyActivity, +) + + +class IssueTypeSerializer(BaseSerializer): + issue_exists = serializers.BooleanField(read_only=True) + + class Meta: + model = IssueType + fields = "__all__" + read_only_fields = [ + "workspace", + "project", + "is_default", + ] + + +class IssuePropertySerializer(BaseSerializer): + + class Meta: + model = IssueProperty + fields = "__all__" + read_only_fields = [ + "name", + "issue_type", + "workspace", + "project", + ] + + +class IssuePropertyOptionSerializer(BaseSerializer): + + class Meta: + model = IssuePropertyOption + fields = "__all__" + read_only_fields = [ + "property", + "workspace", + "project", + ] + + +class IssuePropertyActivitySerializer(BaseSerializer): + + class Meta: + model = IssuePropertyActivity + fields = "__all__" + read_only_fields = [ + "workspace", + "project", + "issue", + ] diff --git a/apiserver/plane/ee/urls/app/__init__.py b/apiserver/plane/ee/urls/app/__init__.py index 9bc3a66b0a..836cac98d6 100644 --- a/apiserver/plane/ee/urls/app/__init__.py +++ b/apiserver/plane/ee/urls/app/__init__.py @@ -3,6 +3,7 @@ from .cycle import urlpatterns as cycles_patterns from .issue import urlpatterns as issue_patterns from .page import urlpatterns as page_patterns from .views import urlpatterns as views_patterns +from .issue_property import urlpatterns as issue_property_patterns from .workspace import urlpatterns as workspace_patterns urlpatterns = [ @@ -11,5 +12,6 @@ urlpatterns = [ *issue_patterns, *page_patterns, *views_patterns, + *issue_property_patterns, *workspace_patterns, ] diff --git a/apiserver/plane/ee/urls/app/ai.py b/apiserver/plane/ee/urls/app/ai.py index b212bf9704..3f61052a84 100644 --- a/apiserver/plane/ee/urls/app/ai.py +++ b/apiserver/plane/ee/urls/app/ai.py @@ -1,6 +1,7 @@ # Django imports from django.urls import path +# Module imports from plane.ee.views.app import RephraseGrammarEndpoint diff --git a/apiserver/plane/ee/urls/app/issue_property.py b/apiserver/plane/ee/urls/app/issue_property.py new file mode 100644 index 0000000000..f47a119fb3 --- /dev/null +++ b/apiserver/plane/ee/urls/app/issue_property.py @@ -0,0 +1,87 @@ +# Django imports +from django.urls import path + +# Module imports +from plane.ee.views.app.issue_property import ( + IssueTypeEndpoint, + DefaultIssueTypeEndpoint, + IssuePropertyValueEndpoint, + IssuePropertyEndpoint, + IssuePropertyOptionEndpoint, + IssuePropertyActivityEndpoint, +) + +urlpatterns = [ + # Issue types + path( + "workspaces//projects//issue-types/", + IssueTypeEndpoint.as_view(), + name="issue-types", + ), + path( + "workspaces//projects//issue-types//", + IssueTypeEndpoint.as_view(), + name="issue-types", + ), + path( + "workspaces//projects//default-issue-types/", + DefaultIssueTypeEndpoint.as_view(), + name="default-issue-types", + ), + ## Issue type + # Issue properties + path( + "workspaces//projects//issue-properties/", + IssuePropertyEndpoint.as_view(), + name="issue-properties", + ), + path( + "workspaces//projects//issue-properties//", + IssuePropertyEndpoint.as_view(), + name="issue-properties", + ), + path( + "workspaces//projects//issue-types//issue-properties/", + IssuePropertyEndpoint.as_view(), + name="issue-properties", + ), + path( + "workspaces//projects//issue-types//issue-properties//", + IssuePropertyEndpoint.as_view(), + name="issue-properties", + ), + # End of issue properties + # Issue property options + path( + "workspaces//projects//issue-property-options/", + IssuePropertyOptionEndpoint.as_view(), + name="issue-property-options", + ), + path( + "workspaces//projects//issue-properties//options/", + IssuePropertyOptionEndpoint.as_view(), + name="issue-property-options", + ), + path( + "workspaces//projects//issue-properties//options//", + IssuePropertyOptionEndpoint.as_view(), + name="issue-property-options", + ), + # Issue property values + path( + "workspaces//projects//issues//values/", + IssuePropertyValueEndpoint.as_view(), + name="issue-property-values", + ), + path( + "workspaces//projects//issues//values//", + IssuePropertyValueEndpoint.as_view(), + name="issue-property-values", + ), + ## Issue property value + path( + "workspaces//projects//issues//property-activity/", + IssuePropertyActivityEndpoint.as_view(), + name="issue-property-activity", + ), +] diff --git a/apiserver/plane/ee/utils/__init__.py b/apiserver/plane/ee/utils/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/apiserver/plane/ee/utils/issue_property_validators.py b/apiserver/plane/ee/utils/issue_property_validators.py new file mode 100644 index 0000000000..15f7f9c7fa --- /dev/null +++ b/apiserver/plane/ee/utils/issue_property_validators.py @@ -0,0 +1,403 @@ +# Python imports +import uuid +from datetime import datetime + +# Django imports +from django.core.exceptions import ValidationError +from django.core.validators import URLValidator +from django.core.validators import validate_email + +# Module imports +from plane.ee.models import ( + PropertyTypeEnum, + IssuePropertyValue, + IssuePropertyOption, + RelationTypeEnum, +) +from plane.db.models import Issue, WorkspaceMember + +## Validation functions + + +def validate_text(issue_property, value): + pass + + +def validate_uuid(issue_property, value): + try: + # Validate the UUID + uuid.UUID(str(value), version=4) + except ValueError: + # Raise a validation error + raise ValidationError(f"{value} is not a valid UUID") + + +def validate_datetime(issue_property, value): + try: + # Validate the date + datetime.strptime(value, "%Y-%m-%dT%H:%M:%S.%fZ") + except ValueError: + # Raise a validation error + raise ValidationError(f"{value} is not a valid date") + + +def validate_decimal(issue_property, value): + try: + # Validate the number + float(value) + except ValueError: + # Raise a validation error + raise ValidationError(f"{value} is not a valid decimal") + + +def validate_boolean(issue_property, value): + try: + # Validate the boolean + if value not in ["true", "false"]: + raise ValueError + except ValueError: + # Raise a validation error + raise ValidationError(f"{value} is not a valid boolean") + + +def validate_option(issue_property, value): + if not IssuePropertyOption.objects.filter( + property=issue_property, id=value + ).exists(): + raise ValidationError(f"{value} is not a valid option") + + +def validate_relation(issue_property, value): + # Validate the UUID + validate_uuid(issue_property, value) + # Validate the relation + if issue_property.relation_type == RelationTypeEnum.ISSUE: + if not Issue.objects.filter( + workspace_id=issue_property.workspace_id, id=value + ).exists(): + raise ValidationError(f"{value} is not a valid issue") + elif issue_property.relation_type == RelationTypeEnum.USER: + if not WorkspaceMember.objects.filter( + workspace_id=issue_property.workspace_id, member_id=value + ).exists(): + raise ValidationError(f"{value} is not a valid user") + else: + raise ValidationError( + f"{issue_property.relation_type} is not a valid relation type" + ) + + +def validate_url(issue_property, value): + # Validate the URL + url_validator = URLValidator() + try: + url_validator(value) + except ValidationError: + raise ValidationError(f"{value} is not a valid URL") + + +def validate_email_value(issue_property, value): + try: + # Validate the email + validate_email(value) + except ValidationError: + # Raise a validation error + raise ValidationError(f"{value} is not a valid email") + + +def validate_file(issue_property, value): + pass + + +## Save functions + + +def save_text( + issue_property, values, existing_values, issue_id, project_id, workspace_id +): + + # Case 1 - The property is updated + if existing_values and values[0] != existing_values[0]: + return [ + IssuePropertyValue( + property=issue_property, + value_text=values[0], + issue_id=issue_id, + project_id=project_id, + workspace_id=workspace_id, + ) + ] + + # Case 2 - The property is created + return [ + IssuePropertyValue( + property=issue_property, + value_text=values[0], + issue_id=issue_id, + project_id=project_id, + workspace_id=workspace_id, + ), + ] + + +def save_datetime( + issue_property, values, existing_values, issue_id, project_id, workspace_id +): + bulk_issue_prop_values = [] + for value in values: + # Case 1 - The property is updated + bulk_issue_prop_values.append( + IssuePropertyValue( + property=issue_property, + value_datetime=value, + issue_id=issue_id, + project_id=project_id, + workspace_id=workspace_id, + ) + ) + + return bulk_issue_prop_values + + +def save_decimal( + issue_property, values, existing_values, issue_id, project_id, workspace_id +): + bulk_issue_prop_values = [] + for value in values: + # Case 1 - The property is updated + bulk_issue_prop_values.append( + IssuePropertyValue( + property=issue_property, + value_decimal=value, + issue_id=issue_id, + project_id=project_id, + workspace_id=workspace_id, + ) + ) + + return bulk_issue_prop_values + + +def save_boolean( + issue_property, values, existing_values, issue_id, project_id, workspace_id +): + bulk_issue_prop_values = [] + for value in values: + # Case 1 - The property is updated + bulk_issue_prop_values.append( + IssuePropertyValue( + property=issue_property, + value_boolean=bool(value == "true"), + issue_id=issue_id, + project_id=project_id, + workspace_id=workspace_id, + ) + ) + + return bulk_issue_prop_values + + +def save_option( + issue_property, values, existing_values, issue_id, project_id, workspace_id +): + bulk_issue_prop_values = [] + for value in values: + # Case 1 - The property is updated + bulk_issue_prop_values.append( + IssuePropertyValue( + property=issue_property, + value_option_id=value, + issue_id=issue_id, + project_id=project_id, + workspace_id=workspace_id, + ) + ) + + return bulk_issue_prop_values + + +def save_relation( + issue_property, values, existing_values, issue_id, project_id, workspace_id +): + bulk_issue_prop_values = [] + for value in values: + # Case 1 - The property is updated + bulk_issue_prop_values.append( + IssuePropertyValue( + property=issue_property, + value_uuid=value, + issue_id=issue_id, + project_id=project_id, + workspace_id=workspace_id, + ) + ) + + return bulk_issue_prop_values + + +def save_url( + issue_property, values, existing_values, issue_id, project_id, workspace_id +): + bulk_issue_prop_values = [] + for value in values: + # Case 1 - The property is updated + bulk_issue_prop_values.append( + IssuePropertyValue( + property=issue_property, + value_text=value, + issue_id=issue_id, + project_id=project_id, + workspace_id=workspace_id, + ) + ) + + return bulk_issue_prop_values + + +def save_email( + issue_property, values, existing_values, issue_id, project_id, workspace_id +): + bulk_issue_prop_values = [] + for value in values: + # Case 1 - The property is updated + bulk_issue_prop_values.append( + IssuePropertyValue( + property=issue_property, + value_text=value, + issue_id=issue_id, + project_id=project_id, + workspace_id=workspace_id, + ) + ) + + return bulk_issue_prop_values + + +def save_file( + issue_property, values, existing_values, issue_id, project_id, workspace_id +): + bulk_issue_prop_values = [] + for value in values: + # Case 1 - The property is updated + bulk_issue_prop_values.append( + IssuePropertyValue( + property=issue_property, + value_datetime=value, + issue_id=issue_id, + project_id=project_id, + workspace_id=workspace_id, + ) + ) + + return bulk_issue_prop_values + + +def property_validators( + properties, + property_values, + existing_prop_values, +): + + # Map the property type to the validator + VALIDATOR_MAPPER = { + PropertyTypeEnum.TEXT: validate_text, + PropertyTypeEnum.DATETIME: validate_datetime, + PropertyTypeEnum.DECIMAL: validate_decimal, + PropertyTypeEnum.BOOLEAN: validate_boolean, + PropertyTypeEnum.OPTION: validate_option, + PropertyTypeEnum.RELATION: validate_relation, + PropertyTypeEnum.URL: validate_url, + PropertyTypeEnum.EMAIL: validate_email_value, + PropertyTypeEnum.FILE: validate_file, + } + + # Validate the property values + for property in properties: + # Fetch the validator + validator = VALIDATOR_MAPPER.get(property.property_type) + + # Check if the property type is valid + if not validator: + raise ValidationError( + f"{property.property_type} is not a valid property type" + ) + + # Fetch the value + values = property_values.get(str(property.id), []) + + # Get the existing values + existing_values = [ + prop_value.get("values") + for prop_value in existing_prop_values + if str(prop_value.get("property_id")) == str(property.id) + ] + + # Validate the value + if property.is_required and not values and not existing_values: + raise ValidationError(f"{property.display_name} is required") + + # Validate the value + for value in values: + # Validate the value + validator(issue_property=property, value=value) + + return + + +def property_savers( + properties, + property_values, + issue_id, + project_id, + workspace_id, + existing_prop_values, +): + # Save the property values + SAVE_MAPPER = { + PropertyTypeEnum.TEXT: save_text, + PropertyTypeEnum.DATETIME: save_datetime, + PropertyTypeEnum.DECIMAL: save_decimal, + PropertyTypeEnum.BOOLEAN: save_boolean, + PropertyTypeEnum.OPTION: save_option, + PropertyTypeEnum.RELATION: save_relation, + PropertyTypeEnum.URL: save_url, + PropertyTypeEnum.EMAIL: save_email, + PropertyTypeEnum.FILE: save_file, + } + + bulk_issue_properties = [] + for property in properties: + # Fetch the saver + saver = SAVE_MAPPER.get(property.property_type) + + # Check if the property type is valid + if not saver: + raise ValidationError( + f"{property.property_type} is not a valid property type" + ) + + # Fetch the value + values = property_values.get(str(property.id), []) + + # Get the existing values + existing_values = [ + prop_value.get("values") + for prop_value in existing_prop_values + if str(prop_value.get("property_id")) == str(property.id) + ] + + # Save the value + if values: + # Save the value + bulk_issue_properties.extend( + saver( + issue_property=property, + values=values, + issue_id=issue_id, + project_id=project_id, + workspace_id=workspace_id, + existing_values=existing_values, + ) + ) + + return bulk_issue_properties # Return the bulk issue properties diff --git a/apiserver/plane/ee/views/__init__.py b/apiserver/plane/ee/views/__init__.py index e24c32fe28..a9511177ac 100644 --- a/apiserver/plane/ee/views/__init__.py +++ b/apiserver/plane/ee/views/__init__.py @@ -28,6 +28,8 @@ from plane.ee.views.app.workspace import ( WorkspaceExportWorkLogsEndpoint, ) +from plane.ee.views.app.issue_property import IssuePropertyEndpoint + # Space imports from plane.ee.views.space.page import ( PagePublicEndpoint, diff --git a/apiserver/plane/ee/views/app/issue_property/__init__.py b/apiserver/plane/ee/views/app/issue_property/__init__.py new file mode 100644 index 0000000000..952f4a9f13 --- /dev/null +++ b/apiserver/plane/ee/views/app/issue_property/__init__.py @@ -0,0 +1,5 @@ +from .base import IssuePropertyEndpoint +from .option import IssuePropertyOptionEndpoint +from .type import IssueTypeEndpoint, DefaultIssueTypeEndpoint +from .value import IssuePropertyValueEndpoint +from .activity import IssuePropertyActivityEndpoint diff --git a/apiserver/plane/ee/views/app/issue_property/activity.py b/apiserver/plane/ee/views/app/issue_property/activity.py new file mode 100644 index 0000000000..de5e56afc5 --- /dev/null +++ b/apiserver/plane/ee/views/app/issue_property/activity.py @@ -0,0 +1,33 @@ +# Third party imports +from rest_framework import status +from rest_framework.response import Response + +# Module imports +from plane.ee.serializers import IssuePropertyActivitySerializer +from plane.ee.models import IssuePropertyActivity +from plane.ee.views.base import BaseAPIView +from plane.ee.permissions import ProjectEntityPermission + + +class IssuePropertyActivityEndpoint(BaseAPIView): + + permission_classes = [ + ProjectEntityPermission, + ] + + def get(self, request, slug, project_id, issue_id): + # Get the order by + order_by = request.GET.get("order_by", "-created_at") + + # Get all issue properties for a specific issue + activities = IssuePropertyActivity.objects.filter( + workspace__slug=slug, + project_id=project_id, + issue_id=issue_id, + ).order_by(order_by) + + # Serialize the data + serializer = IssuePropertyActivitySerializer(activities, many=True) + + # Return the response + return Response(serializer.data, status=status.HTTP_200_OK) diff --git a/apiserver/plane/ee/views/app/issue_property/base.py b/apiserver/plane/ee/views/app/issue_property/base.py new file mode 100644 index 0000000000..5f69da08e7 --- /dev/null +++ b/apiserver/plane/ee/views/app/issue_property/base.py @@ -0,0 +1,124 @@ +# Third party imports +from rest_framework import status +from rest_framework.response import Response + +# Module imports +from plane.ee.views.base import BaseAPIView +from plane.ee.models import IssueProperty +from plane.ee.permissions import ProjectEntityPermission +from plane.ee.serializers import ( + IssuePropertySerializer, +) + + +class IssuePropertyEndpoint(BaseAPIView): + permission_classes = [ + ProjectEntityPermission, + ] + + def get(self, request, slug, project_id, issue_type_id=None, pk=None): + # Get a single issue property + if pk: + issue_property = IssueProperty.objects.get( + workspace__slug=slug, project_id=project_id, pk=pk + ) + serializer = IssuePropertySerializer(issue_property) + return Response(serializer.data, status=status.HTTP_200_OK) + + if issue_type_id: + # Get all issue properties for a specific issue type + issue_properties = IssueProperty.objects.filter( + workspace__slug=slug, + project_id=project_id, + issue_type_id=issue_type_id, + ) + serializer = IssuePropertySerializer(issue_properties, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + + # Get all issue properties + issue_types = IssueProperty.objects.filter( + workspace__slug=slug, project_id=project_id + ) + serializer = IssuePropertySerializer(issue_types, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + + def post( + self, + request, + slug, + project_id, + issue_type_id, + ): + # Create a new issue properties + serializer = IssuePropertySerializer(data=request.data) + + # Check is_active + if not request.data.get("is_active"): + request.data["is_active"] = False + + # Check defaults + if ( + not request.data.get("is_multi") + and len(request.data.get("default_value", [])) > 1 + ): + return Response( + { + "error": "Default value must be a single value for non-multi properties" + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Check if required and default_value + if request.data.get("is_required") is True: + request.data["default_value"] = [] + + # Validate the data + serializer.is_valid(raise_exception=True) + # Save the data + serializer.save(project_id=project_id, issue_type_id=issue_type_id) + return Response(serializer.data, status=status.HTTP_201_CREATED) + + def patch(self, request, slug, project_id, issue_type_id, pk): + # Update an issue properties + issue_property = IssueProperty.objects.get( + workspace__slug=slug, + project_id=project_id, + issue_type_id=issue_type_id, + pk=pk, + ) + + # Check defaults + if ( + not request.data.get("is_multi", issue_property.is_multi) + and len(request.data.get("default_value", [])) > 1 + ): + return Response( + { + "error": "Default value must be a single value for non-multi properties" + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Check if is_required is being set to true and remove default_value if so + if request.data.get("is_required", issue_property.is_required) is True: + request.data["default_value"] = [] + + serializer = IssuePropertySerializer( + issue_property, data=request.data, partial=True + ) + # Validate the data + serializer.is_valid(raise_exception=True) + # Save the data + serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) + + def delete(self, request, slug, project_id, issue_type_id, pk): + # Delete an issue properties + issue_property = IssueProperty.objects.get( + workspace__slug=slug, + project_id=project_id, + issue_type_id=issue_type_id, + pk=pk, + ) + issue_property.delete() + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apiserver/plane/ee/views/app/issue_property/option.py b/apiserver/plane/ee/views/app/issue_property/option.py new file mode 100644 index 0000000000..c1ea871f8a --- /dev/null +++ b/apiserver/plane/ee/views/app/issue_property/option.py @@ -0,0 +1,187 @@ +# Django imports +from django.db import models + +# Third party imports +from rest_framework import status +from rest_framework.response import Response + +# Module imports +from plane.ee.permissions import ProjectEntityPermission +from plane.ee.views.base import BaseAPIView +from plane.ee.models import IssuePropertyOption, IssueProperty +from plane.ee.serializers import IssuePropertyOptionSerializer + + +class IssuePropertyOptionEndpoint(BaseAPIView): + permission_classes = [ + ProjectEntityPermission, + ] + + def get(self, request, slug, project_id, issue_property_id=None, pk=None): + # Get a single issue property option + if pk: + issue_property_option = IssuePropertyOption.objects.get( + workspace__slug=slug, project_id=project_id, pk=pk + ) + serializer = IssuePropertyOptionSerializer(issue_property_option) + return Response(serializer.data, status=status.HTTP_200_OK) + + # Get all issue property options for a specific property + if issue_property_id: + issue_property_options = IssuePropertyOption.objects.filter( + workspace__slug=slug, + project_id=project_id, + property_id=issue_property_id, + ) + serializer = IssuePropertyOptionSerializer( + issue_property_options, many=True + ) + return Response(serializer.data, status=status.HTTP_200_OK) + + # Get all issue property options for the project_id in the form of property_id: options[] + issue_property_options = IssuePropertyOption.objects.filter( + workspace__slug=slug, project_id=project_id + ) + + serializer = IssuePropertyOptionSerializer( + issue_property_options, many=True + ) + + response_map = {} + for option in serializer.data: + if str(option["property"]) in response_map: + response_map[str(option["property"])].append(option) + else: + response_map[str(option["property"])] = [option] + + return Response(response_map, status=status.HTTP_200_OK) + + def post(self, request, slug, project_id, issue_property_id): + # Create a new issue property option + # Only allow when property type is option + issue_property = IssueProperty.objects.get( + workspace__slug=slug, project_id=project_id, pk=issue_property_id + ) + + # Check if the property type is option + if issue_property.property_type != "OPTION": + return Response( + {"error": "Property type must be an option"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Check defaults + # if ( + # not issue_property.is_multi + # and len( + # [ + # option + # for option in request.data.get("options", []) + # if option.get("is_default", False) + # ] + # ) + # > 1 + # ): + # return Response( + # {"error": "Property is not multi, only one value allowed"}, + # status=status.HTTP_400_BAD_REQUEST, + # ) + + last_id = IssuePropertyOption.objects.filter( + project=project_id, property_id=issue_property_id + ).aggregate(largest=models.Max("sort_order"))["largest"] + + if last_id: + sort_order = last_id + 10000 + else: + sort_order = 10000 + + bulk_property_options = [] + for option in request.data.get("options", []): + bulk_property_options.append( + IssuePropertyOption( + name=option.get("name"), + sort_order=sort_order, + property_id=issue_property_id, + description=option.get("description", ""), + logo_props=option.get("logo_props", {}), + is_active=option.get("is_active", True), + is_default=option.get("is_default", False), + parent_id=option.get("parent_id"), + workspace_id=issue_property.workspace_id, + project_id=project_id, + ) + ) + sort_order += 10000 + + # Bulk create the options + issue_property_options = IssuePropertyOption.objects.bulk_create( + bulk_property_options, batch_size=100 + ) + + serializer = IssuePropertyOptionSerializer( + issue_property_options, many=True + ) + + # Save the default value + return Response(serializer.data, status=status.HTTP_201_CREATED) + + def patch(self, request, slug, project_id, issue_property_id, pk): + # Update an issue property option + issue_property_option = IssuePropertyOption.objects.get( + workspace__slug=slug, + project_id=project_id, + property_id=issue_property_id, + pk=pk, + ) + + # Fetch the issue property + issue_property = IssueProperty.objects.get( + workspace__slug=slug, project_id=project_id, pk=issue_property_id + ) + + # Check if the property type is option + if issue_property.property_type != "OPTION": + return Response( + {"error": "Property type must be an option"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Check defaults + # if ( + # not issue_property.is_multi + # and IssuePropertyOption.objects.filter( + # property_id=issue_property_id, is_default=True + # ).count() + # == 1 + # and request.data.get("is_default") + # ): + # return Response( + # {"error": "Property is not multi, only one value allowed"}, + # status=status.HTTP_400_BAD_REQUEST, + # ) + + serializer = IssuePropertyOptionSerializer( + issue_property_option, data=request.data, partial=True + ) + # Validate the data + serializer.is_valid(raise_exception=True) + # Save the data + serializer.save() + # If the option is default, add it to the default value + # issue_property.default_value.append(str(serializer.data["id"])) + # issue_property.save() + + # Return the data + return Response(serializer.data, status=status.HTTP_200_OK) + + def delete(self, request, slug, project_id, issue_property_id, pk): + # Delete an issue property option + issue_property_option = IssuePropertyOption.objects.get( + workspace__slug=slug, + project_id=project_id, + property_id=issue_property_id, + pk=pk, + ) + issue_property_option.delete() + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apiserver/plane/ee/views/app/issue_property/type.py b/apiserver/plane/ee/views/app/issue_property/type.py new file mode 100644 index 0000000000..197628af8d --- /dev/null +++ b/apiserver/plane/ee/views/app/issue_property/type.py @@ -0,0 +1,156 @@ +# Django imports +from django.db.models import Exists, OuterRef + +# Third party imports +from rest_framework import status +from rest_framework.response import Response + +# Module imports +from plane.ee.views.base import BaseAPIView +from plane.db.models import IssueType, Issue, Project +from plane.ee.permissions import ProjectEntityPermission +from plane.ee.serializers import IssueTypeSerializer +from plane.payment.flags.flag_decorator import check_feature_flag +from plane.payment.flags.flag import FeatureFlag + + +class IssueTypeEndpoint(BaseAPIView): + + permission_classes = [ + ProjectEntityPermission, + ] + + @check_feature_flag(FeatureFlag.ISSUE_TYPE_SETTINGS) + def get(self, request, slug, project_id, pk=None): + # Get a single issue type + if pk: + issue_type = IssueType.objects.annotate( + issue_exists=Exists( + Issue.objects.filter(project_id=project_id, type_id=pk) + ) + ).get(workspace__slug=slug, project_id=project_id, pk=pk) + serializer = IssueTypeSerializer(issue_type) + return Response(serializer.data, status=status.HTTP_200_OK) + + # Get all issue types + issue_types = IssueType.objects.filter( + workspace__slug=slug, project_id=project_id + ).annotate( + issue_exists=Exists( + Issue.objects.filter( + project_id=project_id, type_id=OuterRef("pk") + ) + ) + ) + serializer = IssueTypeSerializer(issue_types, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + + @check_feature_flag(FeatureFlag.ISSUE_TYPE_SETTINGS) + def post(self, request, slug, project_id): + # Create a new issue type + serializer = IssueTypeSerializer(data=request.data) + # Validate the data + serializer.is_valid(raise_exception=True) + # Save the data + serializer.save(project_id=project_id) + return Response(serializer.data, status=status.HTTP_201_CREATED) + + @check_feature_flag(FeatureFlag.ISSUE_TYPE_SETTINGS) + def patch(self, request, slug, project_id, pk): + # Update an issue type + issue_type = IssueType.objects.get( + workspace__slug=slug, project_id=project_id, pk=pk + ) + + # Default cannot be made in active + if request.data.get("is_default") and not request.data.get( + "is_active" + ): + return Response( + {"error": "Default issue type cannot be inactive"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + serializer = IssueTypeSerializer( + issue_type, data=request.data, partial=True + ) + # Validate the data + serializer.is_valid(raise_exception=True) + # Save the data + serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) + + @check_feature_flag(FeatureFlag.ISSUE_TYPE_SETTINGS) + def delete(self, request, slug, project_id, pk): + # Delete an issue type + issue_type = IssueType.objects.get( + workspace__slug=slug, project_id=project_id, pk=pk + ) + + # Check if there are any issues using this issue type + if Issue.objects.filter(project_id=project_id, type_id=pk).exists(): + return Response( + {"error": "Cannot delete issue type with associated issues"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Check if the issue type is the default issue type + if issue_type.is_default: + return Response( + {"error": "Cannot delete default issue type"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + issue_type.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + +class DefaultIssueTypeEndpoint(BaseAPIView): + + permission_classes = [ + ProjectEntityPermission, + ] + + @check_feature_flag(FeatureFlag.ISSUE_TYPE_SETTINGS) + def post(self, request, slug, project_id): + # Get the project + project = Project.objects.get(pk=project_id) + + # If issue type is already created return an error + if IssueType.objects.filter( + workspace__slug=slug, project_id=project_id + ).exists(): + return Response( + {{"error": "Default issue type already exists"}}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Create a new default issue type + issue_type, _ = IssueType.objects.get_or_create( + project_id=project_id, + name="Task", + is_default=True, + defaults={ + "description": "A work that needs to be done", + "is_default": True, + "weight": 0, + "sort_order": 1, + "logo_props": { + "in_use": "icon", + "icon": {"name": "AlignLeft", "color": "#6d7b8a"}, + }, + }, + ) + # Update existing issues to use the new default issue type + Issue.objects.filter( + project_id=project_id, + workspace__slug=slug, + type_id__isnull=True, + ).update(type_id=issue_type.id) + + # Update the project + project.is_issue_type_enabled = True + project.save() + + # Serialize the data + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apiserver/plane/ee/views/app/issue_property/value.py b/apiserver/plane/ee/views/app/issue_property/value.py new file mode 100644 index 0000000000..8baa0396bc --- /dev/null +++ b/apiserver/plane/ee/views/app/issue_property/value.py @@ -0,0 +1,203 @@ +# Django imports +from django.db.models import F, Value, Case, When +from django.utils import timezone +from django.core.exceptions import ValidationError +from django.db.models import ( + Q, + CharField, +) +from django.db.models.functions import Cast +from django.contrib.postgres.aggregates import ArrayAgg + +# Third party imports +from rest_framework import status +from rest_framework.response import Response + +# Module imports +from plane.ee.models import IssueProperty, IssuePropertyValue, PropertyTypeEnum +from plane.db.models import Issue +from plane.db.models import Project +from plane.ee.views.base import BaseAPIView +from plane.ee.permissions import ProjectEntityPermission +from plane.ee.utils.issue_property_validators import ( + property_validators, + property_savers, +) +from plane.ee.bgtasks.issue_property_activity_task import ( + issue_property_activity, +) + + +class IssuePropertyValueEndpoint(BaseAPIView): + permission_classes = [ + ProjectEntityPermission, + ] + + def query_annotator(self, query): + return query.values("property_id").annotate( + values=ArrayAgg( + Case( + When( + property__property_type__in=[ + PropertyTypeEnum.TEXT, + PropertyTypeEnum.URL, + PropertyTypeEnum.EMAIL, + PropertyTypeEnum.FILE, + ], + then=F("value_text"), + ), + When( + property__property_type=PropertyTypeEnum.DATETIME, + then=Cast( + F("value_datetime"), output_field=CharField() + ), + ), + When( + property__property_type=PropertyTypeEnum.DECIMAL, + then=Cast( + F("value_decimal"), output_field=CharField() + ), + ), + When( + property__property_type=PropertyTypeEnum.BOOLEAN, + then=Cast( + F("value_boolean"), output_field=CharField() + ), + ), + When( + property__property_type=PropertyTypeEnum.RELATION, + then=Cast(F("value_uuid"), output_field=CharField()), + ), + When( + property__property_type=PropertyTypeEnum.OPTION, + then=Cast(F("value_option"), output_field=CharField()), + ), + default=Value( + "" + ), # Default value if none of the conditions match + output_field=CharField(), + ), + filter=Q(property_id=F("property_id")), + distinct=True, + ), + ) + + def get(self, request, slug, project_id, issue_id, issue_property_id=None): + # Get a single issue property value + if issue_property_id: + issue_property_value = IssuePropertyValue.objects.filter( + workspace__slug=slug, + project_id=project_id, + issue_id=issue_id, + property_id=issue_property_id, + ) + + issue_property_value = self.query_annotator( + issue_property_value + ).values("property_id", "value") + + return Response(issue_property_value, status=status.HTTP_200_OK) + + # Get all issue property values + issue_property_values = IssuePropertyValue.objects.filter( + workspace__slug=slug, + project_id=project_id, + issue_id=issue_id, + property__is_active=True, + ) + + # Annotate the query + issue_property_values = self.query_annotator( + issue_property_values + ).values("property_id", "values") + + # Create dictionary of property_id and values + response = { + str(issue_property_value["property_id"]): issue_property_value[ + "values" + ] + for issue_property_value in issue_property_values + } + + return Response(response, status=status.HTTP_200_OK) + + def post(self, request, slug, project_id, issue_id): + try: + # Create a new issue property value + issue_property_values = request.data.get("property_values", {}) + + # Get all the issue property ids + issue_property_ids = list(issue_property_values.keys()) + + # existing values + existing_prop_queryset = IssuePropertyValue.objects.filter( + workspace__slug=slug, + project_id=project_id, + issue_id=issue_id, + ) + + # Get all issue property values + existing_prop_values = self.query_annotator( + existing_prop_queryset + ).values("property_id", "values") + + # Get issue + issue = Issue.objects.get(pk=issue_id) + + # Get Issue Type + issue_type_id = issue.type_id + + # Get Project + project = Project.objects.get(pk=project_id) + workspace_id = project.workspace_id + + # Get all issue properties + issue_properties = IssueProperty.objects.filter( + workspace__slug=slug, + project_id=project_id, + issue_type_id=issue_type_id, + ) + + # Validate the data + property_validators( + properties=issue_properties, + property_values=issue_property_values, + existing_prop_values=existing_prop_values, + ) + + # Save the data + bulk_issue_property_values = property_savers( + properties=issue_properties, + property_values=issue_property_values, + issue_id=issue_id, + workspace_id=workspace_id, + project_id=project_id, + existing_prop_values=existing_prop_values, + ) + + # Delete the old values + existing_prop_queryset.filter( + property_id__in=issue_property_ids + ).delete() + # Bulk create the issue property values + IssuePropertyValue.objects.bulk_create( + bulk_issue_property_values, batch_size=10 + ) + + # Log the activity + issue_property_activity.delay( + existing_values={ + str(prop["property_id"]): prop["values"] + for prop in existing_prop_values + }, + requested_values=issue_property_values, + issue_id=issue_id, + user_id=str(request.user.id), + epoch=int(timezone.now().timestamp()), + ) + return Response(status=status.HTTP_201_CREATED) + except (ValidationError, ValueError) as e: + return Response( + {"error": str(e)}, + status=status.HTTP_400_BAD_REQUEST, + ) diff --git a/apiserver/plane/space/serializer/issue.py b/apiserver/plane/space/serializer/issue.py index 401e7d7191..17e15a7e0d 100644 --- a/apiserver/plane/space/serializer/issue.py +++ b/apiserver/plane/space/serializer/issue.py @@ -27,6 +27,7 @@ from plane.db.models import ( CommentReaction, IssueVote, IssueRelation, + IssueType, ) @@ -359,7 +360,24 @@ class IssueCreateSerializer(BaseSerializer): workspace_id = self.context["workspace_id"] default_assignee_id = self.context["default_assignee_id"] - issue = Issue.objects.create(**validated_data, project_id=project_id) + issue_type_id = validated_data.get("type_id") + + if issue_type_id: + # Check if issue type is valid + issue_type = IssueType.objects.filter( + project_id=project_id, id=issue_type_id + ).first() + else: + # Get default issue type + issue_type = IssueType.objects.filter( + project_id=project_id, is_default=True + ).first() + + issue = Issue.objects.create( + **validated_data, + project_id=project_id, + type=issue_type, + ) # Issue Audit Users created_by_id = issue.created_by_id diff --git a/apiserver/plane/space/views/inbox.py b/apiserver/plane/space/views/inbox.py index 3358ff1d3d..793ff19800 100644 --- a/apiserver/plane/space/views/inbox.py +++ b/apiserver/plane/space/views/inbox.py @@ -19,6 +19,7 @@ from plane.db.models import ( IssueLink, IssueAttachment, DeployBoard, + IssueType, ) from plane.app.serializers import ( IssueSerializer, @@ -155,6 +156,10 @@ class InboxIssuePublicViewSet(BaseViewSet): color="#ff7700", ) + issue_type = IssueType.objects.filter( + project_id=project_deploy_board.project_id, is_default=True + ).first() + # create an issue issue = Issue.objects.create( name=request.data.get("issue", {}).get("name"), @@ -165,6 +170,7 @@ class InboxIssuePublicViewSet(BaseViewSet): priority=request.data.get("issue", {}).get("priority", "low"), project_id=project_deploy_board.project_id, state=state, + type=issue_type, ) # Create an Issue Activity diff --git a/apiserver/plane/utils/grouper.py b/apiserver/plane/utils/grouper.py index a3ac2420e7..aabe730cc0 100644 --- a/apiserver/plane/utils/grouper.py +++ b/apiserver/plane/utils/grouper.py @@ -18,7 +18,6 @@ from plane.db.models import ( def issue_queryset_grouper(queryset, group_by, sub_group_by): - FIELD_MAPPER = { "label_ids": "labels__id", "assignee_ids": "assignees__id", @@ -51,7 +50,6 @@ def issue_queryset_grouper(queryset, group_by, sub_group_by): def issue_on_results(issues, group_by, sub_group_by): - FIELD_MAPPER = { "labels__id": "label_ids", "assignees__id": "assignee_ids", @@ -84,6 +82,7 @@ def issue_on_results(issues, group_by, sub_group_by): "is_draft", "archived_at", "state__group", + "type_id", ] if group_by in FIELD_MAPPER: diff --git a/packages/types/src/issues/base.d.ts b/packages/types/src/issues/base.d.ts index 1ad8530cd9..8292c11164 100644 --- a/packages/types/src/issues/base.d.ts +++ b/packages/types/src/issues/base.d.ts @@ -10,7 +10,12 @@ export * from "./issue_relation"; export * from "./issue_sub_issues"; export * from "./activity/base"; -export type TLoader = "init-loader" | "mutation" | "pagination" | undefined; +export type TLoader = + | "init-loader" + | "mutation" + | "pagination" + | "loaded" + | undefined; export type TGroupedIssues = { [group_id: string]: string[]; @@ -36,4 +41,4 @@ export type TGroupedIssueCount = { [group_id: string]: number; }; -export type TUnGroupedIssues = string[]; \ No newline at end of file +export type TUnGroupedIssues = string[]; diff --git a/packages/types/src/issues/issue.d.ts b/packages/types/src/issues/issue.d.ts index 079ef12cc3..97fde468d5 100644 --- a/packages/types/src/issues/issue.d.ts +++ b/packages/types/src/issues/issue.d.ts @@ -25,6 +25,7 @@ export type TBaseIssue = { parent_id: string | null; cycle_id: string | null; module_ids: string[] | null; + type_id: string | null; created_at: string; updated_at: string; @@ -48,6 +49,8 @@ export type TIssue = TBaseIssue & { issue_link?: TIssueLink[]; // tempId is used for optimistic updates. It is not a part of the API response. tempId?: string; + // sourceIssueId is used to store the original issue id when creating a copy of an issue. Used in cloning property values. It is not a part of the API response. + sourceIssueId?: string; }; export type TIssueMap = { @@ -57,18 +60,18 @@ export type TIssueMap = { type TIssueResponseResults = | TBaseIssue[] | { - [key: string]: { - results: - | TBaseIssue[] - | { - [key: string]: { - results: TBaseIssue[]; - total_results: number; - }; - }; - total_results: number; + [key: string]: { + results: + | TBaseIssue[] + | { + [key: string]: { + results: TBaseIssue[]; + total_results: number; + }; }; + total_results: number; }; + }; export type TIssuesResponse = { grouped_by: string; diff --git a/packages/types/src/project/projects.d.ts b/packages/types/src/project/projects.d.ts index f39464dfd6..c53d301c48 100644 --- a/packages/types/src/project/projects.d.ts +++ b/packages/types/src/project/projects.d.ts @@ -34,6 +34,7 @@ export interface IProject { identifier: string; anchor: string | null; is_favorite: boolean; + is_issue_type_enabled: boolean; is_member: boolean; is_time_tracking_enabled: boolean; logo_props: TLogoProps; @@ -52,24 +53,13 @@ export interface IProject { updated_by: string; workspace: IWorkspace | string; workspace_detail: IWorkspaceLite; - emoji: string | null; - icon_prop: { - name: string; - color: string; - } | null; - logo_props: TProjectLogoProps; } export interface IProjectLite { id: string; name: string; identifier: string; - emoji: string | null; - logo_props: TProjectLogoProps; - icon_prop: { - name: string; - color: string; - } | null; + logo_props: TLogoProps; } type ProjectPreferences = { diff --git a/packages/ui/src/collapsible/collapsible.tsx b/packages/ui/src/collapsible/collapsible.tsx index a069be3ed5..2ced4cf0ec 100644 --- a/packages/ui/src/collapsible/collapsible.tsx +++ b/packages/ui/src/collapsible/collapsible.tsx @@ -4,6 +4,7 @@ import { Disclosure, Transition } from "@headlessui/react"; export type TCollapsibleProps = { title: string | React.ReactNode; children: React.ReactNode; + buttonRef?: React.RefObject; className?: string; buttonClassName?: string; isOpen?: boolean; @@ -12,7 +13,7 @@ export type TCollapsibleProps = { }; export const Collapsible: FC = (props) => { - const { title, children, className, buttonClassName, isOpen, onToggle, defaultOpen } = props; + const { title, children, buttonRef, className, buttonClassName, isOpen, onToggle, defaultOpen } = props; // state const [localIsOpen, setLocalIsOpen] = useState(isOpen || defaultOpen ? true : false); @@ -33,7 +34,7 @@ export const Collapsible: FC = (props) => { return ( - + {title} { customButtonClassName = "", buttonClassName = "", className = "", + chevronClassName = "", customButton, placement, disabled = false, @@ -90,11 +91,10 @@ export const CustomSearchSelect = (props: ICustomSearchSelectProps) => { )} diff --git a/packages/ui/src/dropdowns/helper.tsx b/packages/ui/src/dropdowns/helper.tsx index 0eda5ac5c9..8566f183b2 100644 --- a/packages/ui/src/dropdowns/helper.tsx +++ b/packages/ui/src/dropdowns/helper.tsx @@ -12,6 +12,7 @@ export interface IDropdownProps { label?: string | JSX.Element; maxHeight?: "sm" | "rg" | "md" | "lg"; noChevron?: boolean; + chevronClassName?: string; onOpen?: () => void; optionsClassName?: string; placement?: Placement; diff --git a/packages/ui/src/emoji/icons.ts b/packages/ui/src/emoji/icons.ts index 3d650e244e..ab9aa69b3f 100644 --- a/packages/ui/src/emoji/icons.ts +++ b/packages/ui/src/emoji/icons.ts @@ -149,6 +149,9 @@ import { Minus, MinusCircle, MinusSquare, + CircleChevronDown, + UsersRound, + ToggleLeft, } from "lucide-react"; export const MATERIAL_ICONS_LIST = [ @@ -791,6 +794,7 @@ export const LUCIDE_ICONS_LIST = [ { name: "Camera", element: Camera }, { name: "CameraOff", element: CameraOff }, { name: "Cast", element: Cast }, + { name: "CircleChevronDown", element: CircleChevronDown }, { name: "Check", element: Check }, { name: "CheckCircle", element: CheckCircle }, { name: "CheckSquare", element: CheckSquare }, @@ -908,4 +912,6 @@ export const LUCIDE_ICONS_LIST = [ { name: "Minus", element: Minus }, { name: "MinusCircle", element: MinusCircle }, { name: "MinusSquare", element: MinusSquare }, + { name: "ToggleLeft", element: ToggleLeft }, + { name: "UsersRound", element: UsersRound }, ]; diff --git a/packages/ui/src/emoji/logo.tsx b/packages/ui/src/emoji/logo.tsx index 528e160475..a2a5dd6abc 100644 --- a/packages/ui/src/emoji/logo.tsx +++ b/packages/ui/src/emoji/logo.tsx @@ -5,6 +5,7 @@ import useFontFaceObserver from "use-font-face-observer"; import { LUCIDE_ICONS_LIST } from "./icons"; // helpers import { emojiCodeToUnicode } from "./helpers"; +import { cn } from "../../helpers"; type TLogoProps = { in_use: "emoji" | "icon"; @@ -22,10 +23,11 @@ type Props = { logo: TLogoProps; size?: number; type?: "lucide" | "material"; + customColor?: string; }; export const Logo: FC = (props) => { - const { logo, size = 16, type = "material" } = props; + const { logo, size = 16, customColor, type = "material" } = props; // destructuring the logo object const { in_use, emoji, icon } = logo; @@ -72,19 +74,20 @@ export const Logo: FC = (props) => { {lucideIcon && ( )} ) : ( diff --git a/packages/ui/src/form-fields/checkbox-select.tsx b/packages/ui/src/form-fields/checkbox-select.tsx new file mode 100644 index 0000000000..a5e8f3926c --- /dev/null +++ b/packages/ui/src/form-fields/checkbox-select.tsx @@ -0,0 +1,83 @@ +import React from "react"; +// helpers +import { cn } from "../../helpers"; +// components +import { Checkbox } from "./checkbox"; + +type CheckboxSelectProps = { + name?: string; + label?: string | React.ReactNode | undefined; + selected: T; + options: { label: string | React.ReactNode; key: T; indeterminate?: boolean; disabled?: boolean }[]; + onChange: (key: T) => void; + className?: string; + labelClassName?: string; + checkboxClassName?: { input?: string; icon?: string; label?: string; container?: string }; + fieldClassName?: string; + wrapperClassName?: string; + vertical?: boolean; +}; + +// TODO: refactor this to allow multiple selection +export const CheckboxSelect = ( + props: CheckboxSelectProps +) => { + const { + name = "checkbox-select", + label: inputLabel, + labelClassName = "", + wrapperClassName = "", + fieldClassName = "", + checkboxClassName: { + input: inputClassName = "", + icon: iconClassName = "", + label: checkboxLabelClassName = "", + container: containerClassName = "", + } = {}, + options, + vertical, + selected, + onChange, + className, + } = props; + + const wrapperClass = vertical ? "flex flex-col gap-1" : "flex gap-2"; + + const setSelected = (value: T) => { + onChange(value); + }; + + return ( +
+ {inputLabel &&
{inputLabel}
} +
+ {options.map(({ key, label, indeterminate, disabled }, index) => ( +
!disabled && setSelected(key)} + className={cn( + "flex items-center gap-2", + disabled ? `bg-custom-background-200 border-custom-border-200 cursor-not-allowed` : ``, + fieldClassName + )} + > + + +
+ ))} +
+
+ ); +}; diff --git a/packages/ui/src/form-fields/index.ts b/packages/ui/src/form-fields/index.ts index f19adcdc5c..38e6690a93 100644 --- a/packages/ui/src/form-fields/index.ts +++ b/packages/ui/src/form-fields/index.ts @@ -2,3 +2,4 @@ export * from "./input"; export * from "./textarea"; export * from "./input-color-picker"; export * from "./checkbox"; +export * from "./checkbox-select"; diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/header.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/header.tsx index be9e3781a3..b4346394f0 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/header.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/header.tsx @@ -9,10 +9,12 @@ import { Breadcrumbs, CustomMenu } from "@plane/ui"; // components import { BreadcrumbLink, Logo } from "@/components/common"; // constants -import { EUserProjectRoles, PROJECT_SETTINGS_LINKS } from "@/constants/project"; +import { EUserProjectRoles } from "@/constants/project"; // hooks import { useProject, useUser } from "@/hooks/store"; import { useAppRouter } from "@/hooks/use-app-router"; +// plane web constants +import { PROJECT_SETTINGS_LINKS } from "@/plane-web/constants/project"; export const ProjectSettingHeader: FC = observer(() => { // router diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/issue-types/page.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/issue-types/page.tsx new file mode 100644 index 0000000000..a2e7337ff6 --- /dev/null +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/issue-types/page.tsx @@ -0,0 +1,31 @@ +"use client"; + +import { observer } from "mobx-react"; +// components +import { PageHead } from "@/components/core"; +// hooks +import { EUserProjectRoles } from "@/constants/project"; +import { useProject, useUser } from "@/hooks/store"; +import { IssueTypesRoot } from "@/plane-web/components/issue-types"; + +const IssueTypesSettingsPage = observer(() => { + // store hooks + const { + membership: { currentProjectRole }, + } = useUser(); + const { currentProjectDetails } = useProject(); + // derived values + const isAdmin = currentProjectRole === EUserProjectRoles.ADMIN; + const pageTitle = currentProjectDetails?.name ? `${currentProjectDetails?.name} - Issue types` : undefined; + + return ( + <> + +
+ +
+ + ); +}); + +export default IssueTypesSettingsPage; diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/sidebar.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/sidebar.tsx index 0b9a94f82e..c90f155ebe 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/sidebar.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/sidebar.tsx @@ -8,9 +8,11 @@ import { Loader } from "@plane/ui"; // components import { SidebarNavItem } from "@/components/sidebar"; // constants -import { EUserProjectRoles, PROJECT_SETTINGS_LINKS } from "@/constants/project"; +import { EUserProjectRoles } from "@/constants/project"; // hooks import { useUser } from "@/hooks/store"; +// plane web constants +import { PROJECT_SETTINGS_LINKS } from "@/plane-web/constants/project"; export const ProjectSettingsSidebar = () => { const { workspaceSlug, projectId } = useParams(); diff --git a/web/ce/constants/project/settings/index.ts b/web/ce/constants/project/settings/index.ts index 0e849261ac..a6a842e7be 100644 --- a/web/ce/constants/project/settings/index.ts +++ b/web/ce/constants/project/settings/index.ts @@ -1 +1,2 @@ export * from "./features"; +export * from "./tabs"; diff --git a/web/ce/constants/project/settings/tabs.ts b/web/ce/constants/project/settings/tabs.ts new file mode 100644 index 0000000000..6da1430cae --- /dev/null +++ b/web/ce/constants/project/settings/tabs.ts @@ -0,0 +1,82 @@ +// icons +import { SettingIcon } from "@/components/icons/attachment"; +// types +import { Props } from "@/components/icons/types"; +// constants +import { EUserProjectRoles } from "@/constants/project"; + +export const PROJECT_SETTINGS = { + general: { + key: "general", + label: "General", + href: `/settings`, + access: EUserProjectRoles.MEMBER, + highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/`, + Icon: SettingIcon, + }, + members: { + key: "members", + label: "Members", + href: `/settings/members`, + access: EUserProjectRoles.MEMBER, + highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/members/`, + Icon: SettingIcon, + }, + features: { + key: "features", + label: "Features", + href: `/settings/features`, + access: EUserProjectRoles.ADMIN, + highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/features/`, + Icon: SettingIcon, + }, + states: { + key: "states", + label: "States", + href: `/settings/states`, + access: EUserProjectRoles.MEMBER, + highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/states/`, + Icon: SettingIcon, + }, + labels: { + key: "labels", + label: "Labels", + href: `/settings/labels`, + access: EUserProjectRoles.MEMBER, + highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/labels/`, + Icon: SettingIcon, + }, + estimates: { + key: "estimates", + label: "Estimates", + href: `/settings/estimates`, + access: EUserProjectRoles.ADMIN, + highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/estimates/`, + Icon: SettingIcon, + }, + automations: { + key: "automations", + label: "Automations", + href: `/settings/automations`, + access: EUserProjectRoles.ADMIN, + highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/automations/`, + Icon: SettingIcon, + }, +}; + +export const PROJECT_SETTINGS_LINKS: { + key: string; + label: string; + href: string; + access: EUserProjectRoles; + highlight: (pathname: string, baseUrl: string) => boolean; + Icon: React.FC; +}[] = [ + PROJECT_SETTINGS["general"], + PROJECT_SETTINGS["members"], + PROJECT_SETTINGS["features"], + PROJECT_SETTINGS["states"], + PROJECT_SETTINGS["labels"], + PROJECT_SETTINGS["estimates"], + PROJECT_SETTINGS["automations"], + ]; diff --git a/web/core/components/dropdowns/date.tsx b/web/core/components/dropdowns/date.tsx index 327bb45980..c95c0a2633 100644 --- a/web/core/components/dropdowns/date.tsx +++ b/web/core/components/dropdowns/date.tsx @@ -25,6 +25,7 @@ type Props = TDropdownProps & { onClose?: () => void; value: Date | string | null; closeOnSelect?: boolean; + formatToken?: string; }; export const DateDropdown: React.FC = (props) => { @@ -48,6 +49,7 @@ export const DateDropdown: React.FC = (props) => { showTooltip = false, tabIndex, value, + formatToken, } = props; // states const [isOpen, setIsOpen] = useState(false); @@ -126,13 +128,15 @@ export const DateDropdown: React.FC = (props) => { className={buttonClassName} isActive={isOpen} tooltipHeading={placeholder} - tooltipContent={value ? renderFormattedDate(value) : "None"} + tooltipContent={value ? renderFormattedDate(value, formatToken) : "None"} showTooltip={showTooltip} variant={buttonVariant} > {!hideIcon && icon} {BUTTON_VARIANTS_WITH_TEXT.includes(buttonVariant) && ( - {value ? renderFormattedDate(value) : placeholder} + + {value ? renderFormattedDate(value, formatToken) : placeholder} + )} {isClearable && !disabled && isDateSelected && ( = observer((props) => { placement, projectId, showTooltip = false, + showUserDetails = false, tabIndex, value, icon, @@ -75,6 +76,26 @@ export const MemberDropdown: React.FC = observer((props) => { if (!multiple) handleClose(); }; + const getDisplayName = (value: string | string[] | null, showUserDetails: boolean, placeholder: string = "") => { + if (Array.isArray(value)) { + if (value.length > 0) { + if (value.length === 1) { + return getUserDetails(value[0])?.display_name || placeholder; + } else { + return showUserDetails ? `${value.length} members` : ""; + } + } else { + return placeholder; + } + } else { + if (showUserDetails && value) { + return getUserDetails(value)?.display_name || placeholder; + } else { + return placeholder; + } + } + }; + return ( = observer((props) => { onClick={handleOnClick} > = observer((props) => { > {!hideIcon && } {BUTTON_VARIANTS_WITH_TEXT.includes(buttonVariant) && ( - - {Array.isArray(value) && value.length > 0 - ? value.length === 1 - ? getUserDetails(value[0])?.display_name - : "" - : placeholder} + + {getDisplayName(value, showUserDetails, placeholder)} )} {dropdownArrow && ( diff --git a/web/core/components/dropdowns/member/types.d.ts b/web/core/components/dropdowns/member/types.d.ts index 758389650a..9bdc5192c3 100644 --- a/web/core/components/dropdowns/member/types.d.ts +++ b/web/core/components/dropdowns/member/types.d.ts @@ -7,6 +7,7 @@ export type MemberDropdownProps = TDropdownProps & { placeholder?: string; tooltipContent?: string; onClose?: () => void; + showUserDetails?: boolean; } & ( | { multiple: false; diff --git a/web/core/components/estimates/radio-select.tsx b/web/core/components/estimates/radio-select.tsx index 6a35f14e32..022937442a 100644 --- a/web/core/components/estimates/radio-select.tsx +++ b/web/core/components/estimates/radio-select.tsx @@ -4,7 +4,7 @@ import { cn } from "@/helpers/common.helper"; type RadioInputProps = { name?: string; - label: string | React.ReactNode | undefined; + label?: string | React.ReactNode; wrapperClassName?: string; fieldClassName?: string; buttonClassName?: string; @@ -46,14 +46,14 @@ export const RadioInput = ({ return (
-
{inputLabel}
+ {inputLabel &&
{inputLabel}
}
{options.map(({ value, label, disabled }, index) => (
!disabled && setSelected(value)} className={cn( - "flex items-center gap-2", + "flex items-center gap-2 text-base", disabled ? `bg-custom-background-200 border-custom-border-200 cursor-not-allowed` : ``, inputFieldClassName )} @@ -72,7 +72,7 @@ export const RadioInput = ({ disabled={disabled} checked={selected === value} /> -
diff --git a/web/core/components/issues/issue-detail/main-content.tsx b/web/core/components/issues/issue-detail/main-content.tsx index 0236e5f189..f36d8e9849 100644 --- a/web/core/components/issues/issue-detail/main-content.tsx +++ b/web/core/components/issues/issue-detail/main-content.tsx @@ -4,8 +4,6 @@ import { useEffect, useState } from "react"; import { observer } from "mobx-react"; // types import { TIssue } from "@plane/types"; -// ui -import { StateGroupIcon } from "@plane/ui"; // components import { IssueActivity, @@ -17,8 +15,10 @@ import { IssueDetailWidgets, } from "@/components/issues"; // hooks -import { useIssueDetail, useProjectState, useUser } from "@/hooks/store"; +import { useIssueDetail, useUser } from "@/hooks/store"; import useReloadConfirmations from "@/hooks/use-reload-confirmation"; +// plane web components +import { IssueIdentifier } from "@/plane-web/components/issues"; // types import { TIssueOperations } from "./root"; @@ -38,7 +38,6 @@ export const IssueMainContent: React.FC = observer((props) => { const [isSubmitting, setIsSubmitting] = useState<"submitting" | "submitted" | "saved">("saved"); // hooks const { data: currentUser } = useUser(); - const { projectStates } = useProjectState(); const { issue: { getIssueById }, } = useIssueDetail(); @@ -54,8 +53,6 @@ export const IssueMainContent: React.FC = observer((props) => { const issue = issueId ? getIssueById(issueId) : undefined; if (!issue || !issue.project_id) return <>; - const currentIssueState = projectStates?.find((s) => s.id === issue.state_id); - return ( <>
@@ -70,14 +67,8 @@ export const IssueMainContent: React.FC = observer((props) => { )}
- {currentIssueState && ( - - )} - + +
= observer((props) => { issueId={issueId} disabled={!isEditable} /> + + {issue.type_id && ( + + )}
diff --git a/web/core/components/issues/issue-layouts/quick-action-dropdowns/all-issue.tsx b/web/core/components/issues/issue-layouts/quick-action-dropdowns/all-issue.tsx index cc1f9d1fb3..2c12ed18be 100644 --- a/web/core/components/issues/issue-layouts/quick-action-dropdowns/all-issue.tsx +++ b/web/core/components/issues/issue-layouts/quick-action-dropdowns/all-issue.tsx @@ -67,6 +67,7 @@ export const AllIssueQuickActions: React.FC = observer((props { ...issue, name: `${issue.name} (copy)`, + sourceIssueId: issue.id, }, ["id"] ); diff --git a/web/core/components/issues/issue-layouts/quick-action-dropdowns/cycle-issue.tsx b/web/core/components/issues/issue-layouts/quick-action-dropdowns/cycle-issue.tsx index e53a4c2a44..6e5187b64b 100644 --- a/web/core/components/issues/issue-layouts/quick-action-dropdowns/cycle-issue.tsx +++ b/web/core/components/issues/issue-layouts/quick-action-dropdowns/cycle-issue.tsx @@ -77,6 +77,7 @@ export const CycleIssueQuickActions: React.FC = observer((pro { ...issue, name: `${issue.name} (copy)`, + sourceIssueId: issue.id, }, ["id"] ); diff --git a/web/core/components/issues/issue-layouts/quick-action-dropdowns/module-issue.tsx b/web/core/components/issues/issue-layouts/quick-action-dropdowns/module-issue.tsx index 2170a24999..1bfa57d06a 100644 --- a/web/core/components/issues/issue-layouts/quick-action-dropdowns/module-issue.tsx +++ b/web/core/components/issues/issue-layouts/quick-action-dropdowns/module-issue.tsx @@ -77,6 +77,7 @@ export const ModuleIssueQuickActions: React.FC = observer((pr { ...issue, name: `${issue.name} (copy)`, + sourceIssueId: issue.id, }, ["id"] ); diff --git a/web/core/components/issues/issue-layouts/quick-action-dropdowns/project-issue.tsx b/web/core/components/issues/issue-layouts/quick-action-dropdowns/project-issue.tsx index 943b72c345..c47d76af77 100644 --- a/web/core/components/issues/issue-layouts/quick-action-dropdowns/project-issue.tsx +++ b/web/core/components/issues/issue-layouts/quick-action-dropdowns/project-issue.tsx @@ -77,6 +77,7 @@ export const ProjectIssueQuickActions: React.FC = observer((p ...issue, name: `${issue.name} (copy)`, is_draft: isDraftIssue ? false : issue.is_draft, + sourceIssueId: issue.id, }, ["id"] ); diff --git a/web/core/components/issues/issue-modal/components/default-properties.tsx b/web/core/components/issues/issue-modal/components/default-properties.tsx new file mode 100644 index 0000000000..a44b551af6 --- /dev/null +++ b/web/core/components/issues/issue-modal/components/default-properties.tsx @@ -0,0 +1,318 @@ +"use client"; + +import React, { useState } from "react"; +import { observer } from "mobx-react"; +import { Control, Controller } from "react-hook-form"; +import { LayoutPanelTop } from "lucide-react"; +// types +import { ISearchIssueResponse, TIssue } from "@plane/types"; +// ui +import { CustomMenu } from "@plane/ui"; +// components +import { + CycleDropdown, + DateDropdown, + EstimateDropdown, + ModuleDropdown, + PriorityDropdown, + MemberDropdown, + StateDropdown, +} from "@/components/dropdowns"; +import { ParentIssuesListModal } from "@/components/issues"; +import { IssueLabelSelect } from "@/components/issues/select"; +// helpers +import { getDate, renderFormattedPayloadDate } from "@/helpers/date-time.helper"; +import { getTabIndex } from "@/helpers/issue-modal.helper"; +// hooks +import { useProjectEstimates, useProject } from "@/hooks/store"; + +type TIssueDefaultPropertiesProps = { + control: Control; + id: string | undefined; + projectId: string | null; + workspaceSlug: string; + selectedParentIssue: ISearchIssueResponse | null; + startDate: string | null; + targetDate: string | null; + parentId: string | null; + isDraft: boolean; + handleFormChange: () => void; + setLabelModal: React.Dispatch>; + setSelectedParentIssue: (issue: ISearchIssueResponse) => void; +}; + +export const IssueDefaultProperties: React.FC = observer((props) => { + const { + control, + id, + projectId, + workspaceSlug, + selectedParentIssue, + startDate, + targetDate, + parentId, + isDraft, + handleFormChange, + setLabelModal, + setSelectedParentIssue, + } = props; + // states + const [parentIssueListModalOpen, setParentIssueListModalOpen] = useState(false); + // store hooks + const { areEstimateEnabledByProjectId } = useProjectEstimates(); + const { getProjectById } = useProject(); + // derived values + const projectDetails = getProjectById(projectId); + + const minDate = getDate(startDate); + minDate?.setDate(minDate.getDate()); + + const maxDate = getDate(targetDate); + maxDate?.setDate(maxDate.getDate()); + + return ( +
+ ( +
+ { + onChange(stateId); + handleFormChange(); + }} + projectId={projectId ?? undefined} + buttonVariant="border-with-text" + tabIndex={getTabIndex("state_id")} + /> +
+ )} + /> + ( +
+ { + onChange(priority); + handleFormChange(); + }} + buttonVariant="border-with-text" + tabIndex={getTabIndex("priority")} + /> +
+ )} + /> + ( +
+ { + onChange(assigneeIds); + handleFormChange(); + }} + buttonVariant={value?.length > 0 ? "transparent-without-text" : "border-with-text"} + buttonClassName={value?.length > 0 ? "hover:bg-transparent" : ""} + placeholder="Assignees" + multiple + tabIndex={getTabIndex("assignee_ids")} + /> +
+ )} + /> + ( +
+ { + onChange(labelIds); + handleFormChange(); + }} + projectId={projectId ?? undefined} + tabIndex={getTabIndex("label_ids")} + /> +
+ )} + /> + ( +
+ { + onChange(date ? renderFormattedPayloadDate(date) : null); + handleFormChange(); + }} + buttonVariant="border-with-text" + maxDate={maxDate ?? undefined} + placeholder="Start date" + tabIndex={getTabIndex("start_date")} + /> +
+ )} + /> + ( +
+ { + onChange(date ? renderFormattedPayloadDate(date) : null); + handleFormChange(); + }} + buttonVariant="border-with-text" + minDate={minDate ?? undefined} + placeholder="Due date" + tabIndex={getTabIndex("target_date")} + /> +
+ )} + /> + {projectDetails?.cycle_view && ( + ( +
+ { + onChange(cycleId); + handleFormChange(); + }} + placeholder="Cycle" + value={value} + buttonVariant="border-with-text" + tabIndex={getTabIndex("cycle_id")} + /> +
+ )} + /> + )} + {projectDetails?.module_view && workspaceSlug && ( + ( +
+ { + onChange(moduleIds); + handleFormChange(); + }} + placeholder="Modules" + buttonVariant="border-with-text" + tabIndex={getTabIndex("module_ids")} + multiple + showCount + /> +
+ )} + /> + )} + {projectId && areEstimateEnabledByProjectId(projectId) && ( + ( +
+ { + onChange(estimatePoint); + handleFormChange(); + }} + projectId={projectId} + buttonVariant="border-with-text" + tabIndex={getTabIndex("estimate_point")} + placeholder="Estimate" + /> +
+ )} + /> + )} + {parentId ? ( + + + + {selectedParentIssue && `${selectedParentIssue.project__identifier}-${selectedParentIssue.sequence_id}`} + + + } + placement="bottom-start" + tabIndex={getTabIndex("parent_id")} + > + <> + setParentIssueListModalOpen(true)}> + Change parent issue + + ( + { + onChange(null); + handleFormChange(); + }} + > + Remove parent issue + + )} + /> + + + ) : ( + + )} + ( + setParentIssueListModalOpen(false)} + onChange={(issue) => { + onChange(issue.id); + handleFormChange(); + setSelectedParentIssue(issue); + }} + projectId={projectId ?? undefined} + issueId={isDraft ? undefined : id} + /> + )} + /> +
+ ); +}); diff --git a/web/core/components/issues/issue-modal/components/description-editor.tsx b/web/core/components/issues/issue-modal/components/description-editor.tsx new file mode 100644 index 0000000000..d9daca8d83 --- /dev/null +++ b/web/core/components/issues/issue-modal/components/description-editor.tsx @@ -0,0 +1,229 @@ +"use client"; + +import React, { useEffect, useState } from "react"; +import { observer } from "mobx-react"; +import { Control, Controller } from "react-hook-form"; +import { Sparkle } from "lucide-react"; +// editor +import { EditorRefApi } from "@plane/editor"; +// types +import { TIssue } from "@plane/types"; +// ui +import { Loader, setToast, TOAST_TYPE } from "@plane/ui"; +// components +import { GptAssistantPopover } from "@/components/core"; +import { RichTextEditor } from "@/components/editor"; +// helpers +import { getTabIndex } from "@/helpers/issue-modal.helper"; +import { getDescriptionPlaceholder } from "@/helpers/issue.helper"; +// hooks +import { useInstance, useWorkspace } from "@/hooks/store"; +import useKeypress from "@/hooks/use-keypress"; +// services +import { AIService } from "@/services/ai.service"; + +type TIssueDescriptionEditorProps = { + control: Control; + issueName: string; + descriptionHtmlData: string | undefined; + editorRef: React.MutableRefObject; + submitBtnRef: React.MutableRefObject; + gptAssistantModal: boolean; + workspaceSlug: string; + projectId: string | null; + handleFormChange: () => void; + handleDescriptionHTMLDataChange: (descriptionHtmlData: string) => void; + setGptAssistantModal: React.Dispatch>; + handleGptAssistantClose: () => void; + onClose: () => void; +}; + +// services +const aiService = new AIService(); + +export const IssueDescriptionEditor: React.FC = observer((props) => { + const { + control, + issueName, + descriptionHtmlData, + editorRef, + submitBtnRef, + gptAssistantModal, + workspaceSlug, + projectId, + handleFormChange, + handleDescriptionHTMLDataChange, + setGptAssistantModal, + handleGptAssistantClose, + onClose, + } = props; + // states + const [iAmFeelingLucky, setIAmFeelingLucky] = useState(false); + // store hooks + const { getWorkspaceBySlug } = useWorkspace(); + const workspaceId = getWorkspaceBySlug(workspaceSlug?.toString())?.id as string; + const { config } = useInstance(); + + useEffect(() => { + if (descriptionHtmlData) handleDescriptionHTMLDataChange(descriptionHtmlData); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [descriptionHtmlData]); + + const handleKeyDown = (event: KeyboardEvent) => { + if (editorRef.current?.isEditorReadyToDiscard()) { + onClose(); + } else { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: "Editor is still processing changes. Please wait before proceeding.", + }); + event.preventDefault(); // Prevent default action if editor is not ready to discard + } + }; + + useKeypress("Escape", handleKeyDown); + + // handlers + const handleAiAssistance = async (response: string) => { + if (!workspaceSlug || !projectId) return; + + editorRef.current?.setEditorValueAtCursorPosition(response); + }; + + const handleAutoGenerateDescription = async () => { + if (!workspaceSlug || !projectId) return; + + setIAmFeelingLucky(true); + + aiService + .createGptTask(workspaceSlug.toString(), { + prompt: issueName, + task: "Generate a proper description for this issue.", + }) + .then((res) => { + if (res.response === "") + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: + "Issue title isn't informative enough to generate the description. Please try with a different title.", + }); + else handleAiAssistance(res.response_html); + }) + .catch((err) => { + const error = err?.data?.error; + + if (err.status === 429) + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: error || "You have reached the maximum number of requests of 50 requests per month per user.", + }); + else + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: error || "Some error occurred. Please try again.", + }); + }) + .finally(() => setIAmFeelingLucky(false)); + }; + + return ( +
+ {descriptionHtmlData === undefined || !projectId ? ( + + +
+ + +
+
+ + +
+ +
+ +
+
+ + +
+
+ ) : ( + <> + ( + { + onChange(description_html); + handleFormChange(); + }} + onEnterKeyPress={() => submitBtnRef?.current?.click()} + ref={editorRef} + tabIndex={getTabIndex("description_html")} + placeholder={getDescriptionPlaceholder} + containerClassName="pt-3 min-h-[150px]" + /> + )} + /> +
+ {issueName && issueName.trim() !== "" && config?.has_openai_configured && ( + + )} + {config?.has_openai_configured && projectId && ( + { + setGptAssistantModal((prevData) => !prevData); + // this is done so that the title do not reset after gpt popover closed + handleGptAssistantClose(); + }} + onResponse={(response) => { + handleAiAssistance(response); + }} + placement="top-end" + button={ + + } + /> + )} +
+ + )} +
+ ); +}); diff --git a/web/core/components/issues/issue-modal/components/index.ts b/web/core/components/issues/issue-modal/components/index.ts new file mode 100644 index 0000000000..3e0c1d4d9a --- /dev/null +++ b/web/core/components/issues/issue-modal/components/index.ts @@ -0,0 +1,5 @@ +export * from "./project-select"; +export * from "./parent-tag"; +export * from "./title-input"; +export * from "./description-editor"; +export * from "./default-properties"; diff --git a/web/core/components/issues/issue-modal/components/parent-tag.tsx b/web/core/components/issues/issue-modal/components/parent-tag.tsx new file mode 100644 index 0000000000..abda7f8e66 --- /dev/null +++ b/web/core/components/issues/issue-modal/components/parent-tag.tsx @@ -0,0 +1,56 @@ +"use client"; + +import React from "react"; +import { observer } from "mobx-react"; +import { Control, Controller } from "react-hook-form"; +import { X } from "lucide-react"; +// types +import { ISearchIssueResponse, TIssue } from "@plane/types"; +// helpers +import { getTabIndex } from "@/helpers/issue-modal.helper"; + +type TIssueParentTagProps = { + control: Control; + selectedParentIssue: ISearchIssueResponse; + handleFormChange: () => void; + setSelectedParentIssue: (issue: ISearchIssueResponse | null) => void; +}; + +export const IssueParentTag: React.FC = observer((props) => { + const { control, selectedParentIssue, handleFormChange, setSelectedParentIssue } = props; + + return ( + ( +
+
+ + + {selectedParentIssue.project__identifier}-{selectedParentIssue.sequence_id} + + {selectedParentIssue.name.substring(0, 50)} + +
+
+ )} + /> + ); +}); diff --git a/web/core/components/issues/issue-modal/components/project-select.tsx b/web/core/components/issues/issue-modal/components/project-select.tsx new file mode 100644 index 0000000000..5bc5d42c8b --- /dev/null +++ b/web/core/components/issues/issue-modal/components/project-select.tsx @@ -0,0 +1,53 @@ +"use client"; + +import React from "react"; +import { observer } from "mobx-react"; +import { Control, Controller } from "react-hook-form"; +// types +import { TIssue } from "@plane/types"; +// components +import { ProjectDropdown } from "@/components/dropdowns"; +// helpers +import { getTabIndex } from "@/helpers/issue-modal.helper"; +import { shouldRenderProject } from "@/helpers/project.helper"; +// store hooks +import { useUser } from "@/hooks/store"; + +type TIssueProjectSelectProps = { + control: Control; + handleFormChange: () => void; +}; + +export const IssueProjectSelect: React.FC = observer((props) => { + const { control, handleFormChange } = props; + // store hooks + const { projectsWithCreatePermissions } = useUser(); + + return ( + + projectsWithCreatePermissions && projectsWithCreatePermissions[value!] ? ( +
+ { + onChange(projectId); + handleFormChange(); + }} + buttonVariant="border-with-text" + renderCondition={(project) => shouldRenderProject(project)} + tabIndex={getTabIndex("project_id")} + /> +
+ ) : ( + <> + ) + } + /> + ); +}); diff --git a/web/core/components/issues/issue-modal/components/title-input.tsx b/web/core/components/issues/issue-modal/components/title-input.tsx new file mode 100644 index 0000000000..d625826720 --- /dev/null +++ b/web/core/components/issues/issue-modal/components/title-input.tsx @@ -0,0 +1,57 @@ +"use client"; + +import React from "react"; +import { observer } from "mobx-react"; +import { Control, Controller, FieldErrors } from "react-hook-form"; +// types +import { TIssue } from "@plane/types"; +// ui +import { Input } from "@plane/ui"; +// helpers +import { getTabIndex } from "@/helpers/issue-modal.helper"; + +type TIssueTitleInputProps = { + control: Control; + issueTitleRef: React.MutableRefObject; + errors: FieldErrors; + handleFormChange: () => void; +}; + +export const IssueTitleInput: React.FC = observer((props) => { + const { control, issueTitleRef, errors, handleFormChange } = props; + + return ( + <> + ( + { + onChange(e.target.value); + handleFormChange(); + }} + ref={issueTitleRef || ref} + hasError={Boolean(errors.name)} + placeholder="Title" + className="w-full text-base" + tabIndex={getTabIndex("name")} + autoFocus + /> + )} + /> + {errors?.name?.message} + + ); +}); diff --git a/web/core/components/issues/issue-modal/form.tsx b/web/core/components/issues/issue-modal/form.tsx deleted file mode 100644 index 0088f43b89..0000000000 --- a/web/core/components/issues/issue-modal/form.tsx +++ /dev/null @@ -1,852 +0,0 @@ -"use client"; - -import React, { FC, useState, useRef, useEffect } from "react"; -import { observer } from "mobx-react"; -import { useParams } from "next/navigation"; -import { Controller, useForm } from "react-hook-form"; -import { LayoutPanelTop, Sparkle, X } from "lucide-react"; -// editor -import { EditorRefApi } from "@plane/editor"; -// types -import type { TIssue, ISearchIssueResponse } from "@plane/types"; -// hooks -import { Button, CustomMenu, Input, Loader, ToggleSwitch, TOAST_TYPE, setToast } from "@plane/ui"; -// components -import { GptAssistantPopover } from "@/components/core"; -import { - CycleDropdown, - DateDropdown, - EstimateDropdown, - ModuleDropdown, - PriorityDropdown, - ProjectDropdown, - MemberDropdown, - StateDropdown, -} from "@/components/dropdowns"; -import { RichTextEditor } from "@/components/editor/rich-text-editor/rich-text-editor"; -import { ParentIssuesListModal } from "@/components/issues"; -import { IssueLabelSelect } from "@/components/issues/select"; -import { CreateLabelModal } from "@/components/labels"; -// helpers -import { renderFormattedPayloadDate, getDate } from "@/helpers/date-time.helper"; -import { getChangedIssuefields, getDescriptionPlaceholder } from "@/helpers/issue.helper"; -import { shouldRenderProject } from "@/helpers/project.helper"; -// hooks -import { useProjectEstimates, useInstance, useIssueDetail, useProject, useWorkspace, useUser } from "@/hooks/store"; -import useKeypress from "@/hooks/use-keypress"; -import { useProjectIssueProperties } from "@/hooks/use-project-issue-properties"; -// services -import { AIService } from "@/services/ai.service"; - -const defaultValues: Partial = { - project_id: "", - name: "", - description_html: "", - estimate_point: null, - state_id: "", - parent_id: null, - priority: "none", - assignee_ids: [], - label_ids: [], - cycle_id: null, - module_ids: null, - start_date: null, - target_date: null, -}; - -export interface IssueFormProps { - data?: Partial; - issueTitleRef: React.MutableRefObject; - isCreateMoreToggleEnabled: boolean; - onCreateMoreToggleChange: (value: boolean) => void; - onChange?: (formData: Partial | null) => void; - onClose: () => void; - onSubmit: (values: Partial, is_draft_issue?: boolean) => Promise; - projectId: string; - isDraft: boolean; -} - -// services -const aiService = new AIService(); - -const TAB_INDICES = [ - "name", - "description_html", - "feeling_lucky", - "ai_assistant", - "state_id", - "priority", - "assignee_ids", - "label_ids", - "start_date", - "target_date", - "cycle_id", - "module_ids", - "estimate_point", - "parent_id", - "create_more", - "discard_button", - "draft_button", - "submit_button", - "project_id", - "remove_parent", -]; - -const getTabIndex = (key: string) => TAB_INDICES.findIndex((tabIndex) => tabIndex === key) + 1; - -export const IssueFormRoot: FC = observer((props) => { - const { - data, - issueTitleRef, - onChange, - onClose, - onSubmit, - projectId: defaultProjectId, - isCreateMoreToggleEnabled, - onCreateMoreToggleChange, - isDraft, - } = props; - // states - const [labelModal, setLabelModal] = useState(false); - const [parentIssueListModalOpen, setParentIssueListModalOpen] = useState(false); - const [selectedParentIssue, setSelectedParentIssue] = useState(null); - const [gptAssistantModal, setGptAssistantModal] = useState(false); - const [iAmFeelingLucky, setIAmFeelingLucky] = useState(false); - // refs - const editorRef = useRef(null); - const submitBtnRef = useRef(null); - // router - const { workspaceSlug, projectId: routeProjectId } = useParams(); - // store hooks - const workspaceStore = useWorkspace(); - const workspaceId = workspaceStore.getWorkspaceBySlug(workspaceSlug?.toString())?.id as string; - const { config } = useInstance(); - const { projectsWithCreatePermissions } = useUser(); - - const { getProjectById } = useProject(); - const { areEstimateEnabledByProjectId } = useProjectEstimates(); - - const handleKeyDown = (event: KeyboardEvent) => { - if (editorRef.current?.isEditorReadyToDiscard()) { - onClose(); - } else { - setToast({ - type: TOAST_TYPE.ERROR, - title: "Error!", - message: "Editor is still processing changes. Please wait before proceeding.", - }); - event.preventDefault(); // Prevent default action if editor is not ready to discard - } - }; - - useKeypress("Escape", handleKeyDown); - - const { - issue: { getIssueById }, - } = useIssueDetail(); - const { fetchCycles } = useProjectIssueProperties(); - // form info - const { - formState: { errors, isDirty, isSubmitting, dirtyFields }, - handleSubmit, - reset, - watch, - control, - getValues, - setValue, - } = useForm({ - defaultValues: { ...defaultValues, project_id: defaultProjectId, ...data }, - reValidateMode: "onChange", - }); - - const projectId = watch("project_id"); - - //reset few fields on projectId change - useEffect(() => { - if (isDirty) { - const formData = getValues(); - - reset({ - ...defaultValues, - project_id: projectId, - name: formData.name, - description_html: formData.description_html, - priority: formData.priority, - start_date: formData.start_date, - target_date: formData.target_date, - parent_id: formData.parent_id, - }); - } - if (projectId && routeProjectId !== projectId) fetchCycles(workspaceSlug?.toString(), projectId); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [projectId]); - - useEffect(() => { - if (data?.description_html) setValue<"description_html">("description_html", data?.description_html); - }, [data?.description_html]); - - const issueName = watch("name"); - - const handleFormSubmit = async (formData: Partial, is_draft_issue = false) => { - // Check if the editor is ready to discard - if (!editorRef.current?.isEditorReadyToDiscard()) { - setToast({ - type: TOAST_TYPE.ERROR, - title: "Error!", - message: "Editor is not ready to discard changes.", - }); - return; - } - - const submitData = !data?.id - ? formData - : { - ...getChangedIssuefields(formData, dirtyFields as { [key: string]: boolean | undefined }), - project_id: getValues<"project_id">("project_id"), - id: data.id, - description_html: formData.description_html ?? "

", - }; - - // this condition helps to move the issues from draft to project issues - if (formData.hasOwnProperty("is_draft")) submitData.is_draft = formData.is_draft; - - await onSubmit(submitData, is_draft_issue); - - setGptAssistantModal(false); - - reset({ - ...defaultValues, - ...(isCreateMoreToggleEnabled ? { ...data } : {}), - project_id: getValues<"project_id">("project_id"), - description_html: data?.description_html ?? "

", - }); - editorRef?.current?.clearEditor(); - }; - - const handleAiAssistance = async (response: string) => { - if (!workspaceSlug || !projectId) return; - - editorRef.current?.setEditorValueAtCursorPosition(response); - }; - - const handleAutoGenerateDescription = async () => { - if (!workspaceSlug || !projectId) return; - - setIAmFeelingLucky(true); - - aiService - .createGptTask(workspaceSlug.toString(), { - prompt: issueName, - task: "Generate a proper description for this issue.", - }) - .then((res) => { - if (res.response === "") - setToast({ - type: TOAST_TYPE.ERROR, - title: "Error!", - message: - "Issue title isn't informative enough to generate the description. Please try with a different title.", - }); - else handleAiAssistance(res.response_html); - }) - .catch((err) => { - const error = err?.data?.error; - - if (err.status === 429) - setToast({ - type: TOAST_TYPE.ERROR, - title: "Error!", - message: error || "You have reached the maximum number of requests of 50 requests per month per user.", - }); - else - setToast({ - type: TOAST_TYPE.ERROR, - title: "Error!", - message: error || "Some error occurred. Please try again.", - }); - }) - .finally(() => setIAmFeelingLucky(false)); - }; - - const condition = - (watch("name") && watch("name") !== "") || (watch("description_html") && watch("description_html") !== "

"); - - const handleFormChange = () => { - if (!onChange) return; - - if (isDirty && condition) onChange(watch()); - else onChange(null); - }; - - const startDate = watch("start_date"); - const targetDate = watch("target_date"); - - const minDate = getDate(startDate); - minDate?.setDate(minDate.getDate()); - - const maxDate = getDate(targetDate); - maxDate?.setDate(maxDate.getDate()); - - const projectDetails = getProjectById(projectId); - - // executing this useEffect when the parent_id coming from the component prop - useEffect(() => { - const parentId = watch("parent_id") || undefined; - if (!parentId) return; - if (parentId === selectedParentIssue?.id || selectedParentIssue) return; - - const issue = getIssueById(parentId); - if (!issue) return; - - const projectDetails = getProjectById(issue.project_id); - if (!projectDetails) return; - - setSelectedParentIssue({ - id: issue.id, - name: issue.name, - project_id: issue.project_id, - project__identifier: projectDetails.identifier, - project__name: projectDetails.name, - sequence_id: issue.sequence_id, - } as ISearchIssueResponse); - }, [watch, getIssueById, getProjectById, selectedParentIssue]); - - // executing this useEffect when isDirty changes - useEffect(() => { - if (!onChange) return; - - if (isDirty && condition) onChange(watch()); - else onChange(null); - }, [isDirty]); - - return ( - <> - {projectId && ( - setLabelModal(false)} - projectId={projectId} - onSuccess={(response) => { - setValue<"label_ids">("label_ids", [...watch("label_ids"), response.id]); - handleFormChange(); - }} - /> - )} -
handleFormSubmit(data))}> -
-
- {/* Don't show project selection if editing an issue */} - {!data?.id && ( - - projectsWithCreatePermissions && projectsWithCreatePermissions[value!] ? ( -
- { - onChange(projectId); - handleFormChange(); - }} - buttonVariant="border-with-text" - renderCondition={(project) => shouldRenderProject(project)} - tabIndex={getTabIndex("project_id")} - /> -
- ) : ( - <> - ) - } - /> - )} -

{data?.id ? "Update" : "Create"} Issue

-
- {watch("parent_id") && selectedParentIssue && ( - ( -
-
- - - {selectedParentIssue.project__identifier}-{selectedParentIssue.sequence_id} - - {selectedParentIssue.name.substring(0, 50)} - -
-
- )} - /> - )} -
-
- ( - { - onChange(e.target.value); - handleFormChange(); - }} - ref={issueTitleRef || ref} - hasError={Boolean(errors.name)} - placeholder="Title" - className="w-full text-base" - tabIndex={getTabIndex("name")} - autoFocus - /> - )} - /> - {errors?.name?.message} -
-
- {data?.description_html === undefined || !projectId ? ( - - -
- - -
-
- - -
- -
- -
-
- - -
-
- ) : ( - <> - ( - { - onChange(description_html); - handleFormChange(); - }} - onEnterKeyPress={() => submitBtnRef?.current?.click()} - ref={editorRef} - tabIndex={getTabIndex("description_html")} - placeholder={getDescriptionPlaceholder} - containerClassName="pt-3 min-h-[150px]" - /> - )} - /> -
- {issueName && issueName.trim() !== "" && config?.has_openai_configured && ( - - )} - {config?.has_openai_configured && projectId && ( - { - setGptAssistantModal((prevData) => !prevData); - // this is done so that the title do not reset after gpt popover closed - reset(getValues()); - }} - onResponse={(response) => { - handleAiAssistance(response); - }} - placement="top-end" - button={ - - } - /> - )} -
- - )} -
-
- ( -
- { - onChange(stateId); - handleFormChange(); - }} - projectId={projectId ?? undefined} - buttonVariant="border-with-text" - tabIndex={getTabIndex("state_id")} - /> -
- )} - /> - ( -
- { - onChange(priority); - handleFormChange(); - }} - buttonVariant="border-with-text" - tabIndex={getTabIndex("priority")} - /> -
- )} - /> - ( -
- { - onChange(assigneeIds); - handleFormChange(); - }} - buttonVariant={value?.length > 0 ? "transparent-without-text" : "border-with-text"} - buttonClassName={value?.length > 0 ? "hover:bg-transparent" : ""} - placeholder="Assignees" - multiple - tabIndex={getTabIndex("assignee_ids")} - /> -
- )} - /> - ( -
- { - onChange(labelIds); - handleFormChange(); - }} - projectId={projectId ?? undefined} - tabIndex={getTabIndex("label_ids")} - /> -
- )} - /> - ( -
- { - onChange(date ? renderFormattedPayloadDate(date) : null); - handleFormChange(); - }} - buttonVariant="border-with-text" - maxDate={maxDate ?? undefined} - placeholder="Start date" - tabIndex={getTabIndex("start_date")} - /> -
- )} - /> - ( -
- { - onChange(date ? renderFormattedPayloadDate(date) : null); - handleFormChange(); - }} - buttonVariant="border-with-text" - minDate={minDate ?? undefined} - placeholder="Due date" - tabIndex={getTabIndex("target_date")} - /> -
- )} - /> - {projectDetails?.cycle_view && ( - ( -
- { - onChange(cycleId); - handleFormChange(); - }} - placeholder="Cycle" - value={value} - buttonVariant="border-with-text" - tabIndex={getTabIndex("cycle_id")} - /> -
- )} - /> - )} - {projectDetails?.module_view && workspaceSlug && ( - ( -
- { - onChange(moduleIds); - handleFormChange(); - }} - placeholder="Modules" - buttonVariant="border-with-text" - tabIndex={getTabIndex("module_ids")} - multiple - showCount - /> -
- )} - /> - )} - {projectId && areEstimateEnabledByProjectId(projectId) && ( - ( -
- { - onChange(estimatePoint); - handleFormChange(); - }} - projectId={projectId} - buttonVariant="border-with-text" - tabIndex={getTabIndex("estimate_point")} - placeholder="Estimate" - /> -
- )} - /> - )} - {watch("parent_id") ? ( - - - - {selectedParentIssue && - `${selectedParentIssue.project__identifier}-${selectedParentIssue.sequence_id}`} - - - } - placement="bottom-start" - tabIndex={getTabIndex("parent_id")} - > - <> - setParentIssueListModalOpen(true)}> - Change parent issue - - ( - { - onChange(null); - handleFormChange(); - }} - > - Remove parent issue - - )} - /> - - - ) : ( - - )} - ( - setParentIssueListModalOpen(false)} - onChange={(issue) => { - onChange(issue.id); - handleFormChange(); - setSelectedParentIssue(issue); - }} - projectId={projectId ?? undefined} - issueId={isDraft ? undefined : data?.id} - /> - )} - /> -
-
-
-
-
- {!data?.id && ( -
onCreateMoreToggleChange(!isCreateMoreToggleEnabled)} - onKeyDown={(e) => { - if (e.key === "Enter") onCreateMoreToggleChange(!isCreateMoreToggleEnabled); - }} - tabIndex={getTabIndex("create_more")} - role="button" - > - {}} size="sm" /> - Create more -
- )} -
-
- - {isDraft && ( - <> - {data?.id ? ( - - ) : ( - - )} - - )} - -
-
-
- - ); -}); diff --git a/web/core/components/issues/issue-modal/index.ts b/web/core/components/issues/issue-modal/index.ts index feac885d4c..031608e25f 100644 --- a/web/core/components/issues/issue-modal/index.ts +++ b/web/core/components/issues/issue-modal/index.ts @@ -1,3 +1 @@ -export * from "./draft-issue-layout"; -export * from "./form"; export * from "./modal"; diff --git a/web/core/components/issues/issue-modal/modal.tsx b/web/core/components/issues/issue-modal/modal.tsx index 1520ec767b..7a984104eb 100644 --- a/web/core/components/issues/issue-modal/modal.tsx +++ b/web/core/components/issues/issue-modal/modal.tsx @@ -1,321 +1,3 @@ "use client"; -import React, { useEffect, useRef, useState } from "react"; -import { observer } from "mobx-react"; -import { useParams, usePathname } from "next/navigation"; -// types -import type { TIssue } from "@plane/types"; -// ui -import { EModalPosition, EModalWidth, ModalCore, TOAST_TYPE, setToast } from "@plane/ui"; -import { CreateIssueToastActionItems } from "@/components/issues"; -// constants -import { ISSUE_CREATED, ISSUE_UPDATED } from "@/constants/event-tracker"; -import { EIssuesStoreType } from "@/constants/issue"; -// hooks -import { useEventTracker, useCycle, useIssues, useModule, useProject, useIssueDetail } from "@/hooks/store"; -import { useIssueStoreType } from "@/hooks/use-issue-layout-store"; -import { useIssuesActions } from "@/hooks/use-issues-actions"; -import useLocalStorage from "@/hooks/use-local-storage"; -// components -import { DraftIssueLayout } from "./draft-issue-layout"; -import { IssueFormRoot } from "./form"; - -export interface IssuesModalProps { - data?: Partial; - isOpen: boolean; - onClose: () => void; - onSubmit?: (res: TIssue) => Promise; - withDraftIssueWrapper?: boolean; - storeType?: EIssuesStoreType; - isDraft?: boolean; -} - -export const CreateUpdateIssueModal: React.FC = observer((props) => { - const { - data, - isOpen, - onClose, - onSubmit, - withDraftIssueWrapper = true, - storeType: issueStoreFromProps, - isDraft = false, - } = props; - const issueStoreType = useIssueStoreType(); - - const storeType = issueStoreFromProps ?? issueStoreType; - // ref - const issueTitleRef = useRef(null); - // states - const [changesMade, setChangesMade] = useState | null>(null); - const [createMore, setCreateMore] = useState(false); - const [activeProjectId, setActiveProjectId] = useState(null); - const [description, setDescription] = useState(undefined); - // store hooks - const { captureIssueEvent } = useEventTracker(); - const { workspaceSlug, projectId, cycleId, moduleId } = useParams(); - const { workspaceProjectIds } = useProject(); - const { fetchCycleDetails } = useCycle(); - const { fetchModuleDetails } = useModule(); - const { issues } = useIssues(storeType); - const { issues: projectIssues } = useIssues(EIssuesStoreType.PROJECT); - const { issues: draftIssues } = useIssues(EIssuesStoreType.DRAFT); - const { fetchIssue } = useIssueDetail(); - // pathname - const pathname = usePathname(); - // local storage - const { storedValue: localStorageDraftIssues, setValue: setLocalStorageDraftIssue } = useLocalStorage< - Record> - >("draftedIssue", {}); - // current store details - const { createIssue, updateIssue } = useIssuesActions(storeType); - - const fetchIssueDetail = async (issueId: string | undefined) => { - setDescription(undefined); - if (!workspaceSlug) return; - - if (!projectId || issueId === undefined) { - setDescription(data?.description_html || "

"); - return; - } - const response = await fetchIssue( - workspaceSlug.toString(), - projectId.toString(), - issueId, - isDraft ? "DRAFT" : "DEFAULT" - ); - if (response) setDescription(response?.description_html || "

"); - }; - - useEffect(() => { - // fetching issue details - if (isOpen) fetchIssueDetail(data?.id); - - // if modal is closed, reset active project to null - // and return to avoid activeProjectId being set to some other project - if (!isOpen) { - setActiveProjectId(null); - return; - } - - // if data is present, set active project to the project of the - // issue. This has more priority than the project in the url. - if (data && data.project_id) { - setActiveProjectId(data.project_id); - return; - } - - // if data is not present, set active project to the project - // in the url. This has the least priority. - if (workspaceProjectIds && workspaceProjectIds.length > 0 && !activeProjectId) - setActiveProjectId(projectId?.toString() ?? workspaceProjectIds?.[0]); - - // clearing up the description state when we leave the component - return () => setDescription(undefined); - }, [data, projectId, isOpen, activeProjectId]); - - const addIssueToCycle = async (issue: TIssue, cycleId: string) => { - if (!workspaceSlug || !issue.project_id) return; - - await issues.addIssueToCycle(workspaceSlug.toString(), issue.project_id, cycleId, [issue.id]); - fetchCycleDetails(workspaceSlug.toString(), issue.project_id, cycleId); - }; - - const addIssueToModule = async (issue: TIssue, moduleIds: string[]) => { - if (!workspaceSlug || !activeProjectId) return; - - await issues.changeModulesInIssue(workspaceSlug.toString(), activeProjectId, issue.id, moduleIds, []); - moduleIds.forEach((moduleId) => fetchModuleDetails(workspaceSlug.toString(), activeProjectId, moduleId)); - }; - - const handleCreateMoreToggleChange = (value: boolean) => { - setCreateMore(value); - }; - - const handleClose = (saveDraftIssueInLocalStorage?: boolean) => { - if (changesMade && saveDraftIssueInLocalStorage) { - // updating the current edited issue data in the local storage - let draftIssues = localStorageDraftIssues ? localStorageDraftIssues : {}; - if (workspaceSlug) { - draftIssues = { ...draftIssues, [workspaceSlug.toString()]: changesMade }; - setLocalStorageDraftIssue(draftIssues); - } - } - - setActiveProjectId(null); - setChangesMade(null); - onClose(); - }; - - const handleCreateIssue = async ( - payload: Partial, - is_draft_issue: boolean = false - ): Promise => { - if (!workspaceSlug || !payload.project_id) return; - - try { - let response; - - // if draft issue, use draft issue store to create issue - if (is_draft_issue) { - response = await draftIssues.createIssue(workspaceSlug.toString(), payload.project_id, payload); - } - // if cycle id in payload does not match the cycleId in url - // or if the moduleIds in Payload does not match the moduleId in url - // use the project issue store to create issues - else if ( - (payload.cycle_id !== cycleId && storeType === EIssuesStoreType.CYCLE) || - (!payload.module_ids?.includes(moduleId?.toString()) && storeType === EIssuesStoreType.MODULE) - ) { - response = await projectIssues.createIssue(workspaceSlug.toString(), payload.project_id, payload); - } // else just use the existing store type's create method - else if (createIssue) { - response = await createIssue(payload.project_id, payload); - } - - if (!response) throw new Error(); - - // check if we should add issue to cycle/module - if ( - payload.cycle_id && - payload.cycle_id !== "" && - (payload.cycle_id !== cycleId || storeType !== EIssuesStoreType.CYCLE) - ) { - await addIssueToCycle(response, payload.cycle_id); - } - if ( - payload.module_ids && - payload.module_ids.length > 0 && - (!payload.module_ids.includes(moduleId?.toString()) || storeType !== EIssuesStoreType.MODULE) - ) { - await addIssueToModule(response, payload.module_ids); - } - - setToast({ - type: TOAST_TYPE.SUCCESS, - title: "Success!", - message: `${is_draft_issue ? "Draft issue" : "Issue"} created successfully.`, - actionItems: !is_draft_issue && response?.project_id && ( - - ), - }); - captureIssueEvent({ - eventName: ISSUE_CREATED, - payload: { ...response, state: "SUCCESS" }, - path: pathname, - }); - !createMore && handleClose(); - if (createMore) issueTitleRef && issueTitleRef?.current?.focus(); - setDescription("

"); - setChangesMade(null); - return response; - } catch (error) { - setToast({ - type: TOAST_TYPE.ERROR, - title: "Error!", - message: `${is_draft_issue ? "Draft issue" : "Issue"} could not be created. Please try again.`, - }); - captureIssueEvent({ - eventName: ISSUE_CREATED, - payload: { ...payload, state: "FAILED" }, - path: pathname, - }); - } - }; - - const handleUpdateIssue = async (payload: Partial): Promise => { - if (!workspaceSlug || !payload.project_id || !data?.id) return; - - try { - isDraft - ? await draftIssues.updateIssue(workspaceSlug.toString(), payload.project_id, data.id, payload) - : updateIssue && (await updateIssue(payload.project_id, data.id, payload)); - - setToast({ - type: TOAST_TYPE.SUCCESS, - title: "Success!", - message: "Issue updated successfully.", - }); - captureIssueEvent({ - eventName: ISSUE_UPDATED, - payload: { ...payload, issueId: data.id, state: "SUCCESS" }, - path: pathname, - }); - handleClose(); - } catch (error) { - setToast({ - type: TOAST_TYPE.ERROR, - title: "Error!", - message: "Issue could not be updated. Please try again.", - }); - captureIssueEvent({ - eventName: ISSUE_UPDATED, - payload: { ...payload, state: "FAILED" }, - path: pathname, - }); - } - }; - - const handleFormSubmit = async (payload: Partial, is_draft_issue: boolean = false) => { - if (!workspaceSlug || !payload.project_id || !storeType) return; - - let response: TIssue | undefined = undefined; - if (!data?.id) response = await handleCreateIssue(payload, is_draft_issue); - else response = await handleUpdateIssue(payload); - - if (response != undefined && onSubmit) await onSubmit(response); - }; - - const handleFormChange = (formData: Partial | null) => setChangesMade(formData); - - // don't open the modal if there are no projects - if (!workspaceProjectIds || workspaceProjectIds.length === 0 || !activeProjectId) return null; - - return ( - handleClose(true)} - position={EModalPosition.TOP} - width={EModalWidth.XXXXL} - > - {withDraftIssueWrapper ? ( - - ) : ( - handleClose(false)} - isCreateMoreToggleEnabled={createMore} - onCreateMoreToggleChange={handleCreateMoreToggleChange} - onSubmit={handleFormSubmit} - projectId={activeProjectId} - isDraft={isDraft} - /> - )} - - ); -}); +export * from "@/plane-web/components/issues/issue-modal/modal"; diff --git a/web/core/components/issues/issue-update-status.tsx b/web/core/components/issues/issue-update-status.tsx index 018e5754aa..6eb064b529 100644 --- a/web/core/components/issues/issue-update-status.tsx +++ b/web/core/components/issues/issue-update-status.tsx @@ -1,27 +1,16 @@ import React from "react"; import { observer } from "mobx-react"; import { RefreshCw } from "lucide-react"; -import { TIssue } from "@plane/types"; -// types -import { useProject } from "@/hooks/store"; type Props = { isSubmitting: "submitting" | "submitted" | "saved"; - issueDetail?: TIssue; }; export const IssueUpdateStatus: React.FC = observer((props) => { - const { isSubmitting, issueDetail } = props; - // hooks - const { getProjectById } = useProject(); + const { isSubmitting } = props; return ( <> - {issueDetail && ( -

- {getProjectById(issueDetail.project_id)?.identifier}-{issueDetail.sequence_id} -

- )}
= observer((props) => { const { workspaceSlug, issueId, issueOperations, disabled, isArchived, isSubmitting, setIsSubmitting } = props; // store hooks - const { getProjectById } = useProject(); const { data: currentUser } = useUser(); const { issue: { getIssueById }, @@ -46,8 +47,6 @@ export const PeekOverviewIssueDetails: FC = observer( const issue = issueId ? getIssueById(issueId) : undefined; if (!issue || !issue.project_id) return <>; - const projectDetails = getProjectById(issue.project_id); - const issueDescription = issue.description_html !== undefined || issue.description_html !== null ? issue.description_html != "" @@ -57,9 +56,7 @@ export const PeekOverviewIssueDetails: FC = observer( return (
- - {projectDetails?.identifier}-{issue?.sequence_id} - + = observer((pro issueId={issueId} disabled={disabled} /> + + {issue.type_id && ( + + )}
); diff --git a/web/core/components/project/card.tsx b/web/core/components/project/card.tsx index 36c1f72686..678958af85 100644 --- a/web/core/components/project/card.tsx +++ b/web/core/components/project/card.tsx @@ -110,7 +110,7 @@ export const ProjectCard: React.FC = observer((props) => { const MENU_ITEMS: TContextMenuItem[] = [ { key: "settings", - action: () => router.push(`/${workspaceSlug}/projects/${project.id}/settings`), + action: () => router.push(`/${workspaceSlug}/projects/${project.id}/settings`, {}, { showProgressBar: false }), title: "Settings", icon: Settings, shouldRender: !isArchived && (isOwner || isMember), diff --git a/web/core/constants/issue-modal.ts b/web/core/constants/issue-modal.ts new file mode 100644 index 0000000000..2e16176bd3 --- /dev/null +++ b/web/core/constants/issue-modal.ts @@ -0,0 +1,22 @@ +export const ISSUE_FORM_TAB_INDICES = [ + "name", + "description_html", + "feeling_lucky", + "ai_assistant", + "state_id", + "priority", + "assignee_ids", + "label_ids", + "start_date", + "target_date", + "cycle_id", + "module_ids", + "estimate_point", + "parent_id", + "create_more", + "discard_button", + "draft_button", + "submit_button", + "project_id", + "remove_parent", +]; diff --git a/web/core/constants/project.ts b/web/core/constants/project.ts index 6e69d4d760..4a7899fd94 100644 --- a/web/core/constants/project.ts +++ b/web/core/constants/project.ts @@ -1,9 +1,6 @@ // icons import { Globe2, Lock, LucideIcon } from "lucide-react"; import { TProjectAppliedDisplayFilterKeys, TProjectOrderByOptions } from "@plane/types"; -import { SettingIcon } from "@/components/icons/attachment"; -// types -import { Props } from "@/components/icons/types"; export enum EUserProjectRoles { GUEST = 5, @@ -67,80 +64,6 @@ export const PROJECT_UNSPLASH_COVERS = [ "https://images.unsplash.com/photo-1675351066828-6fc770b90dd2?auto=format&fit=crop&q=80&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&w=870&q=80", ]; -export const PROJECT_SETTINGS_LINKS: { - key: string; - label: string; - href: string; - access: EUserProjectRoles; - highlight: (pathname: string, baseUrl: string) => boolean; - Icon: React.FC; -}[] = [ - { - key: "general", - label: "General", - href: `/settings`, - access: EUserProjectRoles.MEMBER, - highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/`, - Icon: SettingIcon, - }, - { - key: "members", - label: "Members", - href: `/settings/members`, - access: EUserProjectRoles.MEMBER, - highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/members/`, - Icon: SettingIcon, - }, - { - key: "features", - label: "Features", - href: `/settings/features`, - access: EUserProjectRoles.ADMIN, - highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/features/`, - Icon: SettingIcon, - }, - { - key: "states", - label: "States", - href: `/settings/states`, - access: EUserProjectRoles.MEMBER, - highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/states/`, - Icon: SettingIcon, - }, - { - key: "labels", - label: "Labels", - href: `/settings/labels`, - access: EUserProjectRoles.MEMBER, - highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/labels/`, - Icon: SettingIcon, - }, - { - key: "integrations", - label: "Integrations", - href: `/settings/integrations`, - access: EUserProjectRoles.ADMIN, - highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/integrations/`, - Icon: SettingIcon, - }, - { - key: "estimates", - label: "Estimates", - href: `/settings/estimates`, - access: EUserProjectRoles.ADMIN, - highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/estimates/`, - Icon: SettingIcon, - }, - { - key: "automations", - label: "Automations", - href: `/settings/automations`, - access: EUserProjectRoles.ADMIN, - highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/automations/`, - Icon: SettingIcon, - }, -]; - export const PROJECT_ORDER_BY_OPTIONS: { key: TProjectOrderByOptions; label: string; diff --git a/web/core/layouts/auth-layout/project-wrapper.tsx b/web/core/layouts/auth-layout/project-wrapper.tsx index 8e3a88eb4c..d4c765cff0 100644 --- a/web/core/layouts/auth-layout/project-wrapper.tsx +++ b/web/core/layouts/auth-layout/project-wrapper.tsx @@ -20,6 +20,7 @@ import { useCommandPalette, } from "@/hooks/store"; // images +import { useIssueTypes } from "@/plane-web/hooks/store"; import emptyProject from "@/public/empty-state/project.svg"; interface IProjectAuthWrapper { @@ -45,6 +46,7 @@ export const ProjectAuthWrapper: FC = observer((props) => { const { fetchProjectStates } = useProjectState(); const { fetchProjectLabels } = useLabel(); const { getProjectEstimates } = useProjectEstimates(); + const { getAllTypesPropertiesOptions } = useIssueTypes(); // router const { workspaceSlug, projectId } = useParams(); @@ -100,6 +102,15 @@ export const ProjectAuthWrapper: FC = observer((props) => { workspaceSlug && projectId ? () => fetchViews(workspaceSlug.toString(), projectId.toString()) : null, { revalidateIfStale: false, revalidateOnFocus: false } ); + // fetching all issue types and properties + useSWR( + workspaceSlug && projectId ? `ISSUE_TYPES_AND_PROPERTIES_${workspaceSlug}_${projectId}` : null, + workspaceSlug && projectId + ? () => getAllTypesPropertiesOptions(workspaceSlug.toString(), projectId.toString()) + : null, + { revalidateIfStale: false, revalidateOnFocus: false } + ); + const projectExists = projectId ? getProjectById(projectId.toString()) : null; // check if the project member apis is loading diff --git a/web/core/store/issue/issue-details/issue.store.ts b/web/core/store/issue/issue-details/issue.store.ts index c464c25893..500d8e034b 100644 --- a/web/core/store/issue/issue-details/issue.store.ts +++ b/web/core/store/issue/issue-details/issue.store.ts @@ -94,6 +94,7 @@ export class IssueStore implements IIssueStore { parent_id: issue?.parent_id, cycle_id: issue?.cycle_id, module_ids: issue?.module_ids, + type_id: issue?.type_id, created_at: issue?.created_at, updated_at: issue?.updated_at, start_date: issue?.start_date, diff --git a/web/ee/components/issue-types/create-update/create-modal.tsx b/web/ee/components/issue-types/create-update/create-modal.tsx new file mode 100644 index 0000000000..04701d898e --- /dev/null +++ b/web/ee/components/issue-types/create-update/create-modal.tsx @@ -0,0 +1,89 @@ +import { FC, useState } from "react"; +// ui +import { EModalPosition, EModalWidth, ModalCore, setToast, TOAST_TYPE } from "@plane/ui"; +// helpers +import { getRandomIconName } from "@/helpers/emoji.helper"; +// plane web components +import { CreateOrUpdateIssueTypeForm } from "@/plane-web/components/issue-types/"; +// hooks +import { useIssueTypes } from "@/plane-web/hooks/store"; +// plane web types +import { TIssueType } from "@/plane-web/types"; + +type Props = { + isModalOpen: boolean; + handleModalClose: () => void; +}; + +export const defaultIssueTypeData: Partial = { + id: undefined, + logo_props: { + in_use: "icon", + icon: { + name: getRandomIconName(), + color: "#6d7b8a", + }, + }, + name: "", + description: "", +}; + +export const CreateIssueTypeModal: FC = (props) => { + const { isModalOpen, handleModalClose } = props; + // states + const [isSubmitting, setIsSubmitting] = useState(false); + const [issueTypeFormData, setIssueTypeFormData] = useState>(defaultIssueTypeData); + // store hooks + const { createType } = useIssueTypes(); + + // handlers + const handleFormDataChange = (key: T, value: TIssueType[T]) => + setIssueTypeFormData((prev) => ({ ...prev, [key]: value })); + + const handleModalClearAndClose = () => { + setIssueTypeFormData(defaultIssueTypeData); + handleModalClose(); + }; + + const handleFormSubmit = async () => { + setIsSubmitting(true); + await createType(issueTypeFormData) + .then(() => { + handleModalClearAndClose(); + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Success!", + message: `Issue type created successfully.`, + }); + }) + .catch(() => { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: `Failed to create issue type. Please try again!`, + }); + }) + .finally(() => { + setIsSubmitting(false); + }); + }; + + if (!isModalOpen) return null; + + return ( + + + + ); +}; diff --git a/web/ee/components/issue-types/create-update/form.tsx b/web/ee/components/issue-types/create-update/form.tsx new file mode 100644 index 0000000000..6233e52c38 --- /dev/null +++ b/web/ee/components/issue-types/create-update/form.tsx @@ -0,0 +1,134 @@ +"use client"; + +import { FormEvent, useState } from "react"; +// ui +import { Button, EmojiIconPicker, EmojiIconPickerTypes, Input, LayersIcon, TextArea } from "@plane/ui"; +// components +import { Logo } from "@/components/common"; +// helpers +import { convertHexEmojiToDecimal } from "@/helpers/emoji.helper"; +// plane web types +import { TIssueType } from "@/plane-web/types"; + +type Props = { + formData: Partial; + isSubmitting: boolean; + handleFormDataChange: (key: T, value: TIssueType[T]) => void; + handleModalClose: () => void; + handleFormSubmit: () => Promise; +}; + +export const CreateOrUpdateIssueTypeForm: React.FC = (props) => { + const { formData, isSubmitting, handleFormDataChange, handleModalClose, handleFormSubmit } = props; + // state + const [isEmojiPickerOpen, setIsEmojiPickerOpen] = useState(false); + const [errors, setErrors] = useState<{ name?: string }>({}); + + const validateForm = (data: Partial) => { + const newErrors: { name?: string } = {}; + if (!data.name || data.name.trim() === "") { + newErrors.name = "Name is required"; + } + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }; + + const handleIssueTypeFormSubmit = async (e: FormEvent) => { + e.preventDefault(); + if (validateForm(formData)) { + await handleFormSubmit(); + } + }; + + const handleNameChange = (value: string) => { + handleFormDataChange("name", value); + validateForm({ ...formData, name: value }); + }; + + return ( +
+
+

{formData.id ? "Update" : "Create"} Issue type

+
+ setIsEmojiPickerOpen(val)} + className="flex items-center justify-center flex-shrink0" + buttonClassName="flex items-center justify-center" + label={ + + <> + {formData?.logo_props?.in_use ? ( + + ) : ( + + )} + + + } + onChange={(val) => { + let logoValue = {}; + if (val?.type === "emoji") + logoValue = { + value: convertHexEmojiToDecimal(val.value.unified), + url: val.value.imageUrl, + }; + else if (val?.type === "icon") logoValue = val.value; + handleFormDataChange("logo_props", { + in_use: val?.type, + [val?.type]: logoValue, + }); + setIsEmojiPickerOpen(false); + }} + defaultIconColor={ + formData?.logo_props?.in_use && formData?.logo_props?.in_use === "icon" + ? formData?.logo_props?.icon?.color + : undefined + } + defaultOpen={ + formData?.logo_props?.in_use && formData?.logo_props?.in_use === "emoji" + ? EmojiIconPickerTypes.EMOJI + : EmojiIconPickerTypes.ICON + } + closeOnSelect={false} + /> +
+ handleNameChange(e.target.value)} + placeholder="Give this issue type a unique name" + className="w-full resize-none text-base" + hasError={Boolean(errors.name)} + tabIndex={1} + autoFocus + /> + {errors.name &&
{errors.name}
} +
+
+
+