Merge branch 'develop' of github.com:makeplane/plane-ee into preview

This commit is contained in:
sriram veeraghanta
2024-08-13 13:57:46 +05:30
164 changed files with 11141 additions and 1047 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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/<str:slug>/features/",
WorkspaceFeaturesEndpoint.as_view(),
name="workspace-features",
),
path(
"workspaces/<str:slug>/project-states/",
WorkspaceProjectStatesEndpoint.as_view(),
name="workspace-project-states",
),
path(
"workspaces/<str:slug>/project-states/<uuid:pk>/",
WorkspaceProjectStatesEndpoint.as_view(),
name="workspace-project-states",
),
path(
"workspaces/<str:slug>/project-states/<uuid:pk>/default/",
WorkspaceProjectStatesDefaultEndpoint.as_view(),
name="workspace-project-states-default",
),
]

View File

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

View File

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

View File

@@ -6,4 +6,4 @@ from plane.ee.views.app.issue import (
BulkSubscribeIssuesEndpoint,
IssueWorkLogsEndpoint,
IssueTotalWorkLogEndpoint,
)
)

View File

@@ -2,3 +2,7 @@ from .worklogs import (
WorkspaceWorkLogsEndpoint,
WorkspaceExportWorkLogsEndpoint,
)
from .feature import WorkspaceFeaturesEndpoint
from .project_state import WorkspaceProjectStatesEndpoint, WorkspaceProjectStatesDefaultEndpoint

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,3 +1,4 @@
export * from "./ai-features";
export * from "./issue-suggestions";
export * from "./document-extensions";
export * from "./ai-features";

View File

@@ -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 (
<>
<PageHead title={pageTitle} />
<div className="mt-10 flex h-full w-full justify-center p-4">
<p className="text-sm text-custom-text-300">You are not authorized to access this page.</p>
</div>
</>
);
const toggleProjectGroupingFeature = async () => {
try {
const payload = {
[EWorkspaceFeatures.IS_PROJECT_GROUPING_ENABLED]: !isProjectGroupingEnabled,
};
await updateWorkspaceFeature(workspaceSlug.toString(), payload);
} catch (error) {
console.error(error);
}
};
return (
<>
<PageHead title={pageTitle} />
<WithFeatureFlagHOC
flag={E_FEATURE_FLAGS.PROJECT_GROUPING}
fallback={<WorkspaceProjectStatesUpgrade />}
workspaceSlug={workspaceSlug?.toString()}
>
<main className="container mx-auto pr-5 space-y-4">
<div className="flex items-center justify-between gap-2 border-b border-custom-border-200 pb-3">
<div>
<h3 className="text-xl font-medium">See progress overview for all projects.</h3>
<span className="text-custom-sidebar-text-400 text-sm font-medium">
State Of Projects is a Plane-only feature for tracking progress of all your projects by any project
property.
</span>
</div>
{isFeatureEnabled && (
<ToggleSwitch value={isProjectGroupingEnabled} onChange={toggleProjectGroupingFeature} size="sm" />
)}
</div>
<WorkspaceProjectStatesRoot
isProjectGroupingEnabled={isProjectGroupingEnabled}
workspaceSlug={workspaceSlug.toString()}
workspaceId={currentWorkspace?.id}
toggleProjectGroupingFeature={toggleProjectGroupingFeature}
/>
</main>
</WithFeatureFlagHOC>
</>
);
});
export default WorklogsPage;

View File

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

View File

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

View File

@@ -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<Props> = observer((props) => {
const { handleRemove, values, editable } = props;
return (
<>
{values.map((status) => {
const accessDetails = NETWORK_CHOICES.find((s) => `${s.key}` === status);
return (
<div key={status} className="flex items-center gap-1 rounded p-1 text-xs bg-custom-background-80">
{accessDetails?.label}
{editable && (
<button
type="button"
className="grid place-items-center text-custom-text-300 hover:text-custom-text-200"
onClick={() => handleRemove(status)}
>
<X size={10} strokeWidth={2} />
</button>
)}
</div>
);
})}
</>
);
});

View File

@@ -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<Props> = 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) => (
<div key={date} className="flex items-center gap-1 rounded bg-custom-background-80 p-1 text-xs">
<span className="normal-case">{getDateLabel(date)}</span>
{editable && (
<button
type="button"
className="grid place-items-center text-custom-text-300 hover:text-custom-text-200"
onClick={() => handleRemove(date)}
>
<X size={10} strokeWidth={2} />
</button>
)}
</div>
))}
</>
);
});

View File

@@ -0,0 +1,5 @@
export * from "./access";
export * from "./date";
export * from "./members";
export * from "./project-display-filters";
export * from "./root";

View File

@@ -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<Props> = 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 (
<div key={memberId} className="flex items-center gap-1 rounded bg-custom-background-80 p-1 text-xs">
<Avatar name={memberDetails.display_name} src={memberDetails.avatar} showTooltip={false} />
<span className="normal-case">{memberDetails.display_name}</span>
{editable && (
<button
type="button"
className="grid place-items-center text-custom-text-300 hover:text-custom-text-200"
onClick={() => handleRemove(memberId)}
>
<X size={10} strokeWidth={2} />
</button>
)}
</div>
);
})}
</>
);
});

View File

@@ -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<Props> = observer((props) => {
const { handleRemove, values, editable } = props;
return (
<>
{values.map((key) => {
const filterLabel = PROJECT_DISPLAY_FILTER_OPTIONS.find((s) => s.key === key)?.label;
return (
<div key={key} className="flex items-center gap-1 rounded p-1 text-xs bg-custom-background-80">
{filterLabel}
{editable && (
<button
type="button"
className="grid place-items-center text-custom-text-300 hover:text-custom-text-200"
onClick={() => handleRemove(key)}
>
<X size={10} strokeWidth={2} />
</button>
)}
</div>
);
})}
</>
);
});

View File

@@ -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> = (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 (
<div className="flex items-start justify-between gap-1.5">
<div className="flex flex-wrap items-stretch gap-2 bg-custom-background-100">
{/* 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 (
<div
key={filterKey}
className="flex flex-wrap items-center gap-2 rounded-md border border-custom-border-200 px-2 py-1 capitalize"
>
<div className="flex flex-wrap items-center gap-1.5">
<span className="text-xs text-custom-text-300">{replaceUnderscoreIfSnakeCase(filterKey)}</span>
{filterKey === "access" && (
<AppliedAccessFilters
editable={isEditingAllowed}
handleRemove={(val) => handleRemoveFilter("access", val)}
values={value}
/>
)}
{DATE_FILTERS.includes(filterKey) && (
<AppliedDateFilters
editable={isEditingAllowed}
handleRemove={(val) => handleRemoveFilter(filterKey, val)}
values={value}
/>
)}
{MEMBERS_FILTERS.includes(filterKey) && (
<AppliedMembersFilters
editable={isEditingAllowed}
handleRemove={(val) => handleRemoveFilter(filterKey, val)}
values={value}
/>
)}
{isEditingAllowed && (
<button
type="button"
className="grid place-items-center text-custom-text-300 hover:text-custom-text-200"
onClick={() => handleRemoveFilter(filterKey, null)}
>
<X size={12} strokeWidth={2} />
</button>
)}
</div>
</div>
);
})}
{/* Applied display filters */}
{appliedDisplayFilters.length > 0 && (
<div
key="project_display_filters"
className="flex flex-wrap items-center gap-2 rounded-md border border-custom-border-200 px-2 py-1 capitalize"
>
<div className="flex flex-wrap items-center gap-1.5">
<span className="text-xs text-custom-text-300">Projects</span>
<AppliedProjectDisplayFilters
editable={isEditingAllowed}
values={appliedDisplayFilters}
handleRemove={(key) => handleRemoveDisplayFilter(key)}
/>
</div>
</div>
)}
{isEditingAllowed && (
<button
type="button"
onClick={handleClearAllFilters}
className="flex items-center gap-2 rounded-md border border-custom-border-200 px-2 py-1 text-xs text-custom-text-300 hover:text-custom-text-200"
>
Clear all
<X size={12} strokeWidth={2} />
</button>
)}
</div>
<Tooltip
tooltipContent={
<p>
<span className="font-semibold">{filteredProjects}</span> of{" "}
<span className="font-semibold">{totalProjects}</span> projects match the applied filters.
</p>
}
>
<span className="bg-custom-background-80 rounded-full text-sm font-medium py-1 px-2.5">
{filteredProjects}/{totalProjects}
</span>
</Tooltip>
</div>
);
};

View File

@@ -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<TProject>;
};
const ProjectAttributes: React.FC<Props> = (props) => {
const { workspaceSlug, currentWorkspace, isProjectGroupingEnabled, data } = props;
const { control } = useFormContext<TProject>();
const { defaultState } = useWorkspaceProjectStates();
return (
<div className="flex flex-wrap items-center gap-2">
{isProjectGroupingEnabled && (
<Controller
name="state_id"
control={control}
render={({ field: { onChange, value } }) => (
<StateDropdown
value={value || data?.state_id || defaultState || ""}
onChange={onChange}
workspaceSlug={workspaceSlug.toString()}
workspaceId={currentWorkspace.id}
buttonClassName="h-7"
disabled={false}
/>
)}
/>
)}
<Controller
name="network"
control={control}
render={({ field: { onChange, value } }) => {
const currentNetwork = NETWORK_CHOICES.find((n) => n.key === value);
return (
<div className="flex-shrink-0 h-7" tabIndex={4}>
<CustomSelect
value={value}
onChange={onChange}
label={
<div className="flex items-center gap-1 h-full">
{currentNetwork ? (
<>
<currentNetwork.icon className="h-3 w-3" />
{currentNetwork.label}
</>
) : (
<span className="text-custom-text-400">Select network</span>
)}
</div>
}
placement="bottom-start"
className="h-full"
buttonClassName="h-full"
noChevron
tabIndex={4}
>
{NETWORK_CHOICES.map((network) => (
<CustomSelect.Option key={network.key} value={network.key}>
<div className="flex items-start gap-2">
<network.icon className="h-3.5 w-3.5" />
<div className="-mt-1">
<p>{network.label}</p>
<p className="text-xs text-custom-text-400">{network.description}</p>
</div>
</div>
</CustomSelect.Option>
))}
</CustomSelect>
</div>
);
}}
/>
{isProjectGroupingEnabled && (
<Controller
control={control}
name="start_date"
render={({ field: { value: startDateValue, onChange: onChangeStartDate } }) => (
<Controller
control={control}
name="target_date"
render={({ field: { value: endDateValue, onChange: onChangeEndDate } }) => (
<DateRangeDropdown
buttonVariant="border-with-text"
className="h-7"
minDate={new Date()}
value={{
from: getDate(startDateValue),
to: getDate(endDateValue),
}}
onSelect={(val) => {
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 && (
<Controller
control={control}
name="priority"
render={({ field: { value, onChange } }) => (
<div className="h-7">
<PriorityDropdown
value={value || data?.priority}
onChange={(priority) => {
onChange(priority);
}}
buttonVariant="border-with-text"
/>
</div>
)}
/>
)}
<Controller
name="project_lead"
control={control}
render={({ field: { value, onChange } }) => {
if (value === undefined || value === null || typeof value === "string")
return (
<div className="flex-shrink-0 h-7" tabIndex={5}>
<MemberDropdown
value={value}
onChange={(lead) => onChange(lead === value ? null : lead)}
placeholder="Lead"
multiple={false}
buttonVariant="border-with-text"
tabIndex={5}
/>
</div>
);
else return <></>;
}}
/>
{isProjectGroupingEnabled && (
<Controller
control={control}
name="members"
render={({ field: { value, onChange } }) => (
<MembersDropdown value={value as unknown as string[]} onChange={onChange} className="h-7" />
)}
/>
)}
</div>
);
};
export default ProjectAttributes;

View File

@@ -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<TProject>;
};
const defaultValues: Partial<TProject> = {
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<Props> = 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<TProject>({
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<TProject>) => {
// 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 (
<FormProvider {...methods}>
<ProjectCreateHeader handleClose={handleClose} />
<form onSubmit={handleSubmit(onSubmit)} className="px-3">
<div className="mt-9 space-y-6 pb-5">
<ProjectCommonAttributes
setValue={setValue}
isMobile={isMobile}
isChangeInIdentifierRequired={isChangeInIdentifierRequired}
setIsChangeInIdentifierRequired={setIsChangeInIdentifierRequired}
/>
<ProjectAttributes
workspaceSlug={workspaceSlug}
currentWorkspace={currentWorkspace}
isProjectGroupingEnabled={isProjectGroupingEnabled}
data={data}
/>
</div>
<div className="flex justify-end gap-2 pt-4 border-t border-custom-border-100">
<Button variant="neutral-primary" size="sm" onClick={handleClose} tabIndex={6}>
Cancel
</Button>
<Button variant="primary" type="submit" size="sm" loading={isSubmitting} tabIndex={7}>
{isSubmitting ? "Creating" : "Create project"}
</Button>
</div>
</form>
</FormProvider>
);
});

View File

@@ -0,0 +1,3 @@
export * from "./scope-dropdown";
export * from "./priority-dropdown";
export * from "./state-dropdown";

View File

@@ -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> = (props) => {
const { value, onChange, disabled = false, buttonClassName = "", className = "" } = props;
const DropdownLabel = () => (
<div
className={cn(
"px-2 text-xs h-full flex cursor-pointer items-center gap-2 text-custom-text-200 border-[0.5px] border-custom-border-300 hover:bg-custom-background-80 rounded",
buttonClassName
)}
>
<Users className="h-3 w-3 flex-shrink-0" />
<span>{value ? value.length : "Members"}</span>
</div>
);
return (
<MemberDropdown
value={value}
onChange={(assigneeIds) => {
onChange(assigneeIds);
}}
buttonClassName={cn({ "hover:bg-transparent": value?.length > 0 }, buttonClassName)}
placeholder="Members"
button={<DropdownLabel />}
className={className}
disabled={disabled}
buttonVariant="border-with-text"
multiple
/>
);
};
export default MembersDropdown;

View File

@@ -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 (
<Tooltip
tooltipHeading="Priority"
tooltipContent={priorityDetails?.label ?? "None"}
disabled={!showTooltip}
isMobile={isMobile}
>
<div
className={cn(
"h-full flex items-center gap-1.5 border-[0.5px] rounded text-xs px-2 py-0.5",
priorityClasses[priority ?? "none"],
{
// compact the icons if text is hidden
"px-0.5": hideText,
// highlight the whole button if text is hidden and priority is urgent
"bg-red-600 border-red-600": priority === "urgent" && hideText && highlightUrgent,
},
className
)}
>
{!hideIcon &&
(priority ? (
<div
className={cn({
// highlight just the icon if text is visible and priority is urgent
"bg-red-600 p-1 rounded": priority === "urgent" && !hideText && highlightUrgent,
})}
>
<PriorityIcon
priority={priority}
size={12}
className={cn("flex-shrink-0", {
// increase the icon size if text is hidden
"h-3.5 w-3.5": hideText,
// centre align the icons if text is hidden
"translate-x-[0.0625rem]": hideText && priority === "high",
"translate-x-0.5": hideText && priority === "medium",
"translate-x-1": hideText && priority === "low",
// highlight the icon if priority is urgent
"text-white": priority === "urgent" && highlightUrgent,
})}
/>
</div>
) : (
<SignalHigh className="size-3" />
))}
{!hideText && <span className="flex-grow truncate">{priorityDetails?.label ?? placeholder}</span>}
{dropdownArrow && (
<ChevronDown className={cn("h-2.5 w-2.5 flex-shrink-0", dropdownArrowClassName)} aria-hidden="true" />
)}
</div>
</Tooltip>
);
};
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 (
<Tooltip
tooltipHeading="Priority"
tooltipContent={priorityDetails?.label ?? "None"}
disabled={!showTooltip}
isMobile={isMobile}
>
<div
className={cn(
"h-full flex items-center gap-1.5 rounded text-xs px-2 py-0.5",
priorityClasses[priority ?? "none"],
{
// compact the icons if text is hidden
"px-0.5": hideText,
// highlight the whole button if text is hidden and priority is urgent
"bg-red-600 border-red-600": priority === "urgent" && hideText && highlightUrgent,
},
className
)}
>
{!hideIcon &&
(priority ? (
<div
className={cn({
// highlight just the icon if text is visible and priority is urgent
"bg-red-600 p-1 rounded": priority === "urgent" && !hideText && highlightUrgent,
})}
>
<PriorityIcon
priority={priority}
size={12}
className={cn("flex-shrink-0", {
// increase the icon size if text is hidden
"h-3.5 w-3.5": hideText,
// centre align the icons if text is hidden
"translate-x-[0.0625rem]": hideText && priority === "high",
"translate-x-0.5": hideText && priority === "medium",
"translate-x-1": hideText && priority === "low",
// highlight the icon if priority is urgent
"text-white": priority === "urgent" && highlightUrgent,
})}
/>
</div>
) : (
<SignalHigh className="size-3" />
))}
{!hideText && <span className="flex-grow truncate">{priorityDetails?.label ?? placeholder}</span>}
{dropdownArrow && (
<ChevronDown className={cn("h-2.5 w-2.5 flex-shrink-0", dropdownArrowClassName)} aria-hidden="true" />
)}
</div>
</Tooltip>
);
};
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 (
<Tooltip
tooltipHeading="Priority"
tooltipContent={priorityDetails?.label ?? "None"}
disabled={!showTooltip}
isMobile={isMobile}
>
<div
className={cn(
"h-full flex items-center gap-1.5 rounded text-xs px-2 py-0.5 hover:bg-custom-background-80",
priorityClasses[priority ?? "none"],
{
// compact the icons if text is hidden
"px-0.5": hideText,
// highlight the whole button if text is hidden and priority is urgent
"bg-red-600 border-red-600": priority === "urgent" && hideText && highlightUrgent,
"bg-custom-background-80": isActive,
},
className
)}
>
{!hideIcon &&
(priority ? (
<div
className={cn({
// highlight just the icon if text is visible and priority is urgent
"bg-red-600 p-1 rounded": priority === "urgent" && !hideText && highlightUrgent,
})}
>
<PriorityIcon
priority={priority}
size={12}
className={cn("flex-shrink-0", {
// increase the icon size if text is hidden
"h-3.5 w-3.5": hideText,
// centre align the icons if text is hidden
"translate-x-[0.0625rem]": hideText && priority === "high",
"translate-x-0.5": hideText && priority === "medium",
"translate-x-1": hideText && priority === "low",
// highlight the icon if priority is urgent
"text-white": priority === "urgent" && highlightUrgent,
})}
/>
</div>
) : (
<SignalHigh className="size-3" />
))}
{!hideText && <span className="flex-grow truncate">{priorityDetails?.label ?? placeholder}</span>}
{dropdownArrow && (
<ChevronDown className={cn("h-2.5 w-2.5 flex-shrink-0", dropdownArrowClassName)} aria-hidden="true" />
)}
</div>
</Tooltip>
);
};
export const PriorityDropdown: React.FC<Props> = (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<HTMLDivElement | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
// popper-js refs
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
// 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: (
<div className="flex items-center gap-2">
<PriorityIcon priority={priority.key} size={14} withContainer />
<span className="flex-grow truncate">{priority.label}</span>
</div>
),
}));
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 (
<Combobox
as="div"
ref={dropdownRef}
tabIndex={tabIndex}
className={cn(
"h-full",
{
"bg-custom-background-80": isOpen,
},
className
)}
value={value}
onChange={dropdownOnChange}
disabled={disabled}
onKeyDown={handleKeyDown}
>
<Combobox.Button as={Fragment}>
{button ? (
<button
ref={setReferenceElement}
type="button"
className={cn("clickable block h-full w-full outline-none", buttonContainerClassName)}
onClick={handleOnClick}
>
{button}
</button>
) : (
<button
ref={setReferenceElement}
type="button"
className={cn(
"clickable block h-full max-w-full outline-none",
{
"cursor-not-allowed text-custom-text-200": disabled,
"cursor-pointer": !disabled,
},
buttonContainerClassName
)}
onClick={handleOnClick}
>
<ButtonToRender
priority={value ?? undefined}
className={cn(buttonClassName, {
"text-custom-text-200": resolvedTheme?.includes("dark") || resolvedTheme === "custom",
})}
highlightUrgent={highlightUrgent}
dropdownArrow={dropdownArrow && !disabled}
dropdownArrowClassName={dropdownArrowClassName}
hideIcon={hideIcon}
placeholder={placeholder}
showTooltip={showTooltip}
hideText={BUTTON_VARIANTS_WITHOUT_TEXT.includes(buttonVariant)}
/>
</button>
)}
</Combobox.Button>
{isOpen && (
<Combobox.Options className="fixed z-10" static>
<div
className="my-1 w-48 rounded border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 text-xs shadow-custom-shadow-rg focus:outline-none"
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
<div className="flex items-center gap-1.5 rounded border border-custom-border-100 bg-custom-background-90 px-2">
<Search className="h-3.5 w-3.5 text-custom-text-400" strokeWidth={1.5} />
<Combobox.Input
as="input"
ref={inputRef}
className="w-full bg-transparent py-1 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search"
displayValue={(assigned: any) => assigned?.name}
onKeyDown={searchInputKeyDown}
/>
</div>
<div className="mt-2 max-h-48 space-y-1 overflow-y-scroll">
{filteredOptions.length > 0 ? (
filteredOptions.map((option) => (
<Combobox.Option
key={option.value}
value={option.value}
className={({ active, selected }) =>
`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 }) => (
<>
<span className="flex-grow truncate">{option.content}</span>
{selected && <Check className="h-3.5 w-3.5 flex-shrink-0" />}
</>
)}
</Combobox.Option>
))
) : (
<p className="text-custom-text-400 italic py-1 px-1.5">No matching results</p>
)}
</div>
</div>
</Combobox.Options>
)}
</Combobox>
);
};

View File

@@ -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<TProjectScopeDropdown> = 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 = () => (
<>
<div className="hidden md:flex relative items-center gap-2">
<div className="whitespace-nowrap font-medium">
{(PROJECT_SCOPES || []).find((scope) => selectedScope === scope.key)?.label}
</div>
<div className="px-2 py-0.5 flex-shrink-0 bg-custom-primary-100/20 text-custom-primary-100 text-xs font-semibold rounded-xl">
{selectedScopeCount}
</div>
</div>
<div className="flex md:hidden text-sm items-center gap-2 neutral-primary text-custom-text-200 justify-center w-full">
<span>{(PROJECT_SCOPES || []).find((scope) => selectedScope === scope.key)?.label}</span>
<ChevronDown className="h-3 w-3 hidden md:flex" strokeWidth={2} />
</div>
</>
);
const DropdownOptions = () =>
(PROJECT_SCOPES || []).map((scope) => (
<CustomMenu.MenuItem
key={scope.key}
className="flex items-center gap-2 truncate"
onClick={() => updateScope(workspaceSlug, scope.key)}
>
<div className="truncate font-medium text-xs">{scope?.label}</div>
<div className="px-2 py-0.5 flex-shrink-0 bg-custom-primary-100/20 text-custom-primary-100 text-xs font-semibold rounded-xl">
{scopeProjectsCount?.[scope.key]}
</div>
</CustomMenu.MenuItem>
));
return (
<CustomMenu
maxHeight={"md"}
className={cn(
"flex flex-grow justify-center text-xs text-custom-text-200 border-[0.5px] border-custom-border-300 hover:bg-custom-background-80 rounded px-3 py-1.5",
className
)}
placement="bottom-start"
customButton={<DropdownLabel />}
customButtonClassName="flex flex-grow justify-center"
closeOnSelect
>
<DropdownOptions />
</CustomMenu>
);
});

View File

@@ -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<TStateDropdown> = 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 = () => (
<div className="flex items-center gap-2 truncate">
<ProjectStateIcon projectStateGroup={selectedState?.group} color={selectedState?.color} width="12" height="12" />{" "}
<span className="flex-grow truncate">{selectedState?.name}</span>
</div>
);
const dropdownOptions = (projectStateIds || []).map((stateId) => {
const state = getProjectStateById(stateId);
return {
value: state?.id,
query: `${state?.name} ${state?.group}`,
content: (
<div className="flex items-center gap-2 truncate">
<ProjectStateIcon projectStateGroup={state?.group} color={state?.color} width="12" height="12" />{" "}
<span className="flex-grow truncate">{state?.name}</span>
</div>
),
};
});
return (
<CustomSearchSelect
value={value}
onChange={onChange}
options={dropdownOptions}
label={dropdownLabel()}
buttonClassName={buttonClassName}
className={className}
disabled={disabled}
noChevron
/>
);
});

View File

@@ -1 +0,0 @@
export * from "ce/components/projects/header";

View File

@@ -0,0 +1,4 @@
export * from "./root";
export * from "./priority";
export * from "./state";
export * from "./users";

View File

@@ -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<TFilterPriority> = 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 (
<>
<FilterHeader
title={`Priority${appliedFiltersCount > 0 ? ` (${appliedFiltersCount})` : ""}`}
isPreviewEnabled={previewEnabled}
handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)}
/>
{previewEnabled && (
<div>
{sortedOptions.length > 0 ? (
sortedOptions.map((priority) => (
<FilterOption
key={priority.key}
isChecked={appliedFilters?.includes(priority.key) ? true : false}
onClick={() => handleFilter(priority.key)}
icon={<PriorityIcon priority={priority.key} className={`h-3 w-3`} />}
title={priority.label}
/>
))
) : (
<p className="text-xs italic text-custom-text-400">No matches found</p>
)}
</div>
)}
</>
);
});

View File

@@ -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<TProjectAttributesDropdown> = 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 (
<FiltersDropdown
icon={<ListFilter className="h-3 w-3" />}
title="Filters"
placement="bottom-end"
isFiltersApplied={isFiltersApplied}
menuButton={menuButton}
>
<div className="flex h-full w-full flex-col overflow-hidden">
<div className="bg-custom-background-100 p-2.5 pb-0">
<div className="flex items-center gap-1.5 rounded border-[0.5px] border-custom-border-200 bg-custom-background-90 px-1.5 py-1 text-xs">
<Search className="text-custom-text-400" size={12} strokeWidth={2} />
<input
type="text"
className="w-full bg-custom-background-90 outline-none placeholder:text-custom-text-400"
placeholder="Search"
value={filtersSearchQuery}
onChange={(e) => setFiltersSearchQuery(e.target.value)}
autoFocus
/>
{filtersSearchQuery !== "" && (
<button type="button" className="grid place-items-center" onClick={() => setFiltersSearchQuery("")}>
<X className="text-custom-text-300" size={12} strokeWidth={2} />
</button>
)}
</div>
</div>
<div className="h-full w-full divide-y divide-custom-border-200 overflow-y-auto px-2.5 vertical-scrollbar scrollbar-sm">
{/* priority */}
<div className="py-2">
<FilterPriority
searchQuery={filtersSearchQuery}
appliedFilters={filters?.attributes?.priority ?? null}
handleUpdate={(val) => updateAttributes(workspaceSlug, "priority", val)}
/>
</div>
{/* state */}
<div className="py-2">
<FilterState
workspaceId={workspaceId}
searchQuery={filtersSearchQuery}
appliedFilters={filters?.attributes?.state ?? null}
handleUpdate={(val) => updateAttributes(workspaceSlug, "state", val)}
/>
</div>
{/* lead */}
<div className="py-2">
<FilterUser
filterTitle="Leads"
searchQuery={filtersSearchQuery}
appliedFilters={filters?.attributes?.lead ?? null}
handleUpdate={(val) => updateAttributes(workspaceSlug, "lead", val)}
/>
</div>
{/* members */}
<div className="py-2">
<FilterUser
filterTitle="Members"
searchQuery={filtersSearchQuery}
appliedFilters={filters?.attributes?.members ?? null}
handleUpdate={(val) => updateAttributes(workspaceSlug, "members", val)}
/>
</div>
</div>
</div>
</FiltersDropdown>
);
});

View File

@@ -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<TFilterState> = 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 (
<>
<FilterHeader
title={`State${appliedFiltersCount > 0 ? ` (${appliedFiltersCount})` : ""}`}
isPreviewEnabled={previewEnabled}
handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)}
/>
{previewEnabled && (
<div>
{sortedOptions.length > 0 ? (
<>
{sortedOptions
.slice(0, itemsToRender)
.map(
(state) =>
state.id &&
state.group &&
state.name && (
<FilterOption
key={state.id}
isChecked={appliedFilters?.includes(state.id) ? true : false}
onClick={() => state.id && handleFilter(state.id)}
icon={<ProjectStateIcon projectStateGroup={state.group} width="14" height="14" />}
title={state.name.charAt(0).toUpperCase() + state.name.slice(1)}
/>
)
)}
{sortedOptions.length > 5 && (
<button
type="button"
className="ml-8 text-xs font-medium text-custom-primary-100"
onClick={handleViewToggle}
>
{itemsToRender === sortedOptions.length ? "View less" : "View all"}
</button>
)}
</>
) : (
<p className="text-xs italic text-custom-text-400">No matches found</p>
)}
</div>
)}
</>
);
});

View File

@@ -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<TFilterUser> = 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 (
<>
<FilterHeader
title={`${filterTitle}${appliedFiltersCount > 0 ? ` (${appliedFiltersCount})` : ""}`}
isPreviewEnabled={previewEnabled}
handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)}
/>
{previewEnabled && (
<div>
{sortedOptions.length > 0 ? (
<>
{sortedOptions
.slice(0, itemsToRender)
.map(
(member) =>
member.id &&
member.display_name && (
<FilterOption
key={member.id}
isChecked={appliedFilters?.includes(member.id) ? true : false}
onClick={() => member.id && handleFilter(member.id)}
icon={<Avatar name={member.display_name} src={member.avatar} showTooltip={false} size="md" />}
title={member.display_name.charAt(0).toUpperCase() + member.display_name.slice(1)}
/>
)
)}
{sortedOptions.length > 5 && (
<button
type="button"
className="ml-8 text-xs font-medium text-custom-primary-100"
onClick={handleViewToggle}
>
{itemsToRender === sortedOptions.length ? "View less" : "View all"}
</button>
)}
</>
) : (
<p className="text-xs italic text-custom-text-400">No matches found</p>
)}
</div>
)}
</>
);
});

View File

@@ -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 (
<Button
size="sm"
onClick={() => {
setTrackElement("Projects page");
toggleCreateProjectModal(true);
}}
className="items-center gap-1"
>
<span className="hidden sm:inline-block">Add</span> Project
</Button>
);
});

View File

@@ -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<TDisplayFilterGroupBy> = observer((props) => {
const { filterValue, handleUpdate } = props;
const [previewEnabled, setPreviewEnabled] = useState(true);
return (
<>
<FilterHeader
title="Group by"
isPreviewEnabled={previewEnabled}
handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)}
/>
{previewEnabled && (
<div>
{PROJECT_GROUP_BY_OPTIONS.map((groupBy) => (
<FilterOption
key={groupBy?.key}
isChecked={filterValue === groupBy?.key ? true : false}
onClick={() => handleUpdate(groupBy.key)}
title={groupBy.title}
multiple={false}
/>
))}
</div>
)}
</>
);
});

View File

@@ -0,0 +1,4 @@
export * from "./root";
export * from "./group-by";
export * from "./sort-by";
export * from "./sort-order";

View File

@@ -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<TProjectDisplayFiltersDropdown> = observer((props) => {
const { workspaceSlug, menuButton } = props;
// hooks
const { filters, updateDisplayFilters } = useProjectFilter();
return (
<div className="">
<FiltersDropdown title="Display" placement="bottom-end" menuButton={menuButton}>
<div className="vertical-scrollbar scrollbar-sm relative h-full w-full divide-y divide-custom-border-200 overflow-hidden overflow-y-auto px-2.5">
{/* group by */}
{filters?.layout === EProjectLayouts.BOARD && (
<div className="py-2">
<DisplayFilterGroupBy
filterValue={filters?.display_filters?.group_by}
handleUpdate={(val) => updateDisplayFilters(workspaceSlug, "group_by", val)}
/>
</div>
)}
{/* sort by */}
<div className="py-2">
<DisplayFilterSortBy
filterValue={filters?.display_filters?.sort_by}
handleUpdate={(val) => updateDisplayFilters(workspaceSlug, "sort_by", val)}
/>
</div>
{/* order by */}
<div className="py-2">
<DisplayFilterSortOrder
filterValue={filters?.display_filters?.sort_order}
handleUpdate={(val) => updateDisplayFilters(workspaceSlug, "sort_order", val)}
/>
</div>
</div>
</FiltersDropdown>
</div>
);
});

View File

@@ -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<TDisplayFilterSortBy> = observer((props) => {
const { filterValue, handleUpdate } = props;
const [previewEnabled, setPreviewEnabled] = useState(true);
return (
<>
<FilterHeader
title="Sort by"
isPreviewEnabled={previewEnabled}
handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)}
/>
{previewEnabled && (
<div>
{PROJECT_SORT_BY_OPTIONS.map((groupBy) => (
<FilterOption
key={groupBy?.key}
isChecked={filterValue === groupBy?.key ? true : false}
onClick={() => handleUpdate(groupBy.key)}
title={groupBy.title}
multiple={false}
/>
))}
</div>
)}
</>
);
});

View File

@@ -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<TDisplayFilterSortOrder> = observer((props) => {
const { filterValue, handleUpdate } = props;
const [previewEnabled, setPreviewEnabled] = useState(true);
return (
<>
<FilterHeader
title="Order by"
isPreviewEnabled={previewEnabled}
handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)}
/>
{previewEnabled && (
<div>
{PROJECT_SORT_ORDER_OPTIONS.map((groupBy) => (
<FilterOption
key={groupBy?.key}
isChecked={filterValue === groupBy?.key ? true : false}
onClick={() => handleUpdate(groupBy.key)}
title={groupBy.title}
multiple={false}
/>
))}
</div>
)}
</>
);
});

View File

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

View File

@@ -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<TProjectLayoutSelection> = observer((props) => {
const { workspaceSlug } = props;
// hooks
const { filters, updateLayout } = useProjectFilter();
const { isMobile } = usePlatformOS();
// derived values
const selectedLayout = filters?.layout || EProjectLayouts.TABLE;
return (
<>
<CustomMenu
maxHeight={"md"}
className="flex md:hidden flex-grow justify-center text-sm text-custom-text-200"
placement="bottom-start"
customButton={<span className="flex flex-grow justify-center text-sm text-custom-text-200 m-auto">Layout</span>}
customButtonClassName="flex flex-grow justify-center text-custom-text-200 text-sm"
closeOnSelect
>
{PROJECT_LAYOUTS.map((layout, index) => (
<CustomMenu.MenuItem
key={index}
onClick={() => updateLayout(workspaceSlug.toString(), layout.key)}
className="flex items-center gap-2"
>
<layout.icon className="h-3 w-3" />
<div className="text-custom-text-300">{layout.title}</div>
</CustomMenu.MenuItem>
))}
</CustomMenu>
<div className="hidden md:flex items-center gap-1 rounded bg-custom-background-80 p-1">
{PROJECT_LAYOUTS.map((layout) => (
<Tooltip key={layout.key} tooltipContent={layout.title} isMobile={isMobile}>
<button
type="button"
className={`group grid h-[22px] w-7 place-items-center overflow-hidden rounded transition-all hover:bg-custom-background-100 ${
selectedLayout == layout.key ? "bg-custom-background-100 shadow-custom-shadow-2xs" : ""
}`}
onClick={() => updateLayout(workspaceSlug, layout.key)}
>
<layout.icon
size={14}
strokeWidth={2}
className={`h-3.5 w-3.5 ${selectedLayout == layout.key ? "text-custom-text-100" : "text-custom-text-200"}`}
/>
</button>
</Tooltip>
))}
</div>
</>
);
});

View File

@@ -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 ? (
<div className="flex-shrink-0 relative z-10 flex h-[3.75rem] w-full bg-custom-sidebar-background-100 p-4">
{/* flex-row items-center justify-between gap-x-2 gap-y-4 */}
<div className="w-full h-full relative flex justify-between items-center gap-x-2 gap-y-4">
<div className="flex items-center gap-4">
{/* bread crumps */}
<Breadcrumbs>
<Breadcrumbs.BreadcrumbItem
type="text"
link={<BreadcrumbLink label="Projects" icon={<Briefcase className="h-4 w-4 text-custom-text-300" />} />}
/>
{isArchived && <Breadcrumbs.BreadcrumbItem type="text" link={<BreadcrumbLink label="Archived" />} />}
</Breadcrumbs>
{/* scope dropdown */}
{!isArchived && (
<div className="hidden md:flex gap-4">
<ProjectScopeDropdown workspaceSlug={workspaceSlug.toString()} />
</div>
)}
</div>
<div className="flex items-center gap-4">
{/* search */}
<ProjectSearch workspaceSlug={workspaceSlug.toString()} />
<div className="hidden md:flex gap-4">
{/* layout selection */}
{!isArchived && <ProjectLayoutSelection workspaceSlug={workspaceSlug.toString()} />}{" "}
{/* attributes dropdown */}
<ProjectAttributesDropdown workspaceSlug={workspaceSlug.toString()} workspaceId={workspaceId} />
{/* display filters dropdown */}
<ProjectDisplayFiltersDropdown workspaceSlug={workspaceSlug.toString()} />
</div>
{/* create project button */}
<ProjectCreateButton />
</div>
</div>
</div>
) : (
<ProjectsBaseHeader />
);
});

View File

@@ -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<TProjectSearch> = observer((props) => {
const {} = props;
// hooks
const { searchQuery, updateSearchQuery } = useProjectFilter();
// refs
const inputRef = useRef<HTMLInputElement>(null);
// states
const [isSearchOpen, setIsSearchOpen] = useState(false);
const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Escape") {
if (searchQuery && searchQuery.trim() !== "") updateSearchQuery("");
else setIsSearchOpen(false);
}
};
return (
<div className="flex items-center">
{!isSearchOpen && (
<button
type="button"
className="-mr-1 p-2 hover:bg-custom-background-80 rounded text-custom-text-400 grid place-items-center"
onClick={() => {
setIsSearchOpen(true);
inputRef.current?.focus();
}}
>
<Search className="h-3.5 w-3.5" />
</button>
)}
<div
className={cn(
"ml-auto flex items-center justify-start gap-1 rounded-md border border-transparent bg-custom-background-100 text-custom-text-400 w-0 transition-[width] ease-linear overflow-hidden opacity-0",
{
"w-30 md:w-64 px-2.5 py-1.5 border-custom-border-200 opacity-100": isSearchOpen,
}
)}
>
<Search className="h-3.5 w-3.5" />
<input
ref={inputRef}
className="w-full max-w-[234px] border-none bg-transparent text-sm text-custom-text-100 placeholder:text-custom-text-400 focus:outline-none"
placeholder="Search"
value={searchQuery}
onChange={(e) => updateSearchQuery(e.target.value)}
onKeyDown={handleInputKeyDown}
/>
{isSearchOpen && (
<button
type="button"
className="grid place-items-center"
onClick={() => {
updateSearchQuery("");
setIsSearchOpen(false);
}}
>
<X className="h-3 w-3" />
</button>
)}
</div>
</div>
);
});

View File

@@ -0,0 +1,6 @@
export * from "./dropdowns";
export * from "./header";
export * from "./root";
export * from "./layouts";

View File

@@ -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 (
<div
className={cn(
`absolute top-0 left-0 h-full w-full items-center text-sm font-medium text-custom-text-300 rounded bg-custom-background-overlay ${dragColumnOrientation}`,
{
"flex flex-col border-[1px] border-custom-border-300 z-[2]": shouldOverlayBeVisible,
},
{ hidden: !shouldOverlayBeVisible }
)}
>
<div
className={cn(
"p-3 my-8 flex flex-col rounded items-center",
{
"text-custom-text-200": shouldOverlayBeVisible,
},
{
"text-custom-text-error": isDropDisabled,
}
)}
>
{dropErrorMessage ? (
<div className="flex items-center">
<AlertCircle width={13} height={13} /> &nbsp;
<span>{dropErrorMessage}</span>
</div>
) : (
<>
{readableOrderBy && (
<span>
The layout is ordered by <span className="font-semibold">{readableOrderBy}</span>.
</span>
)}
<span>Drop here to move the project.</span>
</>
)}
</div>
</div>
);
};

View File

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

View File

@@ -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<TProjectBoardGroupItemHeader> = 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 (
<>
<CreateProjectModal
isOpen={open}
onClose={() => setOpen(false)}
workspaceSlug={workspaceSlug.toString()}
data={details?.prePopulatedPayload}
/>
<div
className={`relative flex flex-shrink-0 gap-2 ${
verticalAlign ? `w-[44px] flex-col items-center` : `w-full flex-row items-center`
}`}
>
<div className="flex-shrink-0 w-5 h-5 rounded hover:bg-custom-background-80 flex justify-center items-center overflow-hidden">
{details?.icon}
</div>
<div
className={`relative flex items-baseline gap-1 ${
verticalAlign ? `flex-col` : `w-full flex-row overflow-hidden`
}`}
>
<div
className={`line-clamp-1 inline-block overflow-hidden truncate font-medium text-custom-text-100 ${
verticalAlign ? `vertical-lr max-h-[400px]` : ``
}`}
>
{details?.title}
</div>
<div className={`flex-shrink-0 text-sm font-medium text-custom-text-300 ${verticalAlign ? `` : `pl-2`}`}>
{projectIds?.length || 0}
</div>
</div>
<div
className="flex h-[20px] w-[20px] flex-shrink-0 cursor-pointer items-center justify-center overflow-hidden rounded-sm transition-all hover:bg-custom-background-80"
// onClick={() => setVerticalAlign(!verticalAlign)}
>
{verticalAlign ? <Maximize2 width={12} strokeWidth={2} /> : <Minimize2 width={12} strokeWidth={2} />}
</div>
<div
onClick={() => 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"
>
<Plus size={14} />
</div>
</div>
</>
);
});

View File

@@ -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<ProjectBoardGroupItem> = observer((props) => {
const { groupByKey, projectIds, verticalAlign, setVerticalAlign, dropErrorMessage = "" } = props;
const [isDraggingOverColumn, setIsDraggingOverColumn] = useState(false);
const columnRef = useRef<HTMLDivElement | null>(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<TProject>) => {
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 (
<div className="h-full relative flex flex-col overflow-hidden space-y-2" ref={columnRef}>
<GroupDragOverlay
dragColumnOrientation={"justify-center"}
canOverlayBeVisible
isDropDisabled={false}
dropErrorMessage={dropErrorMessage}
// orderBy={orderBy}
isDraggingOverColumn={isDraggingOverColumn}
/>
<div className="h-max max-h-full rounded-md relative flex flex-col overflow-hidden bg-custom-background-90 p-2">
{/* header */}
<ProjectBoardGroupItemHeader
groupByKey={groupByKey}
projectIds={projectIds}
verticalAlign={verticalAlign}
setVerticalAlign={setVerticalAlign}
/>
{/* projects placeholder */}
{!verticalAlign && projectIds.length > 0 && (
<ProjectBoardList groupByKey={groupByKey} projectIds={projectIds} />
)}
</div>
</div>
);
});

View File

@@ -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<ProjectBoardGroup> = observer((props) => {
const { groupByProjectIds } = props;
const [verticalAlign, setVerticalAlign] = useState(false);
return (
<div className="w-full h-full overflow-hidden overflow-x-auto relative flex px-3 space-x-3">
{Object.entries(groupByProjectIds)?.map(([groupKey, projectIds]) => (
<div
key={groupKey}
className={cn("py-3 h-full overflow-hidden flex-shrink-0", {
"w-[320px]": !verticalAlign,
})}
>
<ProjectBoardGroupItem
groupByKey={groupKey}
projectIds={projectIds}
verticalAlign={verticalAlign}
setVerticalAlign={setVerticalAlign}
/>
</div>
))}
</div>
);
});

View File

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

View File

@@ -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<ProjectBoardListItem> = observer((props) => {
const { projectId } = props;
// hooks
const { getProjectById } = useProject();
const {
membership: { currentWorkspaceAllProjectsRole },
} = useUser();
// derived values
const project = getProjectById(projectId) as TProject;
const cardRef = useRef<HTMLDivElement | null>(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 (
<div
className="flex whitespace-nowrap gap-2 rounded"
ref={cardRef}
id={project.id}
onDragStart={() => {
if (!isDragAllowed) {
setToast({
title: "Warning!",
type: TOAST_TYPE.ERROR,
message: "You don't have permission to change this project",
});
}
}}
>
<ProjectCard project={project} />
</div>
);
});

View File

@@ -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<ProjectBoardList> = observer((props) => {
const { groupByKey, projectIds } = props;
return (
<div className="max-h-full overflow-hidden overflow-y-auto flex flex-col gap-y-2 mt-2">
{projectIds.map((projectId) => (
<div key={`${groupByKey}-${projectId}`}>
<ProjectBoardListItem groupByKey={groupByKey} projectId={projectId} />
</div>
))}
</div>
);
});

View File

@@ -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 (
<div className="w-full h-full overflow-hidden">
<ProjectBoardGroup groupByProjectIds={groupByProjectIds} />
</div>
);
});

View File

@@ -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: <ProjectStateIcon projectStateGroup={groupKey} width="14" height="14" />,
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: <ProjectStateIcon projectStateGroup={groupKey} width="14" height="14" />,
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: <PriorityIcon priority={priority.key} className={`h-4 w-4`} />,
prePopulatedPayload: {
priority: priority.key,
},
};
}
case "created_by": {
const member = getWorkspaceMemberDetails(groupByKey);
return {
title: member?.display_name || "Created By",
icon: member ? <Avatar name={member.display_name} src={member.avatar} showTooltip={false} size="md" /> : <></>,
prePopulatedPayload: {},
};
}
}
};

View File

@@ -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<TProject>) => void;
workspaceSlug: string;
currentWorkspace: IWorkspace;
};
const Attributes: React.FC<Props> = 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<HTMLDivElement>) => {
e.stopPropagation();
e.preventDefault();
};
return (
<div className="flex gap-2 mt-3 flex-wrap" data-prevent-nprogress>
<div className="h-5" onFocus={handleEventPropagation} onClick={handleEventPropagation}>
<StateDropdown
value={project.state_id || ""}
onChange={(val) => 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"
)}
/>
</div>
<div className="h-5" onFocus={handleEventPropagation} onClick={handleEventPropagation}>
<PriorityDropdown
value={project.priority}
onChange={(data: EProjectPriority | undefined) => 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={
<PriorityIcon
priority={project.priority}
size={12}
withContainer
className={cn({
"cursor-not-allowed": !isEditingAllowed,
})}
/>
}
/>
</div>
<div className="h-5" onFocus={handleEventPropagation} onClick={handleEventPropagation}>
<DateRangeDropdown
buttonVariant="border-with-text"
minDate={new Date()}
value={{
from: getDate(project.start_date),
to: getDate(project.target_date),
}}
onSelect={(val) => {
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}
/>
</div>
{lead && (
<Tooltip tooltipContent="Lead" position={"bottom"} className="ml-4">
<div className="h-5" onFocus={handleEventPropagation} onClick={handleEventPropagation}>
<MemberDropdown
value={project.project_lead ? project.project_lead.toString() : null}
onChange={(val) => 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={
<div
className={cn(
"h-full text-xs px-2 flex items-center gap-2 text-custom-text-200 border-[0.5px] border-custom-border-300 hover:bg-custom-background-80 rounded",
{ "cursor-not-allowed": !isEditingAllowed }
)}
>
<Avatar key={lead.id} name={lead.display_name} src={lead.avatar} size={14} className="text-[9px]" />
<div>{lead.first_name}</div>
</div>
}
/>
</div>
</Tooltip>
)}
{projectMembersIds.length > 0 && (
<MembersDropdown
value={projectMembersIds}
disabled
onChange={() => {}}
className="h-5"
buttonClassName="cursor-not-allowed"
/>
)}
</div>
);
});
export default Attributes;

View File

@@ -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 <ProjectsLoader />;
if (workspaceProjectIds?.length === 0)
return (
<EmptyState
type={EmptyStateType.WORKSPACE_PROJECTS}
primaryButtonOnClick={() => {
setTrackElement("Project empty state");
toggleCreateProjectModal(true);
}}
/>
);
if (filteredProjectIds.length === 0)
return (
<div className="grid h-full w-full place-items-center">
<div className="text-center">
<Image
src={searchQuery && searchQuery.trim() === "" ? AllFiltersImage : NameFilterImage}
className="mx-auto h-36 w-36 sm:h-48 sm:w-48"
alt="No matching projects"
/>
<h5 className="mb-1 mt-7 text-xl font-medium">No matching projects</h5>
<p className="whitespace-pre-line text-base text-custom-text-400">
{searchQuery && searchQuery.trim() === ""
? "Remove the filters to see all projects"
: "No projects detected with the matching\ncriteria. Create a new project instead"}
</p>
</div>
</div>
);
return (
<div className="vertical-scrollbar scrollbar-lg h-full w-full overflow-y-auto p-8">
<div className="grid grid-cols-1 gap-8 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-3">
{filteredProjectIds.map((projectId) => {
const projectDetails = getProjectById(projectId);
if (!projectDetails) return;
return <ProjectCard key={projectDetails.id} project={projectDetails as TProject} />;
})}
</div>
</div>
);
});

View File

@@ -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<Props> = 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<TProject>) => {
updateProject(workspaceSlug.toString(), project.id, data);
};
if (!currentWorkspace) return null;
return (
<>
{/* Delete Project Modal */}
<DeleteProjectModal
project={project}
isOpen={deleteProjectModalOpen}
onClose={() => setDeleteProjectModal(false)}
/>
{/* Join Project Modal */}
{workspaceSlug && (
<JoinProjectModal
workspaceSlug={workspaceSlug.toString()}
project={project}
isOpen={joinProjectModalOpen}
handleClose={() => setJoinProjectModal(false)}
/>
)}
{/* Restore project modal */}
{workspaceSlug && project && (
<ArchiveRestoreProjectModal
workspaceSlug={workspaceSlug.toString()}
projectId={project.id}
isOpen={archiveRestoreProject}
onClose={() => setArchiveRestoreProject(false)}
archive={!isArchived}
/>
)}
<div
className={cn(
"group/project-card flex flex-col rounded border border-custom-border-200 bg-custom-background-100 p-2 justify-between",
{
"bg-custom-background-80": isArchived,
}
)}
>
<ControlLink
href={`/${workspaceSlug}/projects/${project.id}/issues`}
onClick={(e) => {
if (!project.is_member || isArchived) {
e.preventDefault();
e.stopPropagation();
if (!isArchived) setJoinProjectModal(true);
}
}}
data-prevent-nprogress={!project.is_member || isArchived}
>
<Details
project={project}
workspaceSlug={workspaceSlug.toString()}
setJoinProjectModal={setJoinProjectModal}
setArchiveRestoreProject={setArchiveRestoreProject}
setDeleteProjectModal={setDeleteProjectModal}
/>
<Attributes
project={project}
isArchived={isArchived}
handleUpdateProject={handleUpdateProject}
workspaceSlug={workspaceSlug.toString()}
currentWorkspace={currentWorkspace}
/>
</ControlLink>
{!project.is_member && (
<Button
tabIndex={-1}
variant="accent-primary"
className="w-full cursor-pointer rounded px-3 py-1.5 text-center text-sm font-medium outline-none mt-2 flex-end"
onClick={() => setJoinProjectModal(true)}
>
Join
</Button>
)}
</div>
</>
);
});

View File

@@ -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> = (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 (
<div className=" w-full rounded-t ">
<div className="relative ">
<img
src={
project.cover_image ??
"https://images.unsplash.com/photo-1672243775941-10d763d9adef?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1170&q=80"
}
alt={project.name}
className=" w-full rounded object-cover h-[120px]"
ref={projectCardRef}
/>
<div className="flex gap-2 absolute top-2 right-2">
<CustomMenu
customButton={
<span
className="grid place-items-center p-0.5 text-custom-sidebar-text-400 rounded my-auto"
onClick={() => {
setIsMenuActive(!isMenuActive);
}}
>
<MoreHorizontal className="size-4" />
</span>
}
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) => (
<CustomMenu.MenuItem key={item.key} onClick={item.action}>
<div className="flex items-center justify-start gap-2">
{item.icon && <item.icon className={cn("h-3 w-3", item.iconClassName)} />}
<span>{item.title}</span>
</div>
</CustomMenu.MenuItem>
))}
</CustomMenu>
<div data-prevent-nprogress>
{" "}
<FavoriteStar
buttonClassName={cn(
"h-6 w-6 bg-white/10 rounded opacity-0 group-hover/project-card:opacity-100 group-hover/project-card:pointer-events-auto",
{
"opacity-100 pointer-events-auto": isMenuActive,
}
)}
onClick={(e) => {
if (isArchived) return;
e.preventDefault();
e.stopPropagation();
if (project.is_favorite) handleRemoveFromFavorites();
else handleAddToFavorites();
}}
selected={project.is_favorite}
/>
</div>
</div>
</div>
<div className="flex h-10 w-full items-center justify-between gap-3 mt-3 ">
<div className="flex flex-grow items-center gap-2.5 truncate">
<div className="h-9 w-9 flex-shrink-0 grid place-items-center rounded bg-custom-background-90">
<Logo logo={project.logo_props} size={18} />
</div>
<div className="flex w-full flex-col justify-between gap-0.5 truncate">
<div className="flex justify-between">
<h3 className="truncate font-medium ">{project.name}</h3>
</div>
<span className="flex items-center gap-1.5">
<p className="text-xs font-medium ">{project.identifier} </p>
{project.network === 0 && <Lock className="h-2.5 w-2.5 " />}
</span>
</div>
</div>
</div>
</div>
);
};
export default Details;

View File

@@ -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<TProject>));
};
const isAllowed = (projectId: string) =>
currentWorkspaceAllProjectsRole &&
currentWorkspaceAllProjectsRole[projectId] &&
currentWorkspaceAllProjectsRole[projectId] >= EUserProjectRoles.ADMIN;
return (
<div className="h-full w-full">
<GanttChartRoot
border={false}
title="Projects"
loaderTitle="Projects"
blockIds={filteredProjectIds || []}
getBlockById={getBlockById}
blockUpdateHandler={updateProjectBlockStructure}
blockToRender={(data: TProject) => <ProjectGanttBlock projectId={data.id} />}
sidebarToRender={(props) => <ProjectGanttSidebar {...props} showAllBlocks />}
enableBlockLeftResize={isAllowed}
enableBlockRightResize={isAllowed}
enableBlockMove={isAllowed}
enableAddBlock={isAllowed}
enableSelection={false}
showToday={false}
showAllBlocks
/>
</div>
);
});

View File

@@ -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<Props> = 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 (
<div
id={`project-${projectId}`}
className="relative flex h-full w-full cursor-pointer items-center rounded"
style={{
backgroundColor: stateDetails?.color,
}}
>
<div className="absolute left-0 top-0 h-full w-full bg-custom-background-100/50" />
<Tooltip
isMobile={isMobile}
tooltipContent={
<div className="space-y-1">
<h5>{projectDetails?.name}</h5>
<div>
{renderFormattedDate(projectDetails?.start_date ?? "")} to{" "}
{renderFormattedDate(projectDetails?.target_date ?? "")}
</div>
</div>
}
position="top-left"
>
<div className="relative w-full overflow-hidden truncate px-2.5 py-1 text-sm text-custom-text-100">
{projectDetails?.name}
</div>
</Tooltip>
</div>
);
});
// rendering projects on gantt sidebar
export const ProjectGanttSidebarBlock: React.FC<SidebarProps> = 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 (
<Link
href={`/${workspaceSlug}/projects/${projectDetails?.id}/issues`}
className=" w-full cursor-pointer text-sm text-custom-text-100 flex justify-between px-4 h-11"
>
<div className="relative flex h-full w-full cursor-pointer items-center gap-2 py-3">
<div className="flex-shrink-0 text-xs text-custom-text-300 mr-5 w-[60px]">
{projectDetails.identifier} {projectDetails?.sequence_id}
</div>
{stateDetails && (
<ProjectStateIcon
projectStateGroup={stateDetails?.group}
color={stateDetails?.color}
width="16"
height="16"
/>
)}
<Tooltip tooltipContent={projectDetails?.name} isMobile={isMobile}>
<span className="flex-grow truncate text-sm font-medium">{projectDetails?.name}</span>
</Tooltip>
</div>
{duration && (
<div className="flex-shrink-0 h-full text-sm font-medium py-3">
{duration} day{duration > 1 ? "s" : ""}
</div>
)}{" "}
</Link>
);
});

View File

@@ -0,0 +1,2 @@
export * from "./blocks";
export * from "./base-gantt-root";

View File

@@ -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<HTMLDivElement>;
blockIds: string[];
showAllBlocks?: boolean;
};
export const ProjectGanttSidebar: React.FC<Props> = observer((props) => {
const { blockIds, getBlockById, canLoadMoreBlocks, showAllBlocks = false } = props;
return (
<div>
{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 <ProjectGanttSidebarBlock key={blockId} block={block} />;
})}
{canLoadMoreBlocks && (
<div className="p-2">
<div className="flex h-10 md:h-8 w-full items-center justify-between gap-1.5 rounded md:px-1 px-4 py-1.5 bg-custom-background-80 animate-pulse" />
</div>
)}
</>
) : (
<Loader className="space-y-3 pr-2">
<Loader.Item height="34px" />
<Loader.Item height="34px" />
<Loader.Item height="34px" />
<Loader.Item height="34px" />
</Loader>
)}
</div>
);
});

View File

@@ -0,0 +1,2 @@
export * from "./root";
export * from "./board";

View File

@@ -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 (
<div className="flex h-full w-full flex-col">
<EmptyState
type={EmptyStateType.WORKSPACE_PROJECTS}
primaryButtonOnClick={() => {
setTrackElement("Project empty state");
toggleCreateProjectModal(true);
}}
/>
</div>
);
const ProjectLayout = (props: { activeLayout: EProjectLayouts | undefined }) => {
switch (props.activeLayout) {
case EProjectLayouts.BOARD:
return <ProjectBoardLayout />;
case EProjectLayouts.TIMELINE:
return <BaseGanttRoot />;
case EProjectLayouts.GALLERY:
return <BaseProjectRoot />;
case EProjectLayouts.TABLE:
return <BaseSpreadsheetRoot />;
default:
return <></>;
}
};
return <ProjectLayout activeLayout={currentLayout} />;
});

View File

@@ -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<TProjectDisplayFilters>) => {
bulkUpdateDisplayFilters(workspaceSlug.toString(), {
...updatedDisplayFilter,
});
},
[bulkUpdateDisplayFilters]
);
if (!Array.isArray(filteredProjectIds)) return null;
return (
<SpreadsheetView
displayFilters={filters?.display_filters as TProjectDisplayFilters}
handleDisplayFilterUpdate={handleDisplayFiltersUpdate}
projectIds={filteredProjectIds}
updateProject={(projectId, data) =>
updateProject(workspaceSlug.toString(), projectId || "", data) as Promise<TProject>
}
canEditProperties={() => true}
/>
);
});

View File

@@ -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<TProjectDisplayFilters>) => 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 (
<CustomMenu
customButtonClassName="clickable !w-full"
customButtonTabIndex={-1}
className="!w-full"
customButton={
<div className="flex w-full cursor-pointer items-center justify-between gap-1.5 py-2 text-sm text-custom-text-200 hover:text-custom-text-100">
<div className="flex items-center gap-1.5">
{<propertyDetails.icon className="h-4 w-4 text-custom-text-400" />}
{propertyDetails.title}
</div>
{propertyDetails.isSortingAllowed && (
<div className="ml-3 flex gap-2">
{selectedMenuItem?.includes(propertyDetails.title.toLowerCase()) &&
(selectedMenuItem?.includes("desc") ? (
<ArrowUpNarrowWide className="h-3 w-3 stroke-[1.5]" />
) : (
<ArrowDownWideNarrow className="h-3 w-3 stroke-[1.5]" />
))}
<ChevronDownIcon className="h-3 w-3" aria-hidden="true" />
</div>
)}
</div>
}
onMenuClose={onClose}
placement="bottom-start"
closeOnSelect
disabled={!propertyDetails.isSortingAllowed}
>
<CustomMenu.MenuItem onClick={() => handleOrderBy("asc", property)}>
<div
className={`flex items-center justify-between gap-1.5 px-1 ${
selectedMenuItem === `asc_${property}`
? "text-custom-text-100"
: "text-custom-text-200 hover:text-custom-text-100"
}`}
>
<div className="flex items-center gap-2">
<ArrowDownWideNarrow className="h-3 w-3 stroke-[1.5]" />
<span>{propertyDetails.ascendingOrderTitle}</span>
<MoveRight className="h-3 w-3" />
<span>{propertyDetails.descendingOrderTitle}</span>
</div>
{selectedMenuItem === `asc_${property}` && <CheckIcon className="h-3 w-3" />}
</div>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={() => handleOrderBy("desc", property)}>
<div
className={`flex items-center justify-between gap-1.5 px-1 ${
selectedMenuItem === `desc_${property}`
? "text-custom-text-100"
: "text-custom-text-200 hover:text-custom-text-100"
}`}
>
<div className="flex items-center gap-2">
<ArrowUpNarrowWide className="h-3 w-3 stroke-[1.5]" />
<span>{propertyDetails.descendingOrderTitle}</span>
<MoveRight className="h-3 w-3" />
<span>{propertyDetails.ascendingOrderTitle}</span>
</div>
{selectedMenuItem === `desc_${property}` && <CheckIcon className="h-3 w-3" />}
</div>
</CustomMenu.MenuItem>
{selectedMenuItem && selectedMenuItem !== "" && selectedMenuItem.includes(property) && (
<CustomMenu.MenuItem className={`mt-0.5}`} key={property} onClick={() => handleOrderBy("asc", "manual")}>
<div className="flex items-center gap-2 px-1">
<Eraser className="h-3 w-3" />
<span>Clear sorting</span>
</div>
</CustomMenu.MenuItem>
)}
</CustomMenu>
);
};

View File

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

View File

@@ -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<Props> = 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 (
<div
onClick={issueCount ? redirectToIssueDetail : () => {}}
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}
</div>
);
});

View File

@@ -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<Props> = observer((props: Props) => {
const { project } = props;
// hooks
const { workspaceSlug } = useParams();
const {
workspace: { getWorkspaceMemberDetails },
} = useMember();
const lead = getWorkspaceMemberDetails(project.project_lead as string);
return (
<div
className={cn(
"flex gap-x-2 gap-y-2 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"
)}
>
{lead ? (
<>
{lead.member.avatar && lead.member.avatar.trim() !== "" ? (
<Link href={`/${workspaceSlug}/profile/${lead.member.id}`}>
<span className="relative flex h-5 w-5 items-center justify-center rounded-full capitalize text-white ">
<img
width={20}
src={lead.member.avatar}
className="absolute left-0 top-0 h-5 w-5 rounded-full object-cover"
alt={lead.member.display_name || lead.member.email}
/>
</span>
</Link>
) : (
<Link href={`/${workspaceSlug}/profile/${lead.member.id}`}>
<span className="relative flex h-5 w-5 items-center justify-center rounded-full bg-gray-700 capitalize text-white">
{(lead.member.email ?? lead.member.display_name ?? "?")[0]}
</span>
</Link>
)}
{lead.member.first_name} {lead.member.last_name}
</>
) : (
"-"
)}{" "}
</div>
);
});

View File

@@ -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<Props> = observer((props: Props) => {
const { project } = props;
// derived values
const membersCount = project.members.length ?? 0;
return (
<div
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"
)}
>
{membersCount}
</div>
);
});

View File

@@ -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<TProject>) => void;
disabled: boolean;
};
export const SpreadsheetPriorityColumn: React.FC<Props> = observer((props: Props) => {
const { project, onChange, disabled, onClose } = props;
return (
<div className=" h-11 border-b-[0.5px] border-custom-border-200">
<PriorityDropdown
value={project.priority}
onChange={(data) => 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}
/>
</div>
);
});

View File

@@ -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<TProject>) => void;
disabled: boolean;
};
export const SpreadsheetStateColumn: React.FC<Props> = 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 (
<div className="h-11 border-b-[0.5px] border-custom-border-200">
{currentWorkspace?.id && (
<StateDropdown
workspaceSlug={workspaceSlug.toString()}
workspaceId={currentWorkspace.id}
onChange={(data) => onChange(project, { state_id: data })}
className="h-full "
buttonClassName="h-full m-auto border-none px-4 rounded-none"
value={selectedId!}
disabled={disabled}
/>
)}
</div>
);
});

View File

@@ -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<TProject>) => void;
};
export const SpreadsheetUpdatedOnColumn: React.FC<Props> = observer((props: Props) => {
const { project, onChange, disabled } = props;
return (
<div className="flex h-11 w-full items-center justify-start border-b-[0.5px] border-custom-border-200 text-xs hover:bg-custom-background-80 group-[.selected-issue-row]:bg-custom-primary-100/5 group-[.selected-issue-row]:hover:bg-custom-primary-100/10">
<DateRangeDropdown
buttonVariant="transparent-with-text"
className="h-7"
buttonClassName="px-4 text-left rounded-none group-[.selected-issue-row]:bg-custom-primary-100/5 group-[.selected-issue-row]:hover:bg-custom-primary-100/10"
minDate={new Date()}
value={{
from: getDate(project.start_date),
to: getDate(project.target_date),
}}
onSelect={(val) => {
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}
/>
</div>
);
});

View File

@@ -0,0 +1,4 @@
export * from "./columns";
export * from "./base-spreadsheet-root";
export * from "./spreadsheet-view";
export * from "./spreadsheet-header-column";

View File

@@ -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<TProject>) => Promise<TProject>) | undefined;
};
export const ProjectColumn = observer((props: Props) => {
const { projectDetails, property, updateProject } = props;
// router
const tableCellRef = useRef<HTMLTableCellElement | null>(null);
const {
membership: { currentWorkspaceAllProjectsRole },
} = useUser();
const { Column } = SPREADSHEET_PROPERTY_DETAILS[property];
const isEditingAllowed =
currentWorkspaceAllProjectsRole &&
currentWorkspaceAllProjectsRole[projectDetails.id] &&
currentWorkspaceAllProjectsRole[projectDetails.id] >= EUserProjectRoles.ADMIN;
return (
<td
tabIndex={0}
className="h-11 w-full min-w-36 max-w-48 text-sm after:absolute after:w-full after:bottom-[-1px] after:border after:border-custom-border-100 border-r-[1px] border-custom-border-100"
ref={tableCellRef}
>
<Column
project={projectDetails}
onChange={(project: TProject, data: Partial<TProject>) => {
console.log(JSON.parse(JSON.stringify(project)), data);
updateProject && updateProject(project.id, data);
}}
disabled={!isEditingAllowed}
/>
</td>
);
});

View File

@@ -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<TProject>) => Promise<TProject>) | undefined;
portalElement: React.MutableRefObject<HTMLDivElement | null>;
nestingLevel: number;
projectId: string;
isScrolled: MutableRefObject<boolean>;
containerRef: MutableRefObject<HTMLTableElement | null>;
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 */}
<RenderIfVisible
as="tr"
defaultHeight="calc(2.75rem - 1px)"
root={containerRef}
placeholderChildren={
<td colSpan={100} className="border-[0.5px] border-transparent border-b-custom-border-200" />
}
classNames={cn("bg-custom-background-100 transition-[background-color]")}
verticalOffset={100}
>
<ProjectRowDetails
projectId={projectId}
canEditProperties={canEditProperties}
updateProject={updateProject}
portalElement={portalElement}
isScrolled={isScrolled}
spreadsheetColumnsList={spreadsheetColumnsList}
selectionHelpers={selectionHelpers}
/>
</RenderIfVisible>
</>
);
});
interface ProjectRowDetailsProps {
canEditProperties: (projectId: string | undefined) => boolean;
updateProject: ((projectId: string | null, data: Partial<TProject>) => Promise<TProject>) | undefined;
portalElement: React.MutableRefObject<HTMLDivElement | null>;
projectId: string;
isScrolled: MutableRefObject<boolean>;
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 (
<>
<td
id={`project-${projectId}`}
ref={cellRef}
tabIndex={0}
className="sticky left-0 z-10 group/list-block bg-custom-background-100"
>
<ControlLink
href={`/${workspaceSlug}/projects/${projectId}`}
className={cn(
"group clickable cursor-pointer h-11 w-[28rem] flex items-center text-sm after:absolute border-r-[0.5px] z-10 border-custom-border-200 bg-transparent group-[.selected-issue-row]:bg-custom-primary-100/5 group-[.selected-issue-row]:hover:bg-custom-primary-100/10",
{
"border-b-[0.5px]": true,
"shadow-[8px_22px_22px_10px_rgba(0,0,0,0.05)]": isScrolled.current,
}
)}
onClick={() => {}}
>
<div className="flex items-center gap-2 justify-between h-full w-full pr-4 pl-4 truncate">
<div className="w-full line-clamp-1 text-sm text-custom-text-100">
<div className="w-full overflow-hidden">
<Tooltip tooltipContent={projectDetails.name} isMobile={isMobile}>
<div
className="h-full w-full cursor-pointer truncate pr-4 text-left text-[0.825rem] text-custom-text-100 focus:outline-none flex gap-5"
tabIndex={-1}
>
<span className="text-custom-text-300 w-[60px] text-xs self-center">
{projectDetails.identifier}
</span>
<span> {projectDetails.name}</span>
</div>
</Tooltip>
</div>
</div>
</div>
</ControlLink>
</td>
{/* Rest of the columns */}
{spreadsheetColumnsList.map((property) => (
<ProjectColumn
key={property}
projectDetails={projectDetails as TProject}
disableUserActions={disableUserActions}
property={property}
updateProject={updateProject}
/>
))}
</>
);
});

View File

@@ -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<TProjectDisplayFilters>) => void;
}
export const SpreadsheetHeaderColumn = observer((props: Props) => {
const { displayFilters, property, handleDisplayFilterUpdate } = props;
//hooks
const tableHeaderCellRef = useRef<HTMLTableCellElement | null>(null);
return (
<th
className="h-11 w-full min-w-40 max-w-80 items-center bg-custom-background-90 text-sm font-medium px-4 py-1 border border-b-0 border-t-0 border-custom-border-100"
ref={tableHeaderCellRef}
tabIndex={0}
>
<HeaderColumn
displayFilters={displayFilters}
handleDisplayFilterUpdate={handleDisplayFilterUpdate}
property={property}
onClose={() => {
tableHeaderCellRef?.current?.focus();
}}
/>
</th>
);
});

View File

@@ -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<TProjectDisplayFilters>) => void;
canEditProperties: (projectId: string | undefined) => boolean;
spreadsheetColumnsList: (keyof IProjectDisplayProperties)[];
selectionHelpers: TSelectionHelper;
}
export const SpreadsheetHeader = observer((props: Props) => {
const { displayFilters, handleDisplayFilterUpdate, spreadsheetColumnsList } = props;
return (
<thead className="sticky top-0 left-0 z-[12] border-b-[0.5px] border-custom-border-100">
<tr>
<th
className="group/list-header sticky left-0 z-[15] h-11 w-[28rem] flex items-center gap-1 bg-custom-background-90 text-sm font-medium before:absolute before:h-full before:right-0 before:border-[0.5px] before:border-custom-border-100 pl-4"
tabIndex={-1}
>
<span className="flex h-full w-full flex-grow items-center py-2.5">Projects</span>
</th>
{spreadsheetColumnsList.map((property) => (
<SpreadsheetHeaderColumn
key={property}
property={property}
displayFilters={displayFilters}
handleDisplayFilterUpdate={handleDisplayFilterUpdate}
/>
))}
</tr>
</thead>
);
});

View File

@@ -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<TProjectDisplayFilters>) => void;
projectIds: string[];
updateProject: ((projectId: string | null, data: Partial<TProject>) => Promise<TProject>) | undefined;
canEditProperties: (projectId: string | undefined) => boolean;
portalElement: React.MutableRefObject<HTMLDivElement | null>;
containerRef: MutableRefObject<HTMLTableElement | null>;
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 (
<table className="overflow-y-auto bg-custom-background-100" onKeyDown={handleKeyBoardNavigation}>
<SpreadsheetHeader
displayFilters={displayFilters}
handleDisplayFilterUpdate={handleDisplayFilterUpdate}
canEditProperties={canEditProperties}
spreadsheetColumnsList={spreadsheetColumnsList}
selectionHelpers={selectionHelpers}
/>
<tbody>
{projectIds.map((id) => (
<SpreadsheetProjectRow
key={id}
projectId={id}
canEditProperties={canEditProperties}
nestingLevel={0}
updateProject={updateProject}
portalElement={portalElement}
containerRef={containerRef}
isScrolled={isScrolled}
spreadsheetColumnsList={spreadsheetColumnsList}
selectionHelpers={selectionHelpers}
/>
))}
</tbody>
</table>
);
});

View File

@@ -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<TProjectDisplayFilters>) => void;
projectIds: string[] | undefined;
updateProject: ((projectId: string | null, data: Partial<TProject>) => Promise<TProject>) | undefined;
canEditProperties: (projectId: string | undefined) => boolean;
};
export const SpreadsheetView: React.FC<Props> = observer((props) => {
const { displayFilters, handleDisplayFilterUpdate, projectIds, updateProject, canEditProperties } = props;
// refs
const containerRef = useRef<HTMLTableElement | null>(null);
const portalRef = useRef<HTMLDivElement | null>(null);
// plane web hooks
const isBulkOperationsEnabled = useBulkOperationStatus();
if (!projectIds || projectIds.length === 0)
return (
<div className="grid h-full w-full place-items-center">
<LogoSpinner />
</div>
);
return (
<div className="relative flex h-full w-full flex-col overflow-x-hidden whitespace-nowrap rounded-lg bg-custom-background-200 text-custom-text-200">
<div ref={portalRef} className="spreadsheet-menu-portal" />
<MultipleSelectGroup
containerRef={containerRef}
entities={{
[SPREADSHEET_SELECT_GROUP]: projectIds,
}}
disabled={!isBulkOperationsEnabled}
>
{(helpers) => (
<>
<div ref={containerRef} className="vertical-scrollbar horizontal-scrollbar scrollbar-lg h-full w-full">
<SpreadsheetTable
displayFilters={displayFilters}
handleDisplayFilterUpdate={handleDisplayFilterUpdate}
projectIds={projectIds}
portalElement={portalRef}
updateProject={updateProject}
canEditProperties={canEditProperties}
containerRef={containerRef}
spreadsheetColumnsList={SPREADSHEET_PROPERTY_LIST}
selectionHelpers={helpers}
/>
</div>
</>
)}
</MultipleSelectGroup>
</div>
);
});

View File

@@ -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) => (
<div className="flex text-sm items-center gap-2 neutral-primary text-custom-text-200">
{label}
<ChevronDown className="h-3 w-3" strokeWidth={2} />
</div>
);
if (!workspaceId || !workspaceSlug) return null;
return (
<div className="flex py-2 border-b border-custom-border-200 md:hidden bg-custom-background-100 w-full">
{!isArchived && (
<div className="border-l border-custom-border-200 flex justify-around w-full">
<ProjectLayoutSelection workspaceSlug={workspaceSlug.toString()} />
</div>
)}
{!isArchived && (
<div className="border-l border-custom-border-200 flex justify-around w-full">
<ProjectScopeDropdown workspaceSlug={workspaceSlug.toString()} className={"border-none"} />
</div>
)}
<div className="border-l border-custom-border-200 flex justify-around w-full">
<ProjectAttributesDropdown
workspaceSlug={workspaceSlug.toString()}
workspaceId={workspaceId}
menuButton={customButton("Filters")}
/>
</div>
<div className="border-l border-custom-border-200 flex justify-around w-full">
<ProjectDisplayFiltersDropdown workspaceSlug={workspaceSlug.toString()} menuButton={customButton("Display")} />
</div>
</div>
);
});

View File

@@ -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 (
<>
<PageHead title={pageTitle} />
{isProjectGroupingEnabled ? (
<div className="h-full w-full overflow-hidden">
<WorkspaceProjectsRoot workspaceSlug={workspaceSlug.toString()} workspaceId={currentWorkspaceId} />
</div>
) : (
<Root />
)}
</>
);
});

View File

@@ -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<TWorkspaceProjectsRoot> = observer((props) => {
const { workspaceSlug } = props;
// hooks
const { initWorkspaceFilters } = useProjectFilter();
useEffect(() => {
if (workspaceSlug) initWorkspaceFilters(workspaceSlug);
}, [workspaceSlug, initWorkspaceFilters]);
return <ProjectLayoutRoot />;
});

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