Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6bdbf4e536 | |||
| 94f421f27d | |||
| 8d7425a3b7 | |||
| 211d5e1cd0 | |||
| 3c6bbaef3c | |||
| 4159d12959 | |||
| 2f2f8dc5f4 | |||
| 756a71ca78 | |||
| 36b3328c5e | |||
| a5c1282e52 | |||
| ed64168ca7 | |||
| f54f3a6091 | |||
| 2d9464e841 | |||
| 70f72a2b0f | |||
| c0b5e0e766 | |||
| fedcdf0c84 | |||
| ff936887d2 |
@@ -116,7 +116,7 @@ class WebhookSerializer(DynamicBaseSerializer):
|
||||
class Meta:
|
||||
model = Webhook
|
||||
fields = "__all__"
|
||||
read_only_fields = ["workspace", "secret_key"]
|
||||
read_only_fields = ["workspace", "secret_key", "deleted_at"]
|
||||
|
||||
|
||||
class WebhookLogSerializer(DynamicBaseSerializer):
|
||||
|
||||
@@ -16,7 +16,7 @@ urlpatterns = [
|
||||
name="project-issue-search",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/entity-search/",
|
||||
"workspaces/<str:slug>/entity-search/",
|
||||
SearchEndpoint.as_view(),
|
||||
name="entity-search",
|
||||
),
|
||||
|
||||
@@ -34,6 +34,7 @@ from plane.db.models import (
|
||||
IssueView,
|
||||
ProjectMember,
|
||||
ProjectPage,
|
||||
WorkspaceMember,
|
||||
)
|
||||
|
||||
|
||||
@@ -252,214 +253,456 @@ class GlobalSearchEndpoint(BaseAPIView):
|
||||
|
||||
|
||||
class SearchEndpoint(BaseAPIView):
|
||||
def get(self, request, slug, project_id):
|
||||
def get(self, request, slug):
|
||||
query = request.query_params.get("query", False)
|
||||
query_types = request.query_params.get("query_type", "user_mention").split(",")
|
||||
query_types = [qt.strip() for qt in query_types]
|
||||
count = int(request.query_params.get("count", 5))
|
||||
project_id = request.query_params.get("project_id", None)
|
||||
issue_id = request.query_params.get("issue_id", None)
|
||||
|
||||
response_data = {}
|
||||
|
||||
for query_type in query_types:
|
||||
if query_type == "user_mention":
|
||||
fields = [
|
||||
"member__first_name",
|
||||
"member__last_name",
|
||||
"member__display_name",
|
||||
]
|
||||
q = Q()
|
||||
|
||||
if query:
|
||||
for field in fields:
|
||||
q |= Q(**{f"{field}__icontains": query})
|
||||
users = (
|
||||
ProjectMember.objects.filter(
|
||||
q, is_active=True, project_id=project_id, workspace__slug=slug, member__is_bot=False
|
||||
)
|
||||
.annotate(
|
||||
member__avatar_url=Case(
|
||||
When(
|
||||
member__avatar_asset__isnull=False,
|
||||
then=Concat(
|
||||
Value("/api/assets/v2/static/"),
|
||||
"member__avatar_asset",
|
||||
Value("/"),
|
||||
),
|
||||
),
|
||||
When(
|
||||
member__avatar_asset__isnull=True, then="member__avatar"
|
||||
),
|
||||
default=Value(None),
|
||||
output_field=models.CharField(),
|
||||
)
|
||||
)
|
||||
.order_by("-created_at")
|
||||
.values("member__avatar_url", "member__display_name", "member__id")[
|
||||
:count
|
||||
if project_id:
|
||||
for query_type in query_types:
|
||||
if query_type == "user_mention":
|
||||
fields = [
|
||||
"member__first_name",
|
||||
"member__last_name",
|
||||
"member__display_name",
|
||||
]
|
||||
)
|
||||
response_data["user_mention"] = list(users)
|
||||
q = Q()
|
||||
|
||||
elif query_type == "project":
|
||||
fields = ["name", "identifier"]
|
||||
q = Q()
|
||||
|
||||
if query:
|
||||
for field in fields:
|
||||
q |= Q(**{f"{field}__icontains": query})
|
||||
projects = (
|
||||
Project.objects.filter(
|
||||
q,
|
||||
Q(project_projectmember__member=self.request.user)
|
||||
| Q(network=2),
|
||||
workspace__slug=slug,
|
||||
)
|
||||
.order_by("-created_at")
|
||||
.distinct()
|
||||
.values(
|
||||
"name", "id", "identifier", "logo_props", "workspace__slug"
|
||||
)[:count]
|
||||
)
|
||||
response_data["project"] = list(projects)
|
||||
|
||||
elif query_type == "issue":
|
||||
fields = ["name", "sequence_id", "project__identifier"]
|
||||
q = Q()
|
||||
|
||||
if query:
|
||||
for field in fields:
|
||||
if field == "sequence_id":
|
||||
sequences = re.findall(r"\b\d+\b", query)
|
||||
for sequence_id in sequences:
|
||||
q |= Q(**{"sequence_id": sequence_id})
|
||||
else:
|
||||
if query:
|
||||
for field in fields:
|
||||
q |= Q(**{f"{field}__icontains": query})
|
||||
|
||||
issues = (
|
||||
Issue.issue_objects.filter(
|
||||
base_filters = Q(
|
||||
q,
|
||||
project__project_projectmember__member=self.request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
is_active=True,
|
||||
workspace__slug=slug,
|
||||
member__is_bot=False,
|
||||
project_id=project_id,
|
||||
role__gt=10,
|
||||
)
|
||||
.order_by("-created_at")
|
||||
.distinct()
|
||||
.values(
|
||||
"name",
|
||||
"id",
|
||||
"sequence_id",
|
||||
"project__identifier",
|
||||
"project_id",
|
||||
"priority",
|
||||
"state_id",
|
||||
"type_id",
|
||||
)[:count]
|
||||
)
|
||||
response_data["issue"] = list(issues)
|
||||
|
||||
elif query_type == "cycle":
|
||||
fields = ["name"]
|
||||
q = Q()
|
||||
|
||||
if query:
|
||||
for field in fields:
|
||||
q |= Q(**{f"{field}__icontains": query})
|
||||
|
||||
cycles = (
|
||||
Cycle.objects.filter(
|
||||
q,
|
||||
project__project_projectmember__member=self.request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
workspace__slug=slug,
|
||||
)
|
||||
.annotate(
|
||||
status=Case(
|
||||
When(
|
||||
Q(start_date__lte=timezone.now())
|
||||
& Q(end_date__gte=timezone.now()),
|
||||
then=Value("CURRENT"),
|
||||
),
|
||||
When(start_date__gt=timezone.now(), then=Value("UPCOMING")),
|
||||
When(end_date__lt=timezone.now(), then=Value("COMPLETED")),
|
||||
When(
|
||||
Q(start_date__isnull=True) & Q(end_date__isnull=True),
|
||||
then=Value("DRAFT"),
|
||||
),
|
||||
default=Value("DRAFT"),
|
||||
output_field=CharField(),
|
||||
if issue_id:
|
||||
issue_created_by = (
|
||||
Issue.objects.filter(id=issue_id)
|
||||
.values_list("created_by_id", flat=True)
|
||||
.first()
|
||||
)
|
||||
# Add condition to include `issue_created_by` in the query
|
||||
filters = Q(member_id=issue_created_by) | base_filters
|
||||
else:
|
||||
filters = base_filters
|
||||
|
||||
# Query to fetch users
|
||||
users = (
|
||||
ProjectMember.objects.filter(filters)
|
||||
.annotate(
|
||||
member__avatar_url=Case(
|
||||
When(
|
||||
member__avatar_asset__isnull=False,
|
||||
then=Concat(
|
||||
Value("/api/assets/v2/static/"),
|
||||
"member__avatar_asset",
|
||||
Value("/"),
|
||||
),
|
||||
),
|
||||
When(
|
||||
member__avatar_asset__isnull=True,
|
||||
then="member__avatar",
|
||||
),
|
||||
default=Value(None),
|
||||
output_field=CharField(),
|
||||
)
|
||||
)
|
||||
.order_by("-created_at")
|
||||
.values(
|
||||
"member__avatar_url",
|
||||
"member__display_name",
|
||||
"member__id",
|
||||
)[:count]
|
||||
)
|
||||
.order_by("-created_at")
|
||||
.distinct()
|
||||
.values(
|
||||
"name",
|
||||
"id",
|
||||
"project_id",
|
||||
"project__identifier",
|
||||
"status",
|
||||
"workspace__slug",
|
||||
)[:count]
|
||||
)
|
||||
response_data["cycle"] = list(cycles)
|
||||
|
||||
elif query_type == "module":
|
||||
fields = ["name"]
|
||||
q = Q()
|
||||
response_data["user_mention"] = list(users)
|
||||
|
||||
if query:
|
||||
for field in fields:
|
||||
q |= Q(**{f"{field}__icontains": query})
|
||||
elif query_type == "project":
|
||||
fields = ["name", "identifier"]
|
||||
q = Q()
|
||||
|
||||
modules = (
|
||||
Module.objects.filter(
|
||||
q,
|
||||
project__project_projectmember__member=self.request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
workspace__slug=slug,
|
||||
if query:
|
||||
for field in fields:
|
||||
q |= Q(**{f"{field}__icontains": query})
|
||||
projects = (
|
||||
Project.objects.filter(
|
||||
q,
|
||||
Q(project_projectmember__member=self.request.user)
|
||||
| Q(network=2),
|
||||
workspace__slug=slug,
|
||||
)
|
||||
.order_by("-created_at")
|
||||
.distinct()
|
||||
.values(
|
||||
"name", "id", "identifier", "logo_props", "workspace__slug"
|
||||
)[:count]
|
||||
)
|
||||
.order_by("-created_at")
|
||||
.distinct()
|
||||
.values(
|
||||
"name",
|
||||
"id",
|
||||
"project_id",
|
||||
"project__identifier",
|
||||
"status",
|
||||
"workspace__slug",
|
||||
)[:count]
|
||||
)
|
||||
response_data["module"] = list(modules)
|
||||
response_data["project"] = list(projects)
|
||||
|
||||
elif query_type == "page":
|
||||
fields = ["name"]
|
||||
q = Q()
|
||||
elif query_type == "issue":
|
||||
fields = ["name", "sequence_id", "project__identifier"]
|
||||
q = Q()
|
||||
|
||||
if query:
|
||||
for field in fields:
|
||||
q |= Q(**{f"{field}__icontains": query})
|
||||
if query:
|
||||
for field in fields:
|
||||
if field == "sequence_id":
|
||||
sequences = re.findall(r"\b\d+\b", query)
|
||||
for sequence_id in sequences:
|
||||
q |= Q(**{"sequence_id": sequence_id})
|
||||
else:
|
||||
q |= Q(**{f"{field}__icontains": query})
|
||||
|
||||
pages = (
|
||||
Page.objects.filter(
|
||||
q,
|
||||
projects__project_projectmember__member=self.request.user,
|
||||
projects__project_projectmember__is_active=True,
|
||||
projects__id=project_id,
|
||||
workspace__slug=slug,
|
||||
access=0,
|
||||
issues = (
|
||||
Issue.issue_objects.filter(
|
||||
q,
|
||||
project__project_projectmember__member=self.request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
)
|
||||
.order_by("-created_at")
|
||||
.distinct()
|
||||
.values(
|
||||
"name",
|
||||
"id",
|
||||
"sequence_id",
|
||||
"project__identifier",
|
||||
"project_id",
|
||||
"priority",
|
||||
"state_id",
|
||||
"type_id",
|
||||
)[:count]
|
||||
)
|
||||
.order_by("-created_at")
|
||||
.distinct()
|
||||
.values(
|
||||
"name", "id", "logo_props", "projects__id", "workspace__slug"
|
||||
)[:count]
|
||||
)
|
||||
response_data["page"] = list(pages)
|
||||
response_data["issue"] = list(issues)
|
||||
|
||||
else:
|
||||
return Response(
|
||||
{"error": f"Invalid query type: {query_type}"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
elif query_type == "cycle":
|
||||
fields = ["name"]
|
||||
q = Q()
|
||||
|
||||
return Response(response_data, status=status.HTTP_200_OK)
|
||||
if query:
|
||||
for field in fields:
|
||||
q |= Q(**{f"{field}__icontains": query})
|
||||
|
||||
cycles = (
|
||||
Cycle.objects.filter(
|
||||
q,
|
||||
project__project_projectmember__member=self.request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
)
|
||||
.annotate(
|
||||
status=Case(
|
||||
When(
|
||||
Q(start_date__lte=timezone.now())
|
||||
& Q(end_date__gte=timezone.now()),
|
||||
then=Value("CURRENT"),
|
||||
),
|
||||
When(
|
||||
start_date__gt=timezone.now(),
|
||||
then=Value("UPCOMING"),
|
||||
),
|
||||
When(
|
||||
end_date__lt=timezone.now(), then=Value("COMPLETED")
|
||||
),
|
||||
When(
|
||||
Q(start_date__isnull=True)
|
||||
& Q(end_date__isnull=True),
|
||||
then=Value("DRAFT"),
|
||||
),
|
||||
default=Value("DRAFT"),
|
||||
output_field=CharField(),
|
||||
)
|
||||
)
|
||||
.order_by("-created_at")
|
||||
.distinct()
|
||||
.values(
|
||||
"name",
|
||||
"id",
|
||||
"project_id",
|
||||
"project__identifier",
|
||||
"status",
|
||||
"workspace__slug",
|
||||
)[:count]
|
||||
)
|
||||
response_data["cycle"] = list(cycles)
|
||||
|
||||
elif query_type == "module":
|
||||
fields = ["name"]
|
||||
q = Q()
|
||||
|
||||
if query:
|
||||
for field in fields:
|
||||
q |= Q(**{f"{field}__icontains": query})
|
||||
|
||||
modules = (
|
||||
Module.objects.filter(
|
||||
q,
|
||||
project__project_projectmember__member=self.request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
)
|
||||
.order_by("-created_at")
|
||||
.distinct()
|
||||
.values(
|
||||
"name",
|
||||
"id",
|
||||
"project_id",
|
||||
"project__identifier",
|
||||
"status",
|
||||
"workspace__slug",
|
||||
)[:count]
|
||||
)
|
||||
response_data["module"] = list(modules)
|
||||
|
||||
elif query_type == "page":
|
||||
fields = ["name"]
|
||||
q = Q()
|
||||
|
||||
if query:
|
||||
for field in fields:
|
||||
q |= Q(**{f"{field}__icontains": query})
|
||||
|
||||
pages = (
|
||||
Page.objects.filter(
|
||||
q,
|
||||
projects__project_projectmember__member=self.request.user,
|
||||
projects__project_projectmember__is_active=True,
|
||||
projects__id=project_id,
|
||||
workspace__slug=slug,
|
||||
access=0,
|
||||
)
|
||||
.order_by("-created_at")
|
||||
.distinct()
|
||||
.values(
|
||||
"name",
|
||||
"id",
|
||||
"logo_props",
|
||||
"projects__id",
|
||||
"workspace__slug",
|
||||
)[:count]
|
||||
)
|
||||
response_data["page"] = list(pages)
|
||||
return Response(response_data, status=status.HTTP_200_OK)
|
||||
|
||||
else:
|
||||
for query_type in query_types:
|
||||
if query_type == "user_mention":
|
||||
fields = [
|
||||
"member__first_name",
|
||||
"member__last_name",
|
||||
"member__display_name",
|
||||
]
|
||||
q = Q()
|
||||
|
||||
if query:
|
||||
for field in fields:
|
||||
q |= Q(**{f"{field}__icontains": query})
|
||||
users = (
|
||||
WorkspaceMember.objects.filter(
|
||||
q,
|
||||
is_active=True,
|
||||
workspace__slug=slug,
|
||||
member__is_bot=False,
|
||||
)
|
||||
.annotate(
|
||||
member__avatar_url=Case(
|
||||
When(
|
||||
member__avatar_asset__isnull=False,
|
||||
then=Concat(
|
||||
Value("/api/assets/v2/static/"),
|
||||
"member__avatar_asset",
|
||||
Value("/"),
|
||||
),
|
||||
),
|
||||
When(
|
||||
member__avatar_asset__isnull=True,
|
||||
then="member__avatar",
|
||||
),
|
||||
default=Value(None),
|
||||
output_field=models.CharField(),
|
||||
)
|
||||
)
|
||||
.order_by("-created_at")
|
||||
.values(
|
||||
"member__avatar_url", "member__display_name", "member__id"
|
||||
)[:count]
|
||||
)
|
||||
response_data["user_mention"] = list(users)
|
||||
|
||||
elif query_type == "project":
|
||||
fields = ["name", "identifier"]
|
||||
q = Q()
|
||||
|
||||
if query:
|
||||
for field in fields:
|
||||
q |= Q(**{f"{field}__icontains": query})
|
||||
projects = (
|
||||
Project.objects.filter(
|
||||
q,
|
||||
Q(project_projectmember__member=self.request.user)
|
||||
| Q(network=2),
|
||||
workspace__slug=slug,
|
||||
)
|
||||
.order_by("-created_at")
|
||||
.distinct()
|
||||
.values(
|
||||
"name", "id", "identifier", "logo_props", "workspace__slug"
|
||||
)[:count]
|
||||
)
|
||||
response_data["project"] = list(projects)
|
||||
|
||||
elif query_type == "issue":
|
||||
fields = ["name", "sequence_id", "project__identifier"]
|
||||
q = Q()
|
||||
|
||||
if query:
|
||||
for field in fields:
|
||||
if field == "sequence_id":
|
||||
sequences = re.findall(r"\b\d+\b", query)
|
||||
for sequence_id in sequences:
|
||||
q |= Q(**{"sequence_id": sequence_id})
|
||||
else:
|
||||
q |= Q(**{f"{field}__icontains": query})
|
||||
|
||||
issues = (
|
||||
Issue.issue_objects.filter(
|
||||
q,
|
||||
project__project_projectmember__member=self.request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
workspace__slug=slug,
|
||||
)
|
||||
.order_by("-created_at")
|
||||
.distinct()
|
||||
.values(
|
||||
"name",
|
||||
"id",
|
||||
"sequence_id",
|
||||
"project__identifier",
|
||||
"project_id",
|
||||
"priority",
|
||||
"state_id",
|
||||
"type_id",
|
||||
)[:count]
|
||||
)
|
||||
response_data["issue"] = list(issues)
|
||||
|
||||
elif query_type == "cycle":
|
||||
fields = ["name"]
|
||||
q = Q()
|
||||
|
||||
if query:
|
||||
for field in fields:
|
||||
q |= Q(**{f"{field}__icontains": query})
|
||||
|
||||
cycles = (
|
||||
Cycle.objects.filter(
|
||||
q,
|
||||
project__project_projectmember__member=self.request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
workspace__slug=slug,
|
||||
)
|
||||
.annotate(
|
||||
status=Case(
|
||||
When(
|
||||
Q(start_date__lte=timezone.now())
|
||||
& Q(end_date__gte=timezone.now()),
|
||||
then=Value("CURRENT"),
|
||||
),
|
||||
When(
|
||||
start_date__gt=timezone.now(),
|
||||
then=Value("UPCOMING"),
|
||||
),
|
||||
When(
|
||||
end_date__lt=timezone.now(), then=Value("COMPLETED")
|
||||
),
|
||||
When(
|
||||
Q(start_date__isnull=True)
|
||||
& Q(end_date__isnull=True),
|
||||
then=Value("DRAFT"),
|
||||
),
|
||||
default=Value("DRAFT"),
|
||||
output_field=CharField(),
|
||||
)
|
||||
)
|
||||
.order_by("-created_at")
|
||||
.distinct()
|
||||
.values(
|
||||
"name",
|
||||
"id",
|
||||
"project_id",
|
||||
"project__identifier",
|
||||
"status",
|
||||
"workspace__slug",
|
||||
)[:count]
|
||||
)
|
||||
response_data["cycle"] = list(cycles)
|
||||
|
||||
elif query_type == "module":
|
||||
fields = ["name"]
|
||||
q = Q()
|
||||
|
||||
if query:
|
||||
for field in fields:
|
||||
q |= Q(**{f"{field}__icontains": query})
|
||||
|
||||
modules = (
|
||||
Module.objects.filter(
|
||||
q,
|
||||
project__project_projectmember__member=self.request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
workspace__slug=slug,
|
||||
)
|
||||
.order_by("-created_at")
|
||||
.distinct()
|
||||
.values(
|
||||
"name",
|
||||
"id",
|
||||
"project_id",
|
||||
"project__identifier",
|
||||
"status",
|
||||
"workspace__slug",
|
||||
)[:count]
|
||||
)
|
||||
response_data["module"] = list(modules)
|
||||
|
||||
elif query_type == "page":
|
||||
fields = ["name"]
|
||||
q = Q()
|
||||
|
||||
if query:
|
||||
for field in fields:
|
||||
q |= Q(**{f"{field}__icontains": query})
|
||||
|
||||
pages = (
|
||||
Page.objects.filter(
|
||||
q,
|
||||
projects__project_projectmember__member=self.request.user,
|
||||
projects__project_projectmember__is_active=True,
|
||||
workspace__slug=slug,
|
||||
access=0,
|
||||
is_global=True,
|
||||
)
|
||||
.order_by("-created_at")
|
||||
.distinct()
|
||||
.values(
|
||||
"name",
|
||||
"id",
|
||||
"logo_props",
|
||||
"projects__id",
|
||||
"workspace__slug",
|
||||
)[:count]
|
||||
)
|
||||
response_data["page"] = list(pages)
|
||||
return Response(response_data, status=status.HTTP_200_OK)
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
# Generated by Django 4.2.15 on 2024-12-24 14:57
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('db', '0087_remove_issueversion_description_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="sticky",
|
||||
name="sort_order",
|
||||
field=models.FloatField(default=65535),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="WorkspaceUserLink",
|
||||
fields=[
|
||||
(
|
||||
"created_at",
|
||||
models.DateTimeField(auto_now_add=True, verbose_name="Created At"),
|
||||
),
|
||||
(
|
||||
"updated_at",
|
||||
models.DateTimeField(
|
||||
auto_now=True, verbose_name="Last Modified At"
|
||||
),
|
||||
),
|
||||
(
|
||||
"deleted_at",
|
||||
models.DateTimeField(
|
||||
blank=True, null=True, verbose_name="Deleted At"
|
||||
),
|
||||
),
|
||||
(
|
||||
"id",
|
||||
models.UUIDField(
|
||||
db_index=True,
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
unique=True,
|
||||
),
|
||||
),
|
||||
("title", models.CharField(blank=True, max_length=255, null=True)),
|
||||
("url", models.TextField()),
|
||||
("metadata", models.JSONField(default=dict)),
|
||||
(
|
||||
"created_by",
|
||||
models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="%(class)s_created_by",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="Created By",
|
||||
),
|
||||
),
|
||||
(
|
||||
"owner",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="owner_workspace_user_link",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
(
|
||||
"project",
|
||||
models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="project_%(class)s",
|
||||
to="db.project",
|
||||
),
|
||||
),
|
||||
(
|
||||
"updated_by",
|
||||
models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="%(class)s_updated_by",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="Last Modified By",
|
||||
),
|
||||
),
|
||||
(
|
||||
"workspace",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="workspace_%(class)s",
|
||||
to="db.workspace",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Workspace User Link",
|
||||
"verbose_name_plural": "Workspace User Links",
|
||||
"db_table": "workspace_user_links",
|
||||
"ordering": ("-created_at",),
|
||||
},
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="pagelog",
|
||||
name="entity_name",
|
||||
field=models.CharField(max_length=30, verbose_name="Transaction Type"),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name="webhook",
|
||||
unique_together={("workspace", "url", "deleted_at")},
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="webhook",
|
||||
constraint=models.UniqueConstraint(
|
||||
condition=models.Q(("deleted_at__isnull", True)),
|
||||
fields=("workspace", "url"),
|
||||
name="webhook_url_unique_url_when_deleted_at_null",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -68,6 +68,7 @@ from .workspace import (
|
||||
WorkspaceMemberInvite,
|
||||
WorkspaceTheme,
|
||||
WorkspaceUserProperties,
|
||||
WorkspaceUserLink,
|
||||
)
|
||||
|
||||
from .favorite import UserFavorite
|
||||
|
||||
@@ -90,7 +90,7 @@ class PageLog(BaseModel):
|
||||
page = models.ForeignKey(Page, related_name="page_log", on_delete=models.CASCADE)
|
||||
entity_identifier = models.UUIDField(null=True)
|
||||
entity_name = models.CharField(
|
||||
max_length=30, choices=TYPE_CHOICES, verbose_name="Transaction Type"
|
||||
max_length=30, verbose_name="Transaction Type"
|
||||
)
|
||||
workspace = models.ForeignKey(
|
||||
"db.Workspace", on_delete=models.CASCADE, related_name="workspace_page_log"
|
||||
|
||||
@@ -24,9 +24,25 @@ class Sticky(BaseModel):
|
||||
owner = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="stickies"
|
||||
)
|
||||
sort_order = models.FloatField(default=65535)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Sticky"
|
||||
verbose_name_plural = "Stickies"
|
||||
db_table = "stickies"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if self._state.adding:
|
||||
# Get the maximum sequence value from the database
|
||||
last_id = Sticky.objects.filter(workspace=self.workspace).aggregate(
|
||||
largest=models.Max("sort_order")
|
||||
)["largest"]
|
||||
# if last_id is not None
|
||||
if last_id is not None:
|
||||
self.sort_order = last_id + 10000
|
||||
|
||||
super(Sticky, self).save(*args, **kwargs)
|
||||
|
||||
def __str__(self):
|
||||
return str(self.name)
|
||||
|
||||
@@ -47,11 +47,18 @@ class Webhook(BaseModel):
|
||||
return f"{self.workspace.slug} {self.url}"
|
||||
|
||||
class Meta:
|
||||
unique_together = ["workspace", "url"]
|
||||
unique_together = ["workspace", "url", "deleted_at"]
|
||||
verbose_name = "Webhook"
|
||||
verbose_name_plural = "Webhooks"
|
||||
db_table = "webhooks"
|
||||
ordering = ("-created_at",)
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["workspace", "url"],
|
||||
condition=models.Q(deleted_at__isnull=True),
|
||||
name="webhook_url_unique_url_when_deleted_at_null",
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
class WebhookLog(BaseModel):
|
||||
|
||||
@@ -322,3 +322,23 @@ class WorkspaceUserProperties(BaseModel):
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.workspace.name} {self.user.email}"
|
||||
|
||||
|
||||
class WorkspaceUserLink(WorkspaceBaseModel):
|
||||
title = models.CharField(max_length=255, null=True, blank=True)
|
||||
url = models.TextField()
|
||||
metadata = models.JSONField(default=dict)
|
||||
owner = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="owner_workspace_user_link",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Workspace User Link"
|
||||
verbose_name_plural = "Workspace User Links"
|
||||
db_table = "workspace_user_links"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.workspace.id} {self.url}"
|
||||
@@ -0,0 +1,6 @@
|
||||
.next
|
||||
.turbo
|
||||
out/
|
||||
dist/
|
||||
build/
|
||||
node_modules/
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"printWidth": 120,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "es5"
|
||||
}
|
||||
@@ -7,7 +7,7 @@ export enum E_PASSWORD_STRENGTH {
|
||||
|
||||
export const PASSWORD_MIN_LENGTH = 8;
|
||||
|
||||
export const PASSWORD_CRITERIA = [
|
||||
export const SPACE_PASSWORD_CRITERIA = [
|
||||
{
|
||||
key: "min_8_char",
|
||||
label: "Min 8 characters",
|
||||
|
||||
@@ -168,32 +168,59 @@ export const DragHandlePlugin = (options: SideMenuPluginProps): SideMenuHandleOp
|
||||
const scrollableParent = getScrollParent(dragHandleElement);
|
||||
if (!scrollableParent) return;
|
||||
|
||||
const viewportHeight = window.innerHeight;
|
||||
const scrollRegionUp = options.scrollThreshold.up;
|
||||
const scrollRegionDown = window.innerHeight - options.scrollThreshold.down;
|
||||
|
||||
const baseSpeed = maxScrollSpeed;
|
||||
const viewportSpeedMultiplier = Math.log10(viewportHeight / 500) + 1;
|
||||
const adjustedMaxSpeed = (baseSpeed / viewportSpeedMultiplier) * 0.8;
|
||||
|
||||
let targetScrollAmount = 0;
|
||||
|
||||
const customEasing = (t: number) => t * t * t;
|
||||
if (isDraggedOutsideWindow === "top") {
|
||||
targetScrollAmount = -maxScrollSpeed * 5;
|
||||
// reduce multiplier for outside window scrolling
|
||||
targetScrollAmount = -adjustedMaxSpeed * 3;
|
||||
} else if (isDraggedOutsideWindow === "bottom") {
|
||||
targetScrollAmount = maxScrollSpeed * 5;
|
||||
targetScrollAmount = adjustedMaxSpeed * 3;
|
||||
} else if (lastClientY < scrollRegionUp) {
|
||||
const ratio = easeOutQuadAnimation((scrollRegionUp - lastClientY) / options.scrollThreshold.up);
|
||||
targetScrollAmount = -maxScrollSpeed * ratio;
|
||||
const distance = scrollRegionUp - lastClientY;
|
||||
const normalizedDistance = distance / scrollRegionUp;
|
||||
|
||||
// apply multiple easing functions for more control
|
||||
const easedRatio = customEasing(normalizedDistance);
|
||||
const smoothedRatio = easeOutQuadAnimation(easedRatio);
|
||||
|
||||
targetScrollAmount = -adjustedMaxSpeed * smoothedRatio;
|
||||
} else if (lastClientY > scrollRegionDown) {
|
||||
const ratio = easeOutQuadAnimation((lastClientY - scrollRegionDown) / options.scrollThreshold.down);
|
||||
targetScrollAmount = maxScrollSpeed * ratio;
|
||||
const distance = lastClientY - scrollRegionDown;
|
||||
const normalizedDistance = distance / (viewportHeight - scrollRegionDown);
|
||||
|
||||
// apply multiple easing functions for more control
|
||||
const easedRatio = customEasing(normalizedDistance);
|
||||
const smoothedRatio = easeOutQuadAnimation(easedRatio);
|
||||
|
||||
targetScrollAmount = adjustedMaxSpeed * smoothedRatio;
|
||||
}
|
||||
|
||||
currentScrollSpeed += (targetScrollAmount - currentScrollSpeed) * acceleration;
|
||||
// dampening the speed based on screen size
|
||||
const dampeningFactor = Math.max(0.3, Math.min(1, viewportHeight / 1000));
|
||||
targetScrollAmount *= dampeningFactor;
|
||||
|
||||
if (Math.abs(currentScrollSpeed) > 0.1) {
|
||||
scrollableParent.scrollBy({ top: currentScrollSpeed });
|
||||
// reduce acceleration for smoother ramping
|
||||
const reducedAcceleration = acceleration * 0.75;
|
||||
currentScrollSpeed += (targetScrollAmount - currentScrollSpeed) * reducedAcceleration;
|
||||
|
||||
// Add minimum threshold for very slow speeds
|
||||
if (Math.abs(currentScrollSpeed) > 0.05) {
|
||||
// Apply additional smoothing to the final scroll amount
|
||||
const smoothedSpeed = Math.sign(currentScrollSpeed) * Math.pow(Math.abs(currentScrollSpeed), 1.2);
|
||||
scrollableParent.scrollBy({ top: smoothedSpeed });
|
||||
}
|
||||
|
||||
scrollAnimationFrame = requestAnimationFrame(scroll);
|
||||
}
|
||||
|
||||
let dragHandleElement: HTMLElement | null = null;
|
||||
// drag handle view actions
|
||||
const showDragHandle = () => dragHandleElement?.classList.remove("drag-handle-hidden");
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { Extensions, JSONContent } from "@tiptap/core";
|
||||
import { Selection } from "@tiptap/pm/state";
|
||||
// plane types
|
||||
import { TWebhookConnectionQueryParams } from "@plane/types";
|
||||
// extension types
|
||||
import { TTextAlign } from "@/extensions";
|
||||
// helpers
|
||||
import { IMarking } from "@/helpers/scroll-to-node";
|
||||
// types
|
||||
@@ -15,7 +19,6 @@ import {
|
||||
TReadOnlyMentionHandler,
|
||||
TServerHandler,
|
||||
} from "@/types";
|
||||
import { TTextAlign } from "@/extensions";
|
||||
|
||||
export type TEditorCommands =
|
||||
| "text"
|
||||
@@ -185,7 +188,5 @@ export type TUserDetails = {
|
||||
|
||||
export type TRealtimeConfig = {
|
||||
url: string;
|
||||
queryParams: {
|
||||
[key: string]: string;
|
||||
};
|
||||
queryParams: TWebhookConnectionQueryParams;
|
||||
};
|
||||
|
||||
Vendored
+16
@@ -0,0 +1,16 @@
|
||||
export type TEpicAnalyticsGroup =
|
||||
| "backlog_issues"
|
||||
| "unstarted_issues"
|
||||
| "started_issues"
|
||||
| "completed_issues"
|
||||
| "cancelled_issues"
|
||||
| "overdue_issues";
|
||||
|
||||
export type TEpicAnalytics = {
|
||||
backlog_issues: number;
|
||||
unstarted_issues: number;
|
||||
started_issues: number;
|
||||
completed_issues: number;
|
||||
cancelled_issues: number;
|
||||
overdue_issues: number;
|
||||
};
|
||||
Vendored
+1
@@ -36,3 +36,4 @@ export * from "./workspace-draft-issues/base";
|
||||
export * from "./command-palette";
|
||||
export * from "./timezone";
|
||||
export * from "./activity";
|
||||
export * from "./epics";
|
||||
|
||||
Vendored
+12
-8
@@ -15,7 +15,8 @@ export type TPage = {
|
||||
label_ids: string[] | undefined;
|
||||
name: string | undefined;
|
||||
owned_by: string | undefined;
|
||||
project_ids: string[] | undefined;
|
||||
project_ids?: string[] | undefined;
|
||||
team: string | null | undefined;
|
||||
updated_at: Date | undefined;
|
||||
updated_by: string | undefined;
|
||||
workspace: string | undefined;
|
||||
@@ -25,11 +26,7 @@ export type TPage = {
|
||||
// page filters
|
||||
export type TPageNavigationTabs = "public" | "private" | "archived";
|
||||
|
||||
export type TPageFiltersSortKey =
|
||||
| "name"
|
||||
| "created_at"
|
||||
| "updated_at"
|
||||
| "opened_at";
|
||||
export type TPageFiltersSortKey = "name" | "created_at" | "updated_at" | "opened_at";
|
||||
|
||||
export type TPageFiltersSortBy = "asc" | "desc";
|
||||
|
||||
@@ -63,10 +60,17 @@ export type TPageVersion = {
|
||||
updated_at: string;
|
||||
updated_by: string;
|
||||
workspace: string;
|
||||
}
|
||||
};
|
||||
|
||||
export type TDocumentPayload = {
|
||||
description_binary: string;
|
||||
description_html: string;
|
||||
description: object;
|
||||
}
|
||||
};
|
||||
|
||||
export type TWebhookConnectionQueryParams = {
|
||||
documentType: "project_page" | "team_page" | "workspace_page";
|
||||
projectId?: string;
|
||||
teamId?: string;
|
||||
workspaceSlug: string;
|
||||
};
|
||||
|
||||
+1
@@ -15,6 +15,7 @@ export interface IProject {
|
||||
archived_at: string | null;
|
||||
archived_issues: number;
|
||||
archived_sub_issues: number;
|
||||
completed_issues: number;
|
||||
close_in: number;
|
||||
created_at: Date;
|
||||
created_by: string;
|
||||
|
||||
Vendored
+8
-12
@@ -6,13 +6,7 @@ import { IProject } from "./project";
|
||||
import { IUser } from "./users";
|
||||
import { IWorkspace } from "./workspace";
|
||||
|
||||
export type TSearchEntities =
|
||||
| "user_mention"
|
||||
| "issue_mention"
|
||||
| "project_mention"
|
||||
| "cycle_mention"
|
||||
| "module_mention"
|
||||
| "page_mention";
|
||||
export type TSearchEntities = "user_mention" | "issue" | "project" | "cycle" | "module" | "page";
|
||||
|
||||
export type TUserSearchResponse = {
|
||||
member__avatar_url: IUser["avatar_url"];
|
||||
@@ -66,16 +60,18 @@ export type TPageSearchResponse = {
|
||||
};
|
||||
|
||||
export type TSearchResponse = {
|
||||
cycle_mention?: TCycleSearchResponse[];
|
||||
issue_mention?: TIssueSearchResponse[];
|
||||
module_mention?: TModuleSearchResponse[];
|
||||
page_mention?: TPageSearchResponse[];
|
||||
project_mention?: TProjectSearchResponse[];
|
||||
cycle?: TCycleSearchResponse[];
|
||||
issue?: TIssueSearchResponse[];
|
||||
module?: TModuleSearchResponse[];
|
||||
page?: TPageSearchResponse[];
|
||||
project?: TProjectSearchResponse[];
|
||||
user_mention?: TUserSearchResponse[];
|
||||
};
|
||||
|
||||
export type TSearchEntityRequestPayload = {
|
||||
count: number;
|
||||
project_id?: string;
|
||||
query_type: TSearchEntities[];
|
||||
query: string;
|
||||
team_id?: string;
|
||||
};
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"isomorphic-dompurify": "^2.16.0",
|
||||
"lodash": "^4.17.21",
|
||||
"react": "^18.3.1",
|
||||
"tailwind-merge": "^2.5.5",
|
||||
"zxcvbn": "^4.4.2"
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
import isEmpty from "lodash/isEmpty";
|
||||
import { IIssueLabel, IIssueLabelTree } from "@plane/types";
|
||||
|
||||
/**
|
||||
* @description Groups an array of objects by a specified key
|
||||
* @param {any[]} array Array to group
|
||||
* @param {string} key Key to group by (supports dot notation for nested objects)
|
||||
* @returns {Object} Grouped object with keys being the grouped values
|
||||
* @example
|
||||
* const array = [{type: 'A', value: 1}, {type: 'B', value: 2}, {type: 'A', value: 3}];
|
||||
* groupBy(array, 'type') // returns { A: [{type: 'A', value: 1}, {type: 'A', value: 3}], B: [{type: 'B', value: 2}] }
|
||||
*/
|
||||
export const groupBy = (array: any[], key: string) => {
|
||||
const innerKey = key.split("."); // split the key by dot
|
||||
return array.reduce((result, currentValue) => {
|
||||
const key = innerKey.reduce((obj, i) => obj?.[i], currentValue) ?? "None"; // get the value of the inner key
|
||||
(result[key] = result[key] || []).push(currentValue);
|
||||
return result;
|
||||
}, {});
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Orders an array by a specified key in ascending or descending order
|
||||
* @param {any[]} orgArray Original array to order
|
||||
* @param {string} key Key to order by (supports dot notation for nested objects)
|
||||
* @param {"ascending" | "descending"} ordering Sort order
|
||||
* @returns {any[]} Ordered array
|
||||
* @example
|
||||
* const array = [{value: 2}, {value: 1}, {value: 3}];
|
||||
* orderArrayBy(array, 'value', 'ascending') // returns [{value: 1}, {value: 2}, {value: 3}]
|
||||
*/
|
||||
export const orderArrayBy = (orgArray: any[], key: string, ordering: "ascending" | "descending" = "ascending") => {
|
||||
if (!orgArray || !Array.isArray(orgArray) || orgArray.length === 0) return [];
|
||||
|
||||
const array = [...orgArray];
|
||||
|
||||
if (key[0] === "-") {
|
||||
ordering = "descending";
|
||||
key = key.slice(1);
|
||||
}
|
||||
|
||||
const innerKey = key.split("."); // split the key by dot
|
||||
|
||||
return array.sort((a, b) => {
|
||||
const keyA = innerKey.reduce((obj, i) => obj[i], a); // get the value of the inner key
|
||||
const keyB = innerKey.reduce((obj, i) => obj[i], b); // get the value of the inner key
|
||||
if (keyA < keyB) {
|
||||
return ordering === "ascending" ? -1 : 1;
|
||||
}
|
||||
if (keyA > keyB) {
|
||||
return ordering === "ascending" ? 1 : -1;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Checks if an array contains duplicate values
|
||||
* @param {any[]} array Array to check for duplicates
|
||||
* @returns {boolean} True if duplicates exist, false otherwise
|
||||
* @example
|
||||
* checkDuplicates([1, 2, 2, 3]) // returns true
|
||||
* checkDuplicates([1, 2, 3]) // returns false
|
||||
*/
|
||||
export const checkDuplicates = (array: any[]) => new Set(array).size !== array.length;
|
||||
|
||||
/**
|
||||
* @description Finds the string with the most characters in an array of strings
|
||||
* @param {string[]} strings Array of strings to check
|
||||
* @returns {string} String with the most characters
|
||||
* @example
|
||||
* findStringWithMostCharacters(['a', 'bb', 'ccc']) // returns 'ccc'
|
||||
*/
|
||||
export const findStringWithMostCharacters = (strings: string[]): string => {
|
||||
if (!strings || strings.length === 0) return "";
|
||||
|
||||
return strings.reduce((longestString, currentString) =>
|
||||
currentString.length > longestString.length ? currentString : longestString
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Checks if two arrays have the same elements regardless of order
|
||||
* @param {any[] | null} arr1 First array
|
||||
* @param {any[] | null} arr2 Second array
|
||||
* @returns {boolean} True if arrays have same elements, false otherwise
|
||||
* @example
|
||||
* checkIfArraysHaveSameElements([1, 2], [2, 1]) // returns true
|
||||
* checkIfArraysHaveSameElements([1, 2], [1, 3]) // returns false
|
||||
*/
|
||||
export const checkIfArraysHaveSameElements = (arr1: any[] | null, arr2: any[] | null): boolean => {
|
||||
if (!arr1 || !arr2) return false;
|
||||
if (!Array.isArray(arr1) || !Array.isArray(arr2)) return false;
|
||||
if (arr1.length === 0 && arr2.length === 0) return true;
|
||||
|
||||
return arr1.length === arr2.length && arr1.every((e) => arr2.includes(e));
|
||||
};
|
||||
|
||||
|
||||
type GroupedItems<T> = { [key: string]: T[] };
|
||||
|
||||
/**
|
||||
* @description Groups an array of objects by a specified field
|
||||
* @param {T[]} array Array to group
|
||||
* @param {keyof T} field Field to group by
|
||||
* @returns {GroupedItems<T>} Grouped object
|
||||
* @example
|
||||
* const array = [{type: 'A', value: 1}, {type: 'B', value: 2}];
|
||||
* groupByField(array, 'type') // returns { A: [{type: 'A', value: 1}], B: [{type: 'B', value: 2}] }
|
||||
*/
|
||||
export const groupByField = <T>(array: T[], field: keyof T): GroupedItems<T> =>
|
||||
array.reduce((grouped: GroupedItems<T>, item: T) => {
|
||||
const key = String(item[field]);
|
||||
grouped[key] = (grouped[key] || []).concat(item);
|
||||
return grouped;
|
||||
}, {});
|
||||
|
||||
/**
|
||||
* @description Sorts an array of objects by a specified field
|
||||
* @param {any[]} array Array to sort
|
||||
* @param {string} field Field to sort by
|
||||
* @returns {any[]} Sorted array
|
||||
* @example
|
||||
* const array = [{value: 2}, {value: 1}];
|
||||
* sortByField(array, 'value') // returns [{value: 1}, {value: 2}]
|
||||
*/
|
||||
export const sortByField = (array: any[], field: string): any[] =>
|
||||
array.sort((a, b) => (a[field] < b[field] ? -1 : a[field] > b[field] ? 1 : 0));
|
||||
|
||||
/**
|
||||
* @description Orders grouped data by a specified field
|
||||
* @param {GroupedItems<T>} groupedData Grouped data object
|
||||
* @param {keyof T} orderBy Field to order by
|
||||
* @returns {GroupedItems<T>} Ordered grouped data
|
||||
*/
|
||||
export const orderGroupedDataByField = <T>(groupedData: GroupedItems<T>, orderBy: keyof T): GroupedItems<T> => {
|
||||
for (const key in groupedData) {
|
||||
if (groupedData.hasOwnProperty(key)) {
|
||||
groupedData[key] = groupedData[key].sort((a, b) => {
|
||||
if (a[orderBy] < b[orderBy]) return -1;
|
||||
if (a[orderBy] > b[orderBy]) return 1;
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
}
|
||||
return groupedData;
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Builds a tree structure from an array of labels
|
||||
* @param {IIssueLabel[]} array Array of labels
|
||||
* @param {any} parent Parent ID
|
||||
* @returns {IIssueLabelTree[]} Tree structure
|
||||
*/
|
||||
export const buildTree = (array: IIssueLabel[], parent = null) => {
|
||||
const tree: IIssueLabelTree[] = [];
|
||||
|
||||
array.forEach((item: any) => {
|
||||
if (item.parent === parent) {
|
||||
const children = buildTree(array, item.id);
|
||||
item.children = children;
|
||||
tree.push(item);
|
||||
}
|
||||
});
|
||||
|
||||
return tree;
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Returns valid keys from object whose value is not falsy
|
||||
* @param {any} obj Object to check
|
||||
* @returns {string[]} Array of valid keys
|
||||
* @example
|
||||
* getValidKeysFromObject({a: 1, b: 0, c: null}) // returns ['a']
|
||||
*/
|
||||
export const getValidKeysFromObject = (obj: any) => {
|
||||
if (!obj || isEmpty(obj) || typeof obj !== "object" || Array.isArray(obj)) return [];
|
||||
|
||||
return Object.keys(obj).filter((key) => !!obj[key]);
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Converts an array of strings into an object with boolean true values
|
||||
* @param {string[]} arrayStrings Array of strings
|
||||
* @returns {Object} Object with string keys and boolean values
|
||||
* @example
|
||||
* convertStringArrayToBooleanObject(['a', 'b']) // returns {a: true, b: true}
|
||||
*/
|
||||
export const convertStringArrayToBooleanObject = (arrayStrings: string[]) => {
|
||||
const obj: { [key: string]: boolean } = {};
|
||||
|
||||
for (const arrayString of arrayStrings) {
|
||||
obj[arrayString] = true;
|
||||
}
|
||||
|
||||
return obj;
|
||||
};
|
||||
@@ -1,8 +1,71 @@
|
||||
import { ReactNode } from "react";
|
||||
import zxcvbn from "zxcvbn";
|
||||
import { E_PASSWORD_STRENGTH, PASSWORD_CRITERIA, PASSWORD_MIN_LENGTH } from "@plane/constants";
|
||||
import {
|
||||
E_PASSWORD_STRENGTH,
|
||||
SPACE_PASSWORD_CRITERIA,
|
||||
PASSWORD_MIN_LENGTH,
|
||||
EErrorAlertType,
|
||||
EAuthErrorCodes,
|
||||
} from "@plane/constants";
|
||||
|
||||
import { EPageTypes, EErrorAlertType, EAuthErrorCodes } from "@plane/constants";
|
||||
/**
|
||||
* @description Password strength levels
|
||||
*/
|
||||
export enum PasswordStrength {
|
||||
EMPTY = "empty",
|
||||
WEAK = "weak",
|
||||
FAIR = "fair",
|
||||
GOOD = "good",
|
||||
STRONG = "strong",
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Password strength criteria type
|
||||
*/
|
||||
export type PasswordCriterion = {
|
||||
regex: RegExp;
|
||||
description: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Password strength criteria
|
||||
*/
|
||||
export const PASSWORD_CRITERIA: PasswordCriterion[] = [
|
||||
{ regex: /[a-z]/, description: "lowercase" },
|
||||
{ regex: /[A-Z]/, description: "uppercase" },
|
||||
{ regex: /[0-9]/, description: "number" },
|
||||
{ regex: /[^a-zA-Z0-9]/, description: "special character" },
|
||||
];
|
||||
|
||||
/**
|
||||
* @description Checks if password meets all criteria
|
||||
* @param {string} password - Password to check
|
||||
* @returns {boolean} Whether password meets all criteria
|
||||
*/
|
||||
export const checkPasswordCriteria = (password: string): boolean =>
|
||||
PASSWORD_CRITERIA.every((criterion) => criterion.regex.test(password));
|
||||
|
||||
/**
|
||||
* @description Checks password strength against criteria
|
||||
* @param {string} password - Password to check
|
||||
* @returns {PasswordStrength} Password strength level
|
||||
* @example
|
||||
* checkPasswordStrength("abc") // returns PasswordStrength.WEAK
|
||||
* checkPasswordStrength("Abc123!@#") // returns PasswordStrength.STRONG
|
||||
*/
|
||||
export const checkPasswordStrength = (password: string): PasswordStrength => {
|
||||
if (!password || password.length === 0) return PasswordStrength.EMPTY;
|
||||
if (password.length < PASSWORD_MIN_LENGTH) return PasswordStrength.WEAK;
|
||||
|
||||
const criteriaCount = PASSWORD_CRITERIA.filter((criterion) => criterion.regex.test(password)).length;
|
||||
|
||||
const zxcvbnScore = zxcvbn(password).score;
|
||||
|
||||
if (criteriaCount <= 1 || zxcvbnScore <= 1) return PasswordStrength.WEAK;
|
||||
if (criteriaCount === 2 || zxcvbnScore === 2) return PasswordStrength.FAIR;
|
||||
if (criteriaCount === 3 || zxcvbnScore === 3) return PasswordStrength.GOOD;
|
||||
return PasswordStrength.STRONG;
|
||||
};
|
||||
|
||||
export type TAuthErrorInfo = {
|
||||
type: EErrorAlertType;
|
||||
@@ -26,9 +89,9 @@ export const getPasswordStrength = (password: string): E_PASSWORD_STRENGTH => {
|
||||
return passwordStrength;
|
||||
}
|
||||
|
||||
const passwordCriteriaValidation = PASSWORD_CRITERIA.map((criteria) => criteria.isCriteriaValid(password)).every(
|
||||
(criterion) => criterion
|
||||
);
|
||||
const passwordCriteriaValidation = SPACE_PASSWORD_CRITERIA.map((criteria) =>
|
||||
criteria.isCriteriaValid(password)
|
||||
).every((criterion) => criterion);
|
||||
const passwordStrengthScore = zxcvbn(password).score;
|
||||
|
||||
if (passwordCriteriaValidation === false || passwordStrengthScore <= 2) {
|
||||
@@ -76,7 +139,7 @@ const errorCodeMessages: {
|
||||
// sign up
|
||||
[EAuthErrorCodes.USER_ALREADY_EXIST]: {
|
||||
title: `User already exists`,
|
||||
message: (email = undefined) => `Your account is already registered. Sign in now.`,
|
||||
message: () => `Your account is already registered. Sign in now.`,
|
||||
},
|
||||
[EAuthErrorCodes.REQUIRED_EMAIL_PASSWORD_SIGN_UP]: {
|
||||
title: `Email and password required`,
|
||||
|
||||
@@ -8,9 +8,13 @@
|
||||
export type RGB = { r: number; g: number; b: number };
|
||||
|
||||
/**
|
||||
* Validates and clamps color values to RGB range (0-255)
|
||||
* @description Validates and clamps color values to RGB range (0-255)
|
||||
* @param {number} value - The color value to validate
|
||||
* @returns {number} Clamped and floored value between 0-255
|
||||
* @example
|
||||
* validateColor(-10) // returns 0
|
||||
* validateColor(300) // returns 255
|
||||
* validateColor(128) // returns 128
|
||||
*/
|
||||
export const validateColor = (value: number) => {
|
||||
if (value < 0) return 0;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { format, isValid } from "date-fns";
|
||||
import { differenceInDays, format, formatDistanceToNow, isAfter, isEqual, isValid, parseISO } from "date-fns";
|
||||
|
||||
/**
|
||||
* This method returns a date from string of type yyyy-mm-dd
|
||||
@@ -31,16 +31,305 @@ export const getDate = (date: string | Date | undefined | null): Date | undefine
|
||||
* @param {Date | string} date
|
||||
* @example renderFormattedDate("2024-01-01") // Jan 01, 2024
|
||||
*/
|
||||
export const renderFormattedDate = (date: string | Date | undefined | null): string | null => {
|
||||
/**
|
||||
* @description Returns date in the formatted format
|
||||
* @param {Date | string} date Date to format
|
||||
* @param {string} formatToken Format token (optional, default: MMM dd, yyyy)
|
||||
* @returns {string | undefined} Formatted date in the desired format
|
||||
* @example
|
||||
* renderFormattedDate("2024-01-01") // returns "Jan 01, 2024"
|
||||
* renderFormattedDate("2024-01-01", "MM-DD-YYYY") // returns "01-01-2024"
|
||||
*/
|
||||
export const renderFormattedDate = (
|
||||
date: string | Date | undefined | null,
|
||||
formatToken: string = "MMM dd, yyyy"
|
||||
): string | undefined => {
|
||||
// Parse the date to check if it is valid
|
||||
const parsedDate = getDate(date);
|
||||
// return if undefined
|
||||
if (!parsedDate) return null;
|
||||
if (!parsedDate) return;
|
||||
// Check if the parsed date is valid before formatting
|
||||
if (!isValid(parsedDate)) return null; // Return null for invalid dates
|
||||
// Format the date in format (MMM dd, yyyy)
|
||||
const formattedDate = format(parsedDate, "MMM dd, yyyy");
|
||||
if (!isValid(parsedDate)) return; // Return undefined for invalid dates
|
||||
let formattedDate;
|
||||
try {
|
||||
// Format the date in the format provided or default format (MMM dd, yyyy)
|
||||
formattedDate = format(parsedDate, formatToken);
|
||||
} catch (e) {
|
||||
// Format the date in format (MMM dd, yyyy) in case of any error
|
||||
formattedDate = format(parsedDate, "MMM dd, yyyy");
|
||||
}
|
||||
return formattedDate;
|
||||
};
|
||||
|
||||
// Note: timeAgo function was incomplete in the original file, so it has been omitted
|
||||
/**
|
||||
* @description Returns total number of days in range
|
||||
* @param {string | Date} startDate - Start date
|
||||
* @param {string | Date} endDate - End date
|
||||
* @param {boolean} inclusive - Include start and end dates (optional, default: true)
|
||||
* @returns {number | undefined} Total number of days
|
||||
* @example
|
||||
* findTotalDaysInRange("2024-01-01", "2024-01-08") // returns 8
|
||||
*/
|
||||
export const findTotalDaysInRange = (
|
||||
startDate: Date | string | undefined | null,
|
||||
endDate: Date | string | undefined | null,
|
||||
inclusive: boolean = true
|
||||
): number | undefined => {
|
||||
// Parse the dates to check if they are valid
|
||||
const parsedStartDate = getDate(startDate);
|
||||
const parsedEndDate = getDate(endDate);
|
||||
// return if undefined
|
||||
if (!parsedStartDate || !parsedEndDate) return;
|
||||
// Check if the parsed dates are valid before calculating the difference
|
||||
if (!isValid(parsedStartDate) || !isValid(parsedEndDate)) return 0; // Return 0 for invalid dates
|
||||
// Calculate the difference in days
|
||||
const diffInDays = differenceInDays(parsedEndDate, parsedStartDate);
|
||||
// Return the difference in days based on inclusive flag
|
||||
return inclusive ? diffInDays + 1 : diffInDays;
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Add number of days to the provided date
|
||||
* @param {string | Date} startDate - Start date
|
||||
* @param {number} numberOfDays - Number of days to add
|
||||
* @returns {Date | undefined} Resulting date
|
||||
* @example
|
||||
* addDaysToDate("2024-01-01", 7) // returns Date(2024-01-08)
|
||||
*/
|
||||
export const addDaysToDate = (startDate: Date | string | undefined | null, numberOfDays: number): Date | undefined => {
|
||||
// Parse the dates to check if they are valid
|
||||
const parsedStartDate = getDate(startDate);
|
||||
// return if undefined
|
||||
if (!parsedStartDate) return;
|
||||
const newDate = new Date(parsedStartDate);
|
||||
newDate.setDate(newDate.getDate() + numberOfDays);
|
||||
return newDate;
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Returns number of days left from today
|
||||
* @param {string | Date} date - Target date
|
||||
* @param {boolean} inclusive - Include today (optional, default: true)
|
||||
* @returns {number | undefined} Number of days left
|
||||
* @example
|
||||
* findHowManyDaysLeft("2024-01-08") // returns days between today and Jan 8, 2024
|
||||
*/
|
||||
export const findHowManyDaysLeft = (
|
||||
date: Date | string | undefined | null,
|
||||
inclusive: boolean = true
|
||||
): number | undefined => {
|
||||
if (!date) return undefined;
|
||||
return findTotalDaysInRange(new Date(), date, inclusive);
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Returns time passed since the event happened
|
||||
* @param {string | number | Date} time - Time to calculate from
|
||||
* @returns {string} Formatted time ago string
|
||||
* @example
|
||||
* calculateTimeAgo("2023-01-01") // returns "1 year ago"
|
||||
*/
|
||||
export const calculateTimeAgo = (time: string | number | Date | null): string => {
|
||||
if (!time) return "";
|
||||
const parsedTime = typeof time === "string" || typeof time === "number" ? parseISO(String(time)) : time;
|
||||
if (!parsedTime) return "";
|
||||
const distance = formatDistanceToNow(parsedTime, { addSuffix: true });
|
||||
return distance;
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Returns short form of time passed (e.g., 1y, 2mo, 3d)
|
||||
* @param {string | number | Date} date - Date to calculate from
|
||||
* @returns {string} Short form time ago
|
||||
* @example
|
||||
* calculateTimeAgoShort("2023-01-01") // returns "1y"
|
||||
*/
|
||||
export const calculateTimeAgoShort = (date: string | number | Date | null): string => {
|
||||
if (!date) return "";
|
||||
|
||||
const parsedDate = typeof date === "string" ? parseISO(date) : new Date(date);
|
||||
const now = new Date();
|
||||
const diffInSeconds = (now.getTime() - parsedDate.getTime()) / 1000;
|
||||
|
||||
if (diffInSeconds < 60) return `${Math.floor(diffInSeconds)}s`;
|
||||
const diffInMinutes = diffInSeconds / 60;
|
||||
if (diffInMinutes < 60) return `${Math.floor(diffInMinutes)}m`;
|
||||
const diffInHours = diffInMinutes / 60;
|
||||
if (diffInHours < 24) return `${Math.floor(diffInHours)}h`;
|
||||
const diffInDays = diffInHours / 24;
|
||||
if (diffInDays < 30) return `${Math.floor(diffInDays)}d`;
|
||||
const diffInMonths = diffInDays / 30;
|
||||
if (diffInMonths < 12) return `${Math.floor(diffInMonths)}mo`;
|
||||
const diffInYears = diffInMonths / 12;
|
||||
return `${Math.floor(diffInYears)}y`;
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Checks if a date is greater than today
|
||||
* @param {string} dateStr - Date string to check
|
||||
* @returns {boolean} True if date is greater than today
|
||||
* @example
|
||||
* isDateGreaterThanToday("2024-12-31") // returns true
|
||||
*/
|
||||
export const isDateGreaterThanToday = (dateStr: string): boolean => {
|
||||
if (!dateStr) return false;
|
||||
const date = parseISO(dateStr);
|
||||
const today = new Date();
|
||||
if (!isValid(date)) return false;
|
||||
return isAfter(date, today);
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Returns week number of date
|
||||
* @param {Date} date - Date to get week number from
|
||||
* @returns {number} Week number (1-52)
|
||||
* @example
|
||||
* getWeekNumberOfDate(new Date("2023-09-01")) // returns 35
|
||||
*/
|
||||
export const getWeekNumberOfDate = (date: Date): number => {
|
||||
const currentDate = date;
|
||||
const startDate = new Date(currentDate.getFullYear(), 0, 1);
|
||||
const days = Math.floor((currentDate.getTime() - startDate.getTime()) / (24 * 60 * 60 * 1000));
|
||||
const weekNumber = Math.ceil((days + 1) / 7);
|
||||
return weekNumber;
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Checks if two dates are equal
|
||||
* @param {Date | string} date1 - First date
|
||||
* @param {Date | string} date2 - Second date
|
||||
* @returns {boolean} True if dates are equal
|
||||
* @example
|
||||
* checkIfDatesAreEqual("2024-01-01", "2024-01-01") // returns true
|
||||
*/
|
||||
export const checkIfDatesAreEqual = (
|
||||
date1: Date | string | null | undefined,
|
||||
date2: Date | string | null | undefined
|
||||
): boolean => {
|
||||
const parsedDate1 = getDate(date1);
|
||||
const parsedDate2 = getDate(date2);
|
||||
if (!parsedDate1 && !parsedDate2) return true;
|
||||
if (!parsedDate1 || !parsedDate2) return false;
|
||||
return isEqual(parsedDate1, parsedDate2);
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Checks if a string matches date format YYYY-MM-DD
|
||||
* @param {string} date - Date string to check
|
||||
* @returns {boolean} True if string matches date format
|
||||
* @example
|
||||
* isInDateFormat("2024-01-01") // returns true
|
||||
*/
|
||||
export const isInDateFormat = (date: string): boolean => {
|
||||
const datePattern = /^\d{4}-\d{2}-\d{2}$/;
|
||||
return datePattern.test(date);
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Converts date string to ISO format
|
||||
* @param {string} dateString - Date string to convert
|
||||
* @returns {string | undefined} ISO date string
|
||||
* @example
|
||||
* convertToISODateString("2024-01-01") // returns "2024-01-01T00:00:00.000Z"
|
||||
*/
|
||||
export const convertToISODateString = (dateString: string | undefined): string | undefined => {
|
||||
if (!dateString) return dateString;
|
||||
const date = new Date(dateString);
|
||||
return date.toISOString();
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Converts date string to epoch timestamp
|
||||
* @param {string} dateString - Date string to convert
|
||||
* @returns {number | undefined} Epoch timestamp
|
||||
* @example
|
||||
* convertToEpoch("2024-01-01") // returns 1704067200000
|
||||
*/
|
||||
export const convertToEpoch = (dateString: string | undefined): number | undefined => {
|
||||
if (!dateString) return undefined;
|
||||
const date = new Date(dateString);
|
||||
return date.getTime();
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Gets current date time in ISO format
|
||||
* @returns {string} Current date time in ISO format
|
||||
* @example
|
||||
* getCurrentDateTimeInISO() // returns "2024-01-01T12:00:00.000Z"
|
||||
*/
|
||||
export const getCurrentDateTimeInISO = (): string => {
|
||||
const date = new Date();
|
||||
return date.toISOString();
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Converts hours and minutes to total minutes
|
||||
* @param {number} hours - Number of hours
|
||||
* @param {number} minutes - Number of minutes
|
||||
* @returns {number} Total minutes
|
||||
* @example
|
||||
* convertHoursMinutesToMinutes(2, 30) // returns 150
|
||||
*/
|
||||
export const convertHoursMinutesToMinutes = (hours: number, minutes: number): number => hours * 60 + minutes;
|
||||
|
||||
/**
|
||||
* @description Converts total minutes to hours and minutes
|
||||
* @param {number} mins - Total minutes
|
||||
* @returns {{ hours: number; minutes: number }} Hours and minutes
|
||||
* @example
|
||||
* convertMinutesToHoursAndMinutes(150) // returns { hours: 2, minutes: 30 }
|
||||
*/
|
||||
export const convertMinutesToHoursAndMinutes = (mins: number): { hours: number; minutes: number } => {
|
||||
const hours = Math.floor(mins / 60);
|
||||
const minutes = Math.floor(mins % 60);
|
||||
return { hours, minutes };
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Converts minutes to hours and minutes string
|
||||
* @param {number} totalMinutes - Total minutes
|
||||
* @returns {string} Formatted string (e.g., "2h 30m")
|
||||
* @example
|
||||
* convertMinutesToHoursMinutesString(150) // returns "2h 30m"
|
||||
*/
|
||||
export const convertMinutesToHoursMinutesString = (totalMinutes: number): string => {
|
||||
const { hours, minutes } = convertMinutesToHoursAndMinutes(totalMinutes);
|
||||
return `${hours ? `${hours}h ` : ``}${minutes ? `${minutes}m ` : ``}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Calculates read time in seconds from word count
|
||||
* @param {number} wordsCount - Number of words
|
||||
* @returns {number} Read time in seconds
|
||||
* @example
|
||||
* getReadTimeFromWordsCount(400) // returns 120
|
||||
*/
|
||||
export const getReadTimeFromWordsCount = (wordsCount: number): number => {
|
||||
const wordsPerMinute = 200;
|
||||
const minutes = wordsCount / wordsPerMinute;
|
||||
return minutes * 60;
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Generates array of dates between start and end dates
|
||||
* @param {string | Date} startDate - Start date
|
||||
* @param {string | Date} endDate - End date
|
||||
* @returns {Array<{ date: string }>} Array of dates
|
||||
* @example
|
||||
* generateDateArray("2024-01-01", "2024-01-03")
|
||||
* // returns [{ date: "2024-01-02" }, { date: "2024-01-03" }]
|
||||
*/
|
||||
export const generateDateArray = (startDate: string | Date, endDate: string | Date): Array<{ date: string }> => {
|
||||
const start = new Date(startDate);
|
||||
const end = new Date(endDate);
|
||||
end.setDate(end.getDate() + 1);
|
||||
|
||||
const dateArray = [];
|
||||
while (start <= end) {
|
||||
start.setDate(start.getDate() + 1);
|
||||
dateArray.push({
|
||||
date: new Date(start).toISOString().split("T")[0],
|
||||
});
|
||||
}
|
||||
return dateArray;
|
||||
};
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
import { MAX_FILE_SIZE } from "@plane/constants";
|
||||
import { getFileURL } from "./file";
|
||||
|
||||
// Define image-related types locally
|
||||
type DeleteImage = (assetUrlWithWorkspaceId: string) => Promise<void>;
|
||||
type RestoreImage = (assetUrlWithWorkspaceId: string) => Promise<void>;
|
||||
type UploadImage = (file: File) => Promise<string>;
|
||||
|
||||
// Define the FileService interface based on usage
|
||||
interface IFileService {
|
||||
deleteOldEditorAsset: (workspaceId: string, src: string) => Promise<void>;
|
||||
deleteNewAsset: (url: string) => Promise<void>;
|
||||
restoreOldEditorAsset: (workspaceId: string, src: string) => Promise<void>;
|
||||
restoreNewAsset: (anchor: string, src: string) => Promise<void>;
|
||||
cancelUpload: () => void;
|
||||
}
|
||||
|
||||
// Define TFileHandler locally since we can't import from @plane/editor
|
||||
interface TFileHandler {
|
||||
getAssetSrc: (path: string) => Promise<string>;
|
||||
cancel: () => void;
|
||||
delete: DeleteImage;
|
||||
upload: UploadImage;
|
||||
restore: RestoreImage;
|
||||
validation: {
|
||||
maxFileSize: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @description generate the file source using assetId
|
||||
* @param {string} anchor
|
||||
* @param {string} assetId
|
||||
*/
|
||||
export const getEditorAssetSrc = (anchor: string, assetId: string): string | undefined => {
|
||||
const url = getFileURL(`/api/public/assets/v2/anchor/${anchor}/${assetId}/`);
|
||||
return url;
|
||||
};
|
||||
|
||||
type TArgs = {
|
||||
anchor: string;
|
||||
uploadFile: (file: File) => Promise<string>;
|
||||
workspaceId: string;
|
||||
fileService: IFileService;
|
||||
};
|
||||
|
||||
/**
|
||||
* @description this function returns the file handler required by the editors
|
||||
* @param {TArgs} args
|
||||
*/
|
||||
export const getEditorFileHandlers = (args: TArgs): TFileHandler => {
|
||||
const { anchor, uploadFile, workspaceId, fileService } = args;
|
||||
|
||||
return {
|
||||
getAssetSrc: async (path: string) => {
|
||||
if (!path) return "";
|
||||
if (path?.startsWith("http")) {
|
||||
return path;
|
||||
} else {
|
||||
return getEditorAssetSrc(anchor, path) ?? "";
|
||||
}
|
||||
},
|
||||
upload: uploadFile,
|
||||
delete: async (src: string) => {
|
||||
if (src?.startsWith("http")) {
|
||||
await fileService.deleteOldEditorAsset(workspaceId, src);
|
||||
} else {
|
||||
await fileService.deleteNewAsset(getEditorAssetSrc(anchor, src) ?? "");
|
||||
}
|
||||
},
|
||||
restore: async (src: string) => {
|
||||
if (src?.startsWith("http")) {
|
||||
await fileService.restoreOldEditorAsset(workspaceId, src);
|
||||
} else {
|
||||
await fileService.restoreNewAsset(anchor, src);
|
||||
}
|
||||
},
|
||||
cancel: fileService.cancelUpload,
|
||||
validation: {
|
||||
maxFileSize: MAX_FILE_SIZE,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* @description this function returns the file handler required by the read-only editors
|
||||
*/
|
||||
export const getReadOnlyEditorFileHandlers = (
|
||||
args: Pick<TArgs, "anchor">
|
||||
): { getAssetSrc: TFileHandler["getAssetSrc"] } => {
|
||||
const { anchor } = args;
|
||||
|
||||
return {
|
||||
getAssetSrc: async (path: string) => {
|
||||
if (!path) return "";
|
||||
if (path?.startsWith("http")) {
|
||||
return path;
|
||||
} else {
|
||||
return getEditorAssetSrc(anchor, path) ?? "";
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -1,8 +1,9 @@
|
||||
export * from "./array";
|
||||
export * from "./auth";
|
||||
export * from "./datetime";
|
||||
export * from "./color";
|
||||
export * from "./common";
|
||||
export * from "./datetime";
|
||||
export * from "./editor";
|
||||
export * from "./emoji";
|
||||
export * from "./file";
|
||||
export * from "./issue";
|
||||
|
||||
+229
-35
@@ -1,9 +1,182 @@
|
||||
import DOMPurify from "isomorphic-dompurify";
|
||||
|
||||
export const addSpaceIfCamelCase = (str: string) => str.replace(/([a-z])([A-Z])/g, "$1 $2");
|
||||
/**
|
||||
* @description Adds space between camelCase words
|
||||
* @param {string} str - String to add spaces to
|
||||
* @returns {string} String with spaces between camelCase words
|
||||
* @example
|
||||
* addSpaceIfCamelCase("camelCase") // returns "camel Case"
|
||||
* addSpaceIfCamelCase("thisIsATest") // returns "this Is A Test"
|
||||
*/
|
||||
export const addSpaceIfCamelCase = (str: string) => {
|
||||
if (str === undefined || str === null) return "";
|
||||
|
||||
if (typeof str !== "string") str = `${str}`;
|
||||
|
||||
return str.replace(/([a-z])([A-Z])/g, "$1 $2");
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Replaces underscores with spaces in snake_case strings
|
||||
* @param {string} str - String to replace underscores in
|
||||
* @returns {string} String with underscores replaced by spaces
|
||||
* @example
|
||||
* replaceUnderscoreIfSnakeCase("snake_case") // returns "snake case"
|
||||
*/
|
||||
export const replaceUnderscoreIfSnakeCase = (str: string) => str.replace(/_/g, " ");
|
||||
|
||||
/**
|
||||
* @description Truncates text to specified length and adds ellipsis
|
||||
* @param {string} str - String to truncate
|
||||
* @param {number} length - Maximum length before truncation
|
||||
* @returns {string} Truncated string with ellipsis if needed
|
||||
* @example
|
||||
* truncateText("This is a long text", 7) // returns "This is..."
|
||||
*/
|
||||
export const truncateText = (str: string, length: number) => {
|
||||
if (!str || str === "") return "";
|
||||
|
||||
return str.length > length ? `${str.substring(0, length)}...` : str;
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Creates a similar string by randomly shuffling characters
|
||||
* @param {string} str - String to shuffle
|
||||
* @returns {string} Shuffled string with same characters
|
||||
* @example
|
||||
* createSimilarString("hello") // might return "olleh" or "lehol"
|
||||
*/
|
||||
export const createSimilarString = (str: string) => {
|
||||
const shuffled = str
|
||||
.split("")
|
||||
.sort(() => Math.random() - 0.5)
|
||||
.join("");
|
||||
|
||||
return shuffled;
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Copies full URL (origin + path) to clipboard
|
||||
* @param {string} path - URL path to copy
|
||||
* @returns {Promise<void>} Promise that resolves when copying is complete
|
||||
* @example
|
||||
* await copyUrlToClipboard("issues/123") // copies "https://example.com/issues/123"
|
||||
*/
|
||||
/**
|
||||
* @description Copies text to clipboard
|
||||
* @param {string} text - Text to copy
|
||||
* @returns {Promise<void>} Promise that resolves when copying is complete
|
||||
* @example
|
||||
* await copyTextToClipboard("Hello, World!") // copies "Hello, World!" to clipboard
|
||||
*/
|
||||
export const copyTextToClipboard = async (text: string): Promise<void> => {
|
||||
if (typeof navigator === "undefined") return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
} catch (err) {
|
||||
console.error("Failed to copy text: ", err);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Copies full URL (origin + path) to clipboard
|
||||
* @param {string} path - URL path to copy
|
||||
* @returns {Promise<void>} Promise that resolves when copying is complete
|
||||
* @example
|
||||
* await copyUrlToClipboard("issues/123") // copies "https://example.com/issues/123"
|
||||
*/
|
||||
export const copyUrlToClipboard = async (path: string) => {
|
||||
const originUrl = typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
|
||||
await copyTextToClipboard(`${originUrl}/${path}`);
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Generates a deterministic HSL color based on input string
|
||||
* @param {string} string - Input string to generate color from
|
||||
* @returns {string} HSL color string
|
||||
* @example
|
||||
* generateRandomColor("hello") // returns consistent HSL color for "hello"
|
||||
* generateRandomColor("") // returns "rgb(var(--color-primary-100))"
|
||||
*/
|
||||
export const generateRandomColor = (string: string): string => {
|
||||
if (!string) return "rgb(var(--color-primary-100))";
|
||||
|
||||
string = `${string}`;
|
||||
|
||||
const uniqueId = string.length.toString() + string;
|
||||
const combinedString = uniqueId + string;
|
||||
|
||||
const hash = Array.from(combinedString).reduce((acc, char) => {
|
||||
const charCode = char.charCodeAt(0);
|
||||
return (acc << 5) - acc + charCode;
|
||||
}, 0);
|
||||
|
||||
const hue = hash % 360;
|
||||
const saturation = 70;
|
||||
const lightness = 60;
|
||||
|
||||
const randomColor = `hsl(${hue}, ${saturation}%, ${lightness}%)`;
|
||||
|
||||
return randomColor;
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Gets first character of first word or first characters of first two words
|
||||
* @param {string} str - Input string
|
||||
* @returns {string} First character(s)
|
||||
* @example
|
||||
* getFirstCharacters("John") // returns "J"
|
||||
* getFirstCharacters("John Doe") // returns "JD"
|
||||
*/
|
||||
export const getFirstCharacters = (str: string) => {
|
||||
const words = str.trim().split(" ");
|
||||
if (words.length === 1) {
|
||||
return words[0].charAt(0);
|
||||
} else {
|
||||
return words[0].charAt(0) + words[1].charAt(0);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Formats number count, showing "99+" for numbers over 99
|
||||
* @param {number} number - Number to format
|
||||
* @returns {string} Formatted number string
|
||||
* @example
|
||||
* getNumberCount(50) // returns "50"
|
||||
* getNumberCount(100) // returns "99+"
|
||||
*/
|
||||
export const getNumberCount = (number: number): string => {
|
||||
if (number > 99) {
|
||||
return "99+";
|
||||
}
|
||||
return number.toString();
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Converts object to URL query parameters string
|
||||
* @param {Object} obj - Object to convert
|
||||
* @returns {string} URL query parameters string
|
||||
* @example
|
||||
* objToQueryParams({ page: 1, search: "test" }) // returns "page=1&search=test"
|
||||
* objToQueryParams({ a: null, b: "test" }) // returns "b=test"
|
||||
*/
|
||||
export const objToQueryParams = (obj: any) => {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (!obj) return params.toString();
|
||||
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
if (value !== undefined && value !== null) params.append(key, value as string);
|
||||
}
|
||||
|
||||
return params.toString();
|
||||
};
|
||||
|
||||
/**
|
||||
* @description: This function will capitalize the first letter of a string
|
||||
* @param str String
|
||||
* @returns String
|
||||
*/
|
||||
export const capitalizeFirstLetter = (str: string) => str.charAt(0).toUpperCase() + str.slice(1);
|
||||
|
||||
/**
|
||||
@@ -15,9 +188,30 @@ export const capitalizeFirstLetter = (str: string) => str.charAt(0).toUpperCase(
|
||||
* const text = stripHTML(html);
|
||||
* console.log(text); // Some text
|
||||
*/
|
||||
/**
|
||||
* @description Sanitizes HTML string by removing tags and properly escaping entities
|
||||
* @param {string} htmlString - HTML string to sanitize
|
||||
* @returns {string} Sanitized string with escaped HTML entities
|
||||
* @example
|
||||
* sanitizeHTML("<p>Hello & 'world'</p>") // returns "Hello & 'world'"
|
||||
*/
|
||||
export const sanitizeHTML = (htmlString: string) => {
|
||||
const sanitizedText = DOMPurify.sanitize(htmlString, { ALLOWED_TAGS: [] }); // sanitize the string to remove all HTML tags
|
||||
return sanitizedText.trim(); // trim the string to remove leading and trailing whitespaces
|
||||
if (!htmlString) return "";
|
||||
|
||||
// First use DOMPurify to remove all HTML tags while preserving text content
|
||||
const sanitizedText = DOMPurify.sanitize(htmlString, {
|
||||
ALLOWED_TAGS: [],
|
||||
ALLOWED_ATTR: [],
|
||||
USE_PROFILES: {
|
||||
html: false,
|
||||
svg: false,
|
||||
svgFilters: false,
|
||||
mathMl: false,
|
||||
},
|
||||
});
|
||||
|
||||
// Additional escaping for quotes and apostrophes
|
||||
return sanitizedText.trim().replace(/'/g, "'").replace(/"/g, """);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -86,42 +280,42 @@ export const checkURLValidity = (url: string): boolean => {
|
||||
};
|
||||
|
||||
// Browser-only clipboard functions
|
||||
let copyTextToClipboard: (text: string) => Promise<void>;
|
||||
// let copyTextToClipboard: (text: string) => Promise<void>;
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
const fallbackCopyTextToClipboard = (text: string) => {
|
||||
const textArea = document.createElement("textarea");
|
||||
textArea.value = text;
|
||||
// if (typeof window !== "undefined") {
|
||||
// const fallbackCopyTextToClipboard = (text: string) => {
|
||||
// const textArea = document.createElement("textarea");
|
||||
// textArea.value = text;
|
||||
|
||||
// Avoid scrolling to bottom
|
||||
textArea.style.top = "0";
|
||||
textArea.style.left = "0";
|
||||
textArea.style.position = "fixed";
|
||||
// // Avoid scrolling to bottom
|
||||
// textArea.style.top = "0";
|
||||
// textArea.style.left = "0";
|
||||
// textArea.style.position = "fixed";
|
||||
|
||||
document.body.appendChild(textArea);
|
||||
textArea.focus();
|
||||
textArea.select();
|
||||
// document.body.appendChild(textArea);
|
||||
// textArea.focus();
|
||||
// textArea.select();
|
||||
|
||||
try {
|
||||
// FIXME: Even though we are using this as a fallback, execCommand is deprecated 👎. We should find a better way to do this.
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/Document/execCommand
|
||||
document.execCommand("copy");
|
||||
} catch (err) {}
|
||||
// try {
|
||||
// // FIXME: Even though we are using this as a fallback, execCommand is deprecated 👎. We should find a better way to do this.
|
||||
// // https://developer.mozilla.org/en-US/docs/Web/API/Document/execCommand
|
||||
// document.execCommand("copy");
|
||||
// } catch (err) {}
|
||||
|
||||
document.body.removeChild(textArea);
|
||||
};
|
||||
// document.body.removeChild(textArea);
|
||||
// };
|
||||
|
||||
copyTextToClipboard = async (text: string) => {
|
||||
if (!navigator.clipboard) {
|
||||
fallbackCopyTextToClipboard(text);
|
||||
return;
|
||||
}
|
||||
await navigator.clipboard.writeText(text);
|
||||
};
|
||||
} else {
|
||||
copyTextToClipboard = async () => {
|
||||
throw new Error("copyTextToClipboard is only available in browser environments");
|
||||
};
|
||||
}
|
||||
// copyTextToClipboard = async (text: string) => {
|
||||
// if (!navigator.clipboard) {
|
||||
// fallbackCopyTextToClipboard(text);
|
||||
// return;
|
||||
// }
|
||||
// await navigator.clipboard.writeText(text);
|
||||
// };
|
||||
// } else {
|
||||
// copyTextToClipboard = async () => {
|
||||
// throw new Error("copyTextToClipboard is only available in browser environments");
|
||||
// };
|
||||
// }
|
||||
|
||||
export { copyTextToClipboard };
|
||||
// export { copyTextToClipboard };
|
||||
|
||||
+100
-10
@@ -1,29 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import useSWR from "swr";
|
||||
// ui
|
||||
// plane types
|
||||
import { TSearchEntityRequestPayload } from "@plane/types";
|
||||
import { EFileAssetType } from "@plane/types/src/enums";
|
||||
// plane ui
|
||||
import { getButtonStyling } from "@plane/ui";
|
||||
// plane utils
|
||||
import { cn } from "@plane/utils";
|
||||
// components
|
||||
import { LogoSpinner } from "@/components/common";
|
||||
import { PageHead } from "@/components/core";
|
||||
import { IssuePeekOverview } from "@/components/issues";
|
||||
import { PageRoot } from "@/components/pages";
|
||||
import { PageRoot, TPageRootConfig, TPageRootHandlers } from "@/components/pages";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
import { getEditorFileHandlers } from "@/helpers/editor.helper";
|
||||
// hooks
|
||||
import { usePage, useProjectPages } from "@/hooks/store";
|
||||
import { useProjectPage, useProjectPages, useWorkspace } from "@/hooks/store";
|
||||
// plane web hooks
|
||||
import { useFileSize } from "@/plane-web/hooks/use-file-size";
|
||||
// plane web services
|
||||
import { WorkspaceService } from "@/plane-web/services";
|
||||
// services
|
||||
import { FileService } from "@/services/file.service";
|
||||
import { ProjectPageService, ProjectPageVersionService } from "@/services/page";
|
||||
const workspaceService = new WorkspaceService();
|
||||
const fileService = new FileService();
|
||||
const projectPageService = new ProjectPageService();
|
||||
const projectPageVersionService = new ProjectPageVersionService();
|
||||
|
||||
const PageDetailsPage = observer(() => {
|
||||
const { workspaceSlug, projectId, pageId } = useParams();
|
||||
|
||||
// store hooks
|
||||
const { getPageById } = useProjectPages();
|
||||
const page = usePage(pageId?.toString() ?? "");
|
||||
const { id, name } = page;
|
||||
|
||||
const { createPage, getPageById } = useProjectPages();
|
||||
const page = useProjectPage(pageId?.toString() ?? "");
|
||||
const { getWorkspaceBySlug } = useWorkspace();
|
||||
// derived values
|
||||
const workspaceId = workspaceSlug ? (getWorkspaceBySlug(workspaceSlug.toString())?.id ?? "") : "";
|
||||
const { id, name, updateDescription } = page;
|
||||
// entity search handler
|
||||
const fetchEntityCallback = useCallback(
|
||||
async (payload: TSearchEntityRequestPayload) =>
|
||||
await workspaceService.searchEntity(workspaceSlug?.toString() ?? "", {
|
||||
...payload,
|
||||
project_id: projectId?.toString() ?? "",
|
||||
}),
|
||||
[projectId, workspaceSlug]
|
||||
);
|
||||
// file size
|
||||
const { maxFileSize } = useFileSize();
|
||||
// fetch page details
|
||||
const { error: pageDetailsError } = useSWR(
|
||||
workspaceSlug && projectId && pageId ? `PAGE_DETAILS_${pageId}` : null,
|
||||
@@ -36,6 +65,62 @@ const PageDetailsPage = observer(() => {
|
||||
revalidateOnReconnect: true,
|
||||
}
|
||||
);
|
||||
// page root handlers
|
||||
const pageRootHandlers: TPageRootHandlers = useMemo(
|
||||
() => ({
|
||||
create: createPage,
|
||||
fetchAllVersions: async (pageId) => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
return await projectPageVersionService.fetchAllVersions(workspaceSlug.toString(), projectId.toString(), pageId);
|
||||
},
|
||||
fetchDescriptionBinary: async () => {
|
||||
if (!workspaceSlug || !projectId || !page.id) return;
|
||||
return await projectPageService.fetchDescriptionBinary(workspaceSlug.toString(), projectId.toString(), page.id);
|
||||
},
|
||||
fetchEntity: fetchEntityCallback,
|
||||
fetchVersionDetails: async (pageId, versionId) => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
return await projectPageVersionService.fetchVersionById(
|
||||
workspaceSlug.toString(),
|
||||
projectId.toString(),
|
||||
pageId,
|
||||
versionId
|
||||
);
|
||||
},
|
||||
getRedirectionLink: (pageId) => `/${workspaceSlug}/projects/${projectId}/pages/${pageId}`,
|
||||
updateDescription,
|
||||
}),
|
||||
[createPage, fetchEntityCallback, page.id, projectId, updateDescription, workspaceSlug]
|
||||
);
|
||||
// page root config
|
||||
const pageRootConfig: TPageRootConfig = useMemo(
|
||||
() => ({
|
||||
fileHandler: getEditorFileHandlers({
|
||||
maxFileSize,
|
||||
projectId: projectId?.toString() ?? "",
|
||||
uploadFile: async (file) => {
|
||||
const { asset_id } = await fileService.uploadProjectAsset(
|
||||
workspaceSlug?.toString() ?? "",
|
||||
projectId?.toString() ?? "",
|
||||
{
|
||||
entity_identifier: id ?? "",
|
||||
entity_type: EFileAssetType.PAGE_DESCRIPTION,
|
||||
},
|
||||
file
|
||||
);
|
||||
return asset_id;
|
||||
},
|
||||
workspaceId,
|
||||
workspaceSlug: workspaceSlug?.toString() ?? "",
|
||||
}),
|
||||
webhookConnectionParams: {
|
||||
documentType: "project_page",
|
||||
projectId: projectId?.toString() ?? "",
|
||||
workspaceSlug: workspaceSlug?.toString() ?? "",
|
||||
},
|
||||
}),
|
||||
[id, maxFileSize, projectId, workspaceId, workspaceSlug]
|
||||
);
|
||||
|
||||
if ((!page || !id) && !pageDetailsError)
|
||||
return (
|
||||
@@ -65,7 +150,12 @@ const PageDetailsPage = observer(() => {
|
||||
<PageHead title={name} />
|
||||
<div className="flex h-full flex-col justify-between">
|
||||
<div className="relative h-full w-full flex-shrink-0 flex flex-col overflow-hidden">
|
||||
<PageRoot page={page} projectId={projectId.toString()} workspaceSlug={workspaceSlug.toString()} />
|
||||
<PageRoot
|
||||
config={pageRootConfig}
|
||||
handlers={pageRootHandlers}
|
||||
page={page}
|
||||
workspaceSlug={workspaceSlug?.toString() ?? ""}
|
||||
/>
|
||||
<IssuePeekOverview />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+3
-3
@@ -15,7 +15,7 @@ import { PageEditInformationPopover } from "@/components/pages";
|
||||
import { convertHexEmojiToDecimal } from "@/helpers/emoji.helper";
|
||||
import { getPageName } from "@/helpers/page.helper";
|
||||
// hooks
|
||||
import { usePage, useProject, useUser, useUserPermissions } from "@/hooks/store";
|
||||
import { useProjectPage, useProject, useUser, useUserPermissions } from "@/hooks/store";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
// plane web components
|
||||
import { PageDetailsHeaderExtraActions } from "@/plane-web/components/pages";
|
||||
@@ -32,7 +32,7 @@ export const PageDetailsHeader = observer(() => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
// store hooks
|
||||
const { currentProjectDetails, loader } = useProject();
|
||||
const page = usePage(pageId?.toString() ?? "");
|
||||
const page = useProjectPage(pageId?.toString() ?? "");
|
||||
const { name, logo_props, updatePageLogo, owned_by } = page;
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
const { data: currentUser } = useUser();
|
||||
@@ -169,7 +169,7 @@ export const PageDetailsHeader = observer(() => {
|
||||
</Header.LeftItem>
|
||||
<Header.RightItem>
|
||||
<PageEditInformationPopover page={page} />
|
||||
<PageDetailsHeaderExtraActions />
|
||||
<PageDetailsHeaderExtraActions page={page} />
|
||||
</Header.RightItem>
|
||||
</Header>
|
||||
);
|
||||
|
||||
+1
-5
@@ -51,11 +51,7 @@ const ProjectPagesPage = observer(() => {
|
||||
projectId={projectId.toString()}
|
||||
pageType={currentPageType()}
|
||||
>
|
||||
<PagesListRoot
|
||||
pageType={currentPageType()}
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
projectId={projectId.toString()}
|
||||
/>
|
||||
<PagesListRoot pageType={currentPageType()} />
|
||||
</PagesListView>
|
||||
</>
|
||||
);
|
||||
|
||||
+15
-10
@@ -5,7 +5,7 @@ import { observer } from "mobx-react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { ChevronDown, CircleUserRound } from "lucide-react";
|
||||
import { Disclosure, Transition } from "@headlessui/react";
|
||||
import type { IUser } from "@plane/types";
|
||||
import type { IUser, TUserProfile } from "@plane/types";
|
||||
import {
|
||||
Button,
|
||||
CustomSelect,
|
||||
@@ -27,7 +27,7 @@ import { USER_ROLES } from "@/constants/workspace";
|
||||
// helpers
|
||||
import { getFileURL } from "@/helpers/file.helper";
|
||||
// hooks
|
||||
import { useUser } from "@/hooks/store";
|
||||
import { useUser, useUserProfile } from "@/hooks/store";
|
||||
|
||||
const defaultValues: Partial<IUser> = {
|
||||
avatar_url: "",
|
||||
@@ -59,29 +59,34 @@ const ProfileSettingsPage = observer(() => {
|
||||
const userCover = watch("cover_image_url");
|
||||
// store hooks
|
||||
const { data: currentUser, updateCurrentUser } = useUser();
|
||||
const { updateUserProfile, data: currentUserProfile } = useUserProfile();
|
||||
|
||||
useEffect(() => {
|
||||
reset({ ...defaultValues, ...currentUser });
|
||||
}, [currentUser, reset]);
|
||||
reset({ ...defaultValues, ...currentUser, ...currentUserProfile });
|
||||
}, [currentUser, currentUserProfile, reset]);
|
||||
|
||||
const onSubmit = async (formData: IUser) => {
|
||||
setIsLoading(true);
|
||||
const payload: Partial<IUser> = {
|
||||
const userPayload: Partial<IUser> = {
|
||||
first_name: formData.first_name,
|
||||
last_name: formData.last_name,
|
||||
avatar_url: formData.avatar_url,
|
||||
role: formData.role,
|
||||
display_name: formData?.display_name,
|
||||
user_timezone: formData.user_timezone,
|
||||
};
|
||||
const userProfilePayload: Partial<TUserProfile> = {
|
||||
role: formData.role ?? undefined,
|
||||
};
|
||||
// if unsplash or a pre-defined image is uploaded, delete the old uploaded asset
|
||||
if (formData.cover_image_url?.startsWith("http")) {
|
||||
payload.cover_image = formData.cover_image_url;
|
||||
payload.cover_image_asset = null;
|
||||
userPayload.cover_image = formData.cover_image_url;
|
||||
userPayload.cover_image_asset = null;
|
||||
}
|
||||
|
||||
const updateCurrentUserDetail = updateCurrentUser(payload).finally(() => setIsLoading(false));
|
||||
setPromiseToast(updateCurrentUserDetail, {
|
||||
const updateUser = Promise.all([updateCurrentUser(userPayload), updateUserProfile(userProfilePayload)]).finally(
|
||||
() => setIsLoading(false)
|
||||
);
|
||||
setPromiseToast(updateUser, {
|
||||
loading: "Updating...",
|
||||
success: {
|
||||
title: "Success!",
|
||||
|
||||
@@ -30,10 +30,11 @@ export type TQuickAddIssueFormRoot = {
|
||||
register: UseFormRegister<TIssue>;
|
||||
onSubmit: () => void;
|
||||
onClose: () => void;
|
||||
isEpic: boolean;
|
||||
};
|
||||
|
||||
export const QuickAddIssueFormRoot: FC<TQuickAddIssueFormRoot> = observer((props) => {
|
||||
const { isOpen, layout, projectId, hasError = false, setFocus, register, onSubmit, onClose } = props;
|
||||
const { isOpen, layout, projectId, hasError = false, setFocus, register, onSubmit, onClose, isEpic } = props;
|
||||
// store hooks
|
||||
const { getProjectById } = useProject();
|
||||
// derived values
|
||||
@@ -70,6 +71,7 @@ export const QuickAddIssueFormRoot: FC<TQuickAddIssueFormRoot> = observer((props
|
||||
hasError={hasError}
|
||||
register={register}
|
||||
onSubmit={onSubmit}
|
||||
isEpic={isEpic}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -11,13 +11,12 @@ type Props = {
|
||||
handleInsertText: (insertOnNextLine: boolean) => void;
|
||||
handleRegenerate: () => Promise<void>;
|
||||
isRegenerating: boolean;
|
||||
projectId: string;
|
||||
response: string | undefined;
|
||||
workspaceSlug: string;
|
||||
};
|
||||
|
||||
export const AskPiMenu: React.FC<Props> = (props) => {
|
||||
const { handleInsertText, handleRegenerate, isRegenerating, projectId, response, workspaceSlug } = props;
|
||||
const { handleInsertText, handleRegenerate, isRegenerating, response, workspaceSlug } = props;
|
||||
// states
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
@@ -42,7 +41,6 @@ export const AskPiMenu: React.FC<Props> = (props) => {
|
||||
containerClassName="!p-0 border-none"
|
||||
editorClassName="!pl-0"
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
/>
|
||||
<div className="mt-3 flex items-center gap-4">
|
||||
<button
|
||||
|
||||
@@ -21,7 +21,6 @@ type Props = {
|
||||
editorRef: RefObject<EditorRefApi>;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
projectId: string;
|
||||
workspaceSlug: string;
|
||||
};
|
||||
|
||||
@@ -59,7 +58,7 @@ const TONES_LIST = [
|
||||
];
|
||||
|
||||
export const EditorAIMenu: React.FC<Props> = (props) => {
|
||||
const { editorRef, isOpen, onClose, projectId, workspaceSlug } = props;
|
||||
const { editorRef, isOpen, onClose, workspaceSlug } = props;
|
||||
// states
|
||||
const [activeTask, setActiveTask] = useState<AI_EDITOR_TASKS | null>(null);
|
||||
const [response, setResponse] = useState<string | undefined>(undefined);
|
||||
@@ -193,7 +192,6 @@ export const EditorAIMenu: React.FC<Props> = (props) => {
|
||||
handleInsertText={handleInsertText}
|
||||
handleRegenerate={handleRegenerate}
|
||||
isRegenerating={isRegenerating}
|
||||
projectId={projectId}
|
||||
response={response}
|
||||
workspaceSlug={workspaceSlug}
|
||||
/>
|
||||
@@ -218,7 +216,6 @@ export const EditorAIMenu: React.FC<Props> = (props) => {
|
||||
containerClassName="!p-0 border-none"
|
||||
editorClassName="!pl-0"
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
/>
|
||||
<div className="mt-3 flex items-center gap-4">
|
||||
<button
|
||||
|
||||
@@ -1 +1,8 @@
|
||||
export const PageDetailsHeaderExtraActions = () => null;
|
||||
// store
|
||||
import { TPageInstance } from "@/store/pages/base-page";
|
||||
|
||||
export type TPageHeaderExtraActionsProps = {
|
||||
page: TPageInstance;
|
||||
};
|
||||
|
||||
export const PageDetailsHeaderExtraActions: React.FC<TPageHeaderExtraActionsProps> = () => null;
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { TEpicAnalyticsGroup } from "@plane/types";
|
||||
|
||||
export const updateEpicAnalytics = () => {
|
||||
const updateAnalytics = (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
epicId: string,
|
||||
data: {
|
||||
incrementStateGroupCount?: TEpicAnalyticsGroup;
|
||||
decrementStateGroupCount?: TEpicAnalyticsGroup;
|
||||
}
|
||||
) => {};
|
||||
|
||||
return { updateAnalytics };
|
||||
};
|
||||
@@ -1,12 +1,18 @@
|
||||
// editor
|
||||
import { TEmbedConfig } from "@plane/editor";
|
||||
// types
|
||||
import { TPageEmbedType } from "@plane/types";
|
||||
// plane types
|
||||
import { TSearchEntityRequestPayload, TSearchResponse } from "@plane/types";
|
||||
// plane web components
|
||||
import { IssueEmbedUpgradeCard } from "@/plane-web/components/pages";
|
||||
|
||||
export type TIssueEmbedHookProps = {
|
||||
fetchEmbedSuggestions?: (payload: TSearchEntityRequestPayload) => Promise<TSearchResponse>;
|
||||
projectId?: string;
|
||||
workspaceSlug?: string;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
export const useIssueEmbed = (workspaceSlug: string, projectId: string, queryType: TPageEmbedType = "issue") => {
|
||||
export const useIssueEmbed = (props: TIssueEmbedHookProps) => {
|
||||
const widgetCallback = () => <IssueEmbedUpgradeCard />;
|
||||
|
||||
const issueEmbedProps: TEmbedConfig["issue"] = {
|
||||
|
||||
@@ -93,10 +93,11 @@ export class IssueActivityStore implements IIssueActivityStore {
|
||||
|
||||
let activityComments: TIssueActivityComment[] = [];
|
||||
|
||||
const currentStore = this.serviceType === EIssueServiceType.EPICS ? this.store.epic : this.store.issue;
|
||||
const currentStore =
|
||||
this.serviceType === EIssueServiceType.EPICS ? this.store.issue.epicDetail : this.store.issue.issueDetail;
|
||||
|
||||
const activities = this.getActivitiesByIssueId(issueId) || [];
|
||||
const comments = currentStore.issueDetail.comment.getCommentsByIssueId(issueId) || [];
|
||||
const comments = currentStore.comment.getCommentsByIssueId(issueId) || [];
|
||||
|
||||
activities.forEach((activityId) => {
|
||||
const activity = this.getActivityById(activityId);
|
||||
@@ -109,7 +110,7 @@ export class IssueActivityStore implements IIssueActivityStore {
|
||||
});
|
||||
|
||||
comments.forEach((commentId) => {
|
||||
const comment = currentStore.issueDetail.comment.getCommentById(commentId);
|
||||
const comment = currentStore.comment.getCommentById(commentId);
|
||||
if (!comment) return;
|
||||
activityComments.push({
|
||||
id: comment.id,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React, { FC, MouseEvent, useEffect } from "react";
|
||||
import React, { FC, MouseEvent, useEffect, useMemo } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
@@ -8,7 +8,16 @@ import { Eye, Users } from "lucide-react";
|
||||
// types
|
||||
import { ICycle, TCycleGroups } from "@plane/types";
|
||||
// ui
|
||||
import { Avatar, AvatarGroup, FavoriteStar, TOAST_TYPE, Tooltip, setPromiseToast, setToast } from "@plane/ui";
|
||||
import {
|
||||
Avatar,
|
||||
AvatarGroup,
|
||||
FavoriteStar,
|
||||
LayersIcon,
|
||||
TOAST_TYPE,
|
||||
Tooltip,
|
||||
setPromiseToast,
|
||||
setToast,
|
||||
} from "@plane/ui";
|
||||
// components
|
||||
import { CycleQuickActions } from "@/components/cycles";
|
||||
import { DateRangeDropdown } from "@/components/dropdowns";
|
||||
@@ -66,6 +75,7 @@ export const CycleListItemAction: FC<Props> = observer((props) => {
|
||||
|
||||
// derived values
|
||||
const cycleStatus = cycleDetails.status ? (cycleDetails.status.toLocaleLowerCase() as TCycleGroups) : "draft";
|
||||
const showIssueCount = useMemo(() => cycleStatus === "draft" || cycleStatus === "upcoming", [cycleStatus]);
|
||||
const isEditingAllowed = allowPermissions(
|
||||
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
|
||||
EUserPermissionsLevel.PROJECT,
|
||||
@@ -216,6 +226,13 @@ export const CycleListItemAction: FC<Props> = observer((props) => {
|
||||
<span>More details</span>
|
||||
</button>
|
||||
|
||||
{showIssueCount && (
|
||||
<div className="flex items-center gap-1">
|
||||
<LayersIcon className="h-4 w-4 text-custom-text-300" />
|
||||
<span className="text-xs text-custom-text-300">{cycleDetails.total_issues}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isActive && (
|
||||
<Controller
|
||||
control={control}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ReactNode, useRef, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { usePopper } from "react-popper";
|
||||
import { Check, ChevronDown, Search } from "lucide-react";
|
||||
import { Briefcase, Check, ChevronDown, Search } from "lucide-react";
|
||||
import { Combobox } from "@headlessui/react";
|
||||
// ui
|
||||
import { ComboDropDown } from "@plane/ui";
|
||||
@@ -143,10 +143,14 @@ export const ProjectDropdown: React.FC<Props> = observer((props) => {
|
||||
if (Array.isArray(value)) {
|
||||
return (
|
||||
<div className="flex items-center gap-0.5">
|
||||
{value.map((projectId) => {
|
||||
const projectDetails = getProjectById(projectId);
|
||||
return projectDetails ? renderIcon(projectDetails) : null;
|
||||
})}
|
||||
{value.length > 0 ? (
|
||||
value.map((projectId) => {
|
||||
const projectDetails = getProjectById(projectId);
|
||||
return projectDetails ? renderIcon(projectDetails) : null;
|
||||
})
|
||||
) : (
|
||||
<Briefcase className="size-3 text-custom-text-300" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
|
||||
@@ -14,9 +14,9 @@ import { useEditorMention } from "@/hooks/use-editor-mention";
|
||||
// plane web hooks
|
||||
import { useEditorFlagging } from "@/plane-web/hooks/use-editor-flagging";
|
||||
import { useFileSize } from "@/plane-web/hooks/use-file-size";
|
||||
// services
|
||||
import { ProjectService } from "@/services/project";
|
||||
const projectService = new ProjectService();
|
||||
// plane web services
|
||||
import { WorkspaceService } from "@/plane-web/services";
|
||||
const workspaceService = new WorkspaceService();
|
||||
|
||||
interface LiteTextEditorWrapperProps
|
||||
extends Omit<ILiteTextEditor, "disabledExtensions" | "fileHandler" | "mentionHandler"> {
|
||||
@@ -55,7 +55,10 @@ export const LiteTextEditor = React.forwardRef<EditorRefApi, LiteTextEditorWrapp
|
||||
// use editor mention
|
||||
const { fetchMentions } = useEditorMention({
|
||||
searchEntity: async (payload) =>
|
||||
await projectService.searchEntity(workspaceSlug?.toString() ?? "", projectId?.toString() ?? "", payload),
|
||||
await workspaceService.searchEntity(workspaceSlug?.toString() ?? "", {
|
||||
...payload,
|
||||
project_id: projectId?.toString() ?? "",
|
||||
}),
|
||||
});
|
||||
// file size
|
||||
const { maxFileSize } = useFileSize();
|
||||
|
||||
@@ -19,11 +19,12 @@ import { getTabIndex } from "@/helpers/tab-indices.helper";
|
||||
// hooks
|
||||
import { useProjectInbox } from "@/hooks/store";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
// plane web services
|
||||
import { WorkspaceService } from "@/plane-web/services";
|
||||
// services
|
||||
import { FileService } from "@/services/file.service";
|
||||
import { ProjectService } from "@/services/project";
|
||||
const fileService = new FileService();
|
||||
const projectService = new ProjectService();
|
||||
const workspaceService = new WorkspaceService();
|
||||
|
||||
type TInboxIssueDescription = {
|
||||
containerClassName?: string;
|
||||
@@ -75,7 +76,10 @@ export const InboxIssueDescription: FC<TInboxIssueDescription> = observer((props
|
||||
onChange={(_description: object, description_html: string) => handleData("description_html", description_html)}
|
||||
placeholder={getDescriptionPlaceholder}
|
||||
searchMentionCallback={async (payload) =>
|
||||
await projectService.searchEntity(workspaceSlug?.toString() ?? "", projectId?.toString() ?? "", payload)
|
||||
await workspaceService.searchEntity(workspaceSlug?.toString() ?? "", {
|
||||
...payload,
|
||||
project_id: projectId?.toString() ?? "",
|
||||
})
|
||||
}
|
||||
containerClassName={containerClassName}
|
||||
onEnterKeyPress={onEnterKeyPress}
|
||||
|
||||
@@ -10,10 +10,11 @@ type TCreateIssueToastActionItems = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
issueId: string;
|
||||
isEpic?: boolean;
|
||||
};
|
||||
|
||||
export const CreateIssueToastActionItems: FC<TCreateIssueToastActionItems> = observer((props) => {
|
||||
const { workspaceSlug, projectId, issueId } = props;
|
||||
const { workspaceSlug, projectId, issueId, isEpic = false } = props;
|
||||
// state
|
||||
const [copied, setCopied] = useState(false);
|
||||
// store hooks
|
||||
@@ -26,7 +27,7 @@ export const CreateIssueToastActionItems: FC<TCreateIssueToastActionItems> = obs
|
||||
|
||||
if (!issue) return null;
|
||||
|
||||
const issueLink = `${workspaceSlug}/projects/${projectId}/issues/${issueId}`;
|
||||
const issueLink = `${workspaceSlug}/projects/${projectId}/${isEpic ? "epics" : "issues"}/${issueId}`;
|
||||
|
||||
const copyToClipboard = async (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
|
||||
try {
|
||||
@@ -43,12 +44,12 @@ export const CreateIssueToastActionItems: FC<TCreateIssueToastActionItems> = obs
|
||||
return (
|
||||
<div className="flex items-center gap-1 text-xs text-custom-text-200">
|
||||
<a
|
||||
href={`/${workspaceSlug}/projects/${projectId}/issues/${issueId}/`}
|
||||
href={`/${workspaceSlug}/projects/${projectId}/${isEpic ? "epics" : "issues"}/${issueId}/`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-custom-primary px-2 py-1 hover:bg-custom-background-90 font-medium rounded"
|
||||
>
|
||||
View issue
|
||||
{`View ${isEpic ? "epic" : "issue"}`}
|
||||
</a>
|
||||
|
||||
{copied ? (
|
||||
|
||||
@@ -11,7 +11,6 @@ import { PROJECT_ERROR_MESSAGES } from "@/constants/project";
|
||||
import { useIssues, useProject, useUser, useUserPermissions } from "@/hooks/store";
|
||||
// plane-web
|
||||
import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
handleClose: () => void;
|
||||
@@ -19,10 +18,11 @@ type Props = {
|
||||
data?: TIssue | TDeDupeIssue;
|
||||
isSubIssue?: boolean;
|
||||
onSubmit?: () => Promise<void>;
|
||||
isEpic?: boolean;
|
||||
};
|
||||
|
||||
export const DeleteIssueModal: React.FC<Props> = (props) => {
|
||||
const { dataId, data, isOpen, handleClose, isSubIssue = false, onSubmit } = props;
|
||||
const { dataId, data, isOpen, handleClose, isSubIssue = false, onSubmit, isEpic = false } = props;
|
||||
// states
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
// store hooks
|
||||
@@ -70,12 +70,14 @@ export const DeleteIssueModal: React.FC<Props> = (props) => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Success!",
|
||||
message: `${isSubIssue ? "Sub-issue" : "Issue"} deleted successfully`,
|
||||
message: `${isSubIssue ? "Sub-issue" : isEpic ? "Epic" : "Issue"} deleted successfully`,
|
||||
});
|
||||
onClose();
|
||||
})
|
||||
.catch((errors) => {
|
||||
const isPermissionError = errors?.error === "Only admin or creator can delete the issue";
|
||||
const isPermissionError =
|
||||
errors?.error ===
|
||||
`Only admin or creator can delete the ${isSubIssue ? "sub-issue" : isEpic ? "epic" : "issue"}`;
|
||||
const currentError = isPermissionError
|
||||
? PROJECT_ERROR_MESSAGES.permissionError
|
||||
: PROJECT_ERROR_MESSAGES.issueDeleteError;
|
||||
@@ -94,14 +96,14 @@ export const DeleteIssueModal: React.FC<Props> = (props) => {
|
||||
handleSubmit={handleIssueDelete}
|
||||
isSubmitting={isDeleting}
|
||||
isOpen={isOpen}
|
||||
title="Delete issue"
|
||||
title={`Delete ${isEpic ? "epic" : "issue"}`}
|
||||
content={
|
||||
<>
|
||||
Are you sure you want to delete issue{" "}
|
||||
{`Are you sure you want to delete ${isEpic ? "epic" : "issue"} `}
|
||||
<span className="break-words font-medium text-custom-text-100">
|
||||
{projectDetails?.identifier}-{issue?.sequence_id}
|
||||
</span>
|
||||
{""}? All of the data related to the issue will be permanently removed. This action cannot be undone.
|
||||
{` ? All of the data related to the ${isEpic ? "epic" : "issue"} will be permanently removed. This action cannot be undone.`}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -16,11 +16,12 @@ import { TIssueOperations } from "@/components/issues/issue-detail";
|
||||
import { getDescriptionPlaceholder } from "@/helpers/issue.helper";
|
||||
// hooks
|
||||
import { useWorkspace } from "@/hooks/store";
|
||||
// plane web services
|
||||
import { WorkspaceService } from "@/plane-web/services";
|
||||
// services
|
||||
import { FileService } from "@/services/file.service";
|
||||
import { ProjectService } from "@/services/project";
|
||||
const workspaceService = new WorkspaceService();
|
||||
const fileService = new FileService();
|
||||
const projectService = new ProjectService();
|
||||
|
||||
export type IssueDescriptionInputProps = {
|
||||
containerClassName?: string;
|
||||
@@ -121,11 +122,10 @@ export const IssueDescriptionInput: FC<IssueDescriptionInputProps> = observer((p
|
||||
placeholder ? placeholder : (isFocused, value) => getDescriptionPlaceholder(isFocused, value)
|
||||
}
|
||||
searchMentionCallback={async (payload) =>
|
||||
await projectService.searchEntity(
|
||||
workspaceSlug?.toString() ?? "",
|
||||
projectId?.toString() ?? "",
|
||||
payload
|
||||
)
|
||||
await workspaceService.searchEntity(workspaceSlug?.toString() ?? "", {
|
||||
...payload,
|
||||
project_id: projectId?.toString() ?? "",
|
||||
})
|
||||
}
|
||||
containerClassName={containerClassName}
|
||||
uploadFile={async (file) => {
|
||||
|
||||
@@ -123,6 +123,7 @@ const HeaderFilters = observer((props: Props) => {
|
||||
states={projectStates}
|
||||
cycleViewDisabled={!currentProjectDetails?.cycle_view}
|
||||
moduleViewDisabled={!currentProjectDetails?.module_view}
|
||||
isEpic={storeType === EIssuesStoreType.EPIC}
|
||||
/>
|
||||
</FiltersDropdown>
|
||||
<FiltersDropdown title="Display" placement="bottom-end">
|
||||
@@ -134,6 +135,7 @@ const HeaderFilters = observer((props: Props) => {
|
||||
handleDisplayPropertiesUpdate={handleDisplayProperties}
|
||||
cycleViewDisabled={!currentProjectDetails?.cycle_view}
|
||||
moduleViewDisabled={!currentProjectDetails?.module_view}
|
||||
isEpic={storeType === EIssuesStoreType.EPIC}
|
||||
/>
|
||||
</FiltersDropdown>
|
||||
{canUserCreateIssue ? (
|
||||
|
||||
@@ -53,11 +53,10 @@ export const SubIssuesCollapsibleContent: FC<Props> = observer((props) => {
|
||||
},
|
||||
});
|
||||
// store hooks
|
||||
const { toggleCreateIssueModal, toggleDeleteIssueModal } = useIssueDetail();
|
||||
const {
|
||||
subIssues: { subIssueHelpersByIssueId, setSubIssueHelpers },
|
||||
toggleCreateIssueModal,
|
||||
toggleDeleteIssueModal,
|
||||
} = useIssueDetail();
|
||||
} = useIssueDetail(issueServiceType);
|
||||
|
||||
// helpers
|
||||
const subIssueOperations = useSubIssueOperations(issueServiceType);
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
"use client";
|
||||
import { useMemo } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useParams, usePathname } from "next/navigation";
|
||||
import { EIssueServiceType } from "@plane/constants";
|
||||
import { TIssue, TIssueServiceType } from "@plane/types";
|
||||
import { TOAST_TYPE, setToast } from "@plane/ui";
|
||||
// helper
|
||||
import { copyTextToClipboard } from "@/helpers/string.helper";
|
||||
// hooks
|
||||
import { useEventTracker, useIssueDetail } from "@/hooks/store";
|
||||
import { useEventTracker, useIssueDetail, useProjectState } from "@/hooks/store";
|
||||
// plane-web
|
||||
import { updateEpicAnalytics } from "@/plane-web/helpers/epic-analytics";
|
||||
// type
|
||||
import { TSubIssueOperations } from "../../sub-issues";
|
||||
|
||||
@@ -20,16 +22,26 @@ export type TRelationIssueOperations = {
|
||||
export const useSubIssueOperations = (
|
||||
issueServiceType: TIssueServiceType = EIssueServiceType.ISSUES
|
||||
): TSubIssueOperations => {
|
||||
// router
|
||||
const { epicId: epicIdParam } = useParams();
|
||||
const pathname = usePathname();
|
||||
// store hooks
|
||||
const {
|
||||
issue: { getIssueById },
|
||||
subIssues: { setSubIssueHelpers },
|
||||
fetchSubIssues,
|
||||
createSubIssues,
|
||||
updateSubIssue,
|
||||
deleteSubIssue,
|
||||
} = useIssueDetail();
|
||||
const { removeSubIssue } = useIssueDetail(issueServiceType);
|
||||
const { getStateById } = useProjectState();
|
||||
const { peekIssue: epicPeekIssue } = useIssueDetail(EIssueServiceType.EPICS);
|
||||
// const { updateEpicAnalytics } = useIssueTypes();
|
||||
const { updateAnalytics } = updateEpicAnalytics();
|
||||
const { fetchSubIssues, removeSubIssue } = useIssueDetail(issueServiceType);
|
||||
const { captureIssueEvent } = useEventTracker();
|
||||
const pathname = usePathname();
|
||||
|
||||
// derived values
|
||||
const epicId = epicIdParam || epicPeekIssue?.issueId;
|
||||
|
||||
const subIssueOperations: TSubIssueOperations = useMemo(
|
||||
() => ({
|
||||
@@ -39,7 +51,7 @@ export const useSubIssueOperations = (
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Link Copied!",
|
||||
message: "Issue link copied to clipboard.",
|
||||
message: `${issueServiceType === EIssueServiceType.ISSUES ? "Issue" : "Epic"} link copied to clipboard`,
|
||||
});
|
||||
});
|
||||
},
|
||||
@@ -50,7 +62,7 @@ export const useSubIssueOperations = (
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: "Error fetching sub-issues",
|
||||
message: `Error fetching ${issueServiceType === EIssueServiceType.ISSUES ? "sub-issues" : "issues"}`,
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -60,13 +72,13 @@ export const useSubIssueOperations = (
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Success!",
|
||||
message: "Sub-issues added successfully",
|
||||
message: `${issueServiceType === EIssueServiceType.ISSUES ? "Sub-issues" : "Issues"} added successfully`,
|
||||
});
|
||||
} catch (error) {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: "Error adding sub-issue",
|
||||
message: `Error adding ${issueServiceType === EIssueServiceType.ISSUES ? "sub-issues" : "issues"}`,
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -82,6 +94,30 @@ export const useSubIssueOperations = (
|
||||
try {
|
||||
setSubIssueHelpers(parentIssueId, "issue_loader", issueId);
|
||||
await updateSubIssue(workspaceSlug, projectId, parentIssueId, issueId, issueData, oldIssue, fromModal);
|
||||
|
||||
if (issueServiceType === EIssueServiceType.EPICS) {
|
||||
const oldState = getStateById(oldIssue?.state_id)?.group;
|
||||
|
||||
if (oldState && oldIssue && issueData && epicId) {
|
||||
// Check if parent_id is changed if yes then decrement the epic analytics count
|
||||
if (issueData.parent_id && oldIssue?.parent_id && issueData.parent_id !== oldIssue?.parent_id) {
|
||||
updateAnalytics(workspaceSlug, projectId, epicId.toString(), {
|
||||
decrementStateGroupCount: `${oldState}_issues`,
|
||||
});
|
||||
}
|
||||
|
||||
// Check if state_id is changed if yes then decrement the old state group count and increment the new state group count
|
||||
if (issueData.state_id) {
|
||||
const newState = getStateById(issueData.state_id)?.group;
|
||||
if (oldState && newState && oldState !== newState) {
|
||||
updateAnalytics(workspaceSlug, projectId, epicId.toString(), {
|
||||
decrementStateGroupCount: `${oldState}_issues`,
|
||||
incrementStateGroupCount: `${newState}_issues`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
captureIssueEvent({
|
||||
eventName: "Sub-issue updated",
|
||||
payload: { ...oldIssue, ...issueData, state: "SUCCESS", element: "Issue detail page" },
|
||||
@@ -118,6 +154,16 @@ export const useSubIssueOperations = (
|
||||
try {
|
||||
setSubIssueHelpers(parentIssueId, "issue_loader", issueId);
|
||||
await removeSubIssue(workspaceSlug, projectId, parentIssueId, issueId);
|
||||
if (issueServiceType === EIssueServiceType.EPICS) {
|
||||
const issueBeforeRemoval = getIssueById(issueId);
|
||||
const oldState = getStateById(issueBeforeRemoval?.state_id)?.group;
|
||||
|
||||
if (epicId && oldState) {
|
||||
updateAnalytics(workspaceSlug, projectId, epicId.toString(), {
|
||||
decrementStateGroupCount: `${oldState}_issues`,
|
||||
});
|
||||
}
|
||||
}
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Success!",
|
||||
|
||||
@@ -38,7 +38,7 @@ export const SubIssuesCollapsibleTitle: FC<Props> = observer((props) => {
|
||||
return (
|
||||
<CollapsibleButton
|
||||
isOpen={isOpen}
|
||||
title="Sub-issues"
|
||||
title={`${issueServiceType === EIssueServiceType.EPICS ? "Issues" : "Sub-issues"}`}
|
||||
indicatorElement={
|
||||
<div className="flex items-center gap-1.5 text-custom-text-300 text-sm">
|
||||
<CircularProgressIndicator size={18} percentage={percentage} strokeWidth={3} />
|
||||
|
||||
@@ -9,18 +9,24 @@ import { cn } from "@/helpers/common.helper";
|
||||
export type TActivitySortRoot = {
|
||||
sortOrder: "asc" | "desc";
|
||||
toggleSort: () => void;
|
||||
className?: string;
|
||||
iconClassName?: string;
|
||||
};
|
||||
export const ActivitySortRoot: FC<TActivitySortRoot> = memo((props) => (
|
||||
<div
|
||||
className={cn(getButtonStyling("neutral-primary", "sm"), "px-2 text-custom-text-300 cursor-pointer")}
|
||||
className={cn(
|
||||
getButtonStyling("neutral-primary", "sm"),
|
||||
"px-2 text-custom-text-300 cursor-pointer",
|
||||
props.className
|
||||
)}
|
||||
onClick={() => {
|
||||
props.toggleSort();
|
||||
}}
|
||||
>
|
||||
{props.sortOrder === "asc" ? (
|
||||
<ArrowUpWideNarrow className="size-4 " />
|
||||
<ArrowUpWideNarrow className={cn("size-4", props.iconClassName)} />
|
||||
) : (
|
||||
<ArrowDownWideNarrow className="size-4 " />
|
||||
<ArrowDownWideNarrow className={cn("size-4", props.iconClassName)} />
|
||||
)}
|
||||
</div>
|
||||
));
|
||||
|
||||
@@ -183,9 +183,12 @@ export const IssueLabelSelect: React.FC<IIssueLabelSelect> = observer((props) =>
|
||||
) : submitting ? (
|
||||
<Loader className="spin h-3.5 w-3.5" />
|
||||
) : canCreateLabel ? (
|
||||
<p
|
||||
onClick={() => {
|
||||
if(!query.length) return
|
||||
<Combobox.Option
|
||||
value={query}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!query.length) return;
|
||||
handleAddLabel(query);
|
||||
}}
|
||||
className={`text-left text-custom-text-200 ${query.length ? "cursor-pointer" : "cursor-default"}`}
|
||||
@@ -197,7 +200,7 @@ export const IssueLabelSelect: React.FC<IIssueLabelSelect> = observer((props) =>
|
||||
) : (
|
||||
"Type to add a new label"
|
||||
)}
|
||||
</p>
|
||||
</Combobox.Option>
|
||||
) : (
|
||||
<p className="text-left text-custom-text-200 ">No matching results.</p>
|
||||
)}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Pencil, Trash2, LinkIcon, ExternalLink } from "lucide-react";
|
||||
import { Pencil, Trash2, LinkIcon, Copy } from "lucide-react";
|
||||
import { EIssueServiceType } from "@plane/constants";
|
||||
import { TIssueServiceType } from "@plane/types";
|
||||
// ui
|
||||
@@ -48,33 +48,33 @@ export const IssueLinkItem: FC<TIssueLinkItem> = observer((props) => {
|
||||
<div className="flex items-center gap-2.5 truncate flex-grow">
|
||||
<LinkIcon className="size-4 flex-shrink-0 text-custom-text-400 group-hover:text-custom-text-200" />
|
||||
<Tooltip tooltipContent={linkDetail.url} isMobile={isMobile}>
|
||||
<span
|
||||
<a
|
||||
href={linkDetail.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="truncate text-sm cursor-pointer flex-grow"
|
||||
onClick={() => {
|
||||
copyTextToClipboard(linkDetail.url);
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Link copied!",
|
||||
message: "Link copied to clipboard",
|
||||
});
|
||||
}}
|
||||
>
|
||||
{linkDetail.title && linkDetail.title !== "" ? linkDetail.title : linkDetail.url}
|
||||
</span>
|
||||
</a>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
<p className="p-1 text-xs align-bottom leading-5 text-custom-text-400 group-hover-text-custom-text-200">
|
||||
{calculateTimeAgoShort(linkDetail.created_at)}
|
||||
</p>
|
||||
<a
|
||||
href={linkDetail.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
<span
|
||||
onClick={() => {
|
||||
copyTextToClipboard(linkDetail.url);
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Link copied!",
|
||||
message: "Link copied to clipboard",
|
||||
});
|
||||
}}
|
||||
className="relative grid place-items-center rounded p-1 text-custom-text-400 outline-none group-hover:text-custom-text-200 cursor-pointer hover:bg-custom-background-80"
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5 stroke-[1.5]" />
|
||||
</a>
|
||||
<Copy className="h-3.5 w-3.5 stroke-[1.5]" />
|
||||
</span>
|
||||
<CustomMenu
|
||||
ellipsis
|
||||
buttonClassName="text-custom-text-400 group-hover:text-custom-text-200"
|
||||
|
||||
@@ -50,7 +50,7 @@ export const BaseCalendarRoot = observer((props: IBaseCalendarRoot) => {
|
||||
const { workspaceSlug } = useParams();
|
||||
|
||||
// hooks
|
||||
const storeType = useIssueStoreType() as CalendarStoreType;
|
||||
const storeType = isEpic ? EIssuesStoreType.EPIC : (useIssueStoreType() as CalendarStoreType);
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
const { issues, issuesFilter, issueMap } = useIssues(storeType);
|
||||
const {
|
||||
|
||||
@@ -87,6 +87,7 @@ export const CalendarIssueBlocks: React.FC<Props> = observer((props) => {
|
||||
}}
|
||||
quickAddCallback={quickAddCallback}
|
||||
addIssuesToView={addIssuesToView}
|
||||
isEpic={isEpic}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -24,10 +24,11 @@ type TCalendarQuickAddIssueActions = {
|
||||
quickAddCallback?: (projectId: string | null | undefined, data: TIssue) => Promise<TIssue | undefined>;
|
||||
addIssuesToView?: (issueIds: string[]) => Promise<any>;
|
||||
onOpen?: () => void;
|
||||
isEpic?: boolean;
|
||||
};
|
||||
|
||||
export const CalendarQuickAddIssueActions: FC<TCalendarQuickAddIssueActions> = observer((props) => {
|
||||
const { prePopulatedData, quickAddCallback, addIssuesToView, onOpen } = props;
|
||||
const { prePopulatedData, quickAddCallback, addIssuesToView, onOpen, isEpic = false } = props;
|
||||
// router
|
||||
const { workspaceSlug, projectId, moduleId } = useParams();
|
||||
// states
|
||||
@@ -118,15 +119,16 @@ export const CalendarQuickAddIssueActions: FC<TCalendarQuickAddIssueActions> = o
|
||||
customButton={
|
||||
<div className="flex w-full items-center gap-x-[6px] rounded-md px-2 py-1.5 text-custom-text-350 hover:text-custom-text-300">
|
||||
<PlusIcon className="h-3.5 w-3.5 stroke-2 flex-shrink-0" />
|
||||
<span className="text-sm font-medium flex-shrink-0">New issue</span>
|
||||
<span className="text-sm font-medium flex-shrink-0">{`New ${isEpic ? "Epic" : "Issue"}`}</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<CustomMenu.MenuItem onClick={handleNewIssue}>New issue</CustomMenu.MenuItem>
|
||||
<CustomMenu.MenuItem onClick={handleExistingIssue}>Add existing issue</CustomMenu.MenuItem>
|
||||
<CustomMenu.MenuItem onClick={handleNewIssue}>{`New ${isEpic ? "Epic" : "Issue"}`}</CustomMenu.MenuItem>
|
||||
{!isEpic && <CustomMenu.MenuItem onClick={handleExistingIssue}>Add existing issue</CustomMenu.MenuItem>}
|
||||
</CustomMenu>
|
||||
</div>
|
||||
}
|
||||
isEpic={isEpic}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
+3
@@ -25,6 +25,7 @@ type Props = {
|
||||
ignoreGroupedFilters?: Partial<TIssueGroupByOptions>[];
|
||||
cycleViewDisabled?: boolean;
|
||||
moduleViewDisabled?: boolean;
|
||||
isEpic?: boolean;
|
||||
};
|
||||
|
||||
export const DisplayFiltersSelection: React.FC<Props> = observer((props) => {
|
||||
@@ -37,6 +38,7 @@ export const DisplayFiltersSelection: React.FC<Props> = observer((props) => {
|
||||
ignoreGroupedFilters = [],
|
||||
cycleViewDisabled = false,
|
||||
moduleViewDisabled = false,
|
||||
isEpic = false,
|
||||
} = props;
|
||||
|
||||
const isDisplayFilterEnabled = (displayFilter: keyof IIssueDisplayFilterOptions) =>
|
||||
@@ -61,6 +63,7 @@ export const DisplayFiltersSelection: React.FC<Props> = observer((props) => {
|
||||
handleUpdate={handleDisplayPropertiesUpdate}
|
||||
cycleViewDisabled={cycleViewDisabled}
|
||||
moduleViewDisabled={moduleViewDisabled}
|
||||
isEpic={isEpic}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
+7
@@ -16,6 +16,7 @@ type Props = {
|
||||
handleUpdate: (updatedDisplayProperties: Partial<IIssueDisplayProperties>) => void;
|
||||
cycleViewDisabled?: boolean;
|
||||
moduleViewDisabled?: boolean;
|
||||
isEpic?: boolean;
|
||||
};
|
||||
|
||||
export const FilterDisplayProperties: React.FC<Props> = observer((props) => {
|
||||
@@ -25,6 +26,7 @@ export const FilterDisplayProperties: React.FC<Props> = observer((props) => {
|
||||
handleUpdate,
|
||||
cycleViewDisabled = false,
|
||||
moduleViewDisabled = false,
|
||||
isEpic = false,
|
||||
} = props;
|
||||
// router
|
||||
const { workspaceSlug, projectId: routerProjectId } = useParams();
|
||||
@@ -45,6 +47,11 @@ export const FilterDisplayProperties: React.FC<Props> = observer((props) => {
|
||||
default:
|
||||
return shouldRenderDisplayProperty({ workspaceSlug: workspaceSlug?.toString(), projectId, key: property.key });
|
||||
}
|
||||
}).map((property) => {
|
||||
if (isEpic && property.key === "sub_issue_count") {
|
||||
return { ...property, title: "Issue count" };
|
||||
}
|
||||
return property;
|
||||
});
|
||||
|
||||
return (
|
||||
|
||||
+4
-3
@@ -11,10 +11,11 @@ import { ISSUE_FILTER_OPTIONS } from "@/constants/issue";
|
||||
type Props = {
|
||||
selectedIssueType: TIssueGroupingFilters | undefined;
|
||||
handleUpdate: (val: TIssueGroupingFilters) => void;
|
||||
isEpic?: boolean;
|
||||
};
|
||||
|
||||
export const FilterIssueGrouping: React.FC<Props> = observer((props) => {
|
||||
const { selectedIssueType, handleUpdate } = props;
|
||||
const { selectedIssueType, handleUpdate, isEpic = false } = props;
|
||||
|
||||
const [previewEnabled, setPreviewEnabled] = React.useState(true);
|
||||
|
||||
@@ -23,7 +24,7 @@ export const FilterIssueGrouping: React.FC<Props> = observer((props) => {
|
||||
return (
|
||||
<>
|
||||
<FilterHeader
|
||||
title="Issue Grouping"
|
||||
title={`${isEpic ? "Epic" : "Issue"} Grouping`}
|
||||
isPreviewEnabled={previewEnabled}
|
||||
handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)}
|
||||
/>
|
||||
@@ -34,7 +35,7 @@ export const FilterIssueGrouping: React.FC<Props> = observer((props) => {
|
||||
key={issueType?.key}
|
||||
isChecked={activeIssueType === issueType?.key ? true : false}
|
||||
onClick={() => handleUpdate(issueType?.key)}
|
||||
title={issueType.title}
|
||||
title={`${issueType.title} ${isEpic ? "Epics" : "Issues"}`}
|
||||
multiple={false}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -42,6 +42,7 @@ type Props = {
|
||||
states?: IState[] | undefined;
|
||||
cycleViewDisabled?: boolean;
|
||||
moduleViewDisabled?: boolean;
|
||||
isEpic?: boolean;
|
||||
};
|
||||
|
||||
export const FilterSelection: React.FC<Props> = observer((props) => {
|
||||
@@ -56,6 +57,7 @@ export const FilterSelection: React.FC<Props> = observer((props) => {
|
||||
states,
|
||||
cycleViewDisabled = false,
|
||||
moduleViewDisabled = false,
|
||||
isEpic = false,
|
||||
} = props;
|
||||
// hooks
|
||||
const { isMobile } = usePlatformOS();
|
||||
@@ -234,6 +236,7 @@ export const FilterSelection: React.FC<Props> = observer((props) => {
|
||||
type: val,
|
||||
})
|
||||
}
|
||||
isEpic={isEpic}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -111,6 +111,7 @@ export const BaseGanttRoot: React.FC<IBaseGanttRoot> = observer((props: IBaseGan
|
||||
target_date: renderFormattedPayloadDate(targetDate),
|
||||
}}
|
||||
quickAddCallback={quickAddIssue}
|
||||
isEpic={isEpic}
|
||||
/>
|
||||
) : undefined;
|
||||
|
||||
@@ -120,8 +121,8 @@ export const BaseGanttRoot: React.FC<IBaseGanttRoot> = observer((props: IBaseGan
|
||||
<div className="h-full w-full">
|
||||
<GanttChartRoot
|
||||
border={false}
|
||||
title="Issues"
|
||||
loaderTitle="Issues"
|
||||
title={isEpic ? "Epics" : "Issues"}
|
||||
loaderTitle={isEpic ? "Epics" : "Issues"}
|
||||
blockIds={issuesIds}
|
||||
blockUpdateHandler={updateIssueBlockStructure}
|
||||
blockToRender={(data: TIssue) => <IssueGanttBlock issueId={data.id} isEpic={isEpic} />}
|
||||
|
||||
@@ -16,6 +16,7 @@ type Props = {
|
||||
dropErrorMessage?: string;
|
||||
orderBy: TIssueOrderByOptions | undefined;
|
||||
isDraggingOverColumn: boolean;
|
||||
isEpic?: boolean;
|
||||
};
|
||||
|
||||
export const GroupDragOverlay = (props: Props) => {
|
||||
@@ -27,6 +28,7 @@ export const GroupDragOverlay = (props: Props) => {
|
||||
dropErrorMessage,
|
||||
orderBy,
|
||||
isDraggingOverColumn,
|
||||
isEpic = false,
|
||||
} = props;
|
||||
|
||||
const shouldOverlayBeVisible = isDraggingOverColumn && canOverlayBeVisible;
|
||||
@@ -68,7 +70,7 @@ export const GroupDragOverlay = (props: Props) => {
|
||||
The layout is ordered by <span className="font-semibold">{readableOrderBy}</span>.
|
||||
</span>
|
||||
)}
|
||||
<span>Drop here to move the issue.</span>
|
||||
<span>{`Drop here to move the ${isEpic ? "epic" : "issue"}.`}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -98,6 +98,7 @@ export const KanBan: React.FC<IKanBan> = observer((props) => {
|
||||
groupBy: group_by as GroupByColumnTypes,
|
||||
includeNone: true,
|
||||
isWorkspaceLevel: isWorkspaceLevel(storeType),
|
||||
isEpic: isEpic,
|
||||
});
|
||||
|
||||
if (!list) return null;
|
||||
|
||||
@@ -82,7 +82,7 @@ export const KanbanGroup = observer((props: IKanbanGroup) => {
|
||||
quickAddCallback,
|
||||
scrollableContainerRef,
|
||||
handleOnDrop,
|
||||
isEpic =false
|
||||
isEpic = false,
|
||||
} = props;
|
||||
// hooks
|
||||
const projectState = useProjectState();
|
||||
@@ -285,6 +285,7 @@ export const KanbanGroup = observer((props: IKanbanGroup) => {
|
||||
dropErrorMessage={dropErrorMessage}
|
||||
orderBy={orderBy}
|
||||
isDraggingOverColumn={isDraggingOverColumn}
|
||||
isEpic={isEpic}
|
||||
/>
|
||||
<KanbanIssueBlocksList
|
||||
sub_group_id={sub_group_id}
|
||||
@@ -312,6 +313,7 @@ export const KanbanGroup = observer((props: IKanbanGroup) => {
|
||||
...(group_by && prePopulateQuickAddData(group_by, sub_group_by, groupId, sub_group_id)),
|
||||
}}
|
||||
quickAddCallback={quickAddCallback}
|
||||
isEpic={isEpic}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -84,6 +84,7 @@ export const List: React.FC<IList> = observer((props) => {
|
||||
groupBy: group_by as GroupByColumnTypes,
|
||||
includeNone: true,
|
||||
isWorkspaceLevel: isWorkspaceLevel(storeType),
|
||||
isEpic: isEpic,
|
||||
});
|
||||
|
||||
// Enable Auto Scroll for Main Kanban
|
||||
|
||||
@@ -284,6 +284,7 @@ export const ListGroup = observer((props: Props) => {
|
||||
dropErrorMessage={group.dropErrorMessage}
|
||||
orderBy={orderBy}
|
||||
isDraggingOverColumn={isDraggingOverColumn}
|
||||
isEpic={isEpic}
|
||||
/>
|
||||
{groupIssueIds && (
|
||||
<IssueBlocksList
|
||||
@@ -312,6 +313,7 @@ export const ListGroup = observer((props: Props) => {
|
||||
prePopulatedData={prePopulateQuickAddData(group_by, group.id)}
|
||||
containerClassName="border-b border-t border-custom-border-200 bg-custom-background-100 "
|
||||
quickAddCallback={quickAddCallback}
|
||||
isEpic={isEpic}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useCallback, useMemo, SyntheticEvent } from "react";
|
||||
import xor from "lodash/xor";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams, usePathname } from "next/navigation";
|
||||
@@ -245,7 +245,7 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
|
||||
|
||||
const redirectToIssueDetail = () => {
|
||||
router.push(
|
||||
`/${workspaceSlug}/projects/${issue.project_id}/${issue.archived_at ? "archives/" : ""}issues/${issue.id}#sub-issues`
|
||||
`/${workspaceSlug}/projects/${issue.project_id}/${issue.archived_at ? "archives/" : ""}${isEpic ? "epics" : "issues"}/${issue.id}#sub-issues`
|
||||
);
|
||||
// router.push({
|
||||
// pathname: `/${workspaceSlug}/projects/${issue.project_id}/${issue.archived_at ? "archives/" : ""}issues/${
|
||||
@@ -265,7 +265,7 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
|
||||
const maxDate = getDate(issue.target_date);
|
||||
maxDate?.setDate(maxDate.getDate());
|
||||
|
||||
const handleEventPropagation = (e: React.MouseEvent) => {
|
||||
const handleEventPropagation = (e: SyntheticEvent<HTMLDivElement>) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
};
|
||||
@@ -275,7 +275,7 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
|
||||
{/* basic properties */}
|
||||
{/* state */}
|
||||
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="state">
|
||||
<div className="h-5" onClick={handleEventPropagation}>
|
||||
<div className="h-5" onFocus={handleEventPropagation} onClick={handleEventPropagation}>
|
||||
<StateDropdown
|
||||
buttonContainerClassName="truncate max-w-40"
|
||||
value={issue.state_id}
|
||||
@@ -291,7 +291,7 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
|
||||
|
||||
{/* priority */}
|
||||
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="priority">
|
||||
<div className="h-5" onClick={handleEventPropagation}>
|
||||
<div className="h-5" onFocus={handleEventPropagation} onClick={handleEventPropagation}>
|
||||
<PriorityDropdown
|
||||
value={issue?.priority}
|
||||
onChange={handlePriority}
|
||||
@@ -306,7 +306,7 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
|
||||
|
||||
{/* start date */}
|
||||
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="start_date">
|
||||
<div className="h-5" onClick={handleEventPropagation}>
|
||||
<div className="h-5" onFocus={handleEventPropagation} onClick={handleEventPropagation}>
|
||||
<DateDropdown
|
||||
value={issue.start_date ?? null}
|
||||
onChange={handleStartDate}
|
||||
@@ -324,7 +324,7 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
|
||||
|
||||
{/* target/due date */}
|
||||
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="due_date">
|
||||
<div className="h-5" onClick={handleEventPropagation}>
|
||||
<div className="h-5" onFocus={handleEventPropagation} onClick={handleEventPropagation}>
|
||||
<DateDropdown
|
||||
value={issue?.target_date ?? null}
|
||||
onChange={handleTargetDate}
|
||||
@@ -344,7 +344,7 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
|
||||
|
||||
{/* assignee */}
|
||||
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="assignee">
|
||||
<div className="h-5" onClick={handleEventPropagation}>
|
||||
<div className="h-5" onFocus={handleEventPropagation} onClick={handleEventPropagation}>
|
||||
<MemberDropdown
|
||||
projectId={issue?.project_id}
|
||||
value={issue?.assignee_ids}
|
||||
@@ -362,52 +362,54 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
|
||||
</div>
|
||||
</WithDisplayPropertiesHOC>
|
||||
|
||||
{!isEpic && (
|
||||
<>
|
||||
{/* modules */}
|
||||
{projectDetails?.module_view && (
|
||||
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="modules">
|
||||
<div className="h-5" onClick={handleEventPropagation}>
|
||||
<ModuleDropdown
|
||||
buttonContainerClassName="truncate max-w-40"
|
||||
projectId={issue?.project_id}
|
||||
value={issue?.module_ids ?? []}
|
||||
onChange={handleModule}
|
||||
disabled={isReadOnly}
|
||||
renderByDefault={isMobile}
|
||||
multiple
|
||||
buttonVariant="border-with-text"
|
||||
showCount
|
||||
showTooltip
|
||||
/>
|
||||
</div>
|
||||
</WithDisplayPropertiesHOC>
|
||||
)}
|
||||
<>
|
||||
{!isEpic && (
|
||||
<>
|
||||
{/* modules */}
|
||||
{projectDetails?.module_view && (
|
||||
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="modules">
|
||||
<div className="h-5" onFocus={handleEventPropagation} onClick={handleEventPropagation}>
|
||||
<ModuleDropdown
|
||||
buttonContainerClassName="truncate max-w-40"
|
||||
projectId={issue?.project_id}
|
||||
value={issue?.module_ids ?? []}
|
||||
onChange={handleModule}
|
||||
disabled={isReadOnly}
|
||||
renderByDefault={isMobile}
|
||||
multiple
|
||||
buttonVariant="border-with-text"
|
||||
showCount
|
||||
showTooltip
|
||||
/>
|
||||
</div>
|
||||
</WithDisplayPropertiesHOC>
|
||||
)}
|
||||
|
||||
{/* cycles */}
|
||||
{projectDetails?.cycle_view && (
|
||||
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="cycle">
|
||||
<div className="h-5" onClick={handleEventPropagation}>
|
||||
<CycleDropdown
|
||||
buttonContainerClassName="truncate max-w-40"
|
||||
projectId={issue?.project_id}
|
||||
value={issue?.cycle_id}
|
||||
onChange={handleCycle}
|
||||
disabled={isReadOnly}
|
||||
buttonVariant="border-with-text"
|
||||
renderByDefault={isMobile}
|
||||
showTooltip
|
||||
/>
|
||||
</div>
|
||||
</WithDisplayPropertiesHOC>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{/* cycles */}
|
||||
{projectDetails?.cycle_view && (
|
||||
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="cycle">
|
||||
<div className="h-5" onFocus={handleEventPropagation} onClick={handleEventPropagation}>
|
||||
<CycleDropdown
|
||||
buttonContainerClassName="truncate max-w-40"
|
||||
projectId={issue?.project_id}
|
||||
value={issue?.cycle_id}
|
||||
onChange={handleCycle}
|
||||
disabled={isReadOnly}
|
||||
buttonVariant="border-with-text"
|
||||
renderByDefault={isMobile}
|
||||
showTooltip
|
||||
/>
|
||||
</div>
|
||||
</WithDisplayPropertiesHOC>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
||||
{/* estimates */}
|
||||
{projectId && areEstimateEnabledByProjectId(projectId?.toString()) && (
|
||||
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="estimate">
|
||||
<div className="h-5" onClick={handleEventPropagation}>
|
||||
<div className="h-5" onFocus={handleEventPropagation} onClick={handleEventPropagation}>
|
||||
<EstimateDropdown
|
||||
value={issue.estimate_point ?? undefined}
|
||||
onChange={handleEstimate}
|
||||
@@ -429,12 +431,13 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
|
||||
shouldRenderProperty={(properties) => !!properties.sub_issue_count && !!subIssueCount}
|
||||
>
|
||||
<Tooltip
|
||||
tooltipHeading="Sub-issues"
|
||||
tooltipHeading={isEpic ? "Issues" : "Sub-issues"}
|
||||
tooltipContent={`${subIssueCount}`}
|
||||
isMobile={isMobile}
|
||||
renderByDefault={false}
|
||||
>
|
||||
<div
|
||||
onFocus={handleEventPropagation}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
@@ -467,6 +470,7 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
|
||||
>
|
||||
<div
|
||||
className="flex h-5 flex-shrink-0 items-center justify-center gap-2 overflow-hidden rounded border-[0.5px] border-custom-border-300 px-2.5 py-1"
|
||||
onFocus={handleEventPropagation}
|
||||
onClick={handleEventPropagation}
|
||||
>
|
||||
<Paperclip className="h-3 w-3 flex-shrink-0" strokeWidth={2} />
|
||||
@@ -489,6 +493,7 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
|
||||
>
|
||||
<div
|
||||
className="flex h-5 flex-shrink-0 items-center justify-center gap-2 overflow-hidden rounded border-[0.5px] border-custom-border-300 px-2.5 py-1"
|
||||
onFocus={handleEventPropagation}
|
||||
onClick={handleEventPropagation}
|
||||
>
|
||||
<Link className="h-3 w-3 flex-shrink-0" strokeWidth={2} />
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Row } from "@plane/ui";
|
||||
import { TQuickAddIssueButton } from "../root";
|
||||
|
||||
export const GanttQuickAddIssueButton: FC<TQuickAddIssueButton> = observer((props) => {
|
||||
const { onClick } = props;
|
||||
const { onClick, isEpic = false } = props;
|
||||
|
||||
return (
|
||||
<button
|
||||
@@ -15,7 +15,7 @@ export const GanttQuickAddIssueButton: FC<TQuickAddIssueButton> = observer((prop
|
||||
>
|
||||
<Row className="flex py-2 gap-2">
|
||||
<PlusIcon className="h-3.5 w-3.5 stroke-2 my-auto" />
|
||||
<span className="text-sm font-medium">New Issue</span>
|
||||
<span className="text-sm font-medium">{`New ${isEpic ? "Epic" : "Issue"}`}</span>
|
||||
</Row>
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -4,7 +4,7 @@ import { PlusIcon } from "lucide-react";
|
||||
import { TQuickAddIssueButton } from "../root";
|
||||
|
||||
export const KanbanQuickAddIssueButton: FC<TQuickAddIssueButton> = observer((props) => {
|
||||
const { onClick } = props;
|
||||
const { onClick, isEpic = false } = props;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -12,7 +12,7 @@ export const KanbanQuickAddIssueButton: FC<TQuickAddIssueButton> = observer((pro
|
||||
onClick={onClick}
|
||||
>
|
||||
<PlusIcon className="h-3.5 w-3.5 stroke-2" />
|
||||
<span className="text-sm font-medium">New Issue</span>
|
||||
<span className="text-sm font-medium">{`New ${isEpic ? "Epic" : "Issue"}`}</span>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Row } from "@plane/ui";
|
||||
import { TQuickAddIssueButton } from "../root";
|
||||
|
||||
export const ListQuickAddIssueButton: FC<TQuickAddIssueButton> = observer((props) => {
|
||||
const { onClick } = props;
|
||||
const { onClick, isEpic = false } = props;
|
||||
|
||||
return (
|
||||
<Row
|
||||
@@ -13,7 +13,7 @@ export const ListQuickAddIssueButton: FC<TQuickAddIssueButton> = observer((props
|
||||
onClick={onClick}
|
||||
>
|
||||
<PlusIcon className="h-3.5 w-3.5 stroke-2" />
|
||||
<span className="text-sm font-medium">New Issue</span>
|
||||
<span className="text-sm font-medium">{`New ${isEpic ? "Epic" : "Issue"}`}</span>
|
||||
</Row>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@ import { PlusIcon } from "lucide-react";
|
||||
import { TQuickAddIssueButton } from "../root";
|
||||
|
||||
export const SpreadsheetAddIssueButton: FC<TQuickAddIssueButton> = observer((props) => {
|
||||
const { onClick } = props;
|
||||
const { onClick, isEpic = false } = props;
|
||||
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
@@ -14,7 +14,7 @@ export const SpreadsheetAddIssueButton: FC<TQuickAddIssueButton> = observer((pro
|
||||
onClick={onClick}
|
||||
>
|
||||
<PlusIcon className="h-3.5 w-3.5 stroke-2" />
|
||||
<span className="text-sm font-medium">New Issue</span>
|
||||
<span className="text-sm font-medium">{`New ${isEpic ? "Epic" : "Issue"}`}</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -3,12 +3,13 @@ import { observer } from "mobx-react";
|
||||
import { TQuickAddIssueForm } from "../root";
|
||||
|
||||
export const CalendarQuickAddIssueForm: FC<TQuickAddIssueForm> = observer((props) => {
|
||||
const { ref, isOpen, projectDetail, register, onSubmit } = props;
|
||||
const { ref, isOpen, projectDetail, register, onSubmit, isEpic } = props;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`z-20 w-full transition-all ${isOpen ? "scale-100 opacity-100" : "pointer-events-none scale-95 opacity-0"
|
||||
}`}
|
||||
className={`z-20 w-full transition-all ${
|
||||
isOpen ? "scale-100 opacity-100" : "pointer-events-none scale-95 opacity-0"
|
||||
}`}
|
||||
>
|
||||
<form
|
||||
ref={ref}
|
||||
@@ -19,9 +20,9 @@ export const CalendarQuickAddIssueForm: FC<TQuickAddIssueForm> = observer((props
|
||||
<input
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
placeholder="Issue Title"
|
||||
placeholder={isEpic ? "Epic Title" : "Issue Title"}
|
||||
{...register("name", {
|
||||
required: "Issue title is required.",
|
||||
required: `${isEpic ? "Epic" : "Issue"} title is required.`,
|
||||
})}
|
||||
className="w-full rounded-md bg-transparent py-1.5 pr-2 text-sm md:text-xs font-medium leading-5 text-custom-text-200 outline-none"
|
||||
/>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { cn } from "@/helpers/common.helper";
|
||||
import { TQuickAddIssueForm } from "../root";
|
||||
|
||||
export const GanttQuickAddIssueForm: FC<TQuickAddIssueForm> = observer((props) => {
|
||||
const { ref, projectDetail, hasError, register, onSubmit } = props;
|
||||
const { ref, projectDetail, hasError, register, onSubmit, isEpic } = props;
|
||||
|
||||
return (
|
||||
<div className={cn("shadow-custom-shadow-sm", hasError && "border border-red-500/20 bg-red-500/10")}>
|
||||
@@ -18,15 +18,15 @@ export const GanttQuickAddIssueForm: FC<TQuickAddIssueForm> = observer((props) =
|
||||
<input
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
placeholder="Issue Title"
|
||||
placeholder={isEpic ? "Epic Title" : "Issue Title"}
|
||||
{...register("name", {
|
||||
required: "Issue title is required.",
|
||||
required: `${isEpic ? "Epic" : "Issue"} title is required.`,
|
||||
})}
|
||||
className="w-full rounded-md bg-transparent px-2 py-3 text-sm font-medium leading-5 text-custom-text-200 outline-none"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
<div className="px-3 py-2 text-xs bg-custom-background-100 italic text-custom-text-200">{`Press 'Enter' to add another issue`}</div>
|
||||
<div className="px-3 py-2 text-xs bg-custom-background-100 italic text-custom-text-200">{`Press 'Enter' to add another ${isEpic ? "epic" : "issue"}`}</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ import { observer } from "mobx-react";
|
||||
import { TQuickAddIssueForm } from "../root";
|
||||
|
||||
export const KanbanQuickAddIssueForm: FC<TQuickAddIssueForm> = observer((props) => {
|
||||
const { ref, projectDetail, register, onSubmit } = props;
|
||||
const { ref, projectDetail, register, onSubmit, isEpic } = props;
|
||||
|
||||
return (
|
||||
<div className="m-1 overflow-hidden rounded shadow-custom-shadow-sm">
|
||||
@@ -12,7 +12,7 @@ export const KanbanQuickAddIssueForm: FC<TQuickAddIssueForm> = observer((props)
|
||||
<h4 className="text-xs font-medium leading-5 text-custom-text-300">{projectDetail?.identifier ?? "..."}</h4>
|
||||
<input
|
||||
autoComplete="off"
|
||||
placeholder="Issue Title"
|
||||
placeholder={isEpic ? "Epic Title" : "Issue Title"}
|
||||
{...register("name", {
|
||||
required: "Issue title is required.",
|
||||
})}
|
||||
@@ -20,7 +20,7 @@ export const KanbanQuickAddIssueForm: FC<TQuickAddIssueForm> = observer((props)
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
<div className="px-3 py-2 text-xs italic text-custom-text-200">{`Press 'Enter' to add another issue`}</div>
|
||||
<div className="px-3 py-2 text-xs italic text-custom-text-200">{`Press 'Enter' to add another ${isEpic ? "epic" : "issue"}`}</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ import { observer } from "mobx-react";
|
||||
import { TQuickAddIssueForm } from "../root";
|
||||
|
||||
export const ListQuickAddIssueForm: FC<TQuickAddIssueForm> = observer((props) => {
|
||||
const { ref, projectDetail, register, onSubmit } = props;
|
||||
const { ref, projectDetail, register, onSubmit, isEpic } = props;
|
||||
|
||||
return (
|
||||
<div className="shadow-custom-shadow-sm">
|
||||
@@ -17,15 +17,15 @@ export const ListQuickAddIssueForm: FC<TQuickAddIssueForm> = observer((props) =>
|
||||
<input
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
placeholder="Issue Title"
|
||||
placeholder={isEpic ? "Epic Title" : "Issue Title"}
|
||||
{...register("name", {
|
||||
required: "Issue title is required.",
|
||||
required: `${isEpic ? "Epic" : "Issue"} title is required.`,
|
||||
})}
|
||||
className="w-full rounded-md bg-transparent px-2 py-3 text-sm font-medium leading-5 text-custom-text-200 outline-none"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
<div className="px-3 py-2 text-xs italic text-custom-text-200">{`Press 'Enter' to add another issue`}</div>
|
||||
<div className="px-3 py-2 text-xs italic text-custom-text-200">{`Press 'Enter' to add another ${isEpic ? "epic" : "issue"}`}</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ import { observer } from "mobx-react";
|
||||
import { TQuickAddIssueForm } from "../root";
|
||||
|
||||
export const SpreadsheetQuickAddIssueForm: FC<TQuickAddIssueForm> = observer((props) => {
|
||||
const { ref, projectDetail, register, onSubmit } = props;
|
||||
const { ref, projectDetail, register, onSubmit, isEpic } = props;
|
||||
|
||||
return (
|
||||
<div className="pb-2">
|
||||
@@ -16,15 +16,15 @@ export const SpreadsheetQuickAddIssueForm: FC<TQuickAddIssueForm> = observer((pr
|
||||
<input
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
placeholder="Issue Title"
|
||||
placeholder={isEpic ? "Epic Title" : "Issue Title"}
|
||||
{...register("name", {
|
||||
required: "Issue title is required.",
|
||||
required: `${isEpic ? "Epic" : "Issue"} title is required.`,
|
||||
})}
|
||||
className="w-full rounded-md bg-transparent py-3 text-sm leading-5 text-custom-text-200 outline-none"
|
||||
/>
|
||||
</form>
|
||||
<p className="ml-3 mt-3 text-xs italic text-custom-text-200">
|
||||
Press {"'"}Enter{"'"} to add another issue
|
||||
{`Press Enter to add another ${isEpic ? "epic" : "issue"}`}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useParams, usePathname } from "next/navigation";
|
||||
import { useForm, UseFormRegister } from "react-hook-form";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
// plane constants
|
||||
import { EIssueLayoutTypes } from "@plane/constants";
|
||||
import { EIssueLayoutTypes, EIssueServiceType } from "@plane/constants";
|
||||
// types
|
||||
import { IProject, TIssue } from "@plane/types";
|
||||
// ui
|
||||
@@ -30,9 +30,11 @@ export type TQuickAddIssueForm = {
|
||||
hasError: boolean;
|
||||
register: UseFormRegister<TIssue>;
|
||||
onSubmit: () => void;
|
||||
isEpic: boolean;
|
||||
};
|
||||
|
||||
export type TQuickAddIssueButton = {
|
||||
isEpic?: boolean;
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
@@ -45,6 +47,7 @@ type TQuickAddIssueRoot = {
|
||||
containerClassName?: string;
|
||||
setIsQuickAddOpen?: (isOpen: boolean) => void;
|
||||
quickAddCallback?: (projectId: string | null | undefined, data: TIssue) => Promise<TIssue | undefined>;
|
||||
isEpic?: boolean;
|
||||
};
|
||||
|
||||
const defaultValues: Partial<TIssue> = {
|
||||
@@ -61,6 +64,7 @@ export const QuickAddIssueRoot: FC<TQuickAddIssueRoot> = observer((props) => {
|
||||
containerClassName = "",
|
||||
setIsQuickAddOpen,
|
||||
quickAddCallback,
|
||||
isEpic = false,
|
||||
} = props;
|
||||
// router
|
||||
const { workspaceSlug, projectId } = useParams();
|
||||
@@ -109,15 +113,16 @@ export const QuickAddIssueRoot: FC<TQuickAddIssueRoot> = observer((props) => {
|
||||
if (quickAddCallback) {
|
||||
const quickAddPromise = quickAddCallback(projectId.toString(), { ...payload });
|
||||
setPromiseToast<any>(quickAddPromise, {
|
||||
loading: "Adding issue...",
|
||||
loading: `Adding ${isEpic ? "epic" : "issue"}...`,
|
||||
success: {
|
||||
title: "Success!",
|
||||
message: () => "Issue created successfully.",
|
||||
message: () => `${isEpic ? "Epic" : "Issue"} created successfully.`,
|
||||
actionItems: (data) => (
|
||||
<CreateIssueToastActionItems
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
projectId={projectId.toString()}
|
||||
issueId={data.id}
|
||||
isEpic={isEpic}
|
||||
/>
|
||||
),
|
||||
},
|
||||
@@ -165,10 +170,11 @@ export const QuickAddIssueRoot: FC<TQuickAddIssueRoot> = observer((props) => {
|
||||
register={register}
|
||||
onSubmit={handleSubmit(onSubmitHandler)}
|
||||
onClose={() => handleIsOpen(false)}
|
||||
isEpic={isEpic}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{QuickAddButton && <QuickAddButton onClick={() => handleIsOpen(true)} />}
|
||||
{QuickAddButton && <QuickAddButton isEpic={isEpic} onClick={() => handleIsOpen(true)} />}
|
||||
{customQuickAddButton && <>{customQuickAddButton}</>}
|
||||
{!QuickAddButton && !customQuickAddButton && (
|
||||
<div
|
||||
@@ -176,7 +182,7 @@ export const QuickAddIssueRoot: FC<TQuickAddIssueRoot> = observer((props) => {
|
||||
onClick={() => handleIsOpen(true)}
|
||||
>
|
||||
<PlusIcon className="h-3.5 w-3.5 stroke-2" />
|
||||
<span className="text-sm font-medium">New Issue</span>
|
||||
<span className="text-sm font-medium">{`New ${isEpic ? "Epic" : "Issue"}`}</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -15,10 +15,11 @@ interface Props {
|
||||
displayFilters: IIssueDisplayFilterOptions;
|
||||
handleDisplayFilterUpdate: (data: Partial<IIssueDisplayFilterOptions>) => void;
|
||||
onClose: () => void;
|
||||
isEpic?: boolean;
|
||||
}
|
||||
|
||||
export const HeaderColumn = (props: Props) => {
|
||||
const { displayFilters, handleDisplayFilterUpdate, property, onClose } = props;
|
||||
const { displayFilters, handleDisplayFilterUpdate, property, onClose, isEpic = false } = props;
|
||||
|
||||
const { storedValue: selectedMenuItem, setValue: setSelectedMenuItem } = useLocalStorage(
|
||||
"spreadsheetViewSorting",
|
||||
@@ -46,7 +47,7 @@ export const HeaderColumn = (props: Props) => {
|
||||
<Row 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}
|
||||
{propertyDetails.title === "Sub-issue" && isEpic ? "Issues" : propertyDetails.title}
|
||||
</div>
|
||||
<div className="ml-3 flex">
|
||||
{activeSortingProperty === property && (
|
||||
|
||||
@@ -18,16 +18,19 @@ export const SpreadsheetSubIssueColumn: React.FC<Props> = observer((props: Props
|
||||
// router
|
||||
const router = useAppRouter();
|
||||
// hooks
|
||||
const { workspaceSlug } = useParams();
|
||||
const { workspaceSlug, epicId } = useParams();
|
||||
// derived values
|
||||
const subIssueCount = issue?.sub_issues_count ?? 0;
|
||||
|
||||
const redirectToIssueDetail = () => {
|
||||
router.push(
|
||||
`/${workspaceSlug?.toString()}/projects/${issue.project_id}/${issue.archived_at ? "archives/" : ""}issues/${issue.id}#sub-issues`
|
||||
`/${workspaceSlug?.toString()}/projects/${issue.project_id}/${issue.archived_at ? "archives/" : ""}${epicId ? "epics" : "issues"}/${issue.id}#sub-issues`
|
||||
);
|
||||
};
|
||||
|
||||
const issueLabel = epicId ? "issue" : "sub-issue";
|
||||
const label = `${subIssueCount} ${issueLabel}${subIssueCount !== 1 ? "s" : ""}`;
|
||||
|
||||
return (
|
||||
<Row
|
||||
onClick={subIssueCount ? redirectToIssueDetail : () => {}}
|
||||
@@ -38,7 +41,7 @@ export const SpreadsheetSubIssueColumn: React.FC<Props> = observer((props: Props
|
||||
}
|
||||
)}
|
||||
>
|
||||
{subIssueCount} {subIssueCount === 1 ? "sub-issue" : "sub-issues"}
|
||||
{label}
|
||||
</Row>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -12,9 +12,17 @@ interface Props {
|
||||
isEstimateEnabled: boolean;
|
||||
displayFilters: IIssueDisplayFilterOptions;
|
||||
handleDisplayFilterUpdate: (data: Partial<IIssueDisplayFilterOptions>) => void;
|
||||
isEpic?: boolean;
|
||||
}
|
||||
export const SpreadsheetHeaderColumn = observer((props: Props) => {
|
||||
const { displayProperties, displayFilters, property, isEstimateEnabled, handleDisplayFilterUpdate } = props;
|
||||
const {
|
||||
displayProperties,
|
||||
displayFilters,
|
||||
property,
|
||||
isEstimateEnabled,
|
||||
handleDisplayFilterUpdate,
|
||||
isEpic = false,
|
||||
} = props;
|
||||
|
||||
//hooks
|
||||
const tableHeaderCellRef = useRef<HTMLTableCellElement | null>(null);
|
||||
@@ -39,6 +47,7 @@ export const SpreadsheetHeaderColumn = observer((props: Props) => {
|
||||
onClose={() => {
|
||||
tableHeaderCellRef?.current?.focus();
|
||||
}}
|
||||
isEpic={isEpic}
|
||||
/>
|
||||
</th>
|
||||
</WithDisplayPropertiesHOC>
|
||||
|
||||
@@ -21,6 +21,7 @@ interface Props {
|
||||
isEstimateEnabled: boolean;
|
||||
spreadsheetColumnsList: (keyof IIssueDisplayProperties)[];
|
||||
selectionHelpers: TSelectionHelper;
|
||||
isEpic?: boolean;
|
||||
}
|
||||
|
||||
export const SpreadsheetHeader = observer((props: Props) => {
|
||||
@@ -32,6 +33,7 @@ export const SpreadsheetHeader = observer((props: Props) => {
|
||||
isEstimateEnabled,
|
||||
spreadsheetColumnsList,
|
||||
selectionHelpers,
|
||||
isEpic = false,
|
||||
} = props;
|
||||
// router
|
||||
const { projectId } = useParams();
|
||||
@@ -62,7 +64,7 @@ export const SpreadsheetHeader = observer((props: Props) => {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<span className="flex h-full w-full flex-grow items-center py-2.5">Issues</span>
|
||||
<span className="flex h-full w-full flex-grow items-center py-2.5">{`${isEpic ? "Epics" : "Issues"}`}</span>
|
||||
</Row>
|
||||
</th>
|
||||
|
||||
@@ -74,6 +76,7 @@ export const SpreadsheetHeader = observer((props: Props) => {
|
||||
displayFilters={displayFilters}
|
||||
handleDisplayFilterUpdate={handleDisplayFilterUpdate}
|
||||
isEstimateEnabled={isEstimateEnabled}
|
||||
isEpic={isEpic}
|
||||
/>
|
||||
))}
|
||||
</tr>
|
||||
|
||||
@@ -112,6 +112,7 @@ export const SpreadsheetTable = observer((props: Props) => {
|
||||
isEstimateEnabled={isEstimateEnabled}
|
||||
spreadsheetColumnsList={spreadsheetColumnsList}
|
||||
selectionHelpers={selectionHelpers}
|
||||
isEpic={isEpic}
|
||||
/>
|
||||
<tbody>
|
||||
{issueIds.map((id) => (
|
||||
|
||||
@@ -117,6 +117,7 @@ export const SpreadsheetView: React.FC<Props> = observer((props) => {
|
||||
layout={EIssueLayoutTypes.SPREADSHEET}
|
||||
QuickAddButton={SpreadsheetAddIssueButton}
|
||||
quickAddCallback={quickAddCallback}
|
||||
isEpic={isEpic}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -68,6 +68,7 @@ type TGetGroupByColumns = {
|
||||
groupBy: GroupByColumnTypes | null;
|
||||
includeNone: boolean;
|
||||
isWorkspaceLevel: boolean;
|
||||
isEpic?: boolean;
|
||||
};
|
||||
|
||||
// NOTE: Type of groupBy is different compared to what's being passed from the components.
|
||||
@@ -77,13 +78,14 @@ export const getGroupByColumns = ({
|
||||
groupBy,
|
||||
includeNone,
|
||||
isWorkspaceLevel,
|
||||
isEpic = false,
|
||||
}: TGetGroupByColumns): IGroupByColumn[] | undefined => {
|
||||
// If no groupBy is specified and includeNone is true, return "All Issues" group
|
||||
if (!groupBy && includeNone) {
|
||||
return [
|
||||
{
|
||||
id: "All Issues",
|
||||
name: "All Issues",
|
||||
name: isEpic ? "All Epics" : "All Issues",
|
||||
payload: {},
|
||||
icon: undefined,
|
||||
},
|
||||
|
||||
@@ -23,10 +23,11 @@ import { getTabIndex } from "@/helpers/tab-indices.helper";
|
||||
import { useInstance, useWorkspace } from "@/hooks/store";
|
||||
import useKeypress from "@/hooks/use-keypress";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
// plane web services
|
||||
import { WorkspaceService } from "@/plane-web/services";
|
||||
// services
|
||||
import { AIService } from "@/services/ai.service";
|
||||
import { FileService } from "@/services/file.service";
|
||||
import { ProjectService } from "@/services/project";
|
||||
|
||||
type TIssueDescriptionEditorProps = {
|
||||
control: Control<TIssue>;
|
||||
@@ -48,9 +49,9 @@ type TIssueDescriptionEditorProps = {
|
||||
};
|
||||
|
||||
// services
|
||||
const workspaceService = new WorkspaceService();
|
||||
const aiService = new AIService();
|
||||
const fileService = new FileService();
|
||||
const projectService = new ProjectService();
|
||||
|
||||
export const IssueDescriptionEditor: React.FC<TIssueDescriptionEditorProps> = observer((props) => {
|
||||
const {
|
||||
@@ -191,11 +192,10 @@ export const IssueDescriptionEditor: React.FC<TIssueDescriptionEditorProps> = ob
|
||||
tabIndex={getIndex("description_html")}
|
||||
placeholder={getDescriptionPlaceholder}
|
||||
searchMentionCallback={async (payload) =>
|
||||
await projectService.searchEntity(
|
||||
workspaceSlug?.toString() ?? "",
|
||||
projectId?.toString() ?? "",
|
||||
payload
|
||||
)
|
||||
await workspaceService.searchEntity(workspaceSlug?.toString() ?? "", {
|
||||
...payload,
|
||||
project_id: projectId?.toString() ?? "",
|
||||
})
|
||||
}
|
||||
containerClassName="pt-3 min-h-[120px]"
|
||||
uploadFile={async (file) => {
|
||||
|
||||
@@ -69,7 +69,7 @@ export const ParentIssuesListModal: React.FC<Props> = ({
|
||||
projectService
|
||||
.projectIssuesSearch(workspaceSlug as string, projectId as string, {
|
||||
search: debouncedSearchTerm,
|
||||
parent: true,
|
||||
parent: searchEpic ? undefined : true,
|
||||
issue_id: issueId,
|
||||
workspace_search: false,
|
||||
epic: searchEpic ? true : undefined,
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { ChevronRight, X, Pencil, Trash, Link as LinkIcon, Loader } from "lucide-react";
|
||||
import { EIssueServiceType } from "@plane/constants";
|
||||
import { TIssue, TIssueServiceType } from "@plane/types";
|
||||
// ui
|
||||
import { ControlLink, CustomMenu, Tooltip } from "@plane/ui";
|
||||
@@ -50,14 +51,14 @@ export const IssueListItem: React.FC<ISubIssues> = observer((props) => {
|
||||
disabled,
|
||||
handleIssueCrudState,
|
||||
subIssueOperations,
|
||||
issueServiceType = EIssueServiceType.ISSUES,
|
||||
} = props;
|
||||
|
||||
const {
|
||||
issue: { getIssueById },
|
||||
subIssues: { subIssueHelpersByIssueId, setSubIssueHelpers },
|
||||
toggleCreateIssueModal,
|
||||
toggleDeleteIssueModal,
|
||||
} = useIssueDetail();
|
||||
} = useIssueDetail(issueServiceType);
|
||||
const { toggleCreateIssueModal, toggleDeleteIssueModal } = useIssueDetail(issueServiceType);
|
||||
const project = useProject();
|
||||
const { getProjectStates } = useProjectState();
|
||||
const { handleRedirection } = useIssuePeekOverviewRedirection();
|
||||
@@ -164,6 +165,7 @@ export const IssueListItem: React.FC<ISubIssues> = observer((props) => {
|
||||
issueId={issueId}
|
||||
disabled={disabled}
|
||||
subIssueOperations={subIssueOperations}
|
||||
issueServiceType={issueServiceType}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -209,7 +211,7 @@ export const IssueListItem: React.FC<ISubIssues> = observer((props) => {
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<X className="h-3.5 w-3.5" strokeWidth={2} />
|
||||
<span>Remove parent issue</span>
|
||||
<span>{`Remove ${issueServiceType === EIssueServiceType.ISSUES ? "parent" : ""} issue`}</span>
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
|
||||
@@ -60,6 +60,7 @@ export const IssueList: FC<IIssueList> = observer((props) => {
|
||||
disabled={disabled}
|
||||
handleIssueCrudState={handleIssueCrudState}
|
||||
subIssueOperations={subIssueOperations}
|
||||
issueServiceType={issueServiceType}
|
||||
/>
|
||||
</Fragment>
|
||||
))}
|
||||
|
||||
@@ -17,11 +17,11 @@ export interface IIssueProperty {
|
||||
}
|
||||
|
||||
export const IssueProperty: React.FC<IIssueProperty> = (props) => {
|
||||
const { workspaceSlug, parentIssueId, issueId, disabled, subIssueOperations } = props;
|
||||
const { workspaceSlug, parentIssueId, issueId, disabled, subIssueOperations, issueServiceType } = props;
|
||||
// hooks
|
||||
const {
|
||||
issue: { getIssueById },
|
||||
} = useIssueDetail();
|
||||
} = useIssueDetail(issueServiceType);
|
||||
|
||||
const issue = getIssueById(issueId);
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { ExternalLink, LinkIcon, Pencil, Trash2 } from "lucide-react";
|
||||
import { Copy, LinkIcon, Pencil, Trash2 } from "lucide-react";
|
||||
// plane types
|
||||
import { ILinkDetails } from "@plane/types";
|
||||
// plane ui
|
||||
@@ -45,12 +45,9 @@ export const ModulesLinksListItem: React.FC<Props> = observer((props) => {
|
||||
<LinkIcon className="h-3 w-3 flex-shrink-0" />
|
||||
</span>
|
||||
<Tooltip tooltipContent={link.title && link.title !== "" ? link.title : link.url} isMobile={isMobile}>
|
||||
<span
|
||||
className="cursor-pointer truncate text-xs"
|
||||
onClick={() => copyToClipboard(link.title && link.title !== "" ? link.title : link.url)}
|
||||
>
|
||||
<a href={link.url} target="_blank" rel="noopener noreferrer" className="cursor-pointer truncate text-xs">
|
||||
{link.title && link.title !== "" ? link.title : link.url}
|
||||
</span>
|
||||
</a>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
@@ -68,14 +65,12 @@ export const ModulesLinksListItem: React.FC<Props> = observer((props) => {
|
||||
<Pencil className="size-3 stroke-[1.5] text-custom-text-200" />
|
||||
</button>
|
||||
)}
|
||||
<a
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="grid place-items-center p-1 hover:bg-custom-background-80"
|
||||
<span
|
||||
onClick={() => copyToClipboard(link.title && link.title !== "" ? link.title : link.url)}
|
||||
className="grid place-items-center p-1 hover:bg-custom-background-80 cursor-pointer"
|
||||
>
|
||||
<ExternalLink className="size-3 stroke-[1.5] text-custom-text-200" />
|
||||
</a>
|
||||
<Copy className="h-3.5 w-3.5 stroke-[1.5]" />
|
||||
</span>
|
||||
{isEditingAllowed && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -9,10 +9,10 @@ import { getFileURL } from "@/helpers/file.helper";
|
||||
// hooks
|
||||
import { useMember } from "@/hooks/store";
|
||||
// store
|
||||
import { IPage } from "@/store/pages/page";
|
||||
import { TPageInstance } from "@/store/pages/base-page";
|
||||
|
||||
type Props = {
|
||||
page: IPage;
|
||||
page: TPageInstance;
|
||||
};
|
||||
|
||||
export const PageEditInformationPopover: React.FC<Props> = observer((props) => {
|
||||
|
||||
@@ -9,10 +9,10 @@ import { DeletePageModal } from "@/components/pages";
|
||||
// helpers
|
||||
import { copyUrlToClipboard } from "@/helpers/string.helper";
|
||||
// store
|
||||
import { IPage } from "@/store/pages/page";
|
||||
import { TPageInstance } from "@/store/pages/base-page";
|
||||
|
||||
type Props = {
|
||||
page: IPage;
|
||||
page: TPageInstance;
|
||||
pageLink: string;
|
||||
parentRef: React.RefObject<HTMLElement>;
|
||||
};
|
||||
@@ -60,7 +60,7 @@ export const PageQuickActions: React.FC<Props> = observer((props) => {
|
||||
title: "Success!",
|
||||
message: `The page has been marked ${changedPageType} and moved to the ${changedPageType} section.`,
|
||||
});
|
||||
} catch (err) {
|
||||
} catch {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
@@ -104,7 +104,7 @@ export const PageQuickActions: React.FC<Props> = observer((props) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<DeletePageModal isOpen={deletePageModal} onClose={() => setDeletePageModal(false)} pageId={page.id ?? ""} />
|
||||
<DeletePageModal isOpen={deletePageModal} onClose={() => setDeletePageModal(false)} page={page} />
|
||||
<ContextMenu parentRef={parentRef} items={MENU_ITEMS} />
|
||||
<CustomMenu placement="bottom-end" ellipsis closeOnSelect>
|
||||
{MENU_ITEMS.map((item) => {
|
||||
|
||||
@@ -1,83 +1,94 @@
|
||||
import { Dispatch, SetStateAction, useCallback, useMemo } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// document-editor
|
||||
import {
|
||||
CollaborativeDocumentEditorWithRef,
|
||||
EditorRefApi,
|
||||
TAIMenuProps,
|
||||
TDisplayConfig,
|
||||
TFileHandler,
|
||||
TRealtimeConfig,
|
||||
TServerHandler,
|
||||
} from "@plane/editor";
|
||||
// types
|
||||
import { EFileAssetType } from "@plane/types/src/enums";
|
||||
// components
|
||||
// plane types
|
||||
import { TSearchEntityRequestPayload, TSearchResponse, TWebhookConnectionQueryParams } from "@plane/types";
|
||||
// plane ui
|
||||
import { Row } from "@plane/ui";
|
||||
// components
|
||||
import { EditorMentionsRoot } from "@/components/editor";
|
||||
import { PageContentBrowser, PageContentLoader, PageEditorTitle } from "@/components/pages";
|
||||
// helpers
|
||||
import { cn, LIVE_BASE_PATH, LIVE_BASE_URL } from "@/helpers/common.helper";
|
||||
import { getEditorFileHandlers } from "@/helpers/editor.helper";
|
||||
import { generateRandomColor } from "@/helpers/string.helper";
|
||||
// hooks
|
||||
import { useUser, useWorkspace } from "@/hooks/store";
|
||||
import { useUser } from "@/hooks/store";
|
||||
import { useEditorMention } from "@/hooks/use-editor-mention";
|
||||
import { usePageFilters } from "@/hooks/use-page-filters";
|
||||
// plane web components
|
||||
import { EditorAIMenu } from "@/plane-web/components/pages";
|
||||
// plane web hooks
|
||||
import { useEditorFlagging } from "@/plane-web/hooks/use-editor-flagging";
|
||||
import { useFileSize } from "@/plane-web/hooks/use-file-size";
|
||||
import { useIssueEmbed } from "@/plane-web/hooks/use-issue-embed";
|
||||
// services
|
||||
import { FileService } from "@/services/file.service";
|
||||
import { ProjectService } from "@/services/project";
|
||||
// store
|
||||
import { IPage } from "@/store/pages/page";
|
||||
// services init
|
||||
const fileService = new FileService();
|
||||
const projectService = new ProjectService();
|
||||
import { TPageInstance } from "@/store/pages/base-page";
|
||||
|
||||
export type TEditorBodyConfig = {
|
||||
fileHandler: TFileHandler;
|
||||
webhookConnectionParams: TWebhookConnectionQueryParams;
|
||||
};
|
||||
|
||||
export type TEditorBodyHandlers = {
|
||||
fetchEntity: (payload: TSearchEntityRequestPayload) => Promise<TSearchResponse>;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
config: TEditorBodyConfig;
|
||||
editorRef: React.RefObject<EditorRefApi>;
|
||||
editorReady: boolean;
|
||||
handleConnectionStatus: Dispatch<SetStateAction<boolean>>;
|
||||
handleEditorReady: Dispatch<SetStateAction<boolean>>;
|
||||
page: IPage;
|
||||
handlers: TEditorBodyHandlers;
|
||||
page: TPageInstance;
|
||||
sidePeekVisible: boolean;
|
||||
workspaceSlug: string;
|
||||
};
|
||||
|
||||
export const PageEditorBody: React.FC<Props> = observer((props) => {
|
||||
const { editorRef, handleConnectionStatus, handleEditorReady, page, sidePeekVisible } = props;
|
||||
// router
|
||||
const { workspaceSlug, projectId } = useParams();
|
||||
const {
|
||||
config,
|
||||
editorRef,
|
||||
handleConnectionStatus,
|
||||
handleEditorReady,
|
||||
handlers,
|
||||
page,
|
||||
sidePeekVisible,
|
||||
workspaceSlug,
|
||||
} = props;
|
||||
// store hooks
|
||||
const { data: currentUser } = useUser();
|
||||
const { getWorkspaceBySlug } = useWorkspace();
|
||||
// derived values
|
||||
const workspaceId = workspaceSlug ? (getWorkspaceBySlug(workspaceSlug.toString())?.id ?? "") : "";
|
||||
const pageId = page?.id;
|
||||
const pageTitle = page?.name ?? "";
|
||||
const { isContentEditable, updateTitle } = page;
|
||||
const { id: pageId, name: pageTitle, isContentEditable, updateTitle } = page;
|
||||
// issue-embed
|
||||
const { issueEmbedProps } = useIssueEmbed({
|
||||
fetchEmbedSuggestions: handlers.fetchEntity,
|
||||
workspaceSlug,
|
||||
});
|
||||
// use editor mention
|
||||
const { fetchMentions } = useEditorMention({
|
||||
searchEntity: async (payload) =>
|
||||
await projectService.searchEntity(workspaceSlug?.toString() ?? "", projectId?.toString() ?? "", payload),
|
||||
searchEntity: handlers.fetchEntity,
|
||||
});
|
||||
// editor flaggings
|
||||
const { documentEditor: disabledExtensions } = useEditorFlagging(workspaceSlug?.toString());
|
||||
const { documentEditor: disabledExtensions } = useEditorFlagging(workspaceSlug);
|
||||
// page filters
|
||||
const { fontSize, fontStyle, isFullWidth } = usePageFilters();
|
||||
// issue-embed
|
||||
const { issueEmbedProps } = useIssueEmbed(workspaceSlug?.toString() ?? "", projectId?.toString() ?? "");
|
||||
// file size
|
||||
const { maxFileSize } = useFileSize();
|
||||
|
||||
const displayConfig: TDisplayConfig = {
|
||||
fontSize,
|
||||
fontStyle,
|
||||
};
|
||||
// derived values
|
||||
const displayConfig: TDisplayConfig = useMemo(
|
||||
() => ({
|
||||
fontSize,
|
||||
fontStyle,
|
||||
}),
|
||||
[fontSize, fontStyle]
|
||||
);
|
||||
|
||||
const getAIMenu = useCallback(
|
||||
({ isOpen, onClose }: TAIMenuProps) => (
|
||||
@@ -85,11 +96,10 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
|
||||
editorRef={editorRef}
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
projectId={projectId?.toString() ?? ""}
|
||||
workspaceSlug={workspaceSlug?.toString() ?? ""}
|
||||
/>
|
||||
),
|
||||
[editorRef, projectId, workspaceSlug]
|
||||
[editorRef, workspaceSlug]
|
||||
);
|
||||
|
||||
const handleServerConnect = useCallback(() => {
|
||||
@@ -119,17 +129,13 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
|
||||
// Construct realtime config
|
||||
return {
|
||||
url: WS_LIVE_URL.toString(),
|
||||
queryParams: {
|
||||
workspaceSlug: workspaceSlug?.toString(),
|
||||
projectId: projectId?.toString(),
|
||||
documentType: "project_page",
|
||||
},
|
||||
queryParams: config.webhookConnectionParams,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error creating realtime config", error);
|
||||
return undefined;
|
||||
}
|
||||
}, [projectId, workspaceSlug]);
|
||||
}, [config.webhookConnectionParams]);
|
||||
|
||||
const userConfig = useMemo(
|
||||
() => ({
|
||||
@@ -169,24 +175,7 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
|
||||
<CollaborativeDocumentEditorWithRef
|
||||
editable={isContentEditable}
|
||||
id={pageId}
|
||||
fileHandler={getEditorFileHandlers({
|
||||
maxFileSize,
|
||||
projectId: projectId?.toString() ?? "",
|
||||
uploadFile: async (file) => {
|
||||
const { asset_id } = await fileService.uploadProjectAsset(
|
||||
workspaceSlug?.toString() ?? "",
|
||||
projectId?.toString() ?? "",
|
||||
{
|
||||
entity_identifier: pageId,
|
||||
entity_type: EFileAssetType.PAGE_DESCRIPTION,
|
||||
},
|
||||
file
|
||||
);
|
||||
return asset_id;
|
||||
},
|
||||
workspaceId,
|
||||
workspaceSlug: workspaceSlug?.toString() ?? "",
|
||||
})}
|
||||
fileHandler={config.fileHandler}
|
||||
handleEditorReady={handleEditorReady}
|
||||
ref={editorRef}
|
||||
containerClassName="h-full p-0 pb-64"
|
||||
|
||||
@@ -13,12 +13,12 @@ import { renderFormattedDate } from "@/helpers/date-time.helper";
|
||||
// hooks
|
||||
import useOnlineStatus from "@/hooks/use-online-status";
|
||||
// store
|
||||
import { IPage } from "@/store/pages/page";
|
||||
import { TPageInstance } from "@/store/pages/base-page";
|
||||
|
||||
type Props = {
|
||||
editorRef: React.RefObject<EditorRefApi>;
|
||||
handleDuplicatePage: () => void;
|
||||
page: IPage;
|
||||
page: TPageInstance;
|
||||
};
|
||||
|
||||
export const PageExtraOptions: React.FC<Props> = observer((props) => {
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { EditorReadOnlyRefApi, EditorRefApi, IMarking } from "@plane/editor";
|
||||
import { EditorRefApi } from "@plane/editor";
|
||||
// components
|
||||
import { Header, EHeaderVariant } from "@plane/ui";
|
||||
import { PageExtraOptions, PageSummaryPopover, PageToolbar } from "@/components/pages";
|
||||
// hooks
|
||||
import { usePageFilters } from "@/hooks/use-page-filters";
|
||||
// store
|
||||
import { IPage } from "@/store/pages/page";
|
||||
import { TPageInstance } from "@/store/pages/base-page";
|
||||
|
||||
type Props = {
|
||||
editorReady: boolean;
|
||||
editorRef: React.RefObject<EditorRefApi>;
|
||||
handleDuplicatePage: () => void;
|
||||
page: IPage;
|
||||
page: TPageInstance;
|
||||
setSidePeekVisible: (sidePeekState: boolean) => void;
|
||||
sidePeekVisible: boolean;
|
||||
};
|
||||
|
||||
@@ -28,12 +28,12 @@ import { usePageFilters } from "@/hooks/use-page-filters";
|
||||
import { useParseEditorContent } from "@/hooks/use-parse-editor-content";
|
||||
import { useQueryParams } from "@/hooks/use-query-params";
|
||||
// store
|
||||
import { IPage } from "@/store/pages/page";
|
||||
import { TPageInstance } from "@/store/pages/base-page";
|
||||
|
||||
type Props = {
|
||||
editorRef: EditorRefApi | null;
|
||||
handleDuplicatePage: () => void;
|
||||
page: IPage;
|
||||
page: TPageInstance;
|
||||
};
|
||||
|
||||
export const PageOptionsDropdown: React.FC<Props> = observer((props) => {
|
||||
|
||||
@@ -8,13 +8,13 @@ import { cn } from "@/helpers/common.helper";
|
||||
// hooks
|
||||
import { usePageFilters } from "@/hooks/use-page-filters";
|
||||
// store
|
||||
import { IPage } from "@/store/pages/page";
|
||||
import { TPageInstance } from "@/store/pages/base-page";
|
||||
|
||||
type Props = {
|
||||
editorReady: boolean;
|
||||
editorRef: React.RefObject<EditorRefApi>;
|
||||
handleDuplicatePage: () => void;
|
||||
page: IPage;
|
||||
page: TPageInstance;
|
||||
setSidePeekVisible: (sidePeekState: boolean) => void;
|
||||
sidePeekVisible: boolean;
|
||||
};
|
||||
|
||||
@@ -4,31 +4,45 @@ import { useSearchParams } from "next/navigation";
|
||||
// editor
|
||||
import { EditorRefApi } from "@plane/editor";
|
||||
// types
|
||||
import { TPage } from "@plane/types";
|
||||
import { TDocumentPayload, TPage, TPageVersion } from "@plane/types";
|
||||
// ui
|
||||
import { setToast, TOAST_TYPE } from "@plane/ui";
|
||||
// components
|
||||
import { PageEditorHeaderRoot, PageEditorBody, PageVersionsOverlay, PagesVersionEditor } from "@/components/pages";
|
||||
import {
|
||||
PageEditorHeaderRoot,
|
||||
PageEditorBody,
|
||||
PageVersionsOverlay,
|
||||
PagesVersionEditor,
|
||||
TEditorBodyHandlers,
|
||||
TEditorBodyConfig,
|
||||
} from "@/components/pages";
|
||||
// hooks
|
||||
import { useProjectPages } from "@/hooks/store";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
import { usePageFallback } from "@/hooks/use-page-fallback";
|
||||
import { useQueryParams } from "@/hooks/use-query-params";
|
||||
// services
|
||||
import { ProjectPageService, ProjectPageVersionService } from "@/services/page";
|
||||
const projectPageService = new ProjectPageService();
|
||||
const projectPageVersionService = new ProjectPageVersionService();
|
||||
// store
|
||||
import { IPage } from "@/store/pages/page";
|
||||
import { TPageInstance } from "@/store/pages/base-page";
|
||||
|
||||
export type TPageRootHandlers = {
|
||||
create: (payload: Partial<TPage>) => Promise<Partial<TPage> | undefined>;
|
||||
fetchAllVersions: (pageId: string) => Promise<TPageVersion[] | undefined>;
|
||||
fetchDescriptionBinary: () => Promise<any>;
|
||||
fetchVersionDetails: (pageId: string, versionId: string) => Promise<TPageVersion | undefined>;
|
||||
getRedirectionLink: (pageId: string) => string;
|
||||
updateDescription: (document: TDocumentPayload) => Promise<void>;
|
||||
} & TEditorBodyHandlers;
|
||||
|
||||
export type TPageRootConfig = TEditorBodyConfig;
|
||||
|
||||
type TPageRootProps = {
|
||||
page: IPage;
|
||||
projectId: string;
|
||||
config: TPageRootConfig;
|
||||
handlers: TPageRootHandlers;
|
||||
page: TPageInstance;
|
||||
workspaceSlug: string;
|
||||
};
|
||||
|
||||
export const PageRoot = observer((props: TPageRootProps) => {
|
||||
const { projectId, workspaceSlug, page } = props;
|
||||
const { config, handlers, page, workspaceSlug } = props;
|
||||
// states
|
||||
const [editorReady, setEditorReady] = useState(false);
|
||||
const [hasConnectionFailed, setHasConnectionFailed] = useState(false);
|
||||
@@ -40,25 +54,18 @@ export const PageRoot = observer((props: TPageRootProps) => {
|
||||
const router = useAppRouter();
|
||||
// search params
|
||||
const searchParams = useSearchParams();
|
||||
// store hooks
|
||||
const { createPage } = useProjectPages();
|
||||
// derived values
|
||||
const { access, description_html, name, isContentEditable, updateDescription } = page;
|
||||
const { access, description_html, name, isContentEditable } = page;
|
||||
// page fallback
|
||||
usePageFallback({
|
||||
editorRef,
|
||||
fetchPageDescription: async () => {
|
||||
if (!page.id) return;
|
||||
return await projectPageService.fetchDescriptionBinary(workspaceSlug, projectId, page.id);
|
||||
},
|
||||
fetchPageDescription: handlers.fetchDescriptionBinary,
|
||||
hasConnectionFailed,
|
||||
updatePageDescription: async (data) => await updateDescription(data),
|
||||
updatePageDescription: handlers.updateDescription,
|
||||
});
|
||||
// update query params
|
||||
const { updateQueryParams } = useQueryParams();
|
||||
|
||||
const handleCreatePage = async (payload: Partial<TPage>) => await createPage(payload);
|
||||
|
||||
const handleDuplicatePage = async () => {
|
||||
const formData: Partial<TPage> = {
|
||||
name: "Copy of " + name,
|
||||
@@ -66,8 +73,9 @@ export const PageRoot = observer((props: TPageRootProps) => {
|
||||
access,
|
||||
};
|
||||
|
||||
await handleCreatePage(formData)
|
||||
.then((res) => router.push(`/${workspaceSlug}/projects/${projectId}/pages/${res?.id}`))
|
||||
await handlers
|
||||
.create(formData)
|
||||
.then((res) => router.push(handlers.getRedirectionLink(res?.id ?? "")))
|
||||
.catch(() =>
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
@@ -105,23 +113,8 @@ export const PageRoot = observer((props: TPageRootProps) => {
|
||||
activeVersion={version}
|
||||
currentVersionDescription={currentVersionDescription ?? null}
|
||||
editorComponent={PagesVersionEditor}
|
||||
fetchAllVersions={async (pageId) => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
return await projectPageVersionService.fetchAllVersions(
|
||||
workspaceSlug.toString(),
|
||||
projectId.toString(),
|
||||
pageId
|
||||
);
|
||||
}}
|
||||
fetchVersionDetails={async (pageId, versionId) => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
return await projectPageVersionService.fetchVersionById(
|
||||
workspaceSlug.toString(),
|
||||
projectId.toString(),
|
||||
pageId,
|
||||
versionId
|
||||
);
|
||||
}}
|
||||
fetchAllVersions={handlers.fetchAllVersions}
|
||||
fetchVersionDetails={handlers.fetchVersionDetails}
|
||||
handleRestore={handleRestoreVersion}
|
||||
isOpen={isVersionsOverlayOpen}
|
||||
onClose={handleCloseVersionsOverlay}
|
||||
@@ -137,12 +130,15 @@ export const PageRoot = observer((props: TPageRootProps) => {
|
||||
sidePeekVisible={sidePeekVisible}
|
||||
/>
|
||||
<PageEditorBody
|
||||
config={config}
|
||||
editorReady={editorReady}
|
||||
editorRef={editorRef}
|
||||
handleConnectionStatus={setHasConnectionFailed}
|
||||
handleEditorReady={setEditorReady}
|
||||
handlers={handlers}
|
||||
page={page}
|
||||
sidePeekVisible={sidePeekVisible}
|
||||
workspaceSlug={workspaceSlug}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -8,13 +8,14 @@ import { EditorRefApi } from "@plane/editor";
|
||||
import { TextArea } from "@plane/ui";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
import { getPageName } from "@/helpers/page.helper";
|
||||
// hooks
|
||||
import { usePageFilters } from "@/hooks/use-page-filters";
|
||||
|
||||
type Props = {
|
||||
editorRef: React.RefObject<EditorRefApi>;
|
||||
readOnly: boolean;
|
||||
title: string;
|
||||
title: string | undefined;
|
||||
updateTitle: (title: string) => void;
|
||||
};
|
||||
|
||||
@@ -33,7 +34,17 @@ export const PageEditorTitle: React.FC<Props> = observer((props) => {
|
||||
return (
|
||||
<div className="relative w-full flex-shrink-0 md:pl-5 px-4">
|
||||
{readOnly ? (
|
||||
<h6 className={cn(titleClassName, "break-words pb-1.5")}>{title}</h6>
|
||||
<h6
|
||||
className={cn(
|
||||
titleClassName,
|
||||
{
|
||||
"text-custom-text-400": !title,
|
||||
},
|
||||
"break-words pb-1.5"
|
||||
)}
|
||||
>
|
||||
{getPageName(title)}
|
||||
</h6>
|
||||
) : (
|
||||
<>
|
||||
<TextArea
|
||||
@@ -62,10 +73,10 @@ export const PageEditorTitle: React.FC<Props> = observer((props) => {
|
||||
>
|
||||
<span
|
||||
className={cn({
|
||||
"text-red-500": title.length > 255,
|
||||
"text-red-500": title && title.length > 255,
|
||||
})}
|
||||
>
|
||||
{title.length}
|
||||
{title?.length}
|
||||
</span>
|
||||
/255
|
||||
</div>
|
||||
|
||||
@@ -11,19 +11,17 @@ import { PageQuickActions } from "@/components/pages/dropdowns";
|
||||
import { renderFormattedDate } from "@/helpers/date-time.helper";
|
||||
import { getFileURL } from "@/helpers/file.helper";
|
||||
// hooks
|
||||
import { useMember, usePage } from "@/hooks/store";
|
||||
import { useMember } from "@/hooks/store";
|
||||
import { TPageInstance } from "@/store/pages/base-page";
|
||||
|
||||
type Props = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
pageId: string;
|
||||
page: TPageInstance;
|
||||
parentRef: React.RefObject<HTMLElement>;
|
||||
};
|
||||
|
||||
export const BlockItemAction: FC<Props> = observer((props) => {
|
||||
const { workspaceSlug, projectId, pageId, parentRef } = props;
|
||||
const { page, parentRef } = props;
|
||||
// store hooks
|
||||
const page = usePage(pageId);
|
||||
const { getUserDetails } = useMember();
|
||||
// derived values
|
||||
const {
|
||||
@@ -34,6 +32,7 @@ export const BlockItemAction: FC<Props> = observer((props) => {
|
||||
canCurrentUserFavoritePage,
|
||||
addToFavorites,
|
||||
removePageFromFavorites,
|
||||
getRedirectionLink,
|
||||
} = page;
|
||||
const ownerDetails = owned_by ? getUserDetails(owned_by) : undefined;
|
||||
|
||||
@@ -94,11 +93,7 @@ export const BlockItemAction: FC<Props> = observer((props) => {
|
||||
)}
|
||||
|
||||
{/* quick actions dropdown */}
|
||||
<PageQuickActions
|
||||
parentRef={parentRef}
|
||||
page={page}
|
||||
pageLink={`${workspaceSlug}/projects/${projectId}/pages/${pageId}`}
|
||||
/>
|
||||
<PageQuickActions parentRef={parentRef} page={page} pageLink={getRedirectionLink()} />
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -10,22 +10,23 @@ import { BlockItemAction } from "@/components/pages/list";
|
||||
// helpers
|
||||
import { getPageName } from "@/helpers/page.helper";
|
||||
// hooks
|
||||
import { usePage } from "@/hooks/store";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
import { TUsePage } from "@/store/pages/base-page";
|
||||
|
||||
type TPageListBlock = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
pageId: string;
|
||||
usePage: TUsePage;
|
||||
};
|
||||
|
||||
export const PageListBlock: FC<TPageListBlock> = observer((props) => {
|
||||
const { workspaceSlug, projectId, pageId } = props;
|
||||
const { pageId, usePage } = props;
|
||||
// refs
|
||||
const parentRef = useRef(null);
|
||||
// hooks
|
||||
const { name, logo_props } = usePage(pageId);
|
||||
const page = usePage(pageId);
|
||||
const { isMobile } = usePlatformOS();
|
||||
// derived values
|
||||
const { name, logo_props, getRedirectionLink } = page;
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
@@ -39,10 +40,8 @@ export const PageListBlock: FC<TPageListBlock> = observer((props) => {
|
||||
</>
|
||||
}
|
||||
title={getPageName(name)}
|
||||
itemLink={`/${workspaceSlug}/projects/${projectId}/pages/${pageId}`}
|
||||
actionableItems={
|
||||
<BlockItemAction workspaceSlug={workspaceSlug} projectId={projectId} pageId={pageId} parentRef={parentRef} />
|
||||
}
|
||||
itemLink={getRedirectionLink()}
|
||||
actionableItems={<BlockItemAction page={page} parentRef={parentRef} />}
|
||||
isMobile={isMobile}
|
||||
parentRef={parentRef}
|
||||
/>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user