Compare commits

..

10 Commits

Author SHA1 Message Date
gakshita 035d32bf94 fix 2025-01-23 17:38:41 +05:30
gakshita 348e8af8ab fix: added calculateI18nTimeAgoShort for calculateTimeAgoShort 2025-01-23 16:45:51 +05:30
gakshita f00d6e10ce feat: added calculateI18nTimeAgo for calculateTimeAgo translations 2025-01-23 15:17:58 +05:30
Vamsi Krishna 8f3a0be177 [WEB-3194]fix: stickies modal close while redirection (#6440)
* fix: stickies modal close while redirection

* chore: added deafult for optional prop
2025-01-22 14:22:24 +05:30
Prateek Shourya 0679e140a2 [WEB-3192] fix: issue relation redirection (#6441) 2025-01-22 14:01:21 +05:30
guru_sainath b611f5110f chore: issue and issue description version endpoints (#6434) 2025-01-21 20:34:43 +05:30
Akshita Goyal 0f7bc6979f fix: new sticky color + recent sticky api call + sticky max height (#6438) 2025-01-21 20:34:00 +05:30
Prateek Shourya 12501d0597 fix: add optional chaning check for actor details (#6437) 2025-01-21 20:33:38 +05:30
Aaryan Khandelwal 3a86fff7c1 [WEB-3045] fix: sticky placeholder, gray color value (#6436)
* fix: sticky placeholder

* chore: update gray color
2025-01-21 20:32:45 +05:30
Aaryan Khandelwal 58a4b45463 [WEB-3045] fix: stickies bugs (#6433)
* fix: stickies bugs

* fix: sticky height fixed

---------

Co-authored-by: gakshita <akshitagoyal1516@gmail.com>
2025-01-21 16:37:27 +05:30
43 changed files with 1322 additions and 294 deletions
@@ -72,6 +72,8 @@ from .issue import (
IssueReactionLiteSerializer,
IssueAttachmentLiteSerializer,
IssueLinkLiteSerializer,
IssueVersionDetailSerializer,
IssueDescriptionVersionDetailSerializer,
)
from .module import (
+63
View File
@@ -33,6 +33,8 @@ from plane.db.models import (
IssueVote,
IssueRelation,
State,
IssueVersion,
IssueDescriptionVersion,
)
@@ -667,3 +669,64 @@ class IssueSubscriberSerializer(BaseSerializer):
model = IssueSubscriber
fields = "__all__"
read_only_fields = ["workspace", "project", "issue"]
class IssueVersionDetailSerializer(BaseSerializer):
class Meta:
model = IssueVersion
fields = [
"id",
"workspace",
"project",
"issue",
"parent",
"state",
"estimate_point",
"name",
"priority",
"start_date",
"target_date",
"assignees",
"sequence_id",
"labels",
"sort_order",
"completed_at",
"archived_at",
"is_draft",
"external_source",
"external_id",
"type",
"cycle",
"modules",
"meta",
"name",
"last_saved_at",
"owned_by",
"created_at",
"updated_at",
"created_by",
"updated_by",
]
read_only_fields = ["workspace", "project", "issue"]
class IssueDescriptionVersionDetailSerializer(BaseSerializer):
class Meta:
model = IssueDescriptionVersion
fields = [
"id",
"workspace",
"project",
"issue",
"description_binary",
"description_html",
"description_stripped",
"description_json",
"last_saved_at",
"owned_by",
"created_at",
"updated_at",
"created_by",
"updated_by",
]
read_only_fields = ["workspace", "project", "issue"]
+22
View File
@@ -24,6 +24,8 @@ from plane.app.views import (
IssueDetailEndpoint,
IssueAttachmentV2Endpoint,
IssueBulkUpdateDateEndpoint,
IssueVersionEndpoint,
IssueDescriptionVersionEndpoint,
)
urlpatterns = [
@@ -256,4 +258,24 @@ urlpatterns = [
IssueBulkUpdateDateEndpoint.as_view(),
name="project-issue-dates",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/versions/",
IssueVersionEndpoint.as_view(),
name="page-versions",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/versions/<uuid:pk>/",
IssueVersionEndpoint.as_view(),
name="page-versions",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/description-versions/",
IssueDescriptionVersionEndpoint.as_view(),
name="page-versions",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/description-versions/<uuid:pk>/",
IssueDescriptionVersionEndpoint.as_view(),
name="page-versions",
),
]
+2
View File
@@ -141,6 +141,8 @@ from .issue.sub_issue import SubIssuesEndpoint
from .issue.subscriber import IssueSubscriberViewSet
from .issue.version import IssueVersionEndpoint, IssueDescriptionVersionEndpoint
from .module.base import (
ModuleViewSet,
ModuleLinkViewSet,
+118
View File
@@ -0,0 +1,118 @@
# Third party imports
from rest_framework import status
from rest_framework.response import Response
# Module imports
from plane.db.models import IssueVersion, IssueDescriptionVersion
from ..base import BaseAPIView
from plane.app.serializers import (
IssueVersionDetailSerializer,
IssueDescriptionVersionDetailSerializer,
)
from plane.app.permissions import allow_permission, ROLE
from plane.utils.global_paginator import paginate
from plane.utils.timezone_converter import user_timezone_converter
class IssueVersionEndpoint(BaseAPIView):
def process_paginated_result(self, fields, results, timezone):
paginated_data = results.values(*fields)
datetime_fields = ["created_at", "updated_at"]
paginated_data = user_timezone_converter(
paginated_data, datetime_fields, timezone
)
return paginated_data
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def get(self, request, slug, project_id, issue_id, pk=None):
if pk:
issue_version = IssueVersion.objects.get(
workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk
)
serializer = IssueVersionDetailSerializer(issue_version)
return Response(serializer.data, status=status.HTTP_200_OK)
cursor = request.GET.get("cursor", None)
required_fields = [
"id",
"workspace",
"project",
"issue",
"last_saved_at",
"owned_by",
"created_at",
"updated_at",
"created_by",
"updated_by",
]
issue_versions_queryset = IssueVersion.objects.filter(
workspace__slug=slug, project_id=project_id, issue_id=issue_id
)
paginated_data = paginate(
base_queryset=issue_versions_queryset,
queryset=issue_versions_queryset,
cursor=cursor,
on_result=lambda results: self.process_paginated_result(
required_fields, results, request.user.user_timezone
),
)
return Response(paginated_data, status=status.HTTP_200_OK)
class IssueDescriptionVersionEndpoint(BaseAPIView):
def process_paginated_result(self, fields, results, timezone):
paginated_data = results.values(*fields)
datetime_fields = ["created_at", "updated_at"]
paginated_data = user_timezone_converter(
paginated_data, datetime_fields, timezone
)
return paginated_data
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def get(self, request, slug, project_id, issue_id, pk=None):
if pk:
issue_description_version = IssueDescriptionVersion.objects.get(
workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk
)
serializer = IssueDescriptionVersionDetailSerializer(
issue_description_version
)
return Response(serializer.data, status=status.HTTP_200_OK)
cursor = request.GET.get("cursor", None)
required_fields = [
"id",
"workspace",
"project",
"issue",
"last_saved_at",
"owned_by",
"created_at",
"updated_at",
"created_by",
"updated_by",
]
issue_description_versions_queryset = IssueDescriptionVersion.objects.filter(
workspace__slug=slug, project_id=project_id, issue_id=issue_id
)
paginated_data = paginate(
base_queryset=issue_description_versions_queryset,
queryset=issue_description_versions_queryset,
cursor=cursor,
on_result=lambda results: self.process_paginated_result(
required_fields, results, request.user.user_timezone
),
)
return Response(paginated_data, status=status.HTTP_200_OK)
+100 -1
View File
@@ -316,5 +316,104 @@
"change_parent_issue": "Change parent issue",
"remove_parent_issue": "Remove parent issue",
"add_parent": "Add parent",
"loading_members": "Loading members..."
"loading_members": "Loading members...",
"date_time" : {
"units_short": {
"s": "s",
"m": "min",
"h": "h",
"d": "j",
"w": "sem",
"mo": "mois",
"y": "an"
},
"date": {
"days": {
"Monday": "Monday",
"Tuesday": "Tuesday",
"Wednesday": "Wednesday",
"Thursday": "Thursday",
"Friday": "Friday",
"Saturday": "Saturday",
"Sunday": "Sunday"
},
"months_short": {
"Jan": "Jan",
"Feb": "Feb",
"Mar": "Mar",
"Apr": "Apr",
"May": "May",
"Jun": "Jun",
"Jul": "Jul",
"Aug": "Aug",
"Sep": "Sep",
"Oct": "Oct",
"Nov": "Nov",
"Dec": "Dec"
},
"format": {
"full": "{day}, {month} {date} {time}",
"shortDate": "{month} {date}",
"timeOnly": "{hours}:{minutes}"
}
},
"standard": {
"less_than_x_seconds": {
"past": "{time, plural, one{less than # second} other{less than # seconds}} ago",
"future": "in {time, plural, one{less than # second} other{less than # seconds}}"
},
"less_than_x_minutes": {
"past": "less than a minute ago",
"future": "in less than a minute"
},
"x_minutes": {
"past": "{time, plural, one{# minute} other{# minutes}} ago",
"future": "in {time, plural, one{# minute} other{# minutes}}"
},
"about_x_hours": {
"past": "{time, plural, one{about # hour} other{about # hours}} ago",
"future": "in {time, plural, one{about # hour} other{about # hours}}"
},
"x_days": {
"past": "{time, plural, one{# day} other{# days}} ago",
"future": "in {time, plural, one{# day} other{# days}}"
},
"about_x_months": {
"past": "{time, plural, one{about # month} other{about # months}} ago",
"future": "in {time, plural, one{about # month} other{about # months}}"
},
"about_x_years": {
"past": "{time, plural, one{about # year} other{about # years}} ago",
"future": "in {time, plural, one{about # year} other{about # years}}"
},
"over_x_years": {
"past": "{time, plural, one{over # year} other{over # years}} ago",
"future": "in {time, plural, one{over # year} other{over # years}}"
},
"almost_x_years": {
"past": "{time, plural, one{almost # year} other{almost # years}} ago",
"future": "in {time, plural, one{almost # year} other{almost # years}}"
}
},
"include_seconds": {
"less_than_5_seconds": {
"past": "less than 5 seconds ago",
"future": "in less than 5 seconds"
},
"less_than_10_seconds": {
"past": "less than 10 seconds ago",
"future": "in less than 10 seconds"
},
"less_than_20_seconds": {
"past": "less than 20 seconds ago",
"future": "in less than 20 seconds"
},
"half_a_minute": {
"past": "half a minute ago",
"future": "in half a minute"
}
}
}
}
+98 -1
View File
@@ -316,5 +316,102 @@
"remove_parent_issue": "Eliminar problema padre",
"add_parent": "Agregar padre",
"loading_members": "Cargando miembros...",
"inbox": "bandeja de entrada"
"inbox": "bandeja de entrada",
"date_time": {
"units_short": {
"s": "s",
"m": "min",
"h": "h",
"d": "d",
"w": "sem",
"mo": "mes",
"y": "año"
},
"date": {
"days": {
"Monday": "lunes",
"Tuesday": "martes",
"Wednesday": "miércoles",
"Thursday": "jueves",
"Friday": "viernes",
"Saturday": "sábado",
"Sunday": "domingo"
},
"months_short": {
"Jan": "ene.",
"Feb": "feb.",
"Mar": "mar.",
"Apr": "abr.",
"May": "may.",
"Jun": "jun.",
"Jul": "jul.",
"Aug": "ago.",
"Sep": "sept.",
"Oct": "oct.",
"Nov": "nov.",
"Dec": "dic."
},
"format": {
"full": "{day} {date} de {month} {time}",
"shortDate": "{date} {month}",
"timeOnly": "{hours}:{minutes}"
}
},
"standard": {
"less_than_x_seconds": {
"past": "hace {time, plural, one{menos de un segundo} other{menos de # segundos}}",
"future": "en {time, plural, one{menos de un segundo} other{menos de # segundos}}"
},
"less_than_x_minutes": {
"past": "hace menos de un minuto",
"future": "en menos de un minuto"
},
"x_minutes": {
"past": "hace {time, plural, one{# minuto} other{# minutos}}",
"future": "en {time, plural, one{# minuto} other{# minutos}}"
},
"about_x_hours": {
"past": "hace {time, plural, one{aproximadamente # hora} other{aproximadamente # horas}}",
"future": "en {time, plural, one{aproximadamente # hora} other{aproximadamente # horas}}"
},
"x_days": {
"past": "hace {time, plural, one{# día} other{# días}}",
"future": "en {time, plural, one{# día} other{# días}}"
},
"about_x_months": {
"past": "hace {time, plural, one{aproximadamente # mes} other{aproximadamente # meses}}",
"future": "en {time, plural, one{aproximadamente # mes} other{aproximadamente # meses}}"
},
"about_x_years": {
"past": "hace {time, plural, one{aproximadamente # año} other{aproximadamente # años}}",
"future": "en {time, plural, one{aproximadamente # año} other{aproximadamente # años}}"
},
"over_x_years": {
"past": "hace {time, plural, one{más de # año} other{más de # años}}",
"future": "en {time, plural, one{más de # año} other{más de # años}}"
},
"almost_x_years": {
"past": "hace {time, plural, one{casi # año} other{casi # años}}",
"future": "en {time, plural, one{casi # año} other{casi # años}}"
}
},
"include_seconds": {
"less_than_5_seconds": {
"past": "hace menos de 5 segundos",
"future": "en menos de 5 segundos"
},
"less_than_10_seconds": {
"past": "hace menos de 10 segundos",
"future": "en menos de 10 segundos"
},
"less_than_20_seconds": {
"past": "hace menos de 20 segundos",
"future": "en menos de 20 segundos"
},
"half_a_minute": {
"past": "hace medio minuto",
"future": "en medio minuto"
}
}
}
}
+98 -1
View File
@@ -316,5 +316,102 @@
"remove_parent_issue": "Supprimer le problème parent",
"add_parent": "Ajouter un parent",
"loading_members": "Chargement des membres...",
"inbox": "Boîte de réception"
"inbox": "Boîte de réception",
"date_time": {
"units_short": {
"s": "s",
"m": "min",
"h": "h",
"d": "j",
"w": "sem",
"mo": "mois",
"y": "an"
},
"date": {
"days": {
"Monday": "lundi",
"Tuesday": "mardi",
"Wednesday": "mercredi",
"Thursday": "jeudi",
"Friday": "vendredi",
"Saturday": "samedi",
"Sunday": "dimanche"
},
"months_short": {
"Jan": "janv.",
"Feb": "févr.",
"Mar": "mars",
"Apr": "avr.",
"May": "mai",
"Jun": "juin",
"Jul": "juil.",
"Aug": "août",
"Sep": "sept.",
"Oct": "oct.",
"Nov": "nov.",
"Dec": "déc."
},
"format": {
"full": "{day} {date} {month} {time}",
"shortDate": "{date} {month}",
"timeOnly": "{hours}:{minutes}"
}
},
"standard": {
"less_than_x_seconds": {
"past": "il y a {time, plural, one{moins d'une seconde} other{moins de # secondes}}",
"future": "dans {time, plural, one{moins d'une seconde} other{moins de # secondes}}"
},
"less_than_x_minutes": {
"past": "il y a moins d'une minute",
"future": "dans moins d'une minute"
},
"x_minutes": {
"past": "il y a {time, plural, one{# minute} other{# minutes}}",
"future": "dans {time, plural, one{# minute} other{# minutes}}"
},
"about_x_hours": {
"past": "il y a {time, plural, one{environ # heure} other{environ # heures}}",
"future": "dans {time, plural, one{environ # heure} other{environ # heures}}"
},
"x_days": {
"past": "il y a {time, plural, one{# jour} other{# jours}}",
"future": "dans {time, plural, one{# jour} other{# jours}}"
},
"about_x_months": {
"past": "il y a {time, plural, one{environ # mois} other{environ # mois}}",
"future": "dans {time, plural, one{environ # mois} other{environ # mois}}"
},
"about_x_years": {
"past": "il y a {time, plural, one{environ # an} other{environ # ans}}",
"future": "dans {time, plural, one{environ # an} other{environ # ans}}"
},
"over_x_years": {
"past": "il y a {time, plural, one{plus d'un an} other{plus de # ans}}",
"future": "dans {time, plural, one{plus d'un an} other{plus de # ans}}"
},
"almost_x_years": {
"past": "il y a {time, plural, one{presque # an} other{presque # ans}}",
"future": "dans {time, plural, one{presque # an} other{presque # ans}}"
}
},
"include_seconds": {
"less_than_5_seconds": {
"past": "il y a moins de 5 secondes",
"future": "dans moins de 5 secondes"
},
"less_than_10_seconds": {
"past": "il y a moins de 10 secondes",
"future": "dans moins de 10 secondes"
},
"less_than_20_seconds": {
"past": "il y a moins de 20 secondes",
"future": "dans moins de 20 secondes"
},
"half_a_minute": {
"past": "il y a 30 secondes",
"future": "dans 30 secondes"
}
}
}
}
+98 -1
View File
@@ -316,5 +316,102 @@
"remove_parent_issue": "親問題を削除",
"add_parent": "親問題を追加",
"loading_members": "メンバーを読み込んでいます...",
"inbox": "受信箱"
"inbox": "受信箱",
"date_time": {
"units_short": {
"s": "秒",
"m": "分",
"h": "時間",
"d": "日",
"w": "週間",
"mo": "ヶ月",
"y": "年"
},
"date": {
"days": {
"Monday": "月曜日",
"Tuesday": "火曜日",
"Wednesday": "水曜日",
"Thursday": "木曜日",
"Friday": "金曜日",
"Saturday": "土曜日",
"Sunday": "日曜日"
},
"months_short": {
"Jan": "1月",
"Feb": "2月",
"Mar": "3月",
"Apr": "4月",
"May": "5月",
"Jun": "6月",
"Jul": "7月",
"Aug": "8月",
"Sep": "9月",
"Oct": "10月",
"Nov": "11月",
"Dec": "12月"
},
"format": {
"full": "{month}{date}日 ({day}) {time}",
"shortDate": "{month}{date}日",
"timeOnly": "{hours}:{minutes}"
}
},
"standard": {
"less_than_x_seconds": {
"past": "{time}秒未満前",
"future": "{time}秒未満後"
},
"less_than_x_minutes": {
"past": "1分未満前",
"future": "1分未満後"
},
"x_minutes": {
"past": "{time}分前",
"future": "{time}分後"
},
"about_x_hours": {
"past": "約{time}時間前",
"future": "約{time}時間後"
},
"x_days": {
"past": "{time}日前",
"future": "{time}日後"
},
"about_x_months": {
"past": "約{time}ヶ月前",
"future": "約{time}ヶ月後"
},
"about_x_years": {
"past": "約{time}年前",
"future": "約{time}年後"
},
"over_x_years": {
"past": "{time}年以上前",
"future": "{time}年以上後"
},
"almost_x_years": {
"past": "ほぼ{time}年前",
"future": "ほぼ{time}年後"
}
},
"include_seconds": {
"less_than_5_seconds": {
"past": "5秒未満前",
"future": "5秒未満後"
},
"less_than_10_seconds": {
"past": "10秒未満前",
"future": "10秒未満後"
},
"less_than_20_seconds": {
"past": "20秒未満前",
"future": "20秒未満後"
},
"half_a_minute": {
"past": "30秒前",
"future": "30秒後"
}
}
}
}
@@ -315,5 +315,102 @@
"remove_parent_issue": "移除父问题",
"add_parent": "添加父问题",
"loading_members": "正在加载成员...",
"inbox": "收件箱"
"inbox": "收件箱",
"date_time": {
"units_short": {
"s": "秒",
"m": "分",
"h": "小时",
"d": "天",
"w": "周",
"mo": "月",
"y": "年"
},
"date": {
"days": {
"Monday": "星期一",
"Tuesday": "星期二",
"Wednesday": "星期三",
"Thursday": "星期四",
"Friday": "星期五",
"Saturday": "星期六",
"Sunday": "星期日"
},
"months_short": {
"Jan": "1月",
"Feb": "2月",
"Mar": "3月",
"Apr": "4月",
"May": "5月",
"Jun": "6月",
"Jul": "7月",
"Aug": "8月",
"Sep": "9月",
"Oct": "10月",
"Nov": "11月",
"Dec": "12月"
},
"format": {
"full": "{month}{date}日 {day} {time}",
"shortDate": "{month}{date}日",
"timeOnly": "{hours}:{minutes}"
}
},
"standard": {
"less_than_x_seconds": {
"past": "{time, plural, one{不到1秒} other{不到#秒}}前",
"future": "{time, plural, one{不到1秒} other{不到#秒}}后"
},
"less_than_x_minutes": {
"past": "不到1分钟前",
"future": "不到1分钟后"
},
"x_minutes": {
"past": "{time, plural, one{#分钟} other{#分钟}}前",
"future": "{time, plural, one{#分钟} other{#分钟}}后"
},
"about_x_hours": {
"past": "大约{time, plural, one{#小时} other{#小时}}前",
"future": "大约{time, plural, one{#小时} other{#小时}}后"
},
"x_days": {
"past": "{time, plural, one{#天} other{#天}}前",
"future": "{time, plural, one{#天} other{#天}}后"
},
"about_x_months": {
"past": "大约{time, plural, one{#个月} other{#个月}}前",
"future": "大约{time, plural, one{#个月} other{#个月}}后"
},
"about_x_years": {
"past": "大约{time, plural, one{#年} other{#年}}前",
"future": "大约{time, plural, one{#年} other{#年}}后"
},
"over_x_years": {
"past": "超过{time, plural, one{#年} other{#年}}前",
"future": "超过{time, plural, one{#年} other{#年}}后"
},
"almost_x_years": {
"past": "接近{time, plural, one{#年} other{#年}}前",
"future": "接近{time, plural, one{#年} other{#年}}后"
}
},
"include_seconds": {
"less_than_5_seconds": {
"past": "不到5秒前",
"future": "不到5秒后"
},
"less_than_10_seconds": {
"past": "不到10秒前",
"future": "不到10秒后"
},
"less_than_20_seconds": {
"past": "不到20秒前",
"future": "不到20秒后"
},
"half_a_minute": {
"past": "半分钟前",
"future": "半分钟后"
}
}
}
}
@@ -2,11 +2,12 @@
import { useState } from "react";
import { XCircle } from "lucide-react";
import { useTranslation } from "@plane/i18n";
import { IApiToken } from "@plane/types";
// components
import { Tooltip } from "@plane/ui";
import { DeleteApiTokenModal } from "@/components/api-token";
import { renderFormattedDate, calculateTimeAgo } from "@/helpers/date-time.helper";
import { renderFormattedDate, calculateI18nTimeAgo } from "@/helpers/date-time.helper";
import { usePlatformOS } from "@/hooks/use-platform-os";
// ui
// helpers
@@ -22,6 +23,9 @@ export const ApiTokenListItem: React.FC<Props> = (props) => {
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
// hooks
const { isMobile } = usePlatformOS();
const { t } = useTranslation();
const { i18n_time_ago, time } = calculateI18nTimeAgo(token.expired_at);
return (
<>
@@ -54,7 +58,7 @@ export const ApiTokenListItem: React.FC<Props> = (props) => {
? token.expired_at
? `Expires ${renderFormattedDate(token.expired_at!)}`
: "Never expires"
: `Expired ${calculateTimeAgo(token.expired_at)}`}
: `Expired ${t(i18n_time_ago, { time })}`}
</p>
</div>
</div>
@@ -3,11 +3,12 @@
import { FC, ReactNode } from "react";
import { Network } from "lucide-react";
// types
import { useTranslation } from "@plane/i18n";
import { TWorkspaceBaseActivity } from "@plane/types";
// ui
import { Tooltip } from "@plane/ui";
// helpers
import { renderFormattedTime, renderFormattedDate, calculateTimeAgo } from "@/helpers/date-time.helper";
import { renderFormattedTime, renderFormattedDate, calculateI18nTimeAgo } from "@/helpers/date-time.helper";
// hooks
import { usePlatformOS } from "@/hooks/use-platform-os";
// local components
@@ -25,8 +26,9 @@ export const ActivityBlockComponent: FC<TActivityBlockComponent> = (props) => {
const { icon, activity, ends, children, customUserName } = props;
// hooks
const { isMobile } = usePlatformOS();
const { t } = useTranslation();
if (!activity) return <></>;
const { i18n_time_ago, time } = calculateI18nTimeAgo(activity.created_at);
return (
<div
className={`relative flex items-center gap-2 text-xs ${
@@ -44,7 +46,7 @@ export const ActivityBlockComponent: FC<TActivityBlockComponent> = (props) => {
tooltipContent={`${renderFormattedDate(activity.created_at)}, ${renderFormattedTime(activity.created_at)}`}
>
<span className="whitespace-nowrap text-custom-text-350 font-medium cursor-help">
{calculateTimeAgo(activity.created_at)}
{t(i18n_time_ago, { time })}
</span>
</Tooltip>
</div>
@@ -5,6 +5,7 @@ import { observer } from "mobx-react";
import Link from "next/link";
import { History } from "lucide-react";
// types
import { useTranslation } from "@plane/i18n";
import { TRecentActivityWidgetResponse } from "@plane/types";
// components
import { Card, Avatar, getButtonStyling } from "@plane/ui";
@@ -12,7 +13,7 @@ import { ActivityIcon, ActivityMessage, IssueLink } from "@/components/core";
import { RecentActivityEmptyState, WidgetLoader, WidgetProps } from "@/components/dashboard/widgets";
// helpers
import { cn } from "@/helpers/common.helper";
import { calculateTimeAgo } from "@/helpers/date-time.helper";
import { calculateI18nTimeAgo } from "@/helpers/date-time.helper";
import { getFileURL } from "@/helpers/file.helper";
// hooks
import { useDashboard, useUser } from "@/hooks/store";
@@ -23,6 +24,7 @@ export const RecentActivityWidget: React.FC<WidgetProps> = observer((props) => {
const { dashboardId, workspaceSlug } = props;
// store hooks
const { data: currentUser } = useUser();
const { t } = useTranslation();
// derived values
const { fetchWidgetStats, getWidgetStats } = useDashboard();
const widgetStats = getWidgetStats<TRecentActivityWidgetResponse[]>(workspaceSlug, dashboardId, WIDGET_KEY);
@@ -44,51 +46,52 @@ export const RecentActivityWidget: React.FC<WidgetProps> = observer((props) => {
</Link>
{widgetStats.length > 0 ? (
<div className="mt-4 space-y-6">
{widgetStats.map((activity) => (
<div key={activity.id} className="flex gap-5">
<div className="flex-shrink-0">
{activity.field ? (
activity.new_value === "restore" ? (
<History className="h-3.5 w-3.5 text-custom-text-200" />
) : (
<div className="flex h-6 w-6 justify-center">
<ActivityIcon activity={activity} />
</div>
)
) : activity.actor_detail.avatar_url && activity.actor_detail.avatar_url !== "" ? (
<Avatar
src={getFileURL(activity.actor_detail.avatar_url)}
name={activity.actor_detail.display_name}
size={24}
className="h-full w-full rounded-full object-cover"
/>
) : (
<div className="grid h-7 w-7 place-items-center rounded-full border-2 border-white bg-gray-700 text-xs text-white">
{activity.actor_detail.is_bot
? activity.actor_detail.first_name.charAt(0)
: activity.actor_detail.display_name.charAt(0)}
</div>
)}
</div>
<div className="-mt-2 break-words">
<p className="inline text-sm text-custom-text-200">
<span className="font-medium text-custom-text-100">
{currentUser?.id === activity.actor_detail.id ? "You" : activity.actor_detail?.display_name}{" "}
</span>
{widgetStats.map((activity) => {
const { i18n_time_ago, time } = calculateI18nTimeAgo(activity.created_at);
return (
<div key={activity.id} className="flex gap-5">
<div className="flex-shrink-0">
{activity.field ? (
<ActivityMessage activity={activity} showIssue />
activity.new_value === "restore" ? (
<History className="h-3.5 w-3.5 text-custom-text-200" />
) : (
<div className="flex h-6 w-6 justify-center">
<ActivityIcon activity={activity} />
</div>
)
) : activity.actor_detail.avatar_url && activity.actor_detail.avatar_url !== "" ? (
<Avatar
src={getFileURL(activity.actor_detail.avatar_url)}
name={activity.actor_detail.display_name}
size={24}
className="h-full w-full rounded-full object-cover"
/>
) : (
<span>
created <IssueLink activity={activity} />
</span>
<div className="grid h-7 w-7 place-items-center rounded-full border-2 border-white bg-gray-700 text-xs text-white">
{activity.actor_detail.is_bot
? activity.actor_detail.first_name.charAt(0)
: activity.actor_detail.display_name.charAt(0)}
</div>
)}
</p>
<p className="text-xs text-custom-text-200 whitespace-nowrap">
{calculateTimeAgo(activity.created_at)}
</p>
</div>
<div className="-mt-2 break-words">
<p className="inline text-sm text-custom-text-200">
<span className="font-medium text-custom-text-100">
{currentUser?.id === activity.actor_detail.id ? "You" : activity.actor_detail?.display_name}{" "}
</span>
{activity.field ? (
<ActivityMessage activity={activity} showIssue />
) : (
<span>
created <IssueLink activity={activity} />
</span>
)}
</p>
<p className="text-xs text-custom-text-200 whitespace-nowrap">{t(i18n_time_ago, { time })}</p>
</div>
</div>
</div>
))}
);
})}
<Link
href={redirectionLink}
className={cn(
@@ -8,7 +8,7 @@ export const STICKY_COLORS_LIST: {
{
key: "gray",
label: "Gray",
backgroundColor: "var(--editor-colors-gray-background)",
backgroundColor: "rgba(var(--color-background-90))",
},
{
key: "peach",
@@ -43,7 +43,6 @@ export const StickyEditor = React.forwardRef<EditorRefApi, StickyEditorWrapperPr
showToolbarInitially = true,
showToolbar = true,
parentClassName = "",
placeholder = "Add comment...",
uploadFile,
...rest
} = props;
@@ -78,7 +77,6 @@ export const StickyEditor = React.forwardRef<EditorRefApi, StickyEditorWrapperPr
mentionHandler={{
renderComponent: () => <></>,
}}
placeholder={placeholder}
containerClassName={cn(containerClassName, "relative")}
{...rest}
/>
+13 -4
View File
@@ -6,6 +6,7 @@ import { IUser } from "@plane/types";
import { Button } from "@plane/ui";
// hooks
import { useCurrentTime } from "@/hooks/use-current-time";
import { useTranslation } from "@plane/i18n";
export interface IUserGreetingsView {
user: IUser;
@@ -16,6 +17,7 @@ export const UserGreetingsView: FC<IUserGreetingsView> = (props) => {
const { user, handleWidgetModal } = props;
// current time hook
const { currentTime } = useCurrentTime();
const { t } = useTranslation();
const hour = new Intl.DateTimeFormat("en-US", {
hour12: false,
@@ -23,10 +25,13 @@ export const UserGreetingsView: FC<IUserGreetingsView> = (props) => {
}).format(currentTime);
const date = new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
}).format(currentTime);
const month = new Intl.DateTimeFormat("en-US", {
month: "short",
}).format(currentTime);
const weekDay = new Intl.DateTimeFormat("en-US", {
weekday: "long",
}).format(currentTime);
@@ -39,6 +44,12 @@ export const UserGreetingsView: FC<IUserGreetingsView> = (props) => {
}).format(currentTime);
const greeting = parseInt(hour, 10) < 12 ? "morning" : parseInt(hour, 10) < 18 ? "afternoon" : "evening";
const translatedTime = t(`date_time.date.format.full`, {
day: t(`date_time.date.days.${weekDay}`),
date,
month: t(`date_time.date.months_short.${month}`),
time: timeString,
});
return (
<div className="flex justify-between">
@@ -48,9 +59,7 @@ export const UserGreetingsView: FC<IUserGreetingsView> = (props) => {
</h3>
<h6 className="flex items-center gap-2 font-medium text-custom-text-400">
<div>{greeting === "morning" ? "🌤️" : greeting === "afternoon" ? "🌥️" : "🌙️"}</div>
<div>
{weekDay}, {date} {timeString}
</div>
<div>{translatedTime}</div>
</h6>
</div>
<Button variant="neutral-primary" size="sm" onClick={handleWidgetModal} className="my-auto mb-0">
@@ -5,10 +5,11 @@ import { FC } from "react";
// ui
import { observer } from "mobx-react";
import { Pencil, Trash2, ExternalLink, EllipsisVertical, Link2, Link } from "lucide-react";
import { useTranslation } from "@plane/i18n";
import { TOAST_TYPE, setToast, CustomMenu, TContextMenuItem } from "@plane/ui";
// helpers
import { cn, copyTextToClipboard } from "@plane/utils";
import { calculateTimeAgo } from "@/helpers/date-time.helper";
import { calculateI18nTimeAgo, calculateTimeAgo } from "@/helpers/date-time.helper";
import { useHome } from "@/hooks/store/use-home";
import { TLinkOperations } from "./use-links";
@@ -24,6 +25,7 @@ export const ProjectLinkDetail: FC<TProjectLinkDetail> = observer((props) => {
const {
quickLinks: { getLinkById, toggleLinkModal, setLinkData },
} = useHome();
const { t } = useTranslation();
const linkDetail = getLinkById(linkId);
if (!linkDetail) return <></>;
@@ -71,6 +73,7 @@ export const ProjectLinkDetail: FC<TProjectLinkDetail> = observer((props) => {
icon: Trash2,
},
];
const { i18n_time_ago, time } = calculateI18nTimeAgo(linkDetail.created_at);
return (
<div
@@ -82,7 +85,7 @@ export const ProjectLinkDetail: FC<TProjectLinkDetail> = observer((props) => {
</div>
<div className="my-auto flex-1">
<div className="text-sm font-medium truncate">{linkDetail.title || linkDetail.url}</div>
<div className="text-xs font-medium text-custom-text-400">{calculateTimeAgo(linkDetail.created_at)}</div>
<div className="text-xs font-medium text-custom-text-400">{t(i18n_time_ago, { time })}</div>
</div>
<CustomMenu
@@ -1,8 +1,9 @@
import { useTranslation } from "@plane/i18n";
import { TActivityEntityData, TIssueEntityData } from "@plane/types";
import { LayersIcon, PriorityIcon, StateGroupIcon, Tooltip } from "@plane/ui";
import { ListItem } from "@/components/core/list";
import { MemberDropdown } from "@/components/dropdowns";
import { calculateTimeAgo } from "@/helpers/date-time.helper";
import { calculateI18nTimeAgo } from "@/helpers/date-time.helper";
import { useIssueDetail, useProjectState } from "@/hooks/store";
import { IssueIdentifier } from "@/plane-web/components/issues";
@@ -16,9 +17,11 @@ export const RecentIssue = (props: BlockProps) => {
// hooks
const { getStateById } = useProjectState();
const { setPeekIssue } = useIssueDetail();
const { t } = useTranslation();
// derived values
const issueDetails: TIssueEntityData = activity.entity_data as TIssueEntityData;
const state = getStateById(issueDetails?.state);
const { i18n_time_ago, time } = calculateI18nTimeAgo(activity.visited_at);
return (
<ListItem
@@ -47,7 +50,7 @@ export const RecentIssue = (props: BlockProps) => {
</div>
)}
<div className="text-custom-text-200 font-medium text-sm whitespace-nowrap">{issueDetails?.name}</div>
<div className="font-medium text-xs text-custom-text-400">{calculateTimeAgo(activity.visited_at)}</div>
<div className="font-medium text-xs text-custom-text-400">{t(i18n_time_ago, { time })} </div>
</div>
}
quickActionElement={
@@ -1,10 +1,11 @@
import { useRouter } from "next/navigation";
import { FileText } from "lucide-react";
import { useTranslation } from "@plane/i18n";
import { TActivityEntityData, TPageEntityData } from "@plane/types";
import { Avatar, Logo } from "@plane/ui";
import { getFileURL } from "@plane/utils";
import { ListItem } from "@/components/core/list";
import { calculateTimeAgo } from "@/helpers/date-time.helper";
import { calculateI18nTimeAgo } from "@/helpers/date-time.helper";
import { useMember } from "@/hooks/store";
type BlockProps = {
@@ -18,9 +19,11 @@ export const RecentPage = (props: BlockProps) => {
const router = useRouter();
// hooks
const { getUserDetails } = useMember();
const { t } = useTranslation();
// derived values
const pageDetails: TPageEntityData = activity.entity_data as TPageEntityData;
const ownerDetails = getUserDetails(pageDetails?.owned_by);
const { i18n_time_ago, time } = calculateI18nTimeAgo(activity.visited_at);
return (
<ListItem
key={activity.id}
@@ -43,7 +46,7 @@ export const RecentPage = (props: BlockProps) => {
</div>
</div>
<div className="text-custom-text-200 font-medium text-sm whitespace-nowrap">{pageDetails?.name}</div>
<div className="font-medium text-xs text-custom-text-400">{calculateTimeAgo(activity.visited_at)}</div>
<div className="font-medium text-xs text-custom-text-400">{t(i18n_time_ago, { time })}</div>
</div>
}
quickActionElement={
@@ -1,9 +1,10 @@
import { useRouter } from "next/navigation";
import { useTranslation } from "@plane/i18n";
import { TActivityEntityData, TProjectEntityData } from "@plane/types";
import { Logo } from "@plane/ui";
import { ListItem } from "@/components/core/list";
import { MemberDropdown } from "@/components/dropdowns";
import { calculateTimeAgo } from "@/helpers/date-time.helper";
import { calculateI18nTimeAgo } from "@/helpers/date-time.helper";
type BlockProps = {
activity: TActivityEntityData;
@@ -14,8 +15,11 @@ export const RecentProject = (props: BlockProps) => {
const { activity, ref, workspaceSlug } = props;
// router
const router = useRouter();
// hooks
const { t } = useTranslation();
// derived values
const projectDetails: TProjectEntityData = activity.entity_data as TProjectEntityData;
const { i18n_time_ago, time } = calculateI18nTimeAgo(activity.visited_at);
return (
<ListItem
@@ -33,7 +37,7 @@ export const RecentProject = (props: BlockProps) => {
</div>
</div>
<div className="text-custom-text-200 font-medium text-sm whitespace-nowrap">{projectDetails?.name}</div>
<div className="font-medium text-xs text-custom-text-400">{calculateTimeAgo(activity.visited_at)}</div>
<div className="font-medium text-xs text-custom-text-400">{t(i18n_time_ago, { time })} </div>
</div>
}
quickActionElement={
@@ -3,8 +3,9 @@
import { FC, ReactNode } from "react";
import { Network } from "lucide-react";
// hooks
import { useTranslation } from "@plane/i18n";
import { Tooltip } from "@plane/ui";
import { renderFormattedTime, renderFormattedDate, calculateTimeAgo } from "@/helpers/date-time.helper";
import { renderFormattedTime, renderFormattedDate, calculateI18nTimeAgo } from "@/helpers/date-time.helper";
import { useIssueDetail } from "@/hooks/store";
import { usePlatformOS } from "@/hooks/use-platform-os";
// ui
@@ -26,10 +27,12 @@ export const IssueActivityBlockComponent: FC<TIssueActivityBlockComponent> = (pr
const {
activity: { getActivityById },
} = useIssueDetail();
const { t } = useTranslation();
const activity = getActivityById(activityId);
const { isMobile } = usePlatformOS();
if (!activity) return <></>;
const { i18n_time_ago, time } = calculateI18nTimeAgo(activity.created_at);
return (
<div
className={`relative flex items-center gap-3 text-xs ${
@@ -48,7 +51,7 @@ export const IssueActivityBlockComponent: FC<TIssueActivityBlockComponent> = (pr
isMobile={isMobile}
tooltipContent={`${renderFormattedDate(activity.created_at)}, ${renderFormattedTime(activity.created_at)}`}
>
<span className="whitespace-nowrap"> {calculateTimeAgo(activity.created_at)}</span>
<span className="whitespace-nowrap"> {t(i18n_time_ago, { time })}</span>
</Tooltip>
</span>
</div>
@@ -2,7 +2,8 @@ import { FC, ReactNode } from "react";
import { observer } from "mobx-react";
import { MessageCircle } from "lucide-react";
// helpers
import { calculateTimeAgo } from "@/helpers/date-time.helper";
import { useTranslation } from "@plane/i18n";
import { calculateI18nTimeAgo } from "@/helpers/date-time.helper";
import { getFileURL } from "@/helpers/file.helper";
// hooks
import { useIssueDetail } from "@/hooks/store";
@@ -20,19 +21,23 @@ export const IssueCommentBlock: FC<TIssueCommentBlock> = observer((props) => {
const {
comment: { getCommentById },
} = useIssueDetail();
const { t } = useTranslation();
const comment = getCommentById(commentId);
if (!comment) return <></>;
const { i18n_time_ago, time } = calculateI18nTimeAgo(comment.created_at);
return (
<div className={`relative flex gap-3 ${ends === "top" ? `pb-2` : ends === "bottom" ? `pt-2` : `py-2`}`}>
<div className="absolute left-[13px] top-0 bottom-0 w-0.5 bg-custom-background-80" aria-hidden />
<div className="flex-shrink-0 relative w-7 h-7 rounded-full flex justify-center items-center z-[3] bg-gray-500 text-white border border-white uppercase font-medium">
{comment.actor_detail.avatar_url && comment.actor_detail.avatar_url !== "" ? (
{comment.actor_detail?.avatar_url && comment.actor_detail?.avatar_url !== "" ? (
<img
src={getFileURL(comment.actor_detail.avatar_url)}
src={getFileURL(comment.actor_detail?.avatar_url)}
alt={
comment.actor_detail.is_bot ? comment.actor_detail.first_name + " Bot" : comment.actor_detail.display_name
comment.actor_detail?.is_bot
? comment.actor_detail?.first_name + " Bot"
: comment.actor_detail?.display_name
}
height={30}
width={30}
@@ -40,9 +45,9 @@ export const IssueCommentBlock: FC<TIssueCommentBlock> = observer((props) => {
/>
) : (
<>
{comment.actor_detail.is_bot
? comment.actor_detail.first_name.charAt(0)
: comment.actor_detail.display_name.charAt(0)}
{comment.actor_detail?.is_bot
? comment.actor_detail?.first_name.charAt(0)
: comment.actor_detail?.display_name.charAt(0)}
</>
)}
<div className="absolute top-2 left-4 w-5 h-5 rounded-full overflow-hidden flex justify-center items-center bg-custom-background-80">
@@ -53,11 +58,11 @@ export const IssueCommentBlock: FC<TIssueCommentBlock> = observer((props) => {
<div className="w-full truncate space-y-1">
<div>
<div className="text-xs capitalize">
{comment.actor_detail.is_bot
? comment.actor_detail.first_name + " Bot"
: comment.actor_detail.display_name}
{comment.actor_detail?.is_bot
? comment.actor_detail?.first_name + " Bot"
: comment.actor_detail?.display_name}
</div>
<div className="text-xs text-custom-text-200">commented {calculateTimeAgo(comment.created_at)}</div>
<div className="text-xs text-custom-text-200">commented {t(i18n_time_ago, { time })}</div>
</div>
<div>{children}</div>
</div>
@@ -4,11 +4,12 @@ import { FC } from "react";
// hooks
// ui
import { Pencil, Trash2, LinkIcon, ExternalLink } from "lucide-react";
import { useTranslation } from "@plane/i18n";
import { Tooltip, TOAST_TYPE, setToast } from "@plane/ui";
// icons
// types
// helpers
import { calculateTimeAgo } from "@/helpers/date-time.helper";
import { calculateI18nTimeAgo } from "@/helpers/date-time.helper";
import { copyTextToClipboard } from "@/helpers/string.helper";
import { useIssueDetail, useMember } from "@/hooks/store";
import { usePlatformOS } from "@/hooks/use-platform-os";
@@ -31,6 +32,7 @@ export const IssueLinkDetail: FC<TIssueLinkDetail> = (props) => {
} = useIssueDetail();
const { getUserDetails } = useMember();
const { isMobile } = usePlatformOS();
const { t } = useTranslation();
const linkDetail = getLinkById(linkId);
if (!linkDetail) return <></>;
@@ -40,7 +42,7 @@ export const IssueLinkDetail: FC<TIssueLinkDetail> = (props) => {
};
const createdByDetails = getUserDetails(linkDetail.created_by_id);
const { i18n_time_ago, time } = calculateI18nTimeAgo(linkDetail.created_at);
return (
<div key={linkId}>
<div className="relative flex flex-col rounded-md bg-custom-background-90 p-2.5">
@@ -107,7 +109,7 @@ export const IssueLinkDetail: FC<TIssueLinkDetail> = (props) => {
<div className="px-5">
<p className="mt-0.5 stroke-[1.5] text-xs text-custom-text-300">
Added {calculateTimeAgo(linkDetail.created_at)}
Added {t(i18n_time_ago, { time })}
<br />
{createdByDetails && (
<>
@@ -8,12 +8,13 @@ import { TIssueServiceType } from "@plane/types";
// ui
import { Tooltip, TOAST_TYPE, setToast, CustomMenu } from "@plane/ui";
// helpers
import { calculateTimeAgoShort } from "@/helpers/date-time.helper";
import { calculateI18nTimeAgoShort } from "@/helpers/date-time.helper";
import { copyTextToClipboard } from "@/helpers/string.helper";
// hooks
import { useIssueDetail } from "@/hooks/store";
import { usePlatformOS } from "@/hooks/use-platform-os";
import { TLinkOperationsModal } from "./create-update-link-modal";
import { useTranslation } from "@plane/i18n";
type TIssueLinkItem = {
linkId: string;
@@ -32,6 +33,7 @@ export const IssueLinkItem: FC<TIssueLinkItem> = observer((props) => {
link: { getLinkById },
} = useIssueDetail(issueServiceType);
const { isMobile } = usePlatformOS();
const { t } = useTranslation();
const linkDetail = getLinkById(linkId);
if (!linkDetail) return <></>;
@@ -39,6 +41,7 @@ export const IssueLinkItem: FC<TIssueLinkItem> = observer((props) => {
toggleIssueLinkModalStore(modalToggle);
setIssueLinkData(linkDetail);
};
const { i18n_key, time } = calculateI18nTimeAgoShort(linkDetail.created_at);
return (
<>
<div
@@ -60,7 +63,8 @@ export const IssueLinkItem: FC<TIssueLinkItem> = observer((props) => {
</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)}
{time}
{t(i18n_key)}
</p>
<span
onClick={() => {
@@ -53,20 +53,26 @@ export const RelationIssueListItem: FC<Props> = observer((props) => {
} = useIssueDetail(issueServiceType);
const project = useProject();
const { getProjectStates } = useProjectState();
const { handleRedirection } = useIssuePeekOverviewRedirection();
const { isMobile } = usePlatformOS();
// derived values
const issue = getIssueById(relationIssueId);
const { handleRedirection } = useIssuePeekOverviewRedirection(!!issue?.is_epic);
const projectDetail = (issue && issue.project_id && project.getProjectById(issue.project_id)) || undefined;
const currentIssueStateDetail =
(issue?.project_id && getProjectStates(issue?.project_id)?.find((state) => issue?.state_id == state.id)) ||
undefined;
if (!issue) return <></>;
const issueLink = `/${workspaceSlug}/projects/${projectId}/${issue.is_epic ? "epics" : "issues"}/${issue.id}`;
// handlers
const handleIssuePeekOverview = (issue: TIssue) => handleRedirection(workspaceSlug, issue, isMobile);
const handleIssuePeekOverview = (issue: TIssue) => {
if (issueServiceType === EIssueServiceType.ISSUES && issue.is_epic) {
// open epics in new tab
window.open(issueLink, "_blank");
return;
}
handleRedirection(workspaceSlug, issue, isMobile);
};
const handleEditIssue = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
e.stopPropagation();
@@ -85,7 +91,7 @@ export const RelationIssueListItem: FC<Props> = observer((props) => {
const handleCopyIssueLink = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
e.stopPropagation();
e.preventDefault();
issueOperations.copyText(`${workspaceSlug}/projects/${issue.project_id}/issues/${issue.id}`);
issueOperations.copyText(issueLink);
};
const handleRemoveRelation = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
@@ -98,7 +104,7 @@ export const RelationIssueListItem: FC<Props> = observer((props) => {
<div key={relationIssueId}>
<ControlLink
id={`issue-${issue.id}`}
href={`/${workspaceSlug}/projects/${issue.project_id}/issues/${issue.id}`}
href={issueLink}
onClick={() => handleIssuePeekOverview(issue)}
className="w-full cursor-pointer"
>
@@ -1,11 +1,12 @@
import { observer } from "mobx-react";
import { Copy, LinkIcon, Pencil, Trash2 } from "lucide-react";
// plane types
import { useTranslation } from "@plane/i18n";
import { ILinkDetails } from "@plane/types";
// plane ui
import { setToast, TOAST_TYPE, Tooltip } from "@plane/ui";
// helpers
import { calculateTimeAgo } from "@/helpers/date-time.helper";
import { calculateI18nTimeAgo } from "@/helpers/date-time.helper";
import { copyTextToClipboard } from "@/helpers/string.helper";
// hooks
import { useMember } from "@/hooks/store";
@@ -22,6 +23,7 @@ export const ModulesLinksListItem: React.FC<Props> = observer((props) => {
const { handleDeleteLink, handleEditLink, isEditingAllowed, link } = props;
// store hooks
const { getUserDetails } = useMember();
const { t } = useTranslation();
// derived values
const createdByDetails = getUserDetails(link.created_by);
// platform os
@@ -36,6 +38,7 @@ export const ModulesLinksListItem: React.FC<Props> = observer((props) => {
})
);
};
const { i18n_time_ago, time } = calculateI18nTimeAgo(link.created_at);
return (
<div className="relative flex flex-col rounded-md bg-custom-background-90 p-2.5">
@@ -88,7 +91,7 @@ export const ModulesLinksListItem: React.FC<Props> = observer((props) => {
</div>
<div className="px-5">
<p className="mt-0.5 stroke-[1.5] text-xs text-custom-text-300">
Added {calculateTimeAgo(link.created_at)}
Added {t(i18n_time_ago, { time })}
<br />
{createdByDetails && (
<>by {createdByDetails?.is_bot ? createdByDetails?.first_name + " Bot" : createdByDetails?.display_name}</>
@@ -1,8 +1,9 @@
import { observer } from "mobx-react";
// helpers
import { calculateTimeAgoShort } from "@/helpers/date-time.helper";
import { calculateI18nTimeAgoShort } from "@/helpers/date-time.helper";
// store types
import { TPageInstance } from "@/store/pages/base-page";
import { useTranslation } from "@plane/i18n";
type Props = {
page: TPageInstance;
@@ -10,10 +11,15 @@ type Props = {
export const PageEditInformationPopover: React.FC<Props> = observer((props) => {
const { page } = props;
const { t } = useTranslation();
const { i18n_key, time } = page.updated_at ? calculateI18nTimeAgoShort(page.updated_at) : { i18n_key: "", time: 0 };
return (
<div className="flex-shrink-0 whitespace-nowrap">
<span className="text-sm text-custom-text-300">Edited {calculateTimeAgoShort(page.updated_at ?? "")} ago</span>
<span className="text-sm text-custom-text-300">
Edited {time}
{t(i18n_key)}
</span>
</div>
);
});
@@ -3,6 +3,7 @@ import Link from "next/link";
import { useParams } from "next/navigation";
// icons
import { History, MessageSquare } from "lucide-react";
import { useTranslation } from "@plane/i18n";
import { IUserActivityResponse } from "@plane/types";
// hooks
// components
@@ -12,7 +13,7 @@ import { RichTextReadOnlyEditor } from "@/components/editor/rich-text-editor/ric
// ui
import { ActivitySettingsLoader } from "@/components/ui";
// helpers
import { calculateTimeAgo } from "@/helpers/date-time.helper";
import { calculateI18nTimeAgo } from "@/helpers/date-time.helper";
import { getFileURL } from "@/helpers/file.helper";
// hooks
import { useUser } from "@/hooks/store";
@@ -27,6 +28,7 @@ export const ActivityList: React.FC<Props> = observer((props) => {
const { workspaceSlug } = useParams();
// store hooks
const { data: currentUser } = useUser();
const { t } = useTranslation();
// TODO: refactor this component
return (
@@ -34,6 +36,7 @@ export const ActivityList: React.FC<Props> = observer((props) => {
{activity ? (
<ul role="list">
{activity.results.map((activityItem) => {
const { i18n_time_ago, time } = calculateI18nTimeAgo(activityItem.created_at);
if (activityItem.field === "comment")
return (
<div key={activityItem.id} className="mt-2">
@@ -66,9 +69,7 @@ export const ActivityList: React.FC<Props> = observer((props) => {
? activityItem.actor_detail.first_name + " Bot"
: activityItem.actor_detail.display_name}
</div>
<p className="mt-0.5 text-xs text-custom-text-200">
Commented {calculateTimeAgo(activityItem.created_at)}
</p>
<p className="mt-0.5 text-xs text-custom-text-200">Commented {t(i18n_time_ago, { time })}</p>
</div>
<div className="issue-comments-section p-0">
<RichTextReadOnlyEditor
@@ -155,9 +156,7 @@ export const ActivityList: React.FC<Props> = observer((props) => {
)}{" "}
<div className="inline gap-1">
{message}{" "}
<span className="flex-shrink-0 whitespace-nowrap">
{calculateTimeAgo(activityItem.created_at)}
</span>
<span className="flex-shrink-0 whitespace-nowrap">{t(i18n_time_ago, { time })}</span>
</div>
</div>
</div>
@@ -5,13 +5,14 @@ import useSWR from "swr";
// icons
import { History, MessageSquare } from "lucide-react";
// hooks
import { useTranslation } from "@plane/i18n";
import { ActivityIcon, ActivityMessage, IssueLink } from "@/components/core";
import { RichTextReadOnlyEditor } from "@/components/editor/rich-text-editor/rich-text-read-only-editor";
import { ActivitySettingsLoader } from "@/components/ui";
// constants
import { USER_ACTIVITY } from "@/constants/fetch-keys";
// helpers
import { calculateTimeAgo } from "@/helpers/date-time.helper";
import { calculateI18nTimeAgo } from "@/helpers/date-time.helper";
import { getFileURL } from "@/helpers/file.helper";
// hooks
import { useUser } from "@/hooks/store";
@@ -32,7 +33,7 @@ export const ProfileActivityListPage: React.FC<Props> = observer((props) => {
const { cursor, perPage, updateResultsCount, updateTotalPages, updateEmptyState } = props;
// store hooks
const { data: currentUser } = useUser();
const { t } = useTranslation();
const { data: userProfileActivity } = useSWR(
USER_ACTIVITY({
cursor,
@@ -60,6 +61,7 @@ export const ProfileActivityListPage: React.FC<Props> = observer((props) => {
{userProfileActivity ? (
<ul role="list">
{userProfileActivity.results.map((activityItem: any) => {
const { i18n_time_ago, time } = calculateI18nTimeAgo(activityItem.created_at);
if (activityItem.field === "comment")
return (
<div key={activityItem.id} className="mt-2">
@@ -92,9 +94,7 @@ export const ProfileActivityListPage: React.FC<Props> = observer((props) => {
? activityItem.actor_detail.first_name + " Bot"
: activityItem.actor_detail.display_name}
</div>
<p className="mt-0.5 text-xs text-custom-text-200">
Commented {calculateTimeAgo(activityItem.created_at)}
</p>
<p className="mt-0.5 text-xs text-custom-text-200">Commented {t(i18n_time_ago, { time })}</p>
</div>
<div className="issue-comments-section p-0">
<RichTextReadOnlyEditor
@@ -177,9 +177,7 @@ export const ProfileActivityListPage: React.FC<Props> = observer((props) => {
)}{" "}
<div className="inline gap-1">
{message}{" "}
<span className="flex-shrink-0 whitespace-nowrap">
{calculateTimeAgo(activityItem.created_at)}
</span>
<span className="flex-shrink-0 whitespace-nowrap">{t(i18n_time_ago, { time })}</span>
</div>
</div>
</div>
@@ -4,6 +4,7 @@ import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import useSWR from "swr";
// ui
import { useTranslation } from "@plane/i18n";
import { Loader, Card } from "@plane/ui";
// components
import { ActivityMessage, IssueLink } from "@/components/core";
@@ -11,7 +12,7 @@ import { ProfileEmptyState } from "@/components/ui";
// constants
import { USER_PROFILE_ACTIVITY } from "@/constants/fetch-keys";
// helpers
import { calculateTimeAgo } from "@/helpers/date-time.helper";
import { calculateI18nTimeAgo } from "@/helpers/date-time.helper";
import { getFileURL } from "@/helpers/file.helper";
// hooks
import { useUser } from "@/hooks/store";
@@ -25,6 +26,7 @@ const userService = new UserService();
export const ProfileActivity = observer(() => {
const { workspaceSlug, userId } = useParams();
// store hooks
const { t } = useTranslation();
const { data: currentUser } = useUser();
const { data: userProfileActivity } = useSWR(
@@ -44,40 +46,45 @@ export const ProfileActivity = observer(() => {
{userProfileActivity ? (
userProfileActivity.results.length > 0 ? (
<div className="space-y-5">
{userProfileActivity.results.map((activity) => (
<div key={activity.id} className="flex gap-3">
<div className="flex-shrink-0 grid place-items-center overflow-hidden rounded h-6 w-6">
{activity.actor_detail?.avatar_url && activity.actor_detail?.avatar_url !== "" ? (
<img
src={getFileURL(activity.actor_detail?.avatar_url)}
alt={activity.actor_detail?.display_name}
className="rounded"
/>
) : (
<div className="grid h-6 w-6 place-items-center rounded border-2 bg-gray-700 text-xs text-white">
{activity.actor_detail?.display_name?.charAt(0)}
{userProfileActivity.results.map((activity) => {
{
const { i18n_time_ago, time } = calculateI18nTimeAgo(activity.created_at);
return (
<div key={activity.id} className="flex gap-3">
<div className="flex-shrink-0 grid place-items-center overflow-hidden rounded h-6 w-6">
{activity.actor_detail?.avatar_url && activity.actor_detail?.avatar_url !== "" ? (
<img
src={getFileURL(activity.actor_detail?.avatar_url)}
alt={activity.actor_detail?.display_name}
className="rounded"
/>
) : (
<div className="grid h-6 w-6 place-items-center rounded border-2 bg-gray-700 text-xs text-white">
{activity.actor_detail?.display_name?.charAt(0)}
</div>
)}
</div>
)}
</div>
<div className="-mt-1 w-4/5 break-words">
<p className="inline text-sm text-custom-text-200">
<span className="font-medium text-custom-text-100">
{currentUser?.id === activity.actor_detail?.id ? "You" : activity.actor_detail?.display_name}{" "}
</span>
{activity.field ? (
<ActivityMessage activity={activity} showIssue />
) : (
<span>
created <IssueLink activity={activity} />
</span>
)}
</p>
<p className="text-xs text-custom-text-200 whitespace-nowrap ">
{calculateTimeAgo(activity.created_at)}
</p>
</div>
</div>
))}
<div className="-mt-1 w-4/5 break-words">
<p className="inline text-sm text-custom-text-200">
<span className="font-medium text-custom-text-100">
{currentUser?.id === activity.actor_detail?.id
? "You"
: activity.actor_detail?.display_name}{" "}
</span>
{activity.field ? (
<ActivityMessage activity={activity} showIssue />
) : (
<span>
created <IssueLink activity={activity} />
</span>
)}
</p>
<p className="text-xs text-custom-text-200 whitespace-nowrap ">{t(i18n_time_ago, { time })}</p>
</div>
</div>
);
}
})}
</div>
) : (
<ProfileEmptyState
@@ -7,7 +7,7 @@ import { AlertModalCore, TOAST_TYPE, setToast } from "@plane/ui";
interface IStickyDelete {
isOpen: boolean;
handleSubmit: () => void;
handleSubmit: () => Promise<void>;
handleClose: () => void;
}
@@ -20,11 +20,11 @@ export const StickyDeleteModal: React.FC<IStickyDelete> = observer((props) => {
try {
setLoader(true);
await handleSubmit();
} catch (error) {
} catch {
setToast({
type: TOAST_TYPE.ERROR,
title: "Warning!",
message: "Something went wrong please try again later.",
message: "Something went wrong. Please try again later.",
});
} finally {
setLoader(false);
@@ -38,7 +38,7 @@ export const StickyDeleteModal: React.FC<IStickyDelete> = observer((props) => {
isSubmitting={loader}
isOpen={isOpen}
title="Delete sticky"
content={<>Are you sure you want to delete the sticky? </>}
content="Are you sure you want to delete the sticky?"
/>
);
});
@@ -7,18 +7,17 @@ import type { ElementDragPayload } from "@atlaskit/pragmatic-drag-and-drop/eleme
import { observer } from "mobx-react";
import { usePathname } from "next/navigation";
import Masonry from "react-masonry-component";
// plane ui
import { Loader } from "@plane/ui";
// components
import { EmptyState } from "@/components/empty-state";
import { StickiesEmptyState } from "@/components/home/widgets/empty-states/stickies";
// constants
import { EmptyStateType } from "@/constants/empty-state";
// hooks
import { useSticky } from "@/hooks/use-stickies";
import { useStickyOperations } from "../sticky/use-operations";
import { StickiesLoader } from "./stickies-loader";
import { StickyDNDWrapper } from "./sticky-dnd-wrapper";
import { getInstructionFromPayload } from "./sticky.helpers";
import { StickiesEmptyState } from "@/components/home/widgets/empty-states/stickies";
type TStickiesLayout = {
workspaceSlug: string;
@@ -42,6 +41,14 @@ export const StickiesList = observer((props: TProps) => {
const itemWidth = `${100 / columnCount}%`;
const totalRows = Math.ceil(workspaceStickyIds.length / columnCount);
const isStickiesPage = pathname?.includes("stickies");
const masonryRef = useRef<any>(null);
const handleLayout = () => {
if (masonryRef.current) {
// Force reflow
masonryRef.current.performLayout();
}
};
// Function to determine if an item is in first or last row
const getRowPositions = (index: number) => {
@@ -72,19 +79,13 @@ export const StickiesList = observer((props: TProps) => {
};
if (loader === "init-loader") {
return (
<div className="min-h-[500px] overflow-scroll pb-2">
<Loader>
<Loader.Item height="300px" width="255px" />
</Loader>
</div>
);
return <StickiesLoader />;
}
if (loader === "loaded" && workspaceStickyIds.length === 0) {
return (
<div className="size-full grid place-items-center">
{isStickiesPage ? (
{isStickiesPage || searchQuery ? (
<EmptyState
type={searchQuery ? EmptyStateType.STICKIES_SEARCH : EmptyStateType.STICKIES}
layout={searchQuery ? "screen-simple" : "screen-detailed"}
@@ -106,7 +107,7 @@ export const StickiesList = observer((props: TProps) => {
return (
<div className="transition-opacity duration-300 ease-in-out">
{/* @ts-expect-error type mismatch here */}
<Masonry elementType="div">
<Masonry elementType="div" ref={masonryRef}>
{workspaceStickyIds.map((stickyId, index) => {
const { isInFirstRow, isInLastRow } = getRowPositions(index);
return (
@@ -119,6 +120,7 @@ export const StickiesList = observer((props: TProps) => {
isLastChild={index === workspaceStickyIds.length - 1}
isInFirstRow={isInFirstRow}
isInLastRow={isInLastRow}
handleLayout={handleLayout}
/>
);
})}
@@ -0,0 +1,45 @@
// plane ui
import { Loader } from "@plane/ui";
export const StickiesLoader = () => (
<div className="overflow-scroll pb-2 grid grid-cols-4 gap-4">
{Array.from({ length: 4 }).map((_, index) => (
<Loader key={index} className="space-y-5 border border-custom-border-200 p-3 rounded">
<div className="space-y-2">
<Loader.Item height="20px" />
<Loader.Item height="15px" width="75%" />
</div>
<div className="space-y-2">
<div className="flex items-center gap-2">
<Loader.Item height="15px" width="15px" className="flex-shrink-0" />
<Loader.Item height="15px" width="100%" />
</div>
<div className="flex items-center gap-2">
<Loader.Item height="15px" width="15px" className="flex-shrink-0" />
<Loader.Item height="15px" width="75%" />
</div>
<div className="flex items-center gap-2">
<Loader.Item height="15px" width="15px" className="flex-shrink-0" />
<Loader.Item height="15px" width="90%" />
</div>
<div className="flex items-center gap-2">
<Loader.Item height="15px" width="15px" className="flex-shrink-0" />
<Loader.Item height="15px" width="60%" />
</div>
<div className="flex items-center gap-2">
<Loader.Item height="15px" width="15px" className="flex-shrink-0" />
<Loader.Item height="15px" width="50%" />
</div>
</div>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<Loader.Item height="25px" width="25px" />
<Loader.Item height="25px" width="25px" />
<Loader.Item height="25px" width="25px" />
</div>
<Loader.Item height="25px" width="25px" className="flex-shrink-0" />
</div>
</Loader>
))}
</div>
);
@@ -10,7 +10,12 @@ import { useSticky } from "@/hooks/use-stickies";
import { ContentOverflowWrapper } from "../../core/content-overflow-HOC";
import { StickiesLayout } from "./stickies-list";
export const StickiesTruncated = observer(() => {
type StickiesTruncatedProps = {
handleClose?: () => void;
};
export const StickiesTruncated = observer((props: StickiesTruncatedProps) => {
const { handleClose = () => {} } = props;
// navigation
const { workspaceSlug } = useParams();
// store hooks
@@ -33,6 +38,7 @@ export const StickiesTruncated = observer(() => {
className={cn(
"gap-1 w-full text-custom-primary-100 text-sm font-medium transition-opacity duration-300 bg-custom-background-90/20"
)}
onClick={handleClose}
>
Show all
</Link>
@@ -15,123 +15,121 @@ import { attachInstruction } from "@atlaskit/pragmatic-drag-and-drop-hitbox/tree
import { observer } from "mobx-react";
import { usePathname } from "next/navigation";
import { createRoot } from "react-dom/client";
// plane types
import { InstructionType } from "@plane/types";
// plane ui
import { DropIndicator } from "@plane/ui";
import { cn } from "@plane/utils";
// components
import { StickyNote } from "../sticky";
// helpers
import { getInstructionFromPayload } from "./sticky.helpers";
// Draggable Sticky Wrapper Component
export const StickyDNDWrapper = observer(
({
stickyId,
workspaceSlug,
itemWidth,
isLastChild,
isInFirstRow,
isInLastRow,
handleDrop,
}: {
stickyId: string;
workspaceSlug: string;
itemWidth: string;
isLastChild: boolean;
isInFirstRow: boolean;
isInLastRow: boolean;
handleDrop: (self: DropTargetRecord, source: ElementDragPayload, location: DragLocationHistory) => void;
}) => {
const pathName = usePathname();
const [isDragging, setIsDragging] = useState(false);
const [instruction, setInstruction] = useState<InstructionType | undefined>(undefined);
const elementRef = useRef<HTMLDivElement>(null);
type Props = {
stickyId: string;
workspaceSlug: string;
itemWidth: string;
isLastChild: boolean;
isInFirstRow: boolean;
isInLastRow: boolean;
handleDrop: (self: DropTargetRecord, source: ElementDragPayload, location: DragLocationHistory) => void;
handleLayout: () => void;
};
useEffect(() => {
const element = elementRef.current;
if (!element) return;
export const StickyDNDWrapper = observer((props: Props) => {
const { stickyId, workspaceSlug, itemWidth, isLastChild, isInFirstRow, isInLastRow, handleDrop, handleLayout } =
props;
// states
const [isDragging, setIsDragging] = useState(false);
const [instruction, setInstruction] = useState<InstructionType | undefined>(undefined);
// refs
const elementRef = useRef<HTMLDivElement>(null);
// navigation
const pathname = usePathname();
const initialData = { id: stickyId, type: "sticky" };
useEffect(() => {
const element = elementRef.current;
if (!element) return;
if (pathName.includes("stickies"))
return combine(
draggable({
element,
dragHandle: element,
getInitialData: () => initialData,
onDragStart: () => {
setIsDragging(true);
},
onDrop: () => {
setIsDragging(false);
},
onGenerateDragPreview: ({ nativeSetDragImage }) => {
setCustomNativeDragPreview({
getOffset: pointerOutsideOfPreview({ x: "-200px", y: "0px" }),
render: ({ container }) => {
const root = createRoot(container);
root.render(
<div className="scale-50">
<div className="-m-2 max-h-[150px]">
<StickyNote
className={"w-[290px]"}
workspaceSlug={workspaceSlug.toString()}
stickyId={stickyId}
showToolbar={false}
/>
</div>
const initialData = { id: stickyId, type: "sticky" };
if (pathname.includes("stickies"))
return combine(
draggable({
element,
dragHandle: element,
getInitialData: () => initialData,
onDragStart: () => {
setIsDragging(true);
},
onDrop: () => {
setIsDragging(false);
},
onGenerateDragPreview: ({ nativeSetDragImage }) => {
setCustomNativeDragPreview({
getOffset: pointerOutsideOfPreview({ x: "-200px", y: "0px" }),
render: ({ container }) => {
const root = createRoot(container);
root.render(
<div className="scale-50">
<div className="-m-2 max-h-[150px]">
<StickyNote
className={"w-[290px]"}
workspaceSlug={workspaceSlug.toString()}
stickyId={stickyId}
showToolbar={false}
/>
</div>
);
return () => root.unmount();
},
nativeSetDragImage,
});
},
}),
dropTargetForElements({
element,
canDrop: ({ source }) => source.data?.type === "sticky",
getData: ({ input, element }) => {
const blockedStates: InstructionType[] = ["make-child"];
if (!isLastChild) {
blockedStates.push("reorder-below");
}
</div>
);
return () => root.unmount();
},
nativeSetDragImage,
});
},
}),
dropTargetForElements({
element,
canDrop: ({ source }) => source.data?.type === "sticky",
getData: ({ input, element }) => {
const blockedStates: InstructionType[] = ["make-child"];
if (!isLastChild) {
blockedStates.push("reorder-below");
}
return attachInstruction(initialData, {
input,
element,
currentLevel: 1,
indentPerLevel: 0,
mode: isLastChild ? "last-in-group" : "standard",
block: blockedStates,
});
},
onDrag: ({ self, source, location }) => {
const instruction = getInstructionFromPayload(self, source, location);
setInstruction(instruction);
},
onDragLeave: () => {
setInstruction(undefined);
},
onDrop: ({ self, source, location }) => {
setInstruction(undefined);
handleDrop(self, source, location);
},
})
);
}, [stickyId, isDragging]);
return attachInstruction(initialData, {
input,
element,
currentLevel: 1,
indentPerLevel: 0,
mode: isLastChild ? "last-in-group" : "standard",
block: blockedStates,
});
},
onDrag: ({ self, source, location }) => {
const instruction = getInstructionFromPayload(self, source, location);
setInstruction(instruction);
},
onDragLeave: () => {
setInstruction(undefined);
},
onDrop: ({ self, source, location }) => {
setInstruction(undefined);
handleDrop(self, source, location);
},
})
);
}, [handleDrop, isDragging, isLastChild, pathname, stickyId, workspaceSlug]);
return (
<div className="relative" style={{ width: itemWidth }}>
{!isInFirstRow && <DropIndicator isVisible={instruction === "reorder-above"} />}
<div
ref={elementRef}
className={cn("flex min-h-[300px] box-border p-2", {
"opacity-50": isDragging,
})}
>
<StickyNote key={stickyId || "new"} workspaceSlug={workspaceSlug} stickyId={stickyId} />
</div>
{!isInLastRow && <DropIndicator isVisible={instruction === "reorder-below"} />}
</div>
);
}
);
return (
<div className="flex min-h-[300px] box-border p-2 flex-col" style={{ width: itemWidth }}>
{!isInFirstRow && <DropIndicator isVisible={instruction === "reorder-above"} />}
<StickyNote
key={stickyId || "new"}
workspaceSlug={workspaceSlug}
stickyId={stickyId}
handleLayout={handleLayout}
/>
{!isInLastRow && <DropIndicator isVisible={instruction === "reorder-below"} />}
</div>
);
});
@@ -1,8 +1,11 @@
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { Plus, X } from "lucide-react";
// plane ui
import { RecentStickyIcon } from "@plane/ui";
// hooks
import { useSticky } from "@/hooks/use-stickies";
// components
import { StickiesTruncated } from "../layout/stickies-truncated";
import { useStickyOperations } from "../sticky/use-operations";
import { StickySearch } from "./search";
@@ -13,8 +16,11 @@ type TProps = {
export const Stickies = observer((props: TProps) => {
const { handleClose } = props;
// navigation
const { workspaceSlug } = useParams();
// store hooks
const { creatingSticky, toggleShowNewSticky } = useSticky();
// sticky operations
const { stickyOperations } = useStickyOperations({ workspaceSlug: workspaceSlug?.toString() });
return (
@@ -22,9 +28,9 @@ export const Stickies = observer((props: TProps) => {
{/* header */}
<div className="flex items-center justify-between mb-6">
{/* Title */}
<div className="text-custom-text-100 flex gap-2">
<RecentStickyIcon className="size-5 rotate-90" />
<p className="text-lg font-medium">Your stickies</p>
<div className="text-custom-text-200 flex items-center gap-2">
<RecentStickyIcon className="size-5 rotate-90 flex-shrink-0" />
<p className="text-xl font-medium">Your stickies</p>
</div>
{/* actions */}
<div className="flex gap-2">
@@ -50,6 +56,7 @@ export const Stickies = observer((props: TProps) => {
</button>
{handleClose && (
<button
type="button"
onClick={handleClose}
className="flex-shrink-0 grid place-items-center text-custom-text-300 hover:text-custom-text-100 hover:bg-custom-background-80 rounded p-1 transition-colors my-auto"
>
@@ -60,7 +67,7 @@ export const Stickies = observer((props: TProps) => {
</div>
{/* content */}
<div className="mb-4 max-h-[625px] overflow-scroll">
<StickiesTruncated />
<StickiesTruncated handleClose={handleClose} />
</div>
</div>
);
@@ -1,19 +1,20 @@
import { useCallback, useEffect, useRef } from "react";
import { DebouncedFunc } from "lodash";
import { Controller, useForm } from "react-hook-form";
// plane editor
import { EditorRefApi } from "@plane/editor";
// plane types
import { TSticky } from "@plane/types";
// plane utils
import { isCommentEmpty } from "@plane/utils";
// hooks
import { useWorkspace } from "@/hooks/store";
// components
import { StickyEditor } from "../../editor";
type TProps = {
stickyData: TSticky | undefined;
stickyData: Partial<TSticky> | undefined;
workspaceSlug: string;
handleUpdate: DebouncedFunc<(payload: Partial<TSticky>) => Promise<void>>;
handleUpdate: (payload: Partial<TSticky>) => void;
stickyId: string | undefined;
showToolbar?: boolean;
handleChange: (data: Partial<TSticky>) => Promise<void>;
@@ -68,7 +69,11 @@ export const StickyInput = (props: TProps) => {
onChange(description_html);
handleSubmit(handleFormSubmit)();
}}
placeholder="Click to type here"
placeholder={(_, value) => {
const isContentEmpty = isCommentEmpty(value);
if (!isContentEmpty) return "";
return "Click to type here";
}}
containerClassName="px-0 text-base min-h-[250px] w-full"
uploadFile={async () => ""}
showToolbar={showToolbar}
+13 -10
View File
@@ -1,7 +1,6 @@
import { useCallback, useState } from "react";
import { debounce } from "lodash";
import { observer } from "mobx-react";
import { usePathname } from "next/navigation";
import { Minimize2 } from "lucide-react";
// plane types
import { TSticky } from "@plane/types";
@@ -13,8 +12,7 @@ import { useSticky } from "@/hooks/use-stickies";
import { STICKY_COLORS_LIST } from "../../editor/sticky-editor/color-palette";
import { StickyDeleteModal } from "../delete-modal";
import { StickyInput } from "./inputs";
import { StickyItemDragHandle } from "./sticky-item-drag-handle";
import { useStickyOperations } from "./use-operations";
import { getRandomStickyColor, useStickyOperations } from "./use-operations";
type TProps = {
onClose?: () => void;
@@ -22,11 +20,12 @@ type TProps = {
className?: string;
stickyId: string | undefined;
showToolbar?: boolean;
handleLayout?: () => void;
};
export const StickyNote = observer((props: TProps) => {
const { onClose, workspaceSlug, className = "", stickyId, showToolbar } = props;
const { onClose, workspaceSlug, className = "", stickyId, showToolbar, handleLayout } = props;
// navigation
const pathName = usePathname();
// const pathName = usePathname();
// states
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
// store hooks
@@ -34,8 +33,8 @@ export const StickyNote = observer((props: TProps) => {
// sticky operations
const { stickyOperations } = useStickyOperations({ workspaceSlug });
// derived values
const stickyData = stickyId ? stickies[stickyId] : undefined;
const isStickiesPage = pathName?.includes("stickies");
const stickyData: Partial<TSticky> = stickyId ? stickies[stickyId] : { background_color: getRandomStickyColor() };
// const isStickiesPage = pathName?.includes("stickies");
const backgroundColor =
STICKY_COLORS_LIST.find((c) => c.key === stickyData?.background_color)?.backgroundColor ||
STICKY_COLORS_LIST[0].backgroundColor;
@@ -46,6 +45,7 @@ export const StickyNote = observer((props: TProps) => {
await stickyOperations.update(stickyId, payload);
} else {
await stickyOperations.create({
...stickyData,
...payload,
});
}
@@ -74,12 +74,12 @@ export const StickyNote = observer((props: TProps) => {
handleClose={() => setIsDeleteModalOpen(false)}
/>
<div
className={cn("w-full flex flex-col h-fit rounded p-4 group/sticky", className)}
className={cn("w-full flex flex-col h-fit rounded p-4 group/sticky max-h-[650px] overflow-y-scroll", className)}
style={{
backgroundColor,
}}
>
{isStickiesPage && <StickyItemDragHandle isDragging={false} />}{" "}
{/* {isStickiesPage && <StickyItemDragHandle isDragging={false} />}{" "} */}
{onClose && (
<button type="button" className="flex w-full" onClick={onClose}>
<Minimize2 className="size-4 m-auto mr-0" />
@@ -89,7 +89,10 @@ export const StickyNote = observer((props: TProps) => {
<StickyInput
stickyData={stickyData}
workspaceSlug={workspaceSlug}
handleUpdate={debouncedFormSave}
handleUpdate={(payload) => {
handleLayout?.();
debouncedFormSave(payload);
}}
stickyId={stickyId}
handleDelete={() => setIsDeleteModalOpen(true)}
handleChange={handleChange}
@@ -3,12 +3,13 @@
import { FC, useState } from "react";
import { observer } from "mobx-react";
import { Clock } from "lucide-react";
import { useTranslation } from "@plane/i18n";
import { Avatar, Row } from "@plane/ui";
// components
import { NotificationOption } from "@/components/workspace-notifications";
// helpers
import { cn } from "@/helpers/common.helper";
import { calculateTimeAgo, renderFormattedDate, renderFormattedTime } from "@/helpers/date-time.helper";
import { calculateI18nTimeAgo, renderFormattedDate, renderFormattedTime } from "@/helpers/date-time.helper";
import { getFileURL } from "@/helpers/file.helper";
import { sanitizeCommentForNotification } from "@/helpers/notification.helper";
import { replaceUnderscoreIfSnakeCase, stripAndTruncateHTML } from "@/helpers/string.helper";
@@ -26,6 +27,7 @@ export const NotificationItem: FC<TNotificationItem> = observer((props) => {
const { currentSelectedNotificationId, setCurrentSelectedNotificationId } = useWorkspaceNotifications();
const { asJson: notification, markNotificationAsRead } = useNotification(notificationId);
const { getIsIssuePeeked, setPeekIssue } = useIssueDetail();
const { t } = useTranslation();
// states
const [isSnoozeStateModalOpen, setIsSnoozeStateModalOpen] = useState(false);
const [customSnoozeModal, setCustomSnoozeModal] = useState(false);
@@ -56,6 +58,9 @@ export const NotificationItem: FC<TNotificationItem> = observer((props) => {
}
}
};
const { i18n_time_ago, time } = notification?.created_at
? calculateI18nTimeAgo(notification?.created_at)
: { i18n_time_ago: "", time: "" };
if (!workspaceSlug || !notificationId || !notification?.id || !notificationField) return <></>;
@@ -165,7 +170,7 @@ export const NotificationItem: FC<TNotificationItem> = observer((props) => {
</p>
) : (
<p className="mt-auto flex-shrink-0 text-custom-text-300">
{notification.created_at && calculateTimeAgo(notification.created_at)}
{notification.created_at && t(i18n_time_ago, { time })}
</p>
)}
</div>
+1 -1
View File
@@ -1,5 +1,5 @@
import { EUserPermissions } from "ee/constants/user-permissions";
import { Plus, Shapes } from "lucide-react";
import { EUserPermissions } from "ee/constants/user-permissions";
export interface EmptyStateDetails {
key: EmptyStateType;
+3 -2
View File
@@ -21,12 +21,13 @@ export class StickyService extends APIService {
async getStickies(
workspaceSlug: string,
cursor: string,
query?: string
query?: string,
per_page?: number
): Promise<{ results: TSticky[]; total_pages: number }> {
return this.get(`/api/workspaces/${workspaceSlug}/stickies/`, {
params: {
cursor,
per_page: STICKIES_PER_PAGE,
per_page: per_page || STICKIES_PER_PAGE,
query,
},
})
+1 -1
View File
@@ -96,7 +96,7 @@ export class StickyStore implements IStickyStore {
};
fetchRecentSticky = async (workspaceSlug: string) => {
const response = await this.stickyService.getStickies(workspaceSlug, "1:0:0");
const response = await this.stickyService.getStickies(workspaceSlug, "1:0:0", undefined, 1);
runInAction(() => {
this.recentStickyId = response.results[0]?.id;
this.stickies[response.results[0]?.id] = response.results[0];
+200
View File
@@ -156,12 +156,14 @@ export const findHowManyDaysLeft = (
};
// Time Difference Helpers
/**
* @returns {string} formatted date in the form of amount of time passed since the event happened
* @description Returns time passed since the event happened
* @param {string | Date} time
* @example calculateTimeAgo("2023-01-01") // 1 year ago
*/
export const calculateTimeAgo = (time: string | number | Date | null): string => {
if (!time) return "";
// Parse the time to check if it is valid
@@ -173,6 +175,143 @@ export const calculateTimeAgo = (time: string | number | Date | null): string =>
return distance;
};
export const calculateI18nTimeAgo = (time: string | number | Date | null): TimeDistanceResult => {
if (!time) return { i18n_time_ago: "", time: null };
const parsedTime = typeof time === "string" || typeof time === "number" ? parseISO(String(time)) : time;
if (!parsedTime) return { i18n_time_ago: "", time: null };
const diffInSeconds = (Date.now() - parsedTime.getTime()) / 1000;
const distance = getTimeDistance(Math.abs(diffInSeconds));
return distance;
};
interface TimeDistanceResult {
i18n_time_ago: string;
time: number | null;
}
/**
* Calculates relative time distance and returns appropriate translation keys
*/
function getTimeDistance(diffInSeconds: number): TimeDistanceResult {
const absSeconds = Math.abs(diffInSeconds);
const direction = diffInSeconds > 0 ? "past" : "future";
// Convert to larger time units
const minutes = Math.round(absSeconds / 60);
const hours = Math.round(absSeconds / 3600);
const days = Math.round(absSeconds / 86400);
const months = Math.round(absSeconds / 2592000);
const years = Math.round(absSeconds / 31536000);
// Seconds-based intervals (<1 minute)
if (absSeconds < 30)
return {
i18n_time_ago: `date_time.standard.less_than_x_seconds.${direction}`,
time: 30,
};
if (absSeconds < 5)
return {
i18n_time_ago: `date_time.include_seconds.less_than_5_seconds.${direction}`,
time: null,
};
if (absSeconds < 10)
return {
i18n_time_ago: `date_time.include_seconds.less_than_10_seconds.${direction}`,
time: null,
};
if (absSeconds < 20)
return {
i18n_time_ago: `date_time.include_seconds.less_than_20_seconds.${direction}`,
time: null,
};
if (absSeconds < 40)
return {
i18n_time_ago: `date_time.include_seconds.half_a_minute.${direction}`,
time: null,
};
if (absSeconds < 60)
return {
i18n_time_ago: `date_time.standard.less_than_x_minutes.${direction}`,
time: null,
};
// Minutes to Years
if (absSeconds < 90)
return {
i18n_time_ago: `date_time.standard.x_minutes.${direction}`,
time: 1,
};
if (absSeconds < 2670)
return {
i18n_time_ago: `date_time.standard.x_minutes.${direction}`,
time: minutes,
};
if (absSeconds < 5370)
return {
i18n_time_ago: `date_time.standard.about_x_hours.${direction}`,
time: 1,
};
if (absSeconds < 86370)
return {
i18n_time_ago: `date_time.standard.about_x_hours.${direction}`,
time: hours,
};
if (absSeconds < 151200)
return {
i18n_time_ago: `date_time.standard.x_days.${direction}`,
time: 1,
};
if (absSeconds < 2592000)
return {
i18n_time_ago: `date_time.standard.x_days.${direction}`,
time: days,
};
if (absSeconds < 3888000)
return {
i18n_time_ago: `date_time.standard.about_x_months.${direction}`,
time: 1,
};
if (absSeconds < 31536000)
return {
i18n_time_ago: `date_time.standard.about_x_months.${direction}`,
time: months,
};
if (absSeconds < 47304000)
return {
i18n_time_ago: `date_time.standard.about_x_years.${direction}`,
time: 1,
};
if (absSeconds < 56160000)
return {
i18n_time_ago: `date_time.standard.over_x_years.${direction}`,
time: 1,
};
if (absSeconds < 63072000)
return {
i18n_time_ago: `date_time.standard.almost_x_years.${direction}`,
time: 2,
};
// Multiple years
if (months % 12 < 3)
return {
i18n_time_ago: `date_time.standard.about_x_years.${direction}`,
time: years,
};
if (months % 12 < 9)
return {
i18n_time_ago: `date_time.standard.over_x_years.${direction}`,
time: years,
};
return {
i18n_time_ago: `date_time.standard.almost_x_years.${direction}`,
time: years + 1,
};
}
export function calculateTimeAgoShort(date: string | number | Date | null): string {
if (!date) {
return "";
@@ -210,6 +349,67 @@ export function calculateTimeAgoShort(date: string | number | Date | null): stri
return `${Math.floor(diffInYears)}y`;
}
export function calculateI18nTimeAgoShort(date: string | number | Date | null): {
i18n_key: string;
time: number;
} {
if (!date) {
return {
i18n_key: "",
time: 0,
};
}
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 {
i18n_key: "date_time.units_short.s",
time: Math.floor(diffInSeconds),
};
}
const diffInMinutes = diffInSeconds / 60;
if (diffInMinutes < 60) {
return {
i18n_key: "date_time.units_short.m",
time: Math.floor(diffInMinutes),
};
}
const diffInHours = diffInMinutes / 60;
if (diffInHours < 24) {
return {
i18n_key: "date_time.units_short.h",
time: Math.floor(diffInHours),
};
}
const diffInDays = diffInHours / 24;
if (diffInDays < 30) {
return {
i18n_key: "date_time.units_short.d",
time: Math.floor(diffInDays),
};
}
const diffInMonths = diffInDays / 30;
if (diffInMonths < 12) {
return {
i18n_key: "date_time.units_short.mo",
time: Math.floor(diffInMonths),
};
}
const diffInYears = diffInMonths / 12;
return {
i18n_key: "date_time.units_short.y",
time: Math.floor(diffInYears),
};
}
// Date Validation Helpers
/**
* @returns {string} boolean value depending on whether the date is greater than today