[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",
|
||||
]
|
||||
|
||||
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):
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
# Module imports
|
||||
from .base import BaseSerializer
|
||||
from plane.db.models import State
|
||||
from rest_framework import serializers
|
||||
|
||||
|
||||
class StateSerializer(BaseSerializer):
|
||||
|
|
@ -15,6 +16,9 @@ class StateSerializer(BaseSerializer):
|
|||
# If the default is being provided then make all other states default False
|
||||
if data.get("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
|
||||
|
||||
class Meta:
|
||||
|
|
|
|||
|
|
@ -165,6 +165,20 @@ class IntakeIssueListCreateAPIEndpoint(BaseAPIView):
|
|||
]:
|
||||
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
|
||||
issue = Issue.objects.create(
|
||||
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>"),
|
||||
priority=request.data.get("issue", {}).get("priority", "none"),
|
||||
project_id=project_id,
|
||||
state_id=triage_state.id,
|
||||
)
|
||||
|
||||
# create an intake issue
|
||||
|
|
@ -320,7 +335,10 @@ class IntakeIssueDetailAPIEndpoint(BaseAPIView):
|
|||
|
||||
# Get issue data
|
||||
issue_data = request.data.pop("issue", False)
|
||||
issue_serializer = None
|
||||
intake_serializer = None
|
||||
|
||||
# Validate issue data if provided
|
||||
if bool(issue_data):
|
||||
issue = Issue.objects.annotate(
|
||||
label_ids=Coalesce(
|
||||
|
|
@ -344,6 +362,7 @@ class IntakeIssueDetailAPIEndpoint(BaseAPIView):
|
|||
Value([], output_field=ArrayField(UUIDField())),
|
||||
),
|
||||
).get(pk=issue_id, workspace__slug=slug, project_id=project_id)
|
||||
|
||||
# Only allow guests to edit name and description
|
||||
if project_member.role <= 5:
|
||||
issue_data = {
|
||||
|
|
@ -354,71 +373,55 @@ class IntakeIssueDetailAPIEndpoint(BaseAPIView):
|
|||
|
||||
issue_serializer = IssueSerializer(issue, data=issue_data, partial=True)
|
||||
|
||||
if issue_serializer.is_valid():
|
||||
current_instance = issue
|
||||
# Log all the updates
|
||||
requested_data = json.dumps(issue_data, cls=DjangoJSONEncoder)
|
||||
if issue is not None:
|
||||
issue_activity.delay(
|
||||
type="issue.activity.updated",
|
||||
requested_data=requested_data,
|
||||
actor_id=str(request.user.id),
|
||||
issue_id=str(issue_id),
|
||||
project_id=str(project_id),
|
||||
current_instance=json.dumps(
|
||||
IssueSerializer(current_instance).data,
|
||||
cls=DjangoJSONEncoder,
|
||||
),
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
intake=(intake_issue.id),
|
||||
)
|
||||
issue_serializer.save()
|
||||
else:
|
||||
if not issue_serializer.is_valid():
|
||||
return Response(issue_serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Only project admins and members can edit intake issue attributes
|
||||
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)
|
||||
intake_serializer.save()
|
||||
|
||||
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=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 serializer.data["status"] in [1]:
|
||||
issue = Issue.objects.get(pk=issue_id, workspace__slug=slug, project_id=project_id)
|
||||
|
||||
# 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)
|
||||
# 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),
|
||||
)
|
||||
return Response(IntakeIssueSerializer(intake_issue).data, status=status.HTTP_200_OK)
|
||||
else:
|
||||
return Response(IntakeIssueSerializer(intake_issue).data, status=status.HTTP_200_OK)
|
||||
|
||||
|
|
|
|||
|
|
@ -265,6 +265,12 @@ class ProjectListCreateAPIEndpoint(BaseAPIView):
|
|||
"sequence": 55000,
|
||||
"group": "cancelled",
|
||||
},
|
||||
{
|
||||
"name": "Intake Triage",
|
||||
"color": "#4E5355",
|
||||
"sequence": 65000,
|
||||
"group": State.TRIAGE,
|
||||
},
|
||||
]
|
||||
|
||||
State.objects.bulk_create(
|
||||
|
|
|
|||
|
|
@ -36,6 +36,54 @@ class IntakeIssueSerializer(BaseSerializer):
|
|||
]
|
||||
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):
|
||||
# Pass the annotated fields to the Issue instance if they exist
|
||||
if hasattr(instance, "label_ids"):
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ class IssueProjectLiteSerializer(BaseSerializer):
|
|||
class IssueCreateSerializer(BaseSerializer):
|
||||
# ids
|
||||
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(
|
||||
source="parent", queryset=Issue.objects.all(), required=False, allow_null=True
|
||||
|
|
@ -117,6 +117,9 @@ class IssueCreateSerializer(BaseSerializer):
|
|||
return data
|
||||
|
||||
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 (
|
||||
attrs.get("start_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
|
||||
if (
|
||||
attrs.get("state")
|
||||
and not State.objects.filter(
|
||||
and not state_manager.filter(
|
||||
project_id=self.context.get("project_id"),
|
||||
pk=attrs.get("state").id,
|
||||
).exists()
|
||||
|
|
@ -795,6 +798,14 @@ class IssueSerializer(DynamicBaseSerializer):
|
|||
]
|
||||
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):
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
|
|
|||
|
|
@ -24,6 +24,12 @@ class StateSerializer(BaseSerializer):
|
|||
]
|
||||
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 Meta:
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
from django.urls import path
|
||||
|
||||
|
||||
from plane.app.views import StateViewSet
|
||||
from plane.app.views import StateViewSet, IntakeStateEndpoint
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
|
|
@ -15,6 +15,11 @@ urlpatterns = [
|
|||
StateViewSet.as_view({"get": "retrieve", "patch": "partial_update", "delete": "destroy"}),
|
||||
name="project-state",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/intake-state/",
|
||||
IntakeStateEndpoint.as_view(),
|
||||
name="intake-state",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/states/<uuid:pk>/mark-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.sticky import WorkspaceStickyViewSet
|
||||
|
||||
from .state.base import StateViewSet
|
||||
from .state.base import StateViewSet, IntakeStateEndpoint
|
||||
from .view.base import (
|
||||
WorkspaceViewViewSet,
|
||||
WorkspaceViewIssuesViewSet,
|
||||
|
|
|
|||
|
|
@ -228,14 +228,30 @@ class IntakeIssueViewSet(BaseViewSet):
|
|||
]:
|
||||
return Response({"error": "Invalid priority"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# create an issue
|
||||
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(
|
||||
data=request.data.get("issue"),
|
||||
context={
|
||||
"project_id": project_id,
|
||||
"workspace_id": project.workspace_id,
|
||||
"default_assignee_id": project.default_assignee_id,
|
||||
"allow_triage_state": True,
|
||||
},
|
||||
)
|
||||
if serializer.is_valid():
|
||||
|
|
@ -344,6 +360,12 @@ class IntakeIssueViewSet(BaseViewSet):
|
|||
|
||||
# Get issue data
|
||||
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):
|
||||
issue = Issue.objects.annotate(
|
||||
label_ids=Coalesce(
|
||||
|
|
@ -371,119 +393,95 @@ class IntakeIssueViewSet(BaseViewSet):
|
|||
"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, 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():
|
||||
# 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:
|
||||
if not issue_serializer.is_valid():
|
||||
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:
|
||||
serializer = IntakeIssueSerializer(intake_issue, data=request.data, partial=True)
|
||||
current_instance = json.dumps(IntakeIssueSerializer(intake_issue).data, cls=DjangoJSONEncoder)
|
||||
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()
|
||||
intake_current_instance = json.dumps(IntakeIssueSerializer(intake_issue).data, cls=DjangoJSONEncoder)
|
||||
intake_serializer = IntakeIssueSerializer(intake_issue, data=request.data, partial=True)
|
||||
|
||||
# Update the issue state if it is accepted
|
||||
if serializer.data["status"] in [1]:
|
||||
issue = Issue.objects.get(
|
||||
pk=intake_issue.issue_id,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
)
|
||||
if not intake_serializer.is_valid():
|
||||
return Response(intake_serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# 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
|
||||
# Both serializers are valid, now save them
|
||||
if issue_serializer:
|
||||
issue_serializer.save()
|
||||
# Log all the updates
|
||||
if issue is not None:
|
||||
issue_activity.delay(
|
||||
type="intake.activity.created",
|
||||
requested_data=json.dumps(request.data, cls=DjangoJSONEncoder),
|
||||
type="issue.activity.updated",
|
||||
requested_data=issue_requested_data,
|
||||
actor_id=str(request.user.id),
|
||||
issue_id=str(pk),
|
||||
issue_id=str(issue.id),
|
||||
project_id=str(project_id),
|
||||
current_instance=current_instance,
|
||||
current_instance=issue_current_instance,
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=False,
|
||||
notification=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 = (
|
||||
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())),
|
||||
if intake_serializer:
|
||||
intake_serializer.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(pk),
|
||||
project_id=str(project_id),
|
||||
current_instance=intake_current_instance,
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=False,
|
||||
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(
|
||||
"issue__assignees__id",
|
||||
distinct=True,
|
||||
filter=Q(
|
||||
~Q(issue__assignees__id__isnull=True)
|
||||
& Q(issue__issue_assignee__deleted_at__isnull=True)
|
||||
),
|
||||
),
|
||||
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)
|
||||
),
|
||||
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)
|
||||
|
||||
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], creator=True, model=Issue)
|
||||
def retrieve(self, request, slug, project_id, pk):
|
||||
|
|
|
|||
|
|
@ -297,6 +297,12 @@ class ProjectViewSet(BaseViewSet):
|
|||
"sequence": 55000,
|
||||
"group": "cancelled",
|
||||
},
|
||||
{
|
||||
"name": "Intake Triage",
|
||||
"color": "#4E5355",
|
||||
"sequence": 65000,
|
||||
"group": State.TRIAGE,
|
||||
},
|
||||
]
|
||||
|
||||
State.objects.bulk_create(
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ from rest_framework.response import Response
|
|||
from rest_framework import status
|
||||
|
||||
# Module imports
|
||||
from .. import BaseViewSet
|
||||
from .. import BaseViewSet, BaseAPIView
|
||||
from plane.app.serializers import StateSerializer
|
||||
from plane.app.permissions import ROLE, allow_permission
|
||||
from plane.db.models import State, Issue
|
||||
|
|
@ -127,3 +127,16 @@ class StateViewSet(BaseViewSet):
|
|||
|
||||
state.delete()
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
|
|
|
|||
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 .description import Description
|
||||
from plane.db.mixins import ChangeTrackerMixin
|
||||
from .state import State
|
||||
|
||||
|
||||
def get_default_properties():
|
||||
|
|
@ -97,6 +98,7 @@ class IssueManager(SoftDeletionManager):
|
|||
)
|
||||
.filter(deleted_at__isnull=True)
|
||||
.filter(state__is_triage=False)
|
||||
.exclude(state__group=State.TRIAGE)
|
||||
.exclude(archived_at__isnull=False)
|
||||
.exclude(project__archived_at__isnull=False)
|
||||
.exclude(is_draft=True)
|
||||
|
|
|
|||
|
|
@ -7,7 +7,36 @@ from django.db.models import Q
|
|||
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):
|
||||
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")
|
||||
description = models.TextField(verbose_name="State Description", blank=True)
|
||||
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_id = models.CharField(max_length=255, blank=True, null=True)
|
||||
|
||||
objects = StateManager()
|
||||
all_state_objects = models.Manager()
|
||||
triage_objects = TriageStateManager()
|
||||
|
||||
def __str__(self):
|
||||
"""Return name of the state"""
|
||||
return f"{self.name} <{self.project.name}>"
|
||||
|
|
|
|||
|
|
@ -76,5 +76,10 @@ LOGGING = {
|
|||
"handlers": ["console"],
|
||||
"propagate": False,
|
||||
},
|
||||
"plane.migrations": {
|
||||
"level": "INFO",
|
||||
"handlers": ["console"],
|
||||
"propagate": False,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -86,5 +86,10 @@ LOGGING = {
|
|||
"handlers": ["console"],
|
||||
"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
|
||||
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 (
|
||||
IssueSerializer,
|
||||
IntakeIssueSerializer,
|
||||
|
|
@ -121,6 +121,22 @@ class IntakeIssuePublicViewSet(BaseViewSet):
|
|||
]:
|
||||
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
|
||||
issue = Issue.objects.create(
|
||||
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>"),
|
||||
priority=request.data.get("issue", {}).get("priority", "low"),
|
||||
project_id=project_deploy_board.project_id,
|
||||
state_id=triage_state.id,
|
||||
)
|
||||
|
||||
# Create an Issue Activity
|
||||
|
|
@ -191,7 +208,7 @@ class IntakeIssuePublicViewSet(BaseViewSet):
|
|||
issue,
|
||||
data=issue_data,
|
||||
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():
|
||||
|
|
|
|||
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";
|
||||
// components
|
||||
import { DateDropdown } from "@/components/dropdowns/date";
|
||||
import { IntakeStateDropdown } from "@/components/dropdowns/intake-state/dropdown";
|
||||
import { MemberDropdown } from "@/components/dropdowns/member/dropdown";
|
||||
import { PriorityDropdown } from "@/components/dropdowns/priority";
|
||||
import { StateDropdown } from "@/components/dropdowns/state/dropdown";
|
||||
import type { TIssueOperations } from "@/components/issues/issue-detail";
|
||||
import { IssueLabel } from "@/components/issues/issue-detail/label";
|
||||
// hooks
|
||||
|
|
@ -57,18 +57,16 @@ export const InboxIssueContentProperties = observer(function InboxIssueContentPr
|
|||
<h5 className="text-sm font-medium my-4">Properties</h5>
|
||||
<div className={`divide-y-2 divide-custom-border-200 ${!isEditable ? "opacity-60" : ""}`}>
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* State */}
|
||||
{/* Intake State */}
|
||||
<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">
|
||||
<StatePropertyIcon className="h-4 w-4 flex-shrink-0" />
|
||||
<span>State</span>
|
||||
</div>
|
||||
{issue?.state_id && (
|
||||
<StateDropdown
|
||||
<IntakeStateDropdown
|
||||
value={issue?.state_id}
|
||||
onChange={(val) =>
|
||||
issue?.id && issueOperations.update(workspaceSlug, projectId, issue?.id, { state_id: val })
|
||||
}
|
||||
onChange={() => {}}
|
||||
projectId={projectId?.toString() ?? ""}
|
||||
disabled={!isEditable}
|
||||
buttonVariant="transparent-with-text"
|
||||
|
|
|
|||
|
|
@ -53,10 +53,6 @@ export const InboxIssueFilterSelection = observer(function InboxIssueFilterSelec
|
|||
<div className="py-2">
|
||||
<FilterStatus searchQuery={filtersSearchQuery} />
|
||||
</div>
|
||||
{/* state */}
|
||||
<div className="py-2">
|
||||
<FilterState states={projectStates} searchQuery={filtersSearchQuery} />
|
||||
</div>
|
||||
{/* Priority */}
|
||||
<div className="py-2">
|
||||
<FilterPriority searchQuery={filtersSearchQuery} />
|
||||
|
|
|
|||
|
|
@ -10,10 +10,10 @@ import { renderFormattedPayloadDate, getDate, getTabIndex } from "@plane/utils";
|
|||
import { CycleDropdown } from "@/components/dropdowns/cycle";
|
||||
import { DateDropdown } from "@/components/dropdowns/date";
|
||||
import { EstimateDropdown } from "@/components/dropdowns/estimate";
|
||||
import { IntakeStateDropdown } from "@/components/dropdowns/intake-state/dropdown";
|
||||
import { MemberDropdown } from "@/components/dropdowns/member/dropdown";
|
||||
import { ModuleDropdown } from "@/components/dropdowns/module/dropdown";
|
||||
import { PriorityDropdown } from "@/components/dropdowns/priority";
|
||||
import { StateDropdown } from "@/components/dropdowns/state/dropdown";
|
||||
import { ParentIssuesListModal } from "@/components/issues/parent-issues-list-modal";
|
||||
import { IssueLabelSelect } from "@/components/issues/select";
|
||||
// helpers
|
||||
|
|
@ -50,9 +50,9 @@ export const InboxIssueProperties = observer(function InboxIssueProperties(props
|
|||
|
||||
return (
|
||||
<div className="relative flex flex-wrap gap-2 items-center">
|
||||
{/* state */}
|
||||
{/* intake state */}
|
||||
<div className="h-7">
|
||||
<StateDropdown
|
||||
<IntakeStateDropdown
|
||||
value={data?.state_id}
|
||||
onChange={(stateId) => handleData("state_id", stateId)}
|
||||
projectId={projectId}
|
||||
|
|
|
|||
|
|
@ -163,6 +163,9 @@ export const PROJECT_MEMBERS = (workspaceSlug: string, projectId: string) =>
|
|||
export const PROJECT_STATES = (workspaceSlug: string, projectId: string) =>
|
||||
`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) =>
|
||||
`PROJECT_ESTIMATES_${projectId.toString().toUpperCase()}`;
|
||||
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import {
|
|||
PROJECT_ALL_CYCLES,
|
||||
PROJECT_MODULES,
|
||||
PROJECT_VIEWS,
|
||||
PROJECT_INTAKE_STATE,
|
||||
} from "@/constants/fetch-keys";
|
||||
import { captureClick } from "@/helpers/event-tracker.helper";
|
||||
// hooks
|
||||
|
|
@ -58,8 +59,8 @@ export const ProjectAuthWrapper = observer(function ProjectAuthWrapper(props: IP
|
|||
const {
|
||||
project: { fetchProjectMembers, fetchProjectMemberPreferences },
|
||||
} = useMember();
|
||||
const { fetchProjectStates, fetchProjectIntakeState } = useProjectState();
|
||||
const { data: currentUserData } = useUser();
|
||||
const { fetchProjectStates } = useProjectState();
|
||||
const { fetchProjectLabels } = useLabel();
|
||||
const { getProjectEstimates } = useProjectEstimates();
|
||||
|
||||
|
|
@ -112,6 +113,12 @@ export const ProjectAuthWrapper = observer(function ProjectAuthWrapper(props: IP
|
|||
workspaceSlug && projectId ? () => fetchProjectStates(workspaceSlug, projectId) : null,
|
||||
{ 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
|
||||
useSWR(
|
||||
workspaceSlug && projectId ? PROJECT_ESTIMATES(workspaceSlug, projectId) : null,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
// services
|
||||
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";
|
||||
// helpers
|
||||
// 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> {
|
||||
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/states/${stateId}/`)
|
||||
.then((response) => response?.data)
|
||||
|
|
|
|||
|
|
@ -469,8 +469,9 @@ export class ProjectInboxStore implements IProjectInboxStore {
|
|||
);
|
||||
});
|
||||
return inboxIssueResponse;
|
||||
} catch {
|
||||
} catch (error) {
|
||||
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";
|
||||
// plane imports
|
||||
import { STATE_GROUPS } from "@plane/constants";
|
||||
import type { IState } from "@plane/types";
|
||||
import type { IIntakeState, IState } from "@plane/types";
|
||||
// helpers
|
||||
import { sortStates } from "@plane/utils";
|
||||
// plane web
|
||||
|
|
@ -13,19 +13,25 @@ import type { RootStore } from "@/plane-web/store/root.store";
|
|||
export interface IStateStore {
|
||||
//Loaders
|
||||
fetchedMap: Record<string, boolean>;
|
||||
fetchedIntakeMap: Record<string, boolean>;
|
||||
// observables
|
||||
stateMap: Record<string, IState>;
|
||||
intakeStateMap: Record<string, IIntakeState>;
|
||||
// computed
|
||||
workspaceStates: IState[] | undefined;
|
||||
projectStates: IState[] | undefined;
|
||||
groupedProjectStates: Record<string, IState[]> | undefined;
|
||||
// computed actions
|
||||
getStateById: (stateId: string | null | undefined) => IState | undefined;
|
||||
getIntakeStateById: (intakeStateId: string | null | undefined) => IIntakeState | undefined;
|
||||
getProjectStates: (projectId: string | null | undefined) => IState[] | undefined;
|
||||
getProjectIntakeState: (projectId: string | null | undefined) => IIntakeState | undefined;
|
||||
getProjectStateIds: (projectId: string | null | undefined) => string[] | undefined;
|
||||
getProjectIntakeStateIds: (projectId: string | null | undefined) => string[] | undefined;
|
||||
getProjectDefaultStateId: (projectId: string | null | undefined) => string | undefined;
|
||||
// fetch actions
|
||||
fetchProjectStates: (workspaceSlug: string, projectId: string) => Promise<IState[]>;
|
||||
fetchProjectIntakeState: (workspaceSlug: string, projectId: string) => Promise<IIntakeState>;
|
||||
fetchWorkspaceStates: (workspaceSlug: string) => Promise<IState[]>;
|
||||
// crud actions
|
||||
createState: (workspaceSlug: string, projectId: string, data: Partial<IState>) => Promise<IState>;
|
||||
|
|
@ -49,8 +55,10 @@ export interface IStateStore {
|
|||
|
||||
export class StateStore implements IStateStore {
|
||||
stateMap: Record<string, IState> = {};
|
||||
intakeStateMap: Record<string, IIntakeState> = {};
|
||||
//loaders
|
||||
fetchedMap: Record<string, boolean> = {};
|
||||
fetchedIntakeMap: Record<string, boolean> = {};
|
||||
rootStore: RootStore;
|
||||
router;
|
||||
stateService: ProjectStateService;
|
||||
|
|
@ -59,12 +67,15 @@ export class StateStore implements IStateStore {
|
|||
makeObservable(this, {
|
||||
// observables
|
||||
stateMap: observable,
|
||||
intakeStateMap: observable,
|
||||
fetchedMap: observable,
|
||||
fetchedIntakeMap: observable,
|
||||
// computed
|
||||
projectStates: computed,
|
||||
groupedProjectStates: computed,
|
||||
// fetch action
|
||||
fetchProjectStates: action,
|
||||
fetchProjectIntakeState: action,
|
||||
// CRUD actions
|
||||
createState: action,
|
||||
updateState: action,
|
||||
|
|
@ -127,6 +138,15 @@ export class StateStore implements IStateStore {
|
|||
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
|
||||
* @param projectId
|
||||
|
|
@ -138,6 +158,16 @@ export class StateStore implements IStateStore {
|
|||
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
|
||||
* @param projectId
|
||||
|
|
@ -151,6 +181,18 @@ export class StateStore implements IStateStore {
|
|||
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
|
||||
* @param projectId
|
||||
|
|
@ -178,6 +220,21 @@ export class StateStore implements IStateStore {
|
|||
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
|
||||
* @param workspaceSlug
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { EIconSize } from "@plane/constants";
|
||||
import type { TIntakeStateGroups } from "@plane/types";
|
||||
|
||||
export interface IStateGroupIcon {
|
||||
className?: string;
|
||||
|
|
@ -8,6 +9,14 @@ export interface IStateGroupIcon {
|
|||
percentage?: number;
|
||||
}
|
||||
|
||||
export interface IIntakeStateGroupIcon {
|
||||
className?: string;
|
||||
color?: string;
|
||||
stateGroup: TIntakeStateGroups;
|
||||
size?: EIconSize;
|
||||
percentage?: number;
|
||||
}
|
||||
|
||||
export type TStateGroups = "backlog" | "unstarted" | "started" | "completed" | "cancelled";
|
||||
|
||||
export const STATE_GROUP_COLORS: {
|
||||
|
|
@ -20,6 +29,8 @@ export const STATE_GROUP_COLORS: {
|
|||
cancelled: "#9AA4BC",
|
||||
};
|
||||
|
||||
export const INTAKE_STATE_GROUP_COLORS: { [key in TIntakeStateGroups]: string } = { triage: "#4E5355" };
|
||||
|
||||
export const STATE_GROUP_SIZES: {
|
||||
[key in EIconSize]: string;
|
||||
} = {
|
||||
|
|
|
|||
|
|
@ -4,3 +4,4 @@ export * from "./completed-group-icon";
|
|||
export * from "./started-group-icon";
|
||||
export * from "./state-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 "./publish";
|
||||
export * from "./reaction";
|
||||
export * from "./intake";
|
||||
export * from "./rich-filters";
|
||||
export * from "./search";
|
||||
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