Compare commits

...

17 Commits

Author SHA1 Message Date
Palanikannan M 6bdbf4e536 fix: speed variability adjusted 2024-12-30 16:52:30 +05:30
Aaryan Khandelwal 94f421f27d chore: add live server prettier config (#6287) 2024-12-27 21:03:20 +05:30
Aaryan Khandelwal 8d7425a3b7 [PE-182] refactor: pages' components and store for scalability (#6283)
* refactor: created a generic base page instance

* refactor: project store hooks

* chore: add missing prop declaration

* refactor: editor page root and body

* refactor: issue embed hook

* chore: update search entity types

* fix: version editor component

* fix: add page to favorites action

---------

Co-authored-by: Prateek Shourya <prateekshourya29@gmail.com>
2024-12-27 20:41:38 +05:30
Anmol Singh Bhatia 211d5e1cd0 chore: code refactor and build fix (#6285)
* chore: code refactor and build fix

* chore: code refactor

* chore: code refactor
2024-12-27 18:18:45 +05:30
Vamsi Krishna 3c6bbaef3c fix: modified link behaviour to improve accessibility (#6284) 2024-12-27 17:46:40 +05:30
Prateek Shourya 4159d12959 [WEB-2889] fix: global views sorting when hyper model is enabled. (#6280) 2024-12-27 13:03:26 +05:30
Anmol Singh Bhatia 2f2f8dc5f4 [WEB-2880] chore: project detail response updated (#6281)
* chore: project detail response updated

* chore: code refactor
2024-12-27 09:17:35 +05:30
Anmol Singh Bhatia 756a71ca78 [WEB-2907] chore: issue store updated and code refactor (#6279)
* chore: issue and epic store updated and code refactor

* chore: layout ux copy updated
2024-12-26 20:01:32 +05:30
Vamsi Krishna 36b3328c5e fix: user role not updating in user profile (#6278) 2024-12-26 17:19:43 +05:30
Prateek Shourya a5c1282e52 [WEB-2896] fix: mutation problem with issue properties while accepting an intake issue. (#6277) 2024-12-26 16:46:52 +05:30
devin-ai-integration[bot] ed64168ca7 chore(utils): copy helper functions from web/helpers (#6264)
* chore(utils): copy helper functions from web/helpers

Co-Authored-By: sriram@plane.so <sriram@plane.so>

* chore(utils): bump version to 0.24.2

Co-Authored-By: sriram@plane.so <sriram@plane.so>

* chore: bump root package version to 0.24.2

Co-Authored-By: sriram@plane.so <sriram@plane.so>

* fix: remove duplicate function and simplify auth utils

Co-Authored-By: sriram@plane.so <sriram@plane.so>

* fix: improve HTML entity escaping in sanitizeHTML

Co-Authored-By: sriram@plane.so <sriram@plane.so>

* fix: version changes

---------

Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: sriram@plane.so <sriram@plane.so>
2024-12-26 15:27:40 +05:30
Bavisetti Narayan f54f3a6091 chore: workspace entity search endpoint (#6272)
* chore: workspace entity search endpoint

* fix: editor entity search endpoint

* chore: restrict guest users

---------

Co-authored-by: Aaryan Khandelwal <aaryankhandu123@gmail.com>
2024-12-26 15:00:32 +05:30
Bavisetti Narayan 2d9464e841 chore: create unique constraints for webhook (#6257)
* chore: create unique constraints for webhook

* chore: updated the migration file
2024-12-24 21:00:50 +05:30
Vamsi Krishna 70f72a2b0f [WEB-2699]chore: added issue count for upcoming cycles (#6273)
* chore: added issue count for upcoming cycles

* chore: memoized show issue count
2024-12-24 20:53:06 +05:30
Vamsi Krishna c0b5e0e766 fix: label creation (#6271) 2024-12-24 20:52:31 +05:30
Anmol Singh Bhatia fedcdf0c84 [WEB-2879] chore sub issue analytics improvements (#6275)
* chore: epics type added to package

* chore: epic analytics helper function added

* chore: sub issue analytics mutation improvement
2024-12-24 20:52:03 +05:30
Bavisetti Narayan ff936887d2 chore: quick link migration (#6274)
* chore: added workspace link queryset

* chore: added workspace in sort order
2024-12-24 20:51:15 +05:30
126 changed files with 2601 additions and 1034 deletions
+1 -1
View File
@@ -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):
+1 -1
View File
@@ -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",
),
+427 -184
View File
@@ -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",
),
),
]
+1
View File
@@ -68,6 +68,7 @@ from .workspace import (
WorkspaceMemberInvite,
WorkspaceTheme,
WorkspaceUserProperties,
WorkspaceUserLink,
)
from .favorite import UserFavorite
+1 -1
View File
@@ -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"
+16
View File
@@ -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)
+8 -1
View File
@@ -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):
+20
View File
@@ -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}"
+6
View File
@@ -0,0 +1,6 @@
.next
.turbo
out/
dist/
build/
node_modules/
+5
View File
@@ -0,0 +1,5 @@
{
"printWidth": 120,
"tabWidth": 2,
"trailingComma": "es5"
}
+1 -1
View File
@@ -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",
+37 -10
View File
@@ -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");
+5 -4
View File
@@ -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;
};
+16
View File
@@ -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;
};
+1
View File
@@ -36,3 +36,4 @@ export * from "./workspace-draft-issues/base";
export * from "./command-palette";
export * from "./timezone";
export * from "./activity";
export * from "./epics";
+12 -8
View File
@@ -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
View File
@@ -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;
+8 -12
View File
@@ -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;
};
+1
View File
@@ -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"
+197
View File
@@ -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;
};
+69 -6
View File
@@ -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`,
+5 -1
View File
@@ -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;
+296 -7
View File
@@ -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;
};
-103
View File
@@ -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) ?? "";
}
},
};
};
+2 -1
View File
@@ -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
View File
@@ -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 &amp; &apos;world&apos;"
*/
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, "&apos;").replace(/"/g, "&quot;");
};
/**
@@ -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 };
@@ -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>
@@ -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>
);
@@ -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
View File
@@ -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!",
+3 -1
View File
@@ -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
+1 -4
View File
@@ -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
+8 -1
View File
@@ -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;
+15
View File
@@ -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 };
};
+9 -3
View File
@@ -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}
+9 -5
View File
@@ -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) => {
+2
View 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}
/>
</>
);
@@ -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>
)}
@@ -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 (
@@ -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;
};
+36 -40
View File
@@ -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}
/>
</>
);
+15 -4
View File
@@ -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()} />
</>
);
});
+8 -9
View File
@@ -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