feat: estimates revamp and space app refactor (#4742)

* Move code from EE to CE repo

* chore: folder structure updates

* Move sortabla and radio input to packages/ui

* chore: updated empty and loading screens

* chore: delete an estimate point

* chore: estimate point response change

* chore: updated create estimate and handled the build error

* chore: migration fixes

* chore: updated create estimate

* chore: create estimate workflow update

* chore: editing and deleting the existing estimate updates

* chore: updating the new estinates in update modal

* chore: ui changed

* chore: response changes of get and post

* chore: new field added in estimates

* chore: individual endpoint for estimate points

* chore: typo changes

* chore: create estimate point

* chore: integrated new endpoints

* chore: update key value pair

* chore: update sorting in the estimates

* Add custom option in the estimate templates

* chore: handled current project active estimate

* chore: handle estimate update worklfow

* chore: handled estimates switch

* chore: handled estimate edit

* chore: handled close button in estimate edit

* chore: updated ceate estimare workflow

* chore: updated switch estimate

* chore: UI and typos

* chore: resolved build error

* chore: updated delete dropdown and handled the repeated values while creating and updating the estimate point

* chore: handled inline errors in the estimate switch

* chore: handled active and availability vadilation

* chore: handled create and update components in projecr estimates

* chore: added migration

* Add category specific values for custom template

* chore: estimate dropdown handled in issues

* chore: estimate alerts

* chore: updated alerts

* Extract the list row actions

* fix: updated and handled the estimate points

* fix: upgrader ee banner

* Fix issues with sortable

* Fix sortable spacing issue in create estimate modal

* fix: updated the issue create sorting

* chore: removed radio button from ui and updated in the estimates

* chore: resolved import error in packaged ui

* chore: handled props in create modal

* chore: removed ee files

* chore: changed default analytics

* chore: removed the migration file

* chore: estimate point value in graph

* chore: estimate point key change

* chore: squashed migration (#4634)

* chore: squashed migration

* chore: removed instance migraion

* chore: key changes

* chore: issue activity back migration

* dev: replaced estimate key with estimate id and replaced estimate type from number to string in issue

* chore: estimate point value field

* chore: estimate point activity

* chore: removed the unused function

* chore: resolved merge conflicts

* chore: deploy board keys changed

* chore: yarn lock file change

* chore: resolved frontend build

---------

Co-authored-by: guru_sainath <gurusainath007@gmail.com>

* [WEB-1516] refactor: space app routing and layouts (#4705)

* dev: change layout

* chore: replace workspace slug and project id with anchor

* chore: migration fixes

* chore: update filtering logic

* chore: endpoint changes

* chore: update endpoint

* chore: changed url pratterns

* chore: use client side for layout and page

* chore: issue vote changes

* chore: project deploy board response change

* refactor: publish project store and components

* fix: update layout options after fetching settings

* chore: remove unnecessary types

* style: peek overview

* refactor: components folder structure

* fix: redirect from old path

* chore: make the whole issue block clickable

* chore: removed the migration file

* chore: add server side redirection for old routes

* chore: is enabled key change

* chore: update types

* chore: removed the migration file

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>

* Merge develop into revamp-estimates-ce

* chore: removed migration file and updated the estimate system order and removed ee banner

* chore: initial radio select in create estimate

* chore: space key changes

* Fix sortable component as the sort order was broken.

* [WEB-1516] refactor: publish project modal and types (#4716)

* refacotr: project publish

* chore: rename service names

* chore: is_deployed changed to anchor

* chore: update is_deployed key

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>

* [WEB-412] chore: estimates analytics  (#4730)

* chore: estimate points in modules and cycle

* chore: burn down chart analytics

* chore: module serializer change

* dev: handled y-axis estimates in analytics, implemented estimate points on modules

* chore: burn down analytics

* chore: state estimate point analytics

* chore: updated the burn down values

* Remove check mark from estimate point edit field in
create estimate flow

---------

Co-authored-by: guru_sainath <gurusainath007@gmail.com>
Co-authored-by: Satish Gandham <satish.iitg@gmail.com>

---------

Co-authored-by: Satish Gandham <satish.iitg@gmail.com>
Co-authored-by: guru_sainath <gurusainath007@gmail.com>
Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
Co-authored-by: Bavisetti Narayan <72156168+NarayanBavisetti@users.noreply.github.com>
Co-authored-by: Aaryan Khandelwal <65252264+aaryan610@users.noreply.github.com>
Co-authored-by: pushya22 <130810100+pushya22@users.noreply.github.com>
This commit is contained in:
sriram veeraghanta 2024-06-10 12:16:23 +05:30 committed by GitHub
parent fb2b4ae303
commit 59fdd611e4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
223 changed files with 6874 additions and 4658 deletions

View File

@ -784,6 +784,7 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView):
def post(self, request, slug, project_id, cycle_id): def post(self, request, slug, project_id, cycle_id):
new_cycle_id = request.data.get("new_cycle_id", False) new_cycle_id = request.data.get("new_cycle_id", False)
plot_type = request.GET.get("plot_type", "issues")
if not new_cycle_id: if not new_cycle_id:
return Response( return Response(
@ -865,6 +866,7 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView):
queryset=old_cycle.first(), queryset=old_cycle.first(),
slug=slug, slug=slug,
project_id=project_id, project_id=project_id,
plot_type=plot_type,
cycle_id=cycle_id, cycle_id=cycle_id,
) )

View File

@ -22,7 +22,7 @@ from plane.db.models import (
IssueProperty, IssueProperty,
Module, Module,
Project, Project,
ProjectDeployBoard, DeployBoard,
ProjectMember, ProjectMember,
State, State,
Workspace, Workspace,
@ -99,7 +99,7 @@ class ProjectAPIEndpoint(BaseAPIView):
) )
.annotate( .annotate(
is_deployed=Exists( is_deployed=Exists(
ProjectDeployBoard.objects.filter( DeployBoard.objects.filter(
project_id=OuterRef("pk"), project_id=OuterRef("pk"),
workspace__slug=self.kwargs.get("slug"), workspace__slug=self.kwargs.get("slug"),
) )

View File

@ -30,7 +30,7 @@ from .project import (
ProjectIdentifierSerializer, ProjectIdentifierSerializer,
ProjectLiteSerializer, ProjectLiteSerializer,
ProjectMemberLiteSerializer, ProjectMemberLiteSerializer,
ProjectDeployBoardSerializer, DeployBoardSerializer,
ProjectMemberAdminSerializer, ProjectMemberAdminSerializer,
ProjectPublicMemberSerializer, ProjectPublicMemberSerializer,
ProjectMemberRoleSerializer, ProjectMemberRoleSerializer,

View File

@ -11,10 +11,6 @@ from rest_framework import serializers
class EstimateSerializer(BaseSerializer): class EstimateSerializer(BaseSerializer):
workspace_detail = WorkspaceLiteSerializer(
read_only=True, source="workspace"
)
project_detail = ProjectLiteSerializer(read_only=True, source="project")
class Meta: class Meta:
model = Estimate model = Estimate
@ -48,10 +44,6 @@ class EstimatePointSerializer(BaseSerializer):
class EstimateReadSerializer(BaseSerializer): class EstimateReadSerializer(BaseSerializer):
points = EstimatePointSerializer(read_only=True, many=True) points = EstimatePointSerializer(read_only=True, many=True)
workspace_detail = WorkspaceLiteSerializer(
read_only=True, source="workspace"
)
project_detail = ProjectLiteSerializer(read_only=True, source="project")
class Meta: class Meta:
model = Estimate model = Estimate

View File

@ -177,6 +177,8 @@ class ModuleSerializer(DynamicBaseSerializer):
started_issues = serializers.IntegerField(read_only=True) started_issues = serializers.IntegerField(read_only=True)
unstarted_issues = serializers.IntegerField(read_only=True) unstarted_issues = serializers.IntegerField(read_only=True)
backlog_issues = serializers.IntegerField(read_only=True) backlog_issues = serializers.IntegerField(read_only=True)
total_estimate_points = serializers.IntegerField(read_only=True)
completed_estimate_points = serializers.IntegerField(read_only=True)
class Meta: class Meta:
model = Module model = Module
@ -201,6 +203,8 @@ class ModuleSerializer(DynamicBaseSerializer):
"external_id", "external_id",
"logo_props", "logo_props",
# computed fields # computed fields
"total_estimate_points",
"completed_estimate_points",
"is_favorite", "is_favorite",
"total_issues", "total_issues",
"cancelled_issues", "cancelled_issues",

View File

@ -13,7 +13,7 @@ from plane.db.models import (
ProjectMember, ProjectMember,
ProjectMemberInvite, ProjectMemberInvite,
ProjectIdentifier, ProjectIdentifier,
ProjectDeployBoard, DeployBoard,
ProjectPublicMember, ProjectPublicMember,
) )
@ -114,7 +114,7 @@ class ProjectListSerializer(DynamicBaseSerializer):
is_member = serializers.BooleanField(read_only=True) is_member = serializers.BooleanField(read_only=True)
sort_order = serializers.FloatField(read_only=True) sort_order = serializers.FloatField(read_only=True)
member_role = serializers.IntegerField(read_only=True) member_role = serializers.IntegerField(read_only=True)
is_deployed = serializers.BooleanField(read_only=True) anchor = serializers.CharField(read_only=True)
members = serializers.SerializerMethodField() members = serializers.SerializerMethodField()
def get_members(self, obj): def get_members(self, obj):
@ -148,7 +148,7 @@ class ProjectDetailSerializer(BaseSerializer):
is_member = serializers.BooleanField(read_only=True) is_member = serializers.BooleanField(read_only=True)
sort_order = serializers.FloatField(read_only=True) sort_order = serializers.FloatField(read_only=True)
member_role = serializers.IntegerField(read_only=True) member_role = serializers.IntegerField(read_only=True)
is_deployed = serializers.BooleanField(read_only=True) anchor = serializers.CharField(read_only=True)
class Meta: class Meta:
model = Project model = Project
@ -206,14 +206,14 @@ class ProjectMemberLiteSerializer(BaseSerializer):
read_only_fields = fields read_only_fields = fields
class ProjectDeployBoardSerializer(BaseSerializer): class DeployBoardSerializer(BaseSerializer):
project_details = ProjectLiteSerializer(read_only=True, source="project") project_details = ProjectLiteSerializer(read_only=True, source="project")
workspace_detail = WorkspaceLiteSerializer( workspace_detail = WorkspaceLiteSerializer(
read_only=True, source="workspace" read_only=True, source="workspace"
) )
class Meta: class Meta:
model = ProjectDeployBoard model = DeployBoard
fields = "__all__" fields = "__all__"
read_only_fields = [ read_only_fields = [
"workspace", "workspace",

View File

@ -4,6 +4,7 @@ from django.urls import path
from plane.app.views import ( from plane.app.views import (
ProjectEstimatePointEndpoint, ProjectEstimatePointEndpoint,
BulkEstimatePointEndpoint, BulkEstimatePointEndpoint,
EstimatePointEndpoint,
) )
@ -34,4 +35,23 @@ urlpatterns = [
), ),
name="bulk-create-estimate-points", name="bulk-create-estimate-points",
), ),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/estimates/<uuid:estimate_id>/estimate-points/",
EstimatePointEndpoint.as_view(
{
"post": "create",
}
),
name="estimate-points",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/estimates/<uuid:estimate_id>/estimate-points/<estimate_point_id>/",
EstimatePointEndpoint.as_view(
{
"patch": "partial_update",
"delete": "destroy",
}
),
name="estimate-points",
),
] ]

View File

@ -2,6 +2,7 @@ from django.urls import path
from plane.app.views import ( from plane.app.views import (
ProjectViewSet, ProjectViewSet,
DeployBoardViewSet,
ProjectInvitationsViewset, ProjectInvitationsViewset,
ProjectMemberViewSet, ProjectMemberViewSet,
ProjectMemberUserEndpoint, ProjectMemberUserEndpoint,
@ -12,7 +13,6 @@ from plane.app.views import (
ProjectFavoritesViewSet, ProjectFavoritesViewSet,
UserProjectInvitationsViewset, UserProjectInvitationsViewset,
ProjectPublicCoverImagesEndpoint, ProjectPublicCoverImagesEndpoint,
ProjectDeployBoardViewSet,
UserProjectRolesEndpoint, UserProjectRolesEndpoint,
ProjectArchiveUnarchiveEndpoint, ProjectArchiveUnarchiveEndpoint,
) )
@ -157,7 +157,7 @@ urlpatterns = [
), ),
path( path(
"workspaces/<str:slug>/projects/<uuid:project_id>/project-deploy-boards/", "workspaces/<str:slug>/projects/<uuid:project_id>/project-deploy-boards/",
ProjectDeployBoardViewSet.as_view( DeployBoardViewSet.as_view(
{ {
"get": "list", "get": "list",
"post": "create", "post": "create",
@ -167,7 +167,7 @@ urlpatterns = [
), ),
path( path(
"workspaces/<str:slug>/projects/<uuid:project_id>/project-deploy-boards/<uuid:pk>/", "workspaces/<str:slug>/projects/<uuid:project_id>/project-deploy-boards/<uuid:pk>/",
ProjectDeployBoardViewSet.as_view( DeployBoardViewSet.as_view(
{ {
"get": "retrieve", "get": "retrieve",
"patch": "partial_update", "patch": "partial_update",

View File

@ -4,7 +4,7 @@ from .project.base import (
ProjectUserViewsEndpoint, ProjectUserViewsEndpoint,
ProjectFavoritesViewSet, ProjectFavoritesViewSet,
ProjectPublicCoverImagesEndpoint, ProjectPublicCoverImagesEndpoint,
ProjectDeployBoardViewSet, DeployBoardViewSet,
ProjectArchiveUnarchiveEndpoint, ProjectArchiveUnarchiveEndpoint,
) )
@ -190,6 +190,7 @@ from .external.base import (
from .estimate.base import ( from .estimate.base import (
ProjectEstimatePointEndpoint, ProjectEstimatePointEndpoint,
BulkEstimatePointEndpoint, BulkEstimatePointEndpoint,
EstimatePointEndpoint,
) )
from .inbox.base import InboxViewSet, InboxIssueViewSet from .inbox.base import InboxViewSet, InboxIssueViewSet

View File

@ -33,7 +33,7 @@ class AnalyticsEndpoint(BaseAPIView):
"state__group", "state__group",
"labels__id", "labels__id",
"assignees__id", "assignees__id",
"estimate_point", "estimate_point__value",
"issue_cycle__cycle_id", "issue_cycle__cycle_id",
"issue_module__module_id", "issue_module__module_id",
"priority", "priority",
@ -381,9 +381,9 @@ class DefaultAnalyticsEndpoint(BaseAPIView):
) )
open_estimate_sum = open_issues_queryset.aggregate( open_estimate_sum = open_issues_queryset.aggregate(
sum=Sum("estimate_point") sum=Sum("point")
)["sum"] )["sum"]
total_estimate_sum = base_issues.aggregate(sum=Sum("estimate_point"))[ total_estimate_sum = base_issues.aggregate(sum=Sum("point"))[
"sum" "sum"
] ]

View File

@ -177,6 +177,7 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView):
) )
def get(self, request, slug, project_id, pk=None): def get(self, request, slug, project_id, pk=None):
plot_type = request.GET.get("plot_type", "issues")
if pk is None: if pk is None:
queryset = ( queryset = (
self.get_queryset() self.get_queryset()
@ -375,6 +376,7 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView):
queryset=queryset, queryset=queryset,
slug=slug, slug=slug,
project_id=project_id, project_id=project_id,
plot_type=plot_type,
cycle_id=pk, cycle_id=pk,
) )

View File

@ -17,8 +17,11 @@ from django.db.models import (
UUIDField, UUIDField,
Value, Value,
When, When,
Subquery,
Sum,
IntegerField,
) )
from django.db.models.functions import Coalesce from django.db.models.functions import Coalesce, Cast
from django.utils import timezone from django.utils import timezone
from django.core.serializers.json import DjangoJSONEncoder from django.core.serializers.json import DjangoJSONEncoder
@ -73,6 +76,89 @@ class CycleViewSet(BaseViewSet):
project_id=self.kwargs.get("project_id"), project_id=self.kwargs.get("project_id"),
workspace__slug=self.kwargs.get("slug"), workspace__slug=self.kwargs.get("slug"),
) )
backlog_estimate_point = (
Issue.issue_objects.filter(
estimate_point__estimate__type="points",
state__group="backlog",
issue_cycle__cycle_id=OuterRef("pk"),
)
.values("issue_cycle__cycle_id")
.annotate(
backlog_estimate_point=Sum(
Cast("estimate_point__value", IntegerField())
)
)
.values("backlog_estimate_point")[:1]
)
unstarted_estimate_point = (
Issue.issue_objects.filter(
estimate_point__estimate__type="points",
state__group="unstarted",
issue_cycle__cycle_id=OuterRef("pk"),
)
.values("issue_cycle__cycle_id")
.annotate(
unstarted_estimate_point=Sum(
Cast("estimate_point__value", IntegerField())
)
)
.values("unstarted_estimate_point")[:1]
)
started_estimate_point = (
Issue.issue_objects.filter(
estimate_point__estimate__type="points",
state__group="started",
issue_cycle__cycle_id=OuterRef("pk"),
)
.values("issue_cycle__cycle_id")
.annotate(
started_estimate_point=Sum(
Cast("estimate_point__value", IntegerField())
)
)
.values("started_estimate_point")[:1]
)
cancelled_estimate_point = (
Issue.issue_objects.filter(
estimate_point__estimate__type="points",
state__group="cancelled",
issue_cycle__cycle_id=OuterRef("pk"),
)
.values("issue_cycle__cycle_id")
.annotate(
cancelled_estimate_point=Sum(
Cast("estimate_point__value", IntegerField())
)
)
.values("cancelled_estimate_point")[:1]
)
completed_estimate_point = (
Issue.issue_objects.filter(
estimate_point__estimate__type="points",
state__group="completed",
issue_cycle__cycle_id=OuterRef("pk"),
)
.values("issue_cycle__cycle_id")
.annotate(
completed_estimate_points=Sum(
Cast("estimate_point__value", IntegerField())
)
)
.values("completed_estimate_points")[:1]
)
total_estimate_point = (
Issue.issue_objects.filter(
estimate_point__estimate__type="points",
issue_cycle__cycle_id=OuterRef("pk"),
)
.values("issue_cycle__cycle_id")
.annotate(
total_estimate_points=Sum(
Cast("estimate_point__value", IntegerField())
)
)
.values("total_estimate_points")[:1]
)
return self.filter_queryset( return self.filter_queryset(
super() super()
.get_queryset() .get_queryset()
@ -197,12 +283,49 @@ class CycleViewSet(BaseViewSet):
Value([], output_field=ArrayField(UUIDField())), Value([], output_field=ArrayField(UUIDField())),
) )
) )
.annotate(
backlog_estimate_points=Coalesce(
Subquery(backlog_estimate_point),
Value(0, output_field=IntegerField()),
),
)
.annotate(
unstarted_estimate_points=Coalesce(
Subquery(unstarted_estimate_point),
Value(0, output_field=IntegerField()),
),
)
.annotate(
started_estimate_points=Coalesce(
Subquery(started_estimate_point),
Value(0, output_field=IntegerField()),
),
)
.annotate(
cancelled_estimate_points=Coalesce(
Subquery(cancelled_estimate_point),
Value(0, output_field=IntegerField()),
),
)
.annotate(
completed_estimate_points=Coalesce(
Subquery(completed_estimate_point),
Value(0, output_field=IntegerField()),
),
)
.annotate(
total_estimate_points=Coalesce(
Subquery(total_estimate_point),
Value(0, output_field=IntegerField()),
),
)
.order_by("-is_favorite", "name") .order_by("-is_favorite", "name")
.distinct() .distinct()
) )
def list(self, request, slug, project_id): def list(self, request, slug, project_id):
queryset = self.get_queryset().filter(archived_at__isnull=True) queryset = self.get_queryset().filter(archived_at__isnull=True)
plot_type = request.GET.get("plot_type", "issues")
cycle_view = request.GET.get("cycle_view", "all") cycle_view = request.GET.get("cycle_view", "all")
# Update the order by # Update the order by
@ -233,6 +356,12 @@ class CycleViewSet(BaseViewSet):
"progress_snapshot", "progress_snapshot",
"logo_props", "logo_props",
# meta fields # meta fields
"backlog_estimate_points",
"unstarted_estimate_points",
"started_estimate_points",
"cancelled_estimate_points",
"completed_estimate_points",
"total_estimate_points",
"is_favorite", "is_favorite",
"total_issues", "total_issues",
"cancelled_issues", "cancelled_issues",
@ -335,6 +464,7 @@ class CycleViewSet(BaseViewSet):
queryset=queryset.first(), queryset=queryset.first(),
slug=slug, slug=slug,
project_id=project_id, project_id=project_id,
plot_type=plot_type,
cycle_id=data[0]["id"], cycle_id=data[0]["id"],
) )
) )
@ -359,6 +489,8 @@ class CycleViewSet(BaseViewSet):
"progress_snapshot", "progress_snapshot",
"logo_props", "logo_props",
# meta fields # meta fields
"completed_estimate_points",
"total_estimate_points",
"is_favorite", "is_favorite",
"total_issues", "total_issues",
"cancelled_issues", "cancelled_issues",
@ -527,6 +659,7 @@ class CycleViewSet(BaseViewSet):
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def retrieve(self, request, slug, project_id, pk): def retrieve(self, request, slug, project_id, pk):
plot_type = request.GET.get("plot_type", "issues")
queryset = ( queryset = (
self.get_queryset().filter(archived_at__isnull=True).filter(pk=pk) self.get_queryset().filter(archived_at__isnull=True).filter(pk=pk)
) )
@ -682,6 +815,7 @@ class CycleViewSet(BaseViewSet):
queryset=queryset, queryset=queryset,
slug=slug, slug=slug,
project_id=project_id, project_id=project_id,
plot_type=plot_type,
cycle_id=pk, cycle_id=pk,
) )
@ -798,6 +932,7 @@ class TransferCycleIssueEndpoint(BaseAPIView):
def post(self, request, slug, project_id, cycle_id): def post(self, request, slug, project_id, cycle_id):
new_cycle_id = request.data.get("new_cycle_id", False) new_cycle_id = request.data.get("new_cycle_id", False)
plot_type = request.GET.get("plot_type", "issues")
if not new_cycle_id: if not new_cycle_id:
return Response( return Response(
@ -879,6 +1014,7 @@ class TransferCycleIssueEndpoint(BaseAPIView):
queryset=old_cycle.first(), queryset=old_cycle.first(),
slug=slug, slug=slug,
project_id=project_id, project_id=project_id,
plot_type=plot_type,
cycle_id=cycle_id, cycle_id=cycle_id,
) )

View File

@ -1,3 +1,6 @@
import random
import string
# Third party imports # Third party imports
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework import status from rest_framework import status
@ -5,7 +8,7 @@ from rest_framework import status
# Module imports # Module imports
from ..base import BaseViewSet, BaseAPIView from ..base import BaseViewSet, BaseAPIView
from plane.app.permissions import ProjectEntityPermission from plane.app.permissions import ProjectEntityPermission
from plane.db.models import Project, Estimate, EstimatePoint from plane.db.models import Project, Estimate, EstimatePoint, Issue
from plane.app.serializers import ( from plane.app.serializers import (
EstimateSerializer, EstimateSerializer,
EstimatePointSerializer, EstimatePointSerializer,
@ -13,6 +16,12 @@ from plane.app.serializers import (
) )
from plane.utils.cache import invalidate_cache from plane.utils.cache import invalidate_cache
def generate_random_name(length=10):
letters = string.ascii_lowercase
return "".join(random.choice(letters) for i in range(length))
class ProjectEstimatePointEndpoint(BaseAPIView): class ProjectEstimatePointEndpoint(BaseAPIView):
permission_classes = [ permission_classes = [
ProjectEntityPermission, ProjectEntityPermission,
@ -49,13 +58,17 @@ class BulkEstimatePointEndpoint(BaseViewSet):
serializer = EstimateReadSerializer(estimates, many=True) serializer = EstimateReadSerializer(estimates, many=True)
return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.data, status=status.HTTP_200_OK)
@invalidate_cache(path="/api/workspaces/:slug/estimates/", url_params=True, user=False) @invalidate_cache(
path="/api/workspaces/:slug/estimates/", url_params=True, user=False
)
def create(self, request, slug, project_id): def create(self, request, slug, project_id):
if not request.data.get("estimate", False): estimate = request.data.get('estimate')
return Response( estimate_name = estimate.get("name", generate_random_name())
{"error": "Estimate is required"}, estimate_type = estimate.get("type", 'categories')
status=status.HTTP_400_BAD_REQUEST, last_used = estimate.get("last_used", False)
) estimate = Estimate.objects.create(
name=estimate_name, project_id=project_id, last_used=last_used, type=estimate_type
)
estimate_points = request.data.get("estimate_points", []) estimate_points = request.data.get("estimate_points", [])
@ -67,14 +80,6 @@ class BulkEstimatePointEndpoint(BaseViewSet):
serializer.errors, status=status.HTTP_400_BAD_REQUEST serializer.errors, status=status.HTTP_400_BAD_REQUEST
) )
estimate_serializer = EstimateSerializer(
data=request.data.get("estimate")
)
if not estimate_serializer.is_valid():
return Response(
estimate_serializer.errors, status=status.HTTP_400_BAD_REQUEST
)
estimate = estimate_serializer.save(project_id=project_id)
estimate_points = EstimatePoint.objects.bulk_create( estimate_points = EstimatePoint.objects.bulk_create(
[ [
EstimatePoint( EstimatePoint(
@ -93,17 +98,8 @@ class BulkEstimatePointEndpoint(BaseViewSet):
ignore_conflicts=True, ignore_conflicts=True,
) )
estimate_point_serializer = EstimatePointSerializer( serializer = EstimateReadSerializer(estimate)
estimate_points, many=True return Response(serializer.data, status=status.HTTP_200_OK)
)
return Response(
{
"estimate": estimate_serializer.data,
"estimate_points": estimate_point_serializer.data,
},
status=status.HTTP_200_OK,
)
def retrieve(self, request, slug, project_id, estimate_id): def retrieve(self, request, slug, project_id, estimate_id):
estimate = Estimate.objects.get( estimate = Estimate.objects.get(
@ -115,13 +111,10 @@ class BulkEstimatePointEndpoint(BaseViewSet):
status=status.HTTP_200_OK, status=status.HTTP_200_OK,
) )
@invalidate_cache(path="/api/workspaces/:slug/estimates/", url_params=True, user=False) @invalidate_cache(
path="/api/workspaces/:slug/estimates/", url_params=True, user=False
)
def partial_update(self, request, slug, project_id, estimate_id): def partial_update(self, request, slug, project_id, estimate_id):
if not request.data.get("estimate", False):
return Response(
{"error": "Estimate is required"},
status=status.HTTP_400_BAD_REQUEST,
)
if not len(request.data.get("estimate_points", [])): if not len(request.data.get("estimate_points", [])):
return Response( return Response(
@ -131,15 +124,10 @@ class BulkEstimatePointEndpoint(BaseViewSet):
estimate = Estimate.objects.get(pk=estimate_id) estimate = Estimate.objects.get(pk=estimate_id)
estimate_serializer = EstimateSerializer( if request.data.get("estimate"):
estimate, data=request.data.get("estimate"), partial=True estimate.name = request.data.get("estimate").get("name", estimate.name)
) estimate.type = request.data.get("estimate").get("type", estimate.type)
if not estimate_serializer.is_valid(): estimate.save()
return Response(
estimate_serializer.errors, status=status.HTTP_400_BAD_REQUEST
)
estimate = estimate_serializer.save()
estimate_points_data = request.data.get("estimate_points", []) estimate_points_data = request.data.get("estimate_points", [])
@ -165,29 +153,113 @@ class BulkEstimatePointEndpoint(BaseViewSet):
estimate_point.value = estimate_point_data[0].get( estimate_point.value = estimate_point_data[0].get(
"value", estimate_point.value "value", estimate_point.value
) )
estimate_point.key = estimate_point_data[0].get(
"key", estimate_point.key
)
updated_estimate_points.append(estimate_point) updated_estimate_points.append(estimate_point)
EstimatePoint.objects.bulk_update( EstimatePoint.objects.bulk_update(
updated_estimate_points, updated_estimate_points,
["value"], ["key", "value"],
batch_size=10, batch_size=10,
) )
estimate_point_serializer = EstimatePointSerializer( estimate_serializer = EstimateReadSerializer(estimate)
estimate_points, many=True
)
return Response( return Response(
{ estimate_serializer.data,
"estimate": estimate_serializer.data,
"estimate_points": estimate_point_serializer.data,
},
status=status.HTTP_200_OK, status=status.HTTP_200_OK,
) )
@invalidate_cache(path="/api/workspaces/:slug/estimates/", url_params=True, user=False) @invalidate_cache(
path="/api/workspaces/:slug/estimates/", url_params=True, user=False
)
def destroy(self, request, slug, project_id, estimate_id): def destroy(self, request, slug, project_id, estimate_id):
estimate = Estimate.objects.get( estimate = Estimate.objects.get(
pk=estimate_id, workspace__slug=slug, project_id=project_id pk=estimate_id, workspace__slug=slug, project_id=project_id
) )
estimate.delete() estimate.delete()
return Response(status=status.HTTP_204_NO_CONTENT) return Response(status=status.HTTP_204_NO_CONTENT)
class EstimatePointEndpoint(BaseViewSet):
permission_classes = [
ProjectEntityPermission,
]
def create(self, request, slug, project_id, estimate_id):
# TODO: add a key validation if the same key already exists
if not request.data.get("key") or not request.data.get("value"):
return Response(
{"error": "Key and value are required"},
status=status.HTTP_400_BAD_REQUEST,
)
key = request.data.get("key", 0)
value = request.data.get("value", "")
estimate_point = EstimatePoint.objects.create(
estimate_id=estimate_id,
project_id=project_id,
key=key,
value=value,
)
serializer = EstimatePointSerializer(estimate_point).data
return Response(serializer, status=status.HTTP_200_OK)
def partial_update(self, request, slug, project_id, estimate_id, estimate_point_id):
# TODO: add a key validation if the same key already exists
estimate_point = EstimatePoint.objects.get(
pk=estimate_point_id,
estimate_id=estimate_id,
project_id=project_id,
workspace__slug=slug,
)
serializer = EstimatePointSerializer(
estimate_point, data=request.data, partial=True
)
if not serializer.is_valid():
return Response(
serializer.errors, status=status.HTTP_400_BAD_REQUEST
)
serializer.save()
return Response(serializer.data, status=status.HTTP_200_OK)
def destroy(
self, request, slug, project_id, estimate_id, estimate_point_id
):
new_estimate_id = request.GET.get("new_estimate_id", None)
estimate_points = EstimatePoint.objects.filter(
estimate_id=estimate_id,
project_id=project_id,
workspace__slug=slug,
)
# update all the issues with the new estimate
if new_estimate_id:
_ = Issue.objects.filter(
project_id=project_id,
workspace__slug=slug,
estimate_id=estimate_point_id,
).update(estimate_id=new_estimate_id)
# delete the estimate point
old_estimate_point = EstimatePoint.objects.filter(
pk=estimate_point_id
).first()
# rearrange the estimate points
updated_estimate_points = []
for estimate_point in estimate_points:
if estimate_point.key > old_estimate_point.key:
estimate_point.key -= 1
updated_estimate_points.append(estimate_point)
EstimatePoint.objects.bulk_update(
updated_estimate_points,
["key"],
batch_size=10,
)
old_estimate_point.delete()
return Response(
EstimatePointSerializer(updated_estimate_points, many=True).data,
status=status.HTTP_200_OK,
)

View File

@ -165,6 +165,7 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView):
) )
def get(self, request, slug, project_id, pk=None): def get(self, request, slug, project_id, pk=None):
plot_type = request.GET.get("plot_type", "issues")
if pk is None: if pk is None:
queryset = self.get_queryset() queryset = self.get_queryset()
modules = queryset.values( # Required fields modules = queryset.values( # Required fields
@ -323,6 +324,7 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView):
queryset=modules, queryset=modules,
slug=slug, slug=slug,
project_id=project_id, project_id=project_id,
plot_type=plot_type,
module_id=pk, module_id=pk,
) )

View File

@ -16,8 +16,9 @@ from django.db.models import (
Subquery, Subquery,
UUIDField, UUIDField,
Value, Value,
Sum,
) )
from django.db.models.functions import Coalesce from django.db.models.functions import Coalesce, Cast
from django.core.serializers.json import DjangoJSONEncoder from django.core.serializers.json import DjangoJSONEncoder
from django.utils import timezone from django.utils import timezone
@ -128,6 +129,34 @@ class ModuleViewSet(BaseViewSet):
.annotate(cnt=Count("pk")) .annotate(cnt=Count("pk"))
.values("cnt") .values("cnt")
) )
completed_estimate_point = (
Issue.issue_objects.filter(
estimate_point__estimate__type="points",
state__group="completed",
issue_module__module_id=OuterRef("pk"),
)
.values("issue_module__module_id")
.annotate(
completed_estimate_points=Sum(
Cast("estimate_point__value", IntegerField())
)
)
.values("completed_estimate_points")[:1]
)
total_estimate_point = (
Issue.issue_objects.filter(
estimate_point__estimate__type="points",
issue_module__module_id=OuterRef("pk"),
)
.values("issue_module__module_id")
.annotate(
total_estimate_points=Sum(
Cast("estimate_point__value", IntegerField())
)
)
.values("total_estimate_points")[:1]
)
return ( return (
super() super()
.get_queryset() .get_queryset()
@ -182,6 +211,18 @@ class ModuleViewSet(BaseViewSet):
Value(0, output_field=IntegerField()), Value(0, output_field=IntegerField()),
) )
) )
.annotate(
completed_estimate_points=Coalesce(
Subquery(completed_estimate_point),
Value(0, output_field=IntegerField()),
),
)
.annotate(
total_estimate_points=Coalesce(
Subquery(total_estimate_point),
Value(0, output_field=IntegerField()),
),
)
.annotate( .annotate(
member_ids=Coalesce( member_ids=Coalesce(
ArrayAgg( ArrayAgg(
@ -233,6 +274,8 @@ class ModuleViewSet(BaseViewSet):
"total_issues", "total_issues",
"started_issues", "started_issues",
"unstarted_issues", "unstarted_issues",
"completed_estimate_points",
"total_estimate_points",
"backlog_issues", "backlog_issues",
"created_at", "created_at",
"updated_at", "updated_at",
@ -284,6 +327,8 @@ class ModuleViewSet(BaseViewSet):
"external_id", "external_id",
"logo_props", "logo_props",
# computed fields # computed fields
"completed_estimate_points",
"total_estimate_points",
"total_issues", "total_issues",
"is_favorite", "is_favorite",
"cancelled_issues", "cancelled_issues",
@ -301,6 +346,7 @@ class ModuleViewSet(BaseViewSet):
return Response(modules, status=status.HTTP_200_OK) return Response(modules, status=status.HTTP_200_OK)
def retrieve(self, request, slug, project_id, pk): def retrieve(self, request, slug, project_id, pk):
plot_type = request.GET.get("plot_type", "burndown")
queryset = ( queryset = (
self.get_queryset() self.get_queryset()
.filter(archived_at__isnull=True) .filter(archived_at__isnull=True)
@ -423,6 +469,7 @@ class ModuleViewSet(BaseViewSet):
queryset=modules, queryset=modules,
slug=slug, slug=slug,
project_id=project_id, project_id=project_id,
plot_type=plot_type,
module_id=pk, module_id=pk,
) )
@ -469,6 +516,8 @@ class ModuleViewSet(BaseViewSet):
"external_id", "external_id",
"logo_props", "logo_props",
# computed fields # computed fields
"completed_estimate_points",
"total_estimate_points",
"is_favorite", "is_favorite",
"cancelled_issues", "cancelled_issues",
"completed_issues", "completed_issues",

View File

@ -28,7 +28,7 @@ from plane.app.views.base import BaseViewSet, BaseAPIView
from plane.app.serializers import ( from plane.app.serializers import (
ProjectSerializer, ProjectSerializer,
ProjectListSerializer, ProjectListSerializer,
ProjectDeployBoardSerializer, DeployBoardSerializer,
) )
from plane.app.permissions import ( from plane.app.permissions import (
@ -46,7 +46,7 @@ from plane.db.models import (
Module, Module,
Cycle, Cycle,
Inbox, Inbox,
ProjectDeployBoard, DeployBoard,
IssueProperty, IssueProperty,
Issue, Issue,
) )
@ -137,12 +137,11 @@ class ProjectViewSet(BaseViewSet):
).values("role") ).values("role")
) )
.annotate( .annotate(
is_deployed=Exists( anchor=DeployBoard.objects.filter(
ProjectDeployBoard.objects.filter( entity_name="project",
project_id=OuterRef("pk"), entity_identifier=OuterRef("pk"),
workspace__slug=self.kwargs.get("slug"), workspace__slug=self.kwargs.get("slug"),
) ).values("anchor")
)
) )
.annotate(sort_order=Subquery(sort_order)) .annotate(sort_order=Subquery(sort_order))
.prefetch_related( .prefetch_related(
@ -639,29 +638,28 @@ class ProjectPublicCoverImagesEndpoint(BaseAPIView):
return Response(files, status=status.HTTP_200_OK) return Response(files, status=status.HTTP_200_OK)
class ProjectDeployBoardViewSet(BaseViewSet): class DeployBoardViewSet(BaseViewSet):
permission_classes = [ permission_classes = [
ProjectMemberPermission, ProjectMemberPermission,
] ]
serializer_class = ProjectDeployBoardSerializer serializer_class = DeployBoardSerializer
model = ProjectDeployBoard model = DeployBoard
def get_queryset(self): def list(self, request, slug, project_id):
return ( project_deploy_board = DeployBoard.objects.filter(
super() entity_name="project",
.get_queryset() entity_identifier=project_id,
.filter( workspace__slug=slug,
workspace__slug=self.kwargs.get("slug"), ).first()
project_id=self.kwargs.get("project_id"),
) serializer = DeployBoardSerializer(project_deploy_board)
.select_related("project") return Response(serializer.data, status=status.HTTP_200_OK)
)
def create(self, request, slug, project_id): def create(self, request, slug, project_id):
comments = request.data.get("comments", False) comments = request.data.get("is_comments_enabled", False)
reactions = request.data.get("reactions", False) reactions = request.data.get("is_reactions_enabled", False)
inbox = request.data.get("inbox", None) inbox = request.data.get("inbox", None)
votes = request.data.get("votes", False) votes = request.data.get("is_votes_enabled", False)
views = request.data.get( views = request.data.get(
"views", "views",
{ {
@ -673,17 +671,18 @@ class ProjectDeployBoardViewSet(BaseViewSet):
}, },
) )
project_deploy_board, _ = ProjectDeployBoard.objects.get_or_create( project_deploy_board, _ = DeployBoard.objects.get_or_create(
anchor=f"{slug}/{project_id}", entity_name="project",
entity_identifier=project_id,
project_id=project_id, project_id=project_id,
) )
project_deploy_board.comments = comments
project_deploy_board.reactions = reactions
project_deploy_board.inbox = inbox project_deploy_board.inbox = inbox
project_deploy_board.votes = votes project_deploy_board.view_props = views
project_deploy_board.views = views project_deploy_board.is_votes_enabled = votes
project_deploy_board.is_comments_enabled = comments
project_deploy_board.is_reactions_enabled = reactions
project_deploy_board.save() project_deploy_board.save()
serializer = ProjectDeployBoardSerializer(project_deploy_board) serializer = DeployBoardSerializer(project_deploy_board)
return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.data, status=status.HTTP_200_OK)

View File

@ -28,6 +28,7 @@ from plane.db.models import (
Project, Project,
State, State,
User, User,
EstimatePoint,
) )
from plane.settings.redis import redis_instance from plane.settings.redis import redis_instance
from plane.utils.exception_logger import log_exception from plane.utils.exception_logger import log_exception
@ -448,21 +449,37 @@ def track_estimate_points(
if current_instance.get("estimate_point") != requested_data.get( if current_instance.get("estimate_point") != requested_data.get(
"estimate_point" "estimate_point"
): ):
old_estimate = (
EstimatePoint.objects.filter(
pk=current_instance.get("estimate_point")
).first()
if current_instance.get("estimate_point") is not None
else None
)
new_estimate = (
EstimatePoint.objects.filter(
pk=requested_data.get("estimate_point")
).first()
if requested_data.get("estimate_point") is not None
else None
)
issue_activities.append( issue_activities.append(
IssueActivity( IssueActivity(
issue_id=issue_id, issue_id=issue_id,
actor_id=actor_id, actor_id=actor_id,
verb="updated", verb="updated",
old_value=( old_identifier=(
current_instance.get("estimate_point") current_instance.get("estimate_point")
if current_instance.get("estimate_point") is not None if current_instance.get("estimate_point") is not None
else "" else None
), ),
new_value=( new_identifier=(
requested_data.get("estimate_point") requested_data.get("estimate_point")
if requested_data.get("estimate_point") is not None if requested_data.get("estimate_point") is not None
else "" else None
), ),
old_value=old_estimate.value if old_estimate else None,
new_value=new_estimate.value if new_estimate else None,
field="estimate_point", field="estimate_point",
project_id=project_id, project_id=project_id,
workspace_id=workspace_id, workspace_id=workspace_id,

View File

@ -0,0 +1,260 @@
# # Generated by Django 4.2.7 on 2024-05-24 09:47
# Python imports
import uuid
from uuid import uuid4
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
import plane.db.models.deploy_board
def issue_estimate_point(apps, schema_editor):
Issue = apps.get_model("db", "Issue")
Project = apps.get_model("db", "Project")
EstimatePoint = apps.get_model("db", "EstimatePoint")
IssueActivity = apps.get_model("db", "IssueActivity")
updated_estimate_point = []
updated_issue_activity = []
# loop through all the projects
for project in Project.objects.filter(estimate__isnull=False):
estimate_points = EstimatePoint.objects.filter(
estimate=project.estimate, project=project
)
for issue_activity in IssueActivity.objects.filter(
field="estimate_point", project=project
):
if issue_activity.new_value:
new_identifier = estimate_points.filter(
key=issue_activity.new_value
).first().id
issue_activity.new_identifier = new_identifier
new_value = estimate_points.filter(
key=issue_activity.new_value
).first().value
issue_activity.new_value = new_value
if issue_activity.old_value:
old_identifier = estimate_points.filter(
key=issue_activity.old_value
).first().id
issue_activity.old_identifier = old_identifier
old_value = estimate_points.filter(
key=issue_activity.old_value
).first().value
issue_activity.old_value = old_value
updated_issue_activity.append(issue_activity)
for issue in Issue.objects.filter(
point__isnull=False, project=project
):
# get the estimate id for the corresponding estimate point in the issue
estimate = estimate_points.filter(key=issue.point).first()
issue.estimate_point = estimate
updated_estimate_point.append(issue)
Issue.objects.bulk_update(
updated_estimate_point, ["estimate_point"], batch_size=1000
)
IssueActivity.objects.bulk_update(
updated_issue_activity,
["new_value", "old_value", "new_identifier", "old_identifier"],
batch_size=1000,
)
def last_used_estimate(apps, schema_editor):
Project = apps.get_model("db", "Project")
Estimate = apps.get_model("db", "Estimate")
# Get all estimate ids used in projects
estimate_ids = Project.objects.filter(estimate__isnull=False).values_list(
"estimate", flat=True
)
# Update all matching estimates
Estimate.objects.filter(id__in=estimate_ids).update(last_used=True)
def populate_deploy_board(apps, schema_editor):
DeployBoard = apps.get_model("db", "DeployBoard")
ProjectDeployBoard = apps.get_model("db", "ProjectDeployBoard")
DeployBoard.objects.bulk_create(
[
DeployBoard(
entity_identifier=deploy_board.project_id,
project_id=deploy_board.project_id,
entity_name="project",
anchor=uuid4().hex,
is_comments_enabled=deploy_board.comments,
is_reactions_enabled=deploy_board.reactions,
inbox=deploy_board.inbox,
is_votes_enabled=deploy_board.votes,
view_props=deploy_board.views,
workspace_id=deploy_board.workspace_id,
created_at=deploy_board.created_at,
updated_at=deploy_board.updated_at,
created_by_id=deploy_board.created_by_id,
updated_by_id=deploy_board.updated_by_id,
)
for deploy_board in ProjectDeployBoard.objects.all()
],
batch_size=100,
)
class Migration(migrations.Migration):
dependencies = [
("db", "0066_account_id_token_cycle_logo_props_module_logo_props"),
]
operations = [
migrations.CreateModel(
name="DeployBoard",
fields=[
(
"created_at",
models.DateTimeField(
auto_now_add=True, verbose_name="Created At"
),
),
(
"updated_at",
models.DateTimeField(
auto_now=True, verbose_name="Last Modified At"
),
),
(
"id",
models.UUIDField(
db_index=True,
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
unique=True,
),
),
("entity_identifier", models.UUIDField(null=True)),
(
"entity_name",
models.CharField(
choices=[
("project", "Project"),
("issue", "Issue"),
("module", "Module"),
("cycle", "Task"),
("page", "Page"),
("view", "View"),
],
max_length=30,
),
),
(
"anchor",
models.CharField(
db_index=True,
default=plane.db.models.deploy_board.get_anchor,
max_length=255,
unique=True,
),
),
("is_comments_enabled", models.BooleanField(default=False)),
("is_reactions_enabled", models.BooleanField(default=False)),
("is_votes_enabled", models.BooleanField(default=False)),
("view_props", models.JSONField(default=dict)),
(
"created_by",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="%(class)s_created_by",
to=settings.AUTH_USER_MODEL,
verbose_name="Created By",
),
),
(
"inbox",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="board_inbox",
to="db.inbox",
),
),
(
"project",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="project_%(class)s",
to="db.project",
),
),
(
"updated_by",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="%(class)s_updated_by",
to=settings.AUTH_USER_MODEL,
verbose_name="Last Modified By",
),
),
(
"workspace",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="workspace_%(class)s",
to="db.workspace",
),
),
],
options={
"verbose_name": "Deploy Board",
"verbose_name_plural": "Deploy Boards",
"db_table": "deploy_boards",
"ordering": ("-created_at",),
"unique_together": {("entity_name", "entity_identifier")},
},
),
migrations.AddField(
model_name="estimate",
name="last_used",
field=models.BooleanField(default=False),
),
# Rename the existing field
migrations.RenameField(
model_name="issue",
old_name="estimate_point",
new_name="point",
),
# Add a new field with the original name as a foreign key
migrations.AddField(
model_name="issue",
name="estimate_point",
field=models.ForeignKey(
on_delete=django.db.models.deletion.SET_NULL,
related_name="issue_estimates",
to="db.EstimatePoint",
blank=True,
null=True,
),
),
migrations.AlterField(
model_name="estimate",
name="type",
field=models.CharField(default="categories", max_length=255),
),
migrations.AlterField(
model_name="estimatepoint",
name="value",
field=models.CharField(max_length=255),
),
migrations.RunPython(issue_estimate_point),
migrations.RunPython(last_used_estimate),
migrations.RunPython(populate_deploy_board),
]

View File

@ -4,6 +4,7 @@ from .asset import FileAsset
from .base import BaseModel from .base import BaseModel
from .cycle import Cycle, CycleFavorite, CycleIssue, CycleUserProperties from .cycle import Cycle, CycleFavorite, CycleIssue, CycleUserProperties
from .dashboard import Dashboard, DashboardWidget, Widget from .dashboard import Dashboard, DashboardWidget, Widget
from .deploy_board import DeployBoard
from .estimate import Estimate, EstimatePoint from .estimate import Estimate, EstimatePoint
from .exporter import ExporterHistory from .exporter import ExporterHistory
from .importer import Importer from .importer import Importer
@ -53,13 +54,13 @@ from .page import Page, PageFavorite, PageLabel, PageLog
from .project import ( from .project import (
Project, Project,
ProjectBaseModel, ProjectBaseModel,
ProjectDeployBoard,
ProjectFavorite, ProjectFavorite,
ProjectIdentifier, ProjectIdentifier,
ProjectMember, ProjectMember,
ProjectMemberInvite, ProjectMemberInvite,
ProjectPublicMember, ProjectPublicMember,
) )
from .deploy_board import DeployBoard
from .session import Session from .session import Session
from .social_connection import SocialLoginConnection from .social_connection import SocialLoginConnection
from .state import State from .state import State

View File

@ -0,0 +1,53 @@
# Python imports
from uuid import uuid4
# Django imports
from django.db import models
# Module imports
from .workspace import WorkspaceBaseModel
def get_anchor():
return uuid4().hex
class DeployBoard(WorkspaceBaseModel):
TYPE_CHOICES = (
("project", "Project"),
("issue", "Issue"),
("module", "Module"),
("cycle", "Task"),
("page", "Page"),
("view", "View"),
)
entity_identifier = models.UUIDField(null=True)
entity_name = models.CharField(
max_length=30,
choices=TYPE_CHOICES,
)
anchor = models.CharField(
max_length=255, default=get_anchor, unique=True, db_index=True
)
is_comments_enabled = models.BooleanField(default=False)
is_reactions_enabled = models.BooleanField(default=False)
inbox = models.ForeignKey(
"db.Inbox",
related_name="board_inbox",
on_delete=models.SET_NULL,
null=True,
)
is_votes_enabled = models.BooleanField(default=False)
view_props = models.JSONField(default=dict)
def __str__(self):
"""Return name of the deploy board"""
return f"{self.entity_identifier} <{self.entity_name}>"
class Meta:
unique_together = ["entity_name", "entity_identifier"]
verbose_name = "Deploy Board"
verbose_name_plural = "Deploy Boards"
db_table = "deploy_boards"
ordering = ("-created_at",)

View File

@ -11,7 +11,8 @@ class Estimate(ProjectBaseModel):
description = models.TextField( description = models.TextField(
verbose_name="Estimate Description", blank=True verbose_name="Estimate Description", blank=True
) )
type = models.CharField(max_length=255, default="Categories") type = models.CharField(max_length=255, default="categories")
last_used = models.BooleanField(default=False)
def __str__(self): def __str__(self):
"""Return name of the estimate""" """Return name of the estimate"""
@ -35,7 +36,7 @@ class EstimatePoint(ProjectBaseModel):
default=0, validators=[MinValueValidator(0), MaxValueValidator(12)] default=0, validators=[MinValueValidator(0), MaxValueValidator(12)]
) )
description = models.TextField(blank=True) description = models.TextField(blank=True)
value = models.CharField(max_length=20) value = models.CharField(max_length=255)
def __str__(self): def __str__(self):
"""Return name of the estimate""" """Return name of the estimate"""

View File

@ -119,11 +119,18 @@ class Issue(ProjectBaseModel):
blank=True, blank=True,
related_name="state_issue", related_name="state_issue",
) )
estimate_point = models.IntegerField( point = models.IntegerField(
validators=[MinValueValidator(0), MaxValueValidator(12)], validators=[MinValueValidator(0), MaxValueValidator(12)],
null=True, null=True,
blank=True, blank=True,
) )
estimate_point = models.ForeignKey(
"db.EstimatePoint",
on_delete=models.SET_NULL,
related_name="issue_estimates",
null=True,
blank=True,
)
name = models.CharField(max_length=255, verbose_name="Issue Name") name = models.CharField(max_length=255, verbose_name="Issue Name")
description = models.JSONField(blank=True, default=dict) description = models.JSONField(blank=True, default=dict)
description_html = models.TextField(blank=True, default="<p></p>") description_html = models.TextField(blank=True, default="<p></p>")

View File

@ -260,6 +260,8 @@ def get_default_views():
} }
# DEPRECATED TODO:
# used to get the old anchors for the project deploy boards
class ProjectDeployBoard(ProjectBaseModel): class ProjectDeployBoard(ProjectBaseModel):
anchor = models.CharField( anchor = models.CharField(
max_length=255, default=get_anchor, unique=True, db_index=True max_length=255, default=get_anchor, unique=True, db_index=True

View File

@ -10,7 +10,7 @@ from plane.space.views import (
urlpatterns = [ urlpatterns = [
path( path(
"workspaces/<str:slug>/project-boards/<uuid:project_id>/inboxes/<uuid:inbox_id>/inbox-issues/", "anchor/<str:anchor>/inboxes/<uuid:inbox_id>/inbox-issues/",
InboxIssuePublicViewSet.as_view( InboxIssuePublicViewSet.as_view(
{ {
"get": "list", "get": "list",
@ -20,7 +20,7 @@ urlpatterns = [
name="inbox-issue", name="inbox-issue",
), ),
path( path(
"workspaces/<str:slug>/project-boards/<uuid:project_id>/inboxes/<uuid:inbox_id>/inbox-issues/<uuid:pk>/", "anchor/<str:anchor>/inboxes/<uuid:inbox_id>/inbox-issues/<uuid:pk>/",
InboxIssuePublicViewSet.as_view( InboxIssuePublicViewSet.as_view(
{ {
"get": "retrieve", "get": "retrieve",
@ -31,7 +31,7 @@ urlpatterns = [
name="inbox-issue", name="inbox-issue",
), ),
path( path(
"workspaces/<str:slug>/project-boards/<uuid:project_id>/issues/<uuid:issue_id>/votes/", "anchor/<str:anchor>/issues/<uuid:issue_id>/votes/",
IssueVotePublicViewSet.as_view( IssueVotePublicViewSet.as_view(
{ {
"get": "list", "get": "list",

View File

@ -10,12 +10,12 @@ from plane.space.views import (
urlpatterns = [ urlpatterns = [
path( path(
"workspaces/<str:slug>/project-boards/<uuid:project_id>/issues/<uuid:issue_id>/", "anchor/<str:anchor>/issues/<uuid:issue_id>/",
IssueRetrievePublicEndpoint.as_view(), IssueRetrievePublicEndpoint.as_view(),
name="workspace-project-boards", name="workspace-project-boards",
), ),
path( path(
"workspaces/<str:slug>/project-boards/<uuid:project_id>/issues/<uuid:issue_id>/comments/", "anchor/<str:anchor>/issues/<uuid:issue_id>/comments/",
IssueCommentPublicViewSet.as_view( IssueCommentPublicViewSet.as_view(
{ {
"get": "list", "get": "list",
@ -25,7 +25,7 @@ urlpatterns = [
name="issue-comments-project-board", name="issue-comments-project-board",
), ),
path( path(
"workspaces/<str:slug>/project-boards/<uuid:project_id>/issues/<uuid:issue_id>/comments/<uuid:pk>/", "anchor/<str:anchor>/issues/<uuid:issue_id>/comments/<uuid:pk>/",
IssueCommentPublicViewSet.as_view( IssueCommentPublicViewSet.as_view(
{ {
"get": "retrieve", "get": "retrieve",
@ -36,7 +36,7 @@ urlpatterns = [
name="issue-comments-project-board", name="issue-comments-project-board",
), ),
path( path(
"workspaces/<str:slug>/project-boards/<uuid:project_id>/issues/<uuid:issue_id>/reactions/", "anchor/<str:anchor>/issues/<uuid:issue_id>/reactions/",
IssueReactionPublicViewSet.as_view( IssueReactionPublicViewSet.as_view(
{ {
"get": "list", "get": "list",
@ -46,7 +46,7 @@ urlpatterns = [
name="issue-reactions-project-board", name="issue-reactions-project-board",
), ),
path( path(
"workspaces/<str:slug>/project-boards/<uuid:project_id>/issues/<uuid:issue_id>/reactions/<str:reaction_code>/", "anchor/<str:anchor>/issues/<uuid:issue_id>/reactions/<str:reaction_code>/",
IssueReactionPublicViewSet.as_view( IssueReactionPublicViewSet.as_view(
{ {
"delete": "destroy", "delete": "destroy",
@ -55,7 +55,7 @@ urlpatterns = [
name="issue-reactions-project-board", name="issue-reactions-project-board",
), ),
path( path(
"workspaces/<str:slug>/project-boards/<uuid:project_id>/comments/<uuid:comment_id>/reactions/", "anchor/<str:anchor>/comments/<uuid:comment_id>/reactions/",
CommentReactionPublicViewSet.as_view( CommentReactionPublicViewSet.as_view(
{ {
"get": "list", "get": "list",
@ -65,7 +65,7 @@ urlpatterns = [
name="comment-reactions-project-board", name="comment-reactions-project-board",
), ),
path( path(
"workspaces/<str:slug>/project-boards/<uuid:project_id>/comments/<uuid:comment_id>/reactions/<str:reaction_code>/", "anchor/<str:anchor>/comments/<uuid:comment_id>/reactions/<str:reaction_code>/",
CommentReactionPublicViewSet.as_view( CommentReactionPublicViewSet.as_view(
{ {
"delete": "destroy", "delete": "destroy",

View File

@ -4,17 +4,23 @@ from django.urls import path
from plane.space.views import ( from plane.space.views import (
ProjectDeployBoardPublicSettingsEndpoint, ProjectDeployBoardPublicSettingsEndpoint,
ProjectIssuesPublicEndpoint, ProjectIssuesPublicEndpoint,
WorkspaceProjectAnchorEndpoint,
) )
urlpatterns = [ urlpatterns = [
path( path(
"workspaces/<str:slug>/project-boards/<uuid:project_id>/settings/", "anchor/<str:anchor>/settings/",
ProjectDeployBoardPublicSettingsEndpoint.as_view(), ProjectDeployBoardPublicSettingsEndpoint.as_view(),
name="project-deploy-board-settings", name="project-deploy-board-settings",
), ),
path( path(
"workspaces/<str:slug>/project-boards/<uuid:project_id>/issues/", "anchor/<str:anchor>/issues/",
ProjectIssuesPublicEndpoint.as_view(), ProjectIssuesPublicEndpoint.as_view(),
name="project-deploy-board", name="project-deploy-board",
), ),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/anchor/",
WorkspaceProjectAnchorEndpoint.as_view(),
name="project-deploy-board",
),
] ]

View File

@ -1,6 +1,7 @@
from .project import ( from .project import (
ProjectDeployBoardPublicSettingsEndpoint, ProjectDeployBoardPublicSettingsEndpoint,
WorkspaceProjectDeployBoardEndpoint, WorkspaceProjectDeployBoardEndpoint,
WorkspaceProjectAnchorEndpoint,
) )
from .issue import ( from .issue import (

View File

@ -18,7 +18,7 @@ from plane.db.models import (
State, State,
IssueLink, IssueLink,
IssueAttachment, IssueAttachment,
ProjectDeployBoard, DeployBoard,
) )
from plane.app.serializers import ( from plane.app.serializers import (
IssueSerializer, IssueSerializer,
@ -39,7 +39,7 @@ class InboxIssuePublicViewSet(BaseViewSet):
] ]
def get_queryset(self): def get_queryset(self):
project_deploy_board = ProjectDeployBoard.objects.get( project_deploy_board = DeployBoard.objects.get(
workspace__slug=self.kwargs.get("slug"), workspace__slug=self.kwargs.get("slug"),
project_id=self.kwargs.get("project_id"), project_id=self.kwargs.get("project_id"),
) )
@ -58,9 +58,9 @@ class InboxIssuePublicViewSet(BaseViewSet):
) )
return InboxIssue.objects.none() return InboxIssue.objects.none()
def list(self, request, slug, project_id, inbox_id): def list(self, request, anchor, inbox_id):
project_deploy_board = ProjectDeployBoard.objects.get( project_deploy_board = DeployBoard.objects.get(
workspace__slug=slug, project_id=project_id anchor=anchor, entity_name="project"
) )
if project_deploy_board.inbox is None: if project_deploy_board.inbox is None:
return Response( return Response(
@ -72,8 +72,8 @@ class InboxIssuePublicViewSet(BaseViewSet):
issues = ( issues = (
Issue.objects.filter( Issue.objects.filter(
issue_inbox__inbox_id=inbox_id, issue_inbox__inbox_id=inbox_id,
workspace__slug=slug, workspace_id=project_deploy_board.workspace_id,
project_id=project_id, project_id=project_deploy_board.project_id,
) )
.filter(**filters) .filter(**filters)
.annotate(bridge_id=F("issue_inbox__id")) .annotate(bridge_id=F("issue_inbox__id"))
@ -117,9 +117,9 @@ class InboxIssuePublicViewSet(BaseViewSet):
status=status.HTTP_200_OK, status=status.HTTP_200_OK,
) )
def create(self, request, slug, project_id, inbox_id): def create(self, request, anchor, inbox_id):
project_deploy_board = ProjectDeployBoard.objects.get( project_deploy_board = DeployBoard.objects.get(
workspace__slug=slug, project_id=project_id anchor=anchor, entity_name="project"
) )
if project_deploy_board.inbox is None: if project_deploy_board.inbox is None:
return Response( return Response(
@ -151,7 +151,7 @@ class InboxIssuePublicViewSet(BaseViewSet):
name="Triage", name="Triage",
group="backlog", group="backlog",
description="Default state for managing all Inbox Issues", description="Default state for managing all Inbox Issues",
project_id=project_id, project_id=project_deploy_board.project_id,
color="#ff7700", color="#ff7700",
) )
@ -163,7 +163,7 @@ class InboxIssuePublicViewSet(BaseViewSet):
"description_html", "<p></p>" "description_html", "<p></p>"
), ),
priority=request.data.get("issue", {}).get("priority", "low"), priority=request.data.get("issue", {}).get("priority", "low"),
project_id=project_id, project_id=project_deploy_board.project_id,
state=state, state=state,
) )
@ -173,14 +173,14 @@ class InboxIssuePublicViewSet(BaseViewSet):
requested_data=json.dumps(request.data, cls=DjangoJSONEncoder), requested_data=json.dumps(request.data, cls=DjangoJSONEncoder),
actor_id=str(request.user.id), actor_id=str(request.user.id),
issue_id=str(issue.id), issue_id=str(issue.id),
project_id=str(project_id), project_id=str(project_deploy_board.project_id),
current_instance=None, current_instance=None,
epoch=int(timezone.now().timestamp()), epoch=int(timezone.now().timestamp()),
) )
# create an inbox issue # create an inbox issue
InboxIssue.objects.create( InboxIssue.objects.create(
inbox_id=inbox_id, inbox_id=inbox_id,
project_id=project_id, project_id=project_deploy_board.project_id,
issue=issue, issue=issue,
source=request.data.get("source", "in-app"), source=request.data.get("source", "in-app"),
) )
@ -188,9 +188,9 @@ class InboxIssuePublicViewSet(BaseViewSet):
serializer = IssueStateInboxSerializer(issue) serializer = IssueStateInboxSerializer(issue)
return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.data, status=status.HTTP_200_OK)
def partial_update(self, request, slug, project_id, inbox_id, pk): def partial_update(self, request, anchor, inbox_id, pk):
project_deploy_board = ProjectDeployBoard.objects.get( project_deploy_board = DeployBoard.objects.get(
workspace__slug=slug, project_id=project_id anchor=anchor, entity_name="project"
) )
if project_deploy_board.inbox is None: if project_deploy_board.inbox is None:
return Response( return Response(
@ -200,8 +200,8 @@ class InboxIssuePublicViewSet(BaseViewSet):
inbox_issue = InboxIssue.objects.get( inbox_issue = InboxIssue.objects.get(
pk=pk, pk=pk,
workspace__slug=slug, workspace_id=project_deploy_board.workspace_id,
project_id=project_id, project_id=project_deploy_board.project_id,
inbox_id=inbox_id, inbox_id=inbox_id,
) )
# Get the project member # Get the project member
@ -216,8 +216,8 @@ class InboxIssuePublicViewSet(BaseViewSet):
issue = Issue.objects.get( issue = Issue.objects.get(
pk=inbox_issue.issue_id, pk=inbox_issue.issue_id,
workspace__slug=slug, workspace_id=project_deploy_board.workspace_id,
project_id=project_id, project_id=project_deploy_board.project_id,
) )
# viewers and guests since only viewers and guests # viewers and guests since only viewers and guests
issue_data = { issue_data = {
@ -242,7 +242,7 @@ class InboxIssuePublicViewSet(BaseViewSet):
requested_data=requested_data, requested_data=requested_data,
actor_id=str(request.user.id), actor_id=str(request.user.id),
issue_id=str(issue.id), issue_id=str(issue.id),
project_id=str(project_id), project_id=str(project_deploy_board.project_id),
current_instance=json.dumps( current_instance=json.dumps(
IssueSerializer(current_instance).data, IssueSerializer(current_instance).data,
cls=DjangoJSONEncoder, cls=DjangoJSONEncoder,
@ -255,9 +255,9 @@ class InboxIssuePublicViewSet(BaseViewSet):
issue_serializer.errors, status=status.HTTP_400_BAD_REQUEST issue_serializer.errors, status=status.HTTP_400_BAD_REQUEST
) )
def retrieve(self, request, slug, project_id, inbox_id, pk): def retrieve(self, request, anchor, inbox_id, pk):
project_deploy_board = ProjectDeployBoard.objects.get( project_deploy_board = DeployBoard.objects.get(
workspace__slug=slug, project_id=project_id anchor=anchor, entity_name="project"
) )
if project_deploy_board.inbox is None: if project_deploy_board.inbox is None:
return Response( return Response(
@ -267,21 +267,21 @@ class InboxIssuePublicViewSet(BaseViewSet):
inbox_issue = InboxIssue.objects.get( inbox_issue = InboxIssue.objects.get(
pk=pk, pk=pk,
workspace__slug=slug, workspace_id=project_deploy_board.workspace_id,
project_id=project_id, project_id=project_deploy_board.project_id,
inbox_id=inbox_id, inbox_id=inbox_id,
) )
issue = Issue.objects.get( issue = Issue.objects.get(
pk=inbox_issue.issue_id, pk=inbox_issue.issue_id,
workspace__slug=slug, workspace_id=project_deploy_board.workspace_id,
project_id=project_id, project_id=project_deploy_board.project_id,
) )
serializer = IssueStateInboxSerializer(issue) serializer = IssueStateInboxSerializer(issue)
return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.data, status=status.HTTP_200_OK)
def destroy(self, request, slug, project_id, inbox_id, pk): def destroy(self, request, anchor, inbox_id, pk):
project_deploy_board = ProjectDeployBoard.objects.get( project_deploy_board = DeployBoard.objects.get(
workspace__slug=slug, project_id=project_id anchor=anchor, entity_name="project"
) )
if project_deploy_board.inbox is None: if project_deploy_board.inbox is None:
return Response( return Response(
@ -291,8 +291,8 @@ class InboxIssuePublicViewSet(BaseViewSet):
inbox_issue = InboxIssue.objects.get( inbox_issue = InboxIssue.objects.get(
pk=pk, pk=pk,
workspace__slug=slug, workspace_id=project_deploy_board.workspace_id,
project_id=project_id, project_id=project_deploy_board.project_id,
inbox_id=inbox_id, inbox_id=inbox_id,
) )

View File

@ -44,7 +44,7 @@ from plane.db.models import (
ProjectMember, ProjectMember,
IssueReaction, IssueReaction,
CommentReaction, CommentReaction,
ProjectDeployBoard, DeployBoard,
IssueVote, IssueVote,
ProjectPublicMember, ProjectPublicMember,
) )
@ -76,15 +76,15 @@ class IssueCommentPublicViewSet(BaseViewSet):
def get_queryset(self): def get_queryset(self):
try: try:
project_deploy_board = ProjectDeployBoard.objects.get( project_deploy_board = DeployBoard.objects.get(
workspace__slug=self.kwargs.get("slug"), anchor=self.kwargs.get("anchor"),
project_id=self.kwargs.get("project_id"), entity_name="project",
) )
if project_deploy_board.comments: if project_deploy_board.is_comments_enabled:
return self.filter_queryset( return self.filter_queryset(
super() super()
.get_queryset() .get_queryset()
.filter(workspace__slug=self.kwargs.get("slug")) .filter(workspace_id=project_deploy_board.workspace_id)
.filter(issue_id=self.kwargs.get("issue_id")) .filter(issue_id=self.kwargs.get("issue_id"))
.filter(access="EXTERNAL") .filter(access="EXTERNAL")
.select_related("project") .select_related("project")
@ -93,8 +93,8 @@ class IssueCommentPublicViewSet(BaseViewSet):
.annotate( .annotate(
is_member=Exists( is_member=Exists(
ProjectMember.objects.filter( ProjectMember.objects.filter(
workspace__slug=self.kwargs.get("slug"), workspace_id=project_deploy_board.workspace_id,
project_id=self.kwargs.get("project_id"), project_id=project_deploy_board.project_id,
member_id=self.request.user.id, member_id=self.request.user.id,
is_active=True, is_active=True,
) )
@ -103,15 +103,15 @@ class IssueCommentPublicViewSet(BaseViewSet):
.distinct() .distinct()
).order_by("created_at") ).order_by("created_at")
return IssueComment.objects.none() return IssueComment.objects.none()
except ProjectDeployBoard.DoesNotExist: except DeployBoard.DoesNotExist:
return IssueComment.objects.none() return IssueComment.objects.none()
def create(self, request, slug, project_id, issue_id): def create(self, request, anchor, issue_id):
project_deploy_board = ProjectDeployBoard.objects.get( project_deploy_board = DeployBoard.objects.get(
workspace__slug=slug, project_id=project_id anchor=anchor, entity_name="project"
) )
if not project_deploy_board.comments: if not project_deploy_board.is_comments_enabled:
return Response( return Response(
{"error": "Comments are not enabled for this project"}, {"error": "Comments are not enabled for this project"},
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,
@ -120,7 +120,7 @@ class IssueCommentPublicViewSet(BaseViewSet):
serializer = IssueCommentSerializer(data=request.data) serializer = IssueCommentSerializer(data=request.data)
if serializer.is_valid(): if serializer.is_valid():
serializer.save( serializer.save(
project_id=project_id, project_id=project_deploy_board.project_id,
issue_id=issue_id, issue_id=issue_id,
actor=request.user, actor=request.user,
access="EXTERNAL", access="EXTERNAL",
@ -132,37 +132,35 @@ class IssueCommentPublicViewSet(BaseViewSet):
), ),
actor_id=str(request.user.id), actor_id=str(request.user.id),
issue_id=str(issue_id), issue_id=str(issue_id),
project_id=str(project_id), project_id=str(project_deploy_board.project_id),
current_instance=None, current_instance=None,
epoch=int(timezone.now().timestamp()), epoch=int(timezone.now().timestamp()),
) )
if not ProjectMember.objects.filter( if not ProjectMember.objects.filter(
project_id=project_id, project_id=project_deploy_board.project_id,
member=request.user, member=request.user,
is_active=True, is_active=True,
).exists(): ).exists():
# Add the user for workspace tracking # Add the user for workspace tracking
_ = ProjectPublicMember.objects.get_or_create( _ = ProjectPublicMember.objects.get_or_create(
project_id=project_id, project_id=project_deploy_board.project_id,
member=request.user, member=request.user,
) )
return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def partial_update(self, request, slug, project_id, issue_id, pk): def partial_update(self, request, anchor, issue_id, pk):
project_deploy_board = ProjectDeployBoard.objects.get( project_deploy_board = DeployBoard.objects.get(
workspace__slug=slug, project_id=project_id anchor=anchor, entity_name="project"
) )
if not project_deploy_board.comments: if not project_deploy_board.is_comments_enabled:
return Response( return Response(
{"error": "Comments are not enabled for this project"}, {"error": "Comments are not enabled for this project"},
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,
) )
comment = IssueComment.objects.get( comment = IssueComment.objects.get(pk=pk, actor=request.user)
workspace__slug=slug, pk=pk, actor=request.user
)
serializer = IssueCommentSerializer( serializer = IssueCommentSerializer(
comment, data=request.data, partial=True comment, data=request.data, partial=True
) )
@ -173,7 +171,7 @@ class IssueCommentPublicViewSet(BaseViewSet):
requested_data=json.dumps(request.data, cls=DjangoJSONEncoder), requested_data=json.dumps(request.data, cls=DjangoJSONEncoder),
actor_id=str(request.user.id), actor_id=str(request.user.id),
issue_id=str(issue_id), issue_id=str(issue_id),
project_id=str(project_id), project_id=str(project_deploy_board.project_id),
current_instance=json.dumps( current_instance=json.dumps(
IssueCommentSerializer(comment).data, IssueCommentSerializer(comment).data,
cls=DjangoJSONEncoder, cls=DjangoJSONEncoder,
@ -183,20 +181,18 @@ class IssueCommentPublicViewSet(BaseViewSet):
return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def destroy(self, request, slug, project_id, issue_id, pk): def destroy(self, request, anchor, issue_id, pk):
project_deploy_board = ProjectDeployBoard.objects.get( project_deploy_board = DeployBoard.objects.get(
workspace__slug=slug, project_id=project_id anchor=anchor, entity_name="project"
) )
if not project_deploy_board.comments: if not project_deploy_board.is_comments_enabled:
return Response( return Response(
{"error": "Comments are not enabled for this project"}, {"error": "Comments are not enabled for this project"},
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,
) )
comment = IssueComment.objects.get( comment = IssueComment.objects.get(
workspace__slug=slug,
pk=pk, pk=pk,
project_id=project_id,
actor=request.user, actor=request.user,
) )
issue_activity.delay( issue_activity.delay(
@ -204,7 +200,7 @@ class IssueCommentPublicViewSet(BaseViewSet):
requested_data=json.dumps({"comment_id": str(pk)}), requested_data=json.dumps({"comment_id": str(pk)}),
actor_id=str(request.user.id), actor_id=str(request.user.id),
issue_id=str(issue_id), issue_id=str(issue_id),
project_id=str(project_id), project_id=str(project_deploy_board.project_id),
current_instance=json.dumps( current_instance=json.dumps(
IssueCommentSerializer(comment).data, IssueCommentSerializer(comment).data,
cls=DjangoJSONEncoder, cls=DjangoJSONEncoder,
@ -221,11 +217,11 @@ class IssueReactionPublicViewSet(BaseViewSet):
def get_queryset(self): def get_queryset(self):
try: try:
project_deploy_board = ProjectDeployBoard.objects.get( project_deploy_board = DeployBoard.objects.get(
workspace__slug=self.kwargs.get("slug"), workspace__slug=self.kwargs.get("slug"),
project_id=self.kwargs.get("project_id"), project_id=self.kwargs.get("project_id"),
) )
if project_deploy_board.reactions: if project_deploy_board.is_reactions_enabled:
return ( return (
super() super()
.get_queryset() .get_queryset()
@ -236,15 +232,15 @@ class IssueReactionPublicViewSet(BaseViewSet):
.distinct() .distinct()
) )
return IssueReaction.objects.none() return IssueReaction.objects.none()
except ProjectDeployBoard.DoesNotExist: except DeployBoard.DoesNotExist:
return IssueReaction.objects.none() return IssueReaction.objects.none()
def create(self, request, slug, project_id, issue_id): def create(self, request, anchor, issue_id):
project_deploy_board = ProjectDeployBoard.objects.get( project_deploy_board = DeployBoard.objects.get(
workspace__slug=slug, project_id=project_id anchor=anchor, entity_name="project"
) )
if not project_deploy_board.reactions: if not project_deploy_board.is_reactions_enabled:
return Response( return Response(
{"error": "Reactions are not enabled for this project board"}, {"error": "Reactions are not enabled for this project board"},
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,
@ -253,16 +249,18 @@ class IssueReactionPublicViewSet(BaseViewSet):
serializer = IssueReactionSerializer(data=request.data) serializer = IssueReactionSerializer(data=request.data)
if serializer.is_valid(): if serializer.is_valid():
serializer.save( serializer.save(
project_id=project_id, issue_id=issue_id, actor=request.user project_id=project_deploy_board.project_id,
issue_id=issue_id,
actor=request.user,
) )
if not ProjectMember.objects.filter( if not ProjectMember.objects.filter(
project_id=project_id, project_id=project_deploy_board.project_id,
member=request.user, member=request.user,
is_active=True, is_active=True,
).exists(): ).exists():
# Add the user for workspace tracking # Add the user for workspace tracking
_ = ProjectPublicMember.objects.get_or_create( _ = ProjectPublicMember.objects.get_or_create(
project_id=project_id, project_id=project_deploy_board.project_id,
member=request.user, member=request.user,
) )
issue_activity.delay( issue_activity.delay(
@ -272,25 +270,25 @@ class IssueReactionPublicViewSet(BaseViewSet):
), ),
actor_id=str(self.request.user.id), actor_id=str(self.request.user.id),
issue_id=str(self.kwargs.get("issue_id", None)), issue_id=str(self.kwargs.get("issue_id", None)),
project_id=str(self.kwargs.get("project_id", None)), project_id=str(project_deploy_board.project_id),
current_instance=None, current_instance=None,
epoch=int(timezone.now().timestamp()), epoch=int(timezone.now().timestamp()),
) )
return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def destroy(self, request, slug, project_id, issue_id, reaction_code): def destroy(self, request, anchor, issue_id, reaction_code):
project_deploy_board = ProjectDeployBoard.objects.get( project_deploy_board = DeployBoard.objects.get(
workspace__slug=slug, project_id=project_id anchor=anchor, entity_name="project"
) )
if not project_deploy_board.reactions: if not project_deploy_board.is_reactions_enabled:
return Response( return Response(
{"error": "Reactions are not enabled for this project board"}, {"error": "Reactions are not enabled for this project board"},
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,
) )
issue_reaction = IssueReaction.objects.get( issue_reaction = IssueReaction.objects.get(
workspace__slug=slug, workspace_id=project_deploy_board.workspace_id,
issue_id=issue_id, issue_id=issue_id,
reaction=reaction_code, reaction=reaction_code,
actor=request.user, actor=request.user,
@ -300,7 +298,7 @@ class IssueReactionPublicViewSet(BaseViewSet):
requested_data=None, requested_data=None,
actor_id=str(self.request.user.id), actor_id=str(self.request.user.id),
issue_id=str(self.kwargs.get("issue_id", None)), issue_id=str(self.kwargs.get("issue_id", None)),
project_id=str(self.kwargs.get("project_id", None)), project_id=str(project_deploy_board.project_id),
current_instance=json.dumps( current_instance=json.dumps(
{ {
"reaction": str(reaction_code), "reaction": str(reaction_code),
@ -319,30 +317,29 @@ class CommentReactionPublicViewSet(BaseViewSet):
def get_queryset(self): def get_queryset(self):
try: try:
project_deploy_board = ProjectDeployBoard.objects.get( project_deploy_board = DeployBoard.objects.get(
workspace__slug=self.kwargs.get("slug"), anchor=self.kwargs.get("anchor"), entity_name="project"
project_id=self.kwargs.get("project_id"),
) )
if project_deploy_board.reactions: if project_deploy_board.is_reactions_enabled:
return ( return (
super() super()
.get_queryset() .get_queryset()
.filter(workspace__slug=self.kwargs.get("slug")) .filter(workspace_id=project_deploy_board.workspace_id)
.filter(project_id=self.kwargs.get("project_id")) .filter(project_id=project_deploy_board.project_id)
.filter(comment_id=self.kwargs.get("comment_id")) .filter(comment_id=self.kwargs.get("comment_id"))
.order_by("-created_at") .order_by("-created_at")
.distinct() .distinct()
) )
return CommentReaction.objects.none() return CommentReaction.objects.none()
except ProjectDeployBoard.DoesNotExist: except DeployBoard.DoesNotExist:
return CommentReaction.objects.none() return CommentReaction.objects.none()
def create(self, request, slug, project_id, comment_id): def create(self, request, anchor, comment_id):
project_deploy_board = ProjectDeployBoard.objects.get( project_deploy_board = DeployBoard.objects.get(
workspace__slug=slug, project_id=project_id anchor=anchor, entity_name="project"
) )
if not project_deploy_board.reactions: if not project_deploy_board.is_reactions_enabled:
return Response( return Response(
{"error": "Reactions are not enabled for this board"}, {"error": "Reactions are not enabled for this board"},
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,
@ -351,18 +348,18 @@ class CommentReactionPublicViewSet(BaseViewSet):
serializer = CommentReactionSerializer(data=request.data) serializer = CommentReactionSerializer(data=request.data)
if serializer.is_valid(): if serializer.is_valid():
serializer.save( serializer.save(
project_id=project_id, project_id=project_deploy_board.project_id,
comment_id=comment_id, comment_id=comment_id,
actor=request.user, actor=request.user,
) )
if not ProjectMember.objects.filter( if not ProjectMember.objects.filter(
project_id=project_id, project_id=project_deploy_board.project_id,
member=request.user, member=request.user,
is_active=True, is_active=True,
).exists(): ).exists():
# Add the user for workspace tracking # Add the user for workspace tracking
_ = ProjectPublicMember.objects.get_or_create( _ = ProjectPublicMember.objects.get_or_create(
project_id=project_id, project_id=project_deploy_board.project_id,
member=request.user, member=request.user,
) )
issue_activity.delay( issue_activity.delay(
@ -379,19 +376,19 @@ class CommentReactionPublicViewSet(BaseViewSet):
return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def destroy(self, request, slug, project_id, comment_id, reaction_code): def destroy(self, request, anchor, comment_id, reaction_code):
project_deploy_board = ProjectDeployBoard.objects.get( project_deploy_board = DeployBoard.objects.get(
workspace__slug=slug, project_id=project_id anchor=anchor, entity_name="project"
) )
if not project_deploy_board.reactions: if not project_deploy_board.is_reactions_enabled:
return Response( return Response(
{"error": "Reactions are not enabled for this board"}, {"error": "Reactions are not enabled for this board"},
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,
) )
comment_reaction = CommentReaction.objects.get( comment_reaction = CommentReaction.objects.get(
project_id=project_id, project_id=project_deploy_board.project_id,
workspace__slug=slug, workspace_id=project_deploy_board.workspace_id,
comment_id=comment_id, comment_id=comment_id,
reaction=reaction_code, reaction=reaction_code,
actor=request.user, actor=request.user,
@ -401,7 +398,7 @@ class CommentReactionPublicViewSet(BaseViewSet):
requested_data=None, requested_data=None,
actor_id=str(self.request.user.id), actor_id=str(self.request.user.id),
issue_id=None, issue_id=None,
project_id=str(self.kwargs.get("project_id", None)), project_id=str(project_deploy_board.project_id),
current_instance=json.dumps( current_instance=json.dumps(
{ {
"reaction": str(reaction_code), "reaction": str(reaction_code),
@ -421,36 +418,42 @@ class IssueVotePublicViewSet(BaseViewSet):
def get_queryset(self): def get_queryset(self):
try: try:
project_deploy_board = ProjectDeployBoard.objects.get( project_deploy_board = DeployBoard.objects.get(
workspace__slug=self.kwargs.get("slug"), workspace__slug=self.kwargs.get("anchor"),
project_id=self.kwargs.get("project_id"), entity_name="project",
) )
if project_deploy_board.votes: if project_deploy_board.is_votes_enabled:
return ( return (
super() super()
.get_queryset() .get_queryset()
.filter(issue_id=self.kwargs.get("issue_id")) .filter(issue_id=self.kwargs.get("issue_id"))
.filter(workspace__slug=self.kwargs.get("slug")) .filter(workspace_id=project_deploy_board.workspace_id)
.filter(project_id=self.kwargs.get("project_id")) .filter(project_id=project_deploy_board.project_id)
) )
return IssueVote.objects.none() return IssueVote.objects.none()
except ProjectDeployBoard.DoesNotExist: except DeployBoard.DoesNotExist:
return IssueVote.objects.none() return IssueVote.objects.none()
def create(self, request, slug, project_id, issue_id): def create(self, request, anchor, issue_id):
print("hite")
project_deploy_board = DeployBoard.objects.get(
anchor=anchor, entity_name="project"
)
print("awer")
issue_vote, _ = IssueVote.objects.get_or_create( issue_vote, _ = IssueVote.objects.get_or_create(
actor_id=request.user.id, actor_id=request.user.id,
project_id=project_id, project_id=project_deploy_board.project_id,
issue_id=issue_id, issue_id=issue_id,
) )
print("AWer")
# Add the user for workspace tracking # Add the user for workspace tracking
if not ProjectMember.objects.filter( if not ProjectMember.objects.filter(
project_id=project_id, project_id=project_deploy_board.project_id,
member=request.user, member=request.user,
is_active=True, is_active=True,
).exists(): ).exists():
_ = ProjectPublicMember.objects.get_or_create( _ = ProjectPublicMember.objects.get_or_create(
project_id=project_id, project_id=project_deploy_board.project_id,
member=request.user, member=request.user,
) )
issue_vote.vote = request.data.get("vote", 1) issue_vote.vote = request.data.get("vote", 1)
@ -462,26 +465,29 @@ class IssueVotePublicViewSet(BaseViewSet):
), ),
actor_id=str(self.request.user.id), actor_id=str(self.request.user.id),
issue_id=str(self.kwargs.get("issue_id", None)), issue_id=str(self.kwargs.get("issue_id", None)),
project_id=str(self.kwargs.get("project_id", None)), project_id=str(project_deploy_board.project_id),
current_instance=None, current_instance=None,
epoch=int(timezone.now().timestamp()), epoch=int(timezone.now().timestamp()),
) )
serializer = IssueVoteSerializer(issue_vote) serializer = IssueVoteSerializer(issue_vote)
return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.data, status=status.HTTP_201_CREATED)
def destroy(self, request, slug, project_id, issue_id): def destroy(self, request, anchor, issue_id):
project_deploy_board = DeployBoard.objects.get(
anchor=anchor, entity_name="project"
)
issue_vote = IssueVote.objects.get( issue_vote = IssueVote.objects.get(
workspace__slug=slug,
project_id=project_id,
issue_id=issue_id, issue_id=issue_id,
actor_id=request.user.id, actor_id=request.user.id,
project_id=project_deploy_board.project_id,
workspace_id=project_deploy_board.workspace_id,
) )
issue_activity.delay( issue_activity.delay(
type="issue_vote.activity.deleted", type="issue_vote.activity.deleted",
requested_data=None, requested_data=None,
actor_id=str(self.request.user.id), actor_id=str(self.request.user.id),
issue_id=str(self.kwargs.get("issue_id", None)), issue_id=str(self.kwargs.get("issue_id", None)),
project_id=str(self.kwargs.get("project_id", None)), project_id=str(project_deploy_board.project_id),
current_instance=json.dumps( current_instance=json.dumps(
{ {
"vote": str(issue_vote.vote), "vote": str(issue_vote.vote),
@ -499,9 +505,14 @@ class IssueRetrievePublicEndpoint(BaseAPIView):
AllowAny, AllowAny,
] ]
def get(self, request, slug, project_id, issue_id): def get(self, request, anchor, issue_id):
project_deploy_board = DeployBoard.objects.get(
anchor=anchor, entity_name="project"
)
issue = Issue.objects.get( issue = Issue.objects.get(
workspace__slug=slug, project_id=project_id, pk=issue_id workspace_id=project_deploy_board.workspace_id,
project_id=project_deploy_board.project_id,
pk=issue_id,
) )
serializer = IssuePublicSerializer(issue) serializer = IssuePublicSerializer(issue)
return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.data, status=status.HTTP_200_OK)
@ -512,14 +523,17 @@ class ProjectIssuesPublicEndpoint(BaseAPIView):
AllowAny, AllowAny,
] ]
def get(self, request, slug, project_id): def get(self, request, anchor):
if not ProjectDeployBoard.objects.filter( if not DeployBoard.objects.filter(
workspace__slug=slug, project_id=project_id anchor=anchor, entity_name="project"
).exists(): ).exists():
return Response( return Response(
{"error": "Project is not published"}, {"error": "Project is not published"},
status=status.HTTP_404_NOT_FOUND, status=status.HTTP_404_NOT_FOUND,
) )
project_deploy_board = DeployBoard.objects.get(
anchor=anchor, entity_name="project"
)
filters = issue_filters(request.query_params, "GET") filters = issue_filters(request.query_params, "GET")
@ -544,8 +558,8 @@ class ProjectIssuesPublicEndpoint(BaseAPIView):
.annotate(count=Func(F("id"), function="Count")) .annotate(count=Func(F("id"), function="Count"))
.values("count") .values("count")
) )
.filter(project_id=project_id) .filter(project_id=project_deploy_board.project_id)
.filter(workspace__slug=slug) .filter(workspace_id=project_deploy_board.workspace_id)
.select_related("project", "workspace", "state", "parent") .select_related("project", "workspace", "state", "parent")
.prefetch_related("assignees", "labels") .prefetch_related("assignees", "labels")
.prefetch_related( .prefetch_related(
@ -652,8 +666,8 @@ class ProjectIssuesPublicEndpoint(BaseAPIView):
states = ( states = (
State.objects.filter( State.objects.filter(
~Q(name="Triage"), ~Q(name="Triage"),
workspace__slug=slug, workspace_id=project_deploy_board.workspace_id,
project_id=project_id, project_id=project_deploy_board.project_id,
) )
.annotate( .annotate(
custom_order=Case( custom_order=Case(
@ -670,7 +684,8 @@ class ProjectIssuesPublicEndpoint(BaseAPIView):
) )
labels = Label.objects.filter( labels = Label.objects.filter(
workspace__slug=slug, project_id=project_id workspace_id=project_deploy_board.workspace_id,
project_id=project_deploy_board.project_id,
).values("id", "name", "color", "parent") ).values("id", "name", "color", "parent")
## Grouping the results ## Grouping the results

View File

@ -11,10 +11,10 @@ from rest_framework.permissions import AllowAny
# Module imports # Module imports
from .base import BaseAPIView from .base import BaseAPIView
from plane.app.serializers import ProjectDeployBoardSerializer from plane.app.serializers import DeployBoardSerializer
from plane.db.models import ( from plane.db.models import (
Project, Project,
ProjectDeployBoard, DeployBoard,
) )
@ -23,11 +23,11 @@ class ProjectDeployBoardPublicSettingsEndpoint(BaseAPIView):
AllowAny, AllowAny,
] ]
def get(self, request, slug, project_id): def get(self, request, anchor):
project_deploy_board = ProjectDeployBoard.objects.get( project_deploy_board = DeployBoard.objects.get(
workspace__slug=slug, project_id=project_id anchor=anchor, entity_name="project"
) )
serializer = ProjectDeployBoardSerializer(project_deploy_board) serializer = DeployBoardSerializer(project_deploy_board)
return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.data, status=status.HTTP_200_OK)
@ -36,13 +36,16 @@ class WorkspaceProjectDeployBoardEndpoint(BaseAPIView):
AllowAny, AllowAny,
] ]
def get(self, request, slug): def get(self, request, anchor):
deploy_board = DeployBoard.objects.filter(anchor=anchor, entity_name="project").values_list
projects = ( projects = (
Project.objects.filter(workspace__slug=slug) Project.objects.filter(workspace=deploy_board.workspace)
.annotate( .annotate(
is_public=Exists( is_public=Exists(
ProjectDeployBoard.objects.filter( DeployBoard.objects.filter(
workspace__slug=slug, project_id=OuterRef("pk") anchor=anchor,
project_id=OuterRef("pk"),
entity_name="project",
) )
) )
) )
@ -58,3 +61,16 @@ class WorkspaceProjectDeployBoardEndpoint(BaseAPIView):
) )
return Response(projects, status=status.HTTP_200_OK) return Response(projects, status=status.HTTP_200_OK)
class WorkspaceProjectAnchorEndpoint(BaseAPIView):
permission_classes = [
AllowAny,
]
def get(self, request, slug, project_id):
project_deploy_board = DeployBoard.objects.get(
workspace__slug=slug, project_id=project_id
)
serializer = DeployBoardSerializer(project_deploy_board)
return Response(serializer.data, status=status.HTTP_200_OK)

View File

@ -4,18 +4,28 @@ from itertools import groupby
# Django import # Django import
from django.db import models from django.db import models
from django.db.models import Case, CharField, Count, F, Sum, Value, When from django.db.models import (
Case,
CharField,
Count,
F,
Sum,
Value,
When,
IntegerField,
)
from django.db.models.functions import ( from django.db.models.functions import (
Coalesce, Coalesce,
Concat, Concat,
ExtractMonth, ExtractMonth,
ExtractYear, ExtractYear,
TruncDate, TruncDate,
Cast,
) )
from django.utils import timezone from django.utils import timezone
# Module imports # Module imports
from plane.db.models import Issue from plane.db.models import Issue, Project
def annotate_with_monthly_dimension(queryset, field_name, attribute): def annotate_with_monthly_dimension(queryset, field_name, attribute):
@ -87,9 +97,9 @@ def build_graph_plot(queryset, x_axis, y_axis, segment=None):
# Estimate # Estimate
else: else:
queryset = queryset.annotate(estimate=Sum("estimate_point")).order_by( queryset = queryset.annotate(
x_axis estimate=Sum(Cast("estimate_point__value", IntegerField()))
) ).order_by(x_axis)
queryset = ( queryset = (
queryset.annotate(segment=F(segment)) if segment else queryset queryset.annotate(segment=F(segment)) if segment else queryset
) )
@ -110,9 +120,33 @@ def build_graph_plot(queryset, x_axis, y_axis, segment=None):
return sort_data(grouped_data, temp_axis) return sort_data(grouped_data, temp_axis)
def burndown_plot(queryset, slug, project_id, cycle_id=None, module_id=None): def burndown_plot(
queryset,
slug,
project_id,
plot_type,
cycle_id=None,
module_id=None,
):
# Total Issues in Cycle or Module # Total Issues in Cycle or Module
total_issues = queryset.total_issues total_issues = queryset.total_issues
# check whether the estimate is a point or not
estimate_type = Project.objects.filter(
workspace__slug=slug,
pk=project_id,
estimate__isnull=False,
estimate__type="points",
).exists()
if estimate_type and plot_type == "points":
issue_estimates = Issue.objects.filter(
workspace__slug=slug,
project_id=project_id,
issue_cycle__cycle_id=cycle_id,
estimate_point__isnull=False,
).values_list("estimate_point__value", flat=True)
issue_estimates = [int(value) for value in issue_estimates]
total_estimate_points = sum(issue_estimates)
if cycle_id: if cycle_id:
if queryset.end_date and queryset.start_date: if queryset.end_date and queryset.start_date:
@ -128,18 +162,32 @@ def burndown_plot(queryset, slug, project_id, cycle_id=None, module_id=None):
chart_data = {str(date): 0 for date in date_range} chart_data = {str(date): 0 for date in date_range}
completed_issues_distribution = ( if plot_type == "points":
Issue.issue_objects.filter( completed_issues_estimate_point_distribution = (
workspace__slug=slug, Issue.issue_objects.filter(
project_id=project_id, workspace__slug=slug,
issue_cycle__cycle_id=cycle_id, project_id=project_id,
issue_cycle__cycle_id=cycle_id,
estimate_point__isnull=False,
)
.annotate(date=TruncDate("completed_at"))
.values("date")
.values("date", "estimate_point__value")
.order_by("date")
)
else:
completed_issues_distribution = (
Issue.issue_objects.filter(
workspace__slug=slug,
project_id=project_id,
issue_cycle__cycle_id=cycle_id,
)
.annotate(date=TruncDate("completed_at"))
.values("date")
.annotate(total_completed=Count("id"))
.values("date", "total_completed")
.order_by("date")
) )
.annotate(date=TruncDate("completed_at"))
.values("date")
.annotate(total_completed=Count("id"))
.values("date", "total_completed")
.order_by("date")
)
if module_id: if module_id:
# Get all dates between the two dates # Get all dates between the two dates
@ -152,31 +200,59 @@ def burndown_plot(queryset, slug, project_id, cycle_id=None, module_id=None):
chart_data = {str(date): 0 for date in date_range} chart_data = {str(date): 0 for date in date_range}
completed_issues_distribution = ( if plot_type == "points":
Issue.issue_objects.filter( completed_issues_estimate_point_distribution = (
workspace__slug=slug, Issue.issue_objects.filter(
project_id=project_id, workspace__slug=slug,
issue_module__module_id=module_id, project_id=project_id,
issue_module__module_id=module_id,
estimate_point__isnull=False,
)
.annotate(date=TruncDate("completed_at"))
.values("date")
.values("date", "estimate_point__value")
.order_by("date")
)
else:
completed_issues_distribution = (
Issue.issue_objects.filter(
workspace__slug=slug,
project_id=project_id,
issue_module__module_id=module_id,
)
.annotate(date=TruncDate("completed_at"))
.values("date")
.annotate(total_completed=Count("id"))
.values("date", "total_completed")
.order_by("date")
) )
.annotate(date=TruncDate("completed_at"))
.values("date")
.annotate(total_completed=Count("id"))
.values("date", "total_completed")
.order_by("date")
)
for date in date_range: for date in date_range:
cumulative_pending_issues = total_issues if plot_type == "points":
total_completed = 0 cumulative_pending_issues = total_estimate_points
total_completed = sum( total_completed = 0
item["total_completed"] total_completed = sum(
for item in completed_issues_distribution int(item["estimate_point__value"])
if item["date"] is not None and item["date"] <= date for item in completed_issues_estimate_point_distribution
) if item["date"] is not None and item["date"] <= date
cumulative_pending_issues -= total_completed )
if date > timezone.now().date(): cumulative_pending_issues -= total_completed
chart_data[str(date)] = None if date > timezone.now().date():
chart_data[str(date)] = None
else:
chart_data[str(date)] = cumulative_pending_issues
else: else:
chart_data[str(date)] = cumulative_pending_issues cumulative_pending_issues = total_issues
total_completed = 0
total_completed = sum(
item["total_completed"]
for item in completed_issues_distribution
if item["date"] is not None and item["date"] <= date
)
cumulative_pending_issues -= total_completed
if date > timezone.now().date():
chart_data[str(date)] = None
else:
chart_data[str(date)] = cumulative_pending_issues
return chart_data return chart_data

View File

@ -54,7 +54,7 @@ export type TXAxisValues =
| "state__group" | "state__group"
| "labels__id" | "labels__id"
| "assignees__id" | "assignees__id"
| "estimate_point" | "estimate_point__value"
| "issue_cycle__cycle_id" | "issue_cycle__cycle_id"
| "issue_module__module_id" | "issue_module__module_id"
| "priority" | "priority"

View File

@ -24,3 +24,16 @@ export enum EIssueCommentAccessSpecifier {
EXTERNAL = "EXTERNAL", EXTERNAL = "EXTERNAL",
INTERNAL = "INTERNAL", INTERNAL = "INTERNAL",
} }
// estimates
export enum EEstimateSystem {
POINTS = "points",
CATEGORIES = "categories",
TIME = "time",
}
export enum EEstimateUpdateStages {
CREATE = "create",
EDIT = "edit",
SWITCH = "switch",
}

View File

@ -1,40 +1,77 @@
export interface IEstimate { import { EEstimateSystem, EEstimateUpdateStages } from "./enums";
created_at: Date;
created_by: string;
description: string;
id: string;
name: string;
project: string;
project_detail: IProject;
updated_at: Date;
updated_by: string;
points: IEstimatePoint[];
workspace: string;
workspace_detail: IWorkspace;
}
export interface IEstimatePoint { export interface IEstimatePoint {
created_at: string; id: string | undefined;
created_by: string; key: number | undefined;
description: string; value: string | undefined;
estimate: string; description: string | undefined;
id: string; workspace: string | undefined;
key: number; project: string | undefined;
project: string; estimate: string | undefined;
updated_at: string; created_at: Date | undefined;
updated_by: string; updated_at: Date | undefined;
value: string; created_by: string | undefined;
workspace: string; updated_by: string | undefined;
}
export type TEstimateSystemKeys =
| EEstimateSystem.POINTS
| EEstimateSystem.CATEGORIES
| EEstimateSystem.TIME;
export interface IEstimate {
id: string | undefined;
name: string | undefined;
description: string | undefined;
type: TEstimateSystemKeys | undefined; // categories, points, time
points: IEstimatePoint[] | undefined;
workspace: string | undefined;
project: string | undefined;
last_used: boolean | undefined;
created_at: Date | undefined;
updated_at: Date | undefined;
created_by: string | undefined;
updated_by: string | undefined;
} }
export interface IEstimateFormData { export interface IEstimateFormData {
estimate: { estimate?: {
name: string; name?: string;
description: string; type?: string;
last_used?: boolean;
}; };
estimate_points: { estimate_points: {
id?: string; id?: string | undefined;
key: number; key: number;
value: string; value: string;
}[]; }[];
} }
export type TEstimatePointsObject = {
id?: string | undefined;
key: number;
value: string;
};
export type TTemplateValues = {
title: string;
values: TEstimatePointsObject[];
hide?: boolean;
};
export type TEstimateSystem = {
name: string;
templates: Record<string, TTemplateValues>;
is_available: boolean;
is_ee: boolean;
};
export type TEstimateSystems = {
[K in TEstimateSystemKeys]: TEstimateSystem;
};
// update estimates
export type TEstimateUpdateStageKeys =
| EEstimateUpdateStages.CREATE
| EEstimateUpdateStages.EDIT
| EEstimateUpdateStages.SWITCH;

View File

@ -15,7 +15,6 @@ export * from "./importer";
export * from "./inbox"; export * from "./inbox";
export * from "./analytics"; export * from "./analytics";
export * from "./api_token"; export * from "./api_token";
export * from "./app";
export * from "./auth"; export * from "./auth";
export * from "./calendar"; export * from "./calendar";
export * from "./instance"; export * from "./instance";
@ -28,3 +27,4 @@ export * from "./webhook";
export * from "./workspace-views"; export * from "./workspace-views";
export * from "./common"; export * from "./common";
export * from "./pragmatic"; export * from "./pragmatic";
export * from "./publish";

View File

@ -15,7 +15,7 @@ export type TIssue = {
priority: TIssuePriorities; priority: TIssuePriorities;
label_ids: string[]; label_ids: string[];
assignee_ids: string[]; assignee_ids: string[];
estimate_point: number | null; estimate_point: string | null;
sub_issues_count: number; sub_issues_count: number;
attachment_count: number; attachment_count: number;

View File

@ -44,6 +44,8 @@ export interface IModule {
target_date: string | null; target_date: string | null;
total_issues: number; total_issues: number;
unstarted_issues: number; unstarted_issues: number;
total_estimate_points?: number;
completed_estimate_points?: number;
updated_at: string; updated_at: string;
updated_by?: string; updated_by?: string;
archived_at: string | null; archived_at: string | null;

View File

@ -32,7 +32,7 @@ export interface IProject {
estimate: string | null; estimate: string | null;
id: string; id: string;
identifier: string; identifier: string;
is_deployed: boolean; anchor: string | null;
is_favorite: boolean; is_favorite: boolean;
is_member: boolean; is_member: boolean;
logo_props: TLogoProps; logo_props: TLogoProps;

41
packages/types/src/publish.d.ts vendored Normal file
View File

@ -0,0 +1,41 @@
import { IProject, IProjectLite, IWorkspaceLite } from "@plane/types";
export type TPublishEntityType = "project";
export type TProjectPublishLayouts =
| "calendar"
| "gantt"
| "kanban"
| "list"
| "spreadsheet";
export type TPublishViewProps = {
calendar?: boolean;
gantt?: boolean;
kanban?: boolean;
list?: boolean;
spreadsheet?: boolean;
};
export type TProjectDetails = IProjectLite &
Pick<IProject, "cover_image" | "logo_props" | "description">;
export type TPublishSettings = {
anchor: string | undefined;
is_comments_enabled: boolean;
created_at: string | undefined;
created_by: string | undefined;
entity_identifier: string | undefined;
entity_name: TPublishEntityType | undefined;
id: string | undefined;
inbox: unknown;
project: string | undefined;
project_details: TProjectDetails | undefined;
is_reactions_enabled: boolean;
updated_at: string | undefined;
updated_by: string | undefined;
view_props: TViewProps | undefined;
is_votes_enabled: boolean;
workspace: string | undefined;
workspace_detail: IWorkspaceLite | undefined;
};

View File

@ -24,7 +24,7 @@
"@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.0.3", "@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.0.3",
"@blueprintjs/core": "^4.16.3", "@blueprintjs/core": "^4.16.3",
"@blueprintjs/popover2": "^1.13.3", "@blueprintjs/popover2": "^1.13.3",
"@headlessui/react": "^1.7.17", "@headlessui/react": "^2.0.3",
"@popperjs/core": "^2.11.8", "@popperjs/core": "^2.11.8",
"clsx": "^2.0.0", "clsx": "^2.0.0",
"emoji-picker-react": "^4.5.16", "emoji-picker-react": "^4.5.16",
@ -33,7 +33,7 @@
"react-color": "^2.19.3", "react-color": "^2.19.3",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-popper": "^2.3.0", "react-popper": "^2.3.0",
"sonner": "^1.4.2", "sonner": "^1.4.41",
"tailwind-merge": "^2.0.0" "tailwind-merge": "^2.0.0"
}, },
"devDependencies": { "devDependencies": {

View File

@ -14,5 +14,6 @@ export * from "./loader";
export * from "./control-link"; export * from "./control-link";
export * from "./toast"; export * from "./toast";
export * from "./drag-handle"; export * from "./drag-handle";
export * from "./typography";
export * from "./drop-indicator"; export * from "./drop-indicator";
export * from "./sortable"; export * from "./sortable";

View File

@ -1,6 +1,5 @@
import type { Meta, StoryObj } from "@storybook/react"; import type { Meta, StoryObj } from "@storybook/react";
import React from "react"; import React from "react";
import { Draggable } from "./draggable";
import { Sortable } from "./sortable"; import { Sortable } from "./sortable";
const meta: Meta<typeof Sortable> = { const meta: Meta<typeof Sortable> = {
@ -13,7 +12,7 @@ type Story = StoryObj<typeof Sortable>;
const data = [ const data = [
{ id: "1", name: "John Doe" }, { id: "1", name: "John Doe" },
{ id: "2", name: "Jane Doe 2" }, { id: "2", name: "Satish" },
{ id: "3", name: "Alice" }, { id: "3", name: "Alice" },
{ id: "4", name: "Bob" }, { id: "4", name: "Bob" },
{ id: "5", name: "Charlie" }, { id: "5", name: "Charlie" },

View File

@ -8,7 +8,7 @@ type Props<T> = {
onChange: (data: T[]) => void; onChange: (data: T[]) => void;
keyExtractor: (item: T, index: number) => string; keyExtractor: (item: T, index: number) => string;
containerClassName?: string; containerClassName?: string;
id: string; id?: string;
}; };
const moveItem = <T,>( const moveItem = <T,>(
@ -17,7 +17,7 @@ const moveItem = <T,>(
destination: T & Record<symbol, string>, destination: T & Record<symbol, string>,
keyExtractor: (item: T, index: number) => string keyExtractor: (item: T, index: number) => string
) => { ) => {
const sourceIndex = data.indexOf(source); const sourceIndex = data.findIndex((item, index) => keyExtractor(item, index) === keyExtractor(source, 0));
if (sourceIndex === -1) return data; if (sourceIndex === -1) return data;
const destinationIndex = data.findIndex((item, index) => keyExtractor(item, index) === keyExtractor(destination, 0)); const destinationIndex = data.findIndex((item, index) => keyExtractor(item, index) === keyExtractor(destination, 0));

View File

@ -0,0 +1,42 @@
import { notFound, redirect } from "next/navigation";
// types
import { TPublishSettings } from "@plane/types";
// services
import PublishService from "@/services/publish.service";
const publishService = new PublishService();
type Props = {
params: {
workspaceSlug: string;
projectId: string;
};
searchParams: any;
};
export default async function IssuesPage(props: Props) {
const { params, searchParams } = props;
// query params
const { workspaceSlug, projectId } = params;
const { board, peekId } = searchParams;
let response: TPublishSettings | undefined = undefined;
try {
response = await publishService.fetchAnchorFromProjectDetails(workspaceSlug, projectId);
} catch (error) {
// redirect to 404 page on error
notFound();
}
let url = "";
if (response?.entity_name === "project") {
url = `/issues/${response?.anchor}`;
const params = new URLSearchParams();
if (board) params.append("board", board);
if (peekId) params.append("peekId", peekId);
if (params.toString()) url += `?${params.toString()}`;
redirect(url);
} else {
notFound();
}
}

View File

@ -1,16 +0,0 @@
"use client";
import { useSearchParams } from "next/navigation";
// components
import { ProjectDetailsView } from "@/components/views";
export default function WorkspaceProjectPage({ params }: { params: { workspace_slug: any; project_id: any } }) {
const { workspace_slug, project_id } = params;
const searchParams = useSearchParams();
const peekId = searchParams.get("peekId") || undefined;
if (!workspace_slug || !project_id) return <></>;
return <ProjectDetailsView workspaceSlug={workspace_slug} projectId={project_id} peekId={peekId} />;
}

View File

@ -1,38 +1,47 @@
"use client"; "use client";
import Image from "next/image"; // ui
import { useTheme } from "next-themes";
import { Button } from "@plane/ui"; import { Button } from "@plane/ui";
// assets
import InstanceFailureDarkImage from "@/public/instance/instance-failure-dark.svg";
import InstanceFailureImage from "@/public/instance/instance-failure.svg";
export default function InstanceError() {
const { resolvedTheme } = useTheme();
const instanceImage = resolvedTheme === "dark" ? InstanceFailureDarkImage : InstanceFailureImage;
const ErrorPage = () => {
const handleRetry = () => { const handleRetry = () => {
window.location.reload(); window.location.reload();
}; };
return ( return (
<div className="relative h-screen overflow-x-hidden overflow-y-auto container px-5 mx-auto flex justify-center items-center"> <div className="grid h-screen place-items-center p-4">
<div className="w-auto max-w-2xl relative space-y-8 py-10"> <div className="space-y-8 text-center">
<div className="relative flex flex-col justify-center items-center space-y-4"> <div className="space-y-2">
<Image src={instanceImage} alt="Plane instance failure image" /> <h3 className="text-lg font-semibold">Exception Detected!</h3>
<h3 className="font-medium text-2xl text-white ">Unable to fetch instance details.</h3> <p className="mx-auto w-1/2 text-sm text-custom-text-200">
<p className="font-medium text-base text-center"> We{"'"}re Sorry! An exception has been detected, and our engineering team has been notified. We apologize
We were unable to fetch the details of the instance. <br /> for any inconvenience this may have caused. Please reach out to our engineering team at{" "}
Fret not, it might just be a connectivity issue. <a href="mailto:support@plane.so" className="text-custom-primary">
support@plane.so
</a>{" "}
or on our{" "}
<a
href="https://discord.com/invite/A92xrEGCge"
target="_blank"
className="text-custom-primary"
rel="noopener noreferrer"
>
Discord
</a>{" "}
server for further assistance.
</p> </p>
</div> </div>
<div className="flex justify-center"> <div className="flex items-center justify-center gap-2">
<Button size="md" onClick={handleRetry}> <Button variant="primary" size="md" onClick={handleRetry}>
Retry Refresh
</Button> </Button>
{/* <Button variant="neutral-primary" size="md" onClick={() => {}}>
Sign out
</Button> */}
</div> </div>
</div> </div>
</div> </div>
); );
} };
export default ErrorPage;

View File

@ -1,25 +1,39 @@
"use client";
import { observer } from "mobx-react-lite";
import Image from "next/image"; import Image from "next/image";
import { notFound } from "next/navigation"; import useSWR from "swr";
// components // components
import IssueNavbar from "@/components/issues/navbar"; import { LogoSpinner } from "@/components/common";
import { IssuesNavbarRoot } from "@/components/issues";
// hooks
import { usePublish, usePublishList } from "@/hooks/store";
// assets // assets
import planeLogo from "public/plane-logo.svg"; import planeLogo from "@/public/plane-logo.svg";
export default async function ProjectLayout({ type Props = {
children,
params,
}: {
children: React.ReactNode; children: React.ReactNode;
params: { workspace_slug: string; project_id: string }; params: {
}) { anchor: string;
const { workspace_slug, project_id } = params; };
};
if (!workspace_slug || !project_id) notFound(); const IssuesLayout = observer((props: Props) => {
const { children, params } = props;
// params
const { anchor } = params;
// store hooks
const { fetchPublishSettings } = usePublishList();
const publishSettings = usePublish(anchor);
// fetch publish settings
useSWR(anchor ? `PUBLISH_SETTINGS_${anchor}` : null, anchor ? () => fetchPublishSettings(anchor) : null);
if (!publishSettings) return <LogoSpinner />;
return ( return (
<div className="relative flex h-screen min-h-[500px] w-screen flex-col overflow-hidden"> <div className="relative flex h-screen min-h-[500px] w-screen flex-col overflow-hidden">
<div className="relative flex h-[60px] flex-shrink-0 select-none items-center border-b border-custom-border-300 bg-custom-sidebar-background-100"> <div className="relative flex h-[60px] flex-shrink-0 select-none items-center border-b border-custom-border-300 bg-custom-sidebar-background-100">
<IssueNavbar workspaceSlug={workspace_slug} projectId={project_id} /> <IssuesNavbarRoot publishSettings={publishSettings} />
</div> </div>
<div className="relative h-full w-full overflow-hidden bg-custom-background-90">{children}</div> <div className="relative h-full w-full overflow-hidden bg-custom-background-90">{children}</div>
<a <a
@ -37,4 +51,6 @@ export default async function ProjectLayout({
</a> </a>
</div> </div>
); );
} });
export default IssuesLayout;

View File

@ -0,0 +1,30 @@
"use client";
import { observer } from "mobx-react-lite";
import { useSearchParams } from "next/navigation";
// components
import { IssuesLayoutsRoot } from "@/components/issues";
// hooks
import { usePublish } from "@/hooks/store";
type Props = {
params: {
anchor: string;
};
};
const IssuesPage = observer((props: Props) => {
const { params } = props;
const { anchor } = params;
// params
const searchParams = useSearchParams();
const peekId = searchParams.get("peekId") || undefined;
const publishSettings = usePublish(anchor);
if (!publishSettings) return null;
return <IssuesLayoutsRoot peekId={peekId} publishSettings={publishSettings} />;
});
export default IssuesPage;

View File

@ -4,20 +4,18 @@ import Image from "next/image";
// assets // assets
import UserLoggedInImage from "public/user-logged-in.svg"; import UserLoggedInImage from "public/user-logged-in.svg";
export default function NotFound() { const NotFound = () => (
return ( <div className="h-screen w-screen grid place-items-center">
<div className="flex h-screen w-screen flex-col"> <div className="text-center">
<div className="grid h-full w-full place-items-center p-6"> <div className="mx-auto size-52 grid place-items-center rounded-full bg-custom-background-80">
<div className="text-center"> <div className="size-32">
<div className="mx-auto grid h-52 w-52 place-items-center rounded-full bg-custom-background-80"> <Image src={UserLoggedInImage} alt="User already logged in" />
<div className="h-32 w-32">
<Image src={UserLoggedInImage} alt="User already logged in" />
</div>
</div>
<h1 className="mt-12 text-3xl font-semibold">Not Found</h1>
<p className="mt-4">Please enter the appropriate project URL to view the issue board.</p>
</div> </div>
</div> </div>
<h1 className="mt-12 text-3xl font-semibold">Not Found</h1>
<p className="mt-4">Please enter the appropriate project URL to view the issue board.</p>
</div> </div>
); </div>
} );
export default NotFound;

View File

@ -1,36 +1,44 @@
"use client"; "use client";
import { observer } from "mobx-react-lite";
import Image from "next/image"; import Image from "next/image";
import { useTheme } from "next-themes";
// components // components
import { UserAvatar } from "@/components/issues/navbar/user-avatar"; import { UserAvatar } from "@/components/issues";
// hooks // hooks
import { useUser } from "@/hooks/store"; import { useUser } from "@/hooks/store";
// assets // assets
import PlaneLogo from "@/public/plane-logos/black-horizontal-with-blue-logo.png"; import PlaneBlackLogo from "@/public/plane-logos/black-horizontal-with-blue-logo.png";
import PlaneWhiteLogo from "@/public/plane-logos/white-horizontal-with-blue-logo.png";
import UserLoggedInImage from "@/public/user-logged-in.svg"; import UserLoggedInImage from "@/public/user-logged-in.svg";
export const UserLoggedIn = () => { export const UserLoggedIn = observer(() => {
// store hooks
const { data: user } = useUser(); const { data: user } = useUser();
// next-themes
const { resolvedTheme } = useTheme();
const logo = resolvedTheme === "dark" ? PlaneWhiteLogo : PlaneBlackLogo;
if (!user) return null; if (!user) return null;
return ( return (
<div className="flex h-screen w-screen flex-col"> <div className="flex flex-col h-screen w-screen">
<div className="relative flex w-full items-center justify-between gap-4 border-b border-custom-border-200 px-6 py-5"> <div className="relative flex w-full items-center justify-between gap-4 border-b border-custom-border-200 px-6 py-5">
<div> <div className="h-[30px] w-[133px]">
<Image src={PlaneLogo} alt="User already logged in" /> <Image src={logo} alt="Plane logo" />
</div> </div>
<UserAvatar /> <UserAvatar />
</div> </div>
<div className="grid h-full w-full place-items-center p-6"> <div className="size-full grid place-items-center p-6">
<div className="text-center"> <div className="text-center">
<div className="mx-auto grid h-52 w-52 place-items-center rounded-full bg-custom-background-80"> <div className="mx-auto size-52 grid place-items-center rounded-full bg-custom-background-80">
<div className="h-32 w-32"> <div className="size-32">
<Image src={UserLoggedInImage} alt="User already logged in" /> <Image src={UserLoggedInImage} alt="User already logged in" />
</div> </div>
</div> </div>
<h1 className="mt-12 text-3xl font-semibold">Logged in Successfully!</h1> <h1 className="mt-12 text-3xl font-semibold">Logged in successfully!</h1>
<p className="mt-4"> <p className="mt-4">
You{"'"}ve successfully logged in. Please enter the appropriate project URL to view the issue board. You{"'"}ve successfully logged in. Please enter the appropriate project URL to view the issue board.
</p> </p>
@ -38,4 +46,4 @@ export const UserLoggedIn = () => {
</div> </div>
</div> </div>
); );
}; });

View File

@ -1,3 +1,2 @@
export * from "./latest-feature-block";
export * from "./project-logo"; export * from "./project-logo";
export * from "./logo-spinner"; export * from "./logo-spinner";

View File

@ -1,40 +0,0 @@
import Image from "next/image";
import Link from "next/link";
import { useTheme } from "next-themes";
// icons
import { Lightbulb } from "lucide-react";
// images
import latestFeatures from "public/onboarding/onboarding-pages.svg";
export const LatestFeatureBlock = () => {
const { resolvedTheme } = useTheme();
return (
<>
<div className="mx-auto mt-16 flex rounded-[3.5px] border border-onboarding-border-200 bg-onboarding-background-100 py-2 sm:w-96">
<Lightbulb className="mx-3 mr-2 h-7 w-7" />
<p className="text-left text-sm text-onboarding-text-100">
Pages gets a facelift! Write anything and use Galileo to help you start.{" "}
<Link href="https://plane.so/changelog" target="_blank" rel="noopener noreferrer">
<span className="text-sm font-medium underline hover:cursor-pointer">Learn more</span>
</Link>
</p>
</div>
<div
className={`mx-auto mt-8 overflow-hidden rounded-md border border-onboarding-border-200 object-cover sm:h-52 sm:w-96 ${
resolvedTheme === "dark" ? "bg-onboarding-background-100" : "bg-custom-primary-70"
}`}
>
<div className="h-[90%]">
<Image
src={latestFeatures}
alt="Plane Issues"
className={`-mt-2 ml-10 h-full rounded-md ${
resolvedTheme === "dark" ? "bg-onboarding-background-100" : "bg-custom-primary-70"
} `}
/>
</div>
</div>
</>
);
};

View File

@ -1,2 +1 @@
export * from "./not-ready-view";
export * from "./instance-failure-view"; export * from "./instance-failure-view";

View File

@ -1,62 +0,0 @@
"use client";
import { FC } from "react";
import Image from "next/image";
import Link from "next/link";
import { useTheme } from "next-themes";
// ui
import { Button } from "@plane/ui";
// helper
import { GOD_MODE_URL, SPACE_BASE_PATH } from "@/helpers/common.helper";
// images
import PlaneTakeOffImage from "@/public/instance/plane-takeoff.png";
import PlaneBackgroundPatternDark from "public/auth/background-pattern-dark.svg";
import PlaneBackgroundPattern from "public/auth/background-pattern.svg";
import BlackHorizontalLogo from "public/plane-logos/black-horizontal-with-blue-logo.png";
import WhiteHorizontalLogo from "public/plane-logos/white-horizontal-with-blue-logo.png";
export const InstanceNotReady: FC = () => {
const { resolvedTheme } = useTheme();
const patternBackground = resolvedTheme === "dark" ? PlaneBackgroundPatternDark : PlaneBackgroundPattern;
const logo = resolvedTheme === "light" ? BlackHorizontalLogo : WhiteHorizontalLogo;
return (
<div className="relative">
<div className="h-screen w-full overflow-hidden overflow-y-auto flex flex-col">
<div className="container h-[110px] flex-shrink-0 mx-auto px-5 lg:px-0 flex items-center justify-between gap-5 z-50">
<div className="flex items-center gap-x-2 py-10">
<Link href={`${SPACE_BASE_PATH}/`} className="h-[30px] w-[133px]">
<Image src={logo} alt="Plane logo" />
</Link>
</div>
</div>
<div className="absolute inset-0 z-0">
<Image src={patternBackground} className="w-screen h-full object-cover" alt="Plane background pattern" />
</div>
<div className="relative z-10 mb-[110px] flex-grow">
<div className="h-full w-full relative container px-5 mx-auto flex justify-center items-center">
<div className="w-auto max-w-2xl relative space-y-8 py-10">
<div className="relative flex flex-col justify-center items-center space-y-4">
<h1 className="text-3xl font-bold pb-3">Welcome aboard Plane!</h1>
<Image src={PlaneTakeOffImage} alt="Plane Logo" />
<p className="font-medium text-base text-onboarding-text-400">
Get started by setting up your instance and workspace
</p>
</div>
<div>
<a href={GOD_MODE_URL}>
<Button size="lg" className="w-full">
Get started
</Button>
</a>
</div>
</div>
</div>
</div>
</div>
</div>
);
};

View File

@ -1,10 +0,0 @@
"use client";
export const IssueBlockDownVotes = ({ number }: { number: number }) => (
<div className="flex h-6 items-center rounded border-[0.5px] border-custom-border-300 px-1.5 py-1 pl-1 text-xs text-custom-text-300">
<span className="material-symbols-rounded !m-0 rotate-180 !p-0 text-base text-custom-text-300">
arrow_upward_alt
</span>
{number}
</div>
);

View File

@ -1,59 +0,0 @@
"use client";
// helpers
import { renderFullDate } from "@/helpers/date-time.helper";
export const dueDateIconDetails = (
date: string,
stateGroup: string
): {
iconName: string;
className: string;
} => {
let iconName = "calendar_today";
let className = "";
if (!date || ["completed", "cancelled"].includes(stateGroup)) {
iconName = "calendar_today";
className = "";
} else {
const today = new Date();
today.setHours(0, 0, 0, 0);
const targetDate = new Date(date);
targetDate.setHours(0, 0, 0, 0);
const timeDifference = targetDate.getTime() - today.getTime();
if (timeDifference < 0) {
iconName = "event_busy";
className = "text-red-500";
} else if (timeDifference === 0) {
iconName = "today";
className = "text-red-500";
} else if (timeDifference === 24 * 60 * 60 * 1000) {
iconName = "event";
className = "text-yellow-500";
} else {
iconName = "calendar_today";
className = "";
}
}
return {
iconName,
className,
};
};
export const IssueBlockDueDate = ({ due_date, group }: { due_date: string; group: string }) => {
const iconDetails = dueDateIconDetails(due_date, group);
return (
<div className="flex items-center gap-1 rounded border-[0.5px] border-custom-border-300 px-2.5 py-1 text-xs text-custom-text-100">
<span className={`material-symbols-rounded -my-0.5 text-sm ${iconDetails.className}`}>
{iconDetails.iconName}
</span>
{renderFullDate(due_date)}
</div>
);
};

View File

@ -1,19 +0,0 @@
"use client";
export const IssueBlockLabels = ({ labels }: any) => (
<div className="relative flex flex-wrap items-center gap-1">
{labels &&
labels.length > 0 &&
labels.map((_label: any) => (
<div
key={_label?.id}
className="flex flex-shrink-0 cursor-default items-center rounded-md border border-custom-border-300 px-2.5 py-1 text-xs shadow-sm"
>
<div className="flex items-center gap-1.5 text-custom-text-200">
<div className="h-2 w-2 rounded-full" style={{ backgroundColor: `${_label?.color}` }} />
<div className="text-xs">{_label?.name}</div>
</div>
</div>
))}
</div>
);

View File

@ -1,18 +0,0 @@
// ui
import { StateGroupIcon } from "@plane/ui";
// constants
import { issueGroupFilter } from "@/constants/issue";
export const IssueBlockState = ({ state }: any) => {
const stateGroup = issueGroupFilter(state.group);
if (stateGroup === null) return <></>;
return (
<div className="flex w-full items-center justify-between gap-1 rounded border-[0.5px] border-custom-border-300 px-2.5 py-1 text-xs shadow-sm duration-300 focus:outline-none">
<div className="flex w-full items-center gap-1.5 text-custom-text-200">
<StateGroupIcon stateGroup={state.group} color={state.color} />
<div className="text-xs">{state?.name}</div>
</div>
</div>
);
};

View File

@ -1,8 +0,0 @@
"use client";
export const IssueBlockUpVotes = ({ number }: { number: number }) => (
<div className="flex h-6 items-center rounded border-[0.5px] border-custom-border-300 px-1.5 py-1 pl-1 text-xs text-custom-text-300">
<span className="material-symbols-rounded !m-0 !p-0 text-base text-custom-text-300">arrow_upward_alt</span>
{number}
</div>
);

View File

@ -1 +0,0 @@
export const IssueCalendarView = () => <div> </div>;

View File

@ -1 +0,0 @@
export const IssueGanttView = () => <div> </div>;

View File

@ -1,82 +0,0 @@
"use client";
import { FC } from "react";
import { observer } from "mobx-react-lite";
import { useRouter, useSearchParams } from "next/navigation";
// components
import { IssueBlockDueDate } from "@/components/issues/board-views/block-due-date";
import { IssueBlockPriority } from "@/components/issues/board-views/block-priority";
import { IssueBlockState } from "@/components/issues/board-views/block-state";
// helpers
import { queryParamGenerator } from "@/helpers/query-param-generator";
// hooks
import { useIssueDetails, useProject } from "@/hooks/store";
// interfaces
import { IIssue } from "@/types/issue";
type IssueKanBanBlockProps = {
issue: IIssue;
workspaceSlug: string;
projectId: string;
params: any;
};
export const IssueKanBanBlock: FC<IssueKanBanBlockProps> = observer((props) => {
const router = useRouter();
const searchParams = useSearchParams();
// query params
const board = searchParams.get("board") || undefined;
const state = searchParams.get("state") || undefined;
const priority = searchParams.get("priority") || undefined;
const labels = searchParams.get("labels") || undefined;
// props
const { workspaceSlug, projectId, issue } = props;
// hooks
const { project } = useProject();
const { setPeekId } = useIssueDetails();
const handleBlockClick = () => {
setPeekId(issue.id);
const { queryParam } = queryParamGenerator({ board, peekId: issue.id, priority, state, labels });
router.push(`/${workspaceSlug}/${projectId}?${queryParam}`);
};
return (
<div className="flex flex-col gap-1.5 space-y-2 rounded border-[0.5px] border-custom-border-200 bg-custom-background-100 px-3 py-2 text-sm shadow-custom-shadow-2xs">
{/* id */}
<div className="break-words text-xs text-custom-text-300">
{project?.identifier}-{issue?.sequence_id}
</div>
{/* name */}
<h6
onClick={handleBlockClick}
role="button"
className="line-clamp-2 cursor-pointer break-words text-sm font-medium"
>
{issue.name}
</h6>
<div className="hide-horizontal-scrollbar relative flex w-full flex-grow items-end gap-2 overflow-x-scroll">
{/* priority */}
{issue?.priority && (
<div className="flex-shrink-0">
<IssueBlockPriority priority={issue?.priority} />
</div>
)}
{/* state */}
{issue?.state_detail && (
<div className="flex-shrink-0">
<IssueBlockState state={issue?.state_detail} />
</div>
)}
{/* due date */}
{issue?.target_date && (
<div className="flex-shrink-0">
<IssueBlockDueDate due_date={issue?.target_date} group={issue?.state_detail?.group} />
</div>
)}
</div>
</div>
);
});

View File

@ -1,28 +0,0 @@
"use client";
// mobx react lite
import { observer } from "mobx-react-lite";
// ui
import { StateGroupIcon } from "@plane/ui";
// constants
import { issueGroupFilter } from "@/constants/issue";
// mobx hook
// import { useIssue } from "@/hooks/store";
// interfaces
import { IIssueState } from "@/types/issue";
export const IssueKanBanHeader = observer(({ state }: { state: IIssueState }) => {
// const { getCountOfIssuesByState } = useIssue();
const stateGroup = issueGroupFilter(state.group);
if (stateGroup === null) return <></>;
return (
<div className="flex items-center gap-2 px-2 pb-2">
<div className="flex h-3.5 w-3.5 flex-shrink-0 items-center justify-center">
<StateGroupIcon stateGroup={state.group} color={state.color} height="14" width="14" />
</div>
<div className="mr-1 truncate font-semibold capitalize text-custom-text-200">{state?.name}</div>
{/* <span className="flex-shrink-0 rounded-full text-custom-text-300">{getCountOfIssuesByState(state.id)}</span> */}
</div>
);
});

View File

@ -1,58 +0,0 @@
"use client";
import { FC } from "react";
import { observer } from "mobx-react-lite";
// components
import { IssueKanBanBlock } from "@/components/issues/board-views/kanban/block";
import { IssueKanBanHeader } from "@/components/issues/board-views/kanban/header";
// ui
import { Icon } from "@/components/ui";
// mobx hook
import { useIssue } from "@/hooks/store";
// interfaces
import { IIssueState, IIssue } from "@/types/issue";
type IssueKanbanViewProps = {
workspaceSlug: string;
projectId: string;
};
export const IssueKanbanView: FC<IssueKanbanViewProps> = observer((props) => {
const { workspaceSlug, projectId } = props;
// store hooks
const { states, getFilteredIssuesByState } = useIssue();
return (
<div className="relative flex h-full w-full gap-3 overflow-hidden overflow-x-auto">
{states &&
states.length > 0 &&
states.map((_state: IIssueState) => (
<div key={_state.id} className="relative flex h-full w-[340px] flex-shrink-0 flex-col">
<div className="flex-shrink-0">
<IssueKanBanHeader state={_state} />
</div>
<div className="hide-vertical-scrollbar h-full w-full overflow-hidden overflow-y-auto">
{getFilteredIssuesByState(_state.id) && getFilteredIssuesByState(_state.id).length > 0 ? (
<div className="space-y-3 px-2 pb-2">
{getFilteredIssuesByState(_state.id).map((_issue: IIssue) => (
<IssueKanBanBlock
key={_issue.id}
issue={_issue}
workspaceSlug={workspaceSlug}
projectId={projectId}
params={{}}
/>
))}
</div>
) : (
<div className="flex items-center justify-center gap-2 pt-10 text-center text-sm font-medium text-custom-text-200">
<Icon iconName="stack" />
No issues in this state
</div>
)}
</div>
</div>
))}
</div>
);
});

View File

@ -1,42 +0,0 @@
"use client";
import { FC } from "react";
import { observer } from "mobx-react-lite";
// components
import { IssueListBlock } from "@/components/issues/board-views/list/block";
import { IssueListHeader } from "@/components/issues/board-views/list/header";
// mobx hook
import { useIssue } from "@/hooks/store";
// types
import { IIssueState, IIssue } from "@/types/issue";
type IssueListViewProps = {
workspaceSlug: string;
projectId: string;
};
export const IssueListView: FC<IssueListViewProps> = observer((props) => {
const { workspaceSlug, projectId } = props;
// store hooks
const { states, getFilteredIssuesByState } = useIssue();
return (
<>
{states &&
states.length > 0 &&
states.map((_state: IIssueState) => (
<div key={_state.id} className="relative w-full">
<IssueListHeader state={_state} />
{getFilteredIssuesByState(_state.id) && getFilteredIssuesByState(_state.id).length > 0 ? (
<div className="divide-y divide-custom-border-200">
{getFilteredIssuesByState(_state.id).map((_issue: IIssue) => (
<IssueListBlock key={_issue.id} issue={_issue} workspaceSlug={workspaceSlug} projectId={projectId} />
))}
</div>
) : (
<div className="bg-custom-background-100 p-3 text-sm text-custom-text-400">No issues.</div>
)}
</div>
))}
</>
);
});

View File

@ -1 +0,0 @@
export const IssueSpreadsheetView = () => <div> </div>;

View File

@ -1,10 +1,10 @@
"use client"; "use client";
// icons
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { X } from "lucide-react"; import { X } from "lucide-react";
// types // types
import { IIssueLabel, IIssueState, TFilters } from "@/types/issue"; import { IStateLite } from "@plane/types";
import { IIssueLabel, TFilters } from "@/types/issue";
// components // components
import { AppliedPriorityFilters } from "./priority"; import { AppliedPriorityFilters } from "./priority";
import { AppliedStateFilters } from "./state"; import { AppliedStateFilters } from "./state";
@ -14,7 +14,7 @@ type Props = {
handleRemoveAllFilters: () => void; handleRemoveAllFilters: () => void;
handleRemoveFilter: (key: keyof TFilters, value: string | null) => void; handleRemoveFilter: (key: keyof TFilters, value: string | null) => void;
labels?: IIssueLabel[] | undefined; labels?: IIssueLabel[] | undefined;
states?: IIssueState[] | undefined; states?: IStateLite[] | undefined;
}; };
export const replaceUnderscoreIfSnakeCase = (str: string) => str.replace(/_/g, " "); export const replaceUnderscoreIfSnakeCase = (str: string) => str.replace(/_/g, " ");

View File

@ -12,18 +12,18 @@ import { TIssueQueryFilters } from "@/types/issue";
import { AppliedFiltersList } from "./filters-list"; import { AppliedFiltersList } from "./filters-list";
type TIssueAppliedFilters = { type TIssueAppliedFilters = {
workspaceSlug: string; anchor: string;
projectId: string;
}; };
export const IssueAppliedFilters: FC<TIssueAppliedFilters> = observer((props) => { export const IssueAppliedFilters: FC<TIssueAppliedFilters> = observer((props) => {
const { anchor } = props;
// router
const router = useRouter(); const router = useRouter();
// props // store hooks
const { workspaceSlug, projectId } = props; const { getIssueFilters, initIssueFilters, updateIssueFilters } = useIssueFilter();
// hooks
const { issueFilters, initIssueFilters, updateIssueFilters } = useIssueFilter();
const { states, labels } = useIssue(); const { states, labels } = useIssue();
// derived values
const issueFilters = getIssueFilters(anchor);
const activeLayout = issueFilters?.display_filters?.layout || undefined; const activeLayout = issueFilters?.display_filters?.layout || undefined;
const userFilters = issueFilters?.filters || {}; const userFilters = issueFilters?.filters || {};
@ -46,30 +46,26 @@ export const IssueAppliedFilters: FC<TIssueAppliedFilters> = observer((props) =>
if (labels.length > 0) params = { ...params, labels: labels.join(",") }; if (labels.length > 0) params = { ...params, labels: labels.join(",") };
params = new URLSearchParams(params).toString(); params = new URLSearchParams(params).toString();
router.push(`/${workspaceSlug}/${projectId}?${params}`); router.push(`/issues/${anchor}?${params}`);
}, },
[workspaceSlug, projectId, activeLayout, issueFilters, router] [activeLayout, anchor, issueFilters, router]
); );
const handleFilters = useCallback( const handleFilters = useCallback(
(key: keyof TIssueQueryFilters, value: string | null) => { (key: keyof TIssueQueryFilters, value: string | null) => {
if (!projectId) return;
let newValues = cloneDeep(issueFilters?.filters?.[key]) ?? []; let newValues = cloneDeep(issueFilters?.filters?.[key]) ?? [];
if (value === null) newValues = []; if (value === null) newValues = [];
else if (newValues.includes(value)) newValues.splice(newValues.indexOf(value), 1); else if (newValues.includes(value)) newValues.splice(newValues.indexOf(value), 1);
updateIssueFilters(projectId, "filters", key, newValues); updateIssueFilters(anchor, "filters", key, newValues);
updateRouteParams(key, newValues); updateRouteParams(key, newValues);
}, },
[projectId, issueFilters, updateIssueFilters, updateRouteParams] [anchor, issueFilters, updateIssueFilters, updateRouteParams]
); );
const handleRemoveAllFilters = () => { const handleRemoveAllFilters = () => {
if (!projectId) return; initIssueFilters(anchor, {
initIssueFilters(projectId, {
display_filters: { layout: activeLayout || "list" }, display_filters: { layout: activeLayout || "list" },
filters: { filters: {
state: [], state: [],
@ -78,13 +74,13 @@ export const IssueAppliedFilters: FC<TIssueAppliedFilters> = observer((props) =>
}, },
}); });
router.push(`/${workspaceSlug}/${projectId}?${`board=${activeLayout || "list"}`}`); router.push(`/issues/${anchor}?${`board=${activeLayout || "list"}`}`);
}; };
if (Object.keys(appliedFilters).length === 0) return null; if (Object.keys(appliedFilters).length === 0) return null;
return ( return (
<div className="border-b border-custom-border-200 p-5 py-3"> <div className="border-b border-custom-border-200 bg-custom-background-100 p-4">
<AppliedFiltersList <AppliedFiltersList
appliedFilters={appliedFilters || {}} appliedFilters={appliedFilters || {}}
handleRemoveFilter={handleFilters as any} handleRemoveFilter={handleFilters as any}

View File

@ -2,13 +2,14 @@
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { X } from "lucide-react"; import { X } from "lucide-react";
import { StateGroupIcon } from "@plane/ui";
// types // types
import { IIssueState } from "@/types/issue"; import { IStateLite } from "@plane/types";
// ui
import { StateGroupIcon } from "@plane/ui";
type Props = { type Props = {
handleRemove: (val: string) => void; handleRemove: (val: string) => void;
states: IIssueState[]; states: IStateLite[];
values: string[]; values: string[];
}; };

View File

@ -17,17 +17,18 @@ import { useIssue, useIssueFilter } from "@/hooks/store";
import { TIssueQueryFilters } from "@/types/issue"; import { TIssueQueryFilters } from "@/types/issue";
type IssueFiltersDropdownProps = { type IssueFiltersDropdownProps = {
workspaceSlug: string; anchor: string;
projectId: string;
}; };
export const IssueFiltersDropdown: FC<IssueFiltersDropdownProps> = observer((props) => { export const IssueFiltersDropdown: FC<IssueFiltersDropdownProps> = observer((props) => {
const { anchor } = props;
// router
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId } = props;
// hooks // hooks
const { issueFilters, updateIssueFilters } = useIssueFilter(); const { getIssueFilters, updateIssueFilters } = useIssueFilter();
const { states, labels } = useIssue(); const { states, labels } = useIssue();
// derived values
const issueFilters = getIssueFilters(anchor);
const activeLayout = issueFilters?.display_filters?.layout || undefined; const activeLayout = issueFilters?.display_filters?.layout || undefined;
const updateRouteParams = useCallback( const updateRouteParams = useCallback(
@ -37,24 +38,24 @@ export const IssueFiltersDropdown: FC<IssueFiltersDropdownProps> = observer((pro
const labels = key === "labels" ? value : issueFilters?.filters?.labels ?? []; const labels = key === "labels" ? value : issueFilters?.filters?.labels ?? [];
const { queryParam } = queryParamGenerator({ board: activeLayout, priority, state, labels }); const { queryParam } = queryParamGenerator({ board: activeLayout, priority, state, labels });
router.push(`/${workspaceSlug}/${projectId}?${queryParam}`); router.push(`/issues/${anchor}?${queryParam}`);
}, },
[workspaceSlug, projectId, activeLayout, issueFilters, router] [anchor, activeLayout, issueFilters, router]
); );
const handleFilters = useCallback( const handleFilters = useCallback(
(key: keyof TIssueQueryFilters, value: string) => { (key: keyof TIssueQueryFilters, value: string) => {
if (!projectId || !value) return; if (!value) return;
const newValues = cloneDeep(issueFilters?.filters?.[key]) ?? []; const newValues = cloneDeep(issueFilters?.filters?.[key]) ?? [];
if (newValues.includes(value)) newValues.splice(newValues.indexOf(value), 1); if (newValues.includes(value)) newValues.splice(newValues.indexOf(value), 1);
else newValues.push(value); else newValues.push(value);
updateIssueFilters(projectId, "filters", key, newValues); updateIssueFilters(anchor, "filters", key, newValues);
updateRouteParams(key, newValues); updateRouteParams(key, newValues);
}, },
[projectId, issueFilters, updateIssueFilters, updateRouteParams] [anchor, issueFilters, updateIssueFilters, updateRouteParams]
); );
return ( return (

View File

@ -4,7 +4,8 @@ import React, { useState } from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { Search, X } from "lucide-react"; import { Search, X } from "lucide-react";
// types // types
import { IIssueState, IIssueLabel, IIssueFilterOptions, TIssueFilterKeys } from "@/types/issue"; import { IStateLite } from "@plane/types";
import { IIssueLabel, IIssueFilterOptions, TIssueFilterKeys } from "@/types/issue";
// components // components
import { FilterPriority, FilterState } from "./"; import { FilterPriority, FilterState } from "./";
@ -13,7 +14,7 @@ type Props = {
handleFilters: (key: keyof IIssueFilterOptions, value: string | string[]) => void; handleFilters: (key: keyof IIssueFilterOptions, value: string | string[]) => void;
layoutDisplayFiltersOptions: TIssueFilterKeys[]; layoutDisplayFiltersOptions: TIssueFilterKeys[];
labels?: IIssueLabel[] | undefined; labels?: IIssueLabel[] | undefined;
states?: IIssueState[] | undefined; states?: IStateLite[] | undefined;
}; };
export const FilterSelection: React.FC<Props> = observer((props) => { export const FilterSelection: React.FC<Props> = observer((props) => {

View File

@ -1,17 +1,18 @@
"use client"; "use client";
import React, { useState } from "react"; import React, { useState } from "react";
// types
import { IStateLite } from "@plane/types";
// ui
import { Loader, StateGroupIcon } from "@plane/ui"; import { Loader, StateGroupIcon } from "@plane/ui";
// components // components
import { FilterHeader, FilterOption } from "@/components/issues/filters/helpers"; import { FilterHeader, FilterOption } from "@/components/issues/filters/helpers";
// types
import { IIssueState } from "@/types/issue";
type Props = { type Props = {
appliedFilters: string[] | null; appliedFilters: string[] | null;
handleUpdate: (val: string) => void; handleUpdate: (val: string) => void;
searchQuery: string; searchQuery: string;
states: IIssueState[] | undefined; states: IStateLite[] | undefined;
}; };
export const FilterState: React.FC<Props> = (props) => { export const FilterState: React.FC<Props> = (props) => {

View File

@ -0,0 +1,2 @@
export * from "./issue-layouts";
export * from "./navbar";

View File

@ -0,0 +1,4 @@
export * from "./kanban";
export * from "./list";
export * from "./properties";
export * from "./root";

View File

@ -0,0 +1,78 @@
"use client";
import { FC } from "react";
import { observer } from "mobx-react-lite";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
// components
import { IssueBlockDueDate, IssueBlockPriority, IssueBlockState } from "@/components/issues";
// helpers
import { queryParamGenerator } from "@/helpers/query-param-generator";
// hooks
import { useIssueDetails, usePublish } from "@/hooks/store";
// interfaces
import { IIssue } from "@/types/issue";
type Props = {
anchor: string;
issue: IIssue;
params: any;
};
export const IssueKanBanBlock: FC<Props> = observer((props) => {
const { anchor, issue } = props;
const searchParams = useSearchParams();
// query params
const board = searchParams.get("board");
const state = searchParams.get("state");
const priority = searchParams.get("priority");
const labels = searchParams.get("labels");
// store hooks
const { project_details } = usePublish(anchor);
const { setPeekId } = useIssueDetails();
const { queryParam } = queryParamGenerator({ board, peekId: issue.id, priority, state, labels });
const handleBlockClick = () => {
setPeekId(issue.id);
};
return (
<Link
href={`/issues/${anchor}?${queryParam}`}
onClick={handleBlockClick}
className="flex flex-col gap-1.5 space-y-2 rounded border-[0.5px] border-custom-border-200 bg-custom-background-100 px-3 py-2 text-sm shadow-custom-shadow-2xs select-none"
>
{/* id */}
<div className="break-words text-xs text-custom-text-300">
{project_details?.identifier}-{issue?.sequence_id}
</div>
{/* name */}
<h6 role="button" className="line-clamp-2 cursor-pointer break-words text-sm">
{issue.name}
</h6>
<div className="hide-horizontal-scrollbar relative flex w-full flex-grow items-end gap-2 overflow-x-scroll">
{/* priority */}
{issue?.priority && (
<div className="flex-shrink-0">
<IssueBlockPriority priority={issue?.priority} />
</div>
)}
{/* state */}
{issue?.state_detail && (
<div className="flex-shrink-0">
<IssueBlockState state={issue?.state_detail} />
</div>
)}
{/* due date */}
{issue?.target_date && (
<div className="flex-shrink-0">
<IssueBlockDueDate due_date={issue?.target_date} group={issue?.state_detail?.group} />
</div>
)}
</div>
</Link>
);
});

View File

@ -0,0 +1,25 @@
"use client";
import { observer } from "mobx-react-lite";
// types
import { IStateLite } from "@plane/types";
// ui
import { StateGroupIcon } from "@plane/ui";
type Props = {
state: IStateLite;
};
export const IssueKanBanHeader: React.FC<Props> = observer((props) => {
const { state } = props;
return (
<div className="flex items-center gap-2 px-2 pb-2">
<div className="flex h-3.5 w-3.5 flex-shrink-0 items-center justify-center">
<StateGroupIcon stateGroup={state.group} color={state.color} height="14" width="14" />
</div>
<div className="mr-1 truncate font-medium capitalize text-custom-text-200">{state?.name}</div>
{/* <span className="flex-shrink-0 rounded-full text-custom-text-300">{getCountOfIssuesByState(state.id)}</span> */}
</div>
);
});

View File

@ -0,0 +1,3 @@
export * from "./block";
export * from "./header";
export * from "./root";

View File

@ -0,0 +1,50 @@
"use client";
import { FC } from "react";
import { observer } from "mobx-react-lite";
// components
import { IssueKanBanBlock, IssueKanBanHeader } from "@/components/issues";
// ui
import { Icon } from "@/components/ui";
// mobx hook
import { useIssue } from "@/hooks/store";
type Props = {
anchor: string;
};
export const IssueKanbanLayoutRoot: FC<Props> = observer((props) => {
const { anchor } = props;
// store hooks
const { states, getFilteredIssuesByState } = useIssue();
return (
<div className="relative flex h-full w-full gap-3 overflow-hidden overflow-x-auto">
{states?.map((state) => {
const issues = getFilteredIssuesByState(state.id);
return (
<div key={state.id} className="relative flex h-full w-[340px] flex-shrink-0 flex-col">
<div className="flex-shrink-0">
<IssueKanBanHeader state={state} />
</div>
<div className="hide-vertical-scrollbar h-full w-full overflow-hidden overflow-y-auto">
{issues && issues.length > 0 ? (
<div className="space-y-3 px-2 pb-2">
{issues.map((issue) => (
<IssueKanBanBlock key={issue.id} anchor={anchor} issue={issue} params={{}} />
))}
</div>
) : (
<div className="flex items-center justify-center gap-2 pt-10 text-center text-sm font-medium text-custom-text-200">
<Icon iconName="stack" />
No issues in this state
</div>
)}
</div>
</div>
);
})}
</div>
);
});

View File

@ -1,56 +1,52 @@
"use client"; "use client";
import { FC } from "react"; import { FC } from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { useRouter, useSearchParams } from "next/navigation"; import Link from "next/link";
import { useSearchParams } from "next/navigation";
// components // components
import { IssueBlockDueDate } from "@/components/issues/board-views/block-due-date"; import { IssueBlockDueDate, IssueBlockLabels, IssueBlockPriority, IssueBlockState } from "@/components/issues";
import { IssueBlockLabels } from "@/components/issues/board-views/block-labels";
import { IssueBlockPriority } from "@/components/issues/board-views/block-priority";
import { IssueBlockState } from "@/components/issues/board-views/block-state";
// helpers // helpers
import { queryParamGenerator } from "@/helpers/query-param-generator"; import { queryParamGenerator } from "@/helpers/query-param-generator";
// hook // hook
import { useIssueDetails, useProject } from "@/hooks/store"; import { useIssueDetails, usePublish } from "@/hooks/store";
// interfaces // types
import { IIssue } from "@/types/issue"; import { IIssue } from "@/types/issue";
// store
type IssueListBlockProps = { type IssueListBlockProps = {
anchor: string;
issue: IIssue; issue: IIssue;
workspaceSlug: string;
projectId: string;
}; };
export const IssueListBlock: FC<IssueListBlockProps> = observer((props) => { export const IssueListLayoutBlock: FC<IssueListBlockProps> = observer((props) => {
const { workspaceSlug, projectId, issue } = props; const { anchor, issue } = props;
const searchParams = useSearchParams();
// query params // query params
const searchParams = useSearchParams();
const board = searchParams.get("board") || undefined; const board = searchParams.get("board") || undefined;
const state = searchParams.get("state") || undefined; const state = searchParams.get("state") || undefined;
const priority = searchParams.get("priority") || undefined; const priority = searchParams.get("priority") || undefined;
const labels = searchParams.get("labels") || undefined; const labels = searchParams.get("labels") || undefined;
// store // store hooks
const { project } = useProject();
const { setPeekId } = useIssueDetails(); const { setPeekId } = useIssueDetails();
// router const { project_details } = usePublish(anchor);
const router = useRouter();
const { queryParam } = queryParamGenerator({ board, peekId: issue.id, priority, state, labels });
const handleBlockClick = () => { const handleBlockClick = () => {
setPeekId(issue.id); setPeekId(issue.id);
const { queryParam } = queryParamGenerator({ board, peekId: issue.id, priority, state, labels });
router.push(`/${workspaceSlug}/${projectId}?${queryParam}`);
}; };
return ( return (
<div className="relative flex items-center gap-10 bg-custom-background-100 p-3"> <Link
href={`/issues/${anchor}?${queryParam}`}
onClick={handleBlockClick}
className="relative flex items-center gap-10 bg-custom-background-100 p-3"
>
<div className="relative flex w-full flex-grow items-center gap-3 overflow-hidden"> <div className="relative flex w-full flex-grow items-center gap-3 overflow-hidden">
{/* id */} {/* id */}
<div className="flex-shrink-0 text-xs font-medium text-custom-text-300"> <div className="flex-shrink-0 text-xs font-medium text-custom-text-300">
{project?.identifier}-{issue?.sequence_id} {project_details?.identifier}-{issue?.sequence_id}
</div> </div>
{/* name */} {/* name */}
<div onClick={handleBlockClick} className="flex-grow cursor-pointer truncate text-sm font-medium"> <div onClick={handleBlockClick} className="flex-grow cursor-pointer truncate text-sm">
{issue.name} {issue.name}
</div> </div>
</div> </div>
@ -84,6 +80,6 @@ export const IssueListBlock: FC<IssueListBlockProps> = observer((props) => {
</div> </div>
)} )}
</div> </div>
</div> </Link>
); );
}); });

View File

@ -1,20 +1,18 @@
"use client"; "use client";
import React from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
// types
import { IStateLite } from "@plane/types";
// ui // ui
import { StateGroupIcon } from "@plane/ui"; import { StateGroupIcon } from "@plane/ui";
// constants
import { issueGroupFilter } from "@/constants/issue";
// mobx hook
// import { useIssue } from "@/hooks/store";
// types
import { IIssueState } from "@/types/issue";
export const IssueListHeader = observer(({ state }: { state: IIssueState }) => { type Props = {
// const { getCountOfIssuesByState } = useIssue(); state: IStateLite;
const stateGroup = issueGroupFilter(state.group); };
// const count = getCountOfIssuesByState(state.id);
if (stateGroup === null) return <></>; export const IssueListLayoutHeader: React.FC<Props> = observer((props) => {
const { state } = props;
return ( return (
<div className="flex items-center gap-2 p-3"> <div className="flex items-center gap-2 p-3">

View File

@ -0,0 +1,3 @@
export * from "./block";
export * from "./header";
export * from "./root";

View File

@ -0,0 +1,40 @@
"use client";
import { FC } from "react";
import { observer } from "mobx-react-lite";
// components
import { IssueListLayoutBlock, IssueListLayoutHeader } from "@/components/issues";
// mobx hook
import { useIssue } from "@/hooks/store";
type Props = {
anchor: string;
};
export const IssuesListLayoutRoot: FC<Props> = observer((props) => {
const { anchor } = props;
// store hooks
const { states, getFilteredIssuesByState } = useIssue();
return (
<>
{states?.map((state) => {
const issues = getFilteredIssuesByState(state.id);
return (
<div key={state.id} className="relative w-full">
<IssueListLayoutHeader state={state} />
{issues && issues.length > 0 ? (
<div className="divide-y divide-custom-border-200">
{issues.map((issue) => (
<IssueListLayoutBlock key={issue.id} anchor={anchor} issue={issue} />
))}
</div>
) : (
<div className="bg-custom-background-100 p-3 text-sm text-custom-text-400">No issues.</div>
)}
</div>
);
})}
</>
);
});

View File

@ -0,0 +1,32 @@
"use client";
import { CalendarCheck2 } from "lucide-react";
// types
import { TStateGroups } from "@plane/types";
// helpers
import { cn } from "@/helpers/common.helper";
import { renderFormattedDate } from "@/helpers/date-time.helper";
import { shouldHighlightIssueDueDate } from "@/helpers/issue.helper";
type Props = {
due_date: string;
group: TStateGroups;
};
export const IssueBlockDueDate = (props: Props) => {
const { due_date, group } = props;
return (
<div
className={cn(
"flex items-center gap-1 rounded border-[0.5px] border-custom-border-300 px-2.5 py-1 text-xs text-custom-text-100",
{
"text-red-500": shouldHighlightIssueDueDate(due_date, group),
}
)}
>
<CalendarCheck2 className="size-3 flex-shrink-0" />
{renderFormattedDate(due_date)}
</div>
);
};

View File

@ -0,0 +1,4 @@
export * from "./due-date";
export * from "./labels";
export * from "./priority";
export * from "./state";

View File

@ -0,0 +1,17 @@
"use client";
export const IssueBlockLabels = ({ labels }: any) => (
<div className="relative flex flex-wrap items-center gap-1">
{labels?.map((_label: any) => (
<div
key={_label?.id}
className="flex flex-shrink-0 cursor-default items-center rounded-md border border-custom-border-300 px-2.5 py-1 text-xs shadow-sm"
>
<div className="flex items-center gap-1.5 text-custom-text-200">
<div className="h-2 w-2 rounded-full" style={{ backgroundColor: `${_label?.color}` }} />
<div className="text-xs">{_label?.name}</div>
</div>
</div>
))}
</div>
);

View File

@ -1,11 +1,11 @@
"use client"; "use client";
// types // types
import { issuePriorityFilter } from "@/constants/issue"; import { TIssuePriorities } from "@plane/types";
import { TIssueFilterPriority } from "@/types/issue";
// constants // constants
import { issuePriorityFilter } from "@/constants/issue";
export const IssueBlockPriority = ({ priority }: { priority: TIssueFilterPriority | null }) => { export const IssueBlockPriority = ({ priority }: { priority: TIssuePriorities | null }) => {
const priority_detail = priority != null ? issuePriorityFilter(priority) : null; const priority_detail = priority != null ? issuePriorityFilter(priority) : null;
if (priority_detail === null) return <></>; if (priority_detail === null) return <></>;

View File

@ -0,0 +1,11 @@
// ui
import { StateGroupIcon } from "@plane/ui";
export const IssueBlockState = ({ state }: any) => (
<div className="flex w-full items-center justify-between gap-1 rounded border-[0.5px] border-custom-border-300 px-2.5 py-1 text-xs shadow-sm duration-300 focus:outline-none">
<div className="flex w-full items-center gap-1.5">
<StateGroupIcon stateGroup={state.group} color={state.color} />
<div className="text-xs">{state?.name}</div>
</div>
</div>
);

View File

@ -6,69 +6,55 @@ import Image from "next/image";
import { useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
import useSWR from "swr"; import useSWR from "swr";
// components // components
import { IssueCalendarView } from "@/components/issues/board-views/calendar"; import { IssueKanbanLayoutRoot, IssuesListLayoutRoot } from "@/components/issues";
import { IssueGanttView } from "@/components/issues/board-views/gantt";
import { IssueKanbanView } from "@/components/issues/board-views/kanban";
import { IssueListView } from "@/components/issues/board-views/list";
import { IssueSpreadsheetView } from "@/components/issues/board-views/spreadsheet";
import { IssueAppliedFilters } from "@/components/issues/filters/applied-filters/root"; import { IssueAppliedFilters } from "@/components/issues/filters/applied-filters/root";
import { IssuePeekOverview } from "@/components/issues/peek-overview"; import { IssuePeekOverview } from "@/components/issues/peek-overview";
// mobx store // hooks
import { useIssue, useUser, useIssueDetails, useIssueFilter, useProject } from "@/hooks/store"; import { useIssue, useIssueDetails, useIssueFilter } from "@/hooks/store";
// store
import { PublishStore } from "@/store/publish/publish.store";
// assets // assets
import SomethingWentWrongImage from "public/something-went-wrong.svg"; import SomethingWentWrongImage from "public/something-went-wrong.svg";
type ProjectDetailsViewProps = { type Props = {
workspaceSlug: string;
projectId: string;
peekId: string | undefined; peekId: string | undefined;
publishSettings: PublishStore;
}; };
export const ProjectDetailsView: FC<ProjectDetailsViewProps> = observer((props) => { export const IssuesLayoutsRoot: FC<Props> = observer((props) => {
// router const { peekId, publishSettings } = props;
const searchParams = useSearchParams();
// query params // query params
const searchParams = useSearchParams();
const states = searchParams.get("states") || undefined; const states = searchParams.get("states") || undefined;
const priority = searchParams.get("priority") || undefined; const priority = searchParams.get("priority") || undefined;
const labels = searchParams.get("labels") || undefined; const labels = searchParams.get("labels") || undefined;
// store hooks
const { workspaceSlug, projectId, peekId } = props; const { getIssueFilters } = useIssueFilter();
// hooks
const { fetchProjectSettings } = useProject();
const { issueFilters } = useIssueFilter();
const { loader, issues, error, fetchPublicIssues } = useIssue(); const { loader, issues, error, fetchPublicIssues } = useIssue();
const issueDetailStore = useIssueDetails(); const issueDetailStore = useIssueDetails();
const { data: currentUser, fetchCurrentUser } = useUser(); // derived values
const { anchor } = publishSettings;
const issueFilters = anchor ? getIssueFilters(anchor) : undefined;
useSWR( useSWR(
workspaceSlug && projectId ? "WORKSPACE_PROJECT_SETTINGS" : null, anchor ? `PUBLIC_ISSUES_${anchor}` : null,
workspaceSlug && projectId ? () => fetchProjectSettings(workspaceSlug, projectId) : null anchor ? () => fetchPublicIssues(anchor, { states, priority, labels }) : null
);
useSWR(
(workspaceSlug && projectId) || states || priority || labels ? "WORKSPACE_PROJECT_PUBLIC_ISSUES" : null,
(workspaceSlug && projectId) || states || priority || labels
? () => fetchPublicIssues(workspaceSlug, projectId, { states, priority, labels })
: null
);
useSWR(
workspaceSlug && projectId && !currentUser ? "WORKSPACE_PROJECT_CURRENT_USER" : null,
workspaceSlug && projectId && !currentUser ? () => fetchCurrentUser() : null
); );
useEffect(() => { useEffect(() => {
if (peekId && workspaceSlug && projectId) { if (peekId) {
issueDetailStore.setPeekId(peekId.toString()); issueDetailStore.setPeekId(peekId.toString());
} }
}, [peekId, issueDetailStore, projectId, workspaceSlug]); }, [peekId, issueDetailStore]);
// derived values // derived values
const activeLayout = issueFilters?.display_filters?.layout || undefined; const activeLayout = issueFilters?.display_filters?.layout || undefined;
if (!anchor) return null;
return ( return (
<div className="relative h-full w-full overflow-hidden"> <div className="relative h-full w-full overflow-hidden">
{workspaceSlug && projectId && peekId && ( {peekId && <IssuePeekOverview anchor={anchor} peekId={peekId} />}
<IssuePeekOverview workspaceSlug={workspaceSlug} projectId={projectId} peekId={peekId} />
)}
{loader && !issues ? ( {loader && !issues ? (
<div className="py-10 text-center text-sm text-custom-text-100">Loading...</div> <div className="py-10 text-center text-sm text-custom-text-100">Loading...</div>
@ -90,21 +76,18 @@ export const ProjectDetailsView: FC<ProjectDetailsViewProps> = observer((props)
activeLayout && ( activeLayout && (
<div className="relative flex h-full w-full flex-col overflow-hidden"> <div className="relative flex h-full w-full flex-col overflow-hidden">
{/* applied filters */} {/* applied filters */}
<IssueAppliedFilters workspaceSlug={workspaceSlug} projectId={projectId} /> <IssueAppliedFilters anchor={anchor} />
{activeLayout === "list" && ( {activeLayout === "list" && (
<div className="relative h-full w-full overflow-y-auto"> <div className="relative h-full w-full overflow-y-auto">
<IssueListView workspaceSlug={workspaceSlug} projectId={projectId} /> <IssuesListLayoutRoot anchor={anchor} />
</div> </div>
)} )}
{activeLayout === "kanban" && ( {activeLayout === "kanban" && (
<div className="relative mx-auto h-full w-full p-5"> <div className="relative mx-auto h-full w-full p-5">
<IssueKanbanView workspaceSlug={workspaceSlug} projectId={projectId} /> <IssueKanbanLayoutRoot anchor={anchor} />
</div> </div>
)} )}
{activeLayout === "calendar" && <IssueCalendarView />}
{activeLayout === "spreadsheet" && <IssueSpreadsheetView />}
{activeLayout === "gantt" && <IssueGanttView />}
</div> </div>
) )
)} )}

View File

@ -4,26 +4,25 @@ import { useEffect, FC } from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
// components // components
import { IssuesLayoutSelection, NavbarTheme, UserAvatar } from "@/components/issues";
import { IssueFiltersDropdown } from "@/components/issues/filters"; import { IssueFiltersDropdown } from "@/components/issues/filters";
import { NavbarIssueBoardView } from "@/components/issues/navbar/issue-board-view";
import { NavbarTheme } from "@/components/issues/navbar/theme";
import { UserAvatar } from "@/components/issues/navbar/user-avatar";
// helpers // helpers
import { queryParamGenerator } from "@/helpers/query-param-generator"; import { queryParamGenerator } from "@/helpers/query-param-generator";
// hooks // hooks
import { useProject, useIssueFilter, useIssueDetails } from "@/hooks/store"; import { useIssueFilter, useIssueDetails } from "@/hooks/store";
import useIsInIframe from "@/hooks/use-is-in-iframe"; import useIsInIframe from "@/hooks/use-is-in-iframe";
// store
import { PublishStore } from "@/store/publish/publish.store";
// types // types
import { TIssueLayout } from "@/types/issue"; import { TIssueLayout } from "@/types/issue";
export type NavbarControlsProps = { export type NavbarControlsProps = {
workspaceSlug: string; publishSettings: PublishStore;
projectId: string;
}; };
export const NavbarControls: FC<NavbarControlsProps> = observer((props) => { export const NavbarControls: FC<NavbarControlsProps> = observer((props) => {
// props // props
const { workspaceSlug, projectId } = props; const { publishSettings } = props;
// router // router
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
@ -34,24 +33,25 @@ export const NavbarControls: FC<NavbarControlsProps> = observer((props) => {
const priority = searchParams.get("priority") || undefined; const priority = searchParams.get("priority") || undefined;
const peekId = searchParams.get("peekId") || undefined; const peekId = searchParams.get("peekId") || undefined;
// hooks // hooks
const { issueFilters, isIssueFiltersUpdated, initIssueFilters } = useIssueFilter(); const { getIssueFilters, isIssueFiltersUpdated, initIssueFilters } = useIssueFilter();
const { settings } = useProject();
const { setPeekId } = useIssueDetails(); const { setPeekId } = useIssueDetails();
// derived values // derived values
const { anchor, view_props, workspace_detail } = publishSettings;
const issueFilters = anchor ? getIssueFilters(anchor) : undefined;
const activeLayout = issueFilters?.display_filters?.layout || undefined; const activeLayout = issueFilters?.display_filters?.layout || undefined;
const isInIframe = useIsInIframe(); const isInIframe = useIsInIframe();
useEffect(() => { useEffect(() => {
if (workspaceSlug && projectId && settings) { if (anchor && workspace_detail) {
const viewsAcceptable: string[] = []; const viewsAcceptable: string[] = [];
let currentBoard: TIssueLayout | null = null; let currentBoard: TIssueLayout | null = null;
if (settings?.views?.list) viewsAcceptable.push("list"); if (view_props?.list) viewsAcceptable.push("list");
if (settings?.views?.kanban) viewsAcceptable.push("kanban"); if (view_props?.kanban) viewsAcceptable.push("kanban");
if (settings?.views?.calendar) viewsAcceptable.push("calendar"); if (view_props?.calendar) viewsAcceptable.push("calendar");
if (settings?.views?.gantt) viewsAcceptable.push("gantt"); if (view_props?.gantt) viewsAcceptable.push("gantt");
if (settings?.views?.spreadsheet) viewsAcceptable.push("spreadsheet"); if (view_props?.spreadsheet) viewsAcceptable.push("spreadsheet");
if (board) { if (board) {
if (viewsAcceptable.includes(board.toString())) currentBoard = board.toString() as TIssueLayout; if (viewsAcceptable.includes(board.toString())) currentBoard = board.toString() as TIssueLayout;
@ -74,39 +74,41 @@ export const NavbarControls: FC<NavbarControlsProps> = observer((props) => {
}, },
}; };
if (!isIssueFiltersUpdated(params)) { if (!isIssueFiltersUpdated(anchor, params)) {
initIssueFilters(projectId, params); initIssueFilters(anchor, params);
router.push(`/${workspaceSlug}/${projectId}?${queryParam}`); router.push(`/issues/${anchor}?${queryParam}`);
} }
} }
} }
} }
}, [ }, [
workspaceSlug, anchor,
projectId,
board, board,
labels, labels,
state, state,
priority, priority,
peekId, peekId,
settings,
activeLayout, activeLayout,
router, router,
initIssueFilters, initIssueFilters,
setPeekId, setPeekId,
isIssueFiltersUpdated, isIssueFiltersUpdated,
view_props,
workspace_detail,
]); ]);
if (!anchor) return null;
return ( return (
<> <>
{/* issue views */} {/* issue views */}
<div className="relative flex flex-shrink-0 items-center gap-1 transition-all delay-150 ease-in-out"> <div className="relative flex flex-shrink-0 items-center gap-1 transition-all delay-150 ease-in-out">
<NavbarIssueBoardView workspaceSlug={workspaceSlug} projectId={projectId} /> <IssuesLayoutSelection anchor={anchor} />
</div> </div>
{/* issue filters */} {/* issue filters */}
<div className="relative flex flex-shrink-0 items-center gap-1 transition-all delay-150 ease-in-out"> <div className="relative flex flex-shrink-0 items-center gap-1 transition-all delay-150 ease-in-out">
<IssueFiltersDropdown workspaceSlug={workspaceSlug} projectId={projectId} /> <IssueFiltersDropdown anchor={anchor} />
</div> </div>
{/* theming */} {/* theming */}

View File

@ -0,0 +1,5 @@
export * from "./controls";
export * from "./layout-selection";
export * from "./root";
export * from "./theme";
export * from "./user-avatar";

View File

@ -1,72 +0,0 @@
"use client";
import { FC } from "react";
import { observer } from "mobx-react-lite";
import { useRouter, useSearchParams } from "next/navigation";
// constants
import { issueLayoutViews } from "@/constants/issue";
// helpers
import { queryParamGenerator } from "@/helpers/query-param-generator";
// hooks
import { useIssueFilter } from "@/hooks/store";
// mobx
import { TIssueLayout } from "@/types/issue";
type NavbarIssueBoardViewProps = {
workspaceSlug: string;
projectId: string;
};
export const NavbarIssueBoardView: FC<NavbarIssueBoardViewProps> = observer((props) => {
const router = useRouter();
const searchParams = useSearchParams();
// query params
const labels = searchParams.get("labels") || undefined;
const state = searchParams.get("state") || undefined;
const priority = searchParams.get("priority") || undefined;
const peekId = searchParams.get("peekId") || undefined;
// props
const { workspaceSlug, projectId } = props;
// hooks
const { layoutOptions, issueFilters, updateIssueFilters } = useIssueFilter();
// derived values
const activeLayout = issueFilters?.display_filters?.layout || undefined;
const handleCurrentBoardView = (boardView: TIssueLayout) => {
updateIssueFilters(projectId, "display_filters", "layout", boardView);
const { queryParam } = queryParamGenerator({ board: boardView, peekId, priority, state, labels });
router.push(`/${workspaceSlug}/${projectId}?${queryParam}`);
};
return (
<>
{issueLayoutViews &&
Object.keys(issueLayoutViews).map((key: string) => {
const layoutKey = key as TIssueLayout;
if (layoutOptions[layoutKey]) {
return (
<div
key={layoutKey}
className={`flex h-[28px] w-[28px] cursor-pointer items-center justify-center rounded-sm ${
layoutKey === activeLayout
? `bg-custom-background-80 text-custom-text-200`
: `text-custom-text-300 hover:bg-custom-background-80`
}`}
onClick={() => handleCurrentBoardView(layoutKey)}
title={layoutKey}
>
<span
className={`material-symbols-rounded text-[18px] ${
issueLayoutViews[layoutKey]?.className ? issueLayoutViews[layoutKey]?.className : ``
}`}
>
{issueLayoutViews[layoutKey]?.icon}
</span>
</div>
);
}
})}
</>
);
});

View File

@ -0,0 +1,67 @@
"use client";
import { FC } from "react";
import { observer } from "mobx-react-lite";
import { useRouter, useSearchParams } from "next/navigation";
// ui
import { Tooltip } from "@plane/ui";
// constants
import { ISSUE_LAYOUTS } from "@/constants/issue";
// helpers
import { queryParamGenerator } from "@/helpers/query-param-generator";
// hooks
import { useIssueFilter } from "@/hooks/store";
// mobx
import { TIssueLayout } from "@/types/issue";
type Props = {
anchor: string;
};
export const IssuesLayoutSelection: FC<Props> = observer((props) => {
const { anchor } = props;
// router
const router = useRouter();
const searchParams = useSearchParams();
// query params
const labels = searchParams.get("labels");
const state = searchParams.get("state");
const priority = searchParams.get("priority");
const peekId = searchParams.get("peekId");
// hooks
const { layoutOptions, getIssueFilters, updateIssueFilters } = useIssueFilter();
// derived values
const issueFilters = getIssueFilters(anchor);
const activeLayout = issueFilters?.display_filters?.layout || undefined;
const handleCurrentBoardView = (boardView: TIssueLayout) => {
updateIssueFilters(anchor, "display_filters", "layout", boardView);
const { queryParam } = queryParamGenerator({ board: boardView, peekId, priority, state, labels });
router.push(`/issues/${anchor}?${queryParam}`);
};
return (
<div className="flex items-center gap-1 rounded bg-custom-background-80 p-1">
{ISSUE_LAYOUTS.map((layout) => {
if (!layoutOptions[layout.key]) return;
return (
<Tooltip key={layout.key} tooltipContent={layout.title}>
<button
type="button"
className={`group grid h-[22px] w-7 place-items-center overflow-hidden rounded transition-all hover:bg-custom-background-100 ${
activeLayout == layout.key ? "bg-custom-background-100 shadow-custom-shadow-2xs" : ""
}`}
onClick={() => handleCurrentBoardView(layout.key)}
>
<layout.icon
strokeWidth={2}
className={`size-3.5 ${activeLayout == layout.key ? "text-custom-text-100" : "text-custom-text-200"}`}
/>
</button>
</Tooltip>
);
})}
</div>
);
});

View File

@ -4,41 +4,40 @@ import { observer } from "mobx-react-lite";
import { Briefcase } from "lucide-react"; import { Briefcase } from "lucide-react";
// components // components
import { ProjectLogo } from "@/components/common"; import { ProjectLogo } from "@/components/common";
import { NavbarControls } from "@/components/issues/navbar/controls"; import { NavbarControls } from "@/components/issues";
// hooks // store
import { useProject } from "@/hooks/store"; import { PublishStore } from "@/store/publish/publish.store";
type IssueNavbarProps = { type Props = {
workspaceSlug: string; publishSettings: PublishStore;
projectId: string;
}; };
const IssueNavbar: FC<IssueNavbarProps> = observer((props) => { export const IssuesNavbarRoot: FC<Props> = observer((props) => {
const { workspaceSlug, projectId } = props; const { publishSettings } = props;
// hooks // hooks
const { project } = useProject(); const { project_details } = publishSettings;
return ( return (
<div className="relative flex justify-between w-full gap-4 px-5"> <div className="relative flex justify-between w-full gap-4 px-5">
{/* project detail */} {/* project detail */}
<div className="flex flex-shrink-0 items-center gap-2"> <div className="flex flex-shrink-0 items-center gap-2">
{project ? ( {project_details ? (
<span className="h-7 w-7 flex-shrink-0 grid place-items-center"> <span className="h-7 w-7 flex-shrink-0 grid place-items-center">
<ProjectLogo logo={project.logo_props} className="text-lg" /> <ProjectLogo logo={project_details.logo_props} className="text-lg" />
</span> </span>
) : ( ) : (
<span className="grid h-7 w-7 flex-shrink-0 place-items-center rounded uppercase"> <span className="grid h-7 w-7 flex-shrink-0 place-items-center rounded uppercase">
<Briefcase className="h-4 w-4" /> <Briefcase className="h-4 w-4" />
</span> </span>
)} )}
<div className="line-clamp-1 max-w-[300px] overflow-hidden text-lg font-medium">{project?.name || `...`}</div> <div className="line-clamp-1 max-w-[300px] overflow-hidden text-lg font-medium">
{project_details?.name || `...`}
</div>
</div> </div>
<div className="flex flex-shrink-0 items-center gap-2"> <div className="flex flex-shrink-0 items-center gap-2">
<NavbarControls workspaceSlug={workspaceSlug} projectId={projectId} /> <NavbarControls publishSettings={publishSettings} />
</div> </div>
</div> </div>
); );
}); });
export default IssueNavbar;

View File

@ -8,7 +8,7 @@ import { TOAST_TYPE, setToast } from "@plane/ui";
// editor components // editor components
import { LiteTextEditor } from "@/components/editor/lite-text-editor"; import { LiteTextEditor } from "@/components/editor/lite-text-editor";
// hooks // hooks
import { useIssueDetails, useProject, useUser } from "@/hooks/store"; import { useIssueDetails, usePublish, useUser } from "@/hooks/store";
// types // types
import { Comment } from "@/types/issue"; import { Comment } from "@/types/issue";
@ -17,22 +17,18 @@ const defaultValues: Partial<Comment> = {
}; };
type Props = { type Props = {
anchor: string;
disabled?: boolean; disabled?: boolean;
workspaceSlug: string;
projectId: string;
}; };
export const AddComment: React.FC<Props> = observer((props) => { export const AddComment: React.FC<Props> = observer((props) => {
// const { disabled = false } = props; const { anchor } = props;
const { workspaceSlug, projectId } = props;
// refs // refs
const editorRef = useRef<EditorRefApi>(null); const editorRef = useRef<EditorRefApi>(null);
// store hooks // store hooks
const { workspace } = useProject();
const { peekId: issueId, addIssueComment } = useIssueDetails(); const { peekId: issueId, addIssueComment } = useIssueDetails();
const { data: currentUser } = useUser(); const { data: currentUser } = useUser();
// derived values const { workspaceSlug, workspace: workspaceID } = usePublish(anchor);
const workspaceId = workspace?.id;
// form info // form info
const { const {
handleSubmit, handleSubmit,
@ -43,9 +39,9 @@ export const AddComment: React.FC<Props> = observer((props) => {
} = useForm<Comment>({ defaultValues }); } = useForm<Comment>({ defaultValues });
const onSubmit = async (formData: Comment) => { const onSubmit = async (formData: Comment) => {
if (!workspaceSlug || !projectId || !issueId || isSubmitting || !formData.comment_html) return; if (!anchor || !issueId || isSubmitting || !formData.comment_html) return;
await addIssueComment(workspaceSlug, projectId, issueId, formData) await addIssueComment(anchor, issueId, formData)
.then(() => { .then(() => {
reset(defaultValues); reset(defaultValues);
editorRef.current?.clearEditor(); editorRef.current?.clearEditor();
@ -71,8 +67,8 @@ export const AddComment: React.FC<Props> = observer((props) => {
onEnterKeyPress={(e) => { onEnterKeyPress={(e) => {
if (currentUser) handleSubmit(onSubmit)(e); if (currentUser) handleSubmit(onSubmit)(e);
}} }}
workspaceId={workspaceId as string} workspaceId={workspaceID?.toString() ?? ""}
workspaceSlug={workspaceSlug} workspaceSlug={workspaceSlug?.toString() ?? ""}
ref={editorRef} ref={editorRef}
initialValue={ initialValue={
!value || value === "" || (typeof value === "object" && Object.keys(value).length === 0) !value || value === "" || (typeof value === "object" && Object.keys(value).length === 0)

View File

@ -10,25 +10,23 @@ import { CommentReactions } from "@/components/issues/peek-overview";
// helpers // helpers
import { timeAgo } from "@/helpers/date-time.helper"; import { timeAgo } from "@/helpers/date-time.helper";
// hooks // hooks
import { useIssueDetails, useProject, useUser } from "@/hooks/store"; import { useIssueDetails, usePublish, useUser } from "@/hooks/store";
import useIsInIframe from "@/hooks/use-is-in-iframe"; import useIsInIframe from "@/hooks/use-is-in-iframe";
// types // types
import { Comment } from "@/types/issue"; import { Comment } from "@/types/issue";
type Props = { type Props = {
workspaceSlug: string; anchor: string;
comment: Comment; comment: Comment;
}; };
export const CommentCard: React.FC<Props> = observer((props) => { export const CommentCard: React.FC<Props> = observer((props) => {
const { comment, workspaceSlug } = props; const { anchor, comment } = props;
// store hooks // store hooks
const { workspace } = useProject();
const { peekId, deleteIssueComment, updateIssueComment } = useIssueDetails(); const { peekId, deleteIssueComment, updateIssueComment } = useIssueDetails();
const { data: currentUser } = useUser(); const { data: currentUser } = useUser();
const { workspaceSlug, workspace: workspaceID } = usePublish(anchor);
const isInIframe = useIsInIframe(); const isInIframe = useIsInIframe();
// derived values
const workspaceId = workspace?.id;
// states // states
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
@ -45,13 +43,13 @@ export const CommentCard: React.FC<Props> = observer((props) => {
}); });
const handleDelete = () => { const handleDelete = () => {
if (!workspaceSlug || !peekId) return; if (!anchor || !peekId) return;
deleteIssueComment(workspaceSlug, comment.project, peekId, comment.id); deleteIssueComment(anchor, peekId, comment.id);
}; };
const handleCommentUpdate = async (formData: Comment) => { const handleCommentUpdate = async (formData: Comment) => {
if (!workspaceSlug || !peekId) return; if (!anchor || !peekId) return;
updateIssueComment(workspaceSlug, comment.project, peekId, comment.id, formData); updateIssueComment(anchor, peekId, comment.id, formData);
setIsEditing(false); setIsEditing(false);
editorRef.current?.setEditorValue(formData.comment_html); editorRef.current?.setEditorValue(formData.comment_html);
showEditorRef.current?.setEditorValue(formData.comment_html); showEditorRef.current?.setEditorValue(formData.comment_html);
@ -103,8 +101,8 @@ export const CommentCard: React.FC<Props> = observer((props) => {
name="comment_html" name="comment_html"
render={({ field: { onChange, value } }) => ( render={({ field: { onChange, value } }) => (
<LiteTextEditor <LiteTextEditor
workspaceId={workspaceId as string} workspaceId={workspaceID?.toString() ?? ""}
workspaceSlug={workspaceSlug} workspaceSlug={workspaceSlug?.toString() ?? ""}
onEnterKeyPress={handleSubmit(handleCommentUpdate)} onEnterKeyPress={handleSubmit(handleCommentUpdate)}
ref={editorRef} ref={editorRef}
initialValue={value} initialValue={value}
@ -135,7 +133,7 @@ export const CommentCard: React.FC<Props> = observer((props) => {
</form> </form>
<div className={`${isEditing ? "hidden" : ""}`}> <div className={`${isEditing ? "hidden" : ""}`}>
<LiteTextReadOnlyEditor ref={showEditorRef} initialValue={comment.comment_html} /> <LiteTextReadOnlyEditor ref={showEditorRef} initialValue={comment.comment_html} />
<CommentReactions commentId={comment.id} projectId={comment.project} workspaceSlug={workspaceSlug} /> <CommentReactions anchor={anchor} commentId={comment.id} />
</div> </div>
</div> </div>
</div> </div>

View File

@ -13,12 +13,12 @@ import { useIssueDetails, useUser } from "@/hooks/store";
import useIsInIframe from "@/hooks/use-is-in-iframe"; import useIsInIframe from "@/hooks/use-is-in-iframe";
type Props = { type Props = {
anchor: string;
commentId: string; commentId: string;
projectId: string;
workspaceSlug: string;
}; };
export const CommentReactions: React.FC<Props> = observer((props) => { export const CommentReactions: React.FC<Props> = observer((props) => {
const { anchor, commentId } = props;
const router = useRouter(); const router = useRouter();
const pathName = usePathname(); const pathName = usePathname();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
@ -28,7 +28,6 @@ export const CommentReactions: React.FC<Props> = observer((props) => {
const priority = searchParams.get("priority") || undefined; const priority = searchParams.get("priority") || undefined;
const labels = searchParams.get("labels") || undefined; const labels = searchParams.get("labels") || undefined;
const { commentId, projectId, workspaceSlug } = props;
// hooks // hooks
const { addCommentReaction, removeCommentReaction, details, peekId } = useIssueDetails(); const { addCommentReaction, removeCommentReaction, details, peekId } = useIssueDetails();
const { data: user } = useUser(); const { data: user } = useUser();
@ -40,13 +39,13 @@ export const CommentReactions: React.FC<Props> = observer((props) => {
const userReactions = commentReactions?.filter((r) => r?.actor_detail?.id === user?.id); const userReactions = commentReactions?.filter((r) => r?.actor_detail?.id === user?.id);
const handleAddReaction = (reactionHex: string) => { const handleAddReaction = (reactionHex: string) => {
if (!workspaceSlug || !projectId || !peekId) return; if (!anchor || !peekId) return;
addCommentReaction(workspaceSlug, projectId, peekId, commentId, reactionHex); addCommentReaction(anchor, peekId, commentId, reactionHex);
}; };
const handleRemoveReaction = (reactionHex: string) => { const handleRemoveReaction = (reactionHex: string) => {
if (!workspaceSlug || !projectId || !peekId) return; if (!anchor || !peekId) return;
removeCommentReaction(workspaceSlug, projectId, peekId, commentId, reactionHex); removeCommentReaction(anchor, peekId, commentId, reactionHex);
}; };
const handleReactionClick = (reactionHex: string) => { const handleReactionClick = (reactionHex: string) => {

View File

@ -11,14 +11,13 @@ import {
import { IIssue } from "@/types/issue"; import { IIssue } from "@/types/issue";
type Props = { type Props = {
anchor: string;
handleClose: () => void; handleClose: () => void;
issueDetails: IIssue | undefined; issueDetails: IIssue | undefined;
workspaceSlug: string;
projectId: string;
}; };
export const FullScreenPeekView: React.FC<Props> = observer((props) => { export const FullScreenPeekView: React.FC<Props> = observer((props) => {
const { handleClose, issueDetails, workspaceSlug, projectId } = props; const { anchor, handleClose, issueDetails } = props;
return ( return (
<div className="grid h-full w-full grid-cols-10 divide-x divide-custom-border-200 overflow-hidden"> <div className="grid h-full w-full grid-cols-10 divide-x divide-custom-border-200 overflow-hidden">
@ -30,17 +29,13 @@ export const FullScreenPeekView: React.FC<Props> = observer((props) => {
<div className="h-full w-full overflow-y-auto px-6"> <div className="h-full w-full overflow-y-auto px-6">
{/* issue title and description */} {/* issue title and description */}
<div className="w-full"> <div className="w-full">
<PeekOverviewIssueDetails issueDetails={issueDetails} /> <PeekOverviewIssueDetails anchor={anchor} issueDetails={issueDetails} />
</div> </div>
{/* divider */} {/* divider */}
<div className="my-5 h-[1] w-full border-t border-custom-border-200" /> <div className="my-5 h-[1] w-full border-t border-custom-border-200" />
{/* issue activity/comments */} {/* issue activity/comments */}
<div className="w-full pb-5"> <div className="w-full pb-5">
<PeekOverviewIssueActivity <PeekOverviewIssueActivity anchor={anchor} issueDetails={issueDetails} />
issueDetails={issueDetails}
workspaceSlug={workspaceSlug}
projectId={projectId}
/>
</div> </div>
</div> </div>
) : ( ) : (

View File

@ -1,10 +1,9 @@
import React from "react"; import React from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { MoveRight } from "lucide-react"; import { Link2, MoveRight } from "lucide-react";
import { Listbox, Transition } from "@headlessui/react"; import { Listbox, Transition } from "@headlessui/react";
// ui // ui
import { setToast, TOAST_TYPE } from "@plane/ui"; import { CenterPanelIcon, FullScreenPanelIcon, setToast, SidePanelIcon, TOAST_TYPE } from "@plane/ui";
import { Icon } from "@/components/ui";
// helpers // helpers
import { copyTextToClipboard } from "@/helpers/string.helper"; import { copyTextToClipboard } from "@/helpers/string.helper";
// hooks // hooks
@ -18,21 +17,21 @@ type Props = {
issueDetails: IIssue | undefined; issueDetails: IIssue | undefined;
}; };
const peekModes: { const PEEK_MODES: {
key: IPeekMode; key: IPeekMode;
icon: string; icon: any;
label: string; label: string;
}[] = [ }[] = [
{ key: "side", icon: "side_navigation", label: "Side Peek" }, { key: "side", icon: SidePanelIcon, label: "Side Peek" },
{ {
key: "modal", key: "modal",
icon: "dialogs", icon: CenterPanelIcon,
label: "Modal Peek", label: "Modal",
}, },
{ {
key: "full", key: "full",
icon: "nearby", icon: FullScreenPanelIcon,
label: "Full Screen Peek", label: "Full Screen",
}, },
]; ];
@ -47,20 +46,22 @@ export const PeekOverviewHeader: React.FC<Props> = observer((props) => {
copyTextToClipboard(urlToCopy).then(() => { copyTextToClipboard(urlToCopy).then(() => {
setToast({ setToast({
type: TOAST_TYPE.INFO, type: TOAST_TYPE.SUCCESS,
title: "Link copied!", title: "Link copied!",
message: "Issue link copied to clipboard", message: "Issue link copied to clipboard.",
}); });
}); });
}; };
const Icon = PEEK_MODES.find((m) => m.key === peekMode)?.icon ?? SidePanelIcon;
return ( return (
<> <>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
{peekMode === "side" && ( {peekMode === "side" && (
<button type="button" onClick={handleClose}> <button type="button" onClick={handleClose} className="text-custom-text-300 hover:text-custom-text-200">
<MoveRight className="h-4 w-4" strokeWidth={2} /> <MoveRight className="size-4" />
</button> </button>
)} )}
<Listbox <Listbox
@ -69,8 +70,10 @@ export const PeekOverviewHeader: React.FC<Props> = observer((props) => {
onChange={(val) => setPeekMode(val)} onChange={(val) => setPeekMode(val)}
className="relative flex-shrink-0 text-left" className="relative flex-shrink-0 text-left"
> >
<Listbox.Button className={`grid place-items-center ${peekMode === "full" ? "rotate-45" : ""}`}> <Listbox.Button
<Icon iconName={peekModes.find((m) => m.key === peekMode)?.icon ?? ""} className="text-[1rem]" /> className={`grid place-items-center text-custom-text-300 hover:text-custom-text-200 ${peekMode === "full" ? "rotate-45" : ""}`}
>
<Icon className="h-4 w-4 text-custom-text-300 hover:text-custom-text-200" />
</Listbox.Button> </Listbox.Button>
<Transition <Transition
@ -84,7 +87,7 @@ export const PeekOverviewHeader: React.FC<Props> = observer((props) => {
> >
<Listbox.Options className="absolute left-0 z-10 mt-1 min-w-[8rem] origin-top-left overflow-y-auto whitespace-nowrap rounded-md border border-custom-border-300 bg-custom-background-90 text-xs shadow-lg focus:outline-none"> <Listbox.Options className="absolute left-0 z-10 mt-1 min-w-[8rem] origin-top-left overflow-y-auto whitespace-nowrap rounded-md border border-custom-border-300 bg-custom-background-90 text-xs shadow-lg focus:outline-none">
<div className="space-y-1 p-2"> <div className="space-y-1 p-2">
{peekModes.map((mode) => ( {PEEK_MODES.map((mode) => (
<Listbox.Option <Listbox.Option
key={mode.key} key={mode.key}
value={mode.key} value={mode.key}
@ -117,8 +120,13 @@ export const PeekOverviewHeader: React.FC<Props> = observer((props) => {
</div> </div>
{isClipboardWriteAllowed && (peekMode === "side" || peekMode === "modal") && ( {isClipboardWriteAllowed && (peekMode === "side" || peekMode === "modal") && (
<div className="flex flex-shrink-0 items-center gap-2"> <div className="flex flex-shrink-0 items-center gap-2">
<button type="button" onClick={handleCopyLink} className="-rotate-45 focus:outline-none" tabIndex={1}> <button
<Icon iconName="link" className="text-[1rem]" /> type="button"
onClick={handleCopyLink}
className="focus:outline-none text-custom-text-300 hover:text-custom-text-200"
tabIndex={1}
>
<Link2 className="h-4 w-4 -rotate-45" />
</button> </button>
</div> </div>
)} )}

View File

@ -7,61 +7,58 @@ import { Button } from "@plane/ui";
import { CommentCard, AddComment } from "@/components/issues/peek-overview"; import { CommentCard, AddComment } from "@/components/issues/peek-overview";
import { Icon } from "@/components/ui"; import { Icon } from "@/components/ui";
// hooks // hooks
import { useIssueDetails, useProject, useUser } from "@/hooks/store"; import { useIssueDetails, usePublish, useUser } from "@/hooks/store";
import useIsInIframe from "@/hooks/use-is-in-iframe"; import useIsInIframe from "@/hooks/use-is-in-iframe";
// types // types
import { IIssue } from "@/types/issue"; import { IIssue } from "@/types/issue";
type Props = { type Props = {
anchor: string;
issueDetails: IIssue; issueDetails: IIssue;
workspaceSlug: string;
projectId: string;
}; };
export const PeekOverviewIssueActivity: React.FC<Props> = observer((props) => { export const PeekOverviewIssueActivity: React.FC<Props> = observer((props) => {
const { workspaceSlug, projectId } = props; const { anchor } = props;
// router // router
const pathname = usePathname(); const pathname = usePathname();
// store // store hooks
const { canComment } = useProject();
const { details, peekId } = useIssueDetails(); const { details, peekId } = useIssueDetails();
const { data: currentUser } = useUser(); const { data: currentUser } = useUser();
const isInIframe = useIsInIframe(); const { canComment } = usePublish(anchor);
// derived values
const comments = details[peekId || ""]?.comments || []; const comments = details[peekId || ""]?.comments || [];
const isInIframe = useIsInIframe();
return ( return (
<div className="pb-10"> <div className="pb-10">
<h4 className="font-medium">Comments</h4> <h4 className="font-medium">Comments</h4>
{workspaceSlug && ( <div className="mt-4">
<div className="mt-4"> <div className="space-y-4">
<div className="space-y-4"> {comments.map((comment) => (
{comments.map((comment: any) => ( <CommentCard key={comment.id} anchor={anchor} comment={comment} />
<CommentCard key={comment.id} comment={comment} workspaceSlug={workspaceSlug?.toString()} /> ))}
))}
</div>
{!isInIframe &&
(currentUser ? (
<>
{canComment && (
<div className="mt-4">
<AddComment disabled={!currentUser} workspaceSlug={workspaceSlug} projectId={projectId} />
</div>
)}
</>
) : (
<div className="mt-4 flex items-center justify-between gap-2 rounded border border-custom-border-300 bg-custom-background-80 px-2 py-2.5">
<p className="flex gap-2 overflow-hidden break-words text-sm text-custom-text-200">
<Icon iconName="lock" className="!text-sm" />
Sign in to add your comment
</p>
<Link href={`/?next_path=${pathname}`}>
<Button variant="primary">Sign in</Button>
</Link>
</div>
))}
</div> </div>
)} {!isInIframe &&
(currentUser ? (
<>
{canComment && (
<div className="mt-4">
<AddComment anchor={anchor} disabled={!currentUser} />
</div>
)}
</>
) : (
<div className="mt-4 flex items-center justify-between gap-2 rounded border border-custom-border-300 bg-custom-background-80 px-2 py-2.5">
<p className="flex gap-2 overflow-hidden break-words text-sm text-custom-text-200">
<Icon iconName="lock" className="!text-sm" />
Sign in to add your comment
</p>
<Link href={`/?next_path=${pathname}`}>
<Button variant="primary">Sign in</Button>
</Link>
</div>
))}
</div>
</div> </div>
); );
}); });

View File

@ -5,26 +5,33 @@ import { IssueReactions } from "@/components/issues/peek-overview";
import { IIssue } from "@/types/issue"; import { IIssue } from "@/types/issue";
type Props = { type Props = {
anchor: string;
issueDetails: IIssue; issueDetails: IIssue;
}; };
export const PeekOverviewIssueDetails: React.FC<Props> = ({ issueDetails }) => ( export const PeekOverviewIssueDetails: React.FC<Props> = (props) => {
<div className="space-y-2"> const { anchor, issueDetails } = props;
<h6 className="font-medium text-custom-text-200">
{issueDetails.project_detail.identifier}-{issueDetails.sequence_id} const description = issueDetails.description_html;
</h6>
<h4 className="break-words text-2xl font-semibold">{issueDetails.name}</h4> return (
{issueDetails.description_html !== "" && issueDetails.description_html !== "<p></p>" && ( <div className="space-y-2">
<RichTextReadOnlyEditor <h6 className="text-base font-medium text-custom-text-400">
initialValue={ {issueDetails.project_detail?.identifier}-{issueDetails?.sequence_id}
!issueDetails.description_html || </h6>
issueDetails.description_html === "" || <h4 className="break-words text-2xl font-medium">{issueDetails.name}</h4>
(typeof issueDetails.description_html === "object" && Object.keys(issueDetails.description_html).length === 0) {description !== "" && description !== "<p></p>" && (
? "<p></p>" <RichTextReadOnlyEditor
: issueDetails.description_html initialValue={
} !description ||
/> description === "" ||
)} (typeof description === "object" && Object.keys(description).length === 0)
<IssueReactions /> ? "<p></p>"
</div> : description
); }
/>
)}
<IssueReactions anchor={anchor} />
</div>
);
};

Some files were not shown because too many files have changed in this diff Show More