Compare commits

...
Author SHA1 Message Date
vamsikrishnamathala 97bdad00f8 Merge branch 'preview' of github.com:makeplane/plane into fix-cycle_timezone 2025-06-09 12:57:47 +05:30
vamsikrishnamathala d7fff8e200 chore: moved helper function to utils package 2025-06-09 12:55:12 +05:30
sriram veeraghanta b3b285b1e5 chore: upgrade django version to 4.2.22 2025-06-09 12:49:26 +05:30
Prateek ShouryaandGitHub 11debee402 fix: build errors related to project member list (#7185) 2025-06-09 00:31:27 +05:30
Vamsi KrishnaandGitHub 1608e4f122 [WEB-3374]feat: added merge date display (#7141)
* feat: added merge date display

* chore: moved formatter ti utils

* chore: removed unwanted props
2025-06-08 23:47:08 +05:30
edeeee1227 [WEB-4063]chore: updated work item email template (#7044)
* chore: updated work item email template

* chore: passed dynamic value for email template

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
2025-06-08 23:46:12 +05:30
sriram veeraghanta 9ff238816b sync: canary changes to preview 2025-06-06 18:06:51 +05:30
sriram veeraghanta 6bd5caf008 chore: upgrade package version 2025-06-06 17:50:31 +05:30
vamsikrishnamathala 66cfb3d8f2 fix: timezone conversion for cycles date 2025-06-06 17:19:39 +05:30
sriram veeraghanta c021aff58f chore: django version upgrade 2025-06-06 16:04:34 +05:30
sriram veeraghanta 683be55883 chore: upgrade nextjs version 2025-06-06 16:02:56 +05:30
Manish Guptaandsriram veeraghanta 970ce8cf26 [INFRA-183] feat: add restore-airgapped script to build workflow (#7170)
* [WEB-4260] chore: add restore-airgapped script to build workflow

* docs: update restore instructions in README for self-hosted and commercial air-gapped versions

* fix: update restore script filename and improve error handling in restore-airgapped script
2025-06-06 15:24:43 +05:30
Manish Guptaandsriram veeraghanta cbbe1a4e4d refactor: Enhance backup and restore scripts for container data (#7055)
* refactor: enhance backup and restore scripts for container data management

* fix: ensure proper quoting in backup script to handle paths with spaces

* fix: ensure backup directory is only removed if tar command succeeds

* CodeRabbit fixes
2025-06-06 15:24:43 +05:30
Manish Guptaandsriram veeraghanta 6a74677cc9 fix: update API service startup check to use HTTP request instead of logs (#7054) 2025-06-06 15:24:43 +05:30
sriram veeraghanta f6ea4f931d Merge branch 'canary' of github.com:makeplane/plane into preview 2025-06-06 15:23:10 +05:30
Aaryan KhandelwalandGitHub 950fcfdb40 [WIKI-391] chore: handle deactivated user display name in version history #7171 2025-06-06 15:04:00 +05:30
Bavisetti NarayanandGitHub 053c895120 [WEB 4252] chore: updated the favicon request for work item link (#7173)
* chore: added the favicon to link

* chore: added none validation for soup
2025-06-06 15:02:00 +05:30
Aaryan KhandelwalandGitHub 245167e8aa refactor: unused components, hooks, constants (#7157)
* refactor: remove unused dashboard components and fetch keys

* refactor: remove unused hooks and wrappers

* chore: remove unused function
2025-06-06 14:09:56 +05:30
sriram veeraghantaandGitHub 461e099bbc release: v0.26.0 #6962 2025-04-28 18:24:37 +05:30
sriram veeraghantaandGitHub 45e25ce18b release: v0.25.3 #6788 2025-03-21 17:26:55 +05:30
sriram veeraghantaandGitHub 4d88dbaf49 release: v0.25.2 (#6736) 2025-03-11 16:01:20 +05:30
+4 e61ff879c4 release: v0.25.1
* fix: issue activity for project id validation (#6668)

* fix: work item attachment count mutation (#6670)

* updated the action to modify the release build assets (#6669)

* feat: russian translation (#6666)

* chore: ru translation updated (#6672)

* fix: state drop down refactor

* fix: intake work item creation refactor

* fix: cleanup for deprecated functions

* fix: date range picker on cycles and modules list (#6676)

* fix: Handled workspace switcher closing on click

* fix: replaced date range picker with date picker at some places

* chore: add common translation keys (#6688)

* chore: add missing translation keys

* chore: add russian translation keys

* fix: issue activity task (#6689)

* changed github workflow action ubuntu version to `ubuntu-22.04` (#6683)

* chore: update russian translation (#6682)

* chore: update russian translation

* chore: rename issues to work items in russian translation

* [PE-275] chore: editor line spacing variables (#6678)

* chore: variable editor line spacing

* chore: variable list spacing

---------

Co-authored-by: Aaryan Khandelwal <aaryankhandu123@gmail.com>

* [WEB-3475] fix: cycle dates dropdown (#6690)

* fix: Handled workspace switcher closing on click

* fix: Cycle date picker

* fix: Made onSelect optional in range range component

* fix: module date picker (#6691)

* fix: Handled workspace switcher closing on click

* fix: reverted module date picker changes

* chore: extended sidebar improvement (#6693)

* feat: italian translations (#6692)

* Create translations.json - ITALIAN translation (#6667)

* chore: italian translation updated

* feat: italian translation added

* fix: module end date translation

---------

Co-authored-by: Nicolas Bossi <nicolasbossi@gmail.com>
Co-authored-by: gakshita <akshitagoyal1516@gmail.com>

* fix: attachment item created by (#6695)

* fix: module flicker issue on property updation (#6699)

* [WEB-3477] fix: mutation issue on moving work items for a manually ended cycle (#6696)

* fix: package version update

* fix: esbuild version fix

* fix: package license repliation

* [WEB-3488] improvement: assignee validation for work item creation (#6701)

* fix: work item assignee update validation (#6704)

---------

Co-authored-by: Nikhil <118773738+pablohashescobar@users.noreply.github.com>
Co-authored-by: Anmol Singh Bhatia <121005188+anmolsinghbhatia@users.noreply.github.com>
Co-authored-by: Manish Gupta <59428681+mguptahub@users.noreply.github.com>
Co-authored-by: Nikita Mitasov <32384814+ch4og@users.noreply.github.com>
Co-authored-by: Akshita Goyal <36129505+gakshita@users.noreply.github.com>
Co-authored-by: Aaryan Khandelwal <65252264+aaryan610@users.noreply.github.com>
Co-authored-by: Akshat Jain <akshatjain9782@gmail.com>
Co-authored-by: Lakhan Baheti <94619783+1akhanBaheti@users.noreply.github.com>
Co-authored-by: Aaryan Khandelwal <aaryankhandu123@gmail.com>
Co-authored-by: Nicolas Bossi <nicolasbossi@gmail.com>
Co-authored-by: gakshita <akshitagoyal1516@gmail.com>
Co-authored-by: Prateek Shourya <prateekshourya29@gmail.com>
2025-03-05 19:15:33 +05:30
sriram veeraghantaandGitHub adeb7d977d Merge pull request #6665 from makeplane/canary
fix: package version update
2025-02-24 20:40:25 +05:30
81 changed files with 386 additions and 2598 deletions
+1 -1
View File
@@ -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 -1
View File
@@ -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"
+7 -1
View File
@@ -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,
+2 -2
View File
@@ -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
+36 -44
View File
@@ -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()
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/propel",
"version": "0.26.0",
"version": "0.26.1",
"private": true,
"license": "AGPL-3.0",
"scripts": {
+1 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/typescript-config",
"version": "0.26.0",
"version": "0.26.1",
"license": "AGPL-3.0",
"private": true,
"files": [
+1 -1
View File
@@ -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 -1
View File
@@ -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,
+109
View File
@@ -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
View File
@@ -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={{
-2
View File
@@ -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>
);
});
+83 -27
View File
@@ -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>
);
+1
View File
@@ -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>;
});
@@ -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>
);
+1 -151
View File
@@ -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()}`;
-93
View File
@@ -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;
-63
View File
@@ -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;
-14
View File
@@ -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;
-41
View File
@@ -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,
};
-29
View File
@@ -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
View File
@@ -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"