[WEB-5282] chore: triage state in intake (#8135)
* chore: traige state in intake * chore: triage state changes * feat: implement intake state dropdown component and integrate into issue properties * chore: added the triage state validation * chore: added triage state filter * chore: added workspace filter * fix: migration file * chore: added triage group state check * chore: updated the filters * chore: updated the filters * chore: added variables for intake state * fix: import error * refactor: improve project intake state retrieval logic and update TriageGroupIcon component * chore: changed the intake validation logic * refactor: update intake state types and clean up unused interfaces * chore: changed the state color * chore: changed the update serializer * chore: updated with current instance * chore: update TriageGroupIcon color to match new intake state group color * chore: stringified value * chore: added validation in serializer * chore: added logger instead of print * fix: correct component closing syntax in ActiveProjectItem * chore: updated the migration file * chore: added noop in migation --------- Co-authored-by: b-saikrishnakanth <bsaikrishnakanth97@gmail.com>
This commit is contained in:
parent
dbc5a6348d
commit
78fbdde165
36 changed files with 952 additions and 181 deletions
|
|
@ -103,6 +103,54 @@ class IntakeIssueUpdateSerializer(BaseSerializer):
|
||||||
"updated_at",
|
"updated_at",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
def validate(self, attrs):
|
||||||
|
"""
|
||||||
|
Validate that if status is being changed to accepted (1),
|
||||||
|
the project has a default state to transition to.
|
||||||
|
"""
|
||||||
|
from plane.db.models import State
|
||||||
|
|
||||||
|
# Check if status is being updated to accepted
|
||||||
|
if attrs.get("status") == 1:
|
||||||
|
intake_issue = self.instance
|
||||||
|
issue = intake_issue.issue
|
||||||
|
|
||||||
|
# Check if issue is in TRIAGE state
|
||||||
|
if issue.state and issue.state.group == State.TRIAGE:
|
||||||
|
# Verify default state exists before allowing the update
|
||||||
|
default_state = State.objects.filter(
|
||||||
|
workspace=intake_issue.workspace, project=intake_issue.project, default=True
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not default_state:
|
||||||
|
raise serializers.ValidationError(
|
||||||
|
{"status": "Cannot accept intake issue: No default state found for the project"}
|
||||||
|
)
|
||||||
|
|
||||||
|
return attrs
|
||||||
|
|
||||||
|
def update(self, instance, validated_data):
|
||||||
|
"""
|
||||||
|
Update intake issue and transition associated issue state if accepted.
|
||||||
|
"""
|
||||||
|
from plane.db.models import State
|
||||||
|
|
||||||
|
# Update the intake issue with validated data
|
||||||
|
instance = super().update(instance, validated_data)
|
||||||
|
|
||||||
|
# If status is accepted (1), update the associated issue state from TRIAGE to default
|
||||||
|
if validated_data.get("status") == 1:
|
||||||
|
issue = instance.issue
|
||||||
|
if issue.state and issue.state.group == State.TRIAGE:
|
||||||
|
# Get the default project state
|
||||||
|
default_state = State.objects.filter(
|
||||||
|
workspace=instance.workspace, project=instance.project, default=True
|
||||||
|
).first()
|
||||||
|
if default_state:
|
||||||
|
issue.state = default_state
|
||||||
|
issue.save()
|
||||||
|
return instance
|
||||||
|
|
||||||
|
|
||||||
class IssueDataSerializer(serializers.Serializer):
|
class IssueDataSerializer(serializers.Serializer):
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
# Module imports
|
# Module imports
|
||||||
from .base import BaseSerializer
|
from .base import BaseSerializer
|
||||||
from plane.db.models import State
|
from plane.db.models import State
|
||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
|
||||||
class StateSerializer(BaseSerializer):
|
class StateSerializer(BaseSerializer):
|
||||||
|
|
@ -15,6 +16,9 @@ class StateSerializer(BaseSerializer):
|
||||||
# If the default is being provided then make all other states default False
|
# If the default is being provided then make all other states default False
|
||||||
if data.get("default", False):
|
if data.get("default", False):
|
||||||
State.objects.filter(project_id=self.context.get("project_id")).update(default=False)
|
State.objects.filter(project_id=self.context.get("project_id")).update(default=False)
|
||||||
|
|
||||||
|
if data.get("group", None) == State.TRIAGE:
|
||||||
|
raise serializers.ValidationError("Cannot create triage state")
|
||||||
return data
|
return data
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|
|
||||||
|
|
@ -165,6 +165,20 @@ class IntakeIssueListCreateAPIEndpoint(BaseAPIView):
|
||||||
]:
|
]:
|
||||||
return Response({"error": "Invalid priority"}, status=status.HTTP_400_BAD_REQUEST)
|
return Response({"error": "Invalid priority"}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
# get the triage state
|
||||||
|
triage_state = State.triage_objects.filter(project_id=project_id, workspace__slug=slug).first()
|
||||||
|
|
||||||
|
if not triage_state:
|
||||||
|
triage_state = State.objects.create(
|
||||||
|
name="Intake Triage",
|
||||||
|
group=State.TRIAGE,
|
||||||
|
project_id=project_id,
|
||||||
|
workspace_id=project.workspace_id,
|
||||||
|
color="#4E5355",
|
||||||
|
sequence=65000,
|
||||||
|
default=False,
|
||||||
|
)
|
||||||
|
|
||||||
# create an issue
|
# create an issue
|
||||||
issue = Issue.objects.create(
|
issue = Issue.objects.create(
|
||||||
name=request.data.get("issue", {}).get("name"),
|
name=request.data.get("issue", {}).get("name"),
|
||||||
|
|
@ -172,6 +186,7 @@ class IntakeIssueListCreateAPIEndpoint(BaseAPIView):
|
||||||
description_html=request.data.get("issue", {}).get("description_html", "<p></p>"),
|
description_html=request.data.get("issue", {}).get("description_html", "<p></p>"),
|
||||||
priority=request.data.get("issue", {}).get("priority", "none"),
|
priority=request.data.get("issue", {}).get("priority", "none"),
|
||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
|
state_id=triage_state.id,
|
||||||
)
|
)
|
||||||
|
|
||||||
# create an intake issue
|
# create an intake issue
|
||||||
|
|
@ -320,7 +335,10 @@ class IntakeIssueDetailAPIEndpoint(BaseAPIView):
|
||||||
|
|
||||||
# Get issue data
|
# Get issue data
|
||||||
issue_data = request.data.pop("issue", False)
|
issue_data = request.data.pop("issue", False)
|
||||||
|
issue_serializer = None
|
||||||
|
intake_serializer = None
|
||||||
|
|
||||||
|
# Validate issue data if provided
|
||||||
if bool(issue_data):
|
if bool(issue_data):
|
||||||
issue = Issue.objects.annotate(
|
issue = Issue.objects.annotate(
|
||||||
label_ids=Coalesce(
|
label_ids=Coalesce(
|
||||||
|
|
@ -344,6 +362,7 @@ class IntakeIssueDetailAPIEndpoint(BaseAPIView):
|
||||||
Value([], output_field=ArrayField(UUIDField())),
|
Value([], output_field=ArrayField(UUIDField())),
|
||||||
),
|
),
|
||||||
).get(pk=issue_id, workspace__slug=slug, project_id=project_id)
|
).get(pk=issue_id, workspace__slug=slug, project_id=project_id)
|
||||||
|
|
||||||
# Only allow guests to edit name and description
|
# Only allow guests to edit name and description
|
||||||
if project_member.role <= 5:
|
if project_member.role <= 5:
|
||||||
issue_data = {
|
issue_data = {
|
||||||
|
|
@ -354,71 +373,55 @@ class IntakeIssueDetailAPIEndpoint(BaseAPIView):
|
||||||
|
|
||||||
issue_serializer = IssueSerializer(issue, data=issue_data, partial=True)
|
issue_serializer = IssueSerializer(issue, data=issue_data, partial=True)
|
||||||
|
|
||||||
if issue_serializer.is_valid():
|
if not issue_serializer.is_valid():
|
||||||
current_instance = issue
|
|
||||||
# Log all the updates
|
|
||||||
requested_data = json.dumps(issue_data, cls=DjangoJSONEncoder)
|
|
||||||
if issue is not None:
|
|
||||||
issue_activity.delay(
|
|
||||||
type="issue.activity.updated",
|
|
||||||
requested_data=requested_data,
|
|
||||||
actor_id=str(request.user.id),
|
|
||||||
issue_id=str(issue_id),
|
|
||||||
project_id=str(project_id),
|
|
||||||
current_instance=json.dumps(
|
|
||||||
IssueSerializer(current_instance).data,
|
|
||||||
cls=DjangoJSONEncoder,
|
|
||||||
),
|
|
||||||
epoch=int(timezone.now().timestamp()),
|
|
||||||
intake=(intake_issue.id),
|
|
||||||
)
|
|
||||||
issue_serializer.save()
|
|
||||||
else:
|
|
||||||
return Response(issue_serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
return Response(issue_serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
# Only project admins and members can edit intake issue attributes
|
# Only project admins and members can edit intake issue attributes
|
||||||
if project_member.role > 15:
|
if project_member.role > 15:
|
||||||
serializer = IntakeIssueUpdateSerializer(intake_issue, data=request.data, partial=True)
|
intake_serializer = IntakeIssueUpdateSerializer(intake_issue, data=request.data, partial=True)
|
||||||
|
|
||||||
|
if not intake_serializer.is_valid():
|
||||||
|
return Response(intake_serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
# Both serializers are valid, now save them
|
||||||
|
if issue_serializer:
|
||||||
|
current_instance = issue
|
||||||
|
# Log all the updates
|
||||||
|
requested_data = json.dumps(issue_data, cls=DjangoJSONEncoder)
|
||||||
|
issue_activity.delay(
|
||||||
|
type="issue.activity.updated",
|
||||||
|
requested_data=requested_data,
|
||||||
|
actor_id=str(request.user.id),
|
||||||
|
issue_id=str(issue_id),
|
||||||
|
project_id=str(project_id),
|
||||||
|
current_instance=json.dumps(
|
||||||
|
IssueSerializer(current_instance).data,
|
||||||
|
cls=DjangoJSONEncoder,
|
||||||
|
),
|
||||||
|
epoch=int(timezone.now().timestamp()),
|
||||||
|
intake=str(intake_issue.id),
|
||||||
|
)
|
||||||
|
issue_serializer.save()
|
||||||
|
|
||||||
|
# Save intake issue (state transition happens in serializer's update method)
|
||||||
|
if intake_serializer:
|
||||||
current_instance = json.dumps(IntakeIssueSerializer(intake_issue).data, cls=DjangoJSONEncoder)
|
current_instance = json.dumps(IntakeIssueSerializer(intake_issue).data, cls=DjangoJSONEncoder)
|
||||||
|
intake_serializer.save()
|
||||||
|
|
||||||
if serializer.is_valid():
|
# create a activity for status change
|
||||||
serializer.save()
|
issue_activity.delay(
|
||||||
# Update the issue state if the issue is rejected or marked as duplicate
|
type="intake.activity.created",
|
||||||
if serializer.data["status"] in [-1, 2]:
|
requested_data=json.dumps(request.data, cls=DjangoJSONEncoder),
|
||||||
issue = Issue.objects.get(pk=issue_id, workspace__slug=slug, project_id=project_id)
|
actor_id=str(request.user.id),
|
||||||
state = State.objects.filter(group="cancelled", workspace__slug=slug, project_id=project_id).first()
|
issue_id=str(issue_id),
|
||||||
if state is not None:
|
project_id=str(project_id),
|
||||||
issue.state = state
|
current_instance=current_instance,
|
||||||
issue.save()
|
epoch=int(timezone.now().timestamp()),
|
||||||
|
notification=False,
|
||||||
# Update the issue state if it is accepted
|
origin=base_host(request=request, is_app=True),
|
||||||
if serializer.data["status"] in [1]:
|
intake=str(intake_issue.id),
|
||||||
issue = Issue.objects.get(pk=issue_id, workspace__slug=slug, project_id=project_id)
|
)
|
||||||
|
return Response(IntakeIssueSerializer(intake_issue).data, status=status.HTTP_200_OK)
|
||||||
# Update the issue state only if it is in triage state
|
|
||||||
if issue.state.is_triage:
|
|
||||||
# Move to default state
|
|
||||||
state = State.objects.filter(workspace__slug=slug, project_id=project_id, default=True).first()
|
|
||||||
if state is not None:
|
|
||||||
issue.state = state
|
|
||||||
issue.save()
|
|
||||||
|
|
||||||
# create a activity for status change
|
|
||||||
issue_activity.delay(
|
|
||||||
type="intake.activity.created",
|
|
||||||
requested_data=json.dumps(request.data, cls=DjangoJSONEncoder),
|
|
||||||
actor_id=str(request.user.id),
|
|
||||||
issue_id=str(issue_id),
|
|
||||||
project_id=str(project_id),
|
|
||||||
current_instance=current_instance,
|
|
||||||
epoch=int(timezone.now().timestamp()),
|
|
||||||
notification=False,
|
|
||||||
origin=base_host(request=request, is_app=True),
|
|
||||||
intake=str(intake_issue.id),
|
|
||||||
)
|
|
||||||
serializer = IntakeIssueSerializer(intake_issue)
|
|
||||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
|
||||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
|
||||||
else:
|
else:
|
||||||
return Response(IntakeIssueSerializer(intake_issue).data, status=status.HTTP_200_OK)
|
return Response(IntakeIssueSerializer(intake_issue).data, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -265,6 +265,12 @@ class ProjectListCreateAPIEndpoint(BaseAPIView):
|
||||||
"sequence": 55000,
|
"sequence": 55000,
|
||||||
"group": "cancelled",
|
"group": "cancelled",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "Intake Triage",
|
||||||
|
"color": "#4E5355",
|
||||||
|
"sequence": 65000,
|
||||||
|
"group": State.TRIAGE,
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
State.objects.bulk_create(
|
State.objects.bulk_create(
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,54 @@ class IntakeIssueSerializer(BaseSerializer):
|
||||||
]
|
]
|
||||||
read_only_fields = ["project", "workspace"]
|
read_only_fields = ["project", "workspace"]
|
||||||
|
|
||||||
|
def validate(self, attrs):
|
||||||
|
"""
|
||||||
|
Validate that if status is being changed to accepted (1),
|
||||||
|
the project has a default state to transition to.
|
||||||
|
"""
|
||||||
|
from plane.db.models import State
|
||||||
|
|
||||||
|
# Check if status is being updated to accepted
|
||||||
|
if attrs.get("status") == 1:
|
||||||
|
intake_issue = self.instance
|
||||||
|
issue = intake_issue.issue
|
||||||
|
|
||||||
|
# Check if issue is in TRIAGE state
|
||||||
|
if issue.state and issue.state.group == State.TRIAGE:
|
||||||
|
# Verify default state exists before allowing the update
|
||||||
|
default_state = State.objects.filter(
|
||||||
|
workspace=intake_issue.workspace, project=intake_issue.project, default=True
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not default_state:
|
||||||
|
raise serializers.ValidationError(
|
||||||
|
{"status": "Cannot accept intake issue: No default state found for the project"}
|
||||||
|
)
|
||||||
|
|
||||||
|
return attrs
|
||||||
|
|
||||||
|
def update(self, instance, validated_data):
|
||||||
|
from plane.db.models import State
|
||||||
|
|
||||||
|
# Update the intake issue
|
||||||
|
instance = super().update(instance, validated_data)
|
||||||
|
|
||||||
|
# If status is accepted (1), transition the issue state from TRIAGE to default
|
||||||
|
if validated_data.get("status") == 1:
|
||||||
|
issue = instance.issue
|
||||||
|
if issue.state and issue.state.group == State.TRIAGE:
|
||||||
|
# Get the default project state
|
||||||
|
default_state = State.objects.filter(
|
||||||
|
workspace=instance.workspace,
|
||||||
|
project=instance.project,
|
||||||
|
default=True
|
||||||
|
).first()
|
||||||
|
if default_state:
|
||||||
|
issue.state = default_state
|
||||||
|
issue.save()
|
||||||
|
|
||||||
|
return instance
|
||||||
|
|
||||||
def to_representation(self, instance):
|
def to_representation(self, instance):
|
||||||
# Pass the annotated fields to the Issue instance if they exist
|
# Pass the annotated fields to the Issue instance if they exist
|
||||||
if hasattr(instance, "label_ids"):
|
if hasattr(instance, "label_ids"):
|
||||||
|
|
|
||||||
|
|
@ -78,7 +78,7 @@ class IssueProjectLiteSerializer(BaseSerializer):
|
||||||
class IssueCreateSerializer(BaseSerializer):
|
class IssueCreateSerializer(BaseSerializer):
|
||||||
# ids
|
# ids
|
||||||
state_id = serializers.PrimaryKeyRelatedField(
|
state_id = serializers.PrimaryKeyRelatedField(
|
||||||
source="state", queryset=State.objects.all(), required=False, allow_null=True
|
source="state", queryset=State.all_state_objects.all(), required=False, allow_null=True
|
||||||
)
|
)
|
||||||
parent_id = serializers.PrimaryKeyRelatedField(
|
parent_id = serializers.PrimaryKeyRelatedField(
|
||||||
source="parent", queryset=Issue.objects.all(), required=False, allow_null=True
|
source="parent", queryset=Issue.objects.all(), required=False, allow_null=True
|
||||||
|
|
@ -117,6 +117,9 @@ class IssueCreateSerializer(BaseSerializer):
|
||||||
return data
|
return data
|
||||||
|
|
||||||
def validate(self, attrs):
|
def validate(self, attrs):
|
||||||
|
allow_triage = self.context.get("allow_triage_state", False)
|
||||||
|
state_manager = State.triage_objects if allow_triage else State.objects
|
||||||
|
|
||||||
if (
|
if (
|
||||||
attrs.get("start_date", None) is not None
|
attrs.get("start_date", None) is not None
|
||||||
and attrs.get("target_date", None) is not None
|
and attrs.get("target_date", None) is not None
|
||||||
|
|
@ -160,7 +163,7 @@ class IssueCreateSerializer(BaseSerializer):
|
||||||
# Check state is from the project only else raise validation error
|
# Check state is from the project only else raise validation error
|
||||||
if (
|
if (
|
||||||
attrs.get("state")
|
attrs.get("state")
|
||||||
and not State.objects.filter(
|
and not state_manager.filter(
|
||||||
project_id=self.context.get("project_id"),
|
project_id=self.context.get("project_id"),
|
||||||
pk=attrs.get("state").id,
|
pk=attrs.get("state").id,
|
||||||
).exists()
|
).exists()
|
||||||
|
|
@ -795,6 +798,14 @@ class IssueSerializer(DynamicBaseSerializer):
|
||||||
]
|
]
|
||||||
read_only_fields = fields
|
read_only_fields = fields
|
||||||
|
|
||||||
|
def validate(self, data):
|
||||||
|
if (
|
||||||
|
data.get("state_id")
|
||||||
|
and not State.objects.filter(project_id=self.context.get("project_id"), pk=data.get("state_id")).exists()
|
||||||
|
):
|
||||||
|
raise serializers.ValidationError("State is not valid please pass a valid state_id")
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
class IssueListDetailSerializer(serializers.Serializer):
|
class IssueListDetailSerializer(serializers.Serializer):
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,12 @@ class StateSerializer(BaseSerializer):
|
||||||
]
|
]
|
||||||
read_only_fields = ["workspace", "project"]
|
read_only_fields = ["workspace", "project"]
|
||||||
|
|
||||||
|
def validate(self, attrs):
|
||||||
|
|
||||||
|
if attrs.get("group") == State.TRIAGE:
|
||||||
|
raise serializers.ValidationError("Cannot create triage state")
|
||||||
|
return attrs
|
||||||
|
|
||||||
|
|
||||||
class StateLiteSerializer(BaseSerializer):
|
class StateLiteSerializer(BaseSerializer):
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
from django.urls import path
|
from django.urls import path
|
||||||
|
|
||||||
|
|
||||||
from plane.app.views import StateViewSet
|
from plane.app.views import StateViewSet, IntakeStateEndpoint
|
||||||
|
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
|
|
@ -15,6 +15,11 @@ urlpatterns = [
|
||||||
StateViewSet.as_view({"get": "retrieve", "patch": "partial_update", "delete": "destroy"}),
|
StateViewSet.as_view({"get": "retrieve", "patch": "partial_update", "delete": "destroy"}),
|
||||||
name="project-state",
|
name="project-state",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"workspaces/<str:slug>/projects/<uuid:project_id>/intake-state/",
|
||||||
|
IntakeStateEndpoint.as_view(),
|
||||||
|
name="intake-state",
|
||||||
|
),
|
||||||
path(
|
path(
|
||||||
"workspaces/<str:slug>/projects/<uuid:project_id>/states/<uuid:pk>/mark-default/",
|
"workspaces/<str:slug>/projects/<uuid:project_id>/states/<uuid:pk>/mark-default/",
|
||||||
StateViewSet.as_view({"post": "mark_as_default"}),
|
StateViewSet.as_view({"post": "mark_as_default"}),
|
||||||
|
|
|
||||||
|
|
@ -80,7 +80,7 @@ from .workspace.cycle import WorkspaceCyclesEndpoint
|
||||||
from .workspace.quick_link import QuickLinkViewSet
|
from .workspace.quick_link import QuickLinkViewSet
|
||||||
from .workspace.sticky import WorkspaceStickyViewSet
|
from .workspace.sticky import WorkspaceStickyViewSet
|
||||||
|
|
||||||
from .state.base import StateViewSet
|
from .state.base import StateViewSet, IntakeStateEndpoint
|
||||||
from .view.base import (
|
from .view.base import (
|
||||||
WorkspaceViewViewSet,
|
WorkspaceViewViewSet,
|
||||||
WorkspaceViewIssuesViewSet,
|
WorkspaceViewIssuesViewSet,
|
||||||
|
|
|
||||||
|
|
@ -228,14 +228,30 @@ class IntakeIssueViewSet(BaseViewSet):
|
||||||
]:
|
]:
|
||||||
return Response({"error": "Invalid priority"}, status=status.HTTP_400_BAD_REQUEST)
|
return Response({"error": "Invalid priority"}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
# create an issue
|
|
||||||
project = Project.objects.get(pk=project_id)
|
project = Project.objects.get(pk=project_id)
|
||||||
|
|
||||||
|
# get the triage state
|
||||||
|
triage_state = State.triage_objects.filter(project_id=project_id, workspace__slug=slug).first()
|
||||||
|
if not triage_state:
|
||||||
|
triage_state = State.objects.create(
|
||||||
|
name="Intake Triage",
|
||||||
|
group=State.TRIAGE,
|
||||||
|
project_id=project_id,
|
||||||
|
workspace_id=project.workspace_id,
|
||||||
|
color="#4E5355",
|
||||||
|
sequence=65000,
|
||||||
|
default=False,
|
||||||
|
)
|
||||||
|
request.data["issue"]["state_id"] = triage_state.id
|
||||||
|
|
||||||
|
# create an issue
|
||||||
serializer = IssueCreateSerializer(
|
serializer = IssueCreateSerializer(
|
||||||
data=request.data.get("issue"),
|
data=request.data.get("issue"),
|
||||||
context={
|
context={
|
||||||
"project_id": project_id,
|
"project_id": project_id,
|
||||||
"workspace_id": project.workspace_id,
|
"workspace_id": project.workspace_id,
|
||||||
"default_assignee_id": project.default_assignee_id,
|
"default_assignee_id": project.default_assignee_id,
|
||||||
|
"allow_triage_state": True,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
if serializer.is_valid():
|
if serializer.is_valid():
|
||||||
|
|
@ -344,6 +360,12 @@ class IntakeIssueViewSet(BaseViewSet):
|
||||||
|
|
||||||
# Get issue data
|
# Get issue data
|
||||||
issue_data = request.data.pop("issue", False)
|
issue_data = request.data.pop("issue", False)
|
||||||
|
issue_serializer = None
|
||||||
|
issue = None
|
||||||
|
issue_current_instance = None
|
||||||
|
issue_requested_data = None
|
||||||
|
|
||||||
|
# Validate issue data if provided
|
||||||
if bool(issue_data):
|
if bool(issue_data):
|
||||||
issue = Issue.objects.annotate(
|
issue = Issue.objects.annotate(
|
||||||
label_ids=Coalesce(
|
label_ids=Coalesce(
|
||||||
|
|
@ -371,119 +393,95 @@ class IntakeIssueViewSet(BaseViewSet):
|
||||||
"description": issue_data.get("description", issue.description),
|
"description": issue_data.get("description", issue.description),
|
||||||
}
|
}
|
||||||
|
|
||||||
current_instance = json.dumps(IssueDetailSerializer(issue).data, cls=DjangoJSONEncoder)
|
issue_current_instance = json.dumps(IssueDetailSerializer(issue).data, cls=DjangoJSONEncoder)
|
||||||
|
issue_requested_data = json.dumps(issue_data, cls=DjangoJSONEncoder)
|
||||||
|
|
||||||
issue_serializer = IssueCreateSerializer(
|
issue_serializer = IssueCreateSerializer(
|
||||||
issue, data=issue_data, partial=True, context={"project_id": project_id}
|
issue, data=issue_data, partial=True, context={"project_id": project_id, "allow_triage_state": True}
|
||||||
)
|
)
|
||||||
|
|
||||||
if issue_serializer.is_valid():
|
if not issue_serializer.is_valid():
|
||||||
# Log all the updates
|
|
||||||
requested_data = json.dumps(issue_data, cls=DjangoJSONEncoder)
|
|
||||||
if issue is not None:
|
|
||||||
issue_activity.delay(
|
|
||||||
type="issue.activity.updated",
|
|
||||||
requested_data=requested_data,
|
|
||||||
actor_id=str(request.user.id),
|
|
||||||
issue_id=str(issue.id),
|
|
||||||
project_id=str(project_id),
|
|
||||||
current_instance=current_instance,
|
|
||||||
epoch=int(timezone.now().timestamp()),
|
|
||||||
notification=True,
|
|
||||||
origin=base_host(request=request, is_app=True),
|
|
||||||
intake=str(intake_issue.id),
|
|
||||||
)
|
|
||||||
# updated issue description version
|
|
||||||
issue_description_version_task.delay(
|
|
||||||
updated_issue=current_instance,
|
|
||||||
issue_id=str(pk),
|
|
||||||
user_id=request.user.id,
|
|
||||||
)
|
|
||||||
issue_serializer.save()
|
|
||||||
else:
|
|
||||||
return Response(issue_serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
return Response(issue_serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
# Only project admins can edit intake issue attributes
|
# Validate intake issue data if user has permission
|
||||||
|
intake_serializer = None
|
||||||
|
intake_current_instance = None
|
||||||
|
|
||||||
if (project_member and project_member.role > ROLE.MEMBER.value) or is_workspace_admin:
|
if (project_member and project_member.role > ROLE.MEMBER.value) or is_workspace_admin:
|
||||||
serializer = IntakeIssueSerializer(intake_issue, data=request.data, partial=True)
|
intake_current_instance = json.dumps(IntakeIssueSerializer(intake_issue).data, cls=DjangoJSONEncoder)
|
||||||
current_instance = json.dumps(IntakeIssueSerializer(intake_issue).data, cls=DjangoJSONEncoder)
|
intake_serializer = IntakeIssueSerializer(intake_issue, data=request.data, partial=True)
|
||||||
if serializer.is_valid():
|
|
||||||
serializer.save()
|
|
||||||
# Update the issue state if the issue is rejected or marked as duplicate
|
|
||||||
if serializer.data["status"] in [-1, 2]:
|
|
||||||
issue = Issue.objects.get(
|
|
||||||
pk=intake_issue.issue_id,
|
|
||||||
workspace__slug=slug,
|
|
||||||
project_id=project_id,
|
|
||||||
)
|
|
||||||
state = State.objects.filter(group="cancelled", workspace__slug=slug, project_id=project_id).first()
|
|
||||||
if state is not None:
|
|
||||||
issue.state = state
|
|
||||||
issue.save()
|
|
||||||
|
|
||||||
# Update the issue state if it is accepted
|
if not intake_serializer.is_valid():
|
||||||
if serializer.data["status"] in [1]:
|
return Response(intake_serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
issue = Issue.objects.get(
|
|
||||||
pk=intake_issue.issue_id,
|
|
||||||
workspace__slug=slug,
|
|
||||||
project_id=project_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Update the issue state only if it is in triage state
|
# Both serializers are valid, now save them
|
||||||
if issue.state.is_triage:
|
if issue_serializer:
|
||||||
# Move to default state
|
issue_serializer.save()
|
||||||
state = State.objects.filter(workspace__slug=slug, project_id=project_id, default=True).first()
|
# Log all the updates
|
||||||
if state is not None:
|
if issue is not None:
|
||||||
issue.state = state
|
|
||||||
issue.save()
|
|
||||||
# create a activity for status change
|
|
||||||
issue_activity.delay(
|
issue_activity.delay(
|
||||||
type="intake.activity.created",
|
type="issue.activity.updated",
|
||||||
requested_data=json.dumps(request.data, cls=DjangoJSONEncoder),
|
requested_data=issue_requested_data,
|
||||||
actor_id=str(request.user.id),
|
actor_id=str(request.user.id),
|
||||||
issue_id=str(pk),
|
issue_id=str(issue.id),
|
||||||
project_id=str(project_id),
|
project_id=str(project_id),
|
||||||
current_instance=current_instance,
|
current_instance=issue_current_instance,
|
||||||
epoch=int(timezone.now().timestamp()),
|
epoch=int(timezone.now().timestamp()),
|
||||||
notification=False,
|
notification=True,
|
||||||
origin=base_host(request=request, is_app=True),
|
origin=base_host(request=request, is_app=True),
|
||||||
intake=(intake_issue.id),
|
intake=str(intake_issue.id),
|
||||||
|
)
|
||||||
|
# updated issue description version
|
||||||
|
issue_description_version_task.delay(
|
||||||
|
updated_issue=issue_current_instance,
|
||||||
|
issue_id=str(pk),
|
||||||
|
user_id=request.user.id,
|
||||||
)
|
)
|
||||||
|
|
||||||
intake_issue = (
|
if intake_serializer:
|
||||||
IntakeIssue.objects.select_related("issue")
|
intake_serializer.save()
|
||||||
.prefetch_related("issue__labels", "issue__assignees")
|
# create a activity for status change
|
||||||
.annotate(
|
issue_activity.delay(
|
||||||
label_ids=Coalesce(
|
type="intake.activity.created",
|
||||||
ArrayAgg(
|
requested_data=json.dumps(request.data, cls=DjangoJSONEncoder),
|
||||||
"issue__labels__id",
|
actor_id=str(request.user.id),
|
||||||
distinct=True,
|
issue_id=str(pk),
|
||||||
filter=Q(
|
project_id=str(project_id),
|
||||||
~Q(issue__labels__id__isnull=True) & Q(issue__label_issue__deleted_at__isnull=True)
|
current_instance=intake_current_instance,
|
||||||
),
|
epoch=int(timezone.now().timestamp()),
|
||||||
),
|
notification=False,
|
||||||
Value([], output_field=ArrayField(UUIDField())),
|
origin=base_host(request=request, is_app=True),
|
||||||
|
intake=str(intake_issue.id),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Fetch and return the updated intake issue
|
||||||
|
intake_issue = (
|
||||||
|
IntakeIssue.objects.select_related("issue")
|
||||||
|
.prefetch_related("issue__labels", "issue__assignees")
|
||||||
|
.annotate(
|
||||||
|
label_ids=Coalesce(
|
||||||
|
ArrayAgg(
|
||||||
|
"issue__labels__id",
|
||||||
|
distinct=True,
|
||||||
|
filter=Q(~Q(issue__labels__id__isnull=True) & Q(issue__label_issue__deleted_at__isnull=True)),
|
||||||
|
),
|
||||||
|
Value([], output_field=ArrayField(UUIDField())),
|
||||||
|
),
|
||||||
|
assignee_ids=Coalesce(
|
||||||
|
ArrayAgg(
|
||||||
|
"issue__assignees__id",
|
||||||
|
distinct=True,
|
||||||
|
filter=Q(
|
||||||
|
~Q(issue__assignees__id__isnull=True) & Q(issue__issue_assignee__deleted_at__isnull=True)
|
||||||
),
|
),
|
||||||
assignee_ids=Coalesce(
|
),
|
||||||
ArrayAgg(
|
Value([], output_field=ArrayField(UUIDField())),
|
||||||
"issue__assignees__id",
|
),
|
||||||
distinct=True,
|
)
|
||||||
filter=Q(
|
.get(intake_id=intake_id.id, issue_id=pk, project_id=project_id)
|
||||||
~Q(issue__assignees__id__isnull=True)
|
)
|
||||||
& Q(issue__issue_assignee__deleted_at__isnull=True)
|
serializer = IntakeIssueDetailSerializer(intake_issue).data
|
||||||
),
|
return Response(serializer, status=status.HTTP_200_OK)
|
||||||
),
|
|
||||||
Value([], output_field=ArrayField(UUIDField())),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.get(intake_id=intake_id.id, issue_id=pk, project_id=project_id)
|
|
||||||
)
|
|
||||||
serializer = IntakeIssueDetailSerializer(intake_issue).data
|
|
||||||
return Response(serializer, status=status.HTTP_200_OK)
|
|
||||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
|
||||||
else:
|
|
||||||
serializer = IntakeIssueDetailSerializer(intake_issue).data
|
|
||||||
return Response(serializer, status=status.HTTP_200_OK)
|
|
||||||
|
|
||||||
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], creator=True, model=Issue)
|
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], creator=True, model=Issue)
|
||||||
def retrieve(self, request, slug, project_id, pk):
|
def retrieve(self, request, slug, project_id, pk):
|
||||||
|
|
|
||||||
|
|
@ -297,6 +297,12 @@ class ProjectViewSet(BaseViewSet):
|
||||||
"sequence": 55000,
|
"sequence": 55000,
|
||||||
"group": "cancelled",
|
"group": "cancelled",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "Intake Triage",
|
||||||
|
"color": "#4E5355",
|
||||||
|
"sequence": 65000,
|
||||||
|
"group": State.TRIAGE,
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
State.objects.bulk_create(
|
State.objects.bulk_create(
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ from rest_framework.response import Response
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
|
|
||||||
# Module imports
|
# Module imports
|
||||||
from .. import BaseViewSet
|
from .. import BaseViewSet, BaseAPIView
|
||||||
from plane.app.serializers import StateSerializer
|
from plane.app.serializers import StateSerializer
|
||||||
from plane.app.permissions import ROLE, allow_permission
|
from plane.app.permissions import ROLE, allow_permission
|
||||||
from plane.db.models import State, Issue
|
from plane.db.models import State, Issue
|
||||||
|
|
@ -127,3 +127,16 @@ class StateViewSet(BaseViewSet):
|
||||||
|
|
||||||
state.delete()
|
state.delete()
|
||||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
|
|
||||||
|
class IntakeStateEndpoint(BaseAPIView):
|
||||||
|
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
||||||
|
def get(self, request, slug, project_id):
|
||||||
|
state = State.triage_objects.filter(workspace__slug=slug, project_id=project_id).first()
|
||||||
|
if not state:
|
||||||
|
return Response(
|
||||||
|
{"error": "Intake triage state not found"},
|
||||||
|
status=status.HTTP_404_NOT_FOUND,
|
||||||
|
)
|
||||||
|
|
||||||
|
return Response(StateSerializer(state).data, status=status.HTTP_200_OK)
|
||||||
|
|
|
||||||
|
|
@ -264,7 +264,7 @@ def create_issues(workspace, project, user_id, issue_count):
|
||||||
Faker.seed(0)
|
Faker.seed(0)
|
||||||
|
|
||||||
states = (
|
states = (
|
||||||
State.objects.filter(workspace=workspace, project=project).exclude(group="Triage").values_list("id", flat=True)
|
State.objects.filter(workspace=workspace, project=project).exclude(group=State.TRIAGE).values_list("id", flat=True)
|
||||||
)
|
)
|
||||||
creators = ProjectMember.objects.filter(workspace=workspace, project=project).values_list("member_id", flat=True)
|
creators = ProjectMember.objects.filter(workspace=workspace, project=project).values_list("member_id", flat=True)
|
||||||
|
|
||||||
|
|
|
||||||
92
apps/api/plane/db/migrations/0112_auto_20251124_0603.py
Normal file
92
apps/api/plane/db/migrations/0112_auto_20251124_0603.py
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
# Generated by Django 4.2.25 on 2025-11-24 06:03
|
||||||
|
|
||||||
|
from django.db import migrations, transaction
|
||||||
|
from django.db.models import OuterRef, Subquery
|
||||||
|
|
||||||
|
import logging
|
||||||
|
logger = logging.getLogger("plane.migrations")
|
||||||
|
|
||||||
|
BATCH_SIZE = 4000
|
||||||
|
|
||||||
|
|
||||||
|
def create_triage_state(apps, _schema_editor):
|
||||||
|
Project = apps.get_model("db", "Project")
|
||||||
|
State = apps.get_model("db", "State")
|
||||||
|
Issue = apps.get_model("db", "Issue")
|
||||||
|
|
||||||
|
|
||||||
|
# 1) Bulk-update existing triage states
|
||||||
|
triage_qs = State.objects.filter(group="triage")
|
||||||
|
projects_with_triage_state = list(triage_qs.values_list("project_id", flat=True))
|
||||||
|
triage_qs.update(
|
||||||
|
name="Triage",
|
||||||
|
color="#4E5355",
|
||||||
|
sequence=65000,
|
||||||
|
default=False,
|
||||||
|
)
|
||||||
|
logger.info(f"Updated {triage_qs.count()} triage states.")
|
||||||
|
|
||||||
|
# 2) Projects that already have a 'Triage' name but not in triage group
|
||||||
|
projects_to_update_qs = (
|
||||||
|
State.objects.exclude(group="triage")
|
||||||
|
.filter(name="Triage")
|
||||||
|
.values_list("project_id", flat=True)
|
||||||
|
)
|
||||||
|
projects_to_update = set(projects_to_update_qs)
|
||||||
|
logger.info(f"Projects to update: {len(projects_to_update)}")
|
||||||
|
|
||||||
|
# 3) Create missing triage states in chunks to avoid memory spike
|
||||||
|
states_to_create = []
|
||||||
|
project_iter = Project.objects.all().values_list("id", "workspace_id").iterator()
|
||||||
|
for proj_id, workspace_id in project_iter:
|
||||||
|
if proj_id in projects_with_triage_state:
|
||||||
|
continue
|
||||||
|
if proj_id in projects_to_update:
|
||||||
|
name = f"Triage-{str(proj_id)[:5]}"
|
||||||
|
else:
|
||||||
|
name = "Triage"
|
||||||
|
states_to_create.append(
|
||||||
|
State(
|
||||||
|
name=name,
|
||||||
|
group="triage",
|
||||||
|
project_id=proj_id,
|
||||||
|
workspace_id=workspace_id,
|
||||||
|
color="#4E5355",
|
||||||
|
sequence=65000,
|
||||||
|
default=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if len(states_to_create) >= BATCH_SIZE:
|
||||||
|
State.objects.bulk_create(states_to_create, batch_size=BATCH_SIZE)
|
||||||
|
states_to_create = []
|
||||||
|
|
||||||
|
if states_to_create:
|
||||||
|
State.objects.bulk_create(states_to_create, batch_size=BATCH_SIZE)
|
||||||
|
|
||||||
|
# 4) Update issues: use deterministic subquery and only update issues that will get a triage state.
|
||||||
|
with transaction.atomic():
|
||||||
|
triage_state_subquery = (
|
||||||
|
State.objects.filter(
|
||||||
|
group="triage",
|
||||||
|
project_id=OuterRef("project_id"),
|
||||||
|
workspace_id=OuterRef("workspace_id"),
|
||||||
|
)
|
||||||
|
.values("id")[:1]
|
||||||
|
)
|
||||||
|
|
||||||
|
updated_count = Issue._default_manager.filter(
|
||||||
|
issue_intake__status__in=[-2, 0],
|
||||||
|
).update(state_id=Subquery(triage_state_subquery))
|
||||||
|
logger.info(f"Updated {updated_count} issues.")
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('db', '0111_notification_notif_receiver_status_idx_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(create_triage_state,
|
||||||
|
reverse_code=migrations.RunPython.noop),
|
||||||
|
]
|
||||||
|
|
@ -19,6 +19,7 @@ from .project import ProjectBaseModel
|
||||||
from plane.utils.uuid import convert_uuid_to_integer
|
from plane.utils.uuid import convert_uuid_to_integer
|
||||||
from .description import Description
|
from .description import Description
|
||||||
from plane.db.mixins import ChangeTrackerMixin
|
from plane.db.mixins import ChangeTrackerMixin
|
||||||
|
from .state import State
|
||||||
|
|
||||||
|
|
||||||
def get_default_properties():
|
def get_default_properties():
|
||||||
|
|
@ -97,6 +98,7 @@ class IssueManager(SoftDeletionManager):
|
||||||
)
|
)
|
||||||
.filter(deleted_at__isnull=True)
|
.filter(deleted_at__isnull=True)
|
||||||
.filter(state__is_triage=False)
|
.filter(state__is_triage=False)
|
||||||
|
.exclude(state__group=State.TRIAGE)
|
||||||
.exclude(archived_at__isnull=False)
|
.exclude(archived_at__isnull=False)
|
||||||
.exclude(project__archived_at__isnull=False)
|
.exclude(project__archived_at__isnull=False)
|
||||||
.exclude(is_draft=True)
|
.exclude(is_draft=True)
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,36 @@ from django.db.models import Q
|
||||||
from .project import ProjectBaseModel
|
from .project import ProjectBaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class StateManager(models.Manager):
|
||||||
|
"""Default manager - excludes triage states"""
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return super().get_queryset().exclude(group=State.TRIAGE)
|
||||||
|
|
||||||
|
|
||||||
|
class TriageStateManager(models.Manager):
|
||||||
|
"""Manager for triage states only"""
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return super().get_queryset().filter(group=State.TRIAGE)
|
||||||
|
|
||||||
|
|
||||||
class State(ProjectBaseModel):
|
class State(ProjectBaseModel):
|
||||||
|
BACKLOG = "backlog"
|
||||||
|
UNSTARTED = "unstarted"
|
||||||
|
STARTED = "started"
|
||||||
|
COMPLETED = "completed"
|
||||||
|
CANCELLED = "cancelled"
|
||||||
|
TRIAGE = "triage"
|
||||||
|
|
||||||
|
GROUP_CHOICES = (
|
||||||
|
(BACKLOG, "Backlog"),
|
||||||
|
(UNSTARTED, "Unstarted"),
|
||||||
|
(STARTED, "Started"),
|
||||||
|
(COMPLETED, "Completed"),
|
||||||
|
(CANCELLED, "Cancelled"),
|
||||||
|
(TRIAGE, "Triage"),
|
||||||
|
)
|
||||||
name = models.CharField(max_length=255, verbose_name="State Name")
|
name = models.CharField(max_length=255, verbose_name="State Name")
|
||||||
description = models.TextField(verbose_name="State Description", blank=True)
|
description = models.TextField(verbose_name="State Description", blank=True)
|
||||||
color = models.CharField(max_length=255, verbose_name="State Color")
|
color = models.CharField(max_length=255, verbose_name="State Color")
|
||||||
|
|
@ -30,6 +59,10 @@ class State(ProjectBaseModel):
|
||||||
external_source = models.CharField(max_length=255, null=True, blank=True)
|
external_source = models.CharField(max_length=255, null=True, blank=True)
|
||||||
external_id = models.CharField(max_length=255, blank=True, null=True)
|
external_id = models.CharField(max_length=255, blank=True, null=True)
|
||||||
|
|
||||||
|
objects = StateManager()
|
||||||
|
all_state_objects = models.Manager()
|
||||||
|
triage_objects = TriageStateManager()
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
"""Return name of the state"""
|
"""Return name of the state"""
|
||||||
return f"{self.name} <{self.project.name}>"
|
return f"{self.name} <{self.project.name}>"
|
||||||
|
|
|
||||||
|
|
@ -76,5 +76,10 @@ LOGGING = {
|
||||||
"handlers": ["console"],
|
"handlers": ["console"],
|
||||||
"propagate": False,
|
"propagate": False,
|
||||||
},
|
},
|
||||||
|
"plane.migrations": {
|
||||||
|
"level": "INFO",
|
||||||
|
"handlers": ["console"],
|
||||||
|
"propagate": False,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -86,5 +86,10 @@ LOGGING = {
|
||||||
"handlers": ["console"],
|
"handlers": ["console"],
|
||||||
"propagate": False,
|
"propagate": False,
|
||||||
},
|
},
|
||||||
|
"plane.migrations": {
|
||||||
|
"level": "DEBUG" if DEBUG else "INFO",
|
||||||
|
"handlers": ["console"],
|
||||||
|
"propagate": False,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ from rest_framework.response import Response
|
||||||
|
|
||||||
# Module imports
|
# Module imports
|
||||||
from .base import BaseViewSet
|
from .base import BaseViewSet
|
||||||
from plane.db.models import IntakeIssue, Issue, IssueLink, FileAsset, DeployBoard
|
from plane.db.models import IntakeIssue, Issue, IssueLink, FileAsset, DeployBoard, State
|
||||||
from plane.app.serializers import (
|
from plane.app.serializers import (
|
||||||
IssueSerializer,
|
IssueSerializer,
|
||||||
IntakeIssueSerializer,
|
IntakeIssueSerializer,
|
||||||
|
|
@ -121,6 +121,22 @@ class IntakeIssuePublicViewSet(BaseViewSet):
|
||||||
]:
|
]:
|
||||||
return Response({"error": "Invalid priority"}, status=status.HTTP_400_BAD_REQUEST)
|
return Response({"error": "Invalid priority"}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
# get the triage state
|
||||||
|
triage_state = State.triage_objects.filter(
|
||||||
|
project_id=project_deploy_board.project_id, workspace_id=project_deploy_board.workspace_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not triage_state:
|
||||||
|
triage_state = State.objects.create(
|
||||||
|
name="Intake Triage",
|
||||||
|
group=State.TRIAGE,
|
||||||
|
project_id=project_deploy_board.project_id,
|
||||||
|
workspace_id=project_deploy_board.workspace_id,
|
||||||
|
color="#4E5355",
|
||||||
|
sequence=65000,
|
||||||
|
default=False,
|
||||||
|
)
|
||||||
|
|
||||||
# create an issue
|
# create an issue
|
||||||
issue = Issue.objects.create(
|
issue = Issue.objects.create(
|
||||||
name=request.data.get("issue", {}).get("name"),
|
name=request.data.get("issue", {}).get("name"),
|
||||||
|
|
@ -128,6 +144,7 @@ class IntakeIssuePublicViewSet(BaseViewSet):
|
||||||
description_html=request.data.get("issue", {}).get("description_html", "<p></p>"),
|
description_html=request.data.get("issue", {}).get("description_html", "<p></p>"),
|
||||||
priority=request.data.get("issue", {}).get("priority", "low"),
|
priority=request.data.get("issue", {}).get("priority", "low"),
|
||||||
project_id=project_deploy_board.project_id,
|
project_id=project_deploy_board.project_id,
|
||||||
|
state_id=triage_state.id,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create an Issue Activity
|
# Create an Issue Activity
|
||||||
|
|
@ -191,7 +208,7 @@ class IntakeIssuePublicViewSet(BaseViewSet):
|
||||||
issue,
|
issue,
|
||||||
data=issue_data,
|
data=issue_data,
|
||||||
partial=True,
|
partial=True,
|
||||||
context={"project_id": project_deploy_board.project_id},
|
context={"project_id": project_deploy_board.project_id, "allow_triage_state": True},
|
||||||
)
|
)
|
||||||
|
|
||||||
if issue_serializer.is_valid():
|
if issue_serializer.is_valid():
|
||||||
|
|
|
||||||
256
apps/web/core/components/dropdowns/intake-state/base.tsx
Normal file
256
apps/web/core/components/dropdowns/intake-state/base.tsx
Normal file
|
|
@ -0,0 +1,256 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { useRef, useState } from "react";
|
||||||
|
import { observer } from "mobx-react";
|
||||||
|
import { usePopper } from "react-popper";
|
||||||
|
import { Search } from "lucide-react";
|
||||||
|
import { Combobox } from "@headlessui/react";
|
||||||
|
// plane imports
|
||||||
|
import { useTranslation } from "@plane/i18n";
|
||||||
|
import { IntakeStateGroupIcon, ChevronDownIcon } from "@plane/propel/icons";
|
||||||
|
import type { IIntakeState } from "@plane/types";
|
||||||
|
import { ComboDropDown, Spinner } from "@plane/ui";
|
||||||
|
import { cn } from "@plane/utils";
|
||||||
|
// components
|
||||||
|
import { DropdownButton } from "@/components/dropdowns/buttons";
|
||||||
|
import { BUTTON_VARIANTS_WITH_TEXT } from "@/components/dropdowns/constants";
|
||||||
|
import type { TDropdownProps } from "@/components/dropdowns/types";
|
||||||
|
// hooks
|
||||||
|
import { useDropdown } from "@/hooks/use-dropdown";
|
||||||
|
// plane web imports
|
||||||
|
import { StateOption } from "@/plane-web/components/workflow";
|
||||||
|
|
||||||
|
export type TWorkItemStateDropdownBaseProps = TDropdownProps & {
|
||||||
|
alwaysAllowStateChange?: boolean;
|
||||||
|
button?: ReactNode;
|
||||||
|
dropdownArrow?: boolean;
|
||||||
|
dropdownArrowClassName?: string;
|
||||||
|
filterAvailableStateIds?: boolean;
|
||||||
|
getStateById: (stateId: string | null | undefined) => IIntakeState | undefined;
|
||||||
|
iconSize?: string;
|
||||||
|
isForWorkItemCreation?: boolean;
|
||||||
|
isInitializing?: boolean;
|
||||||
|
onChange: (val: string) => void;
|
||||||
|
onClose?: () => void;
|
||||||
|
onDropdownOpen?: () => void;
|
||||||
|
projectId: string | undefined;
|
||||||
|
renderByDefault?: boolean;
|
||||||
|
showDefaultState?: boolean;
|
||||||
|
stateIds: string[];
|
||||||
|
value: string | undefined | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WorkItemStateDropdownBase: React.FC<TWorkItemStateDropdownBaseProps> = observer((props) => {
|
||||||
|
const {
|
||||||
|
button,
|
||||||
|
buttonClassName,
|
||||||
|
buttonContainerClassName,
|
||||||
|
buttonVariant,
|
||||||
|
className = "",
|
||||||
|
disabled = false,
|
||||||
|
dropdownArrow = false,
|
||||||
|
dropdownArrowClassName = "",
|
||||||
|
getStateById,
|
||||||
|
hideIcon = false,
|
||||||
|
iconSize = "size-4",
|
||||||
|
isInitializing = false,
|
||||||
|
onChange,
|
||||||
|
onClose,
|
||||||
|
onDropdownOpen,
|
||||||
|
placement,
|
||||||
|
renderByDefault = true,
|
||||||
|
showDefaultState = true,
|
||||||
|
showTooltip = false,
|
||||||
|
stateIds,
|
||||||
|
tabIndex,
|
||||||
|
value,
|
||||||
|
} = props;
|
||||||
|
// refs
|
||||||
|
const dropdownRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
// popper-js refs
|
||||||
|
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
|
||||||
|
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
|
||||||
|
// states
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
// store hooks
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const statesList = stateIds.map((stateId) => getStateById(stateId)).filter((state) => !!state);
|
||||||
|
const defaultState = statesList?.find((state) => state?.default) || statesList[0];
|
||||||
|
const stateValue = !!value ? value : showDefaultState ? defaultState?.id : undefined;
|
||||||
|
// popper-js init
|
||||||
|
const { styles, attributes } = usePopper(referenceElement, popperElement, {
|
||||||
|
placement: placement ?? "bottom-start",
|
||||||
|
modifiers: [
|
||||||
|
{
|
||||||
|
name: "preventOverflow",
|
||||||
|
options: {
|
||||||
|
padding: 12,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
// dropdown init
|
||||||
|
const { handleClose, handleKeyDown, handleOnClick, searchInputKeyDown } = useDropdown({
|
||||||
|
dropdownRef,
|
||||||
|
inputRef,
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onOpen: onDropdownOpen,
|
||||||
|
query,
|
||||||
|
setIsOpen,
|
||||||
|
setQuery,
|
||||||
|
});
|
||||||
|
|
||||||
|
// derived values
|
||||||
|
const options = statesList?.map((state) => ({
|
||||||
|
value: state?.id,
|
||||||
|
query: `${state?.name}`,
|
||||||
|
content: (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<IntakeStateGroupIcon
|
||||||
|
stateGroup={state?.group ?? "triage"}
|
||||||
|
color={state?.color}
|
||||||
|
className={cn("flex-shrink-0", iconSize)}
|
||||||
|
/>
|
||||||
|
<span className="flex-grow truncate text-left">{state?.name}</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const filteredOptions =
|
||||||
|
query === "" ? options : options?.filter((o) => o.query.toLowerCase().includes(query.toLowerCase()));
|
||||||
|
|
||||||
|
const selectedState = stateValue ? getStateById(stateValue) : undefined;
|
||||||
|
|
||||||
|
const dropdownOnChange = (val: string) => {
|
||||||
|
onChange(val);
|
||||||
|
handleClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const comboButton = (
|
||||||
|
<>
|
||||||
|
{button ? (
|
||||||
|
<button
|
||||||
|
ref={setReferenceElement}
|
||||||
|
type="button"
|
||||||
|
className={cn("clickable block h-full w-full outline-none", buttonContainerClassName)}
|
||||||
|
onClick={handleOnClick}
|
||||||
|
disabled={disabled}
|
||||||
|
tabIndex={tabIndex}
|
||||||
|
>
|
||||||
|
{button}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
tabIndex={tabIndex}
|
||||||
|
ref={setReferenceElement}
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
"clickable block h-full max-w-full outline-none",
|
||||||
|
{
|
||||||
|
"cursor-not-allowed text-custom-text-200": disabled,
|
||||||
|
"cursor-pointer": !disabled,
|
||||||
|
},
|
||||||
|
buttonContainerClassName
|
||||||
|
)}
|
||||||
|
onClick={handleOnClick}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<DropdownButton
|
||||||
|
className={buttonClassName}
|
||||||
|
isActive={isOpen}
|
||||||
|
tooltipHeading={t("state")}
|
||||||
|
tooltipContent={selectedState?.name ?? t("state")}
|
||||||
|
showTooltip={showTooltip}
|
||||||
|
variant={buttonVariant}
|
||||||
|
renderToolTipByDefault={renderByDefault}
|
||||||
|
>
|
||||||
|
{isInitializing ? (
|
||||||
|
<Spinner className="h-3.5 w-3.5" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{!hideIcon && (
|
||||||
|
<IntakeStateGroupIcon
|
||||||
|
stateGroup={selectedState?.group ?? "triage"}
|
||||||
|
color={selectedState?.color ?? "rgba(var(--color-text-300))"}
|
||||||
|
className={cn("flex-shrink-0", iconSize)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{BUTTON_VARIANTS_WITH_TEXT.includes(buttonVariant) && (
|
||||||
|
<span className="flex-grow truncate text-left">{selectedState?.name ?? t("state")}</span>
|
||||||
|
)}
|
||||||
|
{dropdownArrow && (
|
||||||
|
<ChevronDownIcon
|
||||||
|
className={cn("h-2.5 w-2.5 flex-shrink-0", dropdownArrowClassName)}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DropdownButton>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ComboDropDown
|
||||||
|
as="div"
|
||||||
|
ref={dropdownRef}
|
||||||
|
className={cn("h-full", className)}
|
||||||
|
value={stateValue}
|
||||||
|
onChange={dropdownOnChange}
|
||||||
|
disabled={disabled}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
button={comboButton}
|
||||||
|
renderByDefault={renderByDefault}
|
||||||
|
>
|
||||||
|
{isOpen && (
|
||||||
|
<Combobox.Options className="fixed z-10" static>
|
||||||
|
<div
|
||||||
|
className="my-1 w-48 rounded border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 text-xs shadow-custom-shadow-rg focus:outline-none"
|
||||||
|
ref={setPopperElement}
|
||||||
|
style={styles.popper}
|
||||||
|
{...attributes.popper}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1.5 rounded border border-custom-border-100 bg-custom-background-90 px-2">
|
||||||
|
<Search className="h-3.5 w-3.5 text-custom-text-400" strokeWidth={1.5} />
|
||||||
|
<Combobox.Input
|
||||||
|
as="input"
|
||||||
|
ref={inputRef}
|
||||||
|
className="w-full bg-transparent py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
placeholder={t("common.search.label")}
|
||||||
|
displayValue={(assigned: any) => assigned?.name}
|
||||||
|
onKeyDown={searchInputKeyDown}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 max-h-48 space-y-1 overflow-y-scroll">
|
||||||
|
{filteredOptions ? (
|
||||||
|
filteredOptions.length > 0 ? (
|
||||||
|
filteredOptions.map((option) => (
|
||||||
|
<StateOption
|
||||||
|
{...props}
|
||||||
|
key={option.value}
|
||||||
|
option={option}
|
||||||
|
selectedValue={value}
|
||||||
|
className="flex w-full cursor-pointer select-none items-center justify-between gap-2 truncate rounded px-1 py-1.5"
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<p className="px-1.5 py-1 italic text-custom-text-400">{t("no_matching_results")}</p>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<p className="px-1.5 py-1 italic text-custom-text-400">{t("loading")}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Combobox.Options>
|
||||||
|
)}
|
||||||
|
</ComboDropDown>
|
||||||
|
);
|
||||||
|
});
|
||||||
48
apps/web/core/components/dropdowns/intake-state/dropdown.tsx
Normal file
48
apps/web/core/components/dropdowns/intake-state/dropdown.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { observer } from "mobx-react";
|
||||||
|
import { useParams } from "next/navigation";
|
||||||
|
// hooks
|
||||||
|
import { useProjectState } from "@/hooks/store/use-project-state";
|
||||||
|
// local imports
|
||||||
|
import type { TWorkItemStateDropdownBaseProps } from "./base";
|
||||||
|
import { WorkItemStateDropdownBase } from "./base";
|
||||||
|
|
||||||
|
type TWorkItemStateDropdownProps = Omit<
|
||||||
|
TWorkItemStateDropdownBaseProps,
|
||||||
|
"stateIds" | "getStateById" | "onDropdownOpen" | "isInitializing"
|
||||||
|
> & {
|
||||||
|
stateIds?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const IntakeStateDropdown: React.FC<TWorkItemStateDropdownProps> = observer((props) => {
|
||||||
|
const { projectId, stateIds: propsStateIds } = props;
|
||||||
|
// router params
|
||||||
|
const { workspaceSlug } = useParams();
|
||||||
|
// states
|
||||||
|
const [stateLoader, setStateLoader] = useState(false);
|
||||||
|
// store hooks
|
||||||
|
const { fetchProjectIntakeState, getProjectIntakeStateIds, getIntakeStateById } = useProjectState();
|
||||||
|
// derived values
|
||||||
|
const stateIds = propsStateIds ?? getProjectIntakeStateIds(projectId);
|
||||||
|
|
||||||
|
// fetch states if not provided
|
||||||
|
const onDropdownOpen = async () => {
|
||||||
|
if ((stateIds === undefined || stateIds.length === 0) && workspaceSlug && projectId) {
|
||||||
|
setStateLoader(true);
|
||||||
|
await fetchProjectIntakeState(workspaceSlug.toString(), projectId);
|
||||||
|
setStateLoader(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<WorkItemStateDropdownBase
|
||||||
|
{...props}
|
||||||
|
getStateById={getIntakeStateById}
|
||||||
|
isInitializing={stateLoader}
|
||||||
|
stateIds={stateIds ?? []}
|
||||||
|
onDropdownOpen={onDropdownOpen}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
@ -14,9 +14,9 @@ import { ControlLink } from "@plane/ui";
|
||||||
import { getDate, renderFormattedPayloadDate, generateWorkItemLink } from "@plane/utils";
|
import { getDate, renderFormattedPayloadDate, generateWorkItemLink } from "@plane/utils";
|
||||||
// components
|
// components
|
||||||
import { DateDropdown } from "@/components/dropdowns/date";
|
import { DateDropdown } from "@/components/dropdowns/date";
|
||||||
|
import { IntakeStateDropdown } from "@/components/dropdowns/intake-state/dropdown";
|
||||||
import { MemberDropdown } from "@/components/dropdowns/member/dropdown";
|
import { MemberDropdown } from "@/components/dropdowns/member/dropdown";
|
||||||
import { PriorityDropdown } from "@/components/dropdowns/priority";
|
import { PriorityDropdown } from "@/components/dropdowns/priority";
|
||||||
import { StateDropdown } from "@/components/dropdowns/state/dropdown";
|
|
||||||
import type { TIssueOperations } from "@/components/issues/issue-detail";
|
import type { TIssueOperations } from "@/components/issues/issue-detail";
|
||||||
import { IssueLabel } from "@/components/issues/issue-detail/label";
|
import { IssueLabel } from "@/components/issues/issue-detail/label";
|
||||||
// hooks
|
// hooks
|
||||||
|
|
@ -57,18 +57,16 @@ export const InboxIssueContentProperties = observer(function InboxIssueContentPr
|
||||||
<h5 className="text-sm font-medium my-4">Properties</h5>
|
<h5 className="text-sm font-medium my-4">Properties</h5>
|
||||||
<div className={`divide-y-2 divide-custom-border-200 ${!isEditable ? "opacity-60" : ""}`}>
|
<div className={`divide-y-2 divide-custom-border-200 ${!isEditable ? "opacity-60" : ""}`}>
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
{/* State */}
|
{/* Intake State */}
|
||||||
<div className="flex h-8 items-center gap-2">
|
<div className="flex h-8 items-center gap-2">
|
||||||
<div className="flex w-2/5 flex-shrink-0 items-center gap-1 text-sm text-custom-text-300">
|
<div className="flex w-2/5 flex-shrink-0 items-center gap-1 text-sm text-custom-text-300">
|
||||||
<StatePropertyIcon className="h-4 w-4 flex-shrink-0" />
|
<StatePropertyIcon className="h-4 w-4 flex-shrink-0" />
|
||||||
<span>State</span>
|
<span>State</span>
|
||||||
</div>
|
</div>
|
||||||
{issue?.state_id && (
|
{issue?.state_id && (
|
||||||
<StateDropdown
|
<IntakeStateDropdown
|
||||||
value={issue?.state_id}
|
value={issue?.state_id}
|
||||||
onChange={(val) =>
|
onChange={() => {}}
|
||||||
issue?.id && issueOperations.update(workspaceSlug, projectId, issue?.id, { state_id: val })
|
|
||||||
}
|
|
||||||
projectId={projectId?.toString() ?? ""}
|
projectId={projectId?.toString() ?? ""}
|
||||||
disabled={!isEditable}
|
disabled={!isEditable}
|
||||||
buttonVariant="transparent-with-text"
|
buttonVariant="transparent-with-text"
|
||||||
|
|
|
||||||
|
|
@ -53,10 +53,6 @@ export const InboxIssueFilterSelection = observer(function InboxIssueFilterSelec
|
||||||
<div className="py-2">
|
<div className="py-2">
|
||||||
<FilterStatus searchQuery={filtersSearchQuery} />
|
<FilterStatus searchQuery={filtersSearchQuery} />
|
||||||
</div>
|
</div>
|
||||||
{/* state */}
|
|
||||||
<div className="py-2">
|
|
||||||
<FilterState states={projectStates} searchQuery={filtersSearchQuery} />
|
|
||||||
</div>
|
|
||||||
{/* Priority */}
|
{/* Priority */}
|
||||||
<div className="py-2">
|
<div className="py-2">
|
||||||
<FilterPriority searchQuery={filtersSearchQuery} />
|
<FilterPriority searchQuery={filtersSearchQuery} />
|
||||||
|
|
|
||||||
|
|
@ -10,10 +10,10 @@ import { renderFormattedPayloadDate, getDate, getTabIndex } from "@plane/utils";
|
||||||
import { CycleDropdown } from "@/components/dropdowns/cycle";
|
import { CycleDropdown } from "@/components/dropdowns/cycle";
|
||||||
import { DateDropdown } from "@/components/dropdowns/date";
|
import { DateDropdown } from "@/components/dropdowns/date";
|
||||||
import { EstimateDropdown } from "@/components/dropdowns/estimate";
|
import { EstimateDropdown } from "@/components/dropdowns/estimate";
|
||||||
|
import { IntakeStateDropdown } from "@/components/dropdowns/intake-state/dropdown";
|
||||||
import { MemberDropdown } from "@/components/dropdowns/member/dropdown";
|
import { MemberDropdown } from "@/components/dropdowns/member/dropdown";
|
||||||
import { ModuleDropdown } from "@/components/dropdowns/module/dropdown";
|
import { ModuleDropdown } from "@/components/dropdowns/module/dropdown";
|
||||||
import { PriorityDropdown } from "@/components/dropdowns/priority";
|
import { PriorityDropdown } from "@/components/dropdowns/priority";
|
||||||
import { StateDropdown } from "@/components/dropdowns/state/dropdown";
|
|
||||||
import { ParentIssuesListModal } from "@/components/issues/parent-issues-list-modal";
|
import { ParentIssuesListModal } from "@/components/issues/parent-issues-list-modal";
|
||||||
import { IssueLabelSelect } from "@/components/issues/select";
|
import { IssueLabelSelect } from "@/components/issues/select";
|
||||||
// helpers
|
// helpers
|
||||||
|
|
@ -50,9 +50,9 @@ export const InboxIssueProperties = observer(function InboxIssueProperties(props
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex flex-wrap gap-2 items-center">
|
<div className="relative flex flex-wrap gap-2 items-center">
|
||||||
{/* state */}
|
{/* intake state */}
|
||||||
<div className="h-7">
|
<div className="h-7">
|
||||||
<StateDropdown
|
<IntakeStateDropdown
|
||||||
value={data?.state_id}
|
value={data?.state_id}
|
||||||
onChange={(stateId) => handleData("state_id", stateId)}
|
onChange={(stateId) => handleData("state_id", stateId)}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
|
|
|
||||||
|
|
@ -163,6 +163,9 @@ export const PROJECT_MEMBERS = (workspaceSlug: string, projectId: string) =>
|
||||||
export const PROJECT_STATES = (workspaceSlug: string, projectId: string) =>
|
export const PROJECT_STATES = (workspaceSlug: string, projectId: string) =>
|
||||||
`PROJECT_STATES_${projectId.toString().toUpperCase()}`;
|
`PROJECT_STATES_${projectId.toString().toUpperCase()}`;
|
||||||
|
|
||||||
|
export const PROJECT_INTAKE_STATE = (workspaceSlug: string, projectId: string) =>
|
||||||
|
`PROJECT_INTAKE_STATE_${projectId.toString().toUpperCase()}`;
|
||||||
|
|
||||||
export const PROJECT_ESTIMATES = (workspaceSlug: string, projectId: string) =>
|
export const PROJECT_ESTIMATES = (workspaceSlug: string, projectId: string) =>
|
||||||
`PROJECT_ESTIMATES_${projectId.toString().toUpperCase()}`;
|
`PROJECT_ESTIMATES_${projectId.toString().toUpperCase()}`;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import {
|
||||||
PROJECT_ALL_CYCLES,
|
PROJECT_ALL_CYCLES,
|
||||||
PROJECT_MODULES,
|
PROJECT_MODULES,
|
||||||
PROJECT_VIEWS,
|
PROJECT_VIEWS,
|
||||||
|
PROJECT_INTAKE_STATE,
|
||||||
} from "@/constants/fetch-keys";
|
} from "@/constants/fetch-keys";
|
||||||
import { captureClick } from "@/helpers/event-tracker.helper";
|
import { captureClick } from "@/helpers/event-tracker.helper";
|
||||||
// hooks
|
// hooks
|
||||||
|
|
@ -58,8 +59,8 @@ export const ProjectAuthWrapper = observer(function ProjectAuthWrapper(props: IP
|
||||||
const {
|
const {
|
||||||
project: { fetchProjectMembers, fetchProjectMemberPreferences },
|
project: { fetchProjectMembers, fetchProjectMemberPreferences },
|
||||||
} = useMember();
|
} = useMember();
|
||||||
|
const { fetchProjectStates, fetchProjectIntakeState } = useProjectState();
|
||||||
const { data: currentUserData } = useUser();
|
const { data: currentUserData } = useUser();
|
||||||
const { fetchProjectStates } = useProjectState();
|
|
||||||
const { fetchProjectLabels } = useLabel();
|
const { fetchProjectLabels } = useLabel();
|
||||||
const { getProjectEstimates } = useProjectEstimates();
|
const { getProjectEstimates } = useProjectEstimates();
|
||||||
|
|
||||||
|
|
@ -112,6 +113,12 @@ export const ProjectAuthWrapper = observer(function ProjectAuthWrapper(props: IP
|
||||||
workspaceSlug && projectId ? () => fetchProjectStates(workspaceSlug, projectId) : null,
|
workspaceSlug && projectId ? () => fetchProjectStates(workspaceSlug, projectId) : null,
|
||||||
{ revalidateIfStale: false, revalidateOnFocus: false }
|
{ revalidateIfStale: false, revalidateOnFocus: false }
|
||||||
);
|
);
|
||||||
|
// fetching project intake state
|
||||||
|
useSWR(
|
||||||
|
workspaceSlug && projectId ? PROJECT_INTAKE_STATE(workspaceSlug, projectId) : null,
|
||||||
|
workspaceSlug && projectId ? () => fetchProjectIntakeState(workspaceSlug, projectId) : null,
|
||||||
|
{ revalidateIfStale: false, revalidateOnFocus: false }
|
||||||
|
);
|
||||||
// fetching project estimates
|
// fetching project estimates
|
||||||
useSWR(
|
useSWR(
|
||||||
workspaceSlug && projectId ? PROJECT_ESTIMATES(workspaceSlug, projectId) : null,
|
workspaceSlug && projectId ? PROJECT_ESTIMATES(workspaceSlug, projectId) : null,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// services
|
// services
|
||||||
import { API_BASE_URL } from "@plane/constants";
|
import { API_BASE_URL } from "@plane/constants";
|
||||||
import type { IState } from "@plane/types";
|
import type { IIntakeState, IState } from "@plane/types";
|
||||||
import { APIService } from "@/services/api.service";
|
import { APIService } from "@/services/api.service";
|
||||||
// helpers
|
// helpers
|
||||||
// types
|
// types
|
||||||
|
|
@ -34,6 +34,14 @@ export class ProjectStateService extends APIService {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getIntakeState(workspaceSlug: string, projectId: string): Promise<IIntakeState> {
|
||||||
|
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/intake-state/`)
|
||||||
|
.then((response) => response?.data)
|
||||||
|
.catch((error) => {
|
||||||
|
throw error?.response?.data;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async getState(workspaceSlug: string, projectId: string, stateId: string): Promise<any> {
|
async getState(workspaceSlug: string, projectId: string, stateId: string): Promise<any> {
|
||||||
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/states/${stateId}/`)
|
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/states/${stateId}/`)
|
||||||
.then((response) => response?.data)
|
.then((response) => response?.data)
|
||||||
|
|
|
||||||
|
|
@ -469,8 +469,9 @@ export class ProjectInboxStore implements IProjectInboxStore {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
return inboxIssueResponse;
|
return inboxIssueResponse;
|
||||||
} catch {
|
} catch (error) {
|
||||||
console.error("Error creating the intake issue");
|
console.error("Error creating the intake issue");
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { action, computed, makeObservable, observable, runInAction } from "mobx"
|
||||||
import { computedFn } from "mobx-utils";
|
import { computedFn } from "mobx-utils";
|
||||||
// plane imports
|
// plane imports
|
||||||
import { STATE_GROUPS } from "@plane/constants";
|
import { STATE_GROUPS } from "@plane/constants";
|
||||||
import type { IState } from "@plane/types";
|
import type { IIntakeState, IState } from "@plane/types";
|
||||||
// helpers
|
// helpers
|
||||||
import { sortStates } from "@plane/utils";
|
import { sortStates } from "@plane/utils";
|
||||||
// plane web
|
// plane web
|
||||||
|
|
@ -13,19 +13,25 @@ import type { RootStore } from "@/plane-web/store/root.store";
|
||||||
export interface IStateStore {
|
export interface IStateStore {
|
||||||
//Loaders
|
//Loaders
|
||||||
fetchedMap: Record<string, boolean>;
|
fetchedMap: Record<string, boolean>;
|
||||||
|
fetchedIntakeMap: Record<string, boolean>;
|
||||||
// observables
|
// observables
|
||||||
stateMap: Record<string, IState>;
|
stateMap: Record<string, IState>;
|
||||||
|
intakeStateMap: Record<string, IIntakeState>;
|
||||||
// computed
|
// computed
|
||||||
workspaceStates: IState[] | undefined;
|
workspaceStates: IState[] | undefined;
|
||||||
projectStates: IState[] | undefined;
|
projectStates: IState[] | undefined;
|
||||||
groupedProjectStates: Record<string, IState[]> | undefined;
|
groupedProjectStates: Record<string, IState[]> | undefined;
|
||||||
// computed actions
|
// computed actions
|
||||||
getStateById: (stateId: string | null | undefined) => IState | undefined;
|
getStateById: (stateId: string | null | undefined) => IState | undefined;
|
||||||
|
getIntakeStateById: (intakeStateId: string | null | undefined) => IIntakeState | undefined;
|
||||||
getProjectStates: (projectId: string | null | undefined) => IState[] | undefined;
|
getProjectStates: (projectId: string | null | undefined) => IState[] | undefined;
|
||||||
|
getProjectIntakeState: (projectId: string | null | undefined) => IIntakeState | undefined;
|
||||||
getProjectStateIds: (projectId: string | null | undefined) => string[] | undefined;
|
getProjectStateIds: (projectId: string | null | undefined) => string[] | undefined;
|
||||||
|
getProjectIntakeStateIds: (projectId: string | null | undefined) => string[] | undefined;
|
||||||
getProjectDefaultStateId: (projectId: string | null | undefined) => string | undefined;
|
getProjectDefaultStateId: (projectId: string | null | undefined) => string | undefined;
|
||||||
// fetch actions
|
// fetch actions
|
||||||
fetchProjectStates: (workspaceSlug: string, projectId: string) => Promise<IState[]>;
|
fetchProjectStates: (workspaceSlug: string, projectId: string) => Promise<IState[]>;
|
||||||
|
fetchProjectIntakeState: (workspaceSlug: string, projectId: string) => Promise<IIntakeState>;
|
||||||
fetchWorkspaceStates: (workspaceSlug: string) => Promise<IState[]>;
|
fetchWorkspaceStates: (workspaceSlug: string) => Promise<IState[]>;
|
||||||
// crud actions
|
// crud actions
|
||||||
createState: (workspaceSlug: string, projectId: string, data: Partial<IState>) => Promise<IState>;
|
createState: (workspaceSlug: string, projectId: string, data: Partial<IState>) => Promise<IState>;
|
||||||
|
|
@ -49,8 +55,10 @@ export interface IStateStore {
|
||||||
|
|
||||||
export class StateStore implements IStateStore {
|
export class StateStore implements IStateStore {
|
||||||
stateMap: Record<string, IState> = {};
|
stateMap: Record<string, IState> = {};
|
||||||
|
intakeStateMap: Record<string, IIntakeState> = {};
|
||||||
//loaders
|
//loaders
|
||||||
fetchedMap: Record<string, boolean> = {};
|
fetchedMap: Record<string, boolean> = {};
|
||||||
|
fetchedIntakeMap: Record<string, boolean> = {};
|
||||||
rootStore: RootStore;
|
rootStore: RootStore;
|
||||||
router;
|
router;
|
||||||
stateService: ProjectStateService;
|
stateService: ProjectStateService;
|
||||||
|
|
@ -59,12 +67,15 @@ export class StateStore implements IStateStore {
|
||||||
makeObservable(this, {
|
makeObservable(this, {
|
||||||
// observables
|
// observables
|
||||||
stateMap: observable,
|
stateMap: observable,
|
||||||
|
intakeStateMap: observable,
|
||||||
fetchedMap: observable,
|
fetchedMap: observable,
|
||||||
|
fetchedIntakeMap: observable,
|
||||||
// computed
|
// computed
|
||||||
projectStates: computed,
|
projectStates: computed,
|
||||||
groupedProjectStates: computed,
|
groupedProjectStates: computed,
|
||||||
// fetch action
|
// fetch action
|
||||||
fetchProjectStates: action,
|
fetchProjectStates: action,
|
||||||
|
fetchProjectIntakeState: action,
|
||||||
// CRUD actions
|
// CRUD actions
|
||||||
createState: action,
|
createState: action,
|
||||||
updateState: action,
|
updateState: action,
|
||||||
|
|
@ -127,6 +138,15 @@ export class StateStore implements IStateStore {
|
||||||
return this.stateMap[stateId] ?? undefined;
|
return this.stateMap[stateId] ?? undefined;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description returns intake state details using intake state id
|
||||||
|
* @param intakeStateId
|
||||||
|
*/
|
||||||
|
getIntakeStateById = computedFn((intakeStateId: string | null | undefined) => {
|
||||||
|
if (!this.intakeStateMap || !intakeStateId) return;
|
||||||
|
return this.intakeStateMap[intakeStateId] ?? undefined;
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the stateMap belongs to a project by projectId
|
* Returns the stateMap belongs to a project by projectId
|
||||||
* @param projectId
|
* @param projectId
|
||||||
|
|
@ -138,6 +158,16 @@ export class StateStore implements IStateStore {
|
||||||
return sortStates(Object.values(this.stateMap).filter((state) => state.project_id === projectId));
|
return sortStates(Object.values(this.stateMap).filter((state) => state.project_id === projectId));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the intake state for a project by projectId
|
||||||
|
* @param projectId
|
||||||
|
* @returns IIntakeState | undefined
|
||||||
|
*/
|
||||||
|
getProjectIntakeState = computedFn((projectId: string | null | undefined) => {
|
||||||
|
if (!projectId || !this.fetchedIntakeMap[projectId]) return;
|
||||||
|
return Object.values(this.intakeStateMap).find((state) => state.project_id === projectId);
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the state ids for a project by projectId
|
* Returns the state ids for a project by projectId
|
||||||
* @param projectId
|
* @param projectId
|
||||||
|
|
@ -151,6 +181,18 @@ export class StateStore implements IStateStore {
|
||||||
return projectStates?.map((state) => state.id) ?? [];
|
return projectStates?.map((state) => state.id) ?? [];
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the intake state ids for a project by projectId
|
||||||
|
* @param projectId
|
||||||
|
* @returns string[]
|
||||||
|
*/
|
||||||
|
getProjectIntakeStateIds = computedFn((projectId: string | null | undefined) => {
|
||||||
|
const workspaceSlug = this.router.workspaceSlug;
|
||||||
|
if (!workspaceSlug || !projectId || !this.fetchedIntakeMap[projectId]) return undefined;
|
||||||
|
const projectIntakeState = this.getProjectIntakeState(projectId);
|
||||||
|
return projectIntakeState?.id ? [projectIntakeState.id] : [];
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the default state id for a project
|
* Returns the default state id for a project
|
||||||
* @param projectId
|
* @param projectId
|
||||||
|
|
@ -178,6 +220,21 @@ export class StateStore implements IStateStore {
|
||||||
return statesResponse;
|
return statesResponse;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* fetches the intakeStateMap of a project
|
||||||
|
* @param workspaceSlug
|
||||||
|
* @param projectId
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
fetchProjectIntakeState = async (workspaceSlug: string, projectId: string) => {
|
||||||
|
const intakeStateResponse = await this.stateService.getIntakeState(workspaceSlug, projectId);
|
||||||
|
runInAction(() => {
|
||||||
|
set(this.intakeStateMap, [intakeStateResponse.id], intakeStateResponse);
|
||||||
|
set(this.fetchedIntakeMap, projectId, true);
|
||||||
|
});
|
||||||
|
return intakeStateResponse;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* fetches the stateMap of all the states in workspace
|
* fetches the stateMap of all the states in workspace
|
||||||
* @param workspaceSlug
|
* @param workspaceSlug
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { EIconSize } from "@plane/constants";
|
import { EIconSize } from "@plane/constants";
|
||||||
|
import type { TIntakeStateGroups } from "@plane/types";
|
||||||
|
|
||||||
export interface IStateGroupIcon {
|
export interface IStateGroupIcon {
|
||||||
className?: string;
|
className?: string;
|
||||||
|
|
@ -8,6 +9,14 @@ export interface IStateGroupIcon {
|
||||||
percentage?: number;
|
percentage?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface IIntakeStateGroupIcon {
|
||||||
|
className?: string;
|
||||||
|
color?: string;
|
||||||
|
stateGroup: TIntakeStateGroups;
|
||||||
|
size?: EIconSize;
|
||||||
|
percentage?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export type TStateGroups = "backlog" | "unstarted" | "started" | "completed" | "cancelled";
|
export type TStateGroups = "backlog" | "unstarted" | "started" | "completed" | "cancelled";
|
||||||
|
|
||||||
export const STATE_GROUP_COLORS: {
|
export const STATE_GROUP_COLORS: {
|
||||||
|
|
@ -20,6 +29,8 @@ export const STATE_GROUP_COLORS: {
|
||||||
cancelled: "#9AA4BC",
|
cancelled: "#9AA4BC",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const INTAKE_STATE_GROUP_COLORS: { [key in TIntakeStateGroups]: string } = { triage: "#4E5355" };
|
||||||
|
|
||||||
export const STATE_GROUP_SIZES: {
|
export const STATE_GROUP_SIZES: {
|
||||||
[key in EIconSize]: string;
|
[key in EIconSize]: string;
|
||||||
} = {
|
} = {
|
||||||
|
|
|
||||||
|
|
@ -4,3 +4,4 @@ export * from "./completed-group-icon";
|
||||||
export * from "./started-group-icon";
|
export * from "./started-group-icon";
|
||||||
export * from "./state-group-icon";
|
export * from "./state-group-icon";
|
||||||
export * from "./unstarted-group-icon";
|
export * from "./unstarted-group-icon";
|
||||||
|
export * from "./intake-state-group-icon";
|
||||||
|
|
|
||||||
26
packages/propel/src/icons/state/intake-state-group-icon.tsx
Normal file
26
packages/propel/src/icons/state/intake-state-group-icon.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { EIconSize } from "@plane/constants";
|
||||||
|
import type { IIntakeStateGroupIcon } from "./helper";
|
||||||
|
import { INTAKE_STATE_GROUP_COLORS, STATE_GROUP_SIZES } from "./helper";
|
||||||
|
import { TriageGroupIcon } from "./triage-group-icon";
|
||||||
|
|
||||||
|
const iconComponents = { triage: TriageGroupIcon };
|
||||||
|
|
||||||
|
export const IntakeStateGroupIcon: React.FC<IIntakeStateGroupIcon> = ({
|
||||||
|
className = "",
|
||||||
|
color,
|
||||||
|
stateGroup,
|
||||||
|
size = EIconSize.SM,
|
||||||
|
}) => {
|
||||||
|
const IntakeStateIconComponent = iconComponents[stateGroup] || TriageGroupIcon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<IntakeStateIconComponent
|
||||||
|
height={STATE_GROUP_SIZES[size]}
|
||||||
|
width={STATE_GROUP_SIZES[size]}
|
||||||
|
color={color ?? INTAKE_STATE_GROUP_COLORS[stateGroup]}
|
||||||
|
className={`flex-shrink-0 ${className}`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
42
packages/propel/src/icons/state/triage-group-icon.tsx
Normal file
42
packages/propel/src/icons/state/triage-group-icon.tsx
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import type { ISvgIcons } from "../type";
|
||||||
|
|
||||||
|
export const TriageGroupIcon: React.FC<ISvgIcons> = ({ width = "20", height = "20", className, color = "#4E5355" }) => {
|
||||||
|
// SVG parameters
|
||||||
|
const viewBoxSize = 16;
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
height={height}
|
||||||
|
width={width}
|
||||||
|
viewBox={`0 0 ${viewBoxSize} ${viewBoxSize}`}
|
||||||
|
className={className}
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
>
|
||||||
|
<g clip-path="url(#clip0_3870_37)">
|
||||||
|
<path
|
||||||
|
d="M0.75 8C0.75 3.99594 3.99594 0.75 8 0.75C8.32217 0.75 8.58333 1.01117 8.58333 1.33333C8.58333 1.6555 8.32217 1.91667 8 1.91667C4.64027 1.91667 1.91667 4.64027 1.91667 8C1.91667 11.3597 4.64027 14.0833 8 14.0833C8.32217 14.0833 8.58333 14.3445 8.58333 14.6667C8.58333 14.9888 8.32217 15.25 8 15.25C3.99594 15.25 0.75 12.0041 0.75 8Z"
|
||||||
|
fill={color}
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M10.9855 13.9615L11.078 14.1465C11.1916 14.3737 11.1001 14.6515 10.8663 14.7508C10.6324 14.8501 10.3689 14.7229 10.2845 14.4831L10.1195 14.0143C10.0505 13.8183 10.154 13.6046 10.3453 13.5234C10.5366 13.4423 10.7621 13.5162 10.8553 13.7019L10.9855 13.9615ZM12.6866 13.0286C12.8599 13.2144 12.8506 13.5069 12.6541 13.6681C12.4576 13.8292 12.1691 13.7815 12.0205 13.5754L11.7298 13.1724C11.6082 13.0038 11.6472 12.7694 11.8079 12.6375C11.9685 12.5057 12.2058 12.5131 12.3476 12.665L12.6866 13.0286ZM13.9148 11.5026C14.1333 11.632 14.2067 11.9149 14.0638 12.1249C13.921 12.3348 13.6308 12.3704 13.4302 12.2147L13.0373 11.9097C12.8731 11.7823 12.8444 11.5465 12.9614 11.3748C13.0783 11.203 13.3082 11.1431 13.487 11.2491L13.9148 11.5026ZM14.6627 9.69141C14.9089 9.75393 15.0592 10.0048 14.9813 10.2466C14.9034 10.4883 14.635 10.6044 14.3985 10.5116L13.9362 10.3302C13.7427 10.2542 13.6486 10.036 13.7122 9.83814C13.7759 9.64027 13.9795 9.51796 14.181 9.56911L14.6627 9.69141ZM14.8689 7.74357C15.1228 7.73414 15.3378 7.93242 15.3313 8.18638C15.3248 8.44044 15.0999 8.62755 14.8468 8.60509L14.3518 8.56118C14.1448 8.54281 13.9931 8.36009 13.9986 8.15234C14.004 7.94452 14.165 7.76971 14.3728 7.762L14.8689 7.74357ZM14.5173 5.8165C14.7582 5.73553 15.0206 5.86505 15.0861 6.11063C15.1515 6.35609 14.9886 6.59895 14.7395 6.64882L14.252 6.74642C14.0484 6.78719 13.8514 6.65476 13.798 6.45402C13.7446 6.2532 13.8497 6.0402 14.0467 5.97425L14.3221 5.88209L14.5173 5.8165ZM13.6369 4.06668C13.8452 3.92123 14.1334 3.97157 14.2654 4.1887C14.3974 4.40584 14.3096 4.68491 14.0846 4.80306L13.6448 5.03401C13.4608 5.13061 13.2344 5.0591 13.1264 4.88152C13.0185 4.70392 13.0592 4.46992 13.2296 4.35097L13.6369 4.06668ZM12.2981 2.63637C12.457 2.43806 12.7476 2.40502 12.9357 2.57589C13.1237 2.74673 13.1182 3.03909 12.9358 3.21591L12.5786 3.56218C12.4294 3.70686 12.1919 3.70215 12.0383 3.56213C11.8847 3.42214 11.8577 3.18617 11.9876 3.02398L12.2981 2.63637ZM10.6103 1.64162C10.7068 1.4066 10.9763 1.29298 11.2048 1.404C11.4332 1.51499 11.5105 1.79695 11.3855 2.01805L11.1409 2.45067C11.0386 2.63174 10.8092 2.69437 10.6221 2.60353C10.435 2.51273 10.3425 2.29386 10.4215 2.10151L10.6103 1.64162Z"
|
||||||
|
fill={color}
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M10.5833 8C10.5832 8.32217 10.3215 8.58345 9.99935 8.58333C9.67737 8.58307 9.41664 8.32199 9.41667 8V7.4082L6.41276 10.4121C6.18495 10.6399 5.81504 10.6406 5.58724 10.4128C5.35943 10.185 5.35943 9.81505 5.58724 9.58724L7.00195 8.17318C7.49071 7.68442 8.05115 7.12332 8.59115 6.58333H8C7.67783 6.58333 7.41667 6.32217 7.41667 6C7.41667 5.67783 7.67783 5.41667 8 5.41667H10C10.3222 5.41667 10.5833 5.67783 10.5833 6V8Z"
|
||||||
|
fill={color}
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M5.41667 8C5.41679 7.67783 5.67848 7.41655 6.00065 7.41667C6.32263 7.41693 6.58336 7.67801 6.58333 8V8.5918L9.58724 5.58789C9.81504 5.36009 10.185 5.35943 10.4128 5.58724C10.6406 5.81505 10.6406 6.18495 10.4128 6.41276L8.99805 7.82682C8.50929 8.31558 7.94884 8.87668 7.40885 9.41667H8C8.32217 9.41667 8.58333 9.67783 8.58333 10C8.58333 10.3222 8.32217 10.5833 8 10.5833H6C5.67783 10.5833 5.41667 10.3222 5.41667 10V8Z"
|
||||||
|
fill={color}
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_3870_37">
|
||||||
|
<rect width="16" height="16" fill="white" />
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -32,6 +32,7 @@ export * from "./pragmatic";
|
||||||
export * from "./project";
|
export * from "./project";
|
||||||
export * from "./publish";
|
export * from "./publish";
|
||||||
export * from "./reaction";
|
export * from "./reaction";
|
||||||
|
export * from "./intake";
|
||||||
export * from "./rich-filters";
|
export * from "./rich-filters";
|
||||||
export * from "./search";
|
export * from "./search";
|
||||||
export * from "./state";
|
export * from "./state";
|
||||||
|
|
|
||||||
1
packages/types/src/intake/index.ts
Normal file
1
packages/types/src/intake/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export * from "./state";
|
||||||
13
packages/types/src/intake/state.ts
Normal file
13
packages/types/src/intake/state.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
export type TIntakeStateGroups = "triage";
|
||||||
|
|
||||||
|
export interface IIntakeState {
|
||||||
|
readonly id: string;
|
||||||
|
color: string;
|
||||||
|
default: boolean;
|
||||||
|
description: string;
|
||||||
|
group: TIntakeStateGroups;
|
||||||
|
name: string;
|
||||||
|
project_id: string;
|
||||||
|
sequence: number;
|
||||||
|
workspace_id: string;
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue