diff --git a/apps/api/plane/api/serializers/intake.py b/apps/api/plane/api/serializers/intake.py index 5fd846d1d..d2cdd5a31 100644 --- a/apps/api/plane/api/serializers/intake.py +++ b/apps/api/plane/api/serializers/intake.py @@ -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): """ diff --git a/apps/api/plane/api/serializers/state.py b/apps/api/plane/api/serializers/state.py index fc6aac15e..10b3e84f9 100644 --- a/apps/api/plane/api/serializers/state.py +++ b/apps/api/plane/api/serializers/state.py @@ -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: diff --git a/apps/api/plane/api/views/intake.py b/apps/api/plane/api/views/intake.py index 7a00fa431..081c2f9f2 100644 --- a/apps/api/plane/api/views/intake.py +++ b/apps/api/plane/api/views/intake.py @@ -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", "

"), 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) diff --git a/apps/api/plane/api/views/project.py b/apps/api/plane/api/views/project.py index 131932bf2..2f3db4237 100644 --- a/apps/api/plane/api/views/project.py +++ b/apps/api/plane/api/views/project.py @@ -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( diff --git a/apps/api/plane/app/serializers/intake.py b/apps/api/plane/app/serializers/intake.py index 7bc258220..9ed3494a7 100644 --- a/apps/api/plane/app/serializers/intake.py +++ b/apps/api/plane/app/serializers/intake.py @@ -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"): diff --git a/apps/api/plane/app/serializers/issue.py b/apps/api/plane/app/serializers/issue.py index 21a286d5e..5e3b93ab6 100644 --- a/apps/api/plane/app/serializers/issue.py +++ b/apps/api/plane/app/serializers/issue.py @@ -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): diff --git a/apps/api/plane/app/serializers/state.py b/apps/api/plane/app/serializers/state.py index 29d8cf302..f7ad053fd 100644 --- a/apps/api/plane/app/serializers/state.py +++ b/apps/api/plane/app/serializers/state.py @@ -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: diff --git a/apps/api/plane/app/urls/state.py b/apps/api/plane/app/urls/state.py index 7dcf01d62..b6135ca95 100644 --- a/apps/api/plane/app/urls/state.py +++ b/apps/api/plane/app/urls/state.py @@ -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//projects//intake-state/", + IntakeStateEndpoint.as_view(), + name="intake-state", + ), path( "workspaces//projects//states//mark-default/", StateViewSet.as_view({"post": "mark_as_default"}), diff --git a/apps/api/plane/app/views/__init__.py b/apps/api/plane/app/views/__init__.py index 500bf0505..5f848a5ba 100644 --- a/apps/api/plane/app/views/__init__.py +++ b/apps/api/plane/app/views/__init__.py @@ -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, diff --git a/apps/api/plane/app/views/intake/base.py b/apps/api/plane/app/views/intake/base.py index cc6379131..d2dd920c8 100644 --- a/apps/api/plane/app/views/intake/base.py +++ b/apps/api/plane/app/views/intake/base.py @@ -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): diff --git a/apps/api/plane/app/views/project/base.py b/apps/api/plane/app/views/project/base.py index 84b2a5629..85207059a 100644 --- a/apps/api/plane/app/views/project/base.py +++ b/apps/api/plane/app/views/project/base.py @@ -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( diff --git a/apps/api/plane/app/views/state/base.py b/apps/api/plane/app/views/state/base.py index bb5026b0f..d5e876f94 100644 --- a/apps/api/plane/app/views/state/base.py +++ b/apps/api/plane/app/views/state/base.py @@ -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) diff --git a/apps/api/plane/bgtasks/dummy_data_task.py b/apps/api/plane/bgtasks/dummy_data_task.py index 3220ef0c0..dff8239c4 100644 --- a/apps/api/plane/bgtasks/dummy_data_task.py +++ b/apps/api/plane/bgtasks/dummy_data_task.py @@ -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) diff --git a/apps/api/plane/db/migrations/0112_auto_20251124_0603.py b/apps/api/plane/db/migrations/0112_auto_20251124_0603.py new file mode 100644 index 000000000..6d5d4b9da --- /dev/null +++ b/apps/api/plane/db/migrations/0112_auto_20251124_0603.py @@ -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), + ] diff --git a/apps/api/plane/db/models/issue.py b/apps/api/plane/db/models/issue.py index 83376c00d..9e040303c 100644 --- a/apps/api/plane/db/models/issue.py +++ b/apps/api/plane/db/models/issue.py @@ -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) diff --git a/apps/api/plane/db/models/state.py b/apps/api/plane/db/models/state.py index e9d56acf9..104954490 100644 --- a/apps/api/plane/db/models/state.py +++ b/apps/api/plane/db/models/state.py @@ -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}>" diff --git a/apps/api/plane/settings/local.py b/apps/api/plane/settings/local.py index 84737712b..15f05aa3d 100644 --- a/apps/api/plane/settings/local.py +++ b/apps/api/plane/settings/local.py @@ -76,5 +76,10 @@ LOGGING = { "handlers": ["console"], "propagate": False, }, + "plane.migrations": { + "level": "INFO", + "handlers": ["console"], + "propagate": False, + }, }, } diff --git a/apps/api/plane/settings/production.py b/apps/api/plane/settings/production.py index 4725db38a..8df7ae906 100644 --- a/apps/api/plane/settings/production.py +++ b/apps/api/plane/settings/production.py @@ -86,5 +86,10 @@ LOGGING = { "handlers": ["console"], "propagate": False, }, + "plane.migrations": { + "level": "DEBUG" if DEBUG else "INFO", + "handlers": ["console"], + "propagate": False, + }, }, } diff --git a/apps/api/plane/space/views/intake.py b/apps/api/plane/space/views/intake.py index 60d4443b5..e82d0523e 100644 --- a/apps/api/plane/space/views/intake.py +++ b/apps/api/plane/space/views/intake.py @@ -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", "

"), 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(): diff --git a/apps/web/core/components/dropdowns/intake-state/base.tsx b/apps/web/core/components/dropdowns/intake-state/base.tsx new file mode 100644 index 000000000..c51c7cbf7 --- /dev/null +++ b/apps/web/core/components/dropdowns/intake-state/base.tsx @@ -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 = 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(null); + const inputRef = useRef(null); + // popper-js refs + const [referenceElement, setReferenceElement] = useState(null); + const [popperElement, setPopperElement] = useState(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: ( +
+ + {state?.name} +
+ ), + })); + + 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 ? ( + + ) : ( + + )} + + ); + + return ( + + {isOpen && ( + +
+
+ + setQuery(e.target.value)} + placeholder={t("common.search.label")} + displayValue={(assigned: any) => assigned?.name} + onKeyDown={searchInputKeyDown} + /> +
+
+ {filteredOptions ? ( + filteredOptions.length > 0 ? ( + filteredOptions.map((option) => ( + + )) + ) : ( +

{t("no_matching_results")}

+ ) + ) : ( +

{t("loading")}

+ )} +
+
+
+ )} +
+ ); +}); diff --git a/apps/web/core/components/dropdowns/intake-state/dropdown.tsx b/apps/web/core/components/dropdowns/intake-state/dropdown.tsx new file mode 100644 index 000000000..11a7652e6 --- /dev/null +++ b/apps/web/core/components/dropdowns/intake-state/dropdown.tsx @@ -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 = 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 ( + + ); +}); diff --git a/apps/web/core/components/inbox/content/issue-properties.tsx b/apps/web/core/components/inbox/content/issue-properties.tsx index 20d1915ee..fcab62d89 100644 --- a/apps/web/core/components/inbox/content/issue-properties.tsx +++ b/apps/web/core/components/inbox/content/issue-properties.tsx @@ -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
Properties
- {/* State */} + {/* Intake State */}
State
{issue?.state_id && ( - - issue?.id && issueOperations.update(workspaceSlug, projectId, issue?.id, { state_id: val }) - } + onChange={() => {}} projectId={projectId?.toString() ?? ""} disabled={!isEditable} buttonVariant="transparent-with-text" diff --git a/apps/web/core/components/inbox/inbox-filter/filters/filter-selection.tsx b/apps/web/core/components/inbox/inbox-filter/filters/filter-selection.tsx index f989be259..88f851493 100644 --- a/apps/web/core/components/inbox/inbox-filter/filters/filter-selection.tsx +++ b/apps/web/core/components/inbox/inbox-filter/filters/filter-selection.tsx @@ -53,10 +53,6 @@ export const InboxIssueFilterSelection = observer(function InboxIssueFilterSelec
- {/* state */} -
- -
{/* Priority */}
diff --git a/apps/web/core/components/inbox/modals/create-modal/issue-properties.tsx b/apps/web/core/components/inbox/modals/create-modal/issue-properties.tsx index ac6992afb..99bf55883 100644 --- a/apps/web/core/components/inbox/modals/create-modal/issue-properties.tsx +++ b/apps/web/core/components/inbox/modals/create-modal/issue-properties.tsx @@ -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 (
- {/* state */} + {/* intake state */}
- handleData("state_id", stateId)} projectId={projectId} diff --git a/apps/web/core/constants/fetch-keys.ts b/apps/web/core/constants/fetch-keys.ts index de51b254c..ab7a6d693 100644 --- a/apps/web/core/constants/fetch-keys.ts +++ b/apps/web/core/constants/fetch-keys.ts @@ -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()}`; diff --git a/apps/web/core/layouts/auth-layout/project-wrapper.tsx b/apps/web/core/layouts/auth-layout/project-wrapper.tsx index 3db09bee6..08515caa1 100644 --- a/apps/web/core/layouts/auth-layout/project-wrapper.tsx +++ b/apps/web/core/layouts/auth-layout/project-wrapper.tsx @@ -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, diff --git a/apps/web/core/services/project/project-state.service.ts b/apps/web/core/services/project/project-state.service.ts index d74fc1391..ca0d344d0 100644 --- a/apps/web/core/services/project/project-state.service.ts +++ b/apps/web/core/services/project/project-state.service.ts @@ -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 { + 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 { return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/states/${stateId}/`) .then((response) => response?.data) diff --git a/apps/web/core/store/inbox/project-inbox.store.ts b/apps/web/core/store/inbox/project-inbox.store.ts index 5d5868624..b5e659361 100644 --- a/apps/web/core/store/inbox/project-inbox.store.ts +++ b/apps/web/core/store/inbox/project-inbox.store.ts @@ -469,8 +469,9 @@ export class ProjectInboxStore implements IProjectInboxStore { ); }); return inboxIssueResponse; - } catch { + } catch (error) { console.error("Error creating the intake issue"); + throw error; } }; diff --git a/apps/web/core/store/state.store.ts b/apps/web/core/store/state.store.ts index 9085ab0d9..91130b826 100644 --- a/apps/web/core/store/state.store.ts +++ b/apps/web/core/store/state.store.ts @@ -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; + fetchedIntakeMap: Record; // observables stateMap: Record; + intakeStateMap: Record; // computed workspaceStates: IState[] | undefined; projectStates: IState[] | undefined; groupedProjectStates: Record | 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; + fetchProjectIntakeState: (workspaceSlug: string, projectId: string) => Promise; fetchWorkspaceStates: (workspaceSlug: string) => Promise; // crud actions createState: (workspaceSlug: string, projectId: string, data: Partial) => Promise; @@ -49,8 +55,10 @@ export interface IStateStore { export class StateStore implements IStateStore { stateMap: Record = {}; + intakeStateMap: Record = {}; //loaders fetchedMap: Record = {}; + fetchedIntakeMap: Record = {}; 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 diff --git a/packages/propel/src/icons/state/helper.tsx b/packages/propel/src/icons/state/helper.tsx index fda3eff34..2e73e7569 100644 --- a/packages/propel/src/icons/state/helper.tsx +++ b/packages/propel/src/icons/state/helper.tsx @@ -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; } = { diff --git a/packages/propel/src/icons/state/index.ts b/packages/propel/src/icons/state/index.ts index 0e5fdadd6..c050c1c32 100644 --- a/packages/propel/src/icons/state/index.ts +++ b/packages/propel/src/icons/state/index.ts @@ -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"; diff --git a/packages/propel/src/icons/state/intake-state-group-icon.tsx b/packages/propel/src/icons/state/intake-state-group-icon.tsx new file mode 100644 index 000000000..23bda1232 --- /dev/null +++ b/packages/propel/src/icons/state/intake-state-group-icon.tsx @@ -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 = ({ + className = "", + color, + stateGroup, + size = EIconSize.SM, +}) => { + const IntakeStateIconComponent = iconComponents[stateGroup] || TriageGroupIcon; + + return ( + + ); +}; diff --git a/packages/propel/src/icons/state/triage-group-icon.tsx b/packages/propel/src/icons/state/triage-group-icon.tsx new file mode 100644 index 000000000..409324f72 --- /dev/null +++ b/packages/propel/src/icons/state/triage-group-icon.tsx @@ -0,0 +1,42 @@ +import * as React from "react"; + +import type { ISvgIcons } from "../type"; + +export const TriageGroupIcon: React.FC = ({ width = "20", height = "20", className, color = "#4E5355" }) => { + // SVG parameters + const viewBoxSize = 16; + return ( + + + + + + + + + + + + + + ); +}; diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index c646506a1..c0656450d 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -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"; diff --git a/packages/types/src/intake/index.ts b/packages/types/src/intake/index.ts new file mode 100644 index 000000000..be6f71571 --- /dev/null +++ b/packages/types/src/intake/index.ts @@ -0,0 +1 @@ +export * from "./state"; diff --git a/packages/types/src/intake/state.ts b/packages/types/src/intake/state.ts new file mode 100644 index 000000000..27984a070 --- /dev/null +++ b/packages/types/src/intake/state.ts @@ -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; +}