mirror of
https://github.com/makeplane/plane.git
synced 2026-07-12 13:29:56 +02:00
[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 <nikhilschacko@gmail.com> Co-authored-by: Satish Gandham <satish.iitg@gmail.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
435
apiserver/plane/ee/bgtasks/issue_property_activity_task.py
Normal file
435
apiserver/plane/ee/bgtasks/issue_property_activity_task.py
Normal file
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
61
apiserver/plane/ee/serializers/app/issue_property.py
Normal file
61
apiserver/plane/ee/serializers/app/issue_property.py
Normal file
@@ -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",
|
||||
]
|
||||
@@ -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,
|
||||
]
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# Django imports
|
||||
from django.urls import path
|
||||
|
||||
# Module imports
|
||||
from plane.ee.views.app import RephraseGrammarEndpoint
|
||||
|
||||
|
||||
|
||||
87
apiserver/plane/ee/urls/app/issue_property.py
Normal file
87
apiserver/plane/ee/urls/app/issue_property.py
Normal file
@@ -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/<str:slug>/projects/<uuid:project_id>/issue-types/",
|
||||
IssueTypeEndpoint.as_view(),
|
||||
name="issue-types",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issue-types/<uuid:pk>/",
|
||||
IssueTypeEndpoint.as_view(),
|
||||
name="issue-types",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/default-issue-types/",
|
||||
DefaultIssueTypeEndpoint.as_view(),
|
||||
name="default-issue-types",
|
||||
),
|
||||
## Issue type
|
||||
# Issue properties
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issue-properties/",
|
||||
IssuePropertyEndpoint.as_view(),
|
||||
name="issue-properties",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issue-properties/<uuid:pk>/",
|
||||
IssuePropertyEndpoint.as_view(),
|
||||
name="issue-properties",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issue-types/<uuid:issue_type_id>/issue-properties/",
|
||||
IssuePropertyEndpoint.as_view(),
|
||||
name="issue-properties",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issue-types/<uuid:issue_type_id>/issue-properties/<uuid:pk>/",
|
||||
IssuePropertyEndpoint.as_view(),
|
||||
name="issue-properties",
|
||||
),
|
||||
# End of issue properties
|
||||
# Issue property options
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issue-property-options/",
|
||||
IssuePropertyOptionEndpoint.as_view(),
|
||||
name="issue-property-options",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issue-properties/<uuid:issue_property_id>/options/",
|
||||
IssuePropertyOptionEndpoint.as_view(),
|
||||
name="issue-property-options",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issue-properties/<uuid:issue_property_id>/options/<uuid:pk>/",
|
||||
IssuePropertyOptionEndpoint.as_view(),
|
||||
name="issue-property-options",
|
||||
),
|
||||
# Issue property values
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/values/",
|
||||
IssuePropertyValueEndpoint.as_view(),
|
||||
name="issue-property-values",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/values/<uuid:pk>/",
|
||||
IssuePropertyValueEndpoint.as_view(),
|
||||
name="issue-property-values",
|
||||
),
|
||||
## Issue property value
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/property-activity/",
|
||||
IssuePropertyActivityEndpoint.as_view(),
|
||||
name="issue-property-activity",
|
||||
),
|
||||
]
|
||||
0
apiserver/plane/ee/utils/__init__.py
Normal file
0
apiserver/plane/ee/utils/__init__.py
Normal file
403
apiserver/plane/ee/utils/issue_property_validators.py
Normal file
403
apiserver/plane/ee/utils/issue_property_validators.py
Normal file
@@ -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
|
||||
@@ -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,
|
||||
|
||||
5
apiserver/plane/ee/views/app/issue_property/__init__.py
Normal file
5
apiserver/plane/ee/views/app/issue_property/__init__.py
Normal file
@@ -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
|
||||
33
apiserver/plane/ee/views/app/issue_property/activity.py
Normal file
33
apiserver/plane/ee/views/app/issue_property/activity.py
Normal file
@@ -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)
|
||||
124
apiserver/plane/ee/views/app/issue_property/base.py
Normal file
124
apiserver/plane/ee/views/app/issue_property/base.py
Normal file
@@ -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)
|
||||
187
apiserver/plane/ee/views/app/issue_property/option.py
Normal file
187
apiserver/plane/ee/views/app/issue_property/option.py
Normal file
@@ -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)
|
||||
156
apiserver/plane/ee/views/app/issue_property/type.py
Normal file
156
apiserver/plane/ee/views/app/issue_property/type.py
Normal file
@@ -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)
|
||||
203
apiserver/plane/ee/views/app/issue_property/value.py
Normal file
203
apiserver/plane/ee/views/app/issue_property/value.py
Normal file
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
9
packages/types/src/issues/base.d.ts
vendored
9
packages/types/src/issues/base.d.ts
vendored
@@ -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[];
|
||||
export type TUnGroupedIssues = string[];
|
||||
|
||||
23
packages/types/src/issues/issue.d.ts
vendored
23
packages/types/src/issues/issue.d.ts
vendored
@@ -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;
|
||||
|
||||
14
packages/types/src/project/projects.d.ts
vendored
14
packages/types/src/project/projects.d.ts
vendored
@@ -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 = {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Disclosure, Transition } from "@headlessui/react";
|
||||
export type TCollapsibleProps = {
|
||||
title: string | React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
buttonRef?: React.RefObject<HTMLButtonElement>;
|
||||
className?: string;
|
||||
buttonClassName?: string;
|
||||
isOpen?: boolean;
|
||||
@@ -12,7 +13,7 @@ export type TCollapsibleProps = {
|
||||
};
|
||||
|
||||
export const Collapsible: FC<TCollapsibleProps> = (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<boolean>(isOpen || defaultOpen ? true : false);
|
||||
|
||||
@@ -33,7 +34,7 @@ export const Collapsible: FC<TCollapsibleProps> = (props) => {
|
||||
|
||||
return (
|
||||
<Disclosure as="div" className={className}>
|
||||
<Disclosure.Button className={buttonClassName} onClick={handleOnClick}>
|
||||
<Disclosure.Button ref={buttonRef} className={buttonClassName} onClick={handleOnClick}>
|
||||
{title}
|
||||
</Disclosure.Button>
|
||||
<Transition
|
||||
|
||||
@@ -15,6 +15,7 @@ export const CustomSearchSelect = (props: ICustomSearchSelectProps) => {
|
||||
customButtonClassName = "",
|
||||
buttonClassName = "",
|
||||
className = "",
|
||||
chevronClassName = "",
|
||||
customButton,
|
||||
placement,
|
||||
disabled = false,
|
||||
@@ -90,11 +91,10 @@ export const CustomSearchSelect = (props: ICustomSearchSelectProps) => {
|
||||
<button
|
||||
ref={setReferenceElement}
|
||||
type="button"
|
||||
className={`flex w-full items-center justify-between gap-1 text-xs ${
|
||||
disabled
|
||||
? "cursor-not-allowed text-custom-text-200"
|
||||
: "cursor-pointer hover:bg-custom-background-80"
|
||||
} ${customButtonClassName}`}
|
||||
className={`flex w-full items-center justify-between gap-1 text-xs ${disabled
|
||||
? "cursor-not-allowed text-custom-text-200"
|
||||
: "cursor-pointer hover:bg-custom-background-80"
|
||||
} ${customButtonClassName}`}
|
||||
onClick={toggleDropdown}
|
||||
>
|
||||
{customButton}
|
||||
@@ -105,17 +105,22 @@ export const CustomSearchSelect = (props: ICustomSearchSelectProps) => {
|
||||
<button
|
||||
ref={setReferenceElement}
|
||||
type="button"
|
||||
className={`flex w-full items-center justify-between gap-1 rounded border-[0.5px] border-custom-border-300 ${
|
||||
input ? "px-3 py-2 text-sm" : "px-2 py-1 text-xs"
|
||||
} ${
|
||||
disabled
|
||||
? "cursor-not-allowed text-custom-text-200"
|
||||
: "cursor-pointer hover:bg-custom-background-80"
|
||||
} ${buttonClassName}`}
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between gap-1 rounded border-[0.5px] border-custom-border-300",
|
||||
{
|
||||
"px-3 py-2 text-sm": input,
|
||||
"px-2 py-1 text-xs": !input,
|
||||
"cursor-not-allowed text-custom-text-200": disabled,
|
||||
"cursor-pointer hover:bg-custom-background-80": !disabled,
|
||||
},
|
||||
buttonClassName
|
||||
)}
|
||||
onClick={toggleDropdown}
|
||||
>
|
||||
{label}
|
||||
{!noChevron && !disabled && <ChevronDown className="h-3 w-3 flex-shrink-0" aria-hidden="true" />}
|
||||
{!noChevron && !disabled && (
|
||||
<ChevronDown className={cn("h-3 w-3 flex-shrink-0", chevronClassName)} aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
</Combobox.Button>
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 },
|
||||
];
|
||||
|
||||
@@ -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> = (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> = (props) => {
|
||||
{lucideIcon && (
|
||||
<lucideIcon.element
|
||||
style={{
|
||||
color: color,
|
||||
color: !customColor ? color : undefined,
|
||||
height: size,
|
||||
width: size,
|
||||
}}
|
||||
className={cn(customColor)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span
|
||||
className="material-symbols-rounded"
|
||||
className={cn("material-symbols-rounded", customColor)}
|
||||
style={{
|
||||
fontSize: size,
|
||||
color: color,
|
||||
color: !customColor ? color : undefined,
|
||||
scale: "115%",
|
||||
}}
|
||||
>
|
||||
|
||||
83
packages/ui/src/form-fields/checkbox-select.tsx
Normal file
83
packages/ui/src/form-fields/checkbox-select.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import React from "react";
|
||||
// helpers
|
||||
import { cn } from "../../helpers";
|
||||
// components
|
||||
import { Checkbox } from "./checkbox";
|
||||
|
||||
type CheckboxSelectProps<T> = {
|
||||
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 = <T extends string | number | readonly string[] | undefined>(
|
||||
props: CheckboxSelectProps<T>
|
||||
) => {
|
||||
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 (
|
||||
<div className={className}>
|
||||
{inputLabel && <div className={cn(`mb-2`, labelClassName)}>{inputLabel}</div>}
|
||||
<div className={cn(`${wrapperClass}`, wrapperClassName)}>
|
||||
{options.map(({ key, label, indeterminate, disabled }, index) => (
|
||||
<div
|
||||
key={index}
|
||||
onClick={() => !disabled && setSelected(key)}
|
||||
className={cn(
|
||||
"flex items-center gap-2",
|
||||
disabled ? `bg-custom-background-200 border-custom-border-200 cursor-not-allowed` : ``,
|
||||
fieldClassName
|
||||
)}
|
||||
>
|
||||
<Checkbox
|
||||
id={`${name}_${index}`}
|
||||
name={name}
|
||||
value={key}
|
||||
checked={selected === key}
|
||||
className={inputClassName}
|
||||
iconClassName={iconClassName}
|
||||
containerClassName={containerClassName}
|
||||
indeterminate={indeterminate}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<label htmlFor={`${name}_${index}`} className={cn("text-base cursor-pointer", checkboxLabelClassName)}>
|
||||
{label}
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -2,3 +2,4 @@ export * from "./input";
|
||||
export * from "./textarea";
|
||||
export * from "./input-color-picker";
|
||||
export * from "./checkbox";
|
||||
export * from "./checkbox-select";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<PageHead title={pageTitle} />
|
||||
<div className={`w-full h-full overflow-hidden py-8 pr-4 ${isAdmin ? "" : "pointer-events-none opacity-60"}`}>
|
||||
<IssueTypesRoot />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default IssueTypesSettingsPage;
|
||||
@@ -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();
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export * from "./features";
|
||||
export * from "./tabs";
|
||||
|
||||
82
web/ce/constants/project/settings/tabs.ts
Normal file
82
web/ce/constants/project/settings/tabs.ts
Normal file
@@ -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<Props>;
|
||||
}[] = [
|
||||
PROJECT_SETTINGS["general"],
|
||||
PROJECT_SETTINGS["members"],
|
||||
PROJECT_SETTINGS["features"],
|
||||
PROJECT_SETTINGS["states"],
|
||||
PROJECT_SETTINGS["labels"],
|
||||
PROJECT_SETTINGS["estimates"],
|
||||
PROJECT_SETTINGS["automations"],
|
||||
];
|
||||
@@ -25,6 +25,7 @@ type Props = TDropdownProps & {
|
||||
onClose?: () => void;
|
||||
value: Date | string | null;
|
||||
closeOnSelect?: boolean;
|
||||
formatToken?: string;
|
||||
};
|
||||
|
||||
export const DateDropdown: React.FC<Props> = (props) => {
|
||||
@@ -48,6 +49,7 @@ export const DateDropdown: React.FC<Props> = (props) => {
|
||||
showTooltip = false,
|
||||
tabIndex,
|
||||
value,
|
||||
formatToken,
|
||||
} = props;
|
||||
// states
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
@@ -126,13 +128,15 @@ export const DateDropdown: React.FC<Props> = (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) && (
|
||||
<span className="flex-grow truncate">{value ? renderFormattedDate(value) : placeholder}</span>
|
||||
<span className="flex-grow truncate">
|
||||
{value ? renderFormattedDate(value, formatToken) : placeholder}
|
||||
</span>
|
||||
)}
|
||||
{isClearable && !disabled && isDateSelected && (
|
||||
<X
|
||||
|
||||
@@ -42,6 +42,7 @@ export const MemberDropdown: React.FC<Props> = observer((props) => {
|
||||
placement,
|
||||
projectId,
|
||||
showTooltip = false,
|
||||
showUserDetails = false,
|
||||
tabIndex,
|
||||
value,
|
||||
icon,
|
||||
@@ -75,6 +76,26 @@ export const MemberDropdown: React.FC<Props> = 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 (
|
||||
<Combobox
|
||||
as="div"
|
||||
@@ -110,7 +131,7 @@ export const MemberDropdown: React.FC<Props> = observer((props) => {
|
||||
onClick={handleOnClick}
|
||||
>
|
||||
<DropdownButton
|
||||
className={buttonClassName}
|
||||
className={cn("text-xs", buttonClassName)}
|
||||
isActive={isOpen}
|
||||
tooltipHeading={placeholder}
|
||||
tooltipContent={tooltipContent ?? `${value?.length ?? 0} assignee${value?.length !== 1 ? "s" : ""}`}
|
||||
@@ -119,12 +140,8 @@ export const MemberDropdown: React.FC<Props> = observer((props) => {
|
||||
>
|
||||
{!hideIcon && <ButtonAvatars showTooltip={showTooltip} userIds={value} icon={icon} />}
|
||||
{BUTTON_VARIANTS_WITH_TEXT.includes(buttonVariant) && (
|
||||
<span className="flex-grow truncate text-xs leading-5">
|
||||
{Array.isArray(value) && value.length > 0
|
||||
? value.length === 1
|
||||
? getUserDetails(value[0])?.display_name
|
||||
: ""
|
||||
: placeholder}
|
||||
<span className="flex-grow truncate leading-5">
|
||||
{getDisplayName(value, showUserDetails, placeholder)}
|
||||
</span>
|
||||
)}
|
||||
{dropdownArrow && (
|
||||
|
||||
@@ -7,6 +7,7 @@ export type MemberDropdownProps = TDropdownProps & {
|
||||
placeholder?: string;
|
||||
tooltipContent?: string;
|
||||
onClose?: () => void;
|
||||
showUserDetails?: boolean;
|
||||
} & (
|
||||
| {
|
||||
multiple: false;
|
||||
|
||||
@@ -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 (
|
||||
<div className={className}>
|
||||
<div className={cn(`mb-2`, inputLabelClassName)}>{inputLabel}</div>
|
||||
{inputLabel && <div className={cn(`mb-2`, inputLabelClassName)}>{inputLabel}</div>}
|
||||
<div className={cn(`${wrapperClass}`, inputWrapperClassName)}>
|
||||
{options.map(({ value, label, disabled }, index) => (
|
||||
<div
|
||||
key={index}
|
||||
onClick={() => !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}
|
||||
/>
|
||||
<label htmlFor={`${name}_${index}`} className="text-base cursor-pointer">
|
||||
<label htmlFor={`${name}_${index}`} className="cursor-pointer">
|
||||
{label}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -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<Props> = 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<Props> = 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 (
|
||||
<>
|
||||
<div className="rounded-lg space-y-4">
|
||||
@@ -70,14 +67,8 @@ export const IssueMainContent: React.FC<Props> = observer((props) => {
|
||||
)}
|
||||
|
||||
<div className="mb-2.5 flex items-center">
|
||||
{currentIssueState && (
|
||||
<StateGroupIcon
|
||||
className="mr-3 h-4 w-4"
|
||||
stateGroup={currentIssueState.group}
|
||||
color={currentIssueState.color}
|
||||
/>
|
||||
)}
|
||||
<IssueUpdateStatus isSubmitting={isSubmitting} issueDetail={issue} />
|
||||
<IssueIdentifier issueId={issueId} projectId={issue.project_id} />
|
||||
<IssueUpdateStatus isSubmitting={isSubmitting} />
|
||||
</div>
|
||||
|
||||
<IssueTitleInput
|
||||
|
||||
@@ -22,6 +22,7 @@ import { shouldHighlightIssueDueDate } from "@/helpers/issue.helper";
|
||||
// hooks
|
||||
import { useProjectEstimates, useIssueDetail, useProject, useProjectState, useMember } from "@/hooks/store";
|
||||
// plane web components
|
||||
import { IssueAdditionalPropertyValuesUpdate } from "@/plane-web/components/issue-types/values";
|
||||
import { IssueWorklogProperty } from "@/plane-web/components/issues";
|
||||
// components
|
||||
import type { TIssueOperations } from "./root";
|
||||
@@ -288,6 +289,15 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
|
||||
issueId={issueId}
|
||||
disabled={!isEditable}
|
||||
/>
|
||||
|
||||
{issue.type_id && (
|
||||
<IssueAdditionalPropertyValuesUpdate
|
||||
issueId={issueId}
|
||||
issueTypeId={issue.type_id}
|
||||
projectId={projectId}
|
||||
workspaceSlug={workspaceSlug}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -67,6 +67,7 @@ export const AllIssueQuickActions: React.FC<IQuickActionProps> = observer((props
|
||||
{
|
||||
...issue,
|
||||
name: `${issue.name} (copy)`,
|
||||
sourceIssueId: issue.id,
|
||||
},
|
||||
["id"]
|
||||
);
|
||||
|
||||
@@ -77,6 +77,7 @@ export const CycleIssueQuickActions: React.FC<IQuickActionProps> = observer((pro
|
||||
{
|
||||
...issue,
|
||||
name: `${issue.name} (copy)`,
|
||||
sourceIssueId: issue.id,
|
||||
},
|
||||
["id"]
|
||||
);
|
||||
|
||||
@@ -77,6 +77,7 @@ export const ModuleIssueQuickActions: React.FC<IQuickActionProps> = observer((pr
|
||||
{
|
||||
...issue,
|
||||
name: `${issue.name} (copy)`,
|
||||
sourceIssueId: issue.id,
|
||||
},
|
||||
["id"]
|
||||
);
|
||||
|
||||
@@ -77,6 +77,7 @@ export const ProjectIssueQuickActions: React.FC<IQuickActionProps> = observer((p
|
||||
...issue,
|
||||
name: `${issue.name} (copy)`,
|
||||
is_draft: isDraftIssue ? false : issue.is_draft,
|
||||
sourceIssueId: issue.id,
|
||||
},
|
||||
["id"]
|
||||
);
|
||||
|
||||
@@ -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<TIssue>;
|
||||
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<React.SetStateAction<boolean>>;
|
||||
setSelectedParentIssue: (issue: ISearchIssueResponse) => void;
|
||||
};
|
||||
|
||||
export const IssueDefaultProperties: React.FC<TIssueDefaultPropertiesProps> = 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 (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Controller
|
||||
control={control}
|
||||
name="state_id"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<div className="h-7">
|
||||
<StateDropdown
|
||||
value={value}
|
||||
onChange={(stateId) => {
|
||||
onChange(stateId);
|
||||
handleFormChange();
|
||||
}}
|
||||
projectId={projectId ?? undefined}
|
||||
buttonVariant="border-with-text"
|
||||
tabIndex={getTabIndex("state_id")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="priority"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<div className="h-7">
|
||||
<PriorityDropdown
|
||||
value={value}
|
||||
onChange={(priority) => {
|
||||
onChange(priority);
|
||||
handleFormChange();
|
||||
}}
|
||||
buttonVariant="border-with-text"
|
||||
tabIndex={getTabIndex("priority")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="assignee_ids"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<div className="h-7">
|
||||
<MemberDropdown
|
||||
projectId={projectId ?? undefined}
|
||||
value={value}
|
||||
onChange={(assigneeIds) => {
|
||||
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")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="label_ids"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<div className="h-7">
|
||||
<IssueLabelSelect
|
||||
setIsOpen={setLabelModal}
|
||||
value={value}
|
||||
onChange={(labelIds) => {
|
||||
onChange(labelIds);
|
||||
handleFormChange();
|
||||
}}
|
||||
projectId={projectId ?? undefined}
|
||||
tabIndex={getTabIndex("label_ids")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="start_date"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<div className="h-7">
|
||||
<DateDropdown
|
||||
value={value}
|
||||
onChange={(date) => {
|
||||
onChange(date ? renderFormattedPayloadDate(date) : null);
|
||||
handleFormChange();
|
||||
}}
|
||||
buttonVariant="border-with-text"
|
||||
maxDate={maxDate ?? undefined}
|
||||
placeholder="Start date"
|
||||
tabIndex={getTabIndex("start_date")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="target_date"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<div className="h-7">
|
||||
<DateDropdown
|
||||
value={value}
|
||||
onChange={(date) => {
|
||||
onChange(date ? renderFormattedPayloadDate(date) : null);
|
||||
handleFormChange();
|
||||
}}
|
||||
buttonVariant="border-with-text"
|
||||
minDate={minDate ?? undefined}
|
||||
placeholder="Due date"
|
||||
tabIndex={getTabIndex("target_date")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
{projectDetails?.cycle_view && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="cycle_id"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<div className="h-7">
|
||||
<CycleDropdown
|
||||
projectId={projectId ?? undefined}
|
||||
onChange={(cycleId) => {
|
||||
onChange(cycleId);
|
||||
handleFormChange();
|
||||
}}
|
||||
placeholder="Cycle"
|
||||
value={value}
|
||||
buttonVariant="border-with-text"
|
||||
tabIndex={getTabIndex("cycle_id")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{projectDetails?.module_view && workspaceSlug && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="module_ids"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<div className="h-7">
|
||||
<ModuleDropdown
|
||||
projectId={projectId ?? undefined}
|
||||
value={value ?? []}
|
||||
onChange={(moduleIds) => {
|
||||
onChange(moduleIds);
|
||||
handleFormChange();
|
||||
}}
|
||||
placeholder="Modules"
|
||||
buttonVariant="border-with-text"
|
||||
tabIndex={getTabIndex("module_ids")}
|
||||
multiple
|
||||
showCount
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{projectId && areEstimateEnabledByProjectId(projectId) && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="estimate_point"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<div className="h-7">
|
||||
<EstimateDropdown
|
||||
value={value || undefined}
|
||||
onChange={(estimatePoint) => {
|
||||
onChange(estimatePoint);
|
||||
handleFormChange();
|
||||
}}
|
||||
projectId={projectId}
|
||||
buttonVariant="border-with-text"
|
||||
tabIndex={getTabIndex("estimate_point")}
|
||||
placeholder="Estimate"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{parentId ? (
|
||||
<CustomMenu
|
||||
customButton={
|
||||
<button
|
||||
type="button"
|
||||
className="flex cursor-pointer items-center justify-between gap-1 rounded border-[0.5px] border-custom-border-300 px-2 py-1.5 text-xs hover:bg-custom-background-80"
|
||||
>
|
||||
<LayoutPanelTop className="h-3 w-3 flex-shrink-0" />
|
||||
<span className="whitespace-nowrap">
|
||||
{selectedParentIssue && `${selectedParentIssue.project__identifier}-${selectedParentIssue.sequence_id}`}
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
placement="bottom-start"
|
||||
tabIndex={getTabIndex("parent_id")}
|
||||
>
|
||||
<>
|
||||
<CustomMenu.MenuItem className="!p-1" onClick={() => setParentIssueListModalOpen(true)}>
|
||||
Change parent issue
|
||||
</CustomMenu.MenuItem>
|
||||
<Controller
|
||||
control={control}
|
||||
name="parent_id"
|
||||
render={({ field: { onChange } }) => (
|
||||
<CustomMenu.MenuItem
|
||||
className="!p-1"
|
||||
onClick={() => {
|
||||
onChange(null);
|
||||
handleFormChange();
|
||||
}}
|
||||
>
|
||||
Remove parent issue
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
</CustomMenu>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="flex cursor-pointer items-center justify-between gap-1 rounded border-[0.5px] border-custom-border-300 px-2 py-1.5 text-xs hover:bg-custom-background-80"
|
||||
onClick={() => setParentIssueListModalOpen(true)}
|
||||
>
|
||||
<LayoutPanelTop className="h-3 w-3 flex-shrink-0" />
|
||||
<span className="whitespace-nowrap">Add parent</span>
|
||||
</button>
|
||||
)}
|
||||
<Controller
|
||||
control={control}
|
||||
name="parent_id"
|
||||
render={({ field: { onChange } }) => (
|
||||
<ParentIssuesListModal
|
||||
isOpen={parentIssueListModalOpen}
|
||||
handleClose={() => setParentIssueListModalOpen(false)}
|
||||
onChange={(issue) => {
|
||||
onChange(issue.id);
|
||||
handleFormChange();
|
||||
setSelectedParentIssue(issue);
|
||||
}}
|
||||
projectId={projectId ?? undefined}
|
||||
issueId={isDraft ? undefined : id}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -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<TIssue>;
|
||||
issueName: string;
|
||||
descriptionHtmlData: string | undefined;
|
||||
editorRef: React.MutableRefObject<EditorRefApi | null>;
|
||||
submitBtnRef: React.MutableRefObject<HTMLButtonElement | null>;
|
||||
gptAssistantModal: boolean;
|
||||
workspaceSlug: string;
|
||||
projectId: string | null;
|
||||
handleFormChange: () => void;
|
||||
handleDescriptionHTMLDataChange: (descriptionHtmlData: string) => void;
|
||||
setGptAssistantModal: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
handleGptAssistantClose: () => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
// services
|
||||
const aiService = new AIService();
|
||||
|
||||
export const IssueDescriptionEditor: React.FC<TIssueDescriptionEditorProps> = 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 (
|
||||
<div className="border-[0.5px] border-custom-border-200 rounded-lg relative">
|
||||
{descriptionHtmlData === undefined || !projectId ? (
|
||||
<Loader className="min-h-[150px] max-h-64 space-y-2 overflow-hidden rounded-md border border-custom-border-200 p-3 py-2 pt-3">
|
||||
<Loader.Item width="100%" height="26px" />
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader.Item width="26px" height="26px" />
|
||||
<Loader.Item width="400px" height="26px" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader.Item width="26px" height="26px" />
|
||||
<Loader.Item width="400px" height="26px" />
|
||||
</div>
|
||||
<Loader.Item width="80%" height="26px" />
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader.Item width="50%" height="26px" />
|
||||
</div>
|
||||
<div className="border-0.5 absolute bottom-2 right-3.5 z-10 flex items-center gap-2">
|
||||
<Loader.Item width="100px" height="26px" />
|
||||
<Loader.Item width="50px" height="26px" />
|
||||
</div>
|
||||
</Loader>
|
||||
) : (
|
||||
<>
|
||||
<Controller
|
||||
name="description_html"
|
||||
control={control}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<RichTextEditor
|
||||
id="issue-modal-editor"
|
||||
initialValue={value ?? ""}
|
||||
value={descriptionHtmlData}
|
||||
workspaceSlug={workspaceSlug?.toString() as string}
|
||||
workspaceId={workspaceId}
|
||||
projectId={projectId}
|
||||
onChange={(_description: object, description_html: string) => {
|
||||
onChange(description_html);
|
||||
handleFormChange();
|
||||
}}
|
||||
onEnterKeyPress={() => submitBtnRef?.current?.click()}
|
||||
ref={editorRef}
|
||||
tabIndex={getTabIndex("description_html")}
|
||||
placeholder={getDescriptionPlaceholder}
|
||||
containerClassName="pt-3 min-h-[150px]"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<div className="border-0.5 z-10 flex items-center justify-end gap-2 p-3">
|
||||
{issueName && issueName.trim() !== "" && config?.has_openai_configured && (
|
||||
<button
|
||||
type="button"
|
||||
className={`flex items-center gap-1 rounded bg-custom-background-90 hover:bg-custom-background-80 px-1.5 py-1 text-xs ${iAmFeelingLucky ? "cursor-wait" : ""
|
||||
}`}
|
||||
onClick={handleAutoGenerateDescription}
|
||||
disabled={iAmFeelingLucky}
|
||||
tabIndex={getTabIndex("feeling_lucky")}
|
||||
>
|
||||
{iAmFeelingLucky ? (
|
||||
"Generating response"
|
||||
) : (
|
||||
<>
|
||||
<Sparkle className="h-3.5 w-3.5" />I{"'"}m feeling lucky
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{config?.has_openai_configured && projectId && (
|
||||
<GptAssistantPopover
|
||||
isOpen={gptAssistantModal}
|
||||
handleClose={() => {
|
||||
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={
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 rounded px-1.5 py-1 text-xs bg-custom-background-90 hover:bg-custom-background-80"
|
||||
onClick={() => setGptAssistantModal((prevData) => !prevData)}
|
||||
tabIndex={getTabIndex("ai_assistant")}
|
||||
>
|
||||
<Sparkle className="h-4 w-4" />
|
||||
AI
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from "./project-select";
|
||||
export * from "./parent-tag";
|
||||
export * from "./title-input";
|
||||
export * from "./description-editor";
|
||||
export * from "./default-properties";
|
||||
@@ -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<TIssue>;
|
||||
selectedParentIssue: ISearchIssueResponse;
|
||||
handleFormChange: () => void;
|
||||
setSelectedParentIssue: (issue: ISearchIssueResponse | null) => void;
|
||||
};
|
||||
|
||||
export const IssueParentTag: React.FC<TIssueParentTagProps> = observer((props) => {
|
||||
const { control, selectedParentIssue, handleFormChange, setSelectedParentIssue } = props;
|
||||
|
||||
return (
|
||||
<Controller
|
||||
control={control}
|
||||
name="parent_id"
|
||||
render={({ field: { onChange } }) => (
|
||||
<div className="flex w-min items-center gap-2 whitespace-nowrap rounded bg-custom-background-90 p-2 text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="block h-1.5 w-1.5 rounded-full"
|
||||
style={{
|
||||
backgroundColor: selectedParentIssue.state__color,
|
||||
}}
|
||||
/>
|
||||
<span className="flex-shrink-0 text-custom-text-200">
|
||||
{selectedParentIssue.project__identifier}-{selectedParentIssue.sequence_id}
|
||||
</span>
|
||||
<span className="truncate font-medium">{selectedParentIssue.name.substring(0, 50)}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="grid place-items-center"
|
||||
onClick={() => {
|
||||
onChange(null);
|
||||
handleFormChange();
|
||||
setSelectedParentIssue(null);
|
||||
}}
|
||||
tabIndex={getTabIndex("remove_parent")}
|
||||
>
|
||||
<X className="h-3 w-3 cursor-pointer" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -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<TIssue>;
|
||||
handleFormChange: () => void;
|
||||
};
|
||||
|
||||
export const IssueProjectSelect: React.FC<TIssueProjectSelectProps> = observer((props) => {
|
||||
const { control, handleFormChange } = props;
|
||||
// store hooks
|
||||
const { projectsWithCreatePermissions } = useUser();
|
||||
|
||||
return (
|
||||
<Controller
|
||||
control={control}
|
||||
name="project_id"
|
||||
rules={{
|
||||
required: true,
|
||||
}}
|
||||
render={({ field: { value, onChange } }) =>
|
||||
projectsWithCreatePermissions && projectsWithCreatePermissions[value!] ? (
|
||||
<div className="h-7">
|
||||
<ProjectDropdown
|
||||
value={value}
|
||||
onChange={(projectId) => {
|
||||
onChange(projectId);
|
||||
handleFormChange();
|
||||
}}
|
||||
buttonVariant="border-with-text"
|
||||
renderCondition={(project) => shouldRenderProject(project)}
|
||||
tabIndex={getTabIndex("project_id")}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<></>
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -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<TIssue>;
|
||||
issueTitleRef: React.MutableRefObject<HTMLInputElement | null>;
|
||||
errors: FieldErrors<TIssue>;
|
||||
handleFormChange: () => void;
|
||||
};
|
||||
|
||||
export const IssueTitleInput: React.FC<TIssueTitleInputProps> = observer((props) => {
|
||||
const { control, issueTitleRef, errors, handleFormChange } = props;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Controller
|
||||
control={control}
|
||||
name="name"
|
||||
rules={{
|
||||
required: "Title is required",
|
||||
maxLength: {
|
||||
value: 255,
|
||||
message: "Title should be less than 255 characters",
|
||||
},
|
||||
}}
|
||||
render={({ field: { value, onChange, ref } }) => (
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
onChange(e.target.value);
|
||||
handleFormChange();
|
||||
}}
|
||||
ref={issueTitleRef || ref}
|
||||
hasError={Boolean(errors.name)}
|
||||
placeholder="Title"
|
||||
className="w-full text-base"
|
||||
tabIndex={getTabIndex("name")}
|
||||
autoFocus
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<span className="text-xs text-red-500">{errors?.name?.message}</span>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -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<TIssue> = {
|
||||
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<TIssue>;
|
||||
issueTitleRef: React.MutableRefObject<HTMLInputElement | null>;
|
||||
isCreateMoreToggleEnabled: boolean;
|
||||
onCreateMoreToggleChange: (value: boolean) => void;
|
||||
onChange?: (formData: Partial<TIssue> | null) => void;
|
||||
onClose: () => void;
|
||||
onSubmit: (values: Partial<TIssue>, is_draft_issue?: boolean) => Promise<void>;
|
||||
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<IssueFormProps> = 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<ISearchIssueResponse | null>(null);
|
||||
const [gptAssistantModal, setGptAssistantModal] = useState(false);
|
||||
const [iAmFeelingLucky, setIAmFeelingLucky] = useState(false);
|
||||
// refs
|
||||
const editorRef = useRef<EditorRefApi>(null);
|
||||
const submitBtnRef = useRef<HTMLButtonElement | null>(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<TIssue>({
|
||||
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<TIssue>, 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 ?? "<p></p>",
|
||||
};
|
||||
|
||||
// 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 ?? "<p></p>",
|
||||
});
|
||||
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") !== "<p></p>");
|
||||
|
||||
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 && (
|
||||
<CreateLabelModal
|
||||
isOpen={labelModal}
|
||||
handleClose={() => setLabelModal(false)}
|
||||
projectId={projectId}
|
||||
onSuccess={(response) => {
|
||||
setValue<"label_ids">("label_ids", [...watch("label_ids"), response.id]);
|
||||
handleFormChange();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<form onSubmit={handleSubmit((data) => handleFormSubmit(data))}>
|
||||
<div className="space-y-5 p-5">
|
||||
<div className="flex items-center gap-x-3">
|
||||
{/* Don't show project selection if editing an issue */}
|
||||
{!data?.id && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="project_id"
|
||||
rules={{
|
||||
required: true,
|
||||
}}
|
||||
render={({ field: { value, onChange } }) =>
|
||||
projectsWithCreatePermissions && projectsWithCreatePermissions[value!] ? (
|
||||
<div className="h-7">
|
||||
<ProjectDropdown
|
||||
value={value}
|
||||
onChange={(projectId) => {
|
||||
onChange(projectId);
|
||||
handleFormChange();
|
||||
}}
|
||||
buttonVariant="border-with-text"
|
||||
renderCondition={(project) => shouldRenderProject(project)}
|
||||
tabIndex={getTabIndex("project_id")}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<></>
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<h3 className="text-xl font-medium text-custom-text-200">{data?.id ? "Update" : "Create"} Issue</h3>
|
||||
</div>
|
||||
{watch("parent_id") && selectedParentIssue && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="parent_id"
|
||||
render={({ field: { onChange } }) => (
|
||||
<div className="flex w-min items-center gap-2 whitespace-nowrap rounded bg-custom-background-90 p-2 text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="block h-1.5 w-1.5 rounded-full"
|
||||
style={{
|
||||
backgroundColor: selectedParentIssue.state__color,
|
||||
}}
|
||||
/>
|
||||
<span className="flex-shrink-0 text-custom-text-200">
|
||||
{selectedParentIssue.project__identifier}-{selectedParentIssue.sequence_id}
|
||||
</span>
|
||||
<span className="truncate font-medium">{selectedParentIssue.name.substring(0, 50)}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="grid place-items-center"
|
||||
onClick={() => {
|
||||
onChange(null);
|
||||
handleFormChange();
|
||||
setSelectedParentIssue(null);
|
||||
}}
|
||||
tabIndex={getTabIndex("remove_parent")}
|
||||
>
|
||||
<X className="h-3 w-3 cursor-pointer" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<Controller
|
||||
control={control}
|
||||
name="name"
|
||||
rules={{
|
||||
required: "Title is required",
|
||||
maxLength: {
|
||||
value: 255,
|
||||
message: "Title should be less than 255 characters",
|
||||
},
|
||||
}}
|
||||
render={({ field: { value, onChange, ref } }) => (
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
onChange(e.target.value);
|
||||
handleFormChange();
|
||||
}}
|
||||
ref={issueTitleRef || ref}
|
||||
hasError={Boolean(errors.name)}
|
||||
placeholder="Title"
|
||||
className="w-full text-base"
|
||||
tabIndex={getTabIndex("name")}
|
||||
autoFocus
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<span className="text-xs text-red-500">{errors?.name?.message}</span>
|
||||
</div>
|
||||
<div className="border-[0.5px] border-custom-border-200 rounded-lg relative">
|
||||
{data?.description_html === undefined || !projectId ? (
|
||||
<Loader className="min-h-[150px] max-h-64 space-y-2 overflow-hidden rounded-md border border-custom-border-200 p-3 py-2 pt-3">
|
||||
<Loader.Item width="100%" height="26px" />
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader.Item width="26px" height="26px" />
|
||||
<Loader.Item width="400px" height="26px" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader.Item width="26px" height="26px" />
|
||||
<Loader.Item width="400px" height="26px" />
|
||||
</div>
|
||||
<Loader.Item width="80%" height="26px" />
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader.Item width="50%" height="26px" />
|
||||
</div>
|
||||
<div className="border-0.5 absolute bottom-2 right-3.5 z-10 flex items-center gap-2">
|
||||
<Loader.Item width="100px" height="26px" />
|
||||
<Loader.Item width="50px" height="26px" />
|
||||
</div>
|
||||
</Loader>
|
||||
) : (
|
||||
<>
|
||||
<Controller
|
||||
name="description_html"
|
||||
control={control}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<RichTextEditor
|
||||
id="issue-modal-editor"
|
||||
initialValue={value ?? ""}
|
||||
value={data.description_html}
|
||||
workspaceSlug={workspaceSlug?.toString() as string}
|
||||
workspaceId={workspaceId}
|
||||
projectId={projectId}
|
||||
onChange={(_description: object, description_html: string) => {
|
||||
onChange(description_html);
|
||||
handleFormChange();
|
||||
}}
|
||||
onEnterKeyPress={() => submitBtnRef?.current?.click()}
|
||||
ref={editorRef}
|
||||
tabIndex={getTabIndex("description_html")}
|
||||
placeholder={getDescriptionPlaceholder}
|
||||
containerClassName="pt-3 min-h-[150px]"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<div className="border-0.5 z-10 flex items-center justify-end gap-2 p-3">
|
||||
{issueName && issueName.trim() !== "" && config?.has_openai_configured && (
|
||||
<button
|
||||
type="button"
|
||||
className={`flex items-center gap-1 rounded bg-custom-background-90 hover:bg-custom-background-80 px-1.5 py-1 text-xs ${
|
||||
iAmFeelingLucky ? "cursor-wait" : ""
|
||||
}`}
|
||||
onClick={handleAutoGenerateDescription}
|
||||
disabled={iAmFeelingLucky}
|
||||
tabIndex={getTabIndex("feeling_lucky")}
|
||||
>
|
||||
{iAmFeelingLucky ? (
|
||||
"Generating response"
|
||||
) : (
|
||||
<>
|
||||
<Sparkle className="h-3.5 w-3.5" />I{"'"}m feeling lucky
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{config?.has_openai_configured && projectId && (
|
||||
<GptAssistantPopover
|
||||
isOpen={gptAssistantModal}
|
||||
handleClose={() => {
|
||||
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={
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 rounded px-1.5 py-1 text-xs bg-custom-background-90 hover:bg-custom-background-80"
|
||||
onClick={() => setGptAssistantModal((prevData) => !prevData)}
|
||||
tabIndex={getTabIndex("ai_assistant")}
|
||||
>
|
||||
<Sparkle className="h-4 w-4" />
|
||||
AI
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Controller
|
||||
control={control}
|
||||
name="state_id"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<div className="h-7">
|
||||
<StateDropdown
|
||||
value={value}
|
||||
onChange={(stateId) => {
|
||||
onChange(stateId);
|
||||
handleFormChange();
|
||||
}}
|
||||
projectId={projectId ?? undefined}
|
||||
buttonVariant="border-with-text"
|
||||
tabIndex={getTabIndex("state_id")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="priority"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<div className="h-7">
|
||||
<PriorityDropdown
|
||||
value={value}
|
||||
onChange={(priority) => {
|
||||
onChange(priority);
|
||||
handleFormChange();
|
||||
}}
|
||||
buttonVariant="border-with-text"
|
||||
tabIndex={getTabIndex("priority")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="assignee_ids"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<div className="h-7">
|
||||
<MemberDropdown
|
||||
projectId={projectId ?? undefined}
|
||||
value={value}
|
||||
onChange={(assigneeIds) => {
|
||||
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")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="label_ids"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<div className="h-7">
|
||||
<IssueLabelSelect
|
||||
setIsOpen={setLabelModal}
|
||||
value={value}
|
||||
onChange={(labelIds) => {
|
||||
onChange(labelIds);
|
||||
handleFormChange();
|
||||
}}
|
||||
projectId={projectId ?? undefined}
|
||||
tabIndex={getTabIndex("label_ids")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="start_date"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<div className="h-7">
|
||||
<DateDropdown
|
||||
value={value}
|
||||
onChange={(date) => {
|
||||
onChange(date ? renderFormattedPayloadDate(date) : null);
|
||||
handleFormChange();
|
||||
}}
|
||||
buttonVariant="border-with-text"
|
||||
maxDate={maxDate ?? undefined}
|
||||
placeholder="Start date"
|
||||
tabIndex={getTabIndex("start_date")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="target_date"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<div className="h-7">
|
||||
<DateDropdown
|
||||
value={value}
|
||||
onChange={(date) => {
|
||||
onChange(date ? renderFormattedPayloadDate(date) : null);
|
||||
handleFormChange();
|
||||
}}
|
||||
buttonVariant="border-with-text"
|
||||
minDate={minDate ?? undefined}
|
||||
placeholder="Due date"
|
||||
tabIndex={getTabIndex("target_date")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
{projectDetails?.cycle_view && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="cycle_id"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<div className="h-7">
|
||||
<CycleDropdown
|
||||
projectId={projectId ?? undefined}
|
||||
onChange={(cycleId) => {
|
||||
onChange(cycleId);
|
||||
handleFormChange();
|
||||
}}
|
||||
placeholder="Cycle"
|
||||
value={value}
|
||||
buttonVariant="border-with-text"
|
||||
tabIndex={getTabIndex("cycle_id")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{projectDetails?.module_view && workspaceSlug && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="module_ids"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<div className="h-7">
|
||||
<ModuleDropdown
|
||||
projectId={projectId ?? undefined}
|
||||
value={value ?? []}
|
||||
onChange={(moduleIds) => {
|
||||
onChange(moduleIds);
|
||||
handleFormChange();
|
||||
}}
|
||||
placeholder="Modules"
|
||||
buttonVariant="border-with-text"
|
||||
tabIndex={getTabIndex("module_ids")}
|
||||
multiple
|
||||
showCount
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{projectId && areEstimateEnabledByProjectId(projectId) && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="estimate_point"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<div className="h-7">
|
||||
<EstimateDropdown
|
||||
value={value || undefined}
|
||||
onChange={(estimatePoint) => {
|
||||
onChange(estimatePoint);
|
||||
handleFormChange();
|
||||
}}
|
||||
projectId={projectId}
|
||||
buttonVariant="border-with-text"
|
||||
tabIndex={getTabIndex("estimate_point")}
|
||||
placeholder="Estimate"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{watch("parent_id") ? (
|
||||
<CustomMenu
|
||||
customButton={
|
||||
<button
|
||||
type="button"
|
||||
className="flex cursor-pointer items-center justify-between gap-1 rounded border-[0.5px] border-custom-border-300 px-2 py-1.5 text-xs hover:bg-custom-background-80"
|
||||
>
|
||||
<LayoutPanelTop className="h-3 w-3 flex-shrink-0" />
|
||||
<span className="whitespace-nowrap">
|
||||
{selectedParentIssue &&
|
||||
`${selectedParentIssue.project__identifier}-${selectedParentIssue.sequence_id}`}
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
placement="bottom-start"
|
||||
tabIndex={getTabIndex("parent_id")}
|
||||
>
|
||||
<>
|
||||
<CustomMenu.MenuItem className="!p-1" onClick={() => setParentIssueListModalOpen(true)}>
|
||||
Change parent issue
|
||||
</CustomMenu.MenuItem>
|
||||
<Controller
|
||||
control={control}
|
||||
name="parent_id"
|
||||
render={({ field: { onChange } }) => (
|
||||
<CustomMenu.MenuItem
|
||||
className="!p-1"
|
||||
onClick={() => {
|
||||
onChange(null);
|
||||
handleFormChange();
|
||||
}}
|
||||
>
|
||||
Remove parent issue
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
</CustomMenu>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="flex cursor-pointer items-center justify-between gap-1 rounded border-[0.5px] border-custom-border-300 px-2 py-1.5 text-xs hover:bg-custom-background-80"
|
||||
onClick={() => setParentIssueListModalOpen(true)}
|
||||
>
|
||||
<LayoutPanelTop className="h-3 w-3 flex-shrink-0" />
|
||||
<span className="whitespace-nowrap">Add parent</span>
|
||||
</button>
|
||||
)}
|
||||
<Controller
|
||||
control={control}
|
||||
name="parent_id"
|
||||
render={({ field: { onChange } }) => (
|
||||
<ParentIssuesListModal
|
||||
isOpen={parentIssueListModalOpen}
|
||||
handleClose={() => setParentIssueListModalOpen(false)}
|
||||
onChange={(issue) => {
|
||||
onChange(issue.id);
|
||||
handleFormChange();
|
||||
setSelectedParentIssue(issue);
|
||||
}}
|
||||
projectId={projectId ?? undefined}
|
||||
issueId={isDraft ? undefined : data?.id}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-5 py-4 flex items-center justify-between gap-2 border-t-[0.5px] border-custom-border-200">
|
||||
<div>
|
||||
{!data?.id && (
|
||||
<div
|
||||
className="inline-flex items-center gap-1.5 cursor-pointer"
|
||||
onClick={() => onCreateMoreToggleChange(!isCreateMoreToggleEnabled)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") onCreateMoreToggleChange(!isCreateMoreToggleEnabled);
|
||||
}}
|
||||
tabIndex={getTabIndex("create_more")}
|
||||
role="button"
|
||||
>
|
||||
<ToggleSwitch value={isCreateMoreToggleEnabled} onChange={() => {}} size="sm" />
|
||||
<span className="text-xs">Create more</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="neutral-primary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (editorRef.current?.isEditorReadyToDiscard()) {
|
||||
onClose();
|
||||
} else {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: "Editor is still processing changes. Please wait before proceeding.",
|
||||
});
|
||||
}
|
||||
}}
|
||||
tabIndex={getTabIndex("discard_button")}
|
||||
>
|
||||
Discard
|
||||
</Button>
|
||||
{isDraft && (
|
||||
<>
|
||||
{data?.id ? (
|
||||
<Button
|
||||
variant="neutral-primary"
|
||||
size="sm"
|
||||
loading={isSubmitting}
|
||||
onClick={handleSubmit((data) => handleFormSubmit({ ...data, is_draft: false }))}
|
||||
tabIndex={getTabIndex("draft_button")}
|
||||
>
|
||||
{isSubmitting ? "Moving" : "Move from draft"}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="neutral-primary"
|
||||
size="sm"
|
||||
loading={isSubmitting}
|
||||
onClick={handleSubmit((data) => handleFormSubmit(data, true))}
|
||||
tabIndex={getTabIndex("draft_button")}
|
||||
>
|
||||
{isSubmitting ? "Saving" : "Save as draft"}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
variant="primary"
|
||||
type="submit"
|
||||
size="sm"
|
||||
ref={submitBtnRef}
|
||||
loading={isSubmitting}
|
||||
tabIndex={isDraft ? getTabIndex("submit_button") : getTabIndex("draft_button")}
|
||||
>
|
||||
{data?.id ? (isSubmitting ? "Updating" : "Update Issue") : isSubmitting ? "Creating" : "Create Issue"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -1,3 +1 @@
|
||||
export * from "./draft-issue-layout";
|
||||
export * from "./form";
|
||||
export * from "./modal";
|
||||
|
||||
@@ -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<TIssue>;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit?: (res: TIssue) => Promise<void>;
|
||||
withDraftIssueWrapper?: boolean;
|
||||
storeType?: EIssuesStoreType;
|
||||
isDraft?: boolean;
|
||||
}
|
||||
|
||||
export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = 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<HTMLInputElement>(null);
|
||||
// states
|
||||
const [changesMade, setChangesMade] = useState<Partial<TIssue> | null>(null);
|
||||
const [createMore, setCreateMore] = useState(false);
|
||||
const [activeProjectId, setActiveProjectId] = useState<string | null>(null);
|
||||
const [description, setDescription] = useState<string | undefined>(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<string, Partial<TIssue>>
|
||||
>("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 || "<p></p>");
|
||||
return;
|
||||
}
|
||||
const response = await fetchIssue(
|
||||
workspaceSlug.toString(),
|
||||
projectId.toString(),
|
||||
issueId,
|
||||
isDraft ? "DRAFT" : "DEFAULT"
|
||||
);
|
||||
if (response) setDescription(response?.description_html || "<p></p>");
|
||||
};
|
||||
|
||||
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<TIssue>,
|
||||
is_draft_issue: boolean = false
|
||||
): Promise<TIssue | undefined> => {
|
||||
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 && (
|
||||
<CreateIssueToastActionItems
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
projectId={response?.project_id}
|
||||
issueId={response.id}
|
||||
/>
|
||||
),
|
||||
});
|
||||
captureIssueEvent({
|
||||
eventName: ISSUE_CREATED,
|
||||
payload: { ...response, state: "SUCCESS" },
|
||||
path: pathname,
|
||||
});
|
||||
!createMore && handleClose();
|
||||
if (createMore) issueTitleRef && issueTitleRef?.current?.focus();
|
||||
setDescription("<p></p>");
|
||||
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<TIssue>): Promise<TIssue | undefined> => {
|
||||
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<TIssue>, 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<TIssue> | null) => setChangesMade(formData);
|
||||
|
||||
// don't open the modal if there are no projects
|
||||
if (!workspaceProjectIds || workspaceProjectIds.length === 0 || !activeProjectId) return null;
|
||||
|
||||
return (
|
||||
<ModalCore
|
||||
isOpen={isOpen}
|
||||
handleClose={() => handleClose(true)}
|
||||
position={EModalPosition.TOP}
|
||||
width={EModalWidth.XXXXL}
|
||||
>
|
||||
{withDraftIssueWrapper ? (
|
||||
<DraftIssueLayout
|
||||
changesMade={changesMade}
|
||||
data={{
|
||||
...data,
|
||||
description_html: description,
|
||||
cycle_id: data?.cycle_id ? data?.cycle_id : cycleId ? cycleId.toString() : null,
|
||||
module_ids: data?.module_ids ? data?.module_ids : moduleId ? [moduleId.toString()] : null,
|
||||
}}
|
||||
issueTitleRef={issueTitleRef}
|
||||
onChange={handleFormChange}
|
||||
onClose={handleClose}
|
||||
onSubmit={handleFormSubmit}
|
||||
projectId={activeProjectId}
|
||||
isCreateMoreToggleEnabled={createMore}
|
||||
onCreateMoreToggleChange={handleCreateMoreToggleChange}
|
||||
isDraft={isDraft}
|
||||
/>
|
||||
) : (
|
||||
<IssueFormRoot
|
||||
issueTitleRef={issueTitleRef}
|
||||
data={{
|
||||
...data,
|
||||
description_html: description,
|
||||
cycle_id: data?.cycle_id ? data?.cycle_id : cycleId ? cycleId.toString() : null,
|
||||
module_ids: data?.module_ids ? data?.module_ids : moduleId ? [moduleId.toString()] : null,
|
||||
}}
|
||||
onClose={() => handleClose(false)}
|
||||
isCreateMoreToggleEnabled={createMore}
|
||||
onCreateMoreToggleChange={handleCreateMoreToggleChange}
|
||||
onSubmit={handleFormSubmit}
|
||||
projectId={activeProjectId}
|
||||
isDraft={isDraft}
|
||||
/>
|
||||
)}
|
||||
</ModalCore>
|
||||
);
|
||||
});
|
||||
export * from "@/plane-web/components/issues/issue-modal/modal";
|
||||
|
||||
@@ -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<Props> = observer((props) => {
|
||||
const { isSubmitting, issueDetail } = props;
|
||||
// hooks
|
||||
const { getProjectById } = useProject();
|
||||
const { isSubmitting } = props;
|
||||
|
||||
return (
|
||||
<>
|
||||
{issueDetail && (
|
||||
<h4 className="mr-4 text-lg font-medium text-custom-text-300">
|
||||
{getProjectById(issueDetail.project_id)?.identifier}-{issueDetail.sequence_id}
|
||||
</h4>
|
||||
)}
|
||||
<div
|
||||
className={`flex items-center gap-x-2 transition-all duration-300 ${
|
||||
isSubmitting === "saved" ? "fade-out" : "fade-in"
|
||||
|
||||
@@ -2,9 +2,11 @@ import { FC, useEffect } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// store hooks
|
||||
import { TIssueOperations } from "@/components/issues";
|
||||
import { useIssueDetail, useProject, useUser } from "@/hooks/store";
|
||||
import { useIssueDetail, useUser } from "@/hooks/store";
|
||||
// hooks
|
||||
import useReloadConfirmations from "@/hooks/use-reload-confirmation";
|
||||
// plane web components
|
||||
import { IssueIdentifier } from "@/plane-web/components/issues";
|
||||
// components
|
||||
import { IssueDescriptionInput } from "../description-input";
|
||||
import { IssueReaction } from "../issue-detail/reactions";
|
||||
@@ -24,7 +26,6 @@ interface IPeekOverviewIssueDetails {
|
||||
export const PeekOverviewIssueDetails: FC<IPeekOverviewIssueDetails> = 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<IPeekOverviewIssueDetails> = 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<IPeekOverviewIssueDetails> = observer(
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<span className="text-base font-medium text-custom-text-400">
|
||||
{projectDetails?.identifier}-{issue?.sequence_id}
|
||||
</span>
|
||||
<IssueIdentifier issueId={issueId} projectId={issue.project_id} />
|
||||
<IssueTitleInput
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={issue.project_id}
|
||||
|
||||
@@ -28,6 +28,7 @@ import { getDate, renderFormattedPayloadDate } from "@/helpers/date-time.helper"
|
||||
import { shouldHighlightIssueDueDate } from "@/helpers/issue.helper";
|
||||
import { useIssueDetail, useMember, useProject, useProjectState } from "@/hooks/store";
|
||||
// plane web components
|
||||
import { IssueAdditionalPropertyValuesUpdate } from "@/plane-web/components/issue-types/values";
|
||||
import { IssueWorklogProperty } from "@/plane-web/components/issues";
|
||||
|
||||
interface IPeekOverviewProperties {
|
||||
@@ -288,6 +289,15 @@ export const PeekOverviewProperties: FC<IPeekOverviewProperties> = observer((pro
|
||||
issueId={issueId}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
{issue.type_id && (
|
||||
<IssueAdditionalPropertyValuesUpdate
|
||||
issueId={issueId}
|
||||
issueTypeId={issue.type_id}
|
||||
projectId={projectId}
|
||||
workspaceSlug={workspaceSlug}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -110,7 +110,7 @@ export const ProjectCard: React.FC<Props> = 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),
|
||||
|
||||
22
web/core/constants/issue-modal.ts
Normal file
22
web/core/constants/issue-modal.ts
Normal file
@@ -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",
|
||||
];
|
||||
@@ -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<Props>;
|
||||
}[] = [
|
||||
{
|
||||
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;
|
||||
|
||||
@@ -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<IProjectAuthWrapper> = 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<IProjectAuthWrapper> = 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
|
||||
|
||||
@@ -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,
|
||||
|
||||
89
web/ee/components/issue-types/create-update/create-modal.tsx
Normal file
89
web/ee/components/issue-types/create-update/create-modal.tsx
Normal file
@@ -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<TIssueType> = {
|
||||
id: undefined,
|
||||
logo_props: {
|
||||
in_use: "icon",
|
||||
icon: {
|
||||
name: getRandomIconName(),
|
||||
color: "#6d7b8a",
|
||||
},
|
||||
},
|
||||
name: "",
|
||||
description: "",
|
||||
};
|
||||
|
||||
export const CreateIssueTypeModal: FC<Props> = (props) => {
|
||||
const { isModalOpen, handleModalClose } = props;
|
||||
// states
|
||||
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
|
||||
const [issueTypeFormData, setIssueTypeFormData] = useState<Partial<TIssueType>>(defaultIssueTypeData);
|
||||
// store hooks
|
||||
const { createType } = useIssueTypes();
|
||||
|
||||
// handlers
|
||||
const handleFormDataChange = <T extends keyof TIssueType>(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 (
|
||||
<ModalCore
|
||||
isOpen={isModalOpen}
|
||||
handleClose={handleModalClearAndClose}
|
||||
position={EModalPosition.CENTER}
|
||||
width={EModalWidth.XXL}
|
||||
>
|
||||
<CreateOrUpdateIssueTypeForm
|
||||
formData={issueTypeFormData}
|
||||
isSubmitting={isSubmitting}
|
||||
handleFormDataChange={handleFormDataChange}
|
||||
handleModalClose={handleModalClearAndClose}
|
||||
handleFormSubmit={handleFormSubmit}
|
||||
/>
|
||||
</ModalCore>
|
||||
);
|
||||
};
|
||||
134
web/ee/components/issue-types/create-update/form.tsx
Normal file
134
web/ee/components/issue-types/create-update/form.tsx
Normal file
@@ -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<TIssueType>;
|
||||
isSubmitting: boolean;
|
||||
handleFormDataChange: <T extends keyof TIssueType>(key: T, value: TIssueType[T]) => void;
|
||||
handleModalClose: () => void;
|
||||
handleFormSubmit: () => Promise<void>;
|
||||
};
|
||||
|
||||
export const CreateOrUpdateIssueTypeForm: React.FC<Props> = (props) => {
|
||||
const { formData, isSubmitting, handleFormDataChange, handleModalClose, handleFormSubmit } = props;
|
||||
// state
|
||||
const [isEmojiPickerOpen, setIsEmojiPickerOpen] = useState(false);
|
||||
const [errors, setErrors] = useState<{ name?: string }>({});
|
||||
|
||||
const validateForm = (data: Partial<TIssueType>) => {
|
||||
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<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
if (validateForm(formData)) {
|
||||
await handleFormSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
const handleNameChange = (value: string) => {
|
||||
handleFormDataChange("name", value);
|
||||
validateForm({ ...formData, name: value });
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleIssueTypeFormSubmit}>
|
||||
<div className="space-y-3 p-5 pb-2">
|
||||
<h3 className="text-xl font-medium text-custom-text-200">{formData.id ? "Update" : "Create"} Issue type</h3>
|
||||
<div className="flex items-start gap-2 w-full">
|
||||
<EmojiIconPicker
|
||||
isOpen={isEmojiPickerOpen}
|
||||
handleToggle={(val: boolean) => setIsEmojiPickerOpen(val)}
|
||||
className="flex items-center justify-center flex-shrink0"
|
||||
buttonClassName="flex items-center justify-center"
|
||||
label={
|
||||
<span className="grid h-10 w-10 place-items-center rounded-md bg-custom-background-80/70">
|
||||
<>
|
||||
{formData?.logo_props?.in_use ? (
|
||||
<Logo logo={formData?.logo_props} size={20} type="lucide" />
|
||||
) : (
|
||||
<LayersIcon className="h-5 w-5 text-custom-text-300" />
|
||||
)}
|
||||
</>
|
||||
</span>
|
||||
}
|
||||
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}
|
||||
/>
|
||||
<div className="space-y-1 flew-grow w-full">
|
||||
<Input
|
||||
id="name"
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => 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 && <div className="text-red-500 text-xs">{errors.name}</div>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<TextArea
|
||||
id="description"
|
||||
name="description"
|
||||
value={formData.description}
|
||||
onChange={(e) => handleFormDataChange("description", e.target.value)}
|
||||
placeholder="Describe what this issue type is meant for and when it’s to be used."
|
||||
className="resize-none min-h-24 text-sm"
|
||||
tabIndex={2}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mx-5 py-3 flex items-center justify-end gap-2 border-t-[0.5px] border-custom-border-100">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button variant="neutral-primary" size="sm" onClick={handleModalClose} tabIndex={3}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="primary" size="sm" type="submit" loading={isSubmitting} tabIndex={4}>
|
||||
{formData.id ? "Update" : "Create"} Issue type
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
3
web/ee/components/issue-types/create-update/index.ts
Normal file
3
web/ee/components/issue-types/create-update/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./form";
|
||||
export * from "./create-modal";
|
||||
export * from "./update-modal";
|
||||
79
web/ee/components/issue-types/create-update/update-modal.tsx
Normal file
79
web/ee/components/issue-types/create-update/update-modal.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import { FC, useEffect, useState } from "react";
|
||||
// ui
|
||||
import { EModalPosition, EModalWidth, ModalCore, setToast, TOAST_TYPE } from "@plane/ui";
|
||||
// plane web components
|
||||
import { CreateOrUpdateIssueTypeForm, defaultIssueTypeData } from "@/plane-web/components/issue-types/";
|
||||
// hooks
|
||||
import { useIssueType } from "@/plane-web/hooks/store";
|
||||
// plane web types
|
||||
import { TIssueType } from "@/plane-web/types";
|
||||
|
||||
type Props = {
|
||||
data: Partial<TIssueType>;
|
||||
isModalOpen: boolean;
|
||||
handleModalClose: () => void;
|
||||
};
|
||||
|
||||
export const UpdateIssueTypeModal: FC<Props> = (props) => {
|
||||
const { data, isModalOpen, handleModalClose } = props;
|
||||
// states
|
||||
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
|
||||
const [issueTypeFormData, setIssueTypeFormData] = useState<Partial<TIssueType> | undefined>(undefined);
|
||||
// store hooks
|
||||
const issueType = useIssueType(data?.id);
|
||||
|
||||
useEffect(() => {
|
||||
if (isModalOpen) {
|
||||
setIssueTypeFormData(data);
|
||||
}
|
||||
}, [data, isModalOpen]);
|
||||
|
||||
// handlers
|
||||
const handleFormDataChange = <T extends keyof TIssueType>(key: T, value: TIssueType[T]) =>
|
||||
setIssueTypeFormData((prev) => ({ ...prev, [key]: value }));
|
||||
|
||||
const handleFormSubmit = async () => {
|
||||
if (!issueTypeFormData) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
await issueType
|
||||
?.updateType(issueTypeFormData)
|
||||
.then(() => {
|
||||
handleModalClose();
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Success!",
|
||||
message: `Issue type ${data?.name} updated successfully.`,
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: `Failed to update issue type. Please try again!`,
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
setIsSubmitting(false);
|
||||
});
|
||||
};
|
||||
|
||||
if (!isModalOpen) return null;
|
||||
|
||||
return (
|
||||
<ModalCore
|
||||
isOpen={isModalOpen}
|
||||
handleClose={handleModalClose}
|
||||
position={EModalPosition.CENTER}
|
||||
width={EModalWidth.XXL}
|
||||
>
|
||||
<CreateOrUpdateIssueTypeForm
|
||||
formData={issueTypeFormData ?? defaultIssueTypeData}
|
||||
isSubmitting={isSubmitting}
|
||||
handleFormDataChange={handleFormDataChange}
|
||||
handleModalClose={handleModalClose}
|
||||
handleFormSubmit={handleFormSubmit}
|
||||
/>
|
||||
</ModalCore>
|
||||
);
|
||||
};
|
||||
1
web/ee/components/issue-types/delete/index.ts
Normal file
1
web/ee/components/issue-types/delete/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./modal";
|
||||
72
web/ee/components/issue-types/delete/modal.tsx
Normal file
72
web/ee/components/issue-types/delete/modal.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// ui
|
||||
import { AlertModalCore, TOAST_TYPE, setToast } from "@plane/ui";
|
||||
// plane web hooks
|
||||
import { useIssueTypes } from "@/plane-web/hooks/store";
|
||||
// plane web types
|
||||
import { TIssueType } from "@/plane-web/types";
|
||||
|
||||
type Props = {
|
||||
data: TIssueType;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export const DeleteIssueTypeModal: React.FC<Props> = observer((props) => {
|
||||
const { data, isOpen, onClose } = props;
|
||||
// states
|
||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||
// store hooks
|
||||
const { deleteType } = useIssueTypes();
|
||||
|
||||
const handleClose = () => {
|
||||
onClose();
|
||||
setIsDeleteLoading(false);
|
||||
};
|
||||
|
||||
const handleDeleteType = async () => {
|
||||
if (!data.id) return;
|
||||
|
||||
setIsDeleteLoading(true);
|
||||
|
||||
await deleteType(data.id)
|
||||
.then(() => {
|
||||
handleClose();
|
||||
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Success!",
|
||||
message: "Issue type deleted successfully.",
|
||||
});
|
||||
})
|
||||
.catch(() =>
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: "Issue type could not be deleted. Please try again.",
|
||||
})
|
||||
)
|
||||
.finally(() => {
|
||||
setIsDeleteLoading(false);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<AlertModalCore
|
||||
handleClose={handleClose}
|
||||
handleSubmit={handleDeleteType}
|
||||
isSubmitting={isDeleteLoading}
|
||||
isOpen={isOpen}
|
||||
title="Delete issue type?"
|
||||
content={
|
||||
<>
|
||||
<p>No issue is yet linked to this type and can be deleted.</p>
|
||||
<p>If deleted this issue type and properties cannot be retrieved.</p>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
});
|
||||
1
web/ee/components/issue-types/dropdowns/index.ts
Normal file
1
web/ee/components/issue-types/dropdowns/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./issue-type";
|
||||
69
web/ee/components/issue-types/dropdowns/issue-type.tsx
Normal file
69
web/ee/components/issue-types/dropdowns/issue-type.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { observer } from "mobx-react";
|
||||
// ui
|
||||
import { CustomSearchSelect, LayersIcon, Loader, Logo } from "@plane/ui";
|
||||
// plane web types
|
||||
import { useIssueTypes } from "@/plane-web/hooks/store";
|
||||
|
||||
type TIssueTypeDropdownProps = {
|
||||
issueTypeId: string | null;
|
||||
projectId: string;
|
||||
handleIssueTypeChange: (value: string) => void;
|
||||
};
|
||||
|
||||
export const IssueTypeDropdown = observer((props: TIssueTypeDropdownProps) => {
|
||||
const { issueTypeId, projectId, handleIssueTypeChange } = props;
|
||||
// store hooks
|
||||
const { getProjectIssueTypeLoader, getProjectActiveIssueTypes } = useIssueTypes();
|
||||
// derived values
|
||||
const issueTypeLoader = getProjectIssueTypeLoader(projectId);
|
||||
const issueTypes = getProjectActiveIssueTypes(projectId);
|
||||
|
||||
const issuePropertyTypeOptions = Object.entries(issueTypes).map(([issueTypeId, issueTypeDetail]) => ({
|
||||
value: issueTypeId,
|
||||
query: issueTypeDetail.name ?? "",
|
||||
content: (
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="flex-shrink-0 grid h-5 w-5 place-items-center rounded bg-custom-background-80">
|
||||
{issueTypeDetail?.logo_props?.in_use ? (
|
||||
<Logo logo={issueTypeDetail.logo_props} size={12} type="lucide" />
|
||||
) : (
|
||||
<LayersIcon className="h-3 w-3 text-custom-text-300" />
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm font-medium text-custom-text-200">{issueTypeDetail.name}</div>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
if (!issueTypeId || issueTypeLoader === "init-loader") {
|
||||
return (
|
||||
<Loader className="w-16 h-full">
|
||||
<Loader.Item height="100%" />
|
||||
</Loader>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<CustomSearchSelect
|
||||
value={issueTypeId}
|
||||
label={
|
||||
<div className="flex gap-1 items-center">
|
||||
<div className="flex-shrink-0 grid h-4 w-4 place-items-center">
|
||||
{issueTypes[issueTypeId]?.logo_props?.in_use ? (
|
||||
<Logo logo={issueTypes[issueTypeId].logo_props} size={12} type="lucide" />
|
||||
) : (
|
||||
<LayersIcon className="h-3 w-3 text-custom-text-300" />
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm font-medium text-custom-text-200">{issueTypes[issueTypeId]?.name}</div>
|
||||
</div>
|
||||
}
|
||||
options={issuePropertyTypeOptions}
|
||||
onChange={handleIssueTypeChange}
|
||||
className="w-full h-full flex"
|
||||
optionsClassName="w-full"
|
||||
buttonClassName="rounded text-sm py-0.5 bg-custom-background-100 border-[0.5px] border-custom-border-300"
|
||||
noChevron
|
||||
/>
|
||||
);
|
||||
});
|
||||
77
web/ee/components/issue-types/empty-state.tsx
Normal file
77
web/ee/components/issue-types/empty-state.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { FC, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Image from "next/image";
|
||||
import { useTheme } from "next-themes";
|
||||
// ui
|
||||
import { Button, setToast, TOAST_TYPE } from "@plane/ui";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
// plane web hooks
|
||||
import { useIssueTypes } from "@/plane-web/hooks/store";
|
||||
|
||||
type TIssueTypeEmptyState = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
};
|
||||
|
||||
export const IssueTypeEmptyState: FC<TIssueTypeEmptyState> = observer((props) => {
|
||||
// props
|
||||
const { workspaceSlug, projectId } = props;
|
||||
// theme
|
||||
const { resolvedTheme } = useTheme();
|
||||
// store hooks
|
||||
const { enableIssueTypes } = useIssueTypes();
|
||||
// states
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
// derived values
|
||||
const resolvedEmptyStatePath = `/empty-state/issue-types/issue-type-${resolvedTheme === "light" ? "light" : "dark"}.svg`;
|
||||
// handlers
|
||||
const handleEnableIssueTypes = async () => {
|
||||
setIsLoading(true);
|
||||
await enableIssueTypes(workspaceSlug, projectId)
|
||||
.then(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Success!",
|
||||
message: "Issue types and custom properties are now enabled for this project",
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: "Failed to enable issue types",
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex justify-center min-h-full overflow-y-auto py-10 px-5">
|
||||
<div className={cn("flex flex-col gap-5 md:min-w-[24rem] max-w-[45rem]")}>
|
||||
<div className="flex flex-col gap-1.5 flex-shrink">
|
||||
<h3 className="text-xl font-semibold">Enable issue types</h3>
|
||||
<p className="text-sm text-custom-text-200">
|
||||
Issue types distinguish different kinds of work in unique ways, helping you to identify, categorize, and
|
||||
report on your team{"’"}s work more effectively.
|
||||
</p>
|
||||
</div>
|
||||
<Image
|
||||
src={resolvedEmptyStatePath}
|
||||
alt="issue type empty state"
|
||||
width={384}
|
||||
height={250}
|
||||
layout="responsive"
|
||||
lazyBoundary="100%"
|
||||
/>
|
||||
<div className="relative flex items-center justify-center gap-2 flex-shrink-0 w-full">
|
||||
<Button disabled={isLoading} onClick={() => handleEnableIssueTypes()}>
|
||||
{isLoading ? "Setting up..." : "Enable"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
1
web/ee/components/issue-types/enable-disable/index.ts
Normal file
1
web/ee/components/issue-types/enable-disable/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./modal";
|
||||
80
web/ee/components/issue-types/enable-disable/modal.tsx
Normal file
80
web/ee/components/issue-types/enable-disable/modal.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// ui
|
||||
import { AlertModalCore, TOAST_TYPE, setToast } from "@plane/ui";
|
||||
// plane web hooks
|
||||
import { useIssueType } from "@/plane-web/hooks/store";
|
||||
|
||||
type Props = {
|
||||
issueTypeId: string;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export const EnableDisableIssueTypeModal: React.FC<Props> = observer((props) => {
|
||||
const { issueTypeId, isOpen, onClose } = props;
|
||||
// states
|
||||
const [isEnableDisableLoading, setIsEnableDisableLoading] = useState(false);
|
||||
// store hooks
|
||||
const issueType = useIssueType(issueTypeId);
|
||||
// derived values
|
||||
const isIssueTypeEnabled = issueType?.is_active;
|
||||
|
||||
const handleClose = () => {
|
||||
onClose();
|
||||
setIsEnableDisableLoading(false);
|
||||
};
|
||||
|
||||
const handleEnableDisable = async () => {
|
||||
if (!issueTypeId) return;
|
||||
setIsEnableDisableLoading(true);
|
||||
await issueType
|
||||
?.updateType({
|
||||
is_active: !isIssueTypeEnabled,
|
||||
})
|
||||
.then(() => {
|
||||
handleClose();
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Success!",
|
||||
message: `Issue type ${isIssueTypeEnabled ? "disabled" : "enabled"} successfully.`,
|
||||
});
|
||||
})
|
||||
.catch(() =>
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: `Issue type could not be ${isIssueTypeEnabled ? "disabled" : "enabled"}. Please try again.`,
|
||||
})
|
||||
)
|
||||
.finally(() => {
|
||||
setIsEnableDisableLoading(false);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<AlertModalCore
|
||||
variant={isIssueTypeEnabled ? "danger" : "primary"}
|
||||
handleClose={handleClose}
|
||||
handleSubmit={handleEnableDisable}
|
||||
isSubmitting={isEnableDisableLoading}
|
||||
isOpen={isOpen}
|
||||
title={`${isIssueTypeEnabled ? "Disable" : "Enable"} issue type?`}
|
||||
primaryButtonText={{
|
||||
loading: "Please wait...",
|
||||
default: `${isIssueTypeEnabled ? "Disable" : "Enable"}`,
|
||||
}}
|
||||
content={
|
||||
<>
|
||||
<p>
|
||||
{isIssueTypeEnabled
|
||||
? "Disabling this issue type will prevent users from creating new issues of this type."
|
||||
: "Enabling this issue type will allow users to create new issues of this type."}
|
||||
</p>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
});
|
||||
10
web/ee/components/issue-types/index.ts
Normal file
10
web/ee/components/issue-types/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export * from "./root";
|
||||
export * from "./create-update";
|
||||
export * from "./enable-disable";
|
||||
export * from "./delete";
|
||||
export * from "./empty-state";
|
||||
export * from "./issue-types-list";
|
||||
export * from "./issue-type-list-item";
|
||||
export * from "./quick-actions";
|
||||
export * from "./properties";
|
||||
export * from "./values";
|
||||
98
web/ee/components/issue-types/issue-type-list-item.tsx
Normal file
98
web/ee/components/issue-types/issue-type-list-item.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
// ui
|
||||
import { Collapsible, LayersIcon, Logo, Tooltip } from "@plane/ui";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
// plane web components
|
||||
import { IssueTypeQuickActions, IssuePropertiesRoot } from "@/plane-web/components/issue-types";
|
||||
// plane web hooks
|
||||
import { useIssueType } from "@/plane-web/hooks/store";
|
||||
|
||||
type TIssueTypeListItem = {
|
||||
issueTypeId: string;
|
||||
};
|
||||
|
||||
export const IssueTypeListItem = observer((props: TIssueTypeListItem) => {
|
||||
const { issueTypeId } = props;
|
||||
// store hooks
|
||||
const issueType = useIssueType(issueTypeId);
|
||||
// derived values
|
||||
const issueTypeDetail = issueType?.asJSON;
|
||||
// state
|
||||
const [isOpen, setIsOpen] = useState(issueTypeDetail?.is_default ?? false);
|
||||
|
||||
if (!issueTypeDetail) return null;
|
||||
|
||||
return (
|
||||
<div className={cn("py-2 border-b border-custom-border-100 last:border-b-0")}>
|
||||
<div
|
||||
className={cn("group/issue-type hover:bg-custom-background-90/60 rounded-md", {
|
||||
"bg-custom-background-90/60": isOpen,
|
||||
})}
|
||||
>
|
||||
<Collapsible
|
||||
key={issueTypeId}
|
||||
isOpen={isOpen}
|
||||
onToggle={() => setIsOpen(!isOpen)}
|
||||
title={
|
||||
<div className="flex items-center w-full px-2 gap-2 cursor-pointer">
|
||||
<div className={cn("flex w-full gap-2 items-center")}>
|
||||
<div className="flex-shrink-0">
|
||||
<ChevronRight
|
||||
className={cn("flex-shrink-0 size-4 transition-all", {
|
||||
"rotate-90 text-custom-text-100": isOpen,
|
||||
"text-custom-text-300": !isOpen,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"flex-shrink-0 grid h-10 w-10 place-items-center rounded-md bg-custom-background-80/70",
|
||||
!issueTypeDetail?.is_active && "opacity-60"
|
||||
)}
|
||||
>
|
||||
{issueTypeDetail?.logo_props?.in_use ? (
|
||||
<Logo logo={issueTypeDetail.logo_props} size={20} type="lucide" />
|
||||
) : (
|
||||
<LayersIcon className="h-5 w-5 text-custom-text-300" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col w-full items-start justify-start">
|
||||
<div className="flex gap-4 items-center">
|
||||
<div className="text-sm text-custom-text-100 font-medium">{issueTypeDetail?.name}</div>
|
||||
</div>
|
||||
<Tooltip tooltipContent={issueTypeDetail?.description} position="bottom-left">
|
||||
<div className="text-sm text-custom-text-300 text-left line-clamp-1">
|
||||
{issueTypeDetail?.description}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-shrink-0 flex gap-4">
|
||||
{issueTypeDetail?.is_default && (
|
||||
<div className="py-1 px-4 text-xs rounded font-medium text-custom-text-300 bg-custom-background-80/70">
|
||||
Default
|
||||
</div>
|
||||
)}
|
||||
{!issueTypeDetail?.is_active && (
|
||||
<div className="flex-shrink-0 py-0.5 px-2 text-xs rounded font-medium text-red-600 bg-red-600/10">
|
||||
Disabled
|
||||
</div>
|
||||
)}
|
||||
{!issueTypeDetail?.is_default && <IssueTypeQuickActions issueTypeId={issueTypeId} />}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
className={cn("p-2")}
|
||||
buttonClassName={cn("flex w-full py-2 gap-2 items-center justify-between")}
|
||||
>
|
||||
<div className="p-2">
|
||||
<IssuePropertiesRoot issueTypeId={issueTypeId} />
|
||||
</div>
|
||||
</Collapsible>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
40
web/ee/components/issue-types/issue-types-list.tsx
Normal file
40
web/ee/components/issue-types/issue-types-list.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { observer } from "mobx-react";
|
||||
// ui
|
||||
import { useParams } from "next/navigation";
|
||||
import { Loader } from "@plane/ui";
|
||||
// plane web components
|
||||
import { IssueTypeListItem } from "@/plane-web/components/issue-types";
|
||||
// plane web hooks
|
||||
import { useIssueTypes } from "@/plane-web/hooks/store";
|
||||
|
||||
export const IssueTypesList = observer(() => {
|
||||
// router
|
||||
const { projectId } = useParams();
|
||||
// store hooks
|
||||
const { getProjectIssueTypeLoader, getProjectIssueTypeIds } = useIssueTypes();
|
||||
// derived states
|
||||
const issueTypeLoader = getProjectIssueTypeLoader(projectId?.toString());
|
||||
const currentProjectIssueTypeIds = getProjectIssueTypeIds(projectId?.toString());
|
||||
|
||||
if (issueTypeLoader === "init-loader") {
|
||||
return (
|
||||
<Loader className="space-y-6 py-2">
|
||||
<Loader className="space-y-2">
|
||||
<Loader.Item height="60px" />
|
||||
<Loader.Item height="240px" />
|
||||
</Loader>
|
||||
<Loader.Item height="60px" />
|
||||
<Loader.Item height="60px" />
|
||||
</Loader>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{currentProjectIssueTypeIds &&
|
||||
currentProjectIssueTypeIds.map((issueTypeId) => (
|
||||
<IssueTypeListItem key={issueTypeId} issueTypeId={issueTypeId} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { observer } from "mobx-react";
|
||||
// plane web components
|
||||
import { PropertySettingsConfiguration, BooleanInput } from "@/plane-web/components/issue-types";
|
||||
// plane web constants
|
||||
import { ISSUE_PROPERTY_SETTINGS_CONFIGURATIONS } from "@/plane-web/constants/issue-properties";
|
||||
// plane web hooks
|
||||
import { useIssueType } from "@/plane-web/hooks/store";
|
||||
// plane web types
|
||||
import { EIssuePropertyType, TIssueProperty, TOperationMode } from "@/plane-web/types";
|
||||
|
||||
type TBooleanAttributesProps = {
|
||||
issueTypeId: string;
|
||||
booleanPropertyDetail: Partial<TIssueProperty<EIssuePropertyType.BOOLEAN>>;
|
||||
currentOperationMode: TOperationMode;
|
||||
onBooleanDetailChange: <K extends keyof TIssueProperty<EIssuePropertyType.BOOLEAN>>(
|
||||
key: K,
|
||||
value: TIssueProperty<EIssuePropertyType.BOOLEAN>[K],
|
||||
shouldSync?: boolean
|
||||
) => void;
|
||||
};
|
||||
|
||||
export const BooleanAttributes = observer((props: TBooleanAttributesProps) => {
|
||||
const { issueTypeId, booleanPropertyDetail, currentOperationMode, onBooleanDetailChange } = props;
|
||||
// store hooks
|
||||
const issueType = useIssueType(issueTypeId);
|
||||
// derived values
|
||||
const isAnyIssueAttached = issueType?.issue_exists;
|
||||
|
||||
return (
|
||||
<>
|
||||
{ISSUE_PROPERTY_SETTINGS_CONFIGURATIONS?.BOOLEAN?.length && (
|
||||
<div className="pb-4">
|
||||
{ISSUE_PROPERTY_SETTINGS_CONFIGURATIONS?.BOOLEAN?.map((configurations, index) => (
|
||||
<PropertySettingsConfiguration
|
||||
key={index}
|
||||
settings={booleanPropertyDetail.settings}
|
||||
settingsConfigurations={configurations}
|
||||
onChange={(value) =>
|
||||
onBooleanDetailChange("settings", value as TIssueProperty<EIssuePropertyType.BOOLEAN>["settings"])
|
||||
}
|
||||
isDisabled={!configurations.allowedEditingModes.includes(currentOperationMode) && isAnyIssueAttached}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-6">
|
||||
<div className="text-xs font-medium text-custom-text-300">Default • Optional</div>
|
||||
<BooleanInput
|
||||
value={booleanPropertyDetail.default_value ?? []}
|
||||
onBooleanValueChange={async (value) => onBooleanDetailChange("default_value", value)}
|
||||
isDisabled={!!booleanPropertyDetail.is_required}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./property-multi-select";
|
||||
export * from "./property-settings-configuration";
|
||||
@@ -0,0 +1,45 @@
|
||||
// plane web components
|
||||
import { RadioInput } from "@/components/estimates";
|
||||
// plane web constants
|
||||
import { DROPDOWN_ATTRIBUTES } from "@/plane-web/constants/issue-properties";
|
||||
// plane web types
|
||||
import { TIssuePropertyTypeKeys } from "@/plane-web/types";
|
||||
|
||||
type TPropertyMultiSelectProps = {
|
||||
value: boolean | undefined;
|
||||
variant?: TIssuePropertyTypeKeys;
|
||||
isDisabled?: boolean;
|
||||
onChange: (value: boolean) => void;
|
||||
};
|
||||
|
||||
export const PropertyMultiSelect = (props: TPropertyMultiSelectProps) => {
|
||||
const { value, variant = "RELATION_ISSUE", isDisabled = false, onChange } = props;
|
||||
// derived values
|
||||
const MULTI_SELECT_ATTRIBUTES = DROPDOWN_ATTRIBUTES[variant] ?? [];
|
||||
|
||||
const memberPickerAttributeOptions = MULTI_SELECT_ATTRIBUTES.map((attribute) => ({
|
||||
label: attribute.label,
|
||||
value: attribute.key,
|
||||
disabled: isDisabled,
|
||||
}));
|
||||
|
||||
const getSelectedValue = () => {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return value ? "multi_select" : "single_select";
|
||||
};
|
||||
|
||||
return (
|
||||
<RadioInput
|
||||
selected={getSelectedValue() ?? ""}
|
||||
options={memberPickerAttributeOptions}
|
||||
onChange={(value) => onChange(value === "multi_select")}
|
||||
className="z-50"
|
||||
buttonClassName="size-3"
|
||||
fieldClassName="text-sm"
|
||||
wrapperClassName="gap-1"
|
||||
vertical
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
import { get, set } from "lodash";
|
||||
// components
|
||||
import { RadioInput } from "@/components/estimates";
|
||||
// plane web constants
|
||||
import { EIssuePropertyType, TIssuePropertySettingsMap, TSettingsConfigurations } from "@/plane-web/types";
|
||||
|
||||
type TPropertySettingsConfigurationProps<T extends EIssuePropertyType> = {
|
||||
settings: TIssuePropertySettingsMap[T] | undefined;
|
||||
settingsConfigurations: TSettingsConfigurations;
|
||||
isDisabled?: boolean;
|
||||
onChange: (value: TIssuePropertySettingsMap[T]) => void;
|
||||
};
|
||||
|
||||
export const PropertyRadioInputSelect = <T extends EIssuePropertyType>(
|
||||
props: TPropertySettingsConfigurationProps<T>
|
||||
) => {
|
||||
const { settings, settingsConfigurations, isDisabled, onChange } = props;
|
||||
|
||||
const radioInputOptions = settingsConfigurations.configurations.options.map((option) => ({
|
||||
label: option.label,
|
||||
value: option.value,
|
||||
disabled: isDisabled,
|
||||
}));
|
||||
|
||||
const handleDataChange = (value: string) => {
|
||||
const updatedSettings = {
|
||||
...settings,
|
||||
};
|
||||
set(updatedSettings, settingsConfigurations.keyToUpdate, value);
|
||||
onChange(updatedSettings as TIssuePropertySettingsMap[T]); // TODO: Fix this
|
||||
};
|
||||
|
||||
const selectedValue = get(settings, settingsConfigurations.keyToUpdate);
|
||||
|
||||
return (
|
||||
<RadioInput
|
||||
selected={selectedValue}
|
||||
options={radioInputOptions}
|
||||
onChange={handleDataChange}
|
||||
className="z-50"
|
||||
buttonClassName="size-3"
|
||||
fieldClassName="text-sm"
|
||||
wrapperClassName="gap-1"
|
||||
vertical
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const PropertySettingsConfiguration = <T extends EIssuePropertyType>(
|
||||
props: TPropertySettingsConfigurationProps<T>
|
||||
) => {
|
||||
const { settings, settingsConfigurations, isDisabled, onChange } = props;
|
||||
|
||||
switch (settingsConfigurations.configurations.componentToRender) {
|
||||
case "radio-input":
|
||||
return (
|
||||
<PropertyRadioInputSelect
|
||||
settings={settings}
|
||||
settingsConfigurations={settingsConfigurations}
|
||||
onChange={onChange}
|
||||
isDisabled={isDisabled}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
import { observer } from "mobx-react";
|
||||
// plane web components
|
||||
import { PropertySettingsConfiguration } from "@/plane-web/components/issue-types/properties";
|
||||
// plane web constants
|
||||
import { ISSUE_PROPERTY_SETTINGS_CONFIGURATIONS } from "@/plane-web/constants/issue-properties";
|
||||
// plane web hooks
|
||||
import { useIssueType } from "@/plane-web/hooks/store";
|
||||
// plane web types
|
||||
import { EIssuePropertyType, TIssueProperty, TOperationMode } from "@/plane-web/types";
|
||||
|
||||
type TDatePickerAttributesProps = {
|
||||
issueTypeId: string;
|
||||
datePickerPropertyDetail: Partial<TIssueProperty<EIssuePropertyType.DATETIME>>;
|
||||
currentOperationMode: TOperationMode;
|
||||
onDatePickerDetailChange: <K extends keyof TIssueProperty<EIssuePropertyType.DATETIME>>(
|
||||
key: K,
|
||||
value: TIssueProperty<EIssuePropertyType.DATETIME>[K],
|
||||
shouldSync?: boolean
|
||||
) => void;
|
||||
};
|
||||
|
||||
export const DatePickerAttributes = observer((props: TDatePickerAttributesProps) => {
|
||||
const { issueTypeId, datePickerPropertyDetail, currentOperationMode, onDatePickerDetailChange } = props;
|
||||
// store hooks
|
||||
const issueType = useIssueType(issueTypeId);
|
||||
// derived values
|
||||
const isAnyIssueAttached = issueType?.issue_exists;
|
||||
|
||||
return (
|
||||
<>
|
||||
{ISSUE_PROPERTY_SETTINGS_CONFIGURATIONS?.DATETIME?.map((configurations, index) => (
|
||||
<PropertySettingsConfiguration
|
||||
key={index}
|
||||
settings={datePickerPropertyDetail.settings}
|
||||
settingsConfigurations={configurations}
|
||||
onChange={(value) =>
|
||||
onDatePickerDetailChange("settings", value as TIssueProperty<EIssuePropertyType.DATETIME>["settings"])
|
||||
}
|
||||
isDisabled={!configurations.allowedEditingModes.includes(currentOperationMode) && isAnyIssueAttached}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
});
|
||||
106
web/ee/components/issue-types/properties/attributes/dropdown.tsx
Normal file
106
web/ee/components/issue-types/properties/attributes/dropdown.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import { observer } from "mobx-react";
|
||||
// plane web components
|
||||
import { OptionValueSelect } from "@/plane-web/components/issue-types";
|
||||
import {
|
||||
DefaultOptionCreateSelect,
|
||||
IssuePropertyOptionsRoot,
|
||||
PropertyMultiSelect,
|
||||
PropertySettingsConfiguration,
|
||||
} from "@/plane-web/components/issue-types/properties";
|
||||
// plane web constants
|
||||
import { ISSUE_PROPERTY_SETTINGS_CONFIGURATIONS } from "@/plane-web/constants/issue-properties";
|
||||
// plane web hooks
|
||||
import { useIssueType } from "@/plane-web/hooks/store";
|
||||
// plane web types
|
||||
import {
|
||||
EIssuePropertyType,
|
||||
TCreationListModes,
|
||||
TIssueProperty,
|
||||
TOperationMode,
|
||||
TIssuePropertyOptionCreateList,
|
||||
} from "@/plane-web/types";
|
||||
|
||||
type TDropdownAttributesProps = {
|
||||
issueTypeId: string;
|
||||
dropdownPropertyDetail: Partial<TIssueProperty<EIssuePropertyType.OPTION>>;
|
||||
currentOperationMode: TOperationMode;
|
||||
issuePropertyOptionCreateList: TIssuePropertyOptionCreateList[];
|
||||
onDropdownDetailChange: <K extends keyof TIssueProperty<EIssuePropertyType.OPTION>>(
|
||||
key: K,
|
||||
value: TIssueProperty<EIssuePropertyType.OPTION>[K],
|
||||
shouldSync?: boolean
|
||||
) => void;
|
||||
handleIssuePropertyOptionCreateList: (mode: TCreationListModes, value: TIssuePropertyOptionCreateList) => void;
|
||||
};
|
||||
|
||||
export const DropdownAttributes = observer((props: TDropdownAttributesProps) => {
|
||||
const {
|
||||
issueTypeId,
|
||||
dropdownPropertyDetail,
|
||||
currentOperationMode,
|
||||
issuePropertyOptionCreateList,
|
||||
onDropdownDetailChange,
|
||||
handleIssuePropertyOptionCreateList,
|
||||
} = props;
|
||||
// store hooks
|
||||
const issueType = useIssueType(issueTypeId);
|
||||
// derived values
|
||||
const isAnyIssueAttached = issueType?.issue_exists;
|
||||
const isOptionDefaultDisabled = dropdownPropertyDetail.is_multi === undefined || !!dropdownPropertyDetail.is_required;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="px-1">
|
||||
<PropertyMultiSelect
|
||||
value={dropdownPropertyDetail.is_multi}
|
||||
variant="OPTION"
|
||||
onChange={(value) => onDropdownDetailChange("is_multi", value)}
|
||||
isDisabled={currentOperationMode === "update" && isAnyIssueAttached}
|
||||
/>
|
||||
</div>
|
||||
{ISSUE_PROPERTY_SETTINGS_CONFIGURATIONS?.OPTION?.length && (
|
||||
<div className="pt-4">
|
||||
{ISSUE_PROPERTY_SETTINGS_CONFIGURATIONS?.OPTION?.map((configurations, index) => (
|
||||
<PropertySettingsConfiguration
|
||||
key={index}
|
||||
settings={dropdownPropertyDetail.settings}
|
||||
settingsConfigurations={configurations}
|
||||
onChange={(value) =>
|
||||
onDropdownDetailChange("settings", value as TIssueProperty<EIssuePropertyType.OPTION>["settings"])
|
||||
}
|
||||
isDisabled={!configurations.allowedEditingModes.includes(currentOperationMode) && isAnyIssueAttached}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<IssuePropertyOptionsRoot
|
||||
issueTypeId={issueTypeId}
|
||||
issuePropertyId={dropdownPropertyDetail.id}
|
||||
issuePropertyOptionCreateList={issuePropertyOptionCreateList}
|
||||
handleIssuePropertyOptionCreateList={handleIssuePropertyOptionCreateList}
|
||||
/>
|
||||
<div className="pt-4 px-1">
|
||||
<div className="text-xs font-medium text-custom-text-300">Default • Optional</div>
|
||||
{dropdownPropertyDetail.id ? (
|
||||
<OptionValueSelect
|
||||
value={dropdownPropertyDetail.default_value ?? []}
|
||||
issueTypeId={issueTypeId}
|
||||
issuePropertyId={dropdownPropertyDetail.id}
|
||||
variant="create"
|
||||
isMultiSelect={dropdownPropertyDetail.is_multi}
|
||||
isRequired={false}
|
||||
isDisabled={isOptionDefaultDisabled}
|
||||
onOptionValueChange={async (value) => onDropdownDetailChange("default_value", value)}
|
||||
/>
|
||||
) : (
|
||||
<DefaultOptionCreateSelect
|
||||
isMultiSelect={dropdownPropertyDetail.is_multi}
|
||||
issuePropertyOptionCreateList={issuePropertyOptionCreateList}
|
||||
handleOptionListUpdate={(value) => handleIssuePropertyOptionCreateList("update", value)}
|
||||
isDisabled={isOptionDefaultDisabled}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
export * from "./common";
|
||||
export * from "./options";
|
||||
export * from "./text";
|
||||
export * from "./number";
|
||||
export * from "./dropdown";
|
||||
export * from "./boolean";
|
||||
export * from "./date-picker";
|
||||
export * from "./member-picker";
|
||||
@@ -0,0 +1,85 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// plane web components
|
||||
import {
|
||||
MemberValueSelect,
|
||||
PropertyMultiSelect,
|
||||
PropertySettingsConfiguration,
|
||||
} from "@/plane-web/components/issue-types";
|
||||
// plane web constants
|
||||
import { ISSUE_PROPERTY_SETTINGS_CONFIGURATIONS } from "@/plane-web/constants/issue-properties";
|
||||
// plane web hooks
|
||||
import { useIssueType } from "@/plane-web/hooks/store";
|
||||
// plane web types
|
||||
import { EIssuePropertyType, TIssueProperty, TOperationMode } from "@/plane-web/types";
|
||||
|
||||
type TMemberPickerAttributesProps = {
|
||||
issueTypeId: string;
|
||||
memberPickerPropertyDetail: Partial<TIssueProperty<EIssuePropertyType.RELATION>>;
|
||||
currentOperationMode: TOperationMode;
|
||||
onMemberPickerDetailChange: <K extends keyof TIssueProperty<EIssuePropertyType.RELATION>>(
|
||||
key: K,
|
||||
value: TIssueProperty<EIssuePropertyType.RELATION>[K],
|
||||
shouldSync?: boolean
|
||||
) => void;
|
||||
};
|
||||
|
||||
export const MemberPickerAttributes = observer((props: TMemberPickerAttributesProps) => {
|
||||
const { issueTypeId, memberPickerPropertyDetail, currentOperationMode, onMemberPickerDetailChange } = props;
|
||||
// router
|
||||
const { projectId } = useParams();
|
||||
// store hooks
|
||||
const issueType = useIssueType(issueTypeId);
|
||||
// derived values
|
||||
const isAnyIssueAttached = issueType?.issue_exists;
|
||||
const isMemberDropdownDisabled =
|
||||
memberPickerPropertyDetail.is_multi === undefined || !!memberPickerPropertyDetail.is_required;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="px-1">
|
||||
<PropertyMultiSelect
|
||||
value={memberPickerPropertyDetail.is_multi}
|
||||
variant="RELATION_USER"
|
||||
onChange={(value) => {
|
||||
onMemberPickerDetailChange("is_multi", value);
|
||||
if (!value) {
|
||||
onMemberPickerDetailChange(
|
||||
"default_value",
|
||||
memberPickerPropertyDetail.default_value?.[0] ? [memberPickerPropertyDetail.default_value?.[0]] : []
|
||||
);
|
||||
}
|
||||
}}
|
||||
isDisabled={currentOperationMode === "update" && isAnyIssueAttached}
|
||||
/>
|
||||
</div>
|
||||
{ISSUE_PROPERTY_SETTINGS_CONFIGURATIONS?.RELATION_USER?.length && (
|
||||
<div className="pt-4">
|
||||
{ISSUE_PROPERTY_SETTINGS_CONFIGURATIONS?.RELATION_USER?.map((configurations, index) => (
|
||||
<PropertySettingsConfiguration
|
||||
key={index}
|
||||
settings={memberPickerPropertyDetail.settings}
|
||||
settingsConfigurations={configurations}
|
||||
onChange={(value) =>
|
||||
onMemberPickerDetailChange("settings", value as TIssueProperty<EIssuePropertyType.RELATION>["settings"])
|
||||
}
|
||||
isDisabled={!configurations.allowedEditingModes.includes(currentOperationMode) && isAnyIssueAttached}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="pt-4">
|
||||
<div className="text-xs font-medium text-custom-text-300">Default • Optional</div>
|
||||
<MemberValueSelect
|
||||
value={memberPickerPropertyDetail.default_value ?? []}
|
||||
projectId={projectId?.toString()}
|
||||
variant="create"
|
||||
isMultiSelect={memberPickerPropertyDetail.is_multi}
|
||||
isRequired={false}
|
||||
isDisabled={isMemberDropdownDisabled}
|
||||
onMemberValueChange={async (value) => onMemberPickerDetailChange("default_value", value)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { observer } from "mobx-react";
|
||||
// plane web components
|
||||
import { PropertySettingsConfiguration, NumberValueInput } from "@/plane-web/components/issue-types/";
|
||||
// plane web constants
|
||||
import { ISSUE_PROPERTY_SETTINGS_CONFIGURATIONS } from "@/plane-web/constants/issue-properties";
|
||||
// plane web hooks
|
||||
import { useIssueType } from "@/plane-web/hooks/store";
|
||||
// plane web types
|
||||
import { EIssuePropertyType, TIssueProperty, TOperationMode } from "@/plane-web/types";
|
||||
|
||||
type TNumberAttributesProps = {
|
||||
issueTypeId: string;
|
||||
numberPropertyDetail: Partial<TIssueProperty<EIssuePropertyType.DECIMAL>>;
|
||||
currentOperationMode: TOperationMode;
|
||||
onNumberDetailChange: <K extends keyof TIssueProperty<EIssuePropertyType.DECIMAL>>(
|
||||
key: K,
|
||||
value: TIssueProperty<EIssuePropertyType.DECIMAL>[K],
|
||||
shouldSync?: boolean
|
||||
) => void;
|
||||
};
|
||||
|
||||
export const NumberAttributes = observer((props: TNumberAttributesProps) => {
|
||||
const { issueTypeId, numberPropertyDetail, currentOperationMode, onNumberDetailChange } = props;
|
||||
// store hooks
|
||||
const issueType = useIssueType(issueTypeId);
|
||||
// derived values
|
||||
const isAnyIssueAttached = issueType?.issue_exists;
|
||||
|
||||
return (
|
||||
<>
|
||||
{ISSUE_PROPERTY_SETTINGS_CONFIGURATIONS?.DECIMAL?.length && (
|
||||
<div className="pb-4">
|
||||
{ISSUE_PROPERTY_SETTINGS_CONFIGURATIONS?.DECIMAL?.map((configurations, index) => (
|
||||
<PropertySettingsConfiguration
|
||||
key={index}
|
||||
settings={numberPropertyDetail.settings}
|
||||
settingsConfigurations={configurations}
|
||||
onChange={(value) =>
|
||||
onNumberDetailChange("settings", value as TIssueProperty<EIssuePropertyType.DECIMAL>["settings"])
|
||||
}
|
||||
isDisabled={!configurations.allowedEditingModes.includes(currentOperationMode) && isAnyIssueAttached}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs font-medium text-custom-text-300">Default • Optional</div>
|
||||
<NumberValueInput
|
||||
propertyId={numberPropertyDetail.id}
|
||||
value={numberPropertyDetail.default_value ?? []}
|
||||
onNumberValueChange={async (value) => onNumberDetailChange("default_value", value)}
|
||||
variant="create"
|
||||
className="w-full text-sm px-1.5 py-0.5 bg-custom-background-100 border-[0.5px] rounded"
|
||||
isDisabled={!!numberPropertyDetail.is_required}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { forwardRef } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// plane web hooks
|
||||
import { IssuePropertyOptionItem } from "@/plane-web/components/issue-types/properties/attributes";
|
||||
// plane web types
|
||||
import { TIssuePropertyOptionCreateList } from "@/plane-web/types";
|
||||
|
||||
type TIssuePropertyCreateOptionItem = {
|
||||
issueTypeId: string;
|
||||
issuePropertyId: string | undefined;
|
||||
propertyOptionCreateListData?: TIssuePropertyOptionCreateList;
|
||||
updateCreateListData?: (value: TIssuePropertyOptionCreateList) => void;
|
||||
};
|
||||
|
||||
export const IssuePropertyCreateOptionItem = observer(
|
||||
forwardRef<HTMLDivElement, TIssuePropertyCreateOptionItem>(function IssuePropertyCreateOptionItem(
|
||||
props: TIssuePropertyCreateOptionItem,
|
||||
ref: React.Ref<HTMLDivElement>
|
||||
) {
|
||||
const { issueTypeId, issuePropertyId, propertyOptionCreateListData, updateCreateListData } = props;
|
||||
|
||||
return (
|
||||
<div ref={ref} className="w-full">
|
||||
<IssuePropertyOptionItem
|
||||
issueTypeId={issueTypeId}
|
||||
issuePropertyId={issuePropertyId}
|
||||
operationMode="create"
|
||||
propertyOptionCreateListData={propertyOptionCreateListData}
|
||||
updateCreateListData={updateCreateListData}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
);
|
||||
@@ -0,0 +1,103 @@
|
||||
import React from "react";
|
||||
import { isEqual } from "lodash";
|
||||
import { observer } from "mobx-react";
|
||||
// components
|
||||
import { CustomSearchSelect } from "@plane/ui";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
// plane web hooks
|
||||
import { TIssuePropertyOptionCreateList } from "@/plane-web/types";
|
||||
|
||||
type TDefaultOptionCreateSelectProps = {
|
||||
isMultiSelect?: boolean;
|
||||
isDisabled?: boolean;
|
||||
issuePropertyOptionCreateList: TIssuePropertyOptionCreateList[];
|
||||
handleOptionListUpdate: (value: TIssuePropertyOptionCreateList) => void;
|
||||
};
|
||||
|
||||
export const DefaultOptionCreateSelect = observer((props: TDefaultOptionCreateSelectProps) => {
|
||||
const { isMultiSelect = false, isDisabled = false, issuePropertyOptionCreateList, handleOptionListUpdate } = props;
|
||||
// derived values
|
||||
const optionsList = issuePropertyOptionCreateList.filter((option) => !!option.name);
|
||||
const selectedDefaultOptionsKeys = optionsList.filter((option) => option.is_default).map((option) => option.key);
|
||||
// states
|
||||
const [data, setData] = React.useState<string[]>(selectedDefaultOptionsKeys ?? []);
|
||||
|
||||
const customSearchOptions = optionsList.map((option) => ({
|
||||
value: option.key,
|
||||
query: option.name ?? "",
|
||||
content: option.name,
|
||||
}));
|
||||
|
||||
const getOptionDetails = (optionId: string) => optionsList.find((option) => option.key === optionId);
|
||||
|
||||
const getDisplayName = () => {
|
||||
if (isMultiSelect) {
|
||||
if (data.length) {
|
||||
if (data.length === 1) {
|
||||
return getOptionDetails(data[0])?.name;
|
||||
} else {
|
||||
return `${data.length} options selected`;
|
||||
}
|
||||
}
|
||||
return "Select options";
|
||||
} else {
|
||||
if (data.length) {
|
||||
return getOptionDetails(data[0])?.name;
|
||||
}
|
||||
return "Select option";
|
||||
}
|
||||
};
|
||||
|
||||
const customSearchSelectProps = {
|
||||
label: getDisplayName(),
|
||||
options: customSearchOptions,
|
||||
className: "group w-full h-full flex",
|
||||
optionsClassName: "w-full",
|
||||
chevronClassName: "h-3.5 w-3.5 hidden group-hover:inline",
|
||||
buttonClassName: cn("rounded text-sm bg-custom-background-100 border-[0.5px] border-custom-border-300", {
|
||||
"text-custom-text-400": !data.length,
|
||||
}),
|
||||
disabled: isDisabled,
|
||||
};
|
||||
|
||||
const onOptionValueChange = (value: string[]) => {
|
||||
issuePropertyOptionCreateList.map((option) => {
|
||||
handleOptionListUpdate({
|
||||
...option,
|
||||
is_default: value.includes(option.key),
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{isMultiSelect ? (
|
||||
<CustomSearchSelect
|
||||
{...customSearchSelectProps}
|
||||
value={data || []}
|
||||
onChange={(optionIds: string[]) => setData(optionIds)}
|
||||
onClose={() => {
|
||||
if (!isEqual(data, selectedDefaultOptionsKeys)) {
|
||||
onOptionValueChange(data);
|
||||
}
|
||||
}}
|
||||
multiple
|
||||
/>
|
||||
) : (
|
||||
<CustomSearchSelect
|
||||
{...customSearchSelectProps}
|
||||
value={data?.[0] || null}
|
||||
onChange={(optionId: string) => {
|
||||
const updatedData = optionId && !data?.includes(optionId) ? [optionId] : [];
|
||||
setData(updatedData);
|
||||
if (!isEqual(updatedData, selectedDefaultOptionsKeys)) {
|
||||
onOptionValueChange(updatedData);
|
||||
}
|
||||
}}
|
||||
multiple={false}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from "./root";
|
||||
export * from "./create-option-item";
|
||||
export * from "./option";
|
||||
export * from "./default-option-create-select";
|
||||
@@ -0,0 +1,105 @@
|
||||
import { FC, useEffect, useState } from "react";
|
||||
import { isEqual } from "lodash";
|
||||
import { observer } from "mobx-react";
|
||||
// ui
|
||||
import { Input, setToast, TOAST_TYPE } from "@plane/ui";
|
||||
// plane web hooks
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
import { usePropertyOption } from "@/plane-web/hooks/store";
|
||||
import { IssuePropertyOption } from "@/plane-web/store/issue-types";
|
||||
// plane web types
|
||||
import { TIssuePropertyOption, TIssuePropertyOptionCreateList, TOperationMode } from "@/plane-web/types";
|
||||
|
||||
type TIssuePropertyOptionItem = {
|
||||
issueTypeId: string;
|
||||
issuePropertyId: string | undefined;
|
||||
optionId?: string;
|
||||
operationMode: TOperationMode;
|
||||
propertyOptionCreateListData?: TIssuePropertyOptionCreateList;
|
||||
updateCreateListData?: (value: TIssuePropertyOptionCreateList) => void;
|
||||
};
|
||||
|
||||
export const IssuePropertyOptionItem: FC<TIssuePropertyOptionItem> = observer((props) => {
|
||||
const {
|
||||
issueTypeId,
|
||||
issuePropertyId,
|
||||
optionId,
|
||||
operationMode,
|
||||
propertyOptionCreateListData,
|
||||
updateCreateListData,
|
||||
} = props;
|
||||
// store hooks
|
||||
const issuePropertyOption = usePropertyOption(issueTypeId, issuePropertyId, optionId);
|
||||
// derived values
|
||||
let key: string;
|
||||
let propertyOptionCreateData;
|
||||
if (propertyOptionCreateListData) {
|
||||
({ key, ...propertyOptionCreateData } = propertyOptionCreateListData);
|
||||
}
|
||||
const propertyOptionDetail = optionId ? issuePropertyOption?.asJSON : propertyOptionCreateData;
|
||||
// return null if no property option found
|
||||
if (!propertyOptionDetail) return null;
|
||||
// states
|
||||
const [error, setError] = useState<string | undefined>(undefined);
|
||||
const [optionData, setOptionData] = useState<Partial<TIssuePropertyOption>>(propertyOptionDetail);
|
||||
|
||||
useEffect(() => {
|
||||
if (optionId && !optionData.name) setError("Option name is required.");
|
||||
else setError(undefined);
|
||||
}, [optionId, optionData]);
|
||||
|
||||
// handlers
|
||||
// handle update option operation
|
||||
const handleUpdateOption = async (data: Partial<IssuePropertyOption>) => {
|
||||
if (!issuePropertyId) return;
|
||||
await issuePropertyOption
|
||||
?.updatePropertyOption(issuePropertyId, data)
|
||||
.then(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Success!",
|
||||
message: `Property option ${optionData?.name} updated successfully.`,
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: error?.error ?? `Failed to update property option. Please try again!`,
|
||||
});
|
||||
setOptionData(propertyOptionDetail);
|
||||
});
|
||||
};
|
||||
|
||||
// handle create/ update operation
|
||||
const handleCreateUpdate = async () => {
|
||||
// return if option name is same as previous or empty
|
||||
if (!optionData.name || isEqual(optionData.name, propertyOptionDetail.name)) return;
|
||||
// handle create property option
|
||||
if (operationMode === "create" && updateCreateListData) updateCreateListData({ key, ...optionData });
|
||||
// handle update property option
|
||||
else if (operationMode === "update") await handleUpdateOption(optionData);
|
||||
};
|
||||
|
||||
// handle changes in option local data
|
||||
const handleOptionDataChange = <T extends keyof TIssuePropertyOption>(key: T, value: TIssuePropertyOption[T]) => {
|
||||
// update property data
|
||||
setOptionData((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
return (
|
||||
<Input
|
||||
id={`option-${optionId}`}
|
||||
value={optionData.name}
|
||||
onChange={(e) => handleOptionDataChange("name", e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && !!optionData.name && e.currentTarget.blur()}
|
||||
onBlur={() => handleCreateUpdate()}
|
||||
placeholder={"Add option"}
|
||||
className={cn("w-full text-sm px-1 py-0.5 bg-custom-background-100 border-[0.5px] rounded", {
|
||||
"border-custom-border-300": !Boolean(error),
|
||||
})}
|
||||
hasError={Boolean(error)}
|
||||
autoFocus
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import { FC, useEffect, useRef } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { v4 } from "uuid";
|
||||
// ui
|
||||
import { setToast, TOAST_TYPE } from "@plane/ui";
|
||||
// plane web components
|
||||
import {
|
||||
IssuePropertyCreateOptionItem,
|
||||
IssuePropertyOptionItem,
|
||||
} from "@/plane-web/components/issue-types/properties/attributes";
|
||||
// plane web hooks
|
||||
import { useIssueProperty, useIssueType } from "@/plane-web/hooks/store";
|
||||
// plane web types
|
||||
import { TCreationListModes, TIssuePropertyOption, TIssuePropertyOptionCreateList } from "@/plane-web/types";
|
||||
|
||||
type TIssuePropertyOptionsRoot = {
|
||||
issueTypeId: string;
|
||||
issuePropertyId: string | undefined;
|
||||
issuePropertyOptionCreateList: TIssuePropertyOptionCreateList[];
|
||||
handleIssuePropertyOptionCreateList: (mode: TCreationListModes, value: TIssuePropertyOptionCreateList) => void;
|
||||
};
|
||||
|
||||
const defaultIssuePropertyOption: Partial<Partial<TIssuePropertyOption>> = {
|
||||
id: undefined,
|
||||
name: undefined,
|
||||
is_default: false,
|
||||
};
|
||||
|
||||
export const IssuePropertyOptionsRoot: FC<TIssuePropertyOptionsRoot> = observer((props) => {
|
||||
const { issueTypeId, issuePropertyId, issuePropertyOptionCreateList, handleIssuePropertyOptionCreateList } = props;
|
||||
// store hooks
|
||||
const issueType = useIssueType(issueTypeId);
|
||||
const issueProperty = useIssueProperty(issueTypeId, issuePropertyId);
|
||||
// derived values
|
||||
const activePropertyOptions = issueProperty?.activePropertyOptions;
|
||||
// refs
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const secondLastElementRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (secondLastElementRef.current) {
|
||||
secondLastElementRef.current.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
// get the input element and focus on it
|
||||
const inputElement = secondLastElementRef.current.querySelector("input");
|
||||
inputElement?.focus();
|
||||
}
|
||||
}, [issuePropertyOptionCreateList]);
|
||||
|
||||
useEffect(() => {
|
||||
const emptyOptions = issuePropertyOptionCreateList.filter((item) => !item.name).length;
|
||||
|
||||
if (emptyOptions < 2) {
|
||||
const optionsToAdd = 2 - emptyOptions;
|
||||
const newOptions = Array.from({ length: optionsToAdd }, () => ({
|
||||
key: v4(),
|
||||
...defaultIssuePropertyOption,
|
||||
}));
|
||||
newOptions.forEach((option) => handleIssuePropertyOptionCreateList("add", option));
|
||||
}
|
||||
}, [handleIssuePropertyOptionCreateList, issuePropertyOptionCreateList]);
|
||||
|
||||
// handlers
|
||||
const handleOptionCreate = async (propertyId: string, value: TIssuePropertyOptionCreateList) => {
|
||||
// get issue property details from the store
|
||||
const issueProperty = issueType?.getPropertyById(propertyId);
|
||||
if (!issueProperty) return;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { key, ...payload } = value;
|
||||
|
||||
await issueProperty
|
||||
.createPropertyOptions([payload])
|
||||
.then(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Success!",
|
||||
message: `Property option created successfully.`,
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: error?.error ?? `Failed to create issue property option. Please try again!`,
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
handleIssuePropertyOptionCreateList("remove", value);
|
||||
});
|
||||
};
|
||||
|
||||
const handleIssuePropertyOptionCreate = (value: TIssuePropertyOptionCreateList) => {
|
||||
// If issuePropertyId is present, then create the property option directly
|
||||
if (issuePropertyId) {
|
||||
handleOptionCreate(issuePropertyId, value);
|
||||
} else {
|
||||
// Else, update the create list
|
||||
handleIssuePropertyOptionCreateList("update", value);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-1 pt-3">
|
||||
<div className="text-xs font-medium text-custom-text-300 pb-1">Add options</div>
|
||||
<div ref={containerRef} className="flex flex-col items-center space-y-1.5 max-h-32 overflow-scroll">
|
||||
{activePropertyOptions &&
|
||||
activePropertyOptions.map(
|
||||
(propertyOption) =>
|
||||
propertyOption.id && (
|
||||
<IssuePropertyOptionItem
|
||||
key={propertyOption.id}
|
||||
issueTypeId={issueTypeId}
|
||||
issuePropertyId={issuePropertyId}
|
||||
optionId={propertyOption.id}
|
||||
operationMode="update"
|
||||
/>
|
||||
)
|
||||
)}
|
||||
{issuePropertyOptionCreateList.map((issuePropertyOption, index) => (
|
||||
<IssuePropertyCreateOptionItem
|
||||
key={issuePropertyOption.key}
|
||||
ref={index === issuePropertyOptionCreateList.length - 2 ? secondLastElementRef : undefined}
|
||||
issueTypeId={issueTypeId}
|
||||
issuePropertyId={issuePropertyId}
|
||||
propertyOptionCreateListData={issuePropertyOption}
|
||||
updateCreateListData={handleIssuePropertyOptionCreate}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
62
web/ee/components/issue-types/properties/attributes/text.tsx
Normal file
62
web/ee/components/issue-types/properties/attributes/text.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { observer } from "mobx-react";
|
||||
// ui
|
||||
import { TextArea } from "@plane/ui";
|
||||
// plane web components
|
||||
import { PropertySettingsConfiguration } from "@/plane-web/components/issue-types/properties";
|
||||
// plane web constants
|
||||
import { ISSUE_PROPERTY_SETTINGS_CONFIGURATIONS } from "@/plane-web/constants/issue-properties";
|
||||
// plane web hooks
|
||||
import { useIssueType } from "@/plane-web/hooks/store";
|
||||
// plane web types
|
||||
import { EIssuePropertyType, TIssueProperty, TOperationMode } from "@/plane-web/types";
|
||||
|
||||
type TTextAttributesProps = {
|
||||
issueTypeId: string;
|
||||
textPropertyDetail: Partial<TIssueProperty<EIssuePropertyType.TEXT>>;
|
||||
currentOperationMode: TOperationMode;
|
||||
onTextDetailChange: <K extends keyof TIssueProperty<EIssuePropertyType.TEXT>>(
|
||||
key: K,
|
||||
value: TIssueProperty<EIssuePropertyType.TEXT>[K],
|
||||
shouldSync?: boolean
|
||||
) => void;
|
||||
};
|
||||
|
||||
export const TextAttributes = observer((props: TTextAttributesProps) => {
|
||||
const { issueTypeId, textPropertyDetail, currentOperationMode, onTextDetailChange } = props;
|
||||
// store hooks
|
||||
const issueType = useIssueType(issueTypeId);
|
||||
// derived values
|
||||
const isAnyIssueAttached = issueType?.issue_exists;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="px-1">
|
||||
{ISSUE_PROPERTY_SETTINGS_CONFIGURATIONS?.TEXT?.map((configurations, index) => (
|
||||
<PropertySettingsConfiguration
|
||||
key={index}
|
||||
settings={textPropertyDetail.settings}
|
||||
settingsConfigurations={configurations}
|
||||
onChange={(value) =>
|
||||
onTextDetailChange("settings", value as TIssueProperty<EIssuePropertyType.TEXT>["settings"])
|
||||
}
|
||||
isDisabled={!configurations.allowedEditingModes.includes(currentOperationMode) && isAnyIssueAttached}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{textPropertyDetail.settings?.display_format === "readonly" && (
|
||||
<div className="pt-4">
|
||||
<div className="text-xs font-medium text-custom-text-300">Read only data</div>
|
||||
<TextArea
|
||||
id="default_value"
|
||||
value={textPropertyDetail.default_value?.[0]}
|
||||
onChange={(e) => onTextDetailChange("default_value", [e.target.value])}
|
||||
className="w-full max-h-28 resize-none text-sm px-1 py-0.5 bg-custom-background-100 border-[0.5px] border-custom-border-300 rounded"
|
||||
tabIndex={1}
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from "./property-title";
|
||||
export * from "./property-type";
|
||||
export * from "./property-attributes";
|
||||
export * from "./selected-attribute-properties";
|
||||
@@ -0,0 +1,105 @@
|
||||
import { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { usePopper } from "react-popper";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { Popover } from "@headlessui/react";
|
||||
// ui
|
||||
import { Tooltip } from "@plane/ui";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
// plane web components
|
||||
import { SelectedAttributeProperties } from "@/plane-web/components/issue-types/properties";
|
||||
// plane web helpers
|
||||
import { getIssuePropertyAttributeDisplayName } from "@/plane-web/helpers/issue-properties.helper";
|
||||
// plane web types
|
||||
import {
|
||||
EIssuePropertyType,
|
||||
TCreationListModes,
|
||||
TIssueProperty,
|
||||
TOperationMode,
|
||||
TIssuePropertyOptionCreateList,
|
||||
} from "@/plane-web/types";
|
||||
|
||||
type TPropertyAttributesDropdownProps = {
|
||||
issueTypeId: string;
|
||||
propertyDetail: Partial<TIssueProperty<EIssuePropertyType>>;
|
||||
currentOperationMode: TOperationMode | null;
|
||||
issuePropertyOptionCreateList: TIssuePropertyOptionCreateList[];
|
||||
onPropertyDetailChange: <K extends keyof TIssueProperty<EIssuePropertyType>>(
|
||||
key: K,
|
||||
value: TIssueProperty<EIssuePropertyType>[K],
|
||||
shouldSync?: boolean
|
||||
) => void;
|
||||
handleIssuePropertyOptionCreateList: (mode: TCreationListModes, value: TIssuePropertyOptionCreateList) => void;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export const PropertyAttributesDropdown = observer((props: TPropertyAttributesDropdownProps) => {
|
||||
const {
|
||||
issueTypeId,
|
||||
propertyDetail,
|
||||
currentOperationMode,
|
||||
issuePropertyOptionCreateList,
|
||||
handleIssuePropertyOptionCreateList,
|
||||
onPropertyDetailChange,
|
||||
disabled,
|
||||
} = props;
|
||||
// states
|
||||
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
|
||||
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
|
||||
// derived values
|
||||
const attributeDisplayName = getIssuePropertyAttributeDisplayName(propertyDetail);
|
||||
|
||||
const { styles, attributes } = usePopper(referenceElement, popperElement, {
|
||||
placement: "bottom-start",
|
||||
});
|
||||
|
||||
if (!currentOperationMode) {
|
||||
return (
|
||||
<span className="px-2 py-0.5 font-medium text-custom-text-300 bg-custom-background-80/40 rounded">
|
||||
{attributeDisplayName ?? ""}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<Tooltip disabled={!disabled} tooltipContent="Please select a property type">
|
||||
<Popover.Button as="div">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"w-full flex items-center justify-between gap-1 px-2 py-1 text-sm bg-custom-background-100 hover:bg-custom-background-80 border-[0.5px] border-custom-border-300 rounded cursor-pointer outline-none",
|
||||
{
|
||||
"bg-custom-background-90 cursor-not-allowed": disabled,
|
||||
"py-2": !attributeDisplayName,
|
||||
}
|
||||
)}
|
||||
disabled={disabled}
|
||||
ref={setReferenceElement}
|
||||
>
|
||||
<span className="text-custom-text-200">{attributeDisplayName ?? ""}</span>
|
||||
<ChevronDown className="h-3 w-3" aria-hidden="true" />
|
||||
</button>
|
||||
</Popover.Button>
|
||||
</Tooltip>
|
||||
<Popover.Panel className="fixed z-10">
|
||||
<div
|
||||
className="w-60 bg-custom-background-100 border-[0.5px] border-custom-border-300 rounded my-1 py-4 px-2 shadow-custom-shadow-rg"
|
||||
ref={setPopperElement}
|
||||
style={styles.popper}
|
||||
{...attributes.popper}
|
||||
>
|
||||
<SelectedAttributeProperties
|
||||
issueTypeId={issueTypeId}
|
||||
propertyDetail={propertyDetail}
|
||||
currentOperationMode={currentOperationMode}
|
||||
issuePropertyOptionCreateList={issuePropertyOptionCreateList}
|
||||
onPropertyDetailChange={onPropertyDetailChange}
|
||||
handleIssuePropertyOptionCreateList={handleIssuePropertyOptionCreateList}
|
||||
/>
|
||||
</div>
|
||||
</Popover.Panel>
|
||||
</Popover>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { usePopper } from "react-popper";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { Popover } from "@headlessui/react";
|
||||
// ui
|
||||
import { Input, TextArea, Tooltip } from "@plane/ui";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
// plane web types
|
||||
import { EIssuePropertyType, TIssueProperty, TOperationMode } from "@/plane-web/types";
|
||||
|
||||
type TPropertyTitleDropdownProps = {
|
||||
propertyDetail: Partial<TIssueProperty<EIssuePropertyType>>;
|
||||
currentOperationMode: TOperationMode | null;
|
||||
error?: string;
|
||||
onPropertyDetailChange: <K extends keyof TIssueProperty<EIssuePropertyType>>(
|
||||
key: K,
|
||||
value: TIssueProperty<EIssuePropertyType>[K],
|
||||
shouldSync?: boolean
|
||||
) => void;
|
||||
};
|
||||
|
||||
// TODO: Update to use CustomMenu
|
||||
export const PropertyTitleDropdown = observer((props: TPropertyTitleDropdownProps) => {
|
||||
const { propertyDetail, currentOperationMode, error, onPropertyDetailChange } = props;
|
||||
// states
|
||||
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
|
||||
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
|
||||
|
||||
const { styles, attributes } = usePopper(referenceElement, popperElement, {
|
||||
placement: "bottom-start",
|
||||
});
|
||||
|
||||
if (!currentOperationMode) {
|
||||
return (
|
||||
<Tooltip tooltipContent={propertyDetail.description} position="top-left" disabled={!propertyDetail.description}>
|
||||
<span className="px-1 truncate">{propertyDetail.display_name ?? ""}</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<Popover.Button as="div">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"w-full flex items-center justify-between px-2 py-1 text-sm gap-1 bg-custom-background-100 hover:bg-custom-background-80 border-[0.5px] border-custom-border-300 rounded cursor-pointer outline-none",
|
||||
Boolean(error) && "border-red-500",
|
||||
!propertyDetail.display_name && "py-2"
|
||||
)}
|
||||
ref={setReferenceElement}
|
||||
>
|
||||
<span className="text-custom-text-200">{propertyDetail.display_name ?? ""}</span>
|
||||
<ChevronDown className="h-3 w-3" aria-hidden="true" />
|
||||
</button>
|
||||
</Popover.Button>
|
||||
<Popover.Panel className="fixed z-10">
|
||||
<div
|
||||
className="w-72 flex flex-col bg-custom-background-100 border-[0.5px] border-custom-border-300 rounded my-1 p-2 gap-2 shadow-custom-shadow-rg"
|
||||
ref={setPopperElement}
|
||||
style={styles.popper}
|
||||
{...attributes.popper}
|
||||
>
|
||||
<div>
|
||||
<div className="text-xs font-medium text-custom-text-300">Name</div>
|
||||
<Input
|
||||
id="display_name"
|
||||
type="text"
|
||||
value={propertyDetail.display_name}
|
||||
onChange={(e) => onPropertyDetailChange("display_name", e.target.value)}
|
||||
className={cn("w-full resize-none text-sm px-1.5 py-0.5 bg-custom-background-100 border-[0.5px] rounded")}
|
||||
tabIndex={1}
|
||||
hasError={Boolean(error)}
|
||||
placeholder={error}
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs font-medium text-custom-text-300">Description (optional)</div>
|
||||
<TextArea
|
||||
id="description"
|
||||
value={propertyDetail.description}
|
||||
onChange={(e) => onPropertyDetailChange("description", e.target.value)}
|
||||
className={cn("w-full resize-none text-sm px-1.5 py-0.5 bg-custom-background-100 border-[0.5px] rounded")}
|
||||
tabIndex={2}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Popover.Panel>
|
||||
</Popover>
|
||||
);
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user