Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97bdad00f8 | ||
|
|
d7fff8e200 | ||
|
|
b3b285b1e5 | ||
|
|
11debee402 | ||
|
|
1608e4f122 | ||
|
|
edeeee1227 | ||
|
|
9ff238816b | ||
|
|
6bd5caf008 | ||
|
|
66cfb3d8f2 | ||
|
|
c021aff58f | ||
|
|
683be55883 | ||
|
|
970ce8cf26 | ||
|
|
cbbe1a4e4d | ||
|
|
6a74677cc9 | ||
|
|
f6ea4f931d | ||
|
|
950fcfdb40 | ||
|
|
053c895120 | ||
|
|
245167e8aa | ||
|
|
461e099bbc | ||
|
|
45e25ce18b | ||
|
|
4d88dbaf49 | ||
|
+4 |
e61ff879c4 | ||
|
|
adeb7d977d |
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "admin",
|
||||
"description": "Admin UI for Plane",
|
||||
"version": "0.26.0",
|
||||
"version": "0.26.1",
|
||||
"license": "AGPL-3.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "plane-api",
|
||||
"version": "0.26.0",
|
||||
"version": "0.26.1",
|
||||
"license": "AGPL-3.0",
|
||||
"private": true,
|
||||
"description": "API server powering Plane's backend"
|
||||
|
||||
@@ -58,7 +58,7 @@ from plane.bgtasks.storage_metadata_task import get_asset_object_metadata
|
||||
from .base import BaseAPIView
|
||||
from plane.utils.host import base_host
|
||||
from plane.bgtasks.webhook_task import model_activity
|
||||
|
||||
from plane.bgtasks.work_item_link_task import crawl_work_item_link_title
|
||||
|
||||
class WorkspaceIssueAPIEndpoint(BaseAPIView):
|
||||
"""
|
||||
@@ -692,6 +692,9 @@ class IssueLinkAPIEndpoint(BaseAPIView):
|
||||
serializer = IssueLinkSerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
serializer.save(project_id=project_id, issue_id=issue_id)
|
||||
crawl_work_item_link_title.delay(
|
||||
serializer.data.get("id"), serializer.data.get("url")
|
||||
)
|
||||
|
||||
link = IssueLink.objects.get(pk=serializer.data["id"])
|
||||
link.created_by_id = request.data.get("created_by", request.user.id)
|
||||
@@ -719,6 +722,9 @@ class IssueLinkAPIEndpoint(BaseAPIView):
|
||||
serializer = IssueLinkSerializer(issue_link, data=request.data, partial=True)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
crawl_work_item_link_title.delay(
|
||||
serializer.data.get("id"), serializer.data.get("url")
|
||||
)
|
||||
issue_activity.delay(
|
||||
type="link.activity.updated",
|
||||
requested_data=requested_data,
|
||||
|
||||
@@ -45,7 +45,7 @@ class IssueLinkViewSet(BaseViewSet):
|
||||
serializer = IssueLinkSerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
serializer.save(project_id=project_id, issue_id=issue_id)
|
||||
crawl_work_item_link_title(
|
||||
crawl_work_item_link_title.delay(
|
||||
serializer.data.get("id"), serializer.data.get("url")
|
||||
)
|
||||
issue_activity.delay(
|
||||
@@ -78,7 +78,7 @@ class IssueLinkViewSet(BaseViewSet):
|
||||
serializer = IssueLinkSerializer(issue_link, data=request.data, partial=True)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
crawl_work_item_link_title(
|
||||
crawl_work_item_link_title.delay(
|
||||
serializer.data.get("id"), serializer.data.get("url")
|
||||
)
|
||||
|
||||
|
||||
@@ -284,6 +284,7 @@ def send_email_notification(
|
||||
"project": str(issue.project.name),
|
||||
"user_preference": f"{base_api}/profile/preferences/email",
|
||||
"comments": comments,
|
||||
"entity_type": "issue",
|
||||
}
|
||||
html_content = render_to_string(
|
||||
"emails/notifications/issue-updates.html", context
|
||||
|
||||
@@ -19,17 +19,6 @@ logger = logging.getLogger("plane.worker")
|
||||
|
||||
DEFAULT_FAVICON = "PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGNsYXNzPSJsdWNpZGUgbHVjaWRlLWxpbmstaWNvbiBsdWNpZGUtbGluayI+PHBhdGggZD0iTTEwIDEzYTUgNSAwIDAgMCA3LjU0LjU0bDMtM2E1IDUgMCAwIDAtNy4wNy03LjA3bC0xLjcyIDEuNzEiLz48cGF0aCBkPSJNMTQgMTFhNSA1IDAgMCAwLTcuNTQtLjU0bC0zIDNhNSA1IDAgMCAwIDcuMDcgNy4wN2wxLjcxLTEuNzEiLz48L3N2Zz4=" # noqa: E501
|
||||
|
||||
|
||||
@shared_task
|
||||
def crawl_work_item_link_title(id: str, url: str) -> None:
|
||||
meta_data = crawl_work_item_link_title_and_favicon(url)
|
||||
issue_link = IssueLink.objects.get(id=id)
|
||||
|
||||
issue_link.metadata = meta_data
|
||||
|
||||
issue_link.save()
|
||||
|
||||
|
||||
def crawl_work_item_link_title_and_favicon(url: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Crawls a URL to extract the title and favicon.
|
||||
@@ -57,17 +46,18 @@ def crawl_work_item_link_title_and_favicon(url: str) -> Dict[str, Any]:
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36" # noqa: E501
|
||||
}
|
||||
|
||||
# Fetch the main page
|
||||
response = requests.get(url, headers=headers, timeout=2)
|
||||
soup = None
|
||||
title = None
|
||||
|
||||
response.raise_for_status()
|
||||
try:
|
||||
response = requests.get(url, headers=headers, timeout=1)
|
||||
|
||||
# Parse HTML
|
||||
soup = BeautifulSoup(response.content, "html.parser")
|
||||
soup = BeautifulSoup(response.content, "html.parser")
|
||||
title_tag = soup.find("title")
|
||||
title = title_tag.get_text().strip() if title_tag else None
|
||||
|
||||
# Extract title
|
||||
title_tag = soup.find("title")
|
||||
title = title_tag.get_text().strip() if title_tag else None
|
||||
except requests.RequestException as e:
|
||||
logger.warning(f"Failed to fetch HTML for title: {str(e)}")
|
||||
|
||||
# Fetch and encode favicon
|
||||
favicon_base64 = fetch_and_encode_favicon(headers, soup, url)
|
||||
@@ -82,14 +72,6 @@ def crawl_work_item_link_title_and_favicon(url: str) -> Dict[str, Any]:
|
||||
|
||||
return result
|
||||
|
||||
except requests.RequestException as e:
|
||||
log_exception(e)
|
||||
return {
|
||||
"error": f"Request failed: {str(e)}",
|
||||
"title": None,
|
||||
"favicon": None,
|
||||
"url": url,
|
||||
}
|
||||
except Exception as e:
|
||||
log_exception(e)
|
||||
return {
|
||||
@@ -100,7 +82,7 @@ def crawl_work_item_link_title_and_favicon(url: str) -> Dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def find_favicon_url(soup: BeautifulSoup, base_url: str) -> Optional[str]:
|
||||
def find_favicon_url(soup: Optional[BeautifulSoup], base_url: str) -> Optional[str]:
|
||||
"""
|
||||
Find the favicon URL from HTML soup.
|
||||
|
||||
@@ -111,18 +93,20 @@ def find_favicon_url(soup: BeautifulSoup, base_url: str) -> Optional[str]:
|
||||
Returns:
|
||||
str: Absolute URL to favicon or None
|
||||
"""
|
||||
# Look for various favicon link tags
|
||||
favicon_selectors = [
|
||||
'link[rel="icon"]',
|
||||
'link[rel="shortcut icon"]',
|
||||
'link[rel="apple-touch-icon"]',
|
||||
'link[rel="apple-touch-icon-precomposed"]',
|
||||
]
|
||||
|
||||
for selector in favicon_selectors:
|
||||
favicon_tag = soup.select_one(selector)
|
||||
if favicon_tag and favicon_tag.get("href"):
|
||||
return urljoin(base_url, favicon_tag["href"])
|
||||
if soup is not None:
|
||||
# Look for various favicon link tags
|
||||
favicon_selectors = [
|
||||
'link[rel="icon"]',
|
||||
'link[rel="shortcut icon"]',
|
||||
'link[rel="apple-touch-icon"]',
|
||||
'link[rel="apple-touch-icon-precomposed"]',
|
||||
]
|
||||
|
||||
for selector in favicon_selectors:
|
||||
favicon_tag = soup.select_one(selector)
|
||||
if favicon_tag and favicon_tag.get("href"):
|
||||
return urljoin(base_url, favicon_tag["href"])
|
||||
|
||||
# Fallback to /favicon.ico
|
||||
parsed_url = urlparse(base_url)
|
||||
@@ -131,7 +115,6 @@ def find_favicon_url(soup: BeautifulSoup, base_url: str) -> Optional[str]:
|
||||
# Check if fallback exists
|
||||
try:
|
||||
response = requests.head(fallback_url, timeout=2)
|
||||
response.raise_for_status()
|
||||
if response.status_code == 200:
|
||||
return fallback_url
|
||||
except requests.RequestException as e:
|
||||
@@ -142,8 +125,8 @@ def find_favicon_url(soup: BeautifulSoup, base_url: str) -> Optional[str]:
|
||||
|
||||
|
||||
def fetch_and_encode_favicon(
|
||||
headers: Dict[str, str], soup: BeautifulSoup, url: str
|
||||
) -> Optional[Dict[str, str]]:
|
||||
headers: Dict[str, str], soup: Optional[BeautifulSoup], url: str
|
||||
) -> Dict[str, Optional[str]]:
|
||||
"""
|
||||
Fetch favicon and encode it as base64.
|
||||
|
||||
@@ -162,8 +145,7 @@ def fetch_and_encode_favicon(
|
||||
"favicon_base64": f"data:image/svg+xml;base64,{DEFAULT_FAVICON}",
|
||||
}
|
||||
|
||||
response = requests.get(favicon_url, headers=headers, timeout=2)
|
||||
response.raise_for_status()
|
||||
response = requests.get(favicon_url, headers=headers, timeout=1)
|
||||
|
||||
# Get content type
|
||||
content_type = response.headers.get("content-type", "image/x-icon")
|
||||
@@ -183,3 +165,13 @@ def fetch_and_encode_favicon(
|
||||
"favicon_url": None,
|
||||
"favicon_base64": f"data:image/svg+xml;base64,{DEFAULT_FAVICON}",
|
||||
}
|
||||
|
||||
|
||||
@shared_task
|
||||
def crawl_work_item_link_title(id: str, url: str) -> None:
|
||||
meta_data = crawl_work_item_link_title_and_favicon(url)
|
||||
issue_link = IssueLink.objects.get(id=id)
|
||||
|
||||
issue_link.metadata = meta_data
|
||||
|
||||
issue_link.save()
|
||||
|
||||
@@ -154,24 +154,6 @@ def build_simple_chart_response(
|
||||
]
|
||||
|
||||
|
||||
def order_by_priority(data: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
priority_order = ["urgent", "high", "medium", "low", "none"]
|
||||
ordered_data = []
|
||||
|
||||
# First add the ordered priorities
|
||||
for priority in priority_order:
|
||||
priority_data = next((item for item in data if item["key"] == priority), None)
|
||||
if priority_data:
|
||||
ordered_data.append(priority_data)
|
||||
|
||||
# Then add any remaining values
|
||||
for item in data:
|
||||
if item["key"] not in priority_order:
|
||||
ordered_data.append(item)
|
||||
|
||||
return ordered_data
|
||||
|
||||
|
||||
def build_analytics_chart(
|
||||
queryset: QuerySet[Issue],
|
||||
x_axis: str,
|
||||
@@ -213,16 +195,10 @@ def build_analytics_chart(
|
||||
group_name_field,
|
||||
aggregate_func,
|
||||
)
|
||||
# Apply priority ordering for grouped response
|
||||
if id_field == "priority":
|
||||
response = order_by_priority(response)
|
||||
else:
|
||||
response = build_simple_chart_response(
|
||||
queryset, id_field, name_field, aggregate_func
|
||||
)
|
||||
# Apply priority ordering for simple response
|
||||
if id_field == "priority":
|
||||
response = order_by_priority(response)
|
||||
schema = {}
|
||||
|
||||
return {"data": response, "schema": schema}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# base requirements
|
||||
|
||||
# django
|
||||
Django==4.2.21
|
||||
Django==4.2.22
|
||||
# rest framework
|
||||
djangorestframework==3.15.2
|
||||
# postgres
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Updates on issue</title>
|
||||
<title>Updates on {{entity_type}}</title>
|
||||
<style type="text/css" emogrify="no"> html { font-family: system-ui; } p, h1, h2, h3, h4, ol, ul { margin: 0; } h-full { height: 100%; } a:hover { color: #3358d4 !important; } </style>
|
||||
<style> *[class="gmail-fix"] { display: none !important; } </style>
|
||||
<style type="text/css" emogrify="no"> @media (max-width: 600px) { .gmx-killpill { content: " \03D1"; } } </style>
|
||||
@@ -37,7 +37,7 @@
|
||||
{% else %}
|
||||
<p style="font-size: 1rem;color: #1f2d5c; line-height: 28px"> {{summary}} <span style="font-size: 1rem; font-weight: 700; line-height: 28px"> {% if data|length > 0 %} {{ data.0.actor_detail.first_name}} {{data.0.actor_detail.last_name}} {% else %} {{ comments.0.actor_detail.first_name}} {{comments.0.actor_detail.last_name}} {% endif %} </span>and others. </p>
|
||||
{% endif %} <!-- {% if actors_involved == 1 %} {% if data|length > 0 and comments|length == 0 %} <p style="font-size: 1rem;color: #1f2d5c; line-height: 28px"> <span style="font-size: 1rem; font-weight: 700; line-height: 28px"> {{ data.0.actor_detail.first_name}} {{data.0.actor_detail.last_name }} </span> made {{total_updates}} {% if total_updates > 1 %}updates{% else %}update{% endif %} to the issue. </p> {% elif data|length == 0 and comments|length > 0 %} <p style="font-size: 1rem;color: #1f2d5c; line-height: 28px"> <span style="font-size: 1rem; font-weight: 700; line-height: 28px"> {{ comments.0.actor_detail.first_name}} {{comments.0.actor_detail.last_name }} </span> added {{total_comments}} new {% if total_comments > 1 %}comments{% else %}comment{% endif %}. </p> {% elif data|length > 0 and comments|length > 0 %} <p style="font-size: 1rem;color: #1f2d5c; line-height: 28px"> <span style="font-size: 1rem; font-weight: 700; line-height: 28px"> {{ data.0.actor_detail.first_name}} {{data.0.actor_detail.last_name }} </span> made {{total_updates}} {% if total_updates > 1 %}updates{% else %}update{% endif %} and added {{total_comments}} new {% if total_comments > 1 %}comments{% else %}comment{% endif %} on the issue. </p> {% endif %} {% else %} <p style="font-size: 1rem;color: #1f2d5c; line-height: 28px"> There are {{ total_updates }} new updates and {{total_comments}} new comments on the issue. </p> {% endif %} --> {% for update in data %} {% if update.changes.name %} <!-- Issue title updated -->
|
||||
<p style="font-size: 1rem; line-height: 28px; color: #1f2d5c"> The issue title has been updated to {{ issue.name}} </p>
|
||||
<p style="font-size: 1rem; line-height: 28px; color: #1f2d5c"> The {{entity_type}} title has been updated to {{ issue.name}} </p>
|
||||
{% endif %} <!-- Outer update Box start --> {% if data %}
|
||||
<div style=" background-color: #f7f9ff; border-radius: 8px; border-style: solid; border-width: 1px; border-color: #c1d0ff; padding: 20px; margin-top: 15px; max-width: 100%; " >
|
||||
<!-- Block Heading -->
|
||||
@@ -224,7 +224,7 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
<a href="{{ issue_url }}" style="text-decoration: none;">
|
||||
<div style=" max-width: min-content; white-space: nowrap; background-color: #3e63dd; padding: 10px 15px; border: 1px solid #2f4ba8; border-radius: 4px; margin-top: 15px; cursor: pointer; font-size: 0.8rem; color: white; " > View issue </div>
|
||||
<div style=" max-width: min-content; white-space: nowrap; background-color: #3e63dd; padding: 10px 15px; border: 1px solid #2f4ba8; border-radius: 4px; margin-top: 15px; cursor: pointer; font-size: 0.8rem; color: white; " > View {{entity_type}} </div>
|
||||
</a>
|
||||
</div>
|
||||
<!-- Footer -->
|
||||
@@ -232,7 +232,7 @@
|
||||
<tr>
|
||||
<td>
|
||||
<div style="font-size: 0.8rem; color: #1c2024">
|
||||
This email was sent to <a href="mailto:{{receiver.email}}" style="color: #3a5bc7; font-weight: 500; text-decoration: none" >{{ receiver.email }}.</a > If you'd rather not receive this kind of email, <a href="{{ issue_url }}" style="color: #3a5bc7; text-decoration: none" >you can unsubscribe to the issue</a > or <a href="{{ user_preference }}" style="color: #3a5bc7; text-decoration: none" >manage your email preferences</a >. <!-- Github | LinkedIn | Twitter -->
|
||||
This email was sent to <a href="mailto:{{receiver.email}}" style="color: #3a5bc7; font-weight: 500; text-decoration: none" >{{ receiver.email }}.</a > If you'd rather not receive this kind of email, <a href="{{ issue_url }}" style="color: #3a5bc7; text-decoration: none" >you can unsubscribe to the {{entity_type}}</a > or <a href="{{ user_preference }}" style="color: #3a5bc7; text-decoration: none" >manage your email preferences</a >. <!-- Github | LinkedIn | Twitter -->
|
||||
<div style="margin-top: 60px; float: right"> <a href="https://github.com/makeplane" target="_blank" style="margin-left: 10px; text-decoration: none" > <img src="https://creative-assets.mailinblue.com/editor/social-icons/rounded_colored/github_32px.png" width="25" height="25" border="0" style="display: inline-block" /> </a> <a href="https://www.linkedin.com/company/planepowers/" target="_blank" style="margin-left: 10px; text-decoration: none" > <img src="https://creative-assets.mailinblue.com/editor/social-icons/rounded_colored/linkedin_32px.png" width="25" height="25" border="0" style="display: inline-block" /> </a> <a href="https://twitter.com/planepowers" target="_blank" style="margin-left: 10px; text-decoration: none" > <img src="https://creative-assets.mailinblue.com/editor/social-icons/rounded_colored/twitter_32px.png" width="25" height="25" border="0" style="display: inline-block" /> </a> </div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "live",
|
||||
"version": "0.26.0",
|
||||
"version": "0.26.1",
|
||||
"license": "AGPL-3.0",
|
||||
"description": "A realtime collaborative server powers Plane's rich text editor",
|
||||
"main": "./src/server.ts",
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"name": "plane",
|
||||
"description": "Open-source project management that unlocks customer value",
|
||||
"repository": "https://github.com/makeplane/plane.git",
|
||||
"version": "0.26.0",
|
||||
"version": "0.26.1",
|
||||
"license": "AGPL-3.0",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@plane/constants",
|
||||
"version": "0.26.0",
|
||||
"version": "0.26.1",
|
||||
"private": true,
|
||||
"main": "./src/index.ts",
|
||||
"license": "AGPL-3.0"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@plane/editor",
|
||||
"version": "0.26.0",
|
||||
"version": "0.26.1",
|
||||
"description": "Core Editor that powers Plane",
|
||||
"license": "AGPL-3.0",
|
||||
"private": true,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@plane/eslint-config",
|
||||
"private": true,
|
||||
"version": "0.26.0",
|
||||
"version": "0.26.1",
|
||||
"license": "AGPL-3.0",
|
||||
"files": [
|
||||
"library.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@plane/hooks",
|
||||
"version": "0.26.0",
|
||||
"version": "0.26.1",
|
||||
"license": "AGPL-3.0",
|
||||
"description": "React hooks that are shared across multiple apps internally",
|
||||
"private": true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@plane/i18n",
|
||||
"version": "0.26.0",
|
||||
"version": "0.26.1",
|
||||
"license": "AGPL-3.0",
|
||||
"description": "I18n shared across multiple apps internally",
|
||||
"private": true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@plane/logger",
|
||||
"version": "0.26.0",
|
||||
"version": "0.26.1",
|
||||
"license": "AGPL-3.0",
|
||||
"description": "Logger shared across multiple apps internally",
|
||||
"private": true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@plane/propel",
|
||||
"version": "0.26.0",
|
||||
"version": "0.26.1",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@plane/services",
|
||||
"version": "0.26.0",
|
||||
"version": "0.26.1",
|
||||
"license": "AGPL-3.0",
|
||||
"private": true,
|
||||
"main": "./src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@plane/shared-state",
|
||||
"version": "0.26.0",
|
||||
"version": "0.26.1",
|
||||
"license": "AGPL-3.0",
|
||||
"description": "Shared state shared across multiple apps internally",
|
||||
"private": true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@plane/tailwind-config",
|
||||
"version": "0.26.0",
|
||||
"version": "0.26.1",
|
||||
"license": "AGPL-3.0",
|
||||
"description": "common tailwind configuration across monorepo",
|
||||
"main": "tailwind.config.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@plane/types",
|
||||
"version": "0.26.0",
|
||||
"version": "0.26.1",
|
||||
"license": "AGPL-3.0",
|
||||
"private": true,
|
||||
"types": "./src/index.d.ts",
|
||||
|
||||
+2
-1
@@ -85,15 +85,16 @@ export interface IProjectMemberLite {
|
||||
export type TProjectMembership = {
|
||||
member: string;
|
||||
role: TUserPermissions | EUserProjectRoles;
|
||||
created_at: string;
|
||||
} & (
|
||||
| {
|
||||
id: string;
|
||||
original_role: EUserProjectRoles;
|
||||
created_at: string;
|
||||
}
|
||||
| {
|
||||
id: null;
|
||||
original_role: null;
|
||||
created_at: null;
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@plane/typescript-config",
|
||||
"version": "0.26.0",
|
||||
"version": "0.26.1",
|
||||
"license": "AGPL-3.0",
|
||||
"private": true,
|
||||
"files": [
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "@plane/ui",
|
||||
"description": "UI components shared across multiple apps internally",
|
||||
"private": true,
|
||||
"version": "0.26.0",
|
||||
"version": "0.26.1",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.mjs",
|
||||
"types": "./dist/index.d.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@plane/utils",
|
||||
"version": "0.26.0",
|
||||
"version": "0.26.1",
|
||||
"description": "Helper functions shared across multiple apps internally",
|
||||
"license": "AGPL-3.0",
|
||||
"private": true,
|
||||
|
||||
@@ -333,3 +333,112 @@ export const generateDateArray = (startDate: string | Date, endDate: string | Da
|
||||
}
|
||||
return dateArray;
|
||||
};
|
||||
|
||||
/**
|
||||
* @returns {string | null} formatted date in the format of yyyy-mm-dd to be used in payload
|
||||
* @description Returns date in the formatted format to be used in payload
|
||||
* @param {Date | string} date
|
||||
* @example renderFormattedPayloadDate("Jan 01, 20224") // "2024-01-01"
|
||||
*/
|
||||
export const renderFormattedPayloadDate = (date: Date | string | undefined | null): string | undefined => {
|
||||
// Parse the date to check if it is valid
|
||||
const parsedDate = getDate(date);
|
||||
// return if undefined
|
||||
if (!parsedDate) return;
|
||||
// Check if the parsed date is valid before formatting
|
||||
if (!isValid(parsedDate)) return; // Return null for invalid dates
|
||||
// Format the date in payload format (yyyy-mm-dd)
|
||||
const formattedDate = format(parsedDate, "yyyy-MM-dd");
|
||||
return formattedDate;
|
||||
};
|
||||
|
||||
/**
|
||||
* @returns {string} safely formatted date or fallback text
|
||||
* @description Safely formats a date using renderFormattedPayloadDate and date-fns format, with fallback for invalid dates
|
||||
* @param {Date | string | undefined | null} date
|
||||
* @param {string} formatToken (optional) // default "MMM dd, yyyy"
|
||||
* @param {string} fallback (optional) // default "Invalid date"
|
||||
* @example renderSafeFormattedDate("2024-01-01") // "Jan 01, 2024"
|
||||
* @example renderSafeFormattedDate(null) // "Invalid date"
|
||||
* @example renderSafeFormattedDate("2024-01-01", "MM/dd/yyyy", "N/A") // "01/01/2024"
|
||||
*/
|
||||
export const renderSafeFormattedDate = (
|
||||
date: Date | string | undefined | null,
|
||||
formatToken: string = "MMM dd, yyyy",
|
||||
fallback: string = "Invalid date"
|
||||
): string => {
|
||||
if (!date) return fallback;
|
||||
|
||||
// Use renderFormattedPayloadDate to get a properly formatted payload date
|
||||
const payloadDate = renderFormattedPayloadDate(date);
|
||||
|
||||
// If renderFormattedPayloadDate returns undefined/null, return fallback
|
||||
if (!payloadDate) return fallback;
|
||||
|
||||
try {
|
||||
// Parse and format the payload date
|
||||
const parsedDate = getDate(payloadDate);
|
||||
if (!parsedDate || !isValid(parsedDate)) return fallback;
|
||||
|
||||
return format(parsedDate, formatToken);
|
||||
} catch (error) {
|
||||
// Return fallback if any error occurs during formatting
|
||||
return fallback;
|
||||
}
|
||||
};
|
||||
|
||||
/*
|
||||
* Formats merged date range display with smart formatting
|
||||
* - Single date: "Jan 24, 2025"
|
||||
* - Same year, same month: "Jan 24 - 28, 2025"
|
||||
* - Same year, different month: "Jan 24 - Feb 6, 2025"
|
||||
* - Different year: "Dec 28, 2024 - Jan 4, 2025"
|
||||
*/
|
||||
export const formatDateRange = (
|
||||
parsedStartDate: Date | null | undefined,
|
||||
parsedEndDate: Date | null | undefined
|
||||
): string => {
|
||||
// If no dates are provided
|
||||
if (!parsedStartDate && !parsedEndDate) {
|
||||
return "";
|
||||
}
|
||||
|
||||
// If only start date is provided
|
||||
if (parsedStartDate && !parsedEndDate) {
|
||||
return format(parsedStartDate, "MMM dd, yyyy");
|
||||
}
|
||||
|
||||
// If only end date is provided
|
||||
if (!parsedStartDate && parsedEndDate) {
|
||||
return format(parsedEndDate, "MMM dd, yyyy");
|
||||
}
|
||||
|
||||
// If both dates are provided
|
||||
if (parsedStartDate && parsedEndDate) {
|
||||
const startYear = parsedStartDate.getFullYear();
|
||||
const startMonth = parsedStartDate.getMonth();
|
||||
const endYear = parsedEndDate.getFullYear();
|
||||
const endMonth = parsedEndDate.getMonth();
|
||||
|
||||
// Same year, same month
|
||||
if (startYear === endYear && startMonth === endMonth) {
|
||||
const startDay = format(parsedStartDate, "dd");
|
||||
const endDay = format(parsedEndDate, "dd");
|
||||
return `${format(parsedStartDate, "MMM")} ${startDay} - ${endDay}, ${startYear}`;
|
||||
}
|
||||
|
||||
// Same year, different month
|
||||
if (startYear === endYear) {
|
||||
const startFormatted = format(parsedStartDate, "MMM dd");
|
||||
const endFormatted = format(parsedEndDate, "MMM dd");
|
||||
return `${startFormatted} - ${endFormatted}, ${startYear}`;
|
||||
}
|
||||
|
||||
// Different year
|
||||
const startFormatted = format(parsedStartDate, "MMM dd, yyyy");
|
||||
const endFormatted = format(parsedEndDate, "MMM dd, yyyy");
|
||||
return `${startFormatted} - ${endFormatted}`;
|
||||
}
|
||||
|
||||
return "";
|
||||
};
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "space",
|
||||
"version": "0.26.0",
|
||||
"version": "0.26.1",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0",
|
||||
"scripts": {
|
||||
|
||||
@@ -31,7 +31,7 @@ export const DescriptionVersionsDropdownItem: React.FC<Props> = observer((props)
|
||||
/>
|
||||
</span>
|
||||
<p className="text-xs text-custom-text-200 flex items-center gap-1.5">
|
||||
<span className="font-medium">{versionCreator?.display_name}</span>
|
||||
<span className="font-medium">{versionCreator?.display_name ?? t("common.deactivated_user")}</span>
|
||||
<span>{calculateTimeAgo(version.last_saved_at)}</span>
|
||||
</p>
|
||||
</CustomMenu.MenuItem>
|
||||
|
||||
@@ -40,7 +40,8 @@ export const DescriptionVersionsDropdown: React.FC<Props> = observer((props) =>
|
||||
</span>
|
||||
<p className="text-xs">
|
||||
{t("description_versions.last_edited_by")}{" "}
|
||||
<span className="font-medium">{lastUpdatedByUserDisplayName}</span> {calculateTimeAgo(lastUpdatedAt)}
|
||||
<span className="font-medium">{lastUpdatedByUserDisplayName ?? t("common.deactivated_user")}</span>{" "}
|
||||
{calculateTimeAgo(lastUpdatedAt)}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -3,21 +3,12 @@
|
||||
import React, { FC, useEffect, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import {
|
||||
ArchiveIcon,
|
||||
ArchiveRestoreIcon,
|
||||
ArrowRight,
|
||||
ChevronRight,
|
||||
EllipsisIcon,
|
||||
LinkIcon,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { ArrowRight, ChevronRight } from "lucide-react";
|
||||
// Plane Imports
|
||||
import { CYCLE_STATUS, CYCLE_UPDATED, EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { ICycle } from "@plane/types";
|
||||
import { CustomMenu, setToast, TOAST_TYPE } from "@plane/ui";
|
||||
import { copyUrlToClipboard } from "@plane/utils";
|
||||
import { setToast, TOAST_TYPE } from "@plane/ui";
|
||||
// components
|
||||
import { DateRangeDropdown } from "@/components/dropdowns";
|
||||
// helpers
|
||||
@@ -239,6 +230,7 @@ export const CycleSidebarHeader: FC<Props> = observer((props) => {
|
||||
{renderFormattedDateInUserTimezone(cycleDetails.end_date ?? "")}
|
||||
</span>
|
||||
}
|
||||
mergeDates
|
||||
showTooltip={!!cycleDetails.start_date && !!cycleDetails.end_date} // show tooltip only if both start and end date are present
|
||||
required={cycleDetails.status !== "draft"}
|
||||
disabled={!isEditingAllowed || isArchived || isCompleted}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React, { FC, MouseEvent, useEffect, useMemo, useState } from "react";
|
||||
import { format, parseISO } from "date-fns";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams, usePathname, useSearchParams } from "next/navigation";
|
||||
import { useForm } from "react-hook-form";
|
||||
@@ -17,12 +16,12 @@ import {
|
||||
import { useLocalStorage } from "@plane/hooks";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { ICycle, TCycleGroups } from "@plane/types";
|
||||
// ui
|
||||
import { Avatar, AvatarGroup, FavoriteStar, LayersIcon, Tooltip, TransferIcon, setPromiseToast } from "@plane/ui";
|
||||
// components
|
||||
import { CycleQuickActions, TransferIssuesModal } from "@/components/cycles";
|
||||
import { DateRangeDropdown } from "@/components/dropdowns";
|
||||
import { ButtonAvatars } from "@/components/dropdowns/member/avatar";
|
||||
import { MergedDateDisplay } from "@/components/dropdowns/merged-date";
|
||||
import { getDate } from "@/helpers/date-time.helper";
|
||||
import { getFileURL } from "@/helpers/file.helper";
|
||||
// hooks
|
||||
@@ -64,7 +63,7 @@ export const CycleListItemAction: FC<Props> = observer((props) => {
|
||||
const searchParams = useSearchParams();
|
||||
const pathname = usePathname();
|
||||
// store hooks
|
||||
const { addCycleToFavorites, removeCycleFromFavorites, updateCycleDetails } = useCycle();
|
||||
const { addCycleToFavorites, removeCycleFromFavorites } = useCycle();
|
||||
const { captureEvent } = useEventTracker();
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
|
||||
@@ -77,7 +76,7 @@ export const CycleListItemAction: FC<Props> = observer((props) => {
|
||||
const { getUserDetails } = useMember();
|
||||
|
||||
// form
|
||||
const { control, reset, getValues } = useForm({
|
||||
const { reset } = useForm({
|
||||
defaultValues,
|
||||
});
|
||||
|
||||
@@ -230,9 +229,7 @@ export const CycleListItemAction: FC<Props> = observer((props) => {
|
||||
>
|
||||
<div className="flex gap-1 text-xs text-custom-text-300 font-medium items-center">
|
||||
<CalendarDays className="h-3 w-3 flex-shrink-0 my-auto" />
|
||||
{cycleDetails.start_date && <span>{format(parseISO(cycleDetails.start_date), "MMM dd, yyyy")}</span>}
|
||||
<ArrowRight className="h-3 w-3 flex-shrink-0 my-auto" />
|
||||
{cycleDetails.end_date && <span>{format(parseISO(cycleDetails.end_date), "MMM dd, yyyy")}</span>}
|
||||
<MergedDateDisplay startDate={cycleDetails.start_date} endDate={cycleDetails.end_date} />
|
||||
</div>
|
||||
</Tooltip>
|
||||
{projectUTCOffset && (
|
||||
@@ -269,6 +266,7 @@ export const CycleListItemAction: FC<Props> = observer((props) => {
|
||||
{renderFormattedDateInUserTimezone(cycleDetails.end_date ?? "")}
|
||||
</span>
|
||||
}
|
||||
mergeDates
|
||||
required={cycleDetails.status !== "draft"}
|
||||
disabled
|
||||
hideIcon={{
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from "./widgets";
|
||||
export * from "./project-empty-state";
|
||||
@@ -1,46 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import Image from "next/image";
|
||||
// ui
|
||||
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
||||
import { Button } from "@plane/ui";
|
||||
// hooks
|
||||
import { useCommandPalette, useEventTracker, useUserPermissions } from "@/hooks/store";
|
||||
// assets
|
||||
import ProjectEmptyStateImage from "@/public/empty-state/onboarding/dashboard-light.webp";
|
||||
|
||||
export const DashboardProjectEmptyState = observer(() => {
|
||||
// store hooks
|
||||
const { toggleCreateProjectModal } = useCommandPalette();
|
||||
const { setTrackElement } = useEventTracker();
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
|
||||
// derived values
|
||||
const canCreateProject = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE);
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex h-full flex-col justify-center space-y-4 lg:w-3/5">
|
||||
<h4 className="text-xl font-semibold">Overview of your projects, activity, and metrics</h4>
|
||||
<p className="text-custom-text-300">
|
||||
Welcome to Plane, we are excited to have you here. Create your first project and track your work items, and this
|
||||
page will transform into a space that helps you progress. Admins will also see items which help their team
|
||||
progress.
|
||||
</p>
|
||||
<Image src={ProjectEmptyStateImage} className="w-full" alt="Project empty state" />
|
||||
{canCreateProject && (
|
||||
<div className="flex justify-center">
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
setTrackElement("Project empty state");
|
||||
toggleCreateProjectModal(true);
|
||||
}}
|
||||
>
|
||||
Build your first project
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -1,165 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import { Tab } from "@headlessui/react";
|
||||
import { EDurationFilters, FILTERED_ISSUES_TABS_LIST, UNFILTERED_ISSUES_TABS_LIST } from "@plane/constants";
|
||||
import { TAssignedIssuesWidgetFilters, TAssignedIssuesWidgetResponse } from "@plane/types";
|
||||
// hooks
|
||||
import { Card } from "@plane/ui";
|
||||
import {
|
||||
DurationFilterDropdown,
|
||||
IssuesErrorState,
|
||||
TabsList,
|
||||
WidgetIssuesList,
|
||||
WidgetLoader,
|
||||
WidgetProps,
|
||||
} from "@/components/dashboard/widgets";
|
||||
import { getCustomDates, getRedirectionFilters, getTabKey } from "@/helpers/dashboard.helper";
|
||||
import { useDashboard } from "@/hooks/store";
|
||||
// components
|
||||
// helpers
|
||||
// types
|
||||
// constants
|
||||
|
||||
const WIDGET_KEY = "assigned_issues";
|
||||
|
||||
export const AssignedIssuesWidget: React.FC<WidgetProps> = observer((props) => {
|
||||
const { dashboardId, workspaceSlug } = props;
|
||||
// states
|
||||
const [fetching, setFetching] = useState(false);
|
||||
// store hooks
|
||||
const { fetchWidgetStats, getWidgetDetails, getWidgetStats, getWidgetStatsError, updateDashboardWidgetFilters } =
|
||||
useDashboard();
|
||||
// derived values
|
||||
const widgetDetails = getWidgetDetails(workspaceSlug, dashboardId, WIDGET_KEY);
|
||||
const widgetStats = getWidgetStats<TAssignedIssuesWidgetResponse>(workspaceSlug, dashboardId, WIDGET_KEY);
|
||||
const widgetStatsError = getWidgetStatsError(workspaceSlug, dashboardId, WIDGET_KEY);
|
||||
const selectedDurationFilter = widgetDetails?.widget_filters.duration ?? EDurationFilters.NONE;
|
||||
const selectedTab = getTabKey(selectedDurationFilter, widgetDetails?.widget_filters.tab);
|
||||
const selectedCustomDates = widgetDetails?.widget_filters.custom_dates ?? [];
|
||||
|
||||
const handleUpdateFilters = async (filters: Partial<TAssignedIssuesWidgetFilters>) => {
|
||||
if (!widgetDetails) return;
|
||||
|
||||
setFetching(true);
|
||||
|
||||
await updateDashboardWidgetFilters(workspaceSlug, dashboardId, widgetDetails.id, {
|
||||
widgetKey: WIDGET_KEY,
|
||||
filters,
|
||||
});
|
||||
|
||||
const filterDates = getCustomDates(
|
||||
filters.duration ?? selectedDurationFilter,
|
||||
filters.custom_dates ?? selectedCustomDates
|
||||
);
|
||||
fetchWidgetStats(workspaceSlug, dashboardId, {
|
||||
widget_key: WIDGET_KEY,
|
||||
issue_type: filters.tab ?? selectedTab,
|
||||
...(filterDates.trim() !== "" ? { target_date: filterDates } : {}),
|
||||
expand: "issue_relation",
|
||||
}).finally(() => setFetching(false));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const filterDates = getCustomDates(selectedDurationFilter, selectedCustomDates);
|
||||
|
||||
fetchWidgetStats(workspaceSlug, dashboardId, {
|
||||
widget_key: WIDGET_KEY,
|
||||
issue_type: selectedTab,
|
||||
...(filterDates.trim() !== "" ? { target_date: filterDates } : {}),
|
||||
expand: "issue_relation",
|
||||
});
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const filterParams = getRedirectionFilters(selectedTab);
|
||||
const tabsList = selectedDurationFilter === "none" ? UNFILTERED_ISSUES_TABS_LIST : FILTERED_ISSUES_TABS_LIST;
|
||||
const selectedTabIndex = tabsList.findIndex((tab) => tab.key === selectedTab);
|
||||
|
||||
if ((!widgetDetails || !widgetStats) && !widgetStatsError) return <WidgetLoader widgetKey={WIDGET_KEY} />;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
{widgetStatsError ? (
|
||||
<IssuesErrorState
|
||||
isRefreshing={fetching}
|
||||
onClick={() =>
|
||||
handleUpdateFilters({
|
||||
duration: EDurationFilters.NONE,
|
||||
tab: "pending",
|
||||
})
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
widgetStats && (
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-2 mb-4">
|
||||
<Link
|
||||
href={`/${workspaceSlug}/workspace-views/assigned/${filterParams}`}
|
||||
className="text-lg font-semibold text-custom-text-300 hover:underline"
|
||||
>
|
||||
Assigned to you
|
||||
</Link>
|
||||
<DurationFilterDropdown
|
||||
customDates={selectedCustomDates}
|
||||
value={selectedDurationFilter}
|
||||
onChange={(val, customDates) => {
|
||||
if (val === "custom" && customDates) {
|
||||
handleUpdateFilters({
|
||||
duration: val,
|
||||
custom_dates: customDates,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (val === selectedDurationFilter) return;
|
||||
|
||||
let newTab = selectedTab;
|
||||
// switch to pending tab if target date is changed to none
|
||||
if (val === "none" && selectedTab !== "completed") newTab = "pending";
|
||||
// switch to upcoming tab if target date is changed to other than none
|
||||
if (val !== "none" && selectedDurationFilter === "none" && selectedTab !== "completed")
|
||||
newTab = "upcoming";
|
||||
|
||||
handleUpdateFilters({
|
||||
duration: val,
|
||||
tab: newTab,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Tab.Group
|
||||
as="div"
|
||||
selectedIndex={selectedTabIndex}
|
||||
onChange={(i) => {
|
||||
const newSelectedTab = tabsList[i];
|
||||
handleUpdateFilters({ tab: newSelectedTab?.key ?? "completed" });
|
||||
}}
|
||||
className="h-full flex flex-col"
|
||||
>
|
||||
<TabsList durationFilter={selectedDurationFilter} selectedTab={selectedTab} />
|
||||
<Tab.Panels as="div" className="h-full">
|
||||
{tabsList.map((tab) => {
|
||||
if (tab.key !== selectedTab) return null;
|
||||
|
||||
return (
|
||||
<Tab.Panel key={tab.key} as="div" className="h-full flex flex-col" static>
|
||||
<WidgetIssuesList
|
||||
tab={tab.key}
|
||||
type="assigned"
|
||||
workspaceSlug={workspaceSlug}
|
||||
widgetStats={widgetStats}
|
||||
isLoading={fetching}
|
||||
/>
|
||||
</Tab.Panel>
|
||||
);
|
||||
})}
|
||||
</Tab.Panels>
|
||||
</Tab.Group>
|
||||
</>
|
||||
)
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
});
|
||||
@@ -1,162 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import { Tab } from "@headlessui/react";
|
||||
import { EDurationFilters, FILTERED_ISSUES_TABS_LIST, UNFILTERED_ISSUES_TABS_LIST } from "@plane/constants";
|
||||
import { TCreatedIssuesWidgetFilters, TCreatedIssuesWidgetResponse } from "@plane/types";
|
||||
// hooks
|
||||
import { Card } from "@plane/ui";
|
||||
import {
|
||||
DurationFilterDropdown,
|
||||
IssuesErrorState,
|
||||
TabsList,
|
||||
WidgetIssuesList,
|
||||
WidgetLoader,
|
||||
WidgetProps,
|
||||
} from "@/components/dashboard/widgets";
|
||||
import { getCustomDates, getRedirectionFilters, getTabKey } from "@/helpers/dashboard.helper";
|
||||
import { useDashboard } from "@/hooks/store";
|
||||
// components
|
||||
// helpers
|
||||
// types
|
||||
// constants
|
||||
|
||||
const WIDGET_KEY = "created_issues";
|
||||
|
||||
export const CreatedIssuesWidget: React.FC<WidgetProps> = observer((props) => {
|
||||
const { dashboardId, workspaceSlug } = props;
|
||||
// states
|
||||
const [fetching, setFetching] = useState(false);
|
||||
// store hooks
|
||||
const { fetchWidgetStats, getWidgetDetails, getWidgetStats, getWidgetStatsError, updateDashboardWidgetFilters } =
|
||||
useDashboard();
|
||||
// derived values
|
||||
const widgetDetails = getWidgetDetails(workspaceSlug, dashboardId, WIDGET_KEY);
|
||||
const widgetStats = getWidgetStats<TCreatedIssuesWidgetResponse>(workspaceSlug, dashboardId, WIDGET_KEY);
|
||||
const widgetStatsError = getWidgetStatsError(workspaceSlug, dashboardId, WIDGET_KEY);
|
||||
const selectedDurationFilter = widgetDetails?.widget_filters.duration ?? EDurationFilters.NONE;
|
||||
const selectedTab = getTabKey(selectedDurationFilter, widgetDetails?.widget_filters.tab);
|
||||
const selectedCustomDates = widgetDetails?.widget_filters.custom_dates ?? [];
|
||||
|
||||
const handleUpdateFilters = async (filters: Partial<TCreatedIssuesWidgetFilters>) => {
|
||||
if (!widgetDetails) return;
|
||||
|
||||
setFetching(true);
|
||||
|
||||
await updateDashboardWidgetFilters(workspaceSlug, dashboardId, widgetDetails.id, {
|
||||
widgetKey: WIDGET_KEY,
|
||||
filters,
|
||||
});
|
||||
|
||||
const filterDates = getCustomDates(
|
||||
filters.duration ?? selectedDurationFilter,
|
||||
filters.custom_dates ?? selectedCustomDates
|
||||
);
|
||||
fetchWidgetStats(workspaceSlug, dashboardId, {
|
||||
widget_key: WIDGET_KEY,
|
||||
issue_type: filters.tab ?? selectedTab,
|
||||
...(filterDates.trim() !== "" ? { target_date: filterDates } : {}),
|
||||
}).finally(() => setFetching(false));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const filterDates = getCustomDates(selectedDurationFilter, selectedCustomDates);
|
||||
|
||||
fetchWidgetStats(workspaceSlug, dashboardId, {
|
||||
widget_key: WIDGET_KEY,
|
||||
issue_type: selectedTab,
|
||||
...(filterDates.trim() !== "" ? { target_date: filterDates } : {}),
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const filterParams = getRedirectionFilters(selectedTab);
|
||||
const tabsList = selectedDurationFilter === "none" ? UNFILTERED_ISSUES_TABS_LIST : FILTERED_ISSUES_TABS_LIST;
|
||||
const selectedTabIndex = tabsList.findIndex((tab) => tab.key === selectedTab);
|
||||
|
||||
if ((!widgetDetails || !widgetStats) && !widgetStatsError) return <WidgetLoader widgetKey={WIDGET_KEY} />;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
{widgetStatsError ? (
|
||||
<IssuesErrorState
|
||||
isRefreshing={fetching}
|
||||
onClick={() =>
|
||||
handleUpdateFilters({
|
||||
duration: EDurationFilters.NONE,
|
||||
tab: "pending",
|
||||
})
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
widgetStats && (
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-2 mb-4">
|
||||
<Link
|
||||
href={`/${workspaceSlug}/workspace-views/created/${filterParams}`}
|
||||
className="text-lg font-semibold text-custom-text-300 hover:underline"
|
||||
>
|
||||
Created by you
|
||||
</Link>
|
||||
<DurationFilterDropdown
|
||||
customDates={selectedCustomDates}
|
||||
value={selectedDurationFilter}
|
||||
onChange={(val, customDates) => {
|
||||
if (val === "custom" && customDates) {
|
||||
handleUpdateFilters({
|
||||
duration: val,
|
||||
custom_dates: customDates,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (val === selectedDurationFilter) return;
|
||||
|
||||
let newTab = selectedTab;
|
||||
// switch to pending tab if target date is changed to none
|
||||
if (val === "none" && selectedTab !== "completed") newTab = "pending";
|
||||
// switch to upcoming tab if target date is changed to other than none
|
||||
if (val !== "none" && selectedDurationFilter === "none" && selectedTab !== "completed")
|
||||
newTab = "upcoming";
|
||||
|
||||
handleUpdateFilters({
|
||||
duration: val,
|
||||
tab: newTab,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Tab.Group
|
||||
as="div"
|
||||
selectedIndex={selectedTabIndex}
|
||||
onChange={(i) => {
|
||||
const newSelectedTab = tabsList[i];
|
||||
handleUpdateFilters({ tab: newSelectedTab.key ?? "completed" });
|
||||
}}
|
||||
className="h-full flex flex-col"
|
||||
>
|
||||
<TabsList durationFilter={selectedDurationFilter} selectedTab={selectedTab} />
|
||||
<Tab.Panels as="div" className="h-full">
|
||||
{tabsList.map((tab) => {
|
||||
if (tab.key !== selectedTab) return null;
|
||||
|
||||
return (
|
||||
<Tab.Panel key={tab.key} as="div" className="h-full flex flex-col" static>
|
||||
<WidgetIssuesList
|
||||
tab={tab.key}
|
||||
type="created"
|
||||
workspaceSlug={workspaceSlug}
|
||||
widgetStats={widgetStats}
|
||||
isLoading={fetching}
|
||||
/>
|
||||
</Tab.Panel>
|
||||
);
|
||||
})}
|
||||
</Tab.Panels>
|
||||
</Tab.Group>
|
||||
</>
|
||||
)
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
});
|
||||
@@ -1,58 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
// components
|
||||
import { DURATION_FILTER_OPTIONS, EDurationFilters } from "@plane/constants";
|
||||
import { CustomMenu } from "@plane/ui";
|
||||
import { DateFilterModal } from "@/components/core";
|
||||
// ui
|
||||
// helpers
|
||||
import { getDurationFilterDropdownLabel } from "@/helpers/dashboard.helper";
|
||||
// constants
|
||||
|
||||
type Props = {
|
||||
customDates?: string[];
|
||||
onChange: (value: EDurationFilters, customDates?: string[]) => void;
|
||||
value: EDurationFilters;
|
||||
};
|
||||
|
||||
export const DurationFilterDropdown: React.FC<Props> = (props) => {
|
||||
const { customDates, onChange, value } = props;
|
||||
// states
|
||||
const [isDateFilterModalOpen, setIsDateFilterModalOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DateFilterModal
|
||||
isOpen={isDateFilterModalOpen}
|
||||
handleClose={() => setIsDateFilterModalOpen(false)}
|
||||
onSelect={(val) => onChange(EDurationFilters.CUSTOM, val)}
|
||||
title="Due date"
|
||||
/>
|
||||
<CustomMenu
|
||||
className="flex-shrink-0"
|
||||
customButton={
|
||||
<div className="px-3 py-2 border-[0.5px] border-custom-border-300 hover:bg-custom-background-80 focus:bg-custom-background-80 text-xs font-medium whitespace-nowrap rounded-md outline-none flex items-center gap-2">
|
||||
{getDurationFilterDropdownLabel(value, customDates ?? [])}
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</div>
|
||||
}
|
||||
placement="bottom-end"
|
||||
closeOnSelect
|
||||
>
|
||||
{DURATION_FILTER_OPTIONS.map((option) => (
|
||||
<CustomMenu.MenuItem
|
||||
key={option.key}
|
||||
onClick={() => {
|
||||
if (option.key === "custom") setIsDateFilterModalOpen(true);
|
||||
else onChange(option.key);
|
||||
}}
|
||||
>
|
||||
{option.label}
|
||||
</CustomMenu.MenuItem>
|
||||
))}
|
||||
</CustomMenu>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./duration-filter";
|
||||
@@ -1,55 +0,0 @@
|
||||
import Image from "next/image";
|
||||
import { useTheme } from "next-themes";
|
||||
import { TIssuesListTypes } from "@plane/types";
|
||||
import CompletedIssuesDark from "@/public/empty-state/dashboard/dark/completed-issues.svg";
|
||||
import OverdueIssuesDark from "@/public/empty-state/dashboard/dark/overdue-issues.svg";
|
||||
import UpcomingIssuesDark from "@/public/empty-state/dashboard/dark/upcoming-issues.svg";
|
||||
import CompletedIssuesLight from "@/public/empty-state/dashboard/light/completed-issues.svg";
|
||||
import OverdueIssuesLight from "@/public/empty-state/dashboard/light/overdue-issues.svg";
|
||||
import UpcomingIssuesLight from "@/public/empty-state/dashboard/light/upcoming-issues.svg";
|
||||
|
||||
export const ASSIGNED_ISSUES_EMPTY_STATES = {
|
||||
pending: {
|
||||
title: "Work items assigned to you that are pending\nwill show up here.",
|
||||
darkImage: UpcomingIssuesDark,
|
||||
lightImage: UpcomingIssuesLight,
|
||||
},
|
||||
upcoming: {
|
||||
title: "Upcoming work items assigned to\nyou will show up here.",
|
||||
darkImage: UpcomingIssuesDark,
|
||||
lightImage: UpcomingIssuesLight,
|
||||
},
|
||||
overdue: {
|
||||
title: "Work items assigned to you that are past\ntheir due date will show up here.",
|
||||
darkImage: OverdueIssuesDark,
|
||||
lightImage: OverdueIssuesLight,
|
||||
},
|
||||
completed: {
|
||||
title: "Work items assigned to you that you have\nmarked Completed will show up here.",
|
||||
darkImage: CompletedIssuesDark,
|
||||
lightImage: CompletedIssuesLight,
|
||||
},
|
||||
};
|
||||
type Props = {
|
||||
type: TIssuesListTypes;
|
||||
};
|
||||
|
||||
export const AssignedIssuesEmptyState: React.FC<Props> = (props) => {
|
||||
const { type } = props;
|
||||
// next-themes
|
||||
const { resolvedTheme } = useTheme();
|
||||
|
||||
const typeDetails = ASSIGNED_ISSUES_EMPTY_STATES[type];
|
||||
|
||||
const image = resolvedTheme === "dark" ? typeDetails.darkImage : typeDetails.lightImage;
|
||||
|
||||
// TODO: update empty state logic to use a general component
|
||||
return (
|
||||
<div className="text-center space-y-6 flex flex-col items-center">
|
||||
<div className="h-24 w-24">
|
||||
<Image src={image} className="w-full h-full" alt="Assigned work items" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-custom-text-300 whitespace-pre-line">{typeDetails.title}</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,55 +0,0 @@
|
||||
import Image from "next/image";
|
||||
import { useTheme } from "next-themes";
|
||||
import { TIssuesListTypes } from "@plane/types";
|
||||
import CompletedIssuesDark from "@/public/empty-state/dashboard/dark/completed-issues.svg";
|
||||
import OverdueIssuesDark from "@/public/empty-state/dashboard/dark/overdue-issues.svg";
|
||||
import UpcomingIssuesDark from "@/public/empty-state/dashboard/dark/upcoming-issues.svg";
|
||||
import CompletedIssuesLight from "@/public/empty-state/dashboard/light/completed-issues.svg";
|
||||
import OverdueIssuesLight from "@/public/empty-state/dashboard/light/overdue-issues.svg";
|
||||
import UpcomingIssuesLight from "@/public/empty-state/dashboard/light/upcoming-issues.svg";
|
||||
|
||||
export const CREATED_ISSUES_EMPTY_STATES = {
|
||||
pending: {
|
||||
title: "Work items created by you that are pending\nwill show up here.",
|
||||
darkImage: UpcomingIssuesDark,
|
||||
lightImage: UpcomingIssuesLight,
|
||||
},
|
||||
upcoming: {
|
||||
title: "Upcoming work items you created\nwill show up here.",
|
||||
darkImage: UpcomingIssuesDark,
|
||||
lightImage: UpcomingIssuesLight,
|
||||
},
|
||||
overdue: {
|
||||
title: "Work items created by you that are past their\ndue date will show up here.",
|
||||
darkImage: OverdueIssuesDark,
|
||||
lightImage: OverdueIssuesLight,
|
||||
},
|
||||
completed: {
|
||||
title: "Work items created by you that you have\nmarked completed will show up here.",
|
||||
darkImage: CompletedIssuesDark,
|
||||
lightImage: CompletedIssuesLight,
|
||||
},
|
||||
};
|
||||
|
||||
type Props = {
|
||||
type: TIssuesListTypes;
|
||||
};
|
||||
|
||||
export const CreatedIssuesEmptyState: React.FC<Props> = (props) => {
|
||||
const { type } = props;
|
||||
// next-themes
|
||||
const { resolvedTheme } = useTheme();
|
||||
|
||||
const typeDetails = CREATED_ISSUES_EMPTY_STATES[type];
|
||||
|
||||
const image = resolvedTheme === "dark" ? typeDetails.darkImage : typeDetails.lightImage;
|
||||
|
||||
return (
|
||||
<div className="text-center space-y-6 flex flex-col items-center">
|
||||
<div className="h-24 w-24">
|
||||
<Image src={image} className="w-full h-full" alt="Assigned work items" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-custom-text-300 whitespace-pre-line">{typeDetails.title}</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +0,0 @@
|
||||
export * from "./assigned-issues";
|
||||
export * from "./created-issues";
|
||||
export * from "./issues-by-priority";
|
||||
export * from "./issues-by-state-group";
|
||||
export * from "./recent-activity";
|
||||
export * from "./recent-collaborators";
|
||||
@@ -1,25 +0,0 @@
|
||||
import Image from "next/image";
|
||||
import { useTheme } from "next-themes";
|
||||
// assets
|
||||
import DarkImage from "@/public/empty-state/dashboard/dark/issues-by-priority.svg";
|
||||
import LightImage from "@/public/empty-state/dashboard/light/issues-by-priority.svg";
|
||||
|
||||
export const IssuesByPriorityEmptyState = () => {
|
||||
// next-themes
|
||||
const { resolvedTheme } = useTheme();
|
||||
|
||||
const image = resolvedTheme === "dark" ? DarkImage : LightImage;
|
||||
|
||||
return (
|
||||
<div className="text-center space-y-6 flex flex-col items-center">
|
||||
<div className="h-24 w-24">
|
||||
<Image src={image} className="w-full h-full" alt="Work items by state group" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-custom-text-300">
|
||||
Work items assigned to you, broken down by
|
||||
<br />
|
||||
priority will show up here.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,25 +0,0 @@
|
||||
import Image from "next/image";
|
||||
import { useTheme } from "next-themes";
|
||||
// assets
|
||||
import DarkImage from "@/public/empty-state/dashboard/dark/issues-by-state-group.svg";
|
||||
import LightImage from "@/public/empty-state/dashboard/light/issues-by-state-group.svg";
|
||||
|
||||
export const IssuesByStateGroupEmptyState = () => {
|
||||
// next-themes
|
||||
const { resolvedTheme } = useTheme();
|
||||
|
||||
const image = resolvedTheme === "dark" ? DarkImage : LightImage;
|
||||
|
||||
return (
|
||||
<div className="text-center space-y-6 flex flex-col items-center">
|
||||
<div className="h-24 w-24">
|
||||
<Image src={image} className="w-full h-full" alt="Work items by state group" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-custom-text-300">
|
||||
Work items assigned to you, broken down by state,
|
||||
<br />
|
||||
will show up here.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,25 +0,0 @@
|
||||
import Image from "next/image";
|
||||
import { useTheme } from "next-themes";
|
||||
// assets
|
||||
import DarkImage from "@/public/empty-state/dashboard/dark/recent-activity.svg";
|
||||
import LightImage from "@/public/empty-state/dashboard/light/recent-activity.svg";
|
||||
|
||||
export const RecentActivityEmptyState = () => {
|
||||
// next-themes
|
||||
const { resolvedTheme } = useTheme();
|
||||
|
||||
const image = resolvedTheme === "dark" ? DarkImage : LightImage;
|
||||
|
||||
return (
|
||||
<div className="text-center space-y-6 flex flex-col items-center">
|
||||
<div className="h-24 w-24">
|
||||
<Image src={image} className="w-full h-full" alt="Work items by state group" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-custom-text-300">
|
||||
All your work items activities across
|
||||
<br />
|
||||
projects will show up here.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,39 +0,0 @@
|
||||
import Image from "next/image";
|
||||
import { useTheme } from "next-themes";
|
||||
// assets
|
||||
import DarkImage1 from "@/public/empty-state/dashboard/dark/recent-collaborators-1.svg";
|
||||
import DarkImage2 from "@/public/empty-state/dashboard/dark/recent-collaborators-2.svg";
|
||||
import DarkImage3 from "@/public/empty-state/dashboard/dark/recent-collaborators-3.svg";
|
||||
import LightImage1 from "@/public/empty-state/dashboard/light/recent-collaborators-1.svg";
|
||||
import LightImage2 from "@/public/empty-state/dashboard/light/recent-collaborators-2.svg";
|
||||
import LightImage3 from "@/public/empty-state/dashboard/light/recent-collaborators-3.svg";
|
||||
|
||||
export const RecentCollaboratorsEmptyState = () => {
|
||||
// next-themes
|
||||
const { resolvedTheme } = useTheme();
|
||||
|
||||
const image1 = resolvedTheme === "dark" ? DarkImage1 : LightImage1;
|
||||
const image2 = resolvedTheme === "dark" ? DarkImage2 : LightImage2;
|
||||
const image3 = resolvedTheme === "dark" ? DarkImage3 : LightImage3;
|
||||
|
||||
return (
|
||||
<div className="mt-7 mb-16 px-36 flex flex-col lg:flex-row items-center justify-between gap-x-24 gap-y-16">
|
||||
<p className="text-sm font-medium text-custom-text-300 lg:w-2/5 flex-shrink-0 text-center lg:text-left">
|
||||
Compare your activities with the top
|
||||
<br />
|
||||
seven in your project.
|
||||
</p>
|
||||
<div className="flex items-center justify-evenly gap-20 lg:w-3/5 flex-shrink-0">
|
||||
<div className="h-24 w-24 flex-shrink-0">
|
||||
<Image src={image1} className="w-full h-full" alt="Recent collaborators" />
|
||||
</div>
|
||||
<div className="h-24 w-24 flex-shrink-0">
|
||||
<Image src={image2} className="w-full h-full" alt="Recent collaborators" />
|
||||
</div>
|
||||
<div className="h-24 w-24 flex-shrink-0 hidden xl:block">
|
||||
<Image src={image3} className="w-full h-full" alt="Recent collaborators" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./issues";
|
||||
@@ -1,34 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { AlertTriangle, RefreshCcw } from "lucide-react";
|
||||
// ui
|
||||
import { Button } from "@plane/ui";
|
||||
|
||||
type Props = {
|
||||
isRefreshing: boolean;
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
export const IssuesErrorState: React.FC<Props> = (props) => {
|
||||
const { isRefreshing, onClick } = props;
|
||||
|
||||
return (
|
||||
<div className="h-full w-full grid place-items-center">
|
||||
<div className="text-center">
|
||||
<div className="h-24 w-24 bg-red-500/20 rounded-full grid place-items-center mx-auto">
|
||||
<AlertTriangle className="h-12 w-12 text-red-500" />
|
||||
</div>
|
||||
<p className="mt-7 text-custom-text-300 text-sm font-medium">There was an error in fetching widget details</p>
|
||||
<Button
|
||||
variant="neutral-primary"
|
||||
prependIcon={<RefreshCcw className="h-3 w-3" />}
|
||||
className="mt-2 mx-auto"
|
||||
onClick={onClick}
|
||||
loading={isRefreshing}
|
||||
>
|
||||
{isRefreshing ? "Retrying" : "Retry"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,11 +0,0 @@
|
||||
export * from "./dropdowns";
|
||||
export * from "./empty-states";
|
||||
export * from "./error-states";
|
||||
export * from "./issue-panels";
|
||||
export * from "./loaders";
|
||||
export * from "./assigned-issues";
|
||||
export * from "./created-issues";
|
||||
export * from "./overview-stats";
|
||||
export * from "./recent-activity";
|
||||
export * from "./recent-collaborators";
|
||||
export * from "./recent-projects";
|
||||
@@ -1,3 +0,0 @@
|
||||
export * from "./issue-list-item";
|
||||
export * from "./issues-list";
|
||||
export * from "./tabs-list";
|
||||
@@ -1,401 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { isToday } from "date-fns/isToday";
|
||||
import { observer } from "mobx-react";
|
||||
// types
|
||||
import { TIssue, TWidgetIssue } from "@plane/types";
|
||||
// ui
|
||||
import { Avatar, AvatarGroup, ControlLink, PriorityIcon } from "@plane/ui";
|
||||
// helpers
|
||||
import { findTotalDaysInRange, getDate, renderFormattedDate } from "@/helpers/date-time.helper";
|
||||
import { getFileURL } from "@/helpers/file.helper";
|
||||
import { generateWorkItemLink } from "@/helpers/issue.helper";
|
||||
// hooks
|
||||
import { useIssueDetail, useMember, useProject } from "@/hooks/store";
|
||||
// plane web components
|
||||
import { IssueIdentifier } from "@/plane-web/components/issues";
|
||||
|
||||
export type IssueListItemProps = {
|
||||
issueId: string;
|
||||
onClick: (issue: TIssue) => void;
|
||||
workspaceSlug: string;
|
||||
};
|
||||
|
||||
export const AssignedUpcomingIssueListItem: React.FC<IssueListItemProps> = observer((props) => {
|
||||
const { issueId, onClick, workspaceSlug } = props;
|
||||
// store hooks
|
||||
const { getProjectById } = useProject();
|
||||
const {
|
||||
issue: { getIssueById },
|
||||
} = useIssueDetail();
|
||||
// derived values
|
||||
const issueDetails = getIssueById(issueId) as TWidgetIssue | undefined;
|
||||
|
||||
if (!issueDetails || !issueDetails.project_id) return null;
|
||||
|
||||
const projectDetails = getProjectById(issueDetails.project_id);
|
||||
|
||||
const blockedByIssues = issueDetails.issue_relation?.filter((issue) => issue.relation_type === "blocked_by") ?? [];
|
||||
|
||||
const blockedByIssueProjectDetails =
|
||||
blockedByIssues.length === 1 ? getProjectById(blockedByIssues[0]?.project_id ?? "") : null;
|
||||
|
||||
const targetDate = getDate(issueDetails.target_date);
|
||||
|
||||
const workItemLink = generateWorkItemLink({
|
||||
workspaceSlug,
|
||||
projectId: issueDetails?.project_id,
|
||||
issueId: issueDetails?.id,
|
||||
projectIdentifier: projectDetails?.identifier,
|
||||
sequenceId: issueDetails?.sequence_id,
|
||||
});
|
||||
|
||||
return (
|
||||
<ControlLink
|
||||
href={workItemLink}
|
||||
onClick={() => onClick(issueDetails)}
|
||||
className="grid grid-cols-12 gap-1 rounded px-3 py-2 hover:bg-custom-background-80"
|
||||
>
|
||||
<div className="col-span-7 flex items-center gap-3">
|
||||
{projectDetails && (
|
||||
<IssueIdentifier
|
||||
issueId={issueDetails.id}
|
||||
projectId={projectDetails?.id}
|
||||
textContainerClassName="text-xs text-custom-text-200 font-medium"
|
||||
/>
|
||||
)}
|
||||
<h6 className="flex-grow truncate text-sm">{issueDetails.name}</h6>
|
||||
</div>
|
||||
<div className="flex justify-center col-span-1 items-center">
|
||||
<PriorityIcon priority={issueDetails.priority} size={12} withContainer />
|
||||
</div>
|
||||
<div className="text-center text-xs col-span-2">
|
||||
{targetDate ? (isToday(targetDate) ? "Today" : renderFormattedDate(targetDate)) : "-"}
|
||||
</div>
|
||||
<div className="flex justify-center text-xs col-span-2">
|
||||
{blockedByIssues.length > 0
|
||||
? blockedByIssues.length > 1
|
||||
? `${blockedByIssues.length} blockers`
|
||||
: blockedByIssueProjectDetails && (
|
||||
<IssueIdentifier
|
||||
projectIdentifier={blockedByIssueProjectDetails?.identifier}
|
||||
projectId={blockedByIssueProjectDetails?.id}
|
||||
issueSequenceId={blockedByIssues[0]?.sequence_id}
|
||||
textContainerClassName="text-xs text-custom-text-200 font-medium"
|
||||
/>
|
||||
)
|
||||
: "-"}
|
||||
</div>
|
||||
</ControlLink>
|
||||
);
|
||||
});
|
||||
|
||||
export const AssignedOverdueIssueListItem: React.FC<IssueListItemProps> = observer((props) => {
|
||||
const { issueId, onClick, workspaceSlug } = props;
|
||||
// store hooks
|
||||
const { getProjectById } = useProject();
|
||||
const {
|
||||
issue: { getIssueById },
|
||||
} = useIssueDetail();
|
||||
// derived values
|
||||
const issueDetails = getIssueById(issueId) as TWidgetIssue | undefined;
|
||||
|
||||
if (!issueDetails || !issueDetails.project_id) return null;
|
||||
|
||||
const projectDetails = getProjectById(issueDetails.project_id);
|
||||
const blockedByIssues = issueDetails.issue_relation?.filter((issue) => issue.relation_type === "blocked_by") ?? [];
|
||||
|
||||
const blockedByIssueProjectDetails =
|
||||
blockedByIssues.length === 1 ? getProjectById(blockedByIssues[0]?.project_id ?? "") : null;
|
||||
|
||||
const dueBy = findTotalDaysInRange(getDate(issueDetails.target_date), new Date(), false) ?? 0;
|
||||
|
||||
const workItemLink = generateWorkItemLink({
|
||||
workspaceSlug,
|
||||
projectId: issueDetails?.project_id,
|
||||
issueId: issueDetails?.id,
|
||||
projectIdentifier: projectDetails?.identifier,
|
||||
sequenceId: issueDetails?.sequence_id,
|
||||
});
|
||||
|
||||
return (
|
||||
<ControlLink
|
||||
href={workItemLink}
|
||||
onClick={() => onClick(issueDetails)}
|
||||
className="grid grid-cols-12 gap-1 rounded px-3 py-2 hover:bg-custom-background-80"
|
||||
>
|
||||
<div className="col-span-7 flex items-center gap-3">
|
||||
{projectDetails && (
|
||||
<IssueIdentifier
|
||||
issueId={issueDetails.id}
|
||||
projectId={projectDetails?.id}
|
||||
textContainerClassName="text-xs text-custom-text-200 font-medium"
|
||||
/>
|
||||
)}
|
||||
<h6 className="flex-grow truncate text-sm">{issueDetails.name}</h6>
|
||||
</div>
|
||||
<div className="flex justify-center col-span-1 items-center">
|
||||
<PriorityIcon priority={issueDetails.priority} size={12} withContainer />
|
||||
</div>
|
||||
<div className="text-center text-xs col-span-2">
|
||||
{dueBy} {`day${dueBy > 1 ? "s" : ""}`}
|
||||
</div>
|
||||
<div className="flex justify-center text-xs col-span-2">
|
||||
{blockedByIssues.length > 0
|
||||
? blockedByIssues.length > 1
|
||||
? `${blockedByIssues.length} blockers`
|
||||
: blockedByIssueProjectDetails && (
|
||||
<IssueIdentifier
|
||||
issueId={blockedByIssues[0]?.id}
|
||||
projectId={blockedByIssueProjectDetails?.id}
|
||||
textContainerClassName="text-xs text-custom-text-200 font-medium"
|
||||
/>
|
||||
)
|
||||
: "-"}
|
||||
</div>
|
||||
</ControlLink>
|
||||
);
|
||||
});
|
||||
|
||||
export const AssignedCompletedIssueListItem: React.FC<IssueListItemProps> = observer((props) => {
|
||||
const { issueId, onClick, workspaceSlug } = props;
|
||||
// store hooks
|
||||
const {
|
||||
issue: { getIssueById },
|
||||
} = useIssueDetail();
|
||||
const { getProjectById } = useProject();
|
||||
// derived values
|
||||
const issueDetails = getIssueById(issueId);
|
||||
|
||||
if (!issueDetails || !issueDetails.project_id) return null;
|
||||
|
||||
const projectDetails = getProjectById(issueDetails.project_id);
|
||||
|
||||
const workItemLink = generateWorkItemLink({
|
||||
workspaceSlug,
|
||||
projectId: issueDetails?.project_id,
|
||||
issueId: issueDetails?.id,
|
||||
projectIdentifier: projectDetails?.identifier,
|
||||
sequenceId: issueDetails?.sequence_id,
|
||||
});
|
||||
|
||||
return (
|
||||
<ControlLink
|
||||
href={workItemLink}
|
||||
onClick={() => onClick(issueDetails)}
|
||||
className="grid grid-cols-12 gap-1 rounded px-3 py-2 hover:bg-custom-background-80"
|
||||
>
|
||||
<div className="col-span-11 flex items-center gap-3">
|
||||
{projectDetails && (
|
||||
<IssueIdentifier
|
||||
issueId={issueDetails.id}
|
||||
projectId={projectDetails?.id}
|
||||
textContainerClassName="text-xs text-custom-text-200 font-medium"
|
||||
/>
|
||||
)}
|
||||
<h6 className="flex-grow truncate text-sm">{issueDetails.name}</h6>
|
||||
</div>
|
||||
<div className="flex justify-center col-span-1 items-center">
|
||||
<PriorityIcon priority={issueDetails.priority} size={12} withContainer />
|
||||
</div>
|
||||
</ControlLink>
|
||||
);
|
||||
});
|
||||
|
||||
export const CreatedUpcomingIssueListItem: React.FC<IssueListItemProps> = observer((props) => {
|
||||
const { issueId, onClick, workspaceSlug } = props;
|
||||
// store hooks
|
||||
const { getUserDetails } = useMember();
|
||||
const {
|
||||
issue: { getIssueById },
|
||||
} = useIssueDetail();
|
||||
const { getProjectById } = useProject();
|
||||
// derived values
|
||||
const issue = getIssueById(issueId);
|
||||
|
||||
if (!issue || !issue.project_id) return null;
|
||||
|
||||
const projectDetails = getProjectById(issue.project_id);
|
||||
const targetDate = getDate(issue.target_date);
|
||||
|
||||
const workItemLink = generateWorkItemLink({
|
||||
workspaceSlug,
|
||||
projectId: issue?.project_id,
|
||||
issueId: issue?.id,
|
||||
projectIdentifier: projectDetails?.identifier,
|
||||
sequenceId: issue?.sequence_id,
|
||||
});
|
||||
|
||||
return (
|
||||
<ControlLink
|
||||
href={workItemLink}
|
||||
onClick={() => onClick(issue)}
|
||||
className="grid grid-cols-12 gap-1 rounded px-3 py-2 hover:bg-custom-background-80"
|
||||
>
|
||||
<div className="col-span-7 flex items-center gap-3">
|
||||
{projectDetails && (
|
||||
<IssueIdentifier
|
||||
issueId={issue.id}
|
||||
projectId={projectDetails?.id}
|
||||
textContainerClassName="text-xs text-custom-text-200 font-medium"
|
||||
/>
|
||||
)}
|
||||
<h6 className="flex-grow truncate text-sm">{issue.name}</h6>
|
||||
</div>
|
||||
<div className="flex justify-center col-span-1 items-center">
|
||||
<PriorityIcon priority={issue.priority} size={12} withContainer />
|
||||
</div>
|
||||
<div className="text-center text-xs col-span-2">
|
||||
{targetDate ? (isToday(targetDate) ? "Today" : renderFormattedDate(targetDate)) : "-"}
|
||||
</div>
|
||||
<div className="flex justify-center text-xs col-span-2">
|
||||
{issue.assignee_ids && issue.assignee_ids?.length > 0 ? (
|
||||
<AvatarGroup>
|
||||
{issue.assignee_ids?.map((assigneeId) => {
|
||||
const userDetails = getUserDetails(assigneeId);
|
||||
|
||||
if (!userDetails) return null;
|
||||
|
||||
return (
|
||||
<Avatar key={assigneeId} src={getFileURL(userDetails.avatar_url)} name={userDetails.display_name} />
|
||||
);
|
||||
})}
|
||||
</AvatarGroup>
|
||||
) : (
|
||||
"-"
|
||||
)}
|
||||
</div>
|
||||
</ControlLink>
|
||||
);
|
||||
});
|
||||
|
||||
export const CreatedOverdueIssueListItem: React.FC<IssueListItemProps> = observer((props) => {
|
||||
const { issueId, onClick, workspaceSlug } = props;
|
||||
// store hooks
|
||||
const { getUserDetails } = useMember();
|
||||
const {
|
||||
issue: { getIssueById },
|
||||
} = useIssueDetail();
|
||||
const { getProjectById } = useProject();
|
||||
// derived values
|
||||
const issue = getIssueById(issueId);
|
||||
|
||||
if (!issue || !issue.project_id) return null;
|
||||
|
||||
const projectDetails = getProjectById(issue.project_id);
|
||||
|
||||
const dueBy: number = findTotalDaysInRange(getDate(issue.target_date), new Date(), false) ?? 0;
|
||||
|
||||
const workItemLink = generateWorkItemLink({
|
||||
workspaceSlug,
|
||||
projectId: issue?.project_id,
|
||||
issueId: issue?.id,
|
||||
projectIdentifier: projectDetails?.identifier,
|
||||
sequenceId: issue?.sequence_id,
|
||||
});
|
||||
|
||||
return (
|
||||
<ControlLink
|
||||
href={workItemLink}
|
||||
onClick={() => onClick(issue)}
|
||||
className="grid grid-cols-12 gap-1 rounded px-3 py-2 hover:bg-custom-background-80"
|
||||
>
|
||||
<div className="col-span-7 flex items-center gap-3">
|
||||
{projectDetails && (
|
||||
<IssueIdentifier
|
||||
issueId={issue.id}
|
||||
projectId={projectDetails?.id}
|
||||
textContainerClassName="text-xs text-custom-text-200 font-medium"
|
||||
/>
|
||||
)}
|
||||
<h6 className="flex-grow truncate text-sm">{issue.name}</h6>
|
||||
</div>
|
||||
<div className="flex justify-center col-span-1 items-center">
|
||||
<PriorityIcon priority={issue.priority} size={12} withContainer />
|
||||
</div>
|
||||
<div className="text-center text-xs col-span-2">
|
||||
{dueBy} {`day${dueBy > 1 ? "s" : ""}`}
|
||||
</div>
|
||||
<div className="flex justify-center text-xs col-span-2">
|
||||
{issue.assignee_ids.length > 0 ? (
|
||||
<AvatarGroup>
|
||||
{issue.assignee_ids?.map((assigneeId) => {
|
||||
const userDetails = getUserDetails(assigneeId);
|
||||
|
||||
if (!userDetails) return null;
|
||||
|
||||
return (
|
||||
<Avatar key={assigneeId} src={getFileURL(userDetails.avatar_url)} name={userDetails.display_name} />
|
||||
);
|
||||
})}
|
||||
</AvatarGroup>
|
||||
) : (
|
||||
"-"
|
||||
)}
|
||||
</div>
|
||||
</ControlLink>
|
||||
);
|
||||
});
|
||||
|
||||
export const CreatedCompletedIssueListItem: React.FC<IssueListItemProps> = observer((props) => {
|
||||
const { issueId, onClick, workspaceSlug } = props;
|
||||
// store hooks
|
||||
const { getUserDetails } = useMember();
|
||||
const {
|
||||
issue: { getIssueById },
|
||||
} = useIssueDetail();
|
||||
const { getProjectById } = useProject();
|
||||
// derived values
|
||||
const issue = getIssueById(issueId);
|
||||
|
||||
if (!issue || !issue.project_id) return null;
|
||||
|
||||
const projectDetails = getProjectById(issue.project_id);
|
||||
|
||||
const workItemLink = generateWorkItemLink({
|
||||
workspaceSlug,
|
||||
projectId: issue?.project_id,
|
||||
issueId: issue?.id,
|
||||
projectIdentifier: projectDetails?.identifier,
|
||||
sequenceId: issue?.sequence_id,
|
||||
});
|
||||
|
||||
return (
|
||||
<ControlLink
|
||||
href={workItemLink}
|
||||
onClick={() => onClick(issue)}
|
||||
className="grid grid-cols-12 gap-1 rounded px-3 py-2 hover:bg-custom-background-80"
|
||||
>
|
||||
<div className="col-span-9 flex items-center gap-3">
|
||||
{projectDetails && (
|
||||
<IssueIdentifier
|
||||
issueId={issue.id}
|
||||
projectId={projectDetails?.id}
|
||||
textContainerClassName="text-xs text-custom-text-200 font-medium"
|
||||
/>
|
||||
)}
|
||||
<h6 className="flex-grow truncate text-sm">{issue.name}</h6>
|
||||
</div>
|
||||
<div className="flex justify-center col-span-1 items-center">
|
||||
<PriorityIcon priority={issue.priority} size={12} withContainer />
|
||||
</div>
|
||||
<div className="flex justify-center text-xs col-span-2">
|
||||
{issue.assignee_ids.length > 0 ? (
|
||||
<AvatarGroup>
|
||||
{issue.assignee_ids?.map((assigneeId) => {
|
||||
const userDetails = getUserDetails(assigneeId);
|
||||
|
||||
if (!userDetails) return null;
|
||||
|
||||
return (
|
||||
<Avatar key={assigneeId} src={getFileURL(userDetails.avatar_url)} name={userDetails.display_name} />
|
||||
);
|
||||
})}
|
||||
</AvatarGroup>
|
||||
) : (
|
||||
"-"
|
||||
)}
|
||||
</div>
|
||||
</ControlLink>
|
||||
);
|
||||
});
|
||||
@@ -1,134 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { TAssignedIssuesWidgetResponse, TCreatedIssuesWidgetResponse, TIssue, TIssuesListTypes } from "@plane/types";
|
||||
// hooks
|
||||
// components
|
||||
import { Loader, getButtonStyling } from "@plane/ui";
|
||||
import {
|
||||
AssignedCompletedIssueListItem,
|
||||
AssignedIssuesEmptyState,
|
||||
AssignedOverdueIssueListItem,
|
||||
AssignedUpcomingIssueListItem,
|
||||
CreatedCompletedIssueListItem,
|
||||
CreatedIssuesEmptyState,
|
||||
CreatedOverdueIssueListItem,
|
||||
CreatedUpcomingIssueListItem,
|
||||
IssueListItemProps,
|
||||
} from "@/components/dashboard/widgets";
|
||||
// ui
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
import { getRedirectionFilters } from "@/helpers/dashboard.helper";
|
||||
// hooks
|
||||
import useIssuePeekOverviewRedirection from "@/hooks/use-issue-peek-overview-redirection";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
|
||||
export type WidgetIssuesListProps = {
|
||||
isLoading: boolean;
|
||||
tab: TIssuesListTypes;
|
||||
type: "assigned" | "created";
|
||||
widgetStats: TAssignedIssuesWidgetResponse | TCreatedIssuesWidgetResponse;
|
||||
workspaceSlug: string;
|
||||
};
|
||||
|
||||
export const WidgetIssuesList: React.FC<WidgetIssuesListProps> = (props) => {
|
||||
const { isLoading, tab, type, widgetStats, workspaceSlug } = props;
|
||||
// hooks
|
||||
const { isMobile } = usePlatformOS();
|
||||
const { handleRedirection } = useIssuePeekOverviewRedirection();
|
||||
|
||||
// handlers
|
||||
const handleIssuePeekOverview = (issue: TIssue) => handleRedirection(workspaceSlug, issue, isMobile);
|
||||
|
||||
const filterParams = getRedirectionFilters(tab);
|
||||
|
||||
const ISSUE_LIST_ITEM: {
|
||||
[key: string]: {
|
||||
[key in TIssuesListTypes]: React.FC<IssueListItemProps>;
|
||||
};
|
||||
} = {
|
||||
assigned: {
|
||||
pending: AssignedUpcomingIssueListItem,
|
||||
upcoming: AssignedUpcomingIssueListItem,
|
||||
overdue: AssignedOverdueIssueListItem,
|
||||
completed: AssignedCompletedIssueListItem,
|
||||
},
|
||||
created: {
|
||||
pending: CreatedUpcomingIssueListItem,
|
||||
upcoming: CreatedUpcomingIssueListItem,
|
||||
overdue: CreatedOverdueIssueListItem,
|
||||
completed: CreatedCompletedIssueListItem,
|
||||
},
|
||||
};
|
||||
|
||||
const issuesList = widgetStats.issues;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="h-full">
|
||||
{isLoading ? (
|
||||
<Loader className="space-y-4 mt-7">
|
||||
<Loader.Item height="25px" />
|
||||
<Loader.Item height="25px" />
|
||||
<Loader.Item height="25px" />
|
||||
<Loader.Item height="25px" />
|
||||
</Loader>
|
||||
) : issuesList.length > 0 ? (
|
||||
<>
|
||||
<div className="mt-7 border-b-[0.5px] border-custom-border-200 grid grid-cols-12 gap-1 text-xs text-custom-text-300 pb-1">
|
||||
<h6
|
||||
className={cn("pl-1 flex items-center gap-1 col-span-7", {
|
||||
"col-span-11": type === "assigned" && tab === "completed",
|
||||
"col-span-9": type === "created" && tab === "completed",
|
||||
})}
|
||||
>
|
||||
Work items
|
||||
<span className="flex-shrink-0 bg-custom-primary-100/20 text-custom-primary-100 text-xs font-medium rounded-xl px-2 flex items-center text-center justify-center">
|
||||
{widgetStats.count}
|
||||
</span>
|
||||
</h6>
|
||||
<h6 className="text-center col-span-1">Priority</h6>
|
||||
{["upcoming", "pending"].includes(tab) && <h6 className="text-center col-span-2">Due date</h6>}
|
||||
{tab === "overdue" && <h6 className="text-center col-span-2">Due by</h6>}
|
||||
{type === "assigned" && tab !== "completed" && <h6 className="text-center col-span-2">Blocked by</h6>}
|
||||
{type === "created" && <h6 className="text-center col-span-2">Assigned to</h6>}
|
||||
</div>
|
||||
<div className="pb-3 mt-2">
|
||||
{issuesList.map((issue) => {
|
||||
const IssueListItem = ISSUE_LIST_ITEM[type][tab];
|
||||
|
||||
if (!IssueListItem) return null;
|
||||
|
||||
return (
|
||||
<IssueListItem
|
||||
key={issue.id}
|
||||
issueId={issue.id}
|
||||
workspaceSlug={workspaceSlug}
|
||||
onClick={handleIssuePeekOverview}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="h-full grid place-items-center my-6">
|
||||
{type === "assigned" && <AssignedIssuesEmptyState type={tab} />}
|
||||
{type === "created" && <CreatedIssuesEmptyState type={tab} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!isLoading && issuesList.length > 0 && (
|
||||
<Link
|
||||
href={`/${workspaceSlug}/workspace-views/${type}/${filterParams}`}
|
||||
className={cn(
|
||||
getButtonStyling("link-primary", "sm"),
|
||||
"w-min my-3 mx-auto py-1 px-2 text-xs hover:bg-custom-primary-100/20"
|
||||
)}
|
||||
>
|
||||
View all work items
|
||||
</Link>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,61 +0,0 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { Tab } from "@headlessui/react";
|
||||
// helpers
|
||||
import { EDurationFilters, FILTERED_ISSUES_TABS_LIST, UNFILTERED_ISSUES_TABS_LIST } from "@plane/constants";
|
||||
import { TIssuesListTypes } from "@plane/types";
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
// types
|
||||
// constants
|
||||
|
||||
type Props = {
|
||||
durationFilter: EDurationFilters;
|
||||
selectedTab: TIssuesListTypes;
|
||||
};
|
||||
|
||||
export const TabsList: React.FC<Props> = observer((props) => {
|
||||
const { durationFilter, selectedTab } = props;
|
||||
|
||||
const tabsList = durationFilter === "none" ? UNFILTERED_ISSUES_TABS_LIST : FILTERED_ISSUES_TABS_LIST;
|
||||
const selectedTabIndex = tabsList.findIndex((tab) => tab.key === selectedTab);
|
||||
|
||||
return (
|
||||
<Tab.List
|
||||
as="div"
|
||||
className="relative border-[0.5px] border-custom-border-200 rounded bg-custom-background-80 p-[1px] grid"
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${tabsList.length}, 1fr)`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute top-1/2 left-[1px] bg-custom-background-100 rounded-[3px] transition-all duration-500 ease-in-out",
|
||||
{
|
||||
// right shadow
|
||||
"shadow-[2px_0_8px_rgba(167,169,174,0.15)]": selectedTabIndex !== tabsList.length - 1,
|
||||
// left shadow
|
||||
"shadow-[-2px_0_8px_rgba(167,169,174,0.15)]": selectedTabIndex !== 0,
|
||||
}
|
||||
)}
|
||||
style={{
|
||||
height: "calc(100% - 2px)",
|
||||
width: `calc(${100 / tabsList.length}% - 1px)`,
|
||||
transform: `translate(${selectedTabIndex * 100}%, -50%)`,
|
||||
}}
|
||||
/>
|
||||
{tabsList.map((tab) => (
|
||||
<Tab
|
||||
key={tab.key}
|
||||
className={cn(
|
||||
"relative z-[1] font-semibold text-xs rounded-[3px] py-1.5 text-custom-text-400 focus:outline-none transition duration-500",
|
||||
{
|
||||
"text-custom-text-100": selectedTab === tab.key,
|
||||
"hover:text-custom-text-300": selectedTab !== tab.key,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<span className="scale-110">{tab.label}</span>
|
||||
</Tab>
|
||||
))}
|
||||
</Tab.List>
|
||||
);
|
||||
});
|
||||
@@ -1,24 +0,0 @@
|
||||
"use client";
|
||||
|
||||
// ui
|
||||
import { Loader } from "@plane/ui";
|
||||
|
||||
export const AssignedIssuesWidgetLoader = () => (
|
||||
<Loader className="bg-custom-background-100 p-6 rounded-xl">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Loader.Item height="17px" width="35%" />
|
||||
<Loader.Item height="17px" width="10%" />
|
||||
</div>
|
||||
<div className="mt-6 space-y-7">
|
||||
<Loader.Item height="29px" />
|
||||
<Loader.Item height="17px" width="10%" />
|
||||
</div>
|
||||
<div className="mt-11 space-y-10">
|
||||
<Loader.Item height="11px" width="35%" />
|
||||
<Loader.Item height="11px" width="45%" />
|
||||
<Loader.Item height="11px" width="55%" />
|
||||
<Loader.Item height="11px" width="40%" />
|
||||
<Loader.Item height="11px" width="60%" />
|
||||
</div>
|
||||
</Loader>
|
||||
);
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./loader";
|
||||
@@ -1,17 +0,0 @@
|
||||
"use client";
|
||||
|
||||
// ui
|
||||
import { Loader } from "@plane/ui";
|
||||
|
||||
export const IssuesByPriorityWidgetLoader = () => (
|
||||
<Loader className="bg-custom-background-100 rounded-xl p-6">
|
||||
<Loader.Item height="17px" width="35%" />
|
||||
<div className="flex items-center gap-1 h-full">
|
||||
<Loader.Item height="119px" width="14%" />
|
||||
<Loader.Item height="119px" width="26%" />
|
||||
<Loader.Item height="119px" width="36%" />
|
||||
<Loader.Item height="119px" width="18%" />
|
||||
<Loader.Item height="119px" width="6%" />
|
||||
</div>
|
||||
</Loader>
|
||||
);
|
||||
@@ -1,24 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import range from "lodash/range";
|
||||
// ui
|
||||
import { Loader } from "@plane/ui";
|
||||
|
||||
export const IssuesByStateGroupWidgetLoader = () => (
|
||||
<Loader className="bg-custom-background-100 rounded-xl p-6">
|
||||
<Loader.Item height="17px" width="35%" />
|
||||
<div className="flex items-center justify-between gap-32 mt-12 pl-6">
|
||||
<div className="w-1/2 grid place-items-center">
|
||||
<div className="rounded-full overflow-hidden relative flex-shrink-0 h-[184px] w-[184px]">
|
||||
<Loader.Item height="184px" width="184px" />
|
||||
<div className="absolute h-[100px] w-[100px] top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-custom-background-100 rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-1/2 space-y-7 flex-shrink-0">
|
||||
{range(5).map((index) => (
|
||||
<Loader.Item key={index} height="11px" width="100%" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Loader>
|
||||
);
|
||||
@@ -1,31 +0,0 @@
|
||||
// components
|
||||
import { TWidgetKeys } from "@plane/types";
|
||||
import { AssignedIssuesWidgetLoader } from "./assigned-issues";
|
||||
import { IssuesByPriorityWidgetLoader } from "./issues-by-priority";
|
||||
import { IssuesByStateGroupWidgetLoader } from "./issues-by-state-group";
|
||||
import { OverviewStatsWidgetLoader } from "./overview-stats";
|
||||
import { RecentActivityWidgetLoader } from "./recent-activity";
|
||||
import { RecentCollaboratorsWidgetLoader } from "./recent-collaborators";
|
||||
import { RecentProjectsWidgetLoader } from "./recent-projects";
|
||||
// types
|
||||
|
||||
type Props = {
|
||||
widgetKey: TWidgetKeys;
|
||||
};
|
||||
|
||||
export const WidgetLoader: React.FC<Props> = (props) => {
|
||||
const { widgetKey } = props;
|
||||
|
||||
const loaders = {
|
||||
overview_stats: <OverviewStatsWidgetLoader />,
|
||||
assigned_issues: <AssignedIssuesWidgetLoader />,
|
||||
created_issues: <AssignedIssuesWidgetLoader />,
|
||||
issues_by_state_groups: <IssuesByStateGroupWidgetLoader />,
|
||||
issues_by_priority: <IssuesByPriorityWidgetLoader />,
|
||||
recent_activity: <RecentActivityWidgetLoader />,
|
||||
recent_projects: <RecentProjectsWidgetLoader />,
|
||||
recent_collaborators: <RecentCollaboratorsWidgetLoader />,
|
||||
};
|
||||
|
||||
return loaders[widgetKey];
|
||||
};
|
||||
@@ -1,16 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import range from "lodash/range";
|
||||
// ui
|
||||
import { Loader } from "@plane/ui";
|
||||
|
||||
export const OverviewStatsWidgetLoader = () => (
|
||||
<Loader className="bg-custom-background-100 rounded-xl py-6 grid grid-cols-4 gap-36 px-12">
|
||||
{range(4).map((index) => (
|
||||
<div key={index} className="space-y-3">
|
||||
<Loader.Item height="11px" width="50%" />
|
||||
<Loader.Item height="15px" />
|
||||
</div>
|
||||
))}
|
||||
</Loader>
|
||||
);
|
||||
@@ -1,22 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import range from "lodash/range";
|
||||
// ui
|
||||
import { Loader } from "@plane/ui";
|
||||
|
||||
export const RecentActivityWidgetLoader = () => (
|
||||
<Loader className="bg-custom-background-100 rounded-xl p-6 space-y-6">
|
||||
<Loader.Item height="17px" width="35%" />
|
||||
{range(7).map((index) => (
|
||||
<div key={index} className="flex items-start gap-3.5">
|
||||
<div className="flex-shrink-0">
|
||||
<Loader.Item height="16px" width="16px" />
|
||||
</div>
|
||||
<div className="space-y-3 flex-shrink-0 w-full">
|
||||
<Loader.Item height="15px" width="70%" />
|
||||
<Loader.Item height="11px" width="10%" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</Loader>
|
||||
);
|
||||
@@ -1,20 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import range from "lodash/range";
|
||||
// ui
|
||||
import { Loader } from "@plane/ui";
|
||||
|
||||
export const RecentCollaboratorsWidgetLoader = () => (
|
||||
<>
|
||||
{range(8).map((index) => (
|
||||
<Loader key={index} className="bg-custom-background-100 rounded-xl px-6 pb-12">
|
||||
<div className="space-y-11 flex flex-col items-center">
|
||||
<div className="rounded-full overflow-hidden h-[69px] w-[69px]">
|
||||
<Loader.Item height="69px" width="69px" />
|
||||
</div>
|
||||
<Loader.Item height="11px" width="70%" />
|
||||
</div>
|
||||
</Loader>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
@@ -1,22 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import range from "lodash/range";
|
||||
// ui
|
||||
import { Loader } from "@plane/ui";
|
||||
|
||||
export const RecentProjectsWidgetLoader = () => (
|
||||
<Loader className="bg-custom-background-100 rounded-xl p-6 space-y-6">
|
||||
<Loader.Item height="17px" width="35%" />
|
||||
{range(5).map((index) => (
|
||||
<div key={index} className="flex items-center gap-6">
|
||||
<div className="flex-shrink-0">
|
||||
<Loader.Item height="60px" width="60px" />
|
||||
</div>
|
||||
<div className="space-y-3 flex-shrink-0 w-full">
|
||||
<Loader.Item height="17px" width="42%" />
|
||||
<Loader.Item height="23px" width="10%" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</Loader>
|
||||
);
|
||||
@@ -1,100 +0,0 @@
|
||||
import { useEffect } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import { TOverviewStatsWidgetResponse } from "@plane/types";
|
||||
// hooks
|
||||
import { Card, ECardSpacing } from "@plane/ui";
|
||||
import { WidgetLoader } from "@/components/dashboard/widgets";
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
import { renderFormattedPayloadDate } from "@/helpers/date-time.helper";
|
||||
import { useDashboard } from "@/hooks/store";
|
||||
// components
|
||||
// helpers
|
||||
// types
|
||||
|
||||
export type WidgetProps = {
|
||||
dashboardId: string;
|
||||
workspaceSlug: string;
|
||||
};
|
||||
|
||||
const WIDGET_KEY = "overview_stats";
|
||||
|
||||
export const OverviewStatsWidget: React.FC<WidgetProps> = observer((props) => {
|
||||
const { dashboardId, workspaceSlug } = props;
|
||||
// store hooks
|
||||
const { fetchWidgetStats, getWidgetStats } = useDashboard();
|
||||
// derived values
|
||||
const widgetStats = getWidgetStats<TOverviewStatsWidgetResponse>(workspaceSlug, dashboardId, WIDGET_KEY);
|
||||
|
||||
const today = renderFormattedPayloadDate(new Date());
|
||||
const STATS_LIST = [
|
||||
{
|
||||
key: "assigned",
|
||||
title: "Work items assigned",
|
||||
count: widgetStats?.assigned_issues_count,
|
||||
link: `/${workspaceSlug}/workspace-views/assigned`,
|
||||
},
|
||||
{
|
||||
key: "overdue",
|
||||
title: "Work items overdue",
|
||||
count: widgetStats?.pending_issues_count,
|
||||
link: `/${workspaceSlug}/workspace-views/assigned/?state_group=backlog,unstarted,started&target_date=${today};before`,
|
||||
},
|
||||
{
|
||||
key: "created",
|
||||
title: "Work items created",
|
||||
count: widgetStats?.created_issues_count,
|
||||
link: `/${workspaceSlug}/workspace-views/created`,
|
||||
},
|
||||
{
|
||||
key: "completed",
|
||||
title: "Work items completed",
|
||||
count: widgetStats?.completed_issues_count,
|
||||
link: `/${workspaceSlug}/workspace-views/assigned?state_group=completed`,
|
||||
},
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
fetchWidgetStats(workspaceSlug, dashboardId, {
|
||||
widget_key: WIDGET_KEY,
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
if (!widgetStats) return <WidgetLoader widgetKey={WIDGET_KEY} />;
|
||||
|
||||
return (
|
||||
<Card
|
||||
spacing={ECardSpacing.SM}
|
||||
className="flex-row grid lg:grid-cols-4 md:grid-cols-2 sm:grid-cols-2 grid-cols-2 space-y-0 p-0.5
|
||||
[&>div>a>div]:border-r
|
||||
[&>div:last-child>a>div]:border-0
|
||||
[&>div>a>div]:border-custom-border-200
|
||||
[&>div:nth-child(2)>a>div]:border-0
|
||||
[&>div:nth-child(2)>a>div]:lg:border-r
|
||||
"
|
||||
>
|
||||
{STATS_LIST.map((stat, index) => (
|
||||
<div
|
||||
key={stat.key}
|
||||
className={cn(
|
||||
`w-full flex flex-col gap-2 hover:bg-custom-background-80`,
|
||||
index === 0 ? "rounded-l-md" : "",
|
||||
index === STATS_LIST.length - 1 ? "rounded-r-md" : "",
|
||||
index === 1 ? "rounded-tr-xl lg:rounded-[0px]" : "",
|
||||
index == 2 ? "rounded-bl-xl lg:rounded-[0px]" : ""
|
||||
)}
|
||||
>
|
||||
<Link href={stat.link} className="py-4 duration-300 rounded-[10px] w-full ">
|
||||
<div className={`relative flex pl-10 sm:pl-20 md:pl-20 lg:pl-20 items-center`}>
|
||||
<div>
|
||||
<h5 className="font-semibold text-xl">{stat.count}</h5>
|
||||
<p className="text-custom-text-300 text-sm xl:text-base">{stat.title}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
);
|
||||
});
|
||||
@@ -1,109 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import { History } from "lucide-react";
|
||||
// types
|
||||
import { TRecentActivityWidgetResponse } from "@plane/types";
|
||||
// components
|
||||
import { Card, Avatar, getButtonStyling } from "@plane/ui";
|
||||
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 { getFileURL } from "@/helpers/file.helper";
|
||||
// hooks
|
||||
import { useDashboard, useUser } from "@/hooks/store";
|
||||
|
||||
const WIDGET_KEY = "recent_activity";
|
||||
|
||||
export const RecentActivityWidget: React.FC<WidgetProps> = observer((props) => {
|
||||
const { dashboardId, workspaceSlug } = props;
|
||||
// store hooks
|
||||
const { data: currentUser } = useUser();
|
||||
// derived values
|
||||
const { fetchWidgetStats, getWidgetStats } = useDashboard();
|
||||
const widgetStats = getWidgetStats<TRecentActivityWidgetResponse[]>(workspaceSlug, dashboardId, WIDGET_KEY);
|
||||
const redirectionLink = `/${workspaceSlug}/profile/${currentUser?.id}/activity`;
|
||||
|
||||
useEffect(() => {
|
||||
fetchWidgetStats(workspaceSlug, dashboardId, {
|
||||
widget_key: WIDGET_KEY,
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
if (!widgetStats) return <WidgetLoader widgetKey={WIDGET_KEY} />;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Link href={redirectionLink} className="text-lg font-semibold text-custom-text-300 hover:underline mb-4">
|
||||
Your work item activities
|
||||
</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>
|
||||
{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>
|
||||
))}
|
||||
<Link
|
||||
href={redirectionLink}
|
||||
className={cn(
|
||||
getButtonStyling("link-primary", "sm"),
|
||||
"mx-auto w-min px-2 py-1 text-xs hover:bg-custom-primary-100/20"
|
||||
)}
|
||||
>
|
||||
View all
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid h-full place-items-center">
|
||||
<RecentActivityEmptyState />
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
});
|
||||
@@ -1,154 +0,0 @@
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
import sortBy from "lodash/sortBy";
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import useSWR from "swr";
|
||||
// types
|
||||
import { TRecentCollaboratorsWidgetResponse } from "@plane/types";
|
||||
// ui
|
||||
import { Avatar } from "@plane/ui";
|
||||
// helpers
|
||||
import { getFileURL } from "@/helpers/file.helper";
|
||||
// hooks
|
||||
import { useDashboard, useMember, useUser } from "@/hooks/store";
|
||||
// components
|
||||
import { WidgetLoader } from "../loaders";
|
||||
|
||||
type CollaboratorListItemProps = {
|
||||
issueCount: number;
|
||||
userId: string;
|
||||
workspaceSlug: string;
|
||||
};
|
||||
|
||||
const CollaboratorListItem: React.FC<CollaboratorListItemProps> = observer((props) => {
|
||||
const { issueCount, userId, workspaceSlug } = props;
|
||||
// store hooks
|
||||
const { data: currentUser } = useUser();
|
||||
const { getUserDetails } = useMember();
|
||||
// derived values
|
||||
const userDetails = getUserDetails(userId);
|
||||
const isCurrentUser = userId === currentUser?.id;
|
||||
|
||||
if (!userDetails || userDetails.is_bot) return null;
|
||||
|
||||
return (
|
||||
<Link href={`/${workspaceSlug}/profile/${userId}`} className="group text-center">
|
||||
<div className="flex justify-center">
|
||||
<Avatar
|
||||
src={getFileURL(userDetails.avatar_url)}
|
||||
name={userDetails.display_name}
|
||||
size={69}
|
||||
className="!text-3xl !font-medium"
|
||||
showTooltip={false}
|
||||
/>
|
||||
</div>
|
||||
<h6 className="mt-6 truncate text-xs font-semibold group-hover:underline">
|
||||
{isCurrentUser ? "You" : userDetails?.display_name}
|
||||
</h6>
|
||||
<p className="mt-2 text-sm">
|
||||
{issueCount} active work items{issueCount > 1 ? "s" : ""}
|
||||
</p>
|
||||
</Link>
|
||||
);
|
||||
});
|
||||
|
||||
type CollaboratorsListProps = {
|
||||
dashboardId: string;
|
||||
searchQuery?: string;
|
||||
workspaceSlug: string;
|
||||
};
|
||||
|
||||
const WIDGET_KEY = "recent_collaborators";
|
||||
|
||||
export const CollaboratorsList: React.FC<CollaboratorsListProps> = (props) => {
|
||||
const { dashboardId, searchQuery = "", workspaceSlug } = props;
|
||||
|
||||
// state
|
||||
const [visibleItems, setVisibleItems] = useState(16);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
// store hooks
|
||||
const { fetchWidgetStats } = useDashboard();
|
||||
const { getUserDetails } = useMember();
|
||||
const { data: currentUser } = useUser();
|
||||
|
||||
const { data: widgetStats } = useSWR(
|
||||
workspaceSlug && dashboardId ? `WIDGET_STATS_${workspaceSlug}_${dashboardId}` : null,
|
||||
workspaceSlug && dashboardId
|
||||
? () =>
|
||||
fetchWidgetStats(workspaceSlug, dashboardId, {
|
||||
widget_key: WIDGET_KEY,
|
||||
})
|
||||
: null
|
||||
) as {
|
||||
data: TRecentCollaboratorsWidgetResponse[] | undefined;
|
||||
};
|
||||
|
||||
if (!widgetStats)
|
||||
return (
|
||||
<div className="mt-7 mb-6 grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 xl:grid-cols-8 gap-2 gap-y-8">
|
||||
<WidgetLoader widgetKey={WIDGET_KEY} />
|
||||
</div>
|
||||
);
|
||||
|
||||
const sortedStats = sortBy(widgetStats, [(user) => user?.user_id !== currentUser?.id]);
|
||||
|
||||
const filteredStats = sortedStats.filter((user) => {
|
||||
if (!user) return false;
|
||||
const userDetails = getUserDetails(user?.user_id);
|
||||
if (!userDetails || userDetails.is_bot) return false;
|
||||
const { display_name, first_name, last_name } = userDetails;
|
||||
const searchLower = searchQuery.toLowerCase();
|
||||
return (
|
||||
display_name?.toLowerCase().includes(searchLower) ||
|
||||
first_name?.toLowerCase().includes(searchLower) ||
|
||||
last_name?.toLowerCase().includes(searchLower)
|
||||
);
|
||||
});
|
||||
|
||||
// Update the displayedStats to always use the visibleItems limit
|
||||
const handleLoadMore = () => {
|
||||
setVisibleItems((prev) => {
|
||||
const newValue = prev + 16;
|
||||
if (newValue >= filteredStats.length) {
|
||||
setIsExpanded(true);
|
||||
return filteredStats.length;
|
||||
}
|
||||
return newValue;
|
||||
});
|
||||
};
|
||||
|
||||
const handleHide = () => {
|
||||
setVisibleItems(16);
|
||||
setIsExpanded(false);
|
||||
};
|
||||
|
||||
const displayedStats = filteredStats.slice(0, visibleItems);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mt-7 mb-6 grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 xl:grid-cols-8 gap-2 gap-y-8">
|
||||
{displayedStats?.map((user) => (
|
||||
<CollaboratorListItem
|
||||
key={user?.user_id}
|
||||
issueCount={user?.active_issue_count}
|
||||
userId={user?.user_id}
|
||||
workspaceSlug={workspaceSlug}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{filteredStats.length > visibleItems && !isExpanded && (
|
||||
<div className="py-4 flex justify-center items-center text-sm font-medium" onClick={handleLoadMore}>
|
||||
<div className="text-custom-primary-90 hover:text-custom-primary-100 transition-all cursor-pointer">
|
||||
Load more
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isExpanded && (
|
||||
<div className="py-4 flex justify-center items-center text-sm font-medium" onClick={handleHide}>
|
||||
<div className="text-custom-primary-90 hover:text-custom-primary-100 transition-all cursor-pointer">Hide</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./root";
|
||||
@@ -1,36 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { Search } from "lucide-react";
|
||||
// types
|
||||
import { Card } from "@plane/ui";
|
||||
import { WidgetProps } from "@/components/dashboard/widgets";
|
||||
// components
|
||||
import { CollaboratorsList } from "./collaborators-list";
|
||||
|
||||
export const RecentCollaboratorsWidget: React.FC<WidgetProps> = (props) => {
|
||||
const { dashboardId, workspaceSlug } = props;
|
||||
// states
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<div className="flex flex-col sm:flex-row items-start justify-between mb-6">
|
||||
<div>
|
||||
<h4 className="text-lg font-semibold text-custom-text-300">Collaborators</h4>
|
||||
<p className="mt-2 text-xs font-medium text-custom-text-300">
|
||||
View and find all members you collaborate with across projects
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-5 sm:mt-0 flex min-w-full md:min-w-72 items-center justify-start gap-2 rounded-md border border-custom-border-200 px-2.5 py-1.5 placeholder:text-custom-text-400">
|
||||
<Search className="h-3.5 w-3.5 text-custom-text-400" />
|
||||
<input
|
||||
className="w-full border-none bg-transparent text-sm focus:outline-none"
|
||||
placeholder="Search for collaborators"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<CollaboratorsList dashboardId={dashboardId} searchQuery={searchQuery} workspaceSlug={workspaceSlug} />
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -1,134 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import { Plus } from "lucide-react";
|
||||
// plane types
|
||||
import { PROJECT_BACKGROUND_COLORS, EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
||||
import { TRecentProjectsWidgetResponse } from "@plane/types";
|
||||
// plane ui
|
||||
import { Avatar, AvatarGroup, Card } from "@plane/ui";
|
||||
// components
|
||||
import { Logo } from "@/components/common";
|
||||
import { WidgetLoader, WidgetProps } from "@/components/dashboard/widgets";
|
||||
// constants
|
||||
// helpers
|
||||
import { getFileURL } from "@/helpers/file.helper";
|
||||
// hooks
|
||||
import {
|
||||
useEventTracker,
|
||||
useDashboard,
|
||||
useProject,
|
||||
useCommandPalette,
|
||||
useUserPermissions,
|
||||
useMember,
|
||||
} from "@/hooks/store";
|
||||
// plane web constants
|
||||
|
||||
const WIDGET_KEY = "recent_projects";
|
||||
|
||||
type ProjectListItemProps = {
|
||||
projectId: string;
|
||||
workspaceSlug: string;
|
||||
};
|
||||
|
||||
const ProjectListItem: React.FC<ProjectListItemProps> = observer((props) => {
|
||||
const { projectId, workspaceSlug } = props;
|
||||
// store hooks
|
||||
const { getProjectById } = useProject();
|
||||
const { getUserDetails } = useMember();
|
||||
// derived values
|
||||
const projectDetails = getProjectById(projectId);
|
||||
|
||||
const randomBgColor = PROJECT_BACKGROUND_COLORS[Math.floor(Math.random() * PROJECT_BACKGROUND_COLORS.length)];
|
||||
|
||||
if (!projectDetails) return null;
|
||||
|
||||
return (
|
||||
<Link href={`/${workspaceSlug}/projects/${projectId}/issues`} className="group flex items-center gap-8">
|
||||
<div
|
||||
className={`grid h-[3.375rem] w-[3.375rem] flex-shrink-0 place-items-center rounded border border-transparent ${randomBgColor}`}
|
||||
>
|
||||
<div className="grid h-7 w-7 place-items-center">
|
||||
<Logo logo={projectDetails.logo_props} size={20} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-grow truncate">
|
||||
<h6 className="truncate text-sm font-medium text-custom-text-300 group-hover:text-custom-text-100 group-hover:underline">
|
||||
{projectDetails.name}
|
||||
</h6>
|
||||
<div className="mt-2">
|
||||
<AvatarGroup>
|
||||
{projectDetails.members?.map((memberId) => {
|
||||
const userDetails = getUserDetails(memberId);
|
||||
if (!userDetails) return null;
|
||||
return (
|
||||
<Avatar key={userDetails.id} src={getFileURL(userDetails.avatar_url)} name={userDetails.display_name} />
|
||||
);
|
||||
})}
|
||||
</AvatarGroup>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
});
|
||||
|
||||
export const RecentProjectsWidget: React.FC<WidgetProps> = observer((props) => {
|
||||
const { dashboardId, workspaceSlug } = props;
|
||||
// store hooks
|
||||
const { toggleCreateProjectModal } = useCommandPalette();
|
||||
const { setTrackElement } = useEventTracker();
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
const { fetchWidgetStats, getWidgetStats } = useDashboard();
|
||||
// derived values
|
||||
const widgetStats = getWidgetStats<TRecentProjectsWidgetResponse>(workspaceSlug, dashboardId, WIDGET_KEY);
|
||||
const canCreateProject = allowPermissions(
|
||||
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
|
||||
EUserPermissionsLevel.WORKSPACE
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchWidgetStats(workspaceSlug, dashboardId, {
|
||||
widget_key: WIDGET_KEY,
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
if (!widgetStats) return <WidgetLoader widgetKey={WIDGET_KEY} />;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Link
|
||||
href={`/${workspaceSlug}/projects`}
|
||||
className="text-lg font-semibold text-custom-text-300 hover:underline mb-4"
|
||||
>
|
||||
Recent projects
|
||||
</Link>
|
||||
<div className="mt-4 space-y-8">
|
||||
{canCreateProject && (
|
||||
<button
|
||||
type="button"
|
||||
className="group flex items-center gap-8"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setTrackElement("Sidebar");
|
||||
toggleCreateProjectModal(true);
|
||||
}}
|
||||
>
|
||||
<div className="grid h-[3.375rem] w-[3.375rem] flex-shrink-0 place-items-center rounded border border-dashed border-custom-primary-60 bg-custom-primary-100/20 text-custom-primary-100">
|
||||
<Plus className="h-6 w-6" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-custom-text-300 group-hover:text-custom-text-100 group-hover:underline">
|
||||
Create new project
|
||||
</p>
|
||||
</button>
|
||||
)}
|
||||
{widgetStats.map((projectId) => (
|
||||
<ProjectListItem key={projectId} projectId={projectId} workspaceSlug={workspaceSlug} />
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
});
|
||||
@@ -4,7 +4,7 @@ import React, { useEffect, useRef, useState } from "react";
|
||||
import { Placement } from "@popperjs/core";
|
||||
import { DateRange, Matcher } from "react-day-picker";
|
||||
import { usePopper } from "react-popper";
|
||||
import { ArrowRight, CalendarCheck2, CalendarDays } from "lucide-react";
|
||||
import { ArrowRight, CalendarCheck2, CalendarDays, X } from "lucide-react";
|
||||
import { Combobox } from "@headlessui/react";
|
||||
// plane imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
@@ -17,6 +17,7 @@ import { renderFormattedDate } from "@/helpers/date-time.helper";
|
||||
import { useDropdown } from "@/hooks/use-dropdown";
|
||||
// components
|
||||
import { DropdownButton } from "./buttons";
|
||||
import { MergedDateDisplay } from "./merged-date";
|
||||
// types
|
||||
import { TButtonVariants } from "./types";
|
||||
|
||||
@@ -30,11 +31,14 @@ type Props = {
|
||||
buttonVariant: TButtonVariants;
|
||||
cancelButtonText?: string;
|
||||
className?: string;
|
||||
clearIconClassName?: string;
|
||||
disabled?: boolean;
|
||||
hideIcon?: {
|
||||
from?: boolean;
|
||||
to?: boolean;
|
||||
};
|
||||
isClearable?: boolean;
|
||||
mergeDates?: boolean;
|
||||
minDate?: Date;
|
||||
maxDate?: Date;
|
||||
onSelect?: (range: DateRange | undefined) => void;
|
||||
@@ -65,11 +69,14 @@ export const DateRangeDropdown: React.FC<Props> = (props) => {
|
||||
buttonToDateClassName,
|
||||
buttonVariant,
|
||||
className,
|
||||
clearIconClassName = "",
|
||||
disabled = false,
|
||||
hideIcon = {
|
||||
from: true,
|
||||
to: true,
|
||||
},
|
||||
isClearable = false,
|
||||
mergeDates,
|
||||
minDate,
|
||||
maxDate,
|
||||
onSelect,
|
||||
@@ -118,20 +125,18 @@ export const DateRangeDropdown: React.FC<Props> = (props) => {
|
||||
setIsOpen,
|
||||
});
|
||||
|
||||
const handleClose = () => {
|
||||
if (!isOpen) return;
|
||||
setIsOpen(false);
|
||||
setDateRange({
|
||||
from: value.from,
|
||||
to: value.to,
|
||||
});
|
||||
if (referenceElement) referenceElement.blur();
|
||||
};
|
||||
|
||||
const disabledDays: Matcher[] = [];
|
||||
if (minDate) disabledDays.push({ before: minDate });
|
||||
if (maxDate) disabledDays.push({ after: maxDate });
|
||||
|
||||
const clearDates = () => {
|
||||
const clearedRange = { from: undefined, to: undefined };
|
||||
setDateRange(clearedRange);
|
||||
onSelect?.(clearedRange);
|
||||
};
|
||||
|
||||
const hasDisplayedDates = dateRange.from || dateRange.to;
|
||||
|
||||
useEffect(() => {
|
||||
setDateRange(value);
|
||||
}, [value]);
|
||||
@@ -158,9 +163,9 @@ export const DateRangeDropdown: React.FC<Props> = (props) => {
|
||||
tooltipContent={
|
||||
customTooltipContent ?? (
|
||||
<>
|
||||
{dateRange.from ? renderFormattedDate(dateRange.from) : "N/A"}
|
||||
{" - "}
|
||||
{dateRange.to ? renderFormattedDate(dateRange.to) : "N/A"}
|
||||
{dateRange.from ? renderFormattedDate(dateRange.from) : ""}
|
||||
{dateRange.from && dateRange.to ? " - " : ""}
|
||||
{dateRange.to ? renderFormattedDate(dateRange.to) : ""}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -168,19 +173,70 @@ export const DateRangeDropdown: React.FC<Props> = (props) => {
|
||||
variant={buttonVariant}
|
||||
renderToolTipByDefault={renderByDefault}
|
||||
>
|
||||
<span
|
||||
className={cn("h-full flex items-center justify-center gap-1 rounded-sm flex-grow", buttonFromDateClassName)}
|
||||
>
|
||||
{!hideIcon.from && <CalendarDays className="h-3 w-3 flex-shrink-0" />}
|
||||
{dateRange.from ? renderFormattedDate(dateRange.from) : renderPlaceholder ? placeholder.from : ""}
|
||||
</span>
|
||||
<ArrowRight className="h-3 w-3 flex-shrink-0" />
|
||||
<span
|
||||
className={cn("h-full flex items-center justify-center gap-1 rounded-sm flex-grow", buttonToDateClassName)}
|
||||
>
|
||||
{!hideIcon.to && <CalendarCheck2 className="h-3 w-3 flex-shrink-0" />}
|
||||
{dateRange.to ? renderFormattedDate(dateRange.to) : renderPlaceholder ? placeholder.to : ""}
|
||||
</span>
|
||||
{mergeDates ? (
|
||||
// Merged date display
|
||||
<div className="flex items-center gap-1.5 w-full">
|
||||
{!hideIcon.from && <CalendarDays className="h-3 w-3 flex-shrink-0" />}
|
||||
{dateRange.from || dateRange.to ? (
|
||||
<MergedDateDisplay
|
||||
startDate={dateRange.from}
|
||||
endDate={dateRange.to}
|
||||
className="flex-grow truncate text-xs"
|
||||
/>
|
||||
) : (
|
||||
renderPlaceholder && (
|
||||
<>
|
||||
<span className="text-custom-text-400">{placeholder.from}</span>
|
||||
<ArrowRight className="h-3 w-3 flex-shrink-0 text-custom-text-400" />
|
||||
<span className="text-custom-text-400">{placeholder.to}</span>
|
||||
</>
|
||||
)
|
||||
)}
|
||||
{isClearable && !disabled && hasDisplayedDates && (
|
||||
<X
|
||||
className={cn("h-2.5 w-2.5 flex-shrink-0 cursor-pointer", clearIconClassName)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
clearDates();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
// Original separate date display
|
||||
<>
|
||||
<span
|
||||
className={cn(
|
||||
"h-full flex items-center justify-center gap-1 rounded-sm flex-grow",
|
||||
buttonFromDateClassName
|
||||
)}
|
||||
>
|
||||
{!hideIcon.from && <CalendarDays className="h-3 w-3 flex-shrink-0" />}
|
||||
{dateRange.from ? renderFormattedDate(dateRange.from) : renderPlaceholder ? placeholder.from : ""}
|
||||
</span>
|
||||
<ArrowRight className="h-3 w-3 flex-shrink-0" />
|
||||
<span
|
||||
className={cn(
|
||||
"h-full flex items-center justify-center gap-1 rounded-sm flex-grow",
|
||||
buttonToDateClassName
|
||||
)}
|
||||
>
|
||||
{!hideIcon.to && <CalendarCheck2 className="h-3 w-3 flex-shrink-0" />}
|
||||
{dateRange.to ? renderFormattedDate(dateRange.to) : renderPlaceholder ? placeholder.to : ""}
|
||||
</span>
|
||||
{isClearable && !disabled && hasDisplayedDates && (
|
||||
<X
|
||||
className={cn("h-2.5 w-2.5 flex-shrink-0 cursor-pointer ml-1", clearIconClassName)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
clearDates();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</DropdownButton>
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ export * from "./cycle";
|
||||
export * from "./date-range";
|
||||
export * from "./date";
|
||||
export * from "./estimate";
|
||||
export * from "./merged-date";
|
||||
export * from "./module";
|
||||
export * from "./priority";
|
||||
export * from "./project";
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// helpers
|
||||
import { formatDateRange } from "@plane/utils";
|
||||
import { getDate } from "@/helpers/date-time.helper";
|
||||
|
||||
type Props = {
|
||||
startDate: Date | string | null | undefined;
|
||||
endDate: Date | string | null | undefined;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats merged date range display with smart formatting
|
||||
* - Single date: "Jan 24, 2025"
|
||||
* - Same year, same month: "Jan 24 - 28, 2025"
|
||||
* - Same year, different month: "Jan 24 - Feb 6, 2025"
|
||||
* - Different year: "Dec 28, 2024 - Jan 4, 2025"
|
||||
*/
|
||||
export const MergedDateDisplay: React.FC<Props> = observer((props) => {
|
||||
const { startDate, endDate, className = "" } = props;
|
||||
|
||||
// Parse dates
|
||||
const parsedStartDate = getDate(startDate);
|
||||
const parsedEndDate = getDate(endDate);
|
||||
|
||||
const displayText = formatDateRange(parsedStartDate, parsedEndDate);
|
||||
|
||||
if (!displayText) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <span className={className}>{displayText}</span>;
|
||||
});
|
||||
+42
-24
@@ -1,11 +1,10 @@
|
||||
// plane imports
|
||||
import { SyntheticEvent } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { CalendarClock } from "lucide-react";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { IIssueDisplayProperties, TIssue } from "@plane/types";
|
||||
// components
|
||||
import { PriorityDropdown, MemberDropdown, StateDropdown, DateDropdown } from "@/components/dropdowns";
|
||||
import { PriorityDropdown, MemberDropdown, StateDropdown, DateRangeDropdown } from "@/components/dropdowns";
|
||||
// hooks
|
||||
import { WithDisplayPropertiesHOC } from "@/components/issues/issue-layouts/properties/with-display-properties-HOC";
|
||||
import { getDate, renderFormattedPayloadDate } from "@/helpers/date-time.helper";
|
||||
@@ -37,6 +36,22 @@ export const SubIssuesListItemProperties: React.FC<Props> = observer((props) =>
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
const handleStartDate = (date: Date | null) => {
|
||||
if (issue.project_id) {
|
||||
updateSubIssue(workspaceSlug, issue.project_id, parentIssueId, issueId, {
|
||||
start_date: date ? renderFormattedPayloadDate(date) : null,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleTargetDate = (date: Date | null) => {
|
||||
if (issue.project_id) {
|
||||
updateSubIssue(workspaceSlug, issue.project_id, parentIssueId, issueId, {
|
||||
target_date: date ? renderFormattedPayloadDate(date) : null,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (!displayProperties) return <></>;
|
||||
|
||||
const maxDate = getDate(issue.target_date);
|
||||
@@ -88,29 +103,32 @@ export const SubIssuesListItemProperties: React.FC<Props> = observer((props) =>
|
||||
</div>
|
||||
</WithDisplayPropertiesHOC>
|
||||
|
||||
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="due_date">
|
||||
<div className="h-5 flex-shrink-0" onFocus={handleEventPropagation} onClick={handleEventPropagation}>
|
||||
<DateDropdown
|
||||
value={issue.target_date ?? null}
|
||||
onChange={(val) =>
|
||||
issue.project_id &&
|
||||
updateSubIssue(
|
||||
workspaceSlug,
|
||||
issue.project_id,
|
||||
parentIssueId,
|
||||
issueId,
|
||||
{
|
||||
target_date: val ? renderFormattedPayloadDate(val) : null,
|
||||
},
|
||||
{ ...issue }
|
||||
)
|
||||
}
|
||||
maxDate={maxDate}
|
||||
placeholder={t("common.order_by.due_date")}
|
||||
icon={<CalendarClock className="h-3 w-3 flex-shrink-0" />}
|
||||
buttonVariant={issue.target_date ? "border-with-text" : "border-without-text"}
|
||||
optionsClassName="z-30"
|
||||
{/* merged dates */}
|
||||
<WithDisplayPropertiesHOC
|
||||
displayProperties={displayProperties}
|
||||
displayPropertyKey={["start_date", "due_date"]}
|
||||
shouldRenderProperty={(properties) => !!(properties.start_date || properties.due_date)}
|
||||
>
|
||||
<div className="h-5" onFocus={handleEventPropagation} onClick={handleEventPropagation}>
|
||||
<DateRangeDropdown
|
||||
value={{
|
||||
from: getDate(issue.start_date) || undefined,
|
||||
to: getDate(issue.target_date) || undefined,
|
||||
}}
|
||||
onSelect={(range) => {
|
||||
handleStartDate(range?.from ?? null);
|
||||
handleTargetDate(range?.to ?? null);
|
||||
}}
|
||||
hideIcon={{
|
||||
from: false,
|
||||
}}
|
||||
isClearable
|
||||
mergeDates
|
||||
buttonVariant={issue.start_date || issue.target_date ? "border-with-text" : "border-without-text"}
|
||||
disabled={!disabled}
|
||||
showTooltip
|
||||
customTooltipHeading="Date Range"
|
||||
renderPlaceholder={false}
|
||||
/>
|
||||
</div>
|
||||
</WithDisplayPropertiesHOC>
|
||||
|
||||
@@ -5,7 +5,7 @@ import xor from "lodash/xor";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams, usePathname } from "next/navigation";
|
||||
// icons
|
||||
import { CalendarCheck2, CalendarClock, Layers, Link, Paperclip } from "lucide-react";
|
||||
import { Layers, Link, Paperclip } from "lucide-react";
|
||||
// types
|
||||
import { ISSUE_UPDATED } from "@plane/constants";
|
||||
// i18n
|
||||
@@ -15,13 +15,13 @@ import { TIssue, IIssueDisplayProperties, TIssuePriorities } from "@plane/types"
|
||||
import { Tooltip } from "@plane/ui";
|
||||
// components
|
||||
import {
|
||||
DateDropdown,
|
||||
EstimateDropdown,
|
||||
PriorityDropdown,
|
||||
MemberDropdown,
|
||||
ModuleDropdown,
|
||||
CycleDropdown,
|
||||
StateDropdown,
|
||||
DateRangeDropdown,
|
||||
} from "@/components/dropdowns";
|
||||
// constants
|
||||
// helpers
|
||||
@@ -265,12 +265,6 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
|
||||
|
||||
const defaultLabelOptions = issue?.label_ids?.map((id) => labelMap[id]) || [];
|
||||
|
||||
const minDate = getDate(issue.start_date);
|
||||
minDate?.setDate(minDate.getDate());
|
||||
|
||||
const maxDate = getDate(issue.target_date);
|
||||
maxDate?.setDate(maxDate.getDate());
|
||||
|
||||
const handleEventPropagation = (e: SyntheticEvent<HTMLDivElement>) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
@@ -310,40 +304,34 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
|
||||
</div>
|
||||
</WithDisplayPropertiesHOC>
|
||||
|
||||
{/* start date */}
|
||||
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="start_date">
|
||||
{/* merged dates */}
|
||||
<WithDisplayPropertiesHOC
|
||||
displayProperties={displayProperties}
|
||||
displayPropertyKey={["start_date", "due_date"]}
|
||||
shouldRenderProperty={(properties) => !!(properties.start_date || properties.due_date)}
|
||||
>
|
||||
<div className="h-5" onFocus={handleEventPropagation} onClick={handleEventPropagation}>
|
||||
<DateDropdown
|
||||
value={issue.start_date ?? null}
|
||||
onChange={handleStartDate}
|
||||
maxDate={maxDate}
|
||||
placeholder={t("common.order_by.start_date")}
|
||||
icon={<CalendarClock className="h-3 w-3 flex-shrink-0" />}
|
||||
buttonVariant={issue.start_date ? "border-with-text" : "border-without-text"}
|
||||
optionsClassName="z-10"
|
||||
disabled={isReadOnly}
|
||||
renderByDefault={isMobile}
|
||||
showTooltip
|
||||
/>
|
||||
</div>
|
||||
</WithDisplayPropertiesHOC>
|
||||
|
||||
{/* target/due date */}
|
||||
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="due_date">
|
||||
<div className="h-5" onFocus={handleEventPropagation} onClick={handleEventPropagation}>
|
||||
<DateDropdown
|
||||
value={issue?.target_date ?? null}
|
||||
onChange={handleTargetDate}
|
||||
minDate={minDate}
|
||||
placeholder={t("common.order_by.due_date")}
|
||||
icon={<CalendarCheck2 className="h-3 w-3 flex-shrink-0" />}
|
||||
buttonVariant={issue.target_date ? "border-with-text" : "border-without-text"}
|
||||
<DateRangeDropdown
|
||||
value={{
|
||||
from: getDate(issue.start_date) || undefined,
|
||||
to: getDate(issue.target_date) || undefined,
|
||||
}}
|
||||
onSelect={(range) => {
|
||||
handleStartDate(range?.from ?? null);
|
||||
handleTargetDate(range?.to ?? null);
|
||||
}}
|
||||
hideIcon={{
|
||||
from: false,
|
||||
}}
|
||||
isClearable
|
||||
mergeDates
|
||||
buttonVariant={issue.start_date || issue.target_date ? "border-with-text" : "border-without-text"}
|
||||
buttonClassName={shouldHighlightIssueDueDate(issue.target_date, stateDetails?.group) ? "text-red-500" : ""}
|
||||
clearIconClassName="!text-custom-text-100"
|
||||
optionsClassName="z-10"
|
||||
disabled={isReadOnly}
|
||||
renderByDefault={isMobile}
|
||||
showTooltip
|
||||
renderPlaceholder={false}
|
||||
customTooltipHeading="Date Range"
|
||||
/>
|
||||
</div>
|
||||
</WithDisplayPropertiesHOC>
|
||||
|
||||
@@ -158,6 +158,7 @@ export const ModuleListItemAction: FC<Props> = observer((props) => {
|
||||
target_date: val?.to ? renderFormattedPayloadDate(val.to) : null,
|
||||
});
|
||||
}}
|
||||
mergeDates
|
||||
placeholder={{
|
||||
from: t("start_date"),
|
||||
to: t("end_date"),
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
// plane types
|
||||
// plane imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { TPageVersion } from "@plane/types";
|
||||
// plane ui
|
||||
import { Avatar } from "@plane/ui";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
@@ -23,6 +23,8 @@ export const PlaneVersionsSidebarListItem: React.FC<Props> = observer((props) =>
|
||||
const { getUserDetails } = useMember();
|
||||
// derived values
|
||||
const ownerDetails = getUserDetails(version.owned_by);
|
||||
// translation
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Link
|
||||
@@ -42,7 +44,7 @@ export const PlaneVersionsSidebarListItem: React.FC<Props> = observer((props) =>
|
||||
size="sm"
|
||||
className="flex-shrink-0"
|
||||
/>
|
||||
<span className="text-custom-text-300">{ownerDetails?.display_name}</span>
|
||||
<span className="text-custom-text-300">{ownerDetails?.display_name ?? t("common.deactivated_user")}</span>
|
||||
</p>
|
||||
</Link>
|
||||
);
|
||||
|
||||
@@ -47,105 +47,19 @@ const paramsToKey = (params: any) => {
|
||||
return `${layoutKey}_${projectKey}_${stateGroupKey}_${stateKey}_${priorityKey}_${assigneesKey}_${mentionsKey}_${createdByKey}_${type}_${groupBy}_${orderBy}_${labelsKey}_${startDateKey}_${targetDateKey}_${sub_issue}_${subscriberKey}`;
|
||||
};
|
||||
|
||||
const myIssuesParamsToKey = (params: any) => {
|
||||
const { assignees, created_by, labels, priority, state_group, subscriber, start_date, target_date } = params;
|
||||
|
||||
let assigneesKey = assignees ? assignees.split(",") : [];
|
||||
let createdByKey = created_by ? created_by.split(",") : [];
|
||||
let stateGroupKey = state_group ? state_group.split(",") : [];
|
||||
let subscriberKey = subscriber ? subscriber.split(",") : [];
|
||||
let priorityKey = priority ? priority.split(",") : [];
|
||||
let labelsKey = labels ? labels.split(",") : [];
|
||||
const startDateKey = start_date ?? "";
|
||||
const targetDateKey = target_date ?? "";
|
||||
const type = params?.type ? params.type.toUpperCase() : "NULL";
|
||||
const groupBy = params?.group_by ? params.group_by.toUpperCase() : "NULL";
|
||||
const orderBy = params?.order_by ? params.order_by.toUpperCase() : "NULL";
|
||||
|
||||
// sorting each keys in ascending order
|
||||
assigneesKey = assigneesKey.sort().join("_");
|
||||
createdByKey = createdByKey.sort().join("_");
|
||||
stateGroupKey = stateGroupKey.sort().join("_");
|
||||
subscriberKey = subscriberKey.sort().join("_");
|
||||
priorityKey = priorityKey.sort().join("_");
|
||||
labelsKey = labelsKey.sort().join("_");
|
||||
|
||||
return `${assigneesKey}_${createdByKey}_${stateGroupKey}_${subscriberKey}_${priorityKey}_${type}_${groupBy}_${orderBy}_${labelsKey}_${startDateKey}_${targetDateKey}`;
|
||||
};
|
||||
|
||||
export const CURRENT_USER = "CURRENT_USER";
|
||||
export const USER_WORKSPACE_INVITATIONS = "USER_WORKSPACE_INVITATIONS";
|
||||
export const USER_WORKSPACES_LIST = "USER_WORKSPACES_LIST";
|
||||
|
||||
export const WORKSPACE_DETAILS = (workspaceSlug: string) => `WORKSPACE_DETAILS_${workspaceSlug.toUpperCase()}`;
|
||||
|
||||
export const WORKSPACE_MEMBERS = (workspaceSlug: string) => `WORKSPACE_MEMBERS_${workspaceSlug.toUpperCase()}`;
|
||||
export const WORKSPACE_MEMBERS_ME = (workspaceSlug: string) => `WORKSPACE_MEMBERS_ME${workspaceSlug.toUpperCase()}`;
|
||||
export const WORKSPACE_INVITATIONS = (workspaceSlug: string) => `WORKSPACE_INVITATIONS_${workspaceSlug.toString()}`;
|
||||
|
||||
export const WORKSPACE_INVITATION = (invitationId: string) => `WORKSPACE_INVITATION_${invitationId}`;
|
||||
export const LAST_ACTIVE_WORKSPACE_AND_PROJECTS = "LAST_ACTIVE_WORKSPACE_AND_PROJECTS";
|
||||
|
||||
export const PROJECTS_LIST = (
|
||||
workspaceSlug: string,
|
||||
params: {
|
||||
is_favorite: "all" | boolean;
|
||||
}
|
||||
) => {
|
||||
if (!params) return `PROJECTS_LIST_${workspaceSlug.toUpperCase()}`;
|
||||
|
||||
return `PROJECTS_LIST_${workspaceSlug.toUpperCase()}_${params.is_favorite.toString().toUpperCase()}`;
|
||||
};
|
||||
export const PROJECT_DETAILS = (projectId: string) => `PROJECT_DETAILS_${projectId.toUpperCase()}`;
|
||||
|
||||
export const PROJECT_MEMBERS = (projectId: string) => `PROJECT_MEMBERS_${projectId.toUpperCase()}`;
|
||||
export const PROJECT_INVITATIONS = (projectId: string) => `PROJECT_INVITATIONS_${projectId.toString()}`;
|
||||
|
||||
export const PROJECT_ISSUES_LIST = (workspaceSlug: string, projectId: string) =>
|
||||
`PROJECT_ISSUES_LIST_${workspaceSlug.toUpperCase()}_${projectId.toUpperCase()}`;
|
||||
export const PROJECT_ISSUES_LIST_WITH_PARAMS = (projectId: string, params?: any) => {
|
||||
if (!params) return `PROJECT_ISSUES_LIST_WITH_PARAMS_${projectId.toUpperCase()}`;
|
||||
|
||||
const paramsKey = paramsToKey(params);
|
||||
|
||||
return `PROJECT_ISSUES_LIST_WITH_PARAMS_${projectId.toUpperCase()}_${paramsKey}`;
|
||||
};
|
||||
export const PROJECT_ARCHIVED_ISSUES_LIST_WITH_PARAMS = (projectId: string, params?: any) => {
|
||||
if (!params) return `PROJECT_ARCHIVED_ISSUES_LIST_WITH_PARAMS_${projectId.toUpperCase()}`;
|
||||
|
||||
const paramsKey = paramsToKey(params);
|
||||
|
||||
return `PROJECT_ARCHIVED_ISSUES_LIST_WITH_PARAMS_${projectId.toUpperCase()}_${paramsKey}`;
|
||||
};
|
||||
|
||||
export const PROJECT_DRAFT_ISSUES_LIST_WITH_PARAMS = (projectId: string, params?: any) => {
|
||||
if (!params) return `PROJECT_DRAFT_ISSUES_LIST_WITH_PARAMS${projectId.toUpperCase()}`;
|
||||
|
||||
const paramsKey = paramsToKey(params);
|
||||
|
||||
return `PROJECT_DRAFT_ISSUES_LIST_WITH_PARAMS${projectId.toUpperCase()}_${paramsKey}`;
|
||||
};
|
||||
|
||||
export const GLOBAL_VIEWS_LIST = (workspaceSlug: string) => `GLOBAL_VIEWS_LIST_${workspaceSlug.toUpperCase()}`;
|
||||
export const GLOBAL_VIEW_DETAILS = (globalViewId: string) => `GLOBAL_VIEW_DETAILS_${globalViewId.toUpperCase()}`;
|
||||
export const GLOBAL_VIEW_ISSUES = (globalViewId: string) => `GLOBAL_VIEW_ISSUES_${globalViewId.toUpperCase()}`;
|
||||
|
||||
export const PROJECT_ISSUES_DETAILS = (issueId: string) => `PROJECT_ISSUES_DETAILS_${issueId.toUpperCase()}`;
|
||||
export const PROJECT_ISSUES_PROPERTIES = (projectId: string) => `PROJECT_ISSUES_PROPERTIES_${projectId.toUpperCase()}`;
|
||||
export const PROJECT_ISSUES_COMMENTS = (issueId: string) => `PROJECT_ISSUES_COMMENTS_${issueId.toUpperCase()}`;
|
||||
export const PROJECT_ISSUES_ACTIVITY = (issueId: string) => `PROJECT_ISSUES_ACTIVITY_${issueId.toUpperCase()}`;
|
||||
export const PROJECT_ISSUE_BY_STATE = (projectId: string) => `PROJECT_ISSUE_BY_STATE_${projectId.toUpperCase()}`;
|
||||
export const PROJECT_ISSUE_LABELS = (projectId: string) => `PROJECT_ISSUE_LABELS_${projectId.toUpperCase()}`;
|
||||
export const WORKSPACE_LABELS = (workspaceSlug: string) => `WORKSPACE_LABELS_${workspaceSlug.toUpperCase()}`;
|
||||
export const PROJECT_GITHUB_REPOSITORY = (projectId: string) => `PROJECT_GITHUB_REPOSITORY_${projectId.toUpperCase()}`;
|
||||
|
||||
// cycles
|
||||
export const CYCLES_LIST = (projectId: string) => `CYCLE_LIST_${projectId.toUpperCase()}`;
|
||||
export const INCOMPLETE_CYCLES_LIST = (projectId: string) => `INCOMPLETE_CYCLES_LIST_${projectId.toUpperCase()}`;
|
||||
export const CURRENT_CYCLE_LIST = (projectId: string) => `CURRENT_CYCLE_LIST_${projectId.toUpperCase()}`;
|
||||
export const UPCOMING_CYCLES_LIST = (projectId: string) => `UPCOMING_CYCLES_LIST_${projectId.toUpperCase()}`;
|
||||
export const DRAFT_CYCLES_LIST = (projectId: string) => `DRAFT_CYCLES_LIST_${projectId.toUpperCase()}`;
|
||||
export const COMPLETED_CYCLES_LIST = (projectId: string) => `COMPLETED_CYCLES_LIST_${projectId.toUpperCase()}`;
|
||||
export const CYCLE_ISSUES = (cycleId: string) => `CYCLE_ISSUES_${cycleId.toUpperCase()}`;
|
||||
export const CYCLE_ISSUES_WITH_PARAMS = (cycleId: string, params?: any) => {
|
||||
if (!params) return `CYCLE_ISSUES_WITH_PARAMS_${cycleId.toUpperCase()}`;
|
||||
|
||||
@@ -153,47 +67,11 @@ export const CYCLE_ISSUES_WITH_PARAMS = (cycleId: string, params?: any) => {
|
||||
|
||||
return `CYCLE_ISSUES_WITH_PARAMS_${cycleId.toUpperCase()}_${paramsKey.toUpperCase()}`;
|
||||
};
|
||||
export const CYCLE_DETAILS = (cycleId: string) => `CYCLE_DETAILS_${cycleId.toUpperCase()}`;
|
||||
|
||||
export const STATES_LIST = (projectId: string) => `STATES_LIST_${projectId.toUpperCase()}`;
|
||||
|
||||
export const USER_ISSUE = (workspaceSlug: string) => `USER_ISSUE_${workspaceSlug.toUpperCase()}`;
|
||||
export const USER_ISSUES = (workspaceSlug: string, params: any) => {
|
||||
const paramsKey = myIssuesParamsToKey(params);
|
||||
|
||||
return `USER_ISSUES_${workspaceSlug.toUpperCase()}_${paramsKey}`;
|
||||
};
|
||||
export const USER_ACTIVITY = (params: { cursor?: string }) => `USER_ACTIVITY_${params?.cursor}`;
|
||||
export const USER_WORKSPACE_DASHBOARD = (workspaceSlug: string) =>
|
||||
`USER_WORKSPACE_DASHBOARD_${workspaceSlug.toUpperCase()}`;
|
||||
export const USER_PROJECT_VIEW = (projectId: string) => `USER_PROJECT_VIEW_${projectId.toUpperCase()}`;
|
||||
|
||||
export const MODULE_LIST = (projectId: string) => `MODULE_LIST_${projectId.toUpperCase()}`;
|
||||
export const MODULE_ISSUES = (moduleId: string) => `MODULE_ISSUES_${moduleId.toUpperCase()}`;
|
||||
export const MODULE_ISSUES_WITH_PARAMS = (moduleId: string, params?: any) => {
|
||||
if (!params) return `MODULE_ISSUES_WITH_PARAMS_${moduleId.toUpperCase()}`;
|
||||
|
||||
const paramsKey = paramsToKey(params);
|
||||
|
||||
return `MODULE_ISSUES_WITH_PARAMS_${moduleId.toUpperCase()}_${paramsKey.toUpperCase()}`;
|
||||
};
|
||||
export const MODULE_DETAILS = (moduleId: string) => `MODULE_DETAILS_${moduleId.toUpperCase()}`;
|
||||
|
||||
export const VIEWS_LIST = (projectId: string) => `VIEWS_LIST_${projectId.toUpperCase()}`;
|
||||
export const VIEW_DETAILS = (viewId: string) => `VIEW_DETAILS_${viewId.toUpperCase()}`;
|
||||
export const VIEW_ISSUES = (viewId: string, params: any) => {
|
||||
if (!params) return `VIEW_ISSUES_${viewId.toUpperCase()}`;
|
||||
|
||||
const paramsKey = paramsToKey(params);
|
||||
|
||||
return `VIEW_ISSUES_${viewId.toUpperCase()}_${paramsKey.toUpperCase()}`;
|
||||
};
|
||||
|
||||
// Issues
|
||||
export const ISSUE_DETAILS = (issueId: string) => `ISSUE_DETAILS_${issueId.toUpperCase()}`;
|
||||
export const SUB_ISSUES = (issueId: string) => `SUB_ISSUES_${issueId.toUpperCase()}`;
|
||||
export const ISSUE_ATTACHMENTS = (issueId: string) => `ISSUE_ATTACHMENTS_${issueId.toUpperCase()}`;
|
||||
export const ARCHIVED_ISSUE_DETAILS = (issueId: string) => `ARCHIVED_ISSUE_DETAILS_${issueId.toUpperCase()}`;
|
||||
|
||||
// integrations
|
||||
export const APP_INTEGRATIONS = "APP_INTEGRATIONS";
|
||||
@@ -222,21 +100,6 @@ export const GITHUB_REPOSITORY_INFO = (workspaceSlug: string, repoName: string)
|
||||
export const SLACK_CHANNEL_INFO = (workspaceSlug: string, projectId: string) =>
|
||||
`SLACK_CHANNEL_INFO_${workspaceSlug.toString().toUpperCase()}_${projectId.toUpperCase()}`;
|
||||
|
||||
// Pages
|
||||
export const RECENT_PAGES_LIST = (projectId: string) => `RECENT_PAGES_LIST_${projectId.toUpperCase()}`;
|
||||
export const ALL_PAGES_LIST = (projectId: string) => `ALL_PAGES_LIST_${projectId.toUpperCase()}`;
|
||||
export const ARCHIVED_PAGES_LIST = (projectId: string) => `ARCHIVED_PAGES_LIST_${projectId.toUpperCase}`;
|
||||
export const FAVORITE_PAGES_LIST = (projectId: string) => `FAVORITE_PAGES_LIST_${projectId.toUpperCase()}`;
|
||||
export const PRIVATE_PAGES_LIST = (projectId: string) => `PRIVATE_PAGES_LIST_${projectId.toUpperCase()}`;
|
||||
export const SHARED_PAGES_LIST = (projectId: string) => `SHARED_PAGES_LIST_${projectId.toUpperCase()}`;
|
||||
export const PAGE_DETAILS = (pageId: string) => `PAGE_DETAILS_${pageId.toUpperCase()}`;
|
||||
export const PAGE_BLOCKS_LIST = (pageId: string) => `PAGE_BLOCK_LIST_${pageId.toUpperCase()}`;
|
||||
export const PAGE_BLOCK_DETAILS = (pageId: string) => `PAGE_BLOCK_DETAILS_${pageId.toUpperCase()}`;
|
||||
export const MY_PAGES_LIST = (pageId: string) => `MY_PAGE_LIST_${pageId}`;
|
||||
// estimates
|
||||
export const ESTIMATES_LIST = (projectId: string) => `ESTIMATES_LIST_${projectId.toUpperCase()}`;
|
||||
export const ESTIMATE_DETAILS = (estimateId: string) => `ESTIMATE_DETAILS_${estimateId.toUpperCase()}`;
|
||||
|
||||
// profile
|
||||
export const USER_PROFILE_DATA = (workspaceSlug: string, userId: string) =>
|
||||
`USER_PROFILE_ACTIVITY_${workspaceSlug.toUpperCase()}_${userId.toUpperCase()}`;
|
||||
@@ -249,19 +112,6 @@ export const USER_PROFILE_ACTIVITY = (
|
||||
) => `USER_WORKSPACE_PROFILE_ACTIVITY_${workspaceSlug.toUpperCase()}_${userId.toUpperCase()}_${params?.cursor}`;
|
||||
export const USER_PROFILE_PROJECT_SEGREGATION = (workspaceSlug: string, userId: string) =>
|
||||
`USER_PROFILE_PROJECT_SEGREGATION_${workspaceSlug.toUpperCase()}_${userId.toUpperCase()}`;
|
||||
export const USER_PROFILE_ISSUES = (workspaceSlug: string, userId: string, params: any) => {
|
||||
const paramsKey = myIssuesParamsToKey(params);
|
||||
|
||||
return `USER_PROFILE_ISSUES_${workspaceSlug.toUpperCase()}_${userId.toUpperCase()}_${paramsKey}`;
|
||||
};
|
||||
|
||||
// reactions
|
||||
export const ISSUE_REACTION_LIST = (workspaceSlug: string, projectId: string, issueId: string) =>
|
||||
`ISSUE_REACTION_LIST_${workspaceSlug.toUpperCase()}_${projectId.toUpperCase()}_${issueId.toUpperCase()}`;
|
||||
export const COMMENT_REACTION_LIST = (workspaceSlug: string, projectId: string, commendId: string) =>
|
||||
`COMMENT_REACTION_LIST_${workspaceSlug.toUpperCase()}_${projectId.toUpperCase()}_${commendId.toUpperCase()}`;
|
||||
|
||||
// api-tokens
|
||||
export const API_TOKENS_LIST = (workspaceSlug: string) => `API_TOKENS_LIST_${workspaceSlug.toUpperCase()}`;
|
||||
export const API_TOKEN_DETAILS = (workspaceSlug: string, tokenId: string) =>
|
||||
`API_TOKEN_DETAILS_${workspaceSlug.toUpperCase()}_${tokenId.toUpperCase()}`;
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
import useSWR from "swr";
|
||||
// fetch keys
|
||||
import { COMMENT_REACTION_LIST } from "@/constants/fetch-keys";
|
||||
// services
|
||||
import { groupReactions } from "@/helpers/emoji.helper";
|
||||
import { useUser } from "@/hooks/store";
|
||||
import { IssueReactionService } from "@/services/issue";
|
||||
// helpers
|
||||
// hooks
|
||||
// services
|
||||
const issueReactionService = new IssueReactionService();
|
||||
|
||||
const useCommentReaction: any = (
|
||||
workspaceSlug?: string | string[] | null,
|
||||
projectId?: string | string[] | null,
|
||||
commendId?: string | string[] | null
|
||||
) => {
|
||||
const {
|
||||
data: commentReactions,
|
||||
mutate: mutateCommentReactions,
|
||||
error,
|
||||
} = useSWR(
|
||||
workspaceSlug && projectId && commendId
|
||||
? COMMENT_REACTION_LIST(workspaceSlug.toString(), projectId.toString(), commendId.toString())
|
||||
: null,
|
||||
workspaceSlug && projectId && commendId
|
||||
? () =>
|
||||
issueReactionService.listIssueCommentReactions(
|
||||
workspaceSlug.toString(),
|
||||
projectId.toString(),
|
||||
commendId.toString()
|
||||
)
|
||||
: null
|
||||
);
|
||||
|
||||
const { data: currentUser } = useUser();
|
||||
|
||||
const groupedReactions = groupReactions(commentReactions || [], "reaction");
|
||||
|
||||
/**
|
||||
* @description Use this function to create user's reaction to an issue. This function will mutate the reactions state.
|
||||
* @param {string} reaction
|
||||
* @example handleReactionDelete("123") // 123 -> is emoji hexa-code
|
||||
*/
|
||||
|
||||
const handleReactionCreate = async (reaction: string) => {
|
||||
if (!workspaceSlug || !projectId || !commendId) return;
|
||||
|
||||
const data = await issueReactionService.createIssueCommentReaction(
|
||||
workspaceSlug.toString(),
|
||||
projectId.toString(),
|
||||
commendId.toString(),
|
||||
{ reaction }
|
||||
);
|
||||
|
||||
mutateCommentReactions((prev: any) => [...(prev || []), data]);
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Use this function to delete user's reaction from an issue. This function will mutate the reactions state.
|
||||
* @param {string} reaction
|
||||
* @example handleReactionDelete("123") // 123 -> is emoji hexa-code
|
||||
*/
|
||||
|
||||
const handleReactionDelete = async (reaction: string) => {
|
||||
if (!workspaceSlug || !projectId || !commendId) return;
|
||||
|
||||
mutateCommentReactions(
|
||||
(prevData: any) => prevData?.filter((r: any) => r.actor !== currentUser?.id || r.reaction !== reaction) || [],
|
||||
false
|
||||
);
|
||||
|
||||
await issueReactionService.deleteIssueCommentReaction(
|
||||
workspaceSlug.toString(),
|
||||
projectId.toString(),
|
||||
commendId.toString(),
|
||||
reaction
|
||||
);
|
||||
|
||||
mutateCommentReactions();
|
||||
};
|
||||
|
||||
return {
|
||||
isLoading: !commentReactions && !error,
|
||||
commentReactions,
|
||||
groupedReactions,
|
||||
handleReactionCreate,
|
||||
handleReactionDelete,
|
||||
mutateCommentReactions,
|
||||
} as const;
|
||||
};
|
||||
|
||||
export default useCommentReaction;
|
||||
@@ -1,63 +0,0 @@
|
||||
import React, { useCallback, useEffect } from "react";
|
||||
// plane helpers
|
||||
import { useOutsideClickDetector } from "@plane/hooks";
|
||||
|
||||
/**
|
||||
* Custom hook for dynamic dropdown position calculation.
|
||||
* @param isOpen - Indicates whether the dropdown is open.
|
||||
* @param handleClose - Callback to handle closing the dropdown.
|
||||
* @param buttonRef - Ref object for the button triggering the dropdown.
|
||||
* @param dropdownRef - Ref object for the dropdown element.
|
||||
*/
|
||||
|
||||
const useDynamicDropdownPosition = (
|
||||
isOpen: boolean,
|
||||
handleClose: () => void,
|
||||
buttonRef: React.RefObject<any>,
|
||||
dropdownRef: React.RefObject<any>
|
||||
) => {
|
||||
const handlePosition = useCallback(() => {
|
||||
const button = buttonRef.current;
|
||||
const dropdown = dropdownRef.current;
|
||||
|
||||
if (!dropdown || !button) return;
|
||||
|
||||
const buttonRect = button.getBoundingClientRect();
|
||||
const dropdownRect = dropdown.getBoundingClientRect();
|
||||
|
||||
const { innerHeight, innerWidth, scrollX, scrollY } = window;
|
||||
|
||||
let top: number = buttonRect.bottom + scrollY;
|
||||
if (top + dropdownRect.height > innerHeight) top = innerHeight - dropdownRect.height;
|
||||
|
||||
let left: number = buttonRect.left + scrollX + (buttonRect.width - dropdownRect.width) / 2;
|
||||
if (left + dropdownRect.width > innerWidth) left = innerWidth - dropdownRect.width;
|
||||
|
||||
dropdown.style.top = `${Math.max(top, 5)}px`;
|
||||
dropdown.style.left = `${Math.max(left, 5)}px`;
|
||||
}, [buttonRef, dropdownRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) handlePosition();
|
||||
}, [handlePosition, isOpen]);
|
||||
|
||||
useOutsideClickDetector(dropdownRef, () => {
|
||||
if (isOpen) handleClose();
|
||||
});
|
||||
|
||||
const handleResize = useCallback(() => {
|
||||
if (isOpen) {
|
||||
handlePosition();
|
||||
}
|
||||
}, [handlePosition, isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener("resize", handleResize);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", handleResize);
|
||||
};
|
||||
}, [isOpen, handleResize]);
|
||||
};
|
||||
|
||||
export default useDynamicDropdownPosition;
|
||||
@@ -1,14 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
const useURLHash = () => {
|
||||
const [hashValue, setHashValue] = useState<string>();
|
||||
|
||||
useEffect(() => {
|
||||
const hash = window.location.hash?.split("#")[1];
|
||||
setHashValue(hash);
|
||||
}, []);
|
||||
|
||||
return hashValue;
|
||||
};
|
||||
|
||||
export default useURLHash;
|
||||
@@ -1,41 +0,0 @@
|
||||
import { useEffect, ReactNode, FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// hooks
|
||||
import { useUser } from "@/hooks/store";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
$crisp: unknown[];
|
||||
CRISP_WEBSITE_ID: unknown;
|
||||
}
|
||||
}
|
||||
|
||||
export interface ICrispWrapper {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const CrispWrapper: FC<ICrispWrapper> = observer((props) => {
|
||||
const { children } = props;
|
||||
const { data: user } = useUser();
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window && user?.email && process.env.NEXT_PUBLIC_CRISP_ID) {
|
||||
window.$crisp = [];
|
||||
window.CRISP_WEBSITE_ID = process.env.NEXT_PUBLIC_CRISP_ID;
|
||||
(function () {
|
||||
const d = document;
|
||||
const s = d.createElement("script");
|
||||
s.src = "https://client.crisp.chat/l.js";
|
||||
s.async = true;
|
||||
d.getElementsByTagName("head")[0].appendChild(s);
|
||||
window.$crisp.push(["set", "user:email", [user.email]]);
|
||||
window.$crisp.push(["do", "chat:hide"]);
|
||||
window.$crisp.push(["do", "chat:close"]);
|
||||
})();
|
||||
}
|
||||
}, [user?.email]);
|
||||
|
||||
return <>{children}</>;
|
||||
});
|
||||
|
||||
export default CrispWrapper;
|
||||
@@ -177,7 +177,7 @@ export abstract class BaseProjectMemberStore implements IBaseProjectMemberStore
|
||||
original_role: projectMember.original_role,
|
||||
member: {
|
||||
...userDetails,
|
||||
joining_date: projectMember.created_at,
|
||||
joining_date: projectMember.created_at ?? undefined,
|
||||
},
|
||||
created_at: projectMember.created_at,
|
||||
};
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
import DOMPurify from "isomorphic-dompurify";
|
||||
import {
|
||||
CYCLE_ISSUES_WITH_PARAMS,
|
||||
MODULE_ISSUES_WITH_PARAMS,
|
||||
PROJECT_ISSUES_LIST_WITH_PARAMS,
|
||||
VIEW_ISSUES,
|
||||
} from "@/constants/fetch-keys";
|
||||
|
||||
export const addSpaceIfCamelCase = (str: string) => {
|
||||
if (str === undefined || str === null) return "";
|
||||
@@ -150,29 +144,6 @@ export const objToQueryParams = (obj: any) => {
|
||||
return params.toString();
|
||||
};
|
||||
|
||||
export const getFetchKeysForIssueMutation = (options: {
|
||||
cycleId?: string | string[];
|
||||
moduleId?: string | string[];
|
||||
viewId?: string | string[];
|
||||
projectId: string;
|
||||
viewGanttParams: any;
|
||||
ganttParams: any;
|
||||
}) => {
|
||||
const { cycleId, moduleId, viewId, projectId, viewGanttParams, ganttParams } = options;
|
||||
|
||||
const ganttFetchKey = cycleId
|
||||
? { ganttFetchKey: CYCLE_ISSUES_WITH_PARAMS(cycleId.toString(), ganttParams) }
|
||||
: moduleId
|
||||
? { ganttFetchKey: MODULE_ISSUES_WITH_PARAMS(moduleId.toString(), ganttParams) }
|
||||
: viewId
|
||||
? { ganttFetchKey: VIEW_ISSUES(viewId.toString(), viewGanttParams) }
|
||||
: { ganttFetchKey: PROJECT_ISSUES_LIST_WITH_PARAMS(projectId?.toString() ?? "", ganttParams) };
|
||||
|
||||
return {
|
||||
...ganttFetchKey,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* @returns {boolean} true if searchQuery is substring of text in the same order, false otherwise
|
||||
* @description Returns true if searchQuery is substring of text in the same order, false otherwise
|
||||
|
||||
+1
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "web",
|
||||
"version": "0.26.0",
|
||||
"version": "0.26.1",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0",
|
||||
"scripts": {
|
||||
@@ -19,7 +19,6 @@
|
||||
"@atlaskit/pragmatic-drag-and-drop": "^1.1.3",
|
||||
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "^1.3.0",
|
||||
"@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.0.3",
|
||||
"@blueprintjs/popover2": "^1.13.3",
|
||||
"@headlessui/react": "^1.7.3",
|
||||
"@intercom/messenger-js-sdk": "^0.0.12",
|
||||
"@plane/constants": "*",
|
||||
@@ -65,7 +64,6 @@
|
||||
"smooth-scroll-into-view-if-needed": "^2.0.2",
|
||||
"swr": "^2.1.3",
|
||||
"tailwind-merge": "^2.0.0",
|
||||
"use-debounce": "^9.0.4",
|
||||
"use-font-face-observer": "^1.2.2",
|
||||
"uuid": "^9.0.0",
|
||||
"zxcvbn": "^4.4.2"
|
||||
|
||||
Reference in New Issue
Block a user