diff --git a/apiserver/plane/app/serializers/project.py b/apiserver/plane/app/serializers/project.py index ee192a2049..1ddcb623c2 100644 --- a/apiserver/plane/app/serializers/project.py +++ b/apiserver/plane/app/serializers/project.py @@ -116,6 +116,12 @@ class ProjectListSerializer(DynamicBaseSerializer): member_role = serializers.IntegerField(read_only=True) anchor = serializers.CharField(read_only=True) members = serializers.SerializerMethodField() + # EE: project_grouping starts + state_id = serializers.UUIDField(read_only=True) + priority = serializers.CharField(read_only=True) + start_date = serializers.DateTimeField(read_only=True) + target_date = serializers.DateTimeField(read_only=True) + # EE: project_grouping ends def get_members(self, obj): project_members = getattr(obj, "members_list", None) diff --git a/apiserver/plane/app/views/project/base.py b/apiserver/plane/app/views/project/base.py index 86c5ba4942..14979d5b1e 100644 --- a/apiserver/plane/app/views/project/base.py +++ b/apiserver/plane/app/views/project/base.py @@ -52,6 +52,16 @@ from plane.db.models import ( from plane.utils.cache import cache_response from plane.bgtasks.webhook_task import model_activity +# EE imports +from plane.ee.models import ProjectState, ProjectAttribute +from plane.ee.utils.workspace_feature import ( + WorkspaceFeatureContext, + check_workspace_feature, +) +from plane.ee.serializers.app.project import ProjectAttributeSerializer +from plane.payment.flags.flag_decorator import check_workspace_feature_flag +from plane.payment.flags.flag import FeatureFlag + class ProjectViewSet(BaseViewSet): serializer_class = ProjectListSerializer @@ -69,6 +79,14 @@ class ProjectViewSet(BaseViewSet): workspace__slug=self.kwargs.get("slug"), is_active=True, ).values("sort_order") + + # EE: project_grouping starts + state_id = ProjectAttribute.objects.filter( + workspace__slug=self.kwargs.get("slug"), + project_id=OuterRef("pk"), + ).values("state_id") + # EE: project_grouping ends + return self.filter_queryset( super() .get_queryset() @@ -143,6 +161,27 @@ class ProjectViewSet(BaseViewSet): ).values("anchor") ) .annotate(sort_order=Subquery(sort_order)) + # EE: project_grouping starts + .annotate(state_id=Subquery(state_id)) + .annotate( + priority=ProjectAttribute.objects.filter( + workspace__slug=self.kwargs.get("slug"), + project_id=OuterRef("pk"), + ).values("priority") + ) + .annotate( + start_date=ProjectAttribute.objects.filter( + workspace__slug=self.kwargs.get("slug"), + project_id=OuterRef("pk"), + ).values("start_date") + ) + .annotate( + target_date=ProjectAttribute.objects.filter( + workspace__slug=self.kwargs.get("slug"), + project_id=OuterRef("pk"), + ).values("target_date") + ) + # EE: project_grouping ends .prefetch_related( Prefetch( "project_projectmember", @@ -337,6 +376,42 @@ class ProjectViewSet(BaseViewSet): ] ) + # validating the PROJECT_GROUPING feature flag is enabled + if check_workspace_feature_flag( + feature_key=FeatureFlag.PROJECT_GROUPING, + slug=slug, + user_id=str(request.user.id), + default_value=False, + ): + # validating the is_project_grouping_enabled workspace feature is enabled + if check_workspace_feature( + slug, + WorkspaceFeatureContext.IS_PROJECT_GROUPING_ENABLED, + ): + state_id = request.data.get("state_id", None) + priority = request.data.get("priority", "none") + start_date = request.data.get("start_date", None) + target_date = request.data.get("target_date", None) + + if state_id is None: + state_id = ( + ProjectState.objects.filter( + workspace=workspace, default=True + ) + .values_list("id", flat=True) + .first() + ) + + # also create project attributes + _ = ProjectAttribute.objects.create( + project_id=serializer.data.get("id"), + state_id=state_id, + priority=priority, + start_date=start_date, + target_date=target_date, + workspace_id=workspace.id, + ) + project = ( self.get_queryset() .filter(pk=serializer.data["id"]) @@ -419,6 +494,34 @@ class ProjectViewSet(BaseViewSet): is_triage=True, ) + # EE: project_grouping starts + # validating the PROJECT_GROUPING feature flag is enabled + if check_workspace_feature_flag( + feature_key=FeatureFlag.PROJECT_GROUPING, + slug=slug, + user_id=str(request.user.id), + default_value=False, + ): + # validating the is_project_grouping_enabled workspace feature is enabled + if check_workspace_feature( + slug, + WorkspaceFeatureContext.IS_PROJECT_GROUPING_ENABLED, + ): + project_attribute = ProjectAttribute.objects.filter( + project_id=project.id + ).first() + if project_attribute is not None: + project_attribute_serializer = ( + ProjectAttributeSerializer( + project_attribute, + data=request.data, + partial=True, + ) + ) + if project_attribute_serializer.is_valid(): + project_attribute_serializer.save() + # EE: project_grouping ends + project = ( self.get_queryset() .filter(pk=serializer.data["id"]) @@ -477,7 +580,6 @@ class ProjectViewSet(BaseViewSet): class ProjectArchiveUnarchiveEndpoint(BaseAPIView): - permission_classes = [ ProjectBasePermission, ] @@ -653,7 +755,9 @@ class ProjectPublicCoverImagesEndpoint(BaseAPIView): # Extracting file keys from the response if "Contents" in response: for content in response["Contents"]: - if not content["Key"].endswith( + if not content[ + "Key" + ].endswith( "/" ): # This line ensures we're only getting files, not "sub-folders" files.append( diff --git a/apiserver/plane/ee/migrations/0002_projectattribute_projectstate_workspacefeature_and_more.py b/apiserver/plane/ee/migrations/0002_issueproperty_deleted_at_and_more.py similarity index 56% rename from apiserver/plane/ee/migrations/0002_projectattribute_projectstate_workspacefeature_and_more.py rename to apiserver/plane/ee/migrations/0002_issueproperty_deleted_at_and_more.py index 00dce58658..bbe87d53b8 100644 --- a/apiserver/plane/ee/migrations/0002_projectattribute_projectstate_workspacefeature_and_more.py +++ b/apiserver/plane/ee/migrations/0002_issueproperty_deleted_at_and_more.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.14 on 2024-08-02 09:52 +# Generated by Django 4.2.14 on 2024-07-31 14:22 from django.conf import settings from django.db import migrations, models @@ -13,8 +13,43 @@ class Migration(migrations.Migration): ] operations = [ + migrations.AddField( + model_name="issueproperty", + name="deleted_at", + field=models.DateTimeField( + blank=True, null=True, verbose_name="Deleted At" + ), + ), + migrations.AddField( + model_name="issuepropertyactivity", + name="deleted_at", + field=models.DateTimeField( + blank=True, null=True, verbose_name="Deleted At" + ), + ), + migrations.AddField( + model_name="issuepropertyoption", + name="deleted_at", + field=models.DateTimeField( + blank=True, null=True, verbose_name="Deleted At" + ), + ), + migrations.AddField( + model_name="issuepropertyvalue", + name="deleted_at", + field=models.DateTimeField( + blank=True, null=True, verbose_name="Deleted At" + ), + ), + migrations.AddField( + model_name="issueworklog", + name="deleted_at", + field=models.DateTimeField( + blank=True, null=True, verbose_name="Deleted At" + ), + ), migrations.CreateModel( - name="ProjectAttribute", + name="WorkspaceFeature", fields=[ ( "created_at", @@ -46,27 +81,43 @@ class Migration(migrations.Migration): ), ), ( - "priority", - models.CharField( - choices=[ - ("none", "None"), - ("low", "Low"), - ("medium", "Medium"), - ("high", "High"), - ("urgent", "Urgent"), - ], - default="none", - max_length=50, + "is_project_grouping_enabled", + models.BooleanField(default=False), + ), + ( + "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", + ), + ), + ( + "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.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="features", + to="db.workspace", ), ), - ("start_date", models.DateTimeField(blank=True, null=True)), - ("target_date", models.DateTimeField(blank=True, null=True)), ], options={ - "verbose_name": "Project Attribute", - "verbose_name_plural": "Project Attributes", - "db_table": "project_attributes", - "ordering": ("created_at",), + "verbose_name": "Workspace Feature", + "verbose_name_plural": "Workspace Features", + "db_table": "workspace_features", + "ordering": ("-created_at",), }, ), migrations.CreateModel( @@ -129,16 +180,45 @@ class Migration(migrations.Migration): "external_id", models.CharField(blank=True, max_length=255, null=True), ), + ( + "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", + ), + ), + ( + "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="project_states", + to="db.workspace", + ), + ), ], options={ "verbose_name": "Project State", "verbose_name_plural": "Project States", "db_table": "project_states", "ordering": ("sequence",), + "unique_together": {("name", "workspace")}, }, ), migrations.CreateModel( - name="WorkspaceFeature", + name="ProjectAttribute", fields=[ ( "created_at", @@ -170,206 +250,72 @@ class Migration(migrations.Migration): ), ), ( - "is_project_grouping_enabled", - models.BooleanField(default=False), + "priority", + models.CharField( + choices=[ + ("none", "None"), + ("low", "Low"), + ("medium", "Medium"), + ("high", "High"), + ("urgent", "Urgent"), + ], + default="none", + max_length=50, + ), + ), + ("start_date", models.DateTimeField(blank=True, null=True)), + ("target_date", models.DateTimeField(blank=True, null=True)), + ( + "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", + ), + ), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_%(class)s", + to="db.project", + ), + ), + ( + "state", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="project_attributes", + to="ee.projectstate", + ), + ), + ( + "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": "Workspace Feature", - "verbose_name_plural": "Workspace Features", - "db_table": "workspace_features", - "ordering": ("-created_at",), + "verbose_name": "Project Attribute", + "verbose_name_plural": "Project Attributes", + "db_table": "project_attributes", + "ordering": ("created_at",), }, ), - migrations.AlterUniqueTogether( - name="issueproperty", - unique_together=set(), - ), - migrations.AlterUniqueTogether( - name="issuepropertyoption", - unique_together=set(), - ), - migrations.AddField( - model_name="issueproperty", - name="deleted_at", - field=models.DateTimeField( - blank=True, null=True, verbose_name="Deleted At" - ), - ), - migrations.AddField( - model_name="issuepropertyactivity", - name="deleted_at", - field=models.DateTimeField( - blank=True, null=True, verbose_name="Deleted At" - ), - ), - migrations.AddField( - model_name="issuepropertyoption", - name="deleted_at", - field=models.DateTimeField( - blank=True, null=True, verbose_name="Deleted At" - ), - ), - migrations.AddField( - model_name="issuepropertyvalue", - name="deleted_at", - field=models.DateTimeField( - blank=True, null=True, verbose_name="Deleted At" - ), - ), - migrations.AddField( - model_name="issueworklog", - name="deleted_at", - field=models.DateTimeField( - blank=True, null=True, verbose_name="Deleted At" - ), - ), - migrations.AlterUniqueTogether( - name="issueproperty", - unique_together={("name", "issue_type", "deleted_at")}, - ), - migrations.AlterUniqueTogether( - name="issuepropertyoption", - unique_together={("name", "property", "deleted_at")}, - ), - migrations.AddConstraint( - model_name="issueproperty", - constraint=models.UniqueConstraint( - condition=models.Q(("deleted_at__isnull", True)), - fields=("name", "issue_type"), - name="issue_property_unique_name_project_when_deleted_at_null", - ), - ), - migrations.AddConstraint( - model_name="issuepropertyoption", - constraint=models.UniqueConstraint( - condition=models.Q(("deleted_at__isnull", True)), - fields=("name", "property"), - name="issue_property_option_unique_name_project_when_deleted_at_null", - ), - ), - migrations.AddField( - model_name="workspacefeature", - name="created_by", - field=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", - ), - ), - migrations.AddField( - model_name="workspacefeature", - name="updated_by", - field=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", - ), - ), - migrations.AddField( - model_name="workspacefeature", - name="workspace", - field=models.OneToOneField( - on_delete=django.db.models.deletion.CASCADE, - related_name="features", - to="db.workspace", - ), - ), - migrations.AddField( - model_name="projectstate", - name="created_by", - field=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", - ), - ), - migrations.AddField( - model_name="projectstate", - name="updated_by", - field=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", - ), - ), - migrations.AddField( - model_name="projectstate", - name="workspace", - field=models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="project_states", - to="db.workspace", - ), - ), - migrations.AddField( - model_name="projectattribute", - name="created_by", - field=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", - ), - ), - migrations.AddField( - model_name="projectattribute", - name="project", - field=models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="project_%(class)s", - to="db.project", - ), - ), - migrations.AddField( - model_name="projectattribute", - name="state", - field=models.ForeignKey( - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="project_attributes", - to="ee.projectstate", - ), - ), - migrations.AddField( - model_name="projectattribute", - name="updated_by", - field=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", - ), - ), - migrations.AddField( - model_name="projectattribute", - name="workspace", - field=models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="workspace_%(class)s", - to="db.workspace", - ), - ), - migrations.AddConstraint( - model_name="projectstate", - constraint=models.UniqueConstraint( - condition=models.Q(("deleted_at__isnull", True)), - fields=("name", "workspace"), - name="project_state_unique_name_project_when_deleted_at_null", - ), - ), - migrations.AlterUniqueTogether( - name="projectstate", - unique_together={("name", "workspace", "deleted_at")}, - ), ] diff --git a/apiserver/plane/ee/serializers/__init__.py b/apiserver/plane/ee/serializers/__init__.py index 86534de50d..25089833f2 100644 --- a/apiserver/plane/ee/serializers/__init__.py +++ b/apiserver/plane/ee/serializers/__init__.py @@ -16,6 +16,9 @@ from .app.issue_property import ( from .app.worklog import IssueWorkLogSerializer from .app.exporter import ExporterHistorySerializer +from .app.workspace.feature import WorkspaceFeatureSerializer +from .app.workspace.project_state import ProjectStateSerializer + # Space imports from .space.page import PagePublicSerializer from .space.views import ViewsPublicSerializer diff --git a/apiserver/plane/ee/serializers/app/project.py b/apiserver/plane/ee/serializers/app/project.py new file mode 100644 index 0000000000..de9cda105d --- /dev/null +++ b/apiserver/plane/ee/serializers/app/project.py @@ -0,0 +1,17 @@ +# Module imports +from plane.app.serializers.base import BaseSerializer +from plane.ee.models import ProjectAttribute +from rest_framework import serializers + + +class ProjectAttributeSerializer(BaseSerializer): + state_id = serializers.UUIDField(required=False) + + class Meta: + model = ProjectAttribute + fields = [ + "state_id", + "priority", + "start_date", + "target_date", + ] diff --git a/apiserver/plane/ee/serializers/app/workspace/feature.py b/apiserver/plane/ee/serializers/app/workspace/feature.py new file mode 100644 index 0000000000..f7e7c4f8d8 --- /dev/null +++ b/apiserver/plane/ee/serializers/app/workspace/feature.py @@ -0,0 +1,8 @@ +from rest_framework import serializers +from plane.ee.models import WorkspaceFeature + + +class WorkspaceFeatureSerializer(serializers.ModelSerializer): + class Meta: + model = WorkspaceFeature + fields = "__all__" diff --git a/apiserver/plane/ee/serializers/app/workspace/project_state.py b/apiserver/plane/ee/serializers/app/workspace/project_state.py new file mode 100644 index 0000000000..aa269778cd --- /dev/null +++ b/apiserver/plane/ee/serializers/app/workspace/project_state.py @@ -0,0 +1,21 @@ +from plane.ee.models import ProjectState +from rest_framework import serializers + + +class ProjectStateSerializer(serializers.ModelSerializer): + class Meta: + model = ProjectState + fields = [ + "id", + "workspace_id", + "name", + "color", + "group", + "default", + "description", + "sequence", + ] + read_only_fields = [ + "workspace", + "project", + ] diff --git a/apiserver/plane/ee/urls/app/workspace.py b/apiserver/plane/ee/urls/app/workspace.py index 19622f886a..283fbb34b7 100644 --- a/apiserver/plane/ee/urls/app/workspace.py +++ b/apiserver/plane/ee/urls/app/workspace.py @@ -5,6 +5,9 @@ from django.urls import path from plane.ee.views import ( WorkspaceWorkLogsEndpoint, WorkspaceExportWorkLogsEndpoint, + WorkspaceFeaturesEndpoint, + WorkspaceProjectStatesEndpoint, + WorkspaceProjectStatesDefaultEndpoint, ) @@ -19,4 +22,24 @@ urlpatterns = [ WorkspaceExportWorkLogsEndpoint.as_view(), name="workspace-work-logs", ), + path( + "workspaces//features/", + WorkspaceFeaturesEndpoint.as_view(), + name="workspace-features", + ), + path( + "workspaces//project-states/", + WorkspaceProjectStatesEndpoint.as_view(), + name="workspace-project-states", + ), + path( + "workspaces//project-states//", + WorkspaceProjectStatesEndpoint.as_view(), + name="workspace-project-states", + ), + path( + "workspaces//project-states//default/", + WorkspaceProjectStatesDefaultEndpoint.as_view(), + name="workspace-project-states-default", + ), ] diff --git a/apiserver/plane/ee/utils/workspace_feature.py b/apiserver/plane/ee/utils/workspace_feature.py new file mode 100644 index 0000000000..a31c9664c3 --- /dev/null +++ b/apiserver/plane/ee/utils/workspace_feature.py @@ -0,0 +1,19 @@ +# Third party imports +from enum import Enum + +from plane.ee.models.workspace import WorkspaceFeature + + +class WorkspaceFeatureContext(Enum): + # Workspace level project states + IS_PROJECT_GROUPING_ENABLED = "is_project_grouping_enabled" + + +def check_workspace_feature(slug, feature: WorkspaceFeatureContext): + # Dynamically build the filter using the feature's value + filter_kwargs = {"workspace__slug": slug, feature.value: True} + is_workspace_feature_enabled = WorkspaceFeature.objects.filter( + **filter_kwargs + ).exists() + + return is_workspace_feature_enabled diff --git a/apiserver/plane/ee/views/__init__.py b/apiserver/plane/ee/views/__init__.py index a9511177ac..5f1b0c767a 100644 --- a/apiserver/plane/ee/views/__init__.py +++ b/apiserver/plane/ee/views/__init__.py @@ -26,6 +26,9 @@ from plane.ee.views.app.views import ( from plane.ee.views.app.workspace import ( WorkspaceWorkLogsEndpoint, WorkspaceExportWorkLogsEndpoint, + WorkspaceFeaturesEndpoint, + WorkspaceProjectStatesEndpoint, + WorkspaceProjectStatesDefaultEndpoint ) from plane.ee.views.app.issue_property import IssuePropertyEndpoint diff --git a/apiserver/plane/ee/views/app/__init__.py b/apiserver/plane/ee/views/app/__init__.py index 175b9f66fa..c114f3feb0 100644 --- a/apiserver/plane/ee/views/app/__init__.py +++ b/apiserver/plane/ee/views/app/__init__.py @@ -6,4 +6,4 @@ from plane.ee.views.app.issue import ( BulkSubscribeIssuesEndpoint, IssueWorkLogsEndpoint, IssueTotalWorkLogEndpoint, -) +) \ No newline at end of file diff --git a/apiserver/plane/ee/views/app/workspace/__init__.py b/apiserver/plane/ee/views/app/workspace/__init__.py index 3f8880f601..abe1ccbc32 100644 --- a/apiserver/plane/ee/views/app/workspace/__init__.py +++ b/apiserver/plane/ee/views/app/workspace/__init__.py @@ -2,3 +2,7 @@ from .worklogs import ( WorkspaceWorkLogsEndpoint, WorkspaceExportWorkLogsEndpoint, ) + +from .feature import WorkspaceFeaturesEndpoint + +from .project_state import WorkspaceProjectStatesEndpoint, WorkspaceProjectStatesDefaultEndpoint diff --git a/apiserver/plane/ee/views/app/workspace/feature.py b/apiserver/plane/ee/views/app/workspace/feature.py new file mode 100644 index 0000000000..09864ac038 --- /dev/null +++ b/apiserver/plane/ee/views/app/workspace/feature.py @@ -0,0 +1,131 @@ +# Third party imports +from rest_framework import status +from rest_framework.response import Response + +from django.db.models import Q + +from plane.db.models import Project, Workspace +from plane.ee.views.base import BaseAPIView +from plane.ee.models import WorkspaceFeature, ProjectState, ProjectAttribute +from plane.ee.permissions import WorkSpaceBasePermission +from plane.ee.serializers import WorkspaceFeatureSerializer + + +class WorkspaceFeaturesEndpoint(BaseAPIView): + permission_classes = (WorkSpaceBasePermission,) + + def get(self, request, slug): + workspace = Workspace.objects.get(slug=slug) + workspace_feature, _ = WorkspaceFeature.objects.get_or_create( + workspace_id=workspace.id + ) + serializer = WorkspaceFeatureSerializer(workspace_feature) + return Response(serializer.data, status=status.HTTP_201_CREATED) + + def patch(self, request, slug): + is_project_grouping_enabled = request.data.get( + "is_project_grouping_enabled", False + ) + workspace = Workspace.objects.get(slug=slug) + workspace_feature = WorkspaceFeature.objects.get( + workspace_id=workspace.id + ) + + if is_project_grouping_enabled: + project_states = ProjectState.objects.filter( + workspace__slug=slug + ).first() + + if not project_states: + # Default states + states = [ + { + "name": "Draft", + "color": "#8B8D98", + "sequence": 15000, + "group": "draft", + "default": True, + }, + { + "name": "Planning", + "color": "#80838D", + "sequence": 25000, + "group": "planning", + }, + { + "name": "Execution", + "color": "#FFBA18", + "sequence": 35000, + "group": "execution", + }, + { + "name": "Monitoring", + "color": "#3E63DD", + "sequence": 45000, + "group": "monitoring", + }, + { + "name": "Completed", + "color": "#3E9B4F", + "sequence": 55000, + "group": "completed", + }, + { + "name": "Cancelled", + "color": "#DC3E42", + "sequence": 65000, + "group": "cancelled", + }, + ] + + ProjectState.objects.bulk_create( + [ + ProjectState( + name=state["name"], + color=state["color"], + sequence=state["sequence"], + workspace=workspace, + group=state["group"], + default=state.get("default", False), + created_by=request.user, + updated_by=request.user, + ) + for state in states + ] + ) + + default_state = ProjectState.objects.filter( + workspace__slug=slug, + default=True, + ).first() + + project_attribute_project_ids = ProjectAttribute.objects.filter( + workspace__slug=slug, + ).values_list("project_id", flat=True) + + projects_ids = Project.objects.filter( + ~Q(id__in=project_attribute_project_ids), + workspace__slug=slug, + ).values_list("id", flat=True) + + # bulk create all the project attributes + if projects_ids: + ProjectAttribute.objects.bulk_create( + [ + ProjectAttribute( + project_id=project_id, + state=default_state, + workspace=workspace, + ) + for project_id in projects_ids + ], + batch_size=10, + ) + + serializer = WorkspaceFeatureSerializer( + workspace_feature, data=request.data, partial=True + ) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) diff --git a/apiserver/plane/ee/views/app/workspace/project_state.py b/apiserver/plane/ee/views/app/workspace/project_state.py new file mode 100644 index 0000000000..0adebba107 --- /dev/null +++ b/apiserver/plane/ee/views/app/workspace/project_state.py @@ -0,0 +1,138 @@ +# Third Party imports +from rest_framework import status +from rest_framework.response import Response + +# Module imports +from plane.payment.flags.flag_decorator import check_feature_flag +from plane.payment.flags.flag import FeatureFlag +from plane.db.models import Workspace +from plane.ee.models import ProjectState, ProjectAttribute +from plane.ee.views.base import BaseAPIView, BaseViewSet +from plane.ee.serializers import ( + ProjectStateSerializer, +) +from plane.app.permissions import WorkSpaceBasePermission + +# EE imports +from plane.ee.utils.workspace_feature import ( + WorkspaceFeatureContext, + check_workspace_feature, +) + + +class WorkspaceProjectStatesEndpoint(BaseAPIView): + permission_classes = [ + WorkSpaceBasePermission, + ] + + @check_feature_flag(FeatureFlag.PROJECT_GROUPING) + def get(self, request, slug): + if check_workspace_feature( + slug, WorkspaceFeatureContext.IS_PROJECT_GROUPING_ENABLED + ): + project_states = ProjectState.objects.filter( + workspace__slug=slug + ).order_by("sequence") + serializer = ProjectStateSerializer(project_states, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + return Response( + {"error": "Project grouping is not enabled for this workspace"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + @check_feature_flag(FeatureFlag.PROJECT_GROUPING) + def post(self, request, slug): + if check_workspace_feature( + slug, WorkspaceFeatureContext.IS_PROJECT_GROUPING_ENABLED + ): + workspace = Workspace.objects.get(slug=slug) + serializer = ProjectStateSerializer(data=request.data) + if serializer.is_valid(): + serializer.save(workspace=workspace) + return Response(serializer.data, status=status.HTTP_200_OK) + return Response( + serializer.errors, status=status.HTTP_400_BAD_REQUEST + ) + return Response( + {"error": "Project grouping is not enabled for this workspace"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + @check_feature_flag(FeatureFlag.PROJECT_GROUPING) + def patch(self, request, slug, pk): + if check_workspace_feature( + slug, WorkspaceFeatureContext.IS_PROJECT_GROUPING_ENABLED + ): + project_states = ProjectState.objects.filter( + workspace__slug=slug, pk=pk + ).first() + serializer = ProjectStateSerializer( + project_states, data=request.data, partial=True + ) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) + return Response( + serializer.errors, status=status.HTTP_400_BAD_REQUEST + ) + return Response( + {"error": "Project grouping is not enabled for this workspace"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + @check_feature_flag(FeatureFlag.PROJECT_GROUPING) + def delete(self, request, slug, pk): + if check_workspace_feature( + slug, WorkspaceFeatureContext.IS_PROJECT_GROUPING_ENABLED + ): + project_state = ProjectState.objects.filter( + workspace__slug=slug, pk=pk + ).first() + + if project_state.default: + return Response( + {"error": "Default state cannot be deleted"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Check for any project in the state + project_exist = ProjectAttribute.objects.filter(state=pk).exists() + + if project_exist: + return Response( + { + "error": "The state is not empty, only empty states can be deleted" + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + project_state.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + return Response( + {"error": "Project grouping is not enabled for this workspace"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + +class WorkspaceProjectStatesDefaultEndpoint(BaseAPIView): + permission_classes = [ + WorkSpaceBasePermission, + ] + + @check_feature_flag(FeatureFlag.PROJECT_GROUPING) + def post(self, request, slug, pk): + if check_workspace_feature( + slug, WorkspaceFeatureContext.IS_PROJECT_GROUPING_ENABLED + ): + # Select all the states which are marked as default + _ = ProjectState.objects.filter( + workspace__slug=slug, default=True + ).update(default=False) + _ = ProjectState.objects.filter( + workspace__slug=slug, pk=pk + ).update(default=True) + return Response(status=status.HTTP_204_NO_CONTENT) + return Response( + {"error": "Project grouping is not enabled for this workspace"}, + status=status.HTTP_400_BAD_REQUEST, + ) diff --git a/apiserver/plane/graphql/mutations/cycle.py b/apiserver/plane/graphql/mutations/cycle.py new file mode 100644 index 0000000000..f555c6c2d3 --- /dev/null +++ b/apiserver/plane/graphql/mutations/cycle.py @@ -0,0 +1,157 @@ +# Python imports +import json + +# Strawberry imports +import strawberry +from strawberry.types import Info +from strawberry.scalars import JSON +from strawberry.permission import PermissionExtension + +# Third-party imports +from asgiref.sync import sync_to_async + +# Django imports +from django.db.models import Q +from django.utils import timezone +from django.core import serializers + +# Module imports +from plane.graphql.permissions.project import ( + ProjectMemberPermission, +) +from plane.db.models import ( + CycleIssue, + Cycle, +) +from plane.graphql.bgtasks.issue_activity_task import issue_activity + + +@strawberry.type +class CycleIssueMutation: + @strawberry.mutation( + extensions=[ + PermissionExtension(permissions=[ProjectMemberPermission()]) + ] + ) + async def create_cycle_issue( + self, + info: Info, + slug: str, + project: strawberry.ID, + cycle: strawberry.ID, + issues: JSON, + ) -> bool: + # Fetch the cycle object asynchronously + cycle = await sync_to_async(Cycle.objects.get)( + workspace__slug=slug, project_id=project, pk=cycle + ) + + # Fetch existing CycleIssues for the given issues asynchronously + existing_cycle_issues = await sync_to_async( + lambda: list( + CycleIssue.objects.filter( + ~Q(cycle_id=cycle), issue_id__in=issues + ).all() + ) + )() + + # Extract issue_ids from the existing CycleIssue objects + existing_issue_ids = set( + map(lambda ci: str(ci.issue_id), existing_cycle_issues) + ) + + # Convert issues to a set of strings + new_issue_ids = set(map(str, issues)) + + # Determine new issues to create + new_issue_ids -= existing_issue_ids + + # Update existing CycleIssues to the new cycle asynchronously + await sync_to_async( + lambda: CycleIssue.objects.filter( + ~Q(cycle=cycle), issue_id__in=existing_issue_ids + ).update(cycle=cycle) + )() + + # Create new CycleIssues for new issues + new_cycle_issues = [ + CycleIssue( + project_id=project, + workspace_id=cycle.workspace_id, + created_by_id=info.context.user.id, + updated_by_id=info.context.user.id, + cycle=cycle, + issue_id=issue_id, + ) + for issue_id in new_issue_ids + ] + created_records = await sync_to_async(CycleIssue.objects.bulk_create)( + new_cycle_issues, batch_size=10 + ) + + update_cycle_issue_activity = [ + { + "old_cycle_id": str(cycle_issue.cycle_id), + "new_cycle_id": str(cycle.id), + "issue_id": str(cycle_issue.issue_id), + } + for cycle_issue in existing_cycle_issues + ] + + # Capture Issue Activity asynchronously + await sync_to_async(issue_activity.delay)( + type="cycle.activity.created", + requested_data=json.dumps({"cycles_list": list(issues)}), + actor_id=str(info.context.user.id), + issue_id=None, + project_id=str(project), + current_instance=json.dumps( + { + "updated_cycle_issues": update_cycle_issue_activity, + "created_cycle_issues": serializers.serialize( + "json", created_records + ), + } + ), + epoch=int(timezone.now().timestamp()), + notification=True, + origin=info.context.request.META.get("HTTP_ORIGIN"), + ) + + return True + + @strawberry.mutation( + extensions=[ + PermissionExtension(permissions=[ProjectMemberPermission()]) + ] + ) + async def delete_cycle_issue( + self, + info: Info, + slug: str, + project: strawberry.ID, + cycle: strawberry.ID, + issue: strawberry.ID, + ) -> bool: + cycle_issue = await sync_to_async(CycleIssue.objects.filter)( + cycle_id=cycle, project_id=project, workspace__slug=slug, issue_id=issue + ) + await sync_to_async(issue_activity.delay)( + type="cycle.activity.deleted", + requested_data=json.dumps( + { + "cycle_id": str(cycle), + "issues": [str(issue)], + } + ), + actor_id=str(info.context.user.id), + issue_id=str(issue), + project_id=str(project), + current_instance=None, + epoch=int(timezone.now().timestamp()), + notification=True, + origin=info.context.request.META.get("HTTP_ORIGIN"), + ) + await sync_to_async(cycle_issue.delete)() + + return True diff --git a/apiserver/plane/graphql/mutations/issue.py b/apiserver/plane/graphql/mutations/issue.py index a97491cc9a..d07d49eff3 100644 --- a/apiserver/plane/graphql/mutations/issue.py +++ b/apiserver/plane/graphql/mutations/issue.py @@ -6,6 +6,7 @@ import strawberry from strawberry.types import Info from strawberry.scalars import JSON from strawberry.permission import PermissionExtension +from strawberry.file_uploads import Upload # Third-party imports from typing import Optional @@ -262,7 +263,28 @@ class IssueUserPropertyMutation: @strawberry.type class IssueAttachmentMutation: - @strawberry.field( + # @strawberry.field( + # extensions=[PermissionExtension(permissions=[ProjectBasePermission()])] + # ) + # async def create_issue_attachment( + # ) -> IssueAttachment: + # pass + + + # @strawberry.mutation( + # extensions=[PermissionExtension(permissions=[ProjectBasePermission()])] + # ) + # def upload_file(self, file: Upload, info: Info) -> bool: + # content = file.read() + # filename = file.filename + + # # Save the file using Django's file storage + # # file_name = default_storage.save(filename, content) + + # return True + + + @strawberry.mutation( extensions=[PermissionExtension(permissions=[ProjectBasePermission()])] ) async def deleteIssueAttachment( diff --git a/apiserver/plane/graphql/mutations/module.py b/apiserver/plane/graphql/mutations/module.py new file mode 100644 index 0000000000..6548be37f7 --- /dev/null +++ b/apiserver/plane/graphql/mutations/module.py @@ -0,0 +1,117 @@ +# Python imports +import json + +# Strawberry imports +import strawberry +from strawberry.scalars import JSON +from strawberry.types import Info +from strawberry.permission import PermissionExtension + +# Third-party imports +from asgiref.sync import sync_to_async + +# Django imports +from django.utils import timezone + +# Module imports +from plane.graphql.permissions.project import ( + ProjectMemberPermission, +) +from plane.db.models import ( + Project, + ModuleIssue, +) +from plane.graphql.bgtasks.issue_activity_task import issue_activity + + +@strawberry.type +class ModuleIssueMutation: + @strawberry.mutation( + extensions=[ + PermissionExtension(permissions=[ProjectMemberPermission()]) + ] + ) + async def create_module_issues( + self, + info: Info, + slug: str, + project: strawberry.ID, + module: strawberry.ID, + issues: JSON, + ) -> bool: + + project = await sync_to_async(Project.objects.get)(pk=project) + print("issue", issues) + # Create ModuleIssues asynchronously + await sync_to_async( + lambda: ModuleIssue.objects.bulk_create( + [ + ModuleIssue( + issue_id=issue_id, + module_id=module, + project_id=project.id, + workspace_id=project.workspace_id, + created_by_id=info.context.user.id, + updated_by_id=info.context.user.id, + ) + for issue_id in issues + ], + batch_size=10, + ignore_conflicts=True, + ) + )() + + await sync_to_async( + lambda: [ + issue_activity.delay( + type="module.activity.created", + requested_data=json.dumps({"module_id": str(module)}), + actor_id=str(info.context.user.id), + issue_id=str(issue), + project_id=str(project.id), + current_instance=None, + epoch=int(timezone.now().timestamp()), + notification=True, + origin=info.context.request.META.get("HTTP_ORIGIN"), + ) + for issue in issues + ] + )() + + return True + + @strawberry.mutation( + extensions=[ + PermissionExtension(permissions=[ProjectMemberPermission()]) + ] + ) + async def delete_module_issue( + self, + info: Info, + slug: str, + project: strawberry.ID, + module: strawberry.ID, + issue: strawberry.ID, + ) -> bool: + module_issue = await sync_to_async(ModuleIssue.objects.filter)( + module_id=module, + project_id=project, + workspace__slug=slug, + issue_id=issue, + ) + await sync_to_async(issue_activity.delay)( + requested_data=json.dumps({"module_id": str(module)}), + actor_id=str(info.context.user.id), + issue_id=str(issue), + project_id=str(project), + current_instance=None, + type="module.activity.deleted", + epoch=int(timezone.now().timestamp()), + notification=True, + origin=info.context.request.META.get("HTTP_ORIGIN"), + ) + + # Delete the module issue in a synchronous context + await sync_to_async(module_issue.delete)() + + return True diff --git a/apiserver/plane/graphql/queries/cycle.py b/apiserver/plane/graphql/queries/cycle.py new file mode 100644 index 0000000000..6c806eaf0f --- /dev/null +++ b/apiserver/plane/graphql/queries/cycle.py @@ -0,0 +1,82 @@ +# Third-Party Imports +import strawberry + +# Python Standard Library Imports +from asgiref.sync import sync_to_async + +# Strawberry Imports +from strawberry.types import Info +from strawberry.permission import PermissionExtension + +# Module Imports +from plane.db.models import Cycle, Issue +from plane.graphql.types.cycle import CycleType +from plane.graphql.types.issue import IssueType +from plane.graphql.permissions.project import ProjectBasePermission + + +@strawberry.type +class CycleQuery: + @strawberry.field( + extensions=[PermissionExtension(permissions=[ProjectBasePermission()])] + ) + async def cycles( + self, + info: Info, + slug: str, + project: strawberry.ID, + ) -> list[CycleType]: + + cycles = await sync_to_async(list)( + Cycle.objects.filter(workspace__slug=slug) + .filter(project_id=project) + .filter( + project__project_projectmember__member=info.context.user, + project__project_projectmember__is_active=True, + ) + ) + return cycles + + @strawberry.field( + extensions=[PermissionExtension(permissions=[ProjectBasePermission()])] + ) + async def cycle( + self, + info: Info, + slug: str, + project: strawberry.ID, + cycle: strawberry.ID, + ) -> CycleType: + cycle = await sync_to_async(Cycle.objects.get)( + workspace__slug=slug, + project_id=project, + id=cycle, + project__project_projectmember__member=info.context.user, + project__project_projectmember__is_active=True, + ) + return cycle + + +@strawberry.type +class CycleIssueQuery: + @strawberry.field( + extensions=[PermissionExtension(permissions=[ProjectBasePermission()])] + ) + async def cycleIssues( + self, + info: Info, + slug: str, + project: strawberry.ID, + cycle: strawberry.ID, + ) -> list[IssueType]: + + cycles_issues = await sync_to_async(list)( + Issue.issue_objects.filter(workspace__slug=slug) + .filter(project_id=project) + .filter(issue_cycle__cycle_id=cycle) + .filter( + project__project_projectmember__member=info.context.user, + project__project_projectmember__is_active=True, + ) + ) + return cycles_issues diff --git a/apiserver/plane/graphql/queries/issue.py b/apiserver/plane/graphql/queries/issue.py index 86db908463..1b0eb18794 100644 --- a/apiserver/plane/graphql/queries/issue.py +++ b/apiserver/plane/graphql/queries/issue.py @@ -45,9 +45,16 @@ class IssueQuery: filters: Optional[JSON] = {}, orderBy: Optional[str] = "-created_at", groupBy: Optional[str] = None, + type: Optional[str] = "all", ) -> list[IssueType]: + filters = issue_filters(filters, "POST") + if type == "backlog": + filters["state__group__in"] = ["backlog"] + elif type == "active": + filters["state__group__in"] = ["unstarted", "started"] + issues = await sync_to_async(list)( Issue.issue_objects.filter( workspace__slug=slug, project_id=project @@ -114,7 +121,9 @@ class RecentIssuesQuery: ).filter( project__project_projectmember__member=info.context.user, project__project_projectmember__is_active=True, - )[:5] + )[ + :5 + ] ) return issues diff --git a/apiserver/plane/graphql/queries/module.py b/apiserver/plane/graphql/queries/module.py new file mode 100644 index 0000000000..7b68a12546 --- /dev/null +++ b/apiserver/plane/graphql/queries/module.py @@ -0,0 +1,82 @@ +# Third-Party Imports +import strawberry + +# Python Standard Library Imports +from asgiref.sync import sync_to_async + +# Strawberry Imports +from strawberry.types import Info +from strawberry.permission import PermissionExtension + +# Module Imports +from plane.db.models import Module, Issue +from plane.graphql.types.module import ModuleType +from plane.graphql.types.issue import IssueType +from plane.graphql.permissions.project import ProjectBasePermission + + +@strawberry.type +class ModuleQuery: + @strawberry.field( + extensions=[PermissionExtension(permissions=[ProjectBasePermission()])] + ) + async def modules( + self, + info: Info, + slug: str, + project: strawberry.ID, + ) -> list[ModuleType]: + + modules = await sync_to_async(list)( + Module.objects.filter(workspace__slug=slug) + .filter(project_id=project) + .filter( + project__project_projectmember__member=info.context.user, + project__project_projectmember__is_active=True, + ) + ) + return modules + + @strawberry.field( + extensions=[PermissionExtension(permissions=[ProjectBasePermission()])] + ) + async def module( + self, + info: Info, + slug: str, + project: strawberry.ID, + module: strawberry.ID, + ) -> ModuleType: + module = await sync_to_async(Module.objects.get)( + workspace__slug=slug, + project_id=project, + id=module, + project__project_projectmember__member=info.context.user, + project__project_projectmember__is_active=True, + ) + return module + + +@strawberry.type +class ModuleIssueQuery: + @strawberry.field( + extensions=[PermissionExtension(permissions=[ProjectBasePermission()])] + ) + async def moduleIssues( + self, + info: Info, + slug: str, + project: strawberry.ID, + module: strawberry.ID, + ) -> list[IssueType]: + + module_issues = await sync_to_async(list)( + Issue.issue_objects.filter(workspace__slug=slug) + .filter(project_id=project) + .filter(issue_module__module_id=module) + .filter( + project__project_projectmember__member=info.context.user, + project__project_projectmember__is_active=True, + ) + ) + return module_issues diff --git a/apiserver/plane/graphql/schema.py b/apiserver/plane/graphql/schema.py index f725cb74af..95edd12e24 100644 --- a/apiserver/plane/graphql/schema.py +++ b/apiserver/plane/graphql/schema.py @@ -21,6 +21,8 @@ from .queries.issue import ( IssueCommentActivityQuery, ) from .queries.page import PageQuery +from .queries.cycle import CycleQuery, CycleIssueQuery +from .queries.module import ModuleQuery, ModuleIssueQuery from .queries.search import ProjectSearchQuery from .queries.attachment import IssueAttachmentQuery @@ -39,7 +41,8 @@ from .mutations.issue import ( from .mutations.notification import NotificationMutation from .mutations.user import ProfileMutation from .mutations.page import PageFavoriteMutation - +from .mutations.cycle import CycleIssueMutation +from .mutations.module import ModuleIssueMutation # combined query class for all @strawberry.type @@ -64,6 +67,10 @@ class Query( WorkspaceStateQuery, ProjectSearchQuery, IssueAttachmentQuery, + CycleQuery, + CycleIssueQuery, + ModuleQuery, + ModuleIssueQuery, ): pass @@ -82,6 +89,8 @@ class Mutation( ProfileMutation, PageFavoriteMutation, IssueAttachmentMutation, + CycleIssueMutation, + ModuleIssueMutation, ): pass diff --git a/apiserver/plane/graphql/types/cycle.py b/apiserver/plane/graphql/types/cycle.py new file mode 100644 index 0000000000..59b08ec889 --- /dev/null +++ b/apiserver/plane/graphql/types/cycle.py @@ -0,0 +1,76 @@ +# python imports +from typing import Optional +from datetime import date, datetime + +# Strawberry imports +import strawberry +import strawberry_django +from strawberry.types import Info +from strawberry.scalars import JSON + +# Third-party library imports +from asgiref.sync import sync_to_async + + +# Module Imports +from plane.db.models import Cycle, Issue + + +@strawberry_django.type(Cycle) +class CycleType: + id: strawberry.ID + name: str + description: Optional[str] + start_date: Optional[date] + end_date: Optional[date] + owned_by: strawberry.ID + view_props: Optional[JSON] + sort_order: Optional[float] + external_source: Optional[str] + external_id: Optional[strawberry.ID] + progress_snapshot: Optional[JSON] + archived_at: Optional[datetime] + logo_props: Optional[JSON] + project: strawberry.ID + workspace: strawberry.ID + created_by: strawberry.ID + updated_by: strawberry.ID + created_at: datetime + updated_at: datetime + total_issues: int + completed_issues: int + + @strawberry.field + def owned_by(self) -> int: + return self.owned_by_id + + @strawberry.field + def project(self) -> int: + return self.project_id + + @strawberry.field + def workspace(self) -> int: + return self.workspace_id + + @strawberry.field + def created_by(self) -> int: + return self.created_by_id + + @strawberry.field + async def total_issues(self, info: Info) -> int: + total_issues = await sync_to_async( + lambda: Issue.issue_objects.filter( + issue_cycle__cycle_id=self.id + ).count() + )() + return total_issues + + @strawberry.field + async def completed_issues(self, info: Info) -> int: + total_issues = await sync_to_async( + lambda: Issue.issue_objects.filter( + issue_cycle__cycle_id=self.id, + state__group="completed" + ).count() + )() + return total_issues diff --git a/apiserver/plane/graphql/types/issue.py b/apiserver/plane/graphql/types/issue.py index 2c2496324c..740916b71e 100644 --- a/apiserver/plane/graphql/types/issue.py +++ b/apiserver/plane/graphql/types/issue.py @@ -9,6 +9,8 @@ from asgiref.sync import sync_to_async import strawberry import strawberry_django from strawberry.scalars import JSON +from strawberry.types import Info + # Module Imports from plane.db.models import ( @@ -16,6 +18,8 @@ from plane.db.models import ( IssueUserProperty, IssueActivity, IssueComment, + CycleIssue, + ModuleIssue, ) @@ -48,6 +52,8 @@ class IssueType: created_at: datetime updated_at: datetime sequence_id: int + cycle: Optional[strawberry.ID] + modules: Optional[list[strawberry.ID]] @strawberry.field def state(self) -> int: @@ -83,13 +89,28 @@ class IssueType: labels = await sync_to_async(list)(self.labels.all()) return [label.id for label in labels] - # @strawberry.field - # async def attachments(self) -> list[JSON]: - # return await sync_to_async(list) list( - # IssueAttachment.objects.filter(issue_id=self.id).values( - # "id", "attributes", "asset" - # ) - # ) + @strawberry.field + async def cycle(self, info: Info) -> strawberry.ID: + cycle_issue = await sync_to_async( + CycleIssue.objects.filter(issue_id=self.id).first + )() + if cycle_issue: + return str(cycle_issue.cycle_id) + return None + + @strawberry.field + async def modules(self, info: Info) -> list[strawberry.ID]: + # Fetch related module IDs in a synchronous context + module_issues = await sync_to_async( + lambda: list( + ModuleIssue.objects.filter(issue_id=self.id).values_list( + "module_id", flat=True + ) + ) + )() + + # Return the module IDs as strings + return [str(module_id) for module_id in module_issues] @strawberry_django.type(IssueUserProperty) diff --git a/apiserver/plane/graphql/types/module.py b/apiserver/plane/graphql/types/module.py new file mode 100644 index 0000000000..141da5a496 --- /dev/null +++ b/apiserver/plane/graphql/types/module.py @@ -0,0 +1,72 @@ +# python imports +from typing import Optional +from datetime import date, datetime + +# Strawberry imports +import strawberry +import strawberry_django +from strawberry.types import Info +from strawberry.scalars import JSON + +# Module Imports +from plane.db.models import Module, Issue + +# Third-party library imports +from asgiref.sync import sync_to_async + +@strawberry_django.type(Module) +class ModuleType: + name: str + status: str + id: strawberry.ID + project: strawberry.ID + workspace: strawberry.ID + created_by: strawberry.ID + updated_by: strawberry.ID + created_at: datetime + updated_at: datetime + description: Optional[str] + description_text: Optional[str] + description_html: Optional[str] + start_date: Optional[date] + target_date: Optional[date] + lead: Optional[strawberry.ID] + members: Optional[list[strawberry.ID]] + view_props: Optional[JSON] + sort_order: float + external_source: Optional[str] + external_id: Optional[strawberry.ID] + archived_at: Optional[datetime] + logo_props: Optional[JSON] + total_issues: int + completed_issues: int + + @strawberry.field + def project(self) -> int: + return self.project_id + + @strawberry.field + def workspace(self) -> int: + return self.workspace_id + + @strawberry.field + def created_by(self) -> int: + return self.created_by_id + + @strawberry.field + async def total_issues(self, info: Info) -> int: + total_issues = await sync_to_async( + lambda: Issue.issue_objects.filter( + issue_module__module_id=self.id + ).count() + )() + return total_issues + + @strawberry.field + async def completed_issues(self, info: Info) -> int: + total_issues = await sync_to_async( + lambda: Issue.issue_objects.filter( + issue_module__module_id=self.id, state__group="completed" + ).count() + )() + return total_issues diff --git a/apiserver/plane/graphql/types/project.py b/apiserver/plane/graphql/types/project.py index a24803ec90..5e74d89804 100644 --- a/apiserver/plane/graphql/types/project.py +++ b/apiserver/plane/graphql/types/project.py @@ -5,10 +5,14 @@ from datetime import datetime # Strawberry imports import strawberry import strawberry_django +from strawberry.types import Info from strawberry.scalars import JSON # Module imports -from plane.db.models import Project, ProjectMember +from plane.db.models import Project, ProjectMember, Issue + +# Third-party library imports +from asgiref.sync import sync_to_async @strawberry_django.type(Project) @@ -37,6 +41,8 @@ class ProjectType: archived_at: Optional[datetime] is_member: bool is_favorite: bool + total_members: int + total_issues: int @strawberry.field def workspace(self) -> int: @@ -58,6 +64,22 @@ class ProjectType: def default_state(self) -> int: return self.default_state_id + @strawberry.field + async def total_members(self, info: strawberry.Info) -> int: + projects = await sync_to_async( + lambda: ProjectMember.objects.filter( + project_id=self.id, is_active=True + ).count() + )() + return projects + + @strawberry.field + async def total_issues(self, info: Info) -> int: + projects = await sync_to_async( + lambda: Issue.issue_objects.filter(project_id=self.id).count() + )() + return projects + @strawberry_django.type(ProjectMember) class ProjectMemberType: diff --git a/apiserver/plane/payment/flags/flag.py b/apiserver/plane/payment/flags/flag.py index 2f6b967616..ecc80b941c 100644 --- a/apiserver/plane/payment/flags/flag.py +++ b/apiserver/plane/payment/flags/flag.py @@ -28,3 +28,5 @@ class FeatureFlag(Enum): ISSUE_TYPE_SETTINGS = "ISSUE_TYPE_SETTINGS" # Issue Worklog ISSUE_WORKLOG = "ISSUE_WORKLOG" + # Project Grouping + PROJECT_GROUPING = "PROJECT_GROUPING" diff --git a/apiserver/plane/payment/flags/provider.py b/apiserver/plane/payment/flags/provider.py index 340d99449c..2b7d5ead08 100644 --- a/apiserver/plane/payment/flags/provider.py +++ b/apiserver/plane/payment/flags/provider.py @@ -54,12 +54,12 @@ class FlagProvider(AbstractProvider): evaluation_context, ): # Get the targeting key and attributes from the evaluation context - targetting_key = evaluation_context.targeting_key + targeting_key = evaluation_context.targeting_key attributes = evaluation_context.attributes slug = attributes.get("slug") # Get the value of the feature flag value = self.make_request( - user_id=targetting_key, + user_id=targeting_key, slug=slug, feature_key=flag_key, default_value=default_value, diff --git a/packages/editor/src/ee/extensions/index.ts b/packages/editor/src/ee/extensions/index.ts index 63b87a556a..e62449a05d 100644 --- a/packages/editor/src/ee/extensions/index.ts +++ b/packages/editor/src/ee/extensions/index.ts @@ -1,3 +1,4 @@ export * from "./ai-features"; export * from "./issue-suggestions"; export * from "./document-extensions"; +export * from "./ai-features"; diff --git a/web/app/[workspaceSlug]/(projects)/settings/project-states/page.tsx b/web/app/[workspaceSlug]/(projects)/settings/project-states/page.tsx new file mode 100644 index 0000000000..78f8a8b770 --- /dev/null +++ b/web/app/[workspaceSlug]/(projects)/settings/project-states/page.tsx @@ -0,0 +1,96 @@ +"use client"; + +import React from "react"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +// component +import { ToggleSwitch } from "@plane/ui"; +import { PageHead } from "@/components/core"; +// constants +import { EUserWorkspaceRoles } from "@/constants/workspace"; +// store hooks +import { useUser, useWorkspace } from "@/hooks/store"; +// plane web components +import { WithFeatureFlagHOC } from "@/plane-web/components/feature-flags"; +import { + WorkspaceProjectStatesUpgrade, + WorkspaceProjectStatesRoot, +} from "@/plane-web/components/workspace-project-states"; +// plane web hooks +import { useFlag, useWorkspaceFeatures } from "@/plane-web/hooks/store"; +import { E_FEATURE_FLAGS } from "@/plane-web/hooks/store/use-flag"; +import { EWorkspaceFeatures } from "@/plane-web/types/workspace-feature"; + +const WorklogsPage = observer(() => { + // router + const { workspaceSlug } = useParams(); + // store hooks + const { + membership: { currentWorkspaceRole }, + } = useUser(); + const { currentWorkspace } = useWorkspace(); + const { isWorkspaceFeatureEnabled, updateWorkspaceFeature } = useWorkspaceFeatures(); + const isFeatureEnabled = useFlag(workspaceSlug?.toString(), E_FEATURE_FLAGS.PROJECT_GROUPING); + + // derived values + const pageTitle = currentWorkspace?.name ? `${currentWorkspace.name} - Project States` : undefined; + const isAdmin = currentWorkspaceRole === EUserWorkspaceRoles.ADMIN; + const isProjectGroupingEnabled = isWorkspaceFeatureEnabled(EWorkspaceFeatures.IS_PROJECT_GROUPING_ENABLED); + + if (!workspaceSlug || !currentWorkspace?.id) return <>; + + if (!isAdmin) + return ( + <> + +
+

You are not authorized to access this page.

+
+ + ); + + const toggleProjectGroupingFeature = async () => { + try { + const payload = { + [EWorkspaceFeatures.IS_PROJECT_GROUPING_ENABLED]: !isProjectGroupingEnabled, + }; + await updateWorkspaceFeature(workspaceSlug.toString(), payload); + } catch (error) { + console.error(error); + } + }; + + return ( + <> + + } + workspaceSlug={workspaceSlug?.toString()} + > +
+
+
+

See progress overview for all projects.

+ + State Of Projects is a Plane-only feature for tracking progress of all your projects by any project + property. + +
+ {isFeatureEnabled && ( + + )} +
+ +
+
+ + ); +}); + +export default WorklogsPage; diff --git a/web/core/components/workspace/sidebar/workspace-menu.tsx b/web/core/components/workspace/sidebar/workspace-menu.tsx index 9d4495de86..6b52d99b05 100644 --- a/web/core/components/workspace/sidebar/workspace-menu.tsx +++ b/web/core/components/workspace/sidebar/workspace-menu.tsx @@ -79,7 +79,6 @@ export const SidebarWorkspaceMenu = observer(() => { ref={actionSectionRef} className="grid place-items-center p-0.5 text-custom-sidebar-text-400 hover:bg-custom-sidebar-background-80 rounded my-auto" onClick={() => { - console.log("ndkn"); setIsMenuActive(!isMenuActive); }} > diff --git a/web/core/layouts/auth-layout/workspace-wrapper.tsx b/web/core/layouts/auth-layout/workspace-wrapper.tsx index 934f88d6c4..2a03f1b6c9 100644 --- a/web/core/layouts/auth-layout/workspace-wrapper.tsx +++ b/web/core/layouts/auth-layout/workspace-wrapper.tsx @@ -15,7 +15,7 @@ import { useMember, useProject, useUser, useWorkspace } from "@/hooks/store"; import { useFavorite } from "@/hooks/store/use-favorite"; import { usePlatformOS } from "@/hooks/use-platform-os"; // plane web hooks -import { useFlag, useIssueTypes } from "@/plane-web/hooks/store"; +import { useFlag, useIssueTypes, useWorkspaceFeatures, useWorkspaceProjectStates } from "@/plane-web/hooks/store"; import { useFeatureFlags } from "@/plane-web/hooks/store/use-feature-flags"; // images import PlaneBlackLogo from "@/public/plane-logos/black-horizontal-with-blue-logo.png"; @@ -41,6 +41,8 @@ export const WorkspaceAuthWrapper: FC = observer((props) } = useMember(); const { workspaces } = useWorkspace(); const { fetchFeatureFlags } = useFeatureFlags(); + const { fetchWorkspaceFeatures } = useWorkspaceFeatures(); + const { fetchProjectStates } = useWorkspaceProjectStates(); const { fetchAllIssueTypes } = useIssueTypes(); const { isMobile } = usePlatformOS(); @@ -55,6 +57,19 @@ export const WorkspaceAuthWrapper: FC = observer((props) workspaceSlug ? () => fetchFeatureFlags(workspaceSlug.toString()) : null, { revalidateOnFocus: false, errorRetryCount: 1 } ); + // fetch project states + useSWR( + workspaceSlug && currentWorkspace ? `WORKSPACE_WORKLOGS_${workspaceSlug}` : null, + () => (workspaceSlug && currentWorkspace ? fetchProjectStates(workspaceSlug.toString()) : null), + { revalidateOnFocus: false } + ); + + // fetching workspace features + useSWR( + workspaceSlug && currentWorkspace ? `WORKSPACE_FEATURES_${workspaceSlug}` : null, + workspaceSlug && currentWorkspace ? () => fetchWorkspaceFeatures(workspaceSlug.toString()) : null, + { revalidateOnFocus: false } + ); // fetching user workspace information useSWR( diff --git a/web/ee/components/projects/applied-filters/access.tsx b/web/ee/components/projects/applied-filters/access.tsx new file mode 100644 index 0000000000..625c4d9978 --- /dev/null +++ b/web/ee/components/projects/applied-filters/access.tsx @@ -0,0 +1,36 @@ +import { observer } from "mobx-react"; +import { X } from "lucide-react"; +// constants +import { NETWORK_CHOICES } from "@/constants/project"; + +type Props = { + handleRemove: (val: string) => void; + values: string[]; + editable: boolean | undefined; +}; + +export const AppliedAccessFilters: React.FC = observer((props) => { + const { handleRemove, values, editable } = props; + + return ( + <> + {values.map((status) => { + const accessDetails = NETWORK_CHOICES.find((s) => `${s.key}` === status); + return ( +
+ {accessDetails?.label} + {editable && ( + + )} +
+ ); + })} + + ); +}); diff --git a/web/ee/components/projects/applied-filters/date.tsx b/web/ee/components/projects/applied-filters/date.tsx new file mode 100644 index 0000000000..ca9032c1d4 --- /dev/null +++ b/web/ee/components/projects/applied-filters/date.tsx @@ -0,0 +1,55 @@ +import { observer } from "mobx-react"; +import { X } from "lucide-react"; +// helpers +import { PROJECT_CREATED_AT_FILTER_OPTIONS } from "@/constants/filters"; +import { renderFormattedDate } from "@/helpers/date-time.helper"; +import { capitalizeFirstLetter } from "@/helpers/string.helper"; +// constants + +type Props = { + editable: boolean | undefined; + handleRemove: (val: string) => void; + values: string[]; +}; + +export const AppliedDateFilters: React.FC = observer((props) => { + const { editable, handleRemove, values } = props; + + const getDateLabel = (value: string): string => { + let dateLabel = ""; + + const dateDetails = PROJECT_CREATED_AT_FILTER_OPTIONS.find((d) => d.value === value); + + if (dateDetails) dateLabel = dateDetails.name; + else { + const dateParts = value.split(";"); + + if (dateParts.length === 2) { + const [date, time] = dateParts; + + dateLabel = `${capitalizeFirstLetter(time)} ${renderFormattedDate(date)}`; + } + } + + return dateLabel; + }; + + return ( + <> + {values.map((date) => ( +
+ {getDateLabel(date)} + {editable && ( + + )} +
+ ))} + + ); +}); diff --git a/web/ee/components/projects/applied-filters/index.ts b/web/ee/components/projects/applied-filters/index.ts new file mode 100644 index 0000000000..85bcda446c --- /dev/null +++ b/web/ee/components/projects/applied-filters/index.ts @@ -0,0 +1,5 @@ +export * from "./access"; +export * from "./date"; +export * from "./members"; +export * from "./project-display-filters"; +export * from "./root"; diff --git a/web/ee/components/projects/applied-filters/members.tsx b/web/ee/components/projects/applied-filters/members.tsx new file mode 100644 index 0000000000..7c8bde6b06 --- /dev/null +++ b/web/ee/components/projects/applied-filters/members.tsx @@ -0,0 +1,48 @@ +"use client"; + +import { observer } from "mobx-react"; +import { X } from "lucide-react"; +// ui +import { Avatar } from "@plane/ui"; +// types +import { useMember } from "@/hooks/store"; + +type Props = { + handleRemove: (val: string) => void; + values: string[]; + editable: boolean | undefined; +}; + +export const AppliedMembersFilters: React.FC = observer((props) => { + const { handleRemove, values, editable } = props; + // store hooks + const { + workspace: { getWorkspaceMemberDetails }, + } = useMember(); + + return ( + <> + {values.map((memberId) => { + const memberDetails = getWorkspaceMemberDetails(memberId)?.member; + + if (!memberDetails) return null; + + return ( +
+ + {memberDetails.display_name} + {editable && ( + + )} +
+ ); + })} + + ); +}); diff --git a/web/ee/components/projects/applied-filters/project-display-filters.tsx b/web/ee/components/projects/applied-filters/project-display-filters.tsx new file mode 100644 index 0000000000..ce76c23f8e --- /dev/null +++ b/web/ee/components/projects/applied-filters/project-display-filters.tsx @@ -0,0 +1,39 @@ +import { observer } from "mobx-react"; +// icons +import { X } from "lucide-react"; +// types +import { TProjectAppliedDisplayFilterKeys } from "@plane/types"; +// constants +import { PROJECT_DISPLAY_FILTER_OPTIONS } from "@/constants/project"; + +type Props = { + handleRemove: (key: TProjectAppliedDisplayFilterKeys) => void; + values: TProjectAppliedDisplayFilterKeys[]; + editable: boolean | undefined; +}; + +export const AppliedProjectDisplayFilters: React.FC = observer((props) => { + const { handleRemove, values, editable } = props; + + return ( + <> + {values.map((key) => { + const filterLabel = PROJECT_DISPLAY_FILTER_OPTIONS.find((s) => s.key === key)?.label; + return ( +
+ {filterLabel} + {editable && ( + + )} +
+ ); + })} + + ); +}); diff --git a/web/ee/components/projects/applied-filters/root.tsx b/web/ee/components/projects/applied-filters/root.tsx new file mode 100644 index 0000000000..d7f40e1372 --- /dev/null +++ b/web/ee/components/projects/applied-filters/root.tsx @@ -0,0 +1,141 @@ +"use client"; + +import { X } from "lucide-react"; +// types +import { TProjectAppliedDisplayFilterKeys, TProjectFilters } from "@plane/types"; +// ui +import { Tooltip } from "@plane/ui"; +// components +import { + AppliedAccessFilters, + AppliedDateFilters, + AppliedMembersFilters, + AppliedProjectDisplayFilters, +} from "@/components/project"; +// helpers +import { replaceUnderscoreIfSnakeCase } from "@/helpers/string.helper"; + +type Props = { + appliedFilters: TProjectFilters; + appliedDisplayFilters: TProjectAppliedDisplayFilterKeys[]; + handleClearAllFilters: () => void; + handleRemoveFilter: (key: keyof TProjectFilters, value: string | null) => void; + handleRemoveDisplayFilter: (key: TProjectAppliedDisplayFilterKeys) => void; + alwaysAllowEditing?: boolean; + filteredProjects: number; + totalProjects: number; +}; + +const MEMBERS_FILTERS = ["lead", "members"]; +const DATE_FILTERS = ["created_at"]; + +export const ProjectAppliedFiltersList: React.FC = (props) => { + const { + appliedFilters, + appliedDisplayFilters, + handleClearAllFilters, + handleRemoveFilter, + handleRemoveDisplayFilter, + alwaysAllowEditing, + filteredProjects, + totalProjects, + } = props; + + if (!appliedFilters && !appliedDisplayFilters) return null; + if (Object.keys(appliedFilters).length === 0 && appliedDisplayFilters.length === 0) return null; + + const isEditingAllowed = alwaysAllowEditing; + + return ( +
+
+ {/* Applied filters */} + {Object.entries(appliedFilters ?? {}).map(([key, value]) => { + const filterKey = key as keyof TProjectFilters; + + if (!value) return; + if (Array.isArray(value) && value.length === 0) return; + + return ( +
+
+ {replaceUnderscoreIfSnakeCase(filterKey)} + {filterKey === "access" && ( + handleRemoveFilter("access", val)} + values={value} + /> + )} + {DATE_FILTERS.includes(filterKey) && ( + handleRemoveFilter(filterKey, val)} + values={value} + /> + )} + {MEMBERS_FILTERS.includes(filterKey) && ( + handleRemoveFilter(filterKey, val)} + values={value} + /> + )} + {isEditingAllowed && ( + + )} +
+
+ ); + })} + {/* Applied display filters */} + {appliedDisplayFilters.length > 0 && ( +
+
+ Projects + handleRemoveDisplayFilter(key)} + /> +
+
+ )} + {isEditingAllowed && ( + + )} +
+ + {filteredProjects} of{" "} + {totalProjects} projects match the applied filters. +

+ } + > + + {filteredProjects}/{totalProjects} + +
+
+ ); +}; diff --git a/web/ee/components/projects/create/attributes.tsx b/web/ee/components/projects/create/attributes.tsx index 6528de7561..c8e59a96c5 100644 --- a/web/ee/components/projects/create/attributes.tsx +++ b/web/ee/components/projects/create/attributes.tsx @@ -1 +1,171 @@ -export * from "ce/components/projects/create/attributes"; +import { Controller, useFormContext } from "react-hook-form"; +import { IWorkspace } from "@plane/types"; +import { CustomSelect } from "@plane/ui"; +import { DateRangeDropdown, MemberDropdown, PriorityDropdown } from "@/components/dropdowns"; +import { NETWORK_CHOICES } from "@/constants/project"; +import { renderFormattedPayloadDate, getDate } from "@/helpers/date-time.helper"; +import { useWorkspaceProjectStates } from "@/plane-web/hooks/store"; +import { TProject } from "@/plane-web/types/projects"; +import { StateDropdown } from "../dropdowns"; +import MembersDropdown from "../dropdowns/members-dropdown"; + +type Props = { + workspaceSlug: string; + currentWorkspace: IWorkspace; + isProjectGroupingEnabled: boolean; + data?: Partial; +}; +const ProjectAttributes: React.FC = (props) => { + const { workspaceSlug, currentWorkspace, isProjectGroupingEnabled, data } = props; + const { control } = useFormContext(); + const { defaultState } = useWorkspaceProjectStates(); + + return ( +
+ {isProjectGroupingEnabled && ( + ( + + )} + /> + )} + { + const currentNetwork = NETWORK_CHOICES.find((n) => n.key === value); + + return ( +
+ + {currentNetwork ? ( + <> + + {currentNetwork.label} + + ) : ( + Select network + )} +
+ } + placement="bottom-start" + className="h-full" + buttonClassName="h-full" + noChevron + tabIndex={4} + > + {NETWORK_CHOICES.map((network) => ( + +
+ +
+

{network.label}

+

{network.description}

+
+
+
+ ))} + +
+ ); + }} + /> + {isProjectGroupingEnabled && ( + ( + ( + { + console.log({ val }); + onChangeStartDate(val?.from ? renderFormattedPayloadDate(val.from) : null); + onChangeEndDate(val?.to ? renderFormattedPayloadDate(val.to) : null); + }} + placeholder={{ + from: "Start date", + to: "End date", + }} + hideIcon={{ + to: true, + }} + tabIndex={3} + /> + )} + /> + )} + /> + )} + {isProjectGroupingEnabled && ( + ( +
+ { + onChange(priority); + }} + buttonVariant="border-with-text" + /> +
+ )} + /> + )} + { + if (value === undefined || value === null || typeof value === "string") + return ( +
+ onChange(lead === value ? null : lead)} + placeholder="Lead" + multiple={false} + buttonVariant="border-with-text" + tabIndex={5} + /> +
+ ); + else return <>; + }} + /> + {isProjectGroupingEnabled && ( + ( + + )} + /> + )} + + ); +}; +export default ProjectAttributes; diff --git a/web/ee/components/projects/create/root.tsx b/web/ee/components/projects/create/root.tsx index 28663aefeb..be21ebe140 100644 --- a/web/ee/components/projects/create/root.tsx +++ b/web/ee/components/projects/create/root.tsx @@ -1 +1,183 @@ -export * from "ce/components/projects/create/root"; +"use client"; + +import { useState, FC } from "react"; +import { observer } from "mobx-react"; +import { useForm, FormProvider } from "react-hook-form"; +// ui +import { Button, setToast, TOAST_TYPE } from "@plane/ui"; +// constants +import ProjectCommonAttributes from "@/components/project/create/common-attributes"; +import ProjectCreateHeader from "@/components/project/create/header"; +import { PROJECT_CREATED } from "@/constants/event-tracker"; +import { EUserProjectRoles, PROJECT_UNSPLASH_COVERS } from "@/constants/project"; +// helpers +import { getRandomEmoji } from "@/helpers/emoji.helper"; +// hooks +import { useEventTracker, useMember, useProject, useUser, useWorkspace } from "@/hooks/store"; +import { usePlatformOS } from "@/hooks/use-platform-os"; +import { useWorkspaceFeatures } from "@/plane-web/hooks/store"; +import { TProject } from "@/plane-web/types/projects"; +import { EWorkspaceFeatures } from "@/plane-web/types/workspace-feature"; +import ProjectAttributes from "./attributes"; + +type Props = { + setToFavorite?: boolean; + workspaceSlug: string; + onClose: () => void; + handleNextStep: (projectId: string) => void; + data?: Partial; +}; + +const defaultValues: Partial = { + cover_image: PROJECT_UNSPLASH_COVERS[Math.floor(Math.random() * PROJECT_UNSPLASH_COVERS.length)], + description: "", + logo_props: { + in_use: "emoji", + emoji: { + value: getRandomEmoji(), + }, + }, + identifier: "", + name: "", + network: 2, + project_lead: null, +}; + +export const CreateProjectForm: FC = observer((props) => { + const { setToFavorite, workspaceSlug, onClose, handleNextStep, data } = props; + // store + const { captureProjectEvent } = useEventTracker(); + const { addProjectToFavorites, createProject } = useProject(); + // states + const [isChangeInIdentifierRequired, setIsChangeInIdentifierRequired] = useState(true); + const { currentWorkspace } = useWorkspace(); + const { + memberMap, + project: { bulkAddMembersToProject }, + } = useMember(); + const { data: currentUser } = useUser(); + const { isWorkspaceFeatureEnabled } = useWorkspaceFeatures(); + + const isProjectGroupingEnabled = isWorkspaceFeatureEnabled(EWorkspaceFeatures.IS_PROJECT_GROUPING_ENABLED); + + // form info + const methods = useForm({ + defaultValues: { ...defaultValues, ...data }, + reValidateMode: "onChange", + }); + const { + formState: { isSubmitting }, + handleSubmit, + reset, + setValue, + } = methods; + const { isMobile } = usePlatformOS(); + const handleAddToFavorites = (projectId: string) => { + if (!workspaceSlug) return; + + addProjectToFavorites(workspaceSlug.toString(), projectId).catch(() => { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: "Couldn't remove the project from favorites. Please try again.", + }); + }); + }; + + const onSubmit = async (formData: Partial) => { + // Upper case identifier + const members = + formData.members && + formData.members + .filter((id: any) => currentUser && currentUser.id !== id) + .map((id: any) => ({ + id, + member_id: id, + role: EUserProjectRoles.MEMBER, + member__avatar: memberMap[id]?.avatar, + member__display_name: memberMap[id]?.display_name, + })); + formData.identifier = formData.identifier?.toUpperCase(); + if (members) formData.members = members; + + return createProject(workspaceSlug.toString(), formData) + .then((res) => { + const newPayload = { + ...res, + state: "SUCCESS", + }; + isProjectGroupingEnabled && + members && + bulkAddMembersToProject(workspaceSlug.toString(), res.id, { + members, + }); + captureProjectEvent({ + eventName: PROJECT_CREATED, + payload: newPayload, + }); + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Success!", + message: "Project created successfully.", + }); + if (setToFavorite) { + handleAddToFavorites(res.id); + } + handleNextStep(res.id); + }) + .catch((err) => { + Object.keys(err.data).map((key) => { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: err.data[key], + }); + captureProjectEvent({ + eventName: PROJECT_CREATED, + payload: { + ...formData, + state: "FAILED", + }, + }); + }); + }); + }; + const handleClose = () => { + onClose(); + setIsChangeInIdentifierRequired(true); + setTimeout(() => { + reset(); + }, 300); + }; + if (!currentWorkspace) return null; + return ( + + +
+
+ + +
+ +
+ + +
+
+
+ ); +}); diff --git a/web/ee/components/projects/dropdowns/index.ts b/web/ee/components/projects/dropdowns/index.ts new file mode 100644 index 0000000000..99cfabe369 --- /dev/null +++ b/web/ee/components/projects/dropdowns/index.ts @@ -0,0 +1,3 @@ +export * from "./scope-dropdown"; +export * from "./priority-dropdown"; +export * from "./state-dropdown"; diff --git a/web/ee/components/projects/dropdowns/members-dropdown.tsx b/web/ee/components/projects/dropdowns/members-dropdown.tsx new file mode 100644 index 0000000000..8183cbb038 --- /dev/null +++ b/web/ee/components/projects/dropdowns/members-dropdown.tsx @@ -0,0 +1,42 @@ +import { Users } from "lucide-react"; +import { cn } from "@plane/editor"; +import { MemberDropdown } from "@/components/dropdowns"; + +type Props = { + value: string[]; + onChange: (assigneeIds: string[]) => void; + buttonClassName?: string; + className?: string; + disabled?: boolean; +}; +const MembersDropdown: React.FC = (props) => { + const { value, onChange, disabled = false, buttonClassName = "", className = "" } = props; + const DropdownLabel = () => ( +
+ + {value ? value.length : "Members"} +
+ ); + return ( + { + onChange(assigneeIds); + }} + buttonClassName={cn({ "hover:bg-transparent": value?.length > 0 }, buttonClassName)} + placeholder="Members" + button={} + className={className} + disabled={disabled} + buttonVariant="border-with-text" + multiple + /> + ); +}; + +export default MembersDropdown; diff --git a/web/ee/components/projects/dropdowns/priority-dropdown.tsx b/web/ee/components/projects/dropdowns/priority-dropdown.tsx new file mode 100644 index 0000000000..98642684ef --- /dev/null +++ b/web/ee/components/projects/dropdowns/priority-dropdown.tsx @@ -0,0 +1,481 @@ +"use client"; + +import { Fragment, ReactNode, useRef, useState } from "react"; +import { useTheme } from "next-themes"; +import { usePopper } from "react-popper"; +import { Check, ChevronDown, Search, SignalHigh } from "lucide-react"; +import { Combobox } from "@headlessui/react"; +// types +// ui +import { PriorityIcon, Tooltip } from "@plane/ui"; +// constants +// helpers +import { + BACKGROUND_BUTTON_VARIANTS, + BORDER_BUTTON_VARIANTS, + BUTTON_VARIANTS_WITHOUT_TEXT, +} from "@/components/dropdowns/constants"; +import { TDropdownProps } from "@/components/dropdowns/types"; +import { cn } from "@/helpers/common.helper"; +// hooks +import { useDropdown } from "@/hooks/use-dropdown"; +import { usePlatformOS } from "@/hooks/use-platform-os"; +import { PROJECT_PRIORITIES } from "@/plane-web/constants/project"; +import { TProjectPriority } from "@/plane-web/types/workspace-project-filters"; +import { EProjectPriority } from "@/plane-web/types/workspace-project-states"; +// constants +// types + +type Props = TDropdownProps & { + button?: ReactNode; + dropdownArrow?: boolean; + dropdownArrowClassName?: string; + highlightUrgent?: boolean; + onChange: (val: TProjectPriority) => void; + onClose?: () => void; + value: TProjectPriority | undefined | null; +}; + +type ButtonProps = { + className?: string; + dropdownArrow: boolean; + dropdownArrowClassName: string; + hideIcon?: boolean; + hideText?: boolean; + isActive?: boolean; + highlightUrgent: boolean; + placeholder: string; + priority: TProjectPriority | undefined; + showTooltip: boolean; +}; + +const BorderButton = (props: ButtonProps) => { + const { + className, + dropdownArrow, + dropdownArrowClassName, + hideIcon = false, + hideText = false, + highlightUrgent, + placeholder, + priority, + showTooltip, + } = props; + + const priorityDetails = PROJECT_PRIORITIES.find((p) => p.key === priority); + + const priorityClasses = { + urgent: "bg-red-500/20 text-red-950 border-red-500", + high: "bg-orange-500/20 text-orange-950 border-orange-500", + medium: "bg-yellow-500/20 text-yellow-950 border-yellow-500", + low: "bg-custom-primary-100/20 text-custom-primary-950 border-custom-primary-100", + none: "hover:bg-custom-background-80 border-custom-border-300", + }; + + const { isMobile } = usePlatformOS(); + + return ( + + + + ); +}; + +const BackgroundButton = (props: ButtonProps) => { + const { + className, + dropdownArrow, + dropdownArrowClassName, + hideIcon = false, + hideText = false, + highlightUrgent, + placeholder, + priority, + showTooltip, + } = props; + + const priorityDetails = PROJECT_PRIORITIES.find((p) => p.key === priority); + + const priorityClasses = { + urgent: "bg-red-500/20 text-red-950", + high: "bg-orange-500/20 text-orange-950", + medium: "bg-yellow-500/20 text-yellow-950", + low: "bg-blue-500/20 text-blue-950", + none: "bg-custom-background-80", + }; + + const { isMobile } = usePlatformOS(); + + return ( + + + + ); +}; + +const TransparentButton = (props: ButtonProps) => { + const { + className, + dropdownArrow, + dropdownArrowClassName, + hideIcon = false, + hideText = false, + isActive = false, + highlightUrgent, + placeholder, + priority, + showTooltip, + } = props; + + const priorityDetails = PROJECT_PRIORITIES.find((p) => p.key === priority); + + const priorityClasses = { + urgent: "text-red-950", + high: "text-orange-950", + medium: "text-yellow-950", + low: "text-blue-950", + none: "hover:text-custom-text-300", + }; + + const { isMobile } = usePlatformOS(); + + return ( + + + + ); +}; + +export const PriorityDropdown: React.FC = (props) => { + const { + button, + buttonClassName, + buttonContainerClassName, + buttonVariant, + className = "", + disabled = false, + dropdownArrow = false, + dropdownArrowClassName = "", + hideIcon = false, + highlightUrgent = true, + onChange, + onClose, + placeholder = "Priority", + placement, + showTooltip = false, + tabIndex, + value = EProjectPriority.NONE, + } = props; + // states + const [query, setQuery] = useState(""); + const [isOpen, setIsOpen] = useState(false); + // refs + const dropdownRef = useRef(null); + const inputRef = useRef(null); + // popper-js refs + const [referenceElement, setReferenceElement] = useState(null); + const [popperElement, setPopperElement] = useState(null); + // popper-js init + const { styles, attributes } = usePopper(referenceElement, popperElement, { + placement: placement ?? "bottom-start", + modifiers: [ + { + name: "preventOverflow", + options: { + padding: 12, + }, + }, + ], + }); + // next-themes + // TODO: remove this after new theming implementation + const { resolvedTheme } = useTheme(); + + const options = PROJECT_PRIORITIES.map((priority) => ({ + value: priority.key, + query: priority.key, + content: ( +
+ + {priority.label} +
+ ), + })); + + const filteredOptions = + query === "" ? options : options.filter((o) => o.query.toLowerCase().includes(query.toLowerCase())); + + const dropdownOnChange = (val: TProjectPriority) => { + onChange(val); + handleClose(); + }; + + const { handleClose, handleKeyDown, handleOnClick, searchInputKeyDown } = useDropdown({ + dropdownRef, + inputRef, + isOpen, + onClose, + query, + setIsOpen, + setQuery, + }); + + const ButtonToRender = BORDER_BUTTON_VARIANTS.includes(buttonVariant) + ? BorderButton + : BACKGROUND_BUTTON_VARIANTS.includes(buttonVariant) + ? BackgroundButton + : TransparentButton; + + return ( + + + {button ? ( + + ) : ( + + )} + + {isOpen && ( + +
+
+ + setQuery(e.target.value)} + placeholder="Search" + displayValue={(assigned: any) => assigned?.name} + onKeyDown={searchInputKeyDown} + /> +
+
+ {filteredOptions.length > 0 ? ( + filteredOptions.map((option) => ( + + `w-full truncate flex items-center justify-between gap-2 rounded px-1 py-1.5 cursor-pointer select-none ${ + active ? "bg-custom-background-80" : "" + } ${selected ? "text-custom-text-100" : "text-custom-text-200"}` + } + > + {({ selected }) => ( + <> + {option.content} + {selected && } + + )} + + )) + ) : ( +

No matching results

+ )} +
+
+
+ )} +
+ ); +}; diff --git a/web/ee/components/projects/dropdowns/scope-dropdown.tsx b/web/ee/components/projects/dropdowns/scope-dropdown.tsx new file mode 100644 index 0000000000..764aca3645 --- /dev/null +++ b/web/ee/components/projects/dropdowns/scope-dropdown.tsx @@ -0,0 +1,75 @@ +"use client"; + +import { FC } from "react"; +import { observer } from "mobx-react"; +import { ChevronDown } from "lucide-react"; +import { cn } from "@plane/editor"; +import { CustomMenu } from "@plane/ui"; +// plane web constants +import { PROJECT_SCOPES } from "@/plane-web/constants/project"; +// plane web hooks +import { useProjectFilter } from "@/plane-web/hooks/store"; +// plane web types +import { EProjectScope } from "@/plane-web/types/workspace-project-filters"; + +export type TProjectScopeDropdown = { + workspaceSlug: string; + className?: string; +}; + +export const ProjectScopeDropdown: FC = observer((props) => { + const { workspaceSlug, className } = props; + // hooks + const { scopeProjectsCount, filters, updateScope } = useProjectFilter(); + + // derived values + const selectedScope = filters?.scope || EProjectScope.ALL_PROJECTS; + const selectedScopeCount = scopeProjectsCount?.[selectedScope]; + + const DropdownLabel = () => ( + <> +
+
+ {(PROJECT_SCOPES || []).find((scope) => selectedScope === scope.key)?.label} +
+
+ {selectedScopeCount} +
+
+
+ {(PROJECT_SCOPES || []).find((scope) => selectedScope === scope.key)?.label} + +
+ + ); + + const DropdownOptions = () => + (PROJECT_SCOPES || []).map((scope) => ( + updateScope(workspaceSlug, scope.key)} + > +
{scope?.label}
+
+ {scopeProjectsCount?.[scope.key]} +
+
+ )); + + return ( + } + customButtonClassName="flex flex-grow justify-center" + closeOnSelect + > + + + ); +}); diff --git a/web/ee/components/projects/dropdowns/state-dropdown.tsx b/web/ee/components/projects/dropdowns/state-dropdown.tsx new file mode 100644 index 0000000000..0b829aa861 --- /dev/null +++ b/web/ee/components/projects/dropdowns/state-dropdown.tsx @@ -0,0 +1,60 @@ +"use client"; + +import { FC } from "react"; +import { observer } from "mobx-react"; +import { CustomSearchSelect } from "@plane/ui"; +// plane web hooks +import { useWorkspaceProjectStates } from "@/plane-web/hooks/store"; +import { ProjectStateIcon } from "../../workspace-project-states"; + +export type TStateDropdown = { + workspaceSlug: string; + workspaceId: string; + onChange: (stateId: string) => void; + buttonClassName?: string; + className?: string; + value: string; + disabled: boolean; +}; + +export const StateDropdown: FC = observer((props) => { + const { workspaceId, onChange, value, disabled, buttonClassName = "", className = "" } = props; + // hooks + const { getProjectStateIdsByWorkspaceId, getProjectStateById } = useWorkspaceProjectStates(); + + // derived values + const projectStateIds = getProjectStateIdsByWorkspaceId(workspaceId); + const selectedState = getProjectStateById(value); + const dropdownLabel = () => ( +
+ {" "} + {selectedState?.name} +
+ ); + const dropdownOptions = (projectStateIds || []).map((stateId) => { + const state = getProjectStateById(stateId); + return { + value: state?.id, + query: `${state?.name} ${state?.group}`, + content: ( +
+ {" "} + {state?.name} +
+ ), + }; + }); + + return ( + + ); +}); diff --git a/web/ee/components/projects/header.tsx b/web/ee/components/projects/header.tsx deleted file mode 100644 index 983c3d046b..0000000000 --- a/web/ee/components/projects/header.tsx +++ /dev/null @@ -1 +0,0 @@ -export * from "ce/components/projects/header"; diff --git a/web/ee/components/projects/header/attributes-dropdown/index.ts b/web/ee/components/projects/header/attributes-dropdown/index.ts new file mode 100644 index 0000000000..f177cd3617 --- /dev/null +++ b/web/ee/components/projects/header/attributes-dropdown/index.ts @@ -0,0 +1,4 @@ +export * from "./root"; +export * from "./priority"; +export * from "./state"; +export * from "./users"; diff --git a/web/ee/components/projects/header/attributes-dropdown/priority.tsx b/web/ee/components/projects/header/attributes-dropdown/priority.tsx new file mode 100644 index 0000000000..880af90a6d --- /dev/null +++ b/web/ee/components/projects/header/attributes-dropdown/priority.tsx @@ -0,0 +1,66 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { observer } from "mobx-react"; +import { PriorityIcon } from "@plane/ui"; +// components +import { FilterHeader, FilterOption } from "@/components/issues"; +// plane web constants +import { PROJECT_PRIORITIES } from "@/plane-web/constants/project"; +// plane web types +import { TProjectPriority } from "@/plane-web/types/workspace-project-filters"; + +type TFilterPriority = { + searchQuery: string; + appliedFilters: TProjectPriority[] | null; + handleUpdate: (val: TProjectPriority[]) => void; +}; + +export const FilterPriority: React.FC = observer((props) => { + const { searchQuery, appliedFilters, handleUpdate } = props; + // states + const [previewEnabled, setPreviewEnabled] = useState(true); + const appliedFiltersCount = appliedFilters?.length ?? 0; + + const sortedOptions = useMemo( + () => + PROJECT_PRIORITIES.filter((priority) => priority.key.includes(searchQuery.toLowerCase()) || searchQuery === ""), + // eslint-disable-next-line react-hooks/exhaustive-deps + [searchQuery] + ); + + const handleFilter = (val: TProjectPriority) => { + if (appliedFilters?.includes(val)) { + handleUpdate(appliedFilters.filter((priority) => priority !== val)); + } else { + handleUpdate([...(appliedFilters ?? []), val]); + } + }; + + return ( + <> + 0 ? ` (${appliedFiltersCount})` : ""}`} + isPreviewEnabled={previewEnabled} + handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)} + /> + {previewEnabled && ( +
+ {sortedOptions.length > 0 ? ( + sortedOptions.map((priority) => ( + handleFilter(priority.key)} + icon={} + title={priority.label} + /> + )) + ) : ( +

No matches found

+ )} +
+ )} + + ); +}); diff --git a/web/ee/components/projects/header/attributes-dropdown/root.tsx b/web/ee/components/projects/header/attributes-dropdown/root.tsx new file mode 100644 index 0000000000..845c0fd5d2 --- /dev/null +++ b/web/ee/components/projects/header/attributes-dropdown/root.tsx @@ -0,0 +1,96 @@ +" use client"; + +import { FC, useState } from "react"; +import { observer } from "mobx-react"; +import { ChevronDown, ListFilter, Search, X } from "lucide-react"; +// components +import { FiltersDropdown } from "@/components/issues"; +// plane web components +import { FilterPriority, FilterState, FilterUser } from "@/plane-web/components/projects"; +// plane web hooks +import { useProjectFilter } from "@/plane-web/hooks/store"; + +type TProjectAttributesDropdown = { + workspaceSlug: string; + workspaceId: string; + menuButton?: React.ReactNode; +}; + +export const ProjectAttributesDropdown: FC = observer((props) => { + const { workspaceSlug, workspaceId, menuButton } = props; + // hooks + const { appliedAttributesCount, filters, updateAttributes } = useProjectFilter(); + // states + const [filtersSearchQuery, setFiltersSearchQuery] = useState(""); + + // derived values + const isFiltersApplied = appliedAttributesCount > 0 ? true : false; + + return ( + } + title="Filters" + placement="bottom-end" + isFiltersApplied={isFiltersApplied} + menuButton={menuButton} + > +
+
+
+ + setFiltersSearchQuery(e.target.value)} + autoFocus + /> + {filtersSearchQuery !== "" && ( + + )} +
+
+
+ {/* priority */} +
+ updateAttributes(workspaceSlug, "priority", val)} + /> +
+ {/* state */} +
+ updateAttributes(workspaceSlug, "state", val)} + /> +
+ {/* lead */} +
+ updateAttributes(workspaceSlug, "lead", val)} + /> +
+ {/* members */} +
+ updateAttributes(workspaceSlug, "members", val)} + /> +
+
+
+
+ ); +}); diff --git a/web/ee/components/projects/header/attributes-dropdown/state.tsx b/web/ee/components/projects/header/attributes-dropdown/state.tsx new file mode 100644 index 0000000000..9f0de1bedf --- /dev/null +++ b/web/ee/components/projects/header/attributes-dropdown/state.tsx @@ -0,0 +1,101 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { observer } from "mobx-react"; +// hooks +import { FilterHeader, FilterOption } from "@/components/issues"; +// plane web components +import { ProjectStateIcon } from "@/plane-web/components/workspace-project-states"; +// plane web hooks +import { useWorkspaceProjectStates } from "@/plane-web/hooks/store"; + +type TFilterState = { + workspaceId: string; + searchQuery: string; + appliedFilters: string[] | null; + handleUpdate: (val: string[]) => void; +}; + +export const FilterState: React.FC = observer((props) => { + const { workspaceId, searchQuery, appliedFilters, handleUpdate } = props; + // hooks + const { getProjectStatesByWorkspaceId } = useWorkspaceProjectStates(); + // states + const [itemsToRender, setItemsToRender] = useState(5); + const [previewEnabled, setPreviewEnabled] = useState(true); + + // derived values + const appliedFiltersCount = appliedFilters?.length ?? 0; + const workspaceProjectStates = getProjectStatesByWorkspaceId(workspaceId); + + const sortedOptions = useMemo( + () => + (workspaceProjectStates ?? []).filter( + (state) => + (state.name || "").includes(searchQuery.toLowerCase()) || + (state.group || "").includes(searchQuery.toLowerCase()) || + searchQuery === "" + ), + // eslint-disable-next-line react-hooks/exhaustive-deps + [searchQuery] + ); + + const handleViewToggle = () => { + if (!sortedOptions) return; + if (itemsToRender === sortedOptions.length) setItemsToRender(5); + else setItemsToRender(sortedOptions.length); + }; + + const handleFilter = (val: string) => { + if (appliedFilters?.includes(val)) { + handleUpdate(appliedFilters.filter((priority) => priority !== val)); + } else { + handleUpdate([...(appliedFilters ?? []), val]); + } + }; + + return ( + <> + 0 ? ` (${appliedFiltersCount})` : ""}`} + isPreviewEnabled={previewEnabled} + handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)} + /> + {previewEnabled && ( +
+ {sortedOptions.length > 0 ? ( + <> + {sortedOptions + .slice(0, itemsToRender) + .map( + (state) => + state.id && + state.group && + state.name && ( + state.id && handleFilter(state.id)} + icon={} + title={state.name.charAt(0).toUpperCase() + state.name.slice(1)} + /> + ) + )} + {sortedOptions.length > 5 && ( + + )} + + ) : ( +

No matches found

+ )} +
+ )} + + ); +}); diff --git a/web/ee/components/projects/header/attributes-dropdown/users.tsx b/web/ee/components/projects/header/attributes-dropdown/users.tsx new file mode 100644 index 0000000000..5e3f04fba5 --- /dev/null +++ b/web/ee/components/projects/header/attributes-dropdown/users.tsx @@ -0,0 +1,111 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { observer } from "mobx-react"; +import { Avatar } from "@plane/ui"; +// components +import { FilterHeader, FilterOption } from "@/components/issues"; +// hooks +import { useMember } from "@/hooks/store"; + +type TFilterUser = { + filterTitle: string; + searchQuery: string; + appliedFilters: string[] | null; + handleUpdate: (val: string[]) => void; +}; + +export const FilterUser: React.FC = observer((props) => { + const { filterTitle = "Users", searchQuery, appliedFilters, handleUpdate } = props; + // hooks + const { + workspace: { workspaceMemberIds, getWorkspaceMemberDetails }, + } = useMember(); + // states + const [itemsToRender, setItemsToRender] = useState(5); + const [previewEnabled, setPreviewEnabled] = useState(true); + + // derived values + const appliedFiltersCount = appliedFilters?.length ?? 0; + const workspaceMembers = (workspaceMemberIds || []) + .map((id) => { + const member = getWorkspaceMemberDetails(id); + if (member) { + return member?.member; + } else { + return undefined; + } + }) + .filter((member) => member !== undefined); + + const sortedOptions = useMemo( + () => + (workspaceMembers ?? []).filter( + (member) => + (member?.display_name || "").includes(searchQuery.toLowerCase()) || + (member?.first_name || "").includes(searchQuery.toLowerCase()) || + (member?.last_name || "").includes(searchQuery.toLowerCase()) || + searchQuery === "" + ), + // eslint-disable-next-line react-hooks/exhaustive-deps + [searchQuery] + ); + + const handleViewToggle = () => { + if (!sortedOptions) return; + if (itemsToRender === sortedOptions.length) setItemsToRender(5); + else setItemsToRender(sortedOptions.length); + }; + + const handleFilter = (val: string) => { + if (appliedFilters?.includes(val)) { + handleUpdate(appliedFilters.filter((priority) => priority !== val)); + } else { + handleUpdate([...(appliedFilters ?? []), val]); + } + }; + + return ( + <> + 0 ? ` (${appliedFiltersCount})` : ""}`} + isPreviewEnabled={previewEnabled} + handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)} + /> + {previewEnabled && ( +
+ {sortedOptions.length > 0 ? ( + <> + {sortedOptions + .slice(0, itemsToRender) + .map( + (member) => + member.id && + member.display_name && ( + member.id && handleFilter(member.id)} + icon={} + title={member.display_name.charAt(0).toUpperCase() + member.display_name.slice(1)} + /> + ) + )} + {sortedOptions.length > 5 && ( + + )} + + ) : ( +

No matches found

+ )} +
+ )} + + ); +}); diff --git a/web/ee/components/projects/header/create-project-button.tsx b/web/ee/components/projects/header/create-project-button.tsx new file mode 100644 index 0000000000..6cfc6a3862 --- /dev/null +++ b/web/ee/components/projects/header/create-project-button.tsx @@ -0,0 +1,35 @@ +"use client"; + +import { FC } from "react"; +import { observer } from "mobx-react"; +import { Button } from "@plane/ui"; +// constants +import { EUserWorkspaceRoles } from "@/constants/workspace"; +// hooks +import { useCommandPalette, useEventTracker, useUser } from "@/hooks/store"; + +export const ProjectCreateButton: FC = observer((props) => { + const {} = props; + // hooks + const { setTrackElement } = useEventTracker(); + const { toggleCreateProjectModal } = useCommandPalette(); + const { + membership: { currentWorkspaceRole }, + } = useUser(); + + const isAuthorizedUser = !!currentWorkspaceRole && currentWorkspaceRole >= EUserWorkspaceRoles.MEMBER; + + if (!isAuthorizedUser) return <>; + return ( + + ); +}); diff --git a/web/ee/components/projects/header/display-filters-dropdown/group-by.tsx b/web/ee/components/projects/header/display-filters-dropdown/group-by.tsx new file mode 100644 index 0000000000..1fdd8c9bf3 --- /dev/null +++ b/web/ee/components/projects/header/display-filters-dropdown/group-by.tsx @@ -0,0 +1,42 @@ +import React, { useState } from "react"; +import { observer } from "mobx-react"; +// components +import { FilterHeader, FilterOption } from "@/components/issues"; +// constants +import { PROJECT_GROUP_BY_OPTIONS } from "@/plane-web/constants/project"; +// types +import { TProjectGroupBy } from "@/plane-web/types/workspace-project-filters"; + +type TDisplayFilterGroupBy = { + filterValue: TProjectGroupBy | undefined; + handleUpdate: (val: TProjectGroupBy) => void; +}; + +export const DisplayFilterGroupBy: React.FC = observer((props) => { + const { filterValue, handleUpdate } = props; + + const [previewEnabled, setPreviewEnabled] = useState(true); + + return ( + <> + setPreviewEnabled(!previewEnabled)} + /> + {previewEnabled && ( +
+ {PROJECT_GROUP_BY_OPTIONS.map((groupBy) => ( + handleUpdate(groupBy.key)} + title={groupBy.title} + multiple={false} + /> + ))} +
+ )} + + ); +}); diff --git a/web/ee/components/projects/header/display-filters-dropdown/index.ts b/web/ee/components/projects/header/display-filters-dropdown/index.ts new file mode 100644 index 0000000000..e6b12655f6 --- /dev/null +++ b/web/ee/components/projects/header/display-filters-dropdown/index.ts @@ -0,0 +1,4 @@ +export * from "./root"; +export * from "./group-by"; +export * from "./sort-by"; +export * from "./sort-order"; diff --git a/web/ee/components/projects/header/display-filters-dropdown/root.tsx b/web/ee/components/projects/header/display-filters-dropdown/root.tsx new file mode 100644 index 0000000000..bc110604f4 --- /dev/null +++ b/web/ee/components/projects/header/display-filters-dropdown/root.tsx @@ -0,0 +1,54 @@ +" use client"; + +import { FC } from "react"; +import { observer } from "mobx-react"; +// components +import { FiltersDropdown } from "@/components/issues"; +// plane web components +import { DisplayFilterGroupBy, DisplayFilterSortBy, DisplayFilterSortOrder } from "@/plane-web/components/projects"; +// plane web hooks +import { useProjectFilter } from "@/plane-web/hooks/store"; +import { EProjectLayouts } from "@/plane-web/types/workspace-project-filters"; + +type TProjectDisplayFiltersDropdown = { + workspaceSlug: string; + menuButton?: React.ReactNode; +}; + +export const ProjectDisplayFiltersDropdown: FC = observer((props) => { + const { workspaceSlug, menuButton } = props; + // hooks + const { filters, updateDisplayFilters } = useProjectFilter(); + return ( +
+ +
+ {/* group by */} + {filters?.layout === EProjectLayouts.BOARD && ( +
+ updateDisplayFilters(workspaceSlug, "group_by", val)} + /> +
+ )} + + {/* sort by */} +
+ updateDisplayFilters(workspaceSlug, "sort_by", val)} + /> +
+ {/* order by */} +
+ updateDisplayFilters(workspaceSlug, "sort_order", val)} + /> +
+
+
+
+ ); +}); diff --git a/web/ee/components/projects/header/display-filters-dropdown/sort-by.tsx b/web/ee/components/projects/header/display-filters-dropdown/sort-by.tsx new file mode 100644 index 0000000000..082e17daf6 --- /dev/null +++ b/web/ee/components/projects/header/display-filters-dropdown/sort-by.tsx @@ -0,0 +1,42 @@ +import React, { useState } from "react"; +import { observer } from "mobx-react"; +// components +import { FilterHeader, FilterOption } from "@/components/issues"; +// constants +import { PROJECT_SORT_BY_OPTIONS } from "@/plane-web/constants/project"; +// types +import { TProjectSortBy } from "@/plane-web/types/workspace-project-filters"; + +type TDisplayFilterSortBy = { + filterValue: TProjectSortBy | undefined; + handleUpdate: (val: TProjectSortBy) => void; +}; + +export const DisplayFilterSortBy: React.FC = observer((props) => { + const { filterValue, handleUpdate } = props; + + const [previewEnabled, setPreviewEnabled] = useState(true); + + return ( + <> + setPreviewEnabled(!previewEnabled)} + /> + {previewEnabled && ( +
+ {PROJECT_SORT_BY_OPTIONS.map((groupBy) => ( + handleUpdate(groupBy.key)} + title={groupBy.title} + multiple={false} + /> + ))} +
+ )} + + ); +}); diff --git a/web/ee/components/projects/header/display-filters-dropdown/sort-order.tsx b/web/ee/components/projects/header/display-filters-dropdown/sort-order.tsx new file mode 100644 index 0000000000..d358bbceba --- /dev/null +++ b/web/ee/components/projects/header/display-filters-dropdown/sort-order.tsx @@ -0,0 +1,42 @@ +import React, { useState } from "react"; +import { observer } from "mobx-react"; +// components +import { FilterHeader, FilterOption } from "@/components/issues"; +// constants +import { PROJECT_SORT_ORDER_OPTIONS } from "@/plane-web/constants/project"; +// types +import { TProjectSortOrder } from "@/plane-web/types/workspace-project-filters"; + +type TDisplayFilterSortOrder = { + filterValue: TProjectSortOrder | undefined; + handleUpdate: (val: TProjectSortOrder) => void; +}; + +export const DisplayFilterSortOrder: React.FC = observer((props) => { + const { filterValue, handleUpdate } = props; + + const [previewEnabled, setPreviewEnabled] = useState(true); + + return ( + <> + setPreviewEnabled(!previewEnabled)} + /> + {previewEnabled && ( +
+ {PROJECT_SORT_ORDER_OPTIONS.map((groupBy) => ( + handleUpdate(groupBy.key)} + title={groupBy.title} + multiple={false} + /> + ))} +
+ )} + + ); +}); diff --git a/web/ee/components/projects/header/index.ts b/web/ee/components/projects/header/index.ts new file mode 100644 index 0000000000..8e453de3b8 --- /dev/null +++ b/web/ee/components/projects/header/index.ts @@ -0,0 +1,6 @@ +export * from "./root"; +export * from "./search-projects"; +export * from "./layout-selection"; +export * from "./attributes-dropdown"; +export * from "./display-filters-dropdown"; +export * from "./create-project-button"; diff --git a/web/ee/components/projects/header/layout-selection.tsx b/web/ee/components/projects/header/layout-selection.tsx new file mode 100644 index 0000000000..c6ee2e4263 --- /dev/null +++ b/web/ee/components/projects/header/layout-selection.tsx @@ -0,0 +1,70 @@ +"use client"; + +import { FC } from "react"; +import { observer } from "mobx-react"; +import { CustomMenu, Tooltip } from "@plane/ui"; +// hooks +import { usePlatformOS } from "@/hooks/use-platform-os"; +// plane web constants +import { PROJECT_LAYOUTS } from "@/plane-web/constants/project"; +// plane web hooks +import { useProjectFilter } from "@/plane-web/hooks/store"; +// plane web types +import { EProjectLayouts } from "@/plane-web/types/workspace-project-filters"; + +type TProjectLayoutSelection = { + workspaceSlug: string; +}; + +export const ProjectLayoutSelection: FC = observer((props) => { + const { workspaceSlug } = props; + // hooks + const { filters, updateLayout } = useProjectFilter(); + const { isMobile } = usePlatformOS(); + + // derived values + const selectedLayout = filters?.layout || EProjectLayouts.TABLE; + + return ( + <> + Layout} + customButtonClassName="flex flex-grow justify-center text-custom-text-200 text-sm" + closeOnSelect + > + {PROJECT_LAYOUTS.map((layout, index) => ( + updateLayout(workspaceSlug.toString(), layout.key)} + className="flex items-center gap-2" + > + +
{layout.title}
+
+ ))} +
+
+ {PROJECT_LAYOUTS.map((layout) => ( + + + + ))} +
+ + ); +}); diff --git a/web/ee/components/projects/header/root.tsx b/web/ee/components/projects/header/root.tsx new file mode 100644 index 0000000000..dea86104cd --- /dev/null +++ b/web/ee/components/projects/header/root.tsx @@ -0,0 +1,77 @@ +"use client"; + +import { observer } from "mobx-react"; +import { useParams, usePathname } from "next/navigation"; +import { Briefcase } from "lucide-react"; +import { Breadcrumbs } from "@plane/ui"; +// components +import { BreadcrumbLink } from "@/components/common"; +// hooks +import { ProjectsBaseHeader } from "@/components/project/header"; +import { useWorkspace } from "@/hooks/store"; +// plane web components +import { + ProjectAttributesDropdown, + ProjectCreateButton, + ProjectDisplayFiltersDropdown, + ProjectLayoutSelection, + ProjectScopeDropdown, + ProjectSearch, +} from "@/plane-web/components/projects/"; +import { useWorkspaceFeatures } from "@/plane-web/hooks/store"; +import { EWorkspaceFeatures } from "@/plane-web/types/workspace-feature"; + +export const ProjectsListHeader = observer(() => { + const { workspaceSlug } = useParams(); + // hooks + const { currentWorkspace } = useWorkspace(); + const { isWorkspaceFeatureEnabled } = useWorkspaceFeatures(); + const pathname = usePathname(); + + // derived values + const workspaceId = currentWorkspace?.id || undefined; + const isProjectGroupingEnabled = isWorkspaceFeatureEnabled(EWorkspaceFeatures.IS_PROJECT_GROUPING_ENABLED); + const isArchived = pathname.includes("/archives"); + + if (!workspaceSlug || !workspaceId) return <>; + return isProjectGroupingEnabled ? ( +
+ {/* flex-row items-center justify-between gap-x-2 gap-y-4 */} +
+
+ {/* bread crumps */} + + } />} + /> + {isArchived && } />} + + {/* scope dropdown */} + {!isArchived && ( +
+ +
+ )} +
+
+ {/* search */} + + +
+ {/* layout selection */} + {!isArchived && }{" "} + {/* attributes dropdown */} + + {/* display filters dropdown */} + +
+ {/* create project button */} + +
+
+
+ ) : ( + + ); +}); diff --git a/web/ee/components/projects/header/search-projects.tsx b/web/ee/components/projects/header/search-projects.tsx new file mode 100644 index 0000000000..384308d2f4 --- /dev/null +++ b/web/ee/components/projects/header/search-projects.tsx @@ -0,0 +1,77 @@ +"use client"; + +import { FC, useRef, useState } from "react"; +import { observer } from "mobx-react"; +import { Search, X } from "lucide-react"; +// helpers +import { cn } from "@/helpers/common.helper"; +// plane web hooks +import { useProjectFilter } from "@/plane-web/hooks/store"; + +type TProjectSearch = { + workspaceSlug: string; +}; + +export const ProjectSearch: FC = observer((props) => { + const {} = props; + // hooks + const { searchQuery, updateSearchQuery } = useProjectFilter(); + // refs + const inputRef = useRef(null); + // states + const [isSearchOpen, setIsSearchOpen] = useState(false); + + const handleInputKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Escape") { + if (searchQuery && searchQuery.trim() !== "") updateSearchQuery(""); + else setIsSearchOpen(false); + } + }; + + return ( +
+ {!isSearchOpen && ( + + )} +
+ + updateSearchQuery(e.target.value)} + onKeyDown={handleInputKeyDown} + /> + {isSearchOpen && ( + + )} +
+
+ ); +}); diff --git a/web/ee/components/projects/index.ts b/web/ee/components/projects/index.ts new file mode 100644 index 0000000000..8cf5111c08 --- /dev/null +++ b/web/ee/components/projects/index.ts @@ -0,0 +1,6 @@ +export * from "./dropdowns"; +export * from "./header"; + +export * from "./root"; + +export * from "./layouts"; diff --git a/web/ee/components/projects/layouts/board/group-drag-overlay.tsx b/web/ee/components/projects/layouts/board/group-drag-overlay.tsx new file mode 100644 index 0000000000..3d94547767 --- /dev/null +++ b/web/ee/components/projects/layouts/board/group-drag-overlay.tsx @@ -0,0 +1,67 @@ +import { AlertCircle } from "lucide-react"; +import { TIssueOrderByOptions } from "@plane/types"; +import { ISSUE_ORDER_BY_OPTIONS } from "@/constants/issue"; +import { cn } from "@/helpers/common.helper"; + +type Props = { + dragColumnOrientation: "justify-start" | "justify-center" | "justify-end"; + canOverlayBeVisible: boolean; + isDropDisabled: boolean; + dropErrorMessage?: string; + orderBy?: TIssueOrderByOptions | undefined; + isDraggingOverColumn: boolean; +}; + +export const GroupDragOverlay = (props: Props) => { + const { + dragColumnOrientation, + canOverlayBeVisible, + isDropDisabled, + dropErrorMessage, + orderBy, + isDraggingOverColumn, + } = props; + + const shouldOverlayBeVisible = isDraggingOverColumn && canOverlayBeVisible; + const readableOrderBy = orderBy && ISSUE_ORDER_BY_OPTIONS.find((orderByObj) => orderByObj.key === orderBy)?.title; + + return ( +
+
+ {dropErrorMessage ? ( +
+   + {dropErrorMessage} +
+ ) : ( + <> + {readableOrderBy && ( + + The layout is ordered by {readableOrderBy}. + + )} + Drop here to move the project. + + )} +
+
+ ); +}; diff --git a/web/ee/components/projects/layouts/board/group-item-header/index.ts b/web/ee/components/projects/layouts/board/group-item-header/index.ts new file mode 100644 index 0000000000..1efe34c51e --- /dev/null +++ b/web/ee/components/projects/layouts/board/group-item-header/index.ts @@ -0,0 +1 @@ +export * from "./root"; diff --git a/web/ee/components/projects/layouts/board/group-item-header/root.tsx b/web/ee/components/projects/layouts/board/group-item-header/root.tsx new file mode 100644 index 0000000000..7e0657288d --- /dev/null +++ b/web/ee/components/projects/layouts/board/group-item-header/root.tsx @@ -0,0 +1,99 @@ +"use client"; + +import { FC, useState } from "react"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +import { Maximize2, Minimize2, Plus } from "lucide-react"; +// hooks +import { CreateProjectModal } from "@/components/project"; +import { useMember, useWorkspace } from "@/hooks/store"; + +// plane web hooks +import { useProjectFilter, useWorkspaceProjectStates } from "@/plane-web/hooks/store"; +// plane web types + +import { groupDetails } from "../utils"; + +type TProjectBoardGroupItemHeader = { + groupByKey: string; + projectIds: string[]; + verticalAlign: boolean; + setVerticalAlign: (value: boolean) => void; +}; + +// Todo: Vertical Align +export const ProjectBoardGroupItemHeader: FC = observer((props) => { + const { groupByKey, projectIds, verticalAlign } = props; + //states + const [open, setOpen] = useState(false); + // hooks + const { filters } = useProjectFilter(); + const { getProjectStateById, getProjectStatedByStateGroupKey } = useWorkspaceProjectStates(); + const { + workspace: { getWorkspaceMemberDetails }, + } = useMember(); + const { workspaceSlug } = useParams(); + const { currentWorkspace } = useWorkspace(); + + // derived values + const selectedGroupKey = filters?.display_filters?.group_by; + + const details = groupDetails( + getProjectStateById, + getProjectStatedByStateGroupKey, + getWorkspaceMemberDetails, + groupByKey, + currentWorkspace, + selectedGroupKey + ); + + return ( + <> + setOpen(false)} + workspaceSlug={workspaceSlug.toString()} + data={details?.prePopulatedPayload} + /> +
+
+ {details?.icon} +
+ +
+
+ {details?.title} +
+
+ {projectIds?.length || 0} +
+
+ +
setVerticalAlign(!verticalAlign)} + > + {verticalAlign ? : } +
+
setOpen(true)} + className="cursor-pointer flex-shrink-0 w-5 h-5 rounded hover:bg-custom-background-80 flex justify-center items-center overflow-hidden bg-custom-background-80" + > + +
+
+ + ); +}); diff --git a/web/ee/components/projects/layouts/board/group-item.tsx b/web/ee/components/projects/layouts/board/group-item.tsx new file mode 100644 index 0000000000..4f1e4e5a05 --- /dev/null +++ b/web/ee/components/projects/layouts/board/group-item.tsx @@ -0,0 +1,111 @@ +"use client"; + +import { FC, useEffect, useRef, useState } from "react"; +// plane web components +import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine"; +import { dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter"; +import { autoScrollForElements } from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/element"; +import { observer } from "mobx-react"; +import { setToast, TOAST_TYPE } from "@plane/ui"; +import { useMember, useProject, useWorkspace } from "@/hooks/store"; +import { ProjectBoardGroupItemHeader, ProjectBoardList } from "@/plane-web/components/projects/layouts/board"; +import { useProjectFilter, useWorkspaceProjectStates } from "@/plane-web/hooks/store"; +import { TProject } from "@/plane-web/types/projects"; +import { GroupDragOverlay } from "./group-drag-overlay"; +import { groupDetails, highlightProjectOnDrop } from "./utils"; + +type ProjectBoardGroupItem = { + groupByKey: string; + projectIds: string[]; + verticalAlign: boolean; + setVerticalAlign: (value: boolean) => void; + dropErrorMessage?: string; +}; + +export const ProjectBoardGroupItem: FC = observer((props) => { + const { groupByKey, projectIds, verticalAlign, setVerticalAlign, dropErrorMessage = "" } = props; + const [isDraggingOverColumn, setIsDraggingOverColumn] = useState(false); + const columnRef = useRef(null); + const { currentWorkspace } = useWorkspace(); + const { getProjectStateById, getProjectStatedByStateGroupKey } = useWorkspaceProjectStates(); + const { + workspace: { getWorkspaceMemberDetails }, + } = useMember(); + const { filters } = useProjectFilter(); + const { updateProject } = useProject(); + + const selectedGroupKey = filters?.display_filters?.group_by; + + const handleOnDrop = (sourceId: string, payload: Partial) => { + if (!currentWorkspace?.slug) + return setToast({ type: TOAST_TYPE.ERROR, title: "Error!", message: "Workspace not found" }); + + updateProject(currentWorkspace?.slug, sourceId, payload); + highlightProjectOnDrop(sourceId, true); + }; + + // Enable Kanban Columns as Drop Targets + useEffect(() => { + const element = columnRef.current; + if (!element) return; + + return combine( + dropTargetForElements({ + element, + getData: () => ({ groupByKey, type: "COLUMN" }), + onDragEnter: () => { + setIsDraggingOverColumn(true); + }, + onDragLeave: () => { + setIsDraggingOverColumn(false); + }, + onDragStart: () => { + setIsDraggingOverColumn(true); + }, + onDrop: (payload) => { + setIsDraggingOverColumn(false); + console.log("payload", payload); + if (!payload.source) return; + const sourceId = payload.source.data.id; + const details = groupDetails( + getProjectStateById, + getProjectStatedByStateGroupKey, + getWorkspaceMemberDetails, + groupByKey, + currentWorkspace, + selectedGroupKey + ); + handleOnDrop(sourceId as string, details?.prePopulatedPayload); + }, + }), + autoScrollForElements({ + element, + }) + ); + }, [columnRef, groupByKey, setIsDraggingOverColumn, dropErrorMessage, handleOnDrop]); + return ( +
+ +
+ {/* header */} + + {/* projects placeholder */} + {!verticalAlign && projectIds.length > 0 && ( + + )} +
+
+ ); +}); diff --git a/web/ee/components/projects/layouts/board/group.tsx b/web/ee/components/projects/layouts/board/group.tsx new file mode 100644 index 0000000000..2d32f75551 --- /dev/null +++ b/web/ee/components/projects/layouts/board/group.tsx @@ -0,0 +1,38 @@ +"use client"; + +import { FC, useState } from "react"; +import { observer } from "mobx-react"; +// plane web components +import { cn } from "@plane/editor"; +import { ProjectBoardGroupItem } from "@/plane-web/components/projects/layouts/board"; +// plane web types +import { TProjectsBoardLayoutStructure } from "@/plane-web/types/workspace-project-filters"; + +type ProjectBoardGroup = { + groupByProjectIds: TProjectsBoardLayoutStructure; +}; + +export const ProjectBoardGroup: FC = observer((props) => { + const { groupByProjectIds } = props; + const [verticalAlign, setVerticalAlign] = useState(false); + + return ( +
+ {Object.entries(groupByProjectIds)?.map(([groupKey, projectIds]) => ( +
+ +
+ ))} +
+ ); +}); diff --git a/web/ee/components/projects/layouts/board/index.ts b/web/ee/components/projects/layouts/board/index.ts new file mode 100644 index 0000000000..eb37933678 --- /dev/null +++ b/web/ee/components/projects/layouts/board/index.ts @@ -0,0 +1,6 @@ +export * from "./root"; +export * from "./group"; +export * from "./group-item"; +export * from "./group-item-header"; +export * from "./list"; +export * from "./list-item"; diff --git a/web/ee/components/projects/layouts/board/list-item.tsx b/web/ee/components/projects/layouts/board/list-item.tsx new file mode 100644 index 0000000000..50eacb99a7 --- /dev/null +++ b/web/ee/components/projects/layouts/board/list-item.tsx @@ -0,0 +1,69 @@ +"use client"; + +import { FC, useEffect, useRef } from "react"; +import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine"; +import { draggable } from "@atlaskit/pragmatic-drag-and-drop/element/adapter"; +import { observer } from "mobx-react"; +// hooks +import { setToast, TOAST_TYPE } from "@plane/ui"; +import { EUserProjectRoles } from "@/constants/project"; +import { useProject, useUser } from "@/hooks/store"; +// plane web components +import { ProjectCard } from "@/plane-web/components/projects/layouts/gallery/card"; +// plane web types +import { TProject } from "@/plane-web/types/projects"; + +type ProjectBoardListItem = { + groupByKey: string; + projectId: string; +}; + +export const ProjectBoardListItem: FC = observer((props) => { + const { projectId } = props; + // hooks + const { getProjectById } = useProject(); + const { + membership: { currentWorkspaceAllProjectsRole }, + } = useUser(); + // derived values + const project = getProjectById(projectId) as TProject; + const cardRef = useRef(null); + const isDragAllowed = + currentWorkspaceAllProjectsRole && + currentWorkspaceAllProjectsRole[projectId] && + currentWorkspaceAllProjectsRole[projectId] >= EUserProjectRoles.ADMIN; + if (!project) return <>; + + useEffect(() => { + const element = cardRef.current; + + if (!element) return; + + return combine( + draggable({ + element, + dragHandle: element, + canDrag: () => isDragAllowed, + getInitialData: () => ({ id: project.id, type: "PROJECT" }), + }) + ); + }, [cardRef?.current, project, isDragAllowed]); + return ( +
{ + if (!isDragAllowed) { + setToast({ + title: "Warning!", + type: TOAST_TYPE.ERROR, + message: "You don't have permission to change this project", + }); + } + }} + > + +
+ ); +}); diff --git a/web/ee/components/projects/layouts/board/list.tsx b/web/ee/components/projects/layouts/board/list.tsx new file mode 100644 index 0000000000..d364a98740 --- /dev/null +++ b/web/ee/components/projects/layouts/board/list.tsx @@ -0,0 +1,22 @@ +"use client"; + +import { FC } from "react"; +import { observer } from "mobx-react"; +// plane web components +import { ProjectBoardListItem } from "@/plane-web/components/projects/layouts/board"; + +type ProjectBoardList = { groupByKey: string; projectIds: string[] }; + +export const ProjectBoardList: FC = observer((props) => { + const { groupByKey, projectIds } = props; + + return ( +
+ {projectIds.map((projectId) => ( +
+ +
+ ))} +
+ ); +}); diff --git a/web/ee/components/projects/layouts/board/root.tsx b/web/ee/components/projects/layouts/board/root.tsx new file mode 100644 index 0000000000..95088b11b7 --- /dev/null +++ b/web/ee/components/projects/layouts/board/root.tsx @@ -0,0 +1,24 @@ +"use client"; + +import { FC } from "react"; +import { observer } from "mobx-react"; +// plane web components +import { ProjectBoardGroup } from "@/plane-web/components/projects/layouts/board"; +// plane web hooks +import { useProjectFilter } from "@/plane-web/hooks/store"; +import { EProjectLayouts } from "@/plane-web/types/workspace-project-filters"; + +export const ProjectBoardLayout: FC = observer(() => { + // hooks + + const { getFilteredProjectsByLayout } = useProjectFilter(); + + const groupByProjectIds = getFilteredProjectsByLayout(EProjectLayouts.BOARD); + + if (!groupByProjectIds) return <>; + return ( +
+ +
+ ); +}); diff --git a/web/ee/components/projects/layouts/board/utils.tsx b/web/ee/components/projects/layouts/board/utils.tsx new file mode 100644 index 0000000000..0bfcc7c435 --- /dev/null +++ b/web/ee/components/projects/layouts/board/utils.tsx @@ -0,0 +1,87 @@ +// "states" | "state_groups" | "priority" | "created_by"; + +import smoothScrollIntoView from "smooth-scroll-into-view-if-needed"; +import { IWorkspace, IWorkspaceMember } from "@plane/types"; +import { Avatar, PriorityIcon } from "@plane/ui"; +import { ProjectStateIcon } from "@/plane-web/components/workspace-project-states"; +import { PROJECT_PRIORITY_MAP } from "@/plane-web/constants/project"; +import { WORKSPACE_PROJECT_STATE_GROUPS } from "@/plane-web/constants/workspace-project-states"; +import { GroupDetails, TProjectPriority } from "@/plane-web/types/workspace-project-filters"; +import { EProjectStateGroup, TProjectState, TProjectStateGroupKey } from "@/plane-web/types/workspace-project-states"; + +const HIGHLIGHT_CLASS = "highlight"; +const HIGHLIGHT_WITH_LINE = "highlight-with-line"; + +export const highlightProjectOnDrop = ( + elementId: string | undefined, + shouldScrollIntoView = true, + shouldHighLightWithLine = false +) => { + setTimeout(async () => { + const sourceElementId = elementId ?? ""; + const sourceElement = document.getElementById(sourceElementId); + sourceElement?.classList?.add(shouldHighLightWithLine ? HIGHLIGHT_WITH_LINE : HIGHLIGHT_CLASS); + if (shouldScrollIntoView && sourceElement) + await smoothScrollIntoView(sourceElement, { behavior: "smooth", block: "center", duration: 1500 }); + setTimeout(() => { + sourceElement?.classList?.remove(shouldHighLightWithLine ? HIGHLIGHT_WITH_LINE : HIGHLIGHT_CLASS); + }, 2000); + }, 200); +}; +export const groupDetails = ( + getProjectStateById: (projectStateId: string) => TProjectState | undefined, + getProjectStatedByStateGroupKey: ( + workspaceId: string, + groupKey: TProjectStateGroupKey + ) => TProjectState[] | undefined, + getWorkspaceMemberDetails: (workspaceMemberId: string) => IWorkspaceMember | null, + groupByKey: string, + currentWorkspace: IWorkspace | null, + selectedGroupKey: string | undefined +): GroupDetails | undefined => { + switch (selectedGroupKey) { + case "states": { + const state = getProjectStateById(groupByKey); + const groupKey = state?.group; + return { + title: state?.name || "State", + icon: , + prePopulatedPayload: { + state_id: state && state?.id, + }, + }; + } + case "state_groups": { + const groupKey = groupByKey as TProjectStateGroupKey; + const stateGroup = WORKSPACE_PROJECT_STATE_GROUPS[groupKey]; + const states = + currentWorkspace && getProjectStatedByStateGroupKey(currentWorkspace.id, groupByKey as EProjectStateGroup); + return { + title: stateGroup?.title || "State Group", + icon: , + prePopulatedPayload: { + state_id: states && states[0].id, + }, + }; + } + case "priority": { + const priorityKey = groupByKey as TProjectPriority; + const priority = PROJECT_PRIORITY_MAP[priorityKey]; + return { + title: priority?.label || "Priority", + icon: , + prePopulatedPayload: { + priority: priority.key, + }, + }; + } + case "created_by": { + const member = getWorkspaceMemberDetails(groupByKey); + return { + title: member?.display_name || "Created By", + icon: member ? : <>, + prePopulatedPayload: {}, + }; + } + } +}; diff --git a/web/ee/components/projects/layouts/gallery/attributes.tsx b/web/ee/components/projects/layouts/gallery/attributes.tsx new file mode 100644 index 0000000000..31ed83742e --- /dev/null +++ b/web/ee/components/projects/layouts/gallery/attributes.tsx @@ -0,0 +1,147 @@ +import { SyntheticEvent } from "react"; +import { observer } from "mobx-react"; +import { cn } from "@plane/editor"; +import { IWorkspace } from "@plane/types"; +import { Avatar, PriorityIcon, Tooltip } from "@plane/ui"; +import { DateRangeDropdown, MemberDropdown } from "@/components/dropdowns"; +import { EUserProjectRoles } from "@/constants/project"; +import { renderFormattedPayloadDate, getDate } from "@/helpers/date-time.helper"; +import { useMember, useUser } from "@/hooks/store"; +import { TProject } from "@/plane-web/types/projects"; +import { EProjectPriority } from "@/plane-web/types/workspace-project-states"; +import { StateDropdown, PriorityDropdown } from "../../dropdowns"; +import MembersDropdown from "../../dropdowns/members-dropdown"; + +type Props = { + project: TProject; + isArchived: boolean; + handleUpdateProject: (data: Partial) => void; + workspaceSlug: string; + currentWorkspace: IWorkspace; +}; +const Attributes: React.FC = observer((props) => { + const { project, isArchived, handleUpdateProject, workspaceSlug, currentWorkspace } = props; + const projectMembersIds = project.members?.map((member) => member.member_id); + + const { getUserDetails } = useMember(); + const lead = getUserDetails(project.project_lead as string); + const { + membership: { currentWorkspaceAllProjectsRole }, + } = useUser(); + const isEditingAllowed = + currentWorkspaceAllProjectsRole && + currentWorkspaceAllProjectsRole[project.id] && + currentWorkspaceAllProjectsRole[project.id] >= EUserProjectRoles.ADMIN; + + const handleEventPropagation = (e: SyntheticEvent) => { + e.stopPropagation(); + e.preventDefault(); + }; + return ( +
+
+ handleUpdateProject({ state_id: val })} + workspaceSlug={workspaceSlug.toString()} + workspaceId={currentWorkspace.id} + disabled={!isEditingAllowed || isArchived} + buttonClassName={cn( + "z-1 h-5 px-2 py-0 text-left rounded group-[.selected-project-row]:bg-custom-primary-100/5 group-[.selected-project-row]:hover:bg-custom-primary-100/10" + )} + /> +
+
+ handleUpdateProject({ priority: data })} + buttonVariant="border-with-text" + buttonClassName={cn( + "px-4 text-left rounded group-[.selected-project-row]:bg-custom-primary-100/5 group-[.selected-project-row]:hover:bg-custom-primary-100/10" + )} + buttonContainerClassName="w-full" + className="h-5" + disabled={!isEditingAllowed || isArchived} + button={ + + } + /> +
+ +
+ { + handleUpdateProject({ + start_date: val?.from ? renderFormattedPayloadDate(val.from)! : undefined, + target_date: val?.to ? renderFormattedPayloadDate(val.to)! : undefined, + }); + }} + placeholder={{ + from: "Start date", + to: "End date", + }} + hideIcon={{ + to: true, + }} + tabIndex={3} + buttonClassName={cn("z-1 px-2 py-0 h-5")} + className="h-5" + disabled={!isEditingAllowed || isArchived} + /> +
+ + {lead && ( + +
+ handleUpdateProject({ project_lead: val })} + placeholder="Lead" + multiple={false} + buttonVariant="border-with-text" + tabIndex={5} + buttonClassName="z-1 px-2 py-0 h-5" + className="h-5" + disabled={!isEditingAllowed || isArchived} + button={ +
+ +
{lead.first_name}
+
+ } + /> +
+
+ )} + + {projectMembersIds.length > 0 && ( + {}} + className="h-5" + buttonClassName="cursor-not-allowed" + /> + )} +
+ ); +}); +export default Attributes; diff --git a/web/ee/components/projects/layouts/gallery/base-gallery-root.tsx b/web/ee/components/projects/layouts/gallery/base-gallery-root.tsx new file mode 100644 index 0000000000..9f22a5f076 --- /dev/null +++ b/web/ee/components/projects/layouts/gallery/base-gallery-root.tsx @@ -0,0 +1,70 @@ +import { observer } from "mobx-react"; +import Image from "next/image"; +// components +import { EmptyState } from "@/components/empty-state"; +import { ProjectsLoader } from "@/components/ui"; +// constants +import { EmptyStateType } from "@/constants/empty-state"; +// hooks +import { useCommandPalette, useEventTracker, useProject } from "@/hooks/store"; +// assets +import { useProjectFilter } from "@/plane-web/hooks/store/workspace-project-states/use-project-filters"; +import { TProject } from "@/plane-web/types/projects"; +import { EProjectLayouts } from "@/plane-web/types/workspace-project-filters"; +import AllFiltersImage from "@/public/empty-state/project/all-filters.svg"; +import NameFilterImage from "@/public/empty-state/project/name-filter.svg"; +import { ProjectCard } from "./card"; + +export const BaseProjectRoot = observer(() => { + // store hooks + const { toggleCreateProjectModal } = useCommandPalette(); + const { setTrackElement } = useEventTracker(); + const { workspaceProjectIds, getProjectById, loader } = useProject(); + const { searchQuery, getFilteredProjectsByLayout } = useProjectFilter(); + + const filteredProjectIds = getFilteredProjectsByLayout(EProjectLayouts.GALLERY); + + if (!filteredProjectIds || !workspaceProjectIds || loader) return ; + + if (workspaceProjectIds?.length === 0) + return ( + { + setTrackElement("Project empty state"); + toggleCreateProjectModal(true); + }} + /> + ); + + if (filteredProjectIds.length === 0) + return ( +
+
+ No matching projects +
No matching projects
+

+ {searchQuery && searchQuery.trim() === "" + ? "Remove the filters to see all projects" + : "No projects detected with the matching\ncriteria. Create a new project instead"} +

+
+
+ ); + + return ( +
+
+ {filteredProjectIds.map((projectId) => { + const projectDetails = getProjectById(projectId); + if (!projectDetails) return; + return ; + })} +
+
+ ); +}); diff --git a/web/ee/components/projects/layouts/gallery/card.tsx b/web/ee/components/projects/layouts/gallery/card.tsx new file mode 100644 index 0000000000..49573f1d07 --- /dev/null +++ b/web/ee/components/projects/layouts/gallery/card.tsx @@ -0,0 +1,112 @@ +"use client"; + +import React, { useState } from "react"; +import { observer } from "mobx-react"; +import { useParams, usePathname } from "next/navigation"; +import { cn } from "@plane/editor"; +import { Button, ControlLink } from "@plane/ui"; +import { ArchiveRestoreProjectModal, DeleteProjectModal, JoinProjectModal } from "@/components/project"; +// hooks +import { useProject, useWorkspace } from "@/hooks/store"; +// types +import { TProject } from "@/plane-web/types/projects"; +import Attributes from "./attributes"; +import Details from "./details"; + +type Props = { + project: TProject; +}; + +export const ProjectCard: React.FC = observer((props) => { + const { project } = props; + // states + const [deleteProjectModalOpen, setDeleteProjectModal] = useState(false); + const [joinProjectModalOpen, setJoinProjectModal] = useState(false); + const [archiveRestoreProject, setArchiveRestoreProject] = useState(false); + const { workspaceSlug } = useParams(); + const { currentWorkspace } = useWorkspace(); + const pathname = usePathname(); + const { updateProject } = useProject(); + + const isArchived = pathname.includes("/archives"); + const handleUpdateProject = (data: Partial) => { + updateProject(workspaceSlug.toString(), project.id, data); + }; + + if (!currentWorkspace) return null; + return ( + <> + {/* Delete Project Modal */} + setDeleteProjectModal(false)} + /> + {/* Join Project Modal */} + {workspaceSlug && ( + setJoinProjectModal(false)} + /> + )} + {/* Restore project modal */} + {workspaceSlug && project && ( + setArchiveRestoreProject(false)} + archive={!isArchived} + /> + )} + +
+ { + if (!project.is_member || isArchived) { + e.preventDefault(); + e.stopPropagation(); + if (!isArchived) setJoinProjectModal(true); + } + }} + data-prevent-nprogress={!project.is_member || isArchived} + > +
+ + + {!project.is_member && ( + + )} +
+ + ); +}); diff --git a/web/ee/components/projects/layouts/gallery/details.tsx b/web/ee/components/projects/layouts/gallery/details.tsx new file mode 100644 index 0000000000..72130cf788 --- /dev/null +++ b/web/ee/components/projects/layouts/gallery/details.tsx @@ -0,0 +1,212 @@ +import React, { useRef, useState } from "react"; +import { ArchiveIcon, ArchiveRestoreIcon, LinkIcon, Lock, MoreHorizontal, Settings, Trash2 } from "lucide-react"; +// ui +import { cn } from "@plane/editor"; +import { TOAST_TYPE, setToast, setPromiseToast, TContextMenuItem, FavoriteStar, CustomMenu } from "@plane/ui"; +// components +import { Logo } from "@/components/common"; +// constants +import { EUserProjectRoles } from "@/constants/project"; +// helpers +import { copyUrlToClipboard } from "@/helpers/string.helper"; +// hooks +import { useProject } from "@/hooks/store"; +import { useAppRouter } from "@/hooks/use-app-router"; +import { TProject } from "@/plane-web/types/projects"; + +type Props = { + project: TProject; + workspaceSlug: string; + setJoinProjectModal: (value: boolean) => void; + setArchiveRestoreProject: (value: boolean) => void; + setDeleteProjectModal: (value: boolean) => void; +}; +const Details: React.FC = (props) => { + const { project, workspaceSlug, setArchiveRestoreProject, setDeleteProjectModal } = props; + //state + const [isMenuActive, setIsMenuActive] = useState(false); + // store hooks + const { addProjectToFavorites, removeProjectFromFavorites } = useProject(); + // router + const router = useAppRouter(); + // refs + const projectCardRef = useRef(null); + // auth + const isOwner = project.member_role === EUserProjectRoles.ADMIN; + const isMember = project.member_role === EUserProjectRoles.MEMBER; + // archive + const isArchived = !!project.archived_at; + + const handleAddToFavorites = () => { + if (!workspaceSlug) return; + + const addToFavoritePromise = addProjectToFavorites(workspaceSlug.toString(), project.id); + setPromiseToast(addToFavoritePromise, { + loading: "Adding project to favorites...", + success: { + title: "Success!", + message: () => "Project added to favorites.", + }, + error: { + title: "Error!", + message: () => "Couldn't add the project to favorites. Please try again.", + }, + }); + }; + + const handleRemoveFromFavorites = () => { + if (!workspaceSlug) return; + + const removeFromFavoritePromise = removeProjectFromFavorites(workspaceSlug.toString(), project.id); + setPromiseToast(removeFromFavoritePromise, { + loading: "Removing project from favorites...", + success: { + title: "Success!", + message: () => "Project removed from favorites.", + }, + error: { + title: "Error!", + message: () => "Couldn't remove the project from favorites. Please try again.", + }, + }); + }; + const projectLink = `${workspaceSlug}/projects/${project.id}/issues`; + const handleCopyText = () => + copyUrlToClipboard(projectLink).then(() => + setToast({ + type: TOAST_TYPE.INFO, + title: "Link Copied!", + message: "Project link copied to clipboard.", + }) + ); + + const MENU_ITEMS: TContextMenuItem[] = [ + { + key: "copy-link", + action: handleCopyText, + title: "Copy link", + icon: LinkIcon, + shouldRender: !isArchived, + }, + { + key: "drafted-issues", + action: () => router.push(`/${workspaceSlug}/projects/${project.id}/draft-issues`, {}), + title: "Drafted issues", + icon: Settings, + shouldRender: !isArchived && (isOwner || isMember), + }, + { + key: "restore", + action: () => setArchiveRestoreProject(true), + title: "Restore", + icon: ArchiveRestoreIcon, + shouldRender: isArchived && isOwner, + }, + { + key: "delete", + action: () => setDeleteProjectModal(true), + title: "Delete", + icon: Trash2, + shouldRender: isArchived && isOwner, + }, + { + key: "Archive", + action: () => setArchiveRestoreProject(true), + title: "Archive", + icon: ArchiveIcon, + shouldRender: isOwner && !isArchived, + }, + { + key: "settings", + action: () => router.push(`/${workspaceSlug}/projects/${project.id}/settings`, {}), + title: "Settings", + icon: Settings, + shouldRender: !isArchived && (isOwner || isMember), + }, + ]; + + return ( +
+
+ {project.name} +
+ { + setIsMenuActive(!isMenuActive); + }} + > + + + } + className={cn( + "flex justify-center items-center opacity-0 z-20 pointer-events-none flex-shrink-0 group-hover/project-card:opacity-100 group-hover/project-card:pointer-events-auto my-auto bg-white/10 rounded h-6 w-6 ", + { + "opacity-100 pointer-events-auto": isMenuActive, + } + )} + customButtonClassName="grid place-items-center" + placement="bottom-start" + > + {MENU_ITEMS.filter((item) => item.shouldRender).map((item) => ( + +
+ {item.icon && } + {item.title} +
+
+ ))} +
+ +
+ {" "} + { + if (isArchived) return; + e.preventDefault(); + e.stopPropagation(); + if (project.is_favorite) handleRemoveFromFavorites(); + else handleAddToFavorites(); + }} + selected={project.is_favorite} + /> +
+
+
+
+
+
+ +
+ +
+
+

{project.name}

+
+ +

{project.identifier}

+ {project.network === 0 && } +
+
+
+
+
+ ); +}; +export default Details; diff --git a/web/ee/components/projects/layouts/gantt/base-gantt-root.tsx b/web/ee/components/projects/layouts/gantt/base-gantt-root.tsx new file mode 100644 index 0000000000..ac2b778d2a --- /dev/null +++ b/web/ee/components/projects/layouts/gantt/base-gantt-root.tsx @@ -0,0 +1,86 @@ +import React, { useCallback } from "react"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +import { ChartDataType, GanttChartRoot, IBlockUpdateData, IGanttBlock } from "@/components/gantt-chart"; +import { getMonthChartItemPositionWidthInMonth } from "@/components/gantt-chart/views"; +import { EUserProjectRoles } from "@/constants/project"; +import { getDate } from "@/helpers/date-time.helper"; +//hooks +import { useProject, useUser } from "@/hooks/store"; +// plane web hooks +import { useProjectFilter } from "@/plane-web/hooks/store/workspace-project-states/use-project-filters"; +import { TProject } from "@/plane-web/types/projects"; +import { EProjectLayouts } from "@/plane-web/types/workspace-project-filters"; +import { ProjectGanttBlock } from "./blocks"; +import { ProjectGanttSidebar } from "./sidebar"; + +export const BaseGanttRoot: React.FC = observer(() => { + // store hooks + const { getFilteredProjectsByLayout } = useProjectFilter(); + const { projectMap } = useProject(); + const { workspaceSlug } = useParams(); + + const { + membership: { currentWorkspaceAllProjectsRole }, + } = useUser(); + const { updateProject } = useProject(); + + const filteredProjectIds = getFilteredProjectsByLayout(EProjectLayouts.TIMELINE); + + const getProjectBlocksStructure = (block: TProject): IGanttBlock => ({ + data: block, + id: block?.id, + sort_order: block && block.sort_order!, + start_date: getDate(block?.start_date), + target_date: getDate(block?.target_date), + }); + + const getBlockById = useCallback( + (id: string, currentViewData?: ChartDataType | undefined) => { + const project = projectMap[id] as TProject; + console.log(JSON.parse(JSON.stringify(project)), getDate(project?.start_date)); + const block = getProjectBlocksStructure(project); + if (currentViewData) { + return { + ...block, + position: getMonthChartItemPositionWidthInMonth(currentViewData, block), + }; + } + return block; + }, + [projectMap] + ); + + const updateProjectBlockStructure = async (project: TProject, data: IBlockUpdateData) => { + if (!workspaceSlug) return; + const payload = { ...data }; + updateProject && (await updateProject(workspaceSlug.toString(), project.id, payload as Partial)); + }; + + const isAllowed = (projectId: string) => + currentWorkspaceAllProjectsRole && + currentWorkspaceAllProjectsRole[projectId] && + currentWorkspaceAllProjectsRole[projectId] >= EUserProjectRoles.ADMIN; + + return ( +
+ } + sidebarToRender={(props) => } + enableBlockLeftResize={isAllowed} + enableBlockRightResize={isAllowed} + enableBlockMove={isAllowed} + enableAddBlock={isAllowed} + enableSelection={false} + showToday={false} + showAllBlocks + /> +
+ ); +}); diff --git a/web/ee/components/projects/layouts/gantt/blocks.tsx b/web/ee/components/projects/layouts/gantt/blocks.tsx new file mode 100644 index 0000000000..a425b09e23 --- /dev/null +++ b/web/ee/components/projects/layouts/gantt/blocks.tsx @@ -0,0 +1,112 @@ +"use client"; + +import { observer } from "mobx-react"; +import Link from "next/link"; +import { useParams } from "next/navigation"; +// hooks +// ui +// helpers +import { Tooltip } from "@plane/ui"; +import { IGanttBlock } from "@/components/gantt-chart"; +import { findTotalDaysInRange, renderFormattedDate } from "@/helpers/date-time.helper"; +import { useProject } from "@/hooks/store"; +import { usePlatformOS } from "@/hooks/use-platform-os"; +import { ProjectStateIcon } from "@/plane-web/components/workspace-project-states"; +import { useWorkspaceProjectStates } from "@/plane-web/hooks/store"; +import { TProject } from "@/plane-web/types/projects"; + +type Props = { + projectId: string; +}; +type SidebarProps = { + block: IGanttBlock; +}; + +export const ProjectGanttBlock: React.FC = observer((props) => { + const { projectId } = props; + // store hooks + const { getProjectById } = useProject(); + const { projectStates } = useWorkspaceProjectStates(); + + // derived values + const projectDetails = getProjectById(projectId) as TProject; + + const stateDetails = projectDetails && projectStates[projectDetails.state_id!]; + + const { isMobile } = usePlatformOS(); + + return ( +
+
+ +
{projectDetails?.name}
+
+ {renderFormattedDate(projectDetails?.start_date ?? "")} to{" "} + {renderFormattedDate(projectDetails?.target_date ?? "")} +
+
+ } + position="top-left" + > +
+ {projectDetails?.name} +
+ +
+ ); +}); + +// rendering projects on gantt sidebar +export const ProjectGanttSidebarBlock: React.FC = observer((props) => { + const { block } = props; + const { projectStates } = useWorkspaceProjectStates(); + + // router + const { workspaceSlug: routerWorkspaceSlug } = useParams(); + const workspaceSlug = routerWorkspaceSlug?.toString(); + + // derived values + const projectDetails = block.data; + const stateDetails = projectDetails && projectStates[projectDetails?.state_id]; + const duration = findTotalDaysInRange(projectDetails.start_date, projectDetails.target_date); + + const { isMobile } = usePlatformOS(); + return ( + +
+
+ {projectDetails.identifier} {projectDetails?.sequence_id} +
+ {stateDetails && ( + + )} + + + {projectDetails?.name} + +
+ {duration && ( +
+ {duration} day{duration > 1 ? "s" : ""} +
+ )}{" "} + + ); +}); diff --git a/web/ee/components/projects/layouts/gantt/index.ts b/web/ee/components/projects/layouts/gantt/index.ts new file mode 100644 index 0000000000..b0eb6d232e --- /dev/null +++ b/web/ee/components/projects/layouts/gantt/index.ts @@ -0,0 +1,2 @@ +export * from "./blocks"; +export * from "./base-gantt-root"; diff --git a/web/ee/components/projects/layouts/gantt/sidebar.tsx b/web/ee/components/projects/layouts/gantt/sidebar.tsx new file mode 100644 index 0000000000..204b7de078 --- /dev/null +++ b/web/ee/components/projects/layouts/gantt/sidebar.tsx @@ -0,0 +1,52 @@ +"use client"; + +import { RefObject } from "react"; +import { observer } from "mobx-react"; +// plane +import { Loader } from "@plane/ui"; +// +import { IGanttBlock } from "@/components/gantt-chart"; +import { ProjectGanttSidebarBlock } from "./blocks"; + +type Props = { + getBlockById: (id: string) => IGanttBlock; + canLoadMoreBlocks?: boolean; + loadMoreBlocks?: () => void; + ganttContainerRef: RefObject; + blockIds: string[]; + showAllBlocks?: boolean; +}; + +export const ProjectGanttSidebar: React.FC = observer((props) => { + const { blockIds, getBlockById, canLoadMoreBlocks, showAllBlocks = false } = props; + + return ( +
+ {blockIds ? ( + <> + {blockIds.map((blockId) => { + const block = getBlockById(blockId); + const isBlockVisibleOnSidebar = block?.start_date && block?.target_date; + + // hide the block if it doesn't have start and target dates and showAllBlocks is false + if (!block || (!showAllBlocks && !isBlockVisibleOnSidebar)) return; + + return ; + })} + {canLoadMoreBlocks && ( +
+
+
+ )} + + ) : ( + + + + + + + )} +
+ ); +}); diff --git a/web/ee/components/projects/layouts/index.ts b/web/ee/components/projects/layouts/index.ts new file mode 100644 index 0000000000..b3a9b43f4f --- /dev/null +++ b/web/ee/components/projects/layouts/index.ts @@ -0,0 +1,2 @@ +export * from "./root"; +export * from "./board"; diff --git a/web/ee/components/projects/layouts/root.tsx b/web/ee/components/projects/layouts/root.tsx new file mode 100644 index 0000000000..6f19b2ad18 --- /dev/null +++ b/web/ee/components/projects/layouts/root.tsx @@ -0,0 +1,54 @@ +"use client"; + +import { observer } from "mobx-react"; +// plane web components +import { EmptyState } from "@/components/empty-state"; +import { EmptyStateType } from "@/constants/empty-state"; +import { useCommandPalette, useEventTracker, useProject } from "@/hooks/store"; +import { ProjectBoardLayout } from "@/plane-web/components/projects/layouts"; +// plane web hooks +import { useProjectFilter } from "@/plane-web/hooks/store/workspace-project-states"; +// types +import { EProjectLayouts } from "@/plane-web/types/workspace-project-filters"; +// plane web components +import { BaseProjectRoot } from "./gallery/base-gallery-root"; +import { BaseGanttRoot } from "./gantt/base-gantt-root"; +import { BaseSpreadsheetRoot } from "./spreadsheet/base-spreadsheet-root"; + +export const ProjectLayoutRoot = observer(() => { + const { filters } = useProjectFilter(); + const { workspaceProjectIds } = useProject(); + const { setTrackElement } = useEventTracker(); + const { toggleCreateProjectModal } = useCommandPalette(); + + // derived values + const currentLayout = filters?.layout; + if (workspaceProjectIds?.length === 0) + return ( +
+ { + setTrackElement("Project empty state"); + toggleCreateProjectModal(true); + }} + /> +
+ ); + const ProjectLayout = (props: { activeLayout: EProjectLayouts | undefined }) => { + switch (props.activeLayout) { + case EProjectLayouts.BOARD: + return ; + case EProjectLayouts.TIMELINE: + return ; + case EProjectLayouts.GALLERY: + return ; + case EProjectLayouts.TABLE: + return ; + default: + return <>; + } + }; + + return ; +}); diff --git a/web/ee/components/projects/layouts/spreadsheet/base-spreadsheet-root.tsx b/web/ee/components/projects/layouts/spreadsheet/base-spreadsheet-root.tsx new file mode 100644 index 0000000000..095fb9908d --- /dev/null +++ b/web/ee/components/projects/layouts/spreadsheet/base-spreadsheet-root.tsx @@ -0,0 +1,43 @@ +import { useCallback } from "react"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +// hooks +import { useProject } from "@/hooks/store"; +import { useProjectFilter } from "@/plane-web/hooks/store/workspace-project-states/use-project-filters"; +import { TProject } from "@/plane-web/types/projects"; +import { EProjectLayouts, TProjectDisplayFilters } from "@/plane-web/types/workspace-project-filters"; +import { SpreadsheetView } from "./spreadsheet-view"; + +export const BaseSpreadsheetRoot = observer(() => { + // router + const { workspaceSlug } = useParams(); + // store hooks + + const { updateProject } = useProject(); + const { getFilteredProjectsByLayout, filters, bulkUpdateDisplayFilters } = useProjectFilter(); + + const filteredProjectIds = getFilteredProjectsByLayout(EProjectLayouts.TABLE); + + const handleDisplayFiltersUpdate = useCallback( + (updatedDisplayFilter: Partial) => { + bulkUpdateDisplayFilters(workspaceSlug.toString(), { + ...updatedDisplayFilter, + }); + }, + [bulkUpdateDisplayFilters] + ); + + if (!Array.isArray(filteredProjectIds)) return null; + + return ( + + updateProject(workspaceSlug.toString(), projectId || "", data) as Promise + } + canEditProperties={() => true} + /> + ); +}); diff --git a/web/ee/components/projects/layouts/spreadsheet/columns/header-column.tsx b/web/ee/components/projects/layouts/spreadsheet/columns/header-column.tsx new file mode 100644 index 0000000000..7545c972b5 --- /dev/null +++ b/web/ee/components/projects/layouts/spreadsheet/columns/header-column.tsx @@ -0,0 +1,110 @@ +"use client"; + +//ui +import { ArrowDownWideNarrow, ArrowUpNarrowWide, CheckIcon, ChevronDownIcon, Eraser, MoveRight } from "lucide-react"; +import { CustomMenu } from "@plane/ui"; +//hooks +import useLocalStorage from "@/hooks/use-local-storage"; +import { IProjectDisplayProperties, SPREADSHEET_PROPERTY_DETAILS } from "@/plane-web/constants/project/spreadsheet"; +import { TProjectDisplayFilters, TProjectSortBy } from "@/plane-web/types/workspace-project-filters"; +//types +//constants + +interface Props { + property: keyof IProjectDisplayProperties; + displayFilters: TProjectDisplayFilters; + handleDisplayFilterUpdate: (data: Partial) => void; + onClose: () => void; +} + +export const HeaderColumn = (props: Props) => { + const { handleDisplayFilterUpdate, property, onClose } = props; + + const { storedValue: selectedMenuItem } = useLocalStorage("projectSpreadsheetViewSorting", ""); + const { setValue: setActiveSortingProperty } = useLocalStorage("spreadsheetViewActiveSortingProperty", ""); + const propertyDetails = SPREADSHEET_PROPERTY_DETAILS[property]; + + const handleOrderBy = (order: any, itemKey: string) => { + console.log(order, itemKey); + handleDisplayFilterUpdate({ sort_order: order, sort_by: itemKey as TProjectSortBy }); + + // setSelectedMenuItem(`${order}_${itemKey}`); + setActiveSortingProperty(itemKey); + }; + + return ( + +
+ {} + {propertyDetails.title} +
+ {propertyDetails.isSortingAllowed && ( +
+ {selectedMenuItem?.includes(propertyDetails.title.toLowerCase()) && + (selectedMenuItem?.includes("desc") ? ( + + ) : ( + + ))} +
+ )} +
+ } + onMenuClose={onClose} + placement="bottom-start" + closeOnSelect + disabled={!propertyDetails.isSortingAllowed} + > + handleOrderBy("asc", property)}> +
+
+ + {propertyDetails.ascendingOrderTitle} + + {propertyDetails.descendingOrderTitle} +
+ + {selectedMenuItem === `asc_${property}` && } +
+
+ handleOrderBy("desc", property)}> +
+
+ + {propertyDetails.descendingOrderTitle} + + {propertyDetails.ascendingOrderTitle} +
+ + {selectedMenuItem === `desc_${property}` && } +
+
+ {selectedMenuItem && selectedMenuItem !== "" && selectedMenuItem.includes(property) && ( + handleOrderBy("asc", "manual")}> +
+ + Clear sorting +
+
+ )} + + ); +}; diff --git a/web/ee/components/projects/layouts/spreadsheet/columns/index.ts b/web/ee/components/projects/layouts/spreadsheet/columns/index.ts new file mode 100644 index 0000000000..9bc6c18520 --- /dev/null +++ b/web/ee/components/projects/layouts/spreadsheet/columns/index.ts @@ -0,0 +1,6 @@ +export * from "./priority-column"; +export * from "./state-column"; +export * from "./issue-column"; +export * from "./updated-on-column"; +export * from "./lead-column"; +export * from "./members-column"; diff --git a/web/ee/components/projects/layouts/spreadsheet/columns/issue-column.tsx b/web/ee/components/projects/layouts/spreadsheet/columns/issue-column.tsx new file mode 100644 index 0000000000..639f36446f --- /dev/null +++ b/web/ee/components/projects/layouts/spreadsheet/columns/issue-column.tsx @@ -0,0 +1,47 @@ +import React from "react"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +// helpers +import { SpreadsheetStoreType } from "@/components/issues/issue-layouts/spreadsheet/base-spreadsheet-root"; +import { cn } from "@/helpers/common.helper"; +// hooks +import { useIssues } from "@/hooks/store"; +import { useAppRouter } from "@/hooks/use-app-router"; +import { useIssueStoreType } from "@/hooks/use-issue-layout-store"; +import { TProject } from "@/plane-web/types/projects"; + +type Props = { + project: TProject; +}; + +export const SpreadsheetIssueColumn: React.FC = observer((props: Props) => { + const { project } = props; + // router + const router = useAppRouter(); + // hooks + const { workspaceSlug } = useParams(); + const storeType = useIssueStoreType() as SpreadsheetStoreType; + + const { issueMap } = useIssues(storeType); + + // derived values + const issueCount = Object.keys(issueMap).length ?? 0; + + const redirectToIssueDetail = () => { + router.push(`/${workspaceSlug?.toString()}/projects/${project.id}/issues`); + }; + console.log("issueCount", issueCount); + return ( +
{}} + className={cn( + "flex h-11 w-full items-center border-b-[0.5px] border-custom-border-200 px-4 py-1 text-xs hover:bg-custom-background-80 group-[.selected-project-row]:bg-custom-primary-100/5 group-[.selected-project-row]:hover:bg-custom-primary-100/10", + { + "cursor-pointer": issueCount, + } + )} + > + {issueCount} +
+ ); +}); diff --git a/web/ee/components/projects/layouts/spreadsheet/columns/lead-column.tsx b/web/ee/components/projects/layouts/spreadsheet/columns/lead-column.tsx new file mode 100644 index 0000000000..d3f4dcaf2a --- /dev/null +++ b/web/ee/components/projects/layouts/spreadsheet/columns/lead-column.tsx @@ -0,0 +1,58 @@ +import React from "react"; +import { observer } from "mobx-react"; +import Link from "next/link"; +import { useParams } from "next/navigation"; +// helpers +import { cn } from "@/helpers/common.helper"; +// hooks +import { useMember } from "@/hooks/store"; +import { TProject } from "@/plane-web/types/projects"; + +type Props = { + project: TProject; +}; + +export const SpreadsheetLeadColumn: React.FC = observer((props: Props) => { + const { project } = props; + + // hooks + const { workspaceSlug } = useParams(); + const { + workspace: { getWorkspaceMemberDetails }, + } = useMember(); + const lead = getWorkspaceMemberDetails(project.project_lead as string); + + return ( +
+ {lead ? ( + <> + {lead.member.avatar && lead.member.avatar.trim() !== "" ? ( + + + {lead.member.display_name + + + ) : ( + + + {(lead.member.email ?? lead.member.display_name ?? "?")[0]} + + + )} + {lead.member.first_name} {lead.member.last_name} + + ) : ( + "-" + )}{" "} +
+ ); +}); diff --git a/web/ee/components/projects/layouts/spreadsheet/columns/members-column.tsx b/web/ee/components/projects/layouts/spreadsheet/columns/members-column.tsx new file mode 100644 index 0000000000..c59c4a0f53 --- /dev/null +++ b/web/ee/components/projects/layouts/spreadsheet/columns/members-column.tsx @@ -0,0 +1,27 @@ +import React from "react"; +import { observer } from "mobx-react"; +// helpers +import { cn } from "@/helpers/common.helper"; +// hooks +import { TProject } from "@/plane-web/types/projects"; + +type Props = { + project: TProject; +}; + +export const SpreadsheetMembersColumn: React.FC = observer((props: Props) => { + const { project } = props; + + // derived values + const membersCount = project.members.length ?? 0; + + return ( +
+ {membersCount} +
+ ); +}); diff --git a/web/ee/components/projects/layouts/spreadsheet/columns/priority-column.tsx b/web/ee/components/projects/layouts/spreadsheet/columns/priority-column.tsx new file mode 100644 index 0000000000..7c2b7a95ac --- /dev/null +++ b/web/ee/components/projects/layouts/spreadsheet/columns/priority-column.tsx @@ -0,0 +1,30 @@ +import React from "react"; +import { observer } from "mobx-react"; +// components +import { TProject } from "@/plane-web/types/projects"; +import { PriorityDropdown } from "../../../dropdowns"; + +type Props = { + project: TProject; + onClose?: () => void; + onChange: (project: TProject, data: Partial) => void; + disabled: boolean; +}; + +export const SpreadsheetPriorityColumn: React.FC = observer((props: Props) => { + const { project, onChange, disabled, onClose } = props; + + return ( +
+ onChange(project, { priority: data })} + disabled={disabled} + buttonVariant="transparent-with-text" + buttonClassName="px-4 text-left rounded-none group-[.selected-project-row]:bg-custom-primary-100/5 group-[.selected-project-row]:hover:bg-custom-primary-100/10" + buttonContainerClassName="w-full" + onClose={onClose} + /> +
+ ); +}); diff --git a/web/ee/components/projects/layouts/spreadsheet/columns/state-column.tsx b/web/ee/components/projects/layouts/spreadsheet/columns/state-column.tsx new file mode 100644 index 0000000000..d1aba0d8d3 --- /dev/null +++ b/web/ee/components/projects/layouts/spreadsheet/columns/state-column.tsx @@ -0,0 +1,41 @@ +import React from "react"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +import { useProject, useWorkspace } from "@/hooks/store"; +import { useWorkspaceProjectStates } from "@/plane-web/hooks/store"; +import { TProject } from "@/plane-web/types/projects"; +import { StateDropdown } from "../../../dropdowns"; + +type Props = { + project: TProject; + onClose?: () => void; + onChange: (project: TProject, data: Partial) => void; + disabled: boolean; +}; + +export const SpreadsheetStateColumn: React.FC = observer((props) => { + const { project, onChange, disabled } = props; + const { workspaceSlug } = useParams(); + const { currentWorkspace } = useWorkspace(); + const { getProjectById } = useProject(); + const { defaultState } = useWorkspaceProjectStates(); + + // derived values + const projectDetails = getProjectById(project.id) as TProject; + const selectedId = projectDetails?.state_id || defaultState; + return ( +
+ {currentWorkspace?.id && ( + onChange(project, { state_id: data })} + className="h-full " + buttonClassName="h-full m-auto border-none px-4 rounded-none" + value={selectedId!} + disabled={disabled} + /> + )} +
+ ); +}); diff --git a/web/ee/components/projects/layouts/spreadsheet/columns/updated-on-column.tsx b/web/ee/components/projects/layouts/spreadsheet/columns/updated-on-column.tsx new file mode 100644 index 0000000000..cf1b583556 --- /dev/null +++ b/web/ee/components/projects/layouts/spreadsheet/columns/updated-on-column.tsx @@ -0,0 +1,47 @@ +import React from "react"; +import { observer } from "mobx-react"; +// helpers +import { DateRangeDropdown } from "@/components/dropdowns"; +import { getDate, renderFormattedPayloadDate } from "@/helpers/date-time.helper"; +import { TProject } from "@/plane-web/types/projects"; + +type Props = { + project: TProject; + disabled: boolean; + onChange: (project: TProject, data: Partial) => void; +}; + +export const SpreadsheetUpdatedOnColumn: React.FC = observer((props: Props) => { + const { project, onChange, disabled } = props; + + return ( +
+ { + console.log({ val }); + onChange(project, { + start_date: val?.from ? renderFormattedPayloadDate(val.from)! : undefined, + target_date: val?.to ? renderFormattedPayloadDate(val.to)! : undefined, + }); + }} + placeholder={{ + from: "Start date", + to: "End date", + }} + hideIcon={{ + to: true, + }} + tabIndex={3} + disabled={disabled} + /> +
+ ); +}); diff --git a/web/ee/components/projects/layouts/spreadsheet/index.ts b/web/ee/components/projects/layouts/spreadsheet/index.ts new file mode 100644 index 0000000000..44e7a075eb --- /dev/null +++ b/web/ee/components/projects/layouts/spreadsheet/index.ts @@ -0,0 +1,4 @@ +export * from "./columns"; +export * from "./base-spreadsheet-root"; +export * from "./spreadsheet-view"; +export * from "./spreadsheet-header-column"; diff --git a/web/ee/components/projects/layouts/spreadsheet/project-column.tsx b/web/ee/components/projects/layouts/spreadsheet/project-column.tsx new file mode 100644 index 0000000000..947cd39181 --- /dev/null +++ b/web/ee/components/projects/layouts/spreadsheet/project-column.tsx @@ -0,0 +1,44 @@ +import { useRef } from "react"; +import { observer } from "mobx-react"; +// types +import { EUserProjectRoles } from "@/constants/project"; +import { useUser } from "@/hooks/store"; +import { IProjectDisplayProperties, SPREADSHEET_PROPERTY_DETAILS } from "@/plane-web/constants/project/spreadsheet"; +import { TProject } from "@/plane-web/types/projects"; + +type Props = { + projectDetails: TProject; + disableUserActions: boolean; + property: keyof IProjectDisplayProperties; + updateProject: ((projectId: string | null, data: Partial) => Promise) | undefined; +}; + +export const ProjectColumn = observer((props: Props) => { + const { projectDetails, property, updateProject } = props; + // router + const tableCellRef = useRef(null); + const { + membership: { currentWorkspaceAllProjectsRole }, + } = useUser(); + const { Column } = SPREADSHEET_PROPERTY_DETAILS[property]; + const isEditingAllowed = + currentWorkspaceAllProjectsRole && + currentWorkspaceAllProjectsRole[projectDetails.id] && + currentWorkspaceAllProjectsRole[projectDetails.id] >= EUserProjectRoles.ADMIN; + return ( + + ) => { + console.log(JSON.parse(JSON.stringify(project)), data); + updateProject && updateProject(project.id, data); + }} + disabled={!isEditingAllowed} + /> + + ); +}); diff --git a/web/ee/components/projects/layouts/spreadsheet/project-row.tsx b/web/ee/components/projects/layouts/spreadsheet/project-row.tsx new file mode 100644 index 0000000000..b495538133 --- /dev/null +++ b/web/ee/components/projects/layouts/spreadsheet/project-row.tsx @@ -0,0 +1,154 @@ +"use client"; + +import { MutableRefObject, useRef } from "react"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +// ui +import { ControlLink, Tooltip } from "@plane/ui"; +// components +import RenderIfVisible from "@/components/core/render-if-visible-HOC"; +// constants +// helper +import { cn } from "@/helpers/common.helper"; +// hooks +import { useProject } from "@/hooks/store"; +import { TSelectionHelper } from "@/hooks/use-multiple-select"; +import { usePlatformOS } from "@/hooks/use-platform-os"; +// local components +import { IProjectDisplayProperties } from "@/plane-web/constants/project/spreadsheet"; +import { TProject } from "@/plane-web/types/projects"; +import { ProjectColumn } from "./project-column"; + +interface Props { + canEditProperties: (projectId: string | undefined) => boolean; + updateProject: ((projectId: string | null, data: Partial) => Promise) | undefined; + portalElement: React.MutableRefObject; + nestingLevel: number; + projectId: string; + isScrolled: MutableRefObject; + containerRef: MutableRefObject; + spreadsheetColumnsList: (keyof IProjectDisplayProperties)[]; + spacingLeft?: number; + selectionHelpers: TSelectionHelper; +} + +export const SpreadsheetProjectRow = observer((props: Props) => { + const { + projectId, + portalElement, + updateProject, + canEditProperties, + isScrolled, + containerRef, + spreadsheetColumnsList, + selectionHelpers, + } = props; + + return ( + <> + {/* first column/ project name and key column */} + + } + classNames={cn("bg-custom-background-100 transition-[background-color]")} + verticalOffset={100} + > + + + + ); +}); + +interface ProjectRowDetailsProps { + canEditProperties: (projectId: string | undefined) => boolean; + updateProject: ((projectId: string | null, data: Partial) => Promise) | undefined; + portalElement: React.MutableRefObject; + + projectId: string; + isScrolled: MutableRefObject; + spreadsheetColumnsList: (keyof IProjectDisplayProperties)[]; + spacingLeft?: number; + selectionHelpers: TSelectionHelper; +} + +const ProjectRowDetails = observer((props: ProjectRowDetailsProps) => { + const { projectId, updateProject, canEditProperties, isScrolled, spreadsheetColumnsList } = props; + // refs + const cellRef = useRef(null); + // router + const { workspaceSlug } = useParams(); + // hooks + const { getProjectById } = useProject(); + + const { isMobile } = usePlatformOS(); + + const projectDetails = getProjectById(projectId); + + if (!projectDetails) return null; + + const disableUserActions = !canEditProperties(projectId ?? undefined); + + return ( + <> + + {}} + > +
+
+
+ +
+ + {projectDetails.identifier} + + {projectDetails.name} +
+
+
+
+
+
+ + {/* Rest of the columns */} + {spreadsheetColumnsList.map((property) => ( + + ))} + + ); +}); diff --git a/web/ee/components/projects/layouts/spreadsheet/spreadsheet-header-column.tsx b/web/ee/components/projects/layouts/spreadsheet/spreadsheet-header-column.tsx new file mode 100644 index 0000000000..c54f1e40d4 --- /dev/null +++ b/web/ee/components/projects/layouts/spreadsheet/spreadsheet-header-column.tsx @@ -0,0 +1,37 @@ +import { useRef } from "react"; +//types +import { observer } from "mobx-react"; +import { IProjectDisplayProperties } from "@/plane-web/constants/project/spreadsheet"; +import { TProjectDisplayFilters } from "@/plane-web/types/workspace-project-filters"; +//components + +import { HeaderColumn } from "./columns/header-column"; + +interface Props { + property: keyof IProjectDisplayProperties; + displayFilters: TProjectDisplayFilters; + handleDisplayFilterUpdate: (data: Partial) => void; +} +export const SpreadsheetHeaderColumn = observer((props: Props) => { + const { displayFilters, property, handleDisplayFilterUpdate } = props; + + //hooks + const tableHeaderCellRef = useRef(null); + + return ( + + { + tableHeaderCellRef?.current?.focus(); + }} + /> + + ); +}); diff --git a/web/ee/components/projects/layouts/spreadsheet/spreadsheet-header.tsx b/web/ee/components/projects/layouts/spreadsheet/spreadsheet-header.tsx new file mode 100644 index 0000000000..3bf47d08b8 --- /dev/null +++ b/web/ee/components/projects/layouts/spreadsheet/spreadsheet-header.tsx @@ -0,0 +1,43 @@ +import { observer } from "mobx-react"; +// ui + +// constants +// hooks +import { TSelectionHelper } from "@/hooks/use-multiple-select"; +import { IProjectDisplayProperties } from "@/plane-web/constants/project/spreadsheet"; +import { TProjectDisplayFilters } from "@/plane-web/types/workspace-project-filters"; +import { SpreadsheetHeaderColumn } from "./spreadsheet-header-column"; + +interface Props { + displayFilters: TProjectDisplayFilters; + handleDisplayFilterUpdate: (data: Partial) => void; + canEditProperties: (projectId: string | undefined) => boolean; + spreadsheetColumnsList: (keyof IProjectDisplayProperties)[]; + selectionHelpers: TSelectionHelper; +} + +export const SpreadsheetHeader = observer((props: Props) => { + const { displayFilters, handleDisplayFilterUpdate, spreadsheetColumnsList } = props; + + return ( + + + + Projects + + + {spreadsheetColumnsList.map((property) => ( + + ))} + + + ); +}); diff --git a/web/ee/components/projects/layouts/spreadsheet/spreadsheet-table.tsx b/web/ee/components/projects/layouts/spreadsheet/spreadsheet-table.tsx new file mode 100644 index 0000000000..08973568fb --- /dev/null +++ b/web/ee/components/projects/layouts/spreadsheet/spreadsheet-table.tsx @@ -0,0 +1,103 @@ +import { MutableRefObject, useCallback, useEffect, useRef } from "react"; +import { observer } from "mobx-react"; +//hooks +import { TSelectionHelper } from "@/hooks/use-multiple-select"; +import { useTableKeyboardNavigation } from "@/hooks/use-table-keyboard-navigation"; +// components +import { IProjectDisplayProperties } from "@/plane-web/constants/project/spreadsheet"; +import { TProject } from "@/plane-web/types/projects"; +import { TProjectDisplayFilters } from "@/plane-web/types/workspace-project-filters"; +import { SpreadsheetProjectRow } from "./project-row"; +import { SpreadsheetHeader } from "./spreadsheet-header"; + +type Props = { + displayFilters: TProjectDisplayFilters; + handleDisplayFilterUpdate: (data: Partial) => void; + projectIds: string[]; + updateProject: ((projectId: string | null, data: Partial) => Promise) | undefined; + canEditProperties: (projectId: string | undefined) => boolean; + portalElement: React.MutableRefObject; + containerRef: MutableRefObject; + spreadsheetColumnsList: (keyof IProjectDisplayProperties)[]; + selectionHelpers: TSelectionHelper; +}; + +export const SpreadsheetTable = observer((props: Props) => { + const { + displayFilters, + handleDisplayFilterUpdate, + projectIds, + portalElement, + updateProject, + canEditProperties, + containerRef, + spreadsheetColumnsList, + selectionHelpers, + } = props; + + // states + const isScrolled = useRef(false); + + const handleScroll = useCallback(() => { + if (!containerRef.current) return; + const scrollLeft = containerRef.current.scrollLeft; + + const columnShadow = "8px 22px 22px 10px rgba(0, 0, 0, 0.05)"; // shadow for regular columns + const headerShadow = "8px -22px 22px 10px rgba(0, 0, 0, 0.05)"; // shadow for headers + + //The shadow styles are added this way to avoid re-render of all the rows of table, which could be costly + if (scrollLeft > 0 !== isScrolled.current) { + const firstColumns = containerRef.current.querySelectorAll("table tr td:first-child, th:first-child"); + + for (let i = 0; i < firstColumns.length; i++) { + const shadow = i === 0 ? headerShadow : columnShadow; + if (scrollLeft > 0) { + (firstColumns[i] as HTMLElement).style.boxShadow = shadow; + } else { + (firstColumns[i] as HTMLElement).style.boxShadow = "none"; + } + } + isScrolled.current = scrollLeft > 0; + } + }, [containerRef]); + + useEffect(() => { + const currentContainerRef = containerRef.current; + + if (currentContainerRef) currentContainerRef.addEventListener("scroll", handleScroll); + + return () => { + if (currentContainerRef) currentContainerRef.removeEventListener("scroll", handleScroll); + }; + }, [handleScroll, containerRef]); + + const handleKeyBoardNavigation = useTableKeyboardNavigation(); + + return ( + + + + {projectIds.map((id) => ( + + ))} + +
+ ); +}); diff --git a/web/ee/components/projects/layouts/spreadsheet/spreadsheet-view.tsx b/web/ee/components/projects/layouts/spreadsheet/spreadsheet-view.tsx new file mode 100644 index 0000000000..6624eac090 --- /dev/null +++ b/web/ee/components/projects/layouts/spreadsheet/spreadsheet-view.tsx @@ -0,0 +1,69 @@ +import React, { useRef } from "react"; +import { observer } from "mobx-react"; +// components +import { LogoSpinner } from "@/components/common"; +import { MultipleSelectGroup } from "@/components/core"; +// constants +import { SPREADSHEET_SELECT_GROUP } from "@/constants/spreadsheet"; +// plane web components +// plane web hooks +import { SPREADSHEET_PROPERTY_LIST } from "@/plane-web/constants/project/spreadsheet"; +import { useBulkOperationStatus } from "@/plane-web/hooks/use-bulk-operation-status"; +// types +import { TProject } from "@/plane-web/types/projects"; +import { TProjectDisplayFilters } from "@/plane-web/types/workspace-project-filters"; +import { SpreadsheetTable } from "./spreadsheet-table"; + +type Props = { + displayFilters: TProjectDisplayFilters; + handleDisplayFilterUpdate: (data: Partial) => void; + projectIds: string[] | undefined; + updateProject: ((projectId: string | null, data: Partial) => Promise) | undefined; + canEditProperties: (projectId: string | undefined) => boolean; +}; + +export const SpreadsheetView: React.FC = observer((props) => { + const { displayFilters, handleDisplayFilterUpdate, projectIds, updateProject, canEditProperties } = props; + // refs + const containerRef = useRef(null); + const portalRef = useRef(null); + // plane web hooks + const isBulkOperationsEnabled = useBulkOperationStatus(); + + if (!projectIds || projectIds.length === 0) + return ( +
+ +
+ ); + return ( +
+
+ + {(helpers) => ( + <> +
+ +
+ + )} +
+
+ ); +}); diff --git a/web/ee/components/projects/mobile-header.tsx b/web/ee/components/projects/mobile-header.tsx index c845999a1f..c3b70ca652 100644 --- a/web/ee/components/projects/mobile-header.tsx +++ b/web/ee/components/projects/mobile-header.tsx @@ -1 +1,50 @@ -export * from "ce/components/projects/mobile-header"; +import { observer } from "mobx-react"; +import { useParams, usePathname } from "next/navigation"; +// icons +import { ChevronDown } from "lucide-react"; +// hooks +import { useWorkspace } from "@/hooks/store"; +import { ProjectScopeDropdown } from "./dropdowns"; +import { ProjectAttributesDropdown, ProjectDisplayFiltersDropdown, ProjectLayoutSelection } from "./header"; + +export const ProjectsListMobileHeader = observer(() => { + // router + const { workspaceSlug } = useParams(); + const { currentWorkspace } = useWorkspace(); + const pathname = usePathname(); + + const workspaceId = currentWorkspace?.id || undefined; + const isArchived = pathname.includes("/archives"); + + const customButton = (label: string) => ( +
+ {label} + +
+ ); + if (!workspaceId || !workspaceSlug) return null; + return ( +
+ {!isArchived && ( +
+ +
+ )} + {!isArchived && ( +
+ +
+ )} +
+ +
+
+ +
+
+ ); +}); diff --git a/web/ee/components/projects/page.tsx b/web/ee/components/projects/page.tsx index 569615c49e..30a639c48a 100644 --- a/web/ee/components/projects/page.tsx +++ b/web/ee/components/projects/page.tsx @@ -1 +1,53 @@ -export * from "ce/components/projects/page"; +"use client"; + +import { useEffect } from "react"; +import { observer } from "mobx-react"; +import { useParams, usePathname } from "next/navigation"; +// components +import { PageHead } from "@/components/core"; +// hooks +import Root from "@/components/project/root"; +import { useWorkspace } from "@/hooks/store"; +// plane web components +import { WorkspaceProjectsRoot } from "@/plane-web/components/projects"; +import { useProjectFilter, useWorkspaceFeatures } from "@/plane-web/hooks/store"; +import { EWorkspaceFeatures } from "@/plane-web/types/workspace-feature"; +import { EProjectLayouts } from "@/plane-web/types/workspace-project-filters"; + +export const ProjectPageRoot = observer(() => { + // store + const { workspaceSlug } = useParams(); + const { currentWorkspace } = useWorkspace(); + const { isWorkspaceFeatureEnabled } = useWorkspaceFeatures(); + const pathname = usePathname(); + const { updateAttributes, updateLayout } = useProjectFilter(); + + // derived values + const pageTitle = currentWorkspace?.name ? `${currentWorkspace?.name} - Projects` : undefined; + const currentWorkspaceId = currentWorkspace?.id; + const isProjectGroupingEnabled = isWorkspaceFeatureEnabled(EWorkspaceFeatures.IS_PROJECT_GROUPING_ENABLED); + + useEffect(() => { + if (pathname.includes("/archives")) { + updateAttributes(workspaceSlug.toString(), "archived", true); + updateLayout(workspaceSlug.toString(), EProjectLayouts.GALLERY); + } else { + updateAttributes(workspaceSlug.toString(), "archived", false); + } + }, [pathname]); + + if (!currentWorkspaceId || !workspaceSlug) return <>; + return ( + <> + + + {isProjectGroupingEnabled ? ( +
+ +
+ ) : ( + + )} + + ); +}); diff --git a/web/ee/components/projects/root.tsx b/web/ee/components/projects/root.tsx new file mode 100644 index 0000000000..12ea6905c0 --- /dev/null +++ b/web/ee/components/projects/root.tsx @@ -0,0 +1,25 @@ +"use client"; + +import { FC, useEffect } from "react"; +import { observer } from "mobx-react"; +// plane web components +import { ProjectLayoutRoot } from "@/plane-web/components/projects"; +// plane web hooks +import { useProjectFilter } from "@/plane-web/hooks/store"; + +type TWorkspaceProjectsRoot = { + workspaceSlug: string; + workspaceId: string; +}; + +export const WorkspaceProjectsRoot: FC = observer((props) => { + const { workspaceSlug } = props; + // hooks + const { initWorkspaceFilters } = useProjectFilter(); + + useEffect(() => { + if (workspaceSlug) initWorkspaceFilters(workspaceSlug); + }, [workspaceSlug, initWorkspaceFilters]); + + return ; +}); diff --git a/web/ee/components/worklogs/workspace/empty.tsx b/web/ee/components/worklogs/workspace/empty.tsx index 73bd005d91..6c67b73c9f 100644 --- a/web/ee/components/worklogs/workspace/empty.tsx +++ b/web/ee/components/worklogs/workspace/empty.tsx @@ -11,7 +11,7 @@ export const WorklogEmptyScreen: FC = () => { const resolvedEmptyStatePath = `/empty-state/worklogs/worklog-${resolvedTheme === "light" ? "light" : "dark"}.png`; return ( -
+

No worklogs yet

diff --git a/web/ee/components/workspace-project-states/index.ts b/web/ee/components/workspace-project-states/index.ts new file mode 100644 index 0000000000..fdc32ace7d --- /dev/null +++ b/web/ee/components/workspace-project-states/index.ts @@ -0,0 +1,3 @@ +export * from "./project-state-icon"; + +export * from "./settings"; diff --git a/web/ee/components/workspace-project-states/project-state-icon/cancelled.tsx b/web/ee/components/workspace-project-states/project-state-icon/cancelled.tsx new file mode 100644 index 0000000000..32bb66aed9 --- /dev/null +++ b/web/ee/components/workspace-project-states/project-state-icon/cancelled.tsx @@ -0,0 +1,42 @@ +"use client"; + +import { FC } from "react"; +// types +import { TSvgIcons } from "./types"; + +export const CancelledIcon: FC = (props) => { + const { width, height, className, color, ...rest } = props; + + return ( + <> + + + + + + + ); +}; diff --git a/web/ee/components/workspace-project-states/project-state-icon/completed.tsx b/web/ee/components/workspace-project-states/project-state-icon/completed.tsx new file mode 100644 index 0000000000..06690f85ba --- /dev/null +++ b/web/ee/components/workspace-project-states/project-state-icon/completed.tsx @@ -0,0 +1,36 @@ +"use client"; + +import { FC } from "react"; +// types +import { TSvgIcons } from "./types"; + +export const CompletedIcon: FC = (props) => { + const { width, height, className, color, ...rest } = props; + + return ( + <> + + + + + + ); +}; diff --git a/web/ee/components/workspace-project-states/project-state-icon/draft.tsx b/web/ee/components/workspace-project-states/project-state-icon/draft.tsx new file mode 100644 index 0000000000..afa84140d2 --- /dev/null +++ b/web/ee/components/workspace-project-states/project-state-icon/draft.tsx @@ -0,0 +1,31 @@ +"use client"; + +import { FC } from "react"; +// types +import { TSvgIcons } from "./types"; + +export const DraftIcon: FC = (props) => { + const { width, height, className, color, ...rest } = props; + + return ( + <> + + + + + ); +}; diff --git a/web/ee/components/workspace-project-states/project-state-icon/execution.tsx b/web/ee/components/workspace-project-states/project-state-icon/execution.tsx new file mode 100644 index 0000000000..cce169d7db --- /dev/null +++ b/web/ee/components/workspace-project-states/project-state-icon/execution.tsx @@ -0,0 +1,36 @@ +"use client"; + +import { FC } from "react"; +// types +import { TSvgIcons } from "./types"; + +export const ExecutionIcon: FC = (props) => { + const { width, height, className, color, ...rest } = props; + + return ( + <> + + + + + + ); +}; diff --git a/web/ee/components/workspace-project-states/project-state-icon/index.ts b/web/ee/components/workspace-project-states/project-state-icon/index.ts new file mode 100644 index 0000000000..12275c2077 --- /dev/null +++ b/web/ee/components/workspace-project-states/project-state-icon/index.ts @@ -0,0 +1,12 @@ +export * from "./root"; + +// types +export * from "./types"; + +// icons +export * from "./draft"; +export * from "./planning"; +export * from "./execution"; +export * from "./monitoring"; +export * from "./completed"; +export * from "./cancelled"; diff --git a/web/ee/components/workspace-project-states/project-state-icon/monitoring.tsx b/web/ee/components/workspace-project-states/project-state-icon/monitoring.tsx new file mode 100644 index 0000000000..cbce351fbc --- /dev/null +++ b/web/ee/components/workspace-project-states/project-state-icon/monitoring.tsx @@ -0,0 +1,30 @@ +"use client"; + +import { FC } from "react"; +// types +import { TSvgIcons } from "./types"; + +export const MonitoringIcon: FC = (props) => { + const { width, height, className, color, ...rest } = props; + + return ( + <> + + + + + ); +}; diff --git a/web/ee/components/workspace-project-states/project-state-icon/planning.tsx b/web/ee/components/workspace-project-states/project-state-icon/planning.tsx new file mode 100644 index 0000000000..efe53dff92 --- /dev/null +++ b/web/ee/components/workspace-project-states/project-state-icon/planning.tsx @@ -0,0 +1,36 @@ +"use client"; + +import { FC } from "react"; +// types +import { TSvgIcons } from "./types"; + +export const PlanningIcon: FC = (props) => { + const { width, height, className, color, ...rest } = props; + + return ( + <> + + + + + + ); +}; diff --git a/web/ee/components/workspace-project-states/project-state-icon/root.tsx b/web/ee/components/workspace-project-states/project-state-icon/root.tsx new file mode 100644 index 0000000000..c1a00cc6cc --- /dev/null +++ b/web/ee/components/workspace-project-states/project-state-icon/root.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { FC } from "react"; +// plane web constants +import { WORKSPACE_PROJECT_STATE_GROUPS } from "@/plane-web/constants/workspace-project-states"; +// plane web types +import { EProjectStateGroup, TProjectStateGroupKey } from "@/plane-web/types/workspace-project-states"; +// components +import { CancelledIcon } from "./cancelled"; +import { CompletedIcon } from "./completed"; +import { DraftIcon } from "./draft"; +import { ExecutionIcon } from "./execution"; +import { MonitoringIcon } from "./monitoring"; +import { PlanningIcon } from "./planning"; +// types +import { TProjectStateIcon } from "./types"; + +const projectStateIconComponents = { + [EProjectStateGroup.DRAFT]: DraftIcon, + [EProjectStateGroup.PLANNING]: PlanningIcon, + [EProjectStateGroup.EXECUTION]: ExecutionIcon, + [EProjectStateGroup.MONITORING]: MonitoringIcon, + [EProjectStateGroup.COMPLETED]: CompletedIcon, + [EProjectStateGroup.CANCELLED]: CancelledIcon, +}; + +type TProjectStateIconProps = TProjectStateIcon & { projectStateGroup?: TProjectStateGroupKey }; + +export const ProjectStateIcon: FC = (props) => { + const { + projectStateGroup = EProjectStateGroup.DRAFT, + width = "20", + height = "20", + color, + className = "text-current", + } = props; + + // derived values + const ProjectStateIconComponent = projectStateIconComponents[projectStateGroup]; + const currentStateColor = color ?? WORKSPACE_PROJECT_STATE_GROUPS[projectStateGroup]?.color; + + return ( + + ); +}; diff --git a/web/ee/components/workspace-project-states/project-state-icon/types.ts b/web/ee/components/workspace-project-states/project-state-icon/types.ts new file mode 100644 index 0000000000..312e4b4d50 --- /dev/null +++ b/web/ee/components/workspace-project-states/project-state-icon/types.ts @@ -0,0 +1,8 @@ +export type TProjectStateIcon = { + width?: string; + height?: string; + color?: string; + className?: string | undefined; +}; + +export type TSvgIcons = React.SVGAttributes & TProjectStateIcon; diff --git a/web/ee/components/workspace-project-states/settings/create-update/create.tsx b/web/ee/components/workspace-project-states/settings/create-update/create.tsx new file mode 100644 index 0000000000..c3f7ed6d4e --- /dev/null +++ b/web/ee/components/workspace-project-states/settings/create-update/create.tsx @@ -0,0 +1,74 @@ +"use client"; + +import { FC, useState } from "react"; +import { observer } from "mobx-react"; +import { TOAST_TYPE, setToast } from "@plane/ui"; +// plane web components +import { ProjectStateForm } from "@/plane-web/components/workspace-project-states"; +// plane web constants +import { WORKSPACE_PROJECT_STATE_GROUPS } from "@/plane-web/constants/workspace-project-states"; +// plane web hooks +import { useWorkspaceProjectStates } from "@/plane-web/hooks/store"; +// plane web types +import { TProjectStateGroupKey, TProjectState } from "@/plane-web/types/workspace-project-states"; + +type TProjectStateCreate = { + workspaceSlug: string; + groupKey: TProjectStateGroupKey; + handleClose: () => void; +}; + +export const ProjectStateCreate: FC = observer((props) => { + const { workspaceSlug, groupKey, handleClose } = props; + // hooks + const { createProjectState } = useWorkspaceProjectStates(); + // states + const [loader, setLoader] = useState(false); + + const onCancel = () => { + setLoader(false); + handleClose(); + }; + + const onSubmit = async (formData: Partial) => { + if (!workspaceSlug || !groupKey) return { status: "error" }; + + try { + await createProjectState(workspaceSlug, { ...formData, group: groupKey }); + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Success!", + message: "State created successfully.", + }); + handleClose(); + return { status: "success" }; + } catch (error) { + const errorStatus = error as unknown as { status: number; data: { error: string } }; + if (errorStatus?.status === 400) { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: "State with that name already exists. Please try again with another name.", + }); + return { status: "already_exists" }; + } else { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: errorStatus.data.error ?? "State could not be created. Please try again.", + }); + return { status: "error" }; + } + } + }; + + return ( + + ); +}); diff --git a/web/ee/components/workspace-project-states/settings/create-update/form.tsx b/web/ee/components/workspace-project-states/settings/create-update/form.tsx new file mode 100644 index 0000000000..8f5b8447bb --- /dev/null +++ b/web/ee/components/workspace-project-states/settings/create-update/form.tsx @@ -0,0 +1,108 @@ +"use client"; + +import { FormEvent, FC, useEffect, useState, useMemo } from "react"; +import { TwitterPicker } from "react-color"; +import { Button, Popover, Input, TextArea } from "@plane/ui"; +// plane web types +import { TProjectState } from "@/plane-web/types/workspace-project-states"; + +type TProjectStateForm = { + data: Partial; + onSubmit: (formData: Partial) => Promise<{ status: string }>; + onCancel: () => void; + buttonDisabled: boolean; + buttonTitle: string; +}; + +export const ProjectStateForm: FC = (props) => { + const { data, onSubmit, onCancel, buttonDisabled, buttonTitle } = props; + // states + const [formData, setFromData] = useState | undefined>(undefined); + const [errors, setErrors] = useState> | undefined>(undefined); + + useEffect(() => { + if (data && !formData) setFromData(data); + }, [data, formData]); + + const handleFormData = (key: T, value: TProjectState[T]) => { + setFromData((prev) => ({ ...prev, [key]: value })); + setErrors((prev) => ({ ...prev, [key]: "" })); + }; + + const formSubmit = async (event: FormEvent) => { + event.preventDefault(); + + const name = formData?.name || undefined; + if (!formData || !name) { + let currentErrors: Partial> = {}; + if (!name) currentErrors = { ...currentErrors, name: "Name is required" }; + setErrors(currentErrors); + return; + } + + try { + await onSubmit(formData); + } catch (error) { + console.log("error", error); + } + }; + + const PopoverButton = useMemo( + () => ( +

+ ), + [formData?.color] + ); + + return ( +
+ {/* color */} +
+ + handleFormData("color", value.hex)} /> + +
+ +
+ {/* title */} + handleFormData("name", e.target.value)} + hasError={(errors && Boolean(errors.name)) || false} + className="w-full" + maxLength={100} + autoFocus + /> + + {/* description */} +