mirror of
https://github.com/makeplane/plane.git
synced 2026-07-12 13:29:56 +02:00
Merge branch 'develop' of github.com:makeplane/plane-ee into preview
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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")},
|
||||
),
|
||||
]
|
||||
@@ -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
|
||||
|
||||
17
apiserver/plane/ee/serializers/app/project.py
Normal file
17
apiserver/plane/ee/serializers/app/project.py
Normal 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",
|
||||
]
|
||||
8
apiserver/plane/ee/serializers/app/workspace/feature.py
Normal file
8
apiserver/plane/ee/serializers/app/workspace/feature.py
Normal 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__"
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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",
|
||||
),
|
||||
]
|
||||
|
||||
19
apiserver/plane/ee/utils/workspace_feature.py
Normal file
19
apiserver/plane/ee/utils/workspace_feature.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -6,4 +6,4 @@ from plane.ee.views.app.issue import (
|
||||
BulkSubscribeIssuesEndpoint,
|
||||
IssueWorkLogsEndpoint,
|
||||
IssueTotalWorkLogEndpoint,
|
||||
)
|
||||
)
|
||||
@@ -2,3 +2,7 @@ from .worklogs import (
|
||||
WorkspaceWorkLogsEndpoint,
|
||||
WorkspaceExportWorkLogsEndpoint,
|
||||
)
|
||||
|
||||
from .feature import WorkspaceFeaturesEndpoint
|
||||
|
||||
from .project_state import WorkspaceProjectStatesEndpoint, WorkspaceProjectStatesDefaultEndpoint
|
||||
|
||||
131
apiserver/plane/ee/views/app/workspace/feature.py
Normal file
131
apiserver/plane/ee/views/app/workspace/feature.py
Normal 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)
|
||||
138
apiserver/plane/ee/views/app/workspace/project_state.py
Normal file
138
apiserver/plane/ee/views/app/workspace/project_state.py
Normal 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,
|
||||
)
|
||||
157
apiserver/plane/graphql/mutations/cycle.py
Normal file
157
apiserver/plane/graphql/mutations/cycle.py
Normal 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
|
||||
@@ -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(
|
||||
|
||||
117
apiserver/plane/graphql/mutations/module.py
Normal file
117
apiserver/plane/graphql/mutations/module.py
Normal 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
|
||||
82
apiserver/plane/graphql/queries/cycle.py
Normal file
82
apiserver/plane/graphql/queries/cycle.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
82
apiserver/plane/graphql/queries/module.py
Normal file
82
apiserver/plane/graphql/queries/module.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
|
||||
76
apiserver/plane/graphql/types/cycle.py
Normal file
76
apiserver/plane/graphql/types/cycle.py
Normal 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
|
||||
@@ -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)
|
||||
|
||||
72
apiserver/plane/graphql/types/module.py
Normal file
72
apiserver/plane/graphql/types/module.py
Normal 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
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from "./ai-features";
|
||||
export * from "./issue-suggestions";
|
||||
export * from "./document-extensions";
|
||||
export * from "./ai-features";
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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(
|
||||
|
||||
36
web/ee/components/projects/applied-filters/access.tsx
Normal file
36
web/ee/components/projects/applied-filters/access.tsx
Normal 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>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
});
|
||||
55
web/ee/components/projects/applied-filters/date.tsx
Normal file
55
web/ee/components/projects/applied-filters/date.tsx
Normal 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>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
});
|
||||
5
web/ee/components/projects/applied-filters/index.ts
Normal file
5
web/ee/components/projects/applied-filters/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export * from "./access";
|
||||
export * from "./date";
|
||||
export * from "./members";
|
||||
export * from "./project-display-filters";
|
||||
export * from "./root";
|
||||
48
web/ee/components/projects/applied-filters/members.tsx
Normal file
48
web/ee/components/projects/applied-filters/members.tsx
Normal 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>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
});
|
||||
141
web/ee/components/projects/applied-filters/root.tsx
Normal file
141
web/ee/components/projects/applied-filters/root.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
3
web/ee/components/projects/dropdowns/index.ts
Normal file
3
web/ee/components/projects/dropdowns/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./scope-dropdown";
|
||||
export * from "./priority-dropdown";
|
||||
export * from "./state-dropdown";
|
||||
42
web/ee/components/projects/dropdowns/members-dropdown.tsx
Normal file
42
web/ee/components/projects/dropdowns/members-dropdown.tsx
Normal 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;
|
||||
481
web/ee/components/projects/dropdowns/priority-dropdown.tsx
Normal file
481
web/ee/components/projects/dropdowns/priority-dropdown.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
75
web/ee/components/projects/dropdowns/scope-dropdown.tsx
Normal file
75
web/ee/components/projects/dropdowns/scope-dropdown.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
60
web/ee/components/projects/dropdowns/state-dropdown.tsx
Normal file
60
web/ee/components/projects/dropdowns/state-dropdown.tsx
Normal 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
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -1 +0,0 @@
|
||||
export * from "ce/components/projects/header";
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from "./root";
|
||||
export * from "./priority";
|
||||
export * from "./state";
|
||||
export * from "./users";
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
101
web/ee/components/projects/header/attributes-dropdown/state.tsx
Normal file
101
web/ee/components/projects/header/attributes-dropdown/state.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
111
web/ee/components/projects/header/attributes-dropdown/users.tsx
Normal file
111
web/ee/components/projects/header/attributes-dropdown/users.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
35
web/ee/components/projects/header/create-project-button.tsx
Normal file
35
web/ee/components/projects/header/create-project-button.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from "./root";
|
||||
export * from "./group-by";
|
||||
export * from "./sort-by";
|
||||
export * from "./sort-order";
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
6
web/ee/components/projects/header/index.ts
Normal file
6
web/ee/components/projects/header/index.ts
Normal 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";
|
||||
70
web/ee/components/projects/header/layout-selection.tsx
Normal file
70
web/ee/components/projects/header/layout-selection.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
});
|
||||
77
web/ee/components/projects/header/root.tsx
Normal file
77
web/ee/components/projects/header/root.tsx
Normal 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 />
|
||||
);
|
||||
});
|
||||
77
web/ee/components/projects/header/search-projects.tsx
Normal file
77
web/ee/components/projects/header/search-projects.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
6
web/ee/components/projects/index.ts
Normal file
6
web/ee/components/projects/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export * from "./dropdowns";
|
||||
export * from "./header";
|
||||
|
||||
export * from "./root";
|
||||
|
||||
export * from "./layouts";
|
||||
@@ -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} />
|
||||
<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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./root";
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
});
|
||||
111
web/ee/components/projects/layouts/board/group-item.tsx
Normal file
111
web/ee/components/projects/layouts/board/group-item.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
38
web/ee/components/projects/layouts/board/group.tsx
Normal file
38
web/ee/components/projects/layouts/board/group.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
6
web/ee/components/projects/layouts/board/index.ts
Normal file
6
web/ee/components/projects/layouts/board/index.ts
Normal 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";
|
||||
69
web/ee/components/projects/layouts/board/list-item.tsx
Normal file
69
web/ee/components/projects/layouts/board/list-item.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
22
web/ee/components/projects/layouts/board/list.tsx
Normal file
22
web/ee/components/projects/layouts/board/list.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
24
web/ee/components/projects/layouts/board/root.tsx
Normal file
24
web/ee/components/projects/layouts/board/root.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
87
web/ee/components/projects/layouts/board/utils.tsx
Normal file
87
web/ee/components/projects/layouts/board/utils.tsx
Normal 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: {},
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
147
web/ee/components/projects/layouts/gallery/attributes.tsx
Normal file
147
web/ee/components/projects/layouts/gallery/attributes.tsx
Normal 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;
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
112
web/ee/components/projects/layouts/gallery/card.tsx
Normal file
112
web/ee/components/projects/layouts/gallery/card.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
});
|
||||
212
web/ee/components/projects/layouts/gallery/details.tsx
Normal file
212
web/ee/components/projects/layouts/gallery/details.tsx
Normal 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;
|
||||
86
web/ee/components/projects/layouts/gantt/base-gantt-root.tsx
Normal file
86
web/ee/components/projects/layouts/gantt/base-gantt-root.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
112
web/ee/components/projects/layouts/gantt/blocks.tsx
Normal file
112
web/ee/components/projects/layouts/gantt/blocks.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
2
web/ee/components/projects/layouts/gantt/index.ts
Normal file
2
web/ee/components/projects/layouts/gantt/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./blocks";
|
||||
export * from "./base-gantt-root";
|
||||
52
web/ee/components/projects/layouts/gantt/sidebar.tsx
Normal file
52
web/ee/components/projects/layouts/gantt/sidebar.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
2
web/ee/components/projects/layouts/index.ts
Normal file
2
web/ee/components/projects/layouts/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./root";
|
||||
export * from "./board";
|
||||
54
web/ee/components/projects/layouts/root.tsx
Normal file
54
web/ee/components/projects/layouts/root.tsx
Normal 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} />;
|
||||
});
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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";
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
4
web/ee/components/projects/layouts/spreadsheet/index.ts
Normal file
4
web/ee/components/projects/layouts/spreadsheet/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from "./columns";
|
||||
export * from "./base-spreadsheet-root";
|
||||
export * from "./spreadsheet-view";
|
||||
export * from "./spreadsheet-header-column";
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
154
web/ee/components/projects/layouts/spreadsheet/project-row.tsx
Normal file
154
web/ee/components/projects/layouts/spreadsheet/project-row.tsx
Normal 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}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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 />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
25
web/ee/components/projects/root.tsx
Normal file
25
web/ee/components/projects/root.tsx
Normal 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
Reference in New Issue
Block a user