[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:
Bavisetti Narayan 2025-11-28 16:16:48 +05:30 committed by GitHub
parent dbc5a6348d
commit 78fbdde165
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
36 changed files with 952 additions and 181 deletions

View file

@ -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):
"""

View file

@ -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:

View file

@ -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,11 +373,21 @@ class IntakeIssueDetailAPIEndpoint(BaseAPIView):
issue_serializer = IssueSerializer(issue, data=issue_data, partial=True)
if issue_serializer.is_valid():
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:
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)
if issue is not None:
issue_activity.delay(
type="issue.activity.updated",
requested_data=requested_data,
@ -370,38 +399,14 @@ class IntakeIssueDetailAPIEndpoint(BaseAPIView):
cls=DjangoJSONEncoder,
),
epoch=int(timezone.now().timestamp()),
intake=(intake_issue.id),
intake=str(intake_issue.id),
)
issue_serializer.save()
else:
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)
# Save intake issue (state transition happens in serializer's update method)
if intake_serializer:
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=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()
intake_serializer.save()
# create a activity for status change
issue_activity.delay(
@ -416,9 +421,7 @@ class IntakeIssueDetailAPIEndpoint(BaseAPIView):
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)
return Response(IntakeIssueSerializer(intake_issue).data, status=status.HTTP_200_OK)
else:
return Response(IntakeIssueSerializer(intake_issue).data, status=status.HTTP_200_OK)

View file

@ -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(

View file

@ -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"):

View file

@ -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):

View file

@ -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:

View file

@ -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"}),

View file

@ -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,

View file

@ -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,23 +393,39 @@ 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():
if not issue_serializer.is_valid():
return Response(issue_serializer.errors, status=status.HTTP_400_BAD_REQUEST)
# 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:
intake_current_instance = json.dumps(IntakeIssueSerializer(intake_issue).data, cls=DjangoJSONEncoder)
intake_serializer = IntakeIssueSerializer(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:
issue_serializer.save()
# 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,
requested_data=issue_requested_data,
actor_id=str(request.user.id),
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=True,
origin=base_host(request=request, is_app=True),
@ -395,47 +433,13 @@ class IntakeIssueViewSet(BaseViewSet):
)
# updated issue description version
issue_description_version_task.delay(
updated_issue=current_instance,
updated_issue=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)
# Only project admins can edit intake issue attributes
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()
# 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,
)
# 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()
if intake_serializer:
intake_serializer.save()
# create a activity for status change
issue_activity.delay(
type="intake.activity.created",
@ -443,13 +447,14 @@ class IntakeIssueViewSet(BaseViewSet):
actor_id=str(request.user.id),
issue_id=str(pk),
project_id=str(project_id),
current_instance=current_instance,
current_instance=intake_current_instance,
epoch=int(timezone.now().timestamp()),
notification=False,
origin=base_host(request=request, is_app=True),
intake=(intake_issue.id),
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")
@ -458,9 +463,7 @@ class IntakeIssueViewSet(BaseViewSet):
ArrayAgg(
"issue__labels__id",
distinct=True,
filter=Q(
~Q(issue__labels__id__isnull=True) & Q(issue__label_issue__deleted_at__isnull=True)
),
filter=Q(~Q(issue__labels__id__isnull=True) & Q(issue__label_issue__deleted_at__isnull=True)),
),
Value([], output_field=ArrayField(UUIDField())),
),
@ -469,8 +472,7 @@ class IntakeIssueViewSet(BaseViewSet):
"issue__assignees__id",
distinct=True,
filter=Q(
~Q(issue__assignees__id__isnull=True)
& Q(issue__issue_assignee__deleted_at__isnull=True)
~Q(issue__assignees__id__isnull=True) & Q(issue__issue_assignee__deleted_at__isnull=True)
),
),
Value([], output_field=ArrayField(UUIDField())),
@ -480,10 +482,6 @@ class IntakeIssueViewSet(BaseViewSet):
)
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)
def retrieve(self, request, slug, project_id, pk):

View file

@ -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(

View file

@ -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)

View file

@ -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)

View 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),
]

View file

@ -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)

View file

@ -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}>"

View file

@ -76,5 +76,10 @@ LOGGING = {
"handlers": ["console"],
"propagate": False,
},
"plane.migrations": {
"level": "INFO",
"handlers": ["console"],
"propagate": False,
},
},
}

View file

@ -86,5 +86,10 @@ LOGGING = {
"handlers": ["console"],
"propagate": False,
},
"plane.migrations": {
"level": "DEBUG" if DEBUG else "INFO",
"handlers": ["console"],
"propagate": False,
},
},
}

View file

@ -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():

View 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>
);
});

View 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}
/>
);
});

View file

@ -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"

View file

@ -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} />

View file

@ -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}

View file

@ -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()}`;

View file

@ -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,

View file

@ -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)

View file

@ -469,8 +469,9 @@ export class ProjectInboxStore implements IProjectInboxStore {
);
});
return inboxIssueResponse;
} catch {
} catch (error) {
console.error("Error creating the intake issue");
throw error;
}
};

View file

@ -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

View file

@ -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;
} = {

View file

@ -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";

View 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}`}
/>
);
};

View 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>
);
};

View file

@ -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";

View file

@ -0,0 +1 @@
export * from "./state";

View 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;
}